diff --git a/.credentials/HCAPTCHA_SECRET b/.credentials/HCAPTCHA_SECRET new file mode 100644 index 00000000..7daa69d3 --- /dev/null +++ b/.credentials/HCAPTCHA_SECRET @@ -0,0 +1 @@ +0x0000000000000000000000000000000000000000 diff --git a/.credentials/HCAPTCHA_SITEKEY b/.credentials/HCAPTCHA_SITEKEY new file mode 100644 index 00000000..f5093057 --- /dev/null +++ b/.credentials/HCAPTCHA_SITEKEY @@ -0,0 +1 @@ +10000000-ffff-ffff-ffff-000000000001 diff --git a/.credentials/KFETOPEN_TOKEN b/.credentials/KFETOPEN_TOKEN new file mode 100644 index 00000000..4cbb2bf5 --- /dev/null +++ b/.credentials/KFETOPEN_TOKEN @@ -0,0 +1 @@ +k-feste_token diff --git a/.credentials/SECRET_KEY b/.credentials/SECRET_KEY new file mode 100644 index 00000000..de873cc2 --- /dev/null +++ b/.credentials/SECRET_KEY @@ -0,0 +1 @@ +insecure-key diff --git a/.credentials/SYMPA_PASSWORD b/.credentials/SYMPA_PASSWORD new file mode 100644 index 00000000..fbcf12d5 --- /dev/null +++ b/.credentials/SYMPA_PASSWORD @@ -0,0 +1 @@ +toto diff --git a/.credentials/SYMPA_USERNAME b/.credentials/SYMPA_USERNAME new file mode 100644 index 00000000..d525803f --- /dev/null +++ b/.credentials/SYMPA_USERNAME @@ -0,0 +1 @@ +sympa diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..1d953f4b --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use nix diff --git a/.gitignore b/.gitignore index 347d4b78..9122298b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ cof/settings.py settings.py *~ venv/ +.venv/ .vagrant /src media/ @@ -18,4 +19,6 @@ media/ .cache # VSCode -.vscode/ \ No newline at end of file +.vscode/ +.direnv +.static diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6a6ce23a..ce3bd041 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,8 +1,7 @@ -image: "python:3.5" +image: "python:3.7" variables: # GestioCOF settings - DJANGO_SETTINGS_MODULE: "cof.settings.prod" DBHOST: "postgres" REDIS_HOST: "redis" REDIS_PASSWD: "dummy" @@ -18,23 +17,23 @@ variables: # psql password authentication PGPASSWORD: $POSTGRES_PASSWORD -test: - stage: test + # apps to check migrations for + MIGRATION_APPS: "bda bds cofcms clubs events gestioncof kfet kfetauth kfetcms open petitscours shared" + +.test_template: before_script: - mkdir -p vendor/{pip,apt} - - 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 + - apt-get update -q && apt-get -o dir::cache::archives="vendor/apt" install -yqq postgresql-client libldap2-dev libsasl2-dev + - sed -E 's/^REDIS_HOST.*/REDIS_HOST = "redis"/' gestioasso/settings/secret_example.py > gestioasso/settings/secret.py + - sed -i.bak -E 's;^REDIS_PASSWD = .*$;REDIS_PASSWD = "";' gestioasso/settings/secret.py # Remove the old test database if it has not been done yet - psql --username=$POSTGRES_USER --host=$DBHOST -c "DROP DATABASE IF EXISTS test_$POSTGRES_DB" - pip install --upgrade -r requirements-prod.txt coverage tblib - python --version - script: - - coverage run manage.py test --parallel after_script: - coverage report services: - - postgres:9.6 + - postgres:11.7 - redis:latest cache: key: test @@ -44,17 +43,40 @@ test: # Keep this disabled for now, as it may kill GitLab... # coverage: '/TOTAL.*\s(\d+\.\d+)\%$/' +kfettest: + stage: test + extends: .test_template + variables: + DJANGO_SETTINGS_MODULE: "gestioasso.settings.cof_prod" + script: + - coverage run manage.py test kfet + +coftest: + stage: test + extends: .test_template + variables: + DJANGO_SETTINGS_MODULE: "gestioasso.settings.cof_prod" + script: + - coverage run manage.py test gestioncof bda petitscours shared --parallel + +bdstest: + stage: test + extends: .test_template + variables: + DJANGO_SETTINGS_MODULE: "gestioasso.settings.bds_prod" + script: + - coverage run manage.py test bds clubs events --parallel + linters: - image: python:3.6 stage: test before_script: - mkdir -p vendor/pip - pip install --upgrade black isort flake8 script: - black --check . - - isort --recursive --check-only --diff bda bds clubs cof events gestioncof kfet petitscours provisioning shared utils + - isort --check --diff . # Print errors only - - flake8 --exit-zero bda bds clubs cof events gestioncof kfet petitscours provisioning shared utils + - flake8 --exit-zero bda bds clubs gestioasso events gestioncof kfet petitscours provisioning shared cache: key: linters paths: @@ -63,16 +85,18 @@ linters: # Check whether there are some missing migrations. migration_checks: stage: test + variables: + DJANGO_SETTINGS_MODULE: "gestioasso.settings.local" before_script: - mkdir -p vendor/{pip,apt} - - apt-get update -q && apt-get -o dir::cache::archives="vendor/apt" install -yqq postgresql-client - - cp cof/settings/secret_example.py cof/settings/secret.py - - pip install --upgrade -r requirements-prod.txt + - apt-get update -q && apt-get -o dir::cache::archives="vendor/apt" install -yqq postgresql-client libldap2-dev libsasl2-dev + - cp gestioasso/settings/secret_example.py gestioasso/settings/secret.py + - pip install --upgrade -r requirements-devel.txt - python --version - script: python manage.py makemigrations --dry-run --check + script: python manage.py makemigrations --dry-run --check $MIGRATION_APPS services: # this should not be necessary… - - postgres:9.6 + - postgres:11.7 cache: key: migration_checks paths: diff --git a/.pre-commit.sh b/.pre-commit.sh index 0e0e3c1a..abf1fe7d 100755 --- a/.pre-commit.sh +++ b/.pre-commit.sh @@ -48,7 +48,7 @@ if type isort &>/dev/null; then ISORT_OUTPUT="/tmp/gc-isort-output.log" touch $ISORT_OUTPUT - if ! echo "$STAGED_PYTHON_FILES" | xargs -d'\n' isort --check-only &>$ISORT_OUTPUT; then + if ! echo "$STAGED_PYTHON_FILES" | xargs -d'\n' isort --check &>$ISORT_OUTPUT; then echo "$STAGED_PYTHON_FILES" | xargs -d'\n' isort &>$ISORT_OUTPUT printf "Reformatted.\n" formatter_updated=1 diff --git a/CHANGELOG b/CHANGELOG deleted file mode 100644 index 64816348..00000000 --- a/CHANGELOG +++ /dev/null @@ -1,47 +0,0 @@ -* Le FUTUR ! (pas prêt pour la prod) - -- Nouveau module de gestion des événements -- Nouveau module BDS -- Nouveau module clubs - -* Version 0.3.2 - 04/11/2019 - -- Bugfix: modifier un compte K-Fêt ne supprime plus nom/prénom - -* Version 0.3.1 - 19/10/2019 - -- Bugfix: l'historique des utilisateurices s'affiche à nouveau - -* Version 0.3 - 16/10/2019 - -- Comptes extés: lien pour changer son mot de passe sur la page d'accueil -- Les utilisateurices non-COF peuvent éditer leur profil -- Un peu de pub pour KDEns sur la page d'accueil -- Fix erreur 500 sur /bda/revente//manage -- Si on essaie d'accéder au compte que qqn d'autre on a une 404 (et plus une 403) -- On ne peut plus modifier des comptes COF depuis l'interface K-Fêt -- Le champ de paiement BdA se fait au niveau des attributions -- Affiche un message d'erreur plutôt que de crasher si échec de l'envoi du mail - de bienvenue aux nouveaux membres -- On peut supprimer des comptes et des articles K-Fêt -- Passage à Django2 -- Dev : on peut désactiver la barre de debug avec une variable shell -- Remplace les CSS de Google par des polices de proximité -- Passage du site du COF et de la K-Fêt en Wagtail 2.3 et Wagtail-modeltranslation 0.9 -- Ajoute un lien vers l'administration générale depuis les petits cours -- Abandon de l'ancien catalogue BdA (déjà plus utilisé depuis longtemps) -- Force l'unicité des logins clipper -- Nouveau site du COF en wagtail -- Meilleurs affichage des longues listes de spectacles à cocher dans BdA-Revente -- Bugfix : les pages de la revente ne sont plus accessibles qu'aux membres du - COF - -* Version 0.2 - 07/11/2018 - -- Corrections de bugs d'interface dans l'inscription aux tirages BdA -- On peut annuler une revente à tout moment -- Pleiiiiin de tests - -* Version 0.1 - 09/09/2018 - -Début de la numérotation des versions diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..b68fb40c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,303 @@ +# Changelog + +Liste des changements notables dans GestioCOF depuis la version 0.1 (septembre +2018). + +## Le FUTUR ! (pas prêt pour la prod) + +### Nouveau module de gestion des événements + +- Désormais complet niveau modèles +- Export des participants implémenté + +#### TODO + +- Vue de création d'événements ergonomique +- Vue d'inscription à un événement **ou** intégration propre dans la vue + "inscription d'un nouveau membre" + +### Nouveau module de gestion des clubs + +Uniquement un modèle simple de clubs avec des respos. Aucune gestion des +adhérents ni des cotisations. + +## TODO Prod + +- Lancer `python manage.py update_translation_fields` après la migration +- Mettre à jour les units systemd `daphne.service` et `worker.service` + +- Créer un compte hCaptcha (https://www.hcaptcha.com/), au COF, et remplacer les secrets associés + +## Version ??? - ??/??/???? + +## Version 0.15.1 - 15/06/2023 + +### K-Fêt + +- Rattrape les erreurs d'envoi de mail de négatif +- Utilise l'adresse chefs pour les envois de négatifs + +## Version 0.15 - 22/05/2023 + +### K-Fêt + +- Rajoute un formulaire de contact +- Rajoute un formulaire de demande de soirée +- Désactive les mails d'envoi de négatifs sur les comptes gelés + +## Version 0.14 - 19/05/2023 + +- Répare les dépendances en spécifiant toutes les versions + +### K-Fêt + +- Répare la gestion des changement d'heure via moment.js + +## Version 0.13 - 19/02/2023 + +### K-Fêt + +- Rajoute la valeur des inventaires +- Résout les problèmes de négatif ne disparaissant pas +- Affiche son surnom s'il y en a un +- Bugfixes + +## Version 0.12.1 - 03/10/2022 + +### K-Fêt + +- Fixe un problème de rendu causé par l'agrandissement du menu + +- Mise à jour vers Channels 3.x et Django 3.2 + +## Version 0.12 - 17/06/2022 + +### K-Fêt + +- Ajoute une exception à la limite d'historique pour les comptes `LIQ` et `#13` +- Répare le problème des étiquettes LIQ/Comptes K-Fêt inversées dans les stats des articles K-Fêt + +## Version 0.11 - 26/10/2021 + +### COF + +- Répare un problème de rendu sur le wagtail du COF + +### K-Fêt + +- Ajoute de mails de rappels pour les comptes en négatif +- La recherche de comptes sur K-Psul remarche normalement +- Le pointeur de la souris change de forme quand on survole un item d'autocomplétion +- Modification du gel de compte: + - on ne peut plus geler/dégeler son compte soi-même (il faut la permission "Gérer les permissions K-Fêt") + - on ne peut rien compter sur un compte gelé (aucune override possible), et les K-Fêteux·ses dont le compte est gelé perdent tout accès à K-Psul + - les comptes actuellement gelés (sur l'ancien système) sont dégelés automatiquement +- Modification du fonctionnement des négatifs + - impossible d'avoir des négatifs inférieurs à `kfet_config.overdraft_amount` + - il n'y a plus de limite de temps sur les négatifs + - supression des autorisations de négatif + - il n'est plus possible de réinitialiser la durée d'un négatif en faisant puis en annulant une charge +- La gestion des erreurs passe du client au serveur, ce qui permet d'avoir des messages plus explicites +- La supression d'opérations anciennes est réparée + +## Version 0.10 - 18/04/2021 + +### K-Fêt + +- On fait sauter la limite qui empêchait de vendre plus de 24 unités d'un item à + la fois. +- L'interface indique plus clairement quand on fait une erreur en modifiant un + compte. +- On supprime la fonction "décalage de balance". +- L'accès à l'historique est maintenant limité à 7 jours pour raison de + confidentialité. Les chefs/trez peuvent disposer d'une permission + supplémentaire pour accéder à jusqu'à 30 jours en cas de problème de compta. + L'accès à son historique personnel n'est pas limité. Les durées sont + configurables dans `settings/cof_prod.py`. + +### COF + +- Le Captcha sur la page de demande de petits cours utilise maintenant hCaptcha + au lieu de ReCaptcha, pour mieux respecter la vie privée des utilisateur·ices + +## Version 0.9 - 06/02/2020 + +### COF / BdA + +- Le COF peut remettre à zéro la liste de ses adhérents en août (sans passer par + KDE). +- La page d'accueil affiche la date de fermeture des tirages BdA. +- On peut revendre une place dès qu'on l'a payée, plus besoin de payer toutes + ses places pour pouvoir revendre. +- On s'assure que l'email fourni lors d'une demande de petit cours est valide. + +### BDS + +- Le burô peut maintenant accorder ou révoquer le statut de membre du Burô + en modifiant le profil d'un membre du BDS. +- Le burô peut exporter la liste de ses membres avec email au format CSV depuis + la page d'accueil. + +### K-Fêt + +- On affiche les articles actuellement en vente en premier lors des inventaires + et des commandes. +- On peut supprimer un inventaire. Seuls les articles dont c'est le dernier + inventaire sont affectés. + +## Version 0.8 - 03/12/2020 + +### COF + +- La page "Mes places" dans la section BdA indique quelles places sont sur + listing. +- ergonomie de l'interface admin du BdA : moins d'options inutiles lors de + la sélection de participants. +- les tirages sont maintenant archivables pour éviter d'avoir encore d'autres + options inutiles. +- l'autocomplétion dans l'admin BdA est réparée. +- Les icones de la page de gestion des petits cours sont (à nouveau) réparées. +- On a supprimé la possibilité de modifier les mails automatiques depuis + l'interface admin car trop problématique. Faute de mieux, envoyer un mail à + KDE pour modifier ces mails. +- corrige un crash sporadique sur la page d'inscription au système de petits + cours + +### K-Fêt + +- (fix partiel) Empêche la K-Fêt de modifier des données COF (e.g. nom, prénom, + username) lors de la création d'un nouveau compte. +- Les statistiques de conso globales montrent deux courbes COF / non-COF au + lieu de LIQ / sur compte. +- Un bug empêchait de fermer manuellement la K-Fêt depuis un compte non + privilégié en tapant un mot de passe. C'est corrigé. + +## Version 0.7.2 - 08/09/2020 + +- Nouvelle page 404 +- Correction de bug en K-Fêt : le lien pour créer un nouveau compte exté apparaît + à nouveau dans l'autocomplétion + +## Version 0.7.1 - 05/09/2020 + +Petits ajustements sur le site du COF : + +- Possibilité d'ajouter des champs d'infos supplémentaires en plus de l'email et + de la page web dans les annuaires (clubs et partenaires). +- Corrige un bug d'affichage des adresses emails de clubs + +## Version 0.7 - 29/08/2020 + +### GestioBDS + +- Ajout d'un bouton pour supprimer un compte +- Le nombre d'adhérent⋅es est affiché sur la page d'accueil +- le groupe BDS a les bonnes permissions + +### Site du COF + +- Captcha fonctionnel pour les mailing-listes + +### K-Fêt + +- L'autocomplétion pour la création de compte K-Fêt se lance à 3 caractères seulement, +donc est plus rapide. + +## Version 0.6 - 27/07/2020 + +Arrivée du BDS ! +GestioCOF et GestioBDS ont du code en commun mais tournent de façon séparée, les +deux bases de données sont distinctes. + +## Version 0.5 - 11/07/2020 + +### Problèmes corrigés + +- La recherche d'utilisateurices (COF + K-Fêt) fonctionne de nouveau +- Bug d'affichage quand on a beaucoup de clubs dans le cadre "Accès rapide" sur + la page des clubs (nouveau site du COF) +- Version mobile plus ergonimique sur le nouveau site du COF +- Cliquer sur "visualiser" sur les pages de clubs dans wagtail ne provoque plus + d'erreurs 500 (nouveau site du COF) +- L'historique des ventes des articles K-Fêt fonctionne à nouveau +- Les montants en K-Fêt sont à nouveau affichés en UKF (et non en €). +- Les boutons "afficher/cacher" des mails et noms des participant⋅e⋅s à un + spectacle BdA fonctionnent à nouveau. +- on ne peut plus compter de consos sur ☠☠☠, ni éditer les comptes spéciaux +(LIQ, GNR, ☠☠☠, #13). + +### Nouvelles fonctionnalités + +- On n'affiche que 4 articles sur la pages "nouveautés" (nouveau site du COF) +- Plus de traductions sur le nouveau site du COF +- Les transferts apparaissent maintenant dans l'historique K-Fêt et l'historique + personnel. +- les statistiques K-Fêt remontent à plus d'un an (et le code est simplifié) + +## Version 0.4.1 - 17/01/2020 + +- Corrige un bug sur K-Psul lorsqu'un trigramme contient des caractères réservés + aux urls (\#, /...) + +## Version 0.4 - 15/01/2020 + +- Corrige un bug d'affichage d'images sur l'interface des petits cours +- La page des transferts permet de créer un nombre illimité de transferts en + une fois. +- Nouveau site du COF : les liens sont optionnels dans les descriptions de clubs +- Mise à jour du lien vers le calendire de la K-Fêt sur la page d'accueil +- Certaines opérations sont à nouveau accessibles depuis la session partagée + K-Fêt. +- Le bouton "déconnexion" déconnecte vraiment du CAS pour les comptes clipper +- Corrige un crash sur la page des reventes pour les nouveaux participants. +- Corrige un bug d'affichage pour les trigrammes avec caractères spéciaux + +## Version 0.3.3 - 30/11/2019 + +- Corrige un problème de redirection lors de la déconnexion (CAS seulement) +- Les catégories d'articles K-Fêt peuvent être exemptées de subvention COF +- Corrige un bug d'affichage dans K-Psul quand on annule une transaction sur LIQ +- Corrige une privilege escalation liée aux sessions partagées en K-Fêt + https://git.eleves.ens.fr/klub-dev-ens/gestioCOF/issues/240 + +## Version 0.3.2 - 04/11/2019 + +- Bugfix: modifier un compte K-Fêt ne supprime plus nom/prénom + +## Version 0.3.1 - 19/10/2019 + +- Bugfix: l'historique des utilisateurices s'affiche à nouveau + +## Version 0.3 - 16/10/2019 + +- Comptes extés: lien pour changer son mot de passe sur la page d'accueil +- Les utilisateurices non-COF peuvent éditer leur profil +- Un peu de pub pour KDEns sur la page d'accueil +- Fix erreur 500 sur /bda/revente//manage +- Si on essaie d'accéder au compte que qqn d'autre on a une 404 (et plus une 403) +- On ne peut plus modifier des comptes COF depuis l'interface K-Fêt +- Le champ de paiement BdA se fait au niveau des attributions +- Affiche un message d'erreur plutôt que de crasher si échec de l'envoi du mail + de bienvenue aux nouveaux membres +- On peut supprimer des comptes et des articles K-Fêt +- Passage à Django2 +- Dev : on peut désactiver la barre de debug avec une variable shell +- Remplace les CSS de Google par des polices de proximité +- Passage du site du COF et de la K-Fêt en Wagtail 2.3 et Wagtail-modeltranslation 0.9 +- Ajoute un lien vers l'administration générale depuis les petits cours +- Abandon de l'ancien catalogue BdA (déjà plus utilisé depuis longtemps) +- Force l'unicité des logins clipper +- Nouveau site du COF en wagtail +- Meilleurs affichage des longues listes de spectacles à cocher dans BdA-Revente +- Bugfix : les pages de la revente ne sont plus accessibles qu'aux membres du + COF + +## Version 0.2 - 07/11/2018 + +- Corrections de bugs d'interface dans l'inscription aux tirages BdA +- On peut annuler une revente à tout moment +- Pleiiiiin de tests + +## Version 0.1 - 09/09/2018 + +Début de la numérotation des versions, début du changelog diff --git a/README.md b/README.md index ffe680db..5708277c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# GestioCOF +# GestioCOF / GestioBDS [![pipeline status](https://git.eleves.ens.fr/cof-geek/gestioCOF/badges/master/pipeline.svg)](https://git.eleves.ens.fr/cof-geek/gestioCOF/commits/master) [![coverage report](https://git.eleves.ens.fr/cof-geek/gestioCOF/badges/master/coverage.svg)](https://git.eleves.ens.fr/cof-geek/gestioCOF/commits/master) @@ -18,7 +18,7 @@ Il vous faudra installer pip, les librairies de développement de python ainsi que sqlite3, un moteur de base de données léger et simple d'utilisation. Sous Debian et dérivées (Ubuntu, ...) : - sudo apt-get install python3-pip python3-dev python3-venv sqlite3 + sudo apt-get install python3-pip python3-dev python3-venv sqlite3 libsasl2-dev python-dev-is-python3 libldap2-dev libssl-dev Si vous décidez d'utiliser un environnement virtuel Python (virtualenv; fortement conseillé), déplacez-vous dans le dossier où est installé GestioCOF @@ -30,7 +30,15 @@ Pour l'activer, il faut taper . venv/bin/activate -depuis le même dossier. +depuis le même dossier. Pour préparer l'environnement à l'utilisation de `./manage.py` +(qui permet de faire des tests en local), il faut également taper + + export CREDENTIALS_DIRECTORY=$(realpath .credentials) + export DJANGO_SETTINGS_MODULE=gestioasso.settings.local + export GESTIOCOF_DEBUG=true + export GESTIOCOF_STATIC_ROOT=$(realpath .static) + export GESTIOBDS_DEBUG=true + export GESTIOBDS_STATIC_ROOT=$(realpath .static) Vous pouvez maintenant installer les dépendances Python depuis le fichier `requirements-devel.txt` : @@ -38,11 +46,11 @@ Vous pouvez maintenant installer les dépendances Python depuis le fichier pip install -U pip # parfois nécessaire la première fois pip install -r requirements-devel.txt -Pour terminer, copier le fichier `cof/settings/secret_example.py` vers -`cof/settings/secret.py`. Sous Linux ou Mac, préférez plutôt un lien symbolique +Pour terminer, copier le fichier `gestioasso/settings/secret_example.py` vers +`gestioasso/settings/secret.py`. Sous Linux ou Mac, préférez plutôt un lien symbolique pour profiter de façon transparente des mises à jour du fichier: - ln -s secret_example.py cof/settings/secret.py + ln -s secret_example.py gestioasso/settings/secret.py Nous avons un git hook de pre-commit pour formatter et vérifier que votre code vérifie nos conventions. Pour bénéficier des mises à jour du hook, préférez diff --git a/TODO_PROD.md b/TODO_PROD.md deleted file mode 100644 index 1a7d0736..00000000 --- a/TODO_PROD.md +++ /dev/null @@ -1 +0,0 @@ -- Changer les urls dans les mails "bda-revente" et "bda-shotgun" diff --git a/Vagrantfile b/Vagrantfile index e12a45ed..f34653a5 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -1,47 +1,19 @@ # -*- mode: ruby -*- # vi: set ft=ruby : -# All Vagrant configuration is done below. The "2" in Vagrant.configure -# configures the configuration version (we support older styles for -# backwards compatibility). Please don't change it unless you know what -# you're doing. +# Configuration de base pour GestioCOF. +# Voir https://docs.vagrantup.com pour plus d'informations. Vagrant.configure(2) do |config| - # The most common configuration options are documented and commented below. - # For a complete reference, please see the online documentation at - # https://docs.vagrantup.com. - - config.vm.box = "ubuntu/xenial64" + # On se base sur Debian 10 (Buster) pour avoir le même environnement qu'en + # production. + config.vm.box = "debian/contrib-buster64" # On associe le port 80 dans la machine virtuelle avec le port 8080 de notre # ordinateur, et le port 8000 avec le port 8000. config.vm.network :forwarded_port, guest: 80, host: 8080 config.vm.network :forwarded_port, guest: 8000, host: 8000 - # Create a private network, which allows host-only access to the machine - # using a specific IP. - # config.vm.network "private_network", ip: "192.168.33.10" - - # Provider-specific configuration so you can fine-tune various - # backing providers for Vagrant. These expose provider-specific options. - # Example for VirtualBox: - # - # config.vm.provider "virtualbox" do |vb| - # # Display the VirtualBox GUI when booting the machine - # vb.gui = true - # - # # Customize the amount of memory on the VM: - # vb.memory = "1024" - # end - # - # View the documentation for the provider you are using for more - # information on available options. - - # Enable provisioning with a shell script. Additional provisioners such as - # Puppet, Chef, Ansible, Salt, and Docker are also available. Please see the - # documentation for more information about their specific syntax and use. - # config.vm.provision "shell", inline: <<-SHELL - # sudo apt-get update - # sudo apt-get install -y apache2 - # SHELL + # Le restes de la configuration (installation de paquets, etc) est géré un + # script shell. config.vm.provision :shell, path: "provisioning/bootstrap.sh" end diff --git a/bda/admin.py b/bda/admin.py index 7f626c7a..f52f721d 100644 --- a/bda/admin.py +++ b/bda/admin.py @@ -1,10 +1,11 @@ from datetime import timedelta -from custommail.shortcuts import send_mass_custom_mail from dal.autocomplete import ModelSelect2 from django import forms from django.contrib import admin +from django.core.mail import send_mass_mail from django.db.models import Count, Q, Sum +from django.template import loader from django.template.defaultfilters import pluralize from django.utils import timezone @@ -32,20 +33,6 @@ 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" - - class AttributionTabularAdminForm(forms.ModelForm): listing = None @@ -93,14 +80,17 @@ class WithoutListingAttributionInline(AttributionInline): class ParticipantAdminForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["choicesrevente"].queryset = Spectacle.objects.select_related( - "location" - ) + queryset = Spectacle.objects.select_related("location") + + if self.instance.pk is not None: + queryset = queryset.filter(tirage=self.instance.tirage) + + self.fields["choicesrevente"].queryset = queryset class ParticipantPaidFilter(admin.SimpleListFilter): """ - Permet de filtrer les participants sur s'ils ont payé leurs places ou pas + Permet de filtrer les participants sur s'ils ont payé leurs places ou pas """ title = "A payé" @@ -169,19 +159,23 @@ class ParticipantAdmin(ReadOnlyMixin, admin.ModelAdmin): form = ParticipantAdminForm def send_attribs(self, request, queryset): - datatuple = [] + emails = [] for member in queryset.all(): + subject = "Résultats du tirage au sort" attribs = member.attributions.all() context = {"member": member.user} - shortname = "" + + template_name = "" if len(attribs) == 0: - shortname = "bda-attributions-decus" + template_name = "bda/mails/attributions-decus.txt" else: - shortname = "bda-attributions" + template_name = "bda/mails/attributions.txt" context["places"] = attribs - print(context) - datatuple.append((shortname, context, "bda@ens.fr", [member.user.email])) - send_mass_custom_mail(datatuple) + + message = loader.render_to_string(template_name, context) + emails.append((subject, message, "bda@ens.fr", [member.user.email])) + + send_mass_mail(emails) count = len(queryset.all()) if count == 1: message_bit = "1 membre a" @@ -197,17 +191,6 @@ class ParticipantAdmin(ReadOnlyMixin, admin.ModelAdmin): class AttributionAdminForm(forms.ModelForm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if "spectacle" in self.fields: - self.fields["spectacle"].queryset = Spectacle.objects.select_related( - "location" - ) - if "participant" in self.fields: - self.fields["participant"].queryset = Participant.objects.select_related( - "user", "tirage" - ) - def clean(self): cleaned_data = super().clean() participant = cleaned_data.get("participant") @@ -220,9 +203,14 @@ class AttributionAdminForm(forms.ModelForm): ) return cleaned_data + class Meta: + widgets = { + "participant": ModelSelect2(url="bda-participant-autocomplete"), + "spectacle": ModelSelect2(url="bda-spectacle-autocomplete"), + } + class AttributionAdmin(ReadOnlyMixin, admin.ModelAdmin): - list_display = ("id", "spectacle", "participant", "given", "paid") search_fields = ( "spectacle__title", @@ -235,7 +223,7 @@ class AttributionAdmin(ReadOnlyMixin, admin.ModelAdmin): class ChoixSpectacleAdmin(admin.ModelAdmin): - form = ChoixSpectacleAdminForm + autocomplete_fields = ["participant", "spectacle"] def tirage(self, obj): return obj.participant.tirage @@ -279,15 +267,14 @@ class SalleAdmin(admin.ModelAdmin): class SpectacleReventeAdminForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["confirmed_entry"].queryset = Participant.objects.select_related( - "user", "tirage" - ) - self.fields["seller"].queryset = Participant.objects.select_related( - "user", "tirage" - ) - self.fields["soldTo"].queryset = Participant.objects.select_related( - "user", "tirage" - ) + qset = Participant.objects.select_related("user", "tirage") + + if self.instance.pk is not None: + qset = qset.filter(tirage=self.instance.seller.tirage) + + self.fields["confirmed_entry"].queryset = qset + self.fields["seller"].queryset = qset + self.fields["soldTo"].queryset = qset class SpectacleReventeAdmin(admin.ModelAdmin): diff --git a/bda/algorithm.py b/bda/algorithm.py index add09335..078f2be8 100644 --- a/bda/algorithm.py +++ b/bda/algorithm.py @@ -2,7 +2,6 @@ import random class Algorithm(object): - shows = None ranks = None origranks = None @@ -10,10 +9,10 @@ class Algorithm(object): def __init__(self, shows, members, choices): """Initialisation : - - on aggrège toutes les demandes pour chaque spectacle dans - show.requests - - on crée des tables de demandes pour chaque personne, afin de - pouvoir modifier les rankings""" + - on aggrège toutes les demandes pour chaque spectacle dans + show.requests + - on crée des tables de demandes pour chaque personne, afin de + pouvoir modifier les rankings""" self.max_group = 2 * max(choice.priority for choice in choices) self.shows = [] showdict = {} diff --git a/bda/forms.py b/bda/forms.py index bb79932e..d1d0f74f 100644 --- a/bda/forms.py +++ b/bda/forms.py @@ -3,7 +3,7 @@ from django.forms.models import BaseInlineFormSet from django.template import loader from django.utils import timezone -from bda.models import Attribution, Spectacle, SpectacleRevente +from bda.models import SpectacleRevente class InscriptionInlineFormSet(BaseInlineFormSet): @@ -77,7 +77,7 @@ class ResellForm(forms.Form): super().__init__(*args, **kwargs) self.fields["attributions"] = TemplateLabelField( queryset=participant.attribution_set.filter( - spectacle__date__gte=timezone.now() + spectacle__date__gte=timezone.now(), paid=True ) .exclude(revente__seller=participant) .select_related("spectacle", "spectacle__location", "participant__user"), diff --git a/bda/management/commands/loadbdadevdata.py b/bda/management/commands/loadbdadevdata.py index a608db6a..186e1da7 100644 --- a/bda/management/commands/loadbdadevdata.py +++ b/bda/management/commands/loadbdadevdata.py @@ -81,7 +81,7 @@ class Command(MyBaseCommand): shows = random.sample( list(tirage.spectacle_set.all()), tirage.spectacle_set.count() // 2 ) - for (rank, show) in enumerate(shows): + for rank, show in enumerate(shows): choices.append( ChoixSpectacle( participant=part, diff --git a/bda/migrations/0001_initial.py b/bda/migrations/0001_initial.py index 077ddd4e..5bc848c8 100644 --- a/bda/migrations/0001_initial.py +++ b/bda/migrations/0001_initial.py @@ -6,7 +6,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] operations = [ diff --git a/bda/migrations/0002_add_tirage.py b/bda/migrations/0002_add_tirage.py index f4b01ed2..c2c6bd3c 100644 --- a/bda/migrations/0002_add_tirage.py +++ b/bda/migrations/0002_add_tirage.py @@ -35,7 +35,6 @@ def fill_tirage_fields(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [("bda", "0001_initial")] operations = [ diff --git a/bda/migrations/0003_update_tirage_and_spectacle.py b/bda/migrations/0003_update_tirage_and_spectacle.py index 3548eb88..07f3742e 100644 --- a/bda/migrations/0003_update_tirage_and_spectacle.py +++ b/bda/migrations/0003_update_tirage_and_spectacle.py @@ -5,7 +5,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [("bda", "0002_add_tirage")] operations = [ diff --git a/bda/migrations/0004_mails-rappel.py b/bda/migrations/0004_mails-rappel.py index d331568a..407353a4 100644 --- a/bda/migrations/0004_mails-rappel.py +++ b/bda/migrations/0004_mails-rappel.py @@ -5,7 +5,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [("bda", "0003_update_tirage_and_spectacle")] operations = [ diff --git a/bda/migrations/0005_encoding.py b/bda/migrations/0005_encoding.py index eedfcee4..29ee0027 100644 --- a/bda/migrations/0005_encoding.py +++ b/bda/migrations/0005_encoding.py @@ -5,7 +5,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [("bda", "0004_mails-rappel")] operations = [ diff --git a/bda/migrations/0006_add_tirage_switch.py b/bda/migrations/0006_add_tirage_switch.py index ccfe7505..1535a5fe 100644 --- a/bda/migrations/0006_add_tirage_switch.py +++ b/bda/migrations/0006_add_tirage_switch.py @@ -18,7 +18,6 @@ def forwards_func(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [("bda", "0005_encoding")] operations = [ diff --git a/bda/migrations/0007_extends_spectacle.py b/bda/migrations/0007_extends_spectacle.py index 87182ff7..48865acb 100644 --- a/bda/migrations/0007_extends_spectacle.py +++ b/bda/migrations/0007_extends_spectacle.py @@ -5,7 +5,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [("bda", "0006_add_tirage_switch")] operations = [ diff --git a/bda/migrations/0008_py3.py b/bda/migrations/0008_py3.py index 6aa69abd..3a7dfeb1 100644 --- a/bda/migrations/0008_py3.py +++ b/bda/migrations/0008_py3.py @@ -5,7 +5,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [("bda", "0007_extends_spectacle")] operations = [ diff --git a/bda/migrations/0009_revente.py b/bda/migrations/0009_revente.py index d888140f..7a547f85 100644 --- a/bda/migrations/0009_revente.py +++ b/bda/migrations/0009_revente.py @@ -6,7 +6,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [("bda", "0008_py3")] operations = [ diff --git a/bda/migrations/0010_spectaclerevente_shotgun.py b/bda/migrations/0010_spectaclerevente_shotgun.py index da5c014c..ae0fdff1 100644 --- a/bda/migrations/0010_spectaclerevente_shotgun.py +++ b/bda/migrations/0010_spectaclerevente_shotgun.py @@ -12,15 +12,15 @@ def forwards_func(apps, schema_editor): for revente in SpectacleRevente.objects.all(): is_expired = timezone.now() > revente.date_tirage() - is_direct = revente.attribution.spectacle.date >= revente.date and timezone.now() > revente.date + timedelta( - minutes=15 + is_direct = ( + revente.attribution.spectacle.date >= revente.date + and timezone.now() > revente.date + timedelta(minutes=15) ) revente.shotgun = is_expired or is_direct revente.save() class Migration(migrations.Migration): - dependencies = [("bda", "0009_revente")] operations = [ diff --git a/bda/migrations/0011_tirage_appear_catalogue.py b/bda/migrations/0011_tirage_appear_catalogue.py index 446be392..a8c49e2d 100644 --- a/bda/migrations/0011_tirage_appear_catalogue.py +++ b/bda/migrations/0011_tirage_appear_catalogue.py @@ -5,7 +5,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [("bda", "0010_spectaclerevente_shotgun")] operations = [ diff --git a/bda/migrations/0012_notif_time.py b/bda/migrations/0012_notif_time.py index 96853a24..78ef8dce 100644 --- a/bda/migrations/0012_notif_time.py +++ b/bda/migrations/0012_notif_time.py @@ -5,7 +5,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [("bda", "0011_tirage_appear_catalogue")] operations = [ diff --git a/bda/migrations/0012_swap_double_choice.py b/bda/migrations/0012_swap_double_choice.py index e712f2ff..dcb8056d 100644 --- a/bda/migrations/0012_swap_double_choice.py +++ b/bda/migrations/0012_swap_double_choice.py @@ -13,7 +13,6 @@ def swap_double_choice(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [("bda", "0011_tirage_appear_catalogue")] operations = [ diff --git a/bda/migrations/0013_merge_20180524_2123.py b/bda/migrations/0013_merge_20180524_2123.py index 8f78b6a9..b974abf2 100644 --- a/bda/migrations/0013_merge_20180524_2123.py +++ b/bda/migrations/0013_merge_20180524_2123.py @@ -6,7 +6,6 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [("bda", "0012_notif_time"), ("bda", "0012_swap_double_choice")] operations = [] diff --git a/bda/migrations/0014_attribution_paid_field.py b/bda/migrations/0014_attribution_paid_field.py index b5bb6208..e5ef2b2d 100644 --- a/bda/migrations/0014_attribution_paid_field.py +++ b/bda/migrations/0014_attribution_paid_field.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [("bda", "0013_merge_20180524_2123")] operations = [ diff --git a/bda/migrations/0015_move_bda_payment.py b/bda/migrations/0015_move_bda_payment.py index 93f121a1..a39a159c 100644 --- a/bda/migrations/0015_move_bda_payment.py +++ b/bda/migrations/0015_move_bda_payment.py @@ -29,7 +29,6 @@ def set_participant_payment(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [("bda", "0014_attribution_paid_field")] operations = [ diff --git a/bda/migrations/0016_delete_participant_paid.py b/bda/migrations/0016_delete_participant_paid.py index f59d1eb9..86a17b24 100644 --- a/bda/migrations/0016_delete_participant_paid.py +++ b/bda/migrations/0016_delete_participant_paid.py @@ -4,7 +4,6 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [("bda", "0015_move_bda_payment")] operations = [ diff --git a/bda/migrations/0017_participant_accepte_charte.py b/bda/migrations/0017_participant_accepte_charte.py index 6bd32d8f..3157654b 100644 --- a/bda/migrations/0017_participant_accepte_charte.py +++ b/bda/migrations/0017_participant_accepte_charte.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [("bda", "0016_delete_participant_paid")] operations = [ diff --git a/bda/migrations/0018_auto_20201021_1818.py b/bda/migrations/0018_auto_20201021_1818.py new file mode 100644 index 00000000..444f32d8 --- /dev/null +++ b/bda/migrations/0018_auto_20201021_1818.py @@ -0,0 +1,37 @@ +# Generated by Django 2.2.12 on 2020-10-21 16:18 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("bda", "0017_participant_accepte_charte"), + ] + + operations = [ + migrations.AlterModelOptions( + name="participant", + options={"ordering": ("-tirage", "user__last_name", "user__first_name")}, + ), + migrations.AddField( + model_name="tirage", + name="archived", + field=models.BooleanField(default=False, verbose_name="Archivé"), + ), + migrations.AlterField( + model_name="participant", + name="tirage", + field=models.ForeignKey( + limit_choices_to={"archived": False}, + on_delete=django.db.models.deletion.CASCADE, + to="bda.Tirage", + ), + ), + migrations.AddConstraint( + model_name="participant", + constraint=models.UniqueConstraint( + fields=("tirage", "user"), name="unique_tirage" + ), + ), + ] diff --git a/bda/migrations/0019_auto_20220630_1245.py b/bda/migrations/0019_auto_20220630_1245.py new file mode 100644 index 00000000..12b7149d --- /dev/null +++ b/bda/migrations/0019_auto_20220630_1245.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.13 on 2022-06-30 10:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bda", "0018_auto_20201021_1818"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="choixspectacle", + unique_together=set(), + ), + migrations.AddConstraint( + model_name="choixspectacle", + constraint=models.UniqueConstraint( + fields=("participant", "spectacle"), name="unique_participation" + ), + ), + ] diff --git a/bda/migrations/0019_auto_20240707_1359.py b/bda/migrations/0019_auto_20240707_1359.py new file mode 100644 index 00000000..ad25e0d6 --- /dev/null +++ b/bda/migrations/0019_auto_20240707_1359.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2024-07-07 11:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bda', '0018_auto_20201021_1818'), + ] + + operations = [ + migrations.AlterField( + model_name='attribution', + name='paymenttype', + field=models.CharField(blank=True, choices=[('cash', 'Cash'), ('cb', 'CB'), ('cheque', 'Chèque'), ('virement', 'Virement'), ('autre', 'Autre')], max_length=8, verbose_name='Moyen de paiement'), + ), + ] diff --git a/bda/migrations/0020_merge_0019_auto_20220630_1245_0019_auto_20240707_1359.py b/bda/migrations/0020_merge_0019_auto_20220630_1245_0019_auto_20240707_1359.py new file mode 100644 index 00000000..a8c7a72e --- /dev/null +++ b/bda/migrations/0020_merge_0019_auto_20220630_1245_0019_auto_20240707_1359.py @@ -0,0 +1,13 @@ +# Generated by Django 4.2.16 on 2025-02-26 08:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bda", "0019_auto_20220630_1245"), + ("bda", "0019_auto_20240707_1359"), + ] + + operations = [] diff --git a/bda/models.py b/bda/models.py index f4a0fac6..af0d49fb 100644 --- a/bda/models.py +++ b/bda/models.py @@ -2,14 +2,14 @@ import calendar import random from datetime import timedelta -from custommail.models import CustomMail -from custommail.shortcuts import send_mass_custom_mail from django.conf import settings from django.contrib.auth.models import User from django.contrib.sites.models import Site from django.core import mail +from django.core.mail import EmailMessage, send_mass_mail from django.db import models from django.db.models import Count, Exists +from django.template import loader from django.utils import formats, timezone @@ -31,6 +31,7 @@ class Tirage(models.Model): "Tirage à afficher dans le catalogue", default=False ) enable_do_tirage = models.BooleanField("Le tirage peut être lancé", default=False) + archived = models.BooleanField("Archivé", default=False) def __str__(self): return "%s - %s" % ( @@ -116,16 +117,19 @@ class Spectacle(models.Model): bda_generic.nb_attr = 1 members.append(bda_generic) # On écrit un mail personnalisé à chaque participant - datatuple = [ + mails = [ ( - "bda-rappel", - {"member": member, "nb_attr": member.nb_attr, "show": self}, + str(self), + loader.render_to_string( + "bda/mails/rappel.txt", + context={"member": member, "nb_attr": member.nb_attr, "show": self}, + ), settings.MAIL_DATA["rappels"]["FROM"], [member.email], ) for member in members ] - send_mass_custom_mail(datatuple) + send_mass_mail(mails) # On enregistre le fait que l'envoi a bien eu lieu self.rappel_sent = timezone.now() self.save() @@ -147,6 +151,7 @@ PAYMENT_TYPES = ( ("cash", "Cash"), ("cb", "CB"), ("cheque", "Chèque"), + ("virement", "Virement"), ("autre", "Autre"), ) @@ -159,7 +164,7 @@ class Attribution(models.Model): given = models.BooleanField("Donnée", default=False) paid = models.BooleanField("Payée", default=False) paymenttype = models.CharField( - "Moyen de paiement", max_length=6, choices=PAYMENT_TYPES, blank=True + "Moyen de paiement", max_length=8, choices=PAYMENT_TYPES, blank=True ) def __str__(self): @@ -172,8 +177,8 @@ class Attribution(models.Model): class ParticipantPaidQueryset(models.QuerySet): """ - Un manager qui annote le queryset avec un champ `paid`, - indiquant si un participant a payé toutes ses attributions. + Un manager qui annote le queryset avec un champ `paid`, + indiquant si un participant a payé toutes ses attributions. """ def annotate_paid(self): @@ -194,7 +199,9 @@ class Participant(models.Model): attributions = models.ManyToManyField( Spectacle, through="Attribution", related_name="attributed_to" ) - tirage = models.ForeignKey(Tirage, on_delete=models.CASCADE) + tirage = models.ForeignKey( + Tirage, on_delete=models.CASCADE, limit_choices_to={"archived": False} + ) accepte_charte = models.BooleanField("A accepté la charte BdA", default=False) choicesrevente = models.ManyToManyField( Spectacle, related_name="subscribed", blank=True @@ -205,6 +212,12 @@ class Participant(models.Model): def __str__(self): return "%s - %s" % (self.user, self.tirage.title) + class Meta: + ordering = ("-tirage", "user__last_name", "user__first_name") + constraints = [ + models.UniqueConstraint(fields=("tirage", "user"), name="unique_tirage"), + ] + DOUBLE_CHOICES = ( ("1", "1 place"), @@ -241,7 +254,11 @@ class ChoixSpectacle(models.Model): class Meta: ordering = ("priority",) - unique_together = (("participant", "spectacle"),) + constraints = [ + models.UniqueConstraint( + fields=["participant", "spectacle"], name="unique_participation" + ) + ] verbose_name = "voeu" verbose_name_plural = "voeux" @@ -348,21 +365,24 @@ class SpectacleRevente(models.Model): BdA-Revente à tous les intéressés. """ inscrits = self.attribution.spectacle.subscribed.select_related("user") - datatuple = [ + mails = [ ( - "bda-revente", - { - "member": participant.user, - "show": self.attribution.spectacle, - "revente": self, - "site": Site.objects.get_current(), - }, + "BdA-Revente : {}".format(self.attribution.spectacle.title), + loader.render_to_string( + "bda/mails/revente-new.txt", + context={ + "member": participant.user, + "show": self.attribution.spectacle, + "revente": self, + "site": Site.objects.get_current(), + }, + ), settings.MAIL_DATA["revente"]["FROM"], [participant.user.email], ) for participant in inscrits ] - send_mass_custom_mail(datatuple) + send_mass_mail(mails) self.notif_sent = True self.notif_time = timezone.now() self.save() @@ -373,20 +393,23 @@ class SpectacleRevente(models.Model): leur indiquer qu'il est désormais disponible au shotgun. """ inscrits = self.attribution.spectacle.subscribed.select_related("user") - datatuple = [ + mails = [ ( - "bda-shotgun", - { - "member": participant.user, - "show": self.attribution.spectacle, - "site": Site.objects.get_current(), - }, + "BdA-Revente : {}".format(self.attribution.spectacle.title), + loader.render_to_string( + "bda/mails/revente-shotgun.txt", + context={ + "member": participant.user, + "show": self.attribution.spectacle, + "site": Site.objects.get_current(), + }, + ), settings.MAIL_DATA["revente"]["FROM"], [participant.user.email], ) for participant in inscrits ] - send_mass_custom_mail(datatuple) + send_mass_mail(mails) self.notif_sent = True self.notif_time = timezone.now() # Flag inutile, sauf si l'horloge interne merde @@ -418,31 +441,30 @@ class SpectacleRevente(models.Model): "show": spectacle, } - c_mails_qs = CustomMail.objects.filter( - shortname__in=[ - "bda-revente-winner", - "bda-revente-loser", - "bda-revente-seller", - ] - ) - - c_mails = {cm.shortname: cm for cm in c_mails_qs} + subject = "BdA-Revente : {}".format(spectacle.title) mails.append( - c_mails["bda-revente-winner"].get_message( - context, + EmailMessage( + subject=subject, + body=loader.render_to_string( + "bda/mails/revente-tirage-winner.txt", + context=context, + ), from_email=settings.MAIL_DATA["revente"]["FROM"], to=[winner.user.email], ) ) - mails.append( - c_mails["bda-revente-seller"].get_message( - context, + EmailMessage( + subject=subject, + body=loader.render_to_string( + "bda/mails/revente-tirage-seller.txt", + context=context, + ), from_email=settings.MAIL_DATA["revente"]["FROM"], to=[seller.user.email], reply_to=[winner.user.email], - ) + ), ) # Envoie un mail aux perdants @@ -452,11 +474,15 @@ class SpectacleRevente(models.Model): new_context["acheteur"] = inscrit.user mails.append( - c_mails["bda-revente-loser"].get_message( - new_context, + EmailMessage( + subject=subject, + body=loader.render_to_string( + "bda/mails/revente-tirage-loser.txt", + context=new_context, + ), from_email=settings.MAIL_DATA["revente"]["FROM"], to=[inscrit.user.email], - ) + ), ) mail_conn = mail.get_connection() diff --git a/bda/templates/bda-attrib.html b/bda/templates/bda-attrib.html index fac0de67..057cacb4 100644 --- a/bda/templates/bda-attrib.html +++ b/bda/templates/bda-attrib.html @@ -1,5 +1,5 @@ {% extends "base_title.html" %} -{% load staticfiles %} +{% load static %} {% block extra_head %} diff --git a/bda/templates/bda/etat-places.html b/bda/templates/bda/etat-places.html index 401cc856..d1af0667 100644 --- a/bda/templates/bda/etat-places.html +++ b/bda/templates/bda/etat-places.html @@ -1,5 +1,5 @@ {% extends "base_title.html" %} -{% load staticfiles %} +{% load static %} {% block realcontent %}

État des inscriptions BdA

diff --git a/bda/templates/bda/inscription-tirage.html b/bda/templates/bda/inscription-tirage.html index 3f8091df..1eecd7af 100644 --- a/bda/templates/bda/inscription-tirage.html +++ b/bda/templates/bda/inscription-tirage.html @@ -1,5 +1,5 @@ {% extends "base_title.html" %} -{% load staticfiles %} +{% load static %} {% block extra_head %} diff --git a/bda/templates/bda/mails-rappel.html b/bda/templates/bda/mails-rappel.html index c10503b0..c0770e47 100644 --- a/bda/templates/bda/mails-rappel.html +++ b/bda/templates/bda/mails-rappel.html @@ -26,13 +26,6 @@
-

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

- -
-

Forme des mails

Une seule place

diff --git a/bda/templates/bda/mails/attributions-decus.txt b/bda/templates/bda/mails/attributions-decus.txt new file mode 100644 index 00000000..69fadff6 --- /dev/null +++ b/bda/templates/bda/mails/attributions-decus.txt @@ -0,0 +1,10 @@ +Cher-e {{ member.first_name }}, + +Tu t'es inscrit-e pour le tirage au sort du BdA. Malheureusement, tu n'as +obtenu aucune place. + +Nous proposons cependant de nombreuses offres hors-tirage tout au long de +l'année, et nous t'invitons à nous contacter si l'une d'entre elles +t'intéresse ! +-- +Le Bureau des Arts \ No newline at end of file diff --git a/bda/templates/bda/mails/attributions.txt b/bda/templates/bda/mails/attributions.txt new file mode 100644 index 00000000..002763ae --- /dev/null +++ b/bda/templates/bda/mails/attributions.txt @@ -0,0 +1,29 @@ +Cher-e {{ member.first_name }}, + +Tu t'es inscrit-e pour le tirage au sort du BdA. Tu as été sélectionné-e +pour les spectacles suivants : +{% for place in places %} +- 1 place pour {{ place }}{% endfor %} + +*Paiement* +Au burô : +L'intégralité de ces places de spectacles est à régler dès maintenant, au bureau du COF pendant les heures de permanences (lundi, mardi, jeudi entre 12h et 14h et entre 18h30 et 19h30, mercredi entre 18h30 et 19h30, vendredi entre 12h et 14h). Les places sont à régler AVANT les représentations. Si vous êtes en vacances, vous pourrez venir les régler dès votre retour. Il est demandé à chacun·e de prendre garde à honorer l’ensemble des places qui lui sont attribuées et de s'engager de fait à payer la ou les place(s) qui lui sont attribuées. + +Par virements : +L'intégralité de ces places de spectacles est à régler dès maintenant par virements. Il vous sera demandé d'envoyer une confirmation de l'envoi de virement à bda@ens.fr. +IBAN AEENS : FR76 4255 9100 0008 0263 8331 927 +Motif de virements : AVR25(ou MAI25)-tirageprintemps-NOM-prénom + +Les places sont à régler AVANT les représentations. Il est demandé à chacun·e de prendre garde à honorer l’ensemble des places qui lui sont attribuées et de s'engager de fait à payer la ou les place(s) qui lui sont attribuées. +Des facilités de paiement sont bien évidemment possibles : nous pouvons ne pas encaisser le chèque immédiatement, ou bien découper votre paiement en deux fois. Pour ceux qui ne pourraient pas venir payer au bureau, merci de nous contacter par mail. + +*Mode de retrait des places* +Au moment du paiement, certaines places vous seront remises directement, d'autres seront à récupérer au cours de l'année, d'autres encore seront nominatives et à retirer le soir même dans les théâtres correspondants. Pour chaque spectacle, vous recevrez un mail quelques jours avant la représentation vous indiquant le mode de retrait. +Nous vous rappelons que l'obtention de places du BdA vous engage à respecter les règles de fonctionnement : +https://bda.ens.fr/lequipe/charte-bda/ + +Un système de revente des places via les mails BdA-revente est disponible directement sur votre compte GestioCOF. Pour pouvoir l'utiliser, il faut que vous ayez payé vos places en amont. + +En vous souhaitant de très beaux spectacles tout au long de l'année, +-- +Le Bureau des Arts diff --git a/bda/templates/bda/mails/rappel.txt b/bda/templates/bda/mails/rappel.txt new file mode 100644 index 00000000..74614cbb --- /dev/null +++ b/bda/templates/bda/mails/rappel.txt @@ -0,0 +1,23 @@ +Bonjour {{ member.first_name }}, + +Nous te rappellons que tu as eu la chance d'obtenir {{ nb_attr|pluralize:"une place,deux places" }} +pour {{ show.title }}, le {{ show.date }} au {{ show.location }}. N'oublie pas de t'y rendre ! +{% if nb_attr == 2 %} +Tu as obtenu deux places pour ce spectacle. Nous te rappelons que +ces places sont strictement réservées aux personnes de moins de 28 ans. +{% endif %} +{% if show.listing %}Pour ce spectacle, tu as reçu {{ nb_attr|pluralize:"une place,des places" }} sur +listing. Il te faudra donc te rendre 15 minutes en avance sur les lieux de la représentation +pour {{ nb_attr|pluralize:"la,les" }} retirer. +{% else %}Pour assister à ce spectacle, tu dois présenter les billets qui ont +été distribués au burô. +{% endif %} + +Si tu ne peux plus assister à cette représentation, tu peux +revendre ta place via BdA-revente, accessible directement sur +GestioCOF (lien "revendre une place du premier tirage" sur la page +d'accueil https://www.cof.ens.fr/gestion/). + +En te souhaitant un excellent spectacle, +-- +Le Bureau des Arts \ No newline at end of file diff --git a/bda/templates/bda/mails/revente-new.txt b/bda/templates/bda/mails/revente-new.txt new file mode 100644 index 00000000..7344011a --- /dev/null +++ b/bda/templates/bda/mails/revente-new.txt @@ -0,0 +1,12 @@ +Bonjour {{ member.first_name }} + +Une place pour le spectacle {{ show.title }} ({{ show.date }}) +a été postée sur BdA-Revente. + +Si ce spectacle t'intéresse toujours, merci de nous le signaler en cliquant +sur ce lien : https://{{ site }}{% url "bda-revente-confirm" revente.id %}. +Dans le cas où plusieurs personnes seraient intéressées, nous procèderons à +un tirage au sort le {{ revente.date_tirage|date:"DATE_FORMAT" }}. + +Chaleureusement, +Le BdA \ No newline at end of file diff --git a/bda/templates/bda/mails/revente-seller.txt b/bda/templates/bda/mails/revente-seller.txt new file mode 100644 index 00000000..851ac09c --- /dev/null +++ b/bda/templates/bda/mails/revente-seller.txt @@ -0,0 +1,13 @@ +Bonjour {{ vendeur.first_name }}, + +Tu t’es bien inscrit·e pour revendre une place pour {{ show.title }}. + +{% with revente.date_tirage as time %} +Le tirage au sort entre tout·e·s les racheteuse·eur·s potentiel·le·s aura lieu +le {{ time|date:"DATE_FORMAT" }} à {{ time|time:"TIME_FORMAT" }} (dans {{time|timeuntil }}). +Si personne ne s’est inscrit pour racheter la place, celle-ci apparaîtra parmi +les « Places disponibles immédiatement à la revente » sur GestioCOF. +{% endwith %} + +Bonne revente ! +Le Bureau des Arts \ No newline at end of file diff --git a/bda/templates/bda/mails/revente-shotgun-seller.txt b/bda/templates/bda/mails/revente-shotgun-seller.txt new file mode 100644 index 00000000..e67083fc --- /dev/null +++ b/bda/templates/bda/mails/revente-shotgun-seller.txt @@ -0,0 +1,6 @@ +Bonjour {{ vendeur.first_name }} ! + +Je souhaiterais racheter ta place pour {{ show.title }} le {{ show.date }} ({{ show.location }}) à {{ show.price|floatformat:2 }}€. +Contacte-moi si tu es toujours intéressé·e ! + +{{ acheteur.get_full_name }} ({{ acheteur.email }}) \ No newline at end of file diff --git a/bda/templates/bda/mails/revente-shotgun.txt b/bda/templates/bda/mails/revente-shotgun.txt new file mode 100644 index 00000000..e7b1ce29 --- /dev/null +++ b/bda/templates/bda/mails/revente-shotgun.txt @@ -0,0 +1,11 @@ +Bonjour {{ member.first_name }} + +Une place pour le spectacle {{ show.title }} ({{ show.date }}) +a été postée sur BdA-Revente. + +Puisque ce spectacle a lieu dans moins de 24h, il n'y a pas de tirage au sort pour +cette place : elle est disponible immédiatement à l'adresse +https://{{ site }}{% url "bda-revente-buy" show.id %}, à la disposition de tous. + +Chaleureusement, +Le BdA \ No newline at end of file diff --git a/bda/templates/bda/mails/revente-tirage-loser.txt b/bda/templates/bda/mails/revente-tirage-loser.txt new file mode 100644 index 00000000..c1d49a01 --- /dev/null +++ b/bda/templates/bda/mails/revente-tirage-loser.txt @@ -0,0 +1,9 @@ +Bonjour {{ acheteur.first_name }}, + +Tu t'étais inscrit·e pour la revente de la place de {{ vendeur.get_full_name }} +pour {{ show.title }}. +Malheureusement, une autre personne a été tirée au sort pour racheter la place. +Tu pourras certainement retenter ta chance pour une autre revente ! + +À très bientôt, +Le Bureau des Arts \ No newline at end of file diff --git a/bda/templates/bda/mails/revente-tirage-seller.txt b/bda/templates/bda/mails/revente-tirage-seller.txt new file mode 100644 index 00000000..7abff7ca --- /dev/null +++ b/bda/templates/bda/mails/revente-tirage-seller.txt @@ -0,0 +1,7 @@ +Bonjour {{ vendeur.first_name }}, + +La personne tirée au sort pour racheter ta place pour {{ show.title }} est {{ acheteur.get_full_name }}. +Tu peux le/la contacter à l'adresse {{ acheteur.email }}, ou en répondant à ce mail. + +Chaleureusement, +Le BdA \ No newline at end of file diff --git a/bda/templates/bda/mails/revente-tirage-winner.txt b/bda/templates/bda/mails/revente-tirage-winner.txt new file mode 100644 index 00000000..11428ef7 --- /dev/null +++ b/bda/templates/bda/mails/revente-tirage-winner.txt @@ -0,0 +1,7 @@ +Bonjour {{ acheteur.first_name }}, + +Tu as été tiré·e au sort pour racheter une place pour {{ show.title }} le {{ show.date }} ({{ show.location }}) à {{ show.price|floatformat:2 }}€. +Tu peux contacter le/la vendeur·se à l'adresse {{ vendeur.email }}. + +Chaleureusement, +Le BdA \ No newline at end of file diff --git a/bda/templates/bda/participants.html b/bda/templates/bda/participants.html index 0e0489a8..c99e5182 100644 --- a/bda/templates/bda/participants.html +++ b/bda/templates/bda/participants.html @@ -1,5 +1,5 @@ {% extends "base_title.html" %} -{% load staticfiles %} +{% load static %} {% block realcontent %}

{{ spectacle }}

@@ -16,7 +16,7 @@ {% for participant in participants %} - {{participant.name}} + {{participant.name}} {{participant.nb_places}} place{{participant.nb_places|pluralize}} {{participant.email}} @@ -38,7 +38,7 @@

Ajouter une attribution

- + @@ -56,6 +56,7 @@ Page d'envoi manuel des mails de rappel
+ + + + + {% block extra_head %}{% endblock extra_head %} + + + {% include "bds/nav.html" %} + {% block layout %} +
+
+
+ + {% if messages %} + {% for message in messages %} +
+ {% if 'safe' in message.tags %} + {{ message|safe }} + {% else %} + {{ message }} + {% endif %} + +
+ {% endfor %} + {% endif %} + + {% block content %} + {% endblock content %} +
+
+
+ {% endblock layout %} + + diff --git a/bds/templates/bds/expired_members.html b/bds/templates/bds/expired_members.html new file mode 100644 index 00000000..c5b53dbc --- /dev/null +++ b/bds/templates/bds/expired_members.html @@ -0,0 +1,22 @@ +{% extends "bds/base.html" %} + + +{% block content %} + +

Liste des adhésions expirées

+ +{% if object_list %} +
+
    + {% for p in object_list %} +
  • {{ p.user.first_name }} {{ p.user.last_name }} ({{ p.user.username }}), {{ p.get_cotisation_period_display }}
  • + {% endfor %} +
+
+ + +{% endif %} + +{% endblock %} diff --git a/bds/templates/bds/forms/checkbox.html b/bds/templates/bds/forms/checkbox.html new file mode 100644 index 00000000..109ba75c --- /dev/null +++ b/bds/templates/bds/forms/checkbox.html @@ -0,0 +1,16 @@ +
+ {% if field.auto_id %} + + {% endif %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + + {% if field.help_text %} +

+ {{ field.help_text|safe }} +

+ {% endif %} +
\ No newline at end of file diff --git a/bds/templates/bds/forms/field.html b/bds/templates/bds/forms/field.html new file mode 100644 index 00000000..132d6520 --- /dev/null +++ b/bds/templates/bds/forms/field.html @@ -0,0 +1,33 @@ +{% load bulma_utils %} + +
+ {% if field|is_checkbox %} + + {% include "bds/forms/checkbox.html" with field=field %} + + {% elif field|is_radio %} + + {% include "bds/forms/radio.html" with field=field %} + + {% elif field|is_input %} + + {% include "bds/forms/input.html" with field=field %} + + {% elif field|is_textarea %} + + {% include "bds/forms/textarea.html" with field=field %} + + {% elif field|is_select %} + + {% include "bds/forms/select.html" with field=field %} + + {% elif field|is_file %} + + {% include "bds/forms/file.html" with field=field %} + + {% else %} + + {% include "bds/forms/other.html" with field=field %} + + {% endif %} +
\ No newline at end of file diff --git a/bds/templates/bds/forms/file.html b/bds/templates/bds/forms/file.html new file mode 100644 index 00000000..65c249ef --- /dev/null +++ b/bds/templates/bds/forms/file.html @@ -0,0 +1,31 @@ +{% load bulma_utils %} +{% load i18n %} + + + +
+ + + + {% for error in field.errors %} + {{ error }} + {% endfor %} + + {% if field.help_text %} +

+ {{ field.help_text|safe }} +

+ {% endif %} +
\ No newline at end of file diff --git a/bds/templates/bds/forms/form.html b/bds/templates/bds/forms/form.html new file mode 100644 index 00000000..fdc9dc00 --- /dev/null +++ b/bds/templates/bds/forms/form.html @@ -0,0 +1,22 @@ +{% if errors %} + {% if form.non_field_errors %} +
+
+ +
+
+ {% for non_field_error in form.non_field_errors %} + {{ non_field_error }} + {% endfor %} +
+
+ {% endif %} +{% endif %} + +{% for field in form.hidden_fields %} + {{ field }} +{% endfor %} + +{% for field in form.visible_fields %} + {% include 'bds/forms/field.html' %} +{% endfor %} \ No newline at end of file diff --git a/bds/templates/bds/forms/input.html b/bds/templates/bds/forms/input.html new file mode 100644 index 00000000..51c6f252 --- /dev/null +++ b/bds/templates/bds/forms/input.html @@ -0,0 +1,19 @@ +{% load bulma_utils %} + + + +
+ {{ field|bulmafy:'input' }} + + {% for error in field.errors %} + {{ error }} + {% endfor %} + + {% if field.help_text %} +

+ {{ field.help_text|safe }} +

+ {% endif %} +
\ No newline at end of file diff --git a/bds/templates/bds/forms/other.html b/bds/templates/bds/forms/other.html new file mode 100644 index 00000000..4e1cdc8e --- /dev/null +++ b/bds/templates/bds/forms/other.html @@ -0,0 +1,19 @@ +{% if field.auto_id %} + +{% endif %} + +
+ {{ field }} + + {% for error in field.errors %} + {{ error }} + {% endfor %} + + {% if field.help_text %} +

+ {{ field.help_text|safe }} +

+ {% endif %} +
\ No newline at end of file diff --git a/bds/templates/bds/forms/radio.html b/bds/templates/bds/forms/radio.html new file mode 100644 index 00000000..d2e3b141 --- /dev/null +++ b/bds/templates/bds/forms/radio.html @@ -0,0 +1,24 @@ +{% if field.auto_id %} + +{% endif %} + +
+ {% for choice in field %} + + {% endfor %} + + {% for error in field.errors %} + {{ error }} + {% endfor %} + + {% if field.help_text %} +

+ {{ field.help_text|safe }} +

+ {% endif %} +
\ No newline at end of file diff --git a/bds/templates/bds/forms/select.html b/bds/templates/bds/forms/select.html new file mode 100644 index 00000000..2a064311 --- /dev/null +++ b/bds/templates/bds/forms/select.html @@ -0,0 +1,21 @@ +{% load bulma_utils %} + + + +
+ + {{ field }} + + + {% for error in field.errors %} + {{ error }} + {% endfor %} + + {% if field.help_text %} +

+ {{ field.help_text|safe }} +

+ {% endif %} +
\ No newline at end of file diff --git a/bds/templates/bds/forms/textarea.html b/bds/templates/bds/forms/textarea.html new file mode 100644 index 00000000..c5bc50f4 --- /dev/null +++ b/bds/templates/bds/forms/textarea.html @@ -0,0 +1,19 @@ +{% load bulma_utils %} + + + +
+ {{ field|bulmafy:'textarea' }} + + {% for error in field.errors %} + {{ error }} + {% endfor %} + + {% if field.help_text %} +

+ {{ field.help_text|safe }} +

+ {% endif %} +
\ No newline at end of file diff --git a/bds/templates/bds/home.html b/bds/templates/bds/home.html new file mode 100644 index 00000000..aedbaa25 --- /dev/null +++ b/bds/templates/bds/home.html @@ -0,0 +1,62 @@ +{% extends "bds/base.html" %} +{% load bulma_utils %} + +{% block layout %} +
+
+
+
+
+

{{ member_count }}

+ adhérent·e·s +
+
+
+
+
+
+ + {% if messages %} + {% for message in messages %} +
+ {% if 'safe' in message.tags %} + {{ message|safe }} + {% else %} + {{ message }} + {% endif %} + +
+ {% endfor %} + {% endif %} +
+ Bienvenue sur GestioBDS ! + +
+
+ + Télécharger la liste des membres (CSV) + + Liste des adhésions expirées ({{ nb_expired }}) + +
+
+ + Le site est encore en développement. +
+ Suivez notre avancement sur + + cette milestone sur le gitlab de l'ENS. +
+ Faites vos remarques par mail à + klub-dev@ens.fr + ou en ouvrant une + + issue. +
+
+
+
+{% endblock layout %} + + + diff --git a/bds/templates/bds/nav.html b/bds/templates/bds/nav.html new file mode 100644 index 00000000..ff167189 --- /dev/null +++ b/bds/templates/bds/nav.html @@ -0,0 +1,57 @@ +{% load i18n %} +{% load static %} + + + + diff --git a/bds/templates/bds/search_results.html b/bds/templates/bds/search_results.html new file mode 100644 index 00000000..fec629df --- /dev/null +++ b/bds/templates/bds/search_results.html @@ -0,0 +1,21 @@ +{% extends "shared/search_results.html" %} +{% load i18n %} + +{% block extra_section %} +
  • + {% if not results %} + + {% trans "Aucune correspondance trouvée" %} + + {% else %} + + {% trans "Pas dans la liste ?" %} + + {% endif %} +
  • +
  • + + {% trans "Créer un compte" %} + +
  • +{% endblock %} diff --git a/bds/templates/bds/user_create.html b/bds/templates/bds/user_create.html new file mode 100644 index 00000000..a36874cb --- /dev/null +++ b/bds/templates/bds/user_create.html @@ -0,0 +1,33 @@ +{% extends "bds/base.html" %} +{% load i18n %} + + +{% block content %} + +{% for form in forms.values %} + {% for error in form.non_field_errors %} +
    + {{ error }} +
    + {% endfor %} +{% endfor %} + +

    {% trans "Création d'un profil" %}

    + +
    +
    + {% csrf_token %} + + {% for form in forms.values %} + {% include "bds/forms/form.html" with form=form errors=False %} + {% endfor %} + +
    +

    + +

    +
    +
    +
    + +{% endblock %} diff --git a/bds/templates/bds/user_update.html b/bds/templates/bds/user_update.html new file mode 100644 index 00000000..1f54abe2 --- /dev/null +++ b/bds/templates/bds/user_update.html @@ -0,0 +1,104 @@ +{% extends "bds/base.html" %} +{% load i18n %} + + +{% block content %} + +{% for form in forms.values %} +{% for error in form.non_field_errors %} +
    + {{ error }} +
    +{% endfor %} +{% endfor %} + +

    {% trans "Modification du profil " %}{{ view.user.username }}

    + +
    +
    + {% csrf_token %} + + {% for form in forms.values %} + {% include "bds/forms/form.html" with form=form errors=False %} + {% endfor %} +
    + +
    + {% csrf_token %} +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + + + + + + +{% endblock %} diff --git a/bds/templatetags/bulma_utils.py b/bds/templatetags/bulma_utils.py new file mode 100644 index 00000000..e0e15e2f --- /dev/null +++ b/bds/templatetags/bulma_utils.py @@ -0,0 +1,74 @@ +from django import forms, template + +register = template.Library() + + +@register.filter +def widget_type(field): + return field.field.widget + + +@register.filter +def is_select(field): + return isinstance(field.field.widget, forms.Select) + + +@register.filter +def is_multiple_select(field): + return isinstance(field.field.widget, forms.SelectMultiple) + + +@register.filter +def is_textarea(field): + return isinstance(field.field.widget, forms.Textarea) + + +@register.filter +def is_input(field): + return isinstance( + field.field.widget, + ( + forms.TextInput, + forms.NumberInput, + forms.EmailInput, + forms.PasswordInput, + forms.URLInput, + ), + ) + + +@register.filter +def is_checkbox(field): + return isinstance(field.field.widget, forms.CheckboxInput) + + +@register.filter +def is_multiple_checkbox(field): + return isinstance(field.field.widget, forms.CheckboxSelectMultiple) + + +@register.filter +def is_radio(field): + return isinstance(field.field.widget, forms.RadioSelect) + + +@register.filter +def is_file(field): + return isinstance(field.field.widget, forms.FileInput) + + +@register.filter +def bulmafy(field, css_class): + if len(field.errors) > 0: + css_class += " is-danger" + field_classes = field.field.widget.attrs.get("class", "") + field_classes += f" {css_class}" + return field.as_widget(attrs={"class": field_classes}) + + +@register.filter +def bulma_message_tag(tag): + if tag == "error": + return "danger" + + return tag diff --git a/cof/__init__.py b/bds/tests/__init__.py similarity index 100% rename from cof/__init__.py rename to bds/tests/__init__.py diff --git a/bds/tests/test_views.py b/bds/tests/test_views.py new file mode 100644 index 00000000..332db8d7 --- /dev/null +++ b/bds/tests/test_views.py @@ -0,0 +1,76 @@ +from unittest import mock + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.test import Client, TestCase +from django.urls import reverse, reverse_lazy + +User = get_user_model() + + +def give_bds_buro_permissions(user: User) -> None: + perm = Permission.objects.get(content_type__app_label="bds", codename="is_team") + user.user_permissions.add(perm) + + +def login_url(next=None): + login_url = reverse_lazy(settings.LOGIN_URL) + if next is None: + return login_url + else: + return "{}?next={}".format(login_url, next) + + +class TestHomeView(TestCase): + @mock.patch("gestioncof.signals.messages") + def test_get(self, mock_messages): + user = User.objects.create_user(username="random_user") + give_bds_buro_permissions(user) + self.client.force_login( + user, backend="django.contrib.auth.backends.ModelBackend" + ) + resp = self.client.get(reverse("bds:home")) + self.assertEqual(resp.status_code, 200) + + +class TestRegistrationView(TestCase): + @mock.patch("gestioncof.signals.messages") + def test_get_autocomplete(self, mock_messages): + user = User.objects.create_user(username="toto") + url = reverse("bds:autocomplete") + "?q=foo" + client = Client() + + # Anonymous GET + resp = client.get(url) + self.assertRedirects(resp, login_url(next=url)) + + # Logged-in but unprivileged GET + client.force_login(user, backend="django.contrib.auth.backends.ModelBackend") + resp = client.get(url) + self.assertEqual(resp.status_code, 403) + + # Burô user GET + give_bds_buro_permissions(user) + resp = client.get(url) + self.assertEqual(resp.status_code, 200) + + @mock.patch("gestioncof.signals.messages") + def test_get(self, mock_messages): + user = User.objects.create_user(username="toto") + url = reverse("bds:user.update", args=(user.id,)) + client = Client() + + # Anonymous GET + resp = client.get(url) + self.assertRedirects(resp, login_url(next=url)) + + # Logged-in but unprivileged GET + client.force_login(user, backend="django.contrib.auth.backends.ModelBackend") + resp = client.get(url) + self.assertEqual(resp.status_code, 403) + + # Burô user GET + give_bds_buro_permissions(user) + resp = client.get(url) + self.assertEqual(resp.status_code, 200) diff --git a/bds/urls.py b/bds/urls.py new file mode 100644 index 00000000..68d780df --- /dev/null +++ b/bds/urls.py @@ -0,0 +1,31 @@ +from django.urls import path + +from bds import views +from shared.views import SympaListView + +app_name = "bds" +urlpatterns = [ + path("", views.Home.as_view(), name="home"), + path("autocomplete", views.BDSAutocompleteView.as_view(), name="autocomplete"), + path("user/update/", views.UserUpdateView.as_view(), name="user.update"), + path("user/create/", views.UserCreateView.as_view(), name="user.create"), + path( + "user/create/", + views.UserCreateView.as_view(), + name="user.create.fromclipper", + ), + path("user/delete/", views.UserDeleteView.as_view(), name="user.delete"), + path("members", views.export_members, name="export.members"), + path( + "members/expired", + views.ResetMembershipListView.as_view(), + name="members.expired", + ), + path("members/reset", views.ResetMembershipView.as_view(), name="members.reset"), + # Sympa export view + path( + "sympa/members/", + SympaListView.as_view(filters={"bds__is_member": True}), + name="export.sympa", + ), +] diff --git a/bds/views.py b/bds/views.py index 60f00ef0..5fc4f316 100644 --- a/bds/views.py +++ b/bds/views.py @@ -1 +1,184 @@ -# Create your views here. +import csv + +from django.contrib import messages +from django.contrib.auth import get_user_model +from django.contrib.auth.decorators import permission_required +from django.contrib.auth.models import Permission +from django.http import HttpResponse +from django.shortcuts import get_object_or_404 +from django.urls import reverse, reverse_lazy +from django.utils.translation import gettext_lazy as _ +from django.views.generic import DeleteView, ListView, RedirectView, TemplateView + +from bds.autocomplete import bds_search +from bds.forms import ProfileForm, UserForm, UserFromClipperForm, UserFromScratchForm +from bds.mixins import MultipleFormView, StaffRequiredMixin +from bds.models import BDSProfile +from shared.views import AutocompleteView + +User = get_user_model() + + +class BDSAutocompleteView(StaffRequiredMixin, AutocompleteView): + template_name = "bds/search_results.html" + search_composer = bds_search + + +class Home(StaffRequiredMixin, TemplateView): + template_name = "bds/home.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["member_count"] = BDSProfile.objects.filter(is_member=True).count() + context["nb_expired"] = BDSProfile.expired_members().count() + return context + + +class UserUpdateView(StaffRequiredMixin, MultipleFormView): + template_name = "bds/user_update.html" + + form_classes = { + "user": UserForm, + "profile": ProfileForm, + } + + def get_user_initial(self): + return {"is_buro": self.get_user_instance().has_perm("bds.is_team")} + + def dispatch(self, request, *args, **kwargs): + self.user = get_object_or_404(User, pk=self.kwargs["pk"]) + return super().dispatch(request, *args, **kwargs) + + def get_user_instance(self): + return self.user + + def get_profile_instance(self): + return getattr(self.user, "bds", None) + + def get_success_url(self): + return reverse("bds:user.update", args=(self.user.pk,)) + + def form_valid(self, forms): + user = forms["user"].save() + profile = forms["profile"].save(commit=False) + perm = Permission.objects.get(content_type__app_label="bds", codename="is_team") + if forms["user"].cleaned_data["is_buro"]: + user.user_permissions.add(perm) + else: + user.user_permissions.remove(perm) + profile.user = user + profile.save() + messages.success(self.request, _("Profil mis à jour avec succès !")) + + return super().form_valid(forms) + + def form_invalid(self, forms): + messages.error(self.request, _("Veuillez corriger les erreurs ci-dessous")) + return super().form_invalid(forms) + + +class UserCreateView(StaffRequiredMixin, MultipleFormView): + template_name = "bds/user_create.html" + + def get_form_classes(self): + profile_class = ProfileForm + + if "clipper" in self.kwargs: + user_class = UserFromClipperForm + else: + user_class = UserFromScratchForm + + return {"user": user_class, "profile": profile_class} + + def get_user_initial(self): + if "clipper" in self.kwargs: + clipper = self.kwargs["clipper"] + email = self.request.GET.get("mail", "{}@clipper.ens.fr".format(clipper)) + fullname = self.request.GET.get("fullname", None) + + if fullname: + # Heuristique : le premier mot est le prénom + first_name = fullname.split()[0] + last_name = " ".join(fullname.split()[1:]) + else: + first_name = "" + last_name = "" + + return { + "username": clipper, + "email": email, + "first_name": first_name, + "last_name": last_name, + } + else: + return {} + + def get_success_url(self): + return reverse("bds:user.update", args=(self.user.pk,)) + + def form_valid(self, forms): + # On redéfinit self.user pour get_success_url + self.user = forms["user"].save() + profile = forms["profile"].save(commit=False) + profile.user = self.user + profile.save() + messages.success(self.request, _("Profil créé avec succès !")) + + return super().form_valid(forms) + + def form_invalid(self, forms): + messages.error(self.request, _("Veuillez corriger les erreurs ci-dessous")) + return super().form_invalid(forms) + + +class UserDeleteView(StaffRequiredMixin, DeleteView): + model = User + success_url = reverse_lazy("bds:home") + success_message = "Profil supprimé avec succès !" + + def delete(self, request, *args, **kwargs): + # SuccessMessageMixin does not work with DeleteView, see : + # https://code.djangoproject.com/ticket/21926 + messages.success(request, self.success_message) + + return super().delete(request, *args, **kwargs) + + +class ResetMembershipListView(StaffRequiredMixin, ListView): + model = BDSProfile + template_name = "bds/expired_members.html" + + def get_queryset(self): + return BDSProfile.expired_members() + + +class ResetMembershipView(StaffRequiredMixin, RedirectView): + url = reverse_lazy("bds:members.expired") + + def get(self, request, *args, **kwargs): + qs = BDSProfile.expired_members() + nb = qs.count() + + qs.update(cotisation_period="NO", is_member=False, mails_bds=False) + + messages.success(request, f"{nb} adhésions réinitialisées") + + return super().get(request, *args, **kwargs) + + +@permission_required("bds.is_team") +def export_members(request): + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = "attachment; filename=membres_bds.csv" + + writer = csv.writer(response) + for profile in BDSProfile.objects.filter(is_member=True).all(): + user = profile.user + bits = [ + user.username, + user.get_full_name(), + user.email, + ] + writer.writerow([str(bit) for bit in bits]) + + return response diff --git a/clubs/migrations/0001_initial.py b/clubs/migrations/0001_initial.py index 689b5d33..8b9d60bc 100644 --- a/clubs/migrations/0001_initial.py +++ b/clubs/migrations/0001_initial.py @@ -5,7 +5,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] diff --git a/cof/asgi.py b/cof/asgi.py deleted file mode 100644 index ab4ce291..00000000 --- a/cof/asgi.py +++ /dev/null @@ -1,8 +0,0 @@ -import os - -from channels.asgi import get_channel_layer - -if "DJANGO_SETTINGS_MODULE" not in os.environ: - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cof.settings") - -channel_layer = get_channel_layer() diff --git a/cof/routing.py b/cof/routing.py deleted file mode 100644 index 3c2e5718..00000000 --- a/cof/routing.py +++ /dev/null @@ -1,3 +0,0 @@ -from channels.routing import include - -routing = [include("kfet.routing.routing", path=r"^/ws/k-fet")] diff --git a/cof/settings/common.py b/cof/settings/common.py deleted file mode 100644 index dd5b67b1..00000000 --- a/cof/settings/common.py +++ /dev/null @@ -1,261 +0,0 @@ -""" -Django common settings for cof project. - -Everything which is supposed to be identical between the production server and -the local development server should be here. -""" - -import os -import sys - -try: - from . import secret -except ImportError: - raise ImportError( - "The secret.py file is missing.\n" - "For a development environment, simply copy secret_example.py" - ) - - -def import_secret(name): - """ - Shorthand for importing a value from the secret module and raising an - informative exception if a secret is missing. - """ - try: - return getattr(secret, name) - except AttributeError: - raise RuntimeError("Secret missing: {}".format(name)) - - -SECRET_KEY = import_secret("SECRET_KEY") -ADMINS = import_secret("ADMINS") -SERVER_EMAIL = import_secret("SERVER_EMAIL") -EMAIL_HOST = import_secret("EMAIL_HOST") - -DBNAME = import_secret("DBNAME") -DBUSER = import_secret("DBUSER") -DBPASSWD = import_secret("DBPASSWD") - -REDIS_PASSWD = import_secret("REDIS_PASSWD") -REDIS_DB = import_secret("REDIS_DB") -REDIS_HOST = import_secret("REDIS_HOST") -REDIS_PORT = import_secret("REDIS_PORT") - -KFETOPEN_TOKEN = import_secret("KFETOPEN_TOKEN") -LDAP_SERVER_URL = import_secret("LDAP_SERVER_URL") - - -BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -TESTING = sys.argv[1] == "test" - - -# Application definition -INSTALLED_APPS = [ - "shared", - "gestioncof", - # Must be before 'django.contrib.admin'. - # https://django-autocomplete-light.readthedocs.io/en/master/install.html - "dal", - "dal_select2", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.sites", - "django.contrib.messages", - "cof.apps.IgnoreSrcStaticFilesConfig", # Must be before django admin - # https://github.com/infoportugal/wagtail-modeltranslation/issues/193 - "wagtail_modeltranslation", - "wagtail_modeltranslation.makemigrations", - "wagtail_modeltranslation.migrate", - "modeltranslation", - "django.contrib.admin", - "django.contrib.admindocs", - "bda", - "petitscours", - "captcha", - "django_cas_ng", - "bootstrapform", - "kfet", - "kfet.open", - "channels", - "widget_tweaks", - "custommail", - "djconfig", - "wagtail.contrib.forms", - "wagtail.contrib.redirects", - "wagtail.embeds", - "wagtail.sites", - "wagtail.users", - "wagtail.snippets", - "wagtail.documents", - "wagtail.images", - "wagtail.search", - "wagtail.admin", - "wagtail.core", - "wagtail.contrib.modeladmin", - "wagtail.contrib.routable_page", - "wagtailmenus", - "modelcluster", - "taggit", - "kfet.auth", - "kfet.cms", - "gestioncof.cms", -] - - -MIDDLEWARE = [ - "corsheaders.middleware.CorsMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", - "django.middleware.security.SecurityMiddleware", - "djconfig.middleware.DjConfigMiddleware", - "wagtail.core.middleware.SiteMiddleware", - "wagtail.contrib.redirects.middleware.RedirectMiddleware", - "django.middleware.locale.LocaleMiddleware", -] - -ROOT_URLCONF = "cof.urls" - -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - "django.template.context_processors.i18n", - "django.template.context_processors.media", - "django.template.context_processors.static", - "wagtailmenus.context_processors.wagtailmenus", - "djconfig.context_processors.config", - "gestioncof.shared.context_processor", - "kfet.auth.context_processors.temporary_auth", - "kfet.context_processors.config", - ] - }, - } -] - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql_psycopg2", - "NAME": DBNAME, - "USER": DBUSER, - "PASSWORD": DBPASSWD, - "HOST": os.environ.get("DBHOST", "localhost"), - } -} - - -# Internationalization -# https://docs.djangoproject.com/en/1.8/topics/i18n/ - -LANGUAGE_CODE = "fr-fr" - -TIME_ZONE = "Europe/Paris" - -USE_I18N = True - -USE_L10N = True - -USE_TZ = True - -LANGUAGES = (("fr", "Français"), ("en", "English")) - -# Various additional settings -SITE_ID = 1 - -GRAPPELLI_ADMIN_HEADLINE = "GestioCOF" -GRAPPELLI_ADMIN_TITLE = 'GestioCOF' - -MAIL_DATA = { - "petits_cours": { - "FROM": "Le COF ", - "BCC": "archivescof@gmail.com", - "REPLYTO": "cof@ens.fr", - }, - "rappels": {"FROM": "Le BdA ", "REPLYTO": "Le BdA "}, - "revente": { - "FROM": "BdA-Revente ", - "REPLYTO": "BdA-Revente ", - }, -} - -LOGIN_URL = "cof-login" -LOGIN_REDIRECT_URL = "home" - -CAS_SERVER_URL = "https://cas.eleves.ens.fr/" -CAS_VERSION = "2" -CAS_LOGIN_MSG = None -CAS_IGNORE_REFERER = True -CAS_REDIRECT_URL = "/" -CAS_EMAIL_FORMAT = "%s@clipper.ens.fr" - -AUTHENTICATION_BACKENDS = ( - "django.contrib.auth.backends.ModelBackend", - "gestioncof.shared.COFCASBackend", - "kfet.auth.backends.GenericBackend", -) - - -# reCAPTCHA settings -# https://github.com/praekelt/django-recaptcha -# -# Default settings authorize reCAPTCHA usage for local developement. -# Public and private keys are appended in the 'prod' module settings. - -NOCAPTCHA = True -RECAPTCHA_USE_SSL = True - -CORS_ORIGIN_WHITELIST = ("bda.ens.fr", "www.bda.ens.fr" "cof.ens.fr", "www.cof.ens.fr") - -# Cache settings - -CACHES = { - "default": { - "BACKEND": "redis_cache.RedisCache", - "LOCATION": "redis://:{passwd}@{host}:{port}/db".format( - passwd=REDIS_PASSWD, host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB - ), - } -} - - -# Channels settings - -CHANNEL_LAYERS = { - "default": { - "BACKEND": "asgi_redis.RedisChannelLayer", - "CONFIG": { - "hosts": [ - ( - "redis://:{passwd}@{host}:{port}/{db}".format( - passwd=REDIS_PASSWD, - host=REDIS_HOST, - port=REDIS_PORT, - db=REDIS_DB, - ) - ) - ] - }, - "ROUTING": "cof.routing.routing", - } -} - -FORMAT_MODULE_PATH = "cof.locale" - -# Wagtail settings - -WAGTAIL_SITE_NAME = "GestioCOF" -WAGTAIL_ENABLE_UPDATE_CHECK = False -TAGGIT_CASE_INSENSITIVE = True diff --git a/cof/settings/dev.py b/cof/settings/dev.py deleted file mode 100644 index d287eab8..00000000 --- a/cof/settings/dev.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -Django development settings for the cof project. -The settings that are not listed here are imported from .common -""" - -import os - -from .common import * # NOQA -from .common import INSTALLED_APPS, MIDDLEWARE, TESTING - -EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" - -DEBUG = True - -if TESTING: - PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] - -# As long as these apps are not ready for production, they are only available -# in development mode -INSTALLED_APPS += ["events", "bds", "clubs"] - - -# --- -# Apache static/media config -# --- - -STATIC_URL = "/static/" -STATIC_ROOT = "/srv/gestiocof/static/" - -MEDIA_ROOT = "/srv/gestiocof/media/" -MEDIA_URL = "/media/" - - -# --- -# Debug tool bar -# --- - - -def show_toolbar(request): - """ - On ne veut pas la vérification de INTERNAL_IPS faite par la debug-toolbar - car cela interfère avec l'utilisation de Vagrant. En effet, l'adresse de la - machine physique n'est pas forcément connue, et peut difficilement être - mise dans les INTERNAL_IPS. - """ - env_no_ddt = bool(os.environ.get("DJANGO_NO_DDT", None)) - return DEBUG and not env_no_ddt and not request.path.startswith("/admin/") - - -if not TESTING: - INSTALLED_APPS += ["debug_toolbar"] - - MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE - - DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": show_toolbar} diff --git a/cof/settings/local.py b/cof/settings/local.py deleted file mode 100644 index 06cdf4a0..00000000 --- a/cof/settings/local.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Django local settings for the cof project. -The settings that are not listed here are imported from .common -""" - -import os - -from .dev import * # NOQA -from .dev import BASE_DIR - -# Use sqlite for local development -DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": os.path.join(BASE_DIR, "db.sqlite3"), - } -} - -# Use the default cache backend for local development -CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}} - -# Use the default in memory asgi backend for local development -CHANNEL_LAYERS = { - "default": { - "BACKEND": "asgiref.inmemory.ChannelLayer", - "ROUTING": "cof.routing.routing", - } -} - -# No need to run collectstatic -> unset STATIC_ROOT -STATIC_ROOT = None diff --git a/cof/settings/prod.py b/cof/settings/prod.py deleted file mode 100644 index 748abe73..00000000 --- a/cof/settings/prod.py +++ /dev/null @@ -1,28 +0,0 @@ -""" -Django development settings for the cof project. -The settings that are not listed here are imported from .common -""" - -import os - -from .common import * # NOQA -from .common import BASE_DIR, INSTALLED_APPS, TESTING, import_secret - -DEBUG = False - -ALLOWED_HOSTS = ["cof.ens.fr", "www.cof.ens.fr", "dev.cof.ens.fr"] - -if TESTING: - INSTALLED_APPS += ["events", "clubs"] - -STATIC_ROOT = os.path.join( - os.path.dirname(os.path.dirname(BASE_DIR)), "public", "gestion", "static" -) - -STATIC_URL = "/gestion/static/" -MEDIA_ROOT = os.path.join(os.path.dirname(BASE_DIR), "media") -MEDIA_URL = "/gestion/media/" - - -RECAPTCHA_PUBLIC_KEY = import_secret("RECAPTCHA_PUBLIC_KEY") -RECAPTCHA_PRIVATE_KEY = import_secret("RECAPTCHA_PRIVATE_KEY") diff --git a/cof/urls.py b/cof/urls.py deleted file mode 100644 index 1baa2a8e..00000000 --- a/cof/urls.py +++ /dev/null @@ -1,145 +0,0 @@ -""" -Fichier principal de configuration des urls du projet GestioCOF -""" - -from django.conf import settings -from django.conf.urls.i18n import i18n_patterns -from django.conf.urls.static import static -from django.contrib import admin -from django.contrib.auth import views as django_auth_views -from django.urls import include, path -from django.views.generic.base import TemplateView -from django_cas_ng import views as django_cas_views -from wagtail.admin import urls as wagtailadmin_urls -from wagtail.core import urls as wagtail_urls -from wagtail.documents import urls as wagtaildocs_urls - -from gestioncof import csv_views, views as gestioncof_views -from gestioncof.autocomplete import autocomplete -from gestioncof.urls import ( - calendar_patterns, - clubs_patterns, - events_patterns, - export_patterns, - surveys_patterns, -) - -admin.autodiscover() - -urlpatterns = [ - # Page d'accueil - path("", gestioncof_views.HomeView.as_view(), name="home"), - # Le BdA - path("bda/", include("bda.urls")), - # Les exports - path("export/", include(export_patterns)), - # Les petits cours - path("petitcours/", include("petitscours.urls")), - # Les sondages - path("survey/", include(surveys_patterns)), - # Evenements - path("event/", include(events_patterns)), - # Calendrier - path("calendar/", include(calendar_patterns)), - # Clubs - path("clubs/", include(clubs_patterns)), - # Authentification - path( - "cof/denied", - TemplateView.as_view(template_name="cof-denied.html"), - name="cof-denied", - ), - path("cas/login", django_cas_views.LoginView.as_view(), name="cas_login_view"), - path("cas/logout", django_cas_views.LogoutView.as_view()), - path( - "outsider/login", gestioncof_views.LoginExtView.as_view(), name="ext_login_view" - ), - path( - "outsider/logout", django_auth_views.LogoutView.as_view(), {"next_page": "home"} - ), - path("login", gestioncof_views.login, name="cof-login"), - path("logout", gestioncof_views.logout, name="cof-logout"), - # Infos persos - path("profile", gestioncof_views.profile, name="profile"), - path( - "outsider/password-change", - django_auth_views.PasswordChangeView.as_view(), - name="password_change", - ), - path( - "outsider/password-change-done", - django_auth_views.PasswordChangeDoneView.as_view(), - name="password_change_done", - ), - # Inscription d'un nouveau membre - path("registration", gestioncof_views.registration, name="registration"), - path( - "registration/clipper//", - gestioncof_views.registration_form2, - name="clipper-registration", - ), - path( - "registration/user/", - gestioncof_views.registration_form2, - name="user-registration", - ), - path( - "registration/empty", - gestioncof_views.registration_form2, - name="empty-registration", - ), - # Autocompletion - path( - "autocomplete/registration", autocomplete, name="cof.registration.autocomplete" - ), - path( - "user/autocomplete", - gestioncof_views.user_autocomplete, - name="cof-user-autocomplete", - ), - # Interface admin - path("admin/logout/", gestioncof_views.logout), - path("admin/doc/", include("django.contrib.admindocs.urls")), - path( - "admin///csv/", - csv_views.admin_list_export, - {"fields": ["username"]}, - ), - path("admin/", admin.site.urls), - # Liens utiles du COF et du BdA - path("utile_cof", gestioncof_views.utile_cof, name="utile_cof"), - path("utile_bda", gestioncof_views.utile_bda, name="utile_bda"), - path("utile_bda/bda_diff", gestioncof_views.liste_bdadiff, name="ml_diffbda"), - path("utile_cof/diff_cof", gestioncof_views.liste_diffcof, name="ml_diffcof"), - path( - "utile_bda/bda_revente", - gestioncof_views.liste_bdarevente, - name="ml_bda_revente", - ), - path("k-fet/", include("kfet.urls")), - path("cms/", include(wagtailadmin_urls)), - path("documents/", include(wagtaildocs_urls)), - # djconfig - path("config", gestioncof_views.ConfigUpdate.as_view(), name="config.edit"), -] - -if "events" in settings.INSTALLED_APPS: - # The new event application is still in development - # → for now it is namespaced below events_v2 - # → when the old events system is out, move this above in the others apps - urlpatterns += [path("event_v2/", include("events.urls"))] - -if "debug_toolbar" in settings.INSTALLED_APPS: - import debug_toolbar - - urlpatterns += [path("__debug__/", include(debug_toolbar.urls))] - -if settings.DEBUG: - # Si on est en production, MEDIA_ROOT est servi par Apache. - # Il faut dire à Django de servir MEDIA_ROOT lui-même en développement. - urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) - -# Wagtail for uncatched -urlpatterns += i18n_patterns( - path("", include(wagtail_urls)), prefix_default_language=False -) diff --git a/events/migrations/0001_event.py b/events/migrations/0001_event.py index dc3a6bce..4b70d087 100644 --- a/events/migrations/0001_event.py +++ b/events/migrations/0001_event.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [] diff --git a/events/migrations/0002_event_subscribers.py b/events/migrations/0002_event_subscribers.py index 7c0c35f7..b8980a78 100644 --- a/events/migrations/0002_event_subscribers.py +++ b/events/migrations/0002_event_subscribers.py @@ -5,7 +5,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("events", "0001_event"), diff --git a/events/migrations/0003_options_and_extra_fields.py b/events/migrations/0003_options_and_extra_fields.py new file mode 100644 index 00000000..0e993e7b --- /dev/null +++ b/events/migrations/0003_options_and_extra_fields.py @@ -0,0 +1,198 @@ +# Generated by Django 2.2.8 on 2019-12-22 14:54 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("events", "0002_event_subscribers"), + ] + + operations = [ + migrations.CreateModel( + name="ExtraField", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + max_length=200, verbose_name="champ d'événement supplémentaire" + ), + ), + ( + "field_type", + models.CharField( + choices=[ + ("shorttext", "texte court (une ligne)"), + ("longtext", "texte long (plusieurs lignes)"), + ], + max_length=9, + verbose_name="type de champ", + ), + ), + ], + ), + migrations.CreateModel( + name="Option", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField(max_length=200, verbose_name="option d'événement"), + ), + ( + "multi_choices", + models.BooleanField(default=False, verbose_name="choix multiples"), + ), + ], + options={ + "verbose_name": "option d'événement", + "verbose_name_plural": "options d'événement", + }, + ), + migrations.CreateModel( + name="OptionChoice", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("choice", models.CharField(max_length=200, verbose_name="choix")), + ( + "option", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="choices", + to="events.Option", + ), + ), + ], + options={ + "verbose_name": "choix d'option d'événement", + "verbose_name_plural": "choix d'option d'événement", + }, + ), + migrations.CreateModel( + name="Registration", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ], + options={ + "verbose_name": "inscription à un événement", + "verbose_name_plural": "inscriptions à un événement", + }, + ), + migrations.RemoveField(model_name="event", name="subscribers"), + migrations.AddField( + model_name="event", + name="subscribers", + field=models.ManyToManyField( + through="events.Registration", + to=settings.AUTH_USER_MODEL, + verbose_name="inscrit⋅e⋅s", + ), + ), + migrations.AddField( + model_name="registration", + name="event", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="events.Event" + ), + ), + migrations.AddField( + model_name="registration", + name="options_choices", + field=models.ManyToManyField(to="events.OptionChoice"), + ), + migrations.AddField( + model_name="registration", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + migrations.AddField( + model_name="option", + name="event", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="options", + to="events.Event", + ), + ), + migrations.CreateModel( + name="ExtraFieldContent", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("content", models.TextField(verbose_name="contenu du champ")), + ( + "field", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="events.ExtraField", + ), + ), + ( + "registration", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="extra_info", + to="events.Registration", + ), + ), + ], + options={ + "verbose_name": "contenu d'un champ événement supplémentaire", + "verbose_name_plural": "contenus d'un champ événement supplémentaire", + }, + ), + migrations.AddField( + model_name="extrafield", + name="event", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="extra_fields", + to="events.Event", + ), + ), + ] diff --git a/events/migrations/0004_unique_constraints.py b/events/migrations/0004_unique_constraints.py new file mode 100644 index 00000000..4e73a8a8 --- /dev/null +++ b/events/migrations/0004_unique_constraints.py @@ -0,0 +1,34 @@ +# Generated by Django 2.2.12 on 2020-05-20 15:41 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("events", "0003_options_and_extra_fields"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="extrafield", + unique_together={("event", "name")}, + ), + migrations.AlterUniqueTogether( + name="extrafieldcontent", + unique_together={("field", "registration")}, + ), + migrations.AlterUniqueTogether( + name="option", + unique_together={("event", "name")}, + ), + migrations.AlterUniqueTogether( + name="optionchoice", + unique_together={("option", "choice")}, + ), + migrations.AlterUniqueTogether( + name="registration", + unique_together={("event", "user")}, + ), + ] diff --git a/events/migrations/0005_auto_20220630_1239.py b/events/migrations/0005_auto_20220630_1239.py new file mode 100644 index 00000000..d2624da2 --- /dev/null +++ b/events/migrations/0005_auto_20220630_1239.py @@ -0,0 +1,63 @@ +# Generated by Django 3.2.13 on 2022-06-30 10:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("events", "0004_unique_constraints"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="extrafield", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="extrafieldcontent", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="option", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="optionchoice", + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name="registration", + unique_together=set(), + ), + migrations.AddConstraint( + model_name="extrafield", + constraint=models.UniqueConstraint( + fields=("event", "name"), name="unique_extra_field" + ), + ), + migrations.AddConstraint( + model_name="extrafieldcontent", + constraint=models.UniqueConstraint( + fields=("field", "registration"), name="unique_extra_field_content" + ), + ), + migrations.AddConstraint( + model_name="option", + constraint=models.UniqueConstraint( + fields=("event", "name"), name="unique_event_option" + ), + ), + migrations.AddConstraint( + model_name="optionchoice", + constraint=models.UniqueConstraint( + fields=("option", "choice"), name="unique_option_choice" + ), + ), + migrations.AddConstraint( + model_name="registration", + constraint=models.UniqueConstraint( + fields=("event", "user"), name="unique_registration" + ), + ), + ] diff --git a/events/models.py b/events/models.py index b2876301..a421e8a3 100644 --- a/events/models.py +++ b/events/models.py @@ -1,4 +1,35 @@ +""" +Event framework for GestioCOF and GestioBDS. + +The events implemented in this module provide two types of customisations to event +creators (the COF and BDS staff): options and extra (text) fields. + +Options +------- + +An option is an extra field in the registration form with a predefined list of available +choices. Any number of options can be added to an event. + +For instance, a typical use-case is events where meals are served to participants +with different possible menus, say: vegeterian / vegan / without pork / etc. This +example can be implemented with an `Option(name="menu")` and an `OptionChoice` for each +available menu. + +In this example, the choice was exclusive: participants can only chose one menu. For +situations, where multiple choices can be made at the same time, use the `multi_choices` +flag. + +Extra fields +------------ + +Extra fields can also be added to the registration form that can receive arbitrary text. +Typically, this can be a "remark" field (prefer the LONGTEXT option in this case) or +small form entries such as "phone number" or "emergency contact" (prefer the SHORTTEXT +option in this case). +""" + from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ @@ -16,7 +47,9 @@ class Event(models.Model): ) registration_open = models.BooleanField(_("inscriptions ouvertes"), default=True) old = models.BooleanField(_("archiver (événement fini)"), default=False) - subscribers = models.ManyToManyField(User, verbose_name=_("inscrit⋅e⋅s")) + subscribers = models.ManyToManyField( + User, through="Registration", verbose_name=_("inscrit⋅e⋅s") + ) class Meta: verbose_name = _("événement") @@ -26,8 +59,131 @@ class Event(models.Model): return self.title -# TODO: gérer les options (EventOption & EventOptionChoice de gestioncof) -# par exemple: "option végé au Mega (oui / non)" +class Option(models.Model): + """Extra form fields with a limited set of available choices. -# TODO: gérer les champs commentaires (EventCommentField & EventCommentChoice) -# par exemple: "champ "allergies / régime particulier" au Mega + The available choices are given by `OptionChoice`s (see below). A typical use-case + is events where the participants have the choice between different menus (e.g. + vegan / vegetarian / without-pork / etc). + """ + + event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="options") + name = models.CharField(_("option d'événement"), max_length=200) + multi_choices = models.BooleanField(_("choix multiples"), default=False) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["event", "name"], name="unique_event_option" + ) + ] + verbose_name = _("option d'événement") + verbose_name_plural = _("options d'événement") + + def __str__(self): + return self.name + + +class OptionChoice(models.Model): + """A possible choice for an event option.""" + + option = models.ForeignKey(Option, on_delete=models.CASCADE, related_name="choices") + choice = models.CharField(_("choix"), max_length=200) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["option", "choice"], name="unique_option_choice" + ) + ] + verbose_name = _("choix d'option d'événement") + verbose_name_plural = _("choix d'option d'événement") + + def __str__(self): + return self.choice + + +class ExtraField(models.Model): + """Extra event field receiving arbitrary text. + + Extra text field that can be added by event creators to the event registration form. + Typical examples are "remarks" fields (of type LONGTEXT) or more specific fields + such as "emergency contact" (of type SHORTTEXT probably?). + """ + + LONGTEXT = "longtext" + SHORTTEXT = "shorttext" + + FIELD_TYPE = [ + (SHORTTEXT, _("texte court (une ligne)")), + (LONGTEXT, _("texte long (plusieurs lignes)")), + ] + + event = models.ForeignKey( + Event, on_delete=models.CASCADE, related_name="extra_fields" + ) + name = models.CharField(_("champ d'événement supplémentaire"), max_length=200) + field_type = models.CharField(_("type de champ"), max_length=9, choices=FIELD_TYPE) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["event", "name"], name="unique_extra_field") + ] + + +class ExtraFieldContent(models.Model): + """Value entered in an extra field.""" + + field = models.ForeignKey(ExtraField, on_delete=models.CASCADE) + registration = models.ForeignKey( + "Registration", on_delete=models.CASCADE, related_name="extra_info" + ) + content = models.TextField(_("contenu du champ")) + + def clean(self): + if self.registration.event != self.field.event: + raise ValidationError( + _("Inscription et champ texte incohérents pour ce commentaire") + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["field", "registration"], name="unique_extra_field_content" + ) + ] + verbose_name = _("contenu d'un champ événement supplémentaire") + verbose_name_plural = _("contenus d'un champ événement supplémentaire") + + def __str__(self): + max_length = 50 + if len(self.content) > max_length: + return self.content[: max_length - 1] + "…" + else: + return self.content + + +class Registration(models.Model): + """A user registration to an event.""" + + event = models.ForeignKey(Event, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + options_choices = models.ManyToManyField(OptionChoice) + + def clean(self): + if not all((ch.option.event == self.event for ch in self.options_choices)): + raise ValidationError( + _("Choix d'options incohérents avec l'événement pour cette inscription") + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["event", "user"], name="unique_registration" + ) + ] + verbose_name = _("inscription à un événement") + verbose_name_plural = _("inscriptions à un événement") + + def __str__(self): + return "inscription de {} à {}".format(self.user, self.event) diff --git a/events/tests/test_views.py b/events/tests/test_views.py index 8dd81df7..611f1871 100644 --- a/events/tests/test_views.py +++ b/events/tests/test_views.py @@ -1,11 +1,20 @@ from unittest import mock +from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.test import Client, TestCase from django.urls import reverse -from events.models import Event +from events.models import ( + Event, + ExtraField, + ExtraFieldContent, + Option, + OptionChoice, + Registration, +) +from shared.tests.mixins import CSVResponseMixin User = get_user_model() @@ -15,15 +24,16 @@ def make_user(name): def make_staff_user(name): - view_event_perm = Permission.objects.get_by_natural_key( - codename="view_event", app_label="events", model="event" + view_event_perm = Permission.objects.get( + codename="view_event", + content_type__app_label="events", ) user = make_user(name) user.user_permissions.add(view_event_perm) return user -class CSVExportTest(TestCase): +class MessagePatch: def setUp(self): # Signals handlers on login/logout send messages. # Due to the way the Django' test Client performs login, this raise an @@ -32,28 +42,120 @@ class CSVExportTest(TestCase): patcher_messages.start() self.addCleanup(patcher_messages.stop) + +class CSVExportAccessTest(MessagePatch, TestCase): + def setUp(self): + super().setUp() + self.staff = make_staff_user("staff") self.u1 = make_user("toto") - self.u2 = make_user("titi") self.event = Event.objects.create(title="test_event", location="somewhere") - self.event.subscribers.set([self.u1, self.u2]) self.url = reverse("events:csv-participants", args=[self.event.id]) def test_get(self): client = Client() - client.force_login(self.staff) + client.force_login( + self.staff, backend="django.contrib.auth.backends.ModelBackend" + ) r = client.get(self.url) self.assertEqual(r.status_code, 200) def test_anonymous(self): client = Client() r = client.get(self.url) - self.assertRedirects( - r, "/login?next={}".format(self.url), fetch_redirect_response=False - ) + login_url = "{}?next={}".format(reverse(settings.LOGIN_URL), self.url) + self.assertRedirects(r, login_url, fetch_redirect_response=False) def test_unauthorised(self): client = Client() - client.force_login(self.u1) + client.force_login(self.u1, backend="django.contrib.auth.backends.ModelBackend") r = client.get(self.url) self.assertEqual(r.status_code, 403) + + +class CSVExportContentTest(MessagePatch, CSVResponseMixin, TestCase): + def setUp(self): + super().setUp() + + self.event = Event.objects.create(title="test_event", location="somewhere") + self.url = reverse("events:csv-participants", args=[self.event.id]) + + self.u1 = User.objects.create_user( + username="toto_foo", first_name="toto", last_name="foo", email="toto@a.b" + ) + self.u2 = User.objects.create_user( + username="titi_bar", first_name="titi", last_name="bar", email="titi@a.b" + ) + self.staff = make_staff_user("staff") + self.client = Client() + self.client.force_login( + self.staff, backend="django.contrib.auth.backends.ModelBackend" + ) + + def test_simple_event(self): + self.event.subscribers.set([self.u1, self.u2]) + + response = self.client.get(self.url) + + self.assertCSVEqual( + response, + [ + { + "username": "toto_foo", + "prénom": "toto", + "nom de famille": "foo", + "email": "toto@a.b", + }, + { + "username": "titi_bar", + "prénom": "titi", + "nom de famille": "bar", + "email": "titi@a.b", + }, + ], + ) + + def test_complex_event(self): + registration = Registration.objects.create(event=self.event, user=self.u1) + # Set up some options + option1 = Option.objects.create( + event=self.event, name="abc", multi_choices=False + ) + option2 = Option.objects.create( + event=self.event, name="def", multi_choices=True + ) + OptionChoice.objects.bulk_create( + [ + OptionChoice(option=option1, choice="a"), + OptionChoice(option=option1, choice="b"), + OptionChoice(option=option1, choice="c"), + OptionChoice(option=option2, choice="d"), + OptionChoice(option=option2, choice="e"), + OptionChoice(option=option2, choice="f"), + ] + ) + registration.options_choices.set( + OptionChoice.objects.filter(choice__in=["d", "f"]) + ) + registration.options_choices.add(OptionChoice.objects.get(choice="a")) + # And an extra field + field = ExtraField.objects.create(event=self.event, name="remarks") + ExtraFieldContent.objects.create( + field=field, registration=registration, content="hello" + ) + + response = self.client.get(self.url) + self.assertCSVEqual( + response, + [ + { + "username": "toto_foo", + "prénom": "toto", + "nom de famille": "foo", + "email": "toto@a.b", + "abc": "a", + "def": "d & f", + "remarks": "hello", + } + ], + ) diff --git a/events/views.py b/events/views.py index 6f49cdb7..b47ae76f 100644 --- a/events/views.py +++ b/events/views.py @@ -5,7 +5,7 @@ from django.http import HttpResponse from django.shortcuts import get_object_or_404 from django.utils.text import slugify -from events.models import Event +from events.models import Event, Registration @login_required @@ -13,13 +13,43 @@ from events.models import Event def participants_csv(request, event_id): event = get_object_or_404(Event, id=event_id) + # Create a CSV response filename = "{}-participants.csv".format(slugify(event.title)) response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = 'attachment; filename="{}"'.format(filename) - writer = csv.writer(response) - writer.writerow(["username", "email", "prénom", "nom de famille"]) - for user in event.subscribers.all(): - writer.writerow([user.username, user.email, user.first_name, user.last_name]) + + # The first line of the file is a header + header = ["username", "email", "prénom", "nom de famille"] + options_names = list(event.options.values_list("name", flat=True).order_by("id")) + header += options_names + extra_fields = list( + event.extra_fields.values_list("name", flat=True).order_by("id") + ) + header += extra_fields + writer.writerow(header) + + # Next, one line by registered user + registrations = Registration.objects.filter(event=event) + for registration in registrations: + user = registration.user + row = [user.username, user.email, user.first_name, user.last_name] + + # Options + all_choices = registration.options_choices.values_list("choice", flat=True) + options_choices = [ + " & ".join(all_choices.filter(option__id=id).order_by("id")) + for id in event.options.values_list("id", flat=True).order_by("id") + ] + row += options_choices + # Extra info + extra_info = list( + registration.extra_info.values_list("content", flat=True).order_by( + "field__id" + ) + ) + row += extra_info + + writer.writerow(row) return response diff --git a/cof/locale/__init__.py b/gestioasso/__init__.py similarity index 100% rename from cof/locale/__init__.py rename to gestioasso/__init__.py diff --git a/cof/apps.py b/gestioasso/apps.py similarity index 100% rename from cof/apps.py rename to gestioasso/apps.py diff --git a/gestioasso/asgi.py b/gestioasso/asgi.py new file mode 100644 index 00000000..728a3433 --- /dev/null +++ b/gestioasso/asgi.py @@ -0,0 +1,15 @@ +""" +ASGI entrypoint. Configures Django and then runs the application +defined in the ASGI_APPLICATION setting. +""" + +import os + +import django +from channels.routing import get_default_application + +if "DJANGO_SETTINGS_MODULE" not in os.environ: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "gestioasso.settings.local") + +django.setup() +application = get_default_application() diff --git a/cof/locale/en/__init__.py b/gestioasso/locale/__init__.py similarity index 100% rename from cof/locale/en/__init__.py rename to gestioasso/locale/__init__.py diff --git a/cof/locale/fr/__init__.py b/gestioasso/locale/en/__init__.py similarity index 100% rename from cof/locale/fr/__init__.py rename to gestioasso/locale/en/__init__.py diff --git a/cof/locale/en/formats.py b/gestioasso/locale/en/formats.py similarity index 100% rename from cof/locale/en/formats.py rename to gestioasso/locale/en/formats.py diff --git a/cof/settings/__init__.py b/gestioasso/locale/fr/__init__.py similarity index 100% rename from cof/settings/__init__.py rename to gestioasso/locale/fr/__init__.py diff --git a/cof/locale/fr/formats.py b/gestioasso/locale/fr/formats.py similarity index 100% rename from cof/locale/fr/formats.py rename to gestioasso/locale/fr/formats.py diff --git a/gestioasso/routing.py b/gestioasso/routing.py new file mode 100644 index 00000000..2b42648a --- /dev/null +++ b/gestioasso/routing.py @@ -0,0 +1,20 @@ +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter +from django.core.asgi import get_asgi_application +from django.urls import path + +from kfet.routing import KFRouter + +application = ProtocolTypeRouter( + { + # WebSocket chat handler + "websocket": AuthMiddlewareStack( + URLRouter( + [ + path("ws/k-fet", KFRouter), + ] + ) + ), + "http": get_asgi_application(), + } +) diff --git a/cof/settings/.gitignore b/gestioasso/settings/.gitignore similarity index 100% rename from cof/settings/.gitignore rename to gestioasso/settings/.gitignore diff --git a/gestioncof/templatetags/__init__.py b/gestioasso/settings/__init__.py similarity index 100% rename from gestioncof/templatetags/__init__.py rename to gestioasso/settings/__init__.py diff --git a/gestioasso/settings/bds_prod.py b/gestioasso/settings/bds_prod.py new file mode 100644 index 00000000..361ed7cb --- /dev/null +++ b/gestioasso/settings/bds_prod.py @@ -0,0 +1,38 @@ +""" +Settings de production de GestioBDS. + +Surcharge les settings définis dans common.py +""" + +from .common import * # NOQA +from .common import INSTALLED_APPS + +# --- +# BDS-only Django settings +# --- + +ALLOWED_HOSTS = ["bds.ens.fr", "www.bds.ens.fr", "dev.cof.ens.fr"] + +INSTALLED_APPS += ["bds", "events", "clubs", "authens"] + +STATIC_ROOT = "/srv/bds.ens.fr/public/gestion2/static" +STATIC_URL = "/gestion2/static/" +MEDIA_ROOT = "/srv/bds.ens.fr/gestion2/media" +MEDIA_URL = "/gestion2/media/" + + +# --- +# Auth-related stuff +# --- + +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + "authens.backends.ENSCASBackend", + "authens.backends.OldCASBackend", +] + +AUTHENS_USE_OLDCAS = False + +LOGIN_URL = "authens:login" +LOGIN_REDIRECT_URL = "bds:home" +LOGOUT_REDIRECT_URL = "bds:home" diff --git a/gestioasso/settings/cof_prod.py b/gestioasso/settings/cof_prod.py new file mode 100644 index 00000000..91bee648 --- /dev/null +++ b/gestioasso/settings/cof_prod.py @@ -0,0 +1,233 @@ +""" +Settings de production de GestioCOF. + +Surcharge les settings définis dans common.py +""" + +import os +from datetime import timedelta + +from django.utils import timezone + +from .common import * # NOQA +from .common import ( + AUTHENTICATION_BACKENDS, + BASE_DIR, + INSTALLED_APPS, + MIDDLEWARE, + TEMPLATES, + import_secret, +) + +# --- +# COF-specific secrets +# --- + +REDIS_PASSWD = import_secret("REDIS_PASSWD") +REDIS_DB = import_secret("REDIS_DB") +REDIS_HOST = import_secret("REDIS_HOST") +REDIS_PORT = import_secret("REDIS_PORT") + +HCAPTCHA_SITEKEY = import_secret("HCAPTCHA_SITEKEY") +HCAPTCHA_SECRET = import_secret("HCAPTCHA_SECRET") +KFETOPEN_TOKEN = import_secret("KFETOPEN_TOKEN") + +# --- +# COF-only Django settings +# --- + +ALLOWED_HOSTS = ["cof.ens.fr", "www.cof.ens.fr", "dev.cof.ens.fr"] + +INSTALLED_APPS = ( + [ + "gestioncof", + # Must be before django admin + # https://github.com/infoportugal/wagtail-modeltranslation/issues/193 + "wagtail_modeltranslation", + "wagtail_modeltranslation.makemigrations", + "wagtail_modeltranslation.migrate", + "modeltranslation", + ] + + INSTALLED_APPS + + [ + "bda", + "petitscours", + "hcaptcha", + "kfet", + "kfet.open", + "channels", + "djconfig", + "wagtail.contrib.forms", + "wagtail.contrib.redirects", + "wagtail.embeds", + "wagtail.sites", + "wagtail.users", + "wagtail.snippets", + "wagtail.documents", + "wagtail.images", + "wagtail.search", + "wagtail.admin", + "wagtail", + # "wagtail.contrib.modeladmin", + "wagtail.contrib.routable_page", + "wagtailmenus", + "modelcluster", + "taggit", + "kfet.auth", + "kfet.cms", + "gestioncof.cms", + "django_js_reverse", + ] +) + +MIDDLEWARE = ( + ["corsheaders.middleware.CorsMiddleware"] + + MIDDLEWARE + + [ + "djconfig.middleware.DjConfigMiddleware", + "wagtail.contrib.redirects.middleware.RedirectMiddleware", + ] +) + +TEMPLATES[0]["OPTIONS"]["context_processors"] += [ + "wagtailmenus.context_processors.wagtailmenus", + "djconfig.context_processors.config", + "gestioncof.shared.context_processor", + "kfet.auth.context_processors.temporary_auth", + "kfet.context_processors.config", +] + +STATIC_ROOT = os.path.join( + os.path.dirname(os.path.dirname(BASE_DIR)), "public", "gestion", "static" +) + +STATIC_URL = "/gestion/static/" +MEDIA_ROOT = os.path.join(os.path.dirname(BASE_DIR), "media") +MEDIA_URL = "/gestion/media/" + +CORS_ORIGIN_WHITELIST = ("bda.ens.fr", "www.bda.ens.fr" "cof.ens.fr", "www.cof.ens.fr") + + +ASGI_APPLICATION = "gestioasso.routing.application" + +# --- +# Auth-related stuff +# --- + +AUTHENTICATION_BACKENDS = ( + [ + # Must be in first + "kfet.auth.backends.BlockFrozenAccountBackend" + ] + + AUTHENTICATION_BACKENDS + + [ + "gestioncof.shared.COFCASBackend", + "kfet.auth.backends.GenericBackend", + ] +) +LOGIN_URL = "cof-login" +LOGIN_REDIRECT_URL = "home" + +# --- +# Cache settings +# --- + +CACHES = { + "default": { + "BACKEND": "redis_cache.RedisCache", + "LOCATION": "redis://:{passwd}@{host}:{port}/{db}".format( + passwd=REDIS_PASSWD, host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB + ), + } +} + + +# --- +# Channels settings +# --- + +CHANNEL_LAYERS = { + "default": { + "BACKEND": "shared.channels.ChannelLayer", + "CONFIG": { + "hosts": [ + ( + "redis://:{passwd}@{host}:{port}/{db}".format( + passwd=REDIS_PASSWD, + host=REDIS_HOST, + port=REDIS_PORT, + db=REDIS_DB, + ) + ) + ] + }, + } +} + +# --- +# reCAPTCHA settings +# https://github.com/praekelt/django-recaptcha +# +# Default settings authorize reCAPTCHA usage for local developement. +# Public and private keys are appended in the 'prod' module settings. +# --- + +NOCAPTCHA = True +RECAPTCHA_USE_SSL = True + + +# --- +# Wagtail settings +# --- + +WAGTAIL_SITE_NAME = "GestioCOF" +WAGTAIL_ENABLE_UPDATE_CHECK = False +TAGGIT_CASE_INSENSITIVE = True + + +# --- +# Django-js-reverse settings +# --- + +JS_REVERSE_JS_VAR_NAME = "django_urls" +# Quand on aura namespace les urls... +# JS_REVERSE_INCLUDE_ONLY_NAMESPACES = ['k-fet'] + + +# --- +# Mail config +# --- + +MAIL_DATA = { + "petits_cours": { + "FROM": "Le COF ", + "BCC": "archivescof@gmail.com", + "REPLYTO": "cof@ens.fr", + }, + "rappels": {"FROM": "Le BdA ", "REPLYTO": "Le BdA "}, + "kfet": { + "FROM": "La K-Fêt ", + "REPLYTO": "La K-Fêt ", + }, + "revente": { + "FROM": "BdA-Revente ", + "REPLYTO": "BdA-Revente ", + }, +} + +# --- +# kfet history limits +# --- + +# L'historique n'est accesible que d'aujourd'hui +# à aujourd'hui - KFET_HISTORY_DATE_LIMIT +KFET_HISTORY_DATE_LIMIT = timedelta(days=7) + +# Limite plus longue pour les chefs/trez +# (qui ont la permission kfet.access_old_history) +KFET_HISTORY_LONG_DATE_LIMIT = timedelta(days=30) + +# These accounts don't represent actual people and can be freely accessed +# Identification based on trigrammes +KFET_HISTORY_NO_DATE_LIMIT_TRIGRAMMES = ["LIQ", "#13"] +KFET_HISTORY_NO_DATE_LIMIT = timezone.datetime(1794, 10, 30) # AKA the distant past diff --git a/gestioasso/settings/common.py b/gestioasso/settings/common.py new file mode 100644 index 00000000..13f2e5b1 --- /dev/null +++ b/gestioasso/settings/common.py @@ -0,0 +1,141 @@ +""" +Settings par défaut et settings communs à GestioCOF et GestioBDS. +""" + +import os +import sys + +# --- +# Secrets +# --- + +try: + from . import secret +except ImportError: + raise ImportError( + "The secret.py file is missing.\n" + "For a development environment, simply copy secret_example.py" + ) + + +def import_secret(name): + """ + Shorthand for importing a value from the secret module and raising an + informative exception if a secret is missing. + """ + try: + return getattr(secret, name) + except AttributeError: + raise RuntimeError("Secret missing: {}".format(name)) + + +SECRET_KEY = import_secret("SECRET_KEY") +ADMINS = import_secret("ADMINS") +SERVER_EMAIL = import_secret("SERVER_EMAIL") +EMAIL_HOST = import_secret("EMAIL_HOST") + +DBNAME = import_secret("DBNAME") +DBUSER = import_secret("DBUSER") +DBPASSWD = import_secret("DBPASSWD") + +LDAP_SERVER_URL = import_secret("LDAP_SERVER_URL") + + +# --- +# Default Django settings +# --- + +DEBUG = False # False by default feels safer +TESTING = len(sys.argv) > 1 and sys.argv[1] == "test" +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +INSTALLED_APPS = [ + "shared", + # Must be before 'django.contrib.admin'. + # https://django-autocomplete-light.readthedocs.io/en/master/install.html + "dal", + "dal_select2", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.admin", + "django.contrib.admindocs", + "gestioasso.apps.IgnoreSrcStaticFilesConfig", + "django_cas_ng", + "bootstrapform", + "widget_tweaks", +] + +MIDDLEWARE = [ + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.middleware.locale.LocaleMiddleware", +] + +ROOT_URLCONF = "gestioasso.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + ] + }, + } +] + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": DBNAME, + "USER": DBUSER, + "PASSWORD": DBPASSWD, + "HOST": os.environ.get("DBHOST", "localhost"), + } +} + +SITE_ID = 1 + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +# --- +# Internationalization +# https://docs.djangoproject.com/en/1.8/topics/i18n/ +# --- + +LANGUAGE_CODE = "fr-fr" +TIME_ZONE = "Europe/Paris" +USE_I18N = True +USE_L10N = True +USE_TZ = True +LANGUAGES = (("fr", "Français"), ("en", "English")) +FORMAT_MODULE_PATH = "gestioasso.locale" + + +# --- +# Auth-related stuff +# --- + +AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"] + +CAS_SERVER_URL = "https://cas.eleves.ens.fr/" +CAS_VERSION = "2" +CAS_LOGIN_MSG = None +CAS_IGNORE_REFERER = True +CAS_REDIRECT_URL = "/" +CAS_EMAIL_FORMAT = "%s@clipper.ens.fr" diff --git a/gestioasso/settings/dev.py b/gestioasso/settings/dev.py new file mode 100644 index 00000000..cd254b7a --- /dev/null +++ b/gestioasso/settings/dev.py @@ -0,0 +1,64 @@ +""" +Settings utilisés dans la VM Vagrant. +Active toutes les applications (de GestioCOF et de GestioBDS). + +Surcharge les settings définis dans common.py +""" + +import os + +from . import bds_prod +from .cof_prod import * # NOQA +from .cof_prod import INSTALLED_APPS, MIDDLEWARE, TESTING + +# --- +# Merge COF and BDS configs +# --- + +for app in bds_prod.INSTALLED_APPS: + if app not in INSTALLED_APPS: + INSTALLED_APPS.append(app) + +# --- +# Tweaks for debug/local development +# --- + +ALLOWED_HOSTS = [] + +DEBUG = True +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + +if TESTING: + PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] + +STATIC_URL = "/static/" +STATIC_ROOT = "/srv/gestiocof/static" +MEDIA_URL = "/media/" +MEDIA_ROOT = "/srv/gestiocof/media" + + +# --- +# Debug tool bar +# --- + + +def show_toolbar(request): + """ + On active la debug-toolbar en mode développement local sauf : + - dans l'admin où ça ne sert pas à grand chose; + - si la variable d'environnement DJANGO_NO_DDT est à 1 → ça permet de la désactiver + sans modifier ce fichier en exécutant `export DJANGO_NO_DDT=1` dans le terminal + qui lance `./manage.py runserver`. + + Autre side effect de cette fonction : on ne fait pas la vérification de INTERNAL_IPS + que ferait la debug-toolbar par défaut, ce qui la fait fonctionner aussi à + l'intérieur de Vagrant (comportement non testé depuis un moment…) + """ + env_no_ddt = bool(os.environ.get("DJANGO_NO_DDT", None)) + return DEBUG and not env_no_ddt and not request.path.startswith("/admin/") + + +if not TESTING: + INSTALLED_APPS += ["debug_toolbar"] + MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE + DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": show_toolbar} diff --git a/gestioasso/settings/local.py b/gestioasso/settings/local.py new file mode 100644 index 00000000..c6a22e64 --- /dev/null +++ b/gestioasso/settings/local.py @@ -0,0 +1,82 @@ +""" +Settings utilisés lors d'un développement en local (dans un virtualenv). +Active toutes les applications (de GestioCOF et de GestioBDS). + +Surcharge les settings définis dans common.py +""" +import os + +from . import bds_prod +from .cof_prod import * # NOQA +from .cof_prod import BASE_DIR, INSTALLED_APPS, MIDDLEWARE, TESTING + +# --- +# Merge COF and BDS configs +# --- + +for app in bds_prod.INSTALLED_APPS: + if app not in INSTALLED_APPS: + INSTALLED_APPS.append(app) + +# --- +# Tweaks for debug/local development +# --- + +ALLOWED_HOSTS = [] + +DEBUG = True +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + +SYMPA_PASSWORD = b"sympa" +SYMPA_USERNAME = b"sympa" + +if TESTING: + PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] + +STATIC_URL = "/static/" +MEDIA_URL = "/media/" +MEDIA_ROOT = os.path.join(BASE_DIR, "media") + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), + } +} + +# Use the default cache backend for local development +CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}} + +# Use the default in memory asgi backend for local development +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels.layers.InMemoryChannelLayer", + } +} + + +# --- +# Debug tool bar +# --- + + +def show_toolbar(request): + """ + On active la debug-toolbar en mode développement local sauf : + - dans l'admin où ça ne sert pas à grand chose; + - si la variable d'environnement DJANGO_NO_DDT est à 1 → ça permet de la désactiver + sans modifier ce fichier en exécutant `export DJANGO_NO_DDT=1` dans le terminal + qui lance `./manage.py runserver`. + + Autre side effect de cette fonction : on ne fait pas la vérification de INTERNAL_IPS + que ferait la debug-toolbar par défaut, ce qui la fait fonctionner aussi à + l'intérieur de Vagrant (comportement non testé depuis un moment…) + """ + env_no_ddt = bool(os.environ.get("DJANGO_NO_DDT", None)) + return DEBUG and not env_no_ddt and not request.path.startswith("/admin/") + + +if not TESTING: + INSTALLED_APPS += ["debug_toolbar"] + MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE + DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": show_toolbar} diff --git a/cof/settings/secret_example.py b/gestioasso/settings/secret_example.py similarity index 69% rename from cof/settings/secret_example.py rename to gestioasso/settings/secret_example.py index 7921d467..b93aeb4f 100644 --- a/cof/settings/secret_example.py +++ b/gestioasso/settings/secret_example.py @@ -1,3 +1,7 @@ +""" +Secrets à re-définir en production. +""" + SECRET_KEY = "q()(zn4m63i%5cp4)f+ww4-28_w+ly3q9=6imw2ciu&_(5_4ah" ADMINS = None SERVER_EMAIL = "root@vagrant" @@ -12,8 +16,8 @@ REDIS_PORT = 6379 REDIS_DB = 0 REDIS_HOST = "127.0.0.1" -RECAPTCHA_PUBLIC_KEY = "DUMMY" -RECAPTCHA_PRIVATE_KEY = "DUMMY" +HCAPTCHA_SITEKEY = "10000000-ffff-ffff-ffff-000000000001" +HCAPTCHA_SECRET = "0x0000000000000000000000000000000000000000" EMAIL_HOST = None diff --git a/gestioasso/settings_bds.py b/gestioasso/settings_bds.py new file mode 100644 index 00000000..e640b222 --- /dev/null +++ b/gestioasso/settings_bds.py @@ -0,0 +1,197 @@ +""" +Django settings for the gestioBDS project. +""" + +import os +from pathlib import Path + +from loadcredential import Credentials + +credentials = Credentials(env_prefix="GESTIOBDS_") + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# WARNING: keep the secret key used in production secret! +SECRET_KEY = credentials["SECRET_KEY"] + +# WARNING: don't run with debug turned on in production! +DEBUG = credentials.get_json("DEBUG", False) + +ALLOWED_HOSTS = credentials.get_json("ALLOWED_HOSTS", []) +ADMINS = credentials.get_json("ADMINS", []) + +SERVER_EMAIL = credentials.get("SERVER_EMAIL") +EMAIL_HOST = credentials.get("EMAIL_HOST") + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +SYMPA_PASSWORD = credentials["SYMPA_PASSWORD"].encode() +SYMPA_USERNAME = credentials["SYMPA_USERNAME"].encode() + + +## +# Installed Apps configuration + +INSTALLED_APPS = [ + "shared", + # Must be before 'django.contrib.admin'. + # https://django-autocomplete-light.readthedocs.io/en/master/install.html + "dal", + "dal_select2", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.admin", + "django.contrib.admindocs", + "gestioasso.apps.IgnoreSrcStaticFilesConfig", + "django_cas_ng", + "bootstrapform", + "widget_tweaks", + "bds", + "events", + "clubs", + "authens", +] + + +## +# Middleware configuration + +MIDDLEWARE = [ + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.middleware.locale.LocaleMiddleware", +] + + +## +# URL configuration + +ROOT_URLCONF = "gestioasso.urls" + + +## +# Templates configuration + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + ] + }, + } +] + + +## +# Database configuration + +DATABASES = credentials.get_json( + "DATABASES", + default={ + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": (BASE_DIR / "db.sqlite3"), + } + }, +) + +CACHES = credentials.get_json( + "CACHES", + default={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + }, + }, +) + +CORS_ORIGIN_WHITELIST = credentials.get("CORS_ORIGIN_WHITELIST", []) + + +SITE_ID = 1 + + +### +# Staticfiles configuration + +STATIC_ROOT = credentials["STATIC_ROOT"] +STATIC_URL = "/static/" + +MEDIA_ROOT = credentials.get("MEDIA_ROOT", (BASE_DIR / "media")) +MEDIA_URL = "/media/" + + +## +# Authens and Authentication configuration + +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + "authens.backends.ENSCASBackend", + "authens.backends.OldCASBackend", +] + +AUTHENS_USE_OLDCAS = False + +LOGIN_URL = "authens:login" +LOGIN_REDIRECT_URL = "bds:home" +LOGOUT_REDIRECT_URL = "bds:home" + + +# --- +# Internationalization +# https://docs.djangoproject.com/en/1.8/topics/i18n/ +# --- + +LANGUAGE_CODE = "fr-fr" +TIME_ZONE = "Europe/Paris" +USE_I18N = True +USE_L10N = True +USE_TZ = True +LANGUAGES = (("fr", "Français"), ("en", "English")) +FORMAT_MODULE_PATH = "gestioasso.locale" + +## +# Development configuration + +if DEBUG: + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + + def show_toolbar(request): + """ + On active la debug-toolbar en mode développement local sauf : + - dans l'admin où ça ne sert pas à grand chose; + - si la variable d'environnement DJANGO_NO_DDT est à 1 → ça permet de la désactiver + sans modifier ce fichier en exécutant `export DJANGO_NO_DDT=1` dans le terminal + qui lance `./manage.py runserver`. + + Autre side effect de cette fonction : on ne fait pas la vérification de INTERNAL_IPS + que ferait la debug-toolbar par défaut, ce qui la fait fonctionner aussi à + l'intérieur de Vagrant (comportement non testé depuis un moment…) + """ + + env_no_ddt = bool(os.environ.get("DJANGO_NO_DDT", None)) + return not (env_no_ddt or request.path.startswith("/admin/")) + + ## + # Django Debug Toolbar configuration + + DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": show_toolbar} + INSTALLED_APPS += ["debug_toolbar"] + MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE diff --git a/gestioasso/settings_cof.py b/gestioasso/settings_cof.py new file mode 100644 index 00000000..057019a2 --- /dev/null +++ b/gestioasso/settings_cof.py @@ -0,0 +1,324 @@ +""" +Django settings for the gestioCOF project. +""" + +import os +from datetime import datetime, timedelta +from pathlib import Path + +from django.urls import reverse_lazy +from loadcredential import Credentials + +credentials = Credentials(env_prefix="GESTIOCOF_") + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# WARNING: keep the secret key used in production secret! +SECRET_KEY = credentials["SECRET_KEY"] + +# WARNING: don't run with debug turned on in production! +DEBUG = credentials.get_json("DEBUG", False) + +ALLOWED_HOSTS = credentials.get_json("ALLOWED_HOSTS", []) +ADMINS = credentials.get_json("ADMINS", []) + +SERVER_EMAIL = credentials.get("SERVER_EMAIL") +EMAIL_HOST = credentials.get("EMAIL_HOST") + +LDAP_SERVER_URL = credentials.get("LDAP_SERVER_URL") + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +SYMPA_PASSWORD = credentials["SYMPA_PASSWORD"].encode() +SYMPA_USERNAME = credentials["SYMPA_USERNAME"].encode() + + +## +# Installed Apps configuration + +INSTALLED_APPS = [ + "gestioncof", + # Must be before django admin + # https://github.com/infoportugal/wagtail-modeltranslation/issues/193 + "wagtail_modeltranslation", + "wagtail_modeltranslation.makemigrations", + "wagtail_modeltranslation.migrate", + "modeltranslation", + "shared", + # Must be before 'django.contrib.admin'. + # https://django-autocomplete-light.readthedocs.io/en/master/install.html + "dal", + "dal_select2", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.admin", + "django.contrib.admindocs", + "gestioasso.apps.IgnoreSrcStaticFilesConfig", + "django_cas_ng", + "bootstrapform", + "widget_tweaks", + "bda", + "petitscours", + "hcaptcha", + "kfet", + "kfet.open", + "channels", + "djconfig", + "wagtail.contrib.forms", + "wagtail.contrib.redirects", + "wagtail.embeds", + "wagtail.sites", + "wagtail.users", + "wagtail.snippets", + "wagtail.documents", + "wagtail.images", + "wagtail.search", + "wagtail.admin", + "wagtail", + # "wagtail.contrib.modeladmin", + "wagtail.contrib.routable_page", + "wagtailmenus", + "modelcluster", + "taggit", + "kfet.auth", + "kfet.cms", + "gestioncof.cms", + "django_js_reverse", +] + + +## +# Middleware configuration + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.middleware.locale.LocaleMiddleware", + "djconfig.middleware.DjConfigMiddleware", + "wagtail.contrib.redirects.middleware.RedirectMiddleware", +] + + +## +# URL configuration + +ROOT_URLCONF = "gestioasso.urls" + + +## +# Templates configuration + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "wagtailmenus.context_processors.wagtailmenus", + "djconfig.context_processors.config", + "gestioncof.shared.context_processor", + "kfet.auth.context_processors.temporary_auth", + "kfet.context_processors.config", + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + ] + }, + } +] + + +## +# Wagtail configuration + +WAGTAIL_SITE_NAME = "GestioCOF" +WAGTAIL_ENABLE_UPDATE_CHECK = False +TAGGIT_CASE_INSENSITIVE = True + +## +# Django-js-reverse settings + +JS_REVERSE_JS_VAR_NAME = "django_urls" +# Quand on aura namespace les urls... +# JS_REVERSE_INCLUDE_ONLY_NAMESPACES = ['k-fet'] + +## +# K-Fêt history configuration + +# L'historique n'est accesible que d'aujourd'hui +# à aujourd'hui - KFET_HISTORY_DATE_LIMIT +KFET_HISTORY_DATE_LIMIT = timedelta(days=7) + +# Limite plus longue pour les chefs/trez +# (qui ont la permission kfet.access_old_history) +KFET_HISTORY_LONG_DATE_LIMIT = timedelta(days=30) + +# These accounts don't represent actual people and can be freely accessed +# Identification based on trigrammes +KFET_HISTORY_NO_DATE_LIMIT_TRIGRAMMES = ["LIQ", "#13"] +KFET_HISTORY_NO_DATE_LIMIT = datetime(1794, 10, 30) # AKA the distant past + + +## +# Database configuration + +DATABASES = credentials.get_json( + "DATABASES", + default={ + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": (BASE_DIR / "db.sqlite3"), + } + }, +) + +CACHES = credentials.get_json( + "CACHES", + default={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + }, + }, +) + +CHANNEL_LAYERS = credentials.get_json( + "CHANNEL_LAYERS", + default={ + "default": { + "BACKEND": "channels.layers.InMemoryChannelLayer", + } + }, +) + +ASGI_APPLICATION = "gestioasso.routing.application" + +CORS_ALLOWED_ORIGINS = credentials.get("CORS_ALLOWED_ORIGINS", []) +CSRF_TRUSTED_ORIGINS = [f"https://{host}" for host in ALLOWED_HOSTS] + + +SITE_ID = 1 + + +### +# Staticfiles configuration + +STATIC_ROOT = credentials["STATIC_ROOT"] +STATIC_URL = "/static/" + +MEDIA_ROOT = credentials.get("MEDIA_ROOT", (BASE_DIR / "media")) +MEDIA_URL = "/media/" + + +## +# Authentication configuration + +AUTHENTICATION_BACKENDS = [ + "kfet.auth.backends.BlockFrozenAccountBackend", # Must be in first + "django.contrib.auth.backends.ModelBackend", + "gestioncof.shared.COFCASBackend", + "kfet.auth.backends.GenericBackend", +] + +LOGIN_URL = "cof-login" +LOGIN_REDIRECT_URL = reverse_lazy("home") + +# FIXME: Switch to authens +CAS_SERVER_URL = "https://cas.eleves.ens.fr/" +CAS_VERSION = "2" +CAS_LOGIN_MSG = None +CAS_IGNORE_REFERER = True +CAS_REDIRECT_URL = "/" +CAS_EMAIL_FORMAT = "%s@clipper.ens.fr" + + +## +# h-captcha configuration + +HCAPTCHA_SITEKEY = credentials["HCAPTCHA_SITEKEY"] +HCAPTCHA_SECRET = credentials["HCAPTCHA_SECRET"] + +## +# K-Fêt token for the openness indicator + +KFETOPEN_TOKEN = credentials["KFETOPEN_TOKEN"] + + +## +# Mail configuration + +MAIL_DATA = { + "petits_cours": { + "FROM": "Le COF ", + "BCC": "archivescof@gmail.com", + "REPLYTO": "cof@ens.fr", + }, + "rappels": { + "FROM": "Le BdA ", + "REPLYTO": "Le BdA ", + }, + "kfet": { + "FROM": "La K-Fêt ", + "REPLYTO": "La K-Fêt ", + }, + "revente": { + "FROM": "BdA-Revente ", + "REPLYTO": "BdA-Revente ", + }, +} + + +# --- +# Internationalization +# https://docs.djangoproject.com/en/1.8/topics/i18n/ +# --- + +LANGUAGE_CODE = "fr-fr" +TIME_ZONE = "Europe/Paris" +USE_I18N = True +USE_L10N = True +USE_TZ = True +LANGUAGES = (("fr", "Français"), ("en", "English")) +FORMAT_MODULE_PATH = "gestioasso.locale" + +## +# Development configuration + +if DEBUG: + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + + def show_toolbar(request): + """ + On active la debug-toolbar en mode développement local sauf : + - dans l'admin où ça ne sert pas à grand chose; + - si la variable d'environnement DJANGO_NO_DDT est à 1 → ça permet de la désactiver + sans modifier ce fichier en exécutant `export DJANGO_NO_DDT=1` dans le terminal + qui lance `./manage.py runserver`. + + Autre side effect de cette fonction : on ne fait pas la vérification de INTERNAL_IPS + que ferait la debug-toolbar par défaut, ce qui la fait fonctionner aussi à + l'intérieur de Vagrant (comportement non testé depuis un moment…) + """ + + env_no_ddt = bool(os.environ.get("DJANGO_NO_DDT", None)) + return not (env_no_ddt or request.path.startswith("/admin/")) + + ## + # Django Debug Toolbar configuration + + DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": show_toolbar} + INSTALLED_APPS += ["debug_toolbar"] + MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE diff --git a/gestioasso/urls.py b/gestioasso/urls.py new file mode 100644 index 00000000..753e947d --- /dev/null +++ b/gestioasso/urls.py @@ -0,0 +1,75 @@ +""" +Fichier principal de configuration des urls du projet GestioCOF +""" + +from django.conf import settings +from django.conf.urls.i18n import i18n_patterns +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path +from django.views.generic.base import RedirectView + +bds_is_alone = ( + "bds" in settings.INSTALLED_APPS and "gestioncof" not in settings.INSTALLED_APPS +) + +admin.autodiscover() +urlpatterns = [ + # Website administration (independent from installed apps) + path("admin/doc/", include("django.contrib.admindocs.urls")), + path("admin/", admin.site.urls), +] + +if not bds_is_alone: + # Redirection / → /gestion, only useful for developpers. + urlpatterns.append(path("", RedirectView.as_view(url="gestion/"))) + +# App-specific urls + +app_dict = { + "bds": "" if bds_is_alone else "bds/", + "kfet": "k-fet/", + # Below = GestioCOF → goes under gestion/ + "gestioncof": "gestion/", + "bda": "gestion/bda/", + "petitscours": "gestion/petitcours/", + "events": "gestion/event_v2/", # the events module is still experimental ! + "authens": "gestion/authens/", +} +for app_name, url_prefix in app_dict.items(): + if app_name in settings.INSTALLED_APPS: + urlpatterns += [path(url_prefix, include("{}.urls".format(app_name)))] + + +if "django_js_reverse" in settings.INSTALLED_APPS: + from django_js_reverse.views import urls_js + + urlpatterns += [ + path("jsreverse/", urls_js, name="js_reverse"), + ] + +if "debug_toolbar" in settings.INSTALLED_APPS: + import debug_toolbar + + urlpatterns += [path("__debug__/", include(debug_toolbar.urls))] + +if settings.DEBUG: + # Si on est en production, MEDIA_ROOT est servi par Apache. + # Il faut dire à Django de servir MEDIA_ROOT lui-même en développement. + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + + +# Wagtail URLs (wagtail urls must be last, as catch-all) +if "wagtail" in settings.INSTALLED_APPS: + from wagtail import urls as wagtail_urls + from wagtail.admin import urls as wagtailadmin_urls + from wagtail.documents import urls as wagtaildocs_urls + + urlpatterns += [ + path("cms/", include(wagtailadmin_urls)), + path("documents/", include(wagtaildocs_urls)), + ] + urlpatterns += i18n_patterns( + path("", include(wagtail_urls)), prefix_default_language=False + ) diff --git a/gestioasso/wsgi.py b/gestioasso/wsgi.py new file mode 100644 index 00000000..bdd9a64c --- /dev/null +++ b/gestioasso/wsgi.py @@ -0,0 +1,6 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "gestioasso.settings.bds_prod") +application = get_wsgi_application() diff --git a/gestioncof/__init__.py b/gestioncof/__init__.py index 3bb260b9..e69de29b 100644 --- a/gestioncof/__init__.py +++ b/gestioncof/__init__.py @@ -1 +0,0 @@ -default_app_config = "gestioncof.apps.GestioncofConfig" diff --git a/gestioncof/admin.py b/gestioncof/admin.py index 768cff3b..3576efda 100644 --- a/gestioncof/admin.py +++ b/gestioncof/admin.py @@ -6,7 +6,7 @@ from django.contrib.auth.models import Group, Permission, User from django.db.models import Q from django.urls import reverse from django.utils.safestring import mark_safe -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from gestioncof.models import ( Club, @@ -100,28 +100,6 @@ class CofProfileInline(admin.StackedInline): inline_classes = ("collapse open",) -class FkeyLookup(object): - def __init__(self, fkeydecl, short_description=None, admin_order_field=None): - self.fk, fkattrs = fkeydecl.split("__", 1) - self.fkattrs = fkattrs.split("__") - - self.short_description = short_description or self.fkattrs[-1] - self.admin_order_field = admin_order_field or fkeydecl - - def __get__(self, obj, klass): - if obj is None: - """ - hack required to make Django validate (if obj is - None, then we're a class, and classes are callable - ) - """ - return self - item = getattr(obj, self.fk) - for attr in self.fkattrs: - item = getattr(item, attr) - return item - - def ProfileInfo(field, short_description, boolean=False): def getter(self): try: @@ -134,7 +112,6 @@ def ProfileInfo(field, short_description, boolean=False): return getter -User.profile_login_clipper = FkeyLookup("profile__login_clipper", "Login clipper") User.profile_phone = ProfileInfo("phone", "Téléphone") User.profile_occupation = ProfileInfo("occupation", "Occupation") User.profile_departement = ProfileInfo("departement", "Departement") @@ -153,24 +130,43 @@ class UserProfileAdmin(UserAdmin): is_buro.short_description = "Membre du Buro" is_buro.boolean = True + def is_chef(self, obj): + try: + return obj.profile.is_chef + except CofProfile.DoesNotExist: + return False + + is_chef.short_description = "Chef K-Fêt" + is_chef.boolean = True + def is_cof(self, obj): try: return obj.profile.is_cof except CofProfile.DoesNotExist: return False - is_cof.short_description = "Membre du COF" + is_cof.short_description = "Membre COF" is_cof.boolean = True + def is_kfet(self, obj): + try: + return obj.profile.is_kfet + except CofProfile.DoesNotExist: + return False + + is_kfet.short_description = "Membre K-Fêt" + is_kfet.boolean = True + list_display = UserAdmin.list_display + ( - "profile_login_clipper", "profile_phone", "profile_occupation", "profile_mailing_cof", "profile_mailing_bda", "profile_mailing_bda_revente", "is_cof", + "is_kfet", "is_buro", + "is_chef", ) list_display_links = ("username", "email", "first_name", "last_name") list_filter = UserAdmin.list_filter + ( diff --git a/gestioncof/apps.py b/gestioncof/apps.py index 88e2fbfc..0ac33f93 100644 --- a/gestioncof/apps.py +++ b/gestioncof/apps.py @@ -12,6 +12,7 @@ class GestioncofConfig(AppConfig): def register_config(self): import djconfig + from .forms import GestioncofConfigForm djconfig.register(GestioncofConfigForm) diff --git a/gestioncof/autocomplete.py b/gestioncof/autocomplete.py index e27cdb92..9570acb5 100644 --- a/gestioncof/autocomplete.py +++ b/gestioncof/autocomplete.py @@ -1,94 +1,58 @@ -from django import shortcuts -from django.conf import settings -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.db.models import Q -from django.http import Http404 +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ -from gestioncof.decorators import buro_required -from gestioncof.models import CofProfile +from shared import autocomplete -if getattr(settings, "LDAP_SERVER_URL", None): - from ldap3 import Connection -else: - # shared.tests.testcases.TestCaseMixin.mockLDAP needs - # Connection to be defined in order to mock it. - Connection = None +User = get_user_model() -class Clipper(object): - def __init__(self, clipper, fullname): - if fullname is None: - fullname = "" - assert isinstance(clipper, str) - assert isinstance(fullname, str) - self.clipper = clipper - self.fullname = fullname +class COFMemberSearch(autocomplete.ModelSearch): + model = User + search_fields = ["username", "first_name", "last_name"] + verbose_name = _("Membres du COF") - def __str__(self): - return "{} ({})".format(self.clipper, self.fullname) + def get_queryset_filter(self, *args, **kwargs): + qset_filter = super().get_queryset_filter(*args, **kwargs) + qset_filter &= Q(profile__is_cof=True) + return qset_filter - def __eq__(self, other): - return self.clipper == other.clipper and self.fullname == other.fullname + def result_uuid(self, user): + return user.username + + def result_link(self, user): + return reverse("user-registration", args=(user.username,)) -@buro_required -def autocomplete(request): - if "q" not in request.GET: - raise Http404 - q = request.GET["q"] - data = {"q": q} +class COFOthersSearch(autocomplete.ModelSearch): + model = User + search_fields = ["username", "first_name", "last_name"] + verbose_name = _("Non-membres du COF") - queries = {} - bits = q.split() + def get_queryset_filter(self, *args, **kwargs): + qset_filter = super().get_queryset_filter(*args, **kwargs) + qset_filter &= Q(profile__is_cof=False) + return qset_filter - # Fetching data from User and CofProfile tables - queries["members"] = CofProfile.objects.filter(is_cof=True) - queries["users"] = User.objects.filter(profile__is_cof=False) - for bit in bits: - queries["members"] = queries["members"].filter( - Q(user__first_name__icontains=bit) - | Q(user__last_name__icontains=bit) - | Q(user__username__icontains=bit) - | Q(login_clipper__icontains=bit) - ) - queries["users"] = queries["users"].filter( - Q(first_name__icontains=bit) - | Q(last_name__icontains=bit) - | Q(username__icontains=bit) - ) - queries["members"] = queries["members"].distinct() - queries["users"] = queries["users"].distinct() + def result_uuid(self, user): + return user.username - # Clearing redundancies - usernames = set(queries["members"].values_list("login_clipper", flat="True")) | set( - queries["users"].values_list("profile__login_clipper", flat="True") - ) + def result_link(self, user): + return reverse("user-registration", args=(user.username,)) - # Fetching data from the SPI - if getattr(settings, "LDAP_SERVER_URL", None): - # Fetching - ldap_query = "(&{:s})".format( - "".join( - "(|(cn=*{bit:s}*)(uid=*{bit:s}*))".format(bit=bit) - for bit in bits - if bit.isalnum() - ) - ) - if ldap_query != "(&)": - # If none of the bits were legal, we do not perform the query - entries = None - with Connection(settings.LDAP_SERVER_URL) as conn: - conn.search("dc=spi,dc=ens,dc=fr", ldap_query, attributes=["uid", "cn"]) - entries = conn.entries - # Clearing redundancies - queries["clippers"] = [ - Clipper(entry.uid.value, entry.cn.value) - for entry in entries - if entry.uid.value and entry.uid.value not in usernames - ] - # Resulting data - data.update(queries) - data["options"] = sum(len(query) for query in queries) +class COFLDAPSearch(autocomplete.LDAPSearch): + def result_link(self, clipper): + return reverse("clipper-registration", args=(clipper.clipper, clipper.fullname)) - return shortcuts.render(request, "autocomplete_user.html", data) + +class COFAutocomplete(autocomplete.Compose): + search_units = [ + ("members", COFMemberSearch()), + ("others", COFOthersSearch()), + ("clippers", COFLDAPSearch()), + ] + + +cof_autocomplete = COFAutocomplete() diff --git a/gestioncof/cms/__init__.py b/gestioncof/cms/__init__.py index 043b644d..e69de29b 100644 --- a/gestioncof/cms/__init__.py +++ b/gestioncof/cms/__init__.py @@ -1 +0,0 @@ -default_app_config = "gestioncof.cms.apps.COFCMSAppConfig" diff --git a/gestioncof/cms/fixtures/examplesite0.json b/gestioncof/cms/fixtures/examplesite0.json new file mode 100644 index 00000000..816ebe82 --- /dev/null +++ b/gestioncof/cms/fixtures/examplesite0.json @@ -0,0 +1,641 @@ +[ +{ + "model": "wagtailcore.page", + "pk": 27, + "fields": { + "path": "000100010002", + "depth": 3, + "numchild": 3, + "title": "Site du COF", + "title_fr": "Site du COF", + "title_en": null, + "draft_title": "Site du COF", + "slug": "site", + "slug_fr": "site", + "slug_en": null, + "content_type": 89, + "live": true, + "has_unpublished_changes": false, + "url_path": "/global/site/", + "url_path_fr": "/global/site/", + "url_path_en": "/global/site/", + "owner": 165, + "seo_title": "", + "seo_title_fr": null, + "seo_title_en": null, + "show_in_menus": false, + "search_description": "", + "search_description_fr": "", + "search_description_en": "", + "go_live_at": null, + "expire_at": null, + "expired": false, + "locked": false, + "first_published_at": "2019-02-04T20:54:14.724Z", + "last_published_at": "2019-02-04T20:54:14.724Z", + "latest_revision_created_at": null, + "live_revision": null + } +}, +{ + "model": "wagtailcore.page", + "pk": 28, + "fields": { + "path": "0001000100020001", + "depth": 4, + "numchild": 0, + "title": "Pr\u00e9sentation", + "title_fr": "Pr\u00e9sentation", + "title_en": "Presentation", + "draft_title": "Pr\u00e9sentation", + "slug": "pr\u00e9sentation", + "slug_fr": "pr\u00e9sentation", + "slug_en": null, + "content_type": 88, + "live": true, + "has_unpublished_changes": false, + "url_path": "/global/site/pr\u00e9sentation/", + "url_path_fr": "/global/site/pr\u00e9sentation/", + "url_path_en": "/global/site/pr\u00e9sentation/", + "owner": 165, + "seo_title": "", + "seo_title_fr": null, + "seo_title_en": null, + "show_in_menus": true, + "search_description": "", + "search_description_fr": "", + "search_description_en": "", + "go_live_at": null, + "expire_at": null, + "expired": false, + "locked": false, + "first_published_at": "2019-02-04T20:55:06.574Z", + "last_published_at": "2019-02-04T21:42:00.461Z", + "latest_revision_created_at": null, + "live_revision": null + } +}, +{ + "model": "wagtailcore.page", + "pk": 29, + "fields": { + "path": "0001000100020002", + "depth": 4, + "numchild": 2, + "title": "Actualit\u00e9s", + "title_fr": "Actualit\u00e9s", + "title_en": "News", + "draft_title": "Actualit\u00e9s", + "slug": "actualit\u00e9s", + "slug_fr": "actualit\u00e9s", + "slug_en": "news", + "content_type": 92, + "live": true, + "has_unpublished_changes": false, + "url_path": "/global/site/actualit\u00e9s/", + "url_path_fr": "/global/site/actualit\u00e9s/", + "url_path_en": "/global/site/news/", + "owner": 165, + "seo_title": "", + "seo_title_fr": null, + "seo_title_en": null, + "show_in_menus": true, + "search_description": "", + "search_description_fr": "", + "search_description_en": "", + "go_live_at": null, + "expire_at": null, + "expired": false, + "locked": false, + "first_published_at": "2019-02-04T20:58:47.657Z", + "last_published_at": "2019-02-04T21:43:55.575Z", + "latest_revision_created_at": null, + "live_revision": null + } +}, +{ + "model": "wagtailcore.page", + "pk": 30, + "fields": { + "path": "00010001000200020001", + "depth": 5, + "numchild": 0, + "title": "Grosse teuf en K-F\u00eat", + "title_fr": "Grosse teuf en K-F\u00eat", + "title_en": "Big feast in K-F\u00eat", + "draft_title": "Grosse teuf en K-F\u00eat", + "slug": "grosse-teuf-en-k-f\u00eat", + "slug_fr": "grosse-teuf-en-k-f\u00eat", + "slug_en": "big-feast-in-k-f\u00eat", + "content_type": 93, + "live": true, + "has_unpublished_changes": false, + "url_path": "/global/site/actualit\u00e9s/grosse-teuf-en-k-f\u00eat/", + "url_path_fr": "/global/site/actualit\u00e9s/grosse-teuf-en-k-f\u00eat/", + "url_path_en": "/global/site/news/big-feast-in-k-f\u00eat/", + "owner": 165, + "seo_title": "", + "seo_title_fr": null, + "seo_title_en": null, + "show_in_menus": false, + "search_description": "", + "search_description_fr": "", + "search_description_en": "", + "go_live_at": null, + "expire_at": null, + "expired": false, + "locked": false, + "first_published_at": "2019-02-04T21:04:39.422Z", + "last_published_at": "2019-02-04T21:04:39.422Z", + "latest_revision_created_at": null, + "live_revision": null + } +}, +{ + "model": "wagtailcore.page", + "pk": 31, + "fields": { + "path": "00010001000200020002", + "depth": 5, + "numchild": 0, + "title": "Les 48h des Arts", + "title_fr": "Les 48h des Arts", + "title_en": null, + "draft_title": "Les 48h des Arts", + "slug": "les-48h-des-arts", + "slug_fr": "les-48h-des-arts", + "slug_en": null, + "content_type": 93, + "live": true, + "has_unpublished_changes": false, + "url_path": "/global/site/actualit\u00e9s/les-48h-des-arts/", + "url_path_fr": "/global/site/actualit\u00e9s/les-48h-des-arts/", + "url_path_en": "/global/site/news/les-48h-des-arts/", + "owner": 165, + "seo_title": "", + "seo_title_fr": null, + "seo_title_en": null, + "show_in_menus": false, + "search_description": "", + "search_description_fr": "", + "search_description_en": "", + "go_live_at": null, + "expire_at": null, + "expired": false, + "locked": false, + "first_published_at": "2019-02-04T21:05:27.190Z", + "last_published_at": "2019-02-04T21:05:27.190Z", + "latest_revision_created_at": null, + "live_revision": null + } +}, +{ + "model": "wagtailcore.page", + "pk": 32, + "fields": { + "path": "0001000100020003", + "depth": 4, + "numchild": 1, + "title": "Clubs", + "title_fr": "Clubs", + "title_en": null, + "draft_title": "Clubs", + "slug": "clubs", + "slug_fr": "clubs", + "slug_en": null, + "content_type": 87, + "live": true, + "has_unpublished_changes": false, + "url_path": "/global/site/clubs/", + "url_path_fr": "/global/site/clubs/", + "url_path_en": "/global/site/clubs/", + "owner": 165, + "seo_title": "", + "seo_title_fr": null, + "seo_title_en": null, + "show_in_menus": true, + "search_description": "", + "search_description_fr": "", + "search_description_en": "", + "go_live_at": null, + "expire_at": null, + "expired": false, + "locked": false, + "first_published_at": "2019-02-04T21:44:23.382Z", + "last_published_at": "2019-02-04T21:44:23.382Z", + "latest_revision_created_at": null, + "live_revision": null + } +}, +{ + "model": "wagtailcore.page", + "pk": 33, + "fields": { + "path": "00010001000200030001", + "depth": 5, + "numchild": 0, + "title": "Arts Plastiques", + "title_fr": "Arts Plastiques", + "title_en": null, + "draft_title": "Arts Plastiques", + "slug": "arts-plastiques", + "slug_fr": "arts-plastiques", + "slug_en": null, + "content_type": 90, + "live": true, + "has_unpublished_changes": false, + "url_path": "/global/site/clubs/arts-plastiques/", + "url_path_fr": "/global/site/clubs/arts-plastiques/", + "url_path_en": "/global/site/clubs/arts-plastiques/", + "owner": 165, + "seo_title": "", + "seo_title_fr": null, + "seo_title_en": null, + "show_in_menus": false, + "search_description": "", + "search_description_fr": "", + "search_description_en": "", + "go_live_at": null, + "expire_at": null, + "expired": false, + "locked": false, + "first_published_at": "2019-02-04T21:48:58.013Z", + "last_published_at": "2019-02-04T21:48:58.013Z", + "latest_revision_created_at": null, + "live_revision": null + } +}, +{ + "model": "wagtailcore.collection", + "pk": 3, + "fields": { + "path": "00010002", + "depth": 2, + "numchild": 0, + "name": "COF" + } +}, + { + "model": "wagtailimages.image", + "pk": 33, + "fields": { + "collection": 3, + "title": "COF-17", + "file": "original_images/cof-768x576.jpg", + "width": 768, + "height": 576, + "created_at": "2018-01-22T18:49:25.647Z", + "uploaded_by_user": 165, + "focal_point_x": null, + "focal_point_y": null, + "focal_point_width": null, + "focal_point_height": null, + "file_size": 132330, + "file_hash": "" + } +}, +{ + "model": "wagtailimages.image", + "pk": 34, + "fields": { + "collection": 3, + "title": "Singin in the RENS", + "file": "original_images/singin.jpg", + "width": 682, + "height": 361, + "created_at": "2018-01-22T19:13:49.753Z", + "uploaded_by_user": 165, + "focal_point_x": null, + "focal_point_y": null, + "focal_point_width": null, + "focal_point_height": null, + "file_size": null, + "file_hash": "" + } +}, +{ + "model": "wagtailimages.image", + "pk": 35, + "fields": { + "collection": 3, + "title": "Retour du Bur\u00f4", + "file": "original_images/retour.jpg", + "width": 614, + "height": 211, + "created_at": "2018-01-22T19:16:25.375Z", + "uploaded_by_user": 165, + "focal_point_x": null, + "focal_point_y": null, + "focal_point_width": null, + "focal_point_height": null, + "file_size": null, + "file_hash": "" + } +}, +{ + "model": "wagtailimages.image", + "pk": 36, + "fields": { + "collection": 3, + "title": "elections 18", + "file": "original_images/elections.png", + "width": 850, + "height": 406, + "created_at": "2018-01-22T19:21:31.954Z", + "uploaded_by_user": 165, + "focal_point_x": null, + "focal_point_y": null, + "focal_point_width": null, + "focal_point_height": null, + "file_size": null, + "file_hash": "" + } +}, +{ + "model": "wagtailimages.image", + "pk": 37, + "fields": { + "collection": 3, + "title": "Arts Plastiques", + "file": "original_images/ArtsPla.png", + "width": 150, + "height": 150, + "created_at": "2018-01-22T20:11:56.461Z", + "uploaded_by_user": 165, + "focal_point_x": null, + "focal_point_y": null, + "focal_point_width": null, + "focal_point_height": null, + "file_size": null, + "file_hash": "" + } +}, +{ + "model": "wagtailimages.image", + "pk": 38, + "fields": { + "collection": 3, + "title": "MGEN", + "file": "original_images/MGEN.jpg", + "width": 300, + "height": 204, + "created_at": "2018-01-22T20:20:41.712Z", + "uploaded_by_user": 165, + "focal_point_x": null, + "focal_point_y": null, + "focal_point_width": null, + "focal_point_height": null, + "file_size": null, + "file_hash": "" + } +}, +{ + "model": "wagtailimages.image", + "pk": 39, + "fields": { + "collection": 3, + "title": "MAIF", + "file": "original_images/Logo-MAIF.gif", + "width": 300, + "height": 290, + "created_at": "2018-01-28T16:20:13.828Z", + "uploaded_by_user": 165, + "focal_point_x": null, + "focal_point_y": null, + "focal_point_width": null, + "focal_point_height": null, + "file_size": null, + "file_hash": "" + } +}, +{ + "model": "cofcms.cofrootpage", + "pk": 27, + "fields": { + "introduction": "

    Bienvenue sur le site du COF !

    ", + "introduction_fr": "

    Bienvenue sur le site du COF !

    ", + "introduction_en": "

    " + } +}, +{ + "model": "cofcms.cofpage", + "pk": 28, + "fields": { + "body": "[{\"value\": \"

    On est le COF on est tout gentil

    \", \"type\": \"paragraph\", \"id\": \"0b3a92bd-1e27-433b-842c-ab4f0a2750ad\"}]", + "body_fr": "[{\"value\": \"

    On est le COF on est tout gentil

    \", \"type\": \"paragraph\", \"id\": \"0b3a92bd-1e27-433b-842c-ab4f0a2750ad\"}]", + "body_en": "[]" + } +}, +{ + "model": "cofcms.cofactuindexpage", + "pk": 29, + "fields": {} +}, +{ + "model": "cofcms.cofactupage", + "pk": 30, + "fields": { + "chapo": "Grosse teuf en K-F\u00eat", + "chapo_fr": "Grosse teuf en K-F\u00eat", + "chapo_en": "Big typar in K-F\u00eat", + "body": "

    Viens boire en K-F\u00eat

    ", + "body_fr": "

    Viens boire en K-F\u00eat

    ", + "body_en": "

    ", + "image": 34, + "is_event": true, + "date_start": "2019-02-07T21:00:00Z", + "date_end": "2019-02-08T03:00:00Z", + "all_day": false + } +}, +{ + "model": "cofcms.cofactupage", + "pk": 31, + "fields": { + "chapo": "", + "chapo_fr": "", + "chapo_en": "", + "body": "

    C'est l'art

    ", + "body_fr": "

    C'est l'art

    ", + "body_en": "

    ", + "image": 37, + "is_event": true, + "date_start": "2019-03-16T21:05:00Z", + "date_end": "2019-03-24T21:05:00Z", + "all_day": true + } +}, +{ + "model": "cofcms.cofdirectorypage", + "pk": 32, + "fields": { + "introduction": "

    Ce sont les clubs

    ", + "introduction_fr": "

    Ce sont les clubs

    ", + "introduction_en": "

    ", + "alphabetique": true + } +}, +{ + "model": "cofcms.cofdirectoryentrypage", + "pk": 33, + "fields": { + "body": "

    Club Arts Plastiques

    ", + "body_fr": "

    Club Arts Plastiques

    ", + "body_en": "

    ", + "links": "[{\"value\": {\"texte\": \"Liste Mails\", \"email\": \"artsplastiques@ens.fr\"}, \"type\": \"contact\", \"id\": \"cf198b98-0b84-4f38-ac00-6d883cfd60a4\"}]", + "links_fr": "[{\"value\": {\"texte\": \"Liste Mails\", \"email\": \"artsplastiques@ens.fr\"}, \"type\": \"contact\", \"id\": \"cf198b98-0b84-4f38-ac00-6d883cfd60a4\"}]", + "links_en": "[]", + "image": 37 + } +}, +{ + "model": "wagtailmenus.flatmenuitem", + "pk": 7, + "fields": { + "sort_order": 0, + "link_page": null, + "link_url": "https://www.cof.ens.fr/bda/", + "url_append": "", + "handle": "", + "link_text": "BdA", + "allow_subnav": false, + "menu": 2 + } +}, +{ + "model": "wagtailmenus.flatmenuitem", + "pk": 8, + "fields": { + "sort_order": 1, + "link_page": null, + "link_url": "https://www.cof.ens.fr/bds/", + "url_append": "", + "handle": "", + "link_text": "BdS", + "allow_subnav": false, + "menu": 2 + } +}, +{ + "model": "wagtailmenus.flatmenuitem", + "pk": 9, + "fields": { + "sort_order": 2, + "link_page": null, + "link_url": "https://www.cof.ens.fr/gestion", + "url_append": "", + "handle": "", + "link_text": "GestioCOF", + "allow_subnav": false, + "menu": 2 + } +}, +{ + "model": "wagtailmenus.flatmenuitem", + "pk": 10, + "fields": { + "sort_order": 3, + "link_page": null, + "link_url": "https://www.cof.ens.fr/bocal", + "url_append": "", + "handle": "", + "link_text": "Le BOcal", + "allow_subnav": false, + "menu": 2 + } +}, +{ + "model": "wagtailmenus.flatmenuitem", + "pk": 11, + "fields": { + "sort_order": 4, + "link_page": null, + "link_url": "https://photos.cof.ens.fr/", + "url_append": "", + "handle": "", + "link_text": "Serveur photos", + "allow_subnav": false, + "menu": 2 + } +}, +{ + "model": "wagtailmenus.flatmenuitem", + "pk": 12, + "fields": { + "sort_order": 5, + "link_page": null, + "link_url": "https://www.eleves.ens.fr", + "url_append": "", + "handle": "", + "link_text": "Services \u00e9l\u00e8ves ENS", + "allow_subnav": false, + "menu": 2 + } +}, +{ + "model": "wagtailmenus.flatmenuitem", + "pk": 20, + "fields": { + "sort_order": 0, + "link_page": 28, + "link_url": null, + "url_append": "", + "handle": "", + "link_text": "", + "allow_subnav": false, + "menu": 4 + } +}, +{ + "model": "wagtailmenus.flatmenuitem", + "pk": 21, + "fields": { + "sort_order": 1, + "link_page": 29, + "link_url": null, + "url_append": "", + "handle": "", + "link_text": "", + "allow_subnav": false, + "menu": 4 + } +}, +{ + "model": "wagtailmenus.flatmenuitem", + "pk": 22, + "fields": { + "sort_order": 2, + "link_page": 32, + "link_url": null, + "url_append": "", + "handle": "", + "link_text": "", + "allow_subnav": false, + "menu": 4 + } +}, +{ + "model": "wagtailmenus.flatmenu", + "pk": 2, + "fields": { + "site": 2, + "title": "COF - liens externes", + "handle": "cof-nav-ext", + "heading": "", + "max_levels": 1, + "use_specific": 1 + } +}, +{ + "model": "wagtailmenus.flatmenu", + "pk": 4, + "fields": { + "site": 2, + "title": "COF - liens internes", + "handle": "cof-nav-int", + "heading": "", + "max_levels": 1, + "use_specific": 1 + } +} +] diff --git a/gestioncof/cms/forms.py b/gestioncof/cms/forms.py new file mode 100644 index 00000000..d2766bf0 --- /dev/null +++ b/gestioncof/cms/forms.py @@ -0,0 +1,15 @@ +import re + +from django import forms +from django.utils.translation import gettext as _ + + +class CaptchaForm(forms.Form): + answer = forms.CharField(label="Réponse", max_length=32) + + def clean_answer(self): + value = self.cleaned_data["answer"] + if not re.match(r"(les|the)? *ernests?", value.strip().lower()): + raise forms.ValidationError(_("Réponse incorrecte")) + + return value diff --git a/gestioncof/cms/locale/en/LC_MESSAGES/django.mo b/gestioncof/cms/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 00000000..40b2f45f Binary files /dev/null and b/gestioncof/cms/locale/en/LC_MESSAGES/django.mo differ diff --git a/gestioncof/cms/locale/en/LC_MESSAGES/django.po b/gestioncof/cms/locale/en/LC_MESSAGES/django.po new file mode 100644 index 00000000..d586f73e --- /dev/null +++ b/gestioncof/cms/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,118 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-08-22 12:28+0200\n" +"PO-Revision-Date: 2020-08-22 12:29+0200\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 2.0.6\n" + +#: templates/cofcms/base.html:7 +msgid "Association des élèves de l'ENS Ulm" +msgstr "Student Association of the École Normale Supérieure" + +#: templates/cofcms/calendar.html:9 +msgid "LMMJVSD" +msgstr "MTuWThFSaSu" + +#: templates/cofcms/calendar.html:17 +msgid "Le " +msgstr "On " + +#: templates/cofcms/cof_actu_index_page.html:10 +msgid "Calendrier" +msgstr "Calendar" + +#: templates/cofcms/cof_actu_index_page.html:25 +#: templates/cofcms/cof_actu_index_page.html:47 +msgid "Actualités plus récentes" +msgstr "Newer" + +#: templates/cofcms/cof_actu_index_page.html:28 +#: templates/cofcms/cof_actu_index_page.html:50 +msgid "Actualités plus anciennes" +msgstr "Older" + +#: templates/cofcms/cof_actu_index_page.html:41 +msgid "Lire plus" +msgstr "Read more" + +#: templates/cofcms/cof_actu_page.html:7 +msgid "A lieu" +msgstr "Happens" + +#: templates/cofcms/cof_directory_page.html:9 +msgid "Accès rapide" +msgstr "Quick access" + +#: templates/cofcms/cof_directory_page.html:39 +msgid "Afficher l'adresse mail" +msgstr "Show mail address" + +#: templates/cofcms/cof_root_page.html:11 +msgid "Agenda" +msgstr "Agenda" + +#: templates/cofcms/sympa.html:4 +msgid "Redirection vers" +msgstr "Redirecting to" + +#: templates/cofcms/sympa.html:18 +msgid "Comment s'appellent les poissons du bassin ?" +msgstr "How are called the fish in the school's pond?" + +#: templatetags/cofcms_tags.py:134 templatetags/cofcms_tags.py:163 +#, python-brace-format +msgid "le {datestart}" +msgstr "on {datestart}" + +#: templatetags/cofcms_tags.py:136 +#, python-brace-format +msgid "le {datestart} de {timestart} à {timeend}" +msgstr "on {datestart} from {timestart} to {timeend}" + +#: templatetags/cofcms_tags.py:147 +#, python-brace-format +msgid "du {datestart} au {dateend}{common}" +msgstr "from {datestart} to {dateend}{common}" + +#: templatetags/cofcms_tags.py:153 +#, python-brace-format +msgid "du {datestart}{common} à {timestart} au {dateend} à {timeend}" +msgstr "from {datestart}{common} {timestart} to {dateend} {timeend}" + +#: templatetags/cofcms_tags.py:165 +#, python-brace-format +msgid "le {datestart} à {timestart}" +msgstr "on {datestart} at {timestart}" + +#: views.py:16 +msgid "Réponse incorrecte" +msgstr "Invalid answer" + +#~ msgid "Listes mail" +#~ msgstr "Mailing lists" + +#~ msgid "" +#~ "\n" +#~ " Tous les abonnements aux listes de diffusion de mail à l'ENS se gèrent sur le serveur de mail SYMPA\n" +#~ "\n" +#~ " Pour y accéder : Merci de répondre à cette question anti-robots.\n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " All the mailing list subscriptions can be managed through the SYMPA mail server\n" +#~ "\n" +#~ " In order to access it, please answer this antispam question.\n" +#~ " " diff --git a/gestioncof/cms/migrations/0001_initial.py b/gestioncof/cms/migrations/0001_initial.py index 6c6a801e..ef024c74 100644 --- a/gestioncof/cms/migrations/0001_initial.py +++ b/gestioncof/cms/migrations/0001_initial.py @@ -3,9 +3,9 @@ from __future__ import unicode_literals import django.db.models.deletion +import wagtail.blocks import wagtail.contrib.routable_page.models -import wagtail.core.blocks -import wagtail.core.fields +import wagtail.fields import wagtail.images.blocks from django.db import migrations, models @@ -13,7 +13,6 @@ import gestioncof.cms.models class Migration(migrations.Migration): - initial = True dependencies = [ @@ -73,18 +72,14 @@ class Migration(migrations.Migration): blank=True, null=True, verbose_name="Description rapide" ), ), - ("body", wagtail.core.fields.RichTextField(verbose_name="Contenu")), + ("body", wagtail.fields.RichTextField(verbose_name="Contenu")), ( "body_fr", - wagtail.core.fields.RichTextField( - null=True, verbose_name="Contenu" - ), + wagtail.fields.RichTextField(null=True, verbose_name="Contenu"), ), ( "body_en", - wagtail.core.fields.RichTextField( - null=True, verbose_name="Contenu" - ), + wagtail.fields.RichTextField(null=True, verbose_name="Contenu"), ), ( "is_event", @@ -139,46 +134,40 @@ class Migration(migrations.Migration): to="wagtailcore.Page", ), ), - ("body", wagtail.core.fields.RichTextField(verbose_name="Description")), + ("body", wagtail.fields.RichTextField(verbose_name="Description")), ( "body_fr", - wagtail.core.fields.RichTextField( - null=True, verbose_name="Description" - ), + wagtail.fields.RichTextField(null=True, verbose_name="Description"), ), ( "body_en", - wagtail.core.fields.RichTextField( - null=True, verbose_name="Description" - ), + wagtail.fields.RichTextField(null=True, verbose_name="Description"), ), ( "links", - wagtail.core.fields.StreamField( + wagtail.fields.StreamField( [ ( "lien", - wagtail.core.blocks.StructBlock( + wagtail.blocks.StructBlock( [ ( "url", - wagtail.core.blocks.URLBlock(required=True), + wagtail.blocks.URLBlock(required=True), ), - ("texte", wagtail.core.blocks.CharBlock()), + ("texte", wagtail.blocks.CharBlock()), ] ), ), ( "contact", - wagtail.core.blocks.StructBlock( + wagtail.blocks.StructBlock( [ ( "email", - wagtail.core.blocks.EmailBlock( - required=True - ), + wagtail.blocks.EmailBlock(required=True), ), - ("texte", wagtail.core.blocks.CharBlock()), + ("texte", wagtail.blocks.CharBlock()), ] ), ), @@ -187,31 +176,29 @@ class Migration(migrations.Migration): ), ( "links_fr", - wagtail.core.fields.StreamField( + wagtail.fields.StreamField( [ ( "lien", - wagtail.core.blocks.StructBlock( + wagtail.blocks.StructBlock( [ ( "url", - wagtail.core.blocks.URLBlock(required=True), + wagtail.blocks.URLBlock(required=True), ), - ("texte", wagtail.core.blocks.CharBlock()), + ("texte", wagtail.blocks.CharBlock()), ] ), ), ( "contact", - wagtail.core.blocks.StructBlock( + wagtail.blocks.StructBlock( [ ( "email", - wagtail.core.blocks.EmailBlock( - required=True - ), + wagtail.blocks.EmailBlock(required=True), ), - ("texte", wagtail.core.blocks.CharBlock()), + ("texte", wagtail.blocks.CharBlock()), ] ), ), @@ -221,31 +208,29 @@ class Migration(migrations.Migration): ), ( "links_en", - wagtail.core.fields.StreamField( + wagtail.fields.StreamField( [ ( "lien", - wagtail.core.blocks.StructBlock( + wagtail.blocks.StructBlock( [ ( "url", - wagtail.core.blocks.URLBlock(required=True), + wagtail.blocks.URLBlock(required=True), ), - ("texte", wagtail.core.blocks.CharBlock()), + ("texte", wagtail.blocks.CharBlock()), ] ), ), ( "contact", - wagtail.core.blocks.StructBlock( + wagtail.blocks.StructBlock( [ ( "email", - wagtail.core.blocks.EmailBlock( - required=True - ), + wagtail.blocks.EmailBlock(required=True), ), - ("texte", wagtail.core.blocks.CharBlock()), + ("texte", wagtail.blocks.CharBlock()), ] ), ), @@ -287,17 +272,17 @@ class Migration(migrations.Migration): ), ( "introduction", - wagtail.core.fields.RichTextField(verbose_name="Introduction"), + wagtail.fields.RichTextField(verbose_name="Introduction"), ), ( "introduction_fr", - wagtail.core.fields.RichTextField( + wagtail.fields.RichTextField( null=True, verbose_name="Introduction" ), ), ( "introduction_en", - wagtail.core.fields.RichTextField( + wagtail.fields.RichTextField( null=True, verbose_name="Introduction" ), ), @@ -330,27 +315,27 @@ class Migration(migrations.Migration): ), ( "body", - wagtail.core.fields.StreamField( + wagtail.fields.StreamField( [ ( "heading", - wagtail.core.blocks.CharBlock(classname="full title"), + wagtail.blocks.CharBlock(classname="full title"), ), - ("paragraph", wagtail.core.blocks.RichTextBlock()), + ("paragraph", wagtail.blocks.RichTextBlock()), ("image", wagtail.images.blocks.ImageChooserBlock()), ( "iframe", - wagtail.core.blocks.StructBlock( + wagtail.blocks.StructBlock( [ ( "url", - wagtail.core.blocks.URLBlock( + wagtail.blocks.URLBlock( "Adresse de la page" ), ), ( "height", - wagtail.core.blocks.CharBlock( + wagtail.blocks.CharBlock( "Hauteur (en pixels)" ), ), @@ -362,27 +347,27 @@ class Migration(migrations.Migration): ), ( "body_fr", - wagtail.core.fields.StreamField( + wagtail.fields.StreamField( [ ( "heading", - wagtail.core.blocks.CharBlock(classname="full title"), + wagtail.blocks.CharBlock(classname="full title"), ), - ("paragraph", wagtail.core.blocks.RichTextBlock()), + ("paragraph", wagtail.blocks.RichTextBlock()), ("image", wagtail.images.blocks.ImageChooserBlock()), ( "iframe", - wagtail.core.blocks.StructBlock( + wagtail.blocks.StructBlock( [ ( "url", - wagtail.core.blocks.URLBlock( + wagtail.blocks.URLBlock( "Adresse de la page" ), ), ( "height", - wagtail.core.blocks.CharBlock( + wagtail.blocks.CharBlock( "Hauteur (en pixels)" ), ), @@ -395,27 +380,27 @@ class Migration(migrations.Migration): ), ( "body_en", - wagtail.core.fields.StreamField( + wagtail.fields.StreamField( [ ( "heading", - wagtail.core.blocks.CharBlock(classname="full title"), + wagtail.blocks.CharBlock(classname="full title"), ), - ("paragraph", wagtail.core.blocks.RichTextBlock()), + ("paragraph", wagtail.blocks.RichTextBlock()), ("image", wagtail.images.blocks.ImageChooserBlock()), ( "iframe", - wagtail.core.blocks.StructBlock( + wagtail.blocks.StructBlock( [ ( "url", - wagtail.core.blocks.URLBlock( + wagtail.blocks.URLBlock( "Adresse de la page" ), ), ( "height", - wagtail.core.blocks.CharBlock( + wagtail.blocks.CharBlock( "Hauteur (en pixels)" ), ), @@ -449,17 +434,17 @@ class Migration(migrations.Migration): ), ( "introduction", - wagtail.core.fields.RichTextField(verbose_name="Introduction"), + wagtail.fields.RichTextField(verbose_name="Introduction"), ), ( "introduction_fr", - wagtail.core.fields.RichTextField( + wagtail.fields.RichTextField( null=True, verbose_name="Introduction" ), ), ( "introduction_en", - wagtail.core.fields.RichTextField( + wagtail.fields.RichTextField( null=True, verbose_name="Introduction" ), ), diff --git a/gestioncof/cms/migrations/0002_auto_20190523_1521.py b/gestioncof/cms/migrations/0002_auto_20190523_1521.py index 35e50b8e..1a0a87d4 100644 --- a/gestioncof/cms/migrations/0002_auto_20190523_1521.py +++ b/gestioncof/cms/migrations/0002_auto_20190523_1521.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [("cofcms", "0001_initial")] operations = [ diff --git a/gestioncof/cms/migrations/0003_directory_entry_optional_links.py b/gestioncof/cms/migrations/0003_directory_entry_optional_links.py new file mode 100644 index 00000000..22533193 --- /dev/null +++ b/gestioncof/cms/migrations/0003_directory_entry_optional_links.py @@ -0,0 +1,106 @@ +# Generated by Django 2.2.8 on 2019-12-20 16:22 + +import wagtail.blocks +import wagtail.fields +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("cofcms", "0002_auto_20190523_1521"), + ] + + operations = [ + migrations.AlterField( + model_name="cofdirectoryentrypage", + name="links", + field=wagtail.fields.StreamField( + [ + ( + "lien", + wagtail.blocks.StructBlock( + [ + ("url", wagtail.blocks.URLBlock(required=True)), + ("texte", wagtail.blocks.CharBlock()), + ] + ), + ), + ( + "contact", + wagtail.blocks.StructBlock( + [ + ( + "email", + wagtail.blocks.EmailBlock(required=True), + ), + ("texte", wagtail.blocks.CharBlock()), + ] + ), + ), + ], + blank=True, + ), + ), + migrations.AlterField( + model_name="cofdirectoryentrypage", + name="links_en", + field=wagtail.fields.StreamField( + [ + ( + "lien", + wagtail.blocks.StructBlock( + [ + ("url", wagtail.blocks.URLBlock(required=True)), + ("texte", wagtail.blocks.CharBlock()), + ] + ), + ), + ( + "contact", + wagtail.blocks.StructBlock( + [ + ( + "email", + wagtail.blocks.EmailBlock(required=True), + ), + ("texte", wagtail.blocks.CharBlock()), + ] + ), + ), + ], + blank=True, + null=True, + ), + ), + migrations.AlterField( + model_name="cofdirectoryentrypage", + name="links_fr", + field=wagtail.fields.StreamField( + [ + ( + "lien", + wagtail.blocks.StructBlock( + [ + ("url", wagtail.blocks.URLBlock(required=True)), + ("texte", wagtail.blocks.CharBlock()), + ] + ), + ), + ( + "contact", + wagtail.blocks.StructBlock( + [ + ( + "email", + wagtail.blocks.EmailBlock(required=True), + ), + ("texte", wagtail.blocks.CharBlock()), + ] + ), + ), + ], + blank=True, + null=True, + ), + ), + ] diff --git a/gestioncof/cms/migrations/0004_auto_20200829_2314.py b/gestioncof/cms/migrations/0004_auto_20200829_2314.py new file mode 100644 index 00000000..eb660ad9 --- /dev/null +++ b/gestioncof/cms/migrations/0004_auto_20200829_2314.py @@ -0,0 +1,133 @@ +# Generated by Django 2.2.15 on 2020-08-29 21:14 + +import wagtail.blocks +import wagtail.fields +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("cofcms", "0003_directory_entry_optional_links"), + ] + + operations = [ + migrations.AlterField( + model_name="cofdirectoryentrypage", + name="links", + field=wagtail.fields.StreamField( + [ + ( + "lien", + wagtail.blocks.StructBlock( + [ + ("url", wagtail.blocks.URLBlock(required=True)), + ("texte", wagtail.blocks.CharBlock()), + ] + ), + ), + ( + "contact", + wagtail.blocks.StructBlock( + [ + ( + "email", + wagtail.blocks.EmailBlock(required=True), + ), + ("texte", wagtail.blocks.CharBlock()), + ] + ), + ), + ( + "info", + wagtail.blocks.StructBlock( + [ + ("nom", wagtail.blocks.CharBlock(required=False)), + ("texte", wagtail.blocks.CharBlock(required=True)), + ] + ), + ), + ], + blank=True, + ), + ), + migrations.AlterField( + model_name="cofdirectoryentrypage", + name="links_en", + field=wagtail.fields.StreamField( + [ + ( + "lien", + wagtail.blocks.StructBlock( + [ + ("url", wagtail.blocks.URLBlock(required=True)), + ("texte", wagtail.blocks.CharBlock()), + ] + ), + ), + ( + "contact", + wagtail.blocks.StructBlock( + [ + ( + "email", + wagtail.blocks.EmailBlock(required=True), + ), + ("texte", wagtail.blocks.CharBlock()), + ] + ), + ), + ( + "info", + wagtail.blocks.StructBlock( + [ + ("nom", wagtail.blocks.CharBlock(required=False)), + ("texte", wagtail.blocks.CharBlock(required=True)), + ] + ), + ), + ], + blank=True, + null=True, + ), + ), + migrations.AlterField( + model_name="cofdirectoryentrypage", + name="links_fr", + field=wagtail.fields.StreamField( + [ + ( + "lien", + wagtail.blocks.StructBlock( + [ + ("url", wagtail.blocks.URLBlock(required=True)), + ("texte", wagtail.blocks.CharBlock()), + ] + ), + ), + ( + "contact", + wagtail.blocks.StructBlock( + [ + ( + "email", + wagtail.blocks.EmailBlock(required=True), + ), + ("texte", wagtail.blocks.CharBlock()), + ] + ), + ), + ( + "info", + wagtail.blocks.StructBlock( + [ + ("nom", wagtail.blocks.CharBlock(required=False)), + ("texte", wagtail.blocks.CharBlock(required=True)), + ] + ), + ), + ], + blank=True, + null=True, + ), + ), + ] diff --git a/gestioncof/cms/migrations/0005_alter_cofdirectoryentrypage_links_and_more.py b/gestioncof/cms/migrations/0005_alter_cofdirectoryentrypage_links_and_more.py new file mode 100644 index 00000000..5b563942 --- /dev/null +++ b/gestioncof/cms/migrations/0005_alter_cofdirectoryentrypage_links_and_more.py @@ -0,0 +1,203 @@ +# Generated by Django 4.2.17 on 2024-12-19 12:27 + +import wagtail.blocks +import wagtail.fields +import wagtail.images.blocks +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("cofcms", "0004_auto_20200829_2314"), + ] + + operations = [ + migrations.AlterField( + model_name="cofdirectoryentrypage", + name="links", + field=wagtail.fields.StreamField( + [ + ( + "lien", + wagtail.blocks.StructBlock( + [ + ("url", wagtail.blocks.URLBlock(required=True)), + ("texte", wagtail.blocks.CharBlock()), + ] + ), + ), + ( + "contact", + wagtail.blocks.StructBlock( + [ + ("email", wagtail.blocks.EmailBlock(required=True)), + ("texte", wagtail.blocks.CharBlock()), + ] + ), + ), + ( + "info", + wagtail.blocks.StructBlock( + [ + ("nom", wagtail.blocks.CharBlock(required=False)), + ("texte", wagtail.blocks.CharBlock(required=True)), + ] + ), + ), + ], + blank=True, + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="cofdirectoryentrypage", + name="links_en", + field=wagtail.fields.StreamField( + [ + ( + "lien", + wagtail.blocks.StructBlock( + [ + ("url", wagtail.blocks.URLBlock(required=True)), + ("texte", wagtail.blocks.CharBlock()), + ] + ), + ), + ( + "contact", + wagtail.blocks.StructBlock( + [ + ("email", wagtail.blocks.EmailBlock(required=True)), + ("texte", wagtail.blocks.CharBlock()), + ] + ), + ), + ( + "info", + wagtail.blocks.StructBlock( + [ + ("nom", wagtail.blocks.CharBlock(required=False)), + ("texte", wagtail.blocks.CharBlock(required=True)), + ] + ), + ), + ], + blank=True, + null=True, + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="cofdirectoryentrypage", + name="links_fr", + field=wagtail.fields.StreamField( + [ + ( + "lien", + wagtail.blocks.StructBlock( + [ + ("url", wagtail.blocks.URLBlock(required=True)), + ("texte", wagtail.blocks.CharBlock()), + ] + ), + ), + ( + "contact", + wagtail.blocks.StructBlock( + [ + ("email", wagtail.blocks.EmailBlock(required=True)), + ("texte", wagtail.blocks.CharBlock()), + ] + ), + ), + ( + "info", + wagtail.blocks.StructBlock( + [ + ("nom", wagtail.blocks.CharBlock(required=False)), + ("texte", wagtail.blocks.CharBlock(required=True)), + ] + ), + ), + ], + blank=True, + null=True, + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="cofpage", + name="body", + field=wagtail.fields.StreamField( + [ + ("heading", wagtail.blocks.CharBlock(form_classname="full title")), + ("paragraph", wagtail.blocks.RichTextBlock()), + ("image", wagtail.images.blocks.ImageChooserBlock()), + ( + "iframe", + wagtail.blocks.StructBlock( + [ + ("url", wagtail.blocks.URLBlock("Adresse de la page")), + ( + "height", + wagtail.blocks.CharBlock("Hauteur (en pixels)"), + ), + ] + ), + ), + ], + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="cofpage", + name="body_en", + field=wagtail.fields.StreamField( + [ + ("heading", wagtail.blocks.CharBlock(form_classname="full title")), + ("paragraph", wagtail.blocks.RichTextBlock()), + ("image", wagtail.images.blocks.ImageChooserBlock()), + ( + "iframe", + wagtail.blocks.StructBlock( + [ + ("url", wagtail.blocks.URLBlock("Adresse de la page")), + ( + "height", + wagtail.blocks.CharBlock("Hauteur (en pixels)"), + ), + ] + ), + ), + ], + null=True, + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="cofpage", + name="body_fr", + field=wagtail.fields.StreamField( + [ + ("heading", wagtail.blocks.CharBlock(form_classname="full title")), + ("paragraph", wagtail.blocks.RichTextBlock()), + ("image", wagtail.images.blocks.ImageChooserBlock()), + ( + "iframe", + wagtail.blocks.StructBlock( + [ + ("url", wagtail.blocks.URLBlock("Adresse de la page")), + ( + "height", + wagtail.blocks.CharBlock("Hauteur (en pixels)"), + ), + ] + ), + ), + ], + null=True, + use_json_field=True, + ), + ), + ] diff --git a/gestioncof/cms/models.py b/gestioncof/cms/models.py index 0da0f687..6e9e8715 100644 --- a/gestioncof/cms/models.py +++ b/gestioncof/cms/models.py @@ -1,12 +1,11 @@ from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db import models -from wagtail.admin.edit_handlers import FieldPanel, StreamFieldPanel +from wagtail import blocks +from wagtail.admin.panels import FieldPanel from wagtail.contrib.routable_page.models import RoutablePageMixin, route -from wagtail.core import blocks -from wagtail.core.fields import RichTextField, StreamField -from wagtail.core.models import Page +from wagtail.fields import RichTextField, StreamField from wagtail.images.blocks import ImageChooserBlock -from wagtail.images.edit_handlers import ImageChooserPanel +from wagtail.models import Page # Page pouvant afficher des actualités @@ -31,6 +30,10 @@ class COFRootPage(RoutablePageMixin, Page, COFActuIndexMixin): verbose_name = "Racine site du COF" verbose_name_plural = "Racines site du COF" + @property + def actus(self): + return super().actus[:4] + # Mini calendrier @route(r"^calendar/(\d+)/(\d+)/$") def calendar(self, request, year, month): @@ -38,6 +41,13 @@ class COFRootPage(RoutablePageMixin, Page, COFActuIndexMixin): return raw_calendar_view(request, int(year), int(month)) + # Captcha Mailing-listes + @route(r"^sympa/captcha/$") + def sympa_captcha(self, request): + from .views import sympa_captcha_form_view + + return sympa_captcha_form_view(request) + # Block iframe class IFrameBlock(blocks.StructBlock): @@ -58,10 +68,11 @@ class COFPage(Page): ("paragraph", blocks.RichTextBlock()), ("image", ImageChooserBlock()), ("iframe", IFrameBlock()), - ] + ], + use_json_field=True, ) - content_panels = Page.content_panels + [StreamFieldPanel("body")] + content_panels = Page.content_panels + [FieldPanel("body")] subpage_types = ["COFDirectoryPage", "COFPage"] parent_page_types = ["COFPage", "COFRootPage"] @@ -116,7 +127,7 @@ class COFActuPage(RoutablePageMixin, Page): all_day = models.BooleanField("Toute la journée", default=False, blank=True) content_panels = Page.content_panels + [ - ImageChooserPanel("image"), + FieldPanel("image"), FieldPanel("chapo"), FieldPanel("body", classname="full"), FieldPanel("is_event"), @@ -182,7 +193,18 @@ class COFDirectoryEntryPage(Page): ] ), ), - ] + ( + "info", + blocks.StructBlock( + [ + ("nom", blocks.CharBlock(required=False)), + ("texte", blocks.CharBlock(required=True)), + ] + ), + ), + ], + blank=True, + use_json_field=True, ) image = models.ForeignKey( @@ -195,9 +217,9 @@ class COFDirectoryEntryPage(Page): ) content_panels = Page.content_panels + [ - ImageChooserPanel("image"), + FieldPanel("image"), FieldPanel("body", classname="full"), - StreamFieldPanel("links"), + FieldPanel("links"), ] subpage_types = [] diff --git a/gestioncof/cms/static/cofcms/css/ie.css b/gestioncof/cms/static/cofcms/css/ie.css deleted file mode 100644 index 5cd5b6c5..00000000 --- a/gestioncof/cms/static/cofcms/css/ie.css +++ /dev/null @@ -1,5 +0,0 @@ -/* Welcome to Compass. Use this file to write IE specific override styles. - * Import this file using the following HTML or equivalent: - * */ diff --git a/gestioncof/cms/static/cofcms/css/print.css b/gestioncof/cms/static/cofcms/css/print.css deleted file mode 100644 index b0e9e456..00000000 --- a/gestioncof/cms/static/cofcms/css/print.css +++ /dev/null @@ -1,3 +0,0 @@ -/* Welcome to Compass. Use this file to define print styles. - * Import this file using the following HTML or equivalent: - * */ diff --git a/gestioncof/cms/static/cofcms/css/screen.css b/gestioncof/cms/static/cofcms/css/screen.css index 4cab72c5..bbd90344 100644 --- a/gestioncof/cms/static/cofcms/css/screen.css +++ b/gestioncof/cms/static/cofcms/css/screen.css @@ -2,8 +2,7 @@ * In this file you should write your main styles. (or centralize your imports) * Import this file using the following HTML or equivalent: * */ -@import url("https://fonts.googleapis.com/css?family=Carter+One|Source+Sans+Pro:300,300i,700"); -/* line 5, ../../../../../../../../../../var/lib/gems/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ +/* line 5, ../../../../../../../../../../var/lib/gems/2.5.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, @@ -25,141 +24,158 @@ time, mark, audio, video { vertical-align: baseline; } -/* line 22, ../../../../../../../../../../var/lib/gems/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ +/* line 22, ../../../../../../../../../../var/lib/gems/2.5.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ html { line-height: 1; } -/* line 24, ../../../../../../../../../../var/lib/gems/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ +/* line 24, ../../../../../../../../../../var/lib/gems/2.5.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ ol, ul { list-style: none; } -/* line 26, ../../../../../../../../../../var/lib/gems/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ +/* line 26, ../../../../../../../../../../var/lib/gems/2.5.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ table { border-collapse: collapse; border-spacing: 0; } -/* line 28, ../../../../../../../../../../var/lib/gems/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ +/* line 28, ../../../../../../../../../../var/lib/gems/2.5.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ caption, th, td { text-align: left; font-weight: normal; vertical-align: middle; } -/* line 30, ../../../../../../../../../../var/lib/gems/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ +/* line 30, ../../../../../../../../../../var/lib/gems/2.5.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ q, blockquote { quotes: none; } -/* line 103, ../../../../../../../../../../var/lib/gems/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ +/* line 103, ../../../../../../../../../../var/lib/gems/2.5.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ q:before, q:after, blockquote:before, blockquote:after { content: ""; content: none; } -/* line 32, ../../../../../../../../../../var/lib/gems/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ +/* line 32, ../../../../../../../../../../var/lib/gems/2.5.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ a img { border: none; } -/* line 116, ../../../../../../../../../../var/lib/gems/2.3.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ +/* line 116, ../../../../../../../../../../var/lib/gems/2.5.0/gems/compass-core-1.0.3/stylesheets/compass/reset/_utilities.scss */ article, aside, details, figcaption, figure, footer, header, hgroup, main, menu, nav, section, summary { display: block; } -/* line 12, ../sass/screen.scss */ +/* line 10, ../sass/screen.scss */ *, *:after, *:before { box-sizing: border-box; } -/* line 16, ../sass/screen.scss */ +/* line 14, ../sass/screen.scss */ body { background: #fefefe; font: 17px "Source Sans Pro", "sans-serif"; + color: #000; } -/* line 21, ../sass/screen.scss */ +/* line 20, ../sass/screen.scss */ header { background: #5B0012; } -/* line 25, ../sass/screen.scss */ +/* line 24, ../sass/screen.scss */ h1, h2 { font-family: "Carter One", "serif"; +} + +/* line 28, ../sass/screen.scss */ +h1 { + font-size: 2.3em; color: #90001C; } -/* line 30, ../sass/screen.scss */ -h1 { - font-size: 2.3em; -} - -/* line 34, ../sass/screen.scss */ +/* line 33, ../sass/screen.scss */ h2 { font-size: 1.6em; + color: #b01432; } -/* line 38, ../sass/screen.scss */ +/* line 39, ../sass/screen.scss */ a { color: #CC9500; text-decoration: none; font-weight: bold; + padding: 0 2px; + margin: 0 -2px; +} +/* line 45, ../sass/screen.scss */ +a:hover { + text-decoration: underline; } -/* line 44, ../sass/screen.scss */ +/* line 50, ../sass/screen.scss */ h2 a { font-weight: inherit; color: inherit; } -/* line 50, ../sass/screen.scss */ +/* line 56, ../sass/screen.scss */ header a { color: #fefefe; } -/* line 53, ../sass/screen.scss */ +/* line 58, ../sass/screen.scss */ +header a:hover { + text-decoration: none; +} +/* line 62, ../sass/screen.scss */ header section { display: flex; width: 100%; justify-content: space-between; align-items: stretch; } -/* line 59, ../sass/screen.scss */ +/* line 68, ../sass/screen.scss */ header section.bottom-menu { justify-content: space-around; text-align: center; background: #90001C; } -/* line 65, ../sass/screen.scss */ +/* line 74, ../sass/screen.scss */ header h1 { padding: 0 15px; } -/* line 69, ../sass/screen.scss */ -header nav ul { +/* line 77, ../sass/screen.scss */ +header nav { display: inline-flex; } -/* line 71, ../sass/screen.scss */ +/* line 79, ../sass/screen.scss */ +header nav ul { + display: inline-flex; + flex-wrap: wrap; +} +/* line 82, ../sass/screen.scss */ header nav ul li { display: inline-block; } -/* line 73, ../sass/screen.scss */ +/* line 84, ../sass/screen.scss */ header nav ul li > * { display: block; padding: 10px 15px; font-weight: bold; } -/* line 78, ../sass/screen.scss */ +/* line 89, ../sass/screen.scss */ header nav ul li > *:hover { background: #280008; } -/* line 84, ../sass/screen.scss */ +/* line 95, ../sass/screen.scss */ header nav .lang-select { display: inline-block; height: 100%; vertical-align: top; position: relative; } -/* line 90, ../sass/screen.scss */ +/* line 101, ../sass/screen.scss */ header nav .lang-select:before { content: ""; color: #fff; @@ -171,12 +187,12 @@ header nav .lang-select:before { margin: 10px 0; padding-left: 10px; } -/* line 102, ../sass/screen.scss */ +/* line 113, ../sass/screen.scss */ header nav .lang-select a { padding: 10px 20px; display: block; } -/* line 106, ../sass/screen.scss */ +/* line 117, ../sass/screen.scss */ header nav .lang-select a img { display: block; width: auto; @@ -184,34 +200,34 @@ header nav .lang-select a img { vertical-align: middle; } -/* line 117, ../sass/screen.scss */ +/* line 128, ../sass/screen.scss */ article { line-height: 1.4; } -/* line 119, ../sass/screen.scss */ +/* line 130, ../sass/screen.scss */ article p, article ul { margin: 0.4em 0; } -/* line 122, ../sass/screen.scss */ +/* line 133, ../sass/screen.scss */ article ul { padding-left: 20px; } -/* line 124, ../sass/screen.scss */ +/* line 135, ../sass/screen.scss */ article ul li { list-style: outside; } -/* line 128, ../sass/screen.scss */ +/* line 139, ../sass/screen.scss */ article:last-child { margin-bottom: 30px; } -/* line 133, ../sass/screen.scss */ +/* line 144, ../sass/screen.scss */ .container { max-width: 1000px; margin: 0 auto; position: relative; } -/* line 138, ../sass/screen.scss */ +/* line 149, ../sass/screen.scss */ .container .aside-wrap { position: absolute; top: 30px; @@ -219,7 +235,7 @@ article:last-child { width: 25%; left: 6px; } -/* line 145, ../sass/screen.scss */ +/* line 156, ../sass/screen.scss */ .container .aside-wrap .aside { color: #222; position: fixed; @@ -228,114 +244,180 @@ article:last-child { width: 100%; background: #FFC500; padding: 15px; - box-shadow: -4px 4px 1px rgba(153, 118, 0, 0.3); + box-shadow: -3px 3px 1px rgba(34, 6, 12, 0.3); } -/* line 155, ../sass/screen.scss */ +/* line 166, ../sass/screen.scss */ .container .aside-wrap .aside h2 { - color: #fff; + color: #000; } -/* line 159, ../sass/screen.scss */ +/* line 170, ../sass/screen.scss */ .container .aside-wrap .aside .calendar { margin: 0 auto; display: block; } -/* line 164, ../sass/screen.scss */ -.container .aside-wrap .aside a { - color: #997000; +/* line 174, ../sass/screen.scss */ +.container .aside-wrap .aside .calendar:last-child { + margin-bottom: 40px; } -/* line 170, ../sass/screen.scss */ +/* line 179, ../sass/screen.scss */ +.container .aside-wrap .aside a { + color: #000; + text-decoration: none; + background-image: linear-gradient(to top, rgba(255, 255, 255, 0.7) 30%, rgba(255, 255, 255, 0) 30%); +} +/* line 183, ../sass/screen.scss */ +.container .aside-wrap .aside a:hover { + text-decoration: underline; +} +/* line 188, ../sass/screen.scss */ +.container .aside-wrap .aside .aside-content { + max-height: 70vh; + max-height: calc(80vh - 150px); + overflow-y: auto; + overflow-x: hidden; +} +/* line 196, ../sass/screen.scss */ +.container .aside-wrap .aside ul.directory li { + list-style: "*" inside; + padding-left: 10px; + text-indent: -10px; + margin-bottom: 5px; +} +/* line 206, ../sass/screen.scss */ .container .content { max-width: 900px; margin-left: auto; margin-right: 6px; } -/* line 175, ../sass/screen.scss */ +/* line 211, ../sass/screen.scss */ +.container .content h3 { + font-weight: bold; + font-size: 1.2em; +} +/* line 216, ../sass/screen.scss */ +.container .content h4 { + font-weight: bold; + font-style: italic; +} +/* line 221, ../sass/screen.scss */ +.container .content b, .container .content strong { + font-weight: bold; +} +/* line 225, ../sass/screen.scss */ +.container .content i { + font-style: italic; +} +/* line 229, ../sass/screen.scss */ +.container .content .intro, .container .content article.paragraph, .container .content article.entry { + line-height: 1.5; +} +/* line 232, ../sass/screen.scss */ +.container .content .intro a, .container .content article.paragraph a, .container .content article.entry a { + color: #000; + background-image: linear-gradient(to top, rgba(255, 197, 0, 0.8) 30%, rgba(255, 197, 0, 0) 30%); + text-decoration: none; +} +/* line 238, ../sass/screen.scss */ +.container .content .intro ul, .container .content .intro ol, .container .content article.paragraph ul, .container .content article.paragraph ol, .container .content article.entry ul, .container .content article.entry ol { + padding-left: 1em; +} +/* line 244, ../sass/screen.scss */ +.container .content .intro ul li, .container .content article.paragraph ul li, .container .content article.entry ul li { + list-style: disc; +} +/* line 247, ../sass/screen.scss */ +.container .content .intro ol li, .container .content article.paragraph ol li, .container .content article.entry ol li { + list-style: arabic; +} +/* line 252, ../sass/screen.scss */ .container .content .intro { border-bottom: 3px solid #7f7f7f; margin: 20px 0; margin-top: 5px; padding: 15px 5px; } -/* line 184, ../sass/screen.scss */ +/* line 261, ../sass/screen.scss */ .container .content section article { background: #fff; padding: 20px 30px; - box-shadow: -4px 4px 1px rgba(153, 118, 0, 0.3); - border: 1px solid rgba(153, 118, 0, 0.1); + box-shadow: -3px 3px 1px rgba(34, 6, 12, 0.3); + border: 1px solid rgba(34, 6, 12, 0.1); border-radius: 2px; } -/* line 190, ../sass/screen.scss */ -.container .content section article a { - color: #CC9500; -} -/* line 195, ../sass/screen.scss */ +/* line 269, ../sass/screen.scss */ .container .content section article + h2 { margin-top: 15px; } -/* line 199, ../sass/screen.scss */ +/* line 273, ../sass/screen.scss */ .container .content section article + article { margin-top: 25px; } -/* line 203, ../sass/screen.scss */ +/* line 277, ../sass/screen.scss */ .container .content section .image { margin: 15px 0; text-align: center; padding: 20px; } -/* line 208, ../sass/screen.scss */ +/* line 282, ../sass/screen.scss */ .container .content section .image img { max-width: 100%; height: auto; - box-shadow: -7px 7px 1px rgba(153, 118, 0, 0.2); + box-shadow: -7px 7px 1px rgba(34, 6, 12, 0.2); } -/* line 216, ../sass/screen.scss */ +/* line 290, ../sass/screen.scss */ .container .content section.directory article.entry { - width: 80%; + width: 90%; max-width: 600px; max-height: 100%; position: relative; margin-left: 6%; } -/* line 223, ../sass/screen.scss */ +/* line 297, ../sass/screen.scss */ .container .content section.directory article.entry .entry-image { display: block; float: right; width: 150px; background: #fff; - box-shadow: -4px 4px 1px rgba(153, 118, 0, 0.2); - border-right: 1px solid rgba(153, 118, 0, 0.2); - border-top: 1px solid rgba(153, 118, 0, 0.2); + box-shadow: -4px 4px 1px rgba(34, 6, 12, 0.2); + border-right: 1px solid rgba(34, 6, 12, 0.2); + border-top: 1px solid rgba(34, 6, 12, 0.2); padding: 1px; overflow: hidden; margin-left: 10px; margin-bottom: 10px; transform: translateX(10px); + line-height: 0; } -/* line 237, ../sass/screen.scss */ +/* line 312, ../sass/screen.scss */ .container .content section.directory article.entry .entry-image img { width: auto; height: auto; max-width: 100%; max-height: 100%; } -/* line 245, ../sass/screen.scss */ +/* line 320, ../sass/screen.scss */ .container .content section.directory article.entry ul.links { margin-top: 10px; border-top: 1px solid #90001C; padding-top: 10px; + font-weight: bold; } -/* line 253, ../sass/screen.scss */ +/* line 326, ../sass/screen.scss */ +.container .content section.directory article.entry ul.links .label { + font-weight: normal; +} +/* line 333, ../sass/screen.scss */ .container .content section.actuhome { display: flex; flex-wrap: wrap; justify-content: space-around; align-items: top; } -/* line 259, ../sass/screen.scss */ +/* line 339, ../sass/screen.scss */ .container .content section.actuhome article + article { margin: 0; } -/* line 263, ../sass/screen.scss */ +/* line 343, ../sass/screen.scss */ .container .content section.actuhome article.actu { position: relative; background: none; @@ -345,12 +427,12 @@ article:last-child { min-width: 300px; flex: 1; } -/* line 272, ../sass/screen.scss */ +/* line 352, ../sass/screen.scss */ .container .content section.actuhome article.actu .actu-header { position: relative; - box-shadow: -4px 5px 1px rgba(153, 118, 0, 0.3); - border-right: 1px solid rgba(153, 118, 0, 0.2); - border-top: 1px solid rgba(153, 118, 0, 0.2); + box-shadow: -4px 5px 1px rgba(34, 6, 12, 0.3); + border-right: 1px solid rgba(34, 6, 12, 0.2); + border-top: 1px solid rgba(34, 6, 12, 0.2); min-height: 180px; padding: 0; margin: 0; @@ -359,41 +441,41 @@ article:last-child { background-position: center center; background-repeat: no-repeat; } -/* line 285, ../sass/screen.scss */ +/* line 365, ../sass/screen.scss */ .container .content section.actuhome article.actu .actu-header h2 { position: absolute; width: 100%; bottom: 0; left: 0; padding: 5px; - text-shadow: 0 0 5px rgba(153, 118, 0, 0.8); + text-shadow: 0 0 5px rgba(34, 6, 12, 0.8); background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent); } -/* line 293, ../sass/screen.scss */ +/* line 373, ../sass/screen.scss */ .container .content section.actuhome article.actu .actu-header h2 a { color: #fff; } -/* line 299, ../sass/screen.scss */ +/* line 379, ../sass/screen.scss */ .container .content section.actuhome article.actu .actu-misc { background: white; - box-shadow: -2px 2px 1px rgba(153, 118, 0, 0.2); - border: 1px solid rgba(153, 118, 0, 0.2); + box-shadow: -2px 2px 1px rgba(34, 6, 12, 0.2); + border: 1px solid rgba(34, 6, 12, 0.2); border-radius: 2px; margin: 0 10px; padding: 15px; padding-top: 5px; } -/* line 308, ../sass/screen.scss */ +/* line 388, ../sass/screen.scss */ .container .content section.actuhome article.actu .actu-misc .actu-minical { display: block; } -/* line 311, ../sass/screen.scss */ +/* line 391, ../sass/screen.scss */ .container .content section.actuhome article.actu .actu-misc .actu-dates { display: block; text-align: right; font-size: 0.9em; } -/* line 318, ../sass/screen.scss */ +/* line 398, ../sass/screen.scss */ .container .content section.actuhome article.actu .actu-overlay { display: block; background: none; @@ -405,98 +487,100 @@ article:last-child { z-index: 5; opacity: 0; } -/* line 334, ../sass/screen.scss */ +/* line 414, ../sass/screen.scss */ .container .content section.actulist article.actu { display: flex; width: 100%; padding: 0; } -/* line 339, ../sass/screen.scss */ +/* line 419, ../sass/screen.scss */ .container .content section.actulist article.actu .actu-image { width: 30%; max-width: 200px; background-size: cover; background-position: center center; } -/* line 345, ../sass/screen.scss */ +/* line 425, ../sass/screen.scss */ .container .content section.actulist article.actu .actu-infos { padding: 15px; flex: 1; } -/* line 349, ../sass/screen.scss */ +/* line 429, ../sass/screen.scss */ .container .content section.actulist article.actu .actu-infos .actu-dates { font-weight: bold; font-size: 0.9em; } -/* line 359, ../sass/screen.scss */ +/* line 439, ../sass/screen.scss */ .container .aside-wrap + .content { max-width: 70%; } -/* line 364, ../sass/screen.scss */ +/* line 444, ../sass/screen.scss */ .calendar { color: rgba(0, 0, 0, 0.8); width: 200px; } -/* line 368, ../sass/screen.scss */ +/* line 448, ../sass/screen.scss */ .calendar td, .calendar th { text-align: center; vertical-align: middle; border: 2px solid transparent; padding: 1px; } -/* line 375, ../sass/screen.scss */ +/* line 455, ../sass/screen.scss */ .calendar th { font-weight: bold; } -/* line 379, ../sass/screen.scss */ +/* line 459, ../sass/screen.scss */ .calendar td { font-size: 0.8em; width: 28px; height: 28px; } -/* line 384, ../sass/screen.scss */ +/* line 464, ../sass/screen.scss */ .calendar td.out { opacity: 0.3; } -/* line 387, ../sass/screen.scss */ +/* line 467, ../sass/screen.scss */ .calendar td.today { border-bottom-color: #000; } -/* line 390, ../sass/screen.scss */ +/* line 470, ../sass/screen.scss */ .calendar td:nth-child(7), .calendar td:nth-child(6) { background: rgba(0, 0, 0, 0.2); } -/* line 393, ../sass/screen.scss */ +/* line 473, ../sass/screen.scss */ .calendar td.hasevent { position: relative; font-weight: bold; color: #90001C; font-size: 1em; } -/* line 399, ../sass/screen.scss */ +/* line 479, ../sass/screen.scss */ .calendar td.hasevent > a { padding: 3px; color: #90001C !important; + background: none !important; } -/* line 404, ../sass/screen.scss */ +/* line 485, ../sass/screen.scss */ .calendar td.hasevent ul.cal-events { + font-size: 0.9em; text-align: left; display: none; position: absolute; z-index: 2; background: #fff; - width: 150px; + width: 100px; left: -30px; margin-top: 10px; padding: 5px; background-color: #90001C; } -/* line 417, ../sass/screen.scss */ +/* line 498, ../sass/screen.scss */ .calendar td.hasevent ul.cal-events .datename { display: none; } -/* line 420, ../sass/screen.scss */ +/* line 501, ../sass/screen.scss */ .calendar td.hasevent ul.cal-events:before { top: -12px; left: 38px; @@ -505,33 +589,57 @@ article:last-child { border: 6px solid transparent; border-bottom-color: #90001C; } -/* line 428, ../sass/screen.scss */ +/* line 509, ../sass/screen.scss */ .calendar td.hasevent ul.cal-events a { color: #fff; + background: none !important; } -/* line 433, ../sass/screen.scss */ +/* line 515, ../sass/screen.scss */ .calendar td.hasevent > a:hover { background-color: #90001C; color: #fff !important; } -/* line 437, ../sass/screen.scss */ +/* line 519, ../sass/screen.scss */ .calendar td.hasevent > a:hover + ul.cal-events { display: block; } +/* line 527, ../sass/screen.scss */ +.calendar tr.head th { + position: relative; +} +/* line 529, ../sass/screen.scss */ +.calendar tr.head th a { + position: absolute; + display: block; + width: 150%; + padding: 5px; + top: -5px; + background: none !important; +} +/* line 537, ../sass/screen.scss */ +.calendar tr.head th:first-child a { + left: 0; + text-align: left; +} +/* line 541, ../sass/screen.scss */ +.calendar tr.head th:last-child a { + text-align: right; + right: 0; +} -/* line 445, ../sass/screen.scss */ +/* line 549, ../sass/screen.scss */ #calendar-wrap .details { border-top: 1px solid #90001C; margin-top: 15px; padding-top: 10px; } -/* line 450, ../sass/screen.scss */ +/* line 554, ../sass/screen.scss */ #calendar-wrap .details li.datename { font-weight: bold; font-size: 1.1em; margin-bottom: 5px; } -/* line 451, ../sass/screen.scss */ +/* line 555, ../sass/screen.scss */ #calendar-wrap .details li.datename:after { content: " :"; } @@ -666,4 +774,8 @@ header .minimenu { display: block; padding: 15px; } + /* line 122, ../sass/_responsive.scss */ + .container .aside-wrap .aside .aside-content { + max-height: calc(100vh - 110px); + } } diff --git a/gestioncof/cms/static/cofcms/js/script.js b/gestioncof/cms/static/cofcms/js/script.js index c7693a6d..0c520e9f 100644 --- a/gestioncof/cms/static/cofcms/js/script.js +++ b/gestioncof/cms/static/cofcms/js/script.js @@ -2,10 +2,10 @@ $(function() { $(".facteur").on("click", function(){ var $this = $(this); var sticker = $this.attr('data-mref') - .replace('pont', '.') - .replace('arbre', '@') + .replace(/pont/g, '.') + .replace(/arbre/g, '@') .replace(/(.)-/g, '$1'); - + var boite = $("", {href:"ma"+"il"+"to:"+sticker}).text(sticker); $(this).before(boite) .remove(); diff --git a/gestioncof/cms/static/cofcms/sass/_colors.scss b/gestioncof/cms/static/cofcms/sass/_colors.scss index 2d295b98..e07429f8 100644 --- a/gestioncof/cms/static/cofcms/sass/_colors.scss +++ b/gestioncof/cms/static/cofcms/sass/_colors.scss @@ -5,7 +5,7 @@ $aside: #FFC500; $titre: $sousbandeau; $lien: #CC9500; $headerlien: $fond; -$ombres: darken($aside, 20%); +$ombres: darken(desaturate($bandeau, 30%), 10%); $bodyfont: "Source Sans Pro", "sans-serif"; $headfont: "Carter One", "serif"; diff --git a/gestioncof/cms/static/cofcms/sass/_responsive.scss b/gestioncof/cms/static/cofcms/sass/_responsive.scss index 28216a98..7cf4cb29 100644 --- a/gestioncof/cms/static/cofcms/sass/_responsive.scss +++ b/gestioncof/cms/static/cofcms/sass/_responsive.scss @@ -118,6 +118,10 @@ header .minimenu { } } } + + .aside-content { + max-height: calc(100vh - 110px); + } } } } diff --git a/gestioncof/cms/static/cofcms/sass/screen.scss b/gestioncof/cms/static/cofcms/sass/screen.scss index 43ad8216..2d7c1ad4 100644 --- a/gestioncof/cms/static/cofcms/sass/screen.scss +++ b/gestioncof/cms/static/cofcms/sass/screen.scss @@ -3,8 +3,6 @@ * Import this file using the following HTML or equivalent: * */ -@import url('https://fonts.googleapis.com/css?family=Carter+One|Source+Sans+Pro:300,300i,700'); - @import "compass/reset"; @import "_colors"; @@ -16,6 +14,7 @@ body { background: $fond; font: 17px $bodyfont; + color: #000; } header { @@ -24,21 +23,28 @@ header { h1, h2 { font-family: $headfont; - color: $titre; } h1 { font-size: 2.3em; + color: $titre; } h2 { font-size: 1.6em; + color: desaturate(lighten($titre, 10%), 20%); } + a { color: $lien; text-decoration: none; font-weight: bold; + padding: 0 2px; + margin: 0 -2px; + &:hover { + text-decoration: underline; + } } h2 a { @@ -49,6 +55,9 @@ h2 a { header { a { color: $headerlien; + &:hover { + text-decoration: none; + } } section { display: flex; @@ -66,8 +75,10 @@ header { padding: 0 15px; } nav { + display: inline-flex; ul { display: inline-flex; + flex-wrap: wrap; li { display: inline-block; & > * { @@ -150,19 +161,44 @@ article { width: 100%; background: $aside; padding: 15px; - box-shadow: -4px 4px 1px rgba($ombres, 0.3); + box-shadow: -3px 3px 1px rgba($ombres, 0.3); h2 { - color: #fff; + color: #000; } .calendar { margin: 0 auto; display: block; + + &:last-child { + margin-bottom: 40px; + } } a { - color: darken($lien, 10%); + color: #000; + text-decoration: none; + background-image: linear-gradient(to top, rgba(#fff, 0.7) 30%, rgba(#fff, 0) 30%); + &:hover { + text-decoration: underline; + } + } + + .aside-content { + max-height: 70vh; + max-height: calc(80vh - 150px); + overflow-y: auto; + overflow-x: hidden; + } + + ul.directory { + li { + list-style: "*" inside; + padding-left: 10px; + text-indent: -10px; + margin-bottom: 5px; + } } } } @@ -172,6 +208,47 @@ article { margin-left: auto; margin-right: 6px; + h3 { + font-weight: bold; + font-size: 1.2em; + } + + h4 { + font-weight: bold; + font-style: italic; + } + + b, strong { + font-weight: bold; + } + + i { + font-style: italic; + } + + .intro, article.paragraph, article.entry { + line-height: 1.5; + + a { + color: #000; + background-image: linear-gradient(to top, rgba($aside, 0.8) 30%, rgba($aside, 0) 30%); + text-decoration: none; + } + + ul, ol { + padding-left: 1em; + li { + + } + } + ul li { + list-style: disc; + } + ol li { + list-style: arabic; + } + } + .intro { border-bottom: 3px solid darken($fond, 50%); margin: 20px 0; @@ -184,12 +261,9 @@ article { article { background: #fff; padding: 20px 30px;; - box-shadow: -4px 4px 1px rgba($ombres, 0.3); + box-shadow: -3px 3px 1px rgba($ombres, 0.3); border: 1px solid rgba($ombres, 0.1); border-radius: 2px; - a { - color: $lien; - } } article + h2 { @@ -214,7 +288,7 @@ article { &.directory { article.entry { - width: 80%; + width: 90%; max-width: 600px; max-height: 100%; position: relative; @@ -233,6 +307,7 @@ article { margin-left: 10px; margin-bottom: 10px; transform: translateX(10px); + line-height: 0; img { width: auto; @@ -246,6 +321,11 @@ article { margin-top: 10px; border-top: 1px solid $sousbandeau; padding-top: 10px; + font-weight: bold; + + .label { + font-weight: normal; + } } } } @@ -399,16 +479,17 @@ article { & > a { padding: 3px; color: $sousbandeau !important; + background: none !important; } ul.cal-events { - + font-size: 0.9em; text-align: left; display: none; position: absolute; z-index: 2; background: #fff; - width: 150px; + width: 100px; left: -30px; margin-top: 10px; padding: 5px; @@ -427,6 +508,7 @@ article { } a { color: #fff; + background: none !important; } } @@ -440,6 +522,28 @@ article { } } } + + tr.head { + th { + position: relative; + a { + position: absolute; + display: block; + width: 150%; + padding: 5px; + top: -5px; + background: none !important; + } + &:first-child a { + left: 0; + text-align: left; + } + &:last-child a { + text-align: right; + right: 0; + } + } + } } #calendar-wrap .details { diff --git a/gestioncof/cms/templates/cofcms/base.html b/gestioncof/cms/templates/cofcms/base.html index c11a2761..c420115f 100644 --- a/gestioncof/cms/templates/cofcms/base.html +++ b/gestioncof/cms/templates/cofcms/base.html @@ -4,9 +4,20 @@ - {% block title %}Association des élèves de l'ENS Ulm{% endblock %} + {% block title %} + {% if page.seo_title %} + {{ page.seo_title }} | + {% elif page.title %} + {{ page.title }} | + {% endif %} + {% trans "Association des élèves de l'ENS Ulm" %} + {% endblock %} + + {% block extra_head %}{% endblock %} + + @@ -21,27 +32,27 @@
    {% block superaside %}{% endblock %} - +
    {% block content %}{% endblock %}
    diff --git a/gestioncof/cms/templates/cofcms/base_nav.html b/gestioncof/cms/templates/cofcms/base_nav.html index b7ce4c66..f8b3fa65 100644 --- a/gestioncof/cms/templates/cofcms/base_nav.html +++ b/gestioncof/cms/templates/cofcms/base_nav.html @@ -4,7 +4,11 @@
  • {% if item.link_page %} - {{ item.link_page.title }} + {% if item.link_page.seo_title %} + {{ item.link_page.seo_title }} + {% else %} + {{ item.link_page.title }} + {% endif %} {% else %} diff --git a/gestioncof/cms/templates/cofcms/calendar.html b/gestioncof/cms/templates/cofcms/calendar.html index a6a9d87e..c06a23c7 100644 --- a/gestioncof/cms/templates/cofcms/calendar.html +++ b/gestioncof/cms/templates/cofcms/calendar.html @@ -1,7 +1,7 @@ {% load wagtailcore_tags wagtailroutablepage_tags static i18n %} - + diff --git a/gestioncof/cms/templates/cofcms/cof_actu_index_page.html b/gestioncof/cms/templates/cofcms/cof_actu_index_page.html index a6a909db..a1ea29ed 100644 --- a/gestioncof/cms/templates/cofcms/cof_actu_index_page.html +++ b/gestioncof/cms/templates/cofcms/cof_actu_index_page.html @@ -3,11 +3,11 @@ {% block extra_head %} {{ block.super }} - + {% endblock %} -{% block aside_title %}Calendrier{% endblock %} +{% block aside_title %}{% trans "Calendrier" %}{% endblock %} {% block aside %}
    {% calendar %} @@ -17,17 +17,17 @@ {% block content %}

    {{ page.title }}

    -
    {{ page.introduction|safe }}
    +
    {{ page.introduction|richtext }}
    {% if actus.has_previous %} - Actualités plus récentes - {% endif %} + {% trans "Actualités plus récentes" %} + {% endif %} {% if actus.has_next %} - Actualités plus anciennes - {% endif %} - + {% trans "Actualités plus anciennes" %} + {% endif %} + {% for actu in page.actus %}
    @@ -36,18 +36,18 @@ {% if actu.is_event %}

    {{ actu|dates|capfirst }}
    {{ actu.chapo }}

    {% else %} - {{ actu.body|safe|truncatewords_html:15 }} + {{ actu.body|richtext|truncatewords_html:15 }} {% endif %} - Lire plus > + {% trans "Lire plus" %} >
    {% endfor %} {% if actus.has_previous %} - Actualités plus récentes - {% endif %} + {% trans "Actualités plus récentes" %} + {% endif %} {% if actus.has_next %} - Actualités plus anciennes - {% endif %} + {% trans "Actualités plus anciennes" %} + {% endif %} {% endblock %} diff --git a/gestioncof/cms/templates/cofcms/cof_actu_page.html b/gestioncof/cms/templates/cofcms/cof_actu_page.html index b531aedc..5cd88134 100644 --- a/gestioncof/cms/templates/cofcms/cof_actu_page.html +++ b/gestioncof/cms/templates/cofcms/cof_actu_page.html @@ -1,17 +1,17 @@ {% extends "cofcms/base.html" %} -{% load wagtailimages_tags cofcms_tags i18n %} +{% load wagtailcore_tags wagtailimages_tags cofcms_tags i18n %} {% block content %}

    {{ page.title }}

    -

    A lieu {{ page|dates }}

    +

    {% trans "A lieu" %} {{ page|dates }}

    {{ page.chapo }}

    {% image page.image width-700 %}
    - {{ page.body|safe }} + {{ page.body|richtext }}
    {% endblock %} diff --git a/gestioncof/cms/templates/cofcms/cof_directory_entry_page.html b/gestioncof/cms/templates/cofcms/cof_directory_entry_page.html new file mode 100644 index 00000000..21090f25 --- /dev/null +++ b/gestioncof/cms/templates/cofcms/cof_directory_entry_page.html @@ -0,0 +1,11 @@ +{% extends "cofcms/base.html" %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +

    + Comment t'es arrivé⋅e ici toi ? +

    +{% endblock %} diff --git a/gestioncof/cms/templates/cofcms/cof_directory_page.html b/gestioncof/cms/templates/cofcms/cof_directory_page.html index 6ac5491b..da0fa3ce 100644 --- a/gestioncof/cms/templates/cofcms/cof_directory_page.html +++ b/gestioncof/cms/templates/cofcms/cof_directory_page.html @@ -1,14 +1,14 @@ {% extends "cofcms/base_aside.html" %} -{% load wagtailimages_tags cofcms_tags static %} +{% load wagtailcore_tags wagtailimages_tags cofcms_tags static i18n %} {% block extra_head %} {{ block.super }} - + {% endblock %} -{% block aside_title %}Accès rapide{% endblock %} +{% block aside_title %}{% trans "Accès rapide" %}{% endblock %} {% block aside %} -
      +
        {% for entry in page.entries %}
      • {{ entry.title }}
      • {% endfor %} @@ -18,7 +18,7 @@ {% block content %}

        {{ page.title }}

        -
        {{ page.introduction|safe }}
        +
        {{ page.introduction|richtext }}
        @@ -28,15 +28,17 @@
        {% image entry.image width-150 class="entry-img" %}
        {% endif %}

        {{ entry.title }}

        -
        {{ entry.body|safe }}
        +
        {{ entry.body|richtext }}
        {% if entry.links %}
    < {{ this_month|date:"F Y" }} >
    + {{ user_form | bootstrap }} + {{ profile_form | bootstrap }} +
    +
    + {% if login_clipper or member %} + + {% endif %} + + diff --git a/gestioncof/templates/gestioncof/registration_kf_post.html b/gestioncof/templates/gestioncof/registration_kf_post.html new file mode 100644 index 00000000..b5690d70 --- /dev/null +++ b/gestioncof/templates/gestioncof/registration_kf_post.html @@ -0,0 +1,8 @@ +{% extends "base_title.html" %} + +{% block realcontent %} +

    Inscription d'un nouveau membre

    +
    + {% include "gestioncof/registration_kf_form.html" %} +
    +{% endblock %} diff --git a/gestioncof/templates/gestioncof/reset_comptes.html b/gestioncof/templates/gestioncof/reset_comptes.html new file mode 100644 index 00000000..55d54376 --- /dev/null +++ b/gestioncof/templates/gestioncof/reset_comptes.html @@ -0,0 +1,14 @@ +{% extends "base_title.html" %} + +{% block realcontent %} +

    Remise à zéro des membres COF

    + {% if is_done%} +

    {{nb_adherents}} compte{{ nb_adherents|pluralize }} désinscrit{{ nb_adherents|pluralize }} du COF.

    + {% else%} +
    ATTENTION : Cette action est irréversible.
    +

    Voulez-vous vraiment remettre à zéro le statut COF de tous les membres actuels ?

    +
    + {% csrf_token %} +
    + {% endif %} +{% endblock %} \ No newline at end of file diff --git a/gestioncof/templates/gestioncof/search_results.html b/gestioncof/templates/gestioncof/search_results.html new file mode 100644 index 00000000..fdcd36ce --- /dev/null +++ b/gestioncof/templates/gestioncof/search_results.html @@ -0,0 +1,21 @@ +{% extends "shared/search_results.html" %} +{% load i18n %} + +{% block extra_section %} +
  • + {% if not results %} + + {% trans "Aucune correspondance trouvée" %} + + {% else %} + + {% trans "Pas dans la liste ?" %} + + {% endif %} +
  • +
  • + + {% trans "Créer un compte" %} + +
  • +{% endblock %} diff --git a/gestioncof/templates/gestioncof/self_registration.html b/gestioncof/templates/gestioncof/self_registration.html new file mode 100644 index 00000000..c60dce4f --- /dev/null +++ b/gestioncof/templates/gestioncof/self_registration.html @@ -0,0 +1,28 @@ +{% extends "base_title.html" %} +{% load static %} + +{% block page_size %}col-sm-8{% endblock %} + +{% load bootstrap %} + +{% block realcontent %} + + {% if member %} +

    Inscription K-Fêt du compte GestioCOF existant {{ member.username }}

    + {% else %} +

    Inscription K-Fêt d'un nouveau compte (extérieur ?)

    + {% endif %} +
    + {% csrf_token %} + + {{ user_form | bootstrap }} + {{ profile_form | bootstrap }} + {{ agreement_form | bootstrap }} +
    +
    + {% if login_clipper or member %} + + {% endif %} + +
    +{% endblock %} diff --git a/gestioncof/templates/gestioncof/utile_cof.html b/gestioncof/templates/gestioncof/utile_cof.html index 637055c5..71a3b865 100644 --- a/gestioncof/templates/gestioncof/utile_cof.html +++ b/gestioncof/templates/gestioncof/utile_cof.html @@ -4,21 +4,21 @@ {% endblock %} {% block realcontent %} -

    Liens utiles du COF

    -

    COF

    - +

    Liens utiles du COF

    +

    COF

    + -

    Mega

    - +

    Mega

    + -

    Note : pour ouvrir les fichiers .csv avec Excel, il faut - passer par Fichier > Importer et sélectionner la - virgule comme séparateur.

    +

    Note : pour ouvrir les fichiers .csv avec Excel, il faut + passer par Fichier > Importer et sélectionner la + virgule comme séparateur.

    {% endblock %} diff --git a/gestioncof/templates/kfet-denied.html b/gestioncof/templates/kfet-denied.html new file mode 100644 index 00000000..6d18dbb5 --- /dev/null +++ b/gestioncof/templates/kfet-denied.html @@ -0,0 +1,5 @@ +{% extends "base_title.html" %} + +{% block realcontent %} +

    Section réservée aux membres K-Fêt -- merci de vous inscrire au COF ou de passer au COF/nous envoyer un mail si vous êtes déjà membre :)

    +{% endblock %} diff --git a/gestioncof/templates/registration.html b/gestioncof/templates/registration.html index 2ef997e1..9807afde 100644 --- a/gestioncof/templates/registration.html +++ b/gestioncof/templates/registration.html @@ -1,5 +1,5 @@ {% extends "base_title.html" %} -{% load staticfiles %} +{% load static %} {% block page_size %}col-sm-8{% endblock %} diff --git a/gestioncof/templates/tristate_js.html b/gestioncof/templates/tristate_js.html index af906ebe..6b5312a8 100644 --- a/gestioncof/templates/tristate_js.html +++ b/gestioncof/templates/tristate_js.html @@ -1,4 +1,4 @@ -{% load staticfiles %} +{% load static %} + +{% endblock %} + +{% block title %}Indicateur{% endblock %} + +{% block header %}{% endblock %} +{% block help %}{% endblock %} + +{% block content %} + +
    +

    Non défini

    +

    Fermé

    +

    Ouvert

    +
    + +{% endblock %} diff --git a/kfet/open/templates/kfetopen/init.html b/kfet/open/templates/kfetopen/init.html index 3834b32a..a8161d87 100644 --- a/kfet/open/templates/kfetopen/init.html +++ b/kfet/open/templates/kfetopen/init.html @@ -4,10 +4,8 @@ diff --git a/kfet/open/tests.py b/kfet/open/tests.py index 4e652cb6..3eabcc02 100644 --- a/kfet/open/tests.py +++ b/kfet/open/tests.py @@ -1,24 +1,29 @@ -import json import random from datetime import timedelta from unittest import mock -from channels.channel import Group -from channels.test import ChannelTestCase, WSClient +from asgiref.sync import async_to_sync +from channels.auth import AuthMiddlewareStack +from channels.consumer import get_channel_layer +from channels.testing import WebsocketCommunicator from django.contrib.auth.models import AnonymousUser, Permission, User -from django.test import Client +from django.test import Client, TestCase from django.utils import timezone from . import OpenKfet from .consumers import OpenKfetConsumer -class OpenKfetTest(ChannelTestCase): +def ws_communicator(cls, path: str, headers=[]): + return WebsocketCommunicator(AuthMiddlewareStack(cls.as_asgi()), path, headers) + + +class OpenKfetTest(TestCase): """OpenKfet object unit-tests suite.""" def setUp(self): self.kfet_open = OpenKfet( - cache_prefix="test_kfetopen_%s" % random.randrange(2 ** 20) + cache_prefix="test_kfetopen_%s" % random.randrange(2**20) ) self.addCleanup(self.kfet_open.clear_cache) @@ -79,31 +84,42 @@ class OpenKfetTest(ChannelTestCase): def test_export_user(self): """Export is limited for an anonymous user.""" export = self.kfet_open.export(AnonymousUser()) - self.assertSetEqual(set(["status"]), set(export)) + self.assertSetEqual(set(["status", "type"]), 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")) + is_team = Permission.objects.get( + codename="is_team", content_type__app_label="kfet" + ) + user.user_permissions.add(is_team) export = self.kfet_open.export(user) - self.assertSetEqual(set(["status", "admin_status", "force_close"]), set(export)) + self.assertSetEqual( + set(["status", "admin_status", "force_close", "type"]), set(export) + ) - def test_send_ws(self): - Group("kfet.open.base").add("test.open.base") - Group("kfet.open.team").add("test.open.team") + async def test_send_ws(self): + channel_layer = get_channel_layer() + base_channel = await channel_layer.new_channel() + team_channel = await channel_layer.new_channel() - self.kfet_open.send_ws() + await channel_layer.group_add("kfet.open.base", base_channel) + await channel_layer.group_add("kfet.open.team", team_channel) - recv_base = self.get_next_message("test.open.base", require=True) - base = json.loads(recv_base["text"]) - self.assertSetEqual(set(["status"]), set(base)) + await self.kfet_open.send_ws() - 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)) + base = await channel_layer.receive(base_channel) + + self.assertSetEqual(set(["status", "type"]), set(base)) + + team = await channel_layer.receive(team_channel) + + self.assertSetEqual( + set(["status", "admin_status", "force_close", "type"]), set(team) + ) -class OpenKfetViewsTest(ChannelTestCase): +class OpenKfetViewsTest(TestCase): """OpenKfet views unit-tests suite.""" def setUp(self): @@ -114,8 +130,12 @@ class OpenKfetViewsTest(ChannelTestCase): # get some permissions perms = { - "kfet.is_team": Permission.objects.get(codename="is_team"), - "kfet.can_force_close": Permission.objects.get(codename="can_force_close"), + "kfet.is_team": Permission.objects.get( + codename="is_team", content_type__app_label="kfet" + ), + "kfet.can_force_close": Permission.objects.get( + codename="can_force_close", content_type__app_label="kfet" + ), } # authenticated user and its client @@ -138,7 +158,7 @@ class OpenKfetViewsTest(ChannelTestCase): self.c_a.login(username="admin", password="admin") self.kfet_open = OpenKfet( - cache_prefix="test_kfetopen_%s" % random.randrange(2 ** 20) + cache_prefix="test_kfetopen_%s" % random.randrange(2**20) ) self.addCleanup(self.kfet_open.clear_cache) @@ -170,114 +190,136 @@ class OpenKfetViewsTest(ChannelTestCase): self.assertEqual(403, resp.status_code) -class OpenKfetConsumerTest(ChannelTestCase): +class OpenKfetConsumerTest(TestCase): """OpenKfet consumer unit-tests suite.""" - def test_standard_user(self): + @classmethod + def setUpTestData(cls): + t = User.objects.create_user("team", "", "team") + is_team = Permission.objects.get( + codename="is_team", content_type__app_label="kfet" + ) + t.user_permissions.add(is_team) + + cls.team_user = t + + async def test_standard_user(self): """Lambda user is added to kfet.open.base group.""" # setup anonymous client - c = WSClient() + c = ws_communicator(OpenKfetConsumer, "/ws/k-fet/open") - # connect - c.send_and_consume( - "websocket.connect", path="/ws/k-fet/open", fail_on_none=True - ) + connected, _ = await c.connect() + + self.assertTrue(connected) # initialization data is replied on connection - self.assertIsNotNone(c.receive()) + message = await c.receive_json_from() + self.assertIsNotNone(message) # client belongs to the 'kfet.open' group... - OpenKfetConsumer.group_send("kfet.open.base", {"test": "plop"}) - self.assertEqual(c.receive(), {"test": "plop"}) + channel_layer = get_channel_layer() + + await channel_layer.group_send( + "kfet.open.base", {"test": "plop", "type": "open.status"} + ) + message = await c.receive_json_from() + + self.assertEqual(message, {"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 + await channel_layer.group_send( + "kfet.open.team", {"test": "plop", "type": "open.status"} ) + self.assertTrue(await c.receive_nothing()) - # initialization data is replied on connection - self.assertIsNotNone(c.receive()) + async def test_team_user(self): + """Team user is added to kfet.open.team group.""" - # client belongs to the 'kfet.open.admin' group... - OpenKfetConsumer.group_send("kfet.open.team", {"test": "plop"}) - self.assertEqual(c.receive(), {"test": "plop"}) + # On simule l'appartenance de l'user à la team kfet car l'utilisation de + # tests async avec postgres fait tout planter si on modifie la db dans un + # des sous tests. + with mock.patch("gestioncof.signals.messages"), mock.patch( + "kfet.open.consumers.kfet_is_team", return_value=True + ): + c = ws_communicator(OpenKfetConsumer, "/ws/k-fet/open") - # ... but not to the 'kfet.open' one - OpenKfetConsumer.group_send("kfet.open.base", {"test": "plop"}) - self.assertIsNone(c.receive()) + connected, _ = await c.connect() + + channel_layer = get_channel_layer() + + self.assertTrue(connected) + + # initialization data is replied on connection + message = await c.receive_json_from() + self.assertIsNotNone(message) + + # client belongs to the 'kfet.open.team' group... + await channel_layer.group_send( + "kfet.open.team", {"test": "plop", "type": "open.status"} + ) + message = await c.receive_json_from() + + self.assertEqual(message, {"test": "plop"}) + + # ...but not to the 'kfet.open' one + await channel_layer.group_send( + "kfet.open.base", {"test": "plop", "type": "open.status"} + ) + self.assertTrue(await c.receive_nothing()) -class OpenKfetScenarioTest(ChannelTestCase): +class OpenKfetScenarioTest(TestCase): """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) + @classmethod + def setUpTestData(cls): + # root user + cls.r = User.objects.create_superuser("team", "", "team") # anonymous client (for views) - self.c = Client() - # anonymous client (for websockets) - self.c_ws = WSClient() + cls.c = Client() - # 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) + # root client + cls.r_c = Client() + + with mock.patch("gestioncof.signals.messages"): + cls.r_c.login(username="team", password="team") + + def setUp(self): + # Create channels to listen to messages + channel_layer = get_channel_layer() + + self.channel = async_to_sync(channel_layer.new_channel)() + self.team_channel = async_to_sync(channel_layer.new_channel)() + + async_to_sync(channel_layer.group_add)("kfet.open.base", self.channel) + async_to_sync(channel_layer.group_add)("kfet.open.team", self.team_channel) + + self.receive_msg = lambda c: async_to_sync(channel_layer.receive)(c) self.kfet_open = OpenKfet( - cache_prefix="test_kfetopen_%s" % random.randrange(2 ** 20) + cache_prefix="test_kfetopen_%s" % random.randrange(2**20) ) self.addCleanup(self.kfet_open.clear_cache) - def ws_connect(self, ws_client): - ws_client.send_and_consume( - "websocket.connect", path="/ws/k-fet/open", fail_on_none=True - ) - return ws_client.receive(json=True) + async def ws_connect(self, ws_communicator): + c, _ = await ws_communicator.connect() - 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)) + self.assertTrue(c) + return await ws_communicator.receive_json_from() 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) + msg = self.receive_msg(self.channel) self.assertEqual(OpenKfet.OPENED, msg["status"]) # root user too - msg = self.r_c_ws.receive(json=True) + msg = self.receive_msg(self.team_channel) self.assertEqual(OpenKfet.OPENED, msg["status"]) self.assertEqual(OpenKfet.OPENED, msg["admin_status"]) @@ -285,11 +327,11 @@ class OpenKfetScenarioTest(ChannelTestCase): 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) + msg = self.receive_msg(self.channel) self.assertEqual(OpenKfet.CLOSED, msg["status"]) # root user too - msg = self.r_c_ws.receive(json=True) + msg = self.receive_msg(self.team_channel) self.assertEqual(OpenKfet.CLOSED, msg["status"]) # but root knows things self.assertEqual(OpenKfet.FAKE_CLOSED, msg["admin_status"]) @@ -300,20 +342,42 @@ class OpenKfetScenarioTest(ChannelTestCase): self.kfet_open.raw_open = True self.kfet_open.force_close = True - msg = self.ws_connect(self.c_ws) + async_to_sync(OpenKfet().send_ws)() + + msg = self.receive_msg(self.channel) self.assertEqual(OpenKfet.CLOSED, msg["status"]) - msg = self.ws_connect(self.r_c_ws) + msg = self.receive_msg(self.team_channel) 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) + msg = self.receive_msg(self.channel) self.assertEqual(OpenKfet.OPENED, msg["status"]) - msg = self.r_c_ws.receive(json=True) + msg = self.receive_msg(self.team_channel) self.assertEqual(OpenKfet.OPENED, msg["status"]) self.assertEqual(OpenKfet.OPENED, msg["admin_status"]) self.assertFalse(msg["force_close"]) + + async def test_scenario_3(self): + """Clients connect.""" + # anonymous client (for websockets) + self.c_ws = ws_communicator(OpenKfetConsumer, "/ws/k-fet/open") + + # test for anonymous user + msg = await self.ws_connect(self.c_ws) + self.assertSetEqual(set(["status"]), set(msg)) + + # test for root user + with mock.patch( + "kfet.open.consumers.kfet_is_team", return_value=True + ), mock.patch("kfet.open.open.kfet_is_team", return_value=True): + self.r_c_ws = ws_communicator(OpenKfetConsumer, "/ws/k-fet/open") + + msg = await self.ws_connect(self.r_c_ws) + self.assertSetEqual( + set(["status", "admin_status", "force_close"]), set(msg) + ) diff --git a/kfet/open/urls.py b/kfet/open/urls.py index 8510adc4..28db8618 100644 --- a/kfet/open/urls.py +++ b/kfet/open/urls.py @@ -5,4 +5,5 @@ from . import views urlpatterns = [ path("raw_open", views.raw_open, name="kfet.open.edit_raw_open"), path("force_close", views.force_close, name="kfet.open.edit_force_close"), + path("", views.indicator, name="kfet.open.indicator"), ] diff --git a/kfet/open/views.py b/kfet/open/views.py index 49b91f4a..1a044c68 100644 --- a/kfet/open/views.py +++ b/kfet/open/views.py @@ -1,7 +1,9 @@ +from asgiref.sync import async_to_sync from django.conf import settings from django.contrib.auth.decorators import permission_required from django.core.exceptions import PermissionDenied from django.http import HttpResponse +from django.shortcuts import render from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST @@ -18,7 +20,7 @@ def raw_open(request): raise PermissionDenied raw_open = request.POST.get("raw_open") in TRUE_STR kfet_open.raw_open = raw_open - kfet_open.send_ws() + async_to_sync(kfet_open.send_ws)() return HttpResponse() @@ -27,5 +29,12 @@ def raw_open(request): def force_close(request): force_close = request.POST.get("force_close") in TRUE_STR kfet_open.force_close = force_close - kfet_open.send_ws() + async_to_sync(kfet_open.send_ws)() return HttpResponse() + + +def indicator(request): + return render( + request, + "kfetopen/indicator.html", + ) diff --git a/kfet/routing.py b/kfet/routing.py index ceafca06..a015eebc 100644 --- a/kfet/routing.py +++ b/kfet/routing.py @@ -1,8 +1,13 @@ -from channels.routing import include, route_class +from channels.routing import URLRouter +from django.urls import path -from . import consumers +from kfet.open.routing import OpenRouter -routing = [ - route_class(consumers.KPsul, path=r"^/k-psul/$"), - include("kfet.open.routing.routing", path=r"^/open"), -] +from .consumers import KPsul + +KFRouter = URLRouter( + [ + path("k-psul/", KPsul.as_asgi()), + path("open", OpenRouter), + ] +) diff --git a/kfet/static/kfet/css/base/main.css b/kfet/static/kfet/css/base/main.css index 2ebc90d8..f98ae0f4 100644 --- a/kfet/static/kfet/css/base/main.css +++ b/kfet/static/kfet/css/base/main.css @@ -1,3 +1,40 @@ +/* Navbar hacks -------------------- */ + +.visible-xl, +.visible-xl-block, +.visible-xl-inline, +.visible-xl-inline-block { + display: none !important; +} + +@media (min-width: 1415px) { + .visible-xl { + display: block !important; + } + table.visible-xl { + display: table !important; + } + tr.visible-xl { + display: table-row !important; + } + th.visible-xl, + td.visible-xl { + display: table-cell !important; + } + + .visible-xl-block { + display: block !important; + } + + .visible-xl-inline { + display: inline !important; + } + + .visible-xl-inline-block { + display: inline-block !important; + } +} + /* Global layout ------------------- */ .main-col, .fixed-col { diff --git a/kfet/static/kfet/css/base/misc.css b/kfet/static/kfet/css/base/misc.css index fab1a33a..97c0ee7e 100644 --- a/kfet/static/kfet/css/base/misc.css +++ b/kfet/static/kfet/css/base/misc.css @@ -61,7 +61,7 @@ ul { } .table td.no-padding { - padding:0; + padding:0 !important; } .table thead { diff --git a/kfet/static/kfet/css/history.css b/kfet/static/kfet/css/history.css index 9cd4cd28..437fcd71 100644 --- a/kfet/static/kfet/css/history.css +++ b/kfet/static/kfet/css/history.css @@ -20,7 +20,7 @@ z-index:10; } -#history .opegroup { +#history .group { height:30px; line-height:30px; background-color: #c63b52; @@ -30,29 +30,29 @@ overflow:auto; } -#history .opegroup .time { +#history .group .time { width:70px; } -#history .opegroup .trigramme { +#history .group .trigramme { width:55px; text-align:right; } -#history .opegroup .amount { +#history .group .amount { text-align:right; width:90px; } -#history .opegroup .valid_by { +#history .group .valid_by { padding-left:20px } -#history .opegroup .comment { +#history .group .comment { padding-left:20px; } -#history .ope { +#history .entry { position:relative; height:25px; line-height:24px; @@ -61,38 +61,38 @@ overflow:auto; } -#history .ope .amount { +#history .entry .amount { width:50px; text-align:right; } -#history .ope .infos1 { +#history .entry .infos1 { width:80px; text-align:right; } -#history .ope .infos2 { +#history .entry .infos2 { padding-left:15px; } -#history .ope .addcost { +#history .entry .addcost { padding-left:20px; } -#history .ope .canceled { +#history .entry .canceled { padding-left:20px; } -#history div.ope.ui-selected, #history div.ope.ui-selecting { +#history div.entry.ui-selected, #history div.entry.ui-selecting { background-color:rgba(200,16,46,0.6); color:#FFF; } -#history .ope.canceled, #history .transfer.canceled { +#history .entry.canceled { color:#444; } -#history .ope.canceled::before, #history.transfer.canceled::before { +#history .entry.canceled::before { position: absolute; content: ' '; width:100%; @@ -101,10 +101,15 @@ border-top: 1px solid rgba(200,16,46,0.5); } -#history .transfer .amount { - width:80px; +#history .group .infos { + text-align:center; + width:145px; } -#history .transfer .from_acc { - padding-left:10px; +#history .entry .glyphicon { + padding-left:15px; +} + +#history-form .form-group { + position: relative; } diff --git a/kfet/static/kfet/css/index.css b/kfet/static/kfet/css/index.css index fdb86aff..d81a5074 100644 --- a/kfet/static/kfet/css/index.css +++ b/kfet/static/kfet/css/index.css @@ -41,10 +41,19 @@ } .frozen-account { - background:#5072e0; + background:#000FBA; color:#fff; } +.frozen-account .btn-default { + color: #aaa; +} + +.frozen-account .btn-default:hover, .frozen-account .btn-default.focus, +.frozen-account .btn-default:focus { + color: #ed2545; +} + .main .table a:not(.btn) { color: inherit; @@ -138,7 +147,7 @@ * Specific account create */ -.highlight_autocomplete { +.highlight { font-weight:bold; text-decoration:underline; } @@ -159,7 +168,7 @@ background:rgba(255,255,255,0.9); } -#search_results ul li.user_category { +#search_results ul li.autocomplete-header { font-weight:bold; background:#c8102e; color:#fff; @@ -178,13 +187,25 @@ text-decoration:none; } -#search_results ul li span.text { +#search_results ul li span.autocomplete-item { display:block; padding:5px 20px; } /* Account autocomplete window */ +.jconfirm #search_autocomplete { + margin-bottom: 0; +} + +#account_results { + left:0 !important; +} + +#account_results ul li.autocomplete-header { + display:none; +} + #account_results ul { list-style-type:none; background:rgba(255,255,255,0.9); @@ -198,6 +219,10 @@ width:100%; } +li.autocomplete-value { + cursor: pointer; +} + #account_results .hilight { background:rgba(200,16,46,0.9); color:#fff; diff --git a/kfet/static/kfet/css/kpsul.css b/kfet/static/kfet/css/kpsul.css index 6ae9ebc4..1616ce3e 100644 --- a/kfet/static/kfet/css/kpsul.css +++ b/kfet/static/kfet/css/kpsul.css @@ -98,6 +98,10 @@ input[type=number]::-webkit-outer-spin-button { font-weight:bold; } +#account_data #account-is_cof { + font-weight:bold; +} + #account-name { font-weight:bold; } diff --git a/kfet/static/kfet/css/libs/jconfirm-kfet.css b/kfet/static/kfet/css/libs/jconfirm-kfet.css index a50e22d6..935d4e97 100644 --- a/kfet/static/kfet/css/libs/jconfirm-kfet.css +++ b/kfet/static/kfet/css/libs/jconfirm-kfet.css @@ -25,6 +25,9 @@ .jconfirm .jconfirm-box .content-pane { border-bottom:1px solid #ddd; margin: 0px !important; + /* fixes whitespace below block + see https://stackoverflow.com/a/5804278 */ + vertical-align: middle; } .jconfirm .jconfirm-box .content { @@ -51,7 +54,6 @@ } .jconfirm .jconfirm-box .buttons { - margin-top:-6px; /* j'arrive pas à voir pk y'a un espace au dessus sinon... */ padding:0; height:40px; } diff --git a/kfet/static/kfet/js/account.js b/kfet/static/kfet/js/account.js new file mode 100644 index 00000000..b7c3fdaf --- /dev/null +++ b/kfet/static/kfet/js/account.js @@ -0,0 +1,146 @@ +var Account = Backbone.Model.extend({ + + defaults: { + 'id': 0, + 'name': '', + 'email': '', + 'is_cof': '', + 'is_kfet': '', + 'promo': '', + 'balance': '', + 'is_frozen': false, + 'departement': '', + 'nickname': '', + }, + + url: function () { + return django_urls["kfet.account.read.json"](encodeURIComponent(this.get("trigramme"))) + }, + + reset: function () { + // Réinitialise les attributs du modèle à leurs défaults, sauf le trigramme qui est bind à l'input. + // On n'utilise pas .clear() car on ne veut pas clear le trigramme. + this.set(this.defaults) + }, + + parse: function (resp, options) { + if (_.has(resp, 'balance')) { + return _.extend(resp, { 'balance': parseFloat(resp.balance) }); + } else { + return resp; + } + }, + + view: function () { + if (this.is_empty_account()) { + view_class = EmptyAccountView + } else if (this.get("trigramme") == 'LIQ') { + view_class = LIQView + } else { + view_class = AccountView + } + return new view_class({ model: this }) + }, + + render: function () { + this.view().render(); + }, + + is_empty_account: function () { + return (this.id == 0) + }, +}) + +var AccountView = Backbone.View.extend({ + + el: '#account', + buttons: '.buttons', + id_field: "#id_on_acc", + + props: _.keys(Account.prototype.defaults), + + get: function (property) { + /* If the function this.get_ is defined, + we call it ; else we call this.model.. */ + getter_name = `get_${property}`; + if (_.functions(this).includes(getter_name)) + return this[getter_name]() + else + return this.model.get(property) + }, + + get_is_cof: function () { + return this.model.get("is_cof") ? 'Membre COF' : (this.model.get("is_kfet") ? 'Membre K-Fêt' : 'Non-COF'); + }, + + get_balance: function () { + return amountToUKF(this.model.get("balance"), this.model.get("is_cof"), true); + }, + + attr_data_balance: function () { + // Cette fonction est utilisée uniquement sur un compte valide + + if (this.model.get("is_frozen")) { + return "frozen"; + } else if (this.model.get("balance") < 0) { + return 'neg'; + } else if (this.model.get("balance") <= 5) { + return 'low'; + } else { + return 'ok'; + } + }, + + get_buttons: function () { + var url = django_urls["kfet.account.read"](encodeURIComponent(this.model.get("trigramme"))); + + return ``; + }, + + render: function () { + for (let prop of this.props) { + var selector = "#account-" + prop; + this.$(selector).text(this.get(prop)); + } + + this.$el.attr("data-balance", this.attr_data_balance()); + this.$(this.buttons).html(this.get_buttons()); + $(this.id_field).val(this.get("id")); + }, +}) + +var LIQView = AccountView.extend({ + get_balance: function () { + return ""; + }, + + attr_data_balance: function () { + return 'ok'; + } +}) + +var EmptyAccountView = AccountView.extend({ + get: function () { + return ''; + }, + + attr_data_balance: function () { + return ''; + }, + + get_buttons: function () { + /* Léger changement de fonctionnement : + on affiche *toujours* le bouton de recherche si + le compte est invalide */ + buttons = ''; + trigramme = this.model.get("trigramme") + if (trigramme.is_valid_trigramme()) { + trigramme = encodeURIComponent(trigramme); + var url_base = django_urls["kfet.account.create"](); + var url = `${url_base}?trigramme=${trigramme}`; + buttons += ``; + } + + return buttons + } +}) diff --git a/kfet/static/kfet/js/history.js b/kfet/static/kfet/js/history.js index 7807ee90..57829a10 100644 --- a/kfet/static/kfet/js/history.js +++ b/kfet/static/kfet/js/history.js @@ -2,31 +2,59 @@ function dateUTCToParis(date) { return moment.tz(date, 'UTC').tz('Europe/Paris'); } +// TODO : classifier (later) function KHistory(options = {}) { $.extend(this, KHistory.default_options, options); this.$container = $(this.container); + this.$container.selectable({ + filter: 'div.group, div.entry', + selected: function (e, ui) { + $(ui.selected).each(function () { + if ($(this).hasClass('group')) { + var id = $(this).data('id'); + $(this).siblings('.entry').filter(function () { + return $(this).data('group_id') == id + }).addClass('ui-selected'); + } + }); + }, + }); + this.reset = function () { this.$container.html(''); }; - this.addOpeGroup = function (opegroup) { - var $day = this._getOrCreateDay(opegroup['at']); - var $opegroup = this._opeGroupHtml(opegroup); + this.add_history_group = function (group) { + var $day = this._get_or_create_day(group['at']); + var $group = this._group_html(group); - $day.after($opegroup); + $day.after($group); - var trigramme = opegroup['on_acc_trigramme']; - var is_cof = opegroup['is_cof']; - for (var i = 0; i < opegroup['opes'].length; i++) { - var $ope = this._opeHtml(opegroup['opes'][i], is_cof, trigramme); - $ope.data('opegroup', opegroup['id']); - $opegroup.after($ope); + var trigramme = group['on_acc_trigramme']; + var is_cof = group['is_cof']; + var type = group['type'] + // TODO : simplifier ça ? + switch (type) { + case 'operation': + for (let ope of group['entries']) { + var $ope = this._ope_html(ope, is_cof, trigramme); + $ope.data('group_id', group['id']); + $group.after($ope); + } + break; + case 'transfer': + for (let transfer of group['entries']) { + var $transfer = this._transfer_html(transfer); + $transfer.data('group_id', group['id']); + $group.after($transfer); + } + break; } } - this._opeHtml = function (ope, is_cof, trigramme) { + this._ope_html = function (ope, is_cof, trigramme) { var $ope_html = $(this.template_ope); var parsed_amount = parseFloat(ope['amount']); var amount = amountDisplay(parsed_amount, is_cof, trigramme); @@ -54,7 +82,8 @@ function KHistory(options = {}) { } $ope_html - .data('ope', ope['id']) + .data('type', 'operation') + .data('id', ope['id']) .find('.amount').text(amount).end() .find('.infos1').text(infos1).end() .find('.infos2').text(infos2).end(); @@ -62,54 +91,89 @@ function KHistory(options = {}) { var addcost_for = ope['addcost_for__trigramme']; if (addcost_for) { var addcost_amount = parseFloat(ope['addcost_amount']); - $ope_html.find('.addcost').text('(' + amountDisplay(addcost_amount, is_cof) + 'UKF pour ' + addcost_for + ')'); + $ope_html.find('.addcost').text('(' + amountDisplay(addcost_amount, is_cof) + ' UKF pour ' + addcost_for + ')'); } if (ope['canceled_at']) - this.cancelOpe(ope, $ope_html); + this.cancel_entry(ope, $ope_html); return $ope_html; } - this.cancelOpe = function (ope, $ope = null) { - if (!$ope) - $ope = this.findOpe(ope['id']); + this._transfer_html = function (transfer) { + var $transfer_html = $(this.template_transfer); + var parsed_amount = parseFloat(transfer['amount']); + var amount = parsed_amount.toFixed(2) + '€'; - var cancel = 'Annulé'; - var canceled_at = dateUTCToParis(ope['canceled_at']); - if (ope['canceled_by__trigramme']) - cancel += ' par ' + ope['canceled_by__trigramme']; - cancel += ' le ' + canceled_at.format('DD/MM/YY à HH:mm:ss'); + $transfer_html + .data('type', 'transfer') + .data('id', transfer['id']) + .find('.amount').text(amount).end() + .find('.infos1').text(transfer['from_acc']).end() + .find('.infos2').text(transfer['to_acc']).end(); - $ope.addClass('canceled').find('.canceled').text(cancel); + if (transfer['canceled_at']) + this.cancel_entry(transfer, $transfer_html); + + return $transfer_html; } - this._opeGroupHtml = function (opegroup) { - var $opegroup_html = $(this.template_opegroup); - var at = dateUTCToParis(opegroup['at']).format('HH:mm:ss'); - var trigramme = opegroup['on_acc__trigramme']; - var amount = amountDisplay( - parseFloat(opegroup['amount']), opegroup['is_cof'], trigramme); - var comment = opegroup['comment'] || ''; + this.cancel_entry = function (entry, $entry = null) { + if (!$entry) + $entry = this.find_entry(entry["id"], entry["type"]); - $opegroup_html - .data('opegroup', opegroup['id']) + var cancel = 'Annulé'; + var canceled_at = dateUTCToParis(entry['canceled_at']); + if (entry['canceled_by__trigramme']) + cancel += ' par ' + entry['canceled_by__trigramme']; + cancel += ' le ' + canceled_at.format('DD/MM/YY à HH:mm:ss'); + + $entry.addClass('canceled').find('.canceled').text(cancel); + } + + this._group_html = function (group) { + var type = group['type']; + + + switch (type) { + case 'operation': + var $group_html = $(this.template_opegroup); + var trigramme = group['on_acc__trigramme']; + var amount = amountDisplay( + parseFloat(group['amount']), group['is_cof'], trigramme); + break; + case 'transfer': + var $group_html = $(this.template_transfergroup); + $group_html.find('.infos').text('Transferts').end() + var trigramme = ''; + var amount = ''; + break; + } + + + var at = dateUTCToParis(group['at']).format('HH:mm:ss'); + var comment = group['comment'] || ''; + + $group_html + .data('type', type) + .data('id', group['id']) .find('.time').text(at).end() .find('.amount').text(amount).end() .find('.comment').text(comment).end() .find('.trigramme').text(trigramme).end(); if (!this.display_trigramme) - $opegroup_html.find('.trigramme').remove(); + $group_html.find('.trigramme').remove(); + $group_html.find('.info').remove(); - if (opegroup['valid_by__trigramme']) - $opegroup_html.find('.valid_by').text('Par ' + opegroup['valid_by__trigramme']); + if (group['valid_by__trigramme']) + $group_html.find('.valid_by').text('Par ' + group['valid_by__trigramme']); - return $opegroup_html; + return $group_html; } - this._getOrCreateDay = function (date) { + this._get_or_create_day = function (date) { var at = dateUTCToParis(date); var at_ser = at.format('YYYY-MM-DD'); var $day = this.$container.find('.day').filter(function () { @@ -118,35 +182,127 @@ function KHistory(options = {}) { if ($day.length == 1) return $day; var $day = $(this.template_day).prependTo(this.$container); - return $day.data('date', at_ser).text(at.format('D MMMM')); + return $day.data('date', at_ser).text(at.format('D MMMM YYYY')); } - this.findOpeGroup = function (id) { - return this.$container.find('.opegroup').filter(function () { - return $(this).data('opegroup') == id + this.find_group = function (id, type = "operation") { + return this.$container.find('.group').filter(function () { + return ($(this).data('id') == id && $(this).data("type") == type) }); } - this.findOpe = function (id) { - return this.$container.find('.ope').filter(function () { - return $(this).data('ope') == id + this.find_entry = function (id, type = 'operation') { + return this.$container.find('.entry').filter(function () { + return ($(this).data('id') == id && $(this).data('type') == type) }); } - this.cancelOpeGroup = function (opegroup) { - var $opegroup = this.findOpeGroup(opegroup['id']); - var trigramme = $opegroup.find('.trigramme').text(); + this.update_opegroup = function (group, type = "operation") { + var $group = this.find_group(group['id'], type); + var trigramme = $group.find('.trigramme').text(); var amount = amountDisplay( - parseFloat(opegroup['amount'], opegroup['is_cof'], trigramme)); - $opegroup.find('.amount').text(amount); + parseFloat(group['amount']), group['is_cof'], trigramme); + $group.find('.amount').text(amount); } + this.fetch = function (fetch_options = {}) { + if (typeof (fetch_options) == "string") + data = fetch_options + else + data = $.extend({}, this.fetch_options, fetch_options); + + return $.ajax({ + context: this, + dataType: "json", + url: django_urls["kfet.history.json"](), + method: "GET", + data: data, + }).done(function (data) { + for (let group of data['groups']) { + this.add_history_group(group); + } + }); + } + + this._cancel = function (type, opes, password = "") { + if (window.lock == 1) + return false + window.lock = 1; + var that = this; + return $.ajax({ + dataType: "json", + url: django_urls[`kfet.${type}s.cancel`](), + method: "POST", + data: opes, + beforeSend: function ($xhr) { + $xhr.setRequestHeader("X-CSRFToken", csrftoken); + if (password != '') + $xhr.setRequestHeader("KFetPassword", password); + }, + + }).done(function (data) { + window.lock = 0; + that.$container.find('.ui-selected').removeClass('ui-selected'); + for (let entry of data["canceled"]) { + entry["type"] = type; + that.cancel_entry(entry); + } + if (type == "operation") { + for (let opegroup of (data["opegroups_to_update"] || [])) { + that.update_opegroup(opegroup) + } + } + }).fail(function ($xhr) { + var data = $xhr.responseJSON; + switch ($xhr.status) { + case 403: + requestAuth(data, function (password) { + that._cancel(type, opes, password); + }); + break; + case 400: + displayErrors(data); + break; + } + window.lock = 0; + }); + } + + this.cancel_selected = function () { + var opes_to_cancel = { + "transfers": [], + "operations": [], + } + this.$container.find('.entry.ui-selected').each(function () { + type = $(this).data("type"); + opes_to_cancel[`${type}s`].push($(this).data("id")); + }); + if (opes_to_cancel["transfers"].length > 0 && opes_to_cancel["operations"].length > 0) { + // Lancer 2 requêtes AJAX et gérer tous les cas d'erreurs possibles est trop complexe + $.alert({ + title: 'Erreur', + content: "Impossible de supprimer des transferts et des opérations en même temps !", + backgroundDismiss: true, + animation: 'top', + closeAnimation: 'bottom', + keyboardEnabled: true, + }); + } else if (opes_to_cancel["transfers"].length > 0) { + delete opes_to_cancel["operations"]; + this._cancel("transfer", opes_to_cancel); + } else if (opes_to_cancel["operations"].length > 0) { + delete opes_to_cancel["transfers"]; + this._cancel("operation", opes_to_cancel); + } + } } KHistory.default_options = { container: '#history', template_day: '
    ', - template_opegroup: '
    ', - template_ope: '
    ', + template_opegroup: '
    ', + template_transfergroup: '
    ', + template_ope: '
    ', + template_transfer: '
    ', display_trigramme: true, } diff --git a/kfet/static/kfet/js/kfet.js b/kfet/static/kfet/js/kfet.js index 1002fc32..dbab8937 100644 --- a/kfet/static/kfet/js/kfet.js +++ b/kfet/static/kfet/js/kfet.js @@ -1,3 +1,17 @@ +/* + * Fonctions d'aide à la gestion de trigrammes + */ + +String.prototype.format_trigramme = function () { + return _.toArray(this.toUpperCase()).splice(0,3).join(''); +} + +String.prototype.is_valid_trigramme = function () { + var arr = _.toArray(this); + return arr && arr.length == 3; +} + + /** * CSRF Token */ @@ -14,7 +28,7 @@ function csrfSafeMethod(method) { } $.ajaxSetup({ - beforeSend: function(xhr, settings) { + beforeSend: function (xhr, settings) { if (!csrfSafeMethod(settings.type) && !this.crossDomain) { xhr.setRequestHeader("X-CSRFToken", csrftoken); } @@ -23,7 +37,7 @@ $.ajaxSetup({ function add_csrf_form($form) { $form.append( - $('', {'name': 'csrfmiddlewaretoken', 'value': csrftoken}) + $('', { 'name': 'csrfmiddlewaretoken', 'value': csrftoken }) ); } @@ -64,9 +78,9 @@ class KfetWebsocket { listen() { var that = this; - this.socket = new ReconnectingWebSocket(this.url); + this.socket = new ReconnectingWebSocket(this.url, [], { minReconnectionDelay: 100 }); - this.socket.onmessage = function(e) { + this.socket.onmessage = function (e) { var data = $.extend({}, that.default_msg, JSON.parse(e.data)); for (let handler of that.handlers) { handler(data); @@ -77,119 +91,83 @@ class KfetWebsocket { var OperationWebSocket = new KfetWebsocket({ 'relative_url': 'k-psul/', - 'default_msg': {'opegroups':[],'opes':[],'checkouts':[],'articles':[]}, + 'default_msg': { 'opegroups': [], 'opes': [], 'checkouts': [], 'articles': [] }, }); -function amountDisplay(amount, is_cof=false, tri='') { +function amountDisplay(amount, is_cof = false, tri = '') { if (tri == 'LIQ') - return (- amount).toFixed(2) +'€'; + return (- amount).toFixed(2) + '€'; return amountToUKF(amount, is_cof); } -function amountToUKF(amount, is_cof=false, account=false) { - var rounding = account ? Math.floor : Math.round ; +function amountToUKF(amount, is_cof = false, account = false) { + var rounding = account ? Math.floor : Math.round; var coef_cof = is_cof ? 1 + settings['subvention_cof'] / 100 : 1; return rounding(amount * coef_cof * 10); } -function isValidTrigramme(trigramme) { - var pattern = /^[^a-z]{3}$/; - return trigramme.match(pattern); -} +function getErrorsHtml(data, is_error = true) { + if (is_error) { + data = data.map(error => error.message) + } -function getErrorsHtml(data) { - var content = ''; - if (!data) - return "L'utilisateur n'est pas dans l'équipe"; - if ('operation_group' in data['errors']) { - content += 'Général'; - content += '
      '; - if (data['errors']['operation_group'].indexOf('on_acc') != -1) - content += '
    • Pas de compte sélectionné
    • '; - if (data['errors']['operation_group'].indexOf('checkout') != -1) - content += '
    • Pas de caisse sélectionnée
    • '; - content += '
    '; - } - if ('missing_perms' in data['errors']) { - content += 'Permissions manquantes'; - content += '
      '; - for (var i=0; i'; - content += '
    '; - } - if ('negative' in data['errors']) { - if (window.location.pathname.startsWith('/gestion/')) { - var url_base = '/gestion/k-fet/accounts/'; - } else { - var url_base = '/k-fet/accounts/'; - } - for (var i=0; iAutorisation de négatif requise pour '+data['errors']['negative'][i]+''; - } - } - if ('addcost' in data['errors']) { - content += '
      '; - if (data['errors']['addcost'].indexOf('__all__') != -1) - content += '
    • Compte invalide
    • '; - if (data['errors']['addcost'].indexOf('amount') != -1) - content += '
    • Montant invalide
    • '; - content += '
    '; - } - if ('account' in data['errors']) { - content += 'Général'; - content += '
      '; - content += '
    • Opération invalide sur le compte '+data['errors']['account']+'
    • '; - content += '
    '; + var content = is_error ? "Général :" : "Permissions manquantes :"; + content += "
      "; + for (const message of data) { + content += '
    • ' + message + '
    • '; } + content += "
    "; + return content; } function requestAuth(data, callback, focus_next = null) { - var content = getErrorsHtml(data); - content += '
    ', + var content = getErrorsHtml(data["missing_perms"], is_error = false); + content += '
    '; + $.confirm({ title: 'Authentification requise', content: content, backgroundDismiss: true, - animation:'top', - closeAnimation:'bottom', + animation: 'top', + closeAnimation: 'bottom', keyboardEnabled: true, - confirm: function() { + confirm: function () { var password = this.$content.find('input').val(); callback(password); }, - onOpen: function() { + onOpen: function () { var that = this; - var capslock = -1 ; // 1 -> caps on ; 0 -> caps off ; -1 or 2 -> unknown - this.$content.find('input').on('keypress', function(e) { + var capslock = -1; // 1 -> caps on ; 0 -> caps off ; -1 or 2 -> unknown + this.$content.find('input').on('keypress', function (e) { if (e.keyCode == 13) that.$confirmButton.click(); var s = String.fromCharCode(e.which); - if ((s.toUpperCase() === s && s.toLowerCase() !== s && !e.shiftKey)|| //caps on, shift off + if ((s.toUpperCase() === s && s.toLowerCase() !== s && !e.shiftKey) || //caps on, shift off (s.toUpperCase() !== s && s.toLowerCase() === s && e.shiftKey)) { //caps on, shift on - capslock = 1 ; - } else if ((s.toLowerCase() === s && s.toUpperCase() !== s && !e.shiftKey)|| //caps off, shift off - (s.toLowerCase() !== s && s.toUpperCase() === s && e.shiftKey)) { //caps off, shift on - capslock = 0 ; + capslock = 1; + } else if ((s.toLowerCase() === s && s.toUpperCase() !== s && !e.shiftKey) || //caps off, shift off + (s.toLowerCase() !== s && s.toUpperCase() === s && e.shiftKey)) { //caps off, shift on + capslock = 0; } if (capslock == 1) - $('.capslock .glyphicon').show() ; + $('.capslock .glyphicon').show(); else if (capslock == 0) - $('.capslock .glyphicon').hide() ; + $('.capslock .glyphicon').hide(); }); // Capslock key is not detected by keypress - this.$content.find('input').on('keydown', function(e) { + this.$content.find('input').on('keydown', function (e) { if (e.which == 20) { - capslock = 1-capslock ; + capslock = 1 - capslock; } if (capslock == 1) - $('.capslock .glyphicon').show() ; + $('.capslock .glyphicon').show(); else if (capslock == 0) - $('.capslock .glyphicon').hide() ; + $('.capslock .glyphicon').hide(); }); }, - onClose: function() { + onClose: function () { if (focus_next) this._lastFocused = focus_next; } @@ -197,6 +175,18 @@ function requestAuth(data, callback, focus_next = null) { }); } +function displayErrors(data) { + const content = getErrorsHtml(data["errors"], is_error = true); + $.alert({ + title: 'Erreurs', + content: content, + backgroundDismiss: true, + animation: 'top', + closeAnimation: 'bottom', + keyboardEnabled: true, + }); +} + /** * Setup jquery-confirm @@ -249,7 +239,7 @@ function submit_url(el) { function registerBoolParser(id, true_str, false_str) { $.tablesorter.addParser({ id: id, - format: function(s) { + format: function (s) { return s.toLowerCase() .replace(true_str, 1) .replace(false_str, 0); @@ -270,9 +260,9 @@ registerBoolParser('article__hidden', 'caché', 'affiché'); $.extend(true, $.tablesorter.defaults, { headerTemplate: '{content} {icon}', - cssIconAsc : 'glyphicon glyphicon-chevron-up', - cssIconDesc : 'glyphicon glyphicon-chevron-down', - cssIconNone : 'glyphicon glyphicon-resize-vertical', + cssIconAsc: 'glyphicon glyphicon-chevron-up', + cssIconDesc: 'glyphicon glyphicon-chevron-down', + cssIconNone: 'glyphicon glyphicon-resize-vertical', // Only four-digits format year is handled by the builtin parser // 'shortDate'. @@ -292,16 +282,16 @@ $.extend(true, $.tablesorter.defaults, { // https://mottie.github.io/tablesorter/docs/index.html#variable-language $.extend($.tablesorter.language, { - sortAsc : 'Trié par ordre croissant, ', - sortDesc : 'Trié par ordre décroissant, ', - sortNone : 'Non trié, ', - sortDisabled : 'tri désactivé et/ou non-modifiable', - nextAsc : 'cliquer pour trier par ordre croissant', - nextDesc : 'cliquer pour trier par ordre décroissant', - nextNone : 'cliquer pour retirer le tri' + sortAsc: 'Trié par ordre croissant, ', + sortDesc: 'Trié par ordre décroissant, ', + sortNone: 'Non trié, ', + sortDisabled: 'tri désactivé et/ou non-modifiable', + nextAsc: 'cliquer pour trier par ordre croissant', + nextDesc: 'cliquer pour trier par ordre décroissant', + nextNone: 'cliquer pour retirer le tri' }); -$( function() { +$(function () { $('.sortable').tablesorter(); }); diff --git a/kfet/static/kfet/js/kpsul.js b/kfet/static/kfet/js/kpsul.js new file mode 100644 index 00000000..a1ac8d37 --- /dev/null +++ b/kfet/static/kfet/js/kpsul.js @@ -0,0 +1,154 @@ +class AccountManager { + // Classe pour gérer la partie "compte" de K-Psul + // Devrait être la seule interface entre le JS de K-Psul et la logique des comptes. + constructor() { + // jQuery elements + this._$input = $("#id_trigramme"); + this._$container = $("#account"); + this._$article_select = $("#article_autocomplete") + + // Subordinated classes + this.account = new Account({ "trigramme": "" }); + this.search = new AccountSearch(this) + + // Initialization + this._init_events(); + } + + get data() { + return this.account.toJSON(); + } + + _init_events() { + var that = this; + + // L'input change ; on met à jour le compte + this._$input.on("input", () => this.update()) + + // Raccourci LIQ + this._$input.on("keydown", function (e) { + if (e.key == "ArrowDown") { + that.set("LIQ") + } + }) + + // Fonction de recherche + this._$container.on('click', '.search', function () { + that.search.open(); + }); + + this._$container.on('keydown', function (e) { + if (e.key == "f" && e.ctrlKey) { + // Ctrl + F : universal search shortcut + that.search.open(); + e.preventDefault(); + } + }); + } + + set(trigramme) { + this._$input.val(trigramme); + this.update(); + } + + update() { + var trigramme = this._$input.val().format_trigramme(); + this.account.set({ "trigramme": trigramme }) + if (trigramme.is_valid_trigramme()) { + this.account.fetch({ + "success": this._on_success.bind(this), + "error": this.reset.bind(this, false), + }) + } else { + this.reset() + } + } + + _on_success() { + // On utilise l'objet global window pour accéder aux fonctions nécessaires + this.account.render(); + this._$article_select.focus(); + window.updateBasketAmount(); + window.updateBasketRel(); + } + + reset(hard_reset = false) { + this.account.reset(); + this.account.render(); + + if (hard_reset) { + this._$input.val(""); + this.update() + } + } +} + +class AccountSearch { + + constructor(manager) { + this.manager = manager; + + this._content = '
    '; + this._input = '#search_autocomplete'; + this._results_container = '#account_results'; + + } + + open() { + var that = this; + this._$dialog = $.dialog({ + title: 'Recherche de compte', + content: this._content, + backgroundDismiss: true, + animation: 'top', + closeAnimation: 'bottom', + keyboardEnabled: true, + onOpen: function () { + that._$input = $(that._input); + that._$results_container = $(that._results_container); + that._init_form() + ._init_events(); + }, + }); + } + + _init_form() { + var that = this; + + this._$input.yourlabsAutocomplete({ + url: django_urls['kfet.account.search.autocomplete'](), + minimumCharacters: 2, + id: 'search_autocomplete', + choiceSelector: '.autocomplete-value', + placeholder: "Chercher un utilisateur K-Fêt", + container: that._$results_container, + box: that._$results_container, + fixPosition: function () { }, + }); + + return this; + } + + _init_events() { + this._$input.bind('selectChoice', + (e, choice, autocomplete) => this._on_select(e, choice, autocomplete) + ); + return this; + } + + _on_select(e, choice, autocomplete) { + // Une option est de la forme " ()" + var choice_text = choice.text().trim(); + var trigramme_regex = /\((.{3})\)$/; + // le match est de la forme [, ] + trigramme = choice_text.match(trigramme_regex)[1] + this.manager.set(trigramme); + this.close(); + } + + close() { + if (this._$dialog !== undefined) { + this._$dialog.close(); + } + } +} diff --git a/kfet/static/kfet/js/statistic.js b/kfet/static/kfet/js/statistic.js index 9baa08c4..4da17672 100644 --- a/kfet/static/kfet/js/statistic.js +++ b/kfet/static/kfet/js/statistic.js @@ -1,28 +1,15 @@ -(function($){ +(function ($) { window.StatsGroup = function (url, target) { // a class to properly display statictics // url : points to an ObjectResumeStat that lists the options through JSON // target : element of the DOM where to put the stats - var self = this; var element = $(target); var content = $("
    "); var buttons; - function dictToArray (dict, start) { - // converts the dicts returned by JSONResponse to Arrays - // necessary because for..in does not guarantee the order - if (start === undefined) start = 0; - var array = new Array(); - for (var k in dict) { - array[k] = dict[k]; - } - array.splice(0, start); - return array; - } - - function handleTimeChart (data) { + function handleTimeChart(data) { // reads the balance data and put it into chartjs formatting chart_data = new Array(); for (var i = 0; i < data.length; i++) { @@ -36,7 +23,7 @@ return chart_data; } - function showStats () { + function showStats() { // CALLBACK : called when a button is selected // shows the focus on the correct button @@ -44,24 +31,20 @@ $(this).addClass("focus"); // loads data and shows it - $.getJSON(this.stats_target_url, {format: 'json'}, displayStats); + $.getJSON(this.stats_target_url, displayStats); } - function displayStats (data) { + function displayStats(data) { // reads the json data and updates the chart display var chart_datasets = []; - var charts = dictToArray(data.charts); - // are the points indexed by timestamps? var is_time_chart = data.is_time_chart || false; // reads the charts data - for (var i = 0; i < charts.length; i++) { - var chart = charts[i]; - + for (let chart of data.charts) { // format the data - var chart_data = is_time_chart ? handleTimeChart(chart.values) : dictToArray(chart.values, 0); + var chart_data = is_time_chart ? handleTimeChart(chart.values) : chart.values; chart_datasets.push( { @@ -76,29 +59,24 @@ // options for chartjs var chart_options = - { - responsive: true, - maintainAspectRatio: false, - tooltips: { - mode: 'index', - intersect: false, - }, - hover: { - mode: 'nearest', - intersect: false, - } - }; + { + responsive: true, + maintainAspectRatio: false, + tooltips: { + mode: 'index', + intersect: false, + }, + hover: { + mode: 'nearest', + intersect: false, + } + }; // additionnal options for time-indexed charts if (is_time_chart) { chart_options['scales'] = { xAxes: [{ type: "time", - display: true, - scaleLabel: { - display: false, - labelString: 'Date' - }, time: { tooltipFormat: 'll HH:mm', displayFormats: { @@ -115,26 +93,19 @@ } }], - yAxes: [{ - display: true, - scaleLabel: { - display: false, - labelString: 'value' - } - }] }; } // global object for the options var chart_model = - { - type: 'line', - options: chart_options, - data: { - labels: data.labels || [], - datasets: chart_datasets, - } - }; + { + type: 'line', + options: chart_options, + data: { + labels: data.labels || [], + datasets: chart_datasets, + } + }; // saves the previous charts to be destroyed var prev_chart = content.children(); @@ -151,27 +122,30 @@ } // initialize the interface - function initialize (data) { + function initialize(data) { // creates the bar with the buttons buttons = $("
    + + + + {% endblock %} {% block main %} diff --git a/kfet/templates/kfet/account_create.html b/kfet/templates/kfet/account_create.html index 3fd21a96..13d36748 100644 --- a/kfet/templates/kfet/account_create.html +++ b/kfet/templates/kfet/account_create.html @@ -1,5 +1,5 @@ {% extends "kfet/base_form.html" %} -{% load staticfiles %} +{% load static %} {% block title %}Nouveau compte{% endblock %} {% block header-title %}Création d'un compte{% endblock %} @@ -36,7 +36,7 @@ // Affichage des résultats d'autocomplétion $('input#search_autocomplete').yourlabsAutocomplete({ url: '{% url "kfet.account.create.autocomplete" %}', - minimumCharacters: 0, + minimumCharacters: 3, id: 'search_autocomplete', choiceSelector: 'li:has(a)', container: $("#search_results"), @@ -60,8 +60,7 @@ $('#id_trigramme').on('input', function() { var trigramme = $('#id_trigramme').val().toUpperCase(); - var pattern = /^[^a-z]{3}$/; - if (!(trigramme.match(pattern))) { + if (!(trigramme.is_valid_trigramme())) { $('#id_trigramme') .css('background', '#fff') .css('color', '#000'); diff --git a/kfet/templates/kfet/account_create_autocomplete.html b/kfet/templates/kfet/account_create_autocomplete.html deleted file mode 100644 index 5343b945..00000000 --- a/kfet/templates/kfet/account_create_autocomplete.html +++ /dev/null @@ -1,50 +0,0 @@ -{% load kfet_tags %} - -
      -
    • - - Créer un compte - -
    • - {% if kfet %} -
    • Comptes existants
    • - {% for account, user in kfet %} -
    • {{ account }} [{{ user|highlight_user:q }}]
    • - {% endfor %} - {% endif %} - {% if users_cof %} -
    • Membres du COF
    • - {% for user in users_cof %} -
    • - - {{ user|highlight_user:q }} - -
    • - {% endfor %} - {% endif %} - {% if users_notcof %} -
    • Non-membres du COF
    • - {% for user in users_notcof %} -
    • - - {{ user|highlight_user:q }} - -
    • - {% endfor %} - {% endif %} - {% if clippers %} -
    • Utilisateurs clipper
    • - {% for clipper in clippers %} -
    • - - {{ clipper|highlight_clipper:q }} - -
    • - {% endfor %} - {% endif %} - {% if not q %} -
    • Pas de recherche, pas de résultats !
    • - {% elif not options %} -
    • Aucune correspondance trouvée :-(
    • - {% endif %} -
    diff --git a/kfet/templates/kfet/account_create_special.html b/kfet/templates/kfet/account_create_special.html index bc0fe4fe..5bba9ca7 100644 --- a/kfet/templates/kfet/account_create_special.html +++ b/kfet/templates/kfet/account_create_special.html @@ -1,5 +1,5 @@ {% extends "kfet/base.html" %} -{% load staticfiles %} +{% load static %} {% block title %}Nouveau compte{% endblock %} {% block header-title %}Création d'un compte{% endblock %} diff --git a/kfet/templates/kfet/account_group_form.html b/kfet/templates/kfet/account_group_form.html index a6faf8c5..c9ee04f6 100644 --- a/kfet/templates/kfet/account_group_form.html +++ b/kfet/templates/kfet/account_group_form.html @@ -1,46 +1,12 @@ {% extends 'kfet/base_form.html' %} -{% load staticfiles %} +{% load static %} {% load widget_tweaks %} -{% block extra_head %} - - -{% endblock %} - {% block title %}Permissions - Édition{% endblock %} {% block header-title %}Modification des permissions{% endblock %} {% block main %} -
    - {% csrf_token %} -
    - -
    -
    - K-Fêt - {{ form.name|add_class:"form-control" }} -
    - {% if form.name.errors %} - {{ form.name.errors }} - {% endif %} - {% if form.name.help_text %} - {{ form.name.help_text }} - {% endif %} -
    -
    - {% include "kfet/form_field_snippet.html" with field=form.permissions %} - {% include "kfet/form_submit_snippet.html" with value="Enregistrer" %} -
    - - +{% include "kfet/form_full_snippet.html" with authz=perms.kfet.manage_perms submit_text="Enregistrer" %} {% endblock %} diff --git a/kfet/templates/kfet/account_negative.html b/kfet/templates/kfet/account_negative.html index fa8b508d..c2390f6d 100644 --- a/kfet/templates/kfet/account_negative.html +++ b/kfet/templates/kfet/account_negative.html @@ -10,45 +10,25 @@ {{ negatives|length }} compte{{ negatives|length|pluralize }} en négatif
    -
    - Total: {{ negatives_sum|floatformat:2 }}€ -
    -
    - Plafond par défaut -
      -
    • Montant: {{ kfet_config.overdraft_amount }}€
    • -
    • Pendant: {{ kfet_config.overdraft_duration }}
    • -
    +
    + {{ negatives_sum|floatformat:2 }}€ + de négatif total
    -{% if perms.kfet.change_settings %} -
    -
    -
    -
    -{% endif %} - {% endblock %} {% block main %}
    - +
    - - - - @@ -61,23 +41,13 @@ - - - - {% endfor %}
    Tri. Nom BalanceRéelle DébutDécouvert autoriséJusqu'auBalance offset
    {{ 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'}} {{ neg.authz_overdraft_amount|default_if_none:'' }} - {{ neg.authz_overdraft_until|date:'d/m/Y H:i' }} - {{ neg.balance_offset|default_if_none:'' }}
    -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/kfet/templates/kfet/account_read.html b/kfet/templates/kfet/account_read.html index 698c512e..4c42fb76 100644 --- a/kfet/templates/kfet/account_read.html +++ b/kfet/templates/kfet/account_read.html @@ -1,17 +1,18 @@ {% extends "kfet/base_col_2.html" %} -{% load staticfiles %} +{% load static %} {% load kfet_tags %} {% load l10n %} {% block extra_head %} + - + {% if account.user == request.user %} - +{% autoescape off %} +{% endautoescape %} {% endif %} {% endblock %} @@ -80,7 +82,7 @@ $(document).ready(function() {
    {% endif %} -
    +
    @@ -92,29 +94,22 @@ $(document).ready(function() { khistory = new KHistory({ display_trigramme: false, + fetch_options: { + account: {{ account.pk }}, + } }); - function getHistory() { - var data = { - 'accounts': [{{ account.pk }}], + $(document).on('keydown', function (e) { + if (e.keyCode == 46) { + // DEL (Suppr) + khistory.cancel_selected() } + }); - $.ajax({ - dataType: "json", - url : "{% url 'kfet.history.json' %}", - method : "POST", - data : data, - }) - .done(function(data) { - for (var i=0; i diff --git a/kfet/templates/kfet/account_search_autocomplete.html b/kfet/templates/kfet/account_search_autocomplete.html deleted file mode 100644 index e18eb1eb..00000000 --- a/kfet/templates/kfet/account_search_autocomplete.html +++ /dev/null @@ -1,14 +0,0 @@ -{% load kfet_tags %} - -
      - {% if accounts %} - {% for trigramme, user in accounts %} -
    • {{ user|highlight_text:q }} ({{ trigramme }})
    • - {% endfor %} - {% elif not q %} -
    • Pas de recherche, pas de résultats !
    • - {% else %} -
    • Aucune correspondance trouvée :-(
    • - {% endif %} -
    - diff --git a/kfet/templates/kfet/account_update.html b/kfet/templates/kfet/account_update.html index 36b3d75d..7115b9e2 100644 --- a/kfet/templates/kfet/account_update.html +++ b/kfet/templates/kfet/account_update.html @@ -6,51 +6,39 @@ {% block title %} {% if account.user == request.user %} - Modification de mes informations +Modification de mes informations {% else %} - {{ account.trigramme }} - Édition +{{ account.trigramme }} - Édition {% endif %} {% endblock %} {% block header-title %} {% if account.user == request.user %} - Modification de mes informations +Modification de mes informations {% else %} - Édition du compte {{ account.trigramme }} +Édition du compte {{ account.trigramme }} {% endif %} {% endblock %} {% block footer %} {% if not account.is_team %} - {% include "kfet/base_footer.html" %} +{% include "kfet/base_footer.html" %} {% endif %} {% endblock %} {% block main %} -
    + {% csrf_token %} {% include 'kfet/form_snippet.html' with form=user_info_form %} {% include 'kfet/form_snippet.html' with form=account_form %} + {% include 'kfet/form_snippet.html' with form=frozen_form %} {% include 'kfet/form_snippet.html' with form=group_form %} + {% include 'kfet/form_snippet.html' with form=cof_form %} {% include 'kfet/form_snippet.html' with form=pwd_form %} - {% include 'kfet/form_snippet.html' with form=negative_form %} - {% if perms.kfet.is_team %} - {% include 'kfet/form_authentication_snippet.html' %} - {% endif %} + + {% include 'kfet/form_authentication_snippet.html' %} {% include 'kfet/form_submit_snippet.html' with value="Mettre à jour" %}
    - - {% endblock %} diff --git a/kfet/templates/kfet/article.html b/kfet/templates/kfet/article.html index 6b48ddbb..cb52a5ea 100644 --- a/kfet/templates/kfet/article.html +++ b/kfet/templates/kfet/article.html @@ -40,6 +40,7 @@ Prix Stock En vente + Reservé aux adhérent⋅e⋅s Affiché Dernier inventaire @@ -63,6 +64,7 @@ {{ article.price }}€ {{ article.stock }} {{ article.is_sold | yesno:"En vente,Non vendu"}} + {{ article.no_exte | yesno:"Réservé,Non réservé"}} {{ article.hidden | yesno:"Caché,Affiché" }} {% with last_inventory=article.inventory.0 %} @@ -88,6 +90,7 @@ Prix Stock En vente + Reservé aux adhérent⋅e⋅s Affiché Dernier inventaire @@ -111,6 +114,7 @@ {{ article.price }}€ {{ article.stock }} {{ article.is_sold | yesno:"En vente,Non vendu"}} + {{ article.no_exte | yesno:"Réservé,Non réservé"}} {{ article.hidden | yesno:"Caché,Affiché" }} {% with last_inventory=article.inventory.0 %} diff --git a/kfet/templates/kfet/article_read.html b/kfet/templates/kfet/article_read.html index 0333ec29..52032099 100644 --- a/kfet/templates/kfet/article_read.html +++ b/kfet/templates/kfet/article_read.html @@ -1,8 +1,8 @@ {% extends 'kfet/base_col_2.html' %} -{% load staticfiles kfet_tags %} +{% load static kfet_tags %} {% block extra_head %} - + {% endblock %} @@ -39,6 +39,7 @@
  • Stock: {{ article.stock }}
  • En vente: {{ article.is_sold|yesno|title }}
  • Affiché: {{ article.hidden|yesno|title }}
  • +
  • Réservé aux adhérent⋅e⋅s: {{ article.no_exte|yesno|title }}
  • @@ -160,4 +161,4 @@ }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/kfet/templates/kfet/base.html b/kfet/templates/kfet/base.html index 9b75af03..524e3633 100644 --- a/kfet/templates/kfet/base.html +++ b/kfet/templates/kfet/base.html @@ -19,6 +19,7 @@ {# JS #} + @@ -29,6 +30,17 @@ + {% include "kfetopen/init.html" %} diff --git a/kfet/templates/kfet/base_footer.html b/kfet/templates/kfet/base_footer.html index c5333476..7a016705 100644 --- a/kfet/templates/kfet/base_footer.html +++ b/kfet/templates/kfet/base_footer.html @@ -1,17 +1,13 @@ {% load wagtailcore_tags %} -{% with "k-fet@ens.fr" as kfet_mail %} -
    - -{% endwith %} + diff --git a/kfet/templates/kfet/base_nav.html b/kfet/templates/kfet/base_nav.html index 1cded20b..24f6a554 100644 --- a/kfet/templates/kfet/base_nav.html +++ b/kfet/templates/kfet/base_nav.html @@ -25,6 +25,7 @@ . +
    Grand indicateur {% if perms.kfet.is_team %} {% endif %} @@ -32,20 +33,20 @@ {% for item in menu_items %} {% if item.text == "Accueil" %} - {% else %} - {% endif %} {% endfor %} - {% endif %} + {% if perms.kfet.delete_inventory %} +
    +
    + +
    + {% csrf_token %} +
    + {% endif %} +
    @@ -26,6 +37,12 @@ {% block main %} +
    +
    + Les valeurs de stock sont calculées sur la base du prix actuel des articles. +
    +
    +
    Erreur - {% regroup inventoryarts by article.category as category_list %} + {% regroup inventoryarts by article.category.name as category_list %} {% for category in category_list %} - + {% for inventoryart in category.list %} - + {% endfor %} {% endfor %} + + + + + + + +
    {{ category.grouper.name }}{{ category.grouper }}
    - + {{ inventoryart.article.name }} {{ inventoryart.stock_old }} {{ inventoryart.stock_new }}{{ inventoryart.stock_error }}{{ inventoryart.stock_error }} / {{ inventoryart.amount_error|floatformat:"-2" }} €
    Valeurs totales{{ total_amount_old|floatformat:"-2" }} €{{ total_amount_new|floatformat:"-2" }} €{{ total_amount_error|floatformat:"-2" }} €
    + + {% endblock %} diff --git a/kfet/templates/kfet/kpsul.html b/kfet/templates/kfet/kpsul.html index ff24fcb4..b44f1a25 100644 --- a/kfet/templates/kfet/kpsul.html +++ b/kfet/templates/kfet/kpsul.html @@ -1,11 +1,16 @@ {% extends 'kfet/base.html' %} -{% load staticfiles %} +{% load static %} {% block extra_head %} - + + + + + + {% endblock %} {% block title %}K-Psul{% endblock %} @@ -185,7 +190,7 @@ $(document).ready(function() { // ----- // Lock to avoid multiple requests - lock = 0; + window.lock = 0; // Retrieve settings @@ -209,174 +214,8 @@ $(document).ready(function() { // Account data management // ----- - // Initializing - var account_container = $('#account'); + var account_manager = new AccountManager(); var triInput = $('#id_trigramme'); - var account_data = {}; - var account_data_default = { - 'id' : 0, - 'name' : '', - 'email': '', - 'is_cof' : '', - 'promo' : '', - 'balance': '', - 'trigramme' : '', - 'is_frozen' : false, - 'departement': '', - 'nickname' : '', - }; - - // Display data - function displayAccountData() { - var balance = account_data['trigramme'] != 'LIQ' ? account_data['balance'] : ''; - if (balance != '') - balance = amountToUKF(account_data['balance'], account_data['is_cof'], true); - var is_cof = account_data['trigramme'] ? account_data['is_cof'] : ''; - if (is_cof !== '') - is_cof = is_cof ? 'COF' : 'Non-COF'; - for (var elem in account_data) { - if (elem == 'balance') { - $('#account-balance').text(balance); - } else if (elem == 'is_cof') { - $('#account-is_cof').html(is_cof); - } else { - $('#account-'+elem).text(account_data[elem]); - } - } - if (account_data['is_frozen']) { - $('#account').attr('data-balance', 'frozen'); - } else if (account_data['balance'] >= 5 || account_data['trigramme'] == 'LIQ') { - $('#account').attr('data-balance', 'ok'); - } else if (account_data['balance'] == '') { - $('#account').attr('data-balance', ''); - } else if (account_data['balance'] >= 0) { - $('#account').attr('data-balance', 'low'); - } else { - $('#account').attr('data-balance', 'neg'); - } - - var buttons = ''; - if (account_data['id'] != 0) { - var url_base = '{% url 'kfet.account.read' 'LIQ' %}'; - url_base = url_base.substr(0, url_base.length - 3); - trigramme = encodeURIComponent(account_data['trigramme']) ; - buttons += ''; - } - if (account_data['id'] == 0) { - var trigramme = triInput.val().toUpperCase(); - if (isValidTrigramme(trigramme)) { - var url_base = '{% url 'kfet.account.create' %}' - trigramme = encodeURIComponent(trigramme); - buttons += ''; - } else { - var url_base = '{% url 'kfet.account' %}' - buttons += ''; - } - } - account_container.find('.buttons').html(buttons); - } - - // Search for an account - function searchAccount() { - var content = '
    ' ; - $.dialog({ - title: 'Recherche de compte', - content: content, - backgroundDismiss: true, - animation: 'top', - closeAnimation: 'bottom', - keyboardEnabled: true, - - onOpen: function() { - var that=this ; - $('input#search_autocomplete').yourlabsAutocomplete({ - url: '{% url "kfet.account.search.autocomplete" %}', - minimumCharacters: 2, - id: 'search_autocomplete', - choiceSelector: '.choice', - placeholder: "Chercher un utilisateur K-Fêt", - box: $("#account_results"), - fixPosition: function() {}, - }); - $('input#search_autocomplete').bind( - 'selectChoice', - function(e, choice, autocomplete) { - autocomplete.hide() ; - triInput.val(choice.find('.trigramme').text()) ; - triInput.trigger('input') ; - that.close() ; - }); - } - }); - } - - account_container.on('click', '.search', function () { - searchAccount() ; - }) ; - - account_container.on('keydown', function(e) { - if (e.which == 70 && e.ctrlKey) { - // Ctrl + F : universal search shortcut - searchAccount() ; - e.preventDefault() ; - } - }); - - // Clear data - function resetAccountData() { - account_data = account_data_default; - $('#id_on_acc').val(0); - displayAccountData(); - } - - function resetAccount() { - triInput.val(''); - resetAccountData(); - } - - // Store data - function storeAccountData(data) { - account_data = $.extend({}, account_data_default, data); - account_data['balance'] = parseFloat(account_data['balance']); - $('#id_on_acc').val(account_data['id']); - displayAccountData(); - } - - // Retrieve via ajax - function retrieveAccountData(tri) { - $.ajax({ - dataType: "json", - url : "{% url 'kfet.account.read.json' %}", - method : "POST", - data : { trigramme: tri }, - }) - .done(function(data) { - storeAccountData(data); - articleSelect.focus(); - updateBasketAmount(); - updateBasketRel(); - }) - .fail(function() { resetAccountData() }); - } - - // Event listener - triInput.on('input', function() { - var tri = triInput.val().toUpperCase(); - // Checking if tri is valid to avoid sending requests - if (isValidTrigramme(tri)) { - retrieveAccountData(tri); - } else { - resetAccountData(); - } - }); - - triInput.on('keydown', function(e) { - if (e.keyCode == 40) { - // Arrow Down - Shorcut to LIQ - triInput.val('LIQ'); - triInput.trigger('input'); - } - }); // ----- @@ -462,7 +301,7 @@ $(document).ready(function() { // Event listener checkoutInput.on('change', function() { retrieveCheckoutData(checkoutInput.val()); - if (account_data['trigramme']) { + if (account_manager.data['trigramme']) { articleSelect.focus().select(); } else { triInput.focus().select(); @@ -501,21 +340,6 @@ $(document).ready(function() { $('#id_comment').val(''); } - // ----- - // Errors ajax - // ----- - - function displayErrors(html) { - $.alert({ - title: 'Erreurs', - content: html, - backgroundDismiss: true, - animation: 'top', - closeAnimation: 'bottom', - keyboardEnabled: true, - }); - } - // ----- // Perform operations // ----- @@ -525,9 +349,9 @@ $(document).ready(function() { var operations = $('#operation_formset'); function performOperations(password = '') { - if (lock == 1) + if (window.lock == 1) return false; - lock = 1; + window.lock = 1; var data = operationGroup.serialize() + '&' + operations.serialize(); $.ajax({ dataType: "json", @@ -543,7 +367,7 @@ $(document).ready(function() { .done(function(data) { updatePreviousOp(); coolReset(); - lock = 0; + window.lock = 0; }) .fail(function($xhr) { var data = $xhr.responseJSON; @@ -552,14 +376,14 @@ $(document).ready(function() { requestAuth(data, performOperations, articleSelect); break; case 400: - if ('need_comment' in data['errors']) { + if ('need_comment' in data) { askComment(performOperations); } else { - displayErrors(getErrorsHtml(data)); + displayErrors(data); } break; } - lock = 0; + window.lock = 0; }); } @@ -568,62 +392,13 @@ $(document).ready(function() { performOperations(); }); - // ----- - // Cancel operations - // ----- - - var cancelButton = $('#cancel_operations'); - var cancelForm = $('#cancel_form'); - - function cancelOperations(opes_array, password = '') { - if (lock == 1) - return false - lock = 1; - var data = { 'operations' : opes_array } - $.ajax({ - dataType: "json", - url : "{% url 'kfet.kpsul.cancel_operations' %}", - method : "POST", - data : data, - beforeSend: function ($xhr) { - $xhr.setRequestHeader("X-CSRFToken", csrftoken); - if (password != '') - $xhr.setRequestHeader("KFetPassword", password); - }, - - }) - .done(function(data) { - coolReset(); - lock = 0; - }) - .fail(function($xhr) { - var data = $xhr.responseJSON; - switch ($xhr.status) { - case 403: - requestAuth(data, function(password) { - cancelOperations(opes_array, password); - }, triInput); - break; - case 400: - displayErrors(getErrorsHtml(data)); - break; - } - lock = 0; - }); - } - - // Event listeners - cancelButton.on('click', function() { - cancelOperations(); - }); - // ----- // Articles data // ----- var articles_container = $('#articles_data tbody'); - var article_category_default_html = ''; - var article_default_html = ''; + var article_category_default_html = ''; + var article_default_html = ''; function addArticle(article) { var article_html = $(article_default_html); @@ -636,6 +411,7 @@ $(document).ready(function() { article_html.addClass('low-stock'); } article_html.find('.price').text(amountToUKF(article['price'], false, false)+' UKF'); + article_html.find('.no_exte').text(article['no_exte'] ? "Réservé aux adhérent⋅e⋅s" : ""); var category_html = articles_container .find('#data-category-'+article['category_id']); if (category_html.length == 0) { @@ -655,13 +431,13 @@ $(document).ready(function() { var $after = articles_container.find('#data-category-'+article['category_id']); articles_container .find('.article.data-category-'+article['category_id']).each(function() { - if (article['name'].toLowerCase < $('.name', this).text().toLowerCase()) + if (article['name'].toLowerCase() < $('.name', this).text().toLowerCase()) return false; $after = $(this); }); $after.after(article_html); // Pour l'autocomplétion - articlesList.push([article['name'],article['id'],article['category_id'],article['price'], article['stock'],article['category__has_addcost']]); + articlesList.push([article['name'],article['id'],article['category_id'],article['price'], article['stock'],article['category__has_addcost'],article['category__has_reduction']]); } function getArticles() { @@ -808,7 +584,7 @@ $(document).ready(function() { }); function is_nb_ok(nb) { - return /^[0-9]+$/.test(nb) && nb > 0 && nb <= 24; + return /^[0-9]+$/.test(nb) && nb > 0; } articleNb.on('keydown', function(e) { @@ -847,11 +623,11 @@ $(document).ready(function() { var amount_euro = - article_data[3] * nb ; if (settings['addcost_for'] && settings['addcost_amount'] - && account_data['trigramme'] != settings['addcost_for'] + && account_manager.data['trigramme'] != settings['addcost_for'] && article_data[5]) amount_euro -= settings['addcost_amount'] * nb; var reduc_divisor = 1; - if (account_data['is_cof']) + if (account_manager.data['is_cof'] && article_data[6]) reduc_divisor = 1 + settings['subvention_cof'] / 100; return (amount_euro / reduc_divisor).toFixed(2); } @@ -874,7 +650,7 @@ $(document).ready(function() { .attr('data-opeindex', index) .find('.number').text('('+nb+'/'+article_data[4]+')').end() .find('.name').text(article_data[0]).end() - .find('.amount').text(amountToUKF(amount_euro, account_data['is_cof']), false); + .find('.amount').text(amountToUKF(amount_euro, account_manager.data['is_cof'], false)); basket_container.prepend(article_basket_html); if (is_low_stock(id, nb)) article_basket_html.find('.lowstock') @@ -900,7 +676,7 @@ $(document).ready(function() { .attr('data-opeindex', index) .find('.number').text(amount+"€").end() .find('.name').text('Charge').end() - .find('.amount').text(amountToUKF(amount, account_data['is_cof'], false)); + .find('.amount').text(amountToUKF(amount, account_manager.data['is_cof'], false)); basket_container.prepend(deposit_basket_html); updateBasketRel(); } @@ -913,7 +689,7 @@ $(document).ready(function() { .attr('data-opeindex', index) .find('.number').text(amount+"€").end() .find('.name').text('Édition').end() - .find('.amount').text(amountToUKF(amount, account_data['is_cof'], false)); + .find('.amount').text(amountToUKF(amount, account_manager.data['is_cof'], false)); basket_container.prepend(deposit_basket_html); updateBasketRel(); } @@ -926,7 +702,7 @@ $(document).ready(function() { .attr('data-opeindex', index) .find('.number').text(amount+"€").end() .find('.name').text('Retrait').end() - .find('.amount').text(amountToUKF(amount, account_data['is_cof'], false)); + .find('.amount').text(amountToUKF(amount, account_manager.data['is_cof'], false)); basket_container.prepend(withdraw_basket_html); updateBasketRel(); } @@ -982,7 +758,7 @@ $(document).ready(function() { var amount = $(this).find('#id_form-'+opeindex+'-amount'); if (!deleted && type == "purchase") amount.val(amountEuroPurchase(article_id, article_nb)); - basket_container.find('[data-opeindex='+opeindex+'] .amount').text(amountToUKF(amount.val(), account_data['is_cof'], false)); + basket_container.find('[data-opeindex='+opeindex+'] .amount').text(amountToUKF(amount.val(), account_manager.data['is_cof'], false)); }); } @@ -990,7 +766,7 @@ $(document).ready(function() { function updateBasketRel() { var basketrel_html = ''; - if (account_data['trigramme'] == 'LIQ' && !isBasketEmpty()) { + if (account_manager.data['trigramme'] == 'LIQ' && !isBasketEmpty()) { var amount = - getAmountBasket(); basketrel_html += '
    Total: '+amount.toFixed(2)+' €
    '; if (amount < 5) @@ -999,11 +775,11 @@ $(document).ready(function() { basketrel_html += '
    Sur 10€: '+ (10-amount).toFixed(2) +' €
    '; if (amount < 20) basketrel_html += '
    Sur 20€: '+ (20-amount).toFixed(2) +' €
    '; - } else if (account_data['trigramme'] != '' && !isBasketEmpty()) { + } else if (account_manager.data['trigramme'] != '' && !isBasketEmpty()) { var amount = getAmountBasket(); - var amountUKF = amountToUKF(amount, account_data['is_cof'], false); - var newBalance = account_data['balance'] + amount; - var newBalanceUKF = amountToUKF(newBalance, account_data['is_cof'], true); + var amountUKF = amountToUKF(amount, account_manager.data['is_cof'], false); + var newBalance = account_manager.data['balance'] + amount; + var newBalanceUKF = amountToUKF(newBalance, account_manager.data['is_cof'], true); basketrel_html += '
    Total: '+amountUKF+'
    '; basketrel_html += '
    Nouveau solde: '+newBalanceUKF+'
    '; if (newBalance < 0) @@ -1024,7 +800,7 @@ $(document).ready(function() { var nb_before = formset_container.find("#id_form-"+opeindex+"-article_nb").val(); var nb_after = parseInt(nb_before) + parseInt(nb); var amountEuro_after = amountEuroPurchase(id, nb_after); - var amountUKF_after = amountToUKF(amountEuro_after, account_data['is_cof']); + var amountUKF_after = amountToUKF(amountEuro_after, account_manager.data['is_cof']); if (type == 'purchase') { if (nb_after == 0) { @@ -1235,30 +1011,18 @@ $(document).ready(function() { // History // ----- - khistory = new KHistory(); - - function getHistory() { - var data = { - from: moment().subtract(1, 'days').format('YYYY-MM-DD HH:mm:ss'), - }; - $.ajax({ - dataType: "json", - url : "{% url 'kfet.history.json' %}", - method : "POST", - data : data, - }) - .done(function(data) { - for (var i=0; i'; previousop_html += basketrel_container.html(); previousop_container.html(previousop_html); @@ -1311,7 +1075,7 @@ $(document).ready(function() { }, triInput); break; case 400: - askAddcost(getErrorsHtml(data)); + askAddcost(getErrorsHtml(data["errors"], is_error=true)); break; } }); @@ -1348,29 +1112,10 @@ $(document).ready(function() { // Cancel from history // ----- - khistory.$container.selectable({ - filter: 'div.opegroup, div.ope', - selected: function(e, ui) { - $(ui.selected).each(function() { - if ($(this).hasClass('opegroup')) { - var opegroup = $(this).data('opegroup'); - $(this).siblings('.ope').filter(function() { - return $(this).data('opegroup') == opegroup - }).addClass('ui-selected'); - } - }); - }, - }); - $(document).on('keydown', function (e) { if (e.keyCode == 46) { // DEL (Suppr) - var opes_to_cancel = []; - khistory.$container.find('.ope.ui-selected').each(function () { - opes_to_cancel.push($(this).data('ope')); - }); - if (opes_to_cancel.length > 0) - cancelOperations(opes_to_cancel); + khistory.cancel_selected() } }); @@ -1379,16 +1124,9 @@ $(document).ready(function() { // ----- OperationWebSocket.add_handler(function(data) { - for (var i=0; i
    + {% if perms.kfet.is_team %}
    Éditer - - Créditer - {% if perms.kfet.delete_account %}
    + {% endif %}

    {{ account.name|title }}

      - {% if perms.kfet.is_team %}
    • {{ account.nickname }}
    • - {% endif %}
    • {{ account.email|default:"Pas d'email!" }}
    • -
    • {{ account.departement }} {{ account.promo }}
    • +
    • + {% if account.promo %} + {{ account.departement }} {{ account.promo }} + {% else %} + Sans promo + {% endif %} +
    • {% if account.is_cof %} - Adhérent COF + Membre COF + {% elif account.is_kfet %} + Membre K-Fêt {% else %} Non-COF {% endif %} @@ -47,22 +53,13 @@
    - {% if account.negative %} + {% if account.negative and account.balance_ukf < 0 %}

    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" }} -
    {% endif %} @@ -89,20 +86,20 @@ {% endif %} diff --git a/kfet/templates/kfet/mails/creation_trigramme.txt b/kfet/templates/kfet/mails/creation_trigramme.txt new file mode 100644 index 00000000..25638186 --- /dev/null +++ b/kfet/templates/kfet/mails/creation_trigramme.txt @@ -0,0 +1,12 @@ +Salut {{ account.name }}, + +Ton compte K-Fêt a bien été créé le {{ account.created_at }} avec le trigramme {{ account.trigramme }}. + +Tu peux désormais : +- Accéder à ton historique personnel des consommations : https://{{ site }}{{ url_read }} +- Modifier tes informations : https://{{ site }}{{ url_update }} +- Supprimer ton compte : https://{{ site }}{{ url_delete }} + +En espérant te revoir bientôt, +-- +L'équipe K-Fêt diff --git a/kfet/templates/kfet/mails/demande_soiree.txt b/kfet/templates/kfet/mails/demande_soiree.txt new file mode 100644 index 00000000..9fd6d635 --- /dev/null +++ b/kfet/templates/kfet/mails/demande_soiree.txt @@ -0,0 +1,16 @@ +Bonjour, + +J'aimerais organiser une soirée le {{ date }}, au thème « {{ theme|safe }} », en K-Fêt. +Elle se terminerait à {{ horaire_fin }}, et le service serait en mode {{ service }}. + +Les 4 responsables de la soirée seraient : +- {{ respo1 }} +- {{ respo2 }} +- {{ respo3 }} +- {{ respo4 }} + +Quelques remarques supplémentaires : +{{ remarques|safe }} + +Bien cordialement, +{{ nom|safe }} diff --git a/kfet/templates/kfet/mails/rappel.txt b/kfet/templates/kfet/mails/rappel.txt new file mode 100644 index 00000000..b37b2b0e --- /dev/null +++ b/kfet/templates/kfet/mails/rappel.txt @@ -0,0 +1,8 @@ +Bonjour {{ account.first_name }}, + +Nous te rappelons que tu es en négatif de {{ neg_amount }}€ depuis le {{ start_date }}. +N'oublie pas de régulariser ta situation au plus vite. + +En espérant te revoir très bientôt, +-- +L'équipe K-Fêt diff --git a/kfet/templates/kfet/nav_item.html b/kfet/templates/kfet/nav_item.html index b5981266..76a24ca4 100644 --- a/kfet/templates/kfet/nav_item.html +++ b/kfet/templates/kfet/nav_item.html @@ -11,7 +11,7 @@ -->{{ text }}{{ text }} {% if href %} diff --git a/kfet/templates/kfet/order_create.html b/kfet/templates/kfet/order_create.html index 7cb4d1cb..7e24d1c0 100644 --- a/kfet/templates/kfet/order_create.html +++ b/kfet/templates/kfet/order_create.html @@ -7,11 +7,20 @@ {% block main-size %}col-lg-8 col-lg-offset-2{% endblock %} {% block main %} +
    +
    + +
    + +
    +
    +
    {% csrf_token %}
    @@ -25,7 +34,7 @@ - {% regroup formset by category_name as category_list %} - {% for category in category_list %} - - - + {% regroup formset by is_sold as is_sold_list %} + {% for condition in is_sold_list %} + + + - - {% for form in category.list %} - - {{ form.article }} - - {% for v_chunk in form.v_all %} - - {% endfor %} - - - - - - - - - {% endfor %} - + {% regroup condition.list by category_name as category_list %} + {% for category in category_list %} + + + + + + + {% for form in category.list %} + + {{ form.article }} + + {% for v_chunk in form.v_all %} + + {% endfor %} + + + + + + + + + {% endfor %} + + {% endfor %} {% endfor %}
    V. moy.
    - +
    E.T. @@ -35,7 +44,7 @@ Prév.
    - +
    Stock @@ -46,7 +55,7 @@ Rec.
    - +
    Commande @@ -58,31 +67,39 @@ {% endfor %}
    {{ category.grouper }}
    {% if condition.grouper %} Vendu {% else %} Non vendu {% endif %}
    {{ form.name }}{{ v_chunk }}{{ form.v_moy }}{{ form.v_et }}{{ form.v_prev }}{{ form.stock }}{{ form.box_capacity|default:"" }}{{ form.c_rec }}{{ form.quantity_ordered|add_class:"form-control" }}
    {{ category.grouper }}
    {{ form.name }}{{ v_chunk }}{{ form.v_moy }}{{ form.v_et }}{{ form.v_prev }}{{ form.stock }}{{ form.box_capacity|default:"" }}{{ form.quantity_ordered|add_class:"form-control" }}
    @@ -99,6 +116,42 @@ $(document).ready(function () { $('.glyphicon-question-sign').tooltip({'html': true}) ; }); + +function compute_recommended(nb_weeks, prevision_1w, stock, box_capacity) { + if (!box_capacity) box_capacity = 1; + return Math.ceil(Math.max(Number(nb_weeks) * Number(prevision_1w) - Math.max(Number(stock), 0), 0) / Number(box_capacity)) +} + +function reload_recommended(nb_weeks) { + $(".article-row").each(function () { + const article_row = $(this) + article_row.find(".recommended").text(compute_recommended(nb_weeks, article_row.find(".prev-1w").text(), + article_row.find(".stock").text(), + article_row.find(".capacity").text() + )); + }) + $("#new-order-table").trigger("updateAll", [true, () => { + }]); +} + +$("#nb_weeks").on("change", function (e) { + const nb_weeks = e.target.value; + reload_recommended(nb_weeks); +}) + + + {% endblock %} diff --git a/kfet/templates/kfet/search_results.html b/kfet/templates/kfet/search_results.html new file mode 100644 index 00000000..d1ee70ac --- /dev/null +++ b/kfet/templates/kfet/search_results.html @@ -0,0 +1,21 @@ +{% extends "shared/search_results.html" %} +{% load i18n %} + +{% block extra_section %} +
  • + {% if not results %} + + {% trans "Aucune correspondance trouvée" %} + + {% else %} + + {% trans "Pas dans la liste ?" %} + + {% endif %} +
  • +
  • + + {% trans "Créer un compte" %} + +
  • +{% endblock %} diff --git a/kfet/templates/kfet/transfers.html b/kfet/templates/kfet/transfers.html index f6778b3f..f285b4dc 100644 --- a/kfet/templates/kfet/transfers.html +++ b/kfet/templates/kfet/transfers.html @@ -1,9 +1,15 @@ {% extends 'kfet/base_col_2.html' %} -{% load staticfiles %} +{% load l10n static widget_tweaks %} {% block title %}Transferts{% endblock %} {% block header-title %}Transferts{% endblock %} +{% block extra_head %} + + + +{% endblock %} + {% block fixed %}
    @@ -16,109 +22,31 @@ {% block main %} -
    - {% for transfergroup in transfergroups %} -
    - {{ transfergroup.at }} - {{ transfergroup.valid_by.trigramme }} - {{ transfergroup.comment }} -
    - {% for transfer in transfergroup.transfers.all %} -
    - {{ transfer.amount }} € - {{ transfer.from_acc.trigramme }} - - {{ transfer.to_acc.trigramme }} -
    - {% endfor %} - {% endfor %} -
    + +
    diff --git a/kfet/templates/kfet/transfers_create.html b/kfet/templates/kfet/transfers_create.html index b44bfd2d..41898de2 100644 --- a/kfet/templates/kfet/transfers_create.html +++ b/kfet/templates/kfet/transfers_create.html @@ -1,8 +1,9 @@ {% extends "kfet/base_col_1.html" %} -{% load staticfiles %} +{% load static %} {% block extra_head %} + {% endblock %} {% block title %}Nouveaux transferts{% endblock %} @@ -19,7 +20,7 @@ --> {{ transfer_formset.management_form }}
    - +
    @@ -34,12 +35,12 @@ @@ -51,12 +52,12 @@ diff --git a/kfet/templatetags/kfet_tags.py b/kfet/templatetags/kfet_tags.py index 4c26dd17..db4cfbf1 100644 --- a/kfet/templatetags/kfet_tags.py +++ b/kfet/templatetags/kfet_tags.py @@ -1,8 +1,4 @@ -import re - from django import template -from django.utils.html import escape -from django.utils.safestring import mark_safe from ..utils import to_ukf @@ -11,40 +7,14 @@ register = template.Library() register.filter("ukf", to_ukf) -@register.filter() -def highlight_text(text, q): - q2 = "|".join(re.escape(word) for word in q.split()) - pattern = re.compile(r"(?P%s)" % q2, re.IGNORECASE) - regex = r"\g" - return mark_safe(re.sub(pattern, regex, escape(text))) - - -@register.filter(is_safe=True) -def highlight_user(user, q): - if user.first_name and user.last_name: - text = "%s %s (%s)" % (user.first_name, user.last_name, user.username) - else: - text = user.username - return highlight_text(text, q) - - -@register.filter(is_safe=True) -def highlight_clipper(clipper, q): - if clipper.fullname: - text = "%s (%s)" % (clipper.fullname, clipper.clipper) - else: - text = clipper.clipper - return highlight_text(text, q) - - @register.filter() def widget_type(field): return field.field.widget.__class__.__name__ @register.filter() -def slice(l, start, end=None): +def slice(t, start, end=None): if end is None: end = start start = 0 - return l[start:end] + return t[start:end] diff --git a/kfet/tests/test_models.py b/kfet/tests/test_models.py index 7ce6605c..a534493d 100644 --- a/kfet/tests/test_models.py +++ b/kfet/tests/test_models.py @@ -1,10 +1,12 @@ -import datetime +from datetime import datetime, timedelta, timezone as tz +from decimal import Decimal +from unittest import mock from django.contrib.auth import get_user_model from django.test import TestCase from django.utils import timezone -from kfet.models import Account, Checkout +from kfet.models import Account, AccountNegative, Checkout from .utils import create_user @@ -28,6 +30,56 @@ class AccountTests(TestCase): with self.assertRaises(Account.DoesNotExist): Account.objects.get_by_password("bernard") + @mock.patch("django.utils.timezone.now") + def test_negative_creation(self, mock_now): + now = datetime(2005, 7, 15, tzinfo=tz.utc) + mock_now.return_value = now + self.account.balance = Decimal(-10) + self.account.update_negative() + + self.assertTrue(hasattr(self.account, "negative")) + self.assertEqual(self.account.negative.start, now) + + @mock.patch("django.utils.timezone.now") + def test_negative_no_reset(self, mock_now): + now = datetime(2005, 7, 15, tzinfo=tz.utc) + mock_now.return_value = now + + self.account.balance = Decimal(-10) + AccountNegative.objects.create( + account=self.account, start=now - timedelta(minutes=3) + ) + self.account.refresh_from_db() + + self.account.balance = Decimal(5) + self.account.update_negative() + self.assertTrue(hasattr(self.account, "negative")) + + self.account.balance = Decimal(-10) + self.account.update_negative() + self.assertEqual(self.account.negative.start, now - timedelta(minutes=3)) + + @mock.patch("django.utils.timezone.now") + def test_negative_eventually_resets(self, mock_now): + now = datetime(2005, 7, 15, tzinfo=tz.utc) + mock_now.return_value = now + + self.account.balance = Decimal(-10) + AccountNegative.objects.create( + account=self.account, start=now - timedelta(minutes=20) + ) + self.account.refresh_from_db() + self.account.balance = Decimal(5) + + mock_now.return_value = now - timedelta(minutes=10) + self.account.update_negative() + + mock_now.return_value = now + self.account.update_negative() + self.account.refresh_from_db() + + self.assertFalse(hasattr(self.account, "negative")) + class CheckoutTests(TestCase): def setUp(self): @@ -39,7 +91,7 @@ class CheckoutTests(TestCase): self.c = Checkout( created_by=self.u_acc, valid_from=self.now, - valid_to=self.now + datetime.timedelta(days=1), + valid_to=self.now + timedelta(days=1), ) def test_initial_statement(self): diff --git a/kfet/tests/test_statistic.py b/kfet/tests/test_statistic.py index eda386b7..6d8ecb47 100644 --- a/kfet/tests/test_statistic.py +++ b/kfet/tests/test_statistic.py @@ -18,7 +18,9 @@ class TestStats(TestCase): user.set_password("foobar") user.save() Account.objects.create(trigramme="FOO", cofprofile=user.profile) - perm = Permission.objects.get(codename="is_team") + perm = Permission.objects.get( + codename="is_team", content_type__app_label="kfet" + ) user.user_permissions.add(perm) user2 = User.objects.create(username="Barfoo") @@ -42,9 +44,9 @@ class TestStats(TestCase): "/k-fet/accounts/FOO/stat/operations?{}".format( "&".join( [ - "scale=day", - "types=['purchase']", - "scale_args={'n_steps':+7,+'last':+True}", + "scale-name=day", + "scale-n_steps=7", + "scale-last=True", "format=json", ] ) @@ -62,10 +64,20 @@ class TestStats(TestCase): # receives a Redirect response articles_urls = [ "/k-fet/articles/{}/stat/sales/list".format(article.pk), - "/k-fet/articles/{}/stat/sales".format(article.pk), + "/k-fet/articles/{}/stat/sales?{}".format( + article.pk, + "&".join( + [ + "scale-name=day", + "scale-n_steps=7", + "scale-last=True", + "format=json", + ] + ), + ), ] for url in articles_urls: resp = client.get(url) self.assertEqual(200, resp.status_code) resp2 = client2.get(url, follow=True) - self.assertRedirects(resp2, "/") + self.assertRedirects(resp2, "/gestion/") diff --git a/kfet/tests/test_tests_utils.py b/kfet/tests/test_tests_utils.py index 49661e23..2c42ff79 100644 --- a/kfet/tests/test_tests_utils.py +++ b/kfet/tests/test_tests_utils.py @@ -94,6 +94,7 @@ class PermHelpersTest(TestCaseMixin, TestCase): self.assertQuerysetEqual( user.user_permissions.all(), map(repr, [self.perm1, self.perm2, self.perm_team]), + transform=repr, ordered=False, ) diff --git a/kfet/tests/test_views.py b/kfet/tests/test_views.py index 432a77e8..d09ff3e8 100644 --- a/kfet/tests/test_views.py +++ b/kfet/tests/test_views.py @@ -3,17 +3,20 @@ from datetime import datetime, timedelta from decimal import Decimal from unittest import mock -from django.contrib.auth.models import Group +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.contrib.auth.models import User from django.test import Client, TestCase from django.urls import reverse from django.utils import timezone from .. import KFET_DELETED_TRIGRAMME from ..auth import KFET_GENERIC_TRIGRAMME +from ..auth.models import KFetGroup +from ..auth.utils import hash_password from ..config import kfet_config from ..models import ( Account, - AccountNegative, Article, ArticleCategory, Checkout, @@ -181,10 +184,14 @@ class AccountCreateAutocompleteViewTests(ViewTestCaseMixin, TestCase): 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.assertEqual(len(r.context["results"]), 1) + (res,) = r.context["results"] + self.assertEqual(res.name, "kfet") + + u = self.users["user"] self.assertSetEqual( - set(r.context["kfet"]), set([(self.accounts["user"], self.users["user"])]) + {e.verbose_name for e in res.entries}, + {"{} ({})".format(u, u.profile.account_kfet.trigramme)}, ) @@ -198,7 +205,12 @@ class AccountSearchViewTests(ViewTestCaseMixin, TestCase): 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")])) + + u = self.users["user"] + self.assertSetEqual( + {e.verbose_name for e in r.context["results"][0].entries}, + {"{} ({})".format(u, u.profile.account_kfet.trigramme)}, + ) class AccountReadViewTests(ViewTestCaseMixin, TestCase): @@ -221,7 +233,9 @@ class AccountReadViewTests(ViewTestCaseMixin, TestCase): if user is None: response = client.get(url) self.assertRedirects( - response, "/login?next={}".format(url), fetch_redirect_response=False + response, + "/gestion/login?next={}".format(url), + fetch_redirect_response=False, ) else: client.login(username=user, password=user) @@ -284,8 +298,8 @@ class AccountReadViewTests(ViewTestCaseMixin, TestCase): class AccountUpdateViewTests(ViewTestCaseMixin, TestCase): url_name = "kfet.account.update" - url_kwargs = {"trigramme": "001"} - url_expected = "/k-fet/accounts/001/edit" + url_kwargs = {"trigramme": "100"} + url_expected = "/k-fet/accounts/100/edit" http_methods = ["GET", "POST"] @@ -305,33 +319,25 @@ class AccountUpdateViewTests(ViewTestCaseMixin, TestCase): "promo": "", # 'is_frozen': not checked # Account password - "pwd1": "", - "pwd2": "", + "pwd1": "changed_pwd", + "pwd2": "changed_pwd", } def get_users_extra(self): return { - "user1": create_user("user1", "001"), "team1": create_team("team1", "101", perms=["kfet.change_account"]), + "team2": create_team("team2", "102"), } - # Users with forbidden access users should get a 404 here, to avoid leaking trigrams - # See issue #224 - def test_forbidden(self): - for method in ["get", "post"]: - for user in self.auth_forbidden: - self.assertRedirectsToLoginOr404(user, method, self.url_expected) - self.assertRedirectsToLoginOr404( - user, method, "/k-fet/accounts/NEX/edit" - ) - def assertRedirectsToLoginOr404(self, user, method, url): client = Client() meth = getattr(client, method) if user is None: response = meth(url) self.assertRedirects( - response, "/login?next={}".format(url), fetch_redirect_response=False + response, + "/gestion/login?next={}".format(url), + fetch_redirect_response=False, ) else: client.login(username=user, password=user) @@ -342,46 +348,55 @@ class AccountUpdateViewTests(ViewTestCaseMixin, TestCase): 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) + r = client.post(self.url, self.post_data, follow=True) self.assertRedirects(r, reverse("kfet.account.read", args=["051"])) - self.accounts["user1"].refresh_from_db() - self.users["user1"].refresh_from_db() + # Comportement attendu : compte modifié, + # utilisateur/mdp inchangé, warning pour le mdp + + self.accounts["team"].refresh_from_db() + self.users["team"].refresh_from_db() self.assertInstanceExpected( - self.accounts["user1"], - {"first_name": "first", "last_name": "last", "trigramme": "051"}, + self.accounts["team"], + {"first_name": "team", "last_name": "member", "trigramme": "051"}, + ) + self.assertEqual(self.accounts["team"].password, hash_password("kfetpwd_team")) + + self.assertTrue( + any("mot de passe" in str(msg).casefold() for msg in r.context["messages"]) ) def test_post_ok_self(self): - client = Client() - client.login(username="user1", password="user1") + r = self.client.post(self.url, self.post_data, follow=True) + self.assertRedirects(r, reverse("kfet.account.read", args=["051"])) - post_data = {"first_name": "The first", "last_name": "The last"} + self.accounts["team"].refresh_from_db() + self.users["team"].refresh_from_db() - 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() + # Comportement attendu : compte/mdp modifié, utilisateur inchangé self.assertInstanceExpected( - self.accounts["user1"], {"first_name": "first", "last_name": "last"} + self.accounts["team"], + {"first_name": "team", "last_name": "member", "trigramme": "051"}, ) + self.assertEqual(self.accounts["team"].password, hash_password("changed_pwd")) def test_post_forbidden(self): - r = self.client.post(self.url, self.post_data) - self.assertForbiddenKfet(r) + client = Client() + client.login(username="team2", password="team2") + r = client.post(self.url, self.post_data) + + self.assertTrue( + any( + "permission refusée" in str(msg).casefold() + for msg in r.context["messages"] + ) + ) class AccountDeleteViewTests(ViewTestCaseMixin, TestCase): @@ -451,15 +466,18 @@ class AccountGroupListViewTests(ViewTestCaseMixin, TestCase): 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") + self.group1 = KFetGroup.objects.create(name="Group1") + self.group2 = KFetGroup.objects.create(name="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 + r.context["groups"], + [self.group1.pk, self.group2.pk], + transform=lambda group: group.pk, + ordered=False, ) @@ -497,11 +515,12 @@ class AccountGroupCreateViewTests(ViewTestCaseMixin, TestCase): 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") + group = KFetGroup.objects.get(name="The Group") self.assertQuerysetEqual( group.permissions.all(), map(repr, [self.perms["kfet.is_team"], self.perms["kfet.manage_perms"]]), + transform=repr, ordered=False, ) @@ -538,7 +557,7 @@ class AccountGroupUpdateViewTests(ViewTestCaseMixin, TestCase): 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 = KFetGroup.objects.create(name="Group") self.group.permissions.set(self.perms.values()) def test_get_ok(self): @@ -551,10 +570,11 @@ class AccountGroupUpdateViewTests(ViewTestCaseMixin, TestCase): self.group.refresh_from_db() - self.assertEqual(self.group.name, "K-Fêt The Group") + self.assertEqual(self.group.name, "The Group") self.assertQuerysetEqual( self.group.permissions.all(), map(repr, [self.perms["kfet.is_team"], self.perms["kfet.manage_perms"]]), + transform=repr, ordered=False, ) @@ -582,6 +602,7 @@ class AccountNegativeListViewTests(ViewTestCaseMixin, TestCase): self.assertQuerysetEqual( r.context["negatives"], map(repr, [self.accounts["user"].negative]), + transform=repr, ordered=False, ) @@ -611,7 +632,9 @@ class AccountStatOperationListViewTests(ViewTestCaseMixin, TestCase): if user is None: response = client.get(url) self.assertRedirects( - response, "/login?next={}".format(url), fetch_redirect_response=False + response, + "/gestion/login?next={}".format(url), + fetch_redirect_response=False, ) else: client.login(username=user, password=user) @@ -628,38 +651,50 @@ class AccountStatOperationListViewTests(ViewTestCaseMixin, TestCase): expected_stats = [ { - "label": "Derniers mois", + "label": "Tout le temps", "url": { "path": base_url, "query": { - "scale_n_steps": ["7"], - "scale_name": ["month"], - "types": ["['purchase']"], - "scale_last": ["True"], + "scale-name": ["month"], + "scale-last": ["True"], + "scale-begin": [ + self.accounts["user1"] + .created_at.replace(tzinfo=None) + .isoformat(" ") + ], }, }, }, { - "label": "Dernières semaines", + "label": "1 an", "url": { "path": base_url, "query": { - "scale_n_steps": ["7"], - "scale_name": ["week"], - "types": ["['purchase']"], - "scale_last": ["True"], + "scale-n_steps": ["12"], + "scale-name": ["month"], + "scale-last": ["True"], }, }, }, { - "label": "Derniers jours", + "label": "3 mois", "url": { "path": base_url, "query": { - "scale_n_steps": ["7"], - "scale_name": ["day"], - "types": ["['purchase']"], - "scale_last": ["True"], + "scale-n_steps": ["13"], + "scale-name": ["week"], + "scale-last": ["True"], + }, + }, + }, + { + "label": "2 semaines", + "url": { + "path": base_url, + "query": { + "scale-n_steps": ["14"], + "scale-name": ["day"], + "scale-last": ["True"], }, }, }, @@ -668,7 +703,7 @@ class AccountStatOperationListViewTests(ViewTestCaseMixin, TestCase): for stat, expected in zip(content["stats"], expected_stats): expected_url = expected.pop("url") self.assertUrlsEqual(stat["url"], expected_url) - self.assertDictContainsSubset(expected, stat) + self.assertEqual(stat, {**stat, **expected}) class AccountStatOperationViewTests(ViewTestCaseMixin, TestCase): @@ -693,7 +728,9 @@ class AccountStatOperationViewTests(ViewTestCaseMixin, TestCase): if user is None: response = client.get(url) self.assertRedirects( - response, "/login?next={}".format(url), fetch_redirect_response=False + response, + "/gestion/login?next={}".format(url), + fetch_redirect_response=False, ) else: client.login(username=user, password=user) @@ -704,7 +741,9 @@ class AccountStatOperationViewTests(ViewTestCaseMixin, TestCase): return {"user1": create_user("user1", "001")} def test_ok(self): - r = self.client.get(self.url) + r = self.client.get( + self.url, {"scale-name": "day", "scale-n_steps": 7, "scale-last": True} + ) self.assertEqual(r.status_code, 200) @@ -730,7 +769,9 @@ class AccountStatBalanceListViewTests(ViewTestCaseMixin, TestCase): if user is None: response = client.get(url) self.assertRedirects( - response, "/login?next={}".format(url), fetch_redirect_response=False + response, + "/gestion/login?next={}".format(url), + fetch_redirect_response=False, ) else: client.login(username=user, password=user) @@ -771,7 +812,7 @@ class AccountStatBalanceListViewTests(ViewTestCaseMixin, TestCase): for stat, expected in zip(content["stats"], expected_stats): expected_url = expected.pop("url") self.assertUrlsEqual(stat["url"], expected_url) - self.assertDictContainsSubset(expected, stat) + self.assertEqual(stat, {**stat, **expected}) class AccountStatBalanceViewTests(ViewTestCaseMixin, TestCase): @@ -794,7 +835,9 @@ class AccountStatBalanceViewTests(ViewTestCaseMixin, TestCase): if user is None: response = client.get(url) self.assertRedirects( - response, "/login?next={}".format(url), fetch_redirect_response=False + response, + "/gestion/login?next={}".format(url), + fetch_redirect_response=False, ) else: client.login(username=user, password=user) @@ -837,6 +880,7 @@ class CheckoutListViewTests(ViewTestCaseMixin, TestCase): self.assertQuerysetEqual( r.context["checkouts"], map(repr, [self.checkout1, self.checkout2]), + transform=repr, ordered=False, ) @@ -1027,6 +1071,7 @@ class CheckoutStatementListViewTests(ViewTestCaseMixin, TestCase): self.assertQuerysetEqual( r.context["checkoutstatements"], map(repr, expected_statements), + transform=repr, ordered=False, ) @@ -1253,7 +1298,9 @@ class ArticleCategoryListViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(r.status_code, 200) self.assertQuerysetEqual( - r.context["categories"], map(repr, [self.category1, self.category2]) + r.context["categories"], + map(repr, [self.category1, self.category2]), + transform=repr, ) @@ -1328,7 +1375,9 @@ class ArticleListViewTests(ViewTestCaseMixin, TestCase): r = self.client.get(self.url) self.assertEqual(r.status_code, 200) self.assertQuerysetEqual( - r.context["articles"], map(repr, [self.article1, self.article2]) + r.context["articles"], + map(repr, [self.article1, self.article2]), + transform=repr, ) @@ -1524,6 +1573,21 @@ class ArticleStatSalesListViewTests(ViewTestCaseMixin, TestCase): self.article = Article.objects.create( name="Article", category=ArticleCategory.objects.create(name="Category") ) + 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.opegroup = create_operation_group( + on_acc=self.accounts["user"], + checkout=checkout, + content=[ + {"type": Operation.PURCHASE, "article": self.article, "article_nb": 2}, + ], + ) def test_ok(self): r = self.client.get(self.url) @@ -1535,35 +1599,46 @@ class ArticleStatSalesListViewTests(ViewTestCaseMixin, TestCase): expected_stats = [ { - "label": "Derniers mois", + "label": "Tout le temps", "url": { "path": base_url, "query": { - "scale_n_steps": ["7"], - "scale_name": ["month"], - "scale_last": ["True"], + "scale-name": ["month"], + "scale-last": ["True"], + "scale-begin": [self.opegroup.at.strftime("%Y-%m-%d %H:%M:%S")], }, }, }, { - "label": "Dernières semaines", + "label": "1 an", "url": { "path": base_url, "query": { - "scale_n_steps": ["7"], - "scale_name": ["week"], - "scale_last": ["True"], + "scale-n_steps": ["12"], + "scale-name": ["month"], + "scale-last": ["True"], }, }, }, { - "label": "Derniers jours", + "label": "3 mois", "url": { "path": base_url, "query": { - "scale_n_steps": ["7"], - "scale_name": ["day"], - "scale_last": ["True"], + "scale-n_steps": ["13"], + "scale-name": ["week"], + "scale-last": ["True"], + }, + }, + }, + { + "label": "2 semaines", + "url": { + "path": base_url, + "query": { + "scale-n_steps": ["14"], + "scale-name": ["day"], + "scale-last": ["True"], }, }, }, @@ -1572,7 +1647,7 @@ class ArticleStatSalesListViewTests(ViewTestCaseMixin, TestCase): for stat, expected in zip(content["stats"], expected_stats): expected_url = expected.pop("url") self.assertUrlsEqual(stat["url"], expected_url) - self.assertDictContainsSubset(expected, stat) + self.assertEqual(stat, {**stat, **expected}) class ArticleStatSalesViewTests(ViewTestCaseMixin, TestCase): @@ -1596,7 +1671,9 @@ class ArticleStatSalesViewTests(ViewTestCaseMixin, TestCase): ) def test_ok(self): - r = self.client.get(self.url) + r = self.client.get( + self.url, {"scale-name": "day", "scale-n_steps": 7, "scale-last": True} + ) self.assertEqual(r.status_code, 200) @@ -1639,7 +1716,7 @@ class KPsulCheckoutDataViewTests(ViewTestCaseMixin, TestCase): expected = {"name": "Checkout", "balance": "10.00"} - self.assertDictContainsSubset(expected, content) + self.assertEqual(content, {**content, **expected}) self.assertSetEqual( set(content.keys()), @@ -1726,16 +1803,29 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): price=Decimal("2.5"), stock=20, ) + # Another Article, price=2.5, stock=20, no COF reduction + self.article_no_reduction = Article.objects.create( + category=ArticleCategory.objects.create( + name="Category_no_reduction", + has_reduction=False, + ), + name="Article_no_reduction", + price=Decimal("2.5"), + stock=20, + ) # An Account, trigramme=000, balance=50 # Do not assume user is cof, nor not cof. self.account = self.accounts["user"] self.account.balance = Decimal("50.00") self.account.save() - # Mock consumer of K-Psul websocket to catch what we're sending - kpsul_consumer_patcher = mock.patch("kfet.consumers.KPsul") - self.kpsul_consumer_mock = kpsul_consumer_patcher.start() - self.addCleanup(kpsul_consumer_patcher.stop) + # Create a channel to listen to KPsul's messages + channel_layer = get_channel_layer() + self.channel = async_to_sync(channel_layer.new_channel)() + + async_to_sync(channel_layer.group_add)("kfet.kpsul", self.channel) + + self.receive_msg = lambda: async_to_sync(channel_layer.receive)(self.channel) # Reset cache of kfet config kfet_config._conf_init = False @@ -1779,7 +1869,10 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual(json_data["errors"]["operation_group"], ["on_acc"]) + self.assertCountEqual( + [e["code"] for e in json_data["errors"]], + ["invalid_on_acc", "invalid_formset"], + ) def test_group_on_acc_expects_comment(self): user_add_perms(self.users["team"], ["kfet.perform_commented_operations"]) @@ -1822,7 +1915,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual(json_data["errors"]["need_comment"], True) + self.assertEqual(json_data["need_comment"], True) def test_invalid_group_on_acc_needs_comment_requires_perm(self): self.account.trigramme = "#13" @@ -1845,12 +1938,11 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 403) json_data = json.loads(resp.content.decode("utf-8")) self.assertEqual( - json_data["errors"]["missing_perms"], + json_data["missing_perms"], ["Enregistrer des commandes avec commentaires"], ) - def test_group_on_acc_frozen(self): - user_add_perms(self.users["team"], ["kfet.override_frozen_protection"]) + def test_error_on_acc_frozen(self): self.account.is_frozen = True self.account.save() @@ -1867,30 +1959,9 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): ) resp = self.client.post(self.url, data) - self._assertResponseOk(resp) - - def test_invalid_group_on_acc_frozen_requires_perm(self): - self.account.is_frozen = True - self.account.save() - - data = dict( - self.base_post_data, - **{ - "comment": "A comment to explain it", - "form-TOTAL_FORMS": "1", - "form-0-type": "purchase", - "form-0-amount": "", - "form-0-article": str(self.article.pk), - "form-0-article_nb": "2", - } - ) - resp = self.client.post(self.url, data) - - self.assertEqual(resp.status_code, 403) + self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual( - json_data["errors"]["missing_perms"], ["Forcer le gel d'un compte"] - ) + self.assertEqual([e["code"] for e in json_data["errors"]], ["frozen_acc"]) def test_invalid_group_checkout(self): self.checkout.valid_from -= timedelta(days=300) @@ -1902,7 +1973,10 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual(json_data["errors"]["operation_group"], ["checkout"]) + self.assertCountEqual( + [e["code"] for e in json_data["errors"]], + ["invalid_checkout", "invalid_formset"], + ) def test_invalid_group_expects_one_operation(self): data = dict(self.base_post_data) @@ -1910,7 +1984,10 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual(json_data["errors"]["operations"], []) + self.assertCountEqual( + [e["code"] for e in json_data["errors"]], + ["invalid_formset"], + ) def test_purchase_with_user_is_nof_cof(self): self.account.cofprofile.is_cof = False @@ -1968,12 +2045,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): # Check response content self.assertDictEqual( json_data, - { - "operationgroup": operation_group.pk, - "operations": [operation.pk], - "warnings": {}, - "errors": {}, - }, + {"errors": []}, ) # Check object updates @@ -1985,12 +2057,16 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(self.article.stock, 18) # Check websocket data - self.kpsul_consumer_mock.group_send.assert_called_once_with( - "kfet.kpsul", + ws_data = self.receive_msg() + + self.assertDictEqual( + ws_data, { - "opegroups": [ + "type": "kpsul", + "groups": [ { "add": True, + "type": "operation", "at": mock.ANY, "amount": Decimal("-5.00"), "checkout__name": "Checkout", @@ -1999,7 +2075,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): "is_cof": False, "on_acc__trigramme": "000", "valid_by__trigramme": None, - "opes": [ + "entries": [ { "id": operation.pk, "addcost_amount": None, @@ -2079,6 +2155,35 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.article.refresh_from_db() self.assertEqual(self.article.stock, 18) + def test_purchase_no_reduction(self): + kfet_config.set(kfet_reduction_cof=Decimal("20")) + self.account.cofprofile.is_cof = True + self.account.cofprofile.save() + data = dict( + self.base_post_data, + **{ + "form-TOTAL_FORMS": "2", + "form-0-type": "purchase", + "form-0-amount": "", + "form-0-article": str(self.article_no_reduction.pk), + "form-0-article_nb": "1", + "form-1-type": "purchase", + "form-1-amount": "", + "form-1-article": str(self.article.pk), + "form-1-article_nb": "1", + } + ) + + resp = self.client.post(self.url, data) + self._assertResponseOk(resp) + + operation_group = OperationGroup.objects.get() + self.assertEqual(operation_group.amount, Decimal("-4.50")) + operation = Operation.objects.get(article=self.article) + self.assertEqual(operation.amount, Decimal("-2.00")) + operation = Operation.objects.get(article=self.article_no_reduction) + self.assertEqual(operation.amount, Decimal("-2.50")) + def test_invalid_purchase_expects_article(self): data = dict( self.base_post_data, @@ -2094,9 +2199,9 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual( - json_data["errors"]["operations"], - [{"__all__": ["Un achat nécessite un article et une quantité"]}], + self.assertCountEqual( + [e["code"] for e in json_data["errors"]], + ["invalid_formset"], ) def test_invalid_purchase_expects_article_nb(self): @@ -2114,9 +2219,9 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual( - json_data["errors"]["operations"], - [{"__all__": ["Un achat nécessite un article et une quantité"]}], + self.assertCountEqual( + [e["code"] for e in json_data["errors"]], + ["invalid_formset"], ) def test_invalid_purchase_expects_article_nb_greater_than_1(self): @@ -2134,16 +2239,9 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual( - json_data["errors"]["operations"], - [ - { - "__all__": ["Un achat nécessite un article et une quantité"], - "article_nb": [ - "Assurez-vous que cette valeur est supérieure ou " "égale à 1." - ], - } - ], + self.assertCountEqual( + [e["code"] for e in json_data["errors"]], + ["invalid_formset"], ) def test_invalid_operation_not_purchase_with_cash(self): @@ -2162,7 +2260,10 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual(json_data["errors"]["account"], "LIQ") + self.assertCountEqual( + [e["code"] for e in json_data["errors"]], + ["invalid_liq"], + ) def test_deposit(self): user_add_perms(self.users["team"], ["kfet.perform_deposit"]) @@ -2215,12 +2316,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertDictEqual( json_data, - { - "operationgroup": operation_group.pk, - "operations": [operation.pk], - "warnings": {}, - "errors": {}, - }, + {"errors": []}, ) self.account.refresh_from_db() @@ -2228,12 +2324,16 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.checkout.refresh_from_db() self.assertEqual(self.checkout.balance, Decimal("110.75")) - self.kpsul_consumer_mock.group_send.assert_called_once_with( - "kfet.kpsul", + ws_data = self.receive_msg() + + self.assertDictEqual( + ws_data, { - "opegroups": [ + "type": "kpsul", + "groups": [ { "add": True, + "type": "operation", "at": mock.ANY, "amount": Decimal("10.75"), "checkout__name": "Checkout", @@ -2242,7 +2342,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): "is_cof": False, "on_acc__trigramme": "000", "valid_by__trigramme": "100", - "opes": [ + "entries": [ { "id": operation.pk, "addcost_amount": None, @@ -2278,8 +2378,9 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual( - json_data["errors"]["operations"], [{"__all__": ["Bad request"]}] + self.assertCountEqual( + [e["code"] for e in json_data["errors"]], + ["invalid_formset"], ) def test_invalid_deposit_too_many_params(self): @@ -2297,8 +2398,9 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual( - json_data["errors"]["operations"], [{"__all__": ["Bad request"]}] + self.assertCountEqual( + [e["code"] for e in json_data["errors"]], + ["invalid_formset"], ) def test_invalid_deposit_expects_positive_amount(self): @@ -2316,8 +2418,9 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual( - json_data["errors"]["operations"], [{"__all__": ["Charge non positive"]}] + self.assertCountEqual( + [e["code"] for e in json_data["errors"]], + ["invalid_formset"], ) def test_invalid_deposit_requires_perm(self): @@ -2335,7 +2438,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 403) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual(json_data["errors"]["missing_perms"], ["Effectuer une charge"]) + self.assertEqual(json_data["missing_perms"], ["Effectuer une charge"]) def test_withdraw(self): data = dict( @@ -2387,12 +2490,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertDictEqual( json_data, - { - "operationgroup": operation_group.pk, - "operations": [operation.pk], - "warnings": {}, - "errors": {}, - }, + {"errors": []}, ) self.account.refresh_from_db() @@ -2400,12 +2498,16 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.checkout.refresh_from_db() self.assertEqual(self.checkout.balance, Decimal("89.25")) - self.kpsul_consumer_mock.group_send.assert_called_once_with( - "kfet.kpsul", + ws_data = self.receive_msg() + + self.assertDictEqual( + ws_data, { - "opegroups": [ + "type": "kpsul", + "groups": [ { "add": True, + "type": "operation", "at": mock.ANY, "amount": Decimal("-10.75"), "checkout__name": "Checkout", @@ -2414,7 +2516,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): "is_cof": False, "on_acc__trigramme": "000", "valid_by__trigramme": None, - "opes": [ + "entries": [ { "id": operation.pk, "addcost_amount": None, @@ -2450,8 +2552,9 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual( - json_data["errors"]["operations"], [{"__all__": ["Bad request"]}] + self.assertCountEqual( + [e["code"] for e in json_data["errors"]], + ["invalid_formset"], ) def test_invalid_withdraw_too_many_params(self): @@ -2469,8 +2572,9 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual( - json_data["errors"]["operations"], [{"__all__": ["Bad request"]}] + self.assertCountEqual( + [e["code"] for e in json_data["errors"]], + ["invalid_formset"], ) def test_invalid_withdraw_expects_negative_amount(self): @@ -2488,8 +2592,9 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual( - json_data["errors"]["operations"], [{"__all__": ["Retrait non négatif"]}] + self.assertCountEqual( + [e["code"] for e in json_data["errors"]], + ["invalid_formset"], ) def test_edit(self): @@ -2545,12 +2650,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertDictEqual( json_data, - { - "operationgroup": operation_group.pk, - "operations": [operation.pk], - "warnings": {}, - "errors": {}, - }, + {"errors": []}, ) self.account.refresh_from_db() @@ -2558,12 +2658,16 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.checkout.refresh_from_db() self.assertEqual(self.checkout.balance, Decimal("100.00")) - self.kpsul_consumer_mock.group_send.assert_called_once_with( - "kfet.kpsul", + ws_data = self.receive_msg() + + self.assertDictEqual( + ws_data, { - "opegroups": [ + "type": "kpsul", + "groups": [ { "add": True, + "type": "operation", "at": mock.ANY, "amount": Decimal("10.75"), "checkout__name": "Checkout", @@ -2572,7 +2676,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): "is_cof": False, "on_acc__trigramme": "000", "valid_by__trigramme": "100", - "opes": [ + "entries": [ { "id": operation.pk, "addcost_amount": None, @@ -2610,7 +2714,8 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 403) json_data = json.loads(resp.content.decode("utf-8")) self.assertEqual( - json_data["errors"]["missing_perms"], ["Modifier la balance d'un compte"] + json_data["missing_perms"], + ["Modifier la balance d'un compte"], ) def test_invalid_edit_expects_comment(self): @@ -2630,7 +2735,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual(json_data["errors"]["need_comment"], True) + self.assertEqual(json_data["need_comment"], True) def _setup_addcost(self): self.register_user("addcost", create_user("addcost", "ADD")) @@ -2671,9 +2776,9 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.checkout.refresh_from_db() self.assertEqual(self.checkout.balance, Decimal("100.00")) - ws_data_ope = self.kpsul_consumer_mock.group_send.call_args[0][1]["opegroups"][ - 0 - ]["opes"][0] + ws_data = self.receive_msg() + ws_data_ope = ws_data["groups"][0]["entries"][0] + self.assertEqual(ws_data_ope["addcost_amount"], Decimal("1.00")) self.assertEqual(ws_data_ope["addcost_for__trigramme"], "ADD") @@ -2711,9 +2816,9 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.checkout.refresh_from_db() self.assertEqual(self.checkout.balance, Decimal("100.00")) - ws_data_ope = self.kpsul_consumer_mock.group_send.call_args[0][1]["opegroups"][ - 0 - ]["opes"][0] + ws_data = self.receive_msg() + ws_data_ope = ws_data["groups"][0]["entries"][0] + self.assertEqual(ws_data_ope["addcost_amount"], Decimal("0.80")) self.assertEqual(ws_data_ope["addcost_for__trigramme"], "ADD") @@ -2749,9 +2854,9 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.checkout.refresh_from_db() self.assertEqual(self.checkout.balance, Decimal("106.00")) - ws_data_ope = self.kpsul_consumer_mock.group_send.call_args[0][1]["opegroups"][ - 0 - ]["opes"][0] + ws_data = self.receive_msg() + ws_data_ope = ws_data["groups"][0]["entries"][0] + self.assertEqual(ws_data_ope["addcost_amount"], Decimal("1.00")) self.assertEqual(ws_data_ope["addcost_for__trigramme"], "ADD") @@ -2785,9 +2890,9 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.accounts["addcost"].refresh_from_db() self.assertEqual(self.accounts["addcost"].balance, Decimal("15.00")) - ws_data_ope = self.kpsul_consumer_mock.group_send.call_args[0][1]["opegroups"][ - 0 - ]["opes"][0] + ws_data = self.receive_msg() + ws_data_ope = ws_data["groups"][0]["entries"][0] + self.assertEqual(ws_data_ope["addcost_amount"], None) self.assertEqual(ws_data_ope["addcost_for__trigramme"], None) @@ -2820,9 +2925,9 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.accounts["addcost"].refresh_from_db() self.assertEqual(self.accounts["addcost"].balance, Decimal("0.00")) - ws_data_ope = self.kpsul_consumer_mock.group_send.call_args[0][1]["opegroups"][ - 0 - ]["opes"][0] + ws_data = self.receive_msg() + ws_data_ope = ws_data["groups"][0]["entries"][0] + self.assertEqual(ws_data_ope["addcost_amount"], None) self.assertEqual(ws_data_ope["addcost_for__trigramme"], None) @@ -2917,62 +3022,10 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 403) json_data = json.loads(resp.content.decode("utf-8")) self.assertEqual( - json_data["errors"], - {"missing_perms": ["Enregistrer des commandes en négatif"]}, + json_data["missing_perms"], + ["Enregistrer des commandes en négatif"], ) - def test_invalid_negative_exceeds_allowed_duration_from_config(self): - user_add_perms(self.users["team"], ["kfet.perform_negative_operations"]) - kfet_config.set(overdraft_duration=timedelta(days=5)) - self.account.balance = Decimal("1.00") - self.account.save() - self.account.negative = AccountNegative.objects.create( - account=self.account, start=timezone.now() - timedelta(days=5, minutes=1) - ) - - data = dict( - self.base_post_data, - **{ - "form-TOTAL_FORMS": "1", - "form-0-type": "purchase", - "form-0-amount": "", - "form-0-article": str(self.article.pk), - "form-0-article_nb": "2", - } - ) - resp = self.client.post(self.url, data) - - self.assertEqual(resp.status_code, 403) - json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual(json_data["errors"], {"negative": ["000"]}) - - def test_invalid_negative_exceeds_allowed_duration_from_account(self): - user_add_perms(self.users["team"], ["kfet.perform_negative_operations"]) - kfet_config.set(overdraft_duration=timedelta(days=5)) - self.account.balance = Decimal("1.00") - self.account.save() - self.account.negative = AccountNegative.objects.create( - account=self.account, - start=timezone.now() - timedelta(days=3), - authz_overdraft_until=timezone.now() - timedelta(seconds=1), - ) - - data = dict( - self.base_post_data, - **{ - "form-TOTAL_FORMS": "1", - "form-0-type": "purchase", - "form-0-amount": "", - "form-0-article": str(self.article.pk), - "form-0-article_nb": "2", - } - ) - resp = self.client.post(self.url, data) - - self.assertEqual(resp.status_code, 403) - json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual(json_data["errors"], {"negative": ["000"]}) - def test_invalid_negative_exceeds_amount_allowed_from_config(self): user_add_perms(self.users["team"], ["kfet.perform_negative_operations"]) kfet_config.set(overdraft_amount=Decimal("-1.00")) @@ -2992,38 +3045,13 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): ) resp = self.client.post(self.url, data) - self.assertEqual(resp.status_code, 403) + self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual(json_data["errors"], {"negative": ["000"]}) - - def test_invalid_negative_exceeds_amount_allowed_from_account(self): - user_add_perms(self.users["team"], ["kfet.perform_negative_operations"]) - kfet_config.set(overdraft_amount=Decimal("10.00")) - self.account.balance = Decimal("1.00") - self.account.save() - self.account.update_negative() - self.account.negative = AccountNegative.objects.create( - account=self.account, - start=timezone.now() - timedelta(days=3), - authz_overdraft_amount=Decimal("1.00"), + self.assertCountEqual( + [e["code"] for e in json_data["errors"]], + ["negative"], ) - data = dict( - self.base_post_data, - **{ - "form-TOTAL_FORMS": "1", - "form-0-type": "purchase", - "form-0-amount": "", - "form-0-article": str(self.article.pk), - "form-0-article_nb": "2", - } - ) - resp = self.client.post(self.url, data) - - self.assertEqual(resp.status_code, 403) - json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual(json_data["errors"], {"negative": ["000"]}) - def test_multi_0(self): article2 = Article.objects.create( name="Article 2", @@ -3107,12 +3135,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): # Check response content self.assertDictEqual( json_data, - { - "operationgroup": operation_group.pk, - "operations": [operation_list[0].pk, operation_list[1].pk], - "warnings": {}, - "errors": {}, - }, + {"errors": []}, ) # Check object updates @@ -3126,12 +3149,16 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(article2.stock, -6) # Check websocket data - self.kpsul_consumer_mock.group_send.assert_called_once_with( - "kfet.kpsul", + ws_data = self.receive_msg() + + self.assertDictEqual( + ws_data, { - "opegroups": [ + "type": "kpsul", + "groups": [ { "add": True, + "type": "operation", "at": mock.ANY, "amount": Decimal("-9.00"), "checkout__name": "Checkout", @@ -3140,7 +3167,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): "is_cof": False, "on_acc__trigramme": "000", "valid_by__trigramme": None, - "opes": [ + "entries": [ { "id": operation_list[0].pk, "addcost_amount": None, @@ -3193,7 +3220,7 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): """ - url_name = "kfet.kpsul.cancel_operations" + url_name = "kfet.operations.cancel" url_expected = "/k-fet/k-psul/cancel_operations" http_methods = ["POST"] @@ -3220,10 +3247,14 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): self.account.balance = Decimal("50.00") self.account.save() - # Mock consumer of K-Psul websocket to catch what we're sending - kpsul_consumer_patcher = mock.patch("kfet.consumers.KPsul") - self.kpsul_consumer_mock = kpsul_consumer_patcher.start() - self.addCleanup(kpsul_consumer_patcher.stop) + # Create a channel to listen to KPsul's messages + channel_layer = get_channel_layer() + + self.channel = async_to_sync(channel_layer.new_channel)() + + async_to_sync(channel_layer.group_add)("kfet.kpsul", self.channel) + + self.receive_msg = lambda: async_to_sync(channel_layer.receive)(self.channel) def _assertResponseOk(self, response): """ @@ -3250,7 +3281,10 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual(json_data["errors"], {}) + self.assertCountEqual( + [e["code"] for e in json_data["errors"]], + ["invalid_request"], + ) def test_invalid_operation_not_exist(self): data = {"operations[]": ["1000"]} @@ -3258,7 +3292,10 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual(json_data["errors"], {"opes_notexisting": [1000]}) + self.assertCountEqual( + [e["code"] for e in json_data["errors"]], + ["cancel_missing"], + ) @mock.patch("django.utils.timezone.now") def test_purchase(self, now_mock): @@ -3267,7 +3304,11 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): on_acc=self.account, checkout=self.checkout, content=[ - {"type": Operation.PURCHASE, "article": self.article, "article_nb": 2} + { + "type": Operation.PURCHASE, + "article": self.article, + "article_nb": 2, + } ], ) operation = group.opes.get() @@ -3312,7 +3353,26 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): ) self.assertDictEqual( - json_data, {"canceled": [operation.pk], "errors": {}, "warnings": {}} + json_data, + { + "canceled": [ + { + "id": operation.id, + # l'encodage des dates en JSON est relou... + "canceled_at": mock.ANY, + "canceled_by__trigramme": None, + } + ], + "errors": [], + "warnings": {}, + "opegroups_to_update": [ + { + "id": group.pk, + "amount": str(group.amount), + "is_cof": group.is_cof, + } + ], + }, ) self.account.refresh_from_db() @@ -3322,25 +3382,12 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): self.checkout.refresh_from_db() self.assertEqual(self.checkout.balance, Decimal("100.00")) - self.kpsul_consumer_mock.group_send.assert_called_with( - "kfet.kpsul", + ws_data = self.receive_msg() + + self.assertDictEqual( + ws_data, { - "opegroups": [ - { - "cancellation": True, - "id": group.pk, - "amount": Decimal("0.00"), - "is_cof": False, - } - ], - "opes": [ - { - "cancellation": True, - "id": operation.pk, - "canceled_by__trigramme": None, - "canceled_at": self.now + timedelta(seconds=15), - } - ], + "type": "kpsul", "checkouts": [], "articles": [{"id": self.article.pk, "stock": 22}], }, @@ -3403,11 +3450,11 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): self.checkout.refresh_from_db() self.assertEqual(self.checkout.balance, Decimal("95.00")) - ws_data_checkouts = self.kpsul_consumer_mock.group_send.call_args[0][1][ - "checkouts" - ] + ws_data = self.receive_msg() + self.assertListEqual( - ws_data_checkouts, [{"id": self.checkout.pk, "balance": Decimal("95.00")}] + ws_data["checkouts"], + [{"id": self.checkout.pk, "balance": Decimal("95.00")}], ) def test_purchase_cash_with_addcost(self): @@ -3443,11 +3490,11 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): addcost_account.refresh_from_db() self.assertEqual(addcost_account.balance, Decimal("9.00")) - ws_data_checkouts = self.kpsul_consumer_mock.group_send.call_args[0][1][ - "checkouts" - ] + ws_data = self.receive_msg() + self.assertListEqual( - ws_data_checkouts, [{"id": self.checkout.pk, "balance": Decimal("94.00")}] + ws_data["checkouts"], + [{"id": self.checkout.pk, "balance": Decimal("94.00")}], ) @mock.patch("django.utils.timezone.now") @@ -3500,7 +3547,26 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): ) self.assertDictEqual( - json_data, {"canceled": [operation.pk], "errors": {}, "warnings": {}} + json_data, + { + "canceled": [ + { + "id": operation.id, + # l'encodage des dates en JSON est relou... + "canceled_at": mock.ANY, + "canceled_by__trigramme": None, + } + ], + "errors": [], + "warnings": {}, + "opegroups_to_update": [ + { + "id": group.pk, + "amount": str(group.amount), + "is_cof": group.is_cof, + } + ], + }, ) self.account.refresh_from_db() @@ -3510,25 +3576,12 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): self.checkout.refresh_from_db() self.assertEqual(self.checkout.balance, Decimal("89.25")) - self.kpsul_consumer_mock.group_send.assert_called_with( - "kfet.kpsul", + ws_data = self.receive_msg() + + self.assertDictEqual( + ws_data, { - "opegroups": [ - { - "cancellation": True, - "id": group.pk, - "amount": Decimal("0.00"), - "is_cof": False, - } - ], - "opes": [ - { - "cancellation": True, - "id": operation.pk, - "canceled_by__trigramme": None, - "canceled_at": self.now + timedelta(seconds=15), - } - ], + "type": "kpsul", "checkouts": [{"id": self.checkout.pk, "balance": Decimal("89.25")}], "articles": [], }, @@ -3584,7 +3637,26 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): ) self.assertDictEqual( - json_data, {"canceled": [operation.pk], "errors": {}, "warnings": {}} + json_data, + { + "canceled": [ + { + "id": operation.id, + # l'encodage des dates en JSON est relou... + "canceled_at": mock.ANY, + "canceled_by__trigramme": None, + } + ], + "errors": [], + "warnings": {}, + "opegroups_to_update": [ + { + "id": group.pk, + "amount": str(group.amount), + "is_cof": group.is_cof, + } + ], + }, ) self.account.refresh_from_db() @@ -3594,25 +3666,12 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): self.checkout.refresh_from_db() self.assertEqual(self.checkout.balance, Decimal("110.75")) - self.kpsul_consumer_mock.group_send.assert_called_with( - "kfet.kpsul", + ws_data = self.receive_msg() + + self.assertDictEqual( + ws_data, { - "opegroups": [ - { - "cancellation": True, - "id": group.pk, - "amount": Decimal("0.00"), - "is_cof": False, - } - ], - "opes": [ - { - "cancellation": True, - "id": operation.pk, - "canceled_by__trigramme": None, - "canceled_at": self.now + timedelta(seconds=15), - } - ], + "type": "kpsul", "checkouts": [{"id": self.checkout.pk, "balance": Decimal("110.75")}], "articles": [], }, @@ -3668,7 +3727,26 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): ) self.assertDictEqual( - json_data, {"canceled": [operation.pk], "errors": {}, "warnings": {}} + json_data, + { + "canceled": [ + { + "id": operation.id, + # l'encodage des dates en JSON est relou... + "canceled_at": mock.ANY, + "canceled_by__trigramme": None, + } + ], + "errors": [], + "warnings": {}, + "opegroups_to_update": [ + { + "id": group.pk, + "amount": str(group.amount), + "is_cof": group.is_cof, + } + ], + }, ) self.account.refresh_from_db() @@ -3678,28 +3756,11 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): self.checkout.refresh_from_db() self.assertEqual(self.checkout.balance, Decimal("100.00")) - self.kpsul_consumer_mock.group_send.assert_called_with( - "kfet.kpsul", - { - "opegroups": [ - { - "cancellation": True, - "id": group.pk, - "amount": Decimal("0.00"), - "is_cof": False, - } - ], - "opes": [ - { - "cancellation": True, - "id": operation.pk, - "canceled_by__trigramme": None, - "canceled_at": self.now + timedelta(seconds=15), - } - ], - "checkouts": [], - "articles": [], - }, + ws_data = self.receive_msg() + + self.assertDictEqual( + ws_data, + {"type": "kpsul", "checkouts": [], "articles": []}, ) @mock.patch("django.utils.timezone.now") @@ -3741,8 +3802,8 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 403) json_data = json.loads(resp.content.decode("utf-8")) self.assertEqual( - json_data["errors"], - {"missing_perms": ["Annuler des commandes non récentes"]}, + json_data["missing_perms"], + ["Annuler des commandes non récentes"], ) def test_already_canceled(self): @@ -3866,9 +3927,12 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): data = {"operations[]": [str(operation.pk)]} resp = self.client.post(self.url, data) - self.assertEqual(resp.status_code, 403) + self.assertEqual(resp.status_code, 400) json_data = json.loads(resp.content.decode("utf-8")) - self.assertEqual(json_data["errors"], {"negative": [self.account.trigramme]}) + self.assertCountEqual( + [e["code"] for e in json_data["errors"]], + ["negative"], + ) def test_invalid_negative_requires_perms(self): kfet_config.set(overdraft_amount=Decimal("40.00")) @@ -3887,8 +3951,8 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(resp.status_code, 403) json_data = json.loads(resp.content.decode("utf-8")) self.assertEqual( - json_data["errors"], - {"missing_perms": ["Enregistrer des commandes en négatif"]}, + json_data["missing_perms"], + ["Enregistrer des commandes en négatif"], ) def test_partial_0(self): @@ -3920,13 +3984,33 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): group.refresh_from_db() self.assertEqual(group.amount, Decimal("10.75")) self.assertEqual(group.opes.exclude(canceled_at=None).count(), 3) - + self.maxDiff = None self.assertDictEqual( json_data, { - "canceled": [operation1.pk, operation2.pk], + "canceled": [ + { + "id": operation1.id, + # l'encodage des dates en JSON est relou... + "canceled_at": mock.ANY, + "canceled_by__trigramme": None, + }, + { + "id": operation2.id, + # l'encodage des dates en JSON est relou... + "canceled_at": mock.ANY, + "canceled_by__trigramme": None, + }, + ], + "errors": [], "warnings": {"already_canceled": [operation3.pk]}, - "errors": {}, + "opegroups_to_update": [ + { + "id": group.pk, + "amount": str(group.amount), + "is_cof": group.is_cof, + } + ], }, ) @@ -4016,7 +4100,7 @@ class KPsulArticlesData(ViewTestCaseMixin, TestCase): ] for expected, article in zip(expected_list, articles): - self.assertDictContainsSubset(expected, article) + self.assertEqual(article, {**article, **expected}) self.assertSetEqual( set(article.keys()), set( @@ -4028,6 +4112,7 @@ class KPsulArticlesData(ViewTestCaseMixin, TestCase): "category_id", "category__name", "category__has_addcost", + "category__has_reduction", ] ), ) @@ -4078,31 +4163,44 @@ class HistoryJSONViewTests(ViewTestCaseMixin, TestCase): url_name = "kfet.history.json" url_expected = "/k-fet/history.json" - auth_user = "user" - auth_forbidden = [None] + auth_user = "team" + auth_forbidden = [None, "user", "noaccount"] def test_ok(self): r = self.client.post(self.url) self.assertEqual(r.status_code, 200) + def get_users_extra(self): + noaccount = User.objects.create(username="noaccount") + noaccount.set_password("noaccount") + noaccount.save() + return {"noaccount": noaccount} + class AccountReadJSONViewTests(ViewTestCaseMixin, TestCase): url_name = "kfet.account.read.json" - url_expected = "/k-fet/accounts/read.json" - http_methods = ["POST"] + http_methods = ["GET"] auth_user = "team" auth_forbidden = [None, "user"] + @property + def url_kwargs(self): + return {"trigramme": self.accounts["user"].trigramme} + + @property + def url_expected(self): + return "/k-fet/accounts/{}/.json".format(self.accounts["user"].trigramme) + def test_ok(self): - r = self.client.post(self.url, {"trigramme": "000"}) + r = self.client.get(self.url) 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.assertEqual(content, {**content, **expected}) self.assertSetEqual( set(content.keys()), @@ -4346,7 +4444,9 @@ class InventoryListViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(r.status_code, 200) inventories = r.context["inventories"] - self.assertQuerysetEqual(inventories, map(repr, [self.inventory])) + self.assertQuerysetEqual( + inventories, map(repr, [self.inventory]), transform=repr + ) class InventoryCreateViewTests(ViewTestCaseMixin, TestCase): @@ -4427,6 +4527,87 @@ class InventoryReadViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(r.status_code, 200) +class InventoryDeleteViewTests(ViewTestCaseMixin, TestCase): + url_name = "kfet.inventory.delete" + + auth_user = "team1" + auth_forbidden = [None, "user", "team"] + + def get_users_extra(self): + return { + "user1": create_user("user1", "001"), + "team1": create_team("team1", "101", perms=["kfet.delete_inventory"]), + } + + @property + def url_kwargs(self): + return {"pk": self.inventory1.pk} + + @property + def url_expected(self): + return "/k-fet/inventaires/{}/delete".format(self.inventory1.pk) + + def setUp(self): + super().setUp() + # Deux inventaires : un avec article 1 + 2, l'autre avec 1 + 3 + self.inventory1 = Inventory.objects.create( + by=self.accounts["team"], at=self.now + ) + self.inventory2 = Inventory.objects.create( + by=self.accounts["team"], at=self.now + timedelta(days=1) + ) + category = ArticleCategory.objects.create(name="Category") + # Le stock des articles correspond à leur dernier inventaire + self.article1 = Article.objects.create( + name="Article1", category=category, stock=51 + ) + self.article2 = Article.objects.create( + name="Article2", category=category, stock=42 + ) + self.article3 = Article.objects.create( + name="Article3", category=category, stock=42 + ) + + InventoryArticle.objects.create( + inventory=self.inventory1, + article=self.article1, + stock_old=23, + stock_new=42, + ) + InventoryArticle.objects.create( + inventory=self.inventory1, + article=self.article2, + stock_old=23, + stock_new=42, + ) + InventoryArticle.objects.create( + inventory=self.inventory2, + article=self.article1, + stock_old=42, + stock_new=51, + ) + InventoryArticle.objects.create( + inventory=self.inventory2, + article=self.article3, + stock_old=23, + stock_new=42, + ) + + def test_ok(self): + r = self.client.post(self.url) + self.assertRedirects(r, reverse("kfet.inventory")) + + # On vérifie que l'inventaire n'existe plus + self.assertFalse(Inventory.objects.filter(pk=self.inventory1.pk).exists()) + # On check les stocks + self.article1.refresh_from_db() + self.article2.refresh_from_db() + self.article3.refresh_from_db() + self.assertEqual(self.article1.stock, 51) + self.assertEqual(self.article2.stock, 23) + self.assertEqual(self.article3.stock, 42) + + class OrderListViewTests(ViewTestCaseMixin, TestCase): url_name = "kfet.order" url_expected = "/k-fet/orders/" @@ -4452,7 +4633,7 @@ class OrderListViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(r.status_code, 200) orders = r.context["orders"] - self.assertQuerysetEqual(orders, map(repr, [self.order])) + self.assertQuerysetEqual(orders, map(repr, [self.order]), transform=repr) class OrderReadViewTests(ViewTestCaseMixin, TestCase): @@ -4667,7 +4848,9 @@ class OrderToInventoryViewTests(ViewTestCaseMixin, TestCase): inventory, {"by": self.accounts["team1"], "at": self.now, "order": self.order}, ) - self.assertQuerysetEqual(inventory.articles.all(), map(repr, [self.article])) + self.assertQuerysetEqual( + inventory.articles.all(), map(repr, [self.article]), transform=repr + ) compte = InventoryArticle.objects.get(article=self.article) diff --git a/kfet/tests/testcases.py b/kfet/tests/testcases.py index 95b48d42..a7962f33 100644 --- a/kfet/tests/testcases.py +++ b/kfet/tests/testcases.py @@ -39,7 +39,7 @@ class TestCaseMixin: querystring = QueryDict(mutable=True) querystring["next"] = full_path - login_url = "/login?" + querystring.urlencode(safe="/") + login_url = "/gestion/login?" + querystring.urlencode(safe="/") # We don't focus on what the login view does. # So don't fetch the redirect. @@ -79,10 +79,15 @@ class TestCaseMixin: self.assertEqual(response.status_code, 200) try: form = response.context[form_ctx] - self.assertIn("Permission refusée", form.non_field_errors()) + errors = [y for x in form.errors.as_data().values() for y in x] + self.assertTrue(any(e.code == "permission-denied" for e in errors)) except (AssertionError, AttributeError, KeyError): - messages = [str(msg) for msg in response.context["messages"]] - self.assertIn("Permission refusée", messages) + self.assertTrue( + any( + "permission-denied" in msg.tags + for msg in response.context["messages"] + ) + ) except AssertionError: request = response.wsgi_request raise AssertionError( @@ -248,7 +253,10 @@ class ViewTestCaseMixin(TestCaseMixin): self.register_user(label, user) if self.auth_user: - self.client.force_login(self.users[self.auth_user]) + self.client.force_login( + self.users[self.auth_user], + backend="django.contrib.auth.backends.ModelBackend", + ) def tearDown(self): del self.users_base diff --git a/kfet/urls.py b/kfet/urls.py index 681b7c31..f33cea03 100644 --- a/kfet/urls.py +++ b/kfet/urls.py @@ -1,7 +1,7 @@ from django.contrib.auth.decorators import permission_required from django.urls import include, path, register_converter -from kfet import autocomplete, converters, views +from kfet import converters, views from kfet.decorators import teamkfet_required register_converter(converters.TrigrammeConverter, "trigramme") @@ -9,6 +9,10 @@ register_converter(converters.TrigrammeConverter, "trigramme") urlpatterns = [ path("login/generic", views.login_generic, name="kfet.login.generic"), path("history", views.history, name="kfet.history"), + path("contact", views.ContactView.as_view(), name="kfet.contact"), + path( + "demande-soiree", views.DemandeSoireeView.as_view(), name="kfet.demande-soiree" + ), # ----- # Account urls # ----- @@ -38,13 +42,13 @@ urlpatterns = [ ), path( "autocomplete/account_new", - autocomplete.account_create, + views.AccountCreateAutocompleteView.as_view(), name="kfet.account.create.autocomplete", ), # Account - Search path( "autocomplete/account_search", - autocomplete.account_search, + views.AccountSearchAutocompleteView.as_view(), name="kfet.account.search.autocomplete", ), # Account - Read @@ -219,8 +223,8 @@ urlpatterns = [ ), path( "k-psul/cancel_operations", - views.kpsul_cancel_operations, - name="kfet.kpsul.cancel_operations", + views.cancel_operations, + name="kfet.operations.cancel", ), path( "k-psul/articles_data", @@ -239,7 +243,11 @@ urlpatterns = [ # JSON urls # ----- path("history.json", views.history_json, name="kfet.history.json"), - path("accounts/read.json", views.account_read_json, name="kfet.account.read.json"), + path( + "accounts//.json", + views.account_read_json, + name="kfet.account.read.json", + ), # ----- # Settings urls # ----- @@ -248,7 +256,7 @@ urlpatterns = [ # ----- # Transfers urls # ----- - path("transfers/", views.transfers, name="kfet.transfers"), + path("transfers/", views.TransferView.as_view(), name="kfet.transfers"), path("transfers/new", views.transfers_create, name="kfet.transfers.create"), path("transfers/perform", views.perform_transfers, name="kfet.transfers.perform"), path("transfers/cancel", views.cancel_transfers, name="kfet.transfers.cancel"), @@ -266,6 +274,11 @@ urlpatterns = [ teamkfet_required(views.InventoryRead.as_view()), name="kfet.inventory.read", ), + path( + "inventaires//delete", + views.InventoryDelete.as_view(), + name="kfet.inventory.delete", + ), # ----- # Order urls # ----- diff --git a/kfet/utils.py b/kfet/utils.py index 0c4f170a..d5df3228 100644 --- a/kfet/utils.py +++ b/kfet/utils.py @@ -1,8 +1,8 @@ import json import math -from channels.channel import Group -from channels.generic.websockets import JsonWebsocketConsumer +from asgiref.sync import sync_to_async +from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.core.cache import cache from django.core.serializers.json import DjangoJSONEncoder @@ -63,7 +63,7 @@ class CachedMixin: # Consumers -class DjangoJsonWebsocketConsumer(JsonWebsocketConsumer): +class DjangoJsonWebsocketConsumer(AsyncJsonWebsocketConsumer): """Custom Json Websocket Consumer. Encode to JSON with DjangoJSONEncoder. @@ -71,7 +71,7 @@ class DjangoJsonWebsocketConsumer(JsonWebsocketConsumer): """ @classmethod - def encode_json(cls, content): + async def encode_json(cls, content): return json.dumps(content, cls=DjangoJSONEncoder) @@ -89,31 +89,11 @@ class PermConsumerMixin: http_user = True # Enable message.user perms_connect = [] - def connect(self, message, **kwargs): + async def connect(self): """Check permissions on connection.""" - if message.user.has_perms(self.perms_connect): - super().connect(message, **kwargs) + self.user = self.scope["user"] + + if await sync_to_async(self.user.has_perms)(self.perms_connect): + await super().connect() 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) + await self.close() diff --git a/kfet/views.py b/kfet/views.py index c5d5082b..f85639b5 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1,20 +1,41 @@ -import ast import heapq import statistics from collections import defaultdict +from datetime import datetime, timedelta from decimal import Decimal +from typing import List, Tuple from urllib.parse import urlencode +from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.models import Permission, User from django.contrib.messages.views import SuccessMessageMixin +from django.core.exceptions import SuspiciousOperation +from django.core.mail import EmailMessage from django.db import transaction -from django.db.models import Count, F, Prefetch, Sum -from django.forms import formset_factory -from django.http import Http404, JsonResponse +from django.db.models import ( + Count, + DecimalField, + ExpressionWrapper, + F, + Max, + OuterRef, + Prefetch, + Q, + Subquery, + Sum, +) +from django.forms import ValidationError, formset_factory +from django.http import ( + Http404, + HttpResponseBadRequest, + HttpResponseForbidden, + JsonResponse, +) from django.shortcuts import get_object_or_404, redirect, render +from django.template import loader from django.urls import reverse, reverse_lazy from django.utils import timezone from django.utils.decorators import method_decorator @@ -23,16 +44,18 @@ from django.views.generic.detail import BaseDetailView from django.views.generic.edit import CreateView, DeleteView, UpdateView from gestioncof.models import CofProfile -from kfet import KFET_DELETED_TRIGRAMME, consumers +from kfet import KFET_DELETED_TRIGRAMME from kfet.auth.decorators import kfet_password_auth +from kfet.autocomplete import kfet_account_only_autocomplete, kfet_autocomplete from kfet.config import kfet_config +from kfet.consumers import KPsul from kfet.decorators import teamkfet_required from kfet.forms import ( AccountForm, - AccountNegativeForm, + AccountFrozenForm, AccountNoTriForm, AccountPwdForm, - AccountRestrictForm, + AccountStatForm, AccountTriForm, AddcostForm, ArticleForm, @@ -43,6 +66,9 @@ from kfet.forms import ( CheckoutStatementCreateForm, CheckoutStatementUpdateForm, CofForm, + CofKFForm, + ContactForm, + DemandeSoireeForm, FilterHistoryForm, InventoryArticleForm, KFetConfigForm, @@ -52,6 +78,7 @@ from kfet.forms import ( KPsulOperationGroupForm, OrderArticleForm, OrderArticleToInventoryForm, + StatScaleForm, TransferFormSet, UserForm, UserGroupForm, @@ -75,7 +102,8 @@ from kfet.models import ( Transfer, TransferGroup, ) -from kfet.statistic import ScaleMixin, WeekScale, last_stats_manifest +from kfet.statistic import SCALE_DICT, DayScale, MonthScale, WeekScale, scale_url_params +from shared.views import AutocompleteView from .auth import KFET_GENERIC_TRIGRAMME from .auth.views import ( # noqa @@ -91,6 +119,61 @@ def put_cleaned_data_in_dict(dict, form): dict[field] = form.cleaned_data[field] +class ContactView(FormView): + template_name = "kfet/contact.html" + form_class = ContactForm + success_url = reverse_lazy("kfet.contact") + + def form_valid(self, form): + # Envoie un mail lorsque le formulaire est valide + EmailMessage( + form.cleaned_data["subject"], + form.cleaned_data["message"], + from_email=form.cleaned_data["from_email"], + to=("chefs-k-fet@ens.psl.eu",), + ).send() + + messages.success( + self.request, + "Votre message a bien été envoyé aux Wo·men K-Fêt.", + ) + + return super().form_valid(form) + + +class DemandeSoireeView(FormView): + template_name = "kfet/demande_soiree.html" + form_class = DemandeSoireeForm + success_url = reverse_lazy("kfet.demande-soiree") + + def form_valid(self, form): + destinataires = ["chefs-k-fet@ens.psl.eu"] + + if form.cleaned_data["contact_boum"]: + destinataires.append("boum@ens.psl.eu") + + if form.cleaned_data["contact_pls"]: + destinataires.append("pls@ens.psl.eu") + + # Envoie un mail lorsque le formulaire est valide + EmailMessage( + f"Demande de soirée le {form.cleaned_data['date']}", + loader.render_to_string( + "kfet/mails/demande_soiree.txt", context=form.cleaned_data + ), + from_email=form.cleaned_data["from_email"], + to=destinataires, + cc=[form.cleaned_data["from_email"]], + ).send() + + messages.success( + self.request, + "Votre demande de soirée a bien été envoyée.", + ) + + return super().form_valid(form) + + # ----- # Account views # ----- @@ -102,7 +185,20 @@ def put_cleaned_data_in_dict(dict, form): @teamkfet_required def account(request): accounts = Account.objects.select_related("cofprofile__user").order_by("trigramme") - return render(request, "kfet/account.html", {"accounts": accounts}) + positive_accounts = Account.objects.filter(balance__gte=0).exclude(trigramme="#13") + negative_accounts = Account.objects.filter(balance__lt=0).exclude(trigramme="#13") + + return render( + request, + "kfet/account.html", + { + "accounts": accounts, + "positive_count": positive_accounts.count(), + "positives_sum": sum(acc.balance for acc in positive_accounts), + "negative_count": negative_accounts.count(), + "negatives_sum": sum(acc.balance for acc in negative_accounts), + }, + ) @login_required @@ -122,7 +218,6 @@ def account_is_validandfree_ajax(request): @teamkfet_required @kfet_password_auth def account_create(request): - # Enregistrement if request.method == "POST": trigramme_form = AccountTriForm(request.POST) @@ -149,7 +244,9 @@ def account_create(request): ): # Checking permission if not request.user.has_perm("kfet.add_account"): - messages.error(request, "Permission refusée") + messages.error( + request, "Permission refusée", extra_tags="permission-denied" + ) else: data = {} # Fill data for Account.save() @@ -160,7 +257,13 @@ def account_create(request): account = trigramme_form.save(data=data) account_form = AccountNoTriForm(request.POST, instance=account) account_form.save() + was_kfet = account.is_kfet + account.cofprofile.is_kfet = cof_form.cleaned_data["is_kfet"] + account.cofprofile.save() + if account.cofprofile.is_kfet: + account.cofprofile.make_adh_kfet(request, was_kfet) messages.success(request, "Compte créé : %s" % account.trigramme) + account.send_creation_email() return redirect("kfet.account.create") except Account.UserHasAccount as e: messages.error( @@ -188,7 +291,11 @@ def account_create(request): def account_form_set_readonly_fields(user_form, cof_form): user_form.fields["username"].widget.attrs["readonly"] = True + user_form.fields["first_name"].widget.attrs["readonly"] = True + user_form.fields["last_name"].widget.attrs["readonly"] = True + user_form.fields["email"].widget.attrs["readonly"] = True cof_form.fields["login_clipper"].widget.attrs["readonly"] = True + cof_form.fields["departement"].widget.attrs["readonly"] = True cof_form.fields["is_cof"].widget.attrs["disabled"] = True @@ -321,124 +428,91 @@ def account_read(request, trigramme): # Account - Update -@login_required +@teamkfet_required @kfet_password_auth def account_update(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: - raise Http404 + if not account.editable: + # Plus de leak de trigramme ! + return HttpResponseForbidden user_info_form = UserInfoForm(instance=account.user) - - if request.user.has_perm("kfet.is_team"): - group_form = UserGroupForm(instance=account.user) - account_form = AccountForm(instance=account) - pwd_form = AccountPwdForm() - if account.balance < 0 and not hasattr(account, "negative"): - AccountNegative.objects.create(account=account, start=timezone.now()) - account.refresh_from_db() - if hasattr(account, "negative"): - negative_form = AccountNegativeForm(instance=account.negative) - else: - negative_form = None - else: - account_form = AccountRestrictForm(instance=account) - group_form = None - negative_form = None - pwd_form = None + account_form = AccountForm(instance=account) + group_form = UserGroupForm(instance=account.user) + frozen_form = AccountFrozenForm(instance=account) + cof_form = CofKFForm(instance=account.cofprofile) + pwd_form = AccountPwdForm() if request.method == "POST": - # Update attempt - success = False - missing_perm = True + self_update = request.user == account.user + account_form = AccountForm(request.POST, instance=account) + group_form = UserGroupForm(request.POST, instance=account.user) + frozen_form = AccountFrozenForm(request.POST, instance=account) + cof_form = CofKFForm(request.POST, instance=account.cofprofile) + pwd_form = AccountPwdForm(request.POST, account=account) - if request.user.has_perm("kfet.is_team"): - account_form = AccountForm(request.POST, instance=account) - group_form = UserGroupForm(request.POST, instance=account.user) - pwd_form = AccountPwdForm(request.POST) - if hasattr(account, "negative"): - negative_form = AccountNegativeForm( - request.POST, instance=account.negative - ) + forms = [] + warnings = [] - if request.user.has_perm("kfet.change_account") and account_form.is_valid(): - missing_perm = False + if self_update or request.user.has_perm("kfet.change_account"): + forms.append(account_form) + elif account_form.has_changed(): + warnings.append("compte") - # Updating - account_form.save() + if request.user.has_perm("kfet.manage_perms"): + forms.append(group_form) + forms.append(frozen_form) + elif group_form.has_changed(): + warnings.append("statut d'équipe") - # Checking perm to update password - if ( - request.user.has_perm("kfet.change_account_password") - and pwd_form.is_valid() - ): - pwd = pwd_form.cleaned_data["pwd1"] - account.change_pwd(pwd) - account.save() - messages.success(request, "Mot de passe mis à jour") + if request.user.has_perm("kfet.change_adh"): + forms.append(cof_form) + elif cof_form.has_changed(): + warnings.append("adhésion kfet") - # Checking perm to manage perms - if request.user.has_perm("kfet.manage_perms") and group_form.is_valid(): - group_form.save() + # Il ne faut pas valider `pwd_form` si elle est inchangée + if pwd_form.has_changed(): + if self_update or request.user.has_perm("kfet.change_account_password"): + forms.append(pwd_form) + else: + warnings.append("mot de passe") - # Checking perm to manage negative - if hasattr(account, "negative"): - balance_offset_old = 0 - if account.negative.balance_offset: - balance_offset_old = account.negative.balance_offset - if ( - hasattr(account, "negative") - and request.user.has_perm("kfet.change_accountnegative") - and negative_form.is_valid() - ): - balance_offset_new = negative_form.cleaned_data["balance_offset"] - if not balance_offset_new: - balance_offset_new = 0 - balance_offset_diff = balance_offset_new - balance_offset_old - Account.objects.filter(pk=account.pk).update( - balance=F("balance") + balance_offset_diff - ) - negative_form.save() - if ( - Account.objects.get(pk=account.pk).balance >= 0 - and not balance_offset_new - ): - AccountNegative.objects.get(account=account).delete() - - success = True - messages.success( - request, - "Informations du compte %s mises à jour" % account.trigramme, - ) - - # Modification de ses propres informations - if request.user == account.user: - missing_perm = False - account.refresh_from_db() - account_form = AccountRestrictForm(request.POST, instance=account) - pwd_form = AccountPwdForm(request.POST) - - if account_form.is_valid(): - account_form.save() - success = True - messages.success(request, "Vos informations ont été mises à jour") - - if request.user.has_perm("kfet.is_team") and pwd_form.is_valid(): - pwd = pwd_form.cleaned_data["pwd1"] - account.change_pwd(pwd) - account.save() - messages.success(request, "Votre mot de passe a été mis à jour") - - if missing_perm: - messages.error(request, "Permission refusée") - if success: - return redirect("kfet.account.read", account.trigramme) - else: + # Updating account info + if forms == []: messages.error( - request, "Informations non mises à jour. Corrigez les erreurs" + request, + "Informations non mises à jour : permission refusée", + extra_tags="permission-denied", ) + else: + if all(form.is_valid() for form in forms): + was_kfet = account.is_kfet + for form in forms: + form.save() + if account.is_kfet: + account.cofprofile.make_adh_kfet(request, was_kfet) + + if len(warnings): + messages.warning( + request, + "Permissions insuffisantes pour modifier" + " les informations suivantes : {}.".format(", ".join(warnings)), + ) + if self_update: + messages.success(request, "Vos informations ont été mises à jour !") + else: + messages.success( + request, + "Informations du compte %s mises à jour" % account.trigramme, + ) + + return redirect("kfet.account.read", account.trigramme) + else: + messages.error( + request, "Informations non mises à jour : corrigez les erreurs" + ) return render( request, @@ -447,13 +521,15 @@ def account_update(request, trigramme): "user_info_form": user_info_form, "account": account, "account_form": account_form, + "frozen_form": frozen_form, "group_form": group_form, - "negative_form": negative_form, "pwd_form": pwd_form, + "cof_form": cof_form, }, ) - # Account - Delete + +# Account - Delete class AccountDelete(PermissionRequiredMixin, DeleteView): @@ -492,16 +568,18 @@ class AccountDelete(PermissionRequiredMixin, DeleteView): class AccountNegativeList(ListView): - queryset = AccountNegative.objects.select_related( - "account", "account__cofprofile__user" - ).exclude(account__trigramme="#13") + queryset = ( + AccountNegative.objects.select_related("account", "account__cofprofile__user") + .filter(account__balance__lt=0) + .exclude(account__trigramme="#13") + ) template_name = "kfet/account_negative.html" context_object_name = "negatives" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - real_balances = (neg.account.real_balance for neg in self.object_list) - context["negatives_sum"] = sum(real_balances) + balances = (neg.account.balance for neg in self.object_list) + context["negatives_sum"] = sum(balances) return context @@ -532,7 +610,9 @@ class CheckoutCreate(SuccessMessageMixin, CreateView): def form_valid(self, form): # Checking permission if not self.request.user.has_perm("kfet.add_checkout"): - form.add_error(None, "Permission refusée") + form.add_error( + None, ValidationError("Permission refusée", code="permission-denied") + ) return self.form_invalid(form) # Creating @@ -559,6 +639,7 @@ class CheckoutRead(DetailView): # Checkout - Update +@method_decorator(kfet_password_auth, name="dispatch") class CheckoutUpdate(SuccessMessageMixin, UpdateView): model = Checkout template_name = "kfet/checkout_update.html" @@ -569,7 +650,9 @@ class CheckoutUpdate(SuccessMessageMixin, UpdateView): def form_valid(self, form): # Checking permission if not self.request.user.has_perm("kfet.change_checkout"): - form.add_error(None, "Permission refusée") + form.add_error( + None, ValidationError("Permission refusée", code="permission-denied") + ) return self.form_invalid(form) # Updating return super().form_valid(form) @@ -659,7 +742,9 @@ class CheckoutStatementCreate(SuccessMessageMixin, CreateView): def form_valid(self, form): # Checking permission if not self.request.user.has_perm("kfet.add_checkoutstatement"): - form.add_error(None, "Permission refusée") + form.add_error( + None, ValidationError("Permission refusée", code="permission-denied") + ) return self.form_invalid(form) # Creating form.instance.amount_taken = getAmountTaken(form.instance) @@ -691,7 +776,9 @@ class CheckoutStatementUpdate(SuccessMessageMixin, UpdateView): def form_valid(self, form): # Checking permission if not self.request.user.has_perm("kfet.change_checkoutstatement"): - form.add_error(None, "Permission refusée") + form.add_error( + None, ValidationError("Permission refusée", code="permission-denied") + ) return self.form_invalid(form) # Updating form.instance.amount_taken = getAmountTaken(form.instance) @@ -723,7 +810,9 @@ class CategoryUpdate(SuccessMessageMixin, UpdateView): def form_valid(self, form): # Checking permission if not self.request.user.has_perm("kfet.change_articlecategory"): - form.add_error(None, "Permission refusée") + form.add_error( + None, ValidationError("Permission refusée", code="permission-denied") + ) return self.form_invalid(form) # Updating @@ -761,6 +850,7 @@ class ArticleList(ListView): # Article - Create +@method_decorator(kfet_password_auth, name="dispatch") class ArticleCreate(SuccessMessageMixin, CreateView): model = Article template_name = "kfet/article_create.html" @@ -771,7 +861,9 @@ class ArticleCreate(SuccessMessageMixin, CreateView): def form_valid(self, form): # Checking permission if not self.request.user.has_perm("kfet.add_article"): - form.add_error(None, "Permission refusée") + form.add_error( + None, ValidationError("Permission refusée", code="permission-denied") + ) return self.form_invalid(form) # Save ici pour save le manytomany suppliers @@ -826,6 +918,7 @@ class ArticleRead(DetailView): # Article - Update +@method_decorator(kfet_password_auth, name="dispatch") class ArticleUpdate(SuccessMessageMixin, UpdateView): model = Article template_name = "kfet/article_update.html" @@ -836,7 +929,9 @@ class ArticleUpdate(SuccessMessageMixin, UpdateView): def form_valid(self, form): # Checking permission if not self.request.user.has_perm("kfet.change_article"): - form.add_error(None, "Permission refusée") + form.add_error( + None, ValidationError("Permission refusée", code="permission-denied") + ) return self.form_invalid(form) # Save ici pour save le manytomany suppliers @@ -905,14 +1000,16 @@ def kpsul_get_settings(request): @teamkfet_required -def account_read_json(request): - trigramme = request.POST.get("trigramme", "") +def account_read_json(request, trigramme): account = get_object_or_404(Account, trigramme=trigramme) + if not account.readable: + raise Http404 data = { "id": account.pk, "name": account.name, "email": account.email, "is_cof": account.is_cof, + "is_kfet": account.is_kfet, "promo": account.promo, "balance": account.balance, "is_frozen": account.is_frozen, @@ -969,15 +1066,18 @@ def kpsul_checkout_data(request): @kfet_password_auth def kpsul_update_addcost(request): addcost_form = AddcostForm(request.POST) + data = {"errors": []} if not addcost_form.is_valid(): - data = {"errors": {"addcost": list(addcost_form.errors)}} + for field, errors in addcost_form.errors.items(): + for error in errors: + data["errors"].append({"code": f"invalid_{field}", "message": error}) + return JsonResponse(data, status=400) + required_perms = ["kfet.manage_addcosts"] if not request.user.has_perms(required_perms): - data = { - "errors": {"missing_perms": get_missing_perms(required_perms, request.user)} - } + data["missing_perms"] = get_missing_perms(required_perms, request.user) return JsonResponse(data, status=403) trigramme = addcost_form.cleaned_data["trigramme"] @@ -986,20 +1086,28 @@ def kpsul_update_addcost(request): kfet_config.set(addcost_for=account, addcost_amount=amount) - data = {"addcost": {"for": account and account.trigramme or None, "amount": amount}} - consumers.KPsul.group_send("kfet.kpsul", data) + data = { + "addcost": {"for": account and account.trigramme or None, "amount": amount}, + "type": "kpsul", + } + + KPsul.group_send("kfet.kpsul", data) + return JsonResponse(data) -def get_missing_perms(required_perms, user): - missing_perms_codenames = [ - (perm.split("."))[1] for perm in required_perms if not user.has_perm(perm) - ] - missing_perms = list( - Permission.objects.filter(codename__in=missing_perms_codenames).values_list( - "name", flat=True +def get_missing_perms(required_perms: List[str], user: User) -> List[str]: + def get_perm_name(app_label: str, codename: str) -> str: + return Permission.objects.values_list("name", flat=True).get( + codename=codename, content_type__app_label=app_label ) - ) + + missing_perms = [ + get_perm_name(*perm.split(".")) + for perm in required_perms + if not user.has_perm(perm) + ] + return missing_perms @@ -1007,17 +1115,31 @@ def get_missing_perms(required_perms, user): @kfet_password_auth def kpsul_perform_operations(request): # Initializing response data - data = {"operationgroup": 0, "operations": [], "warnings": {}, "errors": {}} + data = {"errors": []} # Checking operationgroup operationgroup_form = KPsulOperationGroupForm(request.POST) if not operationgroup_form.is_valid(): - data["errors"]["operation_group"] = list(operationgroup_form.errors) + for field in operationgroup_form.errors: + verbose_field, feminin = ( + ("compte", "") if field == "on_acc" else ("caisse", "e") + ) + data["errors"].append( + { + "code": f"invalid_{field}", + "message": f"Pas de {verbose_field} sélectionné{feminin}", + } + ) # Checking operation_formset operation_formset = KPsulOperationFormSet(request.POST) if not operation_formset.is_valid(): - data["errors"]["operations"] = list(operation_formset.errors) + data["errors"].append( + { + "code": "invalid_formset", + "message": "Formulaire d'opérations vide ou invalide", + } + ) # Returning BAD REQUEST if errors if data["errors"]: @@ -1026,6 +1148,7 @@ def kpsul_perform_operations(request): # Pre-saving (no commit) operationgroup = operationgroup_form.save(commit=False) operations = operation_formset.save(commit=False) + on_acc = operationgroup.on_acc # Retrieving COF grant cof_grant = kfet_config.subvention_cof @@ -1039,10 +1162,13 @@ def kpsul_perform_operations(request): to_addcost_for_balance = 0 # For balance of addcost_for to_checkout_balance = 0 # For balance of selected checkout to_articles_stocks = defaultdict(lambda: 0) # For stocks articles - is_addcost = all( - (addcost_for, addcost_amount, addcost_for != operationgroup.on_acc) - ) - need_comment = operationgroup.on_acc.need_comment + is_addcost = all((addcost_for, addcost_amount, addcost_for != on_acc)) + need_comment = on_acc.need_comment + + if on_acc.is_frozen: + data["errors"].append( + {"code": "frozen_acc", "message": f"Le compte {on_acc.trigramme} est gelé"} + ) # Filling data of each operations # + operationgroup + calculating other stuffs @@ -1054,59 +1180,91 @@ def kpsul_perform_operations(request): operation.addcost_amount = addcost_amount * operation.article_nb operation.amount -= operation.addcost_amount to_addcost_for_balance += operation.addcost_amount - if operationgroup.on_acc.is_cash: + if on_acc.is_cash: to_checkout_balance += -operation.amount - if operationgroup.on_acc.is_cof: + if on_acc.is_cof and operation.article.category.has_reduction: if is_addcost and operation.article.category.has_addcost: operation.addcost_amount /= cof_grant_divisor operation.amount = operation.amount / cof_grant_divisor + if not on_acc.is_cof and not on_acc.is_kfet and operation.article.no_exte: + if on_acc.is_cash: + required_perms.add("kfet.perform_liq_reserved") + else: + data["errors"].append( + { + "code": "reserved", + "message": ( + "L'article " + + operation.article.name + + " est réservé aux adhérent⋅e⋅s du COF, or " + + on_acc.trigramme + + " ne l'est pas" + ), + } + ) to_articles_stocks[operation.article] -= operation.article_nb else: - if operationgroup.on_acc.is_cash: - data["errors"]["account"] = "LIQ" + if on_acc.is_cash: + data["errors"].append( + { + "code": "invalid_liq", + "message": ( + "Impossible de compter autre chose que des achats sur LIQ" + ), + } + ) if operation.type != Operation.EDIT: to_checkout_balance += operation.amount operationgroup.amount += operation.amount if operation.type == Operation.DEPOSIT: required_perms.add("kfet.perform_deposit") + if request.user.profile.account_kfet == on_acc: + data["errors"].append( + { + "code": "auto_deposit", + "message": ("Impossible de charger son propre trigramme"), + } + ) if operation.type == Operation.EDIT: required_perms.add("kfet.edit_balance_account") need_comment = True - if operationgroup.on_acc.is_cof: + if on_acc.is_cof: to_addcost_for_balance = to_addcost_for_balance / cof_grant_divisor - (perms, stop) = operationgroup.on_acc.perms_to_perform_operation( - amount=operationgroup.amount - ) + (perms, stop) = on_acc.perms_to_perform_operation(amount=operationgroup.amount) required_perms |= perms + if stop: + data["errors"].append( + { + "code": "negative", + "message": f"Le compte {on_acc.trigramme} a un solde insuffisant.", + } + ) + if need_comment: operationgroup.comment = operationgroup.comment.strip() if not operationgroup.comment: - data["errors"]["need_comment"] = True + data["need_comment"] = True - if data["errors"]: + if data["errors"] or "need_comment" in data: return JsonResponse(data, status=400) - if stop or not request.user.has_perms(required_perms): - missing_perms = get_missing_perms(required_perms, request.user) - if missing_perms: - data["errors"]["missing_perms"] = missing_perms - if stop: - data["errors"]["negative"] = [operationgroup.on_acc.trigramme] + if not request.user.has_perms(required_perms): + data["missing_perms"] = get_missing_perms(required_perms, request.user) return JsonResponse(data, status=403) # If 1 perm is required, filling who perform the operations if required_perms: operationgroup.valid_by = request.user.profile.account_kfet # Filling cof status for statistics - operationgroup.is_cof = operationgroup.on_acc.is_cof + operationgroup.is_cof = on_acc.is_cof + operationgroup.is_kfet = on_acc.is_kfet # Starting transaction to ensure data consistency with transaction.atomic(): # If not cash account, # saving account's balance and adding to Negative if not in - on_acc = operationgroup.on_acc if not on_acc.is_cash: ( Account.objects.filter(pk=on_acc.pk).update( @@ -1130,13 +1288,10 @@ def kpsul_perform_operations(request): # Saving operation group operationgroup.save() - data["operationgroup"] = operationgroup.pk - # Filling operationgroup id for each operations and saving for operation in operations: operation.group = operationgroup operation.save() - data["operations"].append(operation.pk) # Updating articles stock for article in to_articles_stocks: @@ -1145,21 +1300,23 @@ def kpsul_perform_operations(request): ) # Websocket data - websocket_data = {} - websocket_data["opegroups"] = [ + websocket_data = {"type": "kpsul"} + websocket_data["groups"] = [ { "add": True, + "type": "operation", "id": operationgroup.pk, "amount": operationgroup.amount, "checkout__name": operationgroup.checkout.name, "at": operationgroup.at, "is_cof": operationgroup.is_cof, + "is_kfet": operationgroup.is_kfet, "comment": operationgroup.comment, "valid_by__trigramme": ( operationgroup.valid_by and operationgroup.valid_by.trigramme or None ), - "on_acc__trigramme": operationgroup.on_acc.trigramme, - "opes": [], + "on_acc__trigramme": on_acc.trigramme, + "entries": [], } ] for operation in operations: @@ -1177,7 +1334,7 @@ def kpsul_perform_operations(request): "canceled_by__trigramme": None, "canceled_at": None, } - websocket_data["opegroups"][0]["opes"].append(ope_data) + websocket_data["groups"][0]["entries"].append(ope_data) # Need refresh from db cause we used update on queryset operationgroup.checkout.refresh_from_db() websocket_data["checkouts"] = [ @@ -1191,15 +1348,17 @@ def kpsul_perform_operations(request): websocket_data["articles"].append( {"id": article["id"], "stock": article["stock"]} ) - consumers.KPsul.group_send("kfet.kpsul", websocket_data) + + KPsul.group_send("kfet.kpsul", websocket_data) + return JsonResponse(data) @teamkfet_required @kfet_password_auth -def kpsul_cancel_operations(request): +def cancel_operations(request): # Pour la réponse - data = {"canceled": [], "warnings": {}, "errors": {}} + data = {"canceled": [], "warnings": {}, "errors": []} # Checking if BAD REQUEST (opes_pk not int or not existing) try: @@ -1208,29 +1367,41 @@ def kpsul_cancel_operations(request): map(int, filter(None, request.POST.getlist("operations[]", []))) ) except ValueError: + data["errors"].append( + {"code": "invalid_request", "message": "Requête invalide !"} + ) return JsonResponse(data, status=400) + opes_all = Operation.objects.select_related( "group", "group__on_acc", "group__on_acc__negative" ).filter(pk__in=opes_post) opes_pk = [ope.pk for ope in opes_all] opes_notexisting = [ope for ope in opes_post if ope not in opes_pk] if opes_notexisting: - data["errors"]["opes_notexisting"] = opes_notexisting + data["errors"].append( + { + "code": "cancel_missing", + "message": "Opérations inexistantes : {}".format( + ", ".join(map(str, opes_notexisting)) + ), + } + ) return JsonResponse(data, status=400) opes_already_canceled = [] # Déjà annulée opes = [] # Pas déjà annulée required_perms = set() - stop_all = False cancel_duration = kfet_config.cancel_duration - to_accounts_balances = defaultdict( - lambda: 0 - ) # Modifs à faire sur les balances des comptes - to_groups_amounts = defaultdict( - lambda: 0 - ) # ------ sur les montants des groupes d'opé - to_checkouts_balances = defaultdict(lambda: 0) # ------ sur les balances de caisses - to_articles_stocks = defaultdict(lambda: 0) # ------ sur les stocks d'articles + + # Modifs à faire sur les balances des comptes + to_accounts_balances = defaultdict(int) + # ------ sur les montants des groupes d'opé + to_groups_amounts = defaultdict(int) + # ------ sur les balances de caisses + to_checkouts_balances = defaultdict(int) + # ------ sur les stocks d'articles + to_articles_stocks = defaultdict(int) + for ope in opes_all: if ope.canceled_at: # Opération déjà annulée, va pour un warning en Response @@ -1301,16 +1472,22 @@ def kpsul_cancel_operations(request): amount=to_accounts_balances[account] ) required_perms |= perms - stop_all = stop_all or stop if stop: negative_accounts.append(account.trigramme) - if stop_all or not request.user.has_perms(required_perms): - missing_perms = get_missing_perms(required_perms, request.user) - if missing_perms: - data["errors"]["missing_perms"] = missing_perms - if stop_all: - data["errors"]["negative"] = negative_accounts + if negative_accounts: + data["errors"].append( + { + "code": "negative", + "message": "Solde insuffisant pour les comptes suivants : {}".format( + ", ".join(negative_accounts) + ), + } + ) + return JsonResponse(data, status=400) + + if not request.user.has_perms(required_perms): + data["missing_perms"] = get_missing_perms(required_perms, request.user) return JsonResponse(data, status=403) canceled_by = required_perms and request.user.profile.account_kfet or None @@ -1349,11 +1526,15 @@ def kpsul_cancel_operations(request): # Sort objects by pk to get deterministic responses. opegroups_pk = [opegroup.pk for opegroup in to_groups_amounts] opegroups = ( - OperationGroup.objects.values("id", "amount", "is_cof") + OperationGroup.objects.values("id", "amount", "is_cof", "is_kfet") .filter(pk__in=opegroups_pk) .order_by("pk") ) - opes = sorted(opes) + opes = ( + Operation.objects.values("id", "canceled_at", "canceled_by__trigramme") + .filter(pk__in=opes) + .order_by("pk") + ) checkouts_pk = [checkout.pk for checkout in to_checkouts_balances] checkouts = ( Checkout.objects.values("id", "balance") @@ -1364,27 +1545,7 @@ def kpsul_cancel_operations(request): articles = Article.objects.values("id", "stock").filter(pk__in=articles_pk) # Websocket data - websocket_data = {"opegroups": [], "opes": [], "checkouts": [], "articles": []} - - for opegroup in opegroups: - websocket_data["opegroups"].append( - { - "cancellation": True, - "id": opegroup["id"], - "amount": opegroup["amount"], - "is_cof": opegroup["is_cof"], - } - ) - canceled_by__trigramme = canceled_by and canceled_by.trigramme or None - for ope in opes: - websocket_data["opes"].append( - { - "cancellation": True, - "id": ope, - "canceled_by__trigramme": canceled_by__trigramme, - "canceled_at": canceled_at, - } - ) + websocket_data = {"checkouts": [], "articles": [], "type": "kpsul"} for checkout in checkouts: websocket_data["checkouts"].append( {"id": checkout["id"], "balance": checkout["balance"]} @@ -1393,62 +1554,142 @@ def kpsul_cancel_operations(request): websocket_data["articles"].append( {"id": article["id"], "stock": article["stock"]} ) - consumers.KPsul.group_send("kfet.kpsul", websocket_data) - data["canceled"] = opes + KPsul.group_send("kfet.kpsul", websocket_data) + + data["canceled"] = list(opes) + data["opegroups_to_update"] = list(opegroups) if opes_already_canceled: data["warnings"]["already_canceled"] = opes_already_canceled return JsonResponse(data) +def get_history_limit(user) -> Tuple[datetime, datetime]: + """returns a tuple of 2 dates + - the earliest date the given user can view history of any account + - the earliest date the given user can view history of special accounts + (LIQ and #13)""" + now = timezone.now() + if user.has_perm("kfet.access_old_history"): + return ( + now - settings.KFET_HISTORY_LONG_DATE_LIMIT, + settings.KFET_HISTORY_NO_DATE_LIMIT, + ) + if user.has_perm("kfet.is_team"): + limit = now - settings.KFET_HISTORY_DATE_LIMIT + return limit, limit + # should not happen - future earliest date + future = now + timedelta(days=1) + return future, future + + @login_required def history_json(request): # Récupération des paramètres - from_date = request.POST.get("from", None) - to_date = request.POST.get("to", None) - limit = request.POST.get("limit", None) - checkouts = request.POST.getlist("checkouts[]", None) - accounts = request.POST.getlist("accounts[]", None) + form = FilterHistoryForm(request.GET) + + if not form.is_valid(): + return HttpResponseBadRequest() + + start = form.cleaned_data["start"] + end = form.cleaned_data["end"] + account = form.cleaned_data["account"] + checkout = form.cleaned_data["checkout"] + transfers_only = form.cleaned_data["transfers_only"] + opes_only = form.cleaned_data["opes_only"] + + # Construction de la requête (sur les transferts) pour le prefetch + + transfer_queryset_prefetch = Transfer.objects.select_related( + "from_acc", "to_acc", "canceled_by" + ) + + # Le check sur les comptes est dans le prefetch pour les transferts + if account: + transfer_queryset_prefetch = transfer_queryset_prefetch.filter( + Q(from_acc=account) | Q(to_acc=account) + ) + + if not request.user.has_perm("kfet.is_team"): + try: + acc = request.user.profile.account_kfet + transfer_queryset_prefetch = transfer_queryset_prefetch.filter( + Q(from_acc=acc) | Q(to_acc=acc) + ) + except Account.DoesNotExist: + return JsonResponse({}, status=403) + + transfer_prefetch = Prefetch( + "transfers", queryset=transfer_queryset_prefetch, to_attr="filtered_transfers" + ) # Construction de la requête (sur les opérations) pour le prefetch - queryset_prefetch = Operation.objects.select_related( + ope_queryset_prefetch = Operation.objects.select_related( "article", "canceled_by", "addcost_for" ) + ope_prefetch = Prefetch("opes", queryset=ope_queryset_prefetch) # Construction de la requête principale opegroups = ( - OperationGroup.objects.prefetch_related( - Prefetch("opes", queryset=queryset_prefetch) - ) + OperationGroup.objects.prefetch_related(ope_prefetch) .select_related("on_acc", "valid_by") .order_by("at") ) + transfergroups = ( + TransferGroup.objects.prefetch_related(transfer_prefetch) + .select_related("valid_by") + .order_by("at") + ) + + # limite l'accès à l'historique plus vieux que settings.KFET_HISTORY_DATE_LIMIT + limit_date = True + # Application des filtres - if from_date: - opegroups = opegroups.filter(at__gte=from_date) - if to_date: - opegroups = opegroups.filter(at__lt=to_date) - if checkouts: - opegroups = opegroups.filter(checkout_id__in=checkouts) - if accounts: - opegroups = opegroups.filter(on_acc_id__in=accounts) + if start: + opegroups = opegroups.filter(at__gte=start) + transfergroups = transfergroups.filter(at__gte=start) + if end: + opegroups = opegroups.filter(at__lt=end) + transfergroups = transfergroups.filter(at__lt=end) + if checkout: + opegroups = opegroups.filter(checkout=checkout) + transfergroups = TransferGroup.objects.none() + if transfers_only: + opegroups = OperationGroup.objects.none() + if opes_only: + transfergroups = TransferGroup.objects.none() + if account: + opegroups = opegroups.filter(on_acc=account) + if account.user == request.user: + limit_date = False # pas de limite de date sur son propre historique # Un non-membre de l'équipe n'a que accès à son historique - if not request.user.has_perm("kfet.is_team"): - opegroups = opegroups.filter(on_acc=request.user.profile.account_kfet) - if limit: - opegroups = opegroups[:limit] + elif not request.user.has_perm("kfet.is_team"): + # un non membre de la kfet doit avoir le champ account + # pré-rempli, cette requête est douteuse + return JsonResponse({}, status=403) + if limit_date: + # limiter l'accès à l'historique ancien pour confidentialité + earliest_date, earliest_date_no_limit = get_history_limit(request.user) + if ( + account + and account.trigramme in settings.KFET_HISTORY_NO_DATE_LIMIT_TRIGRAMMES + ): + earliest_date = earliest_date_no_limit + opegroups = opegroups.filter(at__gte=earliest_date) + transfergroups = transfergroups.filter(at__gte=earliest_date) # Construction de la réponse - opegroups_list = [] + history_groups = [] for opegroup in opegroups: opegroup_dict = { + "type": "operation", "id": opegroup.id, "amount": opegroup.amount, "at": opegroup.at, "checkout_id": opegroup.checkout_id, "is_cof": opegroup.is_cof, "comment": opegroup.comment, - "opes": [], + "entries": [], "on_acc__trigramme": opegroup.on_acc and opegroup.on_acc.trigramme or None, } if request.user.has_perm("kfet.is_team"): @@ -1472,9 +1713,40 @@ def history_json(request): ope_dict["canceled_by__trigramme"] = ( ope.canceled_by and ope.canceled_by.trigramme or None ) - opegroup_dict["opes"].append(ope_dict) - opegroups_list.append(opegroup_dict) - return JsonResponse({"opegroups": opegroups_list}) + opegroup_dict["entries"].append(ope_dict) + history_groups.append(opegroup_dict) + for transfergroup in transfergroups: + if transfergroup.filtered_transfers: + transfergroup_dict = { + "type": "transfer", + "id": transfergroup.id, + "at": transfergroup.at, + "comment": transfergroup.comment, + "entries": [], + } + if request.user.has_perm("kfet.is_team"): + transfergroup_dict["valid_by__trigramme"] = ( + transfergroup.valid_by and transfergroup.valid_by.trigramme or None + ) + + for transfer in transfergroup.filtered_transfers: + transfer_dict = { + "id": transfer.id, + "amount": transfer.amount, + "canceled_at": transfer.canceled_at, + "from_acc": transfer.from_acc.trigramme, + "to_acc": transfer.to_acc.trigramme, + } + if request.user.has_perm("kfet.is_team"): + transfer_dict["canceled_by__trigramme"] = ( + transfer.canceled_by and transfer.canceled_by.trigramme or None + ) + transfergroup_dict["entries"].append(transfer_dict) + history_groups.append(transfergroup_dict) + + history_groups.sort(key=lambda group: group["at"]) + + return JsonResponse({"groups": history_groups}) @teamkfet_required @@ -1483,17 +1755,32 @@ def kpsul_articles_data(request): "id", "name", "price", + "no_exte", "stock", "category_id", "category__name", "category__has_addcost", + "category__has_reduction", ).filter(is_sold=True) return JsonResponse({"articles": list(articles)}) @teamkfet_required def history(request): - data = {"filter_form": FilterHistoryForm()} + # These limits are only useful for JS datepickers + # They don't enforce anything and can be bypassed + # Serious checks are done in history_json + history_limit, history_no_limit = get_history_limit(request.user) + history_no_limit_account_ids = Account.objects.filter( + trigramme__in=settings.KFET_HISTORY_NO_DATE_LIMIT_TRIGRAMMES + ).values_list("id", flat=True) + format_date = lambda date: date.strftime("%Y-%m-%d %H:%M") + data = { + "filter_form": FilterHistoryForm(), + "history_limit": format_date(history_limit), + "history_no_limit_account_ids": history_no_limit_account_ids, + "history_no_limit": format_date(history_no_limit), + } return render(request, "kfet/history.html", data) @@ -1509,6 +1796,7 @@ class SettingsList(TemplateView): config_list = permission_required("kfet.see_config")(SettingsList.as_view()) +@method_decorator(kfet_password_auth, name="dispatch") class SettingsUpdate(SuccessMessageMixin, FormView): form_class = KFetConfigForm template_name = "kfet/settings_update.html" @@ -1518,7 +1806,9 @@ class SettingsUpdate(SuccessMessageMixin, FormView): def form_valid(self, form): # Checking permission if not self.request.user.has_perm("kfet.change_config"): - form.add_error(None, "Permission refusée") + form.add_error( + None, ValidationError("Permission refusée", code="permission-denied") + ) return self.form_invalid(form) form.save() return super().form_valid(form) @@ -1532,18 +1822,9 @@ config_update = permission_required("kfet.change_config")(SettingsUpdate.as_view # ----- -@teamkfet_required -def transfers(request): - transfers_pre = Prefetch( - "transfers", queryset=(Transfer.objects.select_related("from_acc", "to_acc")) - ) - - transfergroups = ( - TransferGroup.objects.select_related("valid_by") - .prefetch_related(transfers_pre) - .order_by("-at") - ) - return render(request, "kfet/transfers.html", {"transfergroups": transfergroups}) +@method_decorator(teamkfet_required, name="dispatch") +class TransferView(TemplateView): + template_name = "kfet/transfers.html" @teamkfet_required @@ -1557,12 +1838,36 @@ def transfers_create(request): @teamkfet_required @kfet_password_auth def perform_transfers(request): - data = {"errors": {}, "transfers": [], "transfergroup": 0} + data = {"errors": []} # Checking transfer_formset transfer_formset = TransferFormSet(request.POST) - if not transfer_formset.is_valid(): - return JsonResponse({"errors": list(transfer_formset.errors)}, status=400) + try: + if not transfer_formset.is_valid(): + for form_errors in transfer_formset.errors: + for field, errors in form_errors.items(): + if field == "amount": + for error in errors: + data["errors"].append({"code": "amount", "message": error}) + else: + # C'est compliqué de trouver le compte qui pose problème... + acc_error = True + + if acc_error: + data["errors"].append( + { + "code": "invalid_acc", + "message": "L'un des comptes est invalide ou manquant", + } + ) + + return JsonResponse(data, status=400) + + except ValidationError: + data["errors"].append( + {"code": "invalid_request", "message": "Requête invalide"} + ) + return JsonResponse(data, status=400) transfers = transfer_formset.save(commit=False) @@ -1570,31 +1875,51 @@ def perform_transfers(request): required_perms = set( ["kfet.add_transfer"] ) # Required perms to perform all transfers - to_accounts_balances = defaultdict(lambda: 0) # For balances of accounts + to_accounts_balances = defaultdict(int) # For balances of accounts for transfer in transfers: to_accounts_balances[transfer.from_acc] -= transfer.amount to_accounts_balances[transfer.to_acc] += transfer.amount - stop_all = False - negative_accounts = [] # Checking if ok on all accounts + frozen = set() for account in to_accounts_balances: + if account.is_frozen: + frozen.add(account.trigramme) + (perms, stop) = account.perms_to_perform_operation( amount=to_accounts_balances[account] ) required_perms |= perms - stop_all = stop_all or stop if stop: negative_accounts.append(account.trigramme) - if stop_all or not request.user.has_perms(required_perms): - missing_perms = get_missing_perms(required_perms, request.user) - if missing_perms: - data["errors"]["missing_perms"] = missing_perms - if stop_all: - data["errors"]["negative"] = negative_accounts + if frozen: + data["errors"].append( + { + "code": "frozen", + "message": "Les comptes suivants sont gelés : {}".format( + ", ".join(frozen) + ), + } + ) + + if negative_accounts: + data["errors"].append( + { + "code": "negative", + "message": "Solde insuffisant pour les comptes suivants : {}".format( + ", ".join(negative_accounts) + ), + } + ) + + if data["errors"]: + return JsonResponse(data, status=400) + + if not request.user.has_perms(required_perms): + data["missing_perms"] = get_missing_perms(required_perms, request.user) return JsonResponse(data, status=403) # Creating transfer group @@ -1612,35 +1937,24 @@ def perform_transfers(request): balance=F("balance") + to_accounts_balances[account] ) account.refresh_from_db() - if account.balance < 0: - if hasattr(account, "negative"): - if not account.negative.start: - account.negative.start = timezone.now() - account.negative.save() - else: - negative = AccountNegative(account=account, start=timezone.now()) - negative.save() - elif hasattr(account, "negative") and not account.negative.balance_offset: - account.negative.delete() + account.update_negative() # Saving transfer group transfergroup.save() - data["transfergroup"] = transfergroup.pk # Saving all transfers with group for transfer in transfers: transfer.group = transfergroup transfer.save() - data["transfers"].append(transfer.pk) - return JsonResponse(data) + return JsonResponse({}) @teamkfet_required @kfet_password_auth def cancel_transfers(request): # Pour la réponse - data = {"canceled": [], "warnings": {}, "errors": {}} + data = {"canceled": [], "warnings": {}, "errors": []} # Checking if BAD REQUEST (transfers_pk not int or not existing) try: @@ -1649,7 +1963,11 @@ def cancel_transfers(request): map(int, filter(None, request.POST.getlist("transfers[]", []))) ) except ValueError: + data["errors"].append( + {"code": "invalid_request", "message": "Requête invalide !"} + ) return JsonResponse(data, status=400) + transfers_all = Transfer.objects.select_related( "group", "from_acc", "from_acc__negative", "to_acc", "to_acc__negative" ).filter(pk__in=transfers_post) @@ -1658,17 +1976,23 @@ def cancel_transfers(request): transfer for transfer in transfers_post if transfer not in transfers_pk ] if transfers_notexisting: - data["errors"]["transfers_notexisting"] = transfers_notexisting + data["errors"].append( + { + "code": "cancel_missing", + "message": "Transferts inexistants : {}".format( + ", ".join(map(str, transfers_notexisting)) + ), + } + ) return JsonResponse(data, status=400) - transfers_already_canceled = [] # Déjà annulée - transfers = [] # Pas déjà annulée + transfers_already_canceled = [] # Déjà annulés + transfers = [] # Pas déjà annulés required_perms = set() - stop_all = False cancel_duration = kfet_config.cancel_duration - to_accounts_balances = defaultdict( - lambda: 0 - ) # Modifs à faire sur les balances des comptes + + # Modifs à faire sur les balances des comptes + to_accounts_balances = defaultdict(int) for transfer in transfers_all: if transfer.canceled_at: # Transfert déjà annulé, va pour un warning en Response @@ -1696,16 +2020,22 @@ def cancel_transfers(request): amount=to_accounts_balances[account] ) required_perms |= perms - stop_all = stop_all or stop if stop: negative_accounts.append(account.trigramme) - if stop_all or not request.user.has_perms(required_perms): - missing_perms = get_missing_perms(required_perms, request.user) - if missing_perms: - data["errors"]["missing_perms"] = missing_perms - if stop_all: - data["errors"]["negative"] = negative_accounts + if negative_accounts: + data["errors"].append( + { + "code": "negative", + "message": "Solde insuffisant pour les comptes suivants : {}".format( + ", ".join(negative_accounts) + ), + } + ) + return JsonResponse(data, status=400) + + if not request.user.has_perms(required_perms): + data["missing_perms"] = get_missing_perms(required_perms, request.user) return JsonResponse(data, status=403) canceled_by = required_perms and request.user.profile.account_kfet or None @@ -1723,18 +2053,14 @@ def cancel_transfers(request): balance=F("balance") + to_accounts_balances[account] ) account.refresh_from_db() - if account.balance < 0: - if hasattr(account, "negative"): - if not account.negative.start: - account.negative.start = timezone.now() - account.negative.save() - else: - negative = AccountNegative(account=account, start=timezone.now()) - negative.save() - elif hasattr(account, "negative") and not account.negative.balance_offset: - account.negative.delete() + account.update_negative() - data["canceled"] = transfers + transfers = ( + Transfer.objects.values("id", "canceled_at", "canceled_by__trigramme") + .filter(pk__in=transfers) + .order_by("pk") + ) + data["canceled"] = list(transfers) if transfers_already_canceled: data["warnings"]["already_canceled"] = transfers_already_canceled return JsonResponse(data) @@ -1753,14 +2079,14 @@ class InventoryList(ListView): @teamkfet_required @kfet_password_auth def inventory_create(request): - articles = Article.objects.select_related("category").order_by( - "category__name", "name" + "-is_sold", "category__name", "name" ) initial = [] for article in articles: initial.append( { + "is_sold": article.is_sold, "article": article.pk, "stock_old": article.stock, "name": article.name, @@ -1776,10 +2102,11 @@ def inventory_create(request): formset = cls_formset(request.POST, initial=initial) if not request.user.has_perm("kfet.add_inventory"): - messages.error(request, "Permission refusée") + messages.error( + request, "Permission refusée", extra_tags="permission-denied" + ) elif formset.is_valid(): with transaction.atomic(): - articles = Article.objects.select_for_update() inventory = Inventory() inventory.by = request.user.profile.account_kfet @@ -1820,15 +2147,63 @@ class InventoryRead(DetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - inventoryarticles = ( + output_field = DecimalField(max_digits=10, decimal_places=2, default=0) + inventory_articles = ( InventoryArticle.objects.select_related("article", "article__category") .filter(inventory=self.object) + .annotate( + amount_error=ExpressionWrapper( + F("stock_error") * F("article__price"), output_field=output_field + ) + ) .order_by("article__category__name", "article__name") ) - context["inventoryarts"] = inventoryarticles + context["inventoryarts"] = inventory_articles + stats = inventory_articles.aggregate( + new=ExpressionWrapper( + Sum(F("stock_new") * F("article__price")), output_field=output_field + ), + error=Sum("amount_error"), + old=ExpressionWrapper( + Sum(F("stock_old") * F("article__price")), output_field=output_field + ), + ) + context.update( + { + "total_amount_old": stats["old"], + "total_amount_new": stats["new"], + "total_amount_error": stats["error"], + } + ) return context +class InventoryDelete(PermissionRequiredMixin, DeleteView): + model = Inventory + success_url = reverse_lazy("kfet.inventory") + success_message = "Inventaire annulé avec succès !" + permission_required = "kfet.delete_inventory" + + def get(self, request, *args, **kwargs): + return redirect("kfet.inventory.read", self.kwargs.get(self.pk_url_kwarg)) + + def delete(self, request, *args, **kwargs): + inv = self.get_object() + # On met à jour les articles dont c'est le dernier inventaire + # .get() ne marche pas avec OuterRef, donc on utilise .filter() avec [:1] + update_subquery = InventoryArticle.objects.filter( + inventory=inv, article=OuterRef("pk") + ).values("stock_old")[:1] + + Article.objects.annotate(last_env=Max("inventories__at")).filter( + last_env=inv.at + ).update(stock=Subquery(update_subquery)) + + # On a tout mis à jour, on peut delete (avec un message) + messages.success(request, self.success_message) + return super().delete(request, *args, **kwargs) + + # ----- # Order views # ----- @@ -1854,7 +2229,7 @@ def order_create(request, pk): Article.objects.filter(suppliers=supplier.pk) .distinct() .select_related("category") - .order_by("category__name", "name") + .order_by("-is_sold", "category__name", "name") ) # Force hit to cache @@ -1883,21 +2258,11 @@ def order_create(request, pk): v_et = statistics.pstdev(v_3max, v_moy) # Expected sales for next week v_prev = v_moy + v_et - # We want to have 1.5 * the expected sales in stock - # (because sometimes some articles are not delivered) - c_rec_tot = max(v_prev * 1.5 - article.stock, 0) - # If ordered quantity is close enough to a level which can led to free - # boxes, we increase it to this level. + + c_rec_tot = max(v_prev - max(article.stock, 0), 0) if article.box_capacity: c_rec_temp = c_rec_tot / article.box_capacity - if c_rec_temp >= 10: - c_rec = round(c_rec_temp) - elif c_rec_temp > 5: - c_rec = 10 - elif c_rec_temp > 2: - c_rec = 5 - else: - c_rec = round(c_rec_temp) + c_rec = round(c_rec_temp) initial.append( { "article": article.pk, @@ -1910,7 +2275,8 @@ def order_create(request, pk): "v_moy": round(v_moy), "v_et": round(v_et), "v_prev": round(v_prev), - "c_rec": article.box_capacity and c_rec or round(c_rec_tot), + "c_rec_1w": article.box_capacity and c_rec or round(c_rec_tot), + "is_sold": article.is_sold, } ) @@ -1920,7 +2286,9 @@ def order_create(request, pk): formset = cls_formset(request.POST, initial=initial) if not request.user.has_perm("kfet.add_order"): - messages.error(request, "Permission refusée") + messages.error( + request, "Permission refusée", extra_tags="permission-denied" + ) elif formset.is_valid(): order = Order() order.supplier = supplier @@ -2044,7 +2412,9 @@ def order_to_inventory(request, pk): formset = cls_formset(request.POST, initial=initial) if not request.user.has_perm("kfet.order_to_inventory"): - messages.error(request, "Permission refusée") + messages.error( + request, "Permission refusée", extra_tags="permission-denied" + ) elif formset.is_valid(): with transaction.atomic(): inventory = Inventory.objects.create( @@ -2119,7 +2489,9 @@ class SupplierUpdate(SuccessMessageMixin, UpdateView): def form_valid(self, form): # Checking permission if not self.request.user.has_perm("kfet.change_supplier"): - form.add_error(None, "Permission refusée") + form.add_error( + None, ValidationError("Permission refusée", code="permission-denied") + ) return self.form_invalid(form) # Updating return super().form_valid(form) @@ -2129,11 +2501,12 @@ class SupplierUpdate(SuccessMessageMixin, UpdateView): # Statistics # ========== + # --------------- # Vues génériques # --------------- # source : docs.djangoproject.com/fr/1.10/topics/class-based-views/mixins/ -class JSONResponseMixin(object): +class JSONResponseMixin: """ A mixin that can be used to render a JSON response. """ @@ -2162,34 +2535,39 @@ class JSONDetailView(JSONResponseMixin, BaseDetailView): return self.render_to_json_response(context) -class PkUrlMixin(object): - def get_object(self, *args, **kwargs): - get_by = self.kwargs.get(self.pk_url_kwarg) - return get_object_or_404(self.model, **{self.pk_url_kwarg: get_by}) - - class SingleResumeStat(JSONDetailView): - """Manifest for a kind of a stat about an object. + """ + Génère l'interface de sélection pour les statistiques d'un compte/article. + L'interface est constituée d'une série de boutons, qui récupèrent et graphent + des statistiques du même type, sur le même objet mais avec des arguments différents. - Returns JSON whose payload is an array containing descriptions of a stat: - url to retrieve data, label, ... + Attributs : + - url_stat : URL où récupérer les statistiques + - stats : liste de dictionnaires avec les clés suivantes : + - label : texte du bouton + - url_params : paramètres GET à rajouter à `url_stat` + - default : si `True`, graphe à montrer par défaut + On peut aussi définir `stats` dynamiquement, via la fonction `get_stats`. """ - id_prefix = "" - nb_default = 0 - - stats = [] url_stat = None + stats = [] + + def get_stats(self): + return self.stats def get_context_data(self, **kwargs): # On n'hérite pas - object_id = self.object.id context = {} stats = [] - prefix = "{}_{}".format(self.id_prefix, object_id) - for i, stat_def in enumerate(self.stats): + # On peut avoir récupéré self.object via pk ou slug + if self.pk_url_kwarg in self.kwargs: url_pk = getattr(self.object, self.pk_url_kwarg) + else: + url_pk = getattr(self.object, self.slug_url_kwarg) + + for stat_def in self.get_stats(): url_params_d = stat_def.get("url_params", {}) if len(url_params_d) > 0: url_params = "?{}".format(urlencode(url_params_d)) @@ -2198,42 +2576,21 @@ class SingleResumeStat(JSONDetailView): stats.append( { "label": stat_def["label"], - "btn": "btn_{}_{}".format(prefix, i), "url": "{url}{params}".format( url=reverse(self.url_stat, args=[url_pk]), params=url_params ), + "default": stat_def.get("default", False), } ) - context["id_prefix"] = prefix - context["content_id"] = "content_%s" % prefix context["stats"] = stats - context["default_stat"] = self.nb_default - context["object_id"] = object_id return context -# ----------------------- -# Evolution Balance perso -# ----------------------- -ID_PREFIX_ACC_BALANCE = "balance_acc" - - -class AccountStatBalanceList(PkUrlMixin, SingleResumeStat): - """Manifest for balance stats of an account.""" - - model = Account - context_object_name = "account" - pk_url_kwarg = "trigramme" - url_stat = "kfet.account.stat.balance" - id_prefix = ID_PREFIX_ACC_BALANCE - stats = [ - {"label": "Tout le temps"}, - {"label": "1 an", "url_params": {"last_days": 365}}, - {"label": "6 mois", "url_params": {"last_days": 183}}, - {"label": "3 mois", "url_params": {"last_days": 90}}, - {"label": "30 jours", "url_params": {"last_days": 30}}, - ] - nb_default = 0 +class UserAccountMixin: + """ + Mixin qui vérifie que le compte traité par la vue est celui de l'utilisateur·ice + actuel·le. Dans le cas contraire, renvoie un Http404. + """ def get_object(self, *args, **kwargs): obj = super().get_object(*args, **kwargs) @@ -2241,21 +2598,61 @@ class AccountStatBalanceList(PkUrlMixin, SingleResumeStat): raise Http404 return obj - @method_decorator(login_required) - def dispatch(self, *args, **kwargs): - return super().dispatch(*args, **kwargs) + +class ScaleMixin(object): + """Mixin pour utiliser les outils de `kfet.statistic`.""" + + def get_context_data(self, *args, **kwargs): + # On n'hérite pas + form = StatScaleForm(self.request.GET, prefix="scale") + + if not form.is_valid(): + raise SuspiciousOperation( + "Invalid StatScaleForm. Did someone tamper with the GET parameters ?" + ) + + scale_name = form.cleaned_data.pop("name") + scale_cls = SCALE_DICT.get(scale_name) + + self.scale = scale_cls(**form.cleaned_data) + + return {"labels": self.scale.get_labels()} -class AccountStatBalance(PkUrlMixin, JSONDetailView): - """Datasets of balance of an account. +# ----------------------- +# Evolution Balance perso +# ----------------------- - Operations and Transfers are taken into account. +@method_decorator(login_required, name="dispatch") +class AccountStatBalanceList(UserAccountMixin, SingleResumeStat): + """ + Menu général pour l'historique de balance d'un compte """ model = Account - pk_url_kwarg = "trigramme" - context_object_name = "account" + slug_url_kwarg = "trigramme" + slug_field = "trigramme" + url_stat = "kfet.account.stat.balance" + stats = [ + {"label": "Tout le temps"}, + {"label": "1 an", "url_params": {"last_days": 365}}, + {"label": "6 mois", "url_params": {"last_days": 183}}, + {"label": "3 mois", "url_params": {"last_days": 90}, "default": True}, + {"label": "30 jours", "url_params": {"last_days": 30}}, + ] + + +@method_decorator(login_required, name="dispatch") +class AccountStatBalance(UserAccountMixin, JSONDetailView): + """ + Statistiques (JSON) d'historique de balance d'un compte. + Prend en compte les opérations et transferts sur la période donnée. + """ + + model = Account + slug_url_kwarg = "trigramme" + slug_field = "trigramme" def get_changes_list(self, last_days=None, begin_date=None, end_date=None): account = self.object @@ -2334,15 +2731,14 @@ class AccountStatBalance(PkUrlMixin, JSONDetailView): def get_context_data(self, *args, **kwargs): context = {} - last_days = self.request.GET.get("last_days", None) - if last_days is not None: - last_days = int(last_days) - begin_date = self.request.GET.get("begin_date", None) - end_date = self.request.GET.get("end_date", None) + form = AccountStatForm(self.request.GET) - changes = self.get_changes_list( - last_days=last_days, begin_date=begin_date, end_date=end_date - ) + if not form.is_valid(): + raise SuspiciousOperation( + "Invalid AccountStatForm. Did someone tamper with the GET parameters ?" + ) + + changes = self.get_changes_list(**form.cleaned_data) context["charts"] = [ {"color": "rgb(200, 20, 60)", "label": "Balance", "values": changes} @@ -2354,90 +2750,63 @@ class AccountStatBalance(PkUrlMixin, JSONDetailView): # TODO: offset return context - def get_object(self, *args, **kwargs): - obj = super().get_object(*args, **kwargs) - if self.request.user != obj.user: - raise Http404 - return obj - - @method_decorator(login_required) - def dispatch(self, *args, **kwargs): - return super().dispatch(*args, **kwargs) - # ------------------------ # Consommation personnelle # ------------------------ -ID_PREFIX_ACC_LAST = "last_acc" -ID_PREFIX_ACC_LAST_DAYS = "last_days_acc" -ID_PREFIX_ACC_LAST_WEEKS = "last_weeks_acc" -ID_PREFIX_ACC_LAST_MONTHS = "last_months_acc" -class AccountStatOperationList(PkUrlMixin, SingleResumeStat): - """Manifest for operations stats of an account.""" +@method_decorator(login_required, name="dispatch") +class AccountStatOperationList(UserAccountMixin, SingleResumeStat): + """ + Menu général pour l'historique de consommation d'un compte + """ model = Account - context_object_name = "account" - pk_url_kwarg = "trigramme" - id_prefix = ID_PREFIX_ACC_LAST - nb_default = 2 - stats = last_stats_manifest(types=[Operation.PURCHASE]) + slug_url_kwarg = "trigramme" + slug_field = "trigramme" url_stat = "kfet.account.stat.operation" - def get_object(self, *args, **kwargs): - obj = super().get_object(*args, **kwargs) - if self.request.user != obj.user: - raise Http404 - return obj + def get_stats(self): + scales_def = [ + ( + "Tout le temps", + MonthScale, + {"last": True, "begin": self.object.created_at.replace(tzinfo=None)}, + False, + ), + ("1 an", MonthScale, {"last": True, "n_steps": 12}, False), + ("3 mois", WeekScale, {"last": True, "n_steps": 13}, True), + ("2 semaines", DayScale, {"last": True, "n_steps": 14}, False), + ] - @method_decorator(login_required) - def dispatch(self, *args, **kwargs): - return super().dispatch(*args, **kwargs) + return scale_url_params(scales_def) -class AccountStatOperation(ScaleMixin, PkUrlMixin, JSONDetailView): - """Datasets of operations of an account.""" +@method_decorator(login_required, name="dispatch") +class AccountStatOperation(UserAccountMixin, ScaleMixin, JSONDetailView): + """ + Statistiques (JSON) de consommation (nb d'items achetés) d'un compte. + """ model = Account - pk_url_kwarg = "trigramme" - context_object_name = "account" - id_prefix = "" + slug_url_kwarg = "trigramme" + slug_field = "trigramme" - def get_operations(self, scale, types=None): - # On selectionne les opérations qui correspondent - # à l'article en question et qui ne sont pas annulées - # puis on choisi pour chaques intervalle les opérations - # effectuées dans ces intervalles de temps - all_operations = ( - Operation.objects.filter(group__on_acc=self.object, canceled_at=None) + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + + operations = ( + Operation.objects.filter( + type=Operation.PURCHASE, group__on_acc=self.object, canceled_at=None + ) .values("article_nb", "group__at") .order_by("group__at") ) - if types is not None: - all_operations = all_operations.filter(type__in=types) - chunks = scale.get_by_chunks( - all_operations, - field_db="group__at", - field_callback=(lambda d: d["group__at"]), - ) - return chunks - - def get_context_data(self, *args, **kwargs): - old_ctx = super().get_context_data(*args, **kwargs) - context = {"labels": old_ctx["labels"]} - scale = self.scale - - types = self.request.GET.get("types", None) - if types is not None: - types = ast.literal_eval(types) - - operations = self.get_operations(types=types, scale=scale) # On compte les opérations - nb_ventes = [] - for chunk in operations: - ventes = sum(ope["article_nb"] for ope in chunk) - nb_ventes.append(ventes) + nb_ventes = self.scale.chunkify_qs( + operations, field="group__at", aggregate=Sum("article_nb") + ) context["charts"] = [ { @@ -2448,50 +2817,59 @@ class AccountStatOperation(ScaleMixin, PkUrlMixin, JSONDetailView): ] return context - def get_object(self, *args, **kwargs): - obj = super().get_object(*args, **kwargs) - if self.request.user != obj.user: - raise Http404 - return obj - - @method_decorator(login_required) - def dispatch(self, *args, **kwargs): - return super().dispatch(*args, **kwargs) - # ------------------------ -# Article Satistiques Last +# Article Statistiques Last # ------------------------ -ID_PREFIX_ART_LAST = "last_art" -ID_PREFIX_ART_LAST_DAYS = "last_days_art" -ID_PREFIX_ART_LAST_WEEKS = "last_weeks_art" -ID_PREFIX_ART_LAST_MONTHS = "last_months_art" +@method_decorator(teamkfet_required, name="dispatch") class ArticleStatSalesList(SingleResumeStat): - """Manifest for sales stats of an article.""" + """ + Menu pour les statistiques de vente d'un article. + """ model = Article - context_object_name = "article" - id_prefix = ID_PREFIX_ART_LAST nb_default = 2 url_stat = "kfet.article.stat.sales" - stats = last_stats_manifest() - @method_decorator(teamkfet_required) - def dispatch(self, *args, **kwargs): - return super().dispatch(*args, **kwargs) + def get_stats(self): + first_conso = ( + Operation.objects.filter(article=self.object) + .order_by("group__at") + .values_list("group__at", flat=True) + .first() + ) + if first_conso is None: + # On le crée dans le passé au cas où + first_conso = timezone.now() - timedelta(seconds=1) + scales_def = [ + ( + "Tout le temps", + MonthScale, + {"last": True, "begin": first_conso.strftime("%Y-%m-%d %H:%M:%S")}, + False, + ), + ("1 an", MonthScale, {"last": True, "n_steps": 12}, False), + ("3 mois", WeekScale, {"last": True, "n_steps": 13}, True), + ("2 semaines", DayScale, {"last": True, "n_steps": 14}, False), + ] + + return scale_url_params(scales_def) +@method_decorator(teamkfet_required, name="dispatch") class ArticleStatSales(ScaleMixin, JSONDetailView): - """Datasets of sales of an article.""" + """ + Statistiques (JSON) de vente d'un article. + Sépare LIQ et les comptes K-Fêt, et rajoute le total. + """ model = Article context_object_name = "article" def get_context_data(self, *args, **kwargs): - old_ctx = super().get_context_data(*args, **kwargs) - context = {"labels": old_ctx["labels"]} + context = super().get_context_data(*args, **kwargs) scale = self.scale all_purchases = ( @@ -2501,26 +2879,16 @@ class ArticleStatSales(ScaleMixin, JSONDetailView): .values("group__at", "article_nb") .order_by("group__at") ) - liq_only = all_purchases.filter(group__on_acc__trigramme="LIQ") - liq_exclude = all_purchases.exclude(group__on_acc__trigramme="LIQ") + cof_accts = all_purchases.filter(group__on_acc__cofprofile__is_cof=True) + noncof_accts = all_purchases.exclude(group__on_acc__cofprofile__is_cof=True) - chunks_liq = scale.get_by_chunks( - liq_only, field_db="group__at", field_callback=lambda d: d["group__at"] + nb_cof = scale.chunkify_qs( + cof_accts, field="group__at", aggregate=Sum("article_nb") ) - chunks_no_liq = scale.get_by_chunks( - liq_exclude, field_db="group__at", field_callback=lambda d: d["group__at"] + nb_noncof = scale.chunkify_qs( + noncof_accts, field="group__at", aggregate=Sum("article_nb") ) - - # On compte les opérations - nb_ventes = [] - nb_accounts = [] - nb_liq = [] - for chunk_liq, chunk_no_liq in zip(chunks_liq, chunks_no_liq): - sum_accounts = sum(ope["article_nb"] for ope in chunk_no_liq) - sum_liq = sum(ope["article_nb"] for ope in chunk_liq) - nb_ventes.append(sum_accounts + sum_liq) - nb_accounts.append(sum_accounts) - nb_liq.append(sum_liq) + nb_ventes = [n1 + n2 for n1, n2 in zip(nb_cof, nb_noncof)] context["charts"] = [ { @@ -2528,15 +2896,27 @@ class ArticleStatSales(ScaleMixin, JSONDetailView): "label": "Toutes consommations", "values": nb_ventes, }, - {"color": "rgb(54, 162, 235)", "label": "LIQ", "values": nb_liq}, + {"color": "rgb(54, 162, 235)", "label": "Comptes K-Fêt", "values": nb_cof}, { "color": "rgb(255, 205, 86)", - "label": "Comptes K-Fêt", - "values": nb_accounts, + "label": "LIQ", + "values": nb_noncof, }, ] return context - @method_decorator(teamkfet_required) - def dispatch(self, *args, **kwargs): - return super().dispatch(*args, **kwargs) + +# --- +# Autocompletion views +# --- + + +class AccountCreateAutocompleteView(PermissionRequiredMixin, AutocompleteView): + template_name = "kfet/search_results.html" + permission_required = "kfet.is_team" + search_composer = kfet_autocomplete + + +class AccountSearchAutocompleteView(PermissionRequiredMixin, AutocompleteView): + permission_required = "kfet.is_team" + search_composer = kfet_account_only_autocomplete diff --git a/manage.py b/manage.py index 094ec16f..00e46405 100755 --- 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.local") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "gestioasso.settings") from django.core.management import execute_from_command_line diff --git a/npins/default.nix b/npins/default.nix new file mode 100644 index 00000000..d256a275 --- /dev/null +++ b/npins/default.nix @@ -0,0 +1,81 @@ +# Generated by npins. Do not modify; will be overwritten regularly +let + data = builtins.fromJSON (builtins.readFile ./sources.json); + version = data.version; + + mkSource = + spec: + assert spec ? type; + let + path = + if spec.type == "Git" then + mkGitSource spec + else if spec.type == "GitRelease" then + mkGitSource spec + else if spec.type == "PyPi" then + mkPyPiSource spec + else if spec.type == "Channel" then + mkChannelSource spec + else + builtins.throw "Unknown source type ${spec.type}"; + in + spec // { outPath = path; }; + + mkGitSource = + { + repository, + revision, + url ? null, + hash, + branch ? null, + ... + }: + assert repository ? type; + # At the moment, either it is a plain git repository (which has an url), or it is a GitHub/GitLab repository + # In the latter case, there we will always be an url to the tarball + if url != null then + (builtins.fetchTarball { + inherit url; + sha256 = hash; # FIXME: check nix version & use SRI hashes + }) + else + assert repository.type == "Git"; + let + urlToName = + url: rev: + let + matched = builtins.match "^.*/([^/]*)(\\.git)?$" repository.url; + + short = builtins.substring 0 7 rev; + + appendShort = if (builtins.match "[a-f0-9]*" rev) != null then "-${short}" else ""; + in + "${if matched == null then "source" else builtins.head matched}${appendShort}"; + name = urlToName repository.url revision; + in + builtins.fetchGit { + url = repository.url; + rev = revision; + inherit name; + allRefs = true; + # hash = hash; + }; + + mkPyPiSource = + { url, hash, ... }: + builtins.fetchurl { + inherit url; + sha256 = hash; + }; + + mkChannelSource = + { url, hash, ... }: + builtins.fetchTarball { + inherit url; + sha256 = hash; + }; +in +if version == 3 then + builtins.mapAttrs (_: mkSource) data.pins +else + throw "Unsupported format version ${toString version} in sources.json. Try running `npins upgrade`" diff --git a/npins/sources.json b/npins/sources.json new file mode 100644 index 00000000..9ed77931 --- /dev/null +++ b/npins/sources.json @@ -0,0 +1,33 @@ +{ + "pins": { + "kat-pkgs": { + "type": "Git", + "repository": { + "type": "Git", + "url": "https://git.dgnum.eu/lbailly/kat-pkgs.git" + }, + "branch": "master", + "revision": "6b600b716f409c6012b424de006eac3b02148b81", + "url": null, + "hash": "0204f91vxa5qglihpfkf3j5w3k7v98wry861xf2skl024faf9idf" + }, + "nix-pkgs": { + "type": "Git", + "repository": { + "type": "Git", + "url": "https://git.hubrecht.ovh/hubrecht/nix-pkgs" + }, + "branch": "main", + "revision": "ac4ff5a34789ae3398aff9501735b67b6a5a285a", + "url": null, + "hash": "16n37f74p6h30hhid98vab9w5b08xqj4qcshz2kc1jh67z5n49p6" + }, + "nixpkgs": { + "type": "Channel", + "name": "nixos-unstable", + "url": "https://releases.nixos.org/nixos/unstable/nixos-25.05beta719504.a73246e2eef4/nixexprs.tar.xz", + "hash": "1jjmg13jzbqxm5m5ql51n2kq1qggfyb0rhmjwhqhvqxhl350z58a" + } + }, + "version": 3 +} \ No newline at end of file diff --git a/petitscours/forms.py b/petitscours/forms.py index 5309b41d..0d9f38bc 100644 --- a/petitscours/forms.py +++ b/petitscours/forms.py @@ -1,37 +1,28 @@ -from captcha.fields import ReCaptchaField from django import forms from django.contrib.auth.models import User from django.forms import ModelForm -from django.forms.models import BaseInlineFormSet, inlineformset_factory +from django.forms.models import inlineformset_factory +from django.utils.translation import gettext_lazy as _ +from hcaptcha.fields import hCaptchaField from petitscours.models import PetitCoursAbility, PetitCoursDemande -class BaseMatieresFormSet(BaseInlineFormSet): - def clean(self): - super().clean() - if any(self.errors): - # Don't bother validating the formset unless each form is - # valid on its own - return - matieres = [] - for i in range(0, self.total_form_count()): - form = self.forms[i] - if not form.cleaned_data: - continue - matiere = form.cleaned_data["matiere"] - niveau = form.cleaned_data["niveau"] - delete = form.cleaned_data["DELETE"] - if not delete and (matiere, niveau) in matieres: - raise forms.ValidationError( - "Vous ne pouvez pas vous inscrire deux fois pour la " - "même matiere avec le même niveau." - ) - matieres.append((matiere, niveau)) +class hCaptchaFieldWithErrors(hCaptchaField): + """ + Pour l'instant, hCaptchaField ne supporte pas le paramètre `error_messages` lors de + l'initialisation. Du coup, on les redéfinit à la main. + """ + + default_error_messages = { + "required": _("Veuillez vérifier que vous êtes bien humain·e."), + "error_hcaptcha": _("Erreur lors de la vérification."), + "invalid_hcaptcha": _("Échec de la vérification !"), + } class DemandeForm(ModelForm): - captcha = ReCaptchaField(attrs={"theme": "clean", "lang": "fr"}) + captcha = hCaptchaFieldWithErrors() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -58,5 +49,4 @@ MatieresFormSet = inlineformset_factory( User, PetitCoursAbility, fields=("matiere", "niveau", "agrege"), - formset=BaseMatieresFormSet, ) diff --git a/petitscours/models.py b/petitscours/models.py index 27b5e931..0be81449 100644 --- a/petitscours/models.py +++ b/petitscours/models.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from django.db import models from django.db.models import Min from django.utils.functional import cached_property -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from shared.utils import choices_length @@ -44,6 +44,11 @@ class PetitCoursAbility(models.Model): class Meta: app_label = "gestioncof" + constraints = [ + models.UniqueConstraint( + fields=["user", "niveau", "matiere"], name="unique_competence_level" + ) + ] verbose_name = "Compétence petits cours" verbose_name_plural = "Compétences des petits cours" @@ -61,7 +66,7 @@ class PetitCoursAbility(models.Model): class PetitCoursDemande(models.Model): name = models.CharField(_("Nom/prénom"), max_length=200) - email = models.CharField(_("Adresse email"), max_length=300) + email = models.EmailField(_("Adresse email"), max_length=300) phone = models.CharField(_("Téléphone (facultatif)"), max_length=20, blank=True) quand = models.CharField( _("Quand ?"), diff --git a/petitscours/templates/petitscours/demande_detail.html b/petitscours/templates/petitscours/demande_detail.html index 6f8b1f2b..8711fcda 100644 --- a/petitscours/templates/petitscours/demande_detail.html +++ b/petitscours/templates/petitscours/demande_detail.html @@ -1,5 +1,5 @@ {% extends "petitscours/base_title.html" %} -{% load staticfiles %} +{% load static %} {% block page_size %}col-sm-8{% endblock %} @@ -13,7 +13,7 @@ {% include "petitscours/details_demande_infos.html" %}
    - + {{ form.from_acc }} {{ form.amount }} - + {{ form.to_acc }}
    - + {% if demande.traitee %} diff --git a/petitscours/templates/petitscours/demande_list.html b/petitscours/templates/petitscours/demande_list.html index 74654e44..04132d57 100644 --- a/petitscours/templates/petitscours/demande_list.html +++ b/petitscours/templates/petitscours/demande_list.html @@ -1,5 +1,5 @@ {% extends "petitscours/base_title.html" %} -{% load staticfiles %} +{% load static %} {% block realcontent %}

    Demandes de petits cours

    @@ -19,7 +19,7 @@ - + diff --git a/petitscours/templates/petitscours/details_demande_infos.html b/petitscours/templates/petitscours/details_demande_infos.html index 26bf470e..42f37d56 100644 --- a/petitscours/templates/petitscours/details_demande_infos.html +++ b/petitscours/templates/petitscours/details_demande_infos.html @@ -1,4 +1,4 @@ -{% load staticfiles %} +{% load static %}
    Traitée
    Traitée
    Traitée par {{ demande.traitee_par }}
    Traitée le {{ demande.processed }}
    {{ demande.name }} {% for matiere in demande.matieres.all %}{% if forloop.counter0 > 0 %}, {% endif %}{{ matiere }}{% endfor %} {{ demande.created|date:"d E Y" }} {% if demande.traitee_par %}{{ demande.traitee_par.username }}{% else %}{% endif %} Détails
    @@ -9,6 +9,6 @@ - +
    Date {{ demande.created }}
    Nom/prénom {{ demande.name }}
    Fréquence {{ demande.freq }}
    Matières {% for matiere in demande.matieres.all %}{% if forloop.counter0 > 0 %}, {% endif %}{{ matiere }}{% endfor %}
    Niveau souhaité {{ demande.get_niveau_display }}
    Agrégé requis
    Agrégé requis
    Remarques {{ demande.remarques }}
    diff --git a/petitscours/templates/petitscours/inscription.html b/petitscours/templates/petitscours/inscription.html index 9512e0b3..eaf10524 100644 --- a/petitscours/templates/petitscours/inscription.html +++ b/petitscours/templates/petitscours/inscription.html @@ -1,5 +1,5 @@ {% extends "base_title.html" %} -{% load staticfiles %} +{% load static %} {% block extra_head %} diff --git a/petitscours/templates/petitscours/mails/demandeur.txt b/petitscours/templates/petitscours/mails/demandeur.txt new file mode 100644 index 00000000..69fed436 --- /dev/null +++ b/petitscours/templates/petitscours/mails/demandeur.txt @@ -0,0 +1,17 @@ +Bonjour, + +Je vous contacte au sujet de votre annonce passée sur le site du COF pour rentrer en contact avec un élève normalien pour des cours particuliers. Voici les coordonnées d'élèves qui sont motivés par de tels cours et correspondent aux critères que vous nous aviez transmis : + +{% for matiere, proposed in proposals %}¤ {{ matiere }} :{% for user in proposed %} + ¤ {{ user.get_full_name }}{% if user.profile.phone %}, {{ user.profile.phone }}{% endif %}{% if user.email %}, {{ user.email }}{% endif %}{% endfor %} + +{% endfor %}{% if unsatisfied %}Nous n'avons cependant pas pu trouver d'élève disponible pour des cours de {% for matiere in unsatisfied %}{% if forloop.counter0 > 0 %}, {% endif %}{{ matiere }}{% endfor %}. + +{% endif %}Si pour une raison ou une autre ces numéros ne suffisaient pas, n'hésitez pas à répondre à cet e-mail et je vous en ferai parvenir d'autres sans problème. +{% if extra|length > 0 %} +{{ extra|safe }} +{% endif %} +Cordialement, + +-- +Le COF, BdE de l'ENS \ No newline at end of file diff --git a/petitscours/templates/petitscours/mails/eleve.txt b/petitscours/templates/petitscours/mails/eleve.txt new file mode 100644 index 00000000..5f2d4750 --- /dev/null +++ b/petitscours/templates/petitscours/mails/eleve.txt @@ -0,0 +1,28 @@ +Salut, + +Le COF a reçu une demande de petit cours qui te correspond. Tu es en haut de la liste d'attente donc on a transmis tes coordonnées, ainsi que celles de 2 autres qui correspondaient aussi (c'est la vie, on donne les numéros 3 par 3 pour que ce soit plus souple). Voici quelques infos sur l'annonce en question : + +¤ Nom : {{ demande.name }} + +¤ Période : {{ demande.quand }} + +¤ Fréquence : {{ demande.freq }} + +¤ Lieu (si préféré) : {{ demande.lieu }} + +{% if matieres|length > 1 %}¤ Matières : +{% for matiere in matieres %} ¤ {{ matiere }} +{% endfor %}{% else %}¤ Matière : {% for matiere in matieres %}{{ matiere }} +{% endfor %}{% endif %} +¤ Niveau : {{ demande.get_niveau_display }} + +¤ Remarques diverses (désolé pour les balises HTML) : {{ demande.remarques }} + +Voilà, cette personne te contactera peut-être sous peu, tu pourras voir les détails directement avec elle (prix, modalités, ...). Pour indication, 30 Euro/h semble être la moyenne. + +Si tu te rends compte qu'en fait tu ne peux pas/plus donner de cours en ce moment, ça serait cool que tu décoches la case "Recevoir des propositions de petits cours" sur GestioCOF. Ensuite dès que tu voudras réapparaître tu pourras recocher la case et tu seras à nouveau sur la liste. + +À bientôt, + +-- +Le COF, pour les petits cours \ No newline at end of file diff --git a/petitscours/templates/petitscours/traitement_demande_autre_niveau.html b/petitscours/templates/petitscours/traitement_demande_autre_niveau.html index cb3ec379..c10c8aaf 100644 --- a/petitscours/templates/petitscours/traitement_demande_autre_niveau.html +++ b/petitscours/templates/petitscours/traitement_demande_autre_niveau.html @@ -1,5 +1,5 @@ {% extends "petitscours/base_title.html" %} -{% load staticfiles %} +{% load static %} {% block realcontent %}

    diff --git a/petitscours/tests/test_petitscours_views.py b/petitscours/tests/test_views.py similarity index 87% rename from petitscours/tests/test_petitscours_views.py rename to petitscours/tests/test_views.py index 9a3cc3dc..9367c258 100644 --- a/petitscours/tests/test_petitscours_views.py +++ b/petitscours/tests/test_views.py @@ -1,15 +1,13 @@ import json -import os +from unittest import mock -from django.contrib import messages from django.contrib.auth import get_user_model -from django.test import Client, TestCase +from django.test import TestCase from django.urls import reverse -from gestioncof.tests.testcases import ViewTestCaseMixin +from gestioncof.tests.mixins import ViewTestCaseMixin from .utils import ( - PetitCoursTestHelpers, create_petitcours_ability, create_petitcours_demande, create_petitcours_subject, @@ -20,7 +18,7 @@ User = get_user_model() class PetitCoursDemandeListViewTestCase(ViewTestCaseMixin, TestCase): url_name = "petits-cours-demandes-list" - url_expected = "/petitcours/demandes" + url_expected = "/gestion/petitcours/demandes" auth_user = "staff" auth_forbidden = [None, "user", "member"] @@ -49,7 +47,7 @@ class PetitCoursDemandeDetailListViewTestCase(ViewTestCaseMixin, TestCase): @property def url_expected(self): - return "/petitcours/demandes/{}".format(self.demande.pk) + return "/gestion/petitcours/demandes/{}".format(self.demande.pk) def setUp(self): super().setUp() @@ -62,7 +60,7 @@ class PetitCoursDemandeDetailListViewTestCase(ViewTestCaseMixin, TestCase): class PetitCoursInscriptionViewTestCase(ViewTestCaseMixin, TestCase): url_name = "petits-cours-inscription" - url_expected = "/petitcours/inscription" + url_expected = "/gestion/petitcours/inscription" http_methods = ["GET", "POST"] @@ -79,7 +77,9 @@ class PetitCoursInscriptionViewTestCase(ViewTestCaseMixin, TestCase): self.subject2 = create_petitcours_subject(name="Matière 2") def test_get_forbidden_user_not_cof(self): - self.client.force_login(self.users["user"]) + self.client.force_login( + self.users["user"], backend="django.contrib.auth.backends.ModelBackend" + ) resp = self.client.get(self.url) self.assertRedirects(resp, reverse("cof-denied")) @@ -161,9 +161,7 @@ class PetitCoursInscriptionViewTestCase(ViewTestCaseMixin, TestCase): self.assertFalse(self.user.petitcoursability_set.all()) -class PetitCoursTraitementViewTestCase( - ViewTestCaseMixin, PetitCoursTestHelpers, TestCase -): +class PetitCoursTraitementViewTestCase(ViewTestCaseMixin, TestCase): url_name = "petits-cours-demande-traitement" http_methods = ["GET", "POST"] @@ -177,7 +175,7 @@ class PetitCoursTraitementViewTestCase( @property def url_expected(self): - return "/petitcours/demandes/{}/traitement".format(self.demande.pk) + return "/gestion/petitcours/demandes/{}/traitement".format(self.demande.pk) def setUp(self): super().setUp() @@ -189,14 +187,10 @@ class PetitCoursTraitementViewTestCase( self.demande.matieres.add(self.subject) def test_get(self): - self.require_custommails() - resp = self.client.get(self.url) self.assertEqual(resp.status_code, 200) def test_get_with_match(self): - self.require_custommails() - create_petitcours_ability( user=self.user, matiere=self.subject, niveau="college" ) @@ -212,8 +206,6 @@ class PetitCoursTraitementViewTestCase( ) def test_post_with_match(self): - self.require_custommails() - create_petitcours_ability( user=self.user, matiere=self.subject, niveau="college" ) @@ -231,9 +223,7 @@ class PetitCoursTraitementViewTestCase( self.assertIsNotNone(self.demande.processed) -class PetitCoursRetraitementViewTestCase( - ViewTestCaseMixin, PetitCoursTestHelpers, TestCase -): +class PetitCoursRetraitementViewTestCase(ViewTestCaseMixin, TestCase): url_name = "petits-cours-demande-retraitement" http_methods = ["GET", "POST"] @@ -247,22 +237,20 @@ class PetitCoursRetraitementViewTestCase( @property def url_expected(self): - return "/petitcours/demandes/{}/retraitement".format(self.demande.pk) + return "/gestion/petitcours/demandes/{}/retraitement".format(self.demande.pk) def setUp(self): super().setUp() self.demande = create_petitcours_demande() def test_get(self): - self.require_custommails() - resp = self.client.get(self.url) self.assertEqual(resp.status_code, 200) class PetitCoursDemandeViewTestCase(ViewTestCaseMixin, TestCase): url_name = "petits-cours-demande" - url_expected = "/petitcours/demande" + url_expected = "/gestion/petitcours/demande" http_methods = ["GET", "POST"] @@ -271,18 +259,15 @@ class PetitCoursDemandeViewTestCase(ViewTestCaseMixin, TestCase): def setUp(self): super().setUp() - os.environ["RECAPTCHA_TESTING"] = "True" self.subject1 = create_petitcours_subject() self.subject2 = create_petitcours_subject() - def tearDown(self): - os.environ["RECAPTCHA_TESTING"] = "False" - def test_get(self): resp = self.client.get(self.url) self.assertEqual(resp.status_code, 200) - def test_post(self): + @mock.patch("hcaptcha.fields.hCaptchaField.clean") + def test_post(self, mock_clean): data = { "name": "Le nom", "email": "lemail@mail.net", @@ -294,7 +279,7 @@ class PetitCoursDemandeViewTestCase(ViewTestCaseMixin, TestCase): "agrege_requis": "1", "niveau": "lycee", "remarques": "no comment", - "g-recaptcha-response": "PASSED", + "h-captcha-response": 1, } resp = self.client.post(self.url, data) @@ -304,7 +289,7 @@ class PetitCoursDemandeViewTestCase(ViewTestCaseMixin, TestCase): class PetitCoursDemandeRawViewTestCase(ViewTestCaseMixin, TestCase): url_name = "petits-cours-demande-raw" - url_expected = "/petitcours/demande-raw" + url_expected = "/gestion/petitcours/demande-raw" http_methods = ["GET", "POST"] @@ -313,18 +298,15 @@ class PetitCoursDemandeRawViewTestCase(ViewTestCaseMixin, TestCase): def setUp(self): super().setUp() - os.environ["RECAPTCHA_TESTING"] = "True" self.subject1 = create_petitcours_subject() self.subject2 = create_petitcours_subject() - def tearDown(self): - os.environ["RECAPTCHA_TESTING"] = "False" - def test_get(self): resp = self.client.get(self.url) self.assertEqual(resp.status_code, 200) - def test_post(self): + @mock.patch("hcaptcha.fields.hCaptchaField.clean") + def test_post(self, mock_clean): data = { "name": "Le nom", "email": "lemail@mail.net", @@ -336,7 +318,7 @@ class PetitCoursDemandeRawViewTestCase(ViewTestCaseMixin, TestCase): "agrege_requis": "1", "niveau": "lycee", "remarques": "no comment", - "g-recaptcha-response": "PASSED", + "h-captcha-response": 1, } resp = self.client.post(self.url, data) diff --git a/petitscours/tests/utils.py b/petitscours/tests/utils.py index 99113f9e..131f14dc 100644 --- a/petitscours/tests/utils.py +++ b/petitscours/tests/utils.py @@ -1,8 +1,4 @@ -import os - -from django.conf import settings -from django.core.management import call_command - +from gestioncof.tests.utils import create_user from petitscours.models import ( PetitCoursAbility, PetitCoursAttributionCounter, @@ -13,7 +9,7 @@ from petitscours.models import ( def create_petitcours_ability(**kwargs): if "user" not in kwargs: - kwargs["user"] = create_user() + kwargs["user"] = create_user("toto") if "matiere" not in kwargs: kwargs["matiere"] = create_petitcours_subject() if "niveau" not in kwargs: @@ -29,11 +25,3 @@ def create_petitcours_demande(**kwargs): def create_petitcours_subject(**kwargs): return PetitCoursSubject.objects.create(**kwargs) - - -class PetitCoursTestHelpers: - def require_custommails(self): - data_file = os.path.join( - settings.BASE_DIR, "gestioncof", "management", "data", "custommail.json" - ) - call_command("syncmails", data_file, verbosity=0) diff --git a/petitscours/views.py b/petitscours/views.py index e309f24a..616e38d0 100644 --- a/petitscours/views.py +++ b/petitscours/views.py @@ -1,6 +1,5 @@ import json -from custommail.shortcuts import render_custom_mail from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required @@ -8,7 +7,9 @@ from django.contrib.auth.models import User from django.core import mail from django.db import transaction from django.shortcuts import get_object_or_404, redirect, render +from django.template import loader from django.utils import timezone +from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.decorators.csrf import csrf_exempt from django.views.generic import DetailView, ListView @@ -70,15 +71,18 @@ def _finalize_traitement( proposed_for.setdefault(user, []).append(matiere) proposed_mails = _generate_eleve_email(demande, proposed_for) - mainmail = render_custom_mail( - "petits-cours-mail-demandeur", - { - "proposals": proposals.items(), - "unsatisfied": unsatisfied, - "extra": '", - }, + mainmail = ( + "Cours particuliers ENS", + loader.render_to_string( + "petitscours/mails/demandeur.txt", + context={ + "proposals": proposals.items(), + "unsatisfied": unsatisfied, + "extra": '", + }, + ), ) if errors is not None: for error in errors: @@ -100,11 +104,16 @@ def _finalize_traitement( def _generate_eleve_email(demande, proposed_for): + subject = "Petits cours ENS par le COF" return [ ( user, - render_custom_mail( - "petit-cours-mail-eleve", {"demande": demande, "matieres": matieres} + ( + subject, + loader.render_to_string( + "petitscours/mails/eleve.txt", + context={"demande": demande, "matieres": matieres}, + ), ), ) for user, matieres in proposed_for.items() @@ -197,15 +206,20 @@ def _traitement_post(request, demande): else: proposed_for[user].append(matiere) proposed_mails = _generate_eleve_email(demande, proposed_for) - mainmail_object, mainmail_body = render_custom_mail( - "petits-cours-mail-demandeur", - {"proposals": proposals.items(), "unsatisfied": unsatisfied, "extra": extra}, + mainmail_object = "Cours particuliers ENS" + mainmail_body = loader.render_to_string( + "petitscours/mails/demandeur.txt", + context={ + "proposals": proposals.items(), + "unsatisfied": unsatisfied, + "extra": extra, + }, ) frommail = settings.MAIL_DATA["petits_cours"]["FROM"] bccaddress = settings.MAIL_DATA["petits_cours"]["BCC"] replyto = settings.MAIL_DATA["petits_cours"]["REPLYTO"] mails_to_send = [] - for (user, (mail_object, body)) in proposed_mails: + for user, (mail_object, body) in proposed_mails: msg = mail.EmailMessage( mail_object, body, @@ -287,6 +301,7 @@ def inscription(request): @csrf_exempt +@xframe_options_sameorigin def demande(request, *, raw: bool = False): success = False if request.method == "POST": diff --git a/provisioning/bootstrap.sh b/provisioning/bootstrap.sh index cb6917a7..a298dfae 100644 --- a/provisioning/bootstrap.sh +++ b/provisioning/bootstrap.sh @@ -1,74 +1,141 @@ #!/bin/sh -# Stop if an error is encountered -set -e +# Arête le script quand : +# - une erreur survient +# - on essaie d'utiliser une variable non définie +# - on essaie d'écraser un fichier avec une redirection (>). +set -euC -# Configuration de la base de données. Le mot de passe est constant car c'est +# Configuration de la base de données, redis, Django, etc. +# Tous les mots de passe sont constant et en clair dans le fichier car c'est # pour une installation de dév locale qui ne sera accessible que depuis la # machine virtuelle. -DBUSER="cof_gestion" -DBNAME="cof_gestion" -DBPASSWD="4KZt3nGPLVeWSvtBZPSM3fSzXpzEU4" +readonly DBUSER="cof_gestion" +readonly DBNAME="cof_gestion" +readonly DBPASSWD="4KZt3nGPLVeWSvtBZPSM3fSzXpzEU4" +readonly REDIS_PASSWD="dummy" +readonly DJANGO_SETTINGS_MODULE="gestioasso.settings.dev" -# Installation de paquets utiles -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 + +# --- +# Installation des paquets systèmes +# --- + +get_packages_list () { + sed 's/#.*$//' /vagrant/provisioning/packages.list | grep -v '^ *$' +} + +# https://github.com/chef/bento/issues/661 +export DEBIAN_FRONTEND=noninteractive + +apt-get update +apt-get -y upgrade +get_packages_list | xargs apt-get install -y + + +# --- +# Configuration de la base de données +# --- # Postgresql -sudo -u postgres createdb $DBNAME -sudo -u postgres createuser -SdR $DBUSER +pg_user_exists () { + sudo -u postgres psql postgres -tAc \ + "SELECT 1 FROM pg_roles WHERE rolname='$1'" \ + | grep -q '^1$' +} + +pg_db_exists () { + sudo -u postgres psql postgres -tAc \ + "SELECT 1 FROM pg_database WHERE datname='$1'" \ + | grep -q '^1$' +} + +pg_db_exists "$DBNAME" || sudo -u postgres createdb "$DBNAME" +pg_user_exists "$DBUSER" || 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;" -# Redis -REDIS_PASSWD="dummy" -redis-cli CONFIG SET requirepass $REDIS_PASSWD -redis-cli -a $REDIS_PASSWD CONFIG REWRITE +# --- +# Configuration de redis (pour django-channels) +# --- -# Contenu statique +# Redis +redis-cli CONFIG SET requirepass "$REDIS_PASSWD" +redis-cli -a "$REDIS_PASSWD" CONFIG REWRITE + + +# --- +# Préparation de Django +# --- + +# Dossiers pour le 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 +chown -R vagrant:www-data /srv/gestiocof # 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 +sudo -H -u vagrant python3 -m venv ~vagrant/venv +sudo -H -u vagrant ~vagrant/venv/bin/pip install -U pip +sudo -H -u vagrant ~vagrant/venv/bin/pip install \ + -r /vagrant/requirements-prod.txt \ + -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 +ln -s -f secret_example.py gestioasso/settings/secret.py +sudo -H -u vagrant \ + DJANGO_SETTINGS_MODULE="$DJANGO_SETTINGS_MODULE"\ + /bin/sh -c ". ~vagrant/venv/bin/activate && /bin/sh provisioning/prepare_django.sh" +~vagrant/venv/bin/python manage.py collectstatic \ + --noinput \ + --settings "$DJANGO_SETTINGS_MODULE" -# 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 +# --- +# Units systemd +# --- -# Mise en place du .bash_profile pour tout configurer lors du `vagrant ssh` -cat >> ~ubuntu/.bashrc < ~vagrant/.bash_aliases <> /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/daphne.service b/provisioning/daphne.service deleted file mode 100644 index 41327ce5..00000000 --- a/provisioning/daphne.service +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 015e1712..00000000 --- a/provisioning/nginx.conf +++ /dev/null @@ -1,56 +0,0 @@ -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/nginx/gestiocof.conf b/provisioning/nginx/gestiocof.conf new file mode 100644 index 00000000..7d7567c6 --- /dev/null +++ b/provisioning/nginx/gestiocof.conf @@ -0,0 +1,42 @@ +upstream gestiocof { + # Daphne listens on a unix socket + server unix:/srv/gestiocof/gestiocof.sock; +} + +server { + listen 80; + listen [::]:80; + + server_name _; + root /srv/gestiocof/; + + # Redirection: + rewrite ^/$ http://localhost:8080/gestion/ redirect; + rewrite ^/gestion$ http://localhost:8080/gestion/ redirect; + + # Les pages statiques sont servies à part. + location /static { try_files $uri $uri/ =404; } + location /media { try_files $uri $uri/ =404; } + + # On proxy-pass les requêtes vers les pages dynamiques à daphne + location / { + 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_pass http://gestiocof; + } + + # Pour les websockets : + # See http://nginx.org/en/docs/http/websocket.html. + location /ws/ { + proxy_buffering off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_pass http://gestiocof/ws/; + } +} diff --git a/provisioning/packages.list b/provisioning/packages.list new file mode 100644 index 00000000..34714442 --- /dev/null +++ b/provisioning/packages.list @@ -0,0 +1,25 @@ +# Python +python3-pip +python3-dev +python3-venv + +# Pour installer authens depuis git.eleves +git + +# Postgres +libpq-dev +postgresql +postgresql-contrib + +# Pour Pillow +libjpeg-dev + +# Outils de prod +nginx # Test +redis-server + +# Le LDAP +libldap2-dev +libsasl2-dev +slapd +ldap-utils diff --git a/provisioning/prepare_django.sh b/provisioning/prepare_django.sh index f3358873..324deaf1 100644 --- a/provisioning/prepare_django.sh +++ b/provisioning/prepare_django.sh @@ -1,11 +1,10 @@ -#!/bin/bash +#!/bin/sh -# Stop if an error is encountered. -set -e +set -euC -python manage.py migrate +mkdir -p .static +python manage.py migrate --noinput python manage.py sync_page_translation_fields python manage.py update_translation_fields python manage.py loaddata gestion sites articles python manage.py loaddevdata -python manage.py syncmails gestioncof/management/data/custommail.json diff --git a/provisioning/systemd/daphne.service b/provisioning/systemd/daphne.service new file mode 100644 index 00000000..bae9f3ca --- /dev/null +++ b/provisioning/systemd/daphne.service @@ -0,0 +1,17 @@ +Description="GestioCOF - Daphne" +After=syslog.target +After=network.target + +[Service] +Type=simple +User=vagrant +Group=vagrant +TimeoutSec=300 +WorkingDirectory=/vagrant +Environment="DJANGO_SETTINGS_MODULE=gestioasso.settings.dev" +ExecStart=/home/vagrant/venv/bin/daphne \ + -u /srv/gestiocof/gestiocof.sock \ + gestioasso.asgi:application + +[Install] +WantedBy=multi-user.target diff --git a/provisioning/systemd/rappels.service b/provisioning/systemd/rappels.service new file mode 100644 index 00000000..0a4986f9 --- /dev/null +++ b/provisioning/systemd/rappels.service @@ -0,0 +1,8 @@ +[Unit] +Description=Envoi des mails de rappel des spectales BdA + +[Service] +Type=oneshot +User=vagrant +Environment="DJANGO_SETTINGS_MODULE=gestioasso.settings.dev" +ExecStart=/home/vagrant/venv/bin/python /vagrant/manage.py sendrappels diff --git a/provisioning/systemd/rappels.timer b/provisioning/systemd/rappels.timer new file mode 100644 index 00000000..f05c54e0 --- /dev/null +++ b/provisioning/systemd/rappels.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Envoi des mails de rappel des spectales BdA + +[Timer] +OnBootSec=10min +OnUnitActiveSec=3h + +[Install] +WantedBy=timers.target diff --git a/provisioning/systemd/reventes.service b/provisioning/systemd/reventes.service new file mode 100644 index 00000000..266c0646 --- /dev/null +++ b/provisioning/systemd/reventes.service @@ -0,0 +1,8 @@ +[Unit] +Description=Envoi des mails de BdA-Revente + +[Service] +Type=oneshot +User=vagrant +Environment="DJANGO_SETTINGS_MODULE=gestioasso.settings.dev" +ExecStart=/home/vagrant/venv/bin/python /vagrant/manage.py manage_reventes diff --git a/provisioning/systemd/reventes.timer b/provisioning/systemd/reventes.timer new file mode 100644 index 00000000..2ccaf7bf --- /dev/null +++ b/provisioning/systemd/reventes.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Envoi des mails de BdA-Revente + +[Timer] +OnBootSec=15min +OnUnitActiveSec=15min + +[Install] +WantedBy=timers.target diff --git a/provisioning/systemd/worker.service b/provisioning/systemd/worker.service new file mode 100644 index 00000000..0d97e9a4 --- /dev/null +++ b/provisioning/systemd/worker.service @@ -0,0 +1,19 @@ +[Unit] +Description="GestioCOF" +After=syslog.target +After=network.target + +[Service] +Type=simple +User=vagrant +Group=vagrant +TimeoutSec=300 +WorkingDirectory=/vagrant +Environment="DJANGO_SETTINGS_MODULE=gestioasso.settings.dev" +ExecStart=/home/vagrant/venv/bin/python manage.py runworker \ + 'kfet.open.team' \ + 'kfet.open.base' \ + 'kpsul' + +[Install] +WantedBy=multi-user.target diff --git a/provisioning/worker.service b/provisioning/worker.service deleted file mode 100644 index 42836cfe..00000000 --- a/provisioning/worker.service +++ /dev/null @@ -1,16 +0,0 @@ -[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-devel.txt b/requirements-devel.txt index 7907bcd9..2de02a5d 100644 --- a/requirements-devel.txt +++ b/requirements-devel.txt @@ -1,8 +1,8 @@ -r requirements.txt -django-debug-toolbar +django-debug-toolbar==4.4.6 ipython # Tools -# black # Uncomment when GC & most distros run with Python>=3.6 +black==22.3.0 flake8 isort diff --git a/requirements-prod.txt b/requirements-prod.txt index e08ac120..45ac4920 100644 --- a/requirements-prod.txt +++ b/requirements-prod.txt @@ -1,14 +1,15 @@ -r requirements.txt # Postgresql bindings -psycopg2<2.8 +psycopg2==2.9.10 # Redis -django-redis-cache==2.1.* +django-redis-cache==3.0.1 +redis==3.5.3 +channels-redis==3.4.1 # ASGI protocol and HTTP server -asgiref==1.1.1 -daphne==1.3.0 +daphne==3.0.2 # ldap bindings -ldap3 +python-ldap diff --git a/requirements.txt b/requirements.txt index be12f457..65d1d380 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,19 @@ -configparser==3.5.0 -Django==2.2.* -django-autocomplete-light==3.3.* -django-cas-ng==3.6.* -django-djconfig==0.8.0 -django-recaptcha==1.4.0 -icalendar -Pillow -django-bootstrap-form==3.3 -asgi-redis==1.3.0 +Django==4.2.17 +Pillow==11.0.0 +authens==0.2.0 +channels==3.0.5 +configparser==7.1.0 +django-autocomplete-light==3.11.0 +django-bootstrap-form==3.4 +django-cas-ng==5.0.1 +django-cors-headers==4.6.0 +django-djconfig==0.11.0 +django-hCaptcha==0.2.0 +django-js-reverse==0.10.2 +django-widget-tweaks==1.5.0 +icalendar==6.1.0 +python-dateutil==2.9.0.post0 statistics==1.0.3.5 -django-widget-tweaks==1.4.1 -git+https://git.eleves.ens.fr/cof-geek/django_custommail.git#egg=django_custommail -channels==1.1.5 -python-dateutil -wagtail==2.7.* -wagtailmenus==3.* -wagtail-modeltranslation==0.10.* -django-cors-headers==2.2.0 +wagtail-modeltranslation==0.15.1 +wagtail==6.3.1 +wagtailmenus==4.0.1 diff --git a/setup.cfg b/setup.cfg index 100ddb22..9b1c72d0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,13 +3,12 @@ source = bda bds clubs - cof events + gestioasso gestioncof kfet petitscours shared - utils omit = *migrations* *test*.py @@ -36,9 +35,7 @@ combine_as_imports = true default_section = THIRDPARTY force_grid_wrap = 0 include_trailing_comma = true -known_django = django -known_first_party = bda,bds,clubs,cof,events,gestioncof,kfet,petitscours,shared,utils +known_first_party = bda,bds,clubs,cof,events,gestioncof,kfet,petitscours,shared line_length = 88 multi_line_output = 3 -not_skip = __init__.py sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER diff --git a/utils/__init__.py b/shared/__init__.py similarity index 100% rename from utils/__init__.py rename to shared/__init__.py diff --git a/shared/autocomplete.py b/shared/autocomplete.py new file mode 100644 index 00000000..a601d5f9 --- /dev/null +++ b/shared/autocomplete.py @@ -0,0 +1,251 @@ +import logging +from collections import namedtuple + +from django.conf import settings +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ + +if getattr(settings, "LDAP_SERVER_URL", None): + import ldap +else: + # shared.tests.testcases.TestCaseMixin.mockLDAP needs + # an ldap object to be in the scope + ldap = None + + +django_logger = logging.getLogger("django.request") + + +class SearchUnit: + """Base class for all the search utilities. + + A search unit should implement a `search` method taking a list of keywords as + argument and returning an iterable of search results. + + It might optionally implement the following methods and attributes: + + - verbose_name (attribute): a nice name to refer to the results of this search unit + in templates. Examples: "COF Members", "K-Fêt accounts", etc. + + - result_verbose_name (method): a callable that takes one search result as an input + and returns a nice name to refer to this particular result in templates. + Example: `lambda user: user.get_full_name()` + + - result_link (method): a callable that takes one search result and returns a url + to make this particular search result clickable on the search page. For instance + this can be a link to a detail view of the object. + + - result_uuid (method): a callable that takes one result as an input and returns an + identifier that is globally unique across search units for this object. + This is used to compare results coming from different search units in the + `Compose` class. For instance, if the same user can be returned by the LDAP + search and a model search instance, using the clipper login as a UUID in both + units avoids this user to be returned twice by `Compose`. + Returning `None` means that the object should be considered unique. + """ + + # Mandatory method + + def search(self, _keywords): + raise NotImplementedError( + "Class implementing the SearchUnit interface should implement the search " + "method" + ) + + # Optional attributes and methods + + verbose_name = None + + def result_verbose_name(self, result): + """Hook to customize the way results are displayed.""" + return str(result) + + def result_link(self, result): + """Hook to add a link on individual results on the search page.""" + return None + + def result_uuid(self, result): + """A universal unique identifier for the search results.""" + return None + + +# --- +# Model-based search +# --- + + +class ModelSearch(SearchUnit): + """Basic search engine for models based on filtering. + + The class should be configured through its `model` class attribute: the `search` + method will return a queryset of instances of this model. The `search_fields` + attributes indicates which fields to search in. + + Example: + + >>> from django.contrib.auth.models import User + >>> + >>> class UserSearch(ModelSearch): + ... model = User + ... search_fields = ["username", "first_name", "last_name"] + >>> + >>> user_search = UserSearch() # has type ModelSearch[User] + >>> user_search.search(["toto", "foo"]) # returns a queryset of Users + """ + + model = None + search_fields = [] + + def __init__(self): + if self.verbose_name is None: + self.verbose_name = "{} search".format(self.model._meta.verbose_name) + + def get_queryset_filter(self, keywords): + filter_q = Q() + + if not keywords: + return filter_q + + for keyword in keywords: + kw_filter = Q() + for field in self.search_fields: + kw_filter |= Q(**{"{}__icontains".format(field): keyword}) + filter_q &= kw_filter + + return filter_q + + def search(self, keywords): + """Returns the queryset of model instances matching all the keywords. + + The semantic of the search is the following: a model instance appears in the + search results iff all of the keywords given as arguments occur in at least one + of the search fields. + """ + + return self.model.objects.filter(self.get_queryset_filter(keywords)) + + +# --- +# LDAP search +# --- + +Clipper = namedtuple("Clipper", ["clipper", "fullname", "mail"]) + + +class LDAPSearch(SearchUnit): + ldap_server_url = getattr(settings, "LDAP_SERVER_URL", None) + domain_component = "dc=spi,dc=ens,dc=fr" + search_fields = ["cn", "uid"] + attr_list = ["cn", "uid", "mail"] + + verbose_name = _("Comptes clippers") + + def get_ldap_query(self, keywords): + """Return a search query with the following semantics: + + A Clipper appears in the search results iff all of the keywords given as + arguments occur in at least one of the search fields. + """ + + # Dumb but safe + keywords = filter(str.isalnum, keywords) + + ldap_filters = [] + + for keyword in keywords: + ldap_filter = "(|{})".format( + "".join( + "({}=*{}*)".format(field, keyword) for field in self.search_fields + ) + ) + ldap_filters.append(ldap_filter) + + return "(&{})".format("".join(ldap_filters)) + + def search(self, keywords): + """Return a list of Clipper objects matching all the keywords.""" + + query = self.get_ldap_query(keywords) + + if ldap is None or query == "(&)": + return [] + + try: + ldap_obj = ldap.initialize(self.ldap_server_url) + res = ldap_obj.search_s( + self.domain_component, ldap.SCOPE_SUBTREE, query, self.attr_list + ) + return [ + Clipper( + clipper=attrs["uid"][0].decode("utf-8"), + fullname=attrs["cn"][0].decode("utf-8"), + mail=attrs["mail"][0].decode("utf-8"), + ) + for (_, attrs) in res + if "uid" in attrs # Hack to discard weird accounts like root + ] + except ldap.LDAPError as err: + django_logger.error("An LDAP error occurred", exc_info=err) + return [] + + def result_verbose_name(self, clipper): + return "{} ({})".format(clipper.fullname, clipper.clipper) + + def result_uuid(self, clipper): + return clipper.clipper + + +# --- +# Composition of autocomplete units +# --- + + +class Compose: + """Search with several units and remove duplicate results. + + The `search_units` class attribute should be a list of pairs of the form `(name, + search_unit)`. + + The `search` method produces a dictionary whose keys are the `name`s given in + `search_units` and whose values are iterables produced by the different search + units. + + Typical Example: + + >>> from django.contrib.auth.models import User + >>> + >>> class UserSearch(ModelSearch): + ... model = User + ... search_fields = ["username", "first_name", "last_name"] + ... + ... def result_uuid(self, user): + ... # Assuming that `.username` stores the clipper login of already + ... # registered users, this avoids showing the same user twice (here and in + ... # then ldap unit). + ... return user.username + >>> + >>> class UserAndClipperSearch(Compose): + ... search_units = [ + ... ("users", UserSearch()), + ... ("clippers", LDAPSearch()), + ... ] + + In this example, clipper accounts that already have an associated user (i.e. with a + username equal to the clipper login), will not appear in the results. + """ + + search_units = [] + + def search(self, keywords): + seen_uuids = set() + results = {} + for name, search_unit in self.search_units: + uniq_res = [] + for r in search_unit.search(keywords): + uuid = search_unit.result_uuid(r) + if uuid is None or uuid not in seen_uuids: + uniq_res.append(r) + if uuid is not None: + seen_uuids.add(uuid) + results[name] = uniq_res + return results diff --git a/shared/channels.py b/shared/channels.py new file mode 100644 index 00000000..ae8c1248 --- /dev/null +++ b/shared/channels.py @@ -0,0 +1,45 @@ +import datetime +import random +from decimal import Decimal + +import msgpack +from channels_redis.core import RedisChannelLayer + + +def encode_kf(obj): + if isinstance(obj, Decimal): + return {"__decimal__": True, "as_str": str(obj)} + elif isinstance(obj, datetime.datetime): + return {"__datetime__": True, "as_str": obj.strftime("%Y%m%dT%H:%M:%S.%f")} + return obj + + +def decode_kf(obj): + if "__decimal__" in obj: + obj = Decimal(obj["as_str"]) + elif "__datetime__" in obj: + obj = datetime.datetime.strptime(obj["as_str"], "%Y%m%dT%H:%M:%S.%f") + return obj + + +class ChannelLayer(RedisChannelLayer): + def serialize(self, message): + """Serializes to a byte string.""" + value = msgpack.packb(message, default=encode_kf, use_bin_type=True) + + if self.crypter: + value = self.crypter.encrypt(value) + + # As we use an sorted set to expire messages + # we need to guarantee uniqueness, with 12 bytes. + random_prefix = random.getrandbits(8 * 12).to_bytes(12, "big") + return random_prefix + value + + def deserialize(self, message): + """Deserializes from a byte string.""" + # Removes the random prefix + message = message[12:] + + if self.crypter: + message = self.crypter.decrypt(message, self.expiry + 10) + return msgpack.unpackb(message, object_hook=decode_kf, raw=False) diff --git a/shared/forms.py b/shared/forms.py new file mode 100644 index 00000000..97094e29 --- /dev/null +++ b/shared/forms.py @@ -0,0 +1,50 @@ +from django.forms.models import ModelForm + + +class ProtectedModelForm(ModelForm): + """ + Extension de `ModelForm` + + Quand on save un champ `ManyToMany` dans un `ModelForm`, la méthode appelée + est .set(), qui écrase l'intégralité du contenu. + Le problème survient quand le `field` a un queryset restreint, et qu'on ne + veut pas toucher aux choix qui ne sont pas dans ce queryset... + C'est le but de ce mixin. + + Attributs : + - `protected_fields` : champs qu'on souhaite protéger. + """ + + protected_fields = [] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + for field_name in self.protected_fields: + if field_name not in self.fields: + raise ValueError("Le champ %s n'existe pas !" % field_name) + + def _get_protected_elts(self, field_name): + """ + Renvoie tous les éléments de `instance.` qui ne sont pas + dans `self..queryset` (et sont donc à conserver). + + NB : on "désordonne" tous les querysets via `.order_by()` car Django + ne peut pas effectuer une union de QS ordonnés. + """ + if self.instance.pk: + previous = getattr(self.instance, field_name).order_by() + selectable = self.fields[field_name].queryset.order_by() + return previous.difference(selectable) + else: + # Nouvelle instance, rien à protéger. + return self.fields[field_name].queryset.none() + + def clean(self): + cleaned_data = super().clean() + for field_name in self.protected_fields: + selected_elts = cleaned_data[field_name].order_by() + protected_elts = self._get_protected_elts(field_name) + cleaned_data[field_name] = selected_elts.union(protected_elts) + + return cleaned_data diff --git a/shared/static/fonts/CarterOne/carterOne.css b/shared/static/fonts/CarterOne/carterOne.css new file mode 100644 index 00000000..1380934a --- /dev/null +++ b/shared/static/fonts/CarterOne/carterOne.css @@ -0,0 +1,10 @@ +/* carter-one-regular - latin */ +@font-face { + font-family: 'Carter One'; + font-style: normal; + font-weight: 400; + src: local('Carter One'), local('CarterOne'), + url('./fonts/carter-one-v11-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ + url('./fonts/carter-one-v11-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} + diff --git a/shared/static/fonts/CarterOne/fonts/carter-one-v11-latin-regular.woff b/shared/static/fonts/CarterOne/fonts/carter-one-v11-latin-regular.woff new file mode 100644 index 00000000..851ed743 Binary files /dev/null and b/shared/static/fonts/CarterOne/fonts/carter-one-v11-latin-regular.woff differ diff --git a/shared/static/fonts/CarterOne/fonts/carter-one-v11-latin-regular.woff2 b/shared/static/fonts/CarterOne/fonts/carter-one-v11-latin-regular.woff2 new file mode 100644 index 00000000..132f3c5a Binary files /dev/null and b/shared/static/fonts/CarterOne/fonts/carter-one-v11-latin-regular.woff2 differ diff --git a/shared/static/fonts/SourceSansPro/fonts/source-sans-pro-v13-latin-300.woff b/shared/static/fonts/SourceSansPro/fonts/source-sans-pro-v13-latin-300.woff new file mode 100644 index 00000000..98bafe5e Binary files /dev/null and b/shared/static/fonts/SourceSansPro/fonts/source-sans-pro-v13-latin-300.woff differ diff --git a/shared/static/fonts/SourceSansPro/fonts/source-sans-pro-v13-latin-300.woff2 b/shared/static/fonts/SourceSansPro/fonts/source-sans-pro-v13-latin-300.woff2 new file mode 100644 index 00000000..af998cac Binary files /dev/null and b/shared/static/fonts/SourceSansPro/fonts/source-sans-pro-v13-latin-300.woff2 differ diff --git a/shared/static/fonts/SourceSansPro/fonts/source-sans-pro-v13-latin-300italic.woff b/shared/static/fonts/SourceSansPro/fonts/source-sans-pro-v13-latin-300italic.woff new file mode 100644 index 00000000..42232ee2 Binary files /dev/null and b/shared/static/fonts/SourceSansPro/fonts/source-sans-pro-v13-latin-300italic.woff differ diff --git a/shared/static/fonts/SourceSansPro/fonts/source-sans-pro-v13-latin-300italic.woff2 b/shared/static/fonts/SourceSansPro/fonts/source-sans-pro-v13-latin-300italic.woff2 new file mode 100644 index 00000000..6daac0dd Binary files /dev/null and b/shared/static/fonts/SourceSansPro/fonts/source-sans-pro-v13-latin-300italic.woff2 differ diff --git a/shared/static/fonts/SourceSansPro/fonts/source-sans-pro-v13-latin-700.woff b/shared/static/fonts/SourceSansPro/fonts/source-sans-pro-v13-latin-700.woff new file mode 100644 index 00000000..f2a7dd34 Binary files /dev/null and b/shared/static/fonts/SourceSansPro/fonts/source-sans-pro-v13-latin-700.woff differ diff --git a/shared/static/fonts/SourceSansPro/fonts/source-sans-pro-v13-latin-700.woff2 b/shared/static/fonts/SourceSansPro/fonts/source-sans-pro-v13-latin-700.woff2 new file mode 100644 index 00000000..ce34a9fe Binary files /dev/null and b/shared/static/fonts/SourceSansPro/fonts/source-sans-pro-v13-latin-700.woff2 differ diff --git a/shared/static/fonts/SourceSansPro/sourceSansPro.css b/shared/static/fonts/SourceSansPro/sourceSansPro.css new file mode 100644 index 00000000..f7bfbe2f --- /dev/null +++ b/shared/static/fonts/SourceSansPro/sourceSansPro.css @@ -0,0 +1,30 @@ +/* source-sans-pro-300 - latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 300; + src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), + url('./fonts/source-sans-pro-v13-latin-300.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ + url('./fonts/source-sans-pro-v13-latin-300.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} + +/* source-sans-pro-300italic - latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: italic; + font-weight: 300; + src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), + url('./fonts/source-sans-pro-v13-latin-300italic.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ + url('./fonts/source-sans-pro-v13-latin-300italic.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} + +/* source-sans-pro-700 - latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 700; + src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), + url('./fonts/source-sans-pro-v13-latin-700.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ + url('./fonts/source-sans-pro-v13-latin-700.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ +} + diff --git a/shared/static/src/bulma/bulma-rtl.sass b/shared/static/src/bulma/bulma-rtl.sass new file mode 100644 index 00000000..daeba985 --- /dev/null +++ b/shared/static/src/bulma/bulma-rtl.sass @@ -0,0 +1,3 @@ +@charset "utf-8" +$rtl: true +@import "bulma" diff --git a/shared/static/src/bulma/bulma.sass b/shared/static/src/bulma/bulma.sass new file mode 100644 index 00000000..4b7b7a66 --- /dev/null +++ b/shared/static/src/bulma/bulma.sass @@ -0,0 +1,10 @@ +@charset "utf-8" +/*! bulma.io v0.9.0 | MIT License | github.com/jgthms/bulma */ +@import "sass/utilities/_all" +@import "sass/base/_all" +@import "sass/elements/_all" +@import "sass/form/_all" +@import "sass/components/_all" +@import "sass/grid/_all" +@import "sass/helpers/_all" +@import "sass/layout/_all" diff --git a/shared/static/src/bulma/sass/base/_all.sass b/shared/static/src/bulma/sass/base/_all.sass new file mode 100644 index 00000000..ce1dddc9 --- /dev/null +++ b/shared/static/src/bulma/sass/base/_all.sass @@ -0,0 +1,4 @@ +@charset "utf-8" + +@import "minireset.sass" +@import "generic.sass" diff --git a/shared/static/src/bulma/sass/base/generic.sass b/shared/static/src/bulma/sass/base/generic.sass new file mode 100644 index 00000000..75d6efd8 --- /dev/null +++ b/shared/static/src/bulma/sass/base/generic.sass @@ -0,0 +1,142 @@ +$body-background-color: $scheme-main !default +$body-size: 16px !default +$body-min-width: 300px !default +$body-rendering: optimizeLegibility !default +$body-family: $family-primary !default +$body-overflow-x: hidden !default +$body-overflow-y: scroll !default + +$body-color: $text !default +$body-font-size: 1em !default +$body-weight: $weight-normal !default +$body-line-height: 1.5 !default + +$code-family: $family-code !default +$code-padding: 0.25em 0.5em 0.25em !default +$code-weight: normal !default +$code-size: 0.875em !default + +$small-font-size: 0.875em !default + +$hr-background-color: $background !default +$hr-height: 2px !default +$hr-margin: 1.5rem 0 !default + +$strong-color: $text-strong !default +$strong-weight: $weight-bold !default + +$pre-font-size: 0.875em !default +$pre-padding: 1.25rem 1.5rem !default +$pre-code-font-size: 1em !default + +html + background-color: $body-background-color + font-size: $body-size + -moz-osx-font-smoothing: grayscale + -webkit-font-smoothing: antialiased + min-width: $body-min-width + overflow-x: $body-overflow-x + overflow-y: $body-overflow-y + text-rendering: $body-rendering + text-size-adjust: 100% + +article, +aside, +figure, +footer, +header, +hgroup, +section + display: block + +body, +button, +input, +select, +textarea + font-family: $body-family + +code, +pre + -moz-osx-font-smoothing: auto + -webkit-font-smoothing: auto + font-family: $code-family + +body + color: $body-color + font-size: $body-font-size + font-weight: $body-weight + line-height: $body-line-height + +// Inline + +a + color: $link + cursor: pointer + text-decoration: none + strong + color: currentColor + &:hover + color: $link-hover + +code + background-color: $code-background + color: $code + font-size: $code-size + font-weight: $code-weight + padding: $code-padding + +hr + background-color: $hr-background-color + border: none + display: block + height: $hr-height + margin: $hr-margin + +img + height: auto + max-width: 100% + +input[type="checkbox"], +input[type="radio"] + vertical-align: baseline + +small + font-size: $small-font-size + +span + font-style: inherit + font-weight: inherit + +strong + color: $strong-color + font-weight: $strong-weight + +// Block + +fieldset + border: none + +pre + +overflow-touch + background-color: $pre-background + color: $pre + font-size: $pre-font-size + overflow-x: auto + padding: $pre-padding + white-space: pre + word-wrap: normal + code + background-color: transparent + color: currentColor + font-size: $pre-code-font-size + padding: 0 + +table + td, + th + vertical-align: top + &:not([align]) + text-align: inherit + th + color: $text-strong diff --git a/shared/static/src/bulma/sass/base/helpers.sass b/shared/static/src/bulma/sass/base/helpers.sass new file mode 100644 index 00000000..e356830f --- /dev/null +++ b/shared/static/src/bulma/sass/base/helpers.sass @@ -0,0 +1 @@ +@warn "The helpers.sass file is DEPRECATED. It has moved into its own /helpers folder. Please import sass/helpers/_all instead." diff --git a/shared/static/src/bulma/sass/base/minireset.sass b/shared/static/src/bulma/sass/base/minireset.sass new file mode 100644 index 00000000..aa2b6f3a --- /dev/null +++ b/shared/static/src/bulma/sass/base/minireset.sass @@ -0,0 +1,79 @@ +/*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */ +// Blocks +html, +body, +p, +ol, +ul, +li, +dl, +dt, +dd, +blockquote, +figure, +fieldset, +legend, +textarea, +pre, +iframe, +hr, +h1, +h2, +h3, +h4, +h5, +h6 + margin: 0 + padding: 0 + +// Headings +h1, +h2, +h3, +h4, +h5, +h6 + font-size: 100% + font-weight: normal + +// List +ul + list-style: none + +// Form +button, +input, +select, +textarea + margin: 0 + +// Box sizing +html + box-sizing: border-box + +* + &, + &::before, + &::after + box-sizing: inherit + +// Media +img, +video + height: auto + max-width: 100% + +// Iframe +iframe + border: 0 + +// Table +table + border-collapse: collapse + border-spacing: 0 + +td, +th + padding: 0 + &:not([align]) + text-align: inherit diff --git a/shared/static/src/bulma/sass/components/_all.sass b/shared/static/src/bulma/sass/components/_all.sass new file mode 100644 index 00000000..1de2c214 --- /dev/null +++ b/shared/static/src/bulma/sass/components/_all.sass @@ -0,0 +1,14 @@ +@charset "utf-8" + +@import "breadcrumb.sass" +@import "card.sass" +@import "dropdown.sass" +@import "level.sass" +@import "media.sass" +@import "menu.sass" +@import "message.sass" +@import "modal.sass" +@import "navbar.sass" +@import "pagination.sass" +@import "panel.sass" +@import "tabs.sass" diff --git a/shared/static/src/bulma/sass/components/breadcrumb.sass b/shared/static/src/bulma/sass/components/breadcrumb.sass new file mode 100644 index 00000000..f42b0b84 --- /dev/null +++ b/shared/static/src/bulma/sass/components/breadcrumb.sass @@ -0,0 +1,75 @@ +$breadcrumb-item-color: $link !default +$breadcrumb-item-hover-color: $link-hover !default +$breadcrumb-item-active-color: $text-strong !default + +$breadcrumb-item-padding-vertical: 0 !default +$breadcrumb-item-padding-horizontal: 0.75em !default + +$breadcrumb-item-separator-color: $border-hover !default + +.breadcrumb + @extend %block + @extend %unselectable + font-size: $size-normal + white-space: nowrap + a + align-items: center + color: $breadcrumb-item-color + display: flex + justify-content: center + padding: $breadcrumb-item-padding-vertical $breadcrumb-item-padding-horizontal + &:hover + color: $breadcrumb-item-hover-color + li + align-items: center + display: flex + &:first-child a + +ltr-property("padding", 0, false) + &.is-active + a + color: $breadcrumb-item-active-color + cursor: default + pointer-events: none + & + li::before + color: $breadcrumb-item-separator-color + content: "\0002f" + ul, + ol + align-items: flex-start + display: flex + flex-wrap: wrap + justify-content: flex-start + .icon + &:first-child + +ltr-property("margin", 0.5em) + &:last-child + +ltr-property("margin", 0.5em, false) + // Alignment + &.is-centered + ol, + ul + justify-content: center + &.is-right + ol, + ul + justify-content: flex-end + // Sizes + &.is-small + font-size: $size-small + &.is-medium + font-size: $size-medium + &.is-large + font-size: $size-large + // Styles + &.has-arrow-separator + li + li::before + content: "\02192" + &.has-bullet-separator + li + li::before + content: "\02022" + &.has-dot-separator + li + li::before + content: "\000b7" + &.has-succeeds-separator + li + li::before + content: "\0227B" diff --git a/shared/static/src/bulma/sass/components/card.sass b/shared/static/src/bulma/sass/components/card.sass new file mode 100644 index 00000000..db1e5d9b --- /dev/null +++ b/shared/static/src/bulma/sass/components/card.sass @@ -0,0 +1,79 @@ +$card-color: $text !default +$card-background-color: $scheme-main !default +$card-shadow: 0 0.5em 1em -0.125em rgba($scheme-invert, 0.1), 0 0px 0 1px rgba($scheme-invert, 0.02) !default + +$card-header-background-color: transparent !default +$card-header-color: $text-strong !default +$card-header-padding: 0.75rem 1rem !default +$card-header-shadow: 0 0.125em 0.25em rgba($scheme-invert, 0.1) !default +$card-header-weight: $weight-bold !default + +$card-content-background-color: transparent !default +$card-content-padding: 1.5rem !default + +$card-footer-background-color: transparent !default +$card-footer-border-top: 1px solid $border-light !default +$card-footer-padding: 0.75rem !default + +$card-media-margin: $block-spacing !default + +.card + background-color: $card-background-color + box-shadow: $card-shadow + color: $card-color + max-width: 100% + position: relative + +.card-header + background-color: $card-header-background-color + align-items: stretch + box-shadow: $card-header-shadow + display: flex + +.card-header-title + align-items: center + color: $card-header-color + display: flex + flex-grow: 1 + font-weight: $card-header-weight + padding: $card-header-padding + &.is-centered + justify-content: center + +.card-header-icon + align-items: center + cursor: pointer + display: flex + justify-content: center + padding: $card-header-padding + +.card-image + display: block + position: relative + +.card-content + background-color: $card-content-background-color + padding: $card-content-padding + +.card-footer + background-color: $card-footer-background-color + border-top: $card-footer-border-top + align-items: stretch + display: flex + +.card-footer-item + align-items: center + display: flex + flex-basis: 0 + flex-grow: 1 + flex-shrink: 0 + justify-content: center + padding: $card-footer-padding + &:not(:last-child) + +ltr-property("border", $card-footer-border-top) + +// Combinations + +.card + .media:not(:last-child) + margin-bottom: $card-media-margin diff --git a/shared/static/src/bulma/sass/components/dropdown.sass b/shared/static/src/bulma/sass/components/dropdown.sass new file mode 100644 index 00000000..62cb66e4 --- /dev/null +++ b/shared/static/src/bulma/sass/components/dropdown.sass @@ -0,0 +1,81 @@ +$dropdown-menu-min-width: 12rem !default + +$dropdown-content-background-color: $scheme-main !default +$dropdown-content-arrow: $link !default +$dropdown-content-offset: 4px !default +$dropdown-content-padding-bottom: 0.5rem !default +$dropdown-content-padding-top: 0.5rem !default +$dropdown-content-radius: $radius !default +$dropdown-content-shadow: 0 0.5em 1em -0.125em rgba($scheme-invert, 0.1), 0 0px 0 1px rgba($scheme-invert, 0.02) !default +$dropdown-content-z: 20 !default + +$dropdown-item-color: $text !default +$dropdown-item-hover-color: $scheme-invert !default +$dropdown-item-hover-background-color: $background !default +$dropdown-item-active-color: $link-invert !default +$dropdown-item-active-background-color: $link !default + +$dropdown-divider-background-color: $border-light !default + +.dropdown + display: inline-flex + position: relative + vertical-align: top + &.is-active, + &.is-hoverable:hover + .dropdown-menu + display: block + &.is-right + .dropdown-menu + left: auto + right: 0 + &.is-up + .dropdown-menu + bottom: 100% + padding-bottom: $dropdown-content-offset + padding-top: initial + top: auto + +.dropdown-menu + display: none + +ltr-position(0, false) + min-width: $dropdown-menu-min-width + padding-top: $dropdown-content-offset + position: absolute + top: 100% + z-index: $dropdown-content-z + +.dropdown-content + background-color: $dropdown-content-background-color + border-radius: $dropdown-content-radius + box-shadow: $dropdown-content-shadow + padding-bottom: $dropdown-content-padding-bottom + padding-top: $dropdown-content-padding-top + +.dropdown-item + color: $dropdown-item-color + display: block + font-size: 0.875rem + line-height: 1.5 + padding: 0.375rem 1rem + position: relative + +a.dropdown-item, +button.dropdown-item + +ltr-property("padding", 3rem) + text-align: inherit + white-space: nowrap + width: 100% + &:hover + background-color: $dropdown-item-hover-background-color + color: $dropdown-item-hover-color + &.is-active + background-color: $dropdown-item-active-background-color + color: $dropdown-item-active-color + +.dropdown-divider + background-color: $dropdown-divider-background-color + border: none + display: block + height: 1px + margin: 0.5rem 0 diff --git a/shared/static/src/bulma/sass/components/level.sass b/shared/static/src/bulma/sass/components/level.sass new file mode 100644 index 00000000..8f731202 --- /dev/null +++ b/shared/static/src/bulma/sass/components/level.sass @@ -0,0 +1,77 @@ +$level-item-spacing: ($block-spacing / 2) !default + +.level + @extend %block + align-items: center + justify-content: space-between + code + border-radius: $radius + img + display: inline-block + vertical-align: top + // Modifiers + &.is-mobile + display: flex + .level-left, + .level-right + display: flex + .level-left + .level-right + margin-top: 0 + .level-item + &:not(:last-child) + margin-bottom: 0 + +ltr-property("margin", $level-item-spacing) + &:not(.is-narrow) + flex-grow: 1 + // Responsiveness + +tablet + display: flex + & > .level-item + &:not(.is-narrow) + flex-grow: 1 + +.level-item + align-items: center + display: flex + flex-basis: auto + flex-grow: 0 + flex-shrink: 0 + justify-content: center + .title, + .subtitle + margin-bottom: 0 + // Responsiveness + +mobile + &:not(:last-child) + margin-bottom: $level-item-spacing + +.level-left, +.level-right + flex-basis: auto + flex-grow: 0 + flex-shrink: 0 + .level-item + // Modifiers + &.is-flexible + flex-grow: 1 + // Responsiveness + +tablet + &:not(:last-child) + +ltr-property("margin", $level-item-spacing) + +.level-left + align-items: center + justify-content: flex-start + // Responsiveness + +mobile + & + .level-right + margin-top: 1.5rem + +tablet + display: flex + +.level-right + align-items: center + justify-content: flex-end + // Responsiveness + +tablet + display: flex diff --git a/shared/static/src/bulma/sass/components/media.sass b/shared/static/src/bulma/sass/components/media.sass new file mode 100644 index 00000000..777755b2 --- /dev/null +++ b/shared/static/src/bulma/sass/components/media.sass @@ -0,0 +1,52 @@ +$media-border-color: bulmaRgba($border, 0.5) !default +$media-spacing: 1rem +$media-spacing-large: 1.5rem + +.media + align-items: flex-start + display: flex + text-align: inherit + .content:not(:last-child) + margin-bottom: 0.75rem + .media + border-top: 1px solid $media-border-color + display: flex + padding-top: 0.75rem + .content:not(:last-child), + .control:not(:last-child) + margin-bottom: 0.5rem + .media + padding-top: 0.5rem + & + .media + margin-top: 0.5rem + & + .media + border-top: 1px solid $media-border-color + margin-top: $media-spacing + padding-top: $media-spacing + // Sizes + &.is-large + & + .media + margin-top: $media-spacing-large + padding-top: $media-spacing-large + +.media-left, +.media-right + flex-basis: auto + flex-grow: 0 + flex-shrink: 0 + +.media-left + +ltr-property("margin", $media-spacing) + +.media-right + +ltr-property("margin", $media-spacing, false) + +.media-content + flex-basis: auto + flex-grow: 1 + flex-shrink: 1 + text-align: inherit + ++mobile + .media-content + overflow-x: auto diff --git a/shared/static/src/bulma/sass/components/menu.sass b/shared/static/src/bulma/sass/components/menu.sass new file mode 100644 index 00000000..1bf78297 --- /dev/null +++ b/shared/static/src/bulma/sass/components/menu.sass @@ -0,0 +1,57 @@ +$menu-item-color: $text !default +$menu-item-radius: $radius-small !default +$menu-item-hover-color: $text-strong !default +$menu-item-hover-background-color: $background !default +$menu-item-active-color: $link-invert !default +$menu-item-active-background-color: $link !default + +$menu-list-border-left: 1px solid $border !default +$menu-list-line-height: 1.25 !default +$menu-list-link-padding: 0.5em 0.75em !default +$menu-nested-list-margin: 0.75em !default +$menu-nested-list-padding-left: 0.75em !default + +$menu-label-color: $text-light !default +$menu-label-font-size: 0.75em !default +$menu-label-letter-spacing: 0.1em !default +$menu-label-spacing: 1em !default + +.menu + font-size: $size-normal + // Sizes + &.is-small + font-size: $size-small + &.is-medium + font-size: $size-medium + &.is-large + font-size: $size-large + +.menu-list + line-height: $menu-list-line-height + a + border-radius: $menu-item-radius + color: $menu-item-color + display: block + padding: $menu-list-link-padding + &:hover + background-color: $menu-item-hover-background-color + color: $menu-item-hover-color + // Modifiers + &.is-active + background-color: $menu-item-active-background-color + color: $menu-item-active-color + li + ul + +ltr-property("border", $menu-list-border-left, false) + margin: $menu-nested-list-margin + +ltr-property("padding", $menu-nested-list-padding-left, false) + +.menu-label + color: $menu-label-color + font-size: $menu-label-font-size + letter-spacing: $menu-label-letter-spacing + text-transform: uppercase + &:not(:first-child) + margin-top: $menu-label-spacing + &:not(:last-child) + margin-bottom: $menu-label-spacing diff --git a/shared/static/src/bulma/sass/components/message.sass b/shared/static/src/bulma/sass/components/message.sass new file mode 100644 index 00000000..180fbe94 --- /dev/null +++ b/shared/static/src/bulma/sass/components/message.sass @@ -0,0 +1,99 @@ +$message-background-color: $background !default +$message-radius: $radius !default + +$message-header-background-color: $text !default +$message-header-color: $text-invert !default +$message-header-weight: $weight-bold !default +$message-header-padding: 0.75em 1em !default +$message-header-radius: $radius !default + +$message-body-border-color: $border !default +$message-body-border-width: 0 0 0 4px !default +$message-body-color: $text !default +$message-body-padding: 1.25em 1.5em !default +$message-body-radius: $radius !default + +$message-body-pre-background-color: $scheme-main !default +$message-body-pre-code-background-color: transparent !default + +$message-header-body-border-width: 0 !default +$message-colors: $colors !default + +.message + @extend %block + background-color: $message-background-color + border-radius: $message-radius + font-size: $size-normal + strong + color: currentColor + a:not(.button):not(.tag):not(.dropdown-item) + color: currentColor + text-decoration: underline + // Sizes + &.is-small + font-size: $size-small + &.is-medium + font-size: $size-medium + &.is-large + font-size: $size-large + // Colors + @each $name, $components in $message-colors + $color: nth($components, 1) + $color-invert: nth($components, 2) + $color-light: null + $color-dark: null + + @if length($components) >= 3 + $color-light: nth($components, 3) + @if length($components) >= 4 + $color-dark: nth($components, 4) + @else + $color-luminance: colorLuminance($color) + $darken-percentage: $color-luminance * 70% + $desaturate-percentage: $color-luminance * 30% + $color-dark: desaturate(darken($color, $darken-percentage), $desaturate-percentage) + @else + $color-lightning: max((100% - lightness($color)) - 2%, 0%) + $color-light: lighten($color, $color-lightning) + + &.is-#{$name} + background-color: $color-light + .message-header + background-color: $color + color: $color-invert + .message-body + border-color: $color + color: $color-dark + +.message-header + align-items: center + background-color: $message-header-background-color + border-radius: $message-header-radius $message-header-radius 0 0 + color: $message-header-color + display: flex + font-weight: $message-header-weight + justify-content: space-between + line-height: 1.25 + padding: $message-header-padding + position: relative + .delete + flex-grow: 0 + flex-shrink: 0 + +ltr-property("margin", 0.75em, false) + & + .message-body + border-width: $message-header-body-border-width + border-top-left-radius: 0 + border-top-right-radius: 0 + +.message-body + border-color: $message-body-border-color + border-radius: $message-body-radius + border-style: solid + border-width: $message-body-border-width + color: $message-body-color + padding: $message-body-padding + code, + pre + background-color: $message-body-pre-background-color + pre code + background-color: $message-body-pre-code-background-color diff --git a/shared/static/src/bulma/sass/components/modal.sass b/shared/static/src/bulma/sass/components/modal.sass new file mode 100644 index 00000000..f352744a --- /dev/null +++ b/shared/static/src/bulma/sass/components/modal.sass @@ -0,0 +1,113 @@ +$modal-z: 40 !default + +$modal-background-background-color: bulmaRgba($scheme-invert, 0.86) !default + +$modal-content-width: 640px !default +$modal-content-margin-mobile: 20px !default +$modal-content-spacing-mobile: 160px !default +$modal-content-spacing-tablet: 40px !default + +$modal-close-dimensions: 40px !default +$modal-close-right: 20px !default +$modal-close-top: 20px !default + +$modal-card-spacing: 40px !default + +$modal-card-head-background-color: $background !default +$modal-card-head-border-bottom: 1px solid $border !default +$modal-card-head-padding: 20px !default +$modal-card-head-radius: $radius-large !default + +$modal-card-title-color: $text-strong !default +$modal-card-title-line-height: 1 !default +$modal-card-title-size: $size-4 !default + +$modal-card-foot-radius: $radius-large !default +$modal-card-foot-border-top: 1px solid $border !default + +$modal-card-body-background-color: $scheme-main !default +$modal-card-body-padding: 20px !default + +.modal + @extend %overlay + align-items: center + display: none + flex-direction: column + justify-content: center + overflow: hidden + position: fixed + z-index: $modal-z + // Modifiers + &.is-active + display: flex + +.modal-background + @extend %overlay + background-color: $modal-background-background-color + +.modal-content, +.modal-card + margin: 0 $modal-content-margin-mobile + max-height: calc(100vh - #{$modal-content-spacing-mobile}) + overflow: auto + position: relative + width: 100% + // Responsiveness + +tablet + margin: 0 auto + max-height: calc(100vh - #{$modal-content-spacing-tablet}) + width: $modal-content-width + +.modal-close + @extend %delete + background: none + height: $modal-close-dimensions + position: fixed + +ltr-position($modal-close-right) + top: $modal-close-top + width: $modal-close-dimensions + +.modal-card + display: flex + flex-direction: column + max-height: calc(100vh - #{$modal-card-spacing}) + overflow: hidden + -ms-overflow-y: visible + +.modal-card-head, +.modal-card-foot + align-items: center + background-color: $modal-card-head-background-color + display: flex + flex-shrink: 0 + justify-content: flex-start + padding: $modal-card-head-padding + position: relative + +.modal-card-head + border-bottom: $modal-card-head-border-bottom + border-top-left-radius: $modal-card-head-radius + border-top-right-radius: $modal-card-head-radius + +.modal-card-title + color: $modal-card-title-color + flex-grow: 1 + flex-shrink: 0 + font-size: $modal-card-title-size + line-height: $modal-card-title-line-height + +.modal-card-foot + border-bottom-left-radius: $modal-card-foot-radius + border-bottom-right-radius: $modal-card-foot-radius + border-top: $modal-card-foot-border-top + .button + &:not(:last-child) + +ltr-property("margin", 0.5em) + +.modal-card-body + +overflow-touch + background-color: $modal-card-body-background-color + flex-grow: 1 + flex-shrink: 1 + overflow: auto + padding: $modal-card-body-padding diff --git a/shared/static/src/bulma/sass/components/navbar.sass b/shared/static/src/bulma/sass/components/navbar.sass new file mode 100644 index 00000000..a34718ec --- /dev/null +++ b/shared/static/src/bulma/sass/components/navbar.sass @@ -0,0 +1,441 @@ +$navbar-background-color: $scheme-main !default +$navbar-box-shadow-size: 0 2px 0 0 !default +$navbar-box-shadow-color: $background !default +$navbar-height: 3.25rem !default +$navbar-padding-vertical: 1rem !default +$navbar-padding-horizontal: 2rem !default +$navbar-z: 30 !default +$navbar-fixed-z: 30 !default + +$navbar-item-color: $text !default +$navbar-item-hover-color: $link !default +$navbar-item-hover-background-color: $scheme-main-bis !default +$navbar-item-active-color: $scheme-invert !default +$navbar-item-active-background-color: transparent !default +$navbar-item-img-max-height: 1.75rem !default + +$navbar-burger-color: $navbar-item-color !default + +$navbar-tab-hover-background-color: transparent !default +$navbar-tab-hover-border-bottom-color: $link !default +$navbar-tab-active-color: $link !default +$navbar-tab-active-background-color: transparent !default +$navbar-tab-active-border-bottom-color: $link !default +$navbar-tab-active-border-bottom-style: solid !default +$navbar-tab-active-border-bottom-width: 3px !default + +$navbar-dropdown-background-color: $scheme-main !default +$navbar-dropdown-border-top: 2px solid $border !default +$navbar-dropdown-offset: -4px !default +$navbar-dropdown-arrow: $link !default +$navbar-dropdown-radius: $radius-large !default +$navbar-dropdown-z: 20 !default + +$navbar-dropdown-boxed-radius: $radius-large !default +$navbar-dropdown-boxed-shadow: 0 8px 8px bulmaRgba($scheme-invert, 0.1), 0 0 0 1px bulmaRgba($scheme-invert, 0.1) !default + +$navbar-dropdown-item-hover-color: $scheme-invert !default +$navbar-dropdown-item-hover-background-color: $background !default +$navbar-dropdown-item-active-color: $link !default +$navbar-dropdown-item-active-background-color: $background !default + +$navbar-divider-background-color: $background !default +$navbar-divider-height: 2px !default + +$navbar-bottom-box-shadow-size: 0 -2px 0 0 !default + +$navbar-breakpoint: $desktop !default + +=navbar-fixed + left: 0 + position: fixed + right: 0 + z-index: $navbar-fixed-z + +.navbar + background-color: $navbar-background-color + min-height: $navbar-height + position: relative + z-index: $navbar-z + @each $name, $pair in $colors + $color: nth($pair, 1) + $color-invert: nth($pair, 2) + &.is-#{$name} + background-color: $color + color: $color-invert + .navbar-brand + & > .navbar-item, + .navbar-link + color: $color-invert + & > a.navbar-item, + .navbar-link + &:focus, + &:hover, + &.is-active + background-color: bulmaDarken($color, 5%) + color: $color-invert + .navbar-link + &::after + border-color: $color-invert + .navbar-burger + color: $color-invert + +from($navbar-breakpoint) + .navbar-start, + .navbar-end + & > .navbar-item, + .navbar-link + color: $color-invert + & > a.navbar-item, + .navbar-link + &:focus, + &:hover, + &.is-active + background-color: bulmaDarken($color, 5%) + color: $color-invert + .navbar-link + &::after + border-color: $color-invert + .navbar-item.has-dropdown:focus .navbar-link, + .navbar-item.has-dropdown:hover .navbar-link, + .navbar-item.has-dropdown.is-active .navbar-link + background-color: bulmaDarken($color, 5%) + color: $color-invert + .navbar-dropdown + a.navbar-item + &.is-active + background-color: $color + color: $color-invert + & > .container + align-items: stretch + display: flex + min-height: $navbar-height + width: 100% + &.has-shadow + box-shadow: $navbar-box-shadow-size $navbar-box-shadow-color + &.is-fixed-bottom, + &.is-fixed-top + +navbar-fixed + &.is-fixed-bottom + bottom: 0 + &.has-shadow + box-shadow: $navbar-bottom-box-shadow-size $navbar-box-shadow-color + &.is-fixed-top + top: 0 + +html, +body + &.has-navbar-fixed-top + padding-top: $navbar-height + &.has-navbar-fixed-bottom + padding-bottom: $navbar-height + +.navbar-brand, +.navbar-tabs + align-items: stretch + display: flex + flex-shrink: 0 + min-height: $navbar-height + +.navbar-brand + a.navbar-item + &:focus, + &:hover + background-color: transparent + +.navbar-tabs + +overflow-touch + max-width: 100vw + overflow-x: auto + overflow-y: hidden + +.navbar-burger + color: $navbar-burger-color + +hamburger($navbar-height) + +ltr-property("margin", auto, false) + +.navbar-menu + display: none + +.navbar-item, +.navbar-link + color: $navbar-item-color + display: block + line-height: 1.5 + padding: 0.5rem 0.75rem + position: relative + .icon + &:only-child + margin-left: -0.25rem + margin-right: -0.25rem + +a.navbar-item, +.navbar-link + cursor: pointer + &:focus, + &:focus-within, + &:hover, + &.is-active + background-color: $navbar-item-hover-background-color + color: $navbar-item-hover-color + +.navbar-item + flex-grow: 0 + flex-shrink: 0 + img + max-height: $navbar-item-img-max-height + &.has-dropdown + padding: 0 + &.is-expanded + flex-grow: 1 + flex-shrink: 1 + &.is-tab + border-bottom: 1px solid transparent + min-height: $navbar-height + padding-bottom: calc(0.5rem - 1px) + &:focus, + &:hover + background-color: $navbar-tab-hover-background-color + border-bottom-color: $navbar-tab-hover-border-bottom-color + &.is-active + background-color: $navbar-tab-active-background-color + border-bottom-color: $navbar-tab-active-border-bottom-color + border-bottom-style: $navbar-tab-active-border-bottom-style + border-bottom-width: $navbar-tab-active-border-bottom-width + color: $navbar-tab-active-color + padding-bottom: calc(0.5rem - #{$navbar-tab-active-border-bottom-width}) + +.navbar-content + flex-grow: 1 + flex-shrink: 1 + +.navbar-link:not(.is-arrowless) + +ltr-property("padding", 2.5em) + &::after + @extend %arrow + border-color: $navbar-dropdown-arrow + margin-top: -0.375em + +ltr-position(1.125em) + +.navbar-dropdown + font-size: 0.875rem + padding-bottom: 0.5rem + padding-top: 0.5rem + .navbar-item + padding-left: 1.5rem + padding-right: 1.5rem + +.navbar-divider + background-color: $navbar-divider-background-color + border: none + display: none + height: $navbar-divider-height + margin: 0.5rem 0 + ++until($navbar-breakpoint) + .navbar > .container + display: block + .navbar-brand, + .navbar-tabs + .navbar-item + align-items: center + display: flex + .navbar-link + &::after + display: none + .navbar-menu + background-color: $navbar-background-color + box-shadow: 0 8px 16px bulmaRgba($scheme-invert, 0.1) + padding: 0.5rem 0 + &.is-active + display: block + // Fixed navbar + .navbar + &.is-fixed-bottom-touch, + &.is-fixed-top-touch + +navbar-fixed + &.is-fixed-bottom-touch + bottom: 0 + &.has-shadow + box-shadow: 0 -2px 3px bulmaRgba($scheme-invert, 0.1) + &.is-fixed-top-touch + top: 0 + &.is-fixed-top, + &.is-fixed-top-touch + .navbar-menu + +overflow-touch + max-height: calc(100vh - #{$navbar-height}) + overflow: auto + html, + body + &.has-navbar-fixed-top-touch + padding-top: $navbar-height + &.has-navbar-fixed-bottom-touch + padding-bottom: $navbar-height + ++from($navbar-breakpoint) + .navbar, + .navbar-menu, + .navbar-start, + .navbar-end + align-items: stretch + display: flex + .navbar + min-height: $navbar-height + &.is-spaced + padding: $navbar-padding-vertical $navbar-padding-horizontal + .navbar-start, + .navbar-end + align-items: center + a.navbar-item, + .navbar-link + border-radius: $radius + &.is-transparent + a.navbar-item, + .navbar-link + &:focus, + &:hover, + &.is-active + background-color: transparent !important + .navbar-item.has-dropdown + &.is-active, + &.is-hoverable:focus, + &.is-hoverable:focus-within, + &.is-hoverable:hover + .navbar-link + background-color: transparent !important + .navbar-dropdown + a.navbar-item + &:focus, + &:hover + background-color: $navbar-dropdown-item-hover-background-color + color: $navbar-dropdown-item-hover-color + &.is-active + background-color: $navbar-dropdown-item-active-background-color + color: $navbar-dropdown-item-active-color + .navbar-burger + display: none + .navbar-item, + .navbar-link + align-items: center + display: flex + .navbar-item + &.has-dropdown + align-items: stretch + &.has-dropdown-up + .navbar-link::after + transform: rotate(135deg) translate(0.25em, -0.25em) + .navbar-dropdown + border-bottom: $navbar-dropdown-border-top + border-radius: $navbar-dropdown-radius $navbar-dropdown-radius 0 0 + border-top: none + bottom: 100% + box-shadow: 0 -8px 8px bulmaRgba($scheme-invert, 0.1) + top: auto + &.is-active, + &.is-hoverable:focus, + &.is-hoverable:focus-within, + &.is-hoverable:hover + .navbar-dropdown + display: block + .navbar.is-spaced &, + &.is-boxed + opacity: 1 + pointer-events: auto + transform: translateY(0) + .navbar-menu + flex-grow: 1 + flex-shrink: 0 + .navbar-start + justify-content: flex-start + +ltr-property("margin", auto) + .navbar-end + justify-content: flex-end + +ltr-property("margin", auto, false) + .navbar-dropdown + background-color: $navbar-dropdown-background-color + border-bottom-left-radius: $navbar-dropdown-radius + border-bottom-right-radius: $navbar-dropdown-radius + border-top: $navbar-dropdown-border-top + box-shadow: 0 8px 8px bulmaRgba($scheme-invert, 0.1) + display: none + font-size: 0.875rem + +ltr-position(0, false) + min-width: 100% + position: absolute + top: 100% + z-index: $navbar-dropdown-z + .navbar-item + padding: 0.375rem 1rem + white-space: nowrap + a.navbar-item + +ltr-property("padding", 3rem) + &:focus, + &:hover + background-color: $navbar-dropdown-item-hover-background-color + color: $navbar-dropdown-item-hover-color + &.is-active + background-color: $navbar-dropdown-item-active-background-color + color: $navbar-dropdown-item-active-color + .navbar.is-spaced &, + &.is-boxed + border-radius: $navbar-dropdown-boxed-radius + border-top: none + box-shadow: $navbar-dropdown-boxed-shadow + display: block + opacity: 0 + pointer-events: none + top: calc(100% + (#{$navbar-dropdown-offset})) + transform: translateY(-5px) + transition-duration: $speed + transition-property: opacity, transform + &.is-right + left: auto + right: 0 + .navbar-divider + display: block + .navbar > .container, + .container > .navbar + .navbar-brand + +ltr-property("margin", -.75rem, false) + .navbar-menu + +ltr-property("margin", -.75rem) + // Fixed navbar + .navbar + &.is-fixed-bottom-desktop, + &.is-fixed-top-desktop + +navbar-fixed + &.is-fixed-bottom-desktop + bottom: 0 + &.has-shadow + box-shadow: 0 -2px 3px bulmaRgba($scheme-invert, 0.1) + &.is-fixed-top-desktop + top: 0 + html, + body + &.has-navbar-fixed-top-desktop + padding-top: $navbar-height + &.has-navbar-fixed-bottom-desktop + padding-bottom: $navbar-height + &.has-spaced-navbar-fixed-top + padding-top: $navbar-height + ($navbar-padding-vertical * 2) + &.has-spaced-navbar-fixed-bottom + padding-bottom: $navbar-height + ($navbar-padding-vertical * 2) + // Hover/Active states + a.navbar-item, + .navbar-link + &.is-active + color: $navbar-item-active-color + &.is-active:not(:focus):not(:hover) + background-color: $navbar-item-active-background-color + .navbar-item.has-dropdown + &:focus, + &:hover, + &.is-active + .navbar-link + background-color: $navbar-item-hover-background-color + +// Combination + +.hero + &.is-fullheight-with-navbar + min-height: calc(100vh - #{$navbar-height}) diff --git a/shared/static/src/bulma/sass/components/pagination.sass b/shared/static/src/bulma/sass/components/pagination.sass new file mode 100644 index 00000000..822c2e81 --- /dev/null +++ b/shared/static/src/bulma/sass/components/pagination.sass @@ -0,0 +1,150 @@ +$pagination-color: $text-strong !default +$pagination-border-color: $border !default +$pagination-margin: -0.25rem !default +$pagination-min-width: $control-height !default + +$pagination-item-font-size: 1em !default +$pagination-item-margin: 0.25rem !default +$pagination-item-padding-left: 0.5em !default +$pagination-item-padding-right: 0.5em !default + +$pagination-hover-color: $link-hover !default +$pagination-hover-border-color: $link-hover-border !default + +$pagination-focus-color: $link-focus !default +$pagination-focus-border-color: $link-focus-border !default + +$pagination-active-color: $link-active !default +$pagination-active-border-color: $link-active-border !default + +$pagination-disabled-color: $text-light !default +$pagination-disabled-background-color: $border !default +$pagination-disabled-border-color: $border !default + +$pagination-current-color: $link-invert !default +$pagination-current-background-color: $link !default +$pagination-current-border-color: $link !default + +$pagination-ellipsis-color: $grey-light !default + +$pagination-shadow-inset: inset 0 1px 2px rgba($scheme-invert, 0.2) + +.pagination + @extend %block + font-size: $size-normal + margin: $pagination-margin + // Sizes + &.is-small + font-size: $size-small + &.is-medium + font-size: $size-medium + &.is-large + font-size: $size-large + &.is-rounded + .pagination-previous, + .pagination-next + padding-left: 1em + padding-right: 1em + border-radius: $radius-rounded + .pagination-link + border-radius: $radius-rounded + +.pagination, +.pagination-list + align-items: center + display: flex + justify-content: center + text-align: center + +.pagination-previous, +.pagination-next, +.pagination-link, +.pagination-ellipsis + @extend %control + @extend %unselectable + font-size: $pagination-item-font-size + justify-content: center + margin: $pagination-item-margin + padding-left: $pagination-item-padding-left + padding-right: $pagination-item-padding-right + text-align: center + +.pagination-previous, +.pagination-next, +.pagination-link + border-color: $pagination-border-color + color: $pagination-color + min-width: $pagination-min-width + &:hover + border-color: $pagination-hover-border-color + color: $pagination-hover-color + &:focus + border-color: $pagination-focus-border-color + &:active + box-shadow: $pagination-shadow-inset + &[disabled] + background-color: $pagination-disabled-background-color + border-color: $pagination-disabled-border-color + box-shadow: none + color: $pagination-disabled-color + opacity: 0.5 + +.pagination-previous, +.pagination-next + padding-left: 0.75em + padding-right: 0.75em + white-space: nowrap + +.pagination-link + &.is-current + background-color: $pagination-current-background-color + border-color: $pagination-current-border-color + color: $pagination-current-color + +.pagination-ellipsis + color: $pagination-ellipsis-color + pointer-events: none + +.pagination-list + flex-wrap: wrap + ++mobile + .pagination + flex-wrap: wrap + .pagination-previous, + .pagination-next + flex-grow: 1 + flex-shrink: 1 + .pagination-list + li + flex-grow: 1 + flex-shrink: 1 + ++tablet + .pagination-list + flex-grow: 1 + flex-shrink: 1 + justify-content: flex-start + order: 1 + .pagination-previous + order: 2 + .pagination-next + order: 3 + .pagination + justify-content: space-between + &.is-centered + .pagination-previous + order: 1 + .pagination-list + justify-content: center + order: 2 + .pagination-next + order: 3 + &.is-right + .pagination-previous + order: 1 + .pagination-next + order: 2 + .pagination-list + justify-content: flex-end + order: 3 diff --git a/shared/static/src/bulma/sass/components/panel.sass b/shared/static/src/bulma/sass/components/panel.sass new file mode 100644 index 00000000..2f7e2754 --- /dev/null +++ b/shared/static/src/bulma/sass/components/panel.sass @@ -0,0 +1,119 @@ +$panel-margin: $block-spacing !default +$panel-item-border: 1px solid $border-light !default +$panel-radius: $radius-large !default +$panel-shadow: 0 0.5em 1em -0.125em rgba($scheme-invert, 0.1), 0 0px 0 1px rgba($scheme-invert, 0.02) !default + +$panel-heading-background-color: $border-light !default +$panel-heading-color: $text-strong !default +$panel-heading-line-height: 1.25 !default +$panel-heading-padding: 0.75em 1em !default +$panel-heading-radius: $radius !default +$panel-heading-size: 1.25em !default +$panel-heading-weight: $weight-bold !default + +$panel-tabs-font-size: 0.875em !default +$panel-tab-border-bottom: 1px solid $border !default +$panel-tab-active-border-bottom-color: $link-active-border !default +$panel-tab-active-color: $link-active !default + +$panel-list-item-color: $text !default +$panel-list-item-hover-color: $link !default + +$panel-block-color: $text-strong !default +$panel-block-hover-background-color: $background !default +$panel-block-active-border-left-color: $link !default +$panel-block-active-color: $link-active !default +$panel-block-active-icon-color: $link !default + +$panel-icon-color: $text-light !default +$panel-colors: $colors !default + +.panel + border-radius: $panel-radius + box-shadow: $panel-shadow + font-size: $size-normal + &:not(:last-child) + margin-bottom: $panel-margin + // Colors + @each $name, $components in $panel-colors + $color: nth($components, 1) + $color-invert: nth($components, 2) + &.is-#{$name} + .panel-heading + background-color: $color + color: $color-invert + .panel-tabs a.is-active + border-bottom-color: $color + .panel-block.is-active .panel-icon + color: $color + +.panel-tabs, +.panel-block + &:not(:last-child) + border-bottom: $panel-item-border + +.panel-heading + background-color: $panel-heading-background-color + border-radius: $panel-radius $panel-radius 0 0 + color: $panel-heading-color + font-size: $panel-heading-size + font-weight: $panel-heading-weight + line-height: $panel-heading-line-height + padding: $panel-heading-padding + +.panel-tabs + align-items: flex-end + display: flex + font-size: $panel-tabs-font-size + justify-content: center + a + border-bottom: $panel-tab-border-bottom + margin-bottom: -1px + padding: 0.5em + // Modifiers + &.is-active + border-bottom-color: $panel-tab-active-border-bottom-color + color: $panel-tab-active-color + +.panel-list + a + color: $panel-list-item-color + &:hover + color: $panel-list-item-hover-color + +.panel-block + align-items: center + color: $panel-block-color + display: flex + justify-content: flex-start + padding: 0.5em 0.75em + input[type="checkbox"] + +ltr-property("margin", 0.75em) + & > .control + flex-grow: 1 + flex-shrink: 1 + width: 100% + &.is-wrapped + flex-wrap: wrap + &.is-active + border-left-color: $panel-block-active-border-left-color + color: $panel-block-active-color + .panel-icon + color: $panel-block-active-icon-color + &:last-child + border-bottom-left-radius: $panel-radius + border-bottom-right-radius: $panel-radius + +a.panel-block, +label.panel-block + cursor: pointer + &:hover + background-color: $panel-block-hover-background-color + +.panel-icon + +fa(14px, 1em) + color: $panel-icon-color + +ltr-property("margin", 0.75em) + .fa + font-size: inherit + line-height: inherit diff --git a/shared/static/src/bulma/sass/components/tabs.sass b/shared/static/src/bulma/sass/components/tabs.sass new file mode 100644 index 00000000..2308bf09 --- /dev/null +++ b/shared/static/src/bulma/sass/components/tabs.sass @@ -0,0 +1,174 @@ +$tabs-border-bottom-color: $border !default +$tabs-border-bottom-style: solid !default +$tabs-border-bottom-width: 1px !default +$tabs-link-color: $text !default +$tabs-link-hover-border-bottom-color: $text-strong !default +$tabs-link-hover-color: $text-strong !default +$tabs-link-active-border-bottom-color: $link !default +$tabs-link-active-color: $link !default +$tabs-link-padding: 0.5em 1em !default + +$tabs-boxed-link-radius: $radius !default +$tabs-boxed-link-hover-background-color: $background !default +$tabs-boxed-link-hover-border-bottom-color: $border !default + +$tabs-boxed-link-active-background-color: $scheme-main !default +$tabs-boxed-link-active-border-color: $border !default +$tabs-boxed-link-active-border-bottom-color: transparent !default + +$tabs-toggle-link-border-color: $border !default +$tabs-toggle-link-border-style: solid !default +$tabs-toggle-link-border-width: 1px !default +$tabs-toggle-link-hover-background-color: $background !default +$tabs-toggle-link-hover-border-color: $border-hover !default +$tabs-toggle-link-radius: $radius !default +$tabs-toggle-link-active-background-color: $link !default +$tabs-toggle-link-active-border-color: $link !default +$tabs-toggle-link-active-color: $link-invert !default + +.tabs + @extend %block + +overflow-touch + @extend %unselectable + align-items: stretch + display: flex + font-size: $size-normal + justify-content: space-between + overflow: hidden + overflow-x: auto + white-space: nowrap + a + align-items: center + border-bottom-color: $tabs-border-bottom-color + border-bottom-style: $tabs-border-bottom-style + border-bottom-width: $tabs-border-bottom-width + color: $tabs-link-color + display: flex + justify-content: center + margin-bottom: -#{$tabs-border-bottom-width} + padding: $tabs-link-padding + vertical-align: top + &:hover + border-bottom-color: $tabs-link-hover-border-bottom-color + color: $tabs-link-hover-color + li + display: block + &.is-active + a + border-bottom-color: $tabs-link-active-border-bottom-color + color: $tabs-link-active-color + ul + align-items: center + border-bottom-color: $tabs-border-bottom-color + border-bottom-style: $tabs-border-bottom-style + border-bottom-width: $tabs-border-bottom-width + display: flex + flex-grow: 1 + flex-shrink: 0 + justify-content: flex-start + &.is-left + padding-right: 0.75em + &.is-center + flex: none + justify-content: center + padding-left: 0.75em + padding-right: 0.75em + &.is-right + justify-content: flex-end + padding-left: 0.75em + .icon + &:first-child + +ltr-property("margin", 0.5em) + &:last-child + +ltr-property("margin", 0.5em, false) + // Alignment + &.is-centered + ul + justify-content: center + &.is-right + ul + justify-content: flex-end + // Styles + &.is-boxed + a + border: 1px solid transparent + +ltr + border-radius: $tabs-boxed-link-radius $tabs-boxed-link-radius 0 0 + +rtl + border-radius: 0 0 $tabs-boxed-link-radius $tabs-boxed-link-radius + &:hover + background-color: $tabs-boxed-link-hover-background-color + border-bottom-color: $tabs-boxed-link-hover-border-bottom-color + li + &.is-active + a + background-color: $tabs-boxed-link-active-background-color + border-color: $tabs-boxed-link-active-border-color + border-bottom-color: $tabs-boxed-link-active-border-bottom-color !important + &.is-fullwidth + li + flex-grow: 1 + flex-shrink: 0 + &.is-toggle + a + border-color: $tabs-toggle-link-border-color + border-style: $tabs-toggle-link-border-style + border-width: $tabs-toggle-link-border-width + margin-bottom: 0 + position: relative + &:hover + background-color: $tabs-toggle-link-hover-background-color + border-color: $tabs-toggle-link-hover-border-color + z-index: 2 + li + & + li + +ltr-property("margin", -#{$tabs-toggle-link-border-width}, false) + &:first-child a + +ltr + border-top-left-radius: $tabs-toggle-link-radius + border-bottom-left-radius: $tabs-toggle-link-radius + +rtl + border-top-right-radius: $tabs-toggle-link-radius + border-bottom-right-radius: $tabs-toggle-link-radius + &:last-child a + +ltr + border-top-right-radius: $tabs-toggle-link-radius + border-bottom-right-radius: $tabs-toggle-link-radius + +rtl + border-top-left-radius: $tabs-toggle-link-radius + border-bottom-left-radius: $tabs-toggle-link-radius + &.is-active + a + background-color: $tabs-toggle-link-active-background-color + border-color: $tabs-toggle-link-active-border-color + color: $tabs-toggle-link-active-color + z-index: 1 + ul + border-bottom: none + &.is-toggle-rounded + li + &:first-child a + +ltr + border-bottom-left-radius: $radius-rounded + border-top-left-radius: $radius-rounded + padding-left: 1.25em + +rtl + border-bottom-right-radius: $radius-rounded + border-top-right-radius: $radius-rounded + padding-right: 1.25em + &:last-child a + +ltr + border-bottom-right-radius: $radius-rounded + border-top-right-radius: $radius-rounded + padding-right: 1.25em + +rtl + border-bottom-left-radius: $radius-rounded + border-top-left-radius: $radius-rounded + padding-left: 1.25em + // Sizes + &.is-small + font-size: $size-small + &.is-medium + font-size: $size-medium + &.is-large + font-size: $size-large diff --git a/shared/static/src/bulma/sass/elements/_all.sass b/shared/static/src/bulma/sass/elements/_all.sass new file mode 100644 index 00000000..7490c00d --- /dev/null +++ b/shared/static/src/bulma/sass/elements/_all.sass @@ -0,0 +1,15 @@ +@charset "utf-8" + +@import "box.sass" +@import "button.sass" +@import "container.sass" +@import "content.sass" +@import "icon.sass" +@import "image.sass" +@import "notification.sass" +@import "progress.sass" +@import "table.sass" +@import "tag.sass" +@import "title.sass" + +@import "other.sass" diff --git a/shared/static/src/bulma/sass/elements/box.sass b/shared/static/src/bulma/sass/elements/box.sass new file mode 100644 index 00000000..2fd18d49 --- /dev/null +++ b/shared/static/src/bulma/sass/elements/box.sass @@ -0,0 +1,24 @@ +$box-color: $text !default +$box-background-color: $scheme-main !default +$box-radius: $radius-large !default +$box-shadow: 0 0.5em 1em -0.125em rgba($scheme-invert, 0.1), 0 0px 0 1px rgba($scheme-invert, 0.02) !default +$box-padding: 1.25rem !default + +$box-link-hover-shadow: 0 0.5em 1em -0.125em rgba($scheme-invert, 0.1), 0 0 0 1px $link !default +$box-link-active-shadow: inset 0 1px 2px rgba($scheme-invert, 0.2), 0 0 0 1px $link !default + +.box + @extend %block + background-color: $box-background-color + border-radius: $box-radius + box-shadow: $box-shadow + color: $box-color + display: block + padding: $box-padding + +a.box + &:hover, + &:focus + box-shadow: $box-link-hover-shadow + &:active + box-shadow: $box-link-active-shadow diff --git a/shared/static/src/bulma/sass/elements/button.sass b/shared/static/src/bulma/sass/elements/button.sass new file mode 100644 index 00000000..4bdf2534 --- /dev/null +++ b/shared/static/src/bulma/sass/elements/button.sass @@ -0,0 +1,323 @@ +$button-color: $text-strong !default +$button-background-color: $scheme-main !default +$button-family: false !default + +$button-border-color: $border !default +$button-border-width: $control-border-width !default + +$button-padding-vertical: calc(0.5em - #{$button-border-width}) !default +$button-padding-horizontal: 1em !default + +$button-hover-color: $link-hover !default +$button-hover-border-color: $link-hover-border !default + +$button-focus-color: $link-focus !default +$button-focus-border-color: $link-focus-border !default +$button-focus-box-shadow-size: 0 0 0 0.125em !default +$button-focus-box-shadow-color: bulmaRgba($link, 0.25) !default + +$button-active-color: $link-active !default +$button-active-border-color: $link-active-border !default + +$button-text-color: $text !default +$button-text-decoration: underline !default +$button-text-hover-background-color: $background !default +$button-text-hover-color: $text-strong !default + +$button-disabled-background-color: $scheme-main !default +$button-disabled-border-color: $border !default +$button-disabled-shadow: none !default +$button-disabled-opacity: 0.5 !default + +$button-static-color: $text-light !default +$button-static-background-color: $scheme-main-ter !default +$button-static-border-color: $border !default + +// The button sizes use mixins so they can be used at different breakpoints +=button-small + border-radius: $radius-small + font-size: $size-small +=button-normal + font-size: $size-normal +=button-medium + font-size: $size-medium +=button-large + font-size: $size-large + +.button + @extend %control + @extend %unselectable + background-color: $button-background-color + border-color: $button-border-color + border-width: $button-border-width + color: $button-color + cursor: pointer + @if $button-family + font-family: $button-family + justify-content: center + padding-bottom: $button-padding-vertical + padding-left: $button-padding-horizontal + padding-right: $button-padding-horizontal + padding-top: $button-padding-vertical + text-align: center + white-space: nowrap + strong + color: inherit + .icon + &, + &.is-small, + &.is-medium, + &.is-large + height: 1.5em + width: 1.5em + &:first-child:not(:last-child) + +ltr-property("margin", calc(#{-1 / 2 * $button-padding-horizontal} - #{$button-border-width}), false) + +ltr-property("margin", $button-padding-horizontal / 4) + &:last-child:not(:first-child) + +ltr-property("margin", $button-padding-horizontal / 4, false) + +ltr-property("margin", calc(#{-1 / 2 * $button-padding-horizontal} - #{$button-border-width})) + &:first-child:last-child + margin-left: calc(#{-1 / 2 * $button-padding-horizontal} - #{$button-border-width}) + margin-right: calc(#{-1 / 2 * $button-padding-horizontal} - #{$button-border-width}) + // States + &:hover, + &.is-hovered + border-color: $button-hover-border-color + color: $button-hover-color + &:focus, + &.is-focused + border-color: $button-focus-border-color + color: $button-focus-color + &:not(:active) + box-shadow: $button-focus-box-shadow-size $button-focus-box-shadow-color + &:active, + &.is-active + border-color: $button-active-border-color + color: $button-active-color + // Colors + &.is-text + background-color: transparent + border-color: transparent + color: $button-text-color + text-decoration: $button-text-decoration + &:hover, + &.is-hovered, + &:focus, + &.is-focused + background-color: $button-text-hover-background-color + color: $button-text-hover-color + &:active, + &.is-active + background-color: bulmaDarken($button-text-hover-background-color, 5%) + color: $button-text-hover-color + &[disabled], + fieldset[disabled] & + background-color: transparent + border-color: transparent + box-shadow: none + @each $name, $pair in $colors + $color: nth($pair, 1) + $color-invert: nth($pair, 2) + &.is-#{$name} + background-color: $color + border-color: transparent + color: $color-invert + &:hover, + &.is-hovered + background-color: bulmaDarken($color, 2.5%) + border-color: transparent + color: $color-invert + &:focus, + &.is-focused + border-color: transparent + color: $color-invert + &:not(:active) + box-shadow: $button-focus-box-shadow-size bulmaRgba($color, 0.25) + &:active, + &.is-active + background-color: bulmaDarken($color, 5%) + border-color: transparent + color: $color-invert + &[disabled], + fieldset[disabled] & + background-color: $color + border-color: transparent + box-shadow: none + &.is-inverted + background-color: $color-invert + color: $color + &:hover, + &.is-hovered + background-color: bulmaDarken($color-invert, 5%) + &[disabled], + fieldset[disabled] & + background-color: $color-invert + border-color: transparent + box-shadow: none + color: $color + &.is-loading + &::after + border-color: transparent transparent $color-invert $color-invert !important + &.is-outlined + background-color: transparent + border-color: $color + color: $color + &:hover, + &.is-hovered, + &:focus, + &.is-focused + background-color: $color + border-color: $color + color: $color-invert + &.is-loading + &::after + border-color: transparent transparent $color $color !important + &:hover, + &.is-hovered, + &:focus, + &.is-focused + &::after + border-color: transparent transparent $color-invert $color-invert !important + &[disabled], + fieldset[disabled] & + background-color: transparent + border-color: $color + box-shadow: none + color: $color + &.is-inverted.is-outlined + background-color: transparent + border-color: $color-invert + color: $color-invert + &:hover, + &.is-hovered, + &:focus, + &.is-focused + background-color: $color-invert + color: $color + &.is-loading + &:hover, + &.is-hovered, + &:focus, + &.is-focused + &::after + border-color: transparent transparent $color $color !important + &[disabled], + fieldset[disabled] & + background-color: transparent + border-color: $color-invert + box-shadow: none + color: $color-invert + // If light and dark colors are provided + @if length($pair) >= 4 + $color-light: nth($pair, 3) + $color-dark: nth($pair, 4) + &.is-light + background-color: $color-light + color: $color-dark + &:hover, + &.is-hovered + background-color: bulmaDarken($color-light, 2.5%) + border-color: transparent + color: $color-dark + &:active, + &.is-active + background-color: bulmaDarken($color-light, 5%) + border-color: transparent + color: $color-dark + // Sizes + &.is-small + +button-small + &.is-normal + +button-normal + &.is-medium + +button-medium + &.is-large + +button-large + // Modifiers + &[disabled], + fieldset[disabled] & + background-color: $button-disabled-background-color + border-color: $button-disabled-border-color + box-shadow: $button-disabled-shadow + opacity: $button-disabled-opacity + &.is-fullwidth + display: flex + width: 100% + &.is-loading + color: transparent !important + pointer-events: none + &::after + @extend %loader + +center(1em) + position: absolute !important + &.is-static + background-color: $button-static-background-color + border-color: $button-static-border-color + color: $button-static-color + box-shadow: none + pointer-events: none + &.is-rounded + border-radius: $radius-rounded + padding-left: calc(#{$button-padding-horizontal} + 0.25em) + padding-right: calc(#{$button-padding-horizontal} + 0.25em) + +.buttons + align-items: center + display: flex + flex-wrap: wrap + justify-content: flex-start + .button + margin-bottom: 0.5rem + &:not(:last-child):not(.is-fullwidth) + +ltr-property("margin", 0.5rem) + &:last-child + margin-bottom: -0.5rem + &:not(:last-child) + margin-bottom: 1rem + // Sizes + &.are-small + .button:not(.is-normal):not(.is-medium):not(.is-large) + +button-small + &.are-medium + .button:not(.is-small):not(.is-normal):not(.is-large) + +button-medium + &.are-large + .button:not(.is-small):not(.is-normal):not(.is-medium) + +button-large + &.has-addons + .button + &:not(:first-child) + border-bottom-left-radius: 0 + border-top-left-radius: 0 + &:not(:last-child) + border-bottom-right-radius: 0 + border-top-right-radius: 0 + +ltr-property("margin", -1px) + &:last-child + +ltr-property("margin", 0) + &:hover, + &.is-hovered + z-index: 2 + &:focus, + &.is-focused, + &:active, + &.is-active, + &.is-selected + z-index: 3 + &:hover + z-index: 4 + &.is-expanded + flex-grow: 1 + flex-shrink: 1 + &.is-centered + justify-content: center + &:not(.has-addons) + .button:not(.is-fullwidth) + margin-left: 0.25rem + margin-right: 0.25rem + &.is-right + justify-content: flex-end + &:not(.has-addons) + .button:not(.is-fullwidth) + margin-left: 0.25rem + margin-right: 0.25rem diff --git a/shared/static/src/bulma/sass/elements/container.sass b/shared/static/src/bulma/sass/elements/container.sass new file mode 100644 index 00000000..d88eb94a --- /dev/null +++ b/shared/static/src/bulma/sass/elements/container.sass @@ -0,0 +1,24 @@ +$container-offset: (2 * $gap) !default + +.container + flex-grow: 1 + margin: 0 auto + position: relative + width: auto + &.is-fluid + max-width: none + padding-left: $gap + padding-right: $gap + width: 100% + +desktop + max-width: $desktop - $container-offset + +until-widescreen + &.is-widescreen + max-width: $widescreen - $container-offset + +until-fullhd + &.is-fullhd + max-width: $fullhd - $container-offset + +widescreen + max-width: $widescreen - $container-offset + +fullhd + max-width: $fullhd - $container-offset diff --git a/shared/static/src/bulma/sass/elements/content.sass b/shared/static/src/bulma/sass/elements/content.sass new file mode 100644 index 00000000..800268b7 --- /dev/null +++ b/shared/static/src/bulma/sass/elements/content.sass @@ -0,0 +1,155 @@ +$content-heading-color: $text-strong !default +$content-heading-weight: $weight-semibold !default +$content-heading-line-height: 1.125 !default + +$content-blockquote-background-color: $background !default +$content-blockquote-border-left: 5px solid $border !default +$content-blockquote-padding: 1.25em 1.5em !default + +$content-pre-padding: 1.25em 1.5em !default + +$content-table-cell-border: 1px solid $border !default +$content-table-cell-border-width: 0 0 1px !default +$content-table-cell-padding: 0.5em 0.75em !default +$content-table-cell-heading-color: $text-strong !default +$content-table-head-cell-border-width: 0 0 2px !default +$content-table-head-cell-color: $text-strong !default +$content-table-foot-cell-border-width: 2px 0 0 !default +$content-table-foot-cell-color: $text-strong !default + +.content + @extend %block + // Inline + li + li + margin-top: 0.25em + // Block + p, + dl, + ol, + ul, + blockquote, + pre, + table + &:not(:last-child) + margin-bottom: 1em + h1, + h2, + h3, + h4, + h5, + h6 + color: $content-heading-color + font-weight: $content-heading-weight + line-height: $content-heading-line-height + h1 + font-size: 2em + margin-bottom: 0.5em + &:not(:first-child) + margin-top: 1em + h2 + font-size: 1.75em + margin-bottom: 0.5714em + &:not(:first-child) + margin-top: 1.1428em + h3 + font-size: 1.5em + margin-bottom: 0.6666em + &:not(:first-child) + margin-top: 1.3333em + h4 + font-size: 1.25em + margin-bottom: 0.8em + h5 + font-size: 1.125em + margin-bottom: 0.8888em + h6 + font-size: 1em + margin-bottom: 1em + blockquote + background-color: $content-blockquote-background-color + +ltr-property("border", $content-blockquote-border-left, false) + padding: $content-blockquote-padding + ol + list-style-position: outside + +ltr-property("margin", 2em, false) + margin-top: 1em + &:not([type]) + list-style-type: decimal + &.is-lower-alpha + list-style-type: lower-alpha + &.is-lower-roman + list-style-type: lower-roman + &.is-upper-alpha + list-style-type: upper-alpha + &.is-upper-roman + list-style-type: upper-roman + ul + list-style: disc outside + +ltr-property("margin", 2em, false) + margin-top: 1em + ul + list-style-type: circle + margin-top: 0.5em + ul + list-style-type: square + dd + +ltr-property("margin", 2em, false) + figure + margin-left: 2em + margin-right: 2em + text-align: center + &:not(:first-child) + margin-top: 2em + &:not(:last-child) + margin-bottom: 2em + img + display: inline-block + figcaption + font-style: italic + pre + +overflow-touch + overflow-x: auto + padding: $content-pre-padding + white-space: pre + word-wrap: normal + sup, + sub + font-size: 75% + table + width: 100% + td, + th + border: $content-table-cell-border + border-width: $content-table-cell-border-width + padding: $content-table-cell-padding + vertical-align: top + th + color: $content-table-cell-heading-color + &:not([align]) + text-align: inherit + thead + td, + th + border-width: $content-table-head-cell-border-width + color: $content-table-head-cell-color + tfoot + td, + th + border-width: $content-table-foot-cell-border-width + color: $content-table-foot-cell-color + tbody + tr + &:last-child + td, + th + border-bottom-width: 0 + .tabs + li + li + margin-top: 0 + // Sizes + &.is-small + font-size: $size-small + &.is-medium + font-size: $size-medium + &.is-large + font-size: $size-large diff --git a/shared/static/src/bulma/sass/elements/form.sass b/shared/static/src/bulma/sass/elements/form.sass new file mode 100644 index 00000000..3122dc4c --- /dev/null +++ b/shared/static/src/bulma/sass/elements/form.sass @@ -0,0 +1 @@ +@warn "The form.sass file is DEPRECATED. It has moved into its own /form folder. Please import sass/form/_all instead." diff --git a/shared/static/src/bulma/sass/elements/icon.sass b/shared/static/src/bulma/sass/elements/icon.sass new file mode 100644 index 00000000..988546c7 --- /dev/null +++ b/shared/static/src/bulma/sass/elements/icon.sass @@ -0,0 +1,21 @@ +$icon-dimensions: 1.5rem !default +$icon-dimensions-small: 1rem !default +$icon-dimensions-medium: 2rem !default +$icon-dimensions-large: 3rem !default + +.icon + align-items: center + display: inline-flex + justify-content: center + height: $icon-dimensions + width: $icon-dimensions + // Sizes + &.is-small + height: $icon-dimensions-small + width: $icon-dimensions-small + &.is-medium + height: $icon-dimensions-medium + width: $icon-dimensions-medium + &.is-large + height: $icon-dimensions-large + width: $icon-dimensions-large diff --git a/shared/static/src/bulma/sass/elements/image.sass b/shared/static/src/bulma/sass/elements/image.sass new file mode 100644 index 00000000..7547abcf --- /dev/null +++ b/shared/static/src/bulma/sass/elements/image.sass @@ -0,0 +1,71 @@ +$dimensions: 16 24 32 48 64 96 128 !default + +.image + display: block + position: relative + img + display: block + height: auto + width: 100% + &.is-rounded + border-radius: $radius-rounded + &.is-fullwidth + width: 100% + // Ratio + &.is-square, + &.is-1by1, + &.is-5by4, + &.is-4by3, + &.is-3by2, + &.is-5by3, + &.is-16by9, + &.is-2by1, + &.is-3by1, + &.is-4by5, + &.is-3by4, + &.is-2by3, + &.is-3by5, + &.is-9by16, + &.is-1by2, + &.is-1by3 + img, + .has-ratio + @extend %overlay + height: 100% + width: 100% + &.is-square, + &.is-1by1 + padding-top: 100% + &.is-5by4 + padding-top: 80% + &.is-4by3 + padding-top: 75% + &.is-3by2 + padding-top: 66.6666% + &.is-5by3 + padding-top: 60% + &.is-16by9 + padding-top: 56.25% + &.is-2by1 + padding-top: 50% + &.is-3by1 + padding-top: 33.3333% + &.is-4by5 + padding-top: 125% + &.is-3by4 + padding-top: 133.3333% + &.is-2by3 + padding-top: 150% + &.is-3by5 + padding-top: 166.6666% + &.is-9by16 + padding-top: 177.7777% + &.is-1by2 + padding-top: 200% + &.is-1by3 + padding-top: 300% + // Sizes + @each $dimension in $dimensions + &.is-#{$dimension}x#{$dimension} + height: $dimension * 1px + width: $dimension * 1px diff --git a/shared/static/src/bulma/sass/elements/notification.sass b/shared/static/src/bulma/sass/elements/notification.sass new file mode 100644 index 00000000..af1c7be5 --- /dev/null +++ b/shared/static/src/bulma/sass/elements/notification.sass @@ -0,0 +1,48 @@ +$notification-background-color: $background !default +$notification-code-background-color: $scheme-main !default +$notification-radius: $radius !default +$notification-padding: 1.25rem 2.5rem 1.25rem 1.5rem !default +$notification-padding-ltr: 1.25rem 2.5rem 1.25rem 1.5rem !default +$notification-padding-rtl: 1.25rem 1.5rem 1.25rem 2.5rem !default + +.notification + @extend %block + background-color: $notification-background-color + border-radius: $notification-radius + position: relative + +ltr + padding: $notification-padding-ltr + +rtl + padding: $notification-padding-rtl + a:not(.button):not(.dropdown-item) + color: currentColor + text-decoration: underline + strong + color: currentColor + code, + pre + background: $notification-code-background-color + pre code + background: transparent + & > .delete + +ltr-position(0.5rem) + position: absolute + top: 0.5rem + .title, + .subtitle, + .content + color: currentColor + // Colors + @each $name, $pair in $colors + $color: nth($pair, 1) + $color-invert: nth($pair, 2) + &.is-#{$name} + background-color: $color + color: $color-invert + // If light and dark colors are provided + @if length($pair) >= 4 + $color-light: nth($pair, 3) + $color-dark: nth($pair, 4) + &.is-light + background-color: $color-light + color: $color-dark diff --git a/shared/static/src/bulma/sass/elements/other.sass b/shared/static/src/bulma/sass/elements/other.sass new file mode 100644 index 00000000..5725617c --- /dev/null +++ b/shared/static/src/bulma/sass/elements/other.sass @@ -0,0 +1,39 @@ +.block + @extend %block + +.delete + @extend %delete + +.heading + display: block + font-size: 11px + letter-spacing: 1px + margin-bottom: 5px + text-transform: uppercase + +.highlight + @extend %block + font-weight: $weight-normal + max-width: 100% + overflow: hidden + padding: 0 + pre + overflow: auto + max-width: 100% + +.loader + @extend %loader + +.number + align-items: center + background-color: $background + border-radius: $radius-rounded + display: inline-flex + font-size: $size-medium + height: 2em + justify-content: center + margin-right: 1.5rem + min-width: 2.5em + padding: 0.25rem 0.5rem + text-align: center + vertical-align: top diff --git a/shared/static/src/bulma/sass/elements/progress.sass b/shared/static/src/bulma/sass/elements/progress.sass new file mode 100644 index 00000000..bb43bb60 --- /dev/null +++ b/shared/static/src/bulma/sass/elements/progress.sass @@ -0,0 +1,67 @@ +$progress-bar-background-color: $border-light !default +$progress-value-background-color: $text !default +$progress-border-radius: $radius-rounded !default + +$progress-indeterminate-duration: 1.5s !default + +.progress + @extend %block + -moz-appearance: none + -webkit-appearance: none + border: none + border-radius: $progress-border-radius + display: block + height: $size-normal + overflow: hidden + padding: 0 + width: 100% + &::-webkit-progress-bar + background-color: $progress-bar-background-color + &::-webkit-progress-value + background-color: $progress-value-background-color + &::-moz-progress-bar + background-color: $progress-value-background-color + &::-ms-fill + background-color: $progress-value-background-color + border: none + // Colors + @each $name, $pair in $colors + $color: nth($pair, 1) + &.is-#{$name} + &::-webkit-progress-value + background-color: $color + &::-moz-progress-bar + background-color: $color + &::-ms-fill + background-color: $color + &:indeterminate + background-image: linear-gradient(to right, $color 30%, $progress-bar-background-color 30%) + + &:indeterminate + animation-duration: $progress-indeterminate-duration + animation-iteration-count: infinite + animation-name: moveIndeterminate + animation-timing-function: linear + background-color: $progress-bar-background-color + background-image: linear-gradient(to right, $text 30%, $progress-bar-background-color 30%) + background-position: top left + background-repeat: no-repeat + background-size: 150% 150% + &::-webkit-progress-bar + background-color: transparent + &::-moz-progress-bar + background-color: transparent + + // Sizes + &.is-small + height: $size-small + &.is-medium + height: $size-medium + &.is-large + height: $size-large + +@keyframes moveIndeterminate + from + background-position: 200% 0 + to + background-position: -200% 0 diff --git a/shared/static/src/bulma/sass/elements/table.sass b/shared/static/src/bulma/sass/elements/table.sass new file mode 100644 index 00000000..48d7d93e --- /dev/null +++ b/shared/static/src/bulma/sass/elements/table.sass @@ -0,0 +1,129 @@ +$table-color: $text-strong !default +$table-background-color: $scheme-main !default + +$table-cell-border: 1px solid $border !default +$table-cell-border-width: 0 0 1px !default +$table-cell-padding: 0.5em 0.75em !default +$table-cell-heading-color: $text-strong !default + +$table-head-cell-border-width: 0 0 2px !default +$table-head-cell-color: $text-strong !default +$table-foot-cell-border-width: 2px 0 0 !default +$table-foot-cell-color: $text-strong !default + +$table-head-background-color: transparent !default +$table-body-background-color: transparent !default +$table-foot-background-color: transparent !default + +$table-row-hover-background-color: $scheme-main-bis !default + +$table-row-active-background-color: $primary !default +$table-row-active-color: $primary-invert !default + +$table-striped-row-even-background-color: $scheme-main-bis !default +$table-striped-row-even-hover-background-color: $scheme-main-ter !default + +.table + @extend %block + background-color: $table-background-color + color: $table-color + td, + th + border: $table-cell-border + border-width: $table-cell-border-width + padding: $table-cell-padding + vertical-align: top + // Colors + @each $name, $pair in $colors + $color: nth($pair, 1) + $color-invert: nth($pair, 2) + &.is-#{$name} + background-color: $color + border-color: $color + color: $color-invert + // Modifiers + &.is-narrow + white-space: nowrap + width: 1% + &.is-selected + background-color: $table-row-active-background-color + color: $table-row-active-color + a, + strong + color: currentColor + &.is-vcentered + vertical-align: middle + th + color: $table-cell-heading-color + &:not([align]) + text-align: inherit + tr + &.is-selected + background-color: $table-row-active-background-color + color: $table-row-active-color + a, + strong + color: currentColor + td, + th + border-color: $table-row-active-color + color: currentColor + thead + background-color: $table-head-background-color + td, + th + border-width: $table-head-cell-border-width + color: $table-head-cell-color + tfoot + background-color: $table-foot-background-color + td, + th + border-width: $table-foot-cell-border-width + color: $table-foot-cell-color + tbody + background-color: $table-body-background-color + tr + &:last-child + td, + th + border-bottom-width: 0 + // Modifiers + &.is-bordered + td, + th + border-width: 1px + tr + &:last-child + td, + th + border-bottom-width: 1px + &.is-fullwidth + width: 100% + &.is-hoverable + tbody + tr:not(.is-selected) + &:hover + background-color: $table-row-hover-background-color + &.is-striped + tbody + tr:not(.is-selected) + &:hover + background-color: $table-row-hover-background-color + &:nth-child(even) + background-color: $table-striped-row-even-hover-background-color + &.is-narrow + td, + th + padding: 0.25em 0.5em + &.is-striped + tbody + tr:not(.is-selected) + &:nth-child(even) + background-color: $table-striped-row-even-background-color + +.table-container + @extend %block + +overflow-touch + overflow: auto + overflow-y: hidden + max-width: 100% diff --git a/shared/static/src/bulma/sass/elements/tag.sass b/shared/static/src/bulma/sass/elements/tag.sass new file mode 100644 index 00000000..f3c20a21 --- /dev/null +++ b/shared/static/src/bulma/sass/elements/tag.sass @@ -0,0 +1,136 @@ +$tag-background-color: $background !default +$tag-color: $text !default +$tag-radius: $radius !default +$tag-delete-margin: 1px !default + +.tags + align-items: center + display: flex + flex-wrap: wrap + justify-content: flex-start + .tag + margin-bottom: 0.5rem + &:not(:last-child) + +ltr-property("margin", 0.5rem) + &:last-child + margin-bottom: -0.5rem + &:not(:last-child) + margin-bottom: 1rem + // Sizes + &.are-medium + .tag:not(.is-normal):not(.is-large) + font-size: $size-normal + &.are-large + .tag:not(.is-normal):not(.is-medium) + font-size: $size-medium + &.is-centered + justify-content: center + .tag + margin-right: 0.25rem + margin-left: 0.25rem + &.is-right + justify-content: flex-end + .tag + &:not(:first-child) + margin-left: 0.5rem + &:not(:last-child) + margin-right: 0 + &.has-addons + .tag + +ltr-property("margin", 0) + &:not(:first-child) + +ltr-property("margin", 0, false) + +ltr + border-top-left-radius: 0 + border-bottom-left-radius: 0 + +rtl + border-top-right-radius: 0 + border-bottom-right-radius: 0 + &:not(:last-child) + +ltr + border-top-right-radius: 0 + border-bottom-right-radius: 0 + +rtl + border-top-left-radius: 0 + border-bottom-left-radius: 0 + +.tag:not(body) + align-items: center + background-color: $tag-background-color + border-radius: $tag-radius + color: $tag-color + display: inline-flex + font-size: $size-small + height: 2em + justify-content: center + line-height: 1.5 + padding-left: 0.75em + padding-right: 0.75em + white-space: nowrap + .delete + +ltr-property("margin", 0.25rem, false) + +ltr-property("margin", -0.375rem) + // Colors + @each $name, $pair in $colors + $color: nth($pair, 1) + $color-invert: nth($pair, 2) + &.is-#{$name} + background-color: $color + color: $color-invert + // If a light and dark colors are provided + @if length($pair) > 3 + $color-light: nth($pair, 3) + $color-dark: nth($pair, 4) + &.is-light + background-color: $color-light + color: $color-dark + // Sizes + &.is-normal + font-size: $size-small + &.is-medium + font-size: $size-normal + &.is-large + font-size: $size-medium + .icon + &:first-child:not(:last-child) + +ltr-property("margin", -0.375em, false) + +ltr-property("margin", 0.1875em) + &:last-child:not(:first-child) + +ltr-property("margin", 0.1875em, false) + +ltr-property("margin", -0.375em) + &:first-child:last-child + +ltr-property("margin", -0.375em, false) + +ltr-property("margin", -0.375em) + // Modifiers + &.is-delete + +ltr-property("margin", $tag-delete-margin, false) + padding: 0 + position: relative + width: 2em + &::before, + &::after + background-color: currentColor + content: "" + display: block + left: 50% + position: absolute + top: 50% + transform: translateX(-50%) translateY(-50%) rotate(45deg) + transform-origin: center center + &::before + height: 1px + width: 50% + &::after + height: 50% + width: 1px + &:hover, + &:focus + background-color: darken($tag-background-color, 5%) + &:active + background-color: darken($tag-background-color, 10%) + &.is-rounded + border-radius: $radius-rounded + +a.tag + &:hover + text-decoration: underline diff --git a/shared/static/src/bulma/sass/elements/title.sass b/shared/static/src/bulma/sass/elements/title.sass new file mode 100644 index 00000000..fa9947dd --- /dev/null +++ b/shared/static/src/bulma/sass/elements/title.sass @@ -0,0 +1,70 @@ +$title-color: $text-strong !default +$title-family: false !default +$title-size: $size-3 !default +$title-weight: $weight-semibold !default +$title-line-height: 1.125 !default +$title-strong-color: inherit !default +$title-strong-weight: inherit !default +$title-sub-size: 0.75em !default +$title-sup-size: 0.75em !default + +$subtitle-color: $text !default +$subtitle-family: false !default +$subtitle-size: $size-5 !default +$subtitle-weight: $weight-normal !default +$subtitle-line-height: 1.25 !default +$subtitle-strong-color: $text-strong !default +$subtitle-strong-weight: $weight-semibold !default +$subtitle-negative-margin: -1.25rem !default + +.title, +.subtitle + @extend %block + word-break: break-word + em, + span + font-weight: inherit + sub + font-size: $title-sub-size + sup + font-size: $title-sup-size + .tag + vertical-align: middle + +.title + color: $title-color + @if $title-family + font-family: $title-family + font-size: $title-size + font-weight: $title-weight + line-height: $title-line-height + strong + color: $title-strong-color + font-weight: $title-strong-weight + & + .highlight + margin-top: -0.75rem + &:not(.is-spaced) + .subtitle + margin-top: $subtitle-negative-margin + // Sizes + @each $size in $sizes + $i: index($sizes, $size) + &.is-#{$i} + font-size: $size + +.subtitle + color: $subtitle-color + @if $subtitle-family + font-family: $subtitle-family + font-size: $subtitle-size + font-weight: $subtitle-weight + line-height: $subtitle-line-height + strong + color: $subtitle-strong-color + font-weight: $subtitle-strong-weight + &:not(.is-spaced) + .title + margin-top: $subtitle-negative-margin + // Sizes + @each $size in $sizes + $i: index($sizes, $size) + &.is-#{$i} + font-size: $size diff --git a/shared/static/src/bulma/sass/form/_all.sass b/shared/static/src/bulma/sass/form/_all.sass new file mode 100644 index 00000000..d9a2b955 --- /dev/null +++ b/shared/static/src/bulma/sass/form/_all.sass @@ -0,0 +1,8 @@ +@charset "utf-8" + +@import "shared.sass" +@import "input-textarea.sass" +@import "checkbox-radio.sass" +@import "select.sass" +@import "file.sass" +@import "tools.sass" diff --git a/shared/static/src/bulma/sass/form/checkbox-radio.sass b/shared/static/src/bulma/sass/form/checkbox-radio.sass new file mode 100644 index 00000000..96486673 --- /dev/null +++ b/shared/static/src/bulma/sass/form/checkbox-radio.sass @@ -0,0 +1,21 @@ +%checkbox-radio + cursor: pointer + display: inline-block + line-height: 1.25 + position: relative + input + cursor: pointer + &:hover + color: $input-hover-color + &[disabled], + fieldset[disabled] & + color: $input-disabled-color + cursor: not-allowed + +.checkbox + @extend %checkbox-radio + +.radio + @extend %checkbox-radio + & + .radio + +ltr-property("margin", 0.5em, false) diff --git a/shared/static/src/bulma/sass/form/file.sass b/shared/static/src/bulma/sass/form/file.sass new file mode 100644 index 00000000..5fe0eee2 --- /dev/null +++ b/shared/static/src/bulma/sass/form/file.sass @@ -0,0 +1,180 @@ +$file-border-color: $border !default +$file-radius: $radius !default + +$file-cta-background-color: $scheme-main-ter !default +$file-cta-color: $text !default +$file-cta-hover-color: $text-strong !default +$file-cta-active-color: $text-strong !default + +$file-name-border-color: $border !default +$file-name-border-style: solid !default +$file-name-border-width: 1px 1px 1px 0 !default +$file-name-max-width: 16em !default + +.file + @extend %unselectable + align-items: stretch + display: flex + justify-content: flex-start + position: relative + // Colors + @each $name, $pair in $colors + $color: nth($pair, 1) + $color-invert: nth($pair, 2) + &.is-#{$name} + .file-cta + background-color: $color + border-color: transparent + color: $color-invert + &:hover, + &.is-hovered + .file-cta + background-color: bulmaDarken($color, 2.5%) + border-color: transparent + color: $color-invert + &:focus, + &.is-focused + .file-cta + border-color: transparent + box-shadow: 0 0 0.5em bulmaRgba($color, 0.25) + color: $color-invert + &:active, + &.is-active + .file-cta + background-color: bulmaDarken($color, 5%) + border-color: transparent + color: $color-invert + // Sizes + &.is-small + font-size: $size-small + &.is-medium + font-size: $size-medium + .file-icon + .fa + font-size: 21px + &.is-large + font-size: $size-large + .file-icon + .fa + font-size: 28px + // Modifiers + &.has-name + .file-cta + border-bottom-right-radius: 0 + border-top-right-radius: 0 + .file-name + border-bottom-left-radius: 0 + border-top-left-radius: 0 + &.is-empty + .file-cta + border-radius: $file-radius + .file-name + display: none + &.is-boxed + .file-label + flex-direction: column + .file-cta + flex-direction: column + height: auto + padding: 1em 3em + .file-name + border-width: 0 1px 1px + .file-icon + height: 1.5em + width: 1.5em + .fa + font-size: 21px + &.is-small + .file-icon .fa + font-size: 14px + &.is-medium + .file-icon .fa + font-size: 28px + &.is-large + .file-icon .fa + font-size: 35px + &.has-name + .file-cta + border-radius: $file-radius $file-radius 0 0 + .file-name + border-radius: 0 0 $file-radius $file-radius + border-width: 0 1px 1px + &.is-centered + justify-content: center + &.is-fullwidth + .file-label + width: 100% + .file-name + flex-grow: 1 + max-width: none + &.is-right + justify-content: flex-end + .file-cta + border-radius: 0 $file-radius $file-radius 0 + .file-name + border-radius: $file-radius 0 0 $file-radius + border-width: 1px 0 1px 1px + order: -1 + +.file-label + align-items: stretch + display: flex + cursor: pointer + justify-content: flex-start + overflow: hidden + position: relative + &:hover + .file-cta + background-color: bulmaDarken($file-cta-background-color, 2.5%) + color: $file-cta-hover-color + .file-name + border-color: bulmaDarken($file-name-border-color, 2.5%) + &:active + .file-cta + background-color: bulmaDarken($file-cta-background-color, 5%) + color: $file-cta-active-color + .file-name + border-color: bulmaDarken($file-name-border-color, 5%) + +.file-input + height: 100% + left: 0 + opacity: 0 + outline: none + position: absolute + top: 0 + width: 100% + +.file-cta, +.file-name + @extend %control + border-color: $file-border-color + border-radius: $file-radius + font-size: 1em + padding-left: 1em + padding-right: 1em + white-space: nowrap + +.file-cta + background-color: $file-cta-background-color + color: $file-cta-color + +.file-name + border-color: $file-name-border-color + border-style: $file-name-border-style + border-width: $file-name-border-width + display: block + max-width: $file-name-max-width + overflow: hidden + text-align: inherit + text-overflow: ellipsis + +.file-icon + align-items: center + display: flex + height: 1em + justify-content: center + +ltr-property("margin", 0.5em) + width: 1em + .fa + font-size: 14px diff --git a/shared/static/src/bulma/sass/form/input-textarea.sass b/shared/static/src/bulma/sass/form/input-textarea.sass new file mode 100644 index 00000000..a5aef556 --- /dev/null +++ b/shared/static/src/bulma/sass/form/input-textarea.sass @@ -0,0 +1,64 @@ +$textarea-padding: $control-padding-horizontal !default +$textarea-max-height: 40em !default +$textarea-min-height: 8em !default + +%input-textarea + @extend %input + box-shadow: $input-shadow + max-width: 100% + width: 100% + &[readonly] + box-shadow: none + // Colors + @each $name, $pair in $colors + $color: nth($pair, 1) + &.is-#{$name} + border-color: $color + &:focus, + &.is-focused, + &:active, + &.is-active + box-shadow: $input-focus-box-shadow-size bulmaRgba($color, 0.25) + // Sizes + &.is-small + +control-small + &.is-medium + +control-medium + &.is-large + +control-large + // Modifiers + &.is-fullwidth + display: block + width: 100% + &.is-inline + display: inline + width: auto + +.input + @extend %input-textarea + &.is-rounded + border-radius: $radius-rounded + padding-left: calc(#{$control-padding-horizontal} + 0.375em) + padding-right: calc(#{$control-padding-horizontal} + 0.375em) + &.is-static + background-color: transparent + border-color: transparent + box-shadow: none + padding-left: 0 + padding-right: 0 + +.textarea + @extend %input-textarea + display: block + max-width: 100% + min-width: 100% + padding: $textarea-padding + resize: vertical + &:not([rows]) + max-height: $textarea-max-height + min-height: $textarea-min-height + &[rows] + height: initial + // Modifiers + &.has-fixed-size + resize: none diff --git a/shared/static/src/bulma/sass/form/select.sass b/shared/static/src/bulma/sass/form/select.sass new file mode 100644 index 00000000..21d62d0b --- /dev/null +++ b/shared/static/src/bulma/sass/form/select.sass @@ -0,0 +1,85 @@ +.select + display: inline-block + max-width: 100% + position: relative + vertical-align: top + &:not(.is-multiple) + height: $input-height + &:not(.is-multiple):not(.is-loading) + &::after + @extend %arrow + border-color: $input-arrow + +ltr-position(1.125em) + z-index: 4 + &.is-rounded + select + border-radius: $radius-rounded + +ltr-property("padding", 1em, false) + select + @extend %input + cursor: pointer + display: block + font-size: 1em + max-width: 100% + outline: none + &::-ms-expand + display: none + &[disabled]:hover, + fieldset[disabled] &:hover + border-color: $input-disabled-border-color + &:not([multiple]) + +ltr-property("padding", 2.5em) + &[multiple] + height: auto + padding: 0 + option + padding: 0.5em 1em + // States + &:not(.is-multiple):not(.is-loading):hover + &::after + border-color: $input-hover-color + // Colors + @each $name, $pair in $colors + $color: nth($pair, 1) + &.is-#{$name} + &:not(:hover)::after + border-color: $color + select + border-color: $color + &:hover, + &.is-hovered + border-color: bulmaDarken($color, 5%) + &:focus, + &.is-focused, + &:active, + &.is-active + box-shadow: $input-focus-box-shadow-size bulmaRgba($color, 0.25) + // Sizes + &.is-small + +control-small + &.is-medium + +control-medium + &.is-large + +control-large + // Modifiers + &.is-disabled + &::after + border-color: $input-disabled-color + &.is-fullwidth + width: 100% + select + width: 100% + &.is-loading + &::after + @extend %loader + margin-top: 0 + position: absolute + +ltr-position(0.625em) + top: 0.625em + transform: none + &.is-small:after + font-size: $size-small + &.is-medium:after + font-size: $size-medium + &.is-large:after + font-size: $size-large diff --git a/shared/static/src/bulma/sass/form/shared.sass b/shared/static/src/bulma/sass/form/shared.sass new file mode 100644 index 00000000..230a00cb --- /dev/null +++ b/shared/static/src/bulma/sass/form/shared.sass @@ -0,0 +1,55 @@ +$input-color: $text-strong !default +$input-background-color: $scheme-main !default +$input-border-color: $border !default +$input-height: $control-height !default +$input-shadow: inset 0 0.0625em 0.125em rgba($scheme-invert, 0.05) !default +$input-placeholder-color: bulmaRgba($input-color, 0.3) !default + +$input-hover-color: $text-strong !default +$input-hover-border-color: $border-hover !default + +$input-focus-color: $text-strong !default +$input-focus-border-color: $link !default +$input-focus-box-shadow-size: 0 0 0 0.125em !default +$input-focus-box-shadow-color: bulmaRgba($link, 0.25) !default + +$input-disabled-color: $text-light !default +$input-disabled-background-color: $background !default +$input-disabled-border-color: $background !default +$input-disabled-placeholder-color: bulmaRgba($input-disabled-color, 0.3) !default + +$input-arrow: $link !default + +$input-icon-color: $border !default +$input-icon-active-color: $text !default + +$input-radius: $radius !default + +=input + @extend %control + background-color: $input-background-color + border-color: $input-border-color + border-radius: $input-radius + color: $input-color + +placeholder + color: $input-placeholder-color + &:hover, + &.is-hovered + border-color: $input-hover-border-color + &:focus, + &.is-focused, + &:active, + &.is-active + border-color: $input-focus-border-color + box-shadow: $input-focus-box-shadow-size $input-focus-box-shadow-color + &[disabled], + fieldset[disabled] & + background-color: $input-disabled-background-color + border-color: $input-disabled-border-color + box-shadow: none + color: $input-disabled-color + +placeholder + color: $input-disabled-placeholder-color + +%input + +input diff --git a/shared/static/src/bulma/sass/form/tools.sass b/shared/static/src/bulma/sass/form/tools.sass new file mode 100644 index 00000000..d97427c4 --- /dev/null +++ b/shared/static/src/bulma/sass/form/tools.sass @@ -0,0 +1,213 @@ +$label-color: $text-strong !default +$label-weight: $weight-bold !default + +$help-size: $size-small !default + +.label + color: $label-color + display: block + font-size: $size-normal + font-weight: $label-weight + &:not(:last-child) + margin-bottom: 0.5em + // Sizes + &.is-small + font-size: $size-small + &.is-medium + font-size: $size-medium + &.is-large + font-size: $size-large + +.help + display: block + font-size: $help-size + margin-top: 0.25rem + @each $name, $pair in $colors + $color: nth($pair, 1) + &.is-#{$name} + color: $color + +// Containers + +.field + &:not(:last-child) + margin-bottom: 0.75rem + // Modifiers + &.has-addons + display: flex + justify-content: flex-start + .control + &:not(:last-child) + +ltr-property("margin", -1px) + &:not(:first-child):not(:last-child) + .button, + .input, + .select select + border-radius: 0 + &:first-child:not(:only-child) + .button, + .input, + .select select + +ltr + border-bottom-right-radius: 0 + border-top-right-radius: 0 + +rtl + border-bottom-left-radius: 0 + border-top-left-radius: 0 + &:last-child:not(:only-child) + .button, + .input, + .select select + +ltr + border-bottom-left-radius: 0 + border-top-left-radius: 0 + +rtl + border-bottom-right-radius: 0 + border-top-right-radius: 0 + .button, + .input, + .select select + &:not([disabled]) + &:hover, + &.is-hovered + z-index: 2 + &:focus, + &.is-focused, + &:active, + &.is-active + z-index: 3 + &:hover + z-index: 4 + &.is-expanded + flex-grow: 1 + flex-shrink: 1 + &.has-addons-centered + justify-content: center + &.has-addons-right + justify-content: flex-end + &.has-addons-fullwidth + .control + flex-grow: 1 + flex-shrink: 0 + &.is-grouped + display: flex + justify-content: flex-start + & > .control + flex-shrink: 0 + &:not(:last-child) + margin-bottom: 0 + +ltr-property("margin", 0.75rem) + &.is-expanded + flex-grow: 1 + flex-shrink: 1 + &.is-grouped-centered + justify-content: center + &.is-grouped-right + justify-content: flex-end + &.is-grouped-multiline + flex-wrap: wrap + & > .control + &:last-child, + &:not(:last-child) + margin-bottom: 0.75rem + &:last-child + margin-bottom: -0.75rem + &:not(:last-child) + margin-bottom: 0 + &.is-horizontal + +tablet + display: flex + +.field-label + .label + font-size: inherit + +mobile + margin-bottom: 0.5rem + +tablet + flex-basis: 0 + flex-grow: 1 + flex-shrink: 0 + +ltr-property("margin", 1.5rem) + text-align: right + &.is-small + font-size: $size-small + padding-top: 0.375em + &.is-normal + padding-top: 0.375em + &.is-medium + font-size: $size-medium + padding-top: 0.375em + &.is-large + font-size: $size-large + padding-top: 0.375em + +.field-body + .field .field + margin-bottom: 0 + +tablet + display: flex + flex-basis: 0 + flex-grow: 5 + flex-shrink: 1 + .field + margin-bottom: 0 + & > .field + flex-shrink: 1 + &:not(.is-narrow) + flex-grow: 1 + &:not(:last-child) + +ltr-property("margin", 0.75rem) + +.control + box-sizing: border-box + clear: both + font-size: $size-normal + position: relative + text-align: inherit + // Modifiers + &.has-icons-left, + &.has-icons-right + .input, + .select + &:focus + & ~ .icon + color: $input-icon-active-color + &.is-small ~ .icon + font-size: $size-small + &.is-medium ~ .icon + font-size: $size-medium + &.is-large ~ .icon + font-size: $size-large + .icon + color: $input-icon-color + height: $input-height + pointer-events: none + position: absolute + top: 0 + width: $input-height + z-index: 4 + &.has-icons-left + .input, + .select select + padding-left: $input-height + .icon.is-left + left: 0 + &.has-icons-right + .input, + .select select + padding-right: $input-height + .icon.is-right + right: 0 + &.is-loading + &::after + @extend %loader + position: absolute !important + +ltr-position(0.625em) + top: 0.625em + z-index: 4 + &.is-small:after + font-size: $size-small + &.is-medium:after + font-size: $size-medium + &.is-large:after + font-size: $size-large diff --git a/shared/static/src/bulma/sass/grid/_all.sass b/shared/static/src/bulma/sass/grid/_all.sass new file mode 100644 index 00000000..e53070f6 --- /dev/null +++ b/shared/static/src/bulma/sass/grid/_all.sass @@ -0,0 +1,4 @@ +@charset "utf-8" + +@import "columns.sass" +@import "tiles.sass" diff --git a/shared/static/src/bulma/sass/grid/columns.sass b/shared/static/src/bulma/sass/grid/columns.sass new file mode 100644 index 00000000..34a83533 --- /dev/null +++ b/shared/static/src/bulma/sass/grid/columns.sass @@ -0,0 +1,504 @@ +$column-gap: 0.75rem !default + +.column + display: block + flex-basis: 0 + flex-grow: 1 + flex-shrink: 1 + padding: $column-gap + .columns.is-mobile > &.is-narrow + flex: none + .columns.is-mobile > &.is-full + flex: none + width: 100% + .columns.is-mobile > &.is-three-quarters + flex: none + width: 75% + .columns.is-mobile > &.is-two-thirds + flex: none + width: 66.6666% + .columns.is-mobile > &.is-half + flex: none + width: 50% + .columns.is-mobile > &.is-one-third + flex: none + width: 33.3333% + .columns.is-mobile > &.is-one-quarter + flex: none + width: 25% + .columns.is-mobile > &.is-one-fifth + flex: none + width: 20% + .columns.is-mobile > &.is-two-fifths + flex: none + width: 40% + .columns.is-mobile > &.is-three-fifths + flex: none + width: 60% + .columns.is-mobile > &.is-four-fifths + flex: none + width: 80% + .columns.is-mobile > &.is-offset-three-quarters + margin-left: 75% + .columns.is-mobile > &.is-offset-two-thirds + margin-left: 66.6666% + .columns.is-mobile > &.is-offset-half + margin-left: 50% + .columns.is-mobile > &.is-offset-one-third + margin-left: 33.3333% + .columns.is-mobile > &.is-offset-one-quarter + margin-left: 25% + .columns.is-mobile > &.is-offset-one-fifth + margin-left: 20% + .columns.is-mobile > &.is-offset-two-fifths + margin-left: 40% + .columns.is-mobile > &.is-offset-three-fifths + margin-left: 60% + .columns.is-mobile > &.is-offset-four-fifths + margin-left: 80% + @for $i from 0 through 12 + .columns.is-mobile > &.is-#{$i} + flex: none + width: percentage($i / 12) + .columns.is-mobile > &.is-offset-#{$i} + margin-left: percentage($i / 12) + +mobile + &.is-narrow-mobile + flex: none + &.is-full-mobile + flex: none + width: 100% + &.is-three-quarters-mobile + flex: none + width: 75% + &.is-two-thirds-mobile + flex: none + width: 66.6666% + &.is-half-mobile + flex: none + width: 50% + &.is-one-third-mobile + flex: none + width: 33.3333% + &.is-one-quarter-mobile + flex: none + width: 25% + &.is-one-fifth-mobile + flex: none + width: 20% + &.is-two-fifths-mobile + flex: none + width: 40% + &.is-three-fifths-mobile + flex: none + width: 60% + &.is-four-fifths-mobile + flex: none + width: 80% + &.is-offset-three-quarters-mobile + margin-left: 75% + &.is-offset-two-thirds-mobile + margin-left: 66.6666% + &.is-offset-half-mobile + margin-left: 50% + &.is-offset-one-third-mobile + margin-left: 33.3333% + &.is-offset-one-quarter-mobile + margin-left: 25% + &.is-offset-one-fifth-mobile + margin-left: 20% + &.is-offset-two-fifths-mobile + margin-left: 40% + &.is-offset-three-fifths-mobile + margin-left: 60% + &.is-offset-four-fifths-mobile + margin-left: 80% + @for $i from 0 through 12 + &.is-#{$i}-mobile + flex: none + width: percentage($i / 12) + &.is-offset-#{$i}-mobile + margin-left: percentage($i / 12) + +tablet + &.is-narrow, + &.is-narrow-tablet + flex: none + &.is-full, + &.is-full-tablet + flex: none + width: 100% + &.is-three-quarters, + &.is-three-quarters-tablet + flex: none + width: 75% + &.is-two-thirds, + &.is-two-thirds-tablet + flex: none + width: 66.6666% + &.is-half, + &.is-half-tablet + flex: none + width: 50% + &.is-one-third, + &.is-one-third-tablet + flex: none + width: 33.3333% + &.is-one-quarter, + &.is-one-quarter-tablet + flex: none + width: 25% + &.is-one-fifth, + &.is-one-fifth-tablet + flex: none + width: 20% + &.is-two-fifths, + &.is-two-fifths-tablet + flex: none + width: 40% + &.is-three-fifths, + &.is-three-fifths-tablet + flex: none + width: 60% + &.is-four-fifths, + &.is-four-fifths-tablet + flex: none + width: 80% + &.is-offset-three-quarters, + &.is-offset-three-quarters-tablet + margin-left: 75% + &.is-offset-two-thirds, + &.is-offset-two-thirds-tablet + margin-left: 66.6666% + &.is-offset-half, + &.is-offset-half-tablet + margin-left: 50% + &.is-offset-one-third, + &.is-offset-one-third-tablet + margin-left: 33.3333% + &.is-offset-one-quarter, + &.is-offset-one-quarter-tablet + margin-left: 25% + &.is-offset-one-fifth, + &.is-offset-one-fifth-tablet + margin-left: 20% + &.is-offset-two-fifths, + &.is-offset-two-fifths-tablet + margin-left: 40% + &.is-offset-three-fifths, + &.is-offset-three-fifths-tablet + margin-left: 60% + &.is-offset-four-fifths, + &.is-offset-four-fifths-tablet + margin-left: 80% + @for $i from 0 through 12 + &.is-#{$i}, + &.is-#{$i}-tablet + flex: none + width: percentage($i / 12) + &.is-offset-#{$i}, + &.is-offset-#{$i}-tablet + margin-left: percentage($i / 12) + +touch + &.is-narrow-touch + flex: none + &.is-full-touch + flex: none + width: 100% + &.is-three-quarters-touch + flex: none + width: 75% + &.is-two-thirds-touch + flex: none + width: 66.6666% + &.is-half-touch + flex: none + width: 50% + &.is-one-third-touch + flex: none + width: 33.3333% + &.is-one-quarter-touch + flex: none + width: 25% + &.is-one-fifth-touch + flex: none + width: 20% + &.is-two-fifths-touch + flex: none + width: 40% + &.is-three-fifths-touch + flex: none + width: 60% + &.is-four-fifths-touch + flex: none + width: 80% + &.is-offset-three-quarters-touch + margin-left: 75% + &.is-offset-two-thirds-touch + margin-left: 66.6666% + &.is-offset-half-touch + margin-left: 50% + &.is-offset-one-third-touch + margin-left: 33.3333% + &.is-offset-one-quarter-touch + margin-left: 25% + &.is-offset-one-fifth-touch + margin-left: 20% + &.is-offset-two-fifths-touch + margin-left: 40% + &.is-offset-three-fifths-touch + margin-left: 60% + &.is-offset-four-fifths-touch + margin-left: 80% + @for $i from 0 through 12 + &.is-#{$i}-touch + flex: none + width: percentage($i / 12) + &.is-offset-#{$i}-touch + margin-left: percentage($i / 12) + +desktop + &.is-narrow-desktop + flex: none + &.is-full-desktop + flex: none + width: 100% + &.is-three-quarters-desktop + flex: none + width: 75% + &.is-two-thirds-desktop + flex: none + width: 66.6666% + &.is-half-desktop + flex: none + width: 50% + &.is-one-third-desktop + flex: none + width: 33.3333% + &.is-one-quarter-desktop + flex: none + width: 25% + &.is-one-fifth-desktop + flex: none + width: 20% + &.is-two-fifths-desktop + flex: none + width: 40% + &.is-three-fifths-desktop + flex: none + width: 60% + &.is-four-fifths-desktop + flex: none + width: 80% + &.is-offset-three-quarters-desktop + margin-left: 75% + &.is-offset-two-thirds-desktop + margin-left: 66.6666% + &.is-offset-half-desktop + margin-left: 50% + &.is-offset-one-third-desktop + margin-left: 33.3333% + &.is-offset-one-quarter-desktop + margin-left: 25% + &.is-offset-one-fifth-desktop + margin-left: 20% + &.is-offset-two-fifths-desktop + margin-left: 40% + &.is-offset-three-fifths-desktop + margin-left: 60% + &.is-offset-four-fifths-desktop + margin-left: 80% + @for $i from 0 through 12 + &.is-#{$i}-desktop + flex: none + width: percentage($i / 12) + &.is-offset-#{$i}-desktop + margin-left: percentage($i / 12) + +widescreen + &.is-narrow-widescreen + flex: none + &.is-full-widescreen + flex: none + width: 100% + &.is-three-quarters-widescreen + flex: none + width: 75% + &.is-two-thirds-widescreen + flex: none + width: 66.6666% + &.is-half-widescreen + flex: none + width: 50% + &.is-one-third-widescreen + flex: none + width: 33.3333% + &.is-one-quarter-widescreen + flex: none + width: 25% + &.is-one-fifth-widescreen + flex: none + width: 20% + &.is-two-fifths-widescreen + flex: none + width: 40% + &.is-three-fifths-widescreen + flex: none + width: 60% + &.is-four-fifths-widescreen + flex: none + width: 80% + &.is-offset-three-quarters-widescreen + margin-left: 75% + &.is-offset-two-thirds-widescreen + margin-left: 66.6666% + &.is-offset-half-widescreen + margin-left: 50% + &.is-offset-one-third-widescreen + margin-left: 33.3333% + &.is-offset-one-quarter-widescreen + margin-left: 25% + &.is-offset-one-fifth-widescreen + margin-left: 20% + &.is-offset-two-fifths-widescreen + margin-left: 40% + &.is-offset-three-fifths-widescreen + margin-left: 60% + &.is-offset-four-fifths-widescreen + margin-left: 80% + @for $i from 0 through 12 + &.is-#{$i}-widescreen + flex: none + width: percentage($i / 12) + &.is-offset-#{$i}-widescreen + margin-left: percentage($i / 12) + +fullhd + &.is-narrow-fullhd + flex: none + &.is-full-fullhd + flex: none + width: 100% + &.is-three-quarters-fullhd + flex: none + width: 75% + &.is-two-thirds-fullhd + flex: none + width: 66.6666% + &.is-half-fullhd + flex: none + width: 50% + &.is-one-third-fullhd + flex: none + width: 33.3333% + &.is-one-quarter-fullhd + flex: none + width: 25% + &.is-one-fifth-fullhd + flex: none + width: 20% + &.is-two-fifths-fullhd + flex: none + width: 40% + &.is-three-fifths-fullhd + flex: none + width: 60% + &.is-four-fifths-fullhd + flex: none + width: 80% + &.is-offset-three-quarters-fullhd + margin-left: 75% + &.is-offset-two-thirds-fullhd + margin-left: 66.6666% + &.is-offset-half-fullhd + margin-left: 50% + &.is-offset-one-third-fullhd + margin-left: 33.3333% + &.is-offset-one-quarter-fullhd + margin-left: 25% + &.is-offset-one-fifth-fullhd + margin-left: 20% + &.is-offset-two-fifths-fullhd + margin-left: 40% + &.is-offset-three-fifths-fullhd + margin-left: 60% + &.is-offset-four-fifths-fullhd + margin-left: 80% + @for $i from 0 through 12 + &.is-#{$i}-fullhd + flex: none + width: percentage($i / 12) + &.is-offset-#{$i}-fullhd + margin-left: percentage($i / 12) + +.columns + margin-left: (-$column-gap) + margin-right: (-$column-gap) + margin-top: (-$column-gap) + &:last-child + margin-bottom: (-$column-gap) + &:not(:last-child) + margin-bottom: calc(1.5rem - #{$column-gap}) + // Modifiers + &.is-centered + justify-content: center + &.is-gapless + margin-left: 0 + margin-right: 0 + margin-top: 0 + & > .column + margin: 0 + padding: 0 !important + &:not(:last-child) + margin-bottom: 1.5rem + &:last-child + margin-bottom: 0 + &.is-mobile + display: flex + &.is-multiline + flex-wrap: wrap + &.is-vcentered + align-items: center + // Responsiveness + +tablet + &:not(.is-desktop) + display: flex + +desktop + // Modifiers + &.is-desktop + display: flex + +@if $variable-columns + .columns.is-variable + --columnGap: 0.75rem + margin-left: calc(-1 * var(--columnGap)) + margin-right: calc(-1 * var(--columnGap)) + .column + padding-left: var(--columnGap) + padding-right: var(--columnGap) + @for $i from 0 through 8 + &.is-#{$i} + --columnGap: #{$i * 0.25rem} + +mobile + &.is-#{$i}-mobile + --columnGap: #{$i * 0.25rem} + +tablet + &.is-#{$i}-tablet + --columnGap: #{$i * 0.25rem} + +tablet-only + &.is-#{$i}-tablet-only + --columnGap: #{$i * 0.25rem} + +touch + &.is-#{$i}-touch + --columnGap: #{$i * 0.25rem} + +desktop + &.is-#{$i}-desktop + --columnGap: #{$i * 0.25rem} + +desktop-only + &.is-#{$i}-desktop-only + --columnGap: #{$i * 0.25rem} + +widescreen + &.is-#{$i}-widescreen + --columnGap: #{$i * 0.25rem} + +widescreen-only + &.is-#{$i}-widescreen-only + --columnGap: #{$i * 0.25rem} + +fullhd + &.is-#{$i}-fullhd + --columnGap: #{$i * 0.25rem} diff --git a/shared/static/src/bulma/sass/grid/tiles.sass b/shared/static/src/bulma/sass/grid/tiles.sass new file mode 100644 index 00000000..15648c29 --- /dev/null +++ b/shared/static/src/bulma/sass/grid/tiles.sass @@ -0,0 +1,34 @@ +$tile-spacing: 0.75rem !default + +.tile + align-items: stretch + display: block + flex-basis: 0 + flex-grow: 1 + flex-shrink: 1 + min-height: min-content + // Modifiers + &.is-ancestor + margin-left: $tile-spacing * -1 + margin-right: $tile-spacing * -1 + margin-top: $tile-spacing * -1 + &:last-child + margin-bottom: $tile-spacing * -1 + &:not(:last-child) + margin-bottom: $tile-spacing + &.is-child + margin: 0 !important + &.is-parent + padding: $tile-spacing + &.is-vertical + flex-direction: column + & > .tile.is-child:not(:last-child) + margin-bottom: 1.5rem !important + // Responsiveness + +tablet + &:not(.is-child) + display: flex + @for $i from 1 through 12 + &.is-#{$i} + flex: none + width: ($i / 12) * 100% diff --git a/shared/static/src/bulma/sass/helpers/_all.sass b/shared/static/src/bulma/sass/helpers/_all.sass new file mode 100644 index 00000000..89ef0a7f --- /dev/null +++ b/shared/static/src/bulma/sass/helpers/_all.sass @@ -0,0 +1,10 @@ +@charset "utf-8" + +@import "color.sass" +@import "float.sass" +@import "other.sass" +@import "overflow.sass" +@import "position.sass" +@import "spacing.sass" +@import "typography.sass" +@import "visibility.sass" diff --git a/shared/static/src/bulma/sass/helpers/color.sass b/shared/static/src/bulma/sass/helpers/color.sass new file mode 100644 index 00000000..22ac8c51 --- /dev/null +++ b/shared/static/src/bulma/sass/helpers/color.sass @@ -0,0 +1,37 @@ +@each $name, $pair in $colors + $color: nth($pair, 1) + .has-text-#{$name} + color: $color !important + a.has-text-#{$name} + &:hover, + &:focus + color: bulmaDarken($color, 10%) !important + .has-background-#{$name} + background-color: $color !important + @if length($pair) >= 4 + $color-light: nth($pair, 3) + $color-dark: nth($pair, 4) + // Light + .has-text-#{$name}-light + color: $color-light !important + a.has-text-#{$name}-light + &:hover, + &:focus + color: bulmaDarken($color-light, 10%) !important + .has-background-#{$name}-light + background-color: $color-light !important + // Dark + .has-text-#{$name}-dark + color: $color-dark !important + a.has-text-#{$name}-dark + &:hover, + &:focus + color: bulmaLighten($color-dark, 10%) !important + .has-background-#{$name}-dark + background-color: $color-dark !important + +@each $name, $shade in $shades + .has-text-#{$name} + color: $shade !important + .has-background-#{$name} + background-color: $shade !important diff --git a/shared/static/src/bulma/sass/helpers/float.sass b/shared/static/src/bulma/sass/helpers/float.sass new file mode 100644 index 00000000..fc77f179 --- /dev/null +++ b/shared/static/src/bulma/sass/helpers/float.sass @@ -0,0 +1,8 @@ +.is-clearfix + +clearfix + +.is-pulled-left + float: left !important + +.is-pulled-right + float: right !important diff --git a/shared/static/src/bulma/sass/helpers/other.sass b/shared/static/src/bulma/sass/helpers/other.sass new file mode 100644 index 00000000..9aa271b3 --- /dev/null +++ b/shared/static/src/bulma/sass/helpers/other.sass @@ -0,0 +1,8 @@ +.is-radiusless + border-radius: 0 !important + +.is-shadowless + box-shadow: none !important + +.is-unselectable + @extend %unselectable diff --git a/shared/static/src/bulma/sass/helpers/overflow.sass b/shared/static/src/bulma/sass/helpers/overflow.sass new file mode 100644 index 00000000..ef1e3ef0 --- /dev/null +++ b/shared/static/src/bulma/sass/helpers/overflow.sass @@ -0,0 +1,2 @@ +.is-clipped + overflow: hidden !important diff --git a/shared/static/src/bulma/sass/helpers/position.sass b/shared/static/src/bulma/sass/helpers/position.sass new file mode 100644 index 00000000..083b36b7 --- /dev/null +++ b/shared/static/src/bulma/sass/helpers/position.sass @@ -0,0 +1,5 @@ +.is-overlay + @extend %overlay + +.is-relative + position: relative !important diff --git a/shared/static/src/bulma/sass/helpers/spacing.sass b/shared/static/src/bulma/sass/helpers/spacing.sass new file mode 100644 index 00000000..b7e571e8 --- /dev/null +++ b/shared/static/src/bulma/sass/helpers/spacing.sass @@ -0,0 +1,28 @@ +.is-marginless + margin: 0 !important + +.is-paddingless + padding: 0 !important + +$spacing-shortcuts: ("margin": "m", "padding": "p") !default +$spacing-directions: ("top": "t", "right": "r", "bottom": "b", "left": "l") !default +$spacing-horizontal: "x" !default +$spacing-vertical: "y" !default +$spacing-values: ("0": 0, "1": 0.25rem, "2": 0.5rem, "3": 0.75rem, "4": 1rem, "5": 1.5rem, "6": 3rem) !default + +@each $property, $shortcut in $spacing-shortcuts + @each $name, $value in $spacing-values + // Cardinal directions + @each $direction, $suffix in $spacing-directions + .#{$shortcut}#{$suffix}-#{$name} + #{$property}-#{$direction}: $value !important + // Horizontal axis + @if $spacing-horizontal != null + .#{$shortcut}#{$spacing-horizontal}-#{$name} + #{$property}-left: $value !important + #{$property}-right: $value !important + // Vertical axis + @if $spacing-vertical != null + .#{$shortcut}#{$spacing-vertical}-#{$name} + #{$property}-top: $value !important + #{$property}-bottom: $value !important diff --git a/shared/static/src/bulma/sass/helpers/typography.sass b/shared/static/src/bulma/sass/helpers/typography.sass new file mode 100644 index 00000000..eafd7e09 --- /dev/null +++ b/shared/static/src/bulma/sass/helpers/typography.sass @@ -0,0 +1,98 @@ +=typography-size($target:'') + @each $size in $sizes + $i: index($sizes, $size) + .is-size-#{$i}#{if($target == '', '', '-' + $target)} + font-size: $size !important + ++typography-size() + ++mobile + +typography-size('mobile') + ++tablet + +typography-size('tablet') + ++touch + +typography-size('touch') + ++desktop + +typography-size('desktop') + ++widescreen + +typography-size('widescreen') + ++fullhd + +typography-size('fullhd') + +$alignments: ('centered': 'center', 'justified': 'justify', 'left': 'left', 'right': 'right') + +@each $alignment, $text-align in $alignments + .has-text-#{$alignment} + text-align: #{$text-align} !important + +@each $alignment, $text-align in $alignments + +mobile + .has-text-#{$alignment}-mobile + text-align: #{$text-align} !important + +tablet + .has-text-#{$alignment}-tablet + text-align: #{$text-align} !important + +tablet-only + .has-text-#{$alignment}-tablet-only + text-align: #{$text-align} !important + +touch + .has-text-#{$alignment}-touch + text-align: #{$text-align} !important + +desktop + .has-text-#{$alignment}-desktop + text-align: #{$text-align} !important + +desktop-only + .has-text-#{$alignment}-desktop-only + text-align: #{$text-align} !important + +widescreen + .has-text-#{$alignment}-widescreen + text-align: #{$text-align} !important + +widescreen-only + .has-text-#{$alignment}-widescreen-only + text-align: #{$text-align} !important + +fullhd + .has-text-#{$alignment}-fullhd + text-align: #{$text-align} !important + +.is-capitalized + text-transform: capitalize !important + +.is-lowercase + text-transform: lowercase !important + +.is-uppercase + text-transform: uppercase !important + +.is-italic + font-style: italic !important + +.has-text-weight-light + font-weight: $weight-light !important +.has-text-weight-normal + font-weight: $weight-normal !important +.has-text-weight-medium + font-weight: $weight-medium !important +.has-text-weight-semibold + font-weight: $weight-semibold !important +.has-text-weight-bold + font-weight: $weight-bold !important + +.is-family-primary + font-family: $family-primary !important + +.is-family-secondary + font-family: $family-secondary !important + +.is-family-sans-serif + font-family: $family-sans-serif !important + +.is-family-monospace + font-family: $family-monospace !important + +.is-family-code + font-family: $family-code !important diff --git a/shared/static/src/bulma/sass/helpers/visibility.sass b/shared/static/src/bulma/sass/helpers/visibility.sass new file mode 100644 index 00000000..92477f3a --- /dev/null +++ b/shared/static/src/bulma/sass/helpers/visibility.sass @@ -0,0 +1,122 @@ + + +$displays: 'block' 'flex' 'inline' 'inline-block' 'inline-flex' + +@each $display in $displays + .is-#{$display} + display: #{$display} !important + +mobile + .is-#{$display}-mobile + display: #{$display} !important + +tablet + .is-#{$display}-tablet + display: #{$display} !important + +tablet-only + .is-#{$display}-tablet-only + display: #{$display} !important + +touch + .is-#{$display}-touch + display: #{$display} !important + +desktop + .is-#{$display}-desktop + display: #{$display} !important + +desktop-only + .is-#{$display}-desktop-only + display: #{$display} !important + +widescreen + .is-#{$display}-widescreen + display: #{$display} !important + +widescreen-only + .is-#{$display}-widescreen-only + display: #{$display} !important + +fullhd + .is-#{$display}-fullhd + display: #{$display} !important + +.is-hidden + display: none !important + +.is-sr-only + border: none !important + clip: rect(0, 0, 0, 0) !important + height: 0.01em !important + overflow: hidden !important + padding: 0 !important + position: absolute !important + white-space: nowrap !important + width: 0.01em !important + ++mobile + .is-hidden-mobile + display: none !important + ++tablet + .is-hidden-tablet + display: none !important + ++tablet-only + .is-hidden-tablet-only + display: none !important + ++touch + .is-hidden-touch + display: none !important + ++desktop + .is-hidden-desktop + display: none !important + ++desktop-only + .is-hidden-desktop-only + display: none !important + ++widescreen + .is-hidden-widescreen + display: none !important + ++widescreen-only + .is-hidden-widescreen-only + display: none !important + ++fullhd + .is-hidden-fullhd + display: none !important + +.is-invisible + visibility: hidden !important + ++mobile + .is-invisible-mobile + visibility: hidden !important + ++tablet + .is-invisible-tablet + visibility: hidden !important + ++tablet-only + .is-invisible-tablet-only + visibility: hidden !important + ++touch + .is-invisible-touch + visibility: hidden !important + ++desktop + .is-invisible-desktop + visibility: hidden !important + ++desktop-only + .is-invisible-desktop-only + visibility: hidden !important + ++widescreen + .is-invisible-widescreen + visibility: hidden !important + ++widescreen-only + .is-invisible-widescreen-only + visibility: hidden !important + ++fullhd + .is-invisible-fullhd + visibility: hidden !important diff --git a/shared/static/src/bulma/sass/layout/_all.sass b/shared/static/src/bulma/sass/layout/_all.sass new file mode 100644 index 00000000..143ada35 --- /dev/null +++ b/shared/static/src/bulma/sass/layout/_all.sass @@ -0,0 +1,5 @@ +@charset "utf-8" + +@import "hero.sass" +@import "section.sass" +@import "footer.sass" diff --git a/shared/static/src/bulma/sass/layout/footer.sass b/shared/static/src/bulma/sass/layout/footer.sass new file mode 100644 index 00000000..8faa11ed --- /dev/null +++ b/shared/static/src/bulma/sass/layout/footer.sass @@ -0,0 +1,9 @@ +$footer-background-color: $scheme-main-bis !default +$footer-color: false !default +$footer-padding: 3rem 1.5rem 6rem !default + +.footer + background-color: $footer-background-color + padding: $footer-padding + @if $footer-color + color: $footer-color diff --git a/shared/static/src/bulma/sass/layout/hero.sass b/shared/static/src/bulma/sass/layout/hero.sass new file mode 100644 index 00000000..925c98c2 --- /dev/null +++ b/shared/static/src/bulma/sass/layout/hero.sass @@ -0,0 +1,145 @@ +$hero-body-padding: 3rem 1.5rem !default +$hero-body-padding-small: 1.5rem !default +$hero-body-padding-medium: 9rem 1.5rem !default +$hero-body-padding-large: 18rem 1.5rem !default + +// Main container +.hero + align-items: stretch + display: flex + flex-direction: column + justify-content: space-between + .navbar + background: none + .tabs + ul + border-bottom: none + // Colors + @each $name, $pair in $colors + $color: nth($pair, 1) + $color-invert: nth($pair, 2) + &.is-#{$name} + background-color: $color + color: $color-invert + a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current), + strong + color: inherit + .title + color: $color-invert + .subtitle + color: bulmaRgba($color-invert, 0.9) + a:not(.button), + strong + color: $color-invert + .navbar-menu + +touch + background-color: $color + .navbar-item, + .navbar-link + color: bulmaRgba($color-invert, 0.7) + a.navbar-item, + .navbar-link + &:hover, + &.is-active + background-color: bulmaDarken($color, 5%) + color: $color-invert + .tabs + a + color: $color-invert + opacity: 0.9 + &:hover + opacity: 1 + li + &.is-active a + opacity: 1 + &.is-boxed, + &.is-toggle + a + color: $color-invert + &:hover + background-color: bulmaRgba($scheme-invert, 0.1) + li.is-active a + &, + &:hover + background-color: $color-invert + border-color: $color-invert + color: $color + // Modifiers + @if type-of($color) == 'color' + &.is-bold + $gradient-top-left: darken(saturate(adjust-hue($color, -10deg), 10%), 10%) + $gradient-bottom-right: lighten(saturate(adjust-hue($color, 10deg), 5%), 5%) + background-image: linear-gradient(141deg, $gradient-top-left 0%, $color 71%, $gradient-bottom-right 100%) + +mobile + .navbar-menu + background-image: linear-gradient(141deg, $gradient-top-left 0%, $color 71%, $gradient-bottom-right 100%) + // Sizes + &.is-small + .hero-body + padding: $hero-body-padding-small + &.is-medium + +tablet + .hero-body + padding: $hero-body-padding-medium + &.is-large + +tablet + .hero-body + padding: $hero-body-padding-large + &.is-halfheight, + &.is-fullheight, + &.is-fullheight-with-navbar + .hero-body + align-items: center + display: flex + & > .container + flex-grow: 1 + flex-shrink: 1 + &.is-halfheight + min-height: 50vh + &.is-fullheight + min-height: 100vh + +// Components + +.hero-video + @extend %overlay + overflow: hidden + video + left: 50% + min-height: 100% + min-width: 100% + position: absolute + top: 50% + transform: translate3d(-50%, -50%, 0) + // Modifiers + &.is-transparent + opacity: 0.3 + // Responsiveness + +mobile + display: none + +.hero-buttons + margin-top: 1.5rem + // Responsiveness + +mobile + .button + display: flex + &:not(:last-child) + margin-bottom: 0.75rem + +tablet + display: flex + justify-content: center + .button:not(:last-child) + +ltr-property("margin", 1.5rem) + +// Containers + +.hero-head, +.hero-foot + flex-grow: 0 + flex-shrink: 0 + +.hero-body + flex-grow: 1 + flex-shrink: 0 + padding: $hero-body-padding diff --git a/shared/static/src/bulma/sass/layout/section.sass b/shared/static/src/bulma/sass/layout/section.sass new file mode 100644 index 00000000..6f2d3523 --- /dev/null +++ b/shared/static/src/bulma/sass/layout/section.sass @@ -0,0 +1,13 @@ +$section-padding: 3rem 1.5rem !default +$section-padding-medium: 9rem 1.5rem !default +$section-padding-large: 18rem 1.5rem !default + +.section + padding: $section-padding + // Responsiveness + +desktop + // Sizes + &.is-medium + padding: $section-padding-medium + &.is-large + padding: $section-padding-large diff --git a/shared/static/src/bulma/sass/utilities/_all.sass b/shared/static/src/bulma/sass/utilities/_all.sass new file mode 100644 index 00000000..b471577c --- /dev/null +++ b/shared/static/src/bulma/sass/utilities/_all.sass @@ -0,0 +1,8 @@ +@charset "utf-8" + +@import "initial-variables.sass" +@import "functions.sass" +@import "derived-variables.scss" +@import "animations.sass" +@import "mixins.sass" +@import "controls.sass" diff --git a/shared/static/src/bulma/sass/utilities/animations.sass b/shared/static/src/bulma/sass/utilities/animations.sass new file mode 100644 index 00000000..a14525d7 --- /dev/null +++ b/shared/static/src/bulma/sass/utilities/animations.sass @@ -0,0 +1,5 @@ +@keyframes spinAround + from + transform: rotate(0deg) + to + transform: rotate(359deg) diff --git a/shared/static/src/bulma/sass/utilities/controls.sass b/shared/static/src/bulma/sass/utilities/controls.sass new file mode 100644 index 00000000..cc7672a1 --- /dev/null +++ b/shared/static/src/bulma/sass/utilities/controls.sass @@ -0,0 +1,50 @@ +$control-radius: $radius !default +$control-radius-small: $radius-small !default + +$control-border-width: 1px !default + +$control-height: 2.5em !default +$control-line-height: 1.5 !default + +$control-padding-vertical: calc(0.5em - #{$control-border-width}) !default +$control-padding-horizontal: calc(0.75em - #{$control-border-width}) !default + +=control + -moz-appearance: none + -webkit-appearance: none + align-items: center + border: $control-border-width solid transparent + border-radius: $control-radius + box-shadow: none + display: inline-flex + font-size: $size-normal + height: $control-height + justify-content: flex-start + line-height: $control-line-height + padding-bottom: $control-padding-vertical + padding-left: $control-padding-horizontal + padding-right: $control-padding-horizontal + padding-top: $control-padding-vertical + position: relative + vertical-align: top + // States + &:focus, + &.is-focused, + &:active, + &.is-active + outline: none + &[disabled], + fieldset[disabled] & + cursor: not-allowed + +%control + +control + +// The controls sizes use mixins so they can be used at different breakpoints +=control-small + border-radius: $control-radius-small + font-size: $size-small +=control-medium + font-size: $size-medium +=control-large + font-size: $size-large diff --git a/shared/static/src/bulma/sass/utilities/derived-variables.scss b/shared/static/src/bulma/sass/utilities/derived-variables.scss new file mode 100644 index 00000000..54a03585 --- /dev/null +++ b/shared/static/src/bulma/sass/utilities/derived-variables.scss @@ -0,0 +1,132 @@ +$primary: $turquoise !default; + +$info : $cyan !default; +$success: $green !default; +$warning: $yellow !default; +$danger : $red !default; + +$light : $white-ter !default; +$dark : $grey-darker !default; + +// Invert colors + +$orange-invert : findColorInvert($orange) !default; +$yellow-invert : findColorInvert($yellow) !default; +$green-invert : findColorInvert($green) !default; +$turquoise-invert: findColorInvert($turquoise) !default; +$cyan-invert : findColorInvert($cyan) !default; +$blue-invert : findColorInvert($blue) !default; +$purple-invert : findColorInvert($purple) !default; +$red-invert : findColorInvert($red) !default; + +$primary-invert : findColorInvert($primary) !default; +$primary-light : findLightColor($primary) !default; +$primary-dark : findDarkColor($primary) !default; +$info-invert : findColorInvert($info) !default; +$info-light : findLightColor($info) !default; +$info-dark : findDarkColor($info) !default; +$success-invert : findColorInvert($success) !default; +$success-light : findLightColor($success) !default; +$success-dark : findDarkColor($success) !default; +$warning-invert : findColorInvert($warning) !default; +$warning-light : findLightColor($warning) !default; +$warning-dark : findDarkColor($warning) !default; +$danger-invert : findColorInvert($danger) !default; +$danger-light : findLightColor($danger) !default; +$danger-dark : findDarkColor($danger) !default; +$light-invert : findColorInvert($light) !default; +$dark-invert : findColorInvert($dark) !default; + +// General colors + +$scheme-main : $white !default; +$scheme-main-bis : $white-bis !default; +$scheme-main-ter : $white-ter !default; +$scheme-invert : $black !default; +$scheme-invert-bis : $black-bis !default; +$scheme-invert-ter : $black-ter !default; + +$background : $white-ter !default; + +$border : $grey-lighter !default; +$border-hover : $grey-light !default; +$border-light : $grey-lightest !default; +$border-light-hover: $grey-light !default; + +// Text colors + +$text : $grey-dark !default; +$text-invert: findColorInvert($text) !default; +$text-light : $grey !default; +$text-strong: $grey-darker !default; + +// Code colors + +$code : $red !default; +$code-background: $background !default; + +$pre : $text !default; +$pre-background : $background !default; + +// Link colors + +$link : $blue !default; +$link-invert : findColorInvert($link) !default; +$link-light : findLightColor($link) !default; +$link-dark : findDarkColor($link) !default; +$link-visited : $purple !default; + +$link-hover : $grey-darker !default; +$link-hover-border : $grey-light !default; + +$link-focus : $grey-darker !default; +$link-focus-border : $blue !default; + +$link-active : $grey-darker !default; +$link-active-border: $grey-dark !default; + +// Typography + +$family-primary : $family-sans-serif !default; +$family-secondary: $family-sans-serif !default; +$family-code : $family-monospace !default; + +$size-small : $size-7 !default; +$size-normal: $size-6 !default; +$size-medium: $size-5 !default; +$size-large : $size-4 !default; + +// Lists and maps +$custom-colors: null !default; +$custom-shades: null !default; + +$colors: mergeColorMaps( +( + "white" : ($white, $black), + "black" : ($black, $white), + "light" : ($light, $light-invert), + "dark" : ($dark, $dark-invert), + "primary": ($primary, $primary-invert, $primary-light, $primary-dark), + "link" : ($link, $link-invert, $link-light, $link-dark), + "info" : ($info, $info-invert, $info-light, $info-dark), + "success": ($success, $success-invert, $success-light, $success-dark), + "warning": ($warning, $warning-invert, $warning-light, $warning-dark), + "danger" : ($danger, $danger-invert, $danger-light, $danger-dark)), + $custom-colors +) !default; + +$shades: mergeColorMaps( +( + "black-bis" : $black-bis, + "black-ter" : $black-ter, + "grey-darker" : $grey-darker, + "grey-dark" : $grey-dark, + "grey" : $grey, + "grey-light" : $grey-light, + "grey-lighter": $grey-lighter, + "white-ter" : $white-ter, + "white-bis" : $white-bis), + $custom-shades +) !default; + +$sizes: $size-1 $size-2 $size-3 $size-4 $size-5 $size-6 $size-7 !default; diff --git a/shared/static/src/bulma/sass/utilities/functions.sass b/shared/static/src/bulma/sass/utilities/functions.sass new file mode 100644 index 00000000..270121f6 --- /dev/null +++ b/shared/static/src/bulma/sass/utilities/functions.sass @@ -0,0 +1,115 @@ +@function mergeColorMaps($bulma-colors, $custom-colors) + // We return at least Bulma's hard-coded colors + $merged-colors: $bulma-colors + + // We want a map as input + @if type-of($custom-colors) == 'map' + @each $name, $components in $custom-colors + // The color name should be a string + // and the components either a single color + // or a colors list with at least one element + @if type-of($name) == 'string' and (type-of($components) == 'list' or type-of($components) == 'color') and length($components) >= 1 + $color-base: null + $color-invert: null + $color-light: null + $color-dark: null + $value: null + + // The param can either be a single color + // or a list of 2 colors + @if type-of($components) == 'color' + $color-base: $components + $color-invert: findColorInvert($color-base) + $color-light: findLightColor($color-base) + $color-dark: findDarkColor($color-base) + @else if type-of($components) == 'list' + $color-base: nth($components, 1) + // If Invert, Light and Dark are provided + @if length($components) > 3 + $color-invert: nth($components, 2) + $color-light: nth($components, 3) + $color-dark: nth($components, 4) + // If only Invert and Light are provided + @else if length($components) > 2 + $color-invert: nth($components, 2) + $color-light: nth($components, 3) + $color-dark: findDarkColor($color-base) + // If only Invert is provided + @else + $color-invert: nth($components, 2) + $color-light: findLightColor($color-base) + $color-dark: findDarkColor($color-base) + + $value: ($color-base, $color-invert, $color-light, $color-dark) + + // We only want to merge the map if the color base is an actual color + @if type-of($color-base) == 'color' + // We merge this colors elements as map with Bulma's colors map + // (we can override them this way, no multiple definition for the same name) + // $merged-colors: map_merge($merged-colors, ($name: ($color-base, $color-invert, $color-light, $color-dark))) + $merged-colors: map_merge($merged-colors, ($name: $value)) + + @return $merged-colors + +@function powerNumber($number, $exp) + $value: 1 + @if $exp > 0 + @for $i from 1 through $exp + $value: $value * $number + @else if $exp < 0 + @for $i from 1 through -$exp + $value: $value / $number + @return $value + +@function colorLuminance($color) + @if type-of($color) != 'color' + @return 0.55 + $color-rgb: ('red': red($color),'green': green($color),'blue': blue($color)) + @each $name, $value in $color-rgb + $adjusted: 0 + $value: $value / 255 + @if $value < 0.03928 + $value: $value / 12.92 + @else + $value: ($value + .055) / 1.055 + $value: powerNumber($value, 2) + $color-rgb: map-merge($color-rgb, ($name: $value)) + @return (map-get($color-rgb, 'red') * .2126) + (map-get($color-rgb, 'green') * .7152) + (map-get($color-rgb, 'blue') * .0722) + +@function findColorInvert($color) + @if (colorLuminance($color) > 0.55) + @return rgba(#000, 0.7) + @else + @return #fff + +@function findLightColor($color) + @if type-of($color) == 'color' + $l: 96% + @if lightness($color) > 96% + $l: lightness($color) + @return change-color($color, $lightness: $l) + @return $background + +@function findDarkColor($color) + @if type-of($color) == 'color' + $base-l: 29% + $luminance: colorLuminance($color) + $luminance-delta: (0.53 - $luminance) + $target-l: round($base-l + ($luminance-delta * 53)) + @return change-color($color, $lightness: max($base-l, $target-l)) + @return $text-strong + +@function bulmaRgba($color, $alpha) + @if type-of($color) != 'color' + @return $color + @return rgba($color, $alpha) + +@function bulmaDarken($color, $amount) + @if type-of($color) != 'color' + @return $color + @return darken($color, $amount) + +@function bulmaLighten($color, $amount) + @if type-of($color) != 'color' + @return $color + @return lighten($color, $amount) diff --git a/shared/static/src/bulma/sass/utilities/initial-variables.sass b/shared/static/src/bulma/sass/utilities/initial-variables.sass new file mode 100644 index 00000000..a1d688b6 --- /dev/null +++ b/shared/static/src/bulma/sass/utilities/initial-variables.sass @@ -0,0 +1,78 @@ +// Colors + +$black: hsl(0, 0%, 4%) !default +$black-bis: hsl(0, 0%, 7%) !default +$black-ter: hsl(0, 0%, 14%) !default + +$grey-darker: hsl(0, 0%, 21%) !default +$grey-dark: hsl(0, 0%, 29%) !default +$grey: hsl(0, 0%, 48%) !default +$grey-light: hsl(0, 0%, 71%) !default +$grey-lighter: hsl(0, 0%, 86%) !default +$grey-lightest: hsl(0, 0%, 93%) !default + +$white-ter: hsl(0, 0%, 96%) !default +$white-bis: hsl(0, 0%, 98%) !default +$white: hsl(0, 0%, 100%) !default + +$orange: hsl(14, 100%, 53%) !default +$yellow: hsl(48, 100%, 67%) !default +$green: hsl(141, 53%, 53%) !default +$turquoise: hsl(171, 100%, 41%) !default +$cyan: hsl(204, 71%, 53%) !default +$blue: hsl(217, 71%, 53%) !default +$purple: hsl(271, 100%, 71%) !default +$red: hsl(348, 86%, 61%) !default + +// Typography + +$family-sans-serif: BlinkMacSystemFont, -apple-system, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", "Helvetica", "Arial", sans-serif !default +$family-monospace: monospace !default +$render-mode: optimizeLegibility !default + +$size-1: 3rem !default +$size-2: 2.5rem !default +$size-3: 2rem !default +$size-4: 1.5rem !default +$size-5: 1.25rem !default +$size-6: 1rem !default +$size-7: 0.75rem !default + +$weight-light: 300 !default +$weight-normal: 400 !default +$weight-medium: 500 !default +$weight-semibold: 600 !default +$weight-bold: 700 !default + +// Spacing + +$block-spacing: 1.5rem !default + +// Responsiveness + +// The container horizontal gap, which acts as the offset for breakpoints +$gap: 32px !default +// 960, 1152, and 1344 have been chosen because they are divisible by both 12 and 16 +$tablet: 769px !default +// 960px container + 4rem +$desktop: 960px + (2 * $gap) !default +// 1152px container + 4rem +$widescreen: 1152px + (2 * $gap) !default +$widescreen-enabled: true !default +// 1344px container + 4rem +$fullhd: 1344px + (2 * $gap) !default +$fullhd-enabled: true !default + +// Miscellaneous + +$easing: ease-out !default +$radius-small: 2px !default +$radius: 4px !default +$radius-large: 6px !default +$radius-rounded: 290486px !default +$speed: 86ms !default + +// Flags + +$variable-columns: true !default +$rtl: false !default diff --git a/shared/static/src/bulma/sass/utilities/mixins.sass b/shared/static/src/bulma/sass/utilities/mixins.sass new file mode 100644 index 00000000..0ed78c15 --- /dev/null +++ b/shared/static/src/bulma/sass/utilities/mixins.sass @@ -0,0 +1,285 @@ +@import "initial-variables" + +=clearfix + &::after + clear: both + content: " " + display: table + +=center($width, $height: 0) + position: absolute + @if $height != 0 + left: calc(50% - (#{$width} / 2)) + top: calc(50% - (#{$height} / 2)) + @else + left: calc(50% - (#{$width} / 2)) + top: calc(50% - (#{$width} / 2)) + +=fa($size, $dimensions) + display: inline-block + font-size: $size + height: $dimensions + line-height: $dimensions + text-align: center + vertical-align: top + width: $dimensions + +=hamburger($dimensions) + cursor: pointer + display: block + height: $dimensions + position: relative + width: $dimensions + span + background-color: currentColor + display: block + height: 1px + left: calc(50% - 8px) + position: absolute + transform-origin: center + transition-duration: $speed + transition-property: background-color, opacity, transform + transition-timing-function: $easing + width: 16px + &:nth-child(1) + top: calc(50% - 6px) + &:nth-child(2) + top: calc(50% - 1px) + &:nth-child(3) + top: calc(50% + 4px) + &:hover + background-color: bulmaRgba(black, 0.05) + // Modifers + &.is-active + span + &:nth-child(1) + transform: translateY(5px) rotate(45deg) + &:nth-child(2) + opacity: 0 + &:nth-child(3) + transform: translateY(-5px) rotate(-45deg) + +=overflow-touch + -webkit-overflow-scrolling: touch + +=placeholder + $placeholders: ':-moz' ':-webkit-input' '-moz' '-ms-input' + @each $placeholder in $placeholders + &:#{$placeholder}-placeholder + @content + +// Responsiveness + +=from($device) + @media screen and (min-width: $device) + @content + +=until($device) + @media screen and (max-width: $device - 1px) + @content + +=mobile + @media screen and (max-width: $tablet - 1px) + @content + +=tablet + @media screen and (min-width: $tablet), print + @content + +=tablet-only + @media screen and (min-width: $tablet) and (max-width: $desktop - 1px) + @content + +=touch + @media screen and (max-width: $desktop - 1px) + @content + +=desktop + @media screen and (min-width: $desktop) + @content + +=desktop-only + @if $widescreen-enabled + @media screen and (min-width: $desktop) and (max-width: $widescreen - 1px) + @content + +=until-widescreen + @if $widescreen-enabled + @media screen and (max-width: $widescreen - 1px) + @content + +=widescreen + @if $widescreen-enabled + @media screen and (min-width: $widescreen) + @content + +=widescreen-only + @if $widescreen-enabled and $fullhd-enabled + @media screen and (min-width: $widescreen) and (max-width: $fullhd - 1px) + @content + +=until-fullhd + @if $fullhd-enabled + @media screen and (max-width: $fullhd - 1px) + @content + +=fullhd + @if $fullhd-enabled + @media screen and (min-width: $fullhd) + @content + +=ltr + @if not $rtl + @content + +=rtl + @if $rtl + @content + +=ltr-property($property, $spacing, $right: true) + $normal: if($right, "right", "left") + $opposite: if($right, "left", "right") + @if $rtl + #{$property}-#{$opposite}: $spacing + @else + #{$property}-#{$normal}: $spacing + +=ltr-position($spacing, $right: true) + $normal: if($right, "right", "left") + $opposite: if($right, "left", "right") + @if $rtl + #{$opposite}: $spacing + @else + #{$normal}: $spacing + +// Placeholders + +=unselectable + -webkit-touch-callout: none + -webkit-user-select: none + -moz-user-select: none + -ms-user-select: none + user-select: none + +%unselectable + +unselectable + +=arrow($color: transparent) + border: 3px solid $color + border-radius: 2px + border-right: 0 + border-top: 0 + content: " " + display: block + height: 0.625em + margin-top: -0.4375em + pointer-events: none + position: absolute + top: 50% + transform: rotate(-45deg) + transform-origin: center + width: 0.625em + +%arrow + +arrow + +=block($spacing: $block-spacing) + &:not(:last-child) + margin-bottom: $spacing + +%block + +block + +=delete + @extend %unselectable + -moz-appearance: none + -webkit-appearance: none + background-color: bulmaRgba($scheme-invert, 0.2) + border: none + border-radius: $radius-rounded + cursor: pointer + pointer-events: auto + display: inline-block + flex-grow: 0 + flex-shrink: 0 + font-size: 0 + height: 20px + max-height: 20px + max-width: 20px + min-height: 20px + min-width: 20px + outline: none + position: relative + vertical-align: top + width: 20px + &::before, + &::after + background-color: $scheme-main + content: "" + display: block + left: 50% + position: absolute + top: 50% + transform: translateX(-50%) translateY(-50%) rotate(45deg) + transform-origin: center center + &::before + height: 2px + width: 50% + &::after + height: 50% + width: 2px + &:hover, + &:focus + background-color: bulmaRgba($scheme-invert, 0.3) + &:active + background-color: bulmaRgba($scheme-invert, 0.4) + // Sizes + &.is-small + height: 16px + max-height: 16px + max-width: 16px + min-height: 16px + min-width: 16px + width: 16px + &.is-medium + height: 24px + max-height: 24px + max-width: 24px + min-height: 24px + min-width: 24px + width: 24px + &.is-large + height: 32px + max-height: 32px + max-width: 32px + min-height: 32px + min-width: 32px + width: 32px + +%delete + +delete + +=loader + animation: spinAround 500ms infinite linear + border: 2px solid $grey-lighter + border-radius: $radius-rounded + border-right-color: transparent + border-top-color: transparent + content: "" + display: block + height: 1em + position: relative + width: 1em + +%loader + +loader + +=overlay($offset: 0) + bottom: $offset + left: $offset + position: absolute + right: $offset + top: $offset + +%overlay + +overlay diff --git a/gestioncof/static/gestioncof/src/font-awesome/css/font-awesome.css b/shared/static/src/font-awesome/css/font-awesome.css similarity index 100% rename from gestioncof/static/gestioncof/src/font-awesome/css/font-awesome.css rename to shared/static/src/font-awesome/css/font-awesome.css diff --git a/gestioncof/static/gestioncof/src/font-awesome/fonts/FontAwesome.otf b/shared/static/src/font-awesome/fonts/FontAwesome.otf similarity index 100% rename from gestioncof/static/gestioncof/src/font-awesome/fonts/FontAwesome.otf rename to shared/static/src/font-awesome/fonts/FontAwesome.otf diff --git a/gestioncof/static/gestioncof/src/font-awesome/fonts/fontawesome-webfont.eot b/shared/static/src/font-awesome/fonts/fontawesome-webfont.eot similarity index 100% rename from gestioncof/static/gestioncof/src/font-awesome/fonts/fontawesome-webfont.eot rename to shared/static/src/font-awesome/fonts/fontawesome-webfont.eot diff --git a/gestioncof/static/gestioncof/src/font-awesome/fonts/fontawesome-webfont.svg b/shared/static/src/font-awesome/fonts/fontawesome-webfont.svg similarity index 100% rename from gestioncof/static/gestioncof/src/font-awesome/fonts/fontawesome-webfont.svg rename to shared/static/src/font-awesome/fonts/fontawesome-webfont.svg diff --git a/gestioncof/static/gestioncof/src/font-awesome/fonts/fontawesome-webfont.ttf b/shared/static/src/font-awesome/fonts/fontawesome-webfont.ttf similarity index 100% rename from gestioncof/static/gestioncof/src/font-awesome/fonts/fontawesome-webfont.ttf rename to shared/static/src/font-awesome/fonts/fontawesome-webfont.ttf diff --git a/gestioncof/static/gestioncof/src/font-awesome/fonts/fontawesome-webfont.woff b/shared/static/src/font-awesome/fonts/fontawesome-webfont.woff similarity index 100% rename from gestioncof/static/gestioncof/src/font-awesome/fonts/fontawesome-webfont.woff rename to shared/static/src/font-awesome/fonts/fontawesome-webfont.woff diff --git a/gestioncof/static/gestioncof/src/font-awesome/fonts/fontawesome-webfont.woff2 b/shared/static/src/font-awesome/fonts/fontawesome-webfont.woff2 similarity index 100% rename from gestioncof/static/gestioncof/src/font-awesome/fonts/fontawesome-webfont.woff2 rename to shared/static/src/font-awesome/fonts/fontawesome-webfont.woff2 diff --git a/gestioncof/static/gestioncof/src/font-awesome/images/no.png b/shared/static/src/font-awesome/images/no.png similarity index 100% rename from gestioncof/static/gestioncof/src/font-awesome/images/no.png rename to shared/static/src/font-awesome/images/no.png diff --git a/gestioncof/static/gestioncof/src/font-awesome/images/none.png b/shared/static/src/font-awesome/images/none.png similarity index 100% rename from gestioncof/static/gestioncof/src/font-awesome/images/none.png rename to shared/static/src/font-awesome/images/none.png diff --git a/gestioncof/static/gestioncof/src/font-awesome/images/yes.png b/shared/static/src/font-awesome/images/yes.png similarity index 100% rename from gestioncof/static/gestioncof/src/font-awesome/images/yes.png rename to shared/static/src/font-awesome/images/yes.png diff --git a/gestioncof/static/gestioncof/vendor/font-awesome/css/font-awesome.min.css b/shared/static/vendor/font-awesome/css/font-awesome.min.css similarity index 100% rename from gestioncof/static/gestioncof/vendor/font-awesome/css/font-awesome.min.css rename to shared/static/vendor/font-awesome/css/font-awesome.min.css diff --git a/gestioncof/static/gestioncof/vendor/font-awesome/fonts/FontAwesome.otf b/shared/static/vendor/font-awesome/fonts/FontAwesome.otf similarity index 100% rename from gestioncof/static/gestioncof/vendor/font-awesome/fonts/FontAwesome.otf rename to shared/static/vendor/font-awesome/fonts/FontAwesome.otf diff --git a/gestioncof/static/gestioncof/vendor/font-awesome/fonts/fontawesome-webfont.eot b/shared/static/vendor/font-awesome/fonts/fontawesome-webfont.eot similarity index 100% rename from gestioncof/static/gestioncof/vendor/font-awesome/fonts/fontawesome-webfont.eot rename to shared/static/vendor/font-awesome/fonts/fontawesome-webfont.eot diff --git a/gestioncof/static/gestioncof/vendor/font-awesome/fonts/fontawesome-webfont.svg b/shared/static/vendor/font-awesome/fonts/fontawesome-webfont.svg similarity index 100% rename from gestioncof/static/gestioncof/vendor/font-awesome/fonts/fontawesome-webfont.svg rename to shared/static/vendor/font-awesome/fonts/fontawesome-webfont.svg diff --git a/gestioncof/static/gestioncof/vendor/font-awesome/fonts/fontawesome-webfont.ttf b/shared/static/vendor/font-awesome/fonts/fontawesome-webfont.ttf similarity index 100% rename from gestioncof/static/gestioncof/vendor/font-awesome/fonts/fontawesome-webfont.ttf rename to shared/static/vendor/font-awesome/fonts/fontawesome-webfont.ttf diff --git a/gestioncof/static/gestioncof/vendor/font-awesome/fonts/fontawesome-webfont.woff b/shared/static/vendor/font-awesome/fonts/fontawesome-webfont.woff similarity index 100% rename from gestioncof/static/gestioncof/vendor/font-awesome/fonts/fontawesome-webfont.woff rename to shared/static/vendor/font-awesome/fonts/fontawesome-webfont.woff diff --git a/gestioncof/static/gestioncof/vendor/font-awesome/fonts/fontawesome-webfont.woff2 b/shared/static/vendor/font-awesome/fonts/fontawesome-webfont.woff2 similarity index 100% rename from gestioncof/static/gestioncof/vendor/font-awesome/fonts/fontawesome-webfont.woff2 rename to shared/static/vendor/font-awesome/fonts/fontawesome-webfont.woff2 diff --git a/gestioncof/static/gestioncof/vendor/font-awesome/images/no.png b/shared/static/vendor/font-awesome/images/no.png similarity index 100% rename from gestioncof/static/gestioncof/vendor/font-awesome/images/no.png rename to shared/static/vendor/font-awesome/images/no.png diff --git a/gestioncof/static/gestioncof/vendor/font-awesome/images/none.png b/shared/static/vendor/font-awesome/images/none.png similarity index 100% rename from gestioncof/static/gestioncof/vendor/font-awesome/images/none.png rename to shared/static/vendor/font-awesome/images/none.png diff --git a/gestioncof/static/gestioncof/vendor/font-awesome/images/yes.png b/shared/static/vendor/font-awesome/images/yes.png similarity index 100% rename from gestioncof/static/gestioncof/vendor/font-awesome/images/yes.png rename to shared/static/vendor/font-awesome/images/yes.png diff --git a/shared/templates/shared/search_results.html b/shared/templates/shared/search_results.html new file mode 100644 index 00000000..66661e8c --- /dev/null +++ b/shared/templates/shared/search_results.html @@ -0,0 +1,17 @@ +{% load i18n %} + +
      + {% for section in results %} + {% include "shared/search_results_section.html" with section=section %} + {% endfor %} + + {% block extra_section %} + {% if not results %} +
    • + + {% trans "Aucune correspondance trouvée" %} + +
    • + {% endif %} + {% endblock %} +
    diff --git a/shared/templates/shared/search_results_section.html b/shared/templates/shared/search_results_section.html new file mode 100644 index 00000000..800d6557 --- /dev/null +++ b/shared/templates/shared/search_results_section.html @@ -0,0 +1,25 @@ +{% load search_utils %} + +
  • + {{ section.verbose_name }} +
  • + +{% for entry in section.entries %} + {% if forloop.counter < 5 %} +
  • + {% if entry.link %} + + {{ entry.verbose_name | highlight:q }} + + {% else %} + + {{ entry.verbose_name | highlight:q }} + + {% endif %} +
  • + {% elif forloop.counter == 5 %} +
  • + ... +
  • + {% endif %} +{% endfor %} diff --git a/utils/views/__init__.py b/shared/templatetags/__init__.py similarity index 100% rename from utils/views/__init__.py rename to shared/templatetags/__init__.py diff --git a/shared/templatetags/search_utils.py b/shared/templatetags/search_utils.py new file mode 100644 index 00000000..a98c36e5 --- /dev/null +++ b/shared/templatetags/search_utils.py @@ -0,0 +1,15 @@ +import re + +from django import template +from django.utils.safestring import mark_safe + +register = template.Library() + + +@register.filter +def highlight(text, q): + q2 = "|".join(re.escape(word) for word in q.split()) + pattern = re.compile(r"(?P%s)" % q2, re.IGNORECASE) + return mark_safe( + re.sub(pattern, r"\g", text) + ) diff --git a/shared/tests/testcases.py b/shared/tests/mixins.py similarity index 52% rename from shared/tests/testcases.py rename to shared/tests/mixins.py index 35d697e7..ea83616a 100644 --- a/shared/tests/testcases.py +++ b/shared/tests/mixins.py @@ -13,6 +13,138 @@ from django.utils.functional import cached_property User = get_user_model() +class MockLDAPMixin: + """ + Mixin pour simuler un appel à un serveur LDAP (e.g., celui de l'ENS) dans des + tests unitaires. La réponse est une instance d'une classe Entry, qui simule + grossièrement l'interface de ldap3. + Cette classe patche la méthode magique `__enter__`, le code correspondant doit donc + appeler `with Connection(*args, **kwargs) as foo` pour que le test fonctionne. + """ + + class MockLDAPModule: + SCOPE_SUBTREE = None # whatever + + def __init__(self, ldap_obj): + self.ldap_obj = ldap_obj + + def initialize(self, *args): + """Always return the same ldap object.""" + return self.ldap_obj + + def mockLDAP(self, results): + entries = [ + ( + "whatever", + { + "cn": [name.encode("utf-8")], + "uid": [uid.encode("utf-8")], + "mail": [mail.encode("utf-8")], + }, + ) + for uid, name, mail in results + ] + # Mock ldap object whose `search_s` method always returns the same results. + mock_ldap_obj = mock.Mock() + mock_ldap_obj.search_s = mock.Mock(return_value=entries) + + # Mock ldap module whose `initialize_method` always return the same ldap object. + mock_ldap_module = self.MockLDAPModule(mock_ldap_obj) + + patcher = mock.patch("shared.autocomplete.ldap", new=mock_ldap_module) + patcher.start() + self.addCleanup(patcher.stop) + + return mock_ldap_module + + +class CSVResponseMixin: + """ + Mixin pour manipuler des réponses données via CSV. Deux choix sont possibles: + - si `as_dict=False`, convertit le CSV en une liste de listes (une liste par ligne) + - si `as_dict=True`, convertit le CSV en une liste de dicts, avec les champs donnés + par la première ligne du CSV. + """ + + def _load_from_csv_response(self, r, as_dict=False, **reader_kwargs): + content = r.content.decode("utf-8") + + # la dernière ligne du fichier CSV est toujours vide + content = content.split("\n")[:-1] + if as_dict: + content = csv.DictReader(content, **reader_kwargs) + # en python3.7, content est une liste d'OrderedDicts + return list(map(dict, content)) + else: + content = csv.reader(content, **reader_kwargs) + return list(content) + + def assertCSVEqual(self, response, expected): + if type(expected[0]) == list: + as_dict = False + elif type(expected[0]) == dict: + as_dict = True + else: + raise AssertionError( + "Unsupported type in `assertCSVEqual`: " + "%(expected)s is not of type `list` nor `dict` !" + % {"expected": str(expected[0])} + ) + + content = self._load_from_csv_response(response, as_dict=as_dict) + self.assertCountEqual(content, expected) + + +class ICalMixin: + """ + Mixin pour manipuler des iCalendars. Permet de tester l'égalité entre + in iCal d'une part, et une liste d'évènements (représentés par des dicts) + d'autre part. + """ + + def _test_event_equal(self, event, exp): + """ + Les éléments du dict peuvent être de deux types: + - un tuple `(getter, expected_value)`, auquel cas on teste l'égalité + `getter(event[key]) == value)`; + - une variable `value` de n'importe quel autre type, auquel cas on teste + `event[key] == value`. + """ + for key, value in exp.items(): + if isinstance(value, tuple): + getter = value[0] + v = value[1] + else: + getter = lambda v: v + v = value + # dans un iCal, les fields sont en majuscules + if getter(event[key.upper()]) != v: + return False + return True + + def _find_event(self, ev, l): + for i, elt in enumerate(l): + if self._test_event_equal(ev, elt): + return i + return None + + def assertCalEqual(self, ical_content, expected): + remaining = expected.copy() + unexpected = [] + + cal = icalendar.Calendar.from_ical(ical_content) + + for ev in cal.walk("vevent"): + i_found = self._find_event(ev, remaining) + if i_found is not None: + remaining.pop(i_found) + else: + unexpected.append(ev) + + self.assertListEqual(remaining, []) + self.assertListEqual(unexpected, []) + + class TestCaseMixin: def assertForbidden(self, response): """ @@ -91,140 +223,69 @@ class TestCaseMixin: else: self.assertEqual(actual, expected) - def mockLDAP(self, results): - class Elt: - def __init__(self, value): - self.value = value - - class Entry: - def __init__(self, **kwargs): - for k, v in kwargs.items(): - setattr(self, k, Elt(v)) - - results_as_ldap = [Entry(uid=uid, cn=name) for uid, name in results] - - mock_connection = mock.MagicMock() - mock_connection.entries = results_as_ldap - - # Connection is used as a context manager. - mock_context_manager = mock.MagicMock() - mock_context_manager.return_value.__enter__.return_value = mock_connection - - patcher = mock.patch( - "gestioncof.autocomplete.Connection", new=mock_context_manager - ) - patcher.start() - self.addCleanup(patcher.stop) - - return mock_connection - - def load_from_csv_response(self, r): - decoded = r.content.decode("utf-8") - return list(csv.reader(decoded.split("\n")[:-1])) - - def _test_event_equal(self, event, exp): - for k, v_desc in exp.items(): - if isinstance(v_desc, tuple): - v_getter = v_desc[0] - v = v_desc[1] - else: - v_getter = lambda v: v - v = v_desc - if v_getter(event[k.upper()]) != v: - return False - return True - - def _find_event(self, ev, l): - for i, elt in enumerate(l): - if self._test_event_equal(ev, elt): - return elt, i - return False, -1 - - def assertCalEqual(self, ical_content, expected): - remaining = expected.copy() - unexpected = [] - - cal = icalendar.Calendar.from_ical(ical_content) - - for ev in cal.walk("vevent"): - found, i_found = self._find_event(ev, remaining) - if found: - remaining.pop(i_found) - else: - unexpected.append(ev) - - self.assertListEqual(unexpected, []) - self.assertListEqual(remaining, []) - class ViewTestCaseMixin(TestCaseMixin): """ - TestCase extension to ease tests of kfet views. + Utilitaire pour automatiser certains tests sur les vues Django. + Création d'utilisateurs + ------------------------ + # Données de base + On crée dans tous les cas deux utilisateurs : un utilisateur normal "user", + et un superutilisateur "root", avec un mot de passe identique au username. - Urls concerns - ------------- + # Accès et utilisateurs supplémentaires + Les utilisateurs créés sont accessibles dans le dict `self.users`, qui associe + un label à une instance de User. - # Basic usage + Pour rajouter des utilisateurs supplémentaires (et s'assurer qu'ils sont + disponibles dans `self.users`), on peut redéfinir la fonction `get_users_extra()`, + qui doit renvoyer là aussi un dict . - 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. + Misc QoL + ------------------------ + Pour éviter une erreur de login (puisque les messages de Django ne sont pas + disponibles), les messages de bienvenue de GestioCOF sont patchés. + Un attribut `self.now` est fixé au démarrage, pour être donné comme valeur + de retour à un patch local de `django.utils.timezone.now`. Cela permet de + tester des dates/heures de manière robuste. - View url can then be accessed at the 'url' attribute. + Test d'URLS + ------------------------ - # Advanced usage + # Usage basique + Teste que l'URL générée par `reverse` correspond bien à l'URL théorique. + Attributs liés : + - `url_name` : nom de l'URL qui sera donné à `reverse`, + - `url_expected` : URL attendue en retour. + - (optionnels) `url_args` et `url_kwargs` : arguments de l'URL pour `reverse`. - 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). + # Usage avancé + On peut tester plusieurs URLs pour une même vue, en redéfinissant la fonction + `urls_conf()`. Cette fonction doit retourner une liste de dicts, avec les clés + suivantes : `name`, `args`, `kwargs`, `expected`. - The reversed urls can be accessed at the 't_urls' attribute. + # Accès aux URLs générées + Dans le cas d'usage basique, l'attribut `self.url` contient l'URL de la vue testée + (telle que renvoyée par `reverse()`). Si plusieurs URLs sont définies dans + `urls_conf()`, elles sont accessibles par la suite dans `self.reversed_urls`. + Authentification + ------------------------ + Si l'attribut `auth_user` est dans `self.users`, l'utilisateur correspondant + est authentifié avant chaque test (cela n'empêche bien sûr pas de login un autre + utilisateur à la main). - Users concerns - -------------- - - During setup, the following users are created: - - 'user': a basic user without any permission, - - 'root': a superuser, account trigramme: 200. - Their password is their username. - - One can create additionnal users with 'get_users_extra' method, or prevent - these users to be created with 'get_users_base' method. See these two - methods for further informations. - - By using 'register_user' method, these users can then be accessed at - 'users' attribute by their label. - - A user label can be given to 'auth_user' attribute. The related user is - then authenticated on self.client during test setup. Its value defaults to - 'None', meaning no user is authenticated. - - - Automated tests - --------------- - - # Url reverse - - Based on url-related attributes/properties, the test 'test_urls' checks - that expected url is returned by 'reverse' (once with basic url usage and - each for advanced usage). - - # Forbidden responses - - The 'test_forbidden' test verifies that each user, from labels of - 'auth_forbidden' attribute, can't access the url(s), i.e. response should - be a 403, or a redirect to login view. - - Tested HTTP requests are given by 'http_methods' attribute. Additional data - can be given by defining an attribute '_data'. + Test de restrictions d'accès + ------------------------ + L'utilitaire vérifie automatiquement que certains utilisateurs n'ont pas accès à la + vue. Plus spécifiquement, sont testés toutes les méthodes dans `self.http_methods` + et tous les utilisateurs dans `self.auth_forbidden`. Pour rappel, l'utilisateur + `None` sert à tester la vue sans authentification. + On peut donner des paramètres GET/POST/etc. aux tests en définissant un attribut + _data. + TODO (?): faire pareil pour vérifier les GET/POST classiques (code 200) """ url_name = None @@ -239,19 +300,13 @@ class ViewTestCaseMixin(TestCaseMixin): """ Warning: Do not forget to call super().setUp() in subclasses. """ - # Signals handlers on login/logout send messages. - # Due to the way the Django' test Client performs login, this raise an - # error. As workaround, we mock the Django' messages module. + patcher_messages = mock.patch("gestioncof.signals.messages") patcher_messages.start() self.addCleanup(patcher_messages.stop) - # A test can mock 'django.utils.timezone.now' and give this as return - # value. E.g. it is useful if the test checks values of 'auto_now' or - # 'auto_now_add' fields. self.now = timezone.now() - # Register of User instances. self.users = {} for label, user in dict(self.users_base, **self.users_extra).items(): @@ -322,7 +377,7 @@ class ViewTestCaseMixin(TestCaseMixin): ] @property - def t_urls(self): + def reversed_urls(self): return [ reverse( url_conf["name"], @@ -335,16 +390,16 @@ class ViewTestCaseMixin(TestCaseMixin): @property def url(self): - return self.t_urls[0] + return self.reversed_urls[0] def test_urls(self): - for url, conf in zip(self.t_urls, self.urls_conf): + for url, conf in zip(self.reversed_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: + for url in self.reversed_urls: self.check_forbidden(method, url, user) def check_forbidden(self, method, url, user=None): diff --git a/shared/views.py b/shared/views.py new file mode 100644 index 00000000..fbff4ec0 --- /dev/null +++ b/shared/views.py @@ -0,0 +1,100 @@ +import base64 +from collections import namedtuple +from typing import Any + +from dal import autocomplete +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.exceptions import ImproperlyConfigured +from django.http import Http404, HttpResponse +from django.views.generic import TemplateView, View + +from shared.autocomplete import ModelSearch + +User = get_user_model() + + +class SympaListView(View): + realm = "sympa" + + username = settings.SYMPA_USERNAME + password = settings.SYMPA_PASSWORD + + filters: dict[str, Any] = {} + + def dispatch(self, request, *args, **kwargs): + if "HTTP_AUTHORIZATION" in request.META: + auth = request.META["HTTP_AUTHORIZATION"].split() + + if len(auth) == 2 and auth[0].lower() == "basic": + name, passwd = base64.b64decode(auth[1]).split(b":") + + if name == self.username and passwd == self.password: + return self.render_to_response(request, *args, **kwargs) + + return HttpResponse( + status=401, headers={"WWW-Authenticate": f'Basic realm="{self.realm}"'} + ) + + def render_to_response(self, request, *args, **kwargs): + """ + Renders a list of emails in a text response. + """ + + users = User.objects.filter(**self.filters) + + return HttpResponse( + b"\n".join(u.email.encode("utf-8") for u in users if u.email), + content_type="text/plain", + ) + + +class Select2QuerySetView(ModelSearch, autocomplete.Select2QuerySetView): + """Compatibility layer between ModelSearch and Select2QuerySetView.""" + + paginate_by = None + + def get_queryset(self): + keywords = self.q.split() + return super().search(keywords) + + +Section = namedtuple("Section", ("name", "verbose_name", "entries")) +Entry = namedtuple("Entry", ("verbose_name", "link")) + + +class AutocompleteView(TemplateView): + template_name = "shared/search_results.html" + search_composer = None + + def get_search_composer(self): + if self.search_composer is None: + raise ImproperlyConfigured("Please specify a search composer") + return self.search_composer + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + if "q" not in self.request.GET: + raise Http404 + q = self.request.GET["q"] + ctx["q"] = q + ctx["results"] = self.search(keywords=q.split()) + return ctx + + def search(self, keywords): + search_composer = self.get_search_composer() + raw_results = search_composer.search(keywords) + sections = [] + for name, unit in search_composer.search_units: + entries = [ + Entry( + verbose_name=unit.result_verbose_name(res), + link=unit.result_link(res), + ) + for res in raw_results[name] + ] + if entries: + sections.append( + Section(name=name, verbose_name=unit.verbose_name, entries=entries) + ) + return sections diff --git a/shell.nix b/shell.nix new file mode 100644 index 00000000..e426574e --- /dev/null +++ b/shell.nix @@ -0,0 +1,83 @@ +{ + sources ? import ./npins, + pkgs ? import sources.nixpkgs { overlays = [ ]; }, +}: + +let + nix-pkgs = import sources.nix-pkgs { inherit pkgs; }; + kat-pkgs = import sources.kat-pkgs { inherit pkgs; }; + + python3 = pkgs.python3.override { + packageOverrides = final: prev: { + inherit (nix-pkgs) + authens + django-bootstrap-form + django-cas-ng + loadcredential + ; + + inherit (kat-pkgs.python3Packages) + django-djconfig + django-hCaptcha + wagtail-modeltranslation + wagtailmenus + ; + }; + }; +in +pkgs.mkShell { + shellHook = '' + if [ ! -d .static ]; then + mkdir .static + fi + ''; + + env = { + CREDENTIALS_DIRECTORY = builtins.toString ./.credentials; + DJANGO_SETTINGS_MODULE = "gestioasso.settings.local"; + + GESTIOCOF_DEBUG = true; + GESTIOCOF_STATIC_ROOT = builtins.toString ./.static; + + GESTIOBDS_DEBUG = true; + GESTIOBDS_STATIC_ROOT = builtins.toString ./.static; + }; + + packages = [ + (python3.withPackages ( + ps: with ps; [ + authens + channels + configparser + django + django-autocomplete-light + django-bootstrap-form + django-cas-ng + django-cors-headers + django-djconfig + django-hCaptcha + django-js-reverse + django-widget-tweaks + icalendar + loadcredential + pillow + python-dateutil + statistics + wagtail + wagtail-modeltranslation + wagtailmenus + + django-debug-toolbar + ipython + black + flake8 + isort + + daphne + ] + )) + pkgs.npins + ]; + + allowSubstitutes = false; +} diff --git a/utils/views/autocomplete.py b/utils/views/autocomplete.py deleted file mode 100644 index c5d51343..00000000 --- a/utils/views/autocomplete.py +++ /dev/null @@ -1,25 +0,0 @@ -from dal import autocomplete -from django.db.models import Q - - -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())