Merge branch 'master' into supportBDS

This commit is contained in:
Martin Pépin 2017-10-26 08:43:25 +02:00
commit 2aa2dafa13
214 changed files with 42452 additions and 3869 deletions

5
.gitignore vendored
View file

@ -9,3 +9,8 @@ venv/
/src /src
media/ media/
*.log *.log
*.sqlite3
# PyCharm
.idea
.cache

View file

@ -1,25 +1,24 @@
services: services:
- mysql:latest - postgres:latest
- redis:latest - redis:latest
variables: variables:
# GestioCOF settings # GestioCOF settings
DJANGO_SETTINGS_MODULE: "gestioCOF.settings_dev" DJANGO_SETTINGS_MODULE: "cof.settings.prod"
DBNAME: "cof_gestion" DBHOST: "postgres"
DBUSER: "cof_gestion"
DBPASSWD: "cof_password"
DBHOST: "mysql"
REDIS_HOST: "redis" REDIS_HOST: "redis"
REDIS_PASSWD: "dummy"
# Cached packages # Cached packages
PYTHONPATH: "$CI_PROJECT_DIR/vendor/python" PYTHONPATH: "$CI_PROJECT_DIR/vendor/python"
# mysql service configuration # postgres service configuration
MYSQL_DATABASE: "$DBNAME" POSTGRES_PASSWORD: "4KZt3nGPLVeWSvtBZPSM3fSzXpzEU4"
MYSQL_USER: "$DBUSER" POSTGRES_USER: "cof_gestion"
MYSQL_PASSWORD: "$DBPASSWD" POSTGRES_DB: "cof_gestion"
MYSQL_ROOT_PASSWORD: "root_password"
# psql password authentication
PGPASSWORD: $POSTGRES_PASSWORD
cache: cache:
paths: paths:
@ -29,10 +28,12 @@ cache:
before_script: before_script:
- mkdir -p vendor/{python,pip,apt} - mkdir -p vendor/{python,pip,apt}
- apt-get update -q && apt-get -o dir::cache::archives="vendor/apt" install -yqq mysql-client - apt-get update -q && apt-get -o dir::cache::archives="vendor/apt" install -yqq postgresql-client
- mysql --user=root --password="$MYSQL_ROOT_PASSWORD" --host="$DBHOST" - sed -E 's/^REDIS_HOST.*/REDIS_HOST = "redis"/' cof/settings/secret_example.py > cof/settings/secret.py
-e "GRANT ALL ON test_$DBNAME.* TO '$DBUSER'@'%'" - sed -i.bak -E 's;^REDIS_PASSWD = .*$;REDIS_PASSWD = "";' cof/settings/secret.py
- pip install --cache-dir vendor/pip -t vendor/python -r requirements-devel.txt # Remove the old test database if it has not been done yet
- psql --username=$POSTGRES_USER --host=$DBHOST -c "DROP DATABASE IF EXISTS test_$POSTGRES_DB"
- pip install --upgrade --cache-dir vendor/pip -t vendor/python -r requirements.txt
test: test:
stage: test stage: test

118
README.md
View file

@ -66,119 +66,65 @@ car par défaut Django n'écoute que sur l'adresse locale de la machine virtuell
or vous voudrez accéder à GestioCOF depuis votre machine physique. L'url à or vous voudrez accéder à GestioCOF depuis votre machine physique. L'url à
entrer dans le navigateur est `localhost:8000`. entrer dans le navigateur est `localhost:8000`.
#### Serveur de développement type production #### Serveur de développement type production
Sur la VM Vagrant, un serveur apache est configuré pour servir GestioCOF de Juste histoire de jouer, pas indispensable pour développer :
façon similaire à la version en production : on utilise
La VM Vagrant héberge en plus un serveur nginx configuré pour servir GestioCOF
comme en production : on utilise
[Daphne](https://github.com/django/daphne/) et `python manage.py runworker` [Daphne](https://github.com/django/daphne/) et `python manage.py runworker`
derrière un reverse-proxy apache. Le tout est monitoré par derrière un reverse-proxy nginx.
[supervisor](http://supervisord.org/).
Ce serveur se lance tout seul et est accessible en dehors de la VM à l'url Ce serveur se lance tout seul et est accessible en dehors de la VM à l'url
`localhost:8080`. Toutefois il ne se recharge pas tout seul lorsque le code `localhost:8080/gestion/`. Toutefois il ne se recharge pas tout seul lorsque le
change, il faut relancer le worker avec `sudo supervisorctl restart worker` pour code change, il faut relancer le worker avec `sudo systemctl restart
visualiser la dernière version du code. worker.service` pour visualiser la dernière version du code.
### Installation manuelle ### Installation manuelle
Si vous optez pour une installation manuelle plutôt que d'utiliser Vagrant, il Vous pouvez opter pour une installation manuelle plutôt que d'utiliser Vagrant,
est fortement conseillé d'utiliser un environnement virtuel pour Python. il est fortement conseillé d'utiliser un environnement virtuel pour Python.
Il vous faudra installer pip, les librairies de développement de python, un Il vous faudra installer pip, les librairies de développement de python ainsi
client et un serveur MySQL ainsi qu'un serveur redis ; sous Debian et dérivées que sqlite3, un moteur de base de données léger et simple d'utilisation. Sous
(Ubuntu, ...) : Debian et dérivées (Ubuntu, ...) :
sudo apt-get install python-pip python-dev libmysqlclient-dev redis-server sudo apt-get install python3-pip python3-dev sqlite3
Si vous décidez d'utiliser un environnement virtuel Python (virtualenv; Si vous décidez d'utiliser un environnement virtuel Python (virtualenv;
fortement conseillé), déplacez-vous dans le dossier où est installé GestioCOF fortement conseillé), déplacez-vous dans le dossier où est installé GestioCOF
(le dossier où se trouve ce README), et créez-le maintenant : (le dossier où se trouve ce README), et créez-le maintenant :
virtualenv env -p $(which python3) python3 -m venv venv
L'option `-p` sert à préciser l'exécutable python à utiliser. Vous devez choisir Pour l'activer, il faut faire
python3, si c'est la version de python par défaut sur votre système, ceci n'est
pas nécessaire. Pour l'activer, il faut faire
. env/bin/activate . venv/bin/activate
dans le même dossier. dans le même dossier.
Vous pouvez maintenant installer les dépendances Python depuis le fichier Vous pouvez maintenant installer les dépendances Python depuis le fichier
`requirements-devel.txt` : `requirements-devel.txt` :
pip install -U pip
pip install -r requirements-devel.txt pip install -r requirements-devel.txt
Copiez le fichier `cof/settings_dev.py` dans `cof/settings.py`. Pour terminer, copier le fichier `cof/settings/secret_example.py` vers
`cof/settings/secret.py`. Sous Linux ou Mac, préférez plutôt un lien symbolique
pour profiter de façon transparente des mises à jour du fichier:
#### Installation avec MySQL ln -s secret_example.py cof/settings/secret.py
Il faut maintenant installer MySQL. Si vous n'avez pas déjà MySQL installé sur
votre serveur, il faut l'installer ; sous Debian et dérivées (Ubuntu, ...) :
sudo apt-get install mysql-server
Il vous demandera un mot de passe pour le compte d'administration MySQL,
notez-le quelque part (ou n'en mettez pas, le serveur n'est accessible que
localement par défaut). Si vous utilisez une autre distribution, consultez la
documentation de votre distribution pour savoir comment changer ce mot de passe
et démarrer le serveur MySQL (c'est automatique sous Ubuntu).
Vous devez alors créer un utilisateur local et une base `cof_gestion`, avec le
mot de passe de votre choix (remplacez `mot_de_passe`) :
mysql -uroot -e "CREATE DATABASE cof_gestion; GRANT ALL PRIVILEGES ON cof_gestion.* TO 'cof_gestion'@'localhost' IDENTIFIER BY 'mot_de_passe'"
Éditez maintenant le fichier `cof/settings.py` pour y intégrer ces changements ;
la définition de `DATABASES` doit ressembler à (à nouveau, remplacez
`mot_de_passe` de façon appropriée) :
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'cof_gestion',
'USER': 'cof_gestion',
'PASSWORD': 'mot_de_passe',
}
}
#### Installation avec SQLite
GestioCOF est installé avec MySQL sur la VM COF, et afin d'avoir un
environnement de développement aussi proche que possible de ce qui tourne en
vrai pour éviter les mauvaises surprises, il est conseillé d'utiliser MySQL sur
votre machine de développement également. Toutefois, GestioCOF devrait
fonctionner avec d'autres moteurs SQL, et certains préfèrent utiliser SQLite
pour sa légèreté et facilité d'installation.
Si vous décidez d'utiliser SQLite, il faut l'installer ; sous Debian et dérivées :
sudo apt-get install sqlite3
puis éditer le fichier `cof/settings.py` pour que la définition de `DATABASES`
ressemble à :
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
#### Fin d'installation #### Fin d'installation
Il ne vous reste plus qu'à initialiser les modèles de Django avec la commande suivante : Il ne vous reste plus qu'à initialiser les modèles de Django et peupler la base
de donnée avec les données nécessaires au bon fonctionnement de GestioCOF + des
données bidons bien pratiques pour développer avec la commande suivante :
python manage.py migrate bash provisioning/prepare_django.sh
Charger les mails indispensables au bon fonctionnement de GestioCOF :
python manage.py syncmails
Une base de donnée pré-remplie est disponible en lançant les commandes :
python manage.py loaddata gestion sites accounts groups articles
python manage.py loaddevdata
Vous êtes prêts à développer ! Lancer GestioCOF en faisant Vous êtes prêts à développer ! Lancer GestioCOF en faisant
@ -188,7 +134,7 @@ Vous êtes prêts à développer ! Lancer GestioCOF en faisant
Pour mettre à jour les paquets Python, utiliser la commande suivante : Pour mettre à jour les paquets Python, utiliser la commande suivante :
pip install --upgrade -r requirements.txt -r requirements-devel.txt pip install --upgrade -r requirements-devel.txt
Pour mettre à jour les modèles après une migration, il faut ensuite faire : Pour mettre à jour les modèles après une migration, il faut ensuite faire :
@ -197,6 +143,6 @@ Pour mettre à jour les modèles après une migration, il faut ensuite faire :
## Documentation utilisateur ## Documentation utilisateur
Une brève documentation utilisateur pour se familiariser plus vite avec l'outil Une brève documentation utilisateur est accessible sur le
est accessible sur le [wiki](https://git.eleves.ens.fr/cof-geek/gestioCOF/wikis/home) pour avoir une
[wiki](https://git.eleves.ens.fr/cof-geek/gestioCOF/wikis/home). idée de la façon dont le COF utilise GestioCOF.

View file

@ -0,0 +1 @@

View file

@ -12,32 +12,76 @@ from bda.models import Spectacle, Salle, Participant, ChoixSpectacle,\
Attribution, Tirage, Quote, CategorieSpectacle, SpectacleRevente Attribution, Tirage, Quote, CategorieSpectacle, SpectacleRevente
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 ChoixSpectacleInline(admin.TabularInline): class ChoixSpectacleInline(admin.TabularInline):
model = ChoixSpectacle model = ChoixSpectacle
sortable_field_name = "priority" 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): class AttributionInline(admin.TabularInline):
model = Attribution model = Attribution
extra = 0 extra = 0
listing = None
def get_queryset(self, request): def get_queryset(self, request):
qs = super(AttributionInline, self).get_queryset(request) qs = super().get_queryset(request)
return qs.filter(spectacle__listing=False) if self.listing is not None:
qs = qs.filter(spectacle__listing=self.listing)
return qs
class AttributionInlineListing(admin.TabularInline): class WithListingAttributionInline(AttributionInline):
model = Attribution
exclude = ('given', ) exclude = ('given', )
extra = 0 form = WithListingAttributionTabularAdminForm
listing = True
def get_queryset(self, request):
qs = super(AttributionInlineListing, self).get_queryset(request)
return qs.filter(spectacle__listing=True)
class ParticipantAdmin(admin.ModelAdmin): class WithoutListingAttributionInline(AttributionInline):
inlines = [AttributionInline, AttributionInlineListing] form = WithoutListingAttributionTabularAdminForm
listing = False
class ParticipantAdminForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['choicesrevente'].queryset = (
Spectacle.objects
.select_related('location')
)
class ParticipantAdmin(ReadOnlyMixin, admin.ModelAdmin):
inlines = [WithListingAttributionInline, WithoutListingAttributionInline]
def get_queryset(self, request): def get_queryset(self, request):
return Participant.objects.annotate(nb_places=Count('attributions'), return Participant.objects.annotate(nb_places=Count('attributions'),
@ -64,6 +108,8 @@ class ParticipantAdmin(admin.ModelAdmin):
actions_on_bottom = True actions_on_bottom = True
list_per_page = 400 list_per_page = 400
readonly_fields = ("total",) readonly_fields = ("total",)
readonly_fields_update = ('user', 'tirage')
form = ParticipantAdminForm
def send_attribs(self, request, queryset): def send_attribs(self, request, queryset):
datatuple = [] datatuple = []
@ -93,6 +139,20 @@ class ParticipantAdmin(admin.ModelAdmin):
class AttributionAdminForm(forms.ModelForm): 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): def clean(self):
cleaned_data = super(AttributionAdminForm, self).clean() cleaned_data = super(AttributionAdminForm, self).clean()
participant = cleaned_data.get("participant") participant = cleaned_data.get("participant")
@ -105,7 +165,7 @@ class AttributionAdminForm(forms.ModelForm):
return cleaned_data return cleaned_data
class AttributionAdmin(admin.ModelAdmin): class AttributionAdmin(ReadOnlyMixin, admin.ModelAdmin):
def paid(self, obj): def paid(self, obj):
return obj.participant.paid return obj.participant.paid
paid.short_description = 'A payé' paid.short_description = 'A payé'
@ -115,6 +175,7 @@ class AttributionAdmin(admin.ModelAdmin):
'participant__user__first_name', 'participant__user__first_name',
'participant__user__last_name') 'participant__user__last_name')
form = AttributionAdminForm form = AttributionAdminForm
readonly_fields_update = ('spectacle', 'participant')
class ChoixSpectacleAdmin(admin.ModelAdmin): class ChoixSpectacleAdmin(admin.ModelAdmin):
@ -157,6 +218,24 @@ class SalleAdmin(admin.ModelAdmin):
search_fields = ('name', 'address') search_fields = ('name', 'address')
class SpectacleReventeAdminForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['answered_mail'].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): class SpectacleReventeAdmin(admin.ModelAdmin):
""" """
Administration des reventes de spectacles Administration des reventes de spectacles
@ -179,6 +258,7 @@ class SpectacleReventeAdmin(admin.ModelAdmin):
actions = ['transfer', 'reinit'] actions = ['transfer', 'reinit']
actions_on_bottom = True actions_on_bottom = True
form = SpectacleReventeAdminForm
def transfer(self, request, queryset): def transfer(self, request, queryset):
""" """

View file

@ -22,8 +22,7 @@ class Algorithm(object):
show.requests show.requests
- on crée des tables de demandes pour chaque personne, afin de - on crée des tables de demandes pour chaque personne, afin de
pouvoir modifier les rankings""" pouvoir modifier les rankings"""
self.max_group = \ self.max_group = 2*max(choice.priority for choice in choices)
2 * choices.aggregate(Max('priority'))['priority__max']
self.shows = [] self.shows = []
showdict = {} showdict = {}
for show in shows: for show in shows:

View file

