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
media/
*.log
*.sqlite3
# PyCharm
.idea
.cache

View file

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

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

View file

@ -0,0 +1 @@

View file

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

View file

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

View file

@ -1,37 +1,40 @@
# -*- 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.forms.models import BaseInlineFormSet
from django.utils import timezone
from bda.models import Attribution, Spectacle
class BaseBdaFormSet(BaseInlineFormSet):
def clean(self):
"""Checks that no two articles have the same title."""
super(BaseBdaFormSet, self).clean()
if any(self.errors):
# Don't bother validating the formset unless each form is valid on
# its own
return
spectacles = []
for i in range(0, self.total_form_count()):
form = self.forms[i]
if not form.cleaned_data:
continue
spectacle = form.cleaned_data['spectacle']
delete = form.cleaned_data['DELETE']
if not delete and spectacle in spectacles:
raise forms.ValidationError(
"Vous ne pouvez pas vous inscrire deux fois pour le "
"même spectacle.")
spectacles.append(spectacle)
class InscriptionInlineFormSet(BaseInlineFormSet):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# self.instance is a Participant object
tirage = self.instance.tirage
# set once for all "spectacle" field choices
# - restrict choices to the spectacles of this tirage
# - force_choices avoid many db requests
spectacles = tirage.spectacle_set.select_related('location')
choices = [(sp.pk, str(sp)) for sp in spectacles]
self.force_choices('spectacle', choices)
def force_choices(self, name, choices):
"""Set choices of a field.
As ModelChoiceIterator (default use to get choices of a
ModelChoiceField), it appends an empty selection if requested.
"""
for form in self.forms:
field = form.fields[name]
if field.empty_label is not None:
field.choices = [('', field.empty_label)] + choices
else:
field.choices = choices
class TokenForm(forms.Form):
@ -40,35 +43,45 @@ class TokenForm(forms.Form):
class AttributionModelMultipleChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj):
return "%s" % obj.spectacle
return "%s" % str(obj.spectacle)
class ResellForm(forms.Form):
attributions = AttributionModelMultipleChoiceField(
label='',
queryset=Attribution.objects.none(),
widget=forms.CheckboxSelectMultiple,
required=False)
def __init__(self, participant, *args, **kwargs):
super(ResellForm, self).__init__(*args, **kwargs)
self.fields['attributions'].queryset = participant.attribution_set\
.filter(spectacle__date__gte=timezone.now())\
self.fields['attributions'].queryset = (
participant.attribution_set
.filter(spectacle__date__gte=timezone.now())
.exclude(revente__seller=participant)
.select_related('spectacle', 'spectacle__location',
'participant__user')
)
class AnnulForm(forms.Form):
attributions = AttributionModelMultipleChoiceField(
label='',
queryset=Attribution.objects.none(),
widget=forms.CheckboxSelectMultiple,
required=False)
def __init__(self, participant, *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(),
revente__isnull=False,
revente__date__gt=timezone.now()-timedelta(hours=1),
revente__notif_sent=False,
revente__soldTo__isnull=True)
.select_related('spectacle', 'spectacle__location',
'participant__user')
)
class InscriptionReventeForm(forms.Form):
@ -79,5 +92,26 @@ class InscriptionReventeForm(forms.Form):
def __init__(self, tirage, *args, **kwargs):
super(InscriptionReventeForm, self).__init__(*args, **kwargs)
self.fields['spectacles'].queryset = tirage.spectacle_set.filter(
date__gte=timezone.now())
self.fields['spectacles'].queryset = (
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.utils import timezone
def forwards_func(apps, schema_editor):
def fill_tirage_fields(apps, schema_editor):
"""
Create a `Tirage` to fill new field `tirage` of `Participant`
and `Spectacle` already existing.
"""
Participant = apps.get_model("bda", "Participant")
Spectacle = apps.get_model("bda", "Spectacle")
Tirage = apps.get_model("bda", "Tirage")
db_alias = schema_editor.connection.alias
Tirage.objects.using(db_alias).bulk_create([
Tirage(
id=1,
title="Tirage de test (migration)",
active=False,
ouverture=timezone.now(),
fermeture=timezone.now()),
])
# These querysets only contains instances not linked to any `Tirage`.
participants = Participant.objects.filter(tirage=None)
spectacles = Spectacle.objects.filter(tirage=None)
if not participants.count() and not spectacles.count():
# No need to create a "trash" tirage.
return
tirage = Tirage.objects.create(
title="Tirage de test (migration)",
active=False,
ouverture=timezone.now(),
fermeture=timezone.now(),
)
participants.update(tirage=tirage)
spectacles.update(tirage=tirage)
class Migration(migrations.Migration):
@ -35,7 +52,6 @@ class Migration(migrations.Migration):
('active', models.BooleanField(default=True, verbose_name=b'Tirage actif')),
],
),
migrations.RunPython(forwards_func, migrations.RunPython.noop),
migrations.AlterField(
model_name='participant',
name='user',
@ -43,22 +59,36 @@ class Migration(migrations.Migration):
on_delete=models.CASCADE,
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(
model_name='participant',
name='tirage',
field=models.ForeignKey(
on_delete=models.CASCADE,
default=1,
to='bda.Tirage'),
preserve_default=False,
to='bda.Tirage',
null=True
),
),
migrations.AddField(
model_name='spectacle',
name='tirage',
field=models.ForeignKey(
on_delete=models.CASCADE,
default=1,
to='bda.Tirage'),
preserve_default=False,
to='bda.Tirage',
null=True
),
),
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.db import models
from django.db.models import Count
from django.contrib.auth.models import User
from django.conf import settings
from django.utils import timezone, formats
def get_generic_user():
generic, _ = User.objects.get_or_create(
username="bda_generic",
defaults={"email": "bda@ens.fr", "first_name": "Bureau des arts"}
)
return generic
class Tirage(models.Model):
title = models.CharField("Titre", max_length=300)
ouverture = models.DateTimeField("Date et heure d'ouverture du tirage")
fermeture = models.DateTimeField("Date et heure de fermerture du tirage")
tokens = models.TextField("Graine(s) du tirage", blank=True)
active = models.BooleanField("Tirage actif", default=False)
appear_catalogue = models.BooleanField(
"Tirage à afficher dans le catalogue",
default=False
)
enable_do_tirage = models.BooleanField("Le tirage peut être lancé",
default=False)
@ -83,37 +96,43 @@ class Spectacle(models.Model):
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):
"""
Envoie un mail de rappel à toutes les personnes qui ont une place pour
ce spectacle.
"""
# On récupère la liste des participants
members = {}
for attr in Attribution.objects.filter(spectacle=self).all():
member = attr.participant.user
if member.id in members:
members[member.id][1] = 2
else:
members[member.id] = [member, 1]
# FIXME : faire quelque chose de ça, un utilisateur bda_generic ?
# # Pour le BdA
# members[0] = ['BdA', 1, 'bda@ens.fr']
# members[-1] = ['BdA', 2, 'bda@ens.fr']
# On récupère la liste des participants + le BdA
members = list(
User.objects
.filter(participant__attributions=self)
.annotate(nb_attr=Count("id")).order_by()
)
bda_generic = get_generic_user()
bda_generic.nb_attr = 1
members.append(bda_generic)
# On écrit un mail personnalisé à chaque participant
datatuple = [(
'bda-rappel',
{'member': member[0], 'nb_attr': member[1], 'show': self},
{'member': member, "nb_attr": member.nb_attr, 'show': self},
settings.MAIL_DATA['rappels']['FROM'],
[member[0].email])
for member in members.values()
[member.email])
for member in members
]
send_mass_custom_mail(datatuple)
# On enregistre le fait que l'envoi a bien eu lieu
self.rappel_sent = timezone.now()
self.save()
# On renvoie la liste des destinataires
return members.values()
return members
@property
def is_past(self):
@ -344,10 +363,11 @@ class SpectacleRevente(models.Model):
# Envoie un mail aux perdants
for inscrit in inscrits:
if inscrit != winner:
context['acheteur'] = inscrit.user
new_context = dict(context)
new_context['acheteur'] = inscrit.user
datatuple.append((
'bda-revente-loser',
context,
new_context,
settings.MAIL_DATA['revente']['FROM'],
[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 %}
<h2>Mails de rappels</h2>
{% if sent %}
<h3>Les mails de rappel pour le spectacle {{ show.title }} ont bien été envoyés aux personnes suivantes</h3>
<ul>
{% for member in members %}
<li>{{ member.get_full_name }} ({{ member.email }})</li>
{% endfor %}
</ul>
<h3>Les mails de rappel pour le spectacle {{ show.title }} ont bien été envoyés aux personnes suivantes</h3>
<ul>
{% for member in members %}
<li>{{ member.get_full_name }} ({{ member.email }})</li>
{% endfor %}
</ul>
{% else %}
<h3>Voulez vous envoyer les mails de rappel pour le spectacle
{{ show.title }}&nbsp;?</h3>
{% if show.rappel_sent %}
<p class="error">Attention, les mails ont déjà été envoyés le
{{ show.rappel_sent }}</p>
{% endif %}
<h3>Voulez vous envoyer les mails de rappel pour le spectacle {{ show.title }}&nbsp;?</h3>
{% endif %}
{% if not sent %}
<form action="" method="post">
{% csrf_token %}
<div class="pull-right">
<input class="btn btn-primary" type="submit" value="Envoyer" />
</div>
</form>
{% endif %}
<div class="empty-form">
{% if not sent %}
<form action="" method="post">
{% csrf_token %}
<div class="pull-right">
<input class="btn btn-primary" type="submit" value="Envoyer" />
</div>
</form>
{% endif %}
</div>
<hr \>
<p>
<em>Note :</em> le template de ce mail peut être modifié à
<a href="{% url 'admin:custommail_custommail_change' custommail.pk %}">cette adresse</a>
</p>
<hr \>
<br/>
<hr/>
<h3>Forme des mails</h3>
<h4>Une seule place</h4>
{% for part in exemple_mail_1place %}
<pre>{{ part }}</pre>
{% endfor %}
{% for part in exemple_mail_1place %}
<pre>{{ part }}</pre>
{% endfor %}
<h4>Deux places</h4>
{% for part in exemple_mail_2places %}
<pre>{{ part }}</pre>
<pre>{{ part }}</pre>
{% endfor %}
{% endblock %}

View file

@ -36,17 +36,26 @@
</tbody>
</table>
<h3><a href="{% url "admin:bda_attribution_add" %}?spectacle={{spectacle.id}}"><span class="glyphicon glyphicon-plus-sign"></span> Ajouter une attribution</a></h3>
<br>
<button class="btn btn-default" type="button" onclick="toggle('export-mails')">Afficher/Cacher mails participants</button>
<pre id="export-mails" style="display:none">
{%for participant in participants %}{{participant.email}}, {%endfor%}
</pre>
<br>
<button class="btn btn-default" type="button" onclick="toggle('export-salle')">Afficher/Cacher liste noms</button>
<pre id="export-salle" style="display:none">
<div>
<div>
<button class="btn btn-default" type="button" onclick="toggle('export-mails')">Afficher/Cacher mails participants</button>
<pre id="export-mails" style="display:none">{% spaceless %}
{% for participant in participants %}{{ participant.email }}, {% endfor %}
{% endspaceless %}</pre>
</div>
<div>
<button class="btn btn-default" type="button" onclick="toggle('export-salle')">Afficher/Cacher liste noms</button>
<pre id="export-salle" style="display:none">{% spaceless %}
{% for participant in participants %}{{participant.name}} : {{participant.nb_places}} places
{% 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"
src="{% static "js/joequery-Stupid-Table-Plugin/stupidtable.js" %}"></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 -*-
"""
This file demonstrates writing tests using the unittest module. These will pass
when you run "manage.py test".
import json
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 __future__ import print_function
from __future__ import unicode_literals
from .models import Tirage, Spectacle, Salle, CategorieSpectacle
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 test_basic_addition(self):
"""
Tests that 1 + 1 always equals 2.
"""
self.assertEqual(1 + 1, 2)
def bda_participants(self):
"""The BdA participants views can be queried"""
client = Client()
show = self.tirage.spectacle_set.first()
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+)$',
views.revente_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,
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 hashlib
import time
from datetime import timedelta
from custommail.shortcuts import (
send_mass_custom_mail, send_custom_mail, render_custom_mail
)
import json
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.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.views.generic.list import ListView
from cof.decorators import cof_required, buro_required
from bda.models import Spectacle, Participant, ChoixSpectacle, Attribution,\
Tirage, SpectacleRevente
from bda.algorithm import Algorithm
from bda.forms import BaseBdaFormSet, TokenForm, ResellForm, AnnulForm,\
InscriptionReventeForm
from .models import (
Attribution, CategorieSpectacle, ChoixSpectacle, Participant, Salle,
Spectacle, SpectacleRevente, Tirage
)
from .algorithm import Algorithm
from .forms import (
TokenForm, ResellForm, AnnulForm, InscriptionReventeForm, SoldForm,
InscriptionInlineFormSet,
)
@cof_required
@ -39,39 +46,44 @@ def etat_places(request, tirage_id):
Et le total de toutes les demandes
"""
tirage = get_object_or_404(Tirage, id=tirage_id)
spectacles1 = ChoixSpectacle.objects \
.filter(spectacle__tirage=tirage) \
.filter(double_choice="1") \
.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
spectacles = tirage.spectacle_set.select_related('location')
spectacles_dict = {} # index of spectacle by id
for spectacle in spectacles:
spectacle.total = 0
spectacle.ratio = 0.0
spectacle.total = 0 # init total requests
spectacles_dict[spectacle.id] = spectacle
for spectacle in spectacles1:
spectacles_dict[spectacle["spectacle"]].total += spectacle["total"]
spectacles_dict[spectacle["spectacle"]].ratio = \
spectacles_dict[spectacle["spectacle"]].total / \
spectacles_dict[spectacle["spectacle"]].slots
total += spectacle["total"]
for spectacle in spectacles2:
spectacles_dict[spectacle["spectacle"]].total += 2*spectacle["total"]
spectacles_dict[spectacle["spectacle"]].ratio = \
spectacles_dict[spectacle["spectacle"]].total / \
spectacles_dict[spectacle["spectacle"]].slots
total += 2*spectacle["total"]
choices = (
ChoixSpectacle.objects
.filter(spectacle__in=spectacles)
.values('spectacle')
.annotate(total=Count('spectacle'))
)
# choices *by spectacles* whose only 1 place is requested
choices1 = choices.filter(double_choice="1")
# choices *by spectacles* whose 2 places is requested
choices2 = choices.exclude(double_choice="1")
for spectacle in choices1:
pk = spectacle['spectacle']
spectacles_dict[pk].total += spectacle['total']
for spectacle in choices2:
pk = spectacle['spectacle']
spectacles_dict[pk].total += 2*spectacle['total']
# here, each spectacle.total contains the number of requests
slots = 0 # proposed slots
total = 0 # requests
for spectacle in spectacles:
slots += spectacle.slots
total += spectacle.total
spectacle.ratio = spectacle.total / spectacle.slots
context = {
"proposed": tirage.spectacle_set.aggregate(Sum('slots'))['slots__sum'],
"proposed": slots,
"spectacles": spectacles,
"total": total,
'tirage': tirage
@ -89,11 +101,16 @@ def _hash_queryset(queryset):
@cof_required
def places(request, tirage_id):
tirage = get_object_or_404(Tirage, id=tirage_id)
participant, created = Participant.objects.get_or_create(
user=request.user, tirage=tirage)
places = participant.attribution_set.order_by(
"spectacle__date", "spectacle").all()
total = sum([place.spectacle.price for place in places])
participant, _ = (
Participant.objects
.get_or_create(user=request.user, tirage=tirage)
)
places = (
participant.attribution_set
.order_by("spectacle__date", "spectacle")
.select_related("spectacle", "spectacle__location")
)
total = sum(place.spectacle.price for place in places)
filtered_places = []
places_dict = {}
spectacles = []
@ -141,35 +158,31 @@ def inscription(request, tirage_id):
messages.error(request, "Le tirage n'est pas encore ouvert : "
"ouverture le {:s}".format(opening))
return render(request, 'bda/resume-inscription-tirage.html', {})
participant, _ = (
Participant.objects.select_related('tirage')
.get_or_create(user=request.user, tirage=tirage)
)
if timezone.now() > tirage.fermeture:
# Le tirage est fermé.
participant, created = Participant.objects.get_or_create(
user=request.user, tirage=tirage)
choices = participant.choixspectacle_set.order_by("priority").all()
choices = participant.choixspectacle_set.order_by("priority")
messages.error(request,
" C'est fini : tirage au sort dans la journée !")
return render(request, "bda/resume-inscription-tirage.html",
{"choices": choices})
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(
Participant,
ChoixSpectacle,
fields=("spectacle", "double_choice", "priority"),
formset=BaseBdaFormSet,
formfield_callback=formfield_callback)
participant, created = Participant.objects.get_or_create(
user=request.user, tirage=tirage)
formset=InscriptionInlineFormSet,
)
success = False
stateerror = False
if request.method == "POST":
# use *this* queryset
dbstate = _hash_queryset(participant.choixspectacle_set.all())
if "dbstate" in request.POST and dbstate != request.POST["dbstate"]:
stateerror = True
@ -182,9 +195,14 @@ def inscription(request, tirage_id):
formset = BdaFormSet(instance=participant)
else:
formset = BdaFormSet(instance=participant)
# use *this* queryset
dbstate = _hash_queryset(participant.choixspectacle_set.all())
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
if choice.double:
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
start = time.time()
data = {
'shows': tirage_elt.spectacle_set.select_related().all(),
'shows': tirage_elt.spectacle_set.select_related('location'),
'token': token,
'members': tirage_elt.participant_set.all(),
'members': tirage_elt.participant_set.select_related('user'),
'total_slots': 0,
'total_losers': 0,
'total_sold': 0,
@ -228,7 +246,7 @@ def do_tirage(tirage_elt, token):
ChoixSpectacle.objects
.filter(spectacle__tirage=tirage_elt)
.order_by('participant', 'priority')
.select_related().all()
.select_related('participant', 'participant__user', 'spectacle')
)
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
for (show, _, losers) in results:
for (loser, _, _, _) in losers:
loser.choicesrevente.add(show)
loser.save()
ChoixRevente = Participant.choicesrevente.through
# Suppression des reventes demandées/enregistrées
# (si le tirage est relancé)
(
ChoixRevente.objects
.filter(spectacle__tirage=tirage_elt)
.delete()
)
(
SpectacleRevente.objects
.filter(attribution__spectacle__tirage=tirage_elt)
.delete()
)
lost_by = defaultdict(set)
for show, _, losers in results:
for loser, _, _, _ in losers:
lost_by[loser].add(show)
ChoixRevente.objects.bulk_create(
ChoixRevente(participant=member, spectacle=show)
for member, shows in lost_by.items()
for show in shows
)
data["duration"] = time.time() - start
data["results"] = results
@ -316,13 +355,18 @@ def revente(request, tirage_id):
tirage = get_object_or_404(Tirage, id=tirage_id)
participant, created = Participant.objects.get_or_create(
user=request.user, tirage=tirage)
if not participant.paid:
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':
# On met en vente une place
if 'resell' in request.POST:
resellform = ResellForm(participant, request.POST, prefix='resell')
annulform = AnnulForm(participant, prefix='annul')
if resellform.is_valid():
datatuple = []
attributions = resellform.cleaned_data["attributions"]
@ -354,7 +398,6 @@ def revente(request, tirage_id):
# On annule une revente
elif 'annul' in request.POST:
annulform = AnnulForm(participant, request.POST, prefix='annul')
resellform = ResellForm(participant, prefix='resell')
if annulform.is_valid():
attributions = annulform.cleaned_data["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
# gagné le tirage
elif 'transfer' in request.POST:
resellform = ResellForm(participant, prefix='resell')
annulform = AnnulForm(participant, prefix='annul')
soldform = SoldForm(participant, request.POST, prefix='sold')
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
# la personne qui a gagné le tirage ne se manifeste pas). La place est
# alors remise en vente
elif 'reinit' in request.POST:
resellform = ResellForm(participant, prefix='resell')
annulform = AnnulForm(participant, prefix='annul')
revente_id = request.POST['reinit'][0]
rev = SpectacleRevente.objects.filter(soldTo__isnull=False,
id=revente_id)
if rev.exists():
revente = rev.get()
if revente.attribution.spectacle.date > timezone.now():
revente.date = timezone.now() - timedelta(hours=1)
revente.soldTo = None
revente.notif_sent = False
revente.tirage_done = False
revente.shotgun = False
if revente.answered_mail:
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')
soldform = SoldForm(participant, request.POST, prefix='sold')
if soldform.is_valid():
attributions = soldform.cleaned_data['attributions']
for attribution in attributions:
if attribution.spectacle.date > timezone.now():
revente = attribution.revente
revente.date = timezone.now() - timedelta(minutes=65)
revente.soldTo = None
revente.notif_sent = False
revente.tirage_done = False
revente.shotgun = False
if revente.answered_mail:
revente.answered_mail.clear()
revente.save()
overdue = participant.attribution_set.filter(
spectacle__date__gte=timezone.now(),
revente__isnull=False,
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))
sold = participant.attribution_set.filter(
spectacle__date__gte=timezone.now(),
revente__isnull=False,
revente__soldTo__isnull=False)
return render(request, "bda-revente.html",
{'tirage': tirage, 'overdue': overdue, "sold": sold,
return render(request, "bda/reventes.html",
{'tirage': tirage, 'overdue': overdue, "soldform": soldform,
"annulform": annulform, "resellform": resellform})
@ -465,7 +492,6 @@ def list_revente(request, tirage_id):
)
if min_resell is not None:
min_resell.answered_mail.add(participant)
min_resell.save()
inscrit_revente.append(spectacle)
success = True
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,
# on supprime la revente en question.
if reventes.filter(seller=participant).exists():
revente = reventes.filter(seller=participant)[0]
revente.delete()
own_reventes = reventes.filter(seller=participant)
if len(own_reventes) > 0:
own_reventes[0].delete()
return HttpResponseRedirect(reverse("bda-shotgun",
args=[tirage.id]))
reventes_shotgun = list(reventes.filter(shotgun=True).all())
reventes_shotgun = reventes.filter(shotgun=True)
if not reventes_shotgun:
return render(request, "bda-no-revente.html", {})
@ -541,16 +567,21 @@ def buy_revente(request, spectacle_id):
@login_required
def revente_shotgun(request, tirage_id):
tirage = get_object_or_404(Tirage, id=tirage_id)
spectacles = tirage.spectacle_set.filter(
date__gte=timezone.now())
shotgun = []
for spectacle in spectacles:
reventes = SpectacleRevente.objects.filter(
attribution__spectacle=spectacle,
shotgun=True,
soldTo__isnull=True)
if reventes.exists():
shotgun.append(spectacle)
spectacles = (
tirage.spectacle_set
.filter(date__gte=timezone.now())
.select_related('location')
.prefetch_related(Prefetch(
'attribues',
queryset=(
Attribution.objects
.filter(revente__shotgun=True,
revente__soldTo__isnull=True)
),
to_attr='shotguns',
))
)
shotgun = [sp for sp in spectacles if len(sp.shotguns) > 0]
return render(request, "bda-shotgun.html",
{"shotgun": shotgun})
@ -560,7 +591,10 @@ def revente_shotgun(request, tirage_id):
def spectacle(request, tirage_id, spectacle_id):
tirage = get_object_or_404(Tirage, id=tirage_id)
spectacle = get_object_or_404(Spectacle, id=spectacle_id, tirage=tirage)
attributions = spectacle.attribues.all()
attributions = (
spectacle.attribues
.select_related('participant', 'participant__user')
)
participants = {}
for attrib in attributions:
participant = attrib.participant
@ -579,7 +613,7 @@ def spectacle(request, tirage_id, spectacle_id):
participants_info = sorted(participants.values(),
key=lambda part: part['lastname'])
return render(request, "bda-participants.html",
return render(request, "bda/participants.html",
{"spectacle": spectacle, "participants": participants_info})
@ -589,7 +623,10 @@ class SpectacleListView(ListView):
def get_queryset(self):
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
def get_context_data(self, **kwargs):
@ -602,9 +639,12 @@ class SpectacleListView(ListView):
@buro_required
def unpaid(request, tirage_id):
tirage = get_object_or_404(Tirage, id=tirage_id)
unpaid = tirage.participant_set \
.annotate(nb_attributions=Count('attribution')) \
.filter(paid=False, nb_attributions__gt=0).all()
unpaid = (
tirage.participant_set
.annotate(nb_attributions=Count('attribution'))
.filter(paid=False, nb_attributions__gt=0)
.select_related('user')
)
return render(request, "bda-unpaid.html", {"unpaid": unpaid})
@ -612,20 +652,24 @@ def unpaid(request, tirage_id):
def send_rappel(request, spectacle_id):
show = get_object_or_404(Spectacle, id=spectacle_id)
# 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,
'show': show,
'nb_attr': 1
})
exemple_mail_2places = render_custom_mail('bda-rappel', {
exemple_mail_2places = custommail.render({
'member': request.user,
'show': show,
'nb_attr': 2
})
# Contexte
ctxt = {'show': show,
'exemple_mail_1place': exemple_mail_1place,
'exemple_mail_2places': exemple_mail_2places}
ctxt = {
'show': show,
'exemple_mail_1place': exemple_mail_1place,
'exemple_mail_2places': exemple_mail_2places,
'custommail': custommail,
}
# Envoi confirmé
if request.method == 'POST':
members = show.send_rappel()
@ -634,12 +678,24 @@ def send_rappel(request, spectacle_id):
# Demande de confirmation
else:
ctxt['sent'] = False
if show.rappel_sent:
messages.warning(
request,
"Attention, un mail de rappel pour ce spectale a déjà été "
"envoyé le {}".format(formats.localize(
timezone.template_localtime(show.rappel_sent)
))
)
return render(request, "bda/mails-rappel.html", ctxt)
def descriptions_spectacles(request, 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', '')
location_id = request.GET.get('location', '')
if category_name:
@ -650,4 +706,112 @@ def descriptions_spectacles(request, tirage_id):
except ValueError:
return HttpResponseBadRequest(
"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 django import shortcuts
@ -12,6 +10,10 @@ from django.conf import settings
class Clipper(object):
def __init__(self, clipper, fullname):
if fullname is None:
fullname = ""
assert isinstance(clipper, str)
assert isinstance(fullname, str)
self.clipper = clipper
self.fullname = fullname
@ -52,24 +54,28 @@ def autocomplete(request):
)
# Fetching data from the SPI
if hasattr(settings, 'LDAP_SERVER_URL'):
if getattr(settings, 'LDAP_SERVER_URL', None):
# Fetching
ldap_query = '(|{:s})'.format(''.join(
['(cn=*{bit:s}*)(uid=*{bit:s}*)'.format(bit=bit)
for bit in bits]
ldap_query = '(&{:s})'.format(''.join(
'(|(cn=*{bit:s}*)(uid=*{bit:s}*))'.format(bit=bit)
for bit in bits if bit.isalnum()
))
with Connection(settings.LDAP_SERVER_URL) as conn:
conn.search(
'dc=spi,dc=ens,dc=fr', ldap_query,
attributes=['uid', 'cn']
)
queries['clippers'] = conn.entries
# Clearing redundancies
queries['clippers'] = [
Clipper(clipper.uid, clipper.cn)
for clipper in queries['clippers']
if str(clipper.uid) not in usernames
]
if ldap_query != "(&)":
# If none of the bits were legal, we do not perform the query
entries = None
with Connection(settings.LDAP_SERVER_URL) as conn:
conn.search(
'dc=spi,dc=ens,dc=fr', ldap_query,
attributes=['uid', 'cn']
)
entries = conn.entries
# Clearing redundancies
queries['clippers'] = [
Clipper(entry.uid.value, entry.cn.value)
for entry in entries
if entry.uid.value
and entry.uid.value not in usernames
]
# Resulting data
data.update(queries)

View file

@ -1,25 +1,18 @@
# -*- coding: utf-8 -*-
from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals
from djconfig.forms import ConfigForm
from django import forms
from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.models import User
from django.forms.widgets import RadioSelect, CheckboxSelectMultiple
from django.forms.formsets import BaseFormSet, formset_factory
from django.db.models import Max
from django.core.validators import MinLengthValidator
from django.forms.widgets import RadioSelect, CheckboxSelectMultiple
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 .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):
def __init__(self, *args, **kwargs):
@ -136,8 +129,9 @@ class EventStatusFilterForm(forms.Form):
class RegistrationUserForm(forms.ModelForm):
def force_long_username(self):
self.fields['username'].validators = [MinLengthValidator(9)]
def __init__(self, *args, **kw):
super(RegistrationUserForm, self).__init__(*args, **kw)
self.fields['username'].help_text = ""
class Meta:
model = User
@ -182,21 +176,6 @@ class RegistrationCofProfileForm(forms.ModelForm):
"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 Meta:
@ -318,3 +297,16 @@ class CalendarForm(forms.ModelForm):
model = CalendarSubscription
fields = ['subscribe_to_events', 'subscribe_to_my_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
from custommail.shortcuts import render_custom_mail
from django.shortcuts import render, get_object_or_404, redirect
from django.core import mail
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
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.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 .petits_cours_models import (
PetitCoursDemande, PetitCoursAttribution, PetitCoursAttributionCounter,
PetitCoursAbility, PetitCoursSubject
PetitCoursAbility
)
from .decorators import buro_required
from .petits_cours_forms import DemandeForm, MatieresFormSet
from gestion.shared import lock_table, unlock_tables
class DemandeListView(ListView):
model = PetitCoursDemande
queryset = (
PetitCoursDemande.objects
.prefetch_related('matieres')
.order_by('traitee', '-id')
)
template_name = "petits_cours_demandes_list.html"
paginate_by = 20
def get_queryset(self):
return PetitCoursDemande.objects.order_by('traitee', '-id').all()
class DemandeDetailView(DetailView):
model = PetitCoursDemande
template_name = "cof/details_demande_petit_cours.html"
queryset = (
PetitCoursDemande.objects
.prefetch_related('petitcoursattribution_set',
'matieres')
)
context_object_name = "demande"
def get_context_data(self, **kwargs):
@ -103,8 +107,9 @@ def _finalize_traitement(request, demande, proposals, proposed_for,
'style="width:99%; height: 90px;">'
'</textarea>'
})
for error in errors:
messages.error(request, error)
if errors is not None:
for error in errors:
messages.error(request, error)
return render(request, "cof/traitement_demande_petit_cours.html",
{"demande": demande,
"unsatisfied": unsatisfied,
@ -215,7 +220,7 @@ def _traitement_other(request, demande, redo):
proposals = proposals.items()
proposed_for = proposed_for.items()
return render(request,
"gestiocof/traitement_demande_petit_cours_autre_niveau.html",
"cof/traitement_demande_petit_cours_autre_niveau.html",
{"demande": demande,
"unsatisfied": unsatisfied,
"proposals": proposals,
@ -246,37 +251,39 @@ def _traitement_post(request, demande):
proposals_list = proposals.items()
proposed_for = proposed_for.items()
proposed_mails = _generate_eleve_email(demande, proposed_for)
mainmail = render_custom_mail("petits-cours-mail-demandeur", {
"proposals": proposals_list,
"unsatisfied": unsatisfied,
"extra": extra,
})
mainmail_object, mainmail_body = render_custom_mail(
"petits-cours-mail-demandeur",
{
"proposals": proposals_list,
"unsatisfied": unsatisfied,
"extra": extra
}
)
frommail = settings.MAIL_DATA['petits_cours']['FROM']
bccaddress = settings.MAIL_DATA['petits_cours']['BCC']
replyto = settings.MAIL_DATA['petits_cours']['REPLYTO']
mails_to_send = []
for (user, msg) in proposed_mails:
msg = mail.EmailMessage("Petits cours ENS par le COF", msg,
frommail, [user.email],
for (user, (mail_object, body)) in proposed_mails:
msg = mail.EmailMessage(mail_object, body, frommail, [user.email],
[bccaddress], headers={'Reply-To': replyto})
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],
[bccaddress],
headers={'Reply-To': replyto}))
connection = mail.get_connection(fail_silently=True)
connection = mail.get_connection(fail_silently=False)
connection.send_messages(mails_to_send)
lock_table(PetitCoursAttributionCounter, PetitCoursAttribution, User)
for matiere in proposals:
for rank, user in enumerate(proposals[matiere]):
counter = PetitCoursAttributionCounter.objects.get(user=user,
matiere=matiere)
counter.count += 1
counter.save()
attrib = PetitCoursAttribution(user=user, matiere=matiere,
demande=demande, rank=rank + 1)
attrib.save()
unlock_tables()
with transaction.atomic():
for matiere in proposals:
for rank, user in enumerate(proposals[matiere]):
counter = PetitCoursAttributionCounter.objects.get(
user=user, matiere=matiere
)
counter.count += 1
counter.save()
attrib = PetitCoursAttribution(user=user, matiere=matiere,
demande=demande, rank=rank + 1)
attrib.save()
demande.traitee = True
demande.traitee_par = request.user
demande.processed = timezone.now()
@ -301,17 +308,15 @@ def inscription(request):
profile.petits_cours_accept = "receive_proposals" in request.POST
profile.petits_cours_remarques = request.POST["remarques"]
profile.save()
lock_table(PetitCoursAttributionCounter, PetitCoursAbility, User,
PetitCoursSubject)
abilities = (
PetitCoursAbility.objects.filter(user=request.user).all()
)
for ability in abilities:
PetitCoursAttributionCounter.get_uptodate(
ability.user,
ability.matiere
with transaction.atomic():
abilities = (
PetitCoursAbility.objects.filter(user=request.user).all()
)
unlock_tables()
for ability in abilities:
PetitCoursAttributionCounter.get_uptodate(
ability.user,
ability.matiere
)
success = True
formset = MatieresFormSet(instance=request.user)
else:

View file

@ -40,8 +40,9 @@ a {
background: transparent;
}
div.empty-form {
padding-bottom: 2em;
}
a:hover {
color: #444;
@ -341,10 +342,12 @@ fieldset legend {
font-weight: 700;
font-size: large;
color:#DE826B;
padding-bottom: .5em;
}
#main-content h4 {
color:#DE826B;
padding-bottom: .5em;
}
#main-content h2,
@ -814,6 +817,17 @@ header .open > .dropdown-toggle.btn-default {
border-color: white;
}
/* Announcements banner ------------------ */
#banner {
background-color: #d86b01;
width: 100%;
text-align: center;
padding: 10px;
color: white;
font-size: larger;
}
/* FORMS --------------------------------- */
@ -836,7 +850,7 @@ input#search_autocomplete {
height: 40px;
padding: 10px 8px;
margin: 0 auto;
margin-top: 20px;
margin-top: 0px;
display: block;
color: #aaa;
}
@ -1155,3 +1169,10 @@ div.messages div.alert-success div.container {
div.messages div.alert div.container a {
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 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/cof.css" %}" />
<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://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
{% block extra_head %}{% endblock %}

View file

@ -1,5 +1,6 @@
{% extends "cof/base_header.html" %}
{% load i18n %}
{% load wagtailcore_tags %}
{% block homelink %}
{% endblock %}
@ -41,7 +42,21 @@
{% endfor %}
</div>
{% 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>
<div class="hm-block">
<ul>

View file

@ -9,7 +9,9 @@
{% block realcontent %}
<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 class="yourlabs-autocomplete"></div>
<script type="text/javascript">
@ -20,7 +22,6 @@
minimumCharacters: 3,
id: 'search_autocomplete',
choiceSelector: 'li:has(a)',
placeholder: "Chercher un utilisateur par nom, prénom ou identifiant clipper",
box: $(".yourlabs-autocomplete"),
});
$('input#search_autocomplete').bind(

View file

@ -23,7 +23,7 @@ def key(d, key_name):
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)
return mark_safe(re.sub(pattern,
r"<span class='highlight'>\g<filter></span>",

View file

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

View file

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

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 -*-
"""
Fichier principal de configuration des urls du projet GestioCOF
"""
import autocomplete_light
import gestion.urls
import kfet.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.urls import include, url
from django.conf.urls.static import static
@ -21,6 +20,7 @@ from cof.autocomplete import autocomplete
from gestion import views as gestion_views
autocomplete_light.autodiscover()
admin.autodiscover()
urlpatterns = [
@ -82,6 +82,11 @@ urlpatterns = [
name="liste_bdarevente"),
url(r'^k-fet/', include(kfet.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:
@ -90,7 +95,13 @@ if 'debug_toolbar' in settings.INSTALLED_APPS:
url(r'^__debug__/', include(debug_toolbar.urls)),
]
if settings.DEBUG:
# Si on est en production, MEDIA_ROOT est servi par Apache.
# Il faut dire à Django de servir MEDIA_ROOT lui-même en développement.
urlpatterns += static(settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT)
# Wagtail for uncatched
urlpatterns += [
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_cas_ng.backends import CASBackend
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()
from django.contrib.sites.models import Site
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
# et à la fin, ainsi quavec une casse variable. On normalise pour
# éviter les doublons.
username = username.strip().lower()
return username.strip().lower()
profiles = Profile.objects.filter(login_clipper=username)
if len(profiles) > 0:
# XXX. We have to deal with multiple profiles, this should not
# happen
# profile = profiles.order_by('-is_cof')[0]
profile = profiles.first()
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()
def configure_user(self, user):
clipper = user.username
user.profile.login_clipper = clipper
user.profile.save()
user.email = settings.CAS_EMAIL_FORMAT % clipper
user.save()
return user
def authenticate(self, ticket, service, request):
"""Authenticates CAS ticket and retrieves user data"""
user = self.authenticate_cas(ticket, service, request)
if user is None:
return user
profile = user.profile
if not profile.login_clipper:
profile.login_clipper = user.username
profile.save()
if not user.email:
user.email = settings.CAS_EMAIL_FORMAT % profile.login_clipper
user.save()
return user
def context_processor(request):
'''Append extra data to the context of the given request'''
data = {
"user": request.user,
"site": Site.objects.get_current(),
}
return data

View file

@ -6,4 +6,9 @@ class KFetConfig(AppConfig):
verbose_name = "Application K-Fêt"
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 django.shortcuts import render
from django.http import Http404
from django.db.models import Q
from django.conf import settings
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 kfet.models import Account
from .decorators import teamkfet_required
from .models import Account
class Clipper(object):
def __init__(self, clipper, fullname):
if fullname is None:
fullname = ""
assert isinstance(clipper, str)
assert isinstance(fullname, str)
self.clipper = clipper
self.fullname = fullname
@ -37,7 +39,6 @@ def account_create(request):
queries['kfet'] = Account.objects
queries['users_cof'] = User.objects.filter(groups=cof_members)
queries['users_notcof'] = User.objects.exclude(groups=cof_members)
for word in search_words:
queries['kfet'] = queries['kfet'].filter(
Q(profile__user__username__icontains=word)
@ -79,27 +80,54 @@ def account_create(request):
)
# Fetching data from the SPI
if hasattr(settings, 'LDAP_SERVER_URL'):
if getattr(settings, 'LDAP_SERVER_URL', None):
# Fetching
ldap_query = '(|{:s})'.format(''.join(
['(cn=*{bit:s}*)(uid=*{bit:s}*)'.format(bit=bit)
for bit in search_words]
ldap_query = '(&{:s})'.format(''.join(
'(|(cn=*{bit:s}*)(uid=*{bit:s}*))'.format(bit=word)
for word in search_words if word.isalnum()
))
with Connection(settings.LDAP_SERVER_URL) as conn:
conn.search(
'dc=spi,dc=ens,dc=fr', ldap_query,
attributes=['uid', 'cn']
)
queries['clippers'] = conn.entries
# Clearing redundancies
queries['clippers'] = [
Clipper(clipper.uid, clipper.cn)
for clipper in queries['clippers']
if str(clipper.uid) not in usernames
]
if ldap_query != "(&)":
# If none of the bits were legal, we do not perform the query
entries = None
with Connection(settings.LDAP_SERVER_URL) as conn:
conn.search(
'dc=spi,dc=ens,dc=fr', ldap_query,
attributes=['uid', 'cn']
)
entries = conn.entries
# Clearing redundancies
queries['clippers'] = [
Clipper(entry.uid.value, entry.cn.value)
for entry in entries
if entry.uid.value
and entry.uid.value not in usernames
]
# Resulting data
data.update(queries)
data['options'] = any(queries.values())
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 -*-
from __future__ import (absolute_import, division,
print_function, unicode_literals)
from builtins import *
from .utils import DjangoJsonWebsocketConsumer, PermConsumerMixin
from channels import Group
from channels.generic.websockets import JsonWebsocketConsumer
class KPsul(JsonWebsocketConsumer):
# Set to True if you want them, else leave out
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
class KPsul(PermConsumerMixin, DjangoJsonWebsocketConsumer):
groups = ['kfet.kpsul']
perms_connect = ['kfet.is_team']

View file

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

View file

@ -1,12 +1,9 @@
# -*- coding: utf-8 -*-
from __future__ import (absolute_import, division,
print_function, unicode_literals)
from builtins import *
from django.contrib.auth.decorators import user_passes_test
from django_cas_ng.decorators import user_passes_test
def kfet_is_team(user):
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 __future__ import (absolute_import, division,
print_function, unicode_literals)
from builtins import *
from datetime import timedelta
from decimal import Decimal
from djconfig.forms import ConfigForm
from django import forms
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator
from django.contrib.auth.models import User, Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.forms import modelformset_factory, inlineformset_factory
from django.forms.models import BaseInlineFormSet
from django.forms import modelformset_factory
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 .auth.forms import UserGroupForm # noqa
from .models import (
Account, Checkout, Article, OperationGroup, Operation,
CheckoutStatement, ArticleCategory, AccountNegative, Transfer,
Supplier, TransferGroup
)
# -----
# Widgets
# -----
class DateTimeWidget(forms.DateTimeInput):
def __init__(self, attrs = None):
super(DateTimeWidget, self).__init__(attrs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.attrs['format'] = '%Y-%m-%d %H:%M'
class Media:
css = {
'all': ('kfet/css/bootstrap-datetimepicker.min.css',)
}
js = (
'kfet/js/moment.js',
'kfet/js/moment-fr.js',
'kfet/js/bootstrap-datetimepicker.min.js',
)
'all': ('kfet/css/bootstrap-datetimepicker.min.css',)
}
js = ('kfet/js/bootstrap-datetimepicker.min.js',)
# -----
# Account forms
# -----
@ -78,8 +77,11 @@ class AccountRestrictForm(AccountForm):
class AccountPwdForm(forms.Form):
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)
pwd2 = forms.CharField(
label="Confirmer le mot de passe",
widget=forms.PasswordInput)
def clean(self):
@ -108,21 +110,16 @@ class ProfileRestrictForm(ProfileForm):
class Meta(ProfileForm.Meta):
fields = ['departement']
class UserForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
from_clipper = kwargs.pop('from_clipper', False)
new_user = kwargs.get('instance') is None and not from_clipper
super(UserForm, self).__init__(*args, **kwargs)
if new_user:
self.fields['username'].validators = [MinLengthValidator(9)]
class UserForm(forms.ModelForm):
class Meta:
model = User
model = User
fields = ['username', 'first_name', 'last_name', 'email']
help_texts = {
'username': ''
}
class UserRestrictForm(UserForm):
class Meta(UserForm.Meta):
fields = ['first_name', 'last_name']
@ -131,25 +128,6 @@ class UserRestrictTeamForm(UserForm):
class Meta(UserForm.Meta):
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 Meta:
@ -226,22 +204,36 @@ class CheckoutStatementUpdateForm(forms.ModelForm):
model = CheckoutStatement
exclude = ['by', 'at', 'checkout', 'amount_error', 'amount_taken']
# -----
# Category
# -----
class CategoryForm(forms.ModelForm):
class Meta:
model = ArticleCategory
fields = ['name', 'has_addcost']
# -----
# Article forms
# -----
class ArticleForm(forms.ModelForm):
category_new = forms.CharField(
label="Créer une catégorie",
max_length=45,
required = False)
category = forms.ModelChoiceField(
label="Catégorie",
queryset = ArticleCategory.objects.all(),
required = False)
suppliers = forms.ModelMultipleChoiceField(
label="Fournisseurs",
queryset = Supplier.objects.all(),
required = False)
supplier_new = forms.CharField(
label="Créer un fournisseur",
max_length = 45,
required = False)
@ -264,12 +256,12 @@ class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ['name', 'is_sold', 'price', 'stock', 'category', 'box_type',
fields = ['name', 'is_sold', 'hidden', 'price', 'stock', 'category', 'box_type',
'box_capacity']
class ArticleRestrictForm(ArticleForm):
class Meta(ArticleForm.Meta):
fields = ['name', 'is_sold', 'price', 'category', 'box_type',
fields = ['name', 'is_sold', 'hidden', 'price', 'category', 'box_type',
'box_capacity']
# -----
@ -301,12 +293,20 @@ class KPsulAccountForm(forms.ModelForm):
}),
}
class KPsulCheckoutForm(forms.Form):
checkout = forms.ModelChoiceField(
queryset=Checkout.objects.filter(
is_protected=False, valid_from__lte=timezone.now(),
valid_to__gte=timezone.now()),
widget=forms.Select(attrs={'id':'id_checkout_select'}))
queryset=(
Checkout.objects
.filter(
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):
article = forms.ModelChoiceField(
@ -315,11 +315,10 @@ class KPsulOperationForm(forms.ModelForm):
widget = forms.HiddenInput())
class Meta:
model = Operation
fields = ['type', 'amount', 'is_checkout', 'article', 'article_nb']
fields = ['type', 'amount', 'article', 'article_nb']
widgets = {
'type': forms.HiddenInput(),
'amount': forms.HiddenInput(),
'is_checkout': forms.HiddenInput(),
'article_nb': forms.HiddenInput(),
}
@ -335,7 +334,6 @@ class KPsulOperationForm(forms.ModelForm):
"Un achat nécessite un article et une quantité")
if article_nb < 1:
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]:
if not amount or article or article_nb:
raise ValidationError("Bad request")
@ -370,44 +368,53 @@ class AddcostForm(forms.Form):
self.cleaned_data['amount'] = 0
super(AddcostForm, self).clean()
# -----
# Settings forms
# -----
class SettingsForm(forms.ModelForm):
class Meta:
model = Settings
fields = ['value_decimal', 'value_account', 'value_duration']
def clean(self):
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')
class KFetConfigForm(ConfigForm):
type_decimal = ['SUBVENTION_COF', 'ADDCOST_AMOUNT', 'OVERDRAFT_AMOUNT']
type_account = ['ADDCOST_FOR']
type_duration = ['OVERDRAFT_DURATION', 'CANCEL_DURATION']
kfet_reduction_cof = forms.DecimalField(
label='Réduction COF', initial=Decimal('20'),
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):
checkouts = forms.ModelMultipleChoiceField(queryset = Checkout.objects.all())
accounts = forms.ModelMultipleChoiceField(queryset = Account.objects.all())
checkouts = forms.ModelMultipleChoiceField(queryset=Checkout.objects.all())
accounts = forms.ModelMultipleChoiceField(queryset=Account.objects.all())
from_date = forms.DateTimeField(widget=DateTimeWidget)
to_date = forms.DateTimeField(widget=DateTimeWidget)
# -----
# Transfer forms
@ -454,7 +461,7 @@ class InventoryArticleForm(forms.Form):
queryset = Article.objects.all(),
widget = forms.HiddenInput(),
)
stock_new = forms.IntegerField(required = False)
stock_new = forms.IntegerField(required=False)
def __init__(self, *args, **kwargs):
super(InventoryArticleForm, self).__init__(*args, **kwargs)
@ -463,17 +470,20 @@ class InventoryArticleForm(forms.Form):
self.stock_old = kwargs['initial']['stock_old']
self.category = kwargs['initial']['category']
self.category_name = kwargs['initial']['category__name']
self.box_capacity = kwargs['initial']['box_capacity']
# -----
# Order forms
# -----
class OrderArticleForm(forms.Form):
article = forms.ModelChoiceField(
queryset = Article.objects.all(),
widget = forms.HiddenInput(),
queryset=Article.objects.all(),
widget=forms.HiddenInput(),
)
quantity_ordered = forms.IntegerField(required = False)
quantity_ordered = forms.IntegerField(required=False)
def __init__(self, *args, **kwargs):
super(OrderArticleForm, self).__init__(*args, **kwargs)
@ -483,11 +493,7 @@ class OrderArticleForm(forms.Form):
self.category = kwargs['initial']['category']
self.category_name = kwargs['initial']['category__name']
self.box_capacity = kwargs['initial']['box_capacity']
self.v_s1 = kwargs['initial']['v_s1']
self.v_s2 = kwargs['initial']['v_s2']
self.v_s3 = kwargs['initial']['v_s3']
self.v_s4 = kwargs['initial']['v_s4']
self.v_s5 = kwargs['initial']['v_s5']
self.v_all = kwargs['initial']['v_all']
self.v_moy = kwargs['initial']['v_moy']
self.v_et = kwargs['initial']['v_et']
self.v_prev = kwargs['initial']['v_prev']

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 random
from datetime import timedelta
from decimal import Decimal
from django.utils import timezone
from django.contrib.auth.models import User, Group, Permission, ContentType
from django.core.management import call_command
from cof.management.base import MyBaseCommand
from gestion.models import Profile
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
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)),
@ -87,84 +91,68 @@ class Command(MyBaseCommand):
# Compte liquide
self.stdout.write("Création du compte liquide")
liq_user, _ = User.objects.get_or_create(username='liquide')
liq_profile, _ = Profile.objects.get_or_create(user=liq_user)
liq_account, _ = Account.objects.get_or_create(profile=liq_profile,
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
# ---
checkout, _ = Checkout.objects.get_or_create(
checkout, created = Checkout.objects.get_or_create(
created_by=Account.objects.get(trigramme='000'),
name='Chaudron',
valid_from=timezone.now(),
valid_to=timezone.now() + timedelta(days=365)
defaults={
'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
# ---
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
# 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()
call_command('kfet_loadwagtail')

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
from django.db import models
from datetime import date
from django.core.urlresolvers import reverse
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.validators import RegexValidator
from django.contrib.auth.models import User
from django.utils.six.moves import reduce
from django.utils import timezone
from django.utils.encoding import python_2_unicode_compatible
from django.db import models
from django.db import transaction
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 .auth import KFET_GENERIC_TRIGRAMME
from .auth.models import GenericTeamToken # noqa
from .config import kfet_config
from .utils import to_ukf
def choices_length(choices):
return reduce(lambda m, choice: max(m, len(choice[0])), choices, 0)
def default_promo():
now = date.today()
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):
# XXX. change this to "profile"
profile = models.OneToOneField(Profile,
related_name="account_kfet",
on_delete=models.CASCADE)
objects = AccountManager()
profile = models.OneToOneField(
Profile,
related_name="account_kfet",
on_delete=models.PROTECT
)
trigramme = models.CharField(
unique = True,
max_length = 3,
@ -42,14 +72,15 @@ class Account(models.Model):
balance = models.DecimalField(
max_digits = 6, decimal_places = 2,
default = 0)
is_frozen = models.BooleanField(default = False)
created_at = models.DateTimeField(auto_now_add = True, null = True)
is_frozen = models.BooleanField("est gelé", default = False)
created_at = models.DateTimeField(default=timezone.now)
# Optional
PROMO_CHOICES = [(r,r) for r in range(1980, date.today().year+1)]
promo = models.IntegerField(
choices = PROMO_CHOICES,
blank = True, null = True, default = default_promo())
nickname = models.CharField(
"surnom(s)",
max_length = 255,
blank = True, default = "")
password = models.CharField(
@ -57,36 +88,62 @@ class Account(models.Model):
unique = True,
blank = True, null = True, default = None)
class Meta:
permissions = (
('is_team', 'Is part of the team'),
('manage_perms', 'Gérer les permissions K-Fêt'),
('manage_addcosts', 'Gérer les majorations'),
('edit_balance_account', "Modifier la balance d'un compte"),
('change_account_password',
"Modifier le mot de passe d'une personne de l'équipe"),
('special_add_account',
"Créer un compte avec une balance initiale"),
('can_force_close', "Fermer manuellement la K-Fêt"),
('see_config', "Voir la configuration K-Fêt"),
('change_config', "Modifier la configuration K-Fêt"),
)
def __str__(self):
return '%s (%s)' % (self.trigramme, self.name)
# Propriétés pour accéder aux attributs de user et profile et user
# Propriétés pour accéder aux attributs de user et profile
@property
def user(self):
return self.profile.user
@property
def username(self):
return self.profile.user.username
@property
def first_name(self):
return self.profile.user.first_name
@property
def last_name(self):
return self.profile.user.last_name
@property
def email(self):
return self.profile.user.email
@property
def departement(self):
return self.profile.departement
@property
def is_cof(self):
return self.profile.cof.is_cof
# Propriétés supplémentaires
@property
def balance_ukf(self):
return to_ukf(self.balance, is_cof=self.is_cof)
@property
def real_balance(self):
if (hasattr(self, 'negative')):
if hasattr(self, 'negative') and self.negative.balance_offset:
return self.balance - self.negative.balance_offset
return self.balance
@ -102,6 +159,14 @@ class Account(models.Model):
def need_comment(self):
return self.trigramme == '#13'
@property
def readable(self):
return self.trigramme != 'GNR'
@property
def is_team(self):
return self.has_perm('kfet.is_team')
@staticmethod
def is_validandfree(trigramme):
data = { 'is_valid' : False, 'is_free' : False }
@ -114,8 +179,8 @@ class Account(models.Model):
return data
def perms_to_perform_operation(self, amount):
overdraft_duration_max = Settings.OVERDRAFT_DURATION()
overdraft_amount_max = Settings.OVERDRAFT_AMOUNT()
overdraft_duration_max = kfet_config.overdraft_duration
overdraft_amount_max = kfet_config.overdraft_amount
perms = set()
stop_ope = False
# Checking is cash account
@ -157,6 +222,7 @@ class Account(models.Model):
# - Enregistre User, Profile à partir de "data"
# - Enregistre Account
def save(self, data = {}, *args, **kwargs):
if self.pk and data:
# Account update
@ -203,33 +269,89 @@ class Account(models.Model):
self.profile = profile
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
# Pas de suppression possible
# Cas à régler plus tard
def delete(self, *args, **kwargs):
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):
def __init__(self, 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):
created_by = models.ForeignKey(
Account, on_delete = models.PROTECT,
@ -269,29 +391,35 @@ class CheckoutStatement(models.Model):
checkout = models.ForeignKey(
Checkout, on_delete = models.PROTECT,
related_name = "statements")
balance_old = models.DecimalField(max_digits = 6, decimal_places = 2)
balance_new = models.DecimalField(max_digits = 6, decimal_places = 2)
amount_taken = models.DecimalField(max_digits = 6, decimal_places = 2)
amount_error = models.DecimalField(max_digits = 6, decimal_places = 2)
balance_old = models.DecimalField("ancienne balance",
max_digits = 6, decimal_places = 2)
balance_new = models.DecimalField("nouvelle balance",
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)
not_count = models.BooleanField(default=False)
not_count = models.BooleanField("caisse non comptée", default=False)
taken_001 = models.PositiveSmallIntegerField(default=0)
taken_002 = models.PositiveSmallIntegerField(default=0)
taken_005 = models.PositiveSmallIntegerField(default=0)
taken_01 = models.PositiveSmallIntegerField(default=0)
taken_02 = models.PositiveSmallIntegerField(default=0)
taken_05 = models.PositiveSmallIntegerField(default=0)
taken_1 = models.PositiveSmallIntegerField(default=0)
taken_2 = models.PositiveSmallIntegerField(default=0)
taken_5 = models.PositiveSmallIntegerField(default=0)
taken_10 = models.PositiveSmallIntegerField(default=0)
taken_20 = models.PositiveSmallIntegerField(default=0)
taken_50 = models.PositiveSmallIntegerField(default=0)
taken_100 = models.PositiveSmallIntegerField(default=0)
taken_200 = models.PositiveSmallIntegerField(default=0)
taken_500 = models.PositiveSmallIntegerField(default=0)
taken_cheque = models.DecimalField(default=0, max_digits=6, decimal_places=2)
taken_001 = models.PositiveSmallIntegerField("pièces de 1¢", default=0)
taken_002 = models.PositiveSmallIntegerField("pièces de 2¢", default=0)
taken_005 = models.PositiveSmallIntegerField("pièces de 5¢", default=0)
taken_01 = models.PositiveSmallIntegerField("pièces de 10¢", default=0)
taken_02 = models.PositiveSmallIntegerField("pièces de 20¢", default=0)
taken_05 = models.PositiveSmallIntegerField("pièces de 50¢", default=0)
taken_1 = models.PositiveSmallIntegerField("pièces de 1€", default=0)
taken_2 = models.PositiveSmallIntegerField("pièces de 2€", default=0)
taken_5 = models.PositiveSmallIntegerField("billets de 5€", default=0)
taken_10 = models.PositiveSmallIntegerField("billets de 10€", default=0)
taken_20 = models.PositiveSmallIntegerField("billets de 20€", default=0)
taken_50 = models.PositiveSmallIntegerField("billets de 50€", default=0)
taken_100 = models.PositiveSmallIntegerField("billets de 100€", default=0)
taken_200 = models.PositiveSmallIntegerField("billets de 200€", default=0)
taken_500 = models.PositiveSmallIntegerField("billets de 500€", default=0)
taken_cheque = models.DecimalField(
"montant des chèques",
default=0, max_digits=6, decimal_places=2)
def __str__(self):
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)
super(CheckoutStatement, self).save(*args, **kwargs)
@python_2_unicode_compatible
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):
return self.name
@python_2_unicode_compatible
class Article(models.Model):
name = models.CharField(max_length = 45)
is_sold = models.BooleanField(default = True)
name = models.CharField("nom", max_length = 45)
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(
"prix",
max_digits = 6, decimal_places = 2,
default = 0)
stock = models.IntegerField(default = 0)
category = models.ForeignKey(
ArticleCategory, on_delete = models.PROTECT,
related_name = "articles")
related_name = "articles", verbose_name='catégorie')
BOX_TYPE_CHOICES = (
("caisse", "caisse"),
("carton", "carton"),
@ -348,10 +489,12 @@ class Article(models.Model):
("fût", "fût"),
)
box_type = models.CharField(
"type de contenant",
choices = BOX_TYPE_CHOICES,
max_length = choices_length(BOX_TYPE_CHOICES),
blank = True, null = True, default = None)
box_capacity = models.PositiveSmallIntegerField(
"capacité du contenant",
blank = True, null = True, default = None)
def __str__(self):
@ -360,6 +503,10 @@ class Article(models.Model):
def get_absolute_url(self):
return reverse('kfet.article.read', kwargs={'pk': self.pk})
def price_ukf(self):
return to_ukf(self.price)
class ArticleRule(models.Model):
article_on = models.OneToOneField(
Article, on_delete = models.PROTECT,
@ -386,6 +533,10 @@ class Inventory(models.Model):
class Meta:
ordering = ['-at']
permissions = (
('order_to_inventory', "Générer un inventaire à partir d'une commande"),
)
class InventoryArticle(models.Model):
inventory = models.ForeignKey(
@ -403,21 +554,24 @@ class InventoryArticle(models.Model):
self.stock_error = self.stock_new - self.stock_old
super(InventoryArticle, self).save(*args, **kwargs)
@python_2_unicode_compatible
class Supplier(models.Model):
articles = models.ManyToManyField(
Article,
through = 'SupplierArticle',
related_name = "suppliers")
name = models.CharField(max_length = 45)
address = models.TextField()
email = models.EmailField()
phone = models.CharField(max_length = 10)
comment = models.TextField()
verbose_name=_("articles vendus"),
through='SupplierArticle',
related_name='suppliers',
)
name = models.CharField(_("nom"), max_length=45)
address = models.TextField(_("adresse"), blank=True)
email = models.EmailField(_("adresse mail"), blank=True)
phone = models.CharField(_("téléphone"), max_length=20, blank=True)
comment = models.TextField(_("commentaire"), blank=True)
def __str__(self):
return self.name
class SupplierArticle(models.Model):
supplier = models.ForeignKey(
Supplier, on_delete = models.PROTECT)
@ -458,7 +612,7 @@ class OrderArticle(models.Model):
quantity_received = models.IntegerField(default = 0)
class TransferGroup(models.Model):
at = models.DateTimeField(auto_now_add = True)
at = models.DateTimeField(default=timezone.now)
# Optional
comment = models.CharField(
max_length = 255,
@ -468,24 +622,29 @@ class TransferGroup(models.Model):
related_name = "+",
blank = True, null = True, default = None)
class Transfer(models.Model):
group = models.ForeignKey(
TransferGroup, on_delete = models.PROTECT,
related_name = "transfers")
TransferGroup, on_delete=models.PROTECT,
related_name="transfers")
from_acc = models.ForeignKey(
Account, on_delete = models.PROTECT,
related_name = "transfers_from")
Account, on_delete=models.PROTECT,
related_name="transfers_from")
to_acc = models.ForeignKey(
Account, on_delete = models.PROTECT,
related_name = "transfers_to")
amount = models.DecimalField(max_digits = 6, decimal_places = 2)
Account, on_delete=models.PROTECT,
related_name="transfers_to")
amount = models.DecimalField(max_digits=6, decimal_places=2)
# Optional
canceled_by = models.ForeignKey(
Account, on_delete = models.PROTECT,
null = True, blank = True, default = None,
related_name = "+")
Account, on_delete=models.PROTECT,
null=True, blank=True, default=None,
related_name="+")
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):
on_acc = models.ForeignKey(
@ -494,7 +653,7 @@ class OperationGroup(models.Model):
checkout = models.ForeignKey(
Checkout, on_delete = models.PROTECT,
related_name = "opesgroup")
at = models.DateTimeField(auto_now_add = True)
at = models.DateTimeField(default=timezone.now)
amount = models.DecimalField(
max_digits = 6, decimal_places = 2,
default = 0)
@ -508,180 +667,81 @@ class OperationGroup(models.Model):
related_name = "+",
blank = True, null = True, default = None)
def __str__(self):
return ', '.join(map(str, self.opes.all()))
class Operation(models.Model):
PURCHASE = 'purchase'
DEPOSIT = 'deposit'
DEPOSIT = 'deposit'
WITHDRAW = 'withdraw'
INITIAL = 'initial'
EDIT = 'edit'
TYPE_ORDER_CHOICES = (
(PURCHASE, 'Achat'),
(DEPOSIT, 'Charge'),
(WITHDRAW, 'Retrait'),
(INITIAL, 'Initial'),
(EDIT, 'Édition'),
)
group = models.ForeignKey(
OperationGroup, on_delete = models.PROTECT,
related_name = "opes")
OperationGroup, on_delete=models.PROTECT,
related_name="opes")
type = models.CharField(
choices = TYPE_ORDER_CHOICES,
max_length = choices_length(TYPE_ORDER_CHOICES))
choices=TYPE_ORDER_CHOICES,
max_length=choices_length(TYPE_ORDER_CHOICES))
amount = models.DecimalField(
max_digits = 6, decimal_places = 2,
blank = True, default = 0)
is_checkout = models.BooleanField(default = True)
max_digits=6, decimal_places=2,
blank=True, default=0)
# Optional
article = models.ForeignKey(
Article, on_delete = models.PROTECT,
related_name = "operations",
blank = True, null = True, default = None)
Article, on_delete=models.PROTECT,
related_name="operations",
blank=True, null=True, default=None)
article_nb = models.PositiveSmallIntegerField(
blank = True, null = True, default = None)
blank=True, null=True, default=None)
canceled_by = models.ForeignKey(
Account, on_delete = models.PROTECT,
related_name = "+",
blank = True, null = True, default = None)
Account, on_delete=models.PROTECT,
related_name="+",
blank=True, null=True, default=None)
canceled_at = models.DateTimeField(
blank = True, null = True, default = None)
blank=True, null=True, default=None)
addcost_for = models.ForeignKey(
Account, on_delete = models.PROTECT,
related_name = "addcosts",
blank = True, null = True, default = None)
Account, on_delete=models.PROTECT,
related_name="addcosts",
blank=True, null=True, default=None)
addcost_amount = models.DecimalField(
max_digits = 6, decimal_places = 2,
blank = True, null = True, default = None)
max_digits=6, decimal_places=2,
blank=True, null=True, default=None)
class GlobalPermissions(models.Model):
class Meta:
managed = False
permissions = (
('is_team', 'Is part of the team'),
('perform_deposit', 'Effectuer une charge'),
('perform_negative_operations',
'Enregistrer des commandes en négatif'),
('override_frozen_protection', "Forcer le gel d'un compte"),
('cancel_old_operations', 'Annuler des commandes non récentes'),
('manage_perms', 'Gérer les permissions K-Fêt'),
('manage_addcosts', 'Gérer les majorations'),
('perform_commented_operations', 'Enregistrer des commandes avec commentaires'),
('view_negs', 'Voir la liste des négatifs'),
('order_to_inventory', "Générer un inventaire à partir d'une commande"),
('edit_balance_account', "Modifier la balance d'un compte"),
('change_account_password', "Modifier le mot de passe d'une personne de l'équipe"),
('special_add_account', "Créer un compte avec une balance initiale")
('perform_commented_operations',
'Enregistrer des commandes avec commentaires'),
)
class Settings(models.Model):
name = models.CharField(
max_length = 45,
unique = True,
db_index = True)
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)
@property
def is_checkout(self):
return (self.type == Operation.DEPOSIT or
self.type == Operation.WITHDRAW or
(self.type == Operation.PURCHASE and self.group.on_acc.is_cash)
)
@staticmethod
def setting_inst(name):
return Settings.objects.get(name=name)
@staticmethod
def SUBVENTION_COF():
subvention_cof = cache.get('SUBVENTION_COF')
if subvention_cof:
return subvention_cof
try:
subvention_cof = Settings.setting_inst("SUBVENTION_COF").value_decimal
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)
def __str__(self):
templates = {
self.PURCHASE: "{nb} {article.name} ({amount}€)",
self.DEPOSIT: "charge ({amount}€)",
self.WITHDRAW: "retrait ({amount}€)",
self.INITIAL: "initial ({amount}€)",
self.EDIT: "édition ({amount}€)",
}
return templates[self.type].format(nb=self.article_nb,
article=self.article,
amount=self.amount)

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