Compare commits

..

3 commits

Author SHA1 Message Date
Martin Pépin
4ecff9f909
Remote dead code 2020-07-04 12:42:39 +02:00
Martin Pépin
dc79de4031
Make flake8 happy 2020-07-02 00:19:45 +02:00
Martin Pépin
4d96d2c645
Autocompletion refactoring + generic view 2020-07-02 00:19:45 +02:00
527 changed files with 24420 additions and 8850 deletions

1
.envrc
View file

@ -1 +0,0 @@
use nix

4
.gitignore vendored
View file

@ -5,7 +5,6 @@ cof/settings.py
settings.py settings.py
*~ *~
venv/ venv/
.venv/
.vagrant .vagrant
/src /src
media/ media/
@ -19,5 +18,4 @@ media/
.cache .cache
# VSCode # VSCode
.vscode/ .vscode/
.direnv

View file

@ -17,15 +17,12 @@ variables:
# psql password authentication # psql password authentication
PGPASSWORD: $POSTGRES_PASSWORD PGPASSWORD: $POSTGRES_PASSWORD
# apps to check migrations for
MIGRATION_APPS: "bda bds cofcms clubs events gestioncof kfet kfetauth kfetcms open petitscours shared"
.test_template: .test_template:
before_script: before_script:
- mkdir -p vendor/{pip,apt} - mkdir -p vendor/{pip,apt}
- apt-get update -q && apt-get -o dir::cache::archives="vendor/apt" install -yqq postgresql-client libldap2-dev libsasl2-dev - 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 -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 = "";' gestioasso/settings/secret.py - sed -i.bak -E 's;^REDIS_PASSWD = .*$;REDIS_PASSWD = "";' cof/settings/secret.py
# Remove the old test database if it has not been done yet # 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" - psql --username=$POSTGRES_USER --host=$DBHOST -c "DROP DATABASE IF EXISTS test_$POSTGRES_DB"
- pip install --upgrade -r requirements-prod.txt coverage tblib - pip install --upgrade -r requirements-prod.txt coverage tblib
@ -47,7 +44,7 @@ coftest:
stage: test stage: test
extends: .test_template extends: .test_template
variables: variables:
DJANGO_SETTINGS_MODULE: "gestioasso.settings.cof_prod" DJANGO_SETTINGS_MODULE: "cof.settings.cof_prod"
script: script:
- coverage run manage.py test gestioncof bda kfet petitscours shared --parallel - coverage run manage.py test gestioncof bda kfet petitscours shared --parallel
@ -55,7 +52,7 @@ bdstest:
stage: test stage: test
extends: .test_template extends: .test_template
variables: variables:
DJANGO_SETTINGS_MODULE: "gestioasso.settings.bds_prod" DJANGO_SETTINGS_MODULE: "cof.settings.bds_prod"
script: script:
- coverage run manage.py test bds clubs events --parallel - coverage run manage.py test bds clubs events --parallel
@ -66,9 +63,9 @@ linters:
- pip install --upgrade black isort flake8 - pip install --upgrade black isort flake8
script: script:
- black --check . - black --check .
- isort --check --diff . - isort --recursive --check-only --diff bda bds clubs cof events gestioncof kfet petitscours provisioning shared
# Print errors only # Print errors only
- flake8 --exit-zero bda bds clubs gestioasso events gestioncof kfet petitscours provisioning shared - flake8 --exit-zero bda bds clubs cof events gestioncof kfet petitscours provisioning shared
cache: cache:
key: linters key: linters
paths: paths:
@ -78,14 +75,14 @@ linters:
migration_checks: migration_checks:
stage: test stage: test
variables: variables:
DJANGO_SETTINGS_MODULE: "gestioasso.settings.local" DJANGO_SETTINGS_MODULE: "cof.settings.local"
before_script: before_script:
- mkdir -p vendor/{pip,apt} - mkdir -p vendor/{pip,apt}
- apt-get update -q && apt-get -o dir::cache::archives="vendor/apt" install -yqq postgresql-client libldap2-dev libsasl2-dev - 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 - cp cof/settings/secret_example.py cof/settings/secret.py
- pip install --upgrade -r requirements-devel.txt - pip install --upgrade -r requirements-devel.txt
- python --version - python --version
script: python manage.py makemigrations --dry-run --check $MIGRATION_APPS script: python manage.py makemigrations --dry-run --check
services: services:
# this should not be necessary… # this should not be necessary…
- postgres:11.7 - postgres:11.7

View file

@ -48,7 +48,7 @@ if type isort &>/dev/null; then
ISORT_OUTPUT="/tmp/gc-isort-output.log" ISORT_OUTPUT="/tmp/gc-isort-output.log"
touch $ISORT_OUTPUT touch $ISORT_OUTPUT
if ! echo "$STAGED_PYTHON_FILES" | xargs -d'\n' isort --check &>$ISORT_OUTPUT; then if ! echo "$STAGED_PYTHON_FILES" | xargs -d'\n' isort --check-only &>$ISORT_OUTPUT; then
echo "$STAGED_PYTHON_FILES" | xargs -d'\n' isort &>$ISORT_OUTPUT echo "$STAGED_PYTHON_FILES" | xargs -d'\n' isort &>$ISORT_OUTPUT
printf "Reformatted.\n" printf "Reformatted.\n"
formatter_updated=1 formatter_updated=1

View file

@ -16,205 +16,25 @@ Liste des changements notables dans GestioCOF depuis la version 0.1 (septembre
- Vue d'inscription à un événement **ou** intégration propre dans la vue - Vue d'inscription à un événement **ou** intégration propre dans la vue
"inscription d'un nouveau membre" "inscription d'un nouveau membre"
### Nouveau module BDS
Uniquement un modèle BDSProfile pour le moment…
### Nouveau module de gestion des clubs ### Nouveau module de gestion des clubs
Uniquement un modèle simple de clubs avec des respos. Aucune gestion des Uniquement un modèle simple de clubs avec des respos. Aucune gestion des
adhérents ni des cotisations. adhérents ni des cotisations.
## TODO Prod ## Upcoming
- 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
## 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 ### 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 - Bug d'affichage quand on a beaucoup de clubs dans le cadre "Accès rapide" sur
la page des clubs (nouveau site du COF) la page des clubs
- Version mobile plus ergonimique sur le 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 - Cliquer sur "visualiser" sur les pages de clubs dans wagtail ne provoque plus
d'erreurs 500 (nouveau site du COF) d'erreurs 500.
- L'historique des ventes des articles K-Fêt fonctionne à nouveau - L'historique des ventes des articles fonctionne à nouveau
- Les montants en K-Fêt sont à nouveau affichés en UKF (et non en €). - 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 - Les boutons "afficher/cacher" des mails et noms des participant⋅e⋅s à un
spectacle BdA fonctionnent à nouveau. spectacle BdA fonctionnent à nouveau.
@ -223,7 +43,7 @@ deux bases de données sont distinctes.
### Nouvelles fonctionnalités ### Nouvelles fonctionnalités
- On n'affiche que 4 articles sur la pages "nouveautés" (nouveau site du COF) - On n'affiche que 4 articles sur la pages "nouveautés"
- Plus de traductions sur le 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 - Les transferts apparaissent maintenant dans l'historique K-Fêt et l'historique
personnel. personnel.

View file

@ -1,4 +1,4 @@
# GestioCOF / GestioBDS # GestioCOF
[![pipeline status](https://git.eleves.ens.fr/cof-geek/gestioCOF/badges/master/pipeline.svg)](https://git.eleves.ens.fr/cof-geek/gestioCOF/commits/master) [![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) [![coverage report](https://git.eleves.ens.fr/cof-geek/gestioCOF/badges/master/coverage.svg)](https://git.eleves.ens.fr/cof-geek/gestioCOF/commits/master)
@ -38,11 +38,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 -U pip # parfois nécessaire la première fois
pip install -r requirements-devel.txt pip install -r requirements-devel.txt
Pour terminer, copier le fichier `gestioasso/settings/secret_example.py` vers Pour terminer, copier le fichier `cof/settings/secret_example.py` vers
`gestioasso/settings/secret.py`. Sous Linux ou Mac, préférez plutôt un lien symbolique `cof/settings/secret.py`. Sous Linux ou Mac, préférez plutôt un lien symbolique
pour profiter de façon transparente des mises à jour du fichier: pour profiter de façon transparente des mises à jour du fichier:
ln -s secret_example.py gestioasso/settings/secret.py ln -s secret_example.py cof/settings/secret.py
Nous avons un git hook de pre-commit pour formatter et vérifier que votre code 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 vérifie nos conventions. Pour bénéficier des mises à jour du hook, préférez

42
Vagrantfile vendored
View file

@ -1,19 +1,47 @@
# -*- mode: ruby -*- # -*- mode: ruby -*-
# vi: set ft=ruby : # vi: set ft=ruby :
# Configuration de base pour GestioCOF. # All Vagrant configuration is done below. The "2" in Vagrant.configure
# Voir https://docs.vagrantup.com pour plus d'informations. # configures the configuration version (we support older styles for
# backwards compatibility). Please don't change it unless you know what
# you're doing.
Vagrant.configure(2) do |config| Vagrant.configure(2) do |config|
# On se base sur Debian 10 (Buster) pour avoir le même environnement qu'en # The most common configuration options are documented and commented below.
# production. # For a complete reference, please see the online documentation at
config.vm.box = "debian/contrib-buster64" # https://docs.vagrantup.com.
config.vm.box = "ubuntu/xenial64"
# On associe le port 80 dans la machine virtuelle avec le port 8080 de notre # On associe le port 80 dans la machine virtuelle avec le port 8080 de notre
# ordinateur, et le port 8000 avec le port 8000. # ordinateur, et le port 8000 avec le port 8000.
config.vm.network :forwarded_port, guest: 80, host: 8080 config.vm.network :forwarded_port, guest: 80, host: 8080
config.vm.network :forwarded_port, guest: 8000, host: 8000 config.vm.network :forwarded_port, guest: 8000, host: 8000
# Le restes de la configuration (installation de paquets, etc) est géré un # Create a private network, which allows host-only access to the machine
# script shell. # 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
config.vm.provision :shell, path: "provisioning/bootstrap.sh" config.vm.provision :shell, path: "provisioning/bootstrap.sh"
end end

0
bda/__init__.py Normal file
View file

363
bda/admin.py Normal file
View file

@ -0,0 +1,363 @@
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.db.models import Count, Q, Sum
from django.template.defaultfilters import pluralize
from django.utils import timezone
from bda.models import (
Attribution,
CategorieSpectacle,
ChoixSpectacle,
Participant,
Quote,
Salle,
Spectacle,
SpectacleRevente,
Tirage,
)
class ReadOnlyMixin(object):
readonly_fields_update = ()
def get_readonly_fields(self, request, obj=None):
readonly_fields = super().get_readonly_fields(request, obj)
if obj is None:
return readonly_fields
else:
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
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
spectacles = Spectacle.objects.select_related("location")
if self.listing is not None:
spectacles = spectacles.filter(listing=self.listing)
self.fields["spectacle"].queryset = spectacles
class WithoutListingAttributionTabularAdminForm(AttributionTabularAdminForm):
listing = False
class WithListingAttributionTabularAdminForm(AttributionTabularAdminForm):
listing = True
class AttributionInline(admin.TabularInline):
model = Attribution
extra = 0
listing = None
def get_queryset(self, request):
qs = super().get_queryset(request)
if self.listing is not None:
qs = qs.filter(spectacle__listing=self.listing)
return qs
class WithListingAttributionInline(AttributionInline):
exclude = ("given",)
form = WithListingAttributionTabularAdminForm
listing = True
verbose_name_plural = "Attributions sur listing"
class WithoutListingAttributionInline(AttributionInline):
form = WithoutListingAttributionTabularAdminForm
listing = False
verbose_name_plural = "Attributions hors listing"
class ParticipantAdminForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["choicesrevente"].queryset = Spectacle.objects.select_related(
"location"
)
class ParticipantPaidFilter(admin.SimpleListFilter):
"""
Permet de filtrer les participants sur s'ils ont payé leurs places ou pas
"""
title = "A payé"
parameter_name = "paid"
def lookups(self, request, model_admin):
return ((True, "Oui"), (False, "Non"))
def queryset(self, request, queryset):
return queryset.filter(paid=self.value())
class ParticipantAdmin(ReadOnlyMixin, admin.ModelAdmin):
inlines = [WithListingAttributionInline, WithoutListingAttributionInline]
def get_queryset(self, request):
return self.model.objects.annotate_paid().annotate(
nb_places=Count("attributions"),
remain=Sum(
"attribution__spectacle__price", filter=Q(attribution__paid=False)
),
total=Sum("attributions__price"),
)
def nb_places(self, obj):
return obj.nb_places
nb_places.admin_order_field = "nb_places"
nb_places.short_description = "Nombre de places"
def paid(self, obj):
return obj.paid
paid.short_description = "A payé"
paid.boolean = True
paid.admin_order_field = "paid"
def total(self, obj):
tot = obj.total
if tot:
return "%.02f" % tot
else:
return "0 €"
total.admin_order_field = "total"
total.short_description = "Total des places"
def remain(self, obj):
rem = obj.remain
if rem:
return "%.02f" % rem
else:
return "0 €"
remain.admin_order_field = "remain"
remain.short_description = "Reste à payer"
list_display = ("user", "nb_places", "total", "paid", "remain", "tirage")
list_filter = (ParticipantPaidFilter, "tirage")
search_fields = ("user__username", "user__first_name", "user__last_name")
actions = ["send_attribs"]
actions_on_bottom = True
list_per_page = 400
readonly_fields = ("total", "paid")
readonly_fields_update = ("user", "tirage")
form = ParticipantAdminForm
def send_attribs(self, request, queryset):
datatuple = []
for member in queryset.all():
attribs = member.attributions.all()
context = {"member": member.user}
shortname = ""
if len(attribs) == 0:
shortname = "bda-attributions-decus"
else:
shortname = "bda-attributions"
context["places"] = attribs
print(context)
datatuple.append((shortname, context, "bda@ens.fr", [member.user.email]))
send_mass_custom_mail(datatuple)
count = len(queryset.all())
if count == 1:
message_bit = "1 membre a"
plural = ""
else:
message_bit = "%d membres ont" % count
plural = "s"
self.message_user(
request, "%s été informé%s avec succès." % (message_bit, plural)
)
send_attribs.short_description = "Envoyer les résultats par mail"
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")
spectacle = cleaned_data.get("spectacle")
if participant and spectacle:
if participant.tirage != spectacle.tirage:
raise forms.ValidationError(
"Erreur : le participant et le spectacle n'appartiennent"
"pas au même tirage"
)
return cleaned_data
class AttributionAdmin(ReadOnlyMixin, admin.ModelAdmin):
list_display = ("id", "spectacle", "participant", "given", "paid")
search_fields = (
"spectacle__title",
"participant__user__username",
"participant__user__first_name",
"participant__user__last_name",
)
form = AttributionAdminForm
readonly_fields_update = ("spectacle", "participant")
class ChoixSpectacleAdmin(admin.ModelAdmin):
form = ChoixSpectacleAdminForm
def tirage(self, obj):
return obj.participant.tirage
list_display = ("participant", "tirage", "spectacle", "priority", "double_choice")
list_filter = ("double_choice", "participant__tirage")
search_fields = (
"participant__user__username",
"participant__user__first_name",
"participant__user__last_name",
"spectacle__title",
)
class QuoteInline(admin.TabularInline):
model = Quote
class SpectacleAdmin(admin.ModelAdmin):
inlines = [QuoteInline]
model = Spectacle
list_display = ("title", "date", "tirage", "location", "slots", "price", "listing")
list_filter = ("location", "tirage")
search_fields = ("title", "location__name")
readonly_fields = ("rappel_sent",)
class TirageAdmin(admin.ModelAdmin):
model = Tirage
list_display = ("title", "ouverture", "fermeture", "active", "enable_do_tirage")
readonly_fields = ("tokens",)
list_filter = ("active",)
search_fields = ("title",)
class SalleAdmin(admin.ModelAdmin):
model = Salle
search_fields = ("name", "address")
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"
)
class SpectacleReventeAdmin(admin.ModelAdmin):
"""
Administration des reventes de spectacles
"""
model = SpectacleRevente
def spectacle(self, obj):
"""
Raccourci vers le spectacle associé à la revente.
"""
return obj.attribution.spectacle
list_display = ("spectacle", "seller", "date", "soldTo")
raw_id_fields = ("attribution",)
readonly_fields = ("date_tirage",)
search_fields = [
"attribution__spectacle__title",
"seller__user__username",
"seller__user__first_name",
"seller__user__last_name",
]
actions = ["transfer", "reinit"]
actions_on_bottom = True
form = SpectacleReventeAdminForm
def transfer(self, request, queryset):
"""
Effectue le transfert des reventes pour lesquels on connaît l'acheteur.
"""
reventes = queryset.exclude(soldTo__isnull=True).all()
count = reventes.count()
for revente in reventes:
attrib = revente.attribution
attrib.participant = revente.soldTo
attrib.save()
self.message_user(
request,
"%d attribution%s %s été transférée%s avec succès."
% (count, pluralize(count), pluralize(count, "a,ont"), pluralize(count)),
)
transfer.short_description = "Transférer les reventes sélectionnées"
def reinit(self, request, queryset):
"""
Réinitialise les reventes.
"""
count = queryset.count()
for revente in queryset.filter(
attribution__spectacle__date__gte=timezone.now()
):
revente.reset(new_date=timezone.now() - timedelta(hours=1))
self.message_user(
request,
"%d attribution%s %s été réinitialisée%s avec succès."
% (count, pluralize(count), pluralize(count, "a,ont"), pluralize(count)),
)
reinit.short_description = "Réinitialiser les reventes sélectionnées"
admin.site.register(CategorieSpectacle)
admin.site.register(Spectacle, SpectacleAdmin)
admin.site.register(Salle, SalleAdmin)
admin.site.register(Participant, ParticipantAdmin)
admin.site.register(Attribution, AttributionAdmin)
admin.site.register(ChoixSpectacle, ChoixSpectacleAdmin)
admin.site.register(Tirage, TirageAdmin)
admin.site.register(SpectacleRevente, SpectacleReventeAdmin)

103
bda/algorithm.py Normal file
View file

@ -0,0 +1,103 @@
import random
class Algorithm(object):
shows = None
ranks = None
origranks = None
double = None
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"""
self.max_group = 2 * max(choice.priority for choice in choices)
self.shows = []
showdict = {}
for show in shows:
show.nrequests = 0
showdict[show] = show
show.requests = []
self.shows.append(show)
self.ranks = {}
self.origranks = {}
self.choices = {}
next_rank = {}
member_shows = {}
for member in members:
self.ranks[member] = {}
self.choices[member] = {}
next_rank[member] = 1
member_shows[member] = {}
for choice in choices:
member = choice.participant
if choice.spectacle in member_shows[member]:
continue
else:
member_shows[member][choice.spectacle] = True
showdict[choice.spectacle].requests.append(member)
showdict[choice.spectacle].nrequests += 2 if choice.double else 1
self.ranks[member][choice.spectacle] = next_rank[member]
next_rank[member] += 2 if choice.double else 1
self.choices[member][choice.spectacle] = choice
for member in members:
self.origranks[member] = dict(self.ranks[member])
def IncrementRanks(self, member, currank, increment=1):
for show in self.ranks[member]:
if self.ranks[member][show] > currank:
self.ranks[member][show] -= increment
def appendResult(self, l, member, show):
l.append(
(
member,
self.ranks[member][show],
self.origranks[member][show],
self.choices[member][show].double,
)
)
def __call__(self, seed):
random.seed(seed)
results = []
shows = sorted(self.shows, key=lambda x: x.nrequests / x.slots, reverse=True)
for show in shows:
# On regroupe tous les gens ayant le même rang
groups = dict([(i, []) for i in range(1, self.max_group + 1)])
for member in show.requests:
if self.ranks[member][show] == 0:
raise RuntimeError(member, show.title)
groups[self.ranks[member][show]].append(member)
# On passe à l'attribution
winners = []
losers = []
for i in range(1, self.max_group + 1):
group = list(groups[i])
random.shuffle(group)
for member in group:
if self.choices[member][show].double: # double
if len(winners) + 1 < show.slots:
self.appendResult(winners, member, show)
self.appendResult(winners, member, show)
elif (
not self.choices[member][show].autoquit
and len(winners) < show.slots
):
self.appendResult(winners, member, show)
self.appendResult(losers, member, show)
else:
self.appendResult(losers, member, show)
self.appendResult(losers, member, show)
self.IncrementRanks(member, i, 2)
else: # simple
if len(winners) < show.slots:
self.appendResult(winners, member, show)
else:
self.appendResult(losers, member, show)
self.IncrementRanks(member, i)
results.append((show, winners, losers))
return results

184
bda/forms.py Normal file
View file

@ -0,0 +1,184 @@
from django import forms
from django.forms.models import BaseInlineFormSet
from django.template import loader
from django.utils import timezone
from bda.models import Attribution, Spectacle, SpectacleRevente
class InscriptionInlineFormSet(BaseInlineFormSet):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# self.instance is a Participant object
tirage = self.instance.tirage
# set once for all "spectacle" field choices
# - restrict choices to the spectacles of this tirage
# - force_choices avoid many db requests
spectacles = tirage.spectacle_set.select_related("location")
choices = [(sp.pk, str(sp)) for sp in spectacles]
self.force_choices("spectacle", choices)
def force_choices(self, name, choices):
"""Set choices of a field.
As ModelChoiceIterator (default use to get choices of a
ModelChoiceField), it appends an empty selection if requested.
"""
for form in self.forms:
field = form.fields[name]
if field.empty_label is not None:
field.choices = [("", field.empty_label)] + choices
else:
field.choices = choices
class TokenForm(forms.Form):
token = forms.CharField(widget=forms.widgets.Textarea())
class TemplateLabelField(forms.ModelMultipleChoiceField):
"""
Extends ModelMultipleChoiceField to offer two more customization options :
- `label_from_instance` can be used with a template file
- the widget rendering template can be specified with `option_template_name`
"""
def __init__(
self,
label_template_name=None,
context_object_name="obj",
option_template_name=None,
*args,
**kwargs
):
super().__init__(*args, **kwargs)
self.label_template_name = label_template_name
self.context_object_name = context_object_name
if option_template_name is not None:
self.widget.option_template_name = option_template_name
def label_from_instance(self, obj):
if self.label_template_name is None:
return super().label_from_instance(obj)
else:
return loader.render_to_string(
self.label_template_name, context={self.context_object_name: obj}
)
# Formulaires pour revente_manage
class ResellForm(forms.Form):
def __init__(self, participant, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["attributions"] = TemplateLabelField(
queryset=participant.attribution_set.filter(
spectacle__date__gte=timezone.now()
)
.exclude(revente__seller=participant)
.select_related("spectacle", "spectacle__location", "participant__user"),
widget=forms.CheckboxSelectMultiple,
required=False,
label_template_name="bda/forms/attribution_label_table.html",
option_template_name="bda/forms/checkbox_table.html",
context_object_name="attribution",
)
class AnnulForm(forms.Form):
def __init__(self, participant, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["reventes"] = TemplateLabelField(
label="",
queryset=participant.original_shows.filter(
attribution__spectacle__date__gte=timezone.now(), soldTo__isnull=True
)
.select_related(
"attribution__spectacle", "attribution__spectacle__location"
)
.order_by("-date"),
widget=forms.CheckboxSelectMultiple,
required=False,
label_template_name="bda/forms/revente_self_label_table.html",
option_template_name="bda/forms/checkbox_table.html",
context_object_name="revente",
)
class SoldForm(forms.Form):
def __init__(self, participant, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["reventes"] = TemplateLabelField(
queryset=participant.original_shows.filter(soldTo__isnull=False)
.exclude(soldTo=participant)
.select_related(
"attribution__spectacle", "attribution__spectacle__location"
),
widget=forms.CheckboxSelectMultiple,
label_template_name="bda/forms/revente_sold_label_table.html",
option_template_name="bda/forms/checkbox_table.html",
context_object_name="revente",
)
# Formulaire pour revente_subscribe
class InscriptionReventeForm(forms.Form):
def __init__(self, tirage, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["spectacles"] = TemplateLabelField(
queryset=tirage.spectacle_set.select_related("location").filter(
date__gte=timezone.now()
),
widget=forms.CheckboxSelectMultiple,
required=False,
label_template_name="bda/forms/spectacle_label_table.html",
option_template_name="bda/forms/checkbox_table.html",
context_object_name="spectacle",
)
# Formulaires pour revente_tirages
class ReventeTirageAnnulForm(forms.Form):
def __init__(self, participant, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["reventes"] = TemplateLabelField(
queryset=participant.entered.filter(soldTo__isnull=True).select_related(
"attribution__spectacle", "seller__user"
),
widget=forms.CheckboxSelectMultiple,
required=False,
label_template_name="bda/forms/revente_other_label_table.html",
option_template_name="bda/forms/checkbox_table.html",
context_object_name="revente",
)
class ReventeTirageForm(forms.Form):
def __init__(self, participant, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["reventes"] = TemplateLabelField(
queryset=(
SpectacleRevente.objects.filter(
notif_sent=True,
shotgun=False,
tirage_done=False,
attribution__spectacle__tirage=participant.tirage,
)
.exclude(confirmed_entry=participant)
.select_related("attribution__spectacle")
),
widget=forms.CheckboxSelectMultiple,
required=False,
label_template_name="bda/forms/revente_other_label_table.html",
option_template_name="bda/forms/checkbox_table.html",
context_object_name="revente",
)

View file

View file

View file

@ -0,0 +1,101 @@
"""
Crée deux tirages de test et y inscrit les utilisateurs
"""
import os
import random
from django.contrib.auth.models import User
from django.utils import timezone
from bda.models import ChoixSpectacle, Participant, Salle, Spectacle, Tirage
from bda.views import do_tirage
from gestioncof.management.base import MyBaseCommand
# Où sont stockés les fichiers json
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
class Command(MyBaseCommand):
help = "Crée deux tirages de test et y inscrit les utilisateurs."
def handle(self, *args, **options):
# ---
# Tirages
# ---
Tirage.objects.all().delete()
Tirage.objects.bulk_create(
[
Tirage(
title="Tirage de test 1",
ouverture=timezone.now() - timezone.timedelta(days=7),
fermeture=timezone.now(),
active=True,
),
Tirage(
title="Tirage de test 2",
ouverture=timezone.now(),
fermeture=timezone.now() + timezone.timedelta(days=60),
active=True,
),
]
)
tirages = Tirage.objects.all()
# ---
# Salles
# ---
locations = self.from_json("locations.json", DATA_DIR, Salle)
# ---
# Spectacles
# ---
def show_callback(show):
"""
Assigne un tirage, une date et un lieu à un spectacle et décide si
les places sont sur listing.
"""
show.tirage = random.choice(tirages)
show.listing = bool(random.randint(0, 1))
show.date = show.tirage.fermeture + timezone.timedelta(
days=random.randint(60, 90)
)
show.location = random.choice(locations)
return show
shows = self.from_json("shows.json", DATA_DIR, Spectacle, show_callback)
# ---
# Inscriptions
# ---
self.stdout.write("Inscription des utilisateurs aux tirages")
ChoixSpectacle.objects.all().delete()
choices = []
for user in User.objects.filter(profile__is_cof=True):
for tirage in tirages:
part, _ = Participant.objects.get_or_create(user=user, tirage=tirage)
shows = random.sample(
list(tirage.spectacle_set.all()), tirage.spectacle_set.count() // 2
)
for (rank, show) in enumerate(shows):
choices.append(
ChoixSpectacle(
participant=part,
spectacle=show,
priority=rank + 1,
double_choice=random.choice(["1", "double", "autoquit"]),
)
)
ChoixSpectacle.objects.bulk_create(choices)
self.stdout.write("- {:d} inscriptions générées".format(len(choices)))
# ---
# On lance le premier tirage
# ---
self.stdout.write("Lancement du premier tirage")
do_tirage(tirages[0], "dummy_token")

View file

@ -0,0 +1,49 @@
"""
Gestion en ligne de commande des reventes.
"""
from django.core.management import BaseCommand
from django.utils import timezone
from bda.models import SpectacleRevente
class Command(BaseCommand):
help = (
"Envoie les mails de notification et effectue les tirages au sort des reventes"
)
leave_locale_alone = True
def handle(self, *args, **options):
now = timezone.now()
reventes = SpectacleRevente.objects.all()
for revente in reventes:
# Le spectacle est bientôt et on a pas encore envoyé de mail :
# on met la place au shotgun et on prévient.
if revente.is_urgent and not revente.notif_sent:
if revente.can_notif:
self.stdout.write(str(now))
revente.mail_shotgun()
self.stdout.write(
"Mails de disponibilité immédiate envoyés "
"pour la revente [%s]" % revente
)
# Le spectacle est dans plus longtemps : on prévient
elif revente.can_notif and not revente.notif_sent:
self.stdout.write(str(now))
revente.send_notif()
self.stdout.write(
"Mails d'inscription à la revente [%s] envoyés" % revente
)
# On fait le tirage
elif now >= revente.date_tirage and not revente.tirage_done:
self.stdout.write(str(now))
winner = revente.tirage()
self.stdout.write("Tirage effectué pour la revente [%s]" % revente)
if winner:
self.stdout.write("Gagnant : %s" % winner.user)
else:
self.stdout.write("Pas de gagnant ; place au shotgun")

View file

@ -0,0 +1,33 @@
"""
Gestion en ligne de commande des mails de rappel.
"""
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from bda.models import Spectacle
class Command(BaseCommand):
help = (
"Envoie les mails de rappel des spectacles dont la date approche.\n"
"Ne renvoie pas les mails déjà envoyés."
)
leave_locale_alone = True
def handle(self, *args, **options):
now = timezone.now()
delay = timedelta(days=4)
shows = (
Spectacle.objects.filter(date__range=(now, now + delay))
.filter(tirage__active=True)
.filter(rappel_sent__isnull=True)
.all()
)
for show in shows:
show.send_rappel()
self.stdout.write("Mails de rappels pour %s envoyés avec succès." % show)
if not shows:
self.stdout.write("Aucun mail à envoyer.")

View file

@ -0,0 +1,26 @@
[
{
"name": "Cour\u00f4",
"address": "45 rue d'Ulm, cour\u00f4"
},
{
"name": "K-F\u00eat",
"address": "45 rue d'Ulm, escalier C, niveau -1"
},
{
"name": "Th\u00e9\u00e2tre",
"address": "45 rue d'Ulm, escalier C, niveau -1"
},
{
"name": "Cours Pasteur",
"address": "45 rue d'Ulm, cours pasteur"
},
{
"name": "Salle des actes",
"address": "45 rue d'Ulm, escalier A, niveau 1"
},
{
"name": "Amphi Rataud",
"address": "45 rue d'Ulm, NIR, niveau PB"
}
]

View file

@ -0,0 +1,100 @@
[
{
"description": "Jazz / Funk",
"title": "Un super concert",
"price": 10.0,
"slots_description": "Debout",
"slots": 5
},
{
"description": "Homemade",
"title": "Une super pi\u00e8ce",
"price": 10.0,
"slots_description": "Assises",
"slots": 60
},
{
"description": "Plein air, soleil, bonne musique",
"title": "Concert pour la f\u00eate de la musique",
"price": 5.0,
"slots_description": "Debout, attention \u00e0 la fontaine",
"slots": 30
},
{
"description": "Sous le regard s\u00e9v\u00e8re de Louis Pasteur",
"title": "Op\u00e9ra sans d\u00e9cors",
"price": 5.0,
"slots_description": "Assis sur l'herbe",
"slots": 20
},
{
"description": "Buffet \u00e0 la fin",
"title": "Concert Trouv\u00e8re",
"price": 20.0,
"slots_description": "Assises",
"slots": 15
},
{
"description": "Vive les maths",
"title": "Dessin \u00e0 la craie sur tableau noir",
"price": 10.0,
"slots_description": "Assises, tablette pour prendre des notes",
"slots": 30
},
{
"description": "Une pi\u00e8ce \u00e0 un personnage",
"title": "D\u00e9cors, d\u00e9montage en musique",
"price": 0.0,
"slots_description": "Assises",
"slots": 20
},
{
"description": "Annulera, annulera pas\u00a0?",
"title": "La Nuit",
"price": 27.0,
"slots_description": "",
"slots": 1000
},
{
"description": "Le boum fait sa carte blanche",
"title": "Turbomix",
"price": 10.0,
"slots_description": "Debout les mains en l'air",
"slots": 20
},
{
"description": "Unique repr\u00e9sentation",
"title": "Carinettes et trombone",
"price": 15.0,
"slots_description": "Chaises ikea",
"slots": 10
},
{
"description": "Suivi d'une jam session",
"title": "Percussion sur rondins",
"price": 5.0,
"slots_description": "B\u00fbches",
"slots": 14
},
{
"description": "\u00c9preuve sportive et artistique",
"title": "Bassin aux ernests, nage libre",
"price": 5.0,
"slots_description": "Humides",
"slots": 10
},
{
"description": "Sonore",
"title": "Chant du barde",
"price": 13.0,
"slots_description": "Ne venez pas",
"slots": 20
},
{
"description": "Cocorico",
"title": "Chant du coq",
"price": 4.0,
"slots_description": "bancs",
"slots": 15
}
]

View file

@ -0,0 +1,206 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
operations = [
migrations.CreateModel(
name="Attribution",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("given", models.BooleanField(default=False, verbose_name="Donn\xe9e")),
],
),
migrations.CreateModel(
name="ChoixSpectacle",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
(
"priority",
models.PositiveIntegerField(verbose_name=b"Priorit\xc3\xa9"),
),
(
"double_choice",
models.CharField(
default=b"1",
max_length=10,
verbose_name=b"Nombre de places",
choices=[
(b"1", b"1 place"),
(b"autoquit", b"2 places si possible, 1 sinon"),
(b"double", b"2 places sinon rien"),
],
),
),
],
options={
"ordering": ("priority",),
"verbose_name": "voeu",
"verbose_name_plural": "voeux",
},
),
migrations.CreateModel(
name="Participant",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("paid", models.BooleanField(default=False, verbose_name="A pay\xe9")),
(
"paymenttype",
models.CharField(
blank=True,
max_length=6,
verbose_name="Moyen de paiement",
choices=[
(b"cash", "Cash"),
(b"cb", b"CB"),
(b"cheque", "Ch\xe8que"),
(b"autre", "Autre"),
],
),
),
],
),
migrations.CreateModel(
name="Salle",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("name", models.CharField(max_length=300, verbose_name=b"Nom")),
("address", models.TextField(verbose_name=b"Adresse")),
],
),
migrations.CreateModel(
name="Spectacle",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("title", models.CharField(max_length=300, verbose_name=b"Titre")),
("date", models.DateTimeField(verbose_name=b"Date & heure")),
(
"description",
models.TextField(verbose_name=b"Description", blank=True),
),
(
"slots_description",
models.TextField(
verbose_name=b"Description des places", blank=True
),
),
(
"price",
models.FloatField(verbose_name=b"Prix d'une place", blank=True),
),
("slots", models.IntegerField(verbose_name=b"Places")),
(
"priority",
models.IntegerField(default=1000, verbose_name=b"Priorit\xc3\xa9"),
),
(
"location",
models.ForeignKey(to="bda.Salle", on_delete=models.CASCADE),
),
],
options={
"ordering": ("priority", "date", "title"),
"verbose_name": "Spectacle",
},
),
migrations.AddField(
model_name="participant",
name="attributions",
field=models.ManyToManyField(
related_name="attributed_to",
through="bda.Attribution",
to="bda.Spectacle",
),
),
migrations.AddField(
model_name="participant",
name="choices",
field=models.ManyToManyField(
related_name="chosen_by",
through="bda.ChoixSpectacle",
to="bda.Spectacle",
),
),
migrations.AddField(
model_name="participant",
name="user",
field=models.OneToOneField(
to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE
),
),
migrations.AddField(
model_name="choixspectacle",
name="participant",
field=models.ForeignKey(to="bda.Participant", on_delete=models.CASCADE),
),
migrations.AddField(
model_name="choixspectacle",
name="spectacle",
field=models.ForeignKey(
related_name="participants",
to="bda.Spectacle",
on_delete=models.CASCADE,
),
),
migrations.AddField(
model_name="attribution",
name="participant",
field=models.ForeignKey(to="bda.Participant", on_delete=models.CASCADE),
),
migrations.AddField(
model_name="attribution",
name="spectacle",
field=models.ForeignKey(
related_name="attribues", to="bda.Spectacle", on_delete=models.CASCADE
),
),
migrations.AlterUniqueTogether(
name="choixspectacle", unique_together=set([("participant", "spectacle")])
),
]

View file

@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
from django.utils import timezone
def fill_tirage_fields(apps, schema_editor):
"""
Create a `Tirage` to fill new field `tirage` of `Participant`
and `Spectacle` already existing.
"""
Participant = apps.get_model("bda", "Participant")
Spectacle = apps.get_model("bda", "Spectacle")
Tirage = apps.get_model("bda", "Tirage")
# These querysets only contains instances not linked to any `Tirage`.
participants = Participant.objects.filter(tirage=None)
spectacles = Spectacle.objects.filter(tirage=None)
if not participants.count() and not spectacles.count():
# No need to create a "trash" tirage.
return
tirage = Tirage.objects.create(
title="Tirage de test (migration)",
active=False,
ouverture=timezone.now(),
fermeture=timezone.now(),
)
participants.update(tirage=tirage)
spectacles.update(tirage=tirage)
class Migration(migrations.Migration):
dependencies = [("bda", "0001_initial")]
operations = [
migrations.CreateModel(
name="Tirage",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("title", models.CharField(max_length=300, verbose_name=b"Titre")),
(
"ouverture",
models.DateTimeField(
verbose_name=b"Date et heure d'ouverture du tirage"
),
),
(
"fermeture",
models.DateTimeField(
verbose_name=b"Date et heure de fermerture du tirage"
),
),
(
"token",
models.TextField(verbose_name=b"Graine du tirage", blank=True),
),
(
"active",
models.BooleanField(default=True, verbose_name=b"Tirage actif"),
),
],
),
migrations.AlterField(
model_name="participant",
name="user",
field=models.ForeignKey(
to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE
),
),
# Create fields `spectacle` for `Participant` and `Spectacle` models.
# These fields are not nullable, but we first create them as nullable
# to give a default value for existing instances of these models.
migrations.AddField(
model_name="participant",
name="tirage",
field=models.ForeignKey(
to="bda.Tirage", null=True, on_delete=models.CASCADE
),
),
migrations.AddField(
model_name="spectacle",
name="tirage",
field=models.ForeignKey(
to="bda.Tirage", null=True, on_delete=models.CASCADE
),
),
migrations.RunPython(fill_tirage_fields, migrations.RunPython.noop),
migrations.AlterField(
model_name="participant",
name="tirage",
field=models.ForeignKey(to="bda.Tirage", on_delete=models.CASCADE),
),
migrations.AlterField(
model_name="spectacle",
name="tirage",
field=models.ForeignKey(to="bda.Tirage", on_delete=models.CASCADE),
),
]

View file

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0002_add_tirage")]
operations = [
migrations.AlterField(
model_name="spectacle",
name="price",
field=models.FloatField(verbose_name=b"Prix d'une place"),
),
migrations.AlterField(
model_name="tirage",
name="active",
field=models.BooleanField(default=False, verbose_name=b"Tirage actif"),
),
]

View file

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0003_update_tirage_and_spectacle")]
operations = [
migrations.AddField(
model_name="spectacle",
name="listing",
field=models.BooleanField(
default=False, verbose_name=b"Les places sont sur listing"
),
preserve_default=False,
),
migrations.AddField(
model_name="spectacle",
name="rappel_sent",
field=models.DateTimeField(
null=True, verbose_name=b"Mail de rappel envoy\xc3\xa9", blank=True
),
),
]

View file

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0004_mails-rappel")]
operations = [
migrations.AlterField(
model_name="choixspectacle",
name="priority",
field=models.PositiveIntegerField(verbose_name="Priorit\xe9"),
),
migrations.AlterField(
model_name="spectacle",
name="priority",
field=models.IntegerField(default=1000, verbose_name="Priorit\xe9"),
),
migrations.AlterField(
model_name="spectacle",
name="rappel_sent",
field=models.DateTimeField(
null=True, verbose_name="Mail de rappel envoy\xe9", blank=True
),
),
]

View file

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.utils import timezone
def forwards_func(apps, schema_editor):
Tirage = apps.get_model("bda", "Tirage")
db_alias = schema_editor.connection.alias
for tirage in Tirage.objects.using(db_alias).all():
if tirage.tokens:
tirage.tokens = 'Before %s\n"""%s"""\n' % (
timezone.now().strftime("%y-%m-%d %H:%M:%S"),
tirage.tokens,
)
tirage.save()
class Migration(migrations.Migration):
dependencies = [("bda", "0005_encoding")]
operations = [
migrations.RenameField("tirage", "token", "tokens"),
migrations.AddField(
model_name="tirage",
name="enable_do_tirage",
field=models.BooleanField(
default=False, verbose_name=b"Le tirage peut \xc3\xaatre lanc\xc3\xa9"
),
),
migrations.RunPython(forwards_func, migrations.RunPython.noop),
]

View file

@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0006_add_tirage_switch")]
operations = [
migrations.CreateModel(
name="CategorieSpectacle",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
(
"name",
models.CharField(max_length=100, verbose_name="Nom", unique=True),
),
],
options={"verbose_name": "Cat\xe9gorie"},
),
migrations.CreateModel(
name="Quote",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("text", models.TextField(verbose_name="Citation")),
("author", models.CharField(max_length=200, verbose_name="Auteur")),
],
),
migrations.AlterModelOptions(
name="spectacle",
options={"ordering": ("date", "title"), "verbose_name": "Spectacle"},
),
migrations.RemoveField(model_name="spectacle", name="priority"),
migrations.AddField(
model_name="spectacle",
name="ext_link",
field=models.CharField(
max_length=500,
verbose_name="Lien vers le site du spectacle",
blank=True,
),
),
migrations.AddField(
model_name="spectacle",
name="image",
field=models.ImageField(
upload_to="imgs/shows/", null=True, verbose_name="Image", blank=True
),
),
migrations.AlterField(
model_name="tirage",
name="enable_do_tirage",
field=models.BooleanField(
default=False, verbose_name="Le tirage peut \xeatre lanc\xe9"
),
),
migrations.AlterField(
model_name="tirage",
name="tokens",
field=models.TextField(verbose_name="Graine(s) du tirage", blank=True),
),
migrations.AddField(
model_name="spectacle",
name="category",
field=models.ForeignKey(
blank=True,
to="bda.CategorieSpectacle",
on_delete=models.CASCADE,
null=True,
),
),
migrations.AddField(
model_name="spectacle",
name="vips",
field=models.TextField(verbose_name="Personnalit\xe9s", blank=True),
),
migrations.AddField(
model_name="quote",
name="spectacle",
field=models.ForeignKey(to="bda.Spectacle", on_delete=models.CASCADE),
),
]

110
bda/migrations/0008_py3.py Normal file
View file

@ -0,0 +1,110 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0007_extends_spectacle")]
operations = [
migrations.AlterField(
model_name="choixspectacle",
name="double_choice",
field=models.CharField(
verbose_name="Nombre de places",
choices=[
("1", "1 place"),
("autoquit", "2 places si possible, 1 sinon"),
("double", "2 places sinon rien"),
],
max_length=10,
default="1",
),
),
migrations.AlterField(
model_name="participant",
name="paymenttype",
field=models.CharField(
blank=True,
choices=[
("cash", "Cash"),
("cb", "CB"),
("cheque", "Chèque"),
("autre", "Autre"),
],
max_length=6,
verbose_name="Moyen de paiement",
),
),
migrations.AlterField(
model_name="salle",
name="address",
field=models.TextField(verbose_name="Adresse"),
),
migrations.AlterField(
model_name="salle",
name="name",
field=models.CharField(verbose_name="Nom", max_length=300),
),
migrations.AlterField(
model_name="spectacle",
name="date",
field=models.DateTimeField(verbose_name="Date & heure"),
),
migrations.AlterField(
model_name="spectacle",
name="description",
field=models.TextField(verbose_name="Description", blank=True),
),
migrations.AlterField(
model_name="spectacle",
name="listing",
field=models.BooleanField(verbose_name="Les places sont sur listing"),
),
migrations.AlterField(
model_name="spectacle",
name="price",
field=models.FloatField(verbose_name="Prix d'une place"),
),
migrations.AlterField(
model_name="spectacle",
name="slots",
field=models.IntegerField(verbose_name="Places"),
),
migrations.AlterField(
model_name="spectacle",
name="slots_description",
field=models.TextField(verbose_name="Description des places", blank=True),
),
migrations.AlterField(
model_name="spectacle",
name="title",
field=models.CharField(verbose_name="Titre", max_length=300),
),
migrations.AlterField(
model_name="tirage",
name="active",
field=models.BooleanField(verbose_name="Tirage actif", default=False),
),
migrations.AlterField(
model_name="tirage",
name="fermeture",
field=models.DateTimeField(
verbose_name="Date et heure de fermerture du tirage"
),
),
migrations.AlterField(
model_name="tirage",
name="ouverture",
field=models.DateTimeField(
verbose_name="Date et heure d'ouverture du tirage"
),
),
migrations.AlterField(
model_name="tirage",
name="title",
field=models.CharField(verbose_name="Titre", max_length=300),
),
]

View file

@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0008_py3")]
operations = [
migrations.CreateModel(
name="SpectacleRevente",
fields=[
(
"id",
models.AutoField(
serialize=False,
primary_key=True,
auto_created=True,
verbose_name="ID",
),
),
(
"date",
models.DateTimeField(
verbose_name="Date de mise en vente",
default=django.utils.timezone.now,
),
),
(
"notif_sent",
models.BooleanField(
verbose_name="Notification envoyée", default=False
),
),
(
"tirage_done",
models.BooleanField(verbose_name="Tirage effectué", default=False),
),
],
options={"verbose_name": "Revente"},
),
migrations.AddField(
model_name="participant",
name="choicesrevente",
field=models.ManyToManyField(
to="bda.Spectacle", related_name="subscribed", blank=True
),
),
migrations.AddField(
model_name="spectaclerevente",
name="answered_mail",
field=models.ManyToManyField(
to="bda.Participant", related_name="wanted", blank=True
),
),
migrations.AddField(
model_name="spectaclerevente",
name="attribution",
field=models.OneToOneField(
to="bda.Attribution", on_delete=models.CASCADE, related_name="revente"
),
),
migrations.AddField(
model_name="spectaclerevente",
name="seller",
field=models.ForeignKey(
to="bda.Participant",
on_delete=models.CASCADE,
verbose_name="Vendeur",
related_name="original_shows",
),
),
migrations.AddField(
model_name="spectaclerevente",
name="soldTo",
field=models.ForeignKey(
to="bda.Participant",
on_delete=models.CASCADE,
verbose_name="Vendue à",
null=True,
blank=True,
),
),
]

View file

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from datetime import timedelta
from django.db import migrations, models
from django.utils import timezone
def forwards_func(apps, schema_editor):
SpectacleRevente = apps.get_model("bda", "SpectacleRevente")
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
)
revente.shotgun = is_expired or is_direct
revente.save()
class Migration(migrations.Migration):
dependencies = [("bda", "0009_revente")]
operations = [
migrations.AddField(
model_name="spectaclerevente",
name="shotgun",
field=models.BooleanField(
default=False, verbose_name="Disponible imm\xe9diatement"
),
),
migrations.RunPython(forwards_func, migrations.RunPython.noop),
]

View file

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0010_spectaclerevente_shotgun")]
operations = [
migrations.AddField(
model_name="tirage",
name="appear_catalogue",
field=models.BooleanField(
default=False, verbose_name="Tirage à afficher dans le catalogue"
),
)
]

View file

@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0011_tirage_appear_catalogue")]
operations = [
migrations.RenameField(
model_name="spectaclerevente",
old_name="answered_mail",
new_name="confirmed_entry",
),
migrations.AlterField(
model_name="spectaclerevente",
name="confirmed_entry",
field=models.ManyToManyField(
blank=True, related_name="entered", to="bda.Participant"
),
),
migrations.AddField(
model_name="spectaclerevente",
name="notif_time",
field=models.DateTimeField(
blank=True, verbose_name="Moment d'envoi de la notification", null=True
),
),
]

View file

@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
def swap_double_choice(apps, schema_editor):
choices = apps.get_model("bda", "ChoixSpectacle").objects
choices.filter(double_choice="double").update(double_choice="tmp")
choices.filter(double_choice="autoquit").update(double_choice="double")
choices.filter(double_choice="tmp").update(double_choice="autoquit")
class Migration(migrations.Migration):
dependencies = [("bda", "0011_tirage_appear_catalogue")]
operations = [
# Temporarily allow an extra "tmp" value for the `double_choice` field
migrations.AlterField(
model_name="choixspectacle",
name="double_choice",
field=models.CharField(
verbose_name="Nombre de places",
max_length=10,
default="1",
choices=[
("tmp", "tmp"),
("1", "1 place"),
("double", "2 places si possible, 1 sinon"),
("autoquit", "2 places sinon rien"),
],
),
),
migrations.RunPython(swap_double_choice, migrations.RunPython.noop),
migrations.AlterField(
model_name="choixspectacle",
name="double_choice",
field=models.CharField(
verbose_name="Nombre de places",
max_length=10,
default="1",
choices=[
("1", "1 place"),
("double", "2 places si possible, 1 sinon"),
("autoquit", "2 places sinon rien"),
],
),
),
]

View file

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-05-24 19:23
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("bda", "0012_notif_time"), ("bda", "0012_swap_double_choice")]
operations = []

View file

@ -0,0 +1,31 @@
# Generated by Django 2.2 on 2019-06-03 19:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0013_merge_20180524_2123")]
operations = [
migrations.AddField(
model_name="attribution",
name="paid",
field=models.BooleanField(default=False, verbose_name="Payée"),
),
migrations.AddField(
model_name="attribution",
name="paymenttype",
field=models.CharField(
blank=True,
choices=[
("cash", "Cash"),
("cb", "CB"),
("cheque", "Chèque"),
("autre", "Autre"),
],
max_length=6,
verbose_name="Moyen de paiement",
),
),
]

View file

@ -0,0 +1,37 @@
# Generated by Django 2.2 on 2019-06-03 19:30
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
from django.db import migrations
def set_attr_payment(apps, schema_editor):
Attribution = apps.get_model("bda", "Attribution")
for attr in Attribution.objects.all():
attr.paid = attr.participant.paid
attr.paymenttype = attr.participant.paymenttype
attr.save()
def set_participant_payment(apps, schema_editor):
Participant = apps.get_model("bda", "Participant")
for part in Participant.objects.all():
attr_set = part.attribution_set
part.paid = attr_set.exists() and not attr_set.filter(paid=False).exists()
try:
# S'il n'y a qu'un seul type de paiement, on le set
part.paymenttype = (
attr_set.values_list("paymenttype", flat=True).distinct().get()
)
# Sinon, whatever
except (ObjectDoesNotExist, MultipleObjectsReturned):
pass
part.save()
class Migration(migrations.Migration):
dependencies = [("bda", "0014_attribution_paid_field")]
operations = [
migrations.RunPython(set_attr_payment, set_participant_payment, atomic=True)
]

View file

@ -0,0 +1,13 @@
# Generated by Django 2.2 on 2019-06-03 19:30
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("bda", "0015_move_bda_payment")]
operations = [
migrations.RemoveField(model_name="participant", name="paid"),
migrations.RemoveField(model_name="participant", name="paymenttype"),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 2.2 on 2019-09-18 16:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("bda", "0016_delete_participant_paid")]
operations = [
migrations.AddField(
model_name="participant",
name="accepte_charte",
field=models.BooleanField(
default=False, verbose_name="A accepté la charte BdA"
),
)
]

View file

469
bda/models.py Normal file
View file

@ -0,0 +1,469 @@
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.db import models
from django.db.models import Count, Exists
from django.utils import formats, timezone
def get_generic_user():
generic, _ = User.objects.get_or_create(
username="bda_generic",
defaults={"email": "bda@ens.fr", "first_name": "Bureau des arts"},
)
return generic
class Tirage(models.Model):
title = models.CharField("Titre", max_length=300)
ouverture = models.DateTimeField("Date et heure d'ouverture du tirage")
fermeture = models.DateTimeField("Date et heure de fermerture du tirage")
tokens = models.TextField("Graine(s) du tirage", blank=True)
active = models.BooleanField("Tirage actif", default=False)
appear_catalogue = models.BooleanField(
"Tirage à afficher dans le catalogue", default=False
)
enable_do_tirage = models.BooleanField("Le tirage peut être lancé", default=False)
def __str__(self):
return "%s - %s" % (
self.title,
formats.localize(timezone.template_localtime(self.fermeture)),
)
class Salle(models.Model):
name = models.CharField("Nom", max_length=300)
address = models.TextField("Adresse")
def __str__(self):
return self.name
class CategorieSpectacle(models.Model):
name = models.CharField("Nom", max_length=100, unique=True)
def __str__(self):
return self.name
class Meta:
verbose_name = "Catégorie"
class Spectacle(models.Model):
title = models.CharField("Titre", max_length=300)
category = models.ForeignKey(
CategorieSpectacle, on_delete=models.CASCADE, blank=True, null=True
)
date = models.DateTimeField("Date & heure")
location = models.ForeignKey(Salle, on_delete=models.CASCADE)
vips = models.TextField("Personnalités", blank=True)
description = models.TextField("Description", blank=True)
slots_description = models.TextField("Description des places", blank=True)
image = models.ImageField("Image", blank=True, null=True, upload_to="imgs/shows/")
ext_link = models.CharField(
"Lien vers le site du spectacle", blank=True, max_length=500
)
price = models.FloatField("Prix d'une place")
slots = models.IntegerField("Places")
tirage = models.ForeignKey(Tirage, on_delete=models.CASCADE)
listing = models.BooleanField("Les places sont sur listing")
rappel_sent = models.DateTimeField("Mail de rappel envoyé", blank=True, null=True)
class Meta:
verbose_name = "Spectacle"
ordering = ("date", "title")
def timestamp(self):
return "%d" % calendar.timegm(self.date.utctimetuple())
def __str__(self):
return "%s - %s, %s, %.02f" % (
self.title,
formats.localize(timezone.template_localtime(self.date)),
self.location,
self.price,
)
def getImgUrl(self):
"""
Cette fonction permet d'obtenir l'URL de l'image, si elle existe
"""
try:
return self.image.url
except Exception:
return None
def send_rappel(self):
"""
Envoie un mail de rappel à toutes les personnes qui ont une place pour
ce spectacle.
"""
# On récupère la liste des participants + le BdA
members = list(
User.objects.filter(participant__attributions=self)
.annotate(nb_attr=Count("id"))
.order_by()
)
bda_generic = get_generic_user()
bda_generic.nb_attr = 1
members.append(bda_generic)
# On écrit un mail personnalisé à chaque participant
datatuple = [
(
"bda-rappel",
{"member": member, "nb_attr": member.nb_attr, "show": self},
settings.MAIL_DATA["rappels"]["FROM"],
[member.email],
)
for member in members
]
send_mass_custom_mail(datatuple)
# On enregistre le fait que l'envoi a bien eu lieu
self.rappel_sent = timezone.now()
self.save()
# On renvoie la liste des destinataires
return members
@property
def is_past(self):
return self.date < timezone.now()
class Quote(models.Model):
spectacle = models.ForeignKey(Spectacle, on_delete=models.CASCADE)
text = models.TextField("Citation")
author = models.CharField("Auteur", max_length=200)
PAYMENT_TYPES = (
("cash", "Cash"),
("cb", "CB"),
("cheque", "Chèque"),
("autre", "Autre"),
)
class Attribution(models.Model):
participant = models.ForeignKey("Participant", on_delete=models.CASCADE)
spectacle = models.ForeignKey(
Spectacle, on_delete=models.CASCADE, related_name="attribues"
)
given = models.BooleanField("Donnée", default=False)
paid = models.BooleanField("Payée", default=False)
paymenttype = models.CharField(
"Moyen de paiement", max_length=6, choices=PAYMENT_TYPES, blank=True
)
def __str__(self):
return "%s -- %s, %s" % (
self.participant.user,
self.spectacle.title,
self.spectacle.date,
)
class ParticipantPaidQueryset(models.QuerySet):
"""
Un manager qui annote le queryset avec un champ `paid`,
indiquant si un participant a payé toutes ses attributions.
"""
def annotate_paid(self):
# OuterRef permet de se référer à un champ d'un modèle non encore fixé
# Voir:
# https://docs.djangoproject.com/en/2.2/ref/models/expressions/#django.db.models.OuterRef
unpaid = Attribution.objects.filter(
participant=models.OuterRef("pk"), paid=False
)
return self.annotate(paid=~Exists(unpaid))
class Participant(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
choices = models.ManyToManyField(
Spectacle, through="ChoixSpectacle", related_name="chosen_by"
)
attributions = models.ManyToManyField(
Spectacle, through="Attribution", related_name="attributed_to"
)
tirage = models.ForeignKey(Tirage, on_delete=models.CASCADE)
accepte_charte = models.BooleanField("A accepté la charte BdA", default=False)
choicesrevente = models.ManyToManyField(
Spectacle, related_name="subscribed", blank=True
)
objects = ParticipantPaidQueryset.as_manager()
def __str__(self):
return "%s - %s" % (self.user, self.tirage.title)
DOUBLE_CHOICES = (
("1", "1 place"),
("double", "2 places si possible, 1 sinon"),
("autoquit", "2 places sinon rien"),
)
class ChoixSpectacle(models.Model):
participant = models.ForeignKey(Participant, on_delete=models.CASCADE)
spectacle = models.ForeignKey(
Spectacle, on_delete=models.CASCADE, related_name="participants"
)
priority = models.PositiveIntegerField("Priorité")
double_choice = models.CharField(
"Nombre de places", default="1", choices=DOUBLE_CHOICES, max_length=10
)
def get_double(self):
return self.double_choice != "1"
double = property(get_double)
def get_autoquit(self):
return self.double_choice == "autoquit"
autoquit = property(get_autoquit)
def __str__(self):
return "Vœux de %s pour %s" % (
self.participant.user.get_full_name(),
self.spectacle.title,
)
class Meta:
ordering = ("priority",)
unique_together = (("participant", "spectacle"),)
verbose_name = "voeu"
verbose_name_plural = "voeux"
class SpectacleRevente(models.Model):
attribution = models.OneToOneField(
Attribution, on_delete=models.CASCADE, related_name="revente"
)
date = models.DateTimeField("Date de mise en vente", default=timezone.now)
confirmed_entry = models.ManyToManyField(
Participant, related_name="entered", blank=True
)
seller = models.ForeignKey(
Participant,
on_delete=models.CASCADE,
verbose_name="Vendeur",
related_name="original_shows",
)
soldTo = models.ForeignKey(
Participant,
on_delete=models.CASCADE,
verbose_name="Vendue à",
blank=True,
null=True,
)
notif_sent = models.BooleanField("Notification envoyée", default=False)
notif_time = models.DateTimeField(
"Moment d'envoi de la notification", blank=True, null=True
)
tirage_done = models.BooleanField("Tirage effectué", default=False)
shotgun = models.BooleanField("Disponible immédiatement", default=False)
####
# Some class attributes
###
# TODO : settings ?
# Temps minimum entre le tirage et le spectacle
min_margin = timedelta(days=5)
# Temps entre la création d'une revente et l'envoi du mail
remorse_time = timedelta(hours=1)
# Temps min/max d'attente avant le tirage
max_wait_time = timedelta(days=3)
min_wait_time = timedelta(days=1)
@property
def real_notif_time(self):
if self.notif_time:
return self.notif_time
else:
return self.date + self.remorse_time
@property
def date_tirage(self):
"""Renvoie la date du tirage au sort de la revente."""
remaining_time = (
self.attribution.spectacle.date - self.real_notif_time - self.min_margin
)
delay = min(remaining_time, self.max_wait_time)
return self.real_notif_time + delay
@property
def is_urgent(self):
"""
Renvoie True iff la revente doit être mise au shotgun directement.
Plus précisément, on doit avoir min_margin + min_wait_time de marge.
"""
spectacle_date = self.attribution.spectacle.date
return spectacle_date <= timezone.now() + self.min_margin + self.min_wait_time
@property
def can_notif(self):
return timezone.now() >= self.date + self.remorse_time
def __str__(self):
return "%s -- %s" % (self.seller, self.attribution.spectacle.title)
class Meta:
verbose_name = "Revente"
def reset(self, new_date=timezone.now()):
"""Réinitialise la revente pour permettre une remise sur le marché"""
self.seller = self.attribution.participant
self.date = new_date
self.confirmed_entry.clear()
self.soldTo = None
self.notif_sent = False
self.notif_time = None
self.tirage_done = False
self.shotgun = False
self.save()
def send_notif(self):
"""
Envoie une notification pour indiquer la mise en vente d'une place sur
BdA-Revente à tous les intéressés.
"""
inscrits = self.attribution.spectacle.subscribed.select_related("user")
datatuple = [
(
"bda-revente",
{
"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)
self.notif_sent = True
self.notif_time = timezone.now()
self.save()
def mail_shotgun(self):
"""
Envoie un mail à toutes les personnes intéréssées par le spectacle pour
leur indiquer qu'il est désormais disponible au shotgun.
"""
inscrits = self.attribution.spectacle.subscribed.select_related("user")
datatuple = [
(
"bda-shotgun",
{
"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)
self.notif_sent = True
self.notif_time = timezone.now()
# Flag inutile, sauf si l'horloge interne merde
self.tirage_done = True
self.shotgun = True
self.save()
def tirage(self, send_mails=True):
"""
Lance le tirage au sort associé à la revente. Un gagnant est choisi
parmis les personnes intéressées par le spectacle. Les personnes sont
ensuites prévenues par mail du résultat du tirage.
"""
inscrits = list(self.confirmed_entry.all())
spectacle = self.attribution.spectacle
seller = self.seller
winner = None
if inscrits:
# Envoie un mail au gagnant et au vendeur
winner = random.choice(inscrits)
self.soldTo = winner
if send_mails:
mails = []
context = {
"acheteur": winner.user,
"vendeur": seller.user,
"show": spectacle,
}
c_mails_qs = CustomMail.objects.filter(
shortname__in=[
"bda-revente-winner",
"bda-revente-loser",
"bda-revente-seller",
]
)
c_mails = {cm.shortname: cm for cm in c_mails_qs}
mails.append(
c_mails["bda-revente-winner"].get_message(
context,
from_email=settings.MAIL_DATA["revente"]["FROM"],
to=[winner.user.email],
)
)
mails.append(
c_mails["bda-revente-seller"].get_message(
context,
from_email=settings.MAIL_DATA["revente"]["FROM"],
to=[seller.user.email],
reply_to=[winner.user.email],
)
)
# Envoie un mail aux perdants
for inscrit in inscrits:
if inscrit != winner:
new_context = dict(context)
new_context["acheteur"] = inscrit.user
mails.append(
c_mails["bda-revente-loser"].get_message(
new_context,
from_email=settings.MAIL_DATA["revente"]["FROM"],
to=[inscrit.user.email],
)
)
mail_conn = mail.get_connection()
mail_conn.send_messages(mails)
# Si personne ne veut de la place, elle part au shotgun
else:
self.shotgun = True
self.tirage_done = True
self.save()
return winner

125
bda/static/bda/css/bda.css Normal file
View file

@ -0,0 +1,125 @@
form#tokenform {
text-align: center;
font-size: 2em;
}
label {
margin-right: 10px;
vertical-align: top;
}
form#tokenform textarea {
font-size: 2em;
width: 350px;
height: 200px;
font-family: 'Droif Serif', serif;
}
/* wft ?
input {
width: 400px;
font-size: 2em;
}*/
ul.losers {
display: inline;
margin: 0;
padding: 0;
}
ul.losers li {
display: inline;
}
span.details {
font-size: 0.7em;
}
td {
border: 0px solid black;
padding: 2px;
}
.attribresult {
margin: 10px 0px;
}
.spectacle-passe {
opacity:0.5;
}
/** JQuery-Confirm box **/
.jconfirm .jconfirm-bg {
background-color: rgb(0,0,0,0.6) !important;
}
.jconfirm .jconfirm-box {
padding:0;
border-radius:0 !important;
font-family:Roboto;
}
.jconfirm .jconfirm-box .content-pane {
border-bottom:1px solid #ddd;
margin: 0px !important;
}
.jconfirm .jconfirm-box .content {
padding: 5px;
}
.jconfirm .jconfirm-box .content-pane {
border-bottom:1px solid #ddd;
margin: 0px !important;
}
.jconfirm .jconfirm-box .content {
padding: 10px;
}
.jconfirm .jconfirm-box .content a,
.jconfirm .jconfirm-box .content a:hover {
color: #D81138;
font-weight: bold;
}
.jconfirm .jconfirm-box .buttons {
margin-top:-6px; /* j'arrive pas à voir pk y'a un espace au dessus sinon... */
padding:0;
height:40px;
}
.jconfirm .jconfirm-box .buttons button {
min-width:40px;
height:100%;
margin:0;
margin:0 !important;
border-radius: 0 !important;
}
.jconfirm .jconfirm-box .buttons button:first-child:focus,
.jconfirm .jconfirm-box .buttons button:first-child:hover {
color:#FFF !important;
background:forestgreen !important;
}
.jconfirm .jconfirm-box .buttons button:nth-child(2):focus,
.jconfirm .jconfirm-box .buttons button:nth-child(2):hover {
color:#FFF !important;
background:#D93A32 !important;
}
.jconfirm .jconfirm-box div.title-c .title {
display: block;
padding:0 15px;
height:40px;
line-height:40px;
font-family:Dosis;
font-size:20px;
font-weight:bold;
color:#FFF;
background-color:rgb(222, 130, 107);
}

View file

@ -0,0 +1,28 @@
{% extends "bda-attrib.html" %}
{% block extracontent %}
<h2>Attributions (détails)</h2>
<h3 class="horizontal-title">Token :</h3>
<pre>{{ token }}</pre>
<h3 class="horizontal-title">Placés : {{ total_slots }} ; Déçus : {{ total_losers }}</h3>
<table>
{% for member, shows in members2 %}
<tr>
<td>{{ member.user.get_full_name }}</td>
<td>{{ member.user.email }}</td>
<td>Total: {{ member.total }}€</td>
<td style="width: 120px;"></td>
</tr>
{% for show in shows %}
<tr>
<td></td>
<td></td>
<td>{{ show }}</td>
<td></td>
</tr>
{% endfor %}
{% endfor %}
</table>
{% endblock %}

View file

@ -0,0 +1,49 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{% block extra_head %}
<link type="text/css" rel="stylesheet" href="{% static "bda/css/bda.css" %}" />
{% endblock %}
{% block realcontent %}
<h2>Attributions</h2>
<br />
<p class="success">Pour raison de sécurité, le lancement du tirage
a été désactivé. Vous pouvez le réactiver dans
l'<a href="{% url "admin:index" %}">interface admin</a></p>
<h3 class="horizontal-title">Token :</h3>
<pre>{{ token }}</pre>
<h3 class="horizontal-title">Placés : {{ total_slots }} ; Déçus : {{ total_losers }}</h3>
{% if user.profile.is_buro %}<h3 class="horizontal-title">Déficit total: {{ total_deficit }} €, Opéra: {{ opera_deficit }} €, Attribué: {{ total_sold }} €</h3>{% endif %}
<h3 class="horizontal-title">Temps de calcul : {{ duration|floatformat }}s</h3>
{% for show, members, losers in results %}
<div class="attribresult">
<h3 class="horizontal-title">{{ show.title }} - {{ show.date }} @ {{ show.location }}</h3>
<p>
<strong>{{ show.nrequests }} demandes pour {{ show.slots }} places</strong>
{{ show.price }}€ par place{% if user.profile.is_buro and show.nrequests < show.slots %}, {{ show.deficit }} de déficit{% endif %}
</p>
Places :
<ul>
{% for member, rank, origrank, double in members %}
<li>{{ member.user.get_full_name }} <span class="details">(souhait {{ origrank }} &mdash; rang {{ rank }})</span></li>
{% endfor %}
</ul>
Déçus :
{% if not losers %}/{% else %}
<ul class="losers">
{% for member, rank, origrank, double in losers %}
{% if not forloop.first %} ; {% endif %}
<li>{{ member.user.get_full_name }} <span class="details">(souhait {{ origrank }} &mdash; rang {{ rank }})</span></li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endfor %}
{% block extracontent %}
{% endblock %}
{% endblock %}

View file

@ -0,0 +1,7 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2>{{ spectacle }}</h2>
<textarea style="width: 100%; height: 100px; margin-top: 10px;">
{% for attrib in spectacle.attribues.all %}{{ attrib.participant.user.email }}, {% endfor %}</textarea>
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2>Tirage au sort du BdA</h2>
<form action="" method="post" id="tokenform">
{% csrf_token %}
<strong>La graine :</strong>
<div>
{{ form.token }}
</div>
<input type="submit" onsubmit="return confirm('Voulez vous lancer le Tirage maintenant ?\n\nCECI REMETTRA À ZÉRO TOUTES LES DONNÉES si le tirage a déjà été lancé.')" value="Go" />
</form>
{% endblock %}

View file

@ -0,0 +1,8 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2>Impayés</h2>
<textarea style="width: 100%; height: 100px; margin-top: 10px;">
{% for participant in unpaid %}{{ participant.user.email }}, {% endfor %}</textarea>
<h3>Total&nbsp: {{ unpaid|length }}</h3>
{% endblock %}

View file

@ -0,0 +1,50 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{% block realcontent %}
<h2>État des inscriptions BdA</h2>
<table class="table table-striped etat-bda">
<thead>
<tr>
<th data-sort="string">Titre</th>
<th data-sort="int">Date</th>
<th data-sort="string">Lieu</th>
<th data-sort="int">Places</th>
<th data-sort="int">Demandes</th>
<th data-sort="float">Ratio</th>
</tr>
</thead>
<tbody>
{% for spectacle in spectacles %}
<tr>
<td>{{ spectacle.title }}</td>
<td data-sort-value="{{ spectacle.timestamp }}">{{ spectacle.date }}</td>
<td data-sort-value="{{ spectacle.location }}">{{ spectacle.location }}</td>
<td data-sort-value="{{ spectacle.slots }}">{{ spectacle.slots }} places</td>
<td data-sort-value="{{ spectacle.total }}">{{ spectacle.total }} demandes</td>
<td data-sort-value="{{ spectacle.ratio |stringformat:".3f" }}"
class={% if spectacle.ratio < 1.0 %}
"greenratio"
{% else %}
{% if spectacle.ratio < 2.5 %}
"orangeratio"
{% else %}
"redratio"
{% endif %}
{% endif %}>
{{ spectacle.ratio |floatformat }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<span class="bda-prix">
Total&nbsp;: {{ total }} place{{ total|pluralize }} demandée{{ total|pluralize }}
sur {{ proposed }} place{{ proposed|pluralize }} proposée{{ proposed|pluralize }}
</span>
<script type="text/javascript">
$(function(){
$("table.etat-bda").stupidtable();
});
</script>
{% endblock %}

View file

@ -0,0 +1 @@
{% include 'bda/forms/spectacle_label_table.html' with spectacle=attribution.spectacle %}

View file

@ -0,0 +1,4 @@
<tr>
<td><input type="{{ widget.type }}" name="{{ widget.name }}" {% if widget.value != None %} value="{{ widget.value|stringformat:'s' }}"{% endif %}{% include "django/forms/widgets/attrs.html" %}></td>
{{ widget.label }}
</tr>

View file

@ -0,0 +1 @@
<td data-sort-value="{{ revente.date_tirage | date:"U" }}">{{ revente.date_tirage }}</td>

View file

@ -0,0 +1,3 @@
{% include 'bda/forms/spectacle_label_table.html' with spectacle=revente.attribution.spectacle %}
{% with user=revente.seller.user %} <td>{{user.first_name}} {{user.last_name}}</td> {% endwith%}
{% include 'bda/forms/date_tirage.html' %}

View file

@ -0,0 +1,2 @@
{% include 'bda/forms/spectacle_label_table.html' with spectacle=revente.attribution.spectacle %}
{% include 'bda/forms/date_tirage.html' %}

View file

@ -0,0 +1,4 @@
{% include 'bda/forms/spectacle_label_table.html' with spectacle=revente.attribution.spectacle %}
{% with user=revente.soldTo.user %}
<td><a href="mailto:{{ user.email }}">{{user.first_name}} {{user.last_name}}</a></td>
{% endwith %}

View file

@ -0,0 +1,4 @@
<td>{{ spectacle.title }}</td>
<td data-sort-value="{{ spectacle.timestamp }}">{{ spectacle.date }}</td>
<td data-sort-value="{{ spectacle.location }}">{{ spectacle.location }}</td>
<td data-sort-value="{{ spectacle.price |stringformat:".3f" }}">{{ spectacle.price |floatformat }}€</td>

View file

@ -0,0 +1,41 @@
{% load bootstrap %}
{{ formset.non_form_errors.as_ul }}
<table id="bda_formset" class="form table">
{{ formset.management_form }}
{% for form in formset.forms %}
{% if forloop.first %}
<thead><tr>
{% for field in form.visible_fields %}
{% if field.name != "DELETE" and field.name != "priority" %}
<th class="bda-field-{{ field.name }}">{{ field.label|safe|capfirst }}</th>
{% endif %}
{% endfor %}
<th><sup>1</sup></th>
</tr></thead>
<tbody class="bda_formset_content">
{% endif %}
<tr class="{% cycle 'row1' 'row2' %} dynamic-form {% if form.instance.pk %}has_original{% endif %}">
{% for field in form.visible_fields %}
{% if field.name != "DELETE" and field.name != "priority" %}
<td class="bda-field-{{ field.name }}">
{% if forloop.first %}
{{ form.non_field_errors }}
{% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %}
{% endif %}
{{ field.errors.as_ul }}
{{ field | bootstrap }}
</td>
{% endif %}
{% endfor %}
<td class="tools-cell"><div class="tools">
<a href="javascript://" class="glyphicon glyphicon-sort drag-btn" title="Déplacer"></a>
<input type="checkbox" name="{{ form.DELETE.html_name }}" style="display: none;" />
<input type="hidden" name="{{ form.priority.html_name }}" style="{{ form.priority.value }}" />
<a href="javascript://" class="glyphicon glyphicon-remove remove-btn" title="Supprimer"></a>
</div>
<div class="spacer"></div>
</td>
</tr>
{% endfor %}
</tbody>
</table>

View file

@ -0,0 +1,169 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{% block extra_head %}
<script type="text/javascript" src="{% static 'vendor/jquery/jquery-ui.min.js' %}" ></script>
<script type="text/javascript" src="{% static "vendor/jquery/jquery-confirm.js" %}"></script>
<script type="text/javascript" src="{% static 'gestioncof/vendor/jquery.ui.touch-punch.min.js' %}" ></script>
<link type="text/css" rel="stylesheet" href="{% static 'vendor/jquery/jquery-confirm.css' %}">
<link type="text/css" rel="stylesheet" href="{% static 'vendor/jquery/jquery-ui.min.css' %}" />
<link type="text/css" rel="stylesheet" href="{% static 'bda/css/bda.css' %}" />
{% endblock %}
{% block realcontent %}
<script type="text/javascript">
var django = {
"jQuery": jQuery.noConflict(true)
};
(function($) {
cloneMore = function(selector, type) {
var newElement = $(selector).clone(true);
var total = $('#id_' + type + '-TOTAL_FORMS').val();
newElement.find(':input').each(function() {
var name = $(this).attr('name').replace('-' + (total-1) + '-','-' + total + '-');
var id = 'id_' + name;
$(this).attr({'name': name, 'id': id}).val('').removeAttr('checked');
});
newElement.find('label').each(function() {
var newFor = $(this).attr('for').replace('-' + (total-1) + '-','-' + total + '-');
$(this).attr('for', newFor);
});
// Cloning <select> element doesn't properly propagate the default
// selected <option>, so we set it manually.
newElement.find('select').each(function (index, select) {
var defaultValue = $(select).find('option[selected]').val();
if (typeof defaultValue !== 'undefined') {
$(select).val(defaultValue);
}
});
total++;
$('#id_' + type + '-TOTAL_FORMS').val(total);
$(selector).after(newElement);
}
deleteButtonHandler = function(elem) {
elem.bind("click", function() {
var deleteInput = $(this).prev().prev(),
form = $(this).parents(".dynamic-form").first();
// callback
// toggle options.predeleteCssClass and toggle checkbox
if (form.hasClass("has_original")) {
form.toggleClass("predelete");
if (deleteInput.attr("checked")) {
deleteInput.attr("checked", false);
} else {
deleteInput.attr("checked", true);
}
} else {
// Reset the default values
var selects = $(form).find("select");
$(selects[0]).val("");
$(selects[1]).val("1");
}
// callback
});
};
$(document).ready(function($) {
deleteButtonHandler($("table#bda_formset tbody.bda_formset_content").find("a.remove-btn"));
$("table#bda_formset tbody.bda_formset_content").sortable({
handle: "a.drag-btn",
items: "tr",
axis: "y",
appendTo: 'body',
forceHelperSize: true,
placeholder: 'ui-sortable-placeholder',
forcePlaceholderSize: true,
containment: 'form#bda_form',
tolerance: 'pointer',
start: function(evt, ui) {
var template = "",
len = ui.item.children("td").length;
for (var i = 0; i < len; i++) {
template += "<td style='height:" + (ui.item.outerHeight() + 12 ) + "px' class='placeholder-cell'>&nbsp;</td>"
}
template += "";
ui.placeholder.html(template);
},
stop: function(evt, ui) {
// Toggle div.table twice to remove webkits border-spacing bug
$("table#bda_formset").toggle().toggle();
},
});
$("#bda_form").bind("submit", function(){
var sortable_field_name = "priority";
var i = 1;
$(".bda_formset_content").find("tr").each(function(){
var fields = $(this).find("td :input[value]"),
select = $(this).find("td select");
if (select.val() && fields.serialize()) {
$(this).find("input[name$='"+sortable_field_name+"']").val(i);
i++;
}
});
});
});
})(django.jQuery);
</script>
<h2 class="no-bottom-margin">Inscription au tirage au sort du BdA</h2>
<form class="form-horizontal" id="bda_form" method="post" action="{% url 'bda-tirage-inscription' tirage.id %}">
{% csrf_token %}
{% include "bda/inscription-formset.html" %}
<div class="inscription-bottom">
<span class="bda-prix">Prix total actuel : {{ total_price }}€</span>
<div class="pull-right">
<input type="button" class="btn btn-default" value="Ajouter un autre v&oelig;u" id="add_more">
<script>
django.jQuery('#add_more').click(function() {
cloneMore('tbody.bda_formset_content tr:last-child', 'choixspectacle_set');
});
</script>
<input type="hidden" name="dbstate" value="{{ dbstate }}" />
<input type="submit" class="btn btn-primary" id="bda-inscr" value="Enregistrer" />
</div>
<p class="footnotes">
<sup>1</sup>: cette liste de v&oelig;ux est ordonnée (du plus important au moins important), pour ajuster la priorité vous pouvez déplacer chaque v&oelig;u.<br />
</p>
</div>
</form>
{% if not charte %}
<script>
(function ($) {
var charte_ok = false ;
function link_charte() {
$.confirm({
title: 'Charte du BdA',
columnClass: 'col-md-6 col-md-offset-3',
content: `
<div>
En vous inscrivant à ce tirage du Bureau des Arts, vous vous engagez à \
respecter la charte du BdA:</br> \
<a target="_blank" href='https://bda.ens.fr/lequipe/charte-bda/'>https://bda.ens.fr/lequipe/charte-bda/</a>
</div>`,
backgroundDismiss: true,
opacity: 1,
animation:'top',
closeAnimation:'bottom',
keyboardEnabled: true,
confirmButton: '<span class="glyphicon glyphicon-ok"></span>',
cancelButton: '<span class="glyphicon glyphicon-remove"></span>',
confirm: function() {
charte_ok = true ;
$("#bda_form").submit();
},
});
}
$(document).ready(function($) {
$("#bda_form").submit(function(e) {
if (!charte_ok) {
e.preventDefault();
link_charte();
}
})
})
})(django.jQuery);
</script>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,48 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2>Mails de rappels</h2>
{% if sent %}
<h3>Les mails de rappel pour le spectacle {{ show.title }} ont bien été envoyés aux personnes suivantes</h3>
<ul>
{% for member in members %}
<li>{{ member.get_full_name }} ({{ member.email }})</li>
{% endfor %}
</ul>
{% else %}
<h3>Voulez vous envoyer les mails de rappel pour le spectacle {{ show.title }}&nbsp;?</h3>
{% endif %}
<div class="empty-form">
{% if not sent %}
<form action="" method="post">
{% csrf_token %}
<div class="pull-right">
<input class="btn btn-primary" type="submit" value="Envoyer" />
</div>
</form>
{% endif %}
</div>
<hr \>
<p>
<em>Note :</em> le template de ce mail peut être modifié à
<a href="{% url 'admin:custommail_custommail_change' custommail.pk %}">cette adresse</a>
</p>
<hr \>
<h3>Forme des mails</h3>
<h4>Une seule place</h4>
{% for part in exemple_mail_1place %}
<pre>{{ part }}</pre>
{% endfor %}
<h4>Deux places</h4>
{% for part in exemple_mail_2places %}
<pre>{{ part }}</pre>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,71 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{% block realcontent %}
<h2>{{ spectacle }}</h2>
<table class='table table-striped etat-bda'>
<thead>
<tr>
<th data-sort="string">Nom</th>
<th data-sort="int">Places</th>
<th data-sort="string">Adresse Mail</th>
<th data-sort="string">Payé</th>
<th data-sort="string">Donné</th>
</tr>
</thead>
<tbody>
{% for participant in participants %}
<tr>
<td data-sort-value="{{ participan.name}}">{{participant.name}}</td>
<td data-sort-value="{{participant.nb_places}}">{{participant.nb_places}} place{{participant.nb_places|pluralize}}</td>
<td data-sort-value="{{participant.email}}">{{participant.email}}</td>
<td data-sort-value="{{ participant.paid}}" class={%if participant.paid %}"greenratio"{%else%}"redratio"{%endif%}>
{% if participant.paid %}Oui{% else %}Non{%endif%}
</td>
<td data-sort-value="{{participant.given}}" class={%if participant.given == participant.nb_places %}"greenratio"
{%elif participant.given == 0%}"redratio"
{%else%}"orangeratio"
{%endif%}>
{% if participant.given == participant.nb_places %}Oui
{% elif participant.given == 0 %}Non
{% else %}{{participant.given}}/{{participant.nb_places}}
{%endif%}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h3><a href="{% url "admin:bda_attribution_add" %}?spectacle={{spectacle.id}}"><span class="glyphicon glyphicon-plus-sign"></span> Ajouter une attribution</a></h3>
<div>
<div>
<button class="btn btn-default" type="button" onclick="toggle('export-mails')">Afficher/Cacher mails participant⋅e⋅s</button>
<pre id="export-mails" style="display:none">{% spaceless %}
{% for participant in participants %}{{ participant.email }}, {% endfor %}
{% endspaceless %}</pre>
</div>
<div>
<button class="btn btn-default" type="button" onclick="toggle('export-salle')">Afficher/Cacher liste noms</button>
<pre id="export-salle" style="display:none">{% spaceless %}
{% for participant in participants %}{{ participant.name }} : {{ participant.nb_places }} place{{ participant.nb_places|pluralize }}
{% endfor %}
{% endspaceless %}</pre>
</div>
<div>
<a href="{% url 'bda-rappels' spectacle.id %}">Page d'envoi manuel des mails de rappel</a>
</div>
<script type="text/javascript">
function toggle(id) {
var pre = document.getElementById(id) ;
pre.style.display = pre.style.display == "none" ? "block" : "none" ;
}
</script>
<script type="text/javascript">
$(function(){
$("table.etat-bda").stupidtable();
});
</script>
{% endblock %}

View file

@ -0,0 +1,14 @@
{% extends "base_title.html" %}
{% block realcontent %}
{% if choices %}
<h3>Vos v&oelig;ux:</h3>
<ol>
{% for choice in choices %}
<li>{{ choice.spectacle }}{% if choice.double %} (deux places{% if autoquit %}, abandon automatique{% endif %}){% endif %}</li>
{% endfor %}
</ol>
{% else %}
<h3>Vous n'avez enregistré aucun v&oelig;u pour le tirage au sort</h3>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,24 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2><strong>Places attribuées</strong></h3>
{% if places %}
<table class="table table-striped">
{% for place in places %}
<tr>
<td>{{place.spectacle.title}}</td>
<td>{{place.spectacle.location}}</td>
<td>{{place.spectacle.date}}</td>
<td>{% if place.double %}deux places{%else%}une place{% endif %}</td>
</tr>
{% endfor %}
</table>
<h4 class="bda-prix">Total à payer : {{ total|floatformat }}€</h4>
<br/>
<p>Ne manque pas un spectacle avec le
<a href="{% url "calendar" %}">calendrier
automatique&#8239;!</a></p>
{% else %}
<h3>Vous n'avez aucune place :(</h3>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{%block realcontent %}
<h2>Rachat d'une place</h2>
<form action="" method="post">
{% csrf_token %}
<pre>
Bonjour !
Je souhaiterais racheter ta place pour {{spectacle.title}} le {{spectacle.date}} ({{spectacle.location}}) à {{spectacle.price}}€.
Contacte-moi si tu es toujours intéressé-e !
{{user.get_full_name}} ({{user.email}})
</pre>
<input type="submit" class="btn btn-primary pull-right" value="Envoyer">
</form>
<p class="bda-prix">Note : ce mail sera envoyé à une personne au hasard revendant sa place.</p>
{%endblock%}

View file

@ -0,0 +1,9 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{% block realcontent %}
<h2>Inscription à une revente</h2>
<p class="success"> Votre inscription a bien été enregistrée !</p>
<p>Le tirage au sort pour cette revente ({{spectacle}}) sera effectué le {{date}}.
{% endblock %}

View file

@ -0,0 +1,8 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{% block realcontent %}
<h2>Revente de place</h2>
<p class="success">Un mail a bien été envoyé à {{seller.get_full_name}} ({{seller.email}}), pour racheter une place pour {{spectacle.title}} !</p>
{% endblock %}

View file

@ -0,0 +1,121 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{% block realcontent %}
<h2>Gestion des places que je revends</h2>
{% if resell_exists %}
<br />
<h3>Places non revendues</h3>
<form class="form-horizontal" action="" method="post">
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
Cochez les places que vous souhaitez revendre, et validez. Vous aurez
ensuite 1h pour changer d'avis avant que la revente soit confirmée et
que les notifications soient envoyées aux intéressé·e·s.
</div>
{% csrf_token %}
<table class="table table-striped stupidtable">
<thead>
<tr>
<th></th>
<th data-sort="string">Titre</th>
<th data-sort="string">Lieu</th>
<th data-sort="int">Date</th>
<th data-sort="int">Prix</th>
</tr>
</thead>
<tbody>
{% for checkbox in resellform.attributions %}{{ checkbox }}{% endfor %}
</tbody>
</table>
<div class="form-actions">
<input type="submit" class="btn btn-primary" name="resell" value="Revendre les places sélectionnées">
</div>
</form>
<hr />
{% endif %}
{% if annul_exists %}
<h3>Places en cours de revente</h3>
<form action="" method="post">
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
Vous pouvez annuler les reventes qui n'ont pas encore trouvé preneur·se.
</div>
{% csrf_token %}
<table class="table table-striped stupidtable">
<thead>
<tr>
<th></th>
<th data-sort="string">Titre</th>
<th data-sort="int">Date</th>
<th data-sort="string">Lieu</th>
<th data-sort="int">Prix</th>
<th data-sort="int">Tirage le</th>
</tr>
</thead>
<tbody>
{% for checkbox in annulform.reventes %}{{ checkbox }}{% endfor %}
</tbody>
</table>
<input type="submit" class="btn btn-primary" name="annul" value="Annuler les reventes sélectionnées">
</form>
<hr />
{% endif %}
{% if sold_exists %}
<h3>Places revendues</h3>
<form action="" method="post">
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
Pour chaque revente, vous devez soit l'annuler soit la confirmer pour
transférer la place la place à la personne tirée au sort.
L'annulation sert par exemple à pouvoir remettre la place en jeu si
vous ne parvenez pas à entrer en contact avec la personne tirée au
sort.
</div>
{% csrf_token %}
<table class="table table-striped stupidtable">
<thead>
<tr>
<th></th>
<th data-sort="string">Titre</th>
<th data-sort="int">Date</th>
<th data-sort="string">Lieu</th>
<th data-sort="int">Prix</th>
<th>Vendue à</th>
</tr>
</thead>
<tbody>
{% for checkbox in soldform.reventes %}{{ checkbox }}{% endfor %}
</tbody>
</table>
<button type="submit" class="btn btn-primary" name="transfer">Transférer</button>
<button type="submit" class="btn btn-primary" name="reinit">Réinitialiser</button>
</form>
{% endif %}
{% if not resell_exists and not annul_exists and not sold_exists %}
<p>Plus de reventes possibles !</p>
{% endif %}
<script language="JavaScript">
$(function(){
$("table.stupidtable").stupidtable();
});
$("tr").click(function() {
$(this).find("input[type=checkbox]").click()
});
$("input[type=checkbox]").click(function(e) {
e.stopPropagation();
});
</script>
{% endblock %}

View file

@ -0,0 +1,6 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2>BdA-Revente</h2>
<p>Il n'y a plus de places en revente pour ce spectacle, désolé !</p>
{% endblock %}

View file

@ -0,0 +1,6 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2><strong>Nope</strong></h2>
<p>Avant de revendre des places, il faut aller les payer !</p>
{% endblock %}

View file

@ -0,0 +1,29 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2>Places disponibles immédiatement</h2>
{% if spectacles %}
<table class="table table-striped stupidtable" id="bda-shotgun">
<thead>
<tr>
<th data-sort="string">Titre</th>
<th data-sort="int">Date</th>
<th data-sort="string">Lieu</th>
<th data-sort="int">Prix</th>
<th></th>
</tr>
</thead>
<tbody>
{% for spectacle in spectacles %}
<tr>
{% include "bda/forms/spectacle_label_table.html" with spectacle=spectacle %}
<td class="button"><a role="button" class="btn btn-primary" href="{% url 'bda-revente-buy' spectacle.id %}">Racheter</a>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p> Pas de places disponibles immédiatement, désolé !</p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,64 @@
{% extends "base_title.html" %}
{% load staticfiles%}
{% block realcontent %}
<h2>Inscriptions pour BdA-Revente</h2>
<form action="" class="form-horizontal" method="post">
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
Cochez les spectacles pour lesquels vous souhaitez recevoir une
notification quand une place est disponible en revente. <br />
Lorsque vous validez vos choix, si un tirage au sort est en cours pour
un des spectacles que vous avez sélectionné, vous serez automatiquement
inscrit à ce tirage.
</div>
<br />
{% csrf_token %}
<div class="form-group">
<button type="button"
class="btn btn-primary"
onClick="select(true)">Tout sélectionner</button>
<button type="button"
class="btn btn-primary"
onClick="select(false)">Tout désélectionner</button>
<table class="table table-striped stupidtable">
<thead>
<tr>
<th></th>
<th data-sort="string">Titre</th>
<th data-sort="int">Date</th>
<th data-sort="string">Lieu</th>
<th data-sort="int">Prix</th>
</tr>
</thead>
<tbody>
{% for checkbox in form.spectacles %}{{ checkbox }}{% endfor %}
</tbody>
</table>
</div>
<input type="submit"
class="btn btn-primary"
value="S'inscrire pour les places sélectionnées">
</form>
<script language="JavaScript">
function select(check) {
checkboxes = document.getElementsByName("spectacles");
for(var i=0, n=checkboxes.length; i < n; i++) {
checkboxes[i].checked = check;
}
}
$(function(){
$("table.stupidtable").stupidtable();
});
$("tr").click(function() {
$(this).find("input[type=checkbox]").click()
});
$("input[type=checkbox]").click(function(e) {
e.stopPropagation();
});
</script>
{% endblock %}

View file

@ -0,0 +1,99 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{% block realcontent %}
<h2>Tirages au sort de reventes</h2>
{% if annul_exists %}
<h3>Les reventes auxquelles vous êtes inscrit·e</h3>
<form class="form-horizontal" action="" method="post">
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
Voici la liste des reventes auxquelles vous êtes inscrit·e ; si vous ne souhaitez plus participer au tirage au sort vous pouvez vous en désister.
</div>
{% csrf_token %}
<table class="table table-striped stupidtable">
<thead>
<tr>
<th></th>
<th data-sort="string">Titre</th>
<th data-sort="int">Date</th>
<th data-sort="string">Lieu</th>
<th data-sort="int">Prix</th>
<th>Vendue par</th>
<th data-sort="int">Tirage le</th>
</tr>
</thead>
<tbody>
{% for checkbox in annulform.reventes %}{{ checkbox }}{% endfor %}
</tbody>
</table>
<div class="form-actions">
<input type="submit"
class="btn btn-primary"
name="annul"
value="Se désister des tirages sélectionnés">
</div>
</form>
{% endif %}
<hr />
{% if sub_exists %}
<h3>Tirages en cours</h3>
<form class="form-horizontal" action="" method="post">
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
Vous pouvez vous inscrire aux tirages en cours suivants.
</div>
{% csrf_token %}
<table class="table table-striped stupidtable">
<thead>
<tr>
<th></th>
<th data-sort="string">Titre</th>
<th data-sort="int">Date</th>
<th data-sort="string">Lieu</th>
<th data-sort="int">Prix</th>
<th>Vendue par</th>
<th data-sort="int">Tirage le</th>
</tr>
</thead>
<tbody>
{% for checkbox in subform.reventes %}{{ checkbox }}{% endfor %}
</tbody>
</table>
<div class="form-actions">
<input type="submit"
class="btn btn-primary"
name="subscribe"
value="S'inscrire aux tirages sélectionnés">
</div>
</form>
{% endif %}
{% if not annul_exists and not sub_exists %}
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
Aucune revente n'est active pour le moment !
</div>
{% endif %}
<script language="JavaScript">
$(function(){
$("table.stupidtable").stupidtable();
});
$("tr").click(function() {
$(this).find("input[type=checkbox]").click()
});
$("input[type=checkbox]").click(function(e) {
e.stopPropagation();
});
</script>
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2>Nope</h2>
{% if revente.shotgun %}
<p>Le tirage au sort de cette revente a déjà été effectué !</p>
<p>Si personne n'était intéressé, elle est maintenant disponible
<a href="{% url "bda-revente-buy" revente.attribution.spectacle.id %}">ici</a>.</p>
{% else %}
<p> Il n'est pas encore possible de s'inscrire à cette revente, réessaie dans quelque temps !</p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,53 @@
{% extends "base_title.html" %}
{% load staticfiles %}
{% block extra_head %}
<link type="text/css" rel="stylesheet" href="{% static "bda/css/bda.css" %}" />
{% endblock %}
{% block realcontent %}
<h2><strong>{{tirage_name}}</strong></h2>
<h3>Liste des spectacles</h3>
<table class="table table-striped table-hover etat-bda">
<thead>
<tr>
<th data-sort="string">Titre</th>
<th data-sort="int">Date</th>
<th data-sort="string">Lieu</th>
<th data-sort="float">Prix</th>
</tr>
</thead>
<tbody>
{% for spectacle in object_list %}
<tr class="clickable-row {% if spectacle.is_past %}spectacle-passe{% endif %}" data-href="{% url 'bda-spectacle' tirage_id spectacle.id %}">
<td><a href="{% url 'bda-spectacle' tirage_id spectacle.id %}">{{ spectacle.title }} <span style="font-size:small;" class="glyphicon glyphicon-link" aria-hidden="true"></span></a></td>
<td data-sort-value="{{ spectacle.timestamp }}"">{{ spectacle.date }}</td>
<td data-sort-value="{{ spectacle.location }}">{{ spectacle.location }}</td>
<td data-sort-value="{{ spectacle.price |stringformat:".3f" }}">
{{ spectacle.price |floatformat }}€
</td>
</tr>
{% endfor %}
</tbody>
</table>
<script type="text/javascript">
$(function(){
$("table.etat-bda").stupidtable();
});
</script>
<script>
jQuery(document).ready(function($) {
$(".clickable-row").click(function() {
window.document.location = $(this).data("href");
});
});
</script>
<h3> Exports </h3>
<ul>
<li><a href="{% url 'bda-unpaid' tirage_id %}">Mailing list impayés</a>
</ul>
{% endblock %}

View file

@ -0,0 +1,10 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2>Raté, le tirage ne peut pas être lancé&#8239;!</h2>
<p>Soit les inscriptions ne sont en pas encore fermées, soit le lancement du
tirage est désactivé. Si vous savez ce que vous faites, vous pouvez autoriser
le lancement du tirage dans
l'<a href="{% url "admin:index" %}">interface admin</a>.</p>
{% endblock %}

0
bda/tests/__init__.py Normal file
View file

75
bda/tests/mixins.py Normal file
View file

@ -0,0 +1,75 @@
import os
from django.conf import settings
from django.core.management import call_command
from django.utils import timezone
from shared.tests.mixins import ViewTestCaseMixin
from ..models import CategorieSpectacle, Salle, Spectacle, Tirage
from .utils import create_user
class BdAViewTestCaseMixin(ViewTestCaseMixin):
def get_users_base(self):
return {
"bda_other": create_user(username="bda_other"),
"bda_member": create_user(username="bda_member", is_cof=True),
"bda_staff": create_user(username="bda_staff", is_cof=True, is_buro=True),
}
class BdATestHelpers:
bda_testdata = False
def setUp(self):
super().setUp()
if self.bda_testdata:
self.load_bda_testdata()
def require_custommails(self):
data_file = os.path.join(
settings.BASE_DIR, "gestioncof", "management", "data", "custommail.json"
)
call_command("syncmails", data_file, verbosity=0)
def load_bda_testdata(self):
self.tirage = Tirage.objects.create(
title="Test tirage",
appear_catalogue=True,
ouverture=timezone.now(),
fermeture=timezone.now(),
)
self.category = CategorieSpectacle.objects.create(name="Category")
self.location = Salle.objects.create(name="here")
self.show1 = Spectacle.objects.create(
title="foo",
date=timezone.now(),
location=self.location,
price=0,
slots=42,
tirage=self.tirage,
listing=False,
category=self.category,
)
self.show2 = Spectacle.objects.create(
title="bar",
date=timezone.now(),
location=self.location,
price=1,
slots=142,
tirage=self.tirage,
listing=False,
category=self.category,
)
self.show3 = Spectacle.objects.create(
title="baz",
date=timezone.now(),
location=self.location,
price=2,
slots=242,
tirage=self.tirage,
listing=False,
category=self.category,
)

102
bda/tests/test_models.py Normal file
View file

@ -0,0 +1,102 @@
from datetime import timedelta
from unittest import mock
from django.contrib.auth import get_user_model
from django.core import mail
from django.test import TestCase
from django.utils import timezone
from bda.models import (
Attribution,
Participant,
Salle,
Spectacle,
SpectacleRevente,
Tirage,
)
User = get_user_model()
class SpectacleReventeTests(TestCase):
fixtures = ["gestioncof/management/data/custommail.json"]
def setUp(self):
now = timezone.now()
self.t = Tirage.objects.create(
title="Tirage",
ouverture=now - timedelta(days=7),
fermeture=now - timedelta(days=3),
active=True,
)
self.s = Spectacle.objects.create(
title="Spectacle",
date=now + timedelta(days=20),
location=Salle.objects.create(name="Salle", address="Address"),
price=10.5,
slots=5,
tirage=self.t,
listing=False,
)
self.seller = Participant.objects.create(
user=User.objects.create(username="seller", email="seller@mail.net"),
tirage=self.t,
)
self.p1 = Participant.objects.create(
user=User.objects.create(username="part1", email="part1@mail.net"),
tirage=self.t,
)
self.p2 = Participant.objects.create(
user=User.objects.create(username="part2", email="part2@mail.net"),
tirage=self.t,
)
self.p3 = Participant.objects.create(
user=User.objects.create(username="part3", email="part3@mail.net"),
tirage=self.t,
)
self.attr = Attribution.objects.create(
participant=self.seller, spectacle=self.s
)
self.rev = SpectacleRevente.objects.create(
attribution=self.attr, seller=self.seller
)
def test_tirage(self):
revente = self.rev
wanted_by = [self.p1, self.p2, self.p3]
revente.confirmed_entry.set(wanted_by)
with mock.patch("bda.models.random.choice") as mc:
# Set winner to self.p1.
mc.return_value = self.p1
revente.tirage()
# Call to random.choice used participants in wanted_by.
mc_args, _ = mc.call_args
self.assertEqual(set(mc_args[0]), set(wanted_by))
self.assertEqual(revente.soldTo, self.p1)
self.assertTrue(revente.tirage_done)
mails = {m.to[0]: m for m in mail.outbox}
self.assertEqual(len(mails), 4)
m_seller = mails["seller@mail.net"]
self.assertListEqual(m_seller.to, ["seller@mail.net"])
self.assertListEqual(m_seller.reply_to, ["part1@mail.net"])
m_winner = mails["part1@mail.net"]
self.assertListEqual(m_winner.to, ["part1@mail.net"])
self.assertCountEqual(
[mails["part2@mail.net"].to, mails["part3@mail.net"].to],
[["part2@mail.net"], ["part3@mail.net"]],
)

79
bda/tests/test_revente.py Normal file
View file

@ -0,0 +1,79 @@
from datetime import timedelta
from django.contrib.auth.models import User
from django.test import TestCase
from django.utils import timezone
from bda.models import (
Attribution,
CategorieSpectacle,
Participant,
Salle,
Spectacle,
SpectacleRevente,
Tirage,
)
class TestModels(TestCase):
def setUp(self):
self.tirage = Tirage.objects.create(
title="Tirage test",
appear_catalogue=True,
ouverture=timezone.now(),
fermeture=timezone.now(),
)
self.category = CategorieSpectacle.objects.create(name="Category")
self.location = Salle.objects.create(name="here")
self.spectacle_soon = Spectacle.objects.create(
title="foo",
date=timezone.now() + timedelta(days=1),
location=self.location,
price=0,
slots=42,
tirage=self.tirage,
listing=False,
category=self.category,
)
self.spectacle_later = Spectacle.objects.create(
title="bar",
date=timezone.now() + timedelta(days=30),
location=self.location,
price=0,
slots=42,
tirage=self.tirage,
listing=False,
category=self.category,
)
user_buyer = User.objects.create_user(
username="bda_buyer", password="testbuyer"
)
user_seller = User.objects.create_user(
username="bda_seller", password="testseller"
)
self.buyer = Participant.objects.create(user=user_buyer, tirage=self.tirage)
self.seller = Participant.objects.create(user=user_seller, tirage=self.tirage)
self.attr_soon = Attribution.objects.create(
participant=self.seller, spectacle=self.spectacle_soon
)
self.attr_later = Attribution.objects.create(
participant=self.seller, spectacle=self.spectacle_later
)
self.revente_soon = SpectacleRevente.objects.create(
seller=self.seller, attribution=self.attr_soon
)
self.revente_later = SpectacleRevente.objects.create(
seller=self.seller, attribution=self.attr_later
)
def test_urgent(self):
self.assertTrue(self.revente_soon.is_urgent)
self.assertFalse(self.revente_later.is_urgent)
def test_tirage(self):
self.revente_soon.confirmed_entry.add(self.buyer)
self.assertEqual(self.revente_soon.tirage(send_mails=False), self.buyer)
self.assertIsNone(self.revente_later.tirage(send_mails=False))

365
bda/tests/test_views.py Normal file
View file

@ -0,0 +1,365 @@
import json
from datetime import timedelta
from unittest import mock
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import formats, timezone
from ..models import Participant, Tirage
from .mixins import BdATestHelpers, BdAViewTestCaseMixin
User = get_user_model()
class InscriptionViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase):
url_name = "bda-tirage-inscription"
http_methods = ["GET", "POST"]
auth_user = "bda_member"
auth_forbidden = [None, "bda_other"]
bda_testdata = True
@property
def url_kwargs(self):
return {"tirage_id": self.tirage.id}
@property
def url_expected(self):
return "/bda/inscription/{}".format(self.tirage.id)
def test_get_opened(self):
self.tirage.ouverture = timezone.now() - timedelta(days=1)
self.tirage.fermeture = timezone.now() + timedelta(days=1)
self.tirage.save()
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 200)
self.assertFalse(resp.context["messages"])
def test_get_closed_future(self):
self.tirage.ouverture = timezone.now() + timedelta(days=1)
self.tirage.fermeture = timezone.now() + timedelta(days=2)
self.tirage.save()
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 200)
self.assertIn(
"Le tirage n'est pas encore ouvert : ouverture le {}".format(
formats.localize(timezone.template_localtime(self.tirage.ouverture))
),
[str(msg) for msg in resp.context["messages"]],
)
def test_get_closed_past(self):
self.tirage.ouverture = timezone.now() - timedelta(days=2)
self.tirage.fermeture = timezone.now() - timedelta(days=1)
self.tirage.save()
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 200)
self.assertIn(
" C'est fini : tirage au sort dans la journée !",
[str(msg) for msg in resp.context["messages"]],
)
def get_base_post_data(self):
return {
"choixspectacle_set-TOTAL_FORMS": "3",
"choixspectacle_set-INITIAL_FORMS": "0",
"choixspectacle_set-MIN_NUM_FORMS": "0",
"choixspectacle_set-MAX_NUM_FORMS": "1000",
}
base_post_data = property(get_base_post_data)
def test_post(self):
self.tirage.ouverture = timezone.now() - timedelta(days=1)
self.tirage.fermeture = timezone.now() + timedelta(days=1)
self.tirage.save()
data = dict(
self.base_post_data,
**{
"choixspectacle_set-TOTAL_FORMS": "2",
"choixspectacle_set-0-id": "",
"choixspectacle_set-0-participant": "",
"choixspectacle_set-0-spectacle": str(self.show1.pk),
"choixspectacle_set-0-double_choice": "1",
"choixspectacle_set-0-priority": "2",
"choixspectacle_set-1-id": "",
"choixspectacle_set-1-participant": "",
"choixspectacle_set-1-spectacle": str(self.show2.pk),
"choixspectacle_set-1-double_choice": "autoquit",
"choixspectacle_set-1-priority": "1",
}
)
resp = self.client.post(self.url, data)
self.assertEqual(resp.status_code, 200)
self.assertIn(
"Votre inscription a été mise à jour avec succès !",
[str(msg) for msg in resp.context["messages"]],
)
participant = Participant.objects.get(
user=self.users["bda_member"], tirage=self.tirage
)
self.assertSetEqual(
set(
participant.choixspectacle_set.values_list(
"priority", "spectacle_id", "double_choice"
)
),
{(1, self.show2.pk, "autoquit"), (2, self.show1.pk, "1")},
)
def test_post_state_changed(self):
self.tirage.ouverture = timezone.now() - timedelta(days=1)
self.tirage.fermeture = timezone.now() + timedelta(days=1)
self.tirage.save()
data = {"dbstate": "different"}
resp = self.client.post(self.url, data)
self.assertEqual(resp.status_code, 200)
self.assertIn(
"Impossible d'enregistrer vos modifications : vous avez apporté d'autres "
"modifications entre temps.",
[str(msg) for msg in resp.context["messages"]],
)
class PlacesViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase):
url_name = "bda-places-attribuees"
auth_user = "bda_member"
auth_forbidden = [None, "bda_other"]
bda_testdata = True
@property
def url_kwargs(self):
return {"tirage_id": self.tirage.id}
@property
def url_expected(self):
return "/bda/places/{}".format(self.tirage.id)
class EtatPlacesViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase):
url_name = "bda-etat-places"
auth_user = "bda_member"
auth_forbidden = [None, "bda_other"]
bda_testdata = True
@property
def url_kwargs(self):
return {"tirage_id": self.tirage.id}
@property
def url_expected(self):
return "/bda/etat-places/{}".format(self.tirage.id)
class TirageViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase):
url_name = "bda-tirage"
http_methods = ["GET", "POST"]
auth_user = "bda_staff"
auth_forbidden = [None, "bda_other", "bda_member"]
bda_testdata = True
@property
def url_kwargs(self):
return {"tirage_id": self.tirage.id}
@property
def url_expected(self):
return "/bda/tirage/{}".format(self.tirage.id)
def test_perform_tirage_disabled(self):
# Cannot be performed if disabled
self.tirage.enable_do_tirage = False
self.tirage.save()
resp = self.client.get(self.url)
self.assertTemplateUsed(resp, "tirage-failed.html")
def test_perform_tirage_opened_registrations(self):
# Cannot be performed if registrations are still open
self.tirage.enable_do_tirage = True
self.tirage.fermeture = timezone.now() + timedelta(seconds=3600)
self.tirage.save()
resp = self.client.get(self.url)
self.assertTemplateUsed(resp, "tirage-failed.html")
def test_perform_tirage(self):
# Otherwise, perform the tirage
self.tirage.enable_do_tirage = True
self.tirage.fermeture = timezone.now()
self.tirage.save()
resp = self.client.get(self.url)
self.assertTemplateNotUsed(resp, "tirage-failed.html")
class SpectacleListViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase):
url_name = "bda-liste-spectacles"
auth_user = "bda_staff"
auth_forbidden = [None, "bda_other", "bda_member"]
bda_testdata = True
@property
def url_kwargs(self):
return {"tirage_id": self.tirage.id}
@property
def url_expected(self):
return "/bda/spectacles/{}".format(self.tirage.id)
class SpectacleViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase):
url_name = "bda-spectacle"
auth_user = "bda_staff"
auth_forbidden = [None, "bda_other", "bda_member"]
bda_testdata = True
@property
def url_kwargs(self):
return {"tirage_id": self.tirage.id, "spectacle_id": self.show1.id}
@property
def url_expected(self):
return "/bda/spectacles/{}/{}".format(self.tirage.id, self.show1.id)
class UnpaidViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase):
url_name = "bda-unpaid"
auth_user = "bda_staff"
auth_forbidden = [None, "bda_other", "bda_member"]
bda_testdata = True
@property
def url_kwargs(self):
return {"tirage_id": self.tirage.id}
@property
def url_expected(self):
return "/bda/spectacles/unpaid/{}".format(self.tirage.id)
class SendRemindersViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase):
url_name = "bda-rappels"
auth_user = "bda_staff"
auth_forbidden = [None, "bda_other", "bda_member"]
bda_testdata = True
@property
def url_kwargs(self):
return {"spectacle_id": self.show1.id}
@property
def url_expected(self):
return "/bda/mails-rappel/{}".format(self.show1.id)
def test_post(self):
self.require_custommails()
resp = self.client.post(self.url)
self.assertEqual(200, resp.status_code)
# TODO: check that emails are sent
class CatalogueViewTestCase(BdATestHelpers, BdAViewTestCaseMixin, TestCase):
auth_user = None
auth_forbidden = []
bda_testdata = True
def test_api_list(self):
url_list = "/bda/catalogue/list"
resp = self.client.get(url_list)
self.assertJSONEqual(
resp.content.decode("utf-8"),
[{"id": self.tirage.id, "title": self.tirage.title}],
)
def test_api_details(self):
url_details = "/bda/catalogue/details?id={}".format(self.tirage.id)
resp = self.client.get(url_details)
self.assertJSONEqual(
resp.content.decode("utf-8"),
{
"categories": [{"id": self.category.id, "name": self.category.name}],
"locations": [{"id": self.location.id, "name": self.location.name}],
},
)
def test_api_descriptions(self):
url_descriptions = "/bda/catalogue/descriptions?id={}".format(self.tirage.id)
resp = self.client.get(url_descriptions)
raw = resp.content.decode("utf-8")
try:
results = json.loads(raw)
except ValueError:
self.fail("Not valid JSON: {}".format(raw))
self.assertEqual(len(results), 3)
self.assertEqual(
{(s["title"], s["price"], s["slots"]) for s in results},
{("foo", 0, 42), ("bar", 1, 142), ("baz", 2, 242)},
)
# ----- BdA Revente --------------------------------------- #
def make_participant(name: str, tirage: Tirage) -> User:
user = User.objects.create_user(username=name, password=name)
user.profile.is_cof = True
user.profile.save()
Participant.objects.create(user=user, tirage=tirage)
return user
class TestReventeManageTest(TestCase):
def setUp(self):
self.tirage = Tirage.objects.create(
title="tirage1",
ouverture=timezone.now(),
fermeture=timezone.now() + timedelta(days=90),
)
self.user = make_participant("toto", self.tirage)
self.url = reverse("bda-revente-manage", args=[self.tirage.id])
# 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)
def test_can_get(self):
client = Client()
client.force_login(self.user)
r = client.get(self.url)
self.assertEqual(r.status_code, 200)
class TestBdaRevente:
pass
# TODO

36
bda/tests/utils.py Normal file
View file

@ -0,0 +1,36 @@
from datetime import timedelta
from django.contrib.auth.models import User
from django.utils import timezone
from ..models import CategorieSpectacle, Salle, Spectacle, Tirage
def create_user(username, is_cof=False, is_buro=False):
user = User.objects.create_user(username=username, password=username)
user.profile.is_cof = is_cof
user.profile.is_buro = is_buro
user.profile.save()
return user
def user_is_cof(user):
return (user is not None) and user.profile.is_cof
def user_is_staff(user):
return (user is not None) and user.profile.is_buro
def create_spectacle(**kwargs):
defaults = {
"title": "Title",
"category": CategorieSpectacle.objects.first(),
"date": (timezone.now() + timedelta(days=7)).date(),
"location": Salle.objects.first(),
"price": 10.0,
"slots": 20,
"tirage": Tirage.objects.first(),
"listing": False,
}
return Spectacle.objects.create(**dict(defaults, **kwargs))

74
bda/urls.py Normal file
View file

@ -0,0 +1,74 @@
from django.conf.urls import url
from bda import views
from bda.views import SpectacleListView
from gestioncof.decorators import buro_required
urlpatterns = [
url(
r"^inscription/(?P<tirage_id>\d+)$",
views.inscription,
name="bda-tirage-inscription",
),
url(r"^places/(?P<tirage_id>\d+)$", views.places, name="bda-places-attribuees"),
url(r"^etat-places/(?P<tirage_id>\d+)$", views.etat_places, name="bda-etat-places"),
url(r"^tirage/(?P<tirage_id>\d+)$", views.tirage, name="bda-tirage"),
url(
r"^spectacles/(?P<tirage_id>\d+)$",
buro_required(SpectacleListView.as_view()),
name="bda-liste-spectacles",
),
url(
r"^spectacles/(?P<tirage_id>\d+)/(?P<spectacle_id>\d+)$",
views.spectacle,
name="bda-spectacle",
),
url(
r"^spectacles/unpaid/(?P<tirage_id>\d+)$",
views.UnpaidParticipants.as_view(),
name="bda-unpaid",
),
url(
r"^spectacles/autocomplete$",
views.spectacle_autocomplete,
name="bda-spectacle-autocomplete",
),
url(
r"^participants/autocomplete$",
views.participant_autocomplete,
name="bda-participant-autocomplete",
),
# Urls BdA-Revente
url(
r"^revente/(?P<tirage_id>\d+)/manage$",
views.revente_manage,
name="bda-revente-manage",
),
url(
r"^revente/(?P<tirage_id>\d+)/subscribe$",
views.revente_subscribe,
name="bda-revente-subscribe",
),
url(
r"^revente/(?P<tirage_id>\d+)/tirages$",
views.revente_tirages,
name="bda-revente-tirages",
),
url(
r"^revente/(?P<spectacle_id>\d+)/buy$",
views.revente_buy,
name="bda-revente-buy",
),
url(
r"^revente/(?P<revente_id>\d+)/confirm$",
views.revente_confirm,
name="bda-revente-confirm",
),
url(
r"^revente/(?P<tirage_id>\d+)/shotgun$",
views.revente_shotgun,
name="bda-revente-shotgun",
),
url(r"^mails-rappel/(?P<spectacle_id>\d+)$", views.send_rappel, name="bda-rappels"),
url(r"^catalogue/(?P<request_type>[a-z]+)$", views.catalogue, name="bda-catalogue"),
]

910
bda/views.py Normal file
View file

@ -0,0 +1,910 @@
import hashlib
import json
import random
import time
from collections import defaultdict
from custommail.models import CustomMail
from custommail.shortcuts import send_custom_mail, send_mass_custom_mail
from django.conf import settings
from django.contrib import messages
from django.core import serializers
from django.core.exceptions import NON_FIELD_ERRORS
from django.db import transaction
from django.db.models import Count, Prefetch
from django.forms.models import inlineformset_factory
from django.http import HttpResponseBadRequest, HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.template.defaultfilters import pluralize
from django.urls import reverse
from django.utils import formats, timezone
from django.views.generic.list import ListView
from bda.algorithm import Algorithm
from bda.forms import (
AnnulForm,
InscriptionInlineFormSet,
InscriptionReventeForm,
ResellForm,
ReventeTirageAnnulForm,
ReventeTirageForm,
SoldForm,
TokenForm,
)
from bda.models import (
Attribution,
CategorieSpectacle,
ChoixSpectacle,
Participant,
Salle,
Spectacle,
SpectacleRevente,
Tirage,
)
from gestioncof.decorators import BuroRequiredMixin, buro_required, cof_required
from shared.views import Select2QuerySetView
@cof_required
def etat_places(request, tirage_id):
"""
Résumé des spectacles d'un tirage avec pour chaque spectacle :
- Le nombre de places en jeu
- Le nombre de demandes
- Le ratio demandes/places
Et le total de toutes les demandes
"""
tirage = get_object_or_404(Tirage, id=tirage_id)
spectacles = tirage.spectacle_set.select_related("location")
spectacles_dict = {} # index of spectacle by id
for spectacle in spectacles:
spectacle.total = 0 # init total requests
spectacles_dict[spectacle.id] = spectacle
choices = (
ChoixSpectacle.objects.filter(spectacle__in=spectacles)
.values("spectacle")
.annotate(total=Count("spectacle"))
)
# choices *by spectacles* whose only 1 place is requested
choices1 = choices.filter(double_choice="1")
# choices *by spectacles* whose 2 places is requested
choices2 = choices.exclude(double_choice="1")
for spectacle in choices1:
pk = spectacle["spectacle"]
spectacles_dict[pk].total += spectacle["total"]
for spectacle in choices2:
pk = spectacle["spectacle"]
spectacles_dict[pk].total += 2 * spectacle["total"]
# here, each spectacle.total contains the number of requests
slots = 0 # proposed slots
total = 0 # requests
for spectacle in spectacles:
slots += spectacle.slots
total += spectacle.total
spectacle.ratio = spectacle.total / spectacle.slots
context = {
"proposed": slots,
"spectacles": spectacles,
"total": total,
"tirage": tirage,
}
return render(request, "bda/etat-places.html", context)
def _hash_queryset(queryset):
data = serializers.serialize("json", queryset).encode("utf-8")
hasher = hashlib.sha256()
hasher.update(data)
return hasher.hexdigest()
@cof_required
def places(request, tirage_id):
tirage = get_object_or_404(Tirage, id=tirage_id)
participant, _ = Participant.objects.get_or_create(user=request.user, tirage=tirage)
places = participant.attribution_set.order_by(
"spectacle__date", "spectacle"
).select_related("spectacle", "spectacle__location")
total = sum(place.spectacle.price for place in places)
filtered_places = []
places_dict = {}
spectacles = []
dates = []
warning = False
for place in places:
if place.spectacle in spectacles:
places_dict[place.spectacle].double = True
else:
place.double = False
places_dict[place.spectacle] = place
spectacles.append(place.spectacle)
filtered_places.append(place)
date = place.spectacle.date.date()
if date in dates:
warning = True
else:
dates.append(date)
# On prévient l'utilisateur s'il a deux places à la même date
if warning:
messages.warning(
request,
"Attention, vous avez reçu des places pour "
"des spectacles différents à la même date.",
)
return render(
request,
"bda/resume_places.html",
{
"participant": participant,
"places": filtered_places,
"tirage": tirage,
"total": total,
},
)
@cof_required
def inscription(request, tirage_id):
"""
Vue d'inscription à un tirage BdA.
- On vérifie qu'on se situe bien entre la date d'ouverture et la date de
fermeture des inscriptions.
- On vérifie que l'inscription n'a pas été modifiée entre le moment le
client demande le formulaire et le moment il soumet son inscription
(autre session par exemple).
"""
tirage = get_object_or_404(Tirage, id=tirage_id)
if timezone.now() < tirage.ouverture:
# Le tirage n'est pas encore ouvert.
opening = formats.localize(timezone.template_localtime(tirage.ouverture))
messages.error(
request,
"Le tirage n'est pas encore ouvert : " "ouverture le {:s}".format(opening),
)
return render(request, "bda/resume-inscription-tirage.html", {})
participant, _ = Participant.objects.select_related("tirage").get_or_create(
user=request.user, tirage=tirage
)
if timezone.now() > tirage.fermeture:
# Le tirage est fermé.
choices = participant.choixspectacle_set.order_by("priority")
messages.error(request, " C'est fini : tirage au sort dans la journée !")
return render(
request, "bda/resume-inscription-tirage.html", {"choices": choices}
)
BdaFormSet = inlineformset_factory(
Participant,
ChoixSpectacle,
fields=("spectacle", "double_choice", "priority"),
formset=InscriptionInlineFormSet,
error_messages={
NON_FIELD_ERRORS: {
"unique_together": "Vous avez déjà demandé ce voeu plus haut !"
}
},
)
if request.method == "POST":
# use *this* queryset
dbstate = _hash_queryset(participant.choixspectacle_set.all())
if "dbstate" in request.POST and dbstate != request.POST["dbstate"]:
formset = BdaFormSet(instance=participant)
messages.error(
request,
"Impossible d'enregistrer vos modifications "
": vous avez apporté d'autres modifications "
"entre temps.",
)
else:
formset = BdaFormSet(request.POST, instance=participant)
if formset.is_valid():
formset.save()
formset = BdaFormSet(instance=participant)
participant.accepte_charte = True
participant.save()
messages.success(
request, "Votre inscription a été mise à jour avec succès !"
)
else:
messages.error(
request,
"Une erreur s'est produite lors de l'enregistrement de vos vœux. "
"Avez-vous demandé plusieurs fois le même spectacle ?",
)
else:
formset = BdaFormSet(instance=participant)
# use *this* queryset
dbstate = _hash_queryset(participant.choixspectacle_set.all())
total_price = 0
choices = participant.choixspectacle_set.select_related("spectacle")
for choice in choices:
total_price += choice.spectacle.price
if choice.double:
total_price += choice.spectacle.price
return render(
request,
"bda/inscription-tirage.html",
{
"formset": formset,
"total_price": total_price,
"dbstate": dbstate,
"tirage": tirage,
"charte": participant.accepte_charte,
},
)
def do_tirage(tirage_elt, token):
"""
Fonction auxiliaire à la vue ``tirage`` qui lance effectivement le tirage
après qu'on a vérifié que c'est légitime et que le token donné en argument
est correct.
Rend les résultats
"""
# Initialisation du dictionnaire data qui va contenir les résultats
start = time.time()
data = {
"shows": tirage_elt.spectacle_set.select_related("location"),
"token": token,
"members": tirage_elt.participant_set.select_related("user"),
"total_slots": 0,
"total_losers": 0,
"total_sold": 0,
"total_deficit": 0,
"opera_deficit": 0,
}
# On lance le tirage
choices = (
ChoixSpectacle.objects.filter(spectacle__tirage=tirage_elt)
.order_by("participant", "priority")
.select_related("participant", "participant__user", "spectacle")
)
results = Algorithm(data["shows"], data["members"], choices)(token)
# On compte les places attribuées et les déçus
for (_, members, losers) in results:
data["total_slots"] += len(members)
data["total_losers"] += len(losers)
# On calcule le déficit et les bénéfices pour le BdA
# FIXME: le traitement de l'opéra est sale
for (show, members, _) in results:
deficit = (show.slots - len(members)) * show.price
data["total_sold"] += show.slots * show.price
if deficit >= 0:
if "Opéra" in show.location.name:
data["opera_deficit"] += deficit
data["total_deficit"] += deficit
data["total_sold"] -= data["total_deficit"]
# Participant objects are not shared accross spectacle results,
# so assign a single object for each Participant id
members_uniq = {}
members2 = {}
for (show, members, _) in results:
for (member, _, _, _) in members:
if member.id not in members_uniq:
members_uniq[member.id] = member
members2[member] = []
member.total = 0
member = members_uniq[member.id]
members2[member].append(show)
member.total += show.price
members2 = members2.items()
data["members2"] = sorted(members2, key=lambda m: m[0].user.last_name)
# ---
# À partir d'ici, le tirage devient effectif
# ---
# On suppression les vieilles attributions, on sauvegarde le token et on
# désactive le tirage
Attribution.objects.filter(spectacle__tirage=tirage_elt).delete()
tirage_elt.tokens += '{:s}\n"""{:s}"""\n'.format(
timezone.now().strftime("%y-%m-%d %H:%M:%S"), token
)
tirage_elt.enable_do_tirage = False
tirage_elt.save()
# On enregistre les nouvelles attributions
Attribution.objects.bulk_create(
[
Attribution(spectacle=show, participant=member)
for show, members, _ in results
for member, _, _, _ in members
]
)
# On inscrit à BdA-Revente ceux qui n'ont pas eu les places voulues
ChoixRevente = Participant.choicesrevente.through
# Suppression des reventes demandées/enregistrées
# (si le tirage est relancé)
(ChoixRevente.objects.filter(spectacle__tirage=tirage_elt).delete())
(
SpectacleRevente.objects.filter(
attribution__spectacle__tirage=tirage_elt
).delete()
)
lost_by = defaultdict(set)
for show, _, losers in results:
for loser, _, _, _ in losers:
lost_by[loser].add(show)
ChoixRevente.objects.bulk_create(
ChoixRevente(participant=member, spectacle=show)
for member, shows in lost_by.items()
for show in shows
)
data["duration"] = time.time() - start
data["results"] = results
return data
@buro_required
def tirage(request, tirage_id):
tirage_elt = get_object_or_404(Tirage, id=tirage_id)
if not (tirage_elt.enable_do_tirage and tirage_elt.fermeture < timezone.now()):
return render(request, "tirage-failed.html", {"tirage": tirage_elt})
if request.POST:
form = TokenForm(request.POST)
if form.is_valid():
results = do_tirage(tirage_elt, form.cleaned_data["token"])
return render(request, "bda-attrib-extra.html", results)
else:
form = TokenForm()
return render(request, "bda-token.html", {"form": form})
@cof_required
def revente_manage(request, tirage_id):
"""
Gestion de ses propres reventes :
- Création d'une revente
- Annulation d'une revente
- Confirmation d'une revente = transfert de la place à la personne qui
rachète
- Annulation d'une revente après que le tirage a eu lieu
"""
tirage = get_object_or_404(Tirage, id=tirage_id)
participant, created = Participant.objects.annotate_paid().get_or_create(
user=request.user, tirage=tirage
)
# If the participant has just been created, the `paid` field is not
# automatically added by our custom ObjectManager. Skip the check in this
# scenario.
if not created and not participant.paid:
return render(request, "bda/revente/notpaid.html", {})
resellform = ResellForm(participant, prefix="resell")
annulform = AnnulForm(participant, prefix="annul")
soldform = SoldForm(participant, prefix="sold")
if request.method == "POST":
# On met en vente une place
if "resell" in request.POST:
resellform = ResellForm(participant, request.POST, prefix="resell")
if resellform.is_valid():
datatuple = []
attributions = resellform.cleaned_data["attributions"]
with transaction.atomic():
for attribution in attributions:
revente, created = SpectacleRevente.objects.get_or_create(
attribution=attribution, defaults={"seller": participant}
)
if not created:
revente.reset()
context = {
"vendeur": participant.user,
"show": attribution.spectacle,
"revente": revente,
}
datatuple.append(
(
"bda-revente-new",
context,
settings.MAIL_DATA["revente"]["FROM"],
[participant.user.email],
)
)
revente.save()
send_mass_custom_mail(datatuple)
# On annule une revente
elif "annul" in request.POST:
annulform = AnnulForm(participant, request.POST, prefix="annul")
if annulform.is_valid():
reventes = annulform.cleaned_data["reventes"]
for revente in reventes:
revente.delete()
# On confirme une vente en transférant la place à la personne qui a
# gagné le tirage
elif "transfer" in request.POST:
soldform = SoldForm(participant, request.POST, prefix="sold")
if soldform.is_valid():
reventes = soldform.cleaned_data["reventes"]
for revente in reventes:
revente.attribution.participant = revente.soldTo
revente.attribution.save()
# On annule la revente après le tirage au sort (par exemple si
# la personne qui a gagné le tirage ne se manifeste pas). La place est
# alors remise en vente
elif "reinit" in request.POST:
soldform = SoldForm(participant, request.POST, prefix="sold")
if soldform.is_valid():
reventes = soldform.cleaned_data["reventes"]
for revente in reventes:
if revente.attribution.spectacle.date > timezone.now():
# On antidate pour envoyer le mail plus vite
new_date = timezone.now() - SpectacleRevente.remorse_time
revente.reset(new_date=new_date)
sold_exists = soldform.fields["reventes"].queryset.exists()
annul_exists = annulform.fields["reventes"].queryset.exists()
resell_exists = resellform.fields["attributions"].queryset.exists()
return render(
request,
"bda/revente/manage.html",
{
"tirage": tirage,
"soldform": soldform,
"annulform": annulform,
"resellform": resellform,
"sold_exists": sold_exists,
"annul_exists": annul_exists,
"resell_exists": resell_exists,
},
)
@cof_required
def revente_tirages(request, tirage_id):
"""
Affiche à un participant la liste de toutes les reventes en cours (pour un
tirage donné) et lui permet de s'inscrire et se désinscrire à ces reventes.
"""
tirage = get_object_or_404(Tirage, id=tirage_id)
participant, _ = Participant.objects.get_or_create(user=request.user, tirage=tirage)
subform = ReventeTirageForm(participant, prefix="subscribe")
annulform = ReventeTirageAnnulForm(participant, prefix="annul")
if request.method == "POST":
if "subscribe" in request.POST:
subform = ReventeTirageForm(participant, request.POST, prefix="subscribe")
if subform.is_valid():
reventes = subform.cleaned_data["reventes"]
count = reventes.count()
for revente in reventes:
revente.confirmed_entry.add(participant)
if count > 0:
messages.success(
request,
"Tu as bien été inscrit à {} revente{}".format(
count, pluralize(count)
),
)
elif "annul" in request.POST:
annulform = ReventeTirageAnnulForm(
participant, request.POST, prefix="annul"
)
if annulform.is_valid():
reventes = annulform.cleaned_data["reventes"]
count = reventes.count()
for revente in reventes:
revente.confirmed_entry.remove(participant)
if count > 0:
messages.success(
request,
"Tu as bien été désinscrit de {} revente{}".format(
count, pluralize(count)
),
)
annul_exists = annulform.fields["reventes"].queryset.exists()
sub_exists = subform.fields["reventes"].queryset.exists()
return render(
request,
"bda/revente/tirages.html",
{
"annulform": annulform,
"subform": subform,
"annul_exists": annul_exists,
"sub_exists": sub_exists,
},
)
@cof_required
def revente_confirm(request, revente_id):
revente = get_object_or_404(SpectacleRevente, id=revente_id)
participant, _ = Participant.objects.get_or_create(
user=request.user, tirage=revente.attribution.spectacle.tirage
)
if not revente.notif_sent or revente.shotgun:
return render(request, "bda/revente/wrongtime.html", {"revente": revente})
revente.confirmed_entry.add(participant)
return render(
request,
"bda/revente/confirmed.html",
{"spectacle": revente.attribution.spectacle, "date": revente.date_tirage},
)
@cof_required
def revente_subscribe(request, tirage_id):
"""
Permet à un participant de sélectionner ses préférences pour les reventes.
Il recevra des notifications pour les spectacles qui l'intéressent et il
est automatiquement inscrit aux reventes en cours au moment il ajoute un
spectacle à la liste des spectacles qui l'intéressent.
"""
tirage = get_object_or_404(Tirage, id=tirage_id)
participant, _ = Participant.objects.get_or_create(user=request.user, tirage=tirage)
deja_revente = False
success = False
inscrit_revente = []
if request.method == "POST":
form = InscriptionReventeForm(tirage, request.POST)
if form.is_valid():
choices = form.cleaned_data["spectacles"]
participant.choicesrevente.set(choices)
participant.save()
for spectacle in choices:
qset = SpectacleRevente.objects.filter(attribution__spectacle=spectacle)
if qset.filter(shotgun=True, soldTo__isnull=True).exists():
# Une place est disponible au shotgun, on suggère à
# l'utilisateur d'aller la récupérer
deja_revente = True
else:
# La place n'est pas disponible au shotgun, si des reventes
# pour ce spectacle existent déjà, on inscrit la personne à
# la revente ayant le moins d'inscrits
min_resell = (
qset.filter(shotgun=False)
.annotate(nb_subscribers=Count("confirmed_entry"))
.order_by("nb_subscribers")
.first()
)
if min_resell is not None:
min_resell.confirmed_entry.add(participant)
inscrit_revente.append(spectacle)
success = True
else:
form = InscriptionReventeForm(
tirage, initial={"spectacles": participant.choicesrevente.all()}
)
# Messages
if success:
messages.success(request, "Votre inscription a bien été prise en compte")
if deja_revente:
messages.info(
request,
"Des reventes existent déjà pour certains de "
"ces spectacles, vérifiez les places "
"disponibles sans tirage !",
)
if inscrit_revente:
shows = map("<li>{!s}</li>".format, inscrit_revente)
msg = (
"Vous avez été inscrit·e à des reventes en cours pour les spectacles "
"<ul>{:s}</ul>".format("\n".join(shows))
)
messages.info(request, msg, extra_tags="safe")
return render(request, "bda/revente/subscribe.html", {"form": form})
@cof_required
def revente_buy(request, spectacle_id):
spectacle = get_object_or_404(Spectacle, id=spectacle_id)
tirage = spectacle.tirage
participant, _ = Participant.objects.get_or_create(user=request.user, tirage=tirage)
reventes = SpectacleRevente.objects.filter(
attribution__spectacle=spectacle, soldTo__isnull=True
)
# Si l'utilisateur veut racheter une place qu'il est en train de revendre,
# on supprime la revente en question.
own_reventes = reventes.filter(seller=participant)
if len(own_reventes) > 0:
own_reventes[0].delete()
return HttpResponseRedirect(reverse("bda-revente-shotgun", args=[tirage.id]))
reventes_shotgun = reventes.filter(shotgun=True)
if not reventes_shotgun:
return render(request, "bda/revente/none.html", {})
if request.POST:
revente = random.choice(reventes_shotgun)
revente.soldTo = participant
revente.save()
context = {
"show": spectacle,
"acheteur": request.user,
"vendeur": revente.seller.user,
}
send_custom_mail(
"bda-buy-shotgun",
"bda@ens.fr",
[revente.seller.user.email],
context=context,
)
return render(
request,
"bda/revente/mail-success.html",
{"seller": revente.attribution.participant.user, "spectacle": spectacle},
)
return render(
request,
"bda/revente/confirm-shotgun.html",
{"spectacle": spectacle, "user": request.user},
)
@cof_required
def revente_shotgun(request, tirage_id):
tirage = get_object_or_404(Tirage, id=tirage_id)
spectacles = (
tirage.spectacle_set.filter(date__gte=timezone.now())
.select_related("location")
.prefetch_related(
Prefetch(
"attribues",
queryset=(
Attribution.objects.filter(
revente__shotgun=True, revente__soldTo__isnull=True
)
),
to_attr="shotguns",
)
)
)
shotgun = [sp for sp in spectacles if len(sp.shotguns) > 0]
return render(request, "bda/revente/shotgun.html", {"spectacles": shotgun})
@buro_required
def spectacle(request, tirage_id, spectacle_id):
tirage = get_object_or_404(Tirage, id=tirage_id)
spectacle = get_object_or_404(Spectacle, id=spectacle_id, tirage=tirage)
attributions = spectacle.attribues.select_related(
"participant", "participant__user"
)
participants = {}
for attrib in attributions:
participant = attrib.participant
participant_info = {
"lastname": participant.user.last_name,
"name": participant.user.get_full_name,
"username": participant.user.username,
"email": participant.user.email,
"given": int(attrib.given),
"paid": True,
"nb_places": 1,
}
if participant.id in participants:
participants[participant.id]["nb_places"] += 1
participants[participant.id]["given"] += attrib.given
participants[participant.id]["paid"] &= attrib.paid
else:
participants[participant.id] = participant_info
participants_info = sorted(participants.values(), key=lambda part: part["lastname"])
return render(
request,
"bda/participants.html",
{"spectacle": spectacle, "participants": participants_info},
)
class SpectacleListView(ListView):
model = Spectacle
template_name = "spectacle_list.html"
def get_queryset(self):
self.tirage = get_object_or_404(Tirage, id=self.kwargs["tirage_id"])
categories = self.tirage.spectacle_set.select_related("location")
return categories
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["tirage_id"] = self.tirage.id
context["tirage_name"] = self.tirage.title
return context
class UnpaidParticipants(BuroRequiredMixin, ListView):
context_object_name = "unpaid"
template_name = "bda-unpaid.html"
def get_queryset(self):
return (
Participant.objects.annotate_paid()
.filter(tirage__id=self.kwargs["tirage_id"], paid=False)
.select_related("user")
)
@buro_required
def send_rappel(request, spectacle_id):
show = get_object_or_404(Spectacle, id=spectacle_id)
# Mails d'exemples
custommail = CustomMail.objects.get(shortname="bda-rappel")
exemple_mail_1place = custommail.render(
{"member": request.user, "show": show, "nb_attr": 1}
)
exemple_mail_2places = custommail.render(
{"member": request.user, "show": show, "nb_attr": 2}
)
# Contexte
ctxt = {
"show": show,
"exemple_mail_1place": exemple_mail_1place,
"exemple_mail_2places": exemple_mail_2places,
"custommail": custommail,
}
# Envoi confirmé
if request.method == "POST":
members = show.send_rappel()
ctxt["sent"] = True
ctxt["members"] = members
# Demande de confirmation
else:
ctxt["sent"] = False
if show.rappel_sent:
messages.warning(
request,
"Attention, un mail de rappel pour ce spectale a déjà été "
"envoyé le {}".format(
formats.localize(timezone.template_localtime(show.rappel_sent))
),
)
return render(request, "bda/mails-rappel.html", ctxt)
def catalogue(request, request_type):
"""
Vue destinée à communiquer avec un client AJAX, fournissant soit :
- la liste des tirages
- les catégories et salles d'un tirage
- les descriptions d'un tirage (filtrées selon la catégorie et la salle)
"""
if request_type == "list":
# Dans ce cas on retourne la liste des tirages et de leur id en JSON
data_return = list(
Tirage.objects.filter(appear_catalogue=True).values("id", "title")
)
return JsonResponse(data_return, safe=False)
if request_type == "details":
# Dans ce cas on retourne une liste des catégories et des salles
tirage_id = request.GET.get("id", None)
if tirage_id is None:
return HttpResponseBadRequest("Missing GET parameter: id <int>")
try:
tirage = get_object_or_404(Tirage, id=int(tirage_id))
except ValueError:
return HttpResponseBadRequest("Bad format: int expected for `id`")
shows = tirage.spectacle_set.values_list("id", flat=True)
categories = list(
CategorieSpectacle.objects.filter(spectacle__in=shows)
.distinct()
.values("id", "name")
)
locations = list(
Salle.objects.filter(spectacle__in=shows).distinct().values("id", "name")
)
data_return = {"categories": categories, "locations": locations}
return JsonResponse(data_return, safe=False)
if request_type == "descriptions":
# Ici on retourne les descriptions correspondant à la catégorie et
# à la salle spécifiées
tirage_id = request.GET.get("id", "")
categories = request.GET.get("category", "[]")
locations = request.GET.get("location", "[]")
try:
tirage_id = int(tirage_id)
categories_id = json.loads(categories)
locations_id = json.loads(locations)
# Integers expected
if not all(isinstance(id, int) for id in categories_id):
raise ValueError
if not all(isinstance(id, int) for id in locations_id):
raise ValueError
except ValueError: # Contient JSONDecodeError
return HttpResponseBadRequest(
"Parse error, please ensure the GET parameters have the "
"following types:\n"
"id: int, category: [int], location: [int]\n"
"Data received:\n"
"id = {}, category = {}, locations = {}".format(
request.GET.get("id", ""),
request.GET.get("category", "[]"),
request.GET.get("location", "[]"),
)
)
tirage = get_object_or_404(Tirage, id=tirage_id)
shows_qs = tirage.spectacle_set.select_related("location").prefetch_related(
"quote_set"
)
if categories_id and 0 not in categories_id:
shows_qs = shows_qs.filter(category__id__in=categories_id)
if locations_id and 0 not in locations_id:
shows_qs = shows_qs.filter(location__id__in=locations_id)
# On convertit les descriptions à envoyer en une liste facilement
# JSONifiable (il devrait y avoir un moyen plus efficace en
# redéfinissant le serializer de JSON)
data_return = [
{
"title": spectacle.title,
"category": str(spectacle.category),
"date": str(
formats.date_format(
timezone.localtime(spectacle.date), "SHORT_DATETIME_FORMAT"
)
),
"location": str(spectacle.location),
"vips": spectacle.vips,
"description": spectacle.description,
"slots_description": spectacle.slots_description,
"quotes": [
dict(author=quote.author, text=quote.text)
for quote in spectacle.quote_set.all()
],
"image": spectacle.getImgUrl(),
"ext_link": spectacle.ext_link,
"price": spectacle.price,
"slots": spectacle.slots,
}
for spectacle in shows_qs
]
return JsonResponse(data_return, safe=False)
# Si la requête n'est pas de la forme attendue, on quitte avec une erreur
return HttpResponseBadRequest()
##
# Autocomplete views
#
# https://django-autocomplete-light.readthedocs.io/en/master/tutorial.html#create-an-autocomplete-view
##
class ParticipantAutocomplete(Select2QuerySetView):
model = Participant
search_fields = ("user__username", "user__first_name", "user__last_name")
participant_autocomplete = buro_required(ParticipantAutocomplete.as_view())
class SpectacleAutocomplete(Select2QuerySetView):
model = Spectacle
search_fields = ("title",)
spectacle_autocomplete = buro_required(SpectacleAutocomplete.as_view())

0
bds/__init__.py Normal file
View file

5
bds/admin.py Normal file
View file

@ -0,0 +1,5 @@
from django.contrib import admin
from bds.models import BDSProfile
admin.site.register(BDSProfile)

5
bds/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class BdsConfig(AppConfig):
name = "bds"

57
bds/autocomplete.py Normal file
View file

@ -0,0 +1,57 @@
from django.contrib.auth import get_user_model
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from shared import autocomplete
User = get_user_model()
class BDSMemberSearch(autocomplete.ModelSearch):
model = User
search_fields = ["username", "first_name", "last_name"]
verbose_name = _("Membres du BDS")
def get_queryset_filter(self, *args, **kwargs):
qset_filter = super().get_queryset_filter(*args, **kwargs)
qset_filter &= Q(bds__is_member=True)
return qset_filter
def result_uuid(self, user):
return user.username
def result_link(self, user):
return "#TODO"
class BDSOthersSearch(autocomplete.ModelSearch):
model = User
search_fields = ["username", "first_name", "last_name"]
verbose_name = _("Non-membres du BDS")
def get_queryset_filter(self, *args, **kwargs):
qset_filter = super().get_queryset_filter(*args, **kwargs)
qset_filter &= Q(bds__isnull=True) | Q(bds__is_member=False)
return qset_filter
def result_uuid(self, user):
return user.username
def result_link(self, user):
return "#TODO"
class BDSLDAPSearch(autocomplete.LDAPSearch):
def result_link(self, clipper):
return "#TODO"
class BDSSearch(autocomplete.Compose):
search_units = [
("members", BDSMemberSearch()),
("others", BDSOthersSearch()),
("clippers", BDSLDAPSearch()),
]
bds_search = BDSSearch()

View file

@ -0,0 +1,142 @@
# Generated by Django 2.2 on 2019-07-17 12:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import bds.models
class Migration(migrations.Migration):
initial = True
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
operations = [
migrations.CreateModel(
name="BDSProfile",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"phone",
models.CharField(
blank=True, max_length=20, verbose_name="téléphone"
),
),
(
"occupation",
models.CharField(
choices=[
("EXT", "Extérieur"),
("1A", "1A"),
("2A", "2A"),
("3A", "3A"),
("4A", "4A"),
("MAG", "Magistérien"),
("ARC", "Archicube"),
("DOC", "Doctorant"),
("CST", "CST"),
("PER", "Personnel ENS"),
],
default="1A",
max_length=3,
verbose_name="occupation",
),
),
(
"departement",
models.CharField(
blank=True, max_length=50, verbose_name="département"
),
),
(
"birthdate",
models.DateField(
blank=True, null=True, verbose_name="date de naissance"
),
),
(
"mails_bds",
models.BooleanField(
default=False, verbose_name="recevoir les mails du BDS"
),
),
(
"is_buro",
models.BooleanField(
default=False, verbose_name="membre du Burô du BDS"
),
),
(
"has_certificate",
models.BooleanField(
default=False, verbose_name="certificat médical"
),
),
(
"certificate_file",
models.FileField(
blank=True,
upload_to=bds.models.BDSProfile.get_certificate_filename,
verbose_name="fichier de certificat médical",
),
),
(
"ASPSL_number",
models.CharField(
blank=True,
max_length=50,
null=True,
verbose_name="numéro AS PSL",
),
),
(
"FFSU_number",
models.CharField(
blank=True, max_length=50, null=True, verbose_name="numéro FFSU"
),
),
(
"cotisation_period",
models.CharField(
choices=[
("ANN", "Année"),
("SE1", "Premier semestre"),
("SE2", "Deuxième semestre"),
("NO", "Aucune"),
],
default="NO",
max_length=3,
verbose_name="inscription",
),
),
(
"registration_date",
models.DateField(
auto_now_add=True, verbose_name="date d'inscription"
),
),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="bds",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Profil BDS",
"verbose_name_plural": "Profils BDS",
},
)
]

View file

@ -0,0 +1,30 @@
# Generated by Django 2.2 on 2019-07-17 14:56
from django.contrib.auth.management import create_permissions
from django.db import migrations
from django.db.models import Q
def create_bds_buro_group(apps, schema_editor):
for app_config in apps.get_app_configs():
create_permissions(app_config, apps=apps, verbosity=0)
Group = apps.get_model("auth", "Group")
Permission = apps.get_model("auth", "Permission")
group, created = Group.objects.get_or_create(name="Burô du BDS")
if created:
perms = Permission.objects.filter(
Q(content_type__app_label="bds")
| Q(content_type__app_label="auth") & Q(content_type__model="user")
)
group.permissions.set(perms)
group.save()
class Migration(migrations.Migration):
dependencies = [("bds", "0001_initial")]
operations = [
migrations.RunPython(create_bds_buro_group, migrations.RunPython.noop)
]

View file

@ -0,0 +1,22 @@
# Generated by Django 2.2.8 on 2019-12-20 22:48
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bds", "0002_bds_group"),
]
operations = [
migrations.AlterModelOptions(
name="bdsprofile",
options={
"permissions": (("is_team", "est membre du burô"),),
"verbose_name": "Profil BDS",
"verbose_name_plural": "Profils BDS",
},
),
migrations.RemoveField(model_name="bdsprofile", name="is_buro",),
]

View file

@ -0,0 +1,34 @@
# Generated by Django 2.2.8 on 2019-12-22 10:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bds", "0003_staff_permission"),
]
operations = [
migrations.AddField(
model_name="bdsprofile",
name="cotisation_type",
field=models.CharField(
choices=[
("ETU", "Étudiant"),
("NOR", "Normalien"),
("EXT", "Extérieur"),
("ARC", "Archicube"),
],
default="Normalien",
max_length=9,
verbose_name="type de cotisation",
),
preserve_default=False,
),
migrations.AddField(
model_name="bdsprofile",
name="is_member",
field=models.BooleanField(default=False, verbose_name="adhérent⋅e du BDS"),
),
]

View file

5
bds/mixins.py Normal file
View file

@ -0,0 +1,5 @@
from django.contrib.auth.mixins import PermissionRequiredMixin
class StaffRequiredMixin(PermissionRequiredMixin):
permission_required = "bds:is_team"

98
bds/models.py Normal file
View file

@ -0,0 +1,98 @@
from datetime import date
from os.path import splitext
from django.contrib.auth import get_user_model
from django.db import models
from django.utils.translation import gettext_lazy as _
from shared.utils import choices_length
User = get_user_model()
class BDSProfile(models.Model):
OCCUPATION_CHOICES = (
("EXT", "Extérieur"),
("1A", "1A"),
("2A", "2A"),
("3A", "3A"),
("4A", "4A"),
("MAG", "Magistérien"),
("ARC", "Archicube"),
("DOC", "Doctorant"),
("CST", "CST"),
("PER", "Personnel ENS"),
)
TYPE_COTIZ_CHOICES = (
("ETU", "Étudiant"),
("NOR", "Normalien"),
("EXT", "Extérieur"),
("ARC", "Archicube"),
)
COTIZ_DURATION_CHOICES = (
("ANN", "Année"),
("SE1", "Premier semestre"),
("SE2", "Deuxième semestre"),
("NO", "Aucune"),
)
def get_certificate_filename(instance, filename):
_, ext = splitext(filename) # récupère l'extension du fichier
year = str(date.now().year)
return "certifs/{username}-{year}.{ext}".format(
username=instance.user.username, year=year, ext=ext
)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="bds")
phone = models.CharField(_("téléphone"), max_length=20, blank=True)
occupation = models.CharField(
_("occupation"),
default="1A",
choices=OCCUPATION_CHOICES,
max_length=choices_length(OCCUPATION_CHOICES),
)
departement = models.CharField(_("département"), max_length=50, blank=True)
birthdate = models.DateField(
auto_now_add=False,
auto_now=False,
verbose_name=_("date de naissance"),
blank=True,
null=True,
)
mails_bds = models.BooleanField(_("recevoir les mails du BDS"), default=False)
has_certificate = models.BooleanField(_("certificat médical"), default=False)
certificate_file = models.FileField(
_("fichier de certificat médical"),
upload_to=get_certificate_filename,
blank=True,
)
ASPSL_number = models.CharField(
_("numéro AS PSL"), max_length=50, blank=True, null=True
)
FFSU_number = models.CharField(
_("numéro FFSU"), max_length=50, blank=True, null=True
)
is_member = models.BooleanField(_("adhérent⋅e du BDS"), default=False)
cotisation_period = models.CharField(
_("inscription"), default="NO", choices=COTIZ_DURATION_CHOICES, max_length=3
)
registration_date = models.DateField(
auto_now_add=True, verbose_name=_("date d'inscription")
)
cotisation_type = models.CharField(
_("type de cotisation"), choices=TYPE_COTIZ_CHOICES, max_length=9
)
class Meta:
verbose_name = _("Profil BDS")
verbose_name_plural = _("Profils BDS")
permissions = (("is_team", _("est membre du burô")),)
def __str__(self):
return self.user.username

View file

@ -0,0 +1,82 @@
html, body {
padding: 0;
margin: 0;
background: #ddcecc;
font-size: 18px;
}
a {
text-decoration: none;
color: #a82305;
}
/* header */
nav {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
align-items: center;
background: #3e2263;
height: 3em;
padding: 0.4em 1em;
}
nav a, nav a img {
height: 100%;
}
input[type="text"] {
font-size: 18px;
}
#search_autocomplete {
flex: 1;
width: 480px;
margin: 0;
border: 0;
padding: 10px 10px;
}
.highlight {
text-decoration: underline;
font-weight: bold;
}
.yourlabs-autocomplete ul {
width: 500px;
list-style: none;
padding: 0;
margin: 0;
}
.yourlabs-autocomplete ul li {
height: 2em;
line-height: 2em;
width: 500px;
padding: 0;
}
.yourlabs-autocomplete ul li.hilight {
background: #e8554e;
}
.yourlabs-autocomplete ul li a {
color: inherit;
}
.autocomplete-item {
display: block;
width: 480px;
height: 100%;
padding: 2px 10px;
margin: 0;
}
.autocomplete-header {
background: #b497e1;
}
.autocomplete-value, .autocomplete-new, .autocomplete-more {
background: white;
}

View file

@ -0,0 +1,15 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="300px" height="246px" viewBox="0 0 3000 2460" preserveAspectRatio="xMidYMid meet">
<g id="layer101" fill="#ffffff" stroke="none">
<path d="M0 1230 l0 -1230 1500 0 1500 0 0 1230 0 1230 -1500 0 -1500 0 0 -1230z"/>
</g>
<g id="layer102" fill="#3e2263" stroke="none">
<path d="M0 1230 l0 -1230 1500 0 1500 0 0 1230 0 1230 -1500 0 -1500 0 0 -1230z m1716 765 c103 -17 204 -46 284 -83 l55 -25 -40 -8 c-80 -16 -169 -10 -245 16 -123 42 -232 59 -385 59 -126 0 -278 -18 -341 -40 -13 -5 -16 -4 -9 4 14 15 166 60 254 76 102 19 315 19 427 1z m-703 -217 c15 -6 27 -13 27 -17 0 -4 -37 -25 -82 -47 -86 -43 -182 -120 -193 -155 -8 -26 32 -113 78 -167 44 -52 46 -62 10 -62 -39 1 -106 33 -165 80 -40 31 -54 38 -64 29 -21 -18 -17 -50 16 -116 57 -113 151 -199 259 -235 49 -16 51 -18 16 -13 -123 19 -260 151 -322 310 -15 39 -14 -81 1 -147 25 -107 55 -176 112 -251 49 -65 52 -73 38 -88 -21 -23 -104 -62 -216 -100 -51 -18 -97 -35 -102 -40 -4 -4 32 -10 80 -14 106 -9 256 13 317 48 l39 22 56 -33 c32 -18 114 -61 184 -95 80 -39 126 -67 123 -74 -6 -19 12 -16 19 3 9 24 28 5 22 -22 -7 -26 8 -34 18 -9 11 29 28 16 21 -18 -6 -30 -5 -31 10 -13 8 11 15 24 15 29 0 4 5 5 10 2 6 -3 8 -18 4 -33 -5 -26 -5 -26 10 -8 15 19 18 19 118 -12 113 -35 284 -72 329 -72 24 0 29 4 29 25 0 34 -36 125 -63 158 l-22 28 -59 -30 c-74 -39 -165 -43 -233 -10 -24 11 -43 25 -43 30 0 4 28 6 63 2 49 -4 71 -2 98 12 46 23 79 55 79 75 0 16 -48 66 -135 139 l-40 33 43 -23 c55 -30 166 -121 188 -154 9 -13 13 -29 9 -36 -15 -23 -100 -69 -129 -69 -17 0 -38 -5 -46 -10 -12 -8 -10 -10 10 -10 49 1 104 19 149 51 l45 31 24 -28 c33 -40 72 -128 79 -180 10 -79 -25 -96 -233 -114 -84 -7 -148 -7 -218 0 -106 12 -269 51 -317 76 -28 14 -31 13 -62 -10 -56 -43 -167 -89 -225 -94 l-55 -5 6 29 c2 16 14 55 26 86 21 57 27 112 15 142 -3 9 -34 38 -68 64 l-62 49 -104 7 c-166 12 -192 34 -83 70 34 12 65 24 68 28 4 4 -7 27 -24 52 -118 171 -124 402 -16 571 18 29 50 88 70 131 42 93 99 154 168 184 82 35 212 44 280 18z m540 -39 c60 -16 70 -41 75 -196 l5 -134 -39 3 -39 3 -5 114 c-4 93 -8 116 -22 125 -31 19 -41 -7 -47 -126 l-6 -113 -32 -3 c-41 -4 -44 6 -34 151 9 143 25 172 101 186 3 0 22 -4 43 -10z m322 -34 c0 -30 0 -30 -64 -33 l-64 -3 7 -130 7 -129 -35 0 -36 0 0 165 0 166 93 -3 92 -3 0 -30z m115 -55 l5 -84 18 27 c21 33 39 34 60 5 15 -22 16 -18 16 60 l1 82 35 0 35 0 -2 -162 -3 -163 -40 0 c-38 0 -41 2 -53 43 -16 49 -24 48 -42 -7 -12 -38 -16 -41 -50 -41 -34 0 -38 3 -44 31 -8 43 -8 284 1 293 4 4 19 6 33 4 24 -3 25 -6 30 -88z m370 -42 c193 -243 228 -505 100 -759 -61 -120 -195 -260 -328 -343 -66 -41 -60 -26 13 33 36 29 92 85 125 126 182 230 209 500 75 753 -42 79 -45 89 -45 163 0 44 4 79 9 79 5 0 28 -24 51 -52z m-1111 -294 c76 -30 94 -102 41 -156 -27 -27 -29 -32 -17 -47 34 -40 25 -100 -19 -120 -30 -13 -143 -15 -164 -1 -11 7 -15 42 -18 164 l-4 156 23 9 c37 15 115 13 158 -5z m571 -9 c84 -43 61 -135 -43 -173 -46 -18 -52 -23 -52 -48 0 -39 30 -42 79 -9 l37 25 -3 -41 c-4 -49 -32 -76 -90 -85 -32 -6 -41 -2 -68 24 -24 25 -30 39 -30 74 0 57 21 85 87 114 40 18 54 29 51 42 -5 29 -71 28 -111 -2 -19 -14 -36 -26 -39 -26 -11 0 -16 55 -7 72 24 45 131 63 189 33z m-303 -12 c56 -26 75 -65 71 -150 -3 -67 -6 -75 -38 -108 -43 -44 -94 -61 -146 -48 l-39 9 -3 144 c-1 79 0 150 2 157 8 19 110 17 153 -4z"/>
<path d="M1503 633 c4 -3 10 -3 14 0 3 4 0 7 -7 7 -7 0 -10 -3 -7 -7z"/>
<path d="M1532 448 c3 -7 15 -14 29 -16 23 -2 23 -2 5 13 -24 18 -39 20 -34 3z"/>
<path d="M1140 1231 c0 -45 10 -61 36 -61 26 0 64 32 64 53 0 23 -23 37 -62 37 -35 0 -38 -2 -38 -29z"/>
<path d="M1140 1070 c0 -35 17 -45 56 -36 20 5 25 12 22 34 -3 23 -8 27 -40 30 -36 3 -38 2 -38 -28z"/>
<path d="M1430 1151 c0 -72 3 -91 14 -91 28 0 47 13 61 41 31 60 10 121 -47 135 l-28 6 0 -91z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 15.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:ns1="http://sozi.baierouge.fr"
xmlns:cc="http://web.resource.org/cc/"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:dc="http://purl.org/dc/elements/1.1/"
id="Layer_1"
enable-background="new 0 0 40 40"
xml:space="preserve"
viewBox="0 0 40 40"
version="1.1"
y="0px"
x="0px"
>
<polygon
points="36.351 32.435 25.5 36.271 25.5 3.94 36.351 6.242"
fill="#ffffff"
/>
<g
fill="#ffffff"
>
<path
d="m13.627 29.253l8.123-8.941c0.241-0.241 0.379-0.58 0.379-0.934s-0.138-0.693-0.379-0.934l-8.123-8.943c-0.346-0.348-0.853-0.44-1.286-0.238-0.436 0.203-0.717 0.662-0.717 1.171v3.193h-7.745c-0.658 0-1.191 0.572-1.191 1.277v8.943c0 0.705 0.533 1.276 1.191 1.276h7.745v3.194c0 0.509 0.281 0.969 0.716 1.172 0.434 0.204 0.94 0.112 1.287-0.236z"
/>
<path
d="m13.627 29.253l8.123-8.941c0.241-0.241 0.379-0.58 0.379-0.934s-0.138-0.693-0.379-0.934l-8.123-8.943c-0.346-0.348-0.853-0.44-1.286-0.238-0.436 0.203-0.717 0.662-0.717 1.171v3.193h-7.745c-0.658 0-1.191 0.572-1.191 1.277v8.943c0 0.705 0.533 1.276 1.191 1.276h7.745v3.194c0 0.509 0.281 0.969 0.716 1.172 0.434 0.204 0.94 0.112 1.287-0.236z"
/>
</g
>
<path
stroke-linejoin="round"
d="m24.166 6.224h-1.288c-1.78 0-3.223 1.442-3.223 3.223v3.981"
stroke="#ffffff"
stroke-linecap="round"
stroke-miterlimit="10"
fill="none"
/>
<path
stroke-linejoin="round"
d="m19.655 25.495v3.733c0 1.78 1.442 3.223 3.223 3.223h1.288"
stroke="#ffffff"
stroke-linecap="round"
stroke-miterlimit="10"
fill="none"
/>
<metadata
><rdf:RDF
><cc:Work
><dc:format
>image/svg+xml</dc:format
><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage"
/><cc:license
rdf:resource="http://creativecommons.org/licenses/publicdomain/"
/><dc:publisher
><cc:Agent
rdf:about="http://openclipart.org/"
><dc:title
>Openclipart</dc:title
></cc:Agent
></dc:publisher
></cc:Work
><cc:License
rdf:about="http://creativecommons.org/licenses/publicdomain/"
><cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction"
/><cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution"
/><cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks"
/></cc:License
></rdf:RDF
></metadata
></svg
>

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -0,0 +1,23 @@
{% load staticfiles %}
<!DOCTYPE html>
<html>
<head>
<title>{{ site.name }}</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
{# CSS #}
<link rel="stylesheet" href="{% static "bds/css/bds.css" %}">
{# Javascript #}
<script src="{% static 'vendor/jquery/jquery-3.3.1.min.js' %}"></script>
<script src="{% static "vendor/jquery/jquery.autocomplete-light.min.js" %}"></script>
</head>
<body>
{% include "bds/nav.html" %}
{% block content %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,22 @@
{% extends "bds/base.html" %}
{% block content %}
<div style="width: 60%; margin: auto; padding-top: 2em;">
Bienvenue sur GestioBDS&#8239;!
<br>
<br>
Le site est encore en développement.
<br>
Suivez notre avancement sur
<a href="https://git.eleves.ens.fr/klub-dev-ens/gestioCOF/merge_requests?milestone_title=Int%C3%A9gration+du+BDS">
cette milestone</a> sur le gitlab de l'ENS.
<br>
Faites vos remarques par mail à
<a href="mailto:klub-dev@ens.fr"><tt>klub-dev@ens.fr</tt></a>
ou en ouvrant une
<a href="#https://git.eleves.ens.fr/klub-dev-ens/gestioCOF/issues?milestone_title=Int%C3%A9gration+du+BDS">
issue</a>.
</div>
{% endblock %}

View file

@ -0,0 +1,40 @@
{% load i18n %}
{% load static %}
<nav id="search-bar">
<a href="{% url "bds:home" %}">
<img src="{% static "bds/images/logo.svg" %}" alt="bds-logo">
</a>
<div>
<input
type="text"
name="q"
id="search_autocomplete"
spellcheck="false"
placeholder="{% trans "Chercher une personne" %}" />
<div class="yourlabs-autocomplete"></div>
</div>
<a href="#TODO">
<img src="{% static "bds/images/logout.svg" %}" alt="logout">
</a>
</nav>
<script type="text/javascript">
$(document).ready(function() {
$('input#search_autocomplete').yourlabsAutocomplete({
url: '{% url 'bds:autocomplete' %}',
minimumCharacters: 3,
id: 'search_autocomplete',
choiceSelector: 'li:has(a)',
box: $(".yourlabs-autocomplete"),
});
$('input#search_autocomplete').bind(
'selectChoice',
function(e, choice, autocomplete) {
window.location = choice.find('a:first').attr('href');
}
);
});
</script>

View file

@ -14,7 +14,7 @@
{% endif %} {% endif %}
</li> </li>
<li class="autocomplete-new"> <li class="autocomplete-new">
<a class="autocomplete-item" href="{% url "kfet.account.create.empty" %}"> <a class="autocomplete-item" href="#TODO">
{% trans "Créer un compte" %} {% trans "Créer un compte" %}
</a> </a>
</li> </li>

9
bds/urls.py Normal file
View file

@ -0,0 +1,9 @@
from django.urls import path
from bds import views
app_name = "bds"
urlpatterns = [
path("", views.Home.as_view(), name="home"),
path("autocomplete", views.BDSAutocompleteView.as_view(), name="autocomplete"),
]

14
bds/views.py Normal file
View file

@ -0,0 +1,14 @@
from django.views.generic import TemplateView
from bds.autocomplete import bds_search
from bds.mixins import StaffRequiredMixin
from shared.views import AutocompleteView
class BDSAutocompleteView(StaffRequiredMixin, AutocompleteView):
template_name = "bds/search_results.html"
search_composer = bds_search
class Home(StaffRequiredMixin, TemplateView):
template_name = "bds/home.html"

0
clubs/__init__.py Normal file
View file

5
clubs/admin.py Normal file
View file

@ -0,0 +1,5 @@
from django.contrib import admin
from clubs.models import Club
admin.site.register(Club)

Some files were not shown because too many files have changed in this diff Show more