@ -1,37 +1,40 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals
from datetime import timedelta
from django import forms from django import forms
from django.forms.models import BaseInlineFormSet from django.forms.models import BaseInlineFormSet
from django.utils import timezone from django.utils import timezone
from bda.models import Attribution, Spectacle from bda.models import Attribution, Spectacle
class BaseBdaFormSet(BaseInlineFormSet): class InscriptionInlineFormSet(BaseInlineFormSet):
def clean(self):
"""Checks that no two articles have the same title.""" def __init__(self, *args, **kwargs):
super(BaseBdaFormSet, self).clean() super().__init__(*args, **kwargs)
if any(self.errors):
# Don't bother validating the formset unless each form is valid on # self.instance is a Participant object
# its own tirage = self.instance.tirage
return
spectacles = [] # set once for all "spectacle" field choices
for i in range(0, self.total_form_count()): # - restrict choices to the spectacles of this tirage
form = self.forms[i] # - force_choices avoid many db requests
if not form.cleaned_data: spectacles = tirage.spectacle_set.select_related('location')
continue choices = [(sp.pk, str(sp)) for sp in spectacles]
spectacle = form.cleaned_data['spectacle'] self.force_choices('spectacle', choices)
delete = form.cleaned_data['DELETE']
if not delete and spectacle in spectacles: def force_choices(self, name, choices):
raise forms.ValidationError( """Set choices of a field.
"Vous ne pouvez pas vous inscrire deux fois pour le "
"même spectacle.") As ModelChoiceIterator (default use to get choices of a
spectacles.append(spectacle) 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): class TokenForm(forms.Form):
@ -40,35 +43,45 @@ class TokenForm(forms.Form):
class AttributionModelMultipleChoiceField(forms.ModelMultipleChoiceField): class AttributionModelMultipleChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj): def label_from_instance(self, obj):
return "%s" % obj.spectacle return "%s" % str(obj.spectacle)
class ResellForm(forms.Form): class ResellForm(forms.Form):
attributions = AttributionModelMultipleChoiceField( attributions = AttributionModelMultipleChoiceField(
label='',
queryset=Attribution.objects.none(), queryset=Attribution.objects.none(),
widget=forms.CheckboxSelectMultiple, widget=forms.CheckboxSelectMultiple,
required=False) required=False)
def __init__(self, participant, *args, **kwargs): def __init__(self, participant, *args, **kwargs):
super(ResellForm, self).__init__(*args, **kwargs) super(ResellForm, self).__init__(*args, **kwargs)
self.fields['attributions'].queryset = participant.attribution_set\ self.fields['attributions'].queryset = (
.filter(spectacle__date__gte=timezone.now())\ participant.attribution_set
.filter(spectacle__date__gte=timezone.now())
.exclude(revente__seller=participant) .exclude(revente__seller=participant)
.select_related('spectacle', 'spectacle__location',
'participant__user')
)
class AnnulForm(forms.Form): class AnnulForm(forms.Form):
attributions = AttributionModelMultipleChoiceField( attributions = AttributionModelMultipleChoiceField(
label='',
queryset=Attribution.objects.none(), queryset=Attribution.objects.none(),
widget=forms.CheckboxSelectMultiple, widget=forms.CheckboxSelectMultiple,
required=False) required=False)
def __init__(self, participant, *args, **kwargs): def __init__(self, participant, *args, **kwargs):
super(AnnulForm, self).__init__(*args, **kwargs) super(AnnulForm, self).__init__(*args, **kwargs)
self.fields['attributions'].queryset = participant.attribution_set\ self.fields['attributions'].queryset = (
participant.attribution_set
.filter(spectacle__date__gte=timezone.now(), .filter(spectacle__date__gte=timezone.now(),
revente__isnull=False, revente__isnull=False,
revente__date__gt=timezone.now()-timedelta(hours=1), revente__notif_sent=False,
revente__soldTo__isnull=True) revente__soldTo__isnull=True)
.select_related('spectacle', 'spectacle__location',
'participant__user')
)
class InscriptionReventeForm(forms.Form): class InscriptionReventeForm(forms.Form):
@ -79,5 +92,26 @@ class InscriptionReventeForm(forms.Form):
def __init__(self, tirage, *args, **kwargs): def __init__(self, tirage, *args, **kwargs):
super(InscriptionReventeForm, self).__init__(*args, **kwargs) super(InscriptionReventeForm, self).__init__(*args, **kwargs)
self.fields['spectacles'].queryset = tirage.spectacle_set.filter( self.fields['spectacles'].queryset = (
date__gte=timezone.now()) tirage.spectacle_set
.select_related('location')
.filter(date__gte=timezone.now())
)
class SoldForm(forms.Form):
attributions = AttributionModelMultipleChoiceField(
label='',
queryset=Attribution.objects.none(),
widget=forms.CheckboxSelectMultiple)
def __init__(self, participant, *args, **kwargs):
super(SoldForm, self).__init__(*args, **kwargs)
self.fields['attributions'].queryset = (
participant.attribution_set
.filter(revente__isnull=False,
revente__soldTo__isnull=False)
.exclude(revente__soldTo=participant)
.select_related('spectacle', 'spectacle__location',
'participant__user')
)

View file

@ -5,17 +5,34 @@ from django.db import migrations, models
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
def forwards_func(apps, schema_editor):
def fill_tirage_fields(apps, schema_editor):
"""
Create a `Tirage` to fill new field `tirage` of `Participant`
and `Spectacle` already existing.
"""
Participant = apps.get_model("bda", "Participant")
Spectacle = apps.get_model("bda", "Spectacle")
Tirage = apps.get_model("bda", "Tirage") Tirage = apps.get_model("bda", "Tirage")
db_alias = schema_editor.connection.alias
Tirage.objects.using(db_alias).bulk_create([ # These querysets only contains instances not linked to any `Tirage`.
Tirage( participants = Participant.objects.filter(tirage=None)
id=1, spectacles = Spectacle.objects.filter(tirage=None)
title="Tirage de test (migration)",
active=False, if not participants.count() and not spectacles.count():
ouverture=timezone.now(), # No need to create a "trash" tirage.
fermeture=timezone.now()), 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): class Migration(migrations.Migration):
@ -35,7 +52,6 @@ class Migration(migrations.Migration):
('active', models.BooleanField(default=True, verbose_name=b'Tirage actif')), ('active', models.BooleanField(default=True, verbose_name=b'Tirage actif')),
], ],
), ),
migrations.RunPython(forwards_func, migrations.RunPython.noop),
migrations.AlterField( migrations.AlterField(
model_name='participant', model_name='participant',
name='user', name='user',
@ -43,22 +59,36 @@ class Migration(migrations.Migration):
on_delete=models.CASCADE, on_delete=models.CASCADE,
to=settings.AUTH_USER_MODEL), to=settings.AUTH_USER_MODEL),
), ),
# 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( migrations.AddField(
model_name='participant', model_name='participant',
name='tirage', name='tirage',
field=models.ForeignKey( field=models.ForeignKey(
on_delete=models.CASCADE, on_delete=models.CASCADE,
default=1, to='bda.Tirage',
to='bda.Tirage'), null=True
preserve_default=False, ),
), ),
migrations.AddField( migrations.AddField(
model_name='spectacle', model_name='spectacle',
name='tirage', name='tirage',
field=models.ForeignKey( field=models.ForeignKey(
on_delete=models.CASCADE, on_delete=models.CASCADE,
default=1, to='bda.Tirage',
to='bda.Tirage'), null=True
preserve_default=False, ),
),
migrations.RunPython(fill_tirage_fields, migrations.RunPython.noop),
migrations.AlterField(
model_name='participant',
name='tirage',
field=models.ForeignKey(to='bda.Tirage'),
),
migrations.AlterField(
model_name='spectacle',
name='tirage',
field=models.ForeignKey(to='bda.Tirage'),
), ),
] ]

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', '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

@ -7,17 +7,30 @@ from custommail.shortcuts import send_mass_custom_mail
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.db import models from django.db import models
from django.db.models import Count
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.conf import settings from django.conf import settings
from django.utils import timezone, formats from django.utils import timezone, formats
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): class Tirage(models.Model):
title = models.CharField("Titre", max_length=300) title = models.CharField("Titre", max_length=300)
ouverture = models.DateTimeField("Date et heure d'ouverture du tirage") ouverture = models.DateTimeField("Date et heure d'ouverture du tirage")
fermeture = models.DateTimeField("Date et heure de fermerture du tirage") fermeture = models.DateTimeField("Date et heure de fermerture du tirage")
tokens = models.TextField("Graine(s) du tirage", blank=True) tokens = models.TextField("Graine(s) du tirage", blank=True)
active = models.BooleanField("Tirage actif", default=False) 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é", enable_do_tirage = models.BooleanField("Le tirage peut être lancé",
default=False) default=False)
@ -83,37 +96,43 @@ class Spectacle(models.Model):
self.price self.price
) )
def getImgUrl(self):
"""
Cette fonction permet d'obtenir l'URL de l'image, si elle existe
"""
try:
return self.image.url
except:
return None
def send_rappel(self): def send_rappel(self):
""" """
Envoie un mail de rappel à toutes les personnes qui ont une place pour Envoie un mail de rappel à toutes les personnes qui ont une place pour
ce spectacle. ce spectacle.
""" """
# On récupère la liste des participants # On récupère la liste des participants + le BdA
members = {} members = list(
for attr in Attribution.objects.filter(spectacle=self).all(): User.objects
member = attr.participant.user .filter(participant__attributions=self)
if member.id in members: .annotate(nb_attr=Count("id")).order_by()
members[member.id][1] = 2 )
else: bda_generic = get_generic_user()
members[member.id] = [member, 1] bda_generic.nb_attr = 1
# FIXME : faire quelque chose de ça, un utilisateur bda_generic ? members.append(bda_generic)
# # Pour le BdA
# members[0] = ['BdA', 1, 'bda@ens.fr']
# members[-1] = ['BdA', 2, 'bda@ens.fr']
# On écrit un mail personnalisé à chaque participant # On écrit un mail personnalisé à chaque participant
datatuple = [( datatuple = [(
'bda-rappel', 'bda-rappel',
{'member': member[0], 'nb_attr': member[1], 'show': self}, {'member': member, "nb_attr": member.nb_attr, 'show': self},
settings.MAIL_DATA['rappels']['FROM'], settings.MAIL_DATA['rappels']['FROM'],
[member[0].email]) [member.email])
for member in members.values() for member in members
] ]
send_mass_custom_mail(datatuple) send_mass_custom_mail(datatuple)
# On enregistre le fait que l'envoi a bien eu lieu # On enregistre le fait que l'envoi a bien eu lieu
self.rappel_sent = timezone.now() self.rappel_sent = timezone.now()
self.save() self.save()
# On renvoie la liste des destinataires # On renvoie la liste des destinataires
return members.values() return members
@property @property
def is_past(self): def is_past(self):
@ -344,10 +363,11 @@ class SpectacleRevente(models.Model):
# Envoie un mail aux perdants # Envoie un mail aux perdants
for inscrit in inscrits: for inscrit in inscrits:
if inscrit != winner: if inscrit != winner:
context['acheteur'] = inscrit.user new_context = dict(context)
new_context['acheteur'] = inscrit.user
datatuple.append(( datatuple.append((
'bda-revente-loser', 'bda-revente-loser',
context, new_context,
settings.MAIL_DATA['revente']['FROM'], settings.MAIL_DATA['revente']['FROM'],
[inscrit.user.email] [inscrit.user.email]
)) ))

View file

@ -1,73 +0,0 @@
{% extends "base_title.html" %}
{% load bootstrap %}
{% block realcontent %}
<h2>Revente de place</h2>
<h3>Places non revendues</h3>
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
<div class="form-group">
<div class="multiple-checkbox">
<ul>
{% for box in resellform.attributions %}
<li>
{{box.tag}}
{{box.choice_label}}
</li>
{% endfor %}
</ul>
</div>
</div>
<div class="form-actions">
<input type="submit" class="btn btn-primary" name="resell" value="Revendre les places sélectionnées">
</div>
</form>
<br>
{% if annulform.attributions or overdue %}
<h3>Places en cours de revente</h3>
<form action="" method="post">
{% csrf_token %}
<div class="form-group">
<div class="multiple-checkbox">
<ul>
{% for box in annulform.attributions %}
<li>
{{box.tag}}
{{box.choice_label}}
</li>
{% endfor %}
{% for attrib in overdue %}
<li>
<input type="checkbox" style="visibility:hidden">
{{attrib.spectacle}}
</li>
{% endfor %}
</ul>
</div>
</div>
{% if annulform.attributions %}
<input type="submit" class="btn btn-primary" name="annul" value="Annuler les reventes sélectionnées">
{% endif %}
</form>
{% endif %}
<br>
{% if sold %}
<h3>Places revendues</h3>
<table class="table">
{% for attrib in sold %}
<tr>
<form action="" method="post">
{% csrf_token %}
<td>{{attrib.spectacle}}</td>
<td>{{attrib.revente.soldTo.user.get_full_name}}</td>
<td><button type="submit" class="btn btn-primary" name="transfer"
value="{{attrib.revente.id}}">Transférer</button></td>
<td><button type="submit" class="btn btn-primary" name="reinit"
value="{{attrib.revente.id}}">Réinitialiser</button></td>
</form>
</tr>
{% endfor %}
</table>
{% endif %}
{% endblock %}

View file

@ -3,41 +3,46 @@
{% block realcontent %} {% block realcontent %}
<h2>Mails de rappels</h2> <h2>Mails de rappels</h2>
{% if sent %} {% if sent %}
<h3>Les mails de rappel pour le spectacle {{ show.title }} ont bien été envoyés aux personnes suivantes</h3> <h3>Les mails de rappel pour le spectacle {{ show.title }} ont bien été envoyés aux personnes suivantes</h3>
<ul> <ul>
{% for member in members %} {% for member in members %}
<li>{{ member.get_full_name }} ({{ member.email }})</li> <li>{{ member.get_full_name }} ({{ member.email }})</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<h3>Voulez vous envoyer les mails de rappel pour le spectacle <h3>Voulez vous envoyer les mails de rappel pour le spectacle {{ show.title }}&nbsp;?</h3>
{{ show.title }}&nbsp;?</h3>
{% if show.rappel_sent %}
<p class="error">Attention, les mails ont déjà été envoyés le
{{ show.rappel_sent }}</p>
{% endif %}
{% endif %} {% endif %}
{% if not sent %} <div class="empty-form">
<form action="" method="post"> {% if not sent %}
{% csrf_token %} <form action="" method="post">
<div class="pull-right"> {% csrf_token %}
<input class="btn btn-primary" type="submit" value="Envoyer" /> <div class="pull-right">
</div> <input class="btn btn-primary" type="submit" value="Envoyer" />
</form> </div>
{% endif %} </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 \>
<br/>
<hr/>
<h3>Forme des mails</h3> <h3>Forme des mails</h3>
<h4>Une seule place</h4> <h4>Une seule place</h4>
{% for part in exemple_mail_1place %} {% for part in exemple_mail_1place %}
<pre>{{ part }}</pre> <pre>{{ part }}</pre>
{% endfor %} {% endfor %}
<h4>Deux places</h4> <h4>Deux places</h4>
{% for part in exemple_mail_2places %} {% for part in exemple_mail_2places %}
<pre>{{ part }}</pre> <pre>{{ part }}</pre>
{% endfor %} {% endfor %}
{% endblock %} {% endblock %}

View file

@ -36,17 +36,26 @@
</tbody> </tbody>
</table> </table>
<h3><a href="{% url "admin:bda_attribution_add" %}?spectacle={{spectacle.id}}"><span class="glyphicon glyphicon-plus-sign"></span> Ajouter une attribution</a></h3> <h3><a href="{% url "admin:bda_attribution_add" %}?spectacle={{spectacle.id}}"><span class="glyphicon glyphicon-plus-sign"></span> Ajouter une attribution</a></h3>
<br> <div>
<button class="btn btn-default" type="button" onclick="toggle('export-mails')">Afficher/Cacher mails participants</button> <div>
<pre id="export-mails" style="display:none"> <button class="btn btn-default" type="button" onclick="toggle('export-mails')">Afficher/Cacher mails participants</button>
{%for participant in participants %}{{participant.email}}, {%endfor%} <pre id="export-mails" style="display:none">{% spaceless %}
</pre> {% for participant in participants %}{{ participant.email }}, {% endfor %}
<br> {% endspaceless %}</pre>
<button class="btn btn-default" type="button" onclick="toggle('export-salle')">Afficher/Cacher liste noms</button> </div>
<pre id="export-salle" style="display:none">
<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}} places {% for participant in participants %}{{participant.name}} : {{participant.nb_places}} places
{% endfor %} {% endfor %}
</pre> {% 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" <script type="text/javascript"
src="{% static "js/joequery-Stupid-Table-Plugin/stupidtable.js" %}"></script> src="{% static "js/joequery-Stupid-Table-Plugin/stupidtable.js" %}"></script>
<script> <script>

View file

@ -0,0 +1,56 @@
{% extends "base_title.html" %}
{% load bootstrap %}
{% block realcontent %}
<h2>Revente de place</h2>
{% with resell_attributions=resellform.attributions annul_attributions=annulform.attributions sold_attributions=soldform.attributions %}
{% if resellform.attributions %}
<h3>Places non revendues</h3>
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{{resellform|bootstrap}}
<div class="form-actions">
<input type="submit" class="btn btn-primary" name="resell" value="Revendre les places sélectionnées">
</div>
</form>
{% endif %}
<br>
{% if annul_attributions or overdue %}
<h3>Places en cours de revente</h3>
<form action="" method="post">
{% csrf_token %}
<div class='form-group'>
<div class='multiple-checkbox'>
<ul>
{% for attrib in annul_attributions %}
<li>{{attrib.tag}} {{attrib.choice_label}}</li>
{% endfor %}
{% for attrib in overdue %}
<li>
<input type="checkbox" style="visibility:hidden">
{{attrib.spectacle}}
</li>
{% endfor %}
{% if annul_attributions %}
<input type="submit" class="btn btn-primary" name="annul" value="Annuler les reventes sélectionnées">
{% endif %}
</form>
{% endif %}
<br>
{% if sold_attributions %}
<h3>Places revendues</h3>
<form action="" method="post">
{% csrf_token %}
{{soldform|bootstrap}}
<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_attributions and not annul_attributions and not overdue and not sold_attributions %}
<p>Plus de reventes possibles !</p>
{% endif %}
{% endwith %}
{% endblock %}

View file

@ -1,22 +1,105 @@
# -*- coding: utf-8 -*- import json
"""
This file demonstrates writing tests using the unittest module. These will pass
when you run "manage.py test".
Replace this with more appropriate tests for your application. from django.contrib.auth.models import User
""" from django.test import TestCase, Client
from django.utils import timezone
from __future__ import division from .models import Tirage, Spectacle, Salle, CategorieSpectacle
from __future__ import print_function
from __future__ import unicode_literals
from django.test import TestCase class TestBdAViews(TestCase):
def setUp(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")
Spectacle.objects.bulk_create([
Spectacle(
title="foo", date=timezone.now(), location=self.location,
price=0, slots=42, tirage=self.tirage, listing=False,
category=self.category
),
Spectacle(
title="bar", date=timezone.now(), location=self.location,
price=1, slots=142, tirage=self.tirage, listing=False,
category=self.category
),
Spectacle(
title="baz", date=timezone.now(), location=self.location,
price=2, slots=242, tirage=self.tirage, listing=False,
category=self.category
),
])
self.bda_user = User.objects.create_user(
username="bda_user", password="bda4ever"
)
self.bda_user.profile.is_cof = True
self.bda_user.profile.is_buro = True
self.bda_user.profile.save()
class SimpleTest(TestCase): def bda_participants(self):
def test_basic_addition(self): """The BdA participants views can be queried"""
""" client = Client()
Tests that 1 + 1 always equals 2. show = self.tirage.spectacle_set.first()
"""
self.assertEqual(1 + 1, 2) client.login(self.bda_user.username, "bda4ever")
tirage_resp = client.get("/bda/spectacles/{}".format(self.tirage.id))
show_resp = client.get(
"/bda/spectacles/{}/{}".format(self.tirage.id, show.id)
)
reminder_url = "/bda/mails-rappel/{}".format(show.id)
reminder_get_resp = client.get(reminder_url)
reminder_post_resp = client.post(reminder_url)
self.assertEqual(200, tirage_resp.status_code)
self.assertEqual(200, show_resp.status_code)
self.assertEqual(200, reminder_get_resp.status_code)
self.assertEqual(200, reminder_post_resp.status_code)
def test_catalogue(self):
"""Test the catalogue JSON API"""
client = Client()
# The `list` hook
resp = client.get("/bda/catalogue/list")
self.assertJSONEqual(
resp.content.decode("utf-8"),
[{"id": self.tirage.id, "title": self.tirage.title}]
)
# The `details` hook
resp = client.get(
"/bda/catalogue/details?id={}".format(self.tirage.id)
)
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
}],
}
)
# The `descriptions` hook
resp = client.get(
"/bda/catalogue/descriptions?id={}".format(self.tirage.id)
)
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)}
)

View file

@ -44,7 +44,12 @@ urlpatterns = [
url(r'^revente-immediat/(?P<tirage_id>\d+)$', url(r'^revente-immediat/(?P<tirage_id>\d+)$',
views.revente_shotgun, views.revente_shotgun,
name="bda-shotgun"), name="bda-shotgun"),
url(r'^mails-rappel/(?P<spectacle_id>\d+)$', views.send_rappel), url(r'^mails-rappel/(?P<spectacle_id>\d+)$',
views.send_rappel,
name="bda-rappels"
),
url(r'^descriptions/(?P<tirage_id>\d+)$', views.descriptions_spectacles, url(r'^descriptions/(?P<tirage_id>\d+)$', views.descriptions_spectacles,
name='bda-descriptions'), name='bda-descriptions'),
url(r'^catalogue/(?P<request_type>[a-z]+)$', views.catalogue,
name='bda-catalogue'),
] ]

View file

@ -1,32 +1,39 @@
# -*- coding: utf-8 -*-
import random import random
import hashlib import hashlib
import time import time
from datetime import timedelta import json
from custommail.shortcuts import (
send_mass_custom_mail, send_custom_mail, render_custom_mail from collections import defaultdict
) from custommail.shortcuts import send_mass_custom_mail, send_custom_mail
from custommail.models import CustomMail
from datetime import timedelta
from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.db import models, transaction
from django.core import serializers
from django.db.models import Count, Q, Sum
from django.forms.models import inlineformset_factory
from django.http import HttpResponseBadRequest, HttpResponseRedirect
from django.core.urlresolvers import reverse
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core import serializers
from django.core.urlresolvers import reverse
from django.db import transaction
from django.db.models import Count, Q, Prefetch
from django.forms.models import inlineformset_factory
from django.http import (
HttpResponseBadRequest, HttpResponseRedirect, JsonResponse
)
from django.shortcuts import render, get_object_or_404
from django.utils import timezone, formats from django.utils import timezone, formats
from django.views.generic.list import ListView from django.views.generic.list import ListView
from cof.decorators import cof_required, buro_required from cof.decorators import cof_required, buro_required
from bda.models import Spectacle, Participant, ChoixSpectacle, Attribution,\
Tirage, SpectacleRevente from .models import (
from bda.algorithm import Algorithm Attribution, CategorieSpectacle, ChoixSpectacle, Participant, Salle,
from bda.forms import BaseBdaFormSet, TokenForm, ResellForm, AnnulForm,\ Spectacle, SpectacleRevente, Tirage
InscriptionReventeForm )
from .algorithm import Algorithm
from .forms import (
TokenForm, ResellForm, AnnulForm, InscriptionReventeForm, SoldForm,
InscriptionInlineFormSet,
)
@cof_required @cof_required
@ -39,39 +46,44 @@ def etat_places(request, tirage_id):
Et le total de toutes les demandes Et le total de toutes les demandes
""" """
tirage = get_object_or_404(Tirage, id=tirage_id) tirage = get_object_or_404(Tirage, id=tirage_id)
spectacles1 = ChoixSpectacle.objects \
.filter(spectacle__tirage=tirage) \ spectacles = tirage.spectacle_set.select_related('location')
.filter(double_choice="1") \ spectacles_dict = {} # index of spectacle by id
.all() \
.values('spectacle', 'spectacle__title') \
.annotate(total=models.Count('spectacle'))
spectacles2 = ChoixSpectacle.objects \
.filter(spectacle__tirage=tirage) \
.exclude(double_choice="1") \
.all() \
.values('spectacle', 'spectacle__title') \
.annotate(total=models.Count('spectacle'))
spectacles = tirage.spectacle_set.all()
spectacles_dict = {}
total = 0
for spectacle in spectacles: for spectacle in spectacles:
spectacle.total = 0 spectacle.total = 0 # init total requests
spectacle.ratio = 0.0
spectacles_dict[spectacle.id] = spectacle spectacles_dict[spectacle.id] = spectacle
for spectacle in spectacles1:
spectacles_dict[spectacle["spectacle"]].total += spectacle["total"] choices = (
spectacles_dict[spectacle["spectacle"]].ratio = \ ChoixSpectacle.objects
spectacles_dict[spectacle["spectacle"]].total / \ .filter(spectacle__in=spectacles)
spectacles_dict[spectacle["spectacle"]].slots .values('spectacle')
total += spectacle["total"] .annotate(total=Count('spectacle'))
for spectacle in spectacles2: )
spectacles_dict[spectacle["spectacle"]].total += 2*spectacle["total"]
spectacles_dict[spectacle["spectacle"]].ratio = \ # choices *by spectacles* whose only 1 place is requested
spectacles_dict[spectacle["spectacle"]].total / \ choices1 = choices.filter(double_choice="1")
spectacles_dict[spectacle["spectacle"]].slots # choices *by spectacles* whose 2 places is requested
total += 2*spectacle["total"] 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 = { context = {
"proposed": tirage.spectacle_set.aggregate(Sum('slots'))['slots__sum'], "proposed": slots,
"spectacles": spectacles, "spectacles": spectacles,
"total": total, "total": total,
'tirage': tirage 'tirage': tirage
@ -89,11 +101,16 @@ def _hash_queryset(queryset):
@cof_required @cof_required
def places(request, tirage_id): def places(request, tirage_id):
tirage = get_object_or_404(Tirage, id=tirage_id) tirage = get_object_or_404(Tirage, id=tirage_id)
participant, created = Participant.objects.get_or_create( participant, _ = (
user=request.user, tirage=tirage) Participant.objects
places = participant.attribution_set.order_by( .get_or_create(user=request.user, tirage=tirage)
"spectacle__date", "spectacle").all() )
total = sum([place.spectacle.price for place in places]) 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 = [] filtered_places = []
places_dict = {} places_dict = {}
spectacles = [] spectacles = []
@ -141,35 +158,31 @@ def inscription(request, tirage_id):
messages.error(request, "Le tirage n'est pas encore ouvert : " messages.error(request, "Le tirage n'est pas encore ouvert : "
"ouverture le {:s}".format(opening)) "ouverture le {:s}".format(opening))
return render(request, 'bda/resume-inscription-tirage.html', {}) 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: if timezone.now() > tirage.fermeture:
# Le tirage est fermé. # Le tirage est fermé.
participant, created = Participant.objects.get_or_create( choices = participant.choixspectacle_set.order_by("priority")
user=request.user, tirage=tirage)
choices = participant.choixspectacle_set.order_by("priority").all()
messages.error(request, messages.error(request,
" C'est fini : tirage au sort dans la journée !") " C'est fini : tirage au sort dans la journée !")
return render(request, "bda/resume-inscription-tirage.html", return render(request, "bda/resume-inscription-tirage.html",
{"choices": choices}) {"choices": choices})
def formfield_callback(f, **kwargs):
"""
Fonction utilisée par inlineformset_factory ci dessous.
Restreint les spectacles proposés aux spectacles du bo tirage.
"""
if f.name == "spectacle":
kwargs['queryset'] = tirage.spectacle_set
return f.formfield(**kwargs)
BdaFormSet = inlineformset_factory( BdaFormSet = inlineformset_factory(
Participant, Participant,
ChoixSpectacle, ChoixSpectacle,
fields=("spectacle", "double_choice", "priority"), fields=("spectacle", "double_choice", "priority"),
formset=BaseBdaFormSet, formset=InscriptionInlineFormSet,
formfield_callback=formfield_callback) )
participant, created = Participant.objects.get_or_create(
user=request.user, tirage=tirage)
success = False success = False
stateerror = False stateerror = False
if request.method == "POST": if request.method == "POST":
# use *this* queryset
dbstate = _hash_queryset(participant.choixspectacle_set.all()) dbstate = _hash_queryset(participant.choixspectacle_set.all())
if "dbstate" in request.POST and dbstate != request.POST["dbstate"]: if "dbstate" in request.POST and dbstate != request.POST["dbstate"]:
stateerror = True stateerror = True
@ -182,9 +195,14 @@ def inscription(request, tirage_id):
formset = BdaFormSet(instance=participant) formset = BdaFormSet(instance=participant)
else: else:
formset = BdaFormSet(instance=participant) formset = BdaFormSet(instance=participant)
# use *this* queryset
dbstate = _hash_queryset(participant.choixspectacle_set.all()) dbstate = _hash_queryset(participant.choixspectacle_set.all())
total_price = 0 total_price = 0
for choice in participant.choixspectacle_set.all(): choices = (
participant.choixspectacle_set
.select_related('spectacle')
)
for choice in choices:
total_price += choice.spectacle.price total_price += choice.spectacle.price
if choice.double: if choice.double:
total_price += choice.spectacle.price total_price += choice.spectacle.price
@ -213,9 +231,9 @@ def do_tirage(tirage_elt, token):
# Initialisation du dictionnaire data qui va contenir les résultats # Initialisation du dictionnaire data qui va contenir les résultats
start = time.time() start = time.time()
data = { data = {
'shows': tirage_elt.spectacle_set.select_related().all(), 'shows': tirage_elt.spectacle_set.select_related('location'),
'token': token, 'token': token,
'members': tirage_elt.participant_set.all(), 'members': tirage_elt.participant_set.select_related('user'),
'total_slots': 0, 'total_slots': 0,
'total_losers': 0, 'total_losers': 0,
'total_sold': 0, 'total_sold': 0,
@ -228,7 +246,7 @@ def do_tirage(tirage_elt, token):
ChoixSpectacle.objects ChoixSpectacle.objects
.filter(spectacle__tirage=tirage_elt) .filter(spectacle__tirage=tirage_elt)
.order_by('participant', 'priority') .order_by('participant', 'priority')
.select_related().all() .select_related('participant', 'participant__user', 'spectacle')
) )
results = Algorithm(data['shows'], data['members'], choices)(token) results = Algorithm(data['shows'], data['members'], choices)(token)
@ -285,10 +303,31 @@ def do_tirage(tirage_elt, token):
]) ])
# On inscrit à BdA-Revente ceux qui n'ont pas eu les places voulues # On inscrit à BdA-Revente ceux qui n'ont pas eu les places voulues
for (show, _, losers) in results: ChoixRevente = Participant.choicesrevente.through
for (loser, _, _, _) in losers:
loser.choicesrevente.add(show) # Suppression des reventes demandées/enregistrées
loser.save() # (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["duration"] = time.time() - start
data["results"] = results data["results"] = results
@ -316,13 +355,18 @@ def revente(request, tirage_id):
tirage = get_object_or_404(Tirage, id=tirage_id) tirage = get_object_or_404(Tirage, id=tirage_id)
participant, created = Participant.objects.get_or_create( participant, created = Participant.objects.get_or_create(
user=request.user, tirage=tirage) user=request.user, tirage=tirage)
if not participant.paid: if not participant.paid:
return render(request, "bda-notpaid.html", {}) return render(request, "bda-notpaid.html", {})
resellform = ResellForm(participant, prefix='resell')
annulform = AnnulForm(participant, prefix='annul')
soldform = SoldForm(participant, prefix='sold')
if request.method == 'POST': if request.method == 'POST':
# On met en vente une place # On met en vente une place
if 'resell' in request.POST: if 'resell' in request.POST:
resellform = ResellForm(participant, request.POST, prefix='resell') resellform = ResellForm(participant, request.POST, prefix='resell')
annulform = AnnulForm(participant, prefix='annul')
if resellform.is_valid(): if resellform.is_valid():
datatuple = [] datatuple = []
attributions = resellform.cleaned_data["attributions"] attributions = resellform.cleaned_data["attributions"]
@ -354,7 +398,6 @@ def revente(request, tirage_id):
# On annule une revente # On annule une revente
elif 'annul' in request.POST: elif 'annul' in request.POST:
annulform = AnnulForm(participant, request.POST, prefix='annul') annulform = AnnulForm(participant, request.POST, prefix='annul')
resellform = ResellForm(participant, prefix='resell')
if annulform.is_valid(): if annulform.is_valid():
attributions = annulform.cleaned_data["attributions"] attributions = annulform.cleaned_data["attributions"]
for attribution in attributions: for attribution in attributions:
@ -362,58 +405,42 @@ def revente(request, tirage_id):
# On confirme une vente en transférant la place à la personne qui a # On confirme une vente en transférant la place à la personne qui a
# gagné le tirage # gagné le tirage
elif 'transfer' in request.POST: elif 'transfer' in request.POST:
resellform = ResellForm(participant, prefix='resell') soldform = SoldForm(participant, request.POST, prefix='sold')
annulform = AnnulForm(participant, prefix='annul') if soldform.is_valid():
attributions = soldform.cleaned_data['attributions']
for attribution in attributions:
attribution.participant = attribution.revente.soldTo
attribution.save()
revente_id = request.POST['transfer'][0]
rev = SpectacleRevente.objects.filter(soldTo__isnull=False,
id=revente_id)
if rev.exists():
revente = rev.get()
attrib = revente.attribution
attrib.participant = revente.soldTo
attrib.save()
# On annule la revente après le tirage au sort (par exemple si # 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 # la personne qui a gagné le tirage ne se manifeste pas). La place est
# alors remise en vente # alors remise en vente
elif 'reinit' in request.POST: elif 'reinit' in request.POST:
resellform = ResellForm(participant, prefix='resell') soldform = SoldForm(participant, request.POST, prefix='sold')
annulform = AnnulForm(participant, prefix='annul') if soldform.is_valid():
revente_id = request.POST['reinit'][0] attributions = soldform.cleaned_data['attributions']
rev = SpectacleRevente.objects.filter(soldTo__isnull=False, for attribution in attributions:
id=revente_id) if attribution.spectacle.date > timezone.now():
if rev.exists(): revente = attribution.revente
revente = rev.get() revente.date = timezone.now() - timedelta(minutes=65)
if revente.attribution.spectacle.date > timezone.now(): revente.soldTo = None
revente.date = timezone.now() - timedelta(hours=1) revente.notif_sent = False
revente.soldTo = None revente.tirage_done = False
revente.notif_sent = False revente.shotgun = False
revente.tirage_done = False if revente.answered_mail:
revente.shotgun = False revente.answered_mail.clear()
if revente.answered_mail: revente.save()
revente.answered_mail.clear()
revente.save()
else:
resellform = ResellForm(participant, prefix='resell')
annulform = AnnulForm(participant, prefix='annul')
else:
resellform = ResellForm(participant, prefix='resell')
annulform = AnnulForm(participant, prefix='annul')
overdue = participant.attribution_set.filter( overdue = participant.attribution_set.filter(
spectacle__date__gte=timezone.now(), spectacle__date__gte=timezone.now(),
revente__isnull=False, revente__isnull=False,
revente__seller=participant, revente__seller=participant,
revente__date__lte=timezone.now()-timedelta(hours=1)).filter( revente__notif_sent=True)\
.filter(
Q(revente__soldTo__isnull=True) | Q(revente__soldTo=participant)) Q(revente__soldTo__isnull=True) | Q(revente__soldTo=participant))
sold = participant.attribution_set.filter(
spectacle__date__gte=timezone.now(),
revente__isnull=False,
revente__soldTo__isnull=False)
return render(request, "bda-revente.html", return render(request, "bda/reventes.html",
{'tirage': tirage, 'overdue': overdue, "sold": sold, {'tirage': tirage, 'overdue': overdue, "soldform": soldform,
"annulform": annulform, "resellform": resellform}) "annulform": annulform, "resellform": resellform})
@ -465,7 +492,6 @@ def list_revente(request, tirage_id):
) )
if min_resell is not None: if min_resell is not None:
min_resell.answered_mail.add(participant) min_resell.answered_mail.add(participant)
min_resell.save()
inscrit_revente.append(spectacle) inscrit_revente.append(spectacle)
success = True success = True
else: else:
@ -503,13 +529,13 @@ def buy_revente(request, spectacle_id):
# Si l'utilisateur veut racheter une place qu'il est en train de revendre, # Si l'utilisateur veut racheter une place qu'il est en train de revendre,
# on supprime la revente en question. # on supprime la revente en question.
if reventes.filter(seller=participant).exists(): own_reventes = reventes.filter(seller=participant)
revente = reventes.filter(seller=participant)[0] if len(own_reventes) > 0:
revente.delete() own_reventes[0].delete()
return HttpResponseRedirect(reverse("bda-shotgun", return HttpResponseRedirect(reverse("bda-shotgun",
args=[tirage.id])) args=[tirage.id]))
reventes_shotgun = list(reventes.filter(shotgun=True).all()) reventes_shotgun = reventes.filter(shotgun=True)
if not reventes_shotgun: if not reventes_shotgun:
return render(request, "bda-no-revente.html", {}) return render(request, "bda-no-revente.html", {})
@ -541,16 +567,21 @@ def buy_revente(request, spectacle_id):
@login_required @login_required
def revente_shotgun(request, tirage_id): def revente_shotgun(request, tirage_id):
tirage = get_object_or_404(Tirage, id=tirage_id) tirage = get_object_or_404(Tirage, id=tirage_id)
spectacles = tirage.spectacle_set.filter( spectacles = (
date__gte=timezone.now()) tirage.spectacle_set
shotgun = [] .filter(date__gte=timezone.now())
for spectacle in spectacles: .select_related('location')
reventes = SpectacleRevente.objects.filter( .prefetch_related(Prefetch(
attribution__spectacle=spectacle, 'attribues',
shotgun=True, queryset=(
soldTo__isnull=True) Attribution.objects
if reventes.exists(): .filter(revente__shotgun=True,
shotgun.append(spectacle) revente__soldTo__isnull=True)
),
to_attr='shotguns',
))
)
shotgun = [sp for sp in spectacles if len(sp.shotguns) > 0]
return render(request, "bda-shotgun.html", return render(request, "bda-shotgun.html",
{"shotgun": shotgun}) {"shotgun": shotgun})
@ -560,7 +591,10 @@ def revente_shotgun(request, tirage_id):
def spectacle(request, tirage_id, spectacle_id): def spectacle(request, tirage_id, spectacle_id):
tirage = get_object_or_404(Tirage, id=tirage_id) tirage = get_object_or_404(Tirage, id=tirage_id)
spectacle = get_object_or_404(Spectacle, id=spectacle_id, tirage=tirage) spectacle = get_object_or_404(Spectacle, id=spectacle_id, tirage=tirage)
attributions = spectacle.attribues.all() attributions = (
spectacle.attribues
.select_related('participant', 'participant__user')
)
participants = {} participants = {}
for attrib in attributions: for attrib in attributions:
participant = attrib.participant participant = attrib.participant
@ -579,7 +613,7 @@ def spectacle(request, tirage_id, spectacle_id):
participants_info = sorted(participants.values(), participants_info = sorted(participants.values(),
key=lambda part: part['lastname']) key=lambda part: part['lastname'])
return render(request, "bda-participants.html", return render(request, "bda/participants.html",
{"spectacle": spectacle, "participants": participants_info}) {"spectacle": spectacle, "participants": participants_info})
@ -589,7 +623,10 @@ class SpectacleListView(ListView):
def get_queryset(self): def get_queryset(self):
self.tirage = get_object_or_404(Tirage, id=self.kwargs['tirage_id']) self.tirage = get_object_or_404(Tirage, id=self.kwargs['tirage_id'])
categories = self.tirage.spectacle_set.all() categories = (
self.tirage.spectacle_set
.select_related('location')
)
return categories return categories
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -602,9 +639,12 @@ class SpectacleListView(ListView):
@buro_required @buro_required
def unpaid(request, tirage_id): def unpaid(request, tirage_id):
tirage = get_object_or_404(Tirage, id=tirage_id) tirage = get_object_or_404(Tirage, id=tirage_id)
unpaid = tirage.participant_set \ unpaid = (
.annotate(nb_attributions=Count('attribution')) \ tirage.participant_set
.filter(paid=False, nb_attributions__gt=0).all() .annotate(nb_attributions=Count('attribution'))
.filter(paid=False, nb_attributions__gt=0)
.select_related('user')
)
return render(request, "bda-unpaid.html", {"unpaid": unpaid}) return render(request, "bda-unpaid.html", {"unpaid": unpaid})
@ -612,20 +652,24 @@ def unpaid(request, tirage_id):
def send_rappel(request, spectacle_id): def send_rappel(request, spectacle_id):
show = get_object_or_404(Spectacle, id=spectacle_id) show = get_object_or_404(Spectacle, id=spectacle_id)
# Mails d'exemples # Mails d'exemples
exemple_mail_1place = render_custom_mail('bda-rappel', { custommail = CustomMail.objects.get(shortname="bda-rappel")
exemple_mail_1place = custommail.render({
'member': request.user, 'member': request.user,
'show': show, 'show': show,
'nb_attr': 1 'nb_attr': 1
}) })
exemple_mail_2places = render_custom_mail('bda-rappel', { exemple_mail_2places = custommail.render({
'member': request.user, 'member': request.user,
'show': show, 'show': show,
'nb_attr': 2 'nb_attr': 2
}) })
# Contexte # Contexte
ctxt = {'show': show, ctxt = {
'exemple_mail_1place': exemple_mail_1place, 'show': show,
'exemple_mail_2places': exemple_mail_2places} 'exemple_mail_1place': exemple_mail_1place,
'exemple_mail_2places': exemple_mail_2places,
'custommail': custommail,
}
# Envoi confirmé # Envoi confirmé
if request.method == 'POST': if request.method == 'POST':
members = show.send_rappel() members = show.send_rappel()
@ -634,12 +678,24 @@ def send_rappel(request, spectacle_id):
# Demande de confirmation # Demande de confirmation
else: else:
ctxt['sent'] = False 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) return render(request, "bda/mails-rappel.html", ctxt)
def descriptions_spectacles(request, tirage_id): def descriptions_spectacles(request, tirage_id):
tirage = get_object_or_404(Tirage, id=tirage_id) tirage = get_object_or_404(Tirage, id=tirage_id)
shows_qs = tirage.spectacle_set shows_qs = (
tirage.spectacle_set
.select_related('location')
.prefetch_related('quote_set')
)
category_name = request.GET.get('category', '') category_name = request.GET.get('category', '')
location_id = request.GET.get('location', '') location_id = request.GET.get('location', '')
if category_name: if category_name:
@ -650,4 +706,112 @@ def descriptions_spectacles(request, tirage_id):
except ValueError: except ValueError:
return HttpResponseBadRequest( return HttpResponseBadRequest(
"La variable GET 'location' doit contenir un entier") "La variable GET 'location' doit contenir un entier")
return render(request, 'descriptions.html', {'shows': shows_qs.all()}) return render(request, 'descriptions.html', {'shows': shows_qs})
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()

View file

@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
from ldap3 import Connection from ldap3 import Connection
from django import shortcuts from django import shortcuts
@ -12,6 +10,10 @@ from django.conf import settings
class Clipper(object): class Clipper(object):
def __init__(self, clipper, fullname): def __init__(self, clipper, fullname):
if fullname is None:
fullname = ""
assert isinstance(clipper, str)
assert isinstance(fullname, str)
self.clipper = clipper self.clipper = clipper
self.fullname = fullname self.fullname = fullname
@ -52,24 +54,28 @@ def autocomplete(request):
) )
# Fetching data from the SPI # Fetching data from the SPI
if hasattr(settings, 'LDAP_SERVER_URL'): if getattr(settings, 'LDAP_SERVER_URL', None):
# Fetching # Fetching
ldap_query = '(|{:s})'.format(''.join( ldap_query = '(&{:s})'.format(''.join(
['(cn=*{bit:s}*)(uid=*{bit:s}*)'.format(bit=bit) '(|(cn=*{bit:s}*)(uid=*{bit:s}*))'.format(bit=bit)
for bit in bits] for bit in bits if bit.isalnum()
)) ))
with Connection(settings.LDAP_SERVER_URL) as conn: if ldap_query != "(&)":
conn.search( # If none of the bits were legal, we do not perform the query
'dc=spi,dc=ens,dc=fr', ldap_query, entries = None
attributes=['uid', 'cn'] with Connection(settings.LDAP_SERVER_URL) as conn:
) conn.search(
queries['clippers'] = conn.entries 'dc=spi,dc=ens,dc=fr', ldap_query,
# Clearing redundancies attributes=['uid', 'cn']
queries['clippers'] = [ )
Clipper(clipper.uid, clipper.cn) entries = conn.entries
for clipper in queries['clippers'] # Clearing redundancies
if str(clipper.uid) not in usernames queries['clippers'] = [
] Clipper(entry.uid.value, entry.cn.value)
for entry in entries
if entry.uid.value
and entry.uid.value not in usernames
]
# Resulting data # Resulting data
data.update(queries) data.update(queries)

View file

@ -1,25 +1,18 @@
# -*- coding: utf-8 -*- from djconfig.forms import ConfigForm
from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals
from django import forms from django import forms
from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.forms.widgets import RadioSelect, CheckboxSelectMultiple
from django.forms.formsets import BaseFormSet, formset_factory from django.forms.formsets import BaseFormSet, formset_factory
from django.db.models import Max from django.forms.widgets import RadioSelect, CheckboxSelectMultiple
from django.core.validators import MinLengthValidator from django.utils.translation import ugettext_lazy as _
from bda.models import Spectacle
from gestion.models import Profile, EventCommentValue
from .models import CofProfile, CalendarSubscription from .models import CofProfile, CalendarSubscription
from .widgets import TriStateCheckbox from .widgets import TriStateCheckbox
from gestion.models import Profile, EventCommentValue
from gestion.shared import lock_table, unlock_table
from bda.models import Spectacle
class SurveyForm(forms.Form): class SurveyForm(forms.Form):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -136,8 +129,9 @@ class EventStatusFilterForm(forms.Form):
class RegistrationUserForm(forms.ModelForm): class RegistrationUserForm(forms.ModelForm):
def force_long_username(self): def __init__(self, *args, **kw):
self.fields['username'].validators = [MinLengthValidator(9)] super(RegistrationUserForm, self).__init__(*args, **kw)
self.fields['username'].help_text = ""
class Meta: class Meta:
model = User model = User
@ -182,21 +176,6 @@ class RegistrationCofProfileForm(forms.ModelForm):
"mailing", "mailing_bda", "mailing_bda_revente", "mailing", "mailing_bda", "mailing_bda_revente",
] ]
def save(self, *args, **kw):
instance = super(RegistrationCofProfileForm, self).save(*args, **kw)
if instance.is_cof and not instance.num:
# Generate new number
try:
lock_table(CofProfile)
aggregate = CofProfile.objects.aggregate(Max('num'))
instance.num = aggregate['num__max'] + 1
instance.save()
self.cleaned_data['num'] = instance.num
self.data['num'] = instance.num
finally:
unlock_table(CofProfile)
return instance
class RegistrationProfileForm(forms.ModelForm): class RegistrationProfileForm(forms.ModelForm):
class Meta: class Meta:
@ -318,3 +297,16 @@ class CalendarForm(forms.ModelForm):
model = CalendarSubscription model = CalendarSubscription
fields = ['subscribe_to_events', 'subscribe_to_my_shows', fields = ['subscribe_to_events', 'subscribe_to_my_shows',
'other_shows'] 'other_shows']
# ---
# Announcements banner
# TODO: move this to the `gestion` app once the supportBDS branch is merged
# ---
class GestioncofConfigForm(ConfigForm):
gestion_banner = forms.CharField(
label=_("Announcements banner"),
help_text=_("An empty banner disables annoucements"),
max_length=2048
)

View file

@ -1,41 +1,45 @@
# -*- coding: utf-8 -*-
import json import json
from custommail.shortcuts import render_custom_mail from custommail.shortcuts import render_custom_mail
from django.shortcuts import render, get_object_or_404, redirect from django.conf import settings
from django.core import mail from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core import mail
from django.db import transaction
from django.shortcuts import render, get_object_or_404, redirect
from django.utils import timezone
from django.views.generic import ListView, DetailView from django.views.generic import ListView, DetailView
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.utils import timezone
from .decorators import buro_required
from .models import CofProfile from .models import CofProfile
from .petits_cours_models import ( from .petits_cours_models import (
PetitCoursDemande, PetitCoursAttribution, PetitCoursAttributionCounter, PetitCoursDemande, PetitCoursAttribution, PetitCoursAttributionCounter,
PetitCoursAbility, PetitCoursSubject PetitCoursAbility
) )
from .decorators import buro_required
from .petits_cours_forms import DemandeForm, MatieresFormSet from .petits_cours_forms import DemandeForm, MatieresFormSet
from gestion.shared import lock_table, unlock_tables
class DemandeListView(ListView): class DemandeListView(ListView):
model = PetitCoursDemande queryset = (
PetitCoursDemande.objects
.prefetch_related('matieres')
.order_by('traitee', '-id')
)
template_name = "petits_cours_demandes_list.html" template_name = "petits_cours_demandes_list.html"
paginate_by = 20 paginate_by = 20
def get_queryset(self):
return PetitCoursDemande.objects.order_by('traitee', '-id').all()
class DemandeDetailView(DetailView): class DemandeDetailView(DetailView):
model = PetitCoursDemande model = PetitCoursDemande
template_name = "cof/details_demande_petit_cours.html" template_name = "cof/details_demande_petit_cours.html"
queryset = (
PetitCoursDemande.objects
.prefetch_related('petitcoursattribution_set',
'matieres')
)
context_object_name = "demande" context_object_name = "demande"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -103,8 +107,9 @@ def _finalize_traitement(request, demande, proposals, proposed_for,
'style="width:99%; height: 90px;">' 'style="width:99%; height: 90px;">'
'</textarea>' '</textarea>'
}) })
for error in errors: if errors is not None:
messages.error(request, error) for error in errors:
messages.error(request, error)
return render(request, "cof/traitement_demande_petit_cours.html", return render(request, "cof/traitement_demande_petit_cours.html",
{"demande": demande, {"demande": demande,
"unsatisfied": unsatisfied, "unsatisfied": unsatisfied,
@ -215,7 +220,7 @@ def _traitement_other(request, demande, redo):
proposals = proposals.items() proposals = proposals.items()
proposed_for = proposed_for.items() proposed_for = proposed_for.items()
return render(request, return render(request,
"gestiocof/traitement_demande_petit_cours_autre_niveau.html", "cof/traitement_demande_petit_cours_autre_niveau.html",
{"demande": demande, {"demande": demande,
"unsatisfied": unsatisfied, "unsatisfied": unsatisfied,
"proposals": proposals, "proposals": proposals,
@ -246,37 +251,39 @@ def _traitement_post(request, demande):
proposals_list = proposals.items() proposals_list = proposals.items()
proposed_for = proposed_for.items() proposed_for = proposed_for.items()
proposed_mails = _generate_eleve_email(demande, proposed_for) proposed_mails = _generate_eleve_email(demande, proposed_for)
mainmail = render_custom_mail("petits-cours-mail-demandeur", { mainmail_object, mainmail_body = render_custom_mail(
"proposals": proposals_list, "petits-cours-mail-demandeur",
"unsatisfied": unsatisfied, {
"extra": extra, "proposals": proposals_list,
}) "unsatisfied": unsatisfied,
"extra": extra
}
)
frommail = settings.MAIL_DATA['petits_cours']['FROM'] frommail = settings.MAIL_DATA['petits_cours']['FROM']
bccaddress = settings.MAIL_DATA['petits_cours']['BCC'] bccaddress = settings.MAIL_DATA['petits_cours']['BCC']
replyto = settings.MAIL_DATA['petits_cours']['REPLYTO'] replyto = settings.MAIL_DATA['petits_cours']['REPLYTO']
mails_to_send = [] mails_to_send = []
for (user, msg) in proposed_mails: for (user, (mail_object, body)) in proposed_mails:
msg = mail.EmailMessage("Petits cours ENS par le COF", msg, msg = mail.EmailMessage(mail_object, body, frommail, [user.email],
frommail, [user.email],
[bccaddress], headers={'Reply-To': replyto}) [bccaddress], headers={'Reply-To': replyto})
mails_to_send.append(msg) mails_to_send.append(msg)
mails_to_send.append(mail.EmailMessage("Cours particuliers ENS", mainmail, mails_to_send.append(mail.EmailMessage(mainmail_object, mainmail_body,
frommail, [demande.email], frommail, [demande.email],
[bccaddress], [bccaddress],
headers={'Reply-To': replyto})) headers={'Reply-To': replyto}))
connection = mail.get_connection(fail_silently=True) connection = mail.get_connection(fail_silently=False)
connection.send_messages(mails_to_send) connection.send_messages(mails_to_send)
lock_table(PetitCoursAttributionCounter, PetitCoursAttribution, User) with transaction.atomic():
for matiere in proposals: for matiere in proposals:
for rank, user in enumerate(proposals[matiere]): for rank, user in enumerate(proposals[matiere]):
counter = PetitCoursAttributionCounter.objects.get(user=user, counter = PetitCoursAttributionCounter.objects.get(
matiere=matiere) user=user, matiere=matiere
counter.count += 1 )
counter.save() counter.count += 1
attrib = PetitCoursAttribution(user=user, matiere=matiere, counter.save()
demande=demande, rank=rank + 1) attrib = PetitCoursAttribution(user=user, matiere=matiere,
attrib.save() demande=demande, rank=rank + 1)
unlock_tables() attrib.save()
demande.traitee = True demande.traitee = True
demande.traitee_par = request.user demande.traitee_par = request.user
demande.processed = timezone.now() demande.processed = timezone.now()
@ -301,17 +308,15 @@ def inscription(request):
profile.petits_cours_accept = "receive_proposals" in request.POST profile.petits_cours_accept = "receive_proposals" in request.POST
profile.petits_cours_remarques = request.POST["remarques"] profile.petits_cours_remarques = request.POST["remarques"]
profile.save() profile.save()
lock_table(PetitCoursAttributionCounter, PetitCoursAbility, User, with transaction.atomic():
PetitCoursSubject) abilities = (
abilities = ( PetitCoursAbility.objects.filter(user=request.user).all()
PetitCoursAbility.objects.filter(user=request.user).all()
)
for ability in abilities:
PetitCoursAttributionCounter.get_uptodate(
ability.user,
ability.matiere
) )
unlock_tables() for ability in abilities:
PetitCoursAttributionCounter.get_uptodate(
ability.user,
ability.matiere
)
success = True success = True
formset = MatieresFormSet(instance=request.user) formset = MatieresFormSet(instance=request.user)
else: else:

View file

@ -40,8 +40,9 @@ a {
background: transparent; background: transparent;
} }
div.empty-form {
padding-bottom: 2em;
}
a:hover { a:hover {
color: #444; color: #444;
@ -341,10 +342,12 @@ fieldset legend {
font-weight: 700; font-weight: 700;
font-size: large; font-size: large;
color:#DE826B; color:#DE826B;
padding-bottom: .5em;
} }
#main-content h4 { #main-content h4 {
color:#DE826B; color:#DE826B;
padding-bottom: .5em;
} }
#main-content h2, #main-content h2,
@ -814,6 +817,17 @@ header .open > .dropdown-toggle.btn-default {
border-color: white; border-color: white;
} }
/* Announcements banner ------------------ */
#banner {
background-color: #d86b01;
width: 100%;
text-align: center;
padding: 10px;
color: white;
font-size: larger;
}
/* FORMS --------------------------------- */ /* FORMS --------------------------------- */
@ -836,7 +850,7 @@ input#search_autocomplete {
height: 40px; height: 40px;
padding: 10px 8px; padding: 10px 8px;
margin: 0 auto; margin: 0 auto;
margin-top: 20px; margin-top: 0px;
display: block; display: block;
color: #aaa; color: #aaa;
} }
@ -1155,3 +1169,10 @@ div.messages div.alert-success div.container {
div.messages div.alert div.container a { div.messages div.alert div.container a {
color: inherit; color: inherit;
} }
/* Help text */
p.help-block {
margin: 5px auto;
width: 90%;
}

View file

@ -8,12 +8,13 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
{# CSS #} {# CSS #}
<link type="text/css" rel="stylesheet" href="{% static "css/bootstrap.min.css" %}" /> <link type="text/css" rel="stylesheet" href="{% static "css/bootstrap.min.css" %}" />
<link type="text/css" rel="stylesheet" href="{% static "css/cof.css" %}" /> <link type="text/css" rel="stylesheet" href="{% static "css/cof.css" %}" />
<link href="https://fonts.googleapis.com/css?family=Dosis|Dosis:700|Raleway|Roboto:300,300i,700" rel="stylesheet"> <link href="https://fonts.googleapis.com/css?family=Dosis|Dosis:700|Raleway|Roboto:300,300i,700" rel="stylesheet">
<link rel="stylesheet" href="{% static "font-awesome/css/font-awesome.min.css" %}">
{# JS #} {# JS #}
<script src="https://code.jquery.com/jquery-3.1.0.min.js" integrity="sha256-cCueBR6CsyA4/9szpPfrX3s49M9vUU5BgtiJj06wt/s=" crossorigin="anonymous"></script> <script src="https://code.jquery.com/jquery-3.1.0.min.js" integrity="sha256-cCueBR6CsyA4/9szpPfrX3s49M9vUU5BgtiJj06wt/s=" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}

View file

@ -1,5 +1,6 @@
{% extends "cof/base_header.html" %} {% extends "cof/base_header.html" %}
{% load i18n %} {% load i18n %}
{% load wagtailcore_tags %}
{% block homelink %} {% block homelink %}
{% endblock %} {% endblock %}
@ -41,7 +42,21 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
{% endif %}
<h3 class="block-title">K-Fêt<span class="pull-right"><i class="fa fa-coffee"></i></span></h3>
<div class="hm-block">
<ul>
{# TODO: Since Django 1.9, we can store result with "as", allowing proper value management (if None) #}
<li><a href="{% slugurl "k-fet" %}">Page d'accueil</a></li>
<li><a href="https://www.cof.ens.fr/k-fet/calendrier">Calendrier</a></li>
{% if perms.kfet.is_team %}
<li><a href="{% url 'kfet.kpsul' %}">K-Psul</a></li>
{% endif %}
</ul>
</div>
{% if user.profile.is_cof %}
<h3 class="block-title">Divers<span class="pull-right glyphicon glyphicon-question-sign"></span></h3> <h3 class="block-title">Divers<span class="pull-right glyphicon glyphicon-question-sign"></span></h3>
<div class="hm-block"> <div class="hm-block">
<ul> <ul>

View file

@ -9,7 +9,9 @@
{% block realcontent %} {% block realcontent %}
<h2>Inscription d'un nouveau membre</h2> <h2>Inscription d'un nouveau membre</h2>
<input type="text" name="q" id="search_autocomplete" spellcheck="false" /> <p class="help-block">Les mots contenant des caractères non alphanumériques seront ignorés</p>
<input type="text" name="q" id="search_autocomplete" spellcheck="false"
placeholder="Chercher un utilisateur par nom, prénom ou identifiant clipper" />
<div id="form-placeholder"></div> <div id="form-placeholder"></div>
<div class="yourlabs-autocomplete"></div> <div class="yourlabs-autocomplete"></div>
<script type="text/javascript"> <script type="text/javascript">
@ -20,7 +22,6 @@
minimumCharacters: 3, minimumCharacters: 3,
id: 'search_autocomplete', id: 'search_autocomplete',
choiceSelector: 'li:has(a)', choiceSelector: 'li:has(a)',
placeholder: "Chercher un utilisateur par nom, prénom ou identifiant clipper",
box: $(".yourlabs-autocomplete"), box: $(".yourlabs-autocomplete"),
}); });
$('input#search_autocomplete').bind( $('input#search_autocomplete').bind(

View file

@ -23,7 +23,7 @@ def key(d, key_name):
def highlight_text(text, q): def highlight_text(text, q):
q2 = "|".join(q.split()) q2 = "|".join(re.escape(word) for word in q.split())
pattern = re.compile(r"(?P<filter>%s)" % q2, re.IGNORECASE) pattern = re.compile(r"(?P<filter>%s)" % q2, re.IGNORECASE)
return mark_safe(re.sub(pattern, return mark_safe(re.sub(pattern,
r"<span class='highlight'>\g<filter></span>", r"<span class='highlight'>\g<filter></span>",

View file

@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
from django.conf.urls import url from django.conf.urls import url
from .petits_cours_views import DemandeListView, DemandeDetailView from .petits_cours_views import DemandeListView, DemandeDetailView
from .decorators import buro_required from .decorators import buro_required

View file

@ -1,38 +1,38 @@
# -*- coding: utf-8 -*-
import unicodecsv import unicodecsv
import uuid import uuid
from custommail.shortcuts import send_custom_mail
from datetime import timedelta from datetime import timedelta
from icalendar import Calendar, Event as Vevent from icalendar import Calendar, Event as Vevent
from custommail.shortcuts import send_custom_mail
from django.shortcuts import get_object_or_404, render from django.contrib import messages
from django.http import Http404, HttpResponse
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.urlresolvers import reverse_lazy
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, render
from django.utils import timezone from django.utils import timezone
from django.contrib import messages from django.views.generic import FormView
import django.utils.six as six
from bda.models import Tirage, Spectacle
from gestion.models import ( from gestion.models import (
Event, EventRegistration, EventOption, EventOptionChoice, Event, EventRegistration, EventOption, EventOptionChoice,
EventCommentField, EventCommentValue EventCommentField, EventCommentValue, Profile
) )
from .models import Survey, SurveyAnswer, SurveyQuestion, \
SurveyQuestionAnswer, CalendarSubscription
from .models import CofProfile
from .decorators import buro_required, cof_required from .decorators import buro_required, cof_required
from .forms import ( from .forms import (
SurveyForm, SurveyStatusFilterForm, SurveyForm, SurveyStatusFilterForm,
RegistrationUserForm, RegistrationProfileForm, RegistrationCofProfileForm, RegistrationUserForm, RegistrationProfileForm, RegistrationCofProfileForm,
CalendarForm, EventFormset, RegistrationPassUserForm CalendarForm, EventFormset, RegistrationPassUserForm,
GestioncofConfigForm
)
from .models import (
Survey, SurveyAnswer, SurveyQuestion, SurveyQuestionAnswer,
CalendarSubscription, CofProfile
) )
from bda.models import Tirage, Spectacle
from gestion.models import Profile
@login_required @login_required
@ -52,7 +52,10 @@ def home(request):
@login_required @login_required
def survey(request, survey_id): def survey(request, survey_id):
survey = get_object_or_404(Survey, id=survey_id) survey = get_object_or_404(
Survey.objects.prefetch_related('questions', 'questions__answers'),
id=survey_id,
)
if not survey.survey_open or survey.old: if not survey.survey_open or survey.old:
raise Http404 raise Http404
success = False success = False
@ -224,7 +227,6 @@ def registration_form2(request, login_clipper=None, username=None,
elif not login_clipper: elif not login_clipper:
# new user # new user
user_form = RegistrationPassUserForm() user_form = RegistrationPassUserForm()
user_form.force_long_username()
profile_form = RegistrationProfileForm() profile_form = RegistrationProfileForm()
cofprofile_form = RegistrationCofProfileForm() cofprofile_form = RegistrationCofProfileForm()
event_formset = EventFormset(events=events, prefix='events') event_formset = EventFormset(events=events, prefix='events')
@ -273,12 +275,8 @@ def update_event_form_comments(event, form, registration):
def registration(request): def registration(request):
if request.POST: if request.POST:
request_dict = request.POST.copy() request_dict = request.POST.copy()
# num ne peut pas être défini manuellement
if "num" in request_dict:
del request_dict["num"]
member = None member = None
login_clipper = None login_clipper = None
success = False
# ----- # -----
# Remplissage des formulaires # Remplissage des formulaires
@ -300,12 +298,10 @@ def registration(request):
user_form = RegistrationUserForm(request_dict, instance=member) user_form = RegistrationUserForm(request_dict, instance=member)
if member.profile.login_clipper: if member.profile.login_clipper:
login_clipper = member.profile.login_clipper login_clipper = member.profile.login_clipper
else:
user_form.force_long_username()
except User.DoesNotExist: except User.DoesNotExist:
user_form.force_long_username() pass
else: else:
user_form.force_long_username() pass
# ----- # -----
# Validation des formulaires # Validation des formulaires
@ -313,10 +309,11 @@ def registration(request):
if user_form.is_valid(): if user_form.is_valid():
member = user_form.save() member = user_form.save()
cofprofile, _ = (CofProfile.objects cofprofile, _ = (
.get_or_create(profile=member.profile)) CofProfile.objects
.get_or_create(profile=member.profile)
)
was_cof = cofprofile.is_cof was_cof = cofprofile.is_cof
request_dict["num"] = cofprofile.num
# Maintenant on remplit le formulaire de profil # Maintenant on remplit le formulaire de profil
cofprofile_form = RegistrationCofProfileForm( cofprofile_form = RegistrationCofProfileForm(
request_dict, request_dict,
@ -375,16 +372,17 @@ def registration(request):
# l'inscription aux événements et/ou donner la # l'inscription aux événements et/ou donner la
# possibilité d'associer un mail aux événements # possibilité d'associer un mail aux événements
# send_custom_mail(...) # send_custom_mail(...)
success = True # ---
# Messages # Success
if success: # ---
msg = ("L'inscription de {:s} (<tt>{:s}</tt>) a été "
"enregistrée avec succès" msg = ("L'inscription de {:s} (<tt>{:s}</tt>) a été "
.format(member.get_full_name(), member.email)) "enregistrée avec succès."
if member.profile.is_cof: .format(member.get_full_name(), member.email))
msg += "Il est désormais membre du COF n°{:d} !".format( if profile.is_cof:
member.profile.num) msg += "\nIl est désormais membre du COF n°{:d} !".format(
messages.success(request, msg, extra_tags='safe') member.profile.id)
messages.success(request, msg, extra_tags='safe')
return render(request, "cof/registration_post.html", return render(request, "cof/registration_post.html",
{"user_form": user_form, {"user_form": user_form,
"profile_form": profile_form, "profile_form": profile_form,
@ -405,10 +403,10 @@ def export_members(request):
for profile in CofProfile.objects.filter( for profile in CofProfile.objects.filter(
profile__user__groups__name='cof_members').all(): profile__user__groups__name='cof_members').all():
user = profile.user user = profile.user
bits = [profile.num, user.username, user.first_name, user.last_name, bits = [user.id, user.username, user.first_name, user.last_name,
user.email, profile.phone, profile.occupation, user.email, profile.phone, profile.occupation,
profile.departement, profile.type_cotiz] profile.departement, profile.type_cotiz]
writer.writerow([six.text_type(bit) for bit in bits]) writer.writerow([str(bit) for bit in bits])
return response return response
@ -424,78 +422,80 @@ def csv_export_mega(filename, qs):
comments = "---".join( comments = "---".join(
[comment.content for comment in reg.comments.all()]) [comment.content for comment in reg.comments.all()])
bits = [user.username, user.first_name, user.last_name, user.email, bits = [user.username, user.first_name, user.last_name, user.email,
profile.phone, profile.num, profile.phone, user.id,
profile.comments if profile.comments else "", comments] profile.comments if profile.comments else "", comments]
writer.writerow([six.text_type(bit) for bit in bits]) writer.writerow([str(bit) for bit in bits])
return response return response
@buro_required @buro_required
def export_mega_remarksonly(request): def export_mega_remarksonly(request):
filename = 'remarques_mega_2016.csv' filename = 'remarques_mega_2017.csv'
response = HttpResponse(content_type='text/csv') response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename=' + filename response['Content-Disposition'] = 'attachment; filename=' + filename
writer = unicodecsv.writer(response) writer = unicodecsv.writer(response)
event = Event.objects.get(title="Mega 2016") event = Event.objects.get(title="MEGA 2017")
commentfield = event.commentfields.get(name="Commentaires") commentfield = event.commentfields.get(name="Commentaire")
for val in commentfield.values.all(): for val in commentfield.values.all():
reg = val.registration reg = val.registration
user = reg.user user = reg.user
profile = user.profile profile = user.profile
bits = [user.username, user.first_name, user.last_name, user.email, bits = [user.username, user.first_name, user.last_name, user.email,
profile.phone, profile.num, profile.comments, val.content] profile.phone, profile.id, profile.comments, val.content]
writer.writerow([six.text_type(bit) for bit in bits]) writer.writerow([str(bit) for bit in bits])
return response return response
@buro_required # @buro_required
def export_mega_bytype(request, type): # def export_mega_bytype(request, type):
types = {"orga-actif": "Orga élève", # types = {"orga-actif": "Orga élève",
"orga-branleur": "Orga étudiant", # "orga-branleur": "Orga étudiant",
"conscrit-eleve": "Conscrit élève", # "conscrit-eleve": "Conscrit élève",
"conscrit-etudiant": "Conscrit étudiant"} # "conscrit-etudiant": "Conscrit étudiant"}
#
if type not in types: # if type not in types:
raise Http404 # raise Http404
#
event = Event.objects.get(title="Mega 2016") # event = Event.objects.get(title="MEGA 2017")
type_option = event.options.get(name="Type") # type_option = event.options.get(name="Type")
participant_type = type_option.choices.get(value=types[type]).id # participant_type = type_option.choices.get(value=types[type]).id
qs = EventRegistration.objects.filter(event=event).filter( # qs = EventRegistration.objects.filter(event=event).filter(
options__id__exact=participant_type) # options__id__exact=participant_type)
return csv_export_mega(type + '_mega_2016.csv', qs) # return csv_export_mega(type + '_mega_2017.csv', qs)
@buro_required @buro_required
def export_mega_orgas(request): def export_mega_orgas(request):
event = Event.objects.get(title="Mega 2016") event = Event.objects.get(title="MEGA 2017")
type_option = event.options.get(name="Conscrit ou orga ?") type_option = event.options.get(name="Conscrit/Orga ?")
participant_type = type_option.choices.get(value="Vieux").id participant_type = type_option.choices.get(value="Orga").id
qs = EventRegistration.objects.filter(event=event).exclude( qs = EventRegistration.objects.filter(event=event).filter(
options__id=participant_type) options__id=participant_type
return csv_export_mega('orgas_mega_2016.csv', qs) )
return csv_export_mega('orgas_mega_2017.csv', qs)
@buro_required @buro_required
def export_mega_participants(request): def export_mega_participants(request):
event = Event.objects.get(title="Mega 2016") event = Event.objects.get(title="MEGA 2017")
type_option = event.options.get(name="Conscrit ou orga ?") type_option = event.options.get(name="Conscrit/Orga ?")
participant_type = type_option.choices.get(value="Conscrit").id participant_type = type_option.choices.get(value="Conscrit").id
qs = EventRegistration.objects.filter(event=event).filter( qs = EventRegistration.objects.filter(event=event).filter(
options__id=participant_type) options__id=participant_type
return csv_export_mega('participants_mega_2016.csv', qs) )
return csv_export_mega('participants_mega_2017.csv', qs)
@buro_required @buro_required
def export_mega(request): def export_mega(request):
event = Event.objects.filter(title="Mega 2016") event = Event.objects.filter(title="MEGA 2017")
qs = EventRegistration.objects.filter(event=event) \ qs = EventRegistration.objects.filter(event=event) \
.order_by("user__username") .order_by("user__username")
return csv_export_mega('all_mega_2016.csv', qs) return csv_export_mega('all_mega_2017.csv', qs)
@buro_required @buro_required
@ -600,3 +600,18 @@ def calendar_ics(request, token):
response = HttpResponse(content=vcal.to_ical()) response = HttpResponse(content=vcal.to_ical())
response['Content-Type'] = "text/calendar" response['Content-Type'] = "text/calendar"
return response return response
class ConfigUpdate(FormView):
form_class = GestioncofConfigForm
template_name = "gestioncof/banner_update.html"
success_url = reverse_lazy("home")
def dispatch(self, request, *args, **kwargs):
if request.user is None or not request.user.is_superuser:
raise Http404
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
form.save()
return super().form_valid(form)

View file

@ -1,3 +1,6 @@
from kfet.routing import channel_routing as kfet_channel_routings from channels.routing import include
channel_routing = kfet_channel_routings
routing = [
include('kfet.routing.routing', path=r'^/ws/k-fet'),
]

1
gestioCOF/settings/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
secret.py

View file

@ -0,0 +1,234 @@
# -*- coding: utf-8 -*-
"""
Django common settings for cof project.
Everything which is supposed to be identical between the production server and
the local development server should be here.
"""
import os
try:
from . import secret
except ImportError:
raise ImportError(
"The secret.py file is missing.\n"
"For a development environment, simply copy secret_example.py"
)
def import_secret(name):
"""
Shorthand for importing a value from the secret module and raising an
informative exception if a secret is missing.
"""
try:
return getattr(secret, name)
except AttributeError:
raise RuntimeError("Secret missing: {}".format(name))
SECRET_KEY = import_secret("SECRET_KEY")
ADMINS = import_secret("ADMINS")
SERVER_EMAIL = import_secret("SERVER_EMAIL")
DBNAME = import_secret("DBNAME")
DBUSER = import_secret("DBUSER")
DBPASSWD = import_secret("DBPASSWD")
REDIS_PASSWD = import_secret("REDIS_PASSWD")
REDIS_DB = import_secret("REDIS_DB")
REDIS_HOST = import_secret("REDIS_HOST")
REDIS_PORT = import_secret("REDIS_PORT")
RECAPTCHA_PUBLIC_KEY = import_secret("RECAPTCHA_PUBLIC_KEY")
RECAPTCHA_PRIVATE_KEY = import_secret("RECAPTCHA_PRIVATE_KEY")
KFETOPEN_TOKEN = import_secret("KFETOPEN_TOKEN")
LDAP_SERVER_URL = import_secret("LDAP_SERVER_URL")
BASE_DIR = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
# Application definition
INSTALLED_APPS = [
'gestioncof',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.messages',
'django.contrib.staticfiles',
'grappelli',
'django.contrib.admin',
'django.contrib.admindocs',
'bda',
'autocomplete_light',
'captcha',
'django_cas_ng',
'bootstrapform',
'kfet',
'kfet.open',
'channels',
'widget_tweaks',
'custommail',
'djconfig',
'wagtail.wagtailforms',
'wagtail.wagtailredirects',
'wagtail.wagtailembeds',
'wagtail.wagtailsites',
'wagtail.wagtailusers',
'wagtail.wagtailsnippets',
'wagtail.wagtaildocs',
'wagtail.wagtailimages',
'wagtail.wagtailsearch',
'wagtail.wagtailadmin',
'wagtail.wagtailcore',
'wagtail.contrib.modeladmin',
'wagtailmenus',
'modelcluster',
'taggit',
'kfet.auth',
'kfet.cms',
]
MIDDLEWARE_CLASSES = [
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'kfet.auth.middleware.TemporaryAuthMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware',
'djconfig.middleware.DjConfigMiddleware',
'wagtail.wagtailcore.middleware.SiteMiddleware',
'wagtail.wagtailredirects.middleware.RedirectMiddleware',
]
ROOT_URLCONF = 'cof.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'django.core.context_processors.i18n',
'django.core.context_processors.media',
'django.core.context_processors.static',
'wagtailmenus.context_processors.wagtailmenus',
'djconfig.context_processors.config',
'gestioncof.shared.context_processor',
'kfet.auth.context_processors.temporary_auth',
'kfet.context_processors.config',
],
},
},
]
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': DBNAME,
'USER': DBUSER,
'PASSWORD': DBPASSWD,
'HOST': os.environ.get('DBHOST', 'localhost'),
}
}
# Internationalization
# https://docs.djangoproject.com/en/1.8/topics/i18n/
LANGUAGE_CODE = 'fr-fr'
TIME_ZONE = 'Europe/Paris'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Various additional settings
SITE_ID = 1
GRAPPELLI_ADMIN_HEADLINE = "GestioCOF"
GRAPPELLI_ADMIN_TITLE = "<a href=\"/\">GestioCOF</a>"
MAIL_DATA = {
'petits_cours': {
'FROM': "Le COF <cof@ens.fr>",
'BCC': "archivescof@gmail.com",
'REPLYTO': "cof@ens.fr"},
'rappels': {
'FROM': 'Le BdA <bda@ens.fr>',
'REPLYTO': 'Le BdA <bda@ens.fr>'},
'revente': {
'FROM': 'BdA-Revente <bda-revente@ens.fr>',
'REPLYTO': 'BdA-Revente <bda-revente@ens.fr>'},
}
LOGIN_URL = "cof-login"
LOGIN_REDIRECT_URL = "home"
CAS_SERVER_URL = 'https://cas.eleves.ens.fr/'
CAS_VERSION = '3'
CAS_LOGIN_MSG = None
CAS_IGNORE_REFERER = True
CAS_REDIRECT_URL = '/'
CAS_EMAIL_FORMAT = "%s@clipper.ens.fr"
AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend',
'gestioncof.shared.COFCASBackend',
'kfet.auth.backends.GenericBackend',
)
RECAPTCHA_USE_SSL = True
# Cache settings
CACHES = {
'default': {
'BACKEND': 'redis_cache.RedisCache',
'LOCATION': 'redis://:{passwd}@{host}:{port}/db'
.format(passwd=REDIS_PASSWD, host=REDIS_HOST,
port=REDIS_PORT, db=REDIS_DB),
}
}
# Channels settings
CHANNEL_LAYERS = {
"default": {
"BACKEND": "asgi_redis.RedisChannelLayer",
"CONFIG": {
"hosts": [(
"redis://:{passwd}@{host}:{port}/{db}"
.format(passwd=REDIS_PASSWD, host=REDIS_HOST,
port=REDIS_PORT, db=REDIS_DB)
)],
},
"ROUTING": "cof.routing.routing",
}
}
FORMAT_MODULE_PATH = 'cof.locale'
# Wagtail settings
WAGTAIL_SITE_NAME = 'GestioCOF'
WAGTAIL_ENABLE_UPDATE_CHECK = False
TAGGIT_CASE_INSENSITIVE = True

46
gestioCOF/settings/dev.py Normal file
View file

@ -0,0 +1,46 @@
"""
Django development settings for the cof project.
The settings that are not listed here are imported from .common
"""
from .common import * # NOQA
from .common import INSTALLED_APPS, MIDDLEWARE_CLASSES
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
DEBUG = True
# ---
# Apache static/media config
# ---
STATIC_URL = '/static/'
STATIC_ROOT = '/srv/gestiocof/static/'
MEDIA_ROOT = '/srv/gestiocof/media/'
MEDIA_URL = '/media/'
# ---
# Debug tool bar
# ---
def show_toolbar(request):
"""
On ne veut pas la vérification de INTERNAL_IPS faite par la debug-toolbar
car cela interfère avec l'utilisation de Vagrant. En effet, l'adresse de la
machine physique n'est pas forcément connue, et peut difficilement être
mise dans les INTERNAL_IPS.
"""
return DEBUG
INSTALLED_APPS += ["debug_toolbar", "debug_panel"]
MIDDLEWARE_CLASSES = (
["debug_panel.middleware.DebugPanelMiddleware"]
+ MIDDLEWARE_CLASSES
)
DEBUG_TOOLBAR_CONFIG = {
'SHOW_TOOLBAR_CALLBACK': show_toolbar,
}

View file

@ -0,0 +1,36 @@
"""
Django local settings for the cof project.
The settings that are not listed here are imported from .common
"""
import os
from .dev import * # NOQA
from .dev import BASE_DIR
# Use sqlite for local development
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(BASE_DIR, "db.sqlite3")
}
}
# Use the default cache backend for local development
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache"
}
}
# Use the default in memory asgi backend for local development
CHANNEL_LAYERS = {
"default": {
"BACKEND": "asgiref.inmemory.ChannelLayer",
"ROUTING": "cof.routing.routing",
}
}
# No need to run collectstatic -> unset STATIC_ROOT
STATIC_ROOT = None

View file

@ -0,0 +1,30 @@
"""
Django development settings for the cof project.
The settings that are not listed here are imported from .common
"""
import os
from .common import * # NOQA
from .common import BASE_DIR
DEBUG = False
ALLOWED_HOSTS = [
"cof.ens.fr",
"www.cof.ens.fr",
"dev.cof.ens.fr"
]
STATIC_ROOT = os.path.join(
os.path.dirname(os.path.dirname(BASE_DIR)),
"public",
"gestion",
"static",
)
STATIC_URL = "/gestion/static/"
MEDIA_ROOT = os.path.join(os.path.dirname(BASE_DIR), "media")
MEDIA_URL = "/gestion/media/"

View file

@ -0,0 +1,20 @@
SECRET_KEY = 'q()(zn4m63i%5cp4)f+ww4-28_w+ly3q9=6imw2ciu&_(5_4ah'
ADMINS = None
SERVER_EMAIL = "root@vagrant"
DBUSER = "cof_gestion"
DBNAME = "cof_gestion"
DBPASSWD = "4KZt3nGPLVeWSvtBZPSM3fSzXpzEU4"
REDIS_PASSWD = "dummy"
REDIS_PORT = 6379
REDIS_DB = 0
REDIS_HOST = "127.0.0.1"
RECAPTCHA_PUBLIC_KEY = "DUMMY"
RECAPTCHA_PRIVATE_KEY = "DUMMY"
EMAIL_HOST = None
KFETOPEN_TOKEN = "plop"
LDAP_SERVER_URL = None

View file

@ -1,194 +0,0 @@
# -*- coding: utf-8 -*-
"""
Django settings for cof project.
For more information on this file, see
https://docs.djangoproject.com/en/1.8/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.8/ref/settings/
"""
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'q()(zn4m63i%5cp4)f+ww4-28_w+ly3q9=6imw2ciu&_(5_4ah'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
# Application definition
INSTALLED_APPS = (
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.admin',
'django.contrib.admindocs',
'autocomplete_light',
'captcha',
'django_cas_ng',
'debug_toolbar',
'bootstrapform',
'channels',
'widget_tweaks',
'custommail',
'nested_admin',
'bda.apps.BdAConfig',
'bds.apps.BDSConfig',
'cof.apps.COFConfig',
'gestion.apps.GestionConfig',
'kfet.apps.KFetConfig',
)
MIDDLEWARE = [
'debug_toolbar.middleware.DebugToolbarMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'kfet.middleware.kfet_auth_middleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware',
]
ROOT_URLCONF = 'gestioCOF.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'gestion.context_processors.context_processor',
'kfet.context_processors.auth',
],
},
},
]
# Database
# https://docs.djangoproject.com/en/1.8/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': os.environ['DBNAME'],
'USER': os.environ['DBUSER'],
'PASSWORD': os.environ['DBPASSWD'],
'HOST': os.environ.get('DBHOST', 'localhost'),
}
}
# Internationalization
# https://docs.djangoproject.com/en/1.8/topics/i18n/
LANGUAGE_CODE = 'fr-fr'
TIME_ZONE = 'Europe/Paris'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.8/howto/static-files/
STATIC_URL = '/static/'
STATIC_ROOT = '/var/www/static/'
# Media upload (through ImageField, SiteField)
# https://docs.djangoproject.com/en/1.9/ref/models/fields/
MEDIA_ROOT = os.path.join(BASE_DIR, 'media/')
MEDIA_URL = '/media/'
# Various additional settings
SITE_ID = 1
MAIL_DATA = {
'petits_cours': {
'FROM': "Le COF <cof@ens.fr>",
'BCC': "archivescof@gmail.com",
'REPLYTO': "cof@ens.fr"},
'rappels': {
'FROM': 'Le BdA <bda@ens.fr>',
'REPLYTO': 'Le BdA <bda@ens.fr>'},
'revente': {
'FROM': 'BdA-Revente <bda-revente@ens.fr>',
'REPLYTO': 'BdA-Revente <bda-revente@ens.fr>'},
}
LOGIN_URL = "gestion:login"
LOGIN_REDIRECT_URL = "home"
CAS_SERVER_URL = 'https://cas.eleves.ens.fr/'
CAS_IGNORE_REFERER = True
CAS_REDIRECT_URL = '/'
CAS_EMAIL_FORMAT = "%s@clipper.ens.fr"
AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend',
'gestion.backends.COFCASBackend',
'kfet.backends.GenericTeamBackend',
)
# LDAP_SERVER_URL = 'ldaps://ldap.spi.ens.fr:636'
# EMAIL_HOST="nef.ens.fr"
RECAPTCHA_PUBLIC_KEY = "DUMMY"
RECAPTCHA_PRIVATE_KEY = "DUMMY"
RECAPTCHA_USE_SSL = True
# Channels settings
CHANNEL_LAYERS = {
"default": {
"BACKEND": "asgi_redis.RedisChannelLayer",
"CONFIG": {
"hosts": [(os.environ.get("REDIS_HOST", "localhost"), 6379)],
},
"ROUTING": "gestioCOF.routing.channel_routing",
}
}
def show_toolbar(request):
"""
On ne veut pas la vérification de INTERNAL_IPS faite par la debug-toolbar
car cela interfère avec l'utilisation de Vagrant. En effet, l'adresse de la
machine physique n'est pas forcément connue, et peut difficilement être
mise dans les INTERNAL_IPS.
"""
if not DEBUG:
return False
if request.is_ajax():
return False
return True
DEBUG_TOOLBAR_CONFIG = {
'SHOW_TOOLBAR_CALLBACK': show_toolbar,
}
FORMAT_MODULE_PATH = 'gestioCOF.locale'

View file

@ -1,13 +1,12 @@
# -*- coding: utf-8 -*- import autocomplete_light
"""
Fichier principal de configuration des urls du projet GestioCOF
"""
import gestion.urls import gestion.urls
import kfet.urls import kfet.urls
import bda.urls import bda.urls
from wagtail.wagtailadmin import urls as wagtailadmin_urls
from wagtail.wagtailcore import urls as wagtail_urls
from wagtail.wagtaildocs import urls as wagtaildocs_urls
from django.conf import settings from django.conf import settings
from django.conf.urls import include, url from django.conf.urls import include, url
from django.conf.urls.static import static from django.conf.urls.static import static
@ -21,6 +20,7 @@ from cof.autocomplete import autocomplete
from gestion import views as gestion_views from gestion import views as gestion_views
autocomplete_light.autodiscover()
admin.autodiscover() admin.autodiscover()
urlpatterns = [ urlpatterns = [
@ -82,6 +82,11 @@ urlpatterns = [
name="liste_bdarevente"), name="liste_bdarevente"),
url(r'^k-fet/', include(kfet.urls)), url(r'^k-fet/', include(kfet.urls)),
url(r"^_nested_admin/", include("nested_admin.urls")), url(r"^_nested_admin/", include("nested_admin.urls")),
# wagtail
url(r'^cms/', include(wagtailadmin_urls)),
url(r'^documents/', include(wagtaildocs_urls)),
# djconfig
url(r"^config", cof_views.ConfigUpdate.as_view()),
] ]
if 'debug_toolbar' in settings.INSTALLED_APPS: if 'debug_toolbar' in settings.INSTALLED_APPS:
@ -90,7 +95,13 @@ if 'debug_toolbar' in settings.INSTALLED_APPS:
url(r'^__debug__/', include(debug_toolbar.urls)), url(r'^__debug__/', include(debug_toolbar.urls)),
] ]
if settings.DEBUG:
# Si on est en production, MEDIA_ROOT est servi par Apache. # Si on est en production, MEDIA_ROOT est servi par Apache.
# Il faut dire à Django de servir MEDIA_ROOT lui-même en développement. # Il faut dire à Django de servir MEDIA_ROOT lui-même en développement.
urlpatterns += static(settings.MEDIA_URL, urlpatterns += static(settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT) document_root=settings.MEDIA_ROOT)
# Wagtail for uncatched
urlpatterns += [
url(r'', include(wagtail_urls)),
]

View file

@ -1,57 +1,30 @@
# -*- coding: utf-8 -*- from django_cas_ng.backends import CASBackend
from django.conf import settings from django.conf import settings
from django_cas_ng.backends import CASBackend from django.contrib.sites.models import Site
from django_cas_ng.utils import get_cas_client
from django.contrib.auth import get_user_model
from gestion.models import Profile
User = get_user_model()
class COFCASBackend(CASBackend): class COFCASBackend(CASBackend):
def authenticate_cas(self, ticket, service, request):
"""Verifies CAS ticket and gets or creates User object"""
client = get_cas_client(service_url=service)
username, attributes, _ = client.verify_ticket(ticket)
if attributes:
request.session['attributes'] = attributes
if not username:
return None
def clean_username(self, username):
# Le CAS de l'ENS accepte les logins avec des espaces au début # Le CAS de l'ENS accepte les logins avec des espaces au début
# et à la fin, ainsi quavec une casse variable. On normalise pour # et à la fin, ainsi quavec une casse variable. On normalise pour
# éviter les doublons. # éviter les doublons.
username = username.strip().lower() return username.strip().lower()
profiles = Profile.objects.filter(login_clipper=username) def configure_user(self, user):
if len(profiles) > 0: clipper = user.username
# XXX. We have to deal with multiple profiles, this should not user.profile.login_clipper = clipper
# happen user.profile.save()
# profile = profiles.order_by('-is_cof')[0] user.email = settings.CAS_EMAIL_FORMAT % clipper
profile = profiles.first() user.save()
user = profile.user
return user
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
# user will have an "unusable" password
user = User.objects.create_user(username, '')
user.save()
return user return user
def authenticate(self, ticket, service, request):
"""Authenticates CAS ticket and retrieves user data""" def context_processor(request):
user = self.authenticate_cas(ticket, service, request) '''Append extra data to the context of the given request'''
if user is None: data = {
return user "user": request.user,
profile = user.profile "site": Site.objects.get_current(),
if not profile.login_clipper: }
profile.login_clipper = user.username return data
profile.save()
if not user.email:
user.email = settings.CAS_EMAIL_FORMAT % profile.login_clipper
user.save()
return user

View file

@ -6,4 +6,9 @@ class KFetConfig(AppConfig):
verbose_name = "Application K-Fêt" verbose_name = "Application K-Fêt"
def ready(self): def ready(self):
import kfet.signals self.register_config()
def register_config(self):
import djconfig
from kfet.forms import KFetConfigForm
djconfig.register(KFetConfigForm)

4
kfet/auth/__init__.py Normal file
View file

@ -0,0 +1,4 @@
default_app_config = 'kfet.auth.apps.KFetAuthConfig'
KFET_GENERIC_USERNAME = 'kfet_genericteam'
KFET_GENERIC_TRIGRAMME = 'GNR'

14
kfet/auth/apps.py Normal file
View file

@ -0,0 +1,14 @@
from django.apps import AppConfig
from django.db.models.signals import post_migrate
from django.utils.translation import ugettext_lazy as _
class KFetAuthConfig(AppConfig):
name = 'kfet.auth'
label = 'kfetauth'
verbose_name = _("K-Fêt - Authentification et Autorisation")
def ready(self):
from . import signals # noqa
from .utils import setup_kfet_generic_user
post_migrate.connect(setup_kfet_generic_user, sender=self)

43
kfet/auth/backends.py Normal file
View file

@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
from django.contrib.auth import get_user_model
from kfet.models import Account, GenericTeamToken
from .utils import get_kfet_generic_user
User = get_user_model()
class BaseKFetBackend:
def get_user(self, user_id):
"""
Add extra select related up to Account.
"""
try:
return (
User.objects
.select_related('profile__account_kfet')
.get(pk=user_id)
)
except User.DoesNotExist:
return None
class AccountBackend(BaseKFetBackend):
def authenticate(self, request, kfet_password=None):
try:
return Account.objects.get_by_password(kfet_password).user
except Account.DoesNotExist:
return None
class GenericBackend(BaseKFetBackend):
def authenticate(self, request, kfet_token=None):
try:
team_token = GenericTeamToken.objects.get(token=kfet_token)
except GenericTeamToken.DoesNotExist:
return
# No need to keep the token.
team_token.delete()
return get_kfet_generic_user()

View file

@ -0,0 +1,10 @@
from django.contrib.auth.context_processors import PermWrapper
def temporary_auth(request):
if hasattr(request, 'real_user'):
return {
'user': request.real_user,
'perms': PermWrapper(request.real_user),
}
return {}

20
kfet/auth/fields.py Normal file
View file

@ -0,0 +1,20 @@
from django import forms
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.forms import widgets
class KFetPermissionsField(forms.ModelMultipleChoiceField):
def __init__(self, *args, **kwargs):
queryset = Permission.objects.filter(
content_type__in=ContentType.objects.filter(app_label="kfet"),
)
super().__init__(
queryset=queryset,
widget=widgets.CheckboxSelectMultiple,
*args, **kwargs
)
def label_from_instance(self, obj):
return obj.name

48
kfet/auth/forms.py Normal file
View file

@ -0,0 +1,48 @@
from django import forms
from django.contrib.auth.models import Group, User
from .fields import KFetPermissionsField
class GroupForm(forms.ModelForm):
permissions = KFetPermissionsField()
def clean_name(self):
name = self.cleaned_data['name']
return 'K-Fêt %s' % name
def clean_permissions(self):
kfet_perms = self.cleaned_data['permissions']
# TODO: With Django >=1.11, the QuerySet method 'difference' can be
# used.
# other_groups = self.instance.permissions.difference(
# self.fields['permissions'].queryset
# )
if self.instance.pk is None:
return kfet_perms
other_perms = self.instance.permissions.exclude(
pk__in=[p.pk for p in self.fields['permissions'].queryset],
)
return list(kfet_perms) + list(other_perms)
class Meta:
model = Group
fields = ['name', 'permissions']
class UserGroupForm(forms.ModelForm):
groups = forms.ModelMultipleChoiceField(
Group.objects.filter(name__icontains='K-Fêt'),
label='Statut équipe',
required=False)
def clean_groups(self):
kfet_groups = self.cleaned_data.get('groups')
if self.instance.pk is None:
return kfet_groups
other_groups = self.instance.groups.exclude(name__icontains='K-Fêt')
return list(kfet_groups) + list(other_groups)
class Meta:
model = User
fields = ['groups']

38
kfet/auth/middleware.py Normal file
View file

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
from django.contrib.auth import get_user_model
from .backends import AccountBackend
User = get_user_model()
class TemporaryAuthMiddleware:
"""Authenticate another user for this request if AccountBackend succeeds.
By the way, if a user is authenticated, we refresh its from db to add
values from CofProfile and Account of this user.
"""
def process_request(self, request):
if request.user.is_authenticated():
# avoid multiple db accesses in views and templates
request.user = (
User.objects
.select_related('profile__account_kfet')
.get(pk=request.user.pk)
)
temp_request_user = AccountBackend().authenticate(
request,
kfet_password=self.get_kfet_password(request),
)
if temp_request_user:
request.real_user = request.user
request.user = temp_request_user
def get_kfet_password(self, request):
return (
request.META.get('HTTP_KFETPASSWORD') or
request.POST.get('KFETPASSWORD')
)

View file

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('auth', '0006_require_contenttypes_0002'),
# Following dependency allows using Account model to set up the kfet
# generic user in post_migrate receiver.
('kfet', '0058_delete_genericteamtoken'),
]
operations = [
migrations.CreateModel(
name='GenericTeamToken',
fields=[
('id', models.AutoField(verbose_name='ID', auto_created=True, serialize=False, primary_key=True)),
('token', models.CharField(unique=True, max_length=50)),
],
),
]

View file

17
kfet/auth/models.py Normal file
View file

@ -0,0 +1,17 @@
from django.db import models
from django.utils.crypto import get_random_string
class GenericTeamTokenManager(models.Manager):
def create_token(self):
token = get_random_string(50)
while self.filter(token=token).exists():
token = get_random_string(50)
return self.create(token=token)
class GenericTeamToken(models.Model):
token = models.CharField(max_length=50, unique=True)
objects = GenericTeamTokenManager()

40
kfet/auth/signals.py Normal file
View file

@ -0,0 +1,40 @@
from django.contrib import messages
from django.contrib.auth.signals import user_logged_in
from django.core.urlresolvers import reverse
from django.dispatch import receiver
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext as _
from .utils import get_kfet_generic_user
@receiver(user_logged_in)
def suggest_auth_generic(sender, request, user, **kwargs):
"""
Suggest logged in user to continue as the kfet generic user.
Message is only added if the following conditions are met:
- the next page (where user is going to be redirected due to successful
authentication) is related to kfet, i.e. 'k-fet' is in its url.
- logged in user is a kfet staff member (except the generic user).
"""
# Filter against the next page.
if not(hasattr(request, 'GET') and 'next' in request.GET):
return
next_page = request.GET['next']
generic_url = reverse('kfet.login.generic')
if not('k-fet' in next_page and not next_page.startswith(generic_url)):
return
# Filter against the logged in user.
if not(user.has_perm('kfet.is_team') and user != get_kfet_generic_user()):
return
# Seems legit to add message.
text = _("K-Fêt — Ouvrir une session partagée ?")
messages.info(request, mark_safe(
'<a href="#" data-url="{}" onclick="submit_url(this)">{}</a>'
.format(generic_url, text)
))

367
kfet/auth/tests.py Normal file
View file

@ -0,0 +1,367 @@
# -*- coding: utf-8 -*-
from unittest import mock
from django.core import signing
from django.core.urlresolvers import reverse
from django.contrib.auth.models import AnonymousUser, Group, Permission, User
from django.test import RequestFactory, TestCase
from kfet.forms import UserGroupForm
from kfet.models import Account
from . import KFET_GENERIC_TRIGRAMME, KFET_GENERIC_USERNAME
from .backends import AccountBackend, GenericBackend
from .middleware import TemporaryAuthMiddleware
from .models import GenericTeamToken
from .utils import get_kfet_generic_user
from .views import GenericLoginView
##
# Forms
##
class UserGroupFormTests(TestCase):
"""Test suite for UserGroupForm."""
def setUp(self):
# create user
self.user = User.objects.create(username="foo", password="foo")
# create some K-Fêt groups
prefix_name = "K-Fêt "
names = ["Group 1", "Group 2", "Group 3"]
self.kfet_groups = [
Group.objects.create(name=prefix_name+name)
for name in names
]
# create a non-K-Fêt group
self.other_group = Group.objects.create(name="Other group")
def test_choices(self):
"""Only K-Fêt groups are selectable."""
form = UserGroupForm(instance=self.user)
groups_field = form.fields['groups']
self.assertQuerysetEqual(
groups_field.queryset,
[repr(g) for g in self.kfet_groups],
ordered=False,
)
def test_keep_others(self):
"""User stays in its non-K-Fêt groups."""
user = self.user
# add user to a non-K-Fêt group
user.groups.add(self.other_group)
# add user to some K-Fêt groups through UserGroupForm
data = {
'groups': [group.pk for group in self.kfet_groups],
}
form = UserGroupForm(data, instance=user)
form.is_valid()
form.save()
self.assertQuerysetEqual(
user.groups.all(),
[repr(g) for g in [self.other_group] + self.kfet_groups],
ordered=False,
)
class KFetGenericUserTests(TestCase):
def test_exists(self):
"""
The account is set up when app is ready, so it should exist.
"""
generic = Account.objects.get_generic()
self.assertEqual(generic.trigramme, KFET_GENERIC_TRIGRAMME)
self.assertEqual(generic.user.username, KFET_GENERIC_USERNAME)
self.assertEqual(get_kfet_generic_user(), generic.user)
##
# Backends
##
class AccountBackendTests(TestCase):
def setUp(self):
self.request = RequestFactory().get('/')
def test_valid(self):
acc = Account(trigramme='000')
acc.change_pwd('valid')
acc.save({'username': 'user'})
auth = AccountBackend().authenticate(
self.request, kfet_password='valid')
self.assertEqual(auth, acc.user)
def test_invalid(self):
auth = AccountBackend().authenticate(
self.request, kfet_password='invalid')
self.assertIsNone(auth)
class GenericBackendTests(TestCase):
def setUp(self):
self.request = RequestFactory().get('/')
def test_valid(self):
token = GenericTeamToken.objects.create_token()
auth = GenericBackend().authenticate(
self.request, kfet_token=token.token)
self.assertEqual(auth, get_kfet_generic_user())
self.assertEqual(GenericTeamToken.objects.all().count(), 0)
def test_invalid(self):
auth = GenericBackend().authenticate(
self.request, kfet_token='invalid')
self.assertIsNone(auth)
##
# Views
##
class GenericLoginViewTests(TestCase):
def setUp(self):
patcher_messages = mock.patch('gestioncof.signals.messages')
patcher_messages.start()
self.addCleanup(patcher_messages.stop)
user_acc = Account(trigramme='000')
user_acc.save({'username': 'user'})
self.user = user_acc.user
self.user.set_password('user')
self.user.save()
team_acc = Account(trigramme='100')
team_acc.save({'username': 'team'})
self.team = team_acc.user
self.team.set_password('team')
self.team.save()
self.team.user_permissions.add(
Permission.objects.get(
content_type__app_label='kfet', codename='is_team'),
)
self.url = reverse('kfet.login.generic')
self.generic_user = get_kfet_generic_user()
def test_url(self):
self.assertEqual(self.url, '/k-fet/login/generic')
def test_notoken_get(self):
"""
Send confirmation for user to emit POST request, instead of GET.
"""
self.client.login(username='team', password='team')
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
self.assertTemplateUsed(r, 'kfet/confirm_form.html')
def test_notoken_post(self):
"""
POST request without token in COOKIES sets a token and redirects to
logout url.
"""
self.client.login(username='team', password='team')
r = self.client.post(self.url)
self.assertRedirects(
r, '/logout?next={}'.format(self.url),
fetch_redirect_response=False,
)
def test_notoken_not_team(self):
"""
Logged in user must be a team user to initiate login as generic user.
"""
self.client.login(username='user', password='user')
# With GET.
r = self.client.get(self.url)
self.assertRedirects(
r, '/login?next={}'.format(self.url),
fetch_redirect_response=False,
)
# Also with POST.
r = self.client.post(self.url)
self.assertRedirects(
r, '/login?next={}'.format(self.url),
fetch_redirect_response=False,
)
def _set_signed_cookie(self, client, key, value):
signed_value = signing.get_cookie_signer(salt=key).sign(value)
client.cookies.load({key: signed_value})
def _is_cookie_deleted(self, client, key):
try:
self.assertNotIn(key, client.cookies)
except AssertionError:
try:
cookie = client.cookies[key]
# It also can be emptied.
self.assertEqual(cookie.value, '')
self.assertEqual(
cookie['expires'], 'Thu, 01-Jan-1970 00:00:00 GMT')
self.assertEqual(cookie['max-age'], 0)
except AssertionError:
raise AssertionError("The cookie '%s' still exists." % key)
def test_withtoken_valid(self):
"""
The kfet generic user is logged in.
"""
token = GenericTeamToken.objects.create(token='valid')
self._set_signed_cookie(
self.client, GenericLoginView.TOKEN_COOKIE_NAME, 'valid')
r = self.client.get(self.url)
self.assertRedirects(r, reverse('kfet.kpsul'))
self.assertEqual(r.wsgi_request.user, self.generic_user)
self._is_cookie_deleted(
self.client, GenericLoginView.TOKEN_COOKIE_NAME)
with self.assertRaises(GenericTeamToken.DoesNotExist):
token.refresh_from_db()
def test_withtoken_invalid(self):
"""
If token is invalid, delete it and try again.
"""
self._set_signed_cookie(
self.client, GenericLoginView.TOKEN_COOKIE_NAME, 'invalid')
r = self.client.get(self.url)
self.assertRedirects(r, self.url, fetch_redirect_response=False)
self.assertEqual(r.wsgi_request.user, AnonymousUser())
self._is_cookie_deleted(
self.client, GenericLoginView.TOKEN_COOKIE_NAME)
def test_flow_ok(self):
"""
A team user is logged in as the kfet generic user.
"""
self.client.login(username='team', password='team')
next_url = '/k-fet/'
r = self.client.post(
'{}?next={}'.format(self.url, next_url), follow=True)
self.assertEqual(r.wsgi_request.user, self.generic_user)
self.assertEqual(r.wsgi_request.path, '/k-fet/')
##
# Temporary authentication
#
# Includes:
# - TemporaryAuthMiddleware
# - temporary_auth context processor
##
class TemporaryAuthTests(TestCase):
def setUp(self):
patcher_messages = mock.patch('gestioncof.signals.messages')
patcher_messages.start()
self.addCleanup(patcher_messages.stop)
self.factory = RequestFactory()
user1_acc = Account(trigramme='000')
user1_acc.change_pwd('kfet_user1')
user1_acc.save({'username': 'user1'})
self.user1 = user1_acc.user
self.user1.set_password('user1')
self.user1.save()
user2_acc = Account(trigramme='100')
user2_acc.change_pwd('kfet_user2')
user2_acc.save({'username': 'user2'})
self.user2 = user2_acc.user
self.user2.set_password('user2')
self.user2.save()
self.perm = Permission.objects.get(
content_type__app_label='kfet', codename='is_team')
self.user2.user_permissions.add(self.perm)
def test_middleware_header(self):
"""
A user can be authenticated if ``HTTP_KFETPASSWORD`` header of a
request contains a valid kfet password.
"""
request = self.factory.get('/', HTTP_KFETPASSWORD='kfet_user2')
request.user = self.user1
TemporaryAuthMiddleware().process_request(request)
self.assertEqual(request.user, self.user2)
self.assertEqual(request.real_user, self.user1)
def test_middleware_post(self):
"""
A user can be authenticated if ``KFETPASSWORD`` of POST data contains
a valid kfet password.
"""
request = self.factory.post('/', {'KFETPASSWORD': 'kfet_user2'})
request.user = self.user1
TemporaryAuthMiddleware().process_request(request)
self.assertEqual(request.user, self.user2)
self.assertEqual(request.real_user, self.user1)
def test_middleware_invalid(self):
"""
The given password must be a password of an Account.
"""
request = self.factory.post('/', {'KFETPASSWORD': 'invalid'})
request.user = self.user1
TemporaryAuthMiddleware().process_request(request)
self.assertEqual(request.user, self.user1)
self.assertFalse(hasattr(request, 'real_user'))
def test_context_processor(self):
"""
Context variables give the real authenticated user and his permissions.
"""
self.client.login(username='user1', password='user1')
r = self.client.get('/k-fet/accounts/', HTTP_KFETPASSWORD='kfet_user2')
self.assertEqual(r.context['user'], self.user1)
self.assertNotIn('kfet.is_team', r.context['perms'])
def test_auth_not_persistent(self):
"""
The authentication is temporary, i.e. for one request.
"""
self.client.login(username='user1', password='user1')
r1 = self.client.get(
'/k-fet/accounts/', HTTP_KFETPASSWORD='kfet_user2')
self.assertEqual(r1.wsgi_request.user, self.user2)
r2 = self.client.get('/k-fet/accounts/')
self.assertEqual(r2.wsgi_request.user, self.user1)

34
kfet/auth/utils.py Normal file
View file

@ -0,0 +1,34 @@
import hashlib
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from kfet.models import Account
User = get_user_model()
def get_kfet_generic_user():
"""
Return the user related to the kfet generic account.
"""
return Account.objects.get_generic().user
def setup_kfet_generic_user(**kwargs):
"""
First steps of setup of the kfet generic user are done in a migration, as
it is more robust against database schema changes.
Following steps cannot be done from migration.
"""
generic = get_kfet_generic_user()
generic.user_permissions.add(
Permission.objects.get(
content_type__app_label='kfet',
codename='is_team',
)
)
def hash_password(password):
return hashlib.sha256(password.encode('utf-8')).hexdigest()

136
kfet/auth/views.py Normal file
View file

@ -0,0 +1,136 @@
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.contrib.auth import authenticate, login
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.models import Group, User
from django.contrib.auth.views import redirect_to_login
from django.core.urlresolvers import reverse, reverse_lazy
from django.db.models import Prefetch
from django.http import QueryDict
from django.shortcuts import redirect, render
from django.utils.decorators import method_decorator
from django.utils.translation import ugettext_lazy as _
from django.views.generic import View
from django.views.decorators.http import require_http_methods
from django.views.generic.edit import CreateView, UpdateView
from .forms import GroupForm
from .models import GenericTeamToken
class GenericLoginView(View):
"""
View to authenticate as kfet generic user.
It is a 2-step view. First, issue a token if user is a team member and send
him to the logout view (for proper disconnect) with callback url to here.
Then authenticate the token to log in as the kfet generic user.
Token is stored in COOKIES to avoid share it with the authentication
provider, which can be external. Session is unusable as it will be cleared
on logout.
"""
TOKEN_COOKIE_NAME = 'kfettoken'
@method_decorator(require_http_methods(['GET', 'POST']))
def dispatch(self, request, *args, **kwargs):
token = request.get_signed_cookie(self.TOKEN_COOKIE_NAME, None)
if not token:
if not request.user.has_perm('kfet.is_team'):
return redirect_to_login(request.get_full_path())
if request.method == 'POST':
# Step 1: set token and logout user.
return self.prepare_auth()
else:
# GET request should not change server/client states. Send a
# confirmation template to emit a POST request.
return render(request, 'kfet/confirm_form.html', {
'title': _("Ouvrir une session partagée"),
'text': _(
"Êtes-vous sûr·e de vouloir ouvrir une session "
"partagée ?"
),
})
else:
# Step 2: validate token.
return self.validate_auth(token)
def prepare_auth(self):
# Issue token.
token = GenericTeamToken.objects.create_token()
# Prepare callback of logout.
here_url = reverse(login_generic)
if 'next' in self.request.GET:
# Keep given next page.
here_qd = QueryDict(mutable=True)
here_qd['next'] = self.request.GET['next']
here_url += '?{}'.format(here_qd.urlencode())
logout_url = reverse('cof-logout')
logout_qd = QueryDict(mutable=True)
logout_qd['next'] = here_url
logout_url += '?{}'.format(logout_qd.urlencode(safe='/'))
resp = redirect(logout_url)
resp.set_signed_cookie(
self.TOKEN_COOKIE_NAME, token.token, httponly=True)
return resp
def validate_auth(self, token):
# Authenticate with GenericBackend.
user = authenticate(request=self.request, kfet_token=token)
if user:
# Log in generic user.
login(self.request, user)
messages.success(self.request, _(
"K-Fêt — Ouverture d'une session partagée."
))
resp = redirect(self.get_next_url())
else:
# Try again.
resp = redirect(self.request.get_full_path())
# Prevents blocking due to an invalid COOKIE.
resp.delete_cookie(self.TOKEN_COOKIE_NAME)
return resp
def get_next_url(self):
return self.request.GET.get('next', reverse('kfet.kpsul'))
login_generic = GenericLoginView.as_view()
@permission_required('kfet.manage_perms')
def account_group(request):
user_pre = Prefetch(
'user_set',
queryset=User.objects.select_related('profile__account_kfet'),
)
groups = (
Group.objects
.filter(name__icontains='K-Fêt')
.prefetch_related('permissions', user_pre)
)
return render(request, 'kfet/account_group.html', {
'groups': groups,
})
class AccountGroupCreate(SuccessMessageMixin, CreateView):
model = Group
template_name = 'kfet/account_group_form.html'
form_class = GroupForm
success_message = 'Nouveau groupe : %(name)s'
success_url = reverse_lazy('kfet.account.group')
class AccountGroupUpdate(SuccessMessageMixin, UpdateView):
queryset = Group.objects.filter(name__icontains='K-Fêt')
template_name = 'kfet/account_group_form.html'
form_class = GroupForm
success_message = 'Groupe modifié : %(name)s'
success_url = reverse_lazy('kfet.account.group')

View file

@ -1,19 +1,21 @@
# -*- coding: utf-8 -*-
from ldap3 import Connection from ldap3 import Connection
from django.shortcuts import render
from django.http import Http404
from django.db.models import Q
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
from django.db.models import Q
from django.http import Http404
from django.shortcuts import render
from kfet.decorators import teamkfet_required from .decorators import teamkfet_required
from kfet.models import Account from .models import Account
class Clipper(object): class Clipper(object):
def __init__(self, clipper, fullname): def __init__(self, clipper, fullname):
if fullname is None:
fullname = ""
assert isinstance(clipper, str)
assert isinstance(fullname, str)
self.clipper = clipper self.clipper = clipper
self.fullname = fullname self.fullname = fullname
@ -37,7 +39,6 @@ def account_create(request):
queries['kfet'] = Account.objects queries['kfet'] = Account.objects
queries['users_cof'] = User.objects.filter(groups=cof_members) queries['users_cof'] = User.objects.filter(groups=cof_members)
queries['users_notcof'] = User.objects.exclude(groups=cof_members) queries['users_notcof'] = User.objects.exclude(groups=cof_members)
for word in search_words: for word in search_words:
queries['kfet'] = queries['kfet'].filter( queries['kfet'] = queries['kfet'].filter(
Q(profile__user__username__icontains=word) Q(profile__user__username__icontains=word)
@ -79,27 +80,54 @@ def account_create(request):
) )
# Fetching data from the SPI # Fetching data from the SPI
if hasattr(settings, 'LDAP_SERVER_URL'): if getattr(settings, 'LDAP_SERVER_URL', None):
# Fetching # Fetching
ldap_query = '(|{:s})'.format(''.join( ldap_query = '(&{:s})'.format(''.join(
['(cn=*{bit:s}*)(uid=*{bit:s}*)'.format(bit=bit) '(|(cn=*{bit:s}*)(uid=*{bit:s}*))'.format(bit=word)
for bit in search_words] for word in search_words if word.isalnum()
)) ))
with Connection(settings.LDAP_SERVER_URL) as conn: if ldap_query != "(&)":
conn.search( # If none of the bits were legal, we do not perform the query
'dc=spi,dc=ens,dc=fr', ldap_query, entries = None
attributes=['uid', 'cn'] with Connection(settings.LDAP_SERVER_URL) as conn:
) conn.search(
queries['clippers'] = conn.entries 'dc=spi,dc=ens,dc=fr', ldap_query,
# Clearing redundancies attributes=['uid', 'cn']
queries['clippers'] = [ )
Clipper(clipper.uid, clipper.cn) entries = conn.entries
for clipper in queries['clippers'] # Clearing redundancies
if str(clipper.uid) not in usernames queries['clippers'] = [
] Clipper(entry.uid.value, entry.cn.value)
for entry in entries
if entry.uid.value
and entry.uid.value not in usernames
]
# Resulting data # Resulting data
data.update(queries) data.update(queries)
data['options'] = any(queries.values()) data['options'] = any(queries.values())
return render(request, "kfet/account_create_autocomplete.html", data) return render(request, "kfet/account_create_autocomplete.html", data)
@teamkfet_required
def account_search(request):
if "q" not in request.GET:
raise Http404
q = request.GET.get("q")
words = q.split()
data = {'q': q}
for word in words:
query = Account.objects.filter(
Q(cofprofile__user__username__icontains=word) |
Q(cofprofile__user__first_name__icontains=word) |
Q(cofprofile__user__last_name__icontains=word)
).distinct()
query = [(account.trigramme, account.cofprofile.user.get_full_name())
for account in query]
data['accounts'] = query
return render(request, 'kfet/account_search_autocomplete.html', data)

View file

@ -1,51 +0,0 @@
# -*- coding: utf-8 -*-
import hashlib
from django.contrib.auth.models import User, Permission
from kfet.models import Account, GenericTeamToken
class KFetBackend(object):
def authenticate(self, request):
password = request.POST.get('KFETPASSWORD', '')
password = request.META.get('HTTP_KFETPASSWORD', password)
if not password:
return None
password_sha256 = hashlib.sha256(password.encode('utf-8')).hexdigest()
try:
account = Account.objects.get(password=password_sha256)
user = account.profile.user
except Account.DoesNotExist:
return None
return user
class GenericTeamBackend(object):
"""
Authenticate using the generic_team user.
"""
def authenticate(self, username=None, token=None):
valid_token = GenericTeamToken.objects.get(token=token)
if username == 'kfet_genericteam' and valid_token:
# Création du user s'il n'existe pas déjà
user, _ = User.objects.get_or_create(username='kfet_genericteam')
account, _ = Account.objects.get_or_create(
profile=user.profile,
trigramme='GNR'
)
# Ajoute la permission kfet.is_team à ce user
perm_is_team = Permission.objects.get(codename='is_team')
user.user_permissions.add(perm_is_team)
return user
return None
def get_user(self, user_id):
try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
return None

1
kfet/cms/__init__.py Normal file
View file

@ -0,0 +1 @@
default_app_config = 'kfet.cms.apps.KFetCMSAppConfig'

10
kfet/cms/apps.py Normal file
View file

@ -0,0 +1,10 @@
from django.apps import AppConfig
class KFetCMSAppConfig(AppConfig):
name = 'kfet.cms'
label = 'kfetcms'
verbose_name = 'CMS K-Fêt'
def ready(self):
from . import hooks

View file

@ -0,0 +1,20 @@
from kfet.models import Article
def get_articles(request=None):
articles = (
Article.objects
.filter(is_sold=True, hidden=False)
.select_related('category')
.order_by('category__name', 'name')
)
pressions, others = [], []
for article in articles:
if article.category.name == 'Pression':
pressions.append(article)
else:
others.append(article)
return {
'pressions': pressions,
'articles': others,
}

File diff suppressed because one or more lines are too long

12
kfet/cms/hooks.py Normal file
View file

@ -0,0 +1,12 @@
from django.contrib.staticfiles.templatetags.staticfiles import static
from django.utils.html import format_html
from wagtail.wagtailcore import hooks
@hooks.register('insert_editor_css')
def editor_css():
return format_html(
'<link rel="stylesheet" href="{}">',
static('kfetcms/css/editor.css'),
)

View file

@ -0,0 +1,35 @@
from django.contrib.auth.models import Group
from django.core.management import call_command
from django.core.management.base import BaseCommand
from wagtail.wagtailcore.models import Page, Site
class Command(BaseCommand):
help = "Importe des données pour Wagtail"
def add_arguments(self, parser):
parser.add_argument('--file', default='kfet_wagtail_17_05')
def handle(self, *args, **options):
self.stdout.write("Import des données wagtail")
# Nettoyage des données initiales posées par Wagtail dans la migration
# wagtailcore/0002
Group.objects.filter(name__in=('Moderators', 'Editors')).delete()
try:
homepage = Page.objects.get(
title="Welcome to your new Wagtail site!"
)
homepage.delete()
Site.objects.filter(root_page=homepage).delete()
except Page.DoesNotExist:
pass
# Import des données
# Par défaut, il s'agit d'une copie du site K-Fêt (17-05)
call_command('loaddata', options['file'])

View file

@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import wagtail.wagtailsnippets.blocks
import wagtail.wagtailcore.blocks
import wagtail.wagtailcore.fields
import django.db.models.deletion
import kfet.cms.models
class Migration(migrations.Migration):
dependencies = [
('wagtailcore', '0033_remove_golive_expiry_help_text'),
('wagtailimages', '0019_delete_filter'),
]
operations = [
migrations.CreateModel(
name='KFetPage',
fields=[
('page_ptr', models.OneToOneField(serialize=False, primary_key=True, parent_link=True, auto_created=True, to='wagtailcore.Page')),
('no_header', models.BooleanField(verbose_name='Sans en-tête', help_text="Coché, l'en-tête (avec le titre) de la page n'est pas affiché.", default=False)),
('content', wagtail.wagtailcore.fields.StreamField((('rich', wagtail.wagtailcore.blocks.RichTextBlock(label='Éditeur')), ('carte', kfet.cms.models.MenuBlock()), ('group_team', wagtail.wagtailcore.blocks.StructBlock((('show_only', wagtail.wagtailcore.blocks.IntegerBlock(help_text='Nombre initial de membres affichés. Laisser vide pour tou-te-s les afficher.', required=False, label='Montrer seulement')), ('members', wagtail.wagtailcore.blocks.ListBlock(wagtail.wagtailsnippets.blocks.SnippetChooserBlock(kfet.cms.models.MemberTeam), classname='team-group', label='K-Fêt-eux-ses'))))), ('group', wagtail.wagtailcore.blocks.StreamBlock((('rich', wagtail.wagtailcore.blocks.RichTextBlock(label='Éditeur')), ('carte', kfet.cms.models.MenuBlock()), ('group_team', wagtail.wagtailcore.blocks.StructBlock((('show_only', wagtail.wagtailcore.blocks.IntegerBlock(help_text='Nombre initial de membres affichés. Laisser vide pour tou-te-s les afficher.', required=False, label='Montrer seulement')), ('members', wagtail.wagtailcore.blocks.ListBlock(wagtail.wagtailsnippets.blocks.SnippetChooserBlock(kfet.cms.models.MemberTeam), classname='team-group', label='K-Fêt-eux-ses')))))), label='Contenu groupé'))), verbose_name='Contenu')),
('layout', models.CharField(max_length=255, choices=[('kfet/base_col_1.html', 'Une colonne : centrée sur la page'), ('kfet/base_col_2.html', 'Deux colonnes : fixe à gauche, contenu à droite'), ('kfet/base_col_mult.html', 'Contenu scindé sur plusieurs colonnes')], help_text='Comment cette page devrait être affichée ?', verbose_name='Template', default='kfet/base_col_mult.html')),
('main_size', models.CharField(max_length=255, blank=True, verbose_name='Taille de la colonne de contenu')),
('col_count', models.CharField(max_length=255, blank=True, verbose_name='Nombre de colonnes', help_text="S'applique au page dont le contenu est scindé sur plusieurs colonnes")),
],
options={
'verbose_name': 'page K-Fêt',
'verbose_name_plural': 'pages K-Fêt',
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='MemberTeam',
fields=[
('id', models.AutoField(verbose_name='ID', auto_created=True, serialize=False, primary_key=True)),
('first_name', models.CharField(blank=True, max_length=255, verbose_name='Prénom', default='')),
('last_name', models.CharField(blank=True, max_length=255, verbose_name='Nom', default='')),
('nick_name', models.CharField(verbose_name='Alias', blank=True, default='', max_length=255)),
('photo', models.ForeignKey(null=True, related_name='+', on_delete=django.db.models.deletion.SET_NULL, verbose_name='Photo', blank=True, to='wagtailimages.Image')),
],
options={
'verbose_name': 'K-Fêt-eux-se',
},
),
]

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 = [
('kfetcms', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='kfetpage',
name='col_count',
field=models.CharField(blank=True, max_length=255, verbose_name='Nombre de colonnes', help_text="S'applique au page dont le contenu est scindé sur plusieurs colonnes."),
),
]

View file

174
kfet/cms/models.py Normal file
View file

@ -0,0 +1,174 @@
from django.db import models
from django.utils.translation import ugettext_lazy as _
from wagtail.wagtailadmin.edit_handlers import (
FieldPanel, FieldRowPanel, MultiFieldPanel, StreamFieldPanel
)
from wagtail.wagtailcore import blocks
from wagtail.wagtailcore.fields import StreamField
from wagtail.wagtailcore.models import Page
from wagtail.wagtailimages.edit_handlers import ImageChooserPanel
from wagtail.wagtailsnippets.blocks import SnippetChooserBlock
from wagtail.wagtailsnippets.models import register_snippet
from kfet.cms.context_processors import get_articles
@register_snippet
class MemberTeam(models.Model):
first_name = models.CharField(
verbose_name=_('Prénom'),
blank=True, default='', max_length=255,
)
last_name = models.CharField(
verbose_name=_('Nom'),
blank=True, default='', max_length=255,
)
nick_name = models.CharField(
verbose_name=_('Alias'),
blank=True, default='', max_length=255,
)
photo = models.ForeignKey(
'wagtailimages.Image',
verbose_name=_('Photo'),
on_delete=models.SET_NULL,
null=True, blank=True,
related_name='+',
)
class Meta:
verbose_name = _('K-Fêt-eux-se')
panels = [
FieldPanel('first_name'),
FieldPanel('last_name'),
FieldPanel('nick_name'),
ImageChooserPanel('photo'),
]
def __str__(self):
return self.get_full_name()
def get_full_name(self):
return '{} {}'.format(self.first_name, self.last_name).strip()
class MenuBlock(blocks.StaticBlock):
class Meta:
icon = 'list-ul'
label = _('Carte')
template = 'kfetcms/block_menu.html'
def get_context(self, *args, **kwargs):
context = super().get_context(*args, **kwargs)
context.update(get_articles())
return context
class GroupTeamBlock(blocks.StructBlock):
show_only = blocks.IntegerBlock(
label=_('Montrer seulement'),
required=False,
help_text=_(
'Nombre initial de membres affichés. Laisser vide pour tou-te-s '
'les afficher.'
),
)
members = blocks.ListBlock(
SnippetChooserBlock(MemberTeam),
label=_('K-Fêt-eux-ses'),
classname='team-group',
)
class Meta:
icon = 'group'
label = _('Groupe de K-Fêt-eux-ses')
template = 'kfetcms/block_teamgroup.html'
class ChoicesStreamBlock(blocks.StreamBlock):
rich = blocks.RichTextBlock(label=_('Éditeur'))
carte = MenuBlock()
group_team = GroupTeamBlock()
class KFetStreamBlock(ChoicesStreamBlock):
group = ChoicesStreamBlock(label=_('Contenu groupé'))
class KFetPage(Page):
content = StreamField(KFetStreamBlock, verbose_name=_('Contenu'))
# Layout fields
TEMPLATE_COL_1 = 'kfet/base_col_1.html'
TEMPLATE_COL_2 = 'kfet/base_col_2.html'
TEMPLATE_COL_MULT = 'kfet/base_col_mult.html'
no_header = models.BooleanField(
verbose_name=_('Sans en-tête'),
default=False,
help_text=_(
"Coché, l'en-tête (avec le titre) de la page n'est pas affiché."
),
)
layout = models.CharField(
verbose_name=_('Template'),
choices=[
(TEMPLATE_COL_1, _('Une colonne : centrée sur la page')),
(TEMPLATE_COL_2, _('Deux colonnes : fixe à gauche, contenu à droite')),
(TEMPLATE_COL_MULT, _('Contenu scindé sur plusieurs colonnes')),
],
default=TEMPLATE_COL_MULT, max_length=255,
help_text=_(
"Comment cette page devrait être affichée ?"
),
)
main_size = models.CharField(
verbose_name=_('Taille de la colonne de contenu'),
blank=True, max_length=255,
)
col_count = models.CharField(
verbose_name=_('Nombre de colonnes'),
blank=True, max_length=255,
help_text=_(
"S'applique au page dont le contenu est scindé sur plusieurs colonnes."
),
)
# Panels
content_panels = Page.content_panels + [
StreamFieldPanel('content'),
]
layout_panel = [
FieldPanel('no_header'),
FieldPanel('layout'),
FieldRowPanel([
FieldPanel('main_size'),
FieldPanel('col_count'),
]),
]
settings_panels = [
MultiFieldPanel(layout_panel, _('Affichage'))
] + Page.settings_panels
# Base template
template = "kfetcms/base.html"
class Meta:
verbose_name = _('page K-Fêt')
verbose_name_plural = _('pages K-Fêt')
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
page = context['page']
if not page.seo_title:
page.seo_title = page.title
return context

View file

@ -0,0 +1,93 @@
.main.cms {
padding: 20px 15px;
}
@media (min-width: 768px) {
.main.cms {
padding: 35px 30px;
}
}
.cms {
text-align: justify;
font-size: 1.1em;
}
@media (min-width:768px) {
.cms {
font-size: 1.2em;
line-height: 1.6em;
}
}
/* Titles */
.cms h2, .cms h3 {
clear: both;
margin: 0 0 15px;
padding-bottom: 10px;
border-bottom: 1px solid #c8102e;
text-align: left;
font-weight: bold;
}
@media (min-width: 768px) {
.cms h2, .cms h3 {
padding-bottom: 15px;
}
}
/* Paragraphs */
.cms p {
margin-bottom: 20px;
text-indent: 2em;
}
.cms p + :not(h2):not(h3):not(div) {
margin-top: -10px;
}
@media (min-width: 768px) {
.cms p {
padding-bottom: 15px;
}
.cms p + :not(h2):not(h3):not(div) {
margin-top: -30px;
}
}
/* Lists */
.cms ol, .cms ul {
padding: 0 0 0 15px;
margin: 0 0 10px;
}
.cms ul {
list-style-type: square;
}
.cms ol > li, .cms ul > li {
padding-left: 5px;
}
/* Images */
.cms .richtext-image {
max-height: 100%;
margin: 5px 0 15px;
}
.cms .richtext-image.left {
float: left;
margin-right: 30px;
}
.cms .richtext-image.right {
float: right;
margin-left: 30px;
}

View file

@ -0,0 +1,18 @@
.snippets.listing thead, .snippets.listing thead tr {
border: 0;
}
.snippets.listing tbody {
display: block;
column-count: 2;
}
.snippets.listing tbody tr {
display: block;
}
@media (min-width: 992px) {
.snippets.listing tbody {
column-count: 3;
}
}

View file

@ -0,0 +1,3 @@
@import url("base.css");
@import url("menu.css");
@import url("team.css");

View file

@ -0,0 +1,58 @@
.carte {
margin-bottom: 15px;
font-family: "Roboto Slab";
}
.carte .carte-title {
padding-top: 0;
margin-top: 0;
margin-bottom: 0;
}
.carte .carte-list {
width: 100%;
padding: 15px;
list-style-type: none;
}
.carte .carte-item {
position: relative;
text-align: right;
white-space: nowrap;
padding: 0;
}
.carte .carte-item .filler {
position: absolute;
left: 0;
right: 0;
border-bottom: 2px dotted #333;
height: 75%;
}
.carte .carte-item > span {
position: relative;
}
.carte .carte-item .carte-label {
background: white;
float: left;
padding-right: 10px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.carte .carte-item .carte-ukf {
padding: 0 10px;
background: #ffdbc7;
}
.carte-inverted .carte-list,
.carte-inverted .carte-item .carte-label {
background: #ffdbc7;
}
.carte-inverted .carte-item .carte-ukf {
background: white;
}

View file

@ -0,0 +1,47 @@
.team-group {
margin-bottom: 20px;
}
.team-group .col-btn {
margin-bottom: 20px;
}
.team-group .member-more {
display: none;
}
.team-member {
padding: 0;
margin-bottom: 20px;
min-height: 190px;
background-color: inherit;
border: 0;
}
.team-member img {
max-width: 100%;
max-height: 125px;
width: auto;
height: auto;
display: block;
}
.team-member .infos {
height: 50px;
margin-top: 15px;
}
@media (min-width: 768px) {
.team-group {
margin-left: 20px;
margin-right: 20px;
}
.team-member {
min-height: 215px;
}
.team-member img {
max-height: 150px;
}
}

View file

@ -0,0 +1,41 @@
{% extends page.layout %}
{% load static wagtailcore_tags wagtailuserbar %}
{# CSS/JS #}
{% block extra_head %}
{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static "kfetcms/css/index.css" %}">
{% endblock %}
{# Titles #}
{% block title %}{{ page.seo_title }}{% endblock %}
{% block header-title %}{{ page.title }}{% endblock %}
{# Layout #}
{% block main-size %}{{ page.main_size|default:block.super }}{% endblock %}
{% block mult-count %}{{ page.col_count|default:block.super }}{% endblock %}
{% block main-class %}cms main-bg{% endblock %}
{# Content #}
{% block main %}
{% for block in page.content %}
<div class="{% if block.block_type == "rich" or block.block_type == "group" %}unbreakable{% endif %}">
{% include_block block %}
</div>
{% endfor %}
{% wagtailuserbar %}
{% endblock %}
{# Footer #}
{% block footer %}
{% include "kfet/base_footer.html" %}
{% endblock %}

View file

@ -0,0 +1,11 @@
{% load static %}
{% if pressions %}
{% include "kfetcms/block_menu_category.html" with title="Pressions du moment" articles=pressions class="carte-inverted" %}
{% endif %}
{% regroup articles by category as categories %}
{% for category in categories %}
{% include "kfetcms/block_menu_category.html" with title=category.grouper.name articles=category.list %}
{% endfor %}

View file

@ -0,0 +1,12 @@
<div class="carte {{ class }} unbreakable">
<h3 class="carte-title">{{ title }}</h3>
<ul class="carte-list">
{% for article in articles %}
<li class="carte-item">
<div class="filler"></div>
<span class="carte-label">{{ article.name }}</span>
<span class="carte-ukf">{{ article.price_ukf }} UKF</span>
</li>
{% endfor %}
</ul>
</div>

View file

@ -0,0 +1,66 @@
{% load wagtailcore_tags wagtailimages_tags %}
{% with groupteam=value len=value.members|length %}
<div class="team-group row">
{% if len == 2 %}
<div class="visible-sm col-sm-3"></div>
{% endif %}
{% for member in groupteam.members %}
<div class="
{% if len == 1 %}
col-xs-12
{% else %}
col-xs-6
{% if len == 3 %}
col-sm-4
{% elif len == 2 %}
col-sm-3 col-md-6
{% else %}
col-sm-3 col-md-4 col-lg-3
{% endif %}
{% endif %}
{% if groupteam.show_only != None and forloop.counter0 >= groupteam.show_only %}
member-more
{% endif %}
">
<div class="team-member thumbnail text-center">
{% image member.photo max-200x500 %}
<div class="infos">
<b>{{ member.get_full_name }}</b>
<br>
{% if member.nick_name %}
<i>alias</i> {{ member.nick_name }}
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% if groupteam.show_only != None and len > groupteam.show_only %}
<div class="col-xs-12 col-btn text-center">
<button class="btn btn-primary btn-lg more">
{% if groupteam.show_only %}
Y'en a plus !
{% else %}
Les voir
{% endif %}
</button>
</div>
{% endif %}
</div>
{% endwith %}
<script type="text/javascript">
$( function() {
$('.more').click( function() {
$(this).closest('.col-btn').hide();
$(this).closest('.team-group').children('.member-more').show();
});
});
</script>

71
kfet/config.py Normal file
View file

@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
from django.core.exceptions import ValidationError
from django.db import models
from djconfig import config
class KFetConfig(object):
"""kfet app configuration.
Enhance isolation with backend used to store config.
Usable after DjConfig middleware was called.
"""
prefix = 'kfet_'
def __getattr__(self, key):
if key == 'subvention_cof':
# Allows accessing to the reduction as a subvention
# Other reason: backward compatibility
reduction_mult = 1 - self.reduction_cof/100
return (1/reduction_mult - 1) * 100
return getattr(config, self._get_dj_key(key))
def list(self):
"""Get list of kfet app configuration.
Returns:
(key, value) for each configuration entry as list.
"""
# prevent circular imports
from kfet.forms import KFetConfigForm
return [(field.label, getattr(config, name), )
for name, field in KFetConfigForm.base_fields.items()]
def _get_dj_key(self, key):
return '{}{}'.format(self.prefix, key)
def set(self, **kwargs):
"""Update configuration value(s).
Args:
**kwargs: Keyword arguments. Keys must be in kfet config.
Config entries are updated to given values.
"""
# prevent circular imports
from kfet.forms import KFetConfigForm
# get old config
new_cfg = KFetConfigForm().initial
# update to new config
for key, value in kwargs.items():
dj_key = self._get_dj_key(key)
if isinstance(value, models.Model):
new_cfg[dj_key] = value.pk
else:
new_cfg[dj_key] = value
# save new config
cfg_form = KFetConfigForm(new_cfg)
if cfg_form.is_valid():
cfg_form.save()
else:
raise ValidationError(
'Invalid values in kfet_config.set: %(fields)s',
params={'fields': list(cfg_form.errors)})
kfet_config = KFetConfig()

View file

@ -1,26 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import (absolute_import, division, from .utils import DjangoJsonWebsocketConsumer, PermConsumerMixin
print_function, unicode_literals)
from builtins import *
from channels import Group
from channels.generic.websockets import JsonWebsocketConsumer
class KPsul(JsonWebsocketConsumer): class KPsul(PermConsumerMixin, DjangoJsonWebsocketConsumer):
groups = ['kfet.kpsul']
# Set to True if you want them, else leave out perms_connect = ['kfet.is_team']
strict_ordering = False
slight_ordering = False
def connection_groups(self, **kwargs):
return ['kfet.kpsul']
def connect(self, message, **kwargs):
pass
def receive(self, content, **kwargs):
pass
def disconnect(self, message, **kwargs):
pass

View file

@ -1,15 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import (absolute_import, division, from kfet.config import kfet_config
print_function, unicode_literals)
from builtins import *
from django.contrib.auth.context_processors import PermWrapper
def auth(request): def config(request):
if hasattr(request, 'real_user'): return {'kfet_config': kfet_config}
return {
'user': request.real_user,
'perms': PermWrapper(request.real_user),
}
return {}

View file

@ -1,12 +1,9 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import (absolute_import, division, from django.contrib.auth.decorators import user_passes_test
print_function, unicode_literals)
from builtins import *
from django_cas_ng.decorators import user_passes_test
def kfet_is_team(user): def kfet_is_team(user):
return user.has_perm('kfet.is_team') return user.has_perm('kfet.is_team')
teamkfet_required = user_passes_test(lambda u: kfet_is_team(u)) teamkfet_required = user_passes_test(kfet_is_team)

View file

@ -1,40 +1,39 @@
# -*- coding: utf-8 -*- from datetime import timedelta
from __future__ import (absolute_import, division,
print_function, unicode_literals)
from builtins import *
from decimal import Decimal from decimal import Decimal
from djconfig.forms import ConfigForm
from django import forms from django import forms
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator from django.forms import modelformset_factory
from django.contrib.auth.models import User, Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.forms import modelformset_factory, inlineformset_factory
from django.forms.models import BaseInlineFormSet
from django.utils import timezone from django.utils import timezone
from kfet.models import (Account, Checkout, Article, OperationGroup, Operation,
CheckoutStatement, ArticleCategory, Settings, AccountNegative, Transfer,
TransferGroup, Supplier, Inventory, InventoryArticle)
from gestion.models import Profile from gestion.models import Profile
from .auth.forms import UserGroupForm # noqa
from .models import (
Account, Checkout, Article, OperationGroup, Operation,
CheckoutStatement, ArticleCategory, AccountNegative, Transfer,
Supplier, TransferGroup
)
# ----- # -----
# Widgets # Widgets
# ----- # -----
class DateTimeWidget(forms.DateTimeInput): class DateTimeWidget(forms.DateTimeInput):
def __init__(self, attrs = None): def __init__(self, *args, **kwargs):
super(DateTimeWidget, self).__init__(attrs) super().__init__(*args, **kwargs)
self.attrs['format'] = '%Y-%m-%d %H:%M' self.attrs['format'] = '%Y-%m-%d %H:%M'
class Media: class Media:
css = { css = {
'all': ('kfet/css/bootstrap-datetimepicker.min.css',) 'all': ('kfet/css/bootstrap-datetimepicker.min.css',)
} }
js = ( js = ('kfet/js/bootstrap-datetimepicker.min.js',)
'kfet/js/moment.js',
'kfet/js/moment-fr.js',
'kfet/js/bootstrap-datetimepicker.min.js',
)
# ----- # -----
# Account forms # Account forms
# ----- # -----
@ -78,8 +77,11 @@ class AccountRestrictForm(AccountForm):
class AccountPwdForm(forms.Form): class AccountPwdForm(forms.Form):
pwd1 = forms.CharField( pwd1 = forms.CharField(
label="Mot de passe K-Fêt",
help_text="Le mot de passe doit contenir au moins huit caractères",
widget=forms.PasswordInput) widget=forms.PasswordInput)
pwd2 = forms.CharField( pwd2 = forms.CharField(
label="Confirmer le mot de passe",
widget=forms.PasswordInput) widget=forms.PasswordInput)
def clean(self): def clean(self):
@ -108,21 +110,16 @@ class ProfileRestrictForm(ProfileForm):
class Meta(ProfileForm.Meta): class Meta(ProfileForm.Meta):
fields = ['departement'] fields = ['departement']
class UserForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
from_clipper = kwargs.pop('from_clipper', False)
new_user = kwargs.get('instance') is None and not from_clipper
super(UserForm, self).__init__(*args, **kwargs)
if new_user:
self.fields['username'].validators = [MinLengthValidator(9)]
class UserForm(forms.ModelForm):
class Meta: class Meta:
model = User model = User
fields = ['username', 'first_name', 'last_name', 'email'] fields = ['username', 'first_name', 'last_name', 'email']
help_texts = { help_texts = {
'username': '' 'username': ''
} }
class UserRestrictForm(UserForm): class UserRestrictForm(UserForm):
class Meta(UserForm.Meta): class Meta(UserForm.Meta):
fields = ['first_name', 'last_name'] fields = ['first_name', 'last_name']
@ -131,25 +128,6 @@ class UserRestrictTeamForm(UserForm):
class Meta(UserForm.Meta): class Meta(UserForm.Meta):
fields = ['first_name', 'last_name', 'email'] fields = ['first_name', 'last_name', 'email']
class UserGroupForm(forms.ModelForm):
groups = forms.ModelMultipleChoiceField(
Group.objects.filter(name__icontains='K-Fêt'))
class Meta:
model = User
fields = ['groups']
class GroupForm(forms.ModelForm):
permissions = forms.ModelMultipleChoiceField(
queryset= Permission.objects.filter(content_type__in=
ContentType.objects.filter(app_label='kfet')))
def clean_name(self):
name = self.cleaned_data['name']
return 'K-Fêt %s' % name
class Meta:
model = Group
fields = ['name', 'permissions']
class AccountNegativeForm(forms.ModelForm): class AccountNegativeForm(forms.ModelForm):
class Meta: class Meta:
@ -226,22 +204,36 @@ class CheckoutStatementUpdateForm(forms.ModelForm):
model = CheckoutStatement model = CheckoutStatement
exclude = ['by', 'at', 'checkout', 'amount_error', 'amount_taken'] exclude = ['by', 'at', 'checkout', 'amount_error', 'amount_taken']
# -----
# Category
# -----
class CategoryForm(forms.ModelForm):
class Meta:
model = ArticleCategory
fields = ['name', 'has_addcost']
# ----- # -----
# Article forms # Article forms
# ----- # -----
class ArticleForm(forms.ModelForm): class ArticleForm(forms.ModelForm):
category_new = forms.CharField( category_new = forms.CharField(
label="Créer une catégorie",
max_length=45, max_length=45,
required = False) required = False)
category = forms.ModelChoiceField( category = forms.ModelChoiceField(
label="Catégorie",
queryset = ArticleCategory.objects.all(), queryset = ArticleCategory.objects.all(),
required = False) required = False)
suppliers = forms.ModelMultipleChoiceField( suppliers = forms.ModelMultipleChoiceField(
label="Fournisseurs",
queryset = Supplier.objects.all(), queryset = Supplier.objects.all(),
required = False) required = False)
supplier_new = forms.CharField( supplier_new = forms.CharField(
label="Créer un fournisseur",
max_length = 45, max_length = 45,
required = False) required = False)
@ -264,12 +256,12 @@ class ArticleForm(forms.ModelForm):
class Meta: class Meta:
model = Article model = Article
fields = ['name', 'is_sold', 'price', 'stock', 'category', 'box_type', fields = ['name', 'is_sold', 'hidden', 'price', 'stock', 'category', 'box_type',
'box_capacity'] 'box_capacity']
class ArticleRestrictForm(ArticleForm): class ArticleRestrictForm(ArticleForm):
class Meta(ArticleForm.Meta): class Meta(ArticleForm.Meta):
fields = ['name', 'is_sold', 'price', 'category', 'box_type', fields = ['name', 'is_sold', 'hidden', 'price', 'category', 'box_type',
'box_capacity'] 'box_capacity']
# ----- # -----
@ -301,12 +293,20 @@ class KPsulAccountForm(forms.ModelForm):
}), }),
} }
class KPsulCheckoutForm(forms.Form): class KPsulCheckoutForm(forms.Form):
checkout = forms.ModelChoiceField( checkout = forms.ModelChoiceField(
queryset=Checkout.objects.filter( queryset=(
is_protected=False, valid_from__lte=timezone.now(), Checkout.objects
valid_to__gte=timezone.now()), .filter(
widget=forms.Select(attrs={'id':'id_checkout_select'})) is_protected=False,
valid_from__lte=timezone.now(),
valid_to__gte=timezone.now(),
)
),
widget=forms.Select(attrs={'id': 'id_checkout_select'}),
)
class KPsulOperationForm(forms.ModelForm): class KPsulOperationForm(forms.ModelForm):
article = forms.ModelChoiceField( article = forms.ModelChoiceField(
@ -315,11 +315,10 @@ class KPsulOperationForm(forms.ModelForm):
widget = forms.HiddenInput()) widget = forms.HiddenInput())
class Meta: class Meta:
model = Operation model = Operation
fields = ['type', 'amount', 'is_checkout', 'article', 'article_nb'] fields = ['type', 'amount', 'article', 'article_nb']
widgets = { widgets = {
'type': forms.HiddenInput(), 'type': forms.HiddenInput(),
'amount': forms.HiddenInput(), 'amount': forms.HiddenInput(),
'is_checkout': forms.HiddenInput(),
'article_nb': forms.HiddenInput(), 'article_nb': forms.HiddenInput(),
} }
@ -335,7 +334,6 @@ class KPsulOperationForm(forms.ModelForm):
"Un achat nécessite un article et une quantité") "Un achat nécessite un article et une quantité")
if article_nb < 1: if article_nb < 1:
raise ValidationError("Impossible d'acheter moins de 1 article") raise ValidationError("Impossible d'acheter moins de 1 article")
self.cleaned_data['is_checkout'] = True
elif type_ope and type_ope in [Operation.DEPOSIT, Operation.WITHDRAW]: elif type_ope and type_ope in [Operation.DEPOSIT, Operation.WITHDRAW]:
if not amount or article or article_nb: if not amount or article or article_nb:
raise ValidationError("Bad request") raise ValidationError("Bad request")
@ -370,44 +368,53 @@ class AddcostForm(forms.Form):
self.cleaned_data['amount'] = 0 self.cleaned_data['amount'] = 0
super(AddcostForm, self).clean() super(AddcostForm, self).clean()
# ----- # -----
# Settings forms # Settings forms
# ----- # -----
class SettingsForm(forms.ModelForm):
class Meta:
model = Settings
fields = ['value_decimal', 'value_account', 'value_duration']
def clean(self): class KFetConfigForm(ConfigForm):
name = self.instance.name
value_decimal = self.cleaned_data.get('value_decimal')
value_account = self.cleaned_data.get('value_account')
value_duration = self.cleaned_data.get('value_duration')
type_decimal = ['SUBVENTION_COF', 'ADDCOST_AMOUNT', 'OVERDRAFT_AMOUNT'] kfet_reduction_cof = forms.DecimalField(
type_account = ['ADDCOST_FOR'] label='Réduction COF', initial=Decimal('20'),
type_duration = ['OVERDRAFT_DURATION', 'CANCEL_DURATION'] max_digits=6, decimal_places=2,
help_text="Réduction, à donner en pourcentage, appliquée lors d'un "
"achat par un-e membre du COF sur le montant en euros.",
)
kfet_addcost_amount = forms.DecimalField(
label='Montant de la majoration (en €)', initial=Decimal('0'),
required=False,
max_digits=6, decimal_places=2,
)
kfet_addcost_for = forms.ModelChoiceField(
label='Destinataire de la majoration', initial=None, required=False,
help_text='Laissez vide pour désactiver la majoration.',
queryset=(Account.objects
.select_related('cofprofile', 'cofprofile__user')
.all()),
)
kfet_overdraft_duration = forms.DurationField(
label='Durée du découvert autorisé par défaut',
initial=timedelta(days=1),
)
kfet_overdraft_amount = forms.DecimalField(
label='Montant du découvert autorisé par défaut (en €)',
initial=Decimal('20'),
max_digits=6, decimal_places=2,
)
kfet_cancel_duration = forms.DurationField(
label='Durée pour annuler une commande sans mot de passe',
initial=timedelta(minutes=5),
)
self.cleaned_data['name'] = name
if name in type_decimal:
if not value_decimal:
raise ValidationError('Renseignez une valeur décimale')
self.cleaned_data['value_account'] = None
self.cleaned_data['value_duration'] = None
elif name in type_account:
self.cleaned_data['value_decimal'] = None
self.cleaned_data['value_duration'] = None
elif name in type_duration:
if not value_duration:
raise ValidationError('Renseignez une durée')
self.cleaned_data['value_decimal'] = None
self.cleaned_data['value_account'] = None
super(SettingsForm, self).clean()
class FilterHistoryForm(forms.Form): class FilterHistoryForm(forms.Form):
checkouts = forms.ModelMultipleChoiceField(queryset = Checkout.objects.all()) checkouts = forms.ModelMultipleChoiceField(queryset=Checkout.objects.all())
accounts = forms.ModelMultipleChoiceField(queryset = Account.objects.all()) accounts = forms.ModelMultipleChoiceField(queryset=Account.objects.all())
from_date = forms.DateTimeField(widget=DateTimeWidget)
to_date = forms.DateTimeField(widget=DateTimeWidget)
# ----- # -----
# Transfer forms # Transfer forms
@ -454,7 +461,7 @@ class InventoryArticleForm(forms.Form):
queryset = Article.objects.all(), queryset = Article.objects.all(),
widget = forms.HiddenInput(), widget = forms.HiddenInput(),
) )
stock_new = forms.IntegerField(required = False) stock_new = forms.IntegerField(required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(InventoryArticleForm, self).__init__(*args, **kwargs) super(InventoryArticleForm, self).__init__(*args, **kwargs)
@ -463,17 +470,20 @@ class InventoryArticleForm(forms.Form):
self.stock_old = kwargs['initial']['stock_old'] self.stock_old = kwargs['initial']['stock_old']
self.category = kwargs['initial']['category'] self.category = kwargs['initial']['category']
self.category_name = kwargs['initial']['category__name'] self.category_name = kwargs['initial']['category__name']
self.box_capacity = kwargs['initial']['box_capacity']
# ----- # -----
# Order forms # Order forms
# ----- # -----
class OrderArticleForm(forms.Form): class OrderArticleForm(forms.Form):
article = forms.ModelChoiceField( article = forms.ModelChoiceField(
queryset = Article.objects.all(), queryset=Article.objects.all(),
widget = forms.HiddenInput(), widget=forms.HiddenInput(),
) )
quantity_ordered = forms.IntegerField(required = False) quantity_ordered = forms.IntegerField(required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(OrderArticleForm, self).__init__(*args, **kwargs) super(OrderArticleForm, self).__init__(*args, **kwargs)
@ -483,11 +493,7 @@ class OrderArticleForm(forms.Form):
self.category = kwargs['initial']['category'] self.category = kwargs['initial']['category']
self.category_name = kwargs['initial']['category__name'] self.category_name = kwargs['initial']['category__name']
self.box_capacity = kwargs['initial']['box_capacity'] self.box_capacity = kwargs['initial']['box_capacity']
self.v_s1 = kwargs['initial']['v_s1'] self.v_all = kwargs['initial']['v_all']
self.v_s2 = kwargs['initial']['v_s2']
self.v_s3 = kwargs['initial']['v_s3']
self.v_s4 = kwargs['initial']['v_s4']
self.v_s5 = kwargs['initial']['v_s5']
self.v_moy = kwargs['initial']['v_moy'] self.v_moy = kwargs['initial']['v_moy']
self.v_et = kwargs['initial']['v_et'] self.v_et = kwargs['initial']['v_et']
self.v_prev = kwargs['initial']['v_prev'] self.v_prev = kwargs['initial']['v_prev']

View file

@ -0,0 +1,219 @@
"""
Crée des opérations aléatoires réparties sur une période de temps spécifiée
"""
import random
from datetime import timedelta
from decimal import Decimal
from django.utils import timezone
from django.core.management.base import BaseCommand
from kfet.models import (Account, Article, OperationGroup, Operation,
Checkout, Transfer, TransferGroup)
class Command(BaseCommand):
help = ("Crée des opérations réparties uniformément "
"sur une période de temps")
def add_arguments(self, parser):
# Nombre d'opérations à créer
parser.add_argument('opes', type=int,
help='Number of opegroups to create')
# Période sur laquelle créer (depuis num_days avant maintenant)
parser.add_argument('days', type=int,
help='Period in which to create opegroups')
# Optionnel : nombre de transfert à créer (défaut 0)
parser.add_argument('--transfers', type=int, default=0,
help='Number of transfers to create (default 0)')
def handle(self, *args, **options):
self.stdout.write("Génération d'opérations")
# Output log vars
opes_created = 0
purchases = 0
transfers = 0
num_ops = options['opes']
num_transfers = options['transfers']
# Convert to seconds
time = options['days'] * 24 * 3600
now = timezone.now()
checkout = Checkout.objects.first()
articles = Article.objects.all()
accounts = Account.objects.exclude(trigramme='LIQ')
liq_account = Account.objects.get(trigramme='LIQ')
try:
con_account = Account.objects.get(
cofprofile__user__first_name='Assurancetourix'
)
except Account.DoesNotExist:
con_account = random.choice(accounts)
# use to fetch OperationGroup pk created by bulk_create
at_list = []
# use to lazy set OperationGroup pk on Operation objects
ope_by_grp = []
# OperationGroup objects to bulk_create
opegroup_list = []
for i in range(num_ops):
# Randomly pick account
if random.random() > 0.25:
account = random.choice(accounts)
else:
account = liq_account
# Randomly pick time
at = now - timedelta(seconds=random.randint(0, time))
# Majoration sur compte 'concert'
if random.random() < 0.2:
addcost = True
addcost_for = con_account
addcost_amount = Decimal('0.5')
else:
addcost = False
# Initialize opegroup amount
amount = Decimal('0')
# Generating operations
ope_list = []
for j in range(random.randint(1, 4)):
# Operation type
typevar = random.random()
# 0.1 probability to have a charge
if typevar > 0.9 and account != liq_account:
ope = Operation(
type=Operation.DEPOSIT,
amount=Decimal(random.randint(1, 99)/10)
)
# 0.05 probability to have a withdrawal
elif typevar > 0.85 and account != liq_account:
ope = Operation(
type=Operation.WITHDRAW,
amount=-Decimal(random.randint(1, 99)/10)
)
# 0.05 probability to have an edition
elif typevar > 0.8 and account != liq_account:
ope = Operation(
type=Operation.EDIT,
amount=Decimal(random.randint(1, 99)/10)
)
else:
article = random.choice(articles)
nb = random.randint(1, 5)
ope = Operation(
type=Operation.PURCHASE,
amount=-article.price*nb,
article=article,
article_nb=nb
)
purchases += 1
if addcost:
ope.addcost_for = addcost_for
ope.addcost_amount = addcost_amount * nb
ope.amount -= ope.addcost_amount
ope_list.append(ope)
amount += ope.amount
opegroup_list.append(OperationGroup(
on_acc=account,
checkout=checkout,
at=at,
is_cof=account.cofprofile.is_cof,
amount=amount,
))
at_list.append(at)
ope_by_grp.append((at, ope_list, ))
OperationGroup.objects.bulk_create(opegroup_list)
# Fetch created OperationGroup objects pk by at
opegroups = (OperationGroup.objects
.filter(at__in=at_list)
.values('id', 'at'))
opegroups_by = {grp['at']: grp['id'] for grp in opegroups}
all_ope = []
for _ in range(num_ops):
at, ope_list = ope_by_grp.pop()
for ope in ope_list:
ope.group_id = opegroups_by[at]
all_ope.append(ope)
Operation.objects.bulk_create(all_ope)
opes_created = len(all_ope)
# Transfer generation
transfer_by_grp = []
transfergroup_list = []
at_list = []
for i in range(num_transfers):
# Randomly pick time
at = now - timedelta(seconds=random.randint(0, time))
# Choose whether to have a comment
if random.random() > 0.5:
comment = "placeholder comment"
else:
comment = ""
transfergroup_list.append(TransferGroup(
at=at,
comment=comment,
valid_by=random.choice(accounts),
))
at_list.append(at)
# Randomly generate transfer
transfer_list = []
for i in range(random.randint(1, 4)):
transfer_list.append(Transfer(
from_acc=random.choice(accounts),
to_acc=random.choice(accounts),
amount=Decimal(random.randint(1, 99)/10)
))
transfer_by_grp.append((at, transfer_list, ))
TransferGroup.objects.bulk_create(transfergroup_list)
transfergroups = (TransferGroup.objects
.filter(at__in=at_list)
.values('id', 'at'))
transfergroups_by = {grp['at']: grp['id'] for grp in transfergroups}
all_transfer = []
for _ in range(num_transfers):
at, transfer_list = transfer_by_grp.pop()
for transfer in transfer_list:
transfer.group_id = transfergroups_by[at]
all_transfer.append(transfer)
Transfer.objects.bulk_create(all_transfer)
transfers += len(all_transfer)
self.stdout.write(
"- {:d} opérations créées dont {:d} commandes d'articles"
.format(opes_created, purchases))
if transfers:
self.stdout.write("- {:d} transferts créés"
.format(transfers))

View file

@ -5,14 +5,18 @@ Crée des utilisateurs, des articles et des opérations aléatoires
import os import os
import random import random
from datetime import timedelta from datetime import timedelta
from decimal import Decimal
from django.utils import timezone from django.utils import timezone
from django.contrib.auth.models import User, Group, Permission, ContentType from django.contrib.auth.models import User, Group, Permission, ContentType
from django.core.management import call_command
from cof.management.base import MyBaseCommand from cof.management.base import MyBaseCommand
from gestion.models import Profile from gestion.models import Profile
from kfet.models import Account, Article, OperationGroup, Operation, Checkout from kfet.models import Account, Article, OperationGroup, Operation, Checkout
from gestioncof.management.base import MyBaseCommand
from gestioncof.models import CofProfile
from kfet.models import (Account, Checkout, CheckoutStatement, Supplier,
SupplierArticle, Article)
# Où sont stockés les fichiers json # Où sont stockés les fichiers json
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)),
@ -87,84 +91,68 @@ class Command(MyBaseCommand):
# Compte liquide # Compte liquide
self.stdout.write("Création du compte liquide")
liq_user, _ = User.objects.get_or_create(username='liquide') liq_user, _ = User.objects.get_or_create(username='liquide')
liq_profile, _ = Profile.objects.get_or_create(user=liq_user) liq_profile, _ = Profile.objects.get_or_create(user=liq_user)
liq_account, _ = Account.objects.get_or_create(profile=liq_profile, liq_account, _ = Account.objects.get_or_create(profile=liq_profile,
trigramme='LIQ') trigramme='LIQ')
# Root account if existing
root_profile = CofProfile.objects.filter(user__username='root')
if root_profile.exists():
self.stdout.write("Création du compte K-Fêt root")
root_profile = root_profile.get()
Account.objects.get_or_create(cofprofile=root_profile,
trigramme='AAA')
# --- # ---
# Caisse # Caisse
# --- # ---
checkout, _ = Checkout.objects.get_or_create( checkout, created = Checkout.objects.get_or_create(
created_by=Account.objects.get(trigramme='000'), created_by=Account.objects.get(trigramme='000'),
name='Chaudron', name='Chaudron',
valid_from=timezone.now(), defaults={
valid_to=timezone.now() + timedelta(days=365) 'valid_from': timezone.now(),
'valid_to': timezone.now() + timedelta(days=730)
},
) )
if created:
CheckoutStatement.objects.create(
by=Account.objects.get(trigramme='000'),
checkout=checkout,
balance_old=0,
balance_new=0,
amount_taken=0,
amount_error=0
)
# ---
# Fournisseur
# ---
supplier, created = Supplier.objects.get_or_create(name="Panoramix")
if created:
articles = random.sample(list(Article.objects.all()), 40)
to_create = []
for article in articles:
to_create.append(SupplierArticle(
supplier=supplier,
article=article
))
SupplierArticle.objects.bulk_create(to_create)
# --- # ---
# Opérations # Opérations
# --- # ---
self.stdout.write("Génération d'opérations") call_command('createopes', '100', '7', '--transfers=20')
articles = Article.objects.all() # ---
accounts = Account.objects.exclude(trigramme='LIQ') # Wagtail CMS
# ---
num_op = 100 call_command('kfet_loadwagtail')
# Operations are put uniformly over the span of a week
past_date = 3600*24*7
for i in range(num_op):
if random.random() > 0.25:
account = random.choice(accounts)
else:
account = liq_account
amount = Decimal('0')
at = timezone.now() - timedelta(
seconds=random.randint(0, past_date))
opegroup = OperationGroup(
on_acc=account,
checkout=checkout,
at=at,
is_cof=False
)
if hasattr(account.profile, "cof"):
opegroup.is_cof = account.profile.cof.is_cof
opegroup.save()
opegroup.save()
for j in range(random.randint(1, 4)):
typevar = random.random()
if typevar > 0.9 and account != liq_account:
ope = Operation(
group=opegroup,
type=Operation.DEPOSIT,
amount=Decimal(random.randint(1, 99)/10,)
)
elif typevar > 0.8 and account != liq_account:
ope = Operation(
group=opegroup,
type=Operation.WITHDRAW,
amount=-Decimal(random.randint(1, 99)/10,)
)
else:
article = random.choice(articles)
nb = random.randint(1, 5)
ope = Operation(
group=opegroup,
type=Operation.PURCHASE,
amount=-article.price*nb,
article=article,
article_nb=nb
)
ope.save()
amount += ope.amount
opegroup.amount = amount
opegroup.save()

View file

@ -1,16 +0,0 @@
# -*- coding: utf-8 -*-
from kfet.backends import KFetBackend
def kfet_auth_middleware(get_response):
kfet_backend = KFetBackend()
def middleware(request):
temp_request_user = kfet_backend.authenticate(request)
if temp_request_user:
request.real_user = request.user
request.user = temp_request_user
return get_response(request)
return middleware

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 = [
('kfet', '0047_auto_20170104_1528'),
]
operations = [
migrations.AddField(
model_name='article',
name='hidden',
field=models.BooleanField(help_text='Si oui, ne sera pas affiché au public ; par exemple sur la carte.', default=False),
),
]

View file

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('kfet', '0047_auto_20170104_1528'),
]
operations = [
migrations.AlterField(
model_name='operationgroup',
name='at',
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AlterField(
model_name='transfergroup',
name='at',
field=models.DateTimeField(default=django.utils.timezone.now),
),
]

View file

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('kfet', '0048_article_hidden'),
('kfet', '0048_default_datetime'),
]
operations = [
]

View file

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
def adapt_operation_types(apps, schema_editor):
Operation = apps.get_model("kfet", "Operation")
Operation.objects.filter(
is_checkout=False,
type__in=['withdraw', 'deposit']).update(type='edit')
def revert_operation_types(apps, schema_editor):
Operation = apps.get_model("kfet", "Operation")
edits = Operation.objects.filter(type='edit')
edits.filter(amount__gt=0).update(type='deposit')
edits.filter(amount__lte=0).update(type='withdraw')
class Migration(migrations.Migration):
dependencies = [
('kfet', '0049_merge'),
]
operations = [
migrations.AlterField(
model_name='operation',
name='type',
field=models.CharField(choices=[('purchase', 'Achat'), ('deposit', 'Charge'), ('withdraw', 'Retrait'), ('initial', 'Initial'), ('edit', 'Édition')], max_length=8),
),
migrations.RunPython(adapt_operation_types, revert_operation_types),
migrations.RemoveField(
model_name='operation',
name='is_checkout',
),
]

View file

@ -0,0 +1,210 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('kfet', '0050_remove_checkout'),
]
operations = [
migrations.AlterField(
model_name='account',
name='is_frozen',
field=models.BooleanField(default=False, verbose_name='est gelé'),
),
migrations.AlterField(
model_name='account',
name='nickname',
field=models.CharField(default='', max_length=255, verbose_name='surnom(s)', blank=True),
),
migrations.AlterField(
model_name='accountnegative',
name='authz_overdraft_amount',
field=models.DecimalField(max_digits=6, blank=True, default=None, null=True, verbose_name='négatif autorisé', decimal_places=2),
),
migrations.AlterField(
model_name='accountnegative',
name='authz_overdraft_until',
field=models.DateTimeField(default=None, null=True, verbose_name='expiration du négatif', blank=True),
),
migrations.AlterField(
model_name='accountnegative',
name='balance_offset',
field=models.DecimalField(blank=True, max_digits=6, help_text="Montant non compris dans l'autorisation de négatif", default=None, null=True, verbose_name='décalage de balance', decimal_places=2),
),
migrations.AlterField(
model_name='accountnegative',
name='comment',
field=models.CharField(blank=True, max_length=255, verbose_name='commentaire'),
),
migrations.AlterField(
model_name='article',
name='box_capacity',
field=models.PositiveSmallIntegerField(default=None, null=True, verbose_name='capacité du contenant', blank=True),
),
migrations.AlterField(
model_name='article',
name='box_type',
field=models.CharField(blank=True, max_length=7, choices=[('caisse', 'caisse'), ('carton', 'carton'), ('palette', 'palette'), ('fût', 'fût')], default=None, null=True, verbose_name='type de contenant'),
),
migrations.AlterField(
model_name='article',
name='category',
field=models.ForeignKey(related_name='articles', to='kfet.ArticleCategory', on_delete=django.db.models.deletion.PROTECT, verbose_name='catégorie'),
),
migrations.AlterField(
model_name='article',
name='hidden',
field=models.BooleanField(default=False, verbose_name='caché', help_text='Si oui, ne sera pas affiché au public ; par exemple sur la carte.'),
),
migrations.AlterField(
model_name='article',
name='is_sold',
field=models.BooleanField(default=True, verbose_name='en vente'),
),
migrations.AlterField(
model_name='article',
name='name',
field=models.CharField(max_length=45, verbose_name='nom'),
),
migrations.AlterField(
model_name='article',
name='price',
field=models.DecimalField(default=0, verbose_name='prix', decimal_places=2, max_digits=6),
),
migrations.AlterField(
model_name='checkoutstatement',
name='amount_error',
field=models.DecimalField(max_digits=6, verbose_name="montant de l'erreur", decimal_places=2),
),
migrations.AlterField(
model_name='checkoutstatement',
name='amount_taken',
field=models.DecimalField(max_digits=6, verbose_name='montant pris', decimal_places=2),
),
migrations.AlterField(
model_name='checkoutstatement',
name='balance_new',
field=models.DecimalField(max_digits=6, verbose_name='nouvelle balance', decimal_places=2),
),
migrations.AlterField(
model_name='checkoutstatement',
name='balance_old',
field=models.DecimalField(max_digits=6, verbose_name='ancienne balance', decimal_places=2),
),
migrations.AlterField(
model_name='checkoutstatement',
name='not_count',
field=models.BooleanField(default=False, verbose_name='caisse non comptée'),
),
migrations.AlterField(
model_name='checkoutstatement',
name='taken_001',
field=models.PositiveSmallIntegerField(default=0, verbose_name='pièces de 1¢'),
),
migrations.AlterField(
model_name='checkoutstatement',
name='taken_002',
field=models.PositiveSmallIntegerField(default=0, verbose_name='pièces de 2¢'),
),
migrations.AlterField(
model_name='checkoutstatement',
name='taken_005',
field=models.PositiveSmallIntegerField(default=0, verbose_name='pièces de 5¢'),
),
migrations.AlterField(
model_name='checkoutstatement',
name='taken_01',
field=models.PositiveSmallIntegerField(default=0, verbose_name='pièces de 10¢'),
),
migrations.AlterField(
model_name='checkoutstatement',
name='taken_02',
field=models.PositiveSmallIntegerField(default=0, verbose_name='pièces de 20¢'),
),
migrations.AlterField(
model_name='checkoutstatement',
name='taken_05',
field=models.PositiveSmallIntegerField(default=0, verbose_name='pièces de 50¢'),
),
migrations.AlterField(
model_name='checkoutstatement',
name='taken_1',
field=models.PositiveSmallIntegerField(default=0, verbose_name='pièces de 1€'),
),
migrations.AlterField(
model_name='checkoutstatement',
name='taken_10',
field=models.PositiveSmallIntegerField(default=0, verbose_name='billets de 10€'),
),
migrations.AlterField(
model_name='checkoutstatement',
name='taken_100',
field=models.PositiveSmallIntegerField(default=0, verbose_name='billets de 100€'),
),
migrations.AlterField(
model_name='checkoutstatement',
name='taken_2',
field=models.PositiveSmallIntegerField(default=0, verbose_name='pièces de 2€'),
),
migrations.AlterField(
model_name='checkoutstatement',
name='taken_20',
field=models.PositiveSmallIntegerField(default=0, verbose_name='billets de 20€'),
),
migrations.AlterField(
model_name='checkoutstatement',
name='taken_200',
field=models.PositiveSmallIntegerField(default=0, verbose_name='billets de 200€'),
),
migrations.AlterField(
model_name='checkoutstatement',
name='taken_5',
field=models.PositiveSmallIntegerField(default=0, verbose_name='billets de 5€'),
),
migrations.AlterField(
model_name='checkoutstatement',
name='taken_50',
field=models.PositiveSmallIntegerField(default=0, verbose_name='billets de 50€'),
),
migrations.AlterField(
model_name='checkoutstatement',
name='taken_500',
field=models.PositiveSmallIntegerField(default=0, verbose_name='billets de 500€'),
),
migrations.AlterField(
model_name='checkoutstatement',
name='taken_cheque',
field=models.DecimalField(default=0, verbose_name='montant des chèques', decimal_places=2, max_digits=6),
),
migrations.AlterField(
model_name='supplier',
name='address',
field=models.TextField(verbose_name='adresse'),
),
migrations.AlterField(
model_name='supplier',
name='comment',
field=models.TextField(verbose_name='commentaire'),
),
migrations.AlterField(
model_name='supplier',
name='email',
field=models.EmailField(max_length=254, verbose_name='adresse mail'),
),
migrations.AlterField(
model_name='supplier',
name='name',
field=models.CharField(max_length=45, verbose_name='nom'),
),
migrations.AlterField(
model_name='supplier',
name='phone',
field=models.CharField(max_length=10, verbose_name='téléphone'),
),
]

View file

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('kfet', '0051_verbose_names'),
]
operations = [
migrations.AddField(
model_name='articlecategory',
name='has_addcost',
field=models.BooleanField(default=True, help_text="Si oui et qu'une majoration est active, celle-ci sera appliquée aux articles de cette catégorie.", verbose_name='majorée'),
),
migrations.AlterField(
model_name='articlecategory',
name='name',
field=models.CharField(max_length=45, verbose_name='nom'),
),
]

View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('kfet', '0052_category_addcost'),
]
operations = [
migrations.AlterField(
model_name='account',
name='created_at',
field=models.DateTimeField(default=django.utils.timezone.now),
),
]

View file

@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
from kfet.forms import KFetConfigForm
def adapt_settings(apps, schema_editor):
Settings = apps.get_model('kfet', 'Settings')
db_alias = schema_editor.connection.alias
obj = Settings.objects.using(db_alias)
cfg = {}
def try_get(new, old, type_field):
try:
value = getattr(obj.get(name=old), type_field)
cfg[new] = value
except Settings.DoesNotExist:
pass
try:
subvention = obj.get(name='SUBVENTION_COF').value_decimal
subvention_mult = 1 + subvention/100
reduction = (1 - 1/subvention_mult) * 100
cfg['kfet_reduction_cof'] = reduction
except Settings.DoesNotExist:
pass
try_get('kfet_addcost_amount', 'ADDCOST_AMOUNT', 'value_decimal')
try_get('kfet_addcost_for', 'ADDCOST_FOR', 'value_account')
try_get('kfet_overdraft_duration', 'OVERDRAFT_DURATION', 'value_duration')
try_get('kfet_overdraft_amount', 'OVERDRAFT_AMOUNT', 'value_decimal')
try_get('kfet_cancel_duration', 'CANCEL_DURATION', 'value_duration')
cfg_form = KFetConfigForm(initial=cfg)
if cfg_form.is_valid():
cfg_form.save()
class Migration(migrations.Migration):
dependencies = [
('kfet', '0053_created_at'),
('djconfig', '0001_initial'),
]
operations = [
migrations.RunPython(adapt_settings),
migrations.RemoveField(
model_name='settings',
name='value_account',
),
migrations.DeleteModel(
name='Settings',
),
]

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 = [
('kfet', '0053_created_at'),
]
operations = [
migrations.AlterField(
model_name='account',
name='promo',
field=models.IntegerField(blank=True, choices=[(1980, 1980), (1981, 1981), (1982, 1982), (1983, 1983), (1984, 1984), (1985, 1985), (1986, 1986), (1987, 1987), (1988, 1988), (1989, 1989), (1990, 1990), (1991, 1991), (1992, 1992), (1993, 1993), (1994, 1994), (1995, 1995), (1996, 1996), (1997, 1997), (1998, 1998), (1999, 1999), (2000, 2000), (2001, 2001), (2002, 2002), (2003, 2003), (2004, 2004), (2005, 2005), (2006, 2006), (2007, 2007), (2008, 2008), (2009, 2009), (2010, 2010), (2011, 2011), (2012, 2012), (2013, 2013), (2014, 2014), (2015, 2015), (2016, 2016), (2017, 2017)], default=2017, null=True),
),
]

View file

@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
def forwards_perms(apps, schema_editor):
"""Safely delete content type for old kfet.GlobalPermissions model.
Any permissions (except defaults) linked to this content type are updated
to link at its new content type.
Then, delete the content type. This will delete the three defaults
permissions which are assumed unused.
"""
ContentType = apps.get_model('contenttypes', 'contenttype')
try:
ctype_global = ContentType.objects.get(
app_label="kfet", model="globalpermissions",
)
except ContentType.DoesNotExist:
# We are not migrating from existing data, nothing to do.
return
perms = {
'account': (
'is_team', 'manage_perms', 'manage_addcosts',
'edit_balance_account', 'change_account_password',
'special_add_account',
),
'accountnegative': ('view_negs',),
'inventory': ('order_to_inventory',),
'operation': (
'perform_deposit', 'perform_negative_operations',
'override_frozen_protection', 'cancel_old_operations',
'perform_commented_operations',
),
}
Permission = apps.get_model('auth', 'permission')
global_perms = Permission.objects.filter(content_type=ctype_global)
for modelname, codenames in perms.items():
model = apps.get_model('kfet', modelname)
ctype = ContentType.objects.get_for_model(model)
(
global_perms
.filter(codename__in=codenames)
.update(content_type=ctype)
)
ctype_global.delete()
class Migration(migrations.Migration):
dependencies = [
('kfet', '0054_delete_settings'),
('contenttypes', '__latest__'),
('auth', '__latest__'),
]
operations = [
migrations.AlterModelOptions(
name='account',
options={'permissions': (('is_team', 'Is part of the team'), ('manage_perms', 'Gérer les permissions K-Fêt'), ('manage_addcosts', 'Gérer les majorations'), ('edit_balance_account', "Modifier la balance d'un compte"), ('change_account_password', "Modifier le mot de passe d'une personne de l'équipe"), ('special_add_account', 'Créer un compte avec une balance initiale'))},
),
migrations.AlterModelOptions(
name='accountnegative',
options={'permissions': (('view_negs', 'Voir la liste des négatifs'),)},
),
migrations.AlterModelOptions(
name='inventory',
options={'ordering': ['-at'], 'permissions': (('order_to_inventory', "Générer un inventaire à partir d'une commande"),)},
),
migrations.AlterModelOptions(
name='operation',
options={'permissions': (('perform_deposit', 'Effectuer une charge'), ('perform_negative_operations', 'Enregistrer des commandes en négatif'), ('override_frozen_protection', "Forcer le gel d'un compte"), ('cancel_old_operations', 'Annuler des commandes non récentes'), ('perform_commented_operations', 'Enregistrer des commandes avec commentaires'))},
),
migrations.RunPython(forwards_perms),
]

View file

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('kfet', '0055_move_permissions'),
]
operations = [
migrations.AlterModelOptions(
name='account',
options={'permissions': (('is_team', 'Is part of the team'), ('manage_perms', 'Gérer les permissions K-Fêt'), ('manage_addcosts', 'Gérer les majorations'), ('edit_balance_account', "Modifier la balance d'un compte"), ('change_account_password', "Modifier le mot de passe d'une personne de l'équipe"), ('special_add_account', 'Créer un compte avec une balance initiale'), ('can_force_close', 'Fermer manuellement la K-Fêt'))},
),
]

View file

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('kfet', '0056_change_account_meta'),
('kfet', '0054_update_promos'),
]
operations = [
]

View file

@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('kfet', '0057_merge'),
]
operations = [
migrations.DeleteModel(
name='GenericTeamToken',
),
]

View file

@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from kfet.auth import KFET_GENERIC_TRIGRAMME, KFET_GENERIC_USERNAME
def setup_kfet_generic_user(apps, schema_editor):
"""
Setup models instances for the kfet generic account.
Username and trigramme are retrieved from kfet.auth.__init__ module.
Other data are registered here.
See also setup_kfet_generic_user from kfet.auth.utils module.
"""
User = apps.get_model('auth', 'User')
CofProfile = apps.get_model('gestioncof', 'CofProfile')
Account = apps.get_model('kfet', 'Account')
user, _ = User.objects.update_or_create(
username=KFET_GENERIC_USERNAME,
defaults={
'first_name': 'Compte générique K-Fêt',
},
)
profile, _ = CofProfile.objects.update_or_create(user=user)
account, _ = Account.objects.update_or_create(
cofprofile=profile,
defaults={
'trigramme': KFET_GENERIC_TRIGRAMME,
},
)
class Migration(migrations.Migration):
dependencies = [
('kfet', '0058_delete_genericteamtoken'),
]
operations = [
migrations.RunPython(setup_kfet_generic_user),
]

View file

@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('kfet', '0059_create_generic'),
]
operations = [
migrations.AlterField(
model_name='supplier',
name='address',
field=models.TextField(verbose_name='adresse', blank=True),
),
migrations.AlterField(
model_name='supplier',
name='articles',
field=models.ManyToManyField(verbose_name='articles vendus', through='kfet.SupplierArticle', related_name='suppliers', to='kfet.Article'),
),
migrations.AlterField(
model_name='supplier',
name='comment',
field=models.TextField(verbose_name='commentaire', blank=True),
),
migrations.AlterField(
model_name='supplier',
name='email',
field=models.EmailField(max_length=254, verbose_name='adresse mail', blank=True),
),
migrations.AlterField(
model_name='supplier',
name='phone',
field=models.CharField(max_length=20, verbose_name='téléphone', blank=True),
),
]

View file

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('kfet', '0060_amend_supplier'),
]
operations = [
migrations.AlterModelOptions(
name='account',
options={'permissions': (('is_team', 'Is part of the team'), ('manage_perms', 'Gérer les permissions K-Fêt'), ('manage_addcosts', 'Gérer les majorations'), ('edit_balance_account', "Modifier la balance d'un compte"), ('change_account_password', "Modifier le mot de passe d'une personne de l'équipe"), ('special_add_account', 'Créer un compte avec une balance initiale'), ('can_force_close', 'Fermer manuellement la K-Fêt'), ('see_config', 'Voir la configuration K-Fêt'), ('change_config', 'Modifier la configuration K-Fêt'))},
),
]

View file

@ -1,39 +1,69 @@
# -*- coding: utf-8 -*-
from __future__ import (absolute_import, division,
print_function, unicode_literals)
from builtins import *
from datetime import date, timedelta
import re import re
from django.db import models from datetime import date
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils.six.moves import reduce from django.db import models
from django.utils import timezone
from django.utils.encoding import python_2_unicode_compatible
from django.db import transaction from django.db import transaction
from django.db.models import F from django.db.models import F
from django.core.cache import cache from django.utils import timezone
from django.utils.encoding import python_2_unicode_compatible
from django.utils.six.moves import reduce
from django.utils.translation import ugettext_lazy as _
from gestion.models import Profile from gestion.models import Profile
from .auth import KFET_GENERIC_TRIGRAMME
from .auth.models import GenericTeamToken # noqa
from .config import kfet_config
from .utils import to_ukf
def choices_length(choices): def choices_length(choices):
return reduce(lambda m, choice: max(m, len(choice[0])), choices, 0) return reduce(lambda m, choice: max(m, len(choice[0])), choices, 0)
def default_promo(): def default_promo():
now = date.today() now = date.today()
return now.month <= 8 and now.year-1 or now.year return now.month <= 8 and now.year-1 or now.year
@python_2_unicode_compatible
class AccountManager(models.Manager):
"""Manager for Account Model."""
def get_queryset(self):
"""Always append related data to this Account."""
return super().get_queryset().select_related('cofprofile__user',
'negative')
def get_generic(self):
"""
Get the kfet generic account instance.
"""
return self.get(trigramme=KFET_GENERIC_TRIGRAMME)
def get_by_password(self, password):
"""
Get a kfet generic account by clear password.
Raises Account.DoesNotExist if no Account has this password.
"""
from .auth.utils import hash_password
if password is None:
raise self.model.DoesNotExist
return self.get(password=hash_password(password))
class Account(models.Model): class Account(models.Model):
# XXX. change this to "profile" objects = AccountManager()
profile = models.OneToOneField(Profile,
related_name="account_kfet", profile = models.OneToOneField(
on_delete=models.CASCADE) Profile,
related_name="account_kfet",
on_delete=models.PROTECT
)
trigramme = models.CharField( trigramme = models.CharField(
unique = True, unique = True,
max_length = 3, max_length = 3,
@ -42,14 +72,15 @@ class Account(models.Model):
balance = models.DecimalField( balance = models.DecimalField(
max_digits = 6, decimal_places = 2, max_digits = 6, decimal_places = 2,
default = 0) default = 0)
is_frozen = models.BooleanField(default = False) is_frozen = models.BooleanField("est gelé", default = False)
created_at = models.DateTimeField(auto_now_add = True, null = True) created_at = models.DateTimeField(default=timezone.now)
# Optional # Optional
PROMO_CHOICES = [(r,r) for r in range(1980, date.today().year+1)] PROMO_CHOICES = [(r,r) for r in range(1980, date.today().year+1)]
promo = models.IntegerField( promo = models.IntegerField(
choices = PROMO_CHOICES, choices = PROMO_CHOICES,
blank = True, null = True, default = default_promo()) blank = True, null = True, default = default_promo())
nickname = models.CharField( nickname = models.CharField(
"surnom(s)",
max_length = 255, max_length = 255,
blank = True, default = "") blank = True, default = "")
password = models.CharField( password = models.CharField(
@ -57,36 +88,62 @@ class Account(models.Model):
unique = True, unique = True,
blank = True, null = True, default = None) blank = True, null = True, default = None)
class Meta:
permissions = (
('is_team', 'Is part of the team'),
('manage_perms', 'Gérer les permissions K-Fêt'),
('manage_addcosts', 'Gérer les majorations'),
('edit_balance_account', "Modifier la balance d'un compte"),
('change_account_password',
"Modifier le mot de passe d'une personne de l'équipe"),
('special_add_account',
"Créer un compte avec une balance initiale"),
('can_force_close', "Fermer manuellement la K-Fêt"),
('see_config', "Voir la configuration K-Fêt"),
('change_config', "Modifier la configuration K-Fêt"),
)
def __str__(self): def __str__(self):
return '%s (%s)' % (self.trigramme, self.name) return '%s (%s)' % (self.trigramme, self.name)
# Propriétés pour accéder aux attributs de user et profile et user # Propriétés pour accéder aux attributs de user et profile
@property @property
def user(self): def user(self):
return self.profile.user return self.profile.user
@property @property
def username(self): def username(self):
return self.profile.user.username return self.profile.user.username
@property @property
def first_name(self): def first_name(self):
return self.profile.user.first_name return self.profile.user.first_name
@property @property
def last_name(self): def last_name(self):
return self.profile.user.last_name return self.profile.user.last_name
@property @property
def email(self): def email(self):
return self.profile.user.email return self.profile.user.email
@property @property
def departement(self): def departement(self):
return self.profile.departement return self.profile.departement
@property @property
def is_cof(self): def is_cof(self):
return self.profile.cof.is_cof return self.profile.cof.is_cof
# Propriétés supplémentaires # Propriétés supplémentaires
@property
def balance_ukf(self):
return to_ukf(self.balance, is_cof=self.is_cof)
@property @property
def real_balance(self): def real_balance(self):
if (hasattr(self, 'negative')): if hasattr(self, 'negative') and self.negative.balance_offset:
return self.balance - self.negative.balance_offset return self.balance - self.negative.balance_offset
return self.balance return self.balance
@ -102,6 +159,14 @@ class Account(models.Model):
def need_comment(self): def need_comment(self):
return self.trigramme == '#13' return self.trigramme == '#13'
@property
def readable(self):
return self.trigramme != 'GNR'
@property
def is_team(self):
return self.has_perm('kfet.is_team')
@staticmethod @staticmethod
def is_validandfree(trigramme): def is_validandfree(trigramme):
data = { 'is_valid' : False, 'is_free' : False } data = { 'is_valid' : False, 'is_free' : False }
@ -114,8 +179,8 @@ class Account(models.Model):
return data return data
def perms_to_perform_operation(self, amount): def perms_to_perform_operation(self, amount):
overdraft_duration_max = Settings.OVERDRAFT_DURATION() overdraft_duration_max = kfet_config.overdraft_duration
overdraft_amount_max = Settings.OVERDRAFT_AMOUNT() overdraft_amount_max = kfet_config.overdraft_amount
perms = set() perms = set()
stop_ope = False stop_ope = False
# Checking is cash account # Checking is cash account
@ -157,6 +222,7 @@ class Account(models.Model):
# - Enregistre User, Profile à partir de "data" # - Enregistre User, Profile à partir de "data"
# - Enregistre Account # - Enregistre Account
def save(self, data = {}, *args, **kwargs): def save(self, data = {}, *args, **kwargs):
if self.pk and data: if self.pk and data:
# Account update # Account update
@ -203,33 +269,89 @@ class Account(models.Model):
self.profile = profile self.profile = profile
super(Account, self).save(*args, **kwargs) super(Account, self).save(*args, **kwargs)
def change_pwd(self, clear_password):
from .auth.utils import hash_password
self.password = hash_password(clear_password)
# Surcharge de delete # Surcharge de delete
# Pas de suppression possible # Pas de suppression possible
# Cas à régler plus tard # Cas à régler plus tard
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
pass pass
def update_negative(self):
if self.real_balance < 0:
if hasattr(self, 'negative') and not self.negative.start:
self.negative.start = timezone.now()
self.negative.save()
elif not hasattr(self, 'negative'):
self.negative = (
AccountNegative.objects.create(
account=self, start=timezone.now(),
)
)
elif hasattr(self, 'negative'):
# self.real_balance >= 0
balance_offset = self.negative.balance_offset
if balance_offset:
(
Account.objects
.filter(pk=self.pk)
.update(balance=F('balance')-balance_offset)
)
self.refresh_from_db()
self.negative.delete()
class UserHasAccount(Exception): class UserHasAccount(Exception):
def __init__(self, trigramme): def __init__(self, trigramme):
self.trigramme = trigramme self.trigramme = trigramme
class AccountNegative(models.Model):
account = models.OneToOneField(
Account, on_delete = models.PROTECT,
related_name = "negative")
start = models.DateTimeField(
blank = True, null = True, default = None)
balance_offset = models.DecimalField(
max_digits = 6, decimal_places = 2,
blank = True, null = True, default = None)
authz_overdraft_amount = models.DecimalField(
max_digits = 6, decimal_places = 2,
blank = True, null = True, default = None)
authz_overdraft_until = models.DateTimeField(
blank = True, null = True, default = None)
comment = models.CharField(max_length = 255, blank = True)
@python_2_unicode_compatible class AccountNegativeManager(models.Manager):
"""Manager for AccountNegative model."""
def get_queryset(self):
return (
super().get_queryset()
.select_related('account__cofprofile__user')
)
class AccountNegative(models.Model):
objects = AccountNegativeManager()
account = models.OneToOneField(
Account, on_delete=models.PROTECT,
related_name="negative",
)
start = models.DateTimeField(blank=True, null=True, default=None)
balance_offset = models.DecimalField(
"décalage de balance",
help_text="Montant non compris dans l'autorisation de négatif",
max_digits=6, decimal_places=2,
blank=True, null=True, default=None,
)
authz_overdraft_amount = models.DecimalField(
"négatif autorisé",
max_digits=6, decimal_places=2,
blank=True, null=True, default=None,
)
authz_overdraft_until = models.DateTimeField(
"expiration du négatif",
blank=True, null=True, default=None,
)
comment = models.CharField("commentaire", max_length=255, blank=True)
class Meta:
permissions = (
('view_negs', 'Voir la liste des négatifs'),
)
@property
def until_default(self):
return self.start + kfet_config.overdraft_duration
class Checkout(models.Model): class Checkout(models.Model):
created_by = models.ForeignKey( created_by = models.ForeignKey(
Account, on_delete = models.PROTECT, Account, on_delete = models.PROTECT,
@ -269,29 +391,35 @@ class CheckoutStatement(models.Model):
checkout = models.ForeignKey( checkout = models.ForeignKey(
Checkout, on_delete = models.PROTECT, Checkout, on_delete = models.PROTECT,
related_name = "statements") related_name = "statements")
balance_old = models.DecimalField(max_digits = 6, decimal_places = 2) balance_old = models.DecimalField("ancienne balance",
balance_new = models.DecimalField(max_digits = 6, decimal_places = 2) max_digits = 6, decimal_places = 2)
amount_taken = models.DecimalField(max_digits = 6, decimal_places = 2) balance_new = models.DecimalField("nouvelle balance",
amount_error = models.DecimalField(max_digits = 6, decimal_places = 2) max_digits = 6, decimal_places = 2)
amount_taken = models.DecimalField("montant pris",
max_digits = 6, decimal_places = 2)
amount_error = models.DecimalField("montant de l'erreur",
max_digits = 6, decimal_places = 2)
at = models.DateTimeField(auto_now_add = True) at = models.DateTimeField(auto_now_add = True)
not_count = models.BooleanField(default=False) not_count = models.BooleanField("caisse non comptée", default=False)
taken_001 = models.PositiveSmallIntegerField(default=0) taken_001 = models.PositiveSmallIntegerField("pièces de 1¢", default=0)
taken_002 = models.PositiveSmallIntegerField(default=0) taken_002 = models.PositiveSmallIntegerField("pièces de 2¢", default=0)
taken_005 = models.PositiveSmallIntegerField(default=0) taken_005 = models.PositiveSmallIntegerField("pièces de 5¢", default=0)
taken_01 = models.PositiveSmallIntegerField(default=0) taken_01 = models.PositiveSmallIntegerField("pièces de 10¢", default=0)
taken_02 = models.PositiveSmallIntegerField(default=0) taken_02 = models.PositiveSmallIntegerField("pièces de 20¢", default=0)
taken_05 = models.PositiveSmallIntegerField(default=0) taken_05 = models.PositiveSmallIntegerField("pièces de 50¢", default=0)
taken_1 = models.PositiveSmallIntegerField(default=0) taken_1 = models.PositiveSmallIntegerField("pièces de 1€", default=0)
taken_2 = models.PositiveSmallIntegerField(default=0) taken_2 = models.PositiveSmallIntegerField("pièces de 2€", default=0)
taken_5 = models.PositiveSmallIntegerField(default=0) taken_5 = models.PositiveSmallIntegerField("billets de 5€", default=0)
taken_10 = models.PositiveSmallIntegerField(default=0) taken_10 = models.PositiveSmallIntegerField("billets de 10€", default=0)
taken_20 = models.PositiveSmallIntegerField(default=0) taken_20 = models.PositiveSmallIntegerField("billets de 20€", default=0)
taken_50 = models.PositiveSmallIntegerField(default=0) taken_50 = models.PositiveSmallIntegerField("billets de 50€", default=0)
taken_100 = models.PositiveSmallIntegerField(default=0) taken_100 = models.PositiveSmallIntegerField("billets de 100€", default=0)
taken_200 = models.PositiveSmallIntegerField(default=0) taken_200 = models.PositiveSmallIntegerField("billets de 200€", default=0)
taken_500 = models.PositiveSmallIntegerField(default=0) taken_500 = models.PositiveSmallIntegerField("billets de 500€", default=0)
taken_cheque = models.DecimalField(default=0, max_digits=6, decimal_places=2) taken_cheque = models.DecimalField(
"montant des chèques",
default=0, max_digits=6, decimal_places=2)
def __str__(self): def __str__(self):
return '%s %s' % (self.checkout, self.at) return '%s %s' % (self.checkout, self.at)
@ -323,24 +451,37 @@ class CheckoutStatement(models.Model):
balance=F('balance') - last_statement.balance_new + self.balance_new) balance=F('balance') - last_statement.balance_new + self.balance_new)
super(CheckoutStatement, self).save(*args, **kwargs) super(CheckoutStatement, self).save(*args, **kwargs)
@python_2_unicode_compatible @python_2_unicode_compatible
class ArticleCategory(models.Model): class ArticleCategory(models.Model):
name = models.CharField(max_length = 45) name = models.CharField("nom", max_length=45)
has_addcost = models.BooleanField("majorée", default=True,
help_text="Si oui et qu'une majoration "
"est active, celle-ci sera "
"appliquée aux articles de "
"cette catégorie.")
def __str__(self): def __str__(self):
return self.name return self.name
@python_2_unicode_compatible @python_2_unicode_compatible
class Article(models.Model): class Article(models.Model):
name = models.CharField(max_length = 45) name = models.CharField("nom", max_length = 45)
is_sold = models.BooleanField(default = True) is_sold = models.BooleanField("en vente", default = True)
hidden = models.BooleanField("caché",
default=False,
help_text="Si oui, ne sera pas affiché "
"au public ; par exemple "
"sur la carte.")
price = models.DecimalField( price = models.DecimalField(
"prix",
max_digits = 6, decimal_places = 2, max_digits = 6, decimal_places = 2,
default = 0) default = 0)
stock = models.IntegerField(default = 0) stock = models.IntegerField(default = 0)
category = models.ForeignKey( category = models.ForeignKey(
ArticleCategory, on_delete = models.PROTECT, ArticleCategory, on_delete = models.PROTECT,
related_name = "articles") related_name = "articles", verbose_name='catégorie')
BOX_TYPE_CHOICES = ( BOX_TYPE_CHOICES = (
("caisse", "caisse"), ("caisse", "caisse"),
("carton", "carton"), ("carton", "carton"),
@ -348,10 +489,12 @@ class Article(models.Model):
("fût", "fût"), ("fût", "fût"),
) )
box_type = models.CharField( box_type = models.CharField(
"type de contenant",
choices = BOX_TYPE_CHOICES, choices = BOX_TYPE_CHOICES,
max_length = choices_length(BOX_TYPE_CHOICES), max_length = choices_length(BOX_TYPE_CHOICES),
blank = True, null = True, default = None) blank = True, null = True, default = None)
box_capacity = models.PositiveSmallIntegerField( box_capacity = models.PositiveSmallIntegerField(
"capacité du contenant",
blank = True, null = True, default = None) blank = True, null = True, default = None)
def __str__(self): def __str__(self):
@ -360,6 +503,10 @@ class Article(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('kfet.article.read', kwargs={'pk': self.pk}) return reverse('kfet.article.read', kwargs={'pk': self.pk})
def price_ukf(self):
return to_ukf(self.price)
class ArticleRule(models.Model): class ArticleRule(models.Model):
article_on = models.OneToOneField( article_on = models.OneToOneField(
Article, on_delete = models.PROTECT, Article, on_delete = models.PROTECT,
@ -386,6 +533,10 @@ class Inventory(models.Model):
class Meta: class Meta:
ordering = ['-at'] ordering = ['-at']
permissions = (
('order_to_inventory', "Générer un inventaire à partir d'une commande"),
)
class InventoryArticle(models.Model): class InventoryArticle(models.Model):
inventory = models.ForeignKey( inventory = models.ForeignKey(
@ -403,21 +554,24 @@ class InventoryArticle(models.Model):
self.stock_error = self.stock_new - self.stock_old self.stock_error = self.stock_new - self.stock_old
super(InventoryArticle, self).save(*args, **kwargs) super(InventoryArticle, self).save(*args, **kwargs)
@python_2_unicode_compatible
class Supplier(models.Model): class Supplier(models.Model):
articles = models.ManyToManyField( articles = models.ManyToManyField(
Article, Article,
through = 'SupplierArticle', verbose_name=_("articles vendus"),
related_name = "suppliers") through='SupplierArticle',
name = models.CharField(max_length = 45) related_name='suppliers',
address = models.TextField() )
email = models.EmailField() name = models.CharField(_("nom"), max_length=45)
phone = models.CharField(max_length = 10) address = models.TextField(_("adresse"), blank=True)
comment = models.TextField() email = models.EmailField(_("adresse mail"), blank=True)
phone = models.CharField(_("téléphone"), max_length=20, blank=True)
comment = models.TextField(_("commentaire"), blank=True)
def __str__(self): def __str__(self):
return self.name return self.name
class SupplierArticle(models.Model): class SupplierArticle(models.Model):
supplier = models.ForeignKey( supplier = models.ForeignKey(
Supplier, on_delete = models.PROTECT) Supplier, on_delete = models.PROTECT)
@ -458,7 +612,7 @@ class OrderArticle(models.Model):
quantity_received = models.IntegerField(default = 0) quantity_received = models.IntegerField(default = 0)
class TransferGroup(models.Model): class TransferGroup(models.Model):
at = models.DateTimeField(auto_now_add = True) at = models.DateTimeField(default=timezone.now)
# Optional # Optional
comment = models.CharField( comment = models.CharField(
max_length = 255, max_length = 255,
@ -468,24 +622,29 @@ class TransferGroup(models.Model):
related_name = "+", related_name = "+",
blank = True, null = True, default = None) blank = True, null = True, default = None)
class Transfer(models.Model): class Transfer(models.Model):
group = models.ForeignKey( group = models.ForeignKey(
TransferGroup, on_delete = models.PROTECT, TransferGroup, on_delete=models.PROTECT,
related_name = "transfers") related_name="transfers")
from_acc = models.ForeignKey( from_acc = models.ForeignKey(
Account, on_delete = models.PROTECT, Account, on_delete=models.PROTECT,
related_name = "transfers_from") related_name="transfers_from")
to_acc = models.ForeignKey( to_acc = models.ForeignKey(
Account, on_delete = models.PROTECT, Account, on_delete=models.PROTECT,
related_name = "transfers_to") related_name="transfers_to")
amount = models.DecimalField(max_digits = 6, decimal_places = 2) amount = models.DecimalField(max_digits=6, decimal_places=2)
# Optional # Optional
canceled_by = models.ForeignKey( canceled_by = models.ForeignKey(
Account, on_delete = models.PROTECT, Account, on_delete=models.PROTECT,
null = True, blank = True, default = None, null=True, blank=True, default=None,
related_name = "+") related_name="+")
canceled_at = models.DateTimeField( canceled_at = models.DateTimeField(
null = True, blank = True, default = None) null=True, blank=True, default=None)
def __str__(self):
return '{} -> {}: {}'.format(self.from_acc, self.to_acc, self.amount)
class OperationGroup(models.Model): class OperationGroup(models.Model):
on_acc = models.ForeignKey( on_acc = models.ForeignKey(
@ -494,7 +653,7 @@ class OperationGroup(models.Model):
checkout = models.ForeignKey( checkout = models.ForeignKey(
Checkout, on_delete = models.PROTECT, Checkout, on_delete = models.PROTECT,
related_name = "opesgroup") related_name = "opesgroup")
at = models.DateTimeField(auto_now_add = True) at = models.DateTimeField(default=timezone.now)
amount = models.DecimalField( amount = models.DecimalField(
max_digits = 6, decimal_places = 2, max_digits = 6, decimal_places = 2,
default = 0) default = 0)
@ -508,180 +667,81 @@ class OperationGroup(models.Model):
related_name = "+", related_name = "+",
blank = True, null = True, default = None) blank = True, null = True, default = None)
def __str__(self):
return ', '.join(map(str, self.opes.all()))
class Operation(models.Model): class Operation(models.Model):
PURCHASE = 'purchase' PURCHASE = 'purchase'
DEPOSIT = 'deposit' DEPOSIT = 'deposit'
WITHDRAW = 'withdraw' WITHDRAW = 'withdraw'
INITIAL = 'initial' INITIAL = 'initial'
EDIT = 'edit'
TYPE_ORDER_CHOICES = ( TYPE_ORDER_CHOICES = (
(PURCHASE, 'Achat'), (PURCHASE, 'Achat'),
(DEPOSIT, 'Charge'), (DEPOSIT, 'Charge'),
(WITHDRAW, 'Retrait'), (WITHDRAW, 'Retrait'),
(INITIAL, 'Initial'), (INITIAL, 'Initial'),
(EDIT, 'Édition'),
) )
group = models.ForeignKey( group = models.ForeignKey(
OperationGroup, on_delete = models.PROTECT, OperationGroup, on_delete=models.PROTECT,
related_name = "opes") related_name="opes")
type = models.CharField( type = models.CharField(
choices = TYPE_ORDER_CHOICES, choices=TYPE_ORDER_CHOICES,
max_length = choices_length(TYPE_ORDER_CHOICES)) max_length=choices_length(TYPE_ORDER_CHOICES))
amount = models.DecimalField( amount = models.DecimalField(
max_digits = 6, decimal_places = 2, max_digits=6, decimal_places=2,
blank = True, default = 0) blank=True, default=0)
is_checkout = models.BooleanField(default = True)
# Optional # Optional
article = models.ForeignKey( article = models.ForeignKey(
Article, on_delete = models.PROTECT, Article, on_delete=models.PROTECT,
related_name = "operations", related_name="operations",
blank = True, null = True, default = None) blank=True, null=True, default=None)
article_nb = models.PositiveSmallIntegerField( article_nb = models.PositiveSmallIntegerField(
blank = True, null = True, default = None) blank=True, null=True, default=None)
canceled_by = models.ForeignKey( canceled_by = models.ForeignKey(
Account, on_delete = models.PROTECT, Account, on_delete=models.PROTECT,
related_name = "+", related_name="+",
blank = True, null = True, default = None) blank=True, null=True, default=None)
canceled_at = models.DateTimeField( canceled_at = models.DateTimeField(
blank = True, null = True, default = None) blank=True, null=True, default=None)
addcost_for = models.ForeignKey( addcost_for = models.ForeignKey(
Account, on_delete = models.PROTECT, Account, on_delete=models.PROTECT,
related_name = "addcosts", related_name="addcosts",
blank = True, null = True, default = None) blank=True, null=True, default=None)
addcost_amount = models.DecimalField( addcost_amount = models.DecimalField(
max_digits = 6, decimal_places = 2, max_digits=6, decimal_places=2,
blank = True, null = True, default = None) blank=True, null=True, default=None)
class GlobalPermissions(models.Model):
class Meta: class Meta:
managed = False
permissions = ( permissions = (
('is_team', 'Is part of the team'),
('perform_deposit', 'Effectuer une charge'), ('perform_deposit', 'Effectuer une charge'),
('perform_negative_operations', ('perform_negative_operations',
'Enregistrer des commandes en négatif'), 'Enregistrer des commandes en négatif'),
('override_frozen_protection', "Forcer le gel d'un compte"), ('override_frozen_protection', "Forcer le gel d'un compte"),
('cancel_old_operations', 'Annuler des commandes non récentes'), ('cancel_old_operations', 'Annuler des commandes non récentes'),
('manage_perms', 'Gérer les permissions K-Fêt'), ('perform_commented_operations',
('manage_addcosts', 'Gérer les majorations'), 'Enregistrer des commandes avec commentaires'),
('perform_commented_operations', 'Enregistrer des commandes avec commentaires'),
('view_negs', 'Voir la liste des négatifs'),
('order_to_inventory', "Générer un inventaire à partir d'une commande"),
('edit_balance_account', "Modifier la balance d'un compte"),
('change_account_password', "Modifier le mot de passe d'une personne de l'équipe"),
('special_add_account', "Créer un compte avec une balance initiale")
) )
class Settings(models.Model): @property
name = models.CharField( def is_checkout(self):
max_length = 45, return (self.type == Operation.DEPOSIT or
unique = True, self.type == Operation.WITHDRAW or
db_index = True) (self.type == Operation.PURCHASE and self.group.on_acc.is_cash)
value_decimal = models.DecimalField( )
max_digits = 6, decimal_places = 2,
blank = True, null = True, default = None)
value_account = models.ForeignKey(
Account, on_delete = models.PROTECT,
blank = True, null = True, default = None)
value_duration = models.DurationField(
blank = True, null = True, default = None)
@staticmethod def __str__(self):
def setting_inst(name): templates = {
return Settings.objects.get(name=name) self.PURCHASE: "{nb} {article.name} ({amount}€)",
self.DEPOSIT: "charge ({amount}€)",
@staticmethod self.WITHDRAW: "retrait ({amount}€)",
def SUBVENTION_COF(): self.INITIAL: "initial ({amount}€)",
subvention_cof = cache.get('SUBVENTION_COF') self.EDIT: "édition ({amount}€)",
if subvention_cof: }
return subvention_cof return templates[self.type].format(nb=self.article_nb,
try: article=self.article,
subvention_cof = Settings.setting_inst("SUBVENTION_COF").value_decimal amount=self.amount)
except Settings.DoesNotExist:
subvention_cof = 0
cache.set('SUBVENTION_COF', subvention_cof)
return subvention_cof
@staticmethod
def ADDCOST_AMOUNT():
try:
return Settings.setting_inst("ADDCOST_AMOUNT").value_decimal
except Settings.DoesNotExist:
return 0
@staticmethod
def ADDCOST_FOR():
try:
return Settings.setting_inst("ADDCOST_FOR").value_account
except Settings.DoesNotExist:
return None;
@staticmethod
def OVERDRAFT_DURATION():
overdraft_duration = cache.get('OVERDRAFT_DURATION')
if overdraft_duration:
return overdraft_duration
try:
overdraft_duration = Settings.setting_inst("OVERDRAFT_DURATION").value_duration
except Settings.DoesNotExist:
overdraft_duration = timedelta()
cache.set('OVERDRAFT_DURATION', overdraft_duration)
return overdraft_duration
@staticmethod
def OVERDRAFT_AMOUNT():
overdraft_amount = cache.get('OVERDRAFT_AMOUNT')
if overdraft_amount:
return overdraft_amount
try:
overdraft_amount = Settings.setting_inst("OVERDRAFT_AMOUNT").value_decimal
except Settings.DoesNotExist:
overdraft_amount = 0
cache.set('OVERDRAFT_AMOUNT', overdraft_amount)
return overdraft_amount
@staticmethod
def CANCEL_DURATION():
cancel_duration = cache.get('CANCEL_DURATION')
if cancel_duration:
return cancel_duration
try:
cancel_duration = Settings.setting_inst("CANCEL_DURATION").value_duration
except Settings.DoesNotExist:
cancel_duration = timedelta()
cache.set('CANCEL_DURATION', cancel_duration)
return cancel_duration
@staticmethod
def create_missing():
s, created = Settings.objects.get_or_create(name='SUBVENTION_COF')
if created:
s.value_decimal = 25
s.save()
s, created = Settings.objects.get_or_create(name='ADDCOST_AMOUNT')
if created:
s.value_decimal = 0.5
s.save()
s, created = Settings.objects.get_or_create(name='ADDCOST_FOR')
s, created = Settings.objects.get_or_create(name='OVERDRAFT_DURATION')
if created:
s.value_duration = timedelta(days=1) # 24h
s.save()
s, created = Settings.objects.get_or_create(name='OVERDRAFT_AMOUNT')
if created:
s.value_decimal = 20
s.save()
s, created = Settings.objects.get_or_create(name='CANCEL_DURATION')
if created:
s.value_duration = timedelta(minutes=5) # 5min
s.save()
@staticmethod
def empty_cache():
cache.delete_many([
'SUBVENTION_COF', 'OVERDRAFT_DURATION', 'OVERDRAFT_AMOUNT',
'CANCEL_DURATION', 'ADDCOST_AMOUNT', 'ADDCOST_FOR',
])
class GenericTeamToken(models.Model):
token = models.CharField(max_length = 50, unique = True)

1
kfet/open/__init__.py Normal file
View file

@ -0,0 +1 @@
from .open import OpenKfet, kfet_open # noqa

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