Merge branch 'master' into Kerl/drop_py2_compat

This commit is contained in:
Martin Pépin 2018-04-16 14:12:36 +02:00
commit a73736bf41
84 changed files with 10296 additions and 486 deletions

View file

@ -1,5 +1,7 @@
# GestioCOF
![build_status](https://git.eleves.ens.fr/cof-geek/gestioCOF/badges/master/build.svg)
## Installation
### Vagrant

1
TODO_PROD.md Normal file
View file

@ -0,0 +1 @@
- Changer les urls dans les mails "bda-revente" et "bda-shotgun"

View file

@ -234,7 +234,7 @@ class SpectacleReventeAdminForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['answered_mail'].queryset = (
self.fields['confirmed_entry'].queryset = (
Participant.objects
.select_related('user', 'tirage')
)
@ -297,13 +297,7 @@ class SpectacleReventeAdmin(admin.ModelAdmin):
count = queryset.count()
for revente in queryset.filter(
attribution__spectacle__date__gte=timezone.now()):
revente.date = timezone.now() - timedelta(hours=1)
revente.soldTo = None
revente.notif_sent = False
revente.tirage_done = False
if revente.answered_mail:
revente.answered_mail.clear()
revente.save()
revente.reset(new_date=timezone.now() - timedelta(hours=1))
self.message_user(
request,
"%d attribution%s %s été réinitialisée%s avec succès." % (

View file

@ -2,7 +2,7 @@ from django import forms
from django.forms.models import BaseInlineFormSet
from django.utils import timezone
from bda.models import Attribution, Spectacle
from bda.models import Attribution, Spectacle, SpectacleRevente
class InscriptionInlineFormSet(BaseInlineFormSet):
@ -41,7 +41,33 @@ class TokenForm(forms.Form):
class AttributionModelMultipleChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj):
return "%s" % str(obj.spectacle)
return str(obj.spectacle)
class ReventeModelMultipleChoiceField(forms.ModelMultipleChoiceField):
def __init__(self, *args, own=True, **kwargs):
super().__init__(*args, **kwargs)
self.own = own
def label_from_instance(self, obj):
label = "{show}{suffix}"
suffix = ""
if self.own:
# C'est notre propre revente : pas besoin de spécifier le vendeur
if obj.soldTo is not None:
suffix = " -- Vendue à {firstname} {lastname}".format(
firstname=obj.soldTo.user.first_name,
lastname=obj.soldTo.user.last_name,
)
else:
# Ce n'est pas à nous : on ne voit jamais l'acheteur
suffix = " -- Vendue par {firstname} {lastname}".format(
firstname=obj.seller.user.first_name,
lastname=obj.seller.user.last_name,
)
return label.format(show=str(obj.attribution.spectacle),
suffix=suffix)
class ResellForm(forms.Form):
@ -63,7 +89,8 @@ class ResellForm(forms.Form):
class AnnulForm(forms.Form):
attributions = AttributionModelMultipleChoiceField(
reventes = ReventeModelMultipleChoiceField(
own=True,
label='',
queryset=Attribution.objects.none(),
widget=forms.CheckboxSelectMultiple,
@ -71,14 +98,13 @@ class AnnulForm(forms.Form):
def __init__(self, participant, *args, **kwargs):
super(AnnulForm, self).__init__(*args, **kwargs)
self.fields['attributions'].queryset = (
participant.attribution_set
.filter(spectacle__date__gte=timezone.now(),
revente__isnull=False,
revente__notif_sent=False,
revente__soldTo__isnull=True)
.select_related('spectacle', 'spectacle__location',
'participant__user')
self.fields['reventes'].queryset = (
participant.original_shows
.filter(attribution__spectacle__date__gte=timezone.now(),
notif_sent=False,
soldTo__isnull=True)
.select_related('attribution__spectacle',
'attribution__spectacle__location')
)
@ -97,19 +123,58 @@ class InscriptionReventeForm(forms.Form):
)
class ReventeTirageAnnulForm(forms.Form):
reventes = ReventeModelMultipleChoiceField(
own=False,
label='',
queryset=SpectacleRevente.objects.none(),
widget=forms.CheckboxSelectMultiple,
required=False
)
def __init__(self, participant, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['reventes'].queryset = (
participant.entered.filter(soldTo__isnull=True)
.select_related('attribution__spectacle',
'seller__user')
)
class ReventeTirageForm(forms.Form):
reventes = ReventeModelMultipleChoiceField(
own=False,
label='',
queryset=SpectacleRevente.objects.none(),
widget=forms.CheckboxSelectMultiple,
required=False
)
def __init__(self, participant, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['reventes'].queryset = (
SpectacleRevente.objects.filter(
notif_sent=True,
shotgun=False,
tirage_done=False
).exclude(confirmed_entry=participant)
.select_related('attribution__spectacle')
)
class SoldForm(forms.Form):
attributions = AttributionModelMultipleChoiceField(
reventes = ReventeModelMultipleChoiceField(
own=True,
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')
self.fields['reventes'].queryset = (
participant.original_shows
.filter(soldTo__isnull=False)
.exclude(soldTo=participant)
.select_related('attribution__spectacle',
'attribution__spectacle__location')
)

View file

@ -2,7 +2,6 @@
Gestion en ligne de commande des reventes.
"""
from datetime import timedelta
from django.core.management import BaseCommand
from django.utils import timezone
from bda.models import SpectacleRevente
@ -17,23 +16,36 @@ class Command(BaseCommand):
now = timezone.now()
reventes = SpectacleRevente.objects.all()
for revente in reventes:
# Check si < 24h
if (revente.attribution.spectacle.date <=
revente.date + timedelta(days=1)) and \
now >= revente.date + timedelta(minutes=15) and \
not revente.notif_sent:
self.stdout.write(str(now))
revente.mail_shotgun()
self.stdout.write("Mail de disponibilité immédiate envoyé")
# Check si délai de retrait dépassé
elif (now >= revente.date + timedelta(hours=1) and
not revente.notif_sent):
# Le spectacle est bientôt et on a pas encore envoyé de mail :
# on met la place au shotgun et on prévient.
if revente.is_urgent and not revente.notif_sent:
if revente.can_notif:
self.stdout.write(str(now))
revente.mail_shotgun()
self.stdout.write(
"Mails de disponibilité immédiate envoyés "
"pour la revente [%s]" % revente
)
# Le spectacle est dans plus longtemps : on prévient
elif (revente.can_notif and not revente.notif_sent):
self.stdout.write(str(now))
revente.send_notif()
self.stdout.write("Mail d'inscription à une revente envoyé")
# Check si tirage à faire
elif (now >= revente.date_tirage and
not revente.tirage_done):
self.stdout.write(
"Mails d'inscription à la revente [%s] envoyés"
% revente
)
# On fait le tirage
elif (now >= revente.date_tirage and not revente.tirage_done):
self.stdout.write(str(now))
revente.tirage()
self.stdout.write("Tirage effectué, mails envoyés")
winner = revente.tirage()
self.stdout.write(
"Tirage effectué pour la revente [%s]"
% revente
)
if winner:
self.stdout.write("Gagnant : %s" % winner.user)
else:
self.stdout.write("Pas de gagnant ; place au shotgun")

View file

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

View file

@ -172,6 +172,7 @@ class Participant(models.Model):
def __str__(self):
return "%s - %s" % (self.user, self.tirage.title)
DOUBLE_CHOICES = (
("1", "1 place"),
("autoquit", "2 places si possible, 1 sinon"),
@ -230,9 +231,9 @@ class SpectacleRevente(models.Model):
)
date = models.DateTimeField("Date de mise en vente",
default=timezone.now)
answered_mail = models.ManyToManyField(Participant,
related_name="wanted",
blank=True)
confirmed_entry = models.ManyToManyField(Participant,
related_name="entered",
blank=True)
seller = models.ForeignKey(
Participant, on_delete=models.CASCADE,
verbose_name="Vendeur",
@ -246,21 +247,61 @@ class SpectacleRevente(models.Model):
notif_sent = models.BooleanField("Notification envoyée",
default=False)
notif_time = models.DateTimeField("Moment d'envoi de la notification",
blank=True, null=True)
tirage_done = models.BooleanField("Tirage effectué",
default=False)
shotgun = models.BooleanField("Disponible immédiatement",
default=False)
####
# Some class attributes
###
# TODO : settings ?
# Temps minimum entre le tirage et le spectacle
min_margin = timedelta(days=5)
# Temps entre la création d'une revente et l'envoi du mail
remorse_time = timedelta(hours=1)
# Temps min/max d'attente avant le tirage
max_wait_time = timedelta(days=3)
min_wait_time = timedelta(days=1)
@property
def real_notif_time(self):
if self.notif_time:
return self.notif_time
else:
return self.date + self.remorse_time
@property
def date_tirage(self):
"""Renvoie la date du tirage au sort de la revente."""
# L'acheteur doit être connu au plus 12h avant le spectacle
remaining_time = (self.attribution.spectacle.date
- self.date - timedelta(hours=13))
# Au minimum, on attend 2 jours avant le tirage
delay = min(remaining_time, timedelta(days=2))
# Le vendeur a aussi 1h pour changer d'avis
return self.date + delay + timedelta(hours=1)
- self.real_notif_time - self.min_margin)
delay = min(remaining_time, self.max_wait_time)
return self.real_notif_time + delay
@property
def is_urgent(self):
"""
Renvoie True iff la revente doit être mise au shotgun directement.
Plus précisément, on doit avoir min_margin + min_wait_time de marge.
"""
spectacle_date = self.attribution.spectacle.date
return (spectacle_date <= timezone.now() + self.min_margin
+ self.min_wait_time)
@property
def can_notif(self):
return (timezone.now() >= self.date + self.remorse_time)
def __str__(self):
return "%s -- %s" % (self.seller,
@ -269,6 +310,18 @@ class SpectacleRevente(models.Model):
class Meta:
verbose_name = "Revente"
def reset(self, new_date=timezone.now()):
"""Réinitialise la revente pour permettre une remise sur le marché"""
self.seller = self.attribution.participant
self.date = new_date
self.confirmed_entry.clear()
self.soldTo = None
self.notif_sent = False
self.notif_time = None
self.tirage_done = False
self.shotgun = False
self.save()
def send_notif(self):
"""
Envoie une notification pour indiquer la mise en vente d'une place sur
@ -289,6 +342,7 @@ class SpectacleRevente(models.Model):
]
send_mass_custom_mail(datatuple)
self.notif_sent = True
self.notif_time = timezone.now()
self.save()
def mail_shotgun(self):
@ -310,76 +364,79 @@ class SpectacleRevente(models.Model):
]
send_mass_custom_mail(datatuple)
self.notif_sent = True
self.notif_time = timezone.now()
# Flag inutile, sauf si l'horloge interne merde
self.tirage_done = True
self.shotgun = True
self.save()
def tirage(self):
def tirage(self, send_mails=True):
"""
Lance le tirage au sort associé à la revente. Un gagnant est choisi
parmis les personnes intéressées par le spectacle. Les personnes sont
ensuites prévenues par mail du résultat du tirage.
"""
inscrits = list(self.answered_mail.all())
inscrits = list(self.confirmed_entry.all())
spectacle = self.attribution.spectacle
seller = self.seller
winner = None
if inscrits:
# Envoie un mail au gagnant et au vendeur
winner = random.choice(inscrits)
self.soldTo = winner
if send_mails:
mails = []
mails = []
context = {
'acheteur': winner.user,
'vendeur': seller.user,
'show': spectacle,
}
context = {
'acheteur': winner.user,
'vendeur': seller.user,
'show': spectacle,
}
c_mails_qs = CustomMail.objects.filter(shortname__in=[
'bda-revente-winner', 'bda-revente-loser',
'bda-revente-seller',
])
c_mails_qs = CustomMail.objects.filter(shortname__in=[
'bda-revente-winner', 'bda-revente-loser',
'bda-revente-seller',
])
c_mails = {cm.shortname: cm for cm in c_mails_qs}
c_mails = {cm.shortname: cm for cm in c_mails_qs}
mails.append(
c_mails['bda-revente-winner'].get_message(
context,
from_email=settings.MAIL_DATA['revente']['FROM'],
to=[winner.user.email],
)
)
mails.append(
c_mails['bda-revente-seller'].get_message(
context,
from_email=settings.MAIL_DATA['revente']['FROM'],
to=[seller.user.email],
reply_to=[winner.user.email],
)
)
# Envoie un mail aux perdants
for inscrit in inscrits:
if inscrit != winner:
new_context = dict(context)
new_context['acheteur'] = inscrit.user
mails.append(
c_mails['bda-revente-loser'].get_message(
new_context,
from_email=settings.MAIL_DATA['revente']['FROM'],
to=[inscrit.user.email],
)
mails.append(
c_mails['bda-revente-winner'].get_message(
context,
from_email=settings.MAIL_DATA['revente']['FROM'],
to=[winner.user.email],
)
)
mail_conn = mail.get_connection()
mail_conn.send_messages(mails)
mails.append(
c_mails['bda-revente-seller'].get_message(
context,
from_email=settings.MAIL_DATA['revente']['FROM'],
to=[seller.user.email],
reply_to=[winner.user.email],
)
)
# Envoie un mail aux perdants
for inscrit in inscrits:
if inscrit != winner:
new_context = dict(context)
new_context['acheteur'] = inscrit.user
mails.append(
c_mails['bda-revente-loser'].get_message(
new_context,
from_email=settings.MAIL_DATA['revente']['FROM'],
to=[inscrit.user.email],
)
)
mail_conn = mail.get_connection()
mail_conn.send_messages(mails)
# Si personne ne veut de la place, elle part au shotgun
else:
self.shotgun = True
self.tirage_done = True
self.save()
return winner

View file

@ -14,7 +14,7 @@
</tr></thead>
<tbody class="bda_formset_content">
{% endif %}
<tr class="{% cycle row1,row2 %} dynamic-form {% if form.instance.pk %}has_original{% endif %}">
<tr class="{% cycle 'row1' 'row2' %} dynamic-form {% if form.instance.pk %}has_original{% endif %}">
{% for field in form.visible_fields %}
{% if field.name != "DELETE" and field.name != "priority" %}
<td class="bda-field-{{ field.name }}">

View file

@ -27,6 +27,14 @@ var django = {
var newFor = $(this).attr('for').replace('-' + (total-1) + '-','-' + total + '-');
$(this).attr('for', newFor);
});
// Cloning <select> element doesn't properly propagate the default
// selected <option>, so we set it manually.
newElement.find('select').each(function (index, select) {
var defaultValue = $(select).find('option[selected]').val();
if (typeof defaultValue !== 'undefined') {
$(select).val(defaultValue);
}
});
total++;
$('#id_' + type + '-TOTAL_FORMS').val(total);
$(selector).after(newElement);

View file

@ -1,33 +0,0 @@
{% extends "base_title.html" %}
{% load bootstrap %}
{% block realcontent %}
<h2>Inscriptions pour BdA-Revente</h2>
<form action="" class="form-horizontal" method="post">
{% csrf_token %}
<div class="form-group">
<h3>Spectacles</h3>
<br/>
<button type="button" class="btn btn-primary" onClick="select(true)">Tout sélectionner</button>
<button type="button" class="btn btn-primary" onClick="select(false)">Tout désélectionner</button>
<div class="multiple-checkbox">
<ul>
{% for checkbox in form.spectacles %}
<li>{{checkbox}}</li>
{%endfor%}
</ul>
</div>
</div>
<input type="submit" class="btn btn-primary" value="S'inscrire pour les places sélectionnées">
</form>
<script language="JavaScript">
function select(check) {
checkboxes = document.getElementsByName("spectacles");
for(var i=0, n=checkboxes.length;i<n;i++) {
checkboxes[i].checked = check;
}
}
</script>
{% endblock %}

View file

@ -16,7 +16,7 @@
<h4 class="bda-prix">Total à payer : {{ total|floatformat }}€</h4>
<br/>
<p>Ne manque pas un spectacle avec le
<a href="{% url "gestioncof.views.calendar" %}">calendrier
<a href="{% url "calendar" %}">calendrier
automatique&#8239;!</a></p>
{% else %}
<h3>Vous n'avez aucune place :(</h3>

View file

@ -0,0 +1,90 @@
{% extends "base_title.html" %}
{% load bootstrap %}
{% block realcontent %}
<h2>Gestion des places que je revends</h2>
{% with resell_attributions=resellform.attributions annul_reventes=annulform.reventes sold_reventes=soldform.reventes %}
{% if resellform.attributions %}
<br />
<h3>Places non revendues</h3>
<form class="form-horizontal" action="" method="post">
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
Cochez les places que vous souhaitez revendre, et validez. Vous aurez
ensuite 1h pour changer d'avis avant que la revente soit confirmée et
que les notifications soient envoyées aux intéressé·e·s.
</div>
<div class="bootstrap-form-reduce">
{% csrf_token %}
{{ resellform|bootstrap }}
</div>
<div class="form-actions">
<input type="submit" class="btn btn-primary" name="resell" value="Revendre les places sélectionnées">
</div>
</form>
<hr />
{% endif %}
{% if annul_reventes or overdue %}
<h3>Places en cours de revente</h3>
<form action="" method="post">
{% if annul_reventes %}
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
Vous pouvez annuler les places mises en vente il y a moins d'une heure.
</div>
{% endif %}
{% csrf_token %}
<div class='form-group'>
<div class='multiple-checkbox'>
<ul>
{% for revente in annul_reventes %}
<li>{{ revente.tag }} {{ revente.choice_label }}</li>
{% endfor %}
{% for attrib in overdue %}
<li>
<input type="checkbox" style="visibility:hidden">
{{ attrib.spectacle }}
</li>
{% endfor %}
</ul>
</div>
</div>
{% if annul_reventes %}
<input type="submit" class="btn btn-primary" name="annul" value="Annuler les reventes sélectionnées">
{% endif %}
</form>
<hr />
{% endif %}
{% if sold_reventes %}
<h3>Places revendues</h3>
<form action="" method="post">
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
Pour chaque revente, vous devez soit l'annuler soit la confirmer pour
transférer la place la place à la personne tirée au sort.
L'annulation sert par exemple à pouvoir remettre la place en jeu si
vous ne parvenez pas à entrer en contact avec la personne tirée au
sort.
</div>
<div class="bootstrap-form-reduce">
{% csrf_token %}
{{ soldform|bootstrap }}
</div>
<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_reventes %}
<p>Plus de reventes possibles !</p>
{% endif %}
{% endwith %}
{% endblock %}

View file

@ -5,7 +5,7 @@
{% if shotgun %}
<ul class="list-unstyled">
{% for spectacle in shotgun %}
<li><a href="{% url "bda-buy-revente" spectacle.id %}">{{spectacle}}</a></li>
<li><a href="{% url "bda-revente-buy" spectacle.id %}">{{spectacle}}</a></li>
{% endfor %}
{% else %}
<p> Pas de places disponibles immédiatement, désolé !</p>

View file

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

View file

@ -0,0 +1,52 @@
{% extends "base_title.html" %}
{% load bootstrap %}
{% block realcontent %}
<h2>Tirages au sort de reventes</h2>
{% if annulform.reventes %}
<h3>Les reventes auxquelles vous êtes inscrit·e</h3>
<form class="form-horizontal" action="" method="post">
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
Vous pouvez vous désinscrire des reventes suivantes tant que le tirage n'a
pas eu lieu.
</div>
<div class="bootstrap-form-reduce">
{% csrf_token %}
{{ annulform|bootstrap }}
</div>
<div class="form-actions">
<input type="submit"
class="btn btn-primary"
name="annul"
value="Se désinscrire des tirages sélectionnés">
</div>
</form>
<hr />
{% endif %}
{% if subform.reventes %}
<h3>Tirages en cours</h3>
<form class="form-horizontal" action="" method="post">
<div class="bg-info text-info center-block">
<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
Vous pouvez vous inscrire aux tirage en cours suivants.
</div>
<div class="bootstrap-form-reduce">
{% csrf_token %}
{{ subform|bootstrap }}
</div>
<div class="form-actions">
<input type="submit"
class="btn btn-primary"
name="subscribe"
value="S'inscrire aux tirages sélectionnés">
</div>
</form>
{% endif %}
{% endblock %}

View file

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

View file

@ -1,56 +0,0 @@
{% 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

@ -67,7 +67,7 @@ class SpectacleReventeTests(TestCase):
revente = self.rev
wanted_by = [self.p1, self.p2, self.p3]
revente.answered_mail = wanted_by
revente.confirmed_entry = wanted_by
with mock.patch('bda.models.random.choice') as mc:
# Set winner to self.p1.

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

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

View file

@ -10,9 +10,6 @@ urlpatterns = [
url(r'^places/(?P<tirage_id>\d+)$',
views.places,
name="bda-places-attribuees"),
url(r'^revente/(?P<tirage_id>\d+)$',
views.revente,
name='bda-revente'),
url(r'^etat-places/(?P<tirage_id>\d+)$',
views.etat_places,
name='bda-etat-places'),
@ -32,18 +29,28 @@ urlpatterns = [
url(r'^participants/autocomplete$',
views.participant_autocomplete,
name="bda-participant-autocomplete"),
url(r'^liste-revente/(?P<tirage_id>\d+)$',
views.list_revente,
name="bda-liste-revente"),
url(r'^buy-revente/(?P<spectacle_id>\d+)$',
views.buy_revente,
name="bda-buy-revente"),
url(r'^revente-interested/(?P<revente_id>\d+)$',
views.revente_interested,
name='bda-revente-interested'),
url(r'^revente-immediat/(?P<tirage_id>\d+)$',
# Urls BdA-Revente
url(r'^revente/(?P<tirage_id>\d+)/manage$',
views.revente_manage,
name='bda-revente-manage'),
url(r'^revente/(?P<tirage_id>\d+)/subscribe$',
views.revente_subscribe,
name="bda-revente-subscribe"),
url(r'^revente/(?P<tirage_id>\d+)/tirages$',
views.revente_tirages,
name="bda-revente-tirages"),
url(r'^revente/(?P<spectacle_id>\d+)/buy$',
views.revente_buy,
name="bda-revente-buy"),
url(r'^revente/(?P<revente_id>\d+)/confirm$',
views.revente_confirm,
name='bda-revente-confirm'),
url(r'^revente/(?P<tirage_id>\d+)/shotgun$',
views.revente_shotgun,
name="bda-shotgun"),
name="bda-revente-shotgun"),
url(r'^mails-rappel/(?P<spectacle_id>\d+)$',
views.send_rappel,
name="bda-rappels"

View file

@ -3,7 +3,6 @@ import random
import hashlib
import time
import json
from datetime import timedelta
from custommail.shortcuts import send_mass_custom_mail, send_custom_mail
from custommail.models import CustomMail
from django.shortcuts import render, get_object_or_404
@ -12,6 +11,7 @@ from django.contrib import messages
from django.db import transaction
from django.core import serializers
from django.db.models import Count, Q, Prefetch
from django.template.defaultfilters import pluralize
from django.forms.models import inlineformset_factory
from django.http import (
HttpResponseBadRequest, HttpResponseRedirect, JsonResponse
@ -28,7 +28,7 @@ from bda.models import (
from bda.algorithm import Algorithm
from bda.forms import (
TokenForm, ResellForm, AnnulForm, InscriptionReventeForm, SoldForm,
InscriptionInlineFormSet,
InscriptionInlineFormSet, ReventeTirageForm, ReventeTirageAnnulForm
)
from utils.views.autocomplete import Select2QuerySetView
@ -349,13 +349,21 @@ def tirage(request, tirage_id):
@login_required
def revente(request, tirage_id):
def revente_manage(request, tirage_id):
"""
Gestion de ses propres reventes :
- Création d'une revente
- Annulation d'une revente
- Confirmation d'une revente = transfert de la place à la personne qui
rachète
- Annulation d'une revente après que le tirage a eu lieu
"""
tirage = get_object_or_404(Tirage, id=tirage_id)
participant, created = Participant.objects.get_or_create(
user=request.user, tirage=tirage)
if not participant.paid:
return render(request, "bda-notpaid.html", {})
return render(request, "bda/revente/notpaid.html", {})
resellform = ResellForm(participant, prefix='resell')
annulform = AnnulForm(participant, prefix='annul')
@ -375,12 +383,8 @@ def revente(request, tirage_id):
attribution=attribution,
defaults={'seller': participant})
if not created:
revente.seller = participant
revente.date = timezone.now()
revente.soldTo = None
revente.notif_sent = False
revente.tirage_done = False
revente.shotgun = False
revente.reset()
context = {
'vendeur': participant.user,
'show': attribution.spectacle,
@ -397,18 +401,18 @@ def revente(request, tirage_id):
elif 'annul' in request.POST:
annulform = AnnulForm(participant, request.POST, prefix='annul')
if annulform.is_valid():
attributions = annulform.cleaned_data["attributions"]
for attribution in attributions:
attribution.revente.delete()
reventes = annulform.cleaned_data["reventes"]
for revente in reventes:
revente.delete()
# On confirme une vente en transférant la place à la personne qui a
# gagné le tirage
elif 'transfer' in request.POST:
soldform = SoldForm(participant, request.POST, prefix='sold')
if soldform.is_valid():
attributions = soldform.cleaned_data['attributions']
for attribution in attributions:
attribution.participant = attribution.revente.soldTo
attribution.save()
reventes = soldform.cleaned_data['reventes']
for reventes in reventes:
revente.attribution.participant = revente.soldTo
revente.attribution.save()
# On annule la revente après le tirage au sort (par exemple si
# la personne qui a gagné le tirage ne se manifeste pas). La place est
@ -416,18 +420,13 @@ def revente(request, tirage_id):
elif 'reinit' in request.POST:
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()
reventes = soldform.cleaned_data['reventes']
for revente in reventes:
if revente.attribution.spectacle.date > timezone.now():
# On antidate pour envoyer le mail plus vite
new_date = (timezone.now()
- SpectacleRevente.remorse_time)
revente.reset(new_date=new_date)
overdue = participant.attribution_set.filter(
spectacle__date__gte=timezone.now(),
@ -437,28 +436,80 @@ def revente(request, tirage_id):
.filter(
Q(revente__soldTo__isnull=True) | Q(revente__soldTo=participant))
return render(request, "bda/reventes.html",
return render(request, "bda/revente/manage.html",
{'tirage': tirage, 'overdue': overdue, "soldform": soldform,
"annulform": annulform, "resellform": resellform})
@login_required
def revente_interested(request, revente_id):
def revente_tirages(request, tirage_id):
"""
Affiche à un participant la liste de toutes les reventes en cours (pour un
tirage donné) et lui permet de s'inscrire et se désinscrire à ces reventes.
"""
tirage = get_object_or_404(Tirage, id=tirage_id)
participant, _ = Participant.objects.get_or_create(
user=request.user, tirage=tirage)
subform = ReventeTirageForm(participant, prefix="subscribe")
annulform = ReventeTirageAnnulForm(participant, prefix="annul")
if request.method == 'POST':
if "subscribe" in request.POST:
subform = ReventeTirageForm(participant, request.POST,
prefix="subscribe")
if subform.is_valid():
reventes = subform.cleaned_data['reventes']
count = reventes.count()
for revente in reventes:
revente.confirmed_entry.add(participant)
if count > 0:
messages.success(
request,
"Tu as bien été inscrit à {} revente{}"
.format(count, pluralize(count))
)
elif "annul" in request.POST:
annulform = ReventeTirageAnnulForm(participant, request.POST,
prefix="annul")
if annulform.is_valid():
reventes = annulform.cleaned_data['reventes']
count = reventes.count()
for revente in reventes:
revente.confirmed_entry.remove(participant)
if count > 0:
messages.success(
request,
"Tu as bien été désinscrit de {} revente{}"
.format(count, pluralize(count))
)
return render(request, "bda/revente/tirages.html",
{"annulform": annulform, "subform": subform})
@login_required
def revente_confirm(request, revente_id):
revente = get_object_or_404(SpectacleRevente, id=revente_id)
participant, _ = Participant.objects.get_or_create(
user=request.user, tirage=revente.attribution.spectacle.tirage)
if (timezone.now() < revente.date + timedelta(hours=1)) or revente.shotgun:
return render(request, "bda-wrongtime.html",
if not revente.notif_sent or revente.shotgun:
return render(request, "bda/revente/wrongtime.html",
{"revente": revente})
revente.answered_mail.add(participant)
return render(request, "bda-interested.html",
revente.confirmed_entry.add(participant)
return render(request, "bda/revente/confirmed.html",
{"spectacle": revente.attribution.spectacle,
"date": revente.date_tirage})
@login_required
def list_revente(request, tirage_id):
def revente_subscribe(request, tirage_id):
"""
Permet à un participant de sélectionner ses préférences pour les reventes.
Il recevra des notifications pour les spectacles qui l'intéressent et il
est automatiquement inscrit aux reventes en cours au moment il ajoute un
spectacle à la liste des spectacles qui l'intéressent.
"""
tirage = get_object_or_404(Tirage, id=tirage_id)
participant, _ = Participant.objects.get_or_create(
user=request.user, tirage=tirage)
@ -484,12 +535,12 @@ def list_revente(request, tirage_id):
# la revente ayant le moins d'inscrits
min_resell = (
qset.filter(shotgun=False)
.annotate(nb_subscribers=Count('answered_mail'))
.annotate(nb_subscribers=Count('confirmed_entry'))
.order_by('nb_subscribers')
.first()
)
if min_resell is not None:
min_resell.answered_mail.add(participant)
min_resell.confirmed_entry.add(participant)
inscrit_revente.append(spectacle)
success = True
else:
@ -512,11 +563,11 @@ def list_revente(request, tirage_id):
)
messages.info(request, msg, extra_tags="safe")
return render(request, "bda/liste-reventes.html", {"form": form})
return render(request, "bda/revente/subscribe.html", {"form": form})
@login_required
def buy_revente(request, spectacle_id):
def revente_buy(request, spectacle_id):
spectacle = get_object_or_404(Spectacle, id=spectacle_id)
tirage = spectacle.tirage
participant, _ = Participant.objects.get_or_create(
@ -530,13 +581,13 @@ def buy_revente(request, spectacle_id):
own_reventes = reventes.filter(seller=participant)
if len(own_reventes) > 0:
own_reventes[0].delete()
return HttpResponseRedirect(reverse("bda-shotgun",
return HttpResponseRedirect(reverse("bda-revente-shotgun",
args=[tirage.id]))
reventes_shotgun = reventes.filter(shotgun=True)
if not reventes_shotgun:
return render(request, "bda-no-revente.html", {})
return render(request, "bda/revente/none.html", {})
if request.POST:
revente = random.choice(reventes_shotgun)
@ -553,11 +604,11 @@ def buy_revente(request, spectacle_id):
[revente.seller.user.email],
context=context,
)
return render(request, "bda-success.html",
return render(request, "bda/revente/mail-success.html",
{"seller": revente.attribution.participant.user,
"spectacle": spectacle})
return render(request, "revente-confirm.html",
return render(request, "bda/revente/confirm-shotgun.html",
{"spectacle": spectacle,
"user": request.user})
@ -581,7 +632,7 @@ def revente_shotgun(request, tirage_id):
)
shotgun = [sp for sp in spectacles if len(sp.shotguns) > 0]
return render(request, "bda-shotgun.html",
return render(request, "bda/revente/shotgun.html",
{"shotgun": shotgun})

View file

@ -6,6 +6,7 @@ the local development server should be here.
"""
import os
import sys
try:
from . import secret
@ -52,9 +53,13 @@ BASE_DIR = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
TESTING = sys.argv[1] == 'test'
# Application definition
INSTALLED_APPS = [
'shared',
'gestioncof',
# Must be before 'django.contrib.admin'.
@ -98,9 +103,11 @@ INSTALLED_APPS = [
'taggit',
'kfet.auth',
'kfet.cms',
'corsheaders',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
@ -202,6 +209,13 @@ AUTHENTICATION_BACKENDS = (
RECAPTCHA_USE_SSL = True
CORS_ORIGIN_WHITELIST = (
'bda.ens.fr',
'www.bda.ens.fr'
'cof.ens.fr',
'www.cof.ens.fr',
)
# Cache settings
CACHES = {

View file

@ -4,13 +4,18 @@ The settings that are not listed here are imported from .common
"""
from .common import * # NOQA
from .common import INSTALLED_APPS, MIDDLEWARE
from .common import INSTALLED_APPS, MIDDLEWARE, TESTING
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
DEBUG = True
if TESTING:
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.MD5PasswordHasher',
]
# ---
# Apache static/media config
@ -36,12 +41,13 @@ def show_toolbar(request):
"""
return DEBUG
INSTALLED_APPS += ["debug_toolbar", "debug_panel"]
if not TESTING:
INSTALLED_APPS += ["debug_toolbar", "debug_panel"]
MIDDLEWARE = [
"debug_panel.middleware.DebugPanelMiddleware"
] + MIDDLEWARE
MIDDLEWARE = [
"debug_panel.middleware.DebugPanelMiddleware"
] + MIDDLEWARE
DEBUG_TOOLBAR_CONFIG = {
'SHOW_TOOLBAR_CALLBACK': show_toolbar,
}
DEBUG_TOOLBAR_CONFIG = {
'SHOW_TOOLBAR_CALLBACK': show_toolbar,
}

View file

@ -84,13 +84,15 @@ urlpatterns = [
url(r'^utile_bda$', gestioncof_views.utile_bda,
name='utile_bda'),
url(r'^utile_bda/bda_diff$', gestioncof_views.liste_bdadiff),
url(r'^utile_cof/diff_cof$', gestioncof_views.liste_diffcof),
url(r'^utile_cof/diff_cof$', gestioncof_views.liste_diffcof,
name='ml_diffcof'),
url(r'^utile_bda/bda_revente$', gestioncof_views.liste_bdarevente),
url(r'^k-fet/', include('kfet.urls')),
url(r'^cms/', include(wagtailadmin_urls)),
url(r'^documents/', include(wagtaildocs_urls)),
# djconfig
url(r"^config", gestioncof_views.ConfigUpdate.as_view()),
url(r"^config", gestioncof_views.ConfigUpdate.as_view(),
name='config.edit'),
]
if 'debug_toolbar' in settings.INSTALLED_APPS:

View file

@ -351,10 +351,12 @@ EventFormset = formset_factory(AdminEventForm, BaseEventRegistrationFormset)
class CalendarForm(forms.ModelForm):
subscribe_to_events = forms.BooleanField(
initial=True,
label="Événements du COF")
label="Événements du COF",
required=False)
subscribe_to_my_shows = forms.BooleanField(
initial=True,
label="Les spectacles pour lesquels j'ai obtenu une place")
label="Les spectacles pour lesquels j'ai obtenu une place",
required=False)
other_shows = forms.ModelMultipleChoiceField(
label="Spectacles supplémentaires",
queryset=Spectacle.objects.filter(tirage__active=True),

View file

@ -62,8 +62,9 @@ class Command(BaseCommand):
except CustomMail.DoesNotExist:
mail = CustomMail.objects.create(**fields)
status['synced'] += 1
self.stdout.write(
'SYNCED {:s}'.format(fields['shortname']))
if options['verbosity']:
self.stdout.write(
'SYNCED {:s}'.format(fields['shortname']))
assoc['mails'][obj['pk']] = mail
# Variables
@ -78,8 +79,9 @@ class Command(BaseCommand):
except Variable.DoesNotExist:
Variable.objects.create(**fields)
# C'est agréable d'avoir le résultat affiché
self.stdout.write(
'{synced:d} mails synchronized {unchanged:d} unchanged'
.format(**status)
)
if options['verbosity']:
# C'est agréable d'avoir le résultat affiché
self.stdout.write(
'{synced:d} mails synchronized {unchanged:d} unchanged'
.format(**status)
)

View file

@ -159,23 +159,23 @@
},
{
"model": "custommail.custommail",
"pk": 3,
"fields": {
"shortname": "bda-revente",
"subject": "{{ show }}",
"body": "Bonjour {{ member.first_name }}\r\n\r\nUne place pour le spectacle {{ show.title }} ({{ show.date }})\r\na \u00e9t\u00e9 post\u00e9e sur BdA-Revente.\r\n\r\nSi ce spectacle t'int\u00e9resse toujours, merci de nous le signaler en cliquant\r\nsur ce lien : http://{{ site }}{% url \"bda-revente-interested\" revente.id %}.\r\nDans le cas o\u00f9 plusieurs personnes seraient int\u00e9ress\u00e9es, nous proc\u00e8derons \u00e0\r\nun tirage au sort le {{ revente.date_tirage|date:\"DATE_FORMAT\" }}.\r\n\r\nChaleureusement,\r\nLe BdA",
"description": "Notification envoy\u00e9e \u00e0 toutes les personnes int\u00e9ress\u00e9es par un spectacle pour le signaler qu'une place vient d'\u00eatre mise en vente."
},
"pk": 3
"description": "Notification envoy\u00e9e \u00e0 toutes les personnes int\u00e9ress\u00e9es par un spectacle pour leur signaler qu'une place vient d'\u00eatre mise en vente.",
"body": "Bonjour {{ member.first_name }}\r\n\r\nUne place pour le spectacle {{ show.title }} ({{ show.date }})\r\na \u00e9t\u00e9 post\u00e9e sur BdA-Revente.\r\n\r\nSi ce spectacle t'int\u00e9resse toujours, merci de nous le signaler en cliquant\r\nsur ce lien : http://{{ site }}{% url \"bda-revente-confirm\" revente.id %}.\r\nDans le cas o\u00f9 plusieurs personnes seraient int\u00e9ress\u00e9es, nous proc\u00e8derons \u00e0\r\nun tirage au sort le {{ revente.date_tirage|date:\"DATE_FORMAT\" }}.\r\n\r\nChaleureusement,\r\nLe BdA"
}
},
{
"model": "custommail.custommail",
"pk": 4,
"fields": {
"shortname": "bda-shotgun",
"subject": "{{ show }}",
"body": "Bonjour {{ member.first_name }}\r\n\r\nUne place pour le spectacle {{ show.title }} ({{ show.date }})\r\na \u00e9t\u00e9 post\u00e9e sur BdA-Revente.\r\n\r\nPuisque ce spectacle a lieu dans moins de 24h, il n'y a pas de tirage au sort pour\r\ncette place : elle est disponible imm\u00e9diatement \u00e0 l'adresse\r\nhttp://{{ site }}{% url \"bda-buy-revente\" show.id %}, \u00e0 la disposition de tous.\r\n\r\nChaleureusement,\r\nLe BdA",
"description": "Notification signalant qu'une place est au shotgun aux personnes int\u00e9ress\u00e9es."
},
"pk": 4
"description": "Notification signalant qu'une place est au shotgun aux personnes int\u00e9ress\u00e9es.",
"body": "Bonjour {{ member.first_name }}\r\n\r\nUne place pour le spectacle {{ show.title }} ({{ show.date }})\r\na \u00e9t\u00e9 post\u00e9e sur BdA-Revente.\r\n\r\nPuisque ce spectacle a lieu dans moins de 24h, il n'y a pas de tirage au sort pour\r\ncette place : elle est disponible imm\u00e9diatement \u00e0 l'adresse\r\nhttp://{{ site }}{% url \"bda-revente-buy\" show.id %}, \u00e0 la disposition de tous.\r\n\r\nChaleureusement,\r\nLe BdA"
}
},
{
"model": "custommail.custommail",

View file

@ -1140,3 +1140,14 @@ p.help-block {
margin: 5px auto;
width: 90%;
}
div.bg-info {
border-radius: 3px;
padding: 0.3em 1em;
margin-left: 1em;
margin-right: 1em;
}
.bootstrap-form-reduce > .form-group {
margin-top: -16px;
}

View file

@ -11,7 +11,7 @@
{% endif %}
{% include "tristate_js.html" %}
<h3>Filtres</h3>
<form method="post" action="{% url 'gestioncof.views.event_status' event.id %}">
<form method="post" action="{% url 'event.details.status' event.id %}">
{% csrf_token %}
{{ form.as_p }}
<input style="margin-top:10px;" type="submit" class="btn btn-primary" value="Filtrer" />

View file

@ -12,7 +12,7 @@ souscrire aux événements du COF et/ou aux spectacles BdA.
{% if token %}
<p>Votre calendrier (compatible avec toutes les applications d'agenda) se trouve à
<a href="{% url 'gestioncof.views.calendar_ics' token %}">cette adresse</a>.</p>
<a href="{% url 'calendar.ics' token %}">cette adresse</a>.</p>
<ul>
<li>Pour l'ajouter à Thunderbird (lightning), il faut copier ce lien et aller

View file

@ -5,7 +5,7 @@
{% block realcontent %}
<h2>Modifier mon profil</h2>
<form id="profile form-horizontal" method="post" action="{% url 'gestioncof.views.profile' %}">
<form id="profile form-horizontal" method="post" action="{% url 'profile' %}">
<div class="row" style="margin: 0 15%;">
{% csrf_token %}
<fieldset"center-block">

View file

@ -8,7 +8,7 @@
{% if survey.details %}
<p>{{ survey.details }}</p>
{% endif %}
<form class="form-horizontal" method="post" action="{% url 'gestioncof.views.survey' survey.id %}">
<form class="form-horizontal" method="post" action="{% url 'survey.details' survey.id %}">
{% csrf_token %}
{{ form | bootstrap}}

View file

@ -7,15 +7,15 @@
<h2>Liens utiles du COF</h2>
<h3>COF</h3>
<ul>
<li><a href="{% url 'gestioncof.views.export_members' %}">Export des membres du COF</a></li>
<li><a href="{% url 'gestioncof.views.liste_diffcof' %}">Diffusion COF</a></li>
<li><a href="{% url 'cof.membres_export' %}">Export des membres du COF</a></li>
<li><a href="{% url 'ml_diffcof' %}">Diffusion COF</a></li>
</ul>
<h3>Mega</h3>
<ul>
<li><a href="{% url 'gestioncof.views.export_mega_participants' %}">Export des non-orgas uniquement</a></li>
<li><a href="{% url 'gestioncof.views.export_mega_orgas' %}">Export des orgas uniquement</a></li>
<li><a href="{% url 'gestioncof.views.export_mega' %}">Export de tout le monde</a></li>
<li><a href="{% url 'cof.mega_export_participants' %}">Export des non-orgas uniquement</a></li>
<li><a href="{% url 'cof.mega_export_orgas' %}">Export des orgas uniquement</a></li>
<li><a href="{% url 'cof.mega_export' %}">Export de tout le monde</a></li>
</ul>
<p>Note&nbsp;: pour ouvrir les fichiers .csv avec Excel, il faut

View file

@ -43,9 +43,10 @@
<li><a href="{% url "bda-etat-places" tirage.id %}">État des demandes</a></li>
{% else %}
<li><a href="{% url "bda-places-attribuees" tirage.id %}">Mes places</a></li>
<li><a href="{% url "bda-revente" tirage.id %}">Revendre une place</a></li>
<li><a href="{% url "bda-liste-revente" tirage.id %}">S'inscrire à BdA-Revente</a></li>
<li><a href="{% url "bda-shotgun" tirage.id %}">Places disponibles immédiatement</a></li>
<li><a href="{% url "bda-revente-manage" tirage.id %}">Gérer les places que je revends</a></li>
<li><a href="{% url "bda-revente-tirages" tirage.id %}">Voir les reventes en cours</a></li>
<li><a href="{% url "bda-revente-subscribe" tirage.id %}">Indiquer les spectacles qui m'intéressent</a></li>
<li><a href="{% url "bda-revente-shotgun" tirage.id %}">Places disponibles immédiatement</a></li>
{% endif %}
</ul>
{% endfor %}

View file

@ -16,7 +16,7 @@
</tr></thead>
<tbody class="bda_formset_content">
{% endif %}
<tr class="{% cycle row1,row2 %} dynamic-form {% if form.instance.pk %}has_original{% endif %}">
<tr class="{% cycle 'row1' 'row2' %} dynamic-form {% if form.instance.pk %}has_original{% endif %}">
{% for field in form.visible_fields %}
{% if field.name != "DELETE" and field.name != "priority" %}
<td class="bda-field-{{ field.name }}">

View file

@ -4,7 +4,7 @@
{% block page_size %}col-sm-8{% endblock %}
{% block extra_head %}
<script src="{% static "autocomplete_light/autocomplete.js" %}" type="text/javascript"></script>
<script src="{% static "vendor/jquery.autocomplete-light/3.5.0/dist/jquery.autocomplete-light.min.js" %}" type="text/javascript"></script>
{% endblock %}
{% block realcontent %}

View file

@ -11,7 +11,7 @@
{% endif %}
<h3>Filtres</h3>
{% include "tristate_js.html" %}
<form method="post" action="{% url 'gestioncof.views.survey_status' survey.id %}">
<form method="post" action="{% url 'survey.details.status' survey.id %}">
{% csrf_token %}
{{ form.as_p }}
<input style="margin-top:10px;" type="submit" class="btn btn-primary" value="Filtrer" />

View file

View file

@ -0,0 +1,874 @@
import csv
import uuid
from datetime import timedelta
from django.contrib import messages
from django.contrib.messages.api import get_messages
from django.contrib.messages.storage.base import Message
from django.test import Client, TestCase
from django.urls import reverse
from bda.models import Salle, Tirage
from gestioncof.models import (
CalendarSubscription, Club, Event, Survey, SurveyAnswer
)
from gestioncof.tests.testcases import ViewTestCaseMixin
from .utils import create_member, create_root, create_user
class HomeViewTests(ViewTestCaseMixin, TestCase):
url_name = 'home'
url_expected = '/'
auth_user = 'user'
auth_forbidden = [None]
def test(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
class ProfileViewTests(ViewTestCaseMixin, TestCase):
url_name = 'profile'
url_expected = '/profile'
http_methods = ['GET', 'POST']
auth_user = 'member'
auth_forbidden = [None, 'user']
def test_get(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
def test_post(self):
u = self.users['member']
r = self.client.post(self.url, {
'first_name': 'First',
'last_name': 'Last',
'phone': '',
# 'mailing_cof': '1',
# 'mailing_bda': '1',
# 'mailing_bda_revente': '1',
})
self.assertEqual(r.status_code, 200)
expected_message = Message(messages.SUCCESS, (
"Votre profil a été mis à jour avec succès !"
))
self.assertIn(expected_message, get_messages(r.wsgi_request))
u.refresh_from_db()
self.assertEqual(u.first_name, 'First')
self.assertEqual(u.last_name, 'Last')
self.assertFalse(u.profile.mailing_cof)
self.assertFalse(u.profile.mailing_bda)
self.assertFalse(u.profile.mailing_bda_revente)
class UtilsViewTests(ViewTestCaseMixin, TestCase):
url_name = 'utile_cof'
url_expected = '/utile_cof'
auth_user = 'staff'
auth_forbidden = [None, 'user', 'member']
def test(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
class MailingListDiffCof(ViewTestCaseMixin, TestCase):
url_name = 'ml_diffcof'
url_expected = '/utile_cof/diff_cof'
auth_user = 'staff'
auth_forbidden = [None, 'user', 'member']
def setUp(self):
super().setUp()
self.u1 = create_member('u1', attrs={'mailing_cof': True})
self.u2 = create_member('u2', attrs={'mailing_cof': False})
self.u3 = create_user('u3', attrs={'mailing_cof': True})
def test(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.context['personnes'].get(), self.u1.profile)
class ConfigUpdateViewTests(ViewTestCaseMixin, TestCase):
url_name = 'config.edit'
url_expected = '/config'
http_methods = ['GET', 'POST']
auth_user = 'root'
auth_forbidden = [None, 'user', 'member', 'staff']
def get_users_extra(self):
return {
'root': create_root('root'),
}
def test_get(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
def test_post(self):
r = self.client.post(self.url, {
'gestion_banner': 'Announcement !',
})
self.assertRedirects(r, reverse('home'))
class UserAutocompleteViewTests(ViewTestCaseMixin, TestCase):
url_name = 'cof-user-autocomplete'
url_expected = '/user/autocomplete'
auth_user = 'staff'
auth_forbidden = [None, 'user', 'member']
def test(self):
r = self.client.get(self.url, {'q': 'user'})
self.assertEqual(r.status_code, 200)
class ExportMembersViewTests(ViewTestCaseMixin, TestCase):
url_name = 'cof.membres_export'
url_expected = '/export/members'
auth_user = 'staff'
auth_forbidden = [None, 'user', 'member']
def test(self):
u1, u2 = self.users['member'], self.users['staff']
u1.first_name = 'first'
u1.last_name = 'last'
u1.email = 'user@mail.net'
u1.save()
u1.profile.phone = '0123456789'
u1.profile.departement = 'Dept'
u1.profile.save()
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
data = list(csv.reader(r.content.decode('utf-8').split('\n')[:-1]))
expected = [
[
str(u1.pk), 'member', 'first', 'last', 'user@mail.net',
'0123456789', '1A', 'Dept', 'normalien',
],
[str(u2.pk), 'staff', '', '', '', '', '1A', '', 'normalien'],
]
# Sort before checking equality, the order of the output of csv.reader
# does not seem deterministic
expected.sort(key=lambda row: int(row[0]))
data.sort(key=lambda row: int(row[0]))
self.assertListEqual(data, expected)
class MegaHelpers:
def setUp(self):
super().setUp()
u1 = create_user('u1')
u1.first_name = 'first'
u1.last_name = 'last'
u1.email = 'user@mail.net'
u1.save()
u1.profile.phone = '0123456789'
u1.profile.departement = 'Dept'
u1.profile.comments = 'profile.comments'
u1.profile.save()
u2 = create_user('u2')
u2.profile.save()
m = Event.objects.create(title='MEGA 2017')
cf1 = m.commentfields.create(name='Commentaire')
cf2 = m.commentfields.create(
name='Comment Field 2', fieldtype='char',
)
option_type = m.options.create(name='Conscrit/Orga ?')
choice_orga = option_type.choices.create(value='Orga')
choice_conscrit = option_type.choices.create(value='Conscrit')
mr1 = m.eventregistration_set.create(user=u1)
mr1.options.add(choice_orga)
mr1.comments.create(commentfield=cf1, content='Comment 1')
mr1.comments.create(commentfield=cf2, content='Comment 2')
mr2 = m.eventregistration_set.create(user=u2)
mr2.options.add(choice_conscrit)
self.u1 = u1
self.u2 = u2
self.m = m
self.choice_orga = choice_orga
self.choice_conscrit = choice_conscrit
class ExportMegaViewTests(MegaHelpers, ViewTestCaseMixin, TestCase):
url_name = 'cof.mega_export'
url_expected = '/export/mega'
auth_user = 'staff'
auth_forbidden = [None, 'user', 'member']
def test(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
self.assertListEqual(self.load_from_csv_response(r), [
[
'u1', 'first', 'last', 'user@mail.net', '0123456789',
str(self.u1.pk), 'profile.comments', 'Comment 1---Comment 2',
],
['u2', '', '', '', '', str(self.u2.pk), '', ''],
])
class ExportMegaOrgasViewTests(MegaHelpers, ViewTestCaseMixin, TestCase):
url_name = 'cof.mega_export_orgas'
url_expected = '/export/mega/orgas'
auth_user = 'staff'
auth_forbidden = [None, 'user', 'member']
def test(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
self.assertListEqual(self.load_from_csv_response(r), [
[
'u1', 'first', 'last', 'user@mail.net', '0123456789',
str(self.u1.pk), 'profile.comments', 'Comment 1---Comment 2',
],
])
class ExportMegaParticipantsViewTests(
MegaHelpers, ViewTestCaseMixin, TestCase):
url_name = 'cof.mega_export_participants'
url_expected = '/export/mega/participants'
auth_user = 'staff'
auth_forbidden = [None, 'user', 'member']
def test(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
self.assertListEqual(self.load_from_csv_response(r), [
['u2', '', '', '', '', str(self.u2.pk), '', ''],
])
class ExportMegaRemarksViewTests(
MegaHelpers, ViewTestCaseMixin, TestCase):
url_name = 'cof.mega_export_remarks'
url_expected = '/export/mega/avecremarques'
auth_user = 'staff'
auth_forbidden = [None, 'user', 'member']
def test(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
self.assertListEqual(self.load_from_csv_response(r), [
[
'u1', 'first', 'last', 'user@mail.net', '0123456789',
str(self.u1.pk), 'profile.comments', 'Comment 1',
],
])
class ClubListViewTests(ViewTestCaseMixin, TestCase):
url_name = 'liste-clubs'
url_expected = '/clubs/liste'
auth_user = 'member'
auth_forbidden = [None, 'user']
def setUp(self):
super().setUp()
self.c1 = Club.objects.create(name='Club1')
self.c2 = Club.objects.create(name='Club2')
m = self.users['member']
self.c1.membres.add(m)
self.c1.respos.add(m)
def test_as_member(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.context['owned_clubs'].get(), self.c1)
self.assertEqual(r.context['other_clubs'].get(), self.c2)
def test_as_staff(self):
u = self.users['staff']
c = Client()
c.force_login(u)
r = c.get(self.url)
self.assertEqual(r.status_code, 200)
self.assertQuerysetEqual(
r.context['owned_clubs'], map(repr, [self.c1, self.c2]),
ordered=False,
)
class ClubMembersViewTests(ViewTestCaseMixin, TestCase):
url_name = 'membres-club'
auth_user = 'staff'
auth_forbidden = [None, 'user', 'member']
@property
def url_kwargs(self):
return {'name': self.c.name}
@property
def url_expected(self):
return '/clubs/membres/{}'.format(self.c.name)
def setUp(self):
super().setUp()
self.u1 = create_user('u1')
self.u2 = create_user('u2')
self.c = Club.objects.create(name='Club')
self.c.membres.add(self.u1, self.u2)
self.c.respos.add(self.u1)
def test_as_staff(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.context['members_no_respo'].get(), self.u2)
def test_as_respo(self):
u = self.users['user']
self.c.respos.add(u)
c = Client()
c.force_login(u)
r = c.get(self.url)
self.assertEqual(r.status_code, 200)
class ClubChangeRespoViewTests(ViewTestCaseMixin, TestCase):
url_name = 'change-respo'
auth_user = 'staff'
auth_forbidden = [None, 'user', 'member']
@property
def url_kwargs(self):
return {'club_name': self.c.name, 'user_id': self.users['user'].pk}
@property
def url_expected(self):
return '/clubs/change_respo/{}/{}'.format(
self.c.name, self.users['user'].pk,
)
def setUp(self):
super().setUp()
self.c = Club.objects.create(name='Club')
def test(self):
u = self.users['user']
expected_redirect = reverse('membres-club', kwargs={
'name': self.c.name,
})
self.c.membres.add(u)
r = self.client.get(self.url)
self.assertRedirects(r, expected_redirect)
self.assertIn(u, self.c.respos.all())
self.client.get(self.url)
self.assertNotIn(u, self.c.respos.all())
class CalendarViewTests(ViewTestCaseMixin, TestCase):
url_name = 'calendar'
url_expected = '/calendar/subscription'
auth_user = 'member'
auth_forbidden = [None, 'user']
post_expected_message = Message(
messages.SUCCESS, "Calendrier mis à jour avec succès.")
def test_get(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
def test_post_new(self):
r = self.client.post(self.url, {
'subscribe_to_events': True,
'subscribe_to_my_shows': True,
'other_shows': [],
})
self.assertEqual(r.status_code, 200)
self.assertIn(self.post_expected_message, get_messages(r.wsgi_request))
cs = self.users['member'].calendarsubscription
self.assertTrue(cs.subscribe_to_events)
self.assertTrue(cs.subscribe_to_my_shows)
def test_post_edit(self):
u = self.users['member']
token = uuid.uuid4()
cs = CalendarSubscription.objects.create(token=token, user=u)
r = self.client.post(self.url, {
'other_shows': [],
})
self.assertEqual(r.status_code, 200)
self.assertIn(self.post_expected_message, get_messages(r.wsgi_request))
cs.refresh_from_db()
self.assertEqual(cs.token, token)
self.assertFalse(cs.subscribe_to_events)
self.assertFalse(cs.subscribe_to_my_shows)
def test_post_other_shows(self):
t = Tirage.objects.create(
ouverture=self.now,
fermeture=self.now,
active=True,
)
l = Salle.objects.create()
s = t.spectacle_set.create(
date=self.now, price=3.5, slots=20, location=l, listing=True)
r = self.client.post(self.url, {'other_shows': [str(s.pk)]})
self.assertEqual(r.status_code, 200)
class CalendarICSViewTests(ViewTestCaseMixin, TestCase):
url_name = 'calendar.ics'
auth_user = None
auth_forbidden = []
@property
def url_kwargs(self):
return {'token': self.token}
@property
def url_expected(self):
return '/calendar/{}/calendar.ics'.format(self.token)
def setUp(self):
super().setUp()
self.token = uuid.uuid4()
self.t = Tirage.objects.create(
ouverture=self.now,
fermeture=self.now,
active=True,
)
l = Salle.objects.create(name='Location')
self.s1 = self.t.spectacle_set.create(
price=1, slots=10, location=l, listing=True,
title='Spectacle 1', date=self.now + timedelta(days=1),
)
self.s2 = self.t.spectacle_set.create(
price=2, slots=20, location=l, listing=True,
title='Spectacle 2', date=self.now + timedelta(days=2),
)
self.s3 = self.t.spectacle_set.create(
price=3, slots=30, location=l, listing=True,
title='Spectacle 3', date=self.now + timedelta(days=3),
)
def test(self):
u = self.users['user']
p = u.participant_set.create(tirage=self.t)
p.attribution_set.create(spectacle=self.s1)
self.cs = CalendarSubscription.objects.create(
user=u, token=self.token,
subscribe_to_my_shows=True, subscribe_to_events=True,
)
self.cs.other_shows.add(self.s2)
r = self.client.get(self.url)
def get_dt_from_ical(v):
return v.dt
self.assertCalEqual(r.content.decode('utf-8'), [
{
'summary': 'Spectacle 1',
'dtstart': (get_dt_from_ical, (
(self.now + timedelta(days=1)).replace(microsecond=0)
)),
'dtend': (get_dt_from_ical, (
(self.now + timedelta(days=1, hours=2)).replace(
microsecond=0)
)),
'location': 'Location',
'uid': 'show-{}-{}@example.com'.format(self.s1.pk, self.t.pk),
},
{
'summary': 'Spectacle 2',
'dtstart': (get_dt_from_ical, (
(self.now + timedelta(days=2)).replace(microsecond=0)
)),
'dtend': (get_dt_from_ical, (
(self.now + timedelta(days=2, hours=2)).replace(
microsecond=0)
)),
'location': 'Location',
'uid': 'show-{}-{}@example.com'.format(self.s2.pk, self.t.pk),
},
])
class EventViewTests(ViewTestCaseMixin, TestCase):
url_name = 'event.details'
http_methods = ['GET', 'POST']
auth_user = 'user'
auth_forbidden = [None]
post_expected_message = Message(messages.SUCCESS, (
"Votre inscription a bien été enregistrée ! Vous pouvez cependant la "
"modifier jusqu'à la fin des inscriptions."
))
@property
def url_kwargs(self):
return {'event_id': self.e.pk}
@property
def url_expected(self):
return '/event/{}'.format(self.e.pk)
def setUp(self):
super().setUp()
self.e = Event.objects.create()
self.ecf1 = self.e.commentfields.create(name='Comment Field 1')
self.ecf2 = self.e.commentfields.create(
name='Comment Field 2', fieldtype='char',
)
self.o1 = self.e.options.create(name='Option 1')
self.o2 = self.e.options.create(name='Option 2', multi_choices=True)
self.oc1 = self.o1.choices.create(value='O1 - Choice 1')
self.oc2 = self.o1.choices.create(value='O1 - Choice 2')
self.oc3 = self.o2.choices.create(value='O2 - Choice 1')
self.oc4 = self.o2.choices.create(value='O2 - Choice 2')
def test_get(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
def test_post_new(self):
r = self.client.post(self.url, {
'option_{}'.format(self.o1.pk): [str(self.oc1.pk)],
'option_{}'.format(self.o2.pk): [
str(self.oc3.pk), str(self.oc4.pk),
],
})
self.assertEqual(r.status_code, 200)
self.assertIn(self.post_expected_message, get_messages(r.wsgi_request))
er = self.e.eventregistration_set.get(user=self.users['user'])
self.assertQuerysetEqual(
er.options.all(), map(repr, [self.oc1, self.oc3, self.oc4]),
ordered=False,
)
# TODO: Make the view care about comments.
# self.assertQuerysetEqual(
# er.comments.all(), map(repr, []),
# ordered=False,
# )
def test_post_edit(self):
er = self.e.eventregistration_set.create(user=self.users['user'])
er.options.add(self.oc1, self.oc3, self.oc4)
er.comments.create(
commentfield=self.ecf1, content='Comment 1',
)
r = self.client.post(self.url, {
'option_{}'.format(self.o1.pk): [],
'option_{}'.format(self.o2.pk): [str(self.oc3.pk)],
})
self.assertEqual(r.status_code, 200)
self.assertIn(self.post_expected_message, get_messages(r.wsgi_request))
er.refresh_from_db()
self.assertQuerysetEqual(
er.options.all(), map(repr, [self.oc3]),
ordered=False,
)
# TODO: Make the view care about comments.
# self.assertQuerysetEqual(
# er.comments.all(), map(repr, []),
# ordered=False,
# )
class EventStatusViewTests(ViewTestCaseMixin, TestCase):
url_name = 'event.details.status'
http_methods = ['GET', 'POST']
auth_user = 'staff'
auth_forbidden = [None, 'user', 'member']
@property
def url_kwargs(self):
return {'event_id': self.e.pk}
@property
def url_expected(self):
return '/event/{}/status'.format(self.e.pk)
def setUp(self):
super().setUp()
self.e = Event.objects.create()
self.cf1 = self.e.commentfields.create(name='Comment Field 1')
self.cf2 = self.e.commentfields.create(
name='Comment Field 2', fieldtype='char',
)
self.o1 = self.e.options.create(name='Option 1')
self.o2 = self.e.options.create(name='Option 2', multi_choices=True)
self.oc1 = self.o1.choices.create(value='O1 - Choice 1')
self.oc2 = self.o1.choices.create(value='O1 - Choice 2')
self.oc3 = self.o2.choices.create(value='O2 - Choice 1')
self.oc4 = self.o2.choices.create(value='O2 - Choice 2')
self.er1 = self.e.eventregistration_set.create(user=self.users['user'])
self.er1.options.add(self.oc1)
self.er2 = self.e.eventregistration_set.create(
user=self.users['member'],
)
def _get_oc_filter_name(self, oc):
return 'option_{}_choice_{}'.format(oc.event_option.pk, oc.pk)
def _test_filters(self, filters, expected):
r = self.client.post(self.url, {
self._get_oc_filter_name(oc): v for oc, v in filters
})
self.assertEqual(r.status_code, 200)
self.assertQuerysetEqual(
r.context['user_choices'], map(repr, expected),
ordered=False,
)
def test_filter_none(self):
self._test_filters([(self.oc1, 'none')], [self.er1, self.er2])
def test_filter_yes(self):
self._test_filters([(self.oc1, 'yes')], [self.er1])
def test_filter_no(self):
self._test_filters([(self.oc1, 'no')], [self.er2])
class SurveyViewTests(ViewTestCaseMixin, TestCase):
url_name = 'survey.details'
http_methods = ['GET', 'POST']
auth_user = 'user'
auth_forbidden = [None]
post_expected_message = Message(messages.SUCCESS, (
"Votre réponse a bien été enregistrée ! Vous pouvez cependant la "
"modifier jusqu'à la fin du sondage."
))
@property
def url_kwargs(self):
return {'survey_id': self.s.pk}
@property
def url_expected(self):
return '/survey/{}'.format(self.s.pk)
def setUp(self):
super().setUp()
self.s = Survey.objects.create(title='Title')
self.q1 = self.s.questions.create(question='Question 1 ?')
self.q2 = self.s.questions.create(
question='Question 2 ?',
multi_answers=True,
)
self.qa1 = self.q1.answers.create(answer='Q1 - Answer 1')
self.qa2 = self.q1.answers.create(answer='Q1 - Answer 2')
self.qa3 = self.q2.answers.create(answer='Q2 - Answer 1')
self.qa4 = self.q2.answers.create(answer='Q2 - Answer 2')
def test_get(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
def test_post_new(self):
r = self.client.post(self.url, {
'question_{}'.format(self.q1.pk): [str(self.qa1.pk)],
'question_{}'.format(self.q2.pk): [
str(self.qa3.pk), str(self.qa4.pk),
],
})
self.assertEqual(r.status_code, 200)
self.assertIn(self.post_expected_message, get_messages(r.wsgi_request))
a = self.s.surveyanswer_set.get(user=self.users['user'])
self.assertQuerysetEqual(
a.answers.all(), map(repr, [self.qa1, self.qa3, self.qa4]),
ordered=False,
)
def test_post_edit(self):
a = self.s.surveyanswer_set.create(user=self.users['user'])
a.answers.add(self.qa1, self.qa1, self.qa4)
r = self.client.post(self.url, {
'question_{}'.format(self.q1.pk): [],
'question_{}'.format(self.q2.pk): [str(self.qa3.pk)],
})
self.assertEqual(r.status_code, 200)
self.assertIn(self.post_expected_message, get_messages(r.wsgi_request))
a.refresh_from_db()
self.assertQuerysetEqual(
a.answers.all(), map(repr, [self.qa3]),
ordered=False,
)
def test_post_delete(self):
a = self.s.surveyanswer_set.create(user=self.users['user'])
a.answers.add(self.qa1, self.qa4)
r = self.client.post(self.url, {'delete': '1'})
self.assertEqual(r.status_code, 200)
expected_message = Message(
messages.SUCCESS, "Votre réponse a bien été supprimée")
self.assertIn(expected_message, get_messages(r.wsgi_request))
with self.assertRaises(SurveyAnswer.DoesNotExist):
a.refresh_from_db()
def test_forbidden_closed(self):
self.s.survey_open = False
self.s.save()
r = self.client.get(self.url)
self.assertNotEqual(r.status_code, 200)
def test_forbidden_old(self):
self.s.old = True
self.s.save()
r = self.client.get(self.url)
self.assertNotEqual(r.status_code, 200)
class SurveyStatusViewTests(ViewTestCaseMixin, TestCase):
url_name = 'survey.details.status'
http_methods = ['GET', 'POST']
auth_user = 'staff'
auth_forbidden = [None, 'user', 'member']
@property
def url_kwargs(self):
return {'survey_id': self.s.pk}
@property
def url_expected(self):
return '/survey/{}/status'.format(self.s.pk)
def setUp(self):
super().setUp()
self.s = Survey.objects.create(title='Title')
self.q1 = self.s.questions.create(question='Question 1 ?')
self.q2 = self.s.questions.create(
question='Question 2 ?',
multi_answers=True,
)
self.qa1 = self.q1.answers.create(answer='Q1 - Answer 1')
self.qa2 = self.q1.answers.create(answer='Q1 - Answer 2')
self.qa3 = self.q2.answers.create(answer='Q2 - Answer 1')
self.qa4 = self.q2.answers.create(answer='Q2 - Answer 2')
self.a1 = self.s.surveyanswer_set.create(user=self.users['user'])
self.a1.answers.add(self.qa1)
self.a2 = self.s.surveyanswer_set.create(user=self.users['member'])
def test_get(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
def _get_qa_filter_name(self, qa):
return 'question_{}_answer_{}'.format(qa.survey_question.pk, qa.pk)
def _test_filters(self, filters, expected):
r = self.client.post(self.url, {
self._get_qa_filter_name(qa): v for qa, v in filters
})
self.assertEqual(r.status_code, 200)
self.assertQuerysetEqual(
r.context['user_answers'], map(repr, expected),
ordered=False,
)
def test_filter_none(self):
self._test_filters([(self.qa1, 'none')], [self.a1, self.a2])
def test_filter_yes(self):
self._test_filters([(self.qa1, 'yes')], [self.a1])
def test_filter_no(self):
self._test_filters([(self.qa1, 'no')], [self.a2])

View file

@ -0,0 +1,24 @@
from shared.tests.testcases import ViewTestCaseMixin as BaseViewTestCaseMixin
from .utils import create_user, create_member, create_staff
class ViewTestCaseMixin(BaseViewTestCaseMixin):
"""
TestCase extension to ease testing of cof views.
Most information can be found in the base parent class doc.
This class performs some changes to users management, detailed below.
During setup, three users are created:
- 'user': a basic user without any permission,
- 'member': (profile.is_cof is True),
- 'staff': (profile.is_cof is True) && (profile.is_buro is True).
"""
def get_users_base(self):
return {
'user': create_user('user'),
'member': create_member('member'),
'staff': create_staff('staff'),
}

61
gestioncof/tests/utils.py Normal file
View file

@ -0,0 +1,61 @@
from django.contrib.auth import get_user_model
User = get_user_model()
def _create_user(username, is_cof=False, is_staff=False, attrs=None):
if attrs is None:
attrs = {}
password = attrs.pop('password', username)
user_keys = [
'first_name', 'last_name', 'email', 'is_staff', 'is_superuser',
]
user_attrs = {k: v for k, v in attrs.items() if k in user_keys}
profile_keys = [
'is_cof', 'login_clipper', 'phone', 'occupation', 'departement',
'type_cotiz', 'mailing_cof', 'mailing_bda', 'mailing_bda_revente',
'comments', 'is_buro', 'petit_cours_accept',
'petit_cours_remarques',
]
profile_attrs = {k: v for k, v in attrs.items() if k in profile_keys}
if is_cof:
profile_attrs['is_cof'] = True
if is_staff:
# At the moment, admin is accessible by COF staff.
user_attrs['is_staff'] = True
profile_attrs['is_buro'] = True
user = User(username=username, **user_attrs)
user.set_password(password)
user.save()
for k, v in profile_attrs.items():
setattr(user.profile, k, v)
user.profile.save()
return user
def create_user(username, attrs=None):
return _create_user(username, attrs=attrs)
def create_member(username, attrs=None):
return _create_user(username, is_cof=True, attrs=attrs)
def create_staff(username, attrs=None):
return _create_user(username, is_cof=True, is_staff=True, attrs=attrs)
def create_root(username, attrs=None):
if attrs is None:
attrs = {}
attrs.setdefault('is_staff', True)
attrs.setdefault('is_superuser', True)
return _create_user(username, attrs=attrs)

View file

@ -4,12 +4,17 @@ from gestioncof import views, petits_cours_views
from gestioncof.decorators import buro_required
export_patterns = [
url(r'^members$', views.export_members),
url(r'^mega/avecremarques$', views.export_mega_remarksonly),
url(r'^mega/participants$', views.export_mega_participants),
url(r'^mega/orgas$', views.export_mega_orgas),
url(r'^members$', views.export_members,
name='cof.membres_export'),
url(r'^mega/avecremarques$', views.export_mega_remarksonly,
name='cof.mega_export_remarks'),
url(r'^mega/participants$', views.export_mega_participants,
name='cof.mega_export_participants'),
url(r'^mega/orgas$', views.export_mega_orgas,
name='cof.mega_export_orgas'),
# url(r'^mega/(?P<type>.+)$', views.export_mega_bytype),
url(r'^mega$', views.export_mega),
url(r'^mega$', views.export_mega,
name='cof.mega_export'),
]
petitcours_patterns = [
@ -50,7 +55,8 @@ events_patterns = [
calendar_patterns = [
url(r'^subscription$', views.calendar,
name='calendar'),
url(r'^(?P<token>[a-z0-9-]+)/calendar.ics$', views.calendar_ics)
url(r'^(?P<token>[a-z0-9-]+)/calendar.ics$', views.calendar_ics,
name='calendar.ics'),
]
clubs_patterns = [

View file

@ -9,6 +9,7 @@ from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import (
login as django_login_view, logout as django_logout_view,
redirect_to_login,
)
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
@ -338,7 +339,7 @@ def profile(request):
if form.is_valid():
form.save()
messages.success(request,
"Votre profil a été mis à jour avec succès !")
"Votre profil a été mis à jour avec succès !")
else:
form = UserProfileForm(instance=request.user.profile)
return render(request, "gestioncof/profile.html", {"form": form})
@ -566,7 +567,7 @@ def liste_clubs(request):
if request.user.profile.is_buro:
data = {'owned_clubs': clubs.all()}
else:
data = {'owned_clubs': request.user.clubs_geres,
data = {'owned_clubs': request.user.clubs_geres.all(),
'other_clubs': clubs.exclude(respos=request.user)}
return render(request, 'liste_clubs.html', data)
@ -782,7 +783,7 @@ class ConfigUpdate(FormView):
def dispatch(self, request, *args, **kwargs):
if request.user is None or not request.user.is_superuser:
raise Http404
return redirect_to_login(request.get_full_path())
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):

View file

@ -14,6 +14,7 @@ class TriStateCheckbox(Widget):
def render(self, name, value, attrs=None, choices=()):
if value is None:
value = 'none'
final_attrs = self.build_attrs(attrs, value=value)
attrs['value'] = value
final_attrs = self.build_attrs(self.attrs, attrs)
output = ["<span class=\"tristate\"%s></span>" % flatatt(final_attrs)]
return mark_safe('\n'.join(output))

View file

@ -294,17 +294,17 @@ 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(),
)
),
queryset=None,
widget=forms.Select(attrs={'id': 'id_checkout_select'}),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Create the queryset on form instanciation to use the current time.
self.fields['checkout'].queryset = (
Checkout.objects.is_valid().filter(is_protected=False))
class KPsulOperationForm(forms.ModelForm):
article = forms.ModelChoiceField(

View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-04-05 21:47
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('kfet', '0062_delete_globalpermissions'),
]
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), (2018, 2018)], default=2017, null=True),
),
]

View file

@ -340,6 +340,13 @@ class AccountNegative(models.Model):
return self.start + kfet_config.overdraft_duration
class CheckoutQuerySet(models.QuerySet):
def is_valid(self):
now = timezone.now()
return self.filter(valid_from__lte=now, valid_to__gte=now)
class Checkout(models.Model):
created_by = models.ForeignKey(
Account, on_delete = models.PROTECT,
@ -352,6 +359,8 @@ class Checkout(models.Model):
default = 0)
is_protected = models.BooleanField(default = False)
objects = CheckoutQuerySet.as_manager()
def get_absolute_url(self):
return reverse('kfet.checkout.read', kwargs={'pk': self.pk})
@ -361,6 +370,22 @@ class Checkout(models.Model):
def __str__(self):
return self.name
def save(self, *args, **kwargs):
created = self.pk is None
ret = super().save(*args, **kwargs)
if created:
self.statements.create(
amount_taken=0,
balance_old=self.balance,
balance_new=self.balance,
by=self.created_by,
)
return ret
class CheckoutTransfer(models.Model):
from_checkout = models.ForeignKey(
Checkout, on_delete = models.PROTECT,

View file

@ -75,6 +75,10 @@ ul {
padding:8px !important;
}
.table thead .sm-padding {
padding:3px !important;
}
.table tr.section {
background: #c63b52 !important;
color:#fff;

View file

@ -3,6 +3,7 @@
/* Libs customizations */
@import url("libs/jconfirm-kfet.css");
@import url("libs/jquery-tablesorter-kfet.css");
@import url("libs/multiple-select-kfet.css");
/* Base */
@ -54,6 +55,11 @@
color: #C81022;
}
.table thead .glyphicon {
font-size: 12px;
opacity: 0.8;
}
/*
* Pages tableaux seuls
@ -82,6 +88,11 @@
border-radius: 0;
}
.table td.small-width {
/* Header still extends the width of the column, but it will be minimal. */
width: 30px;
}
.auth-form {
padding: 15px 0;
background: #d86c7e;

View file

@ -235,3 +235,77 @@ function submit_url(el) {
let url = $(el).data('url');
create_form(url).appendTo($('body')).submit();
}
/**
* jquery-tablesorter
* https://mottie.github.io/tablesorter/docs/
*
* Known bugs (v2.29.0):
* - Sort order icons in sticky headers are not updated.
* Status: Fixed in next release.
*
* TODO:
* - Handle i18n.
*/
function registerBoolParser(id, true_str, false_str) {
$.tablesorter.addParser({
id: id,
format: function(s) {
return s.toLowerCase()
.replace(true_str, 1)
.replace(false_str, 0);
},
type: 'numeric'
});
}
// Parsers for the text representations of boolean.
registerBoolParser('yesno', 'oui', 'non');
registerBoolParser('article__is_sold', 'en vente', 'non vendu');
registerBoolParser('article__hidden', 'caché', 'affiché');
// https://mottie.github.io/tablesorter/docs/index.html#variable-defaults
$.extend(true, $.tablesorter.defaults, {
headerTemplate: '{content} {icon}',
cssIconAsc : 'glyphicon glyphicon-chevron-up',
cssIconDesc : 'glyphicon glyphicon-chevron-down',
cssIconNone : 'glyphicon glyphicon-resize-vertical',
// Only four-digits format year is handled by the builtin parser
// 'shortDate'.
dateFormat: 'ddmmyyyy',
// Accented characters are replaced with their non-accented one.
sortLocaleCompare: true,
// French format: 1 234,56
usNumberFormat: false,
widgets: ['stickyHeaders'],
widgetOptions: {
stickyHeaders_offset: '.navbar',
}
});
// https://mottie.github.io/tablesorter/docs/index.html#variable-language
$.extend($.tablesorter.language, {
sortAsc : 'Trié par ordre croissant, ',
sortDesc : 'Trié par ordre décroissant, ',
sortNone : 'Non trié, ',
sortDisabled : 'tri désactivé et/ou non-modifiable',
nextAsc : 'cliquer pour trier par ordre croissant',
nextDesc : 'cliquer pour trier par ordre décroissant',
nextNone : 'cliquer pour retirer le tri'
});
$( function() {
$('.sortable').tablesorter();
});

File diff suppressed because it is too large Load diff

View file

@ -37,13 +37,16 @@
<section>
<div class="table-responsive">
<table class="table table-hover table-condensed">
<table
class="table table-hover table-condensed sortable"
{# Initial sort: [(trigramme,asc)] #}
data-sortlist="[[0,0]]">
<thead>
<tr>
<td class="text-center">Tri.</td>
<td>Nom</td>
<td class="text-right">Balance</td>
<td class="text-center">COF</td>
<td class="text-center" data-sorter="yesno">COF</td>
<td>Dpt</td>
<td class="text-center">Promo</td>
</tr>

View file

@ -5,7 +5,7 @@
{% block header-title %}Création d'un compte{% endblock %}
{% block extra_head %}
<script src="{% static "autocomplete_light/autocomplete.js" %}" type="text/javascript"></script>
<script src="{% static "vendor/jquery.autocomplete-light/3.5.0/dist/jquery.autocomplete-light.min.js" %}" type="text/javascript"></script>
{% endblock %}
{% block main %}

View file

@ -35,16 +35,19 @@
{% block main %}
<div class="table-responsive">
<table class="table table-hover table-condensed">
<table
class="table table-hover table-condensed sortable"
{# Initial sort: [(trigramme,asc)] #}
data-sortlist="[[0,0]]">
<thead>
<tr>
<td class="text-center">Tri.</td>
<td>Nom</td>
<td class="text-right">Balance</td>
<td class="text-right">Réelle</td>
<td>Début</td>
<td data-sorter="shortDate">Début</td>
<td>Découvert autorisé</td>
<td>Jusqu'au</td>
<td data-sorter="shortDate">Jusqu'au</td>
<td>Balance offset</td>
</tr>
</thead>
@ -63,9 +66,13 @@
{{ neg.account.real_balance|floatformat:2 }}€
{% endif %}
</td>
<td>{{ neg.start|date:'d/m/Y H:i:s'}}</td>
<td title="{{ neg.start }}">
{{ neg.start|date:'d/m/Y H:i'}}
</td>
<td>{{ neg.authz_overdraft_amount|default_if_none:'' }}</td>
<td>{{ neg.authz_overdrafy_until|default_if_none:'' }}</td>
<td title="{{ neg.authz_overdraft_until }}">
{{ neg.authz_overdraft_until|date:'d/m/Y H:i' }}
</td>
<td>{{ neg.balance_offset|default_if_none:'' }}</td>
</tr>
{% endfor %}

View file

@ -7,8 +7,11 @@
<aside>
<div class="heading">
{{ articles|length }}
<span class="sub">article{{ articles|length|pluralize }}</span>
{{ nb_articles }}
<span class="sub">article{{ nb_articles|pluralize }}</span>
</div>
<div class="heading">
<span class="sub">dont {{ articles|length }} en vente</span>
</div>
</aside>
@ -25,39 +28,99 @@
{% endblock %}
{% block main %}
<h2>Article{{ articles|length|pluralize}} en vente</h2>
<div class="table-responsive">
<table class="table table-hover table-condensed">
<table
class="table table-hover table-condensed sortable"
{# Initial sort: [(is_sold,desc), (name,asc)] #}
data-sortlist="[[3,1], [0,0]]">
<thead>
<tr>
<td>Nom</td>
<td class="text-right">Prix</td>
<td class="text-right">Stock</td>
<td class="text-right">En vente</td>
<td class="text-right">Affiché</td>
<td class="text-right">Dernier inventaire</td>
<td class="text-right" data-sorter="article__is_sold">En vente</td>
<td class="text-right" data-sorter="article__hidden">Affiché</td>
<td class="text-right" data-sorter="shortDate">Dernier inventaire</td>
</tr>
</thead>
<tbody>
{% for article in articles %}
{% ifchanged article.category %}
<tr class="section">
<td colspan="6">{{ article.category.name }}</td>
</tr>
{% endifchanged %}
{% regroup articles by category as category_list %}
{% for category in category_list %}
<tbody class="tablesorter-no-sort">
<tr class="section">
<td colspan="6">{{ category.grouper }}</td>
</tr>
</tbody>
<tbody>
{% for article in category.list %}
<tr>
<td>
<a href="{% url 'kfet.article.read' article.pk %}">
{{ article.name }}
</a>
</td>
<td class="text-right">{{ article.price }}€</td>
<td class="text-right">{{ article.stock }}</td>
<td class="text-right">{{ article.is_sold | yesno:"En vente,Non vendu"}}</td>
<td class="text-right">{{ article.hidden | yesno:"Caché,Affiché" }}</td>
{% with last_inventory=article.inventory.0 %}
<td class="text-right" title="{{ last_inventory.at }}">
{{ last_inventory.at|date:'d/m/Y H:i' }}
</td>
{% endwith %}
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
<h2>Article{{ not_sold_articles|length|pluralize }} non vendu{{ nots_sold_article|length|pluralize }}</h2>
<div class="table-responsive">
<table
class="table table-hover table-condensed sortable"
{# Initial sort: [(is_sold,desc), (name,asc)] #}
data-sortlist="[[3,1], [0,0]]">
<thead>
<tr>
<td>
<a href="{% url 'kfet.article.read' article.pk %}">
{{ article.name }}
</a>
</td>
<td class="text-right">{{ article.price }}€</td>
<td class="text-right">{{ article.stock }}</td>
<td class="text-right">{{ article.is_sold | yesno:"En vente,Non vendu"}}</td>
<td class="text-right">{{ article.hidden | yesno:"Caché,Affiché" }}</td>
<td class="text-right">{{ article.inventory.0.at }}</td>
<td>Nom</td>
<td class="text-right">Prix</td>
<td class="text-right">Stock</td>
<td class="text-right" data-sorter="article__is_sold">En vente</td>
<td class="text-right" data-sorter="article__hidden">Affiché</td>
<td class="text-right" data-sorter="shortDate">Dernier inventaire</td>
</tr>
{% endfor %}
</tbody>
</thead>
{% regroup not_sold_articles by category as not_sold_category_list %}
{% for category in not_sold_category_list %}
<tbody class="tablesorter-no-sort">
<tr class="section">
<td colspan="6">{{ category.grouper }}</td>
</tr>
</tbody>
<tbody>
{% for article in category.list %}
<tr>
<td>
<a href="{% url 'kfet.article.read' article.pk %}">
{{ article.name }}
</a>
</td>
<td class="text-right">{{ article.price }}€</td>
<td class="text-right">{{ article.stock }}</td>
<td class="text-right">{{ article.is_sold | yesno:"En vente,Non vendu"}}</td>
<td class="text-right">{{ article.hidden | yesno:"Caché,Affiché" }}</td>
{% with last_inventory=article.inventory.0 %}
<td class="text-right" title="{{ last_inventory.at }}">
{{ last_inventory.at|date:'d/m/Y H:i' }}
</td>
{% endwith %}
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
</div>

View file

@ -1,7 +1,10 @@
<table class="table table-hover table-condensed">
<table
class="table table-hover table-condensed sortable"
{# Initial sort: [(inventory.at,desc)] #}
data-sortlist="[[0,1]]">
<thead>
<tr>
<td>Date</td>
<td data-sorter="shortDate">Date</td>
<td>Stock</td>
<td>Erreur</td>
</tr>
@ -9,9 +12,9 @@
<tbody>
{% for inventoryart in inventoryarts %}
<tr>
<td>
<td title="{{ inventoryart.inventory.at }}">
<a href="{% url "kfet.inventory.read" inventoryart.inventory.pk %}">
{{ inventoryart.inventory.at }}
{{ inventoryart.inventory.at|date:'d/m/Y H:i' }}
</a>
</td>
<td>{{ inventoryart.stock_new }}</td>

View file

@ -1,7 +1,10 @@
<table class="table table-hover table-condensed">
<table
class="table table-hover table-condensed sortable"
{# Initial sort: [(at,desc)] #}
data-sortlist="[[0,1]]">
<thead>
<tr>
<td>Date</td>
<td data-sorter="shortDate">Date</td>
<td>Fournisseur</td>
<td>HT</td>
<td>TVA</td>
@ -11,7 +14,9 @@
<tbody>
{% for supplierart in supplierarts %}
<tr>
<td>{{ supplierart.at }}</td>
<td title="{{ supplierart.at }}">
{{ supplierart.at|date:'d/m/Y' }}
</td>
<td>{{ supplierart.supplier.name }}</td>
<td>{{ supplierart.price_HT|default_if_none:"" }}</td>
<td>{{ supplierart.TVA|default_if_none:"" }}</td>

View file

@ -24,6 +24,7 @@
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
<script type="text/javascript" src="{% static 'kfet/js/jquery-ui.min.js' %}"></script>
<script type="text/javascript" src="{% static 'kfet/js/jquery-confirm.js' %}"></script>
<script type="text/javascript" src="{% static 'kfet/vendor/jquery-tablesorter/jquery.tablesorter.combined.js' %}"></script>
<script type="text/javascript" src="{% static 'kfet/js/reconnecting-websocket.js' %}"></script>
<script type="text/javascript" src="{% static 'kfet/js/moment.js' %}"></script>
<script type="text/javascript" src="{% static 'kfet/js/moment-fr.js' %}"></script>

View file

@ -17,12 +17,15 @@
{% block main %}
<div class="table-responsive">
<table class="table table-hover table-condensed">
<table
class="table table-hover table-condensed sortable"
{# Initial sort: [(name,asc)] #}
data-sortlist="[[0,0]]">
<thead>
<tr>
<td>Nom</td>
<td class="text-right">Nombre d'articles</td>
<td class="text-right">Peut être majorée</td>
<td class="text-right" data-sorter="yesno">Peut être majorée</td>
</tr>
</thead>
<tbody>

View file

@ -24,13 +24,16 @@
{% block main %}
<div class="table-responsive">
<table class="table table-hover table-condensed">
<table
class="table table-hover table-condensed sortable"
{# Initial sort: [(valid_to,desc)] #}
data-sortlist="[[3,1]]">
<thead>
<tr>
<td>Nom</td>
<td class="text-right">Balance</td>
<td class="text-right">Déb. valid.</td>
<td class="text-right">Fin valid.</td>
<td class="text-right" data-parser="shortDate">Déb. valid.</td>
<td class="text-right" data-parser="shortDate">Fin valid.</td>
<td class="text-right">Protégée</td>
</tr>
</thead>
@ -43,8 +46,12 @@
</a>
</td>
<td class="text-right">{{ checkout.balance}}€</td>
<td class="text-right">{{ checkout.valid_from }}</td>
<td class="text-right">{{ checkout.valid_to }}</td>
<td class="text-right" title="{{ checkout.valid_from }}">
{{ checkout.valid_from|date:'d/m/Y H:i' }}
</td>
<td class="text-right" title="{{ checkout.valid_to }}">
{{ checkout.valid_to|date:'d/m/Y H:i' }}
</td>
<td class="text-right">{{ checkout.is_protected|yesno }}</td>
</tr>
{% endfor %}

View file

@ -14,10 +14,13 @@
{% if not statements %}
Pas de relevé
{% else %}
<table class="table table-hover table-condensed">
<table
class="table table-hover table-condensed sortable"
{# Initial sort: [(at,desc)] #}
data-sortlist="[[0,1]]">
<thead>
<tr>
<td>Date/heure</td>
<td data-sorter="shortDate">Date/heure</td>
<td>Montant pris</td>
<td>Montant laissé</td>
<td>Erreur</td>
@ -25,9 +28,9 @@
<tbody>
{% for statement in statements %}
<tr>
<td>
<td title="{{ statement.at }}">
<a href="{% url 'kfet.checkoutstatement.update' checkout.pk statement.pk %}">
{{ statement.at }}
{{ statement.at|date:'d/m/Y H:i' }}
</a>
</td>
<td>{{ statement.amount_taken }}</td>

View file

@ -17,10 +17,13 @@
{% block main %}
<div class="table-responsive">
<table class="table table-hover table-condensed">
<table
class="table table-hover table-condensed sortable"
{# Initial sort: [(at,desc)] #}
data-sortlist="[[0,1]]">
<thead>
<tr>
<td>Date</td>
<td data-sorter="shortDate">Date</td>
<td>Par</td>
<td>Nb articles</td>
</tr>
@ -28,9 +31,9 @@
<tbody>
{% for inventory in inventories %}
<tr>
<td>
<td title="{{ inventory.at }}">
<a href="{% url 'kfet.inventory.read' inventory.pk %}">
<span>{{ inventory.at }}</span>
{{ inventory.at|date:'d/m/Y H:i' }}
</a>
</td>
<td>{{ inventory.by }}</td>

View file

@ -27,7 +27,10 @@
{% block main %}
<div class="table-responsive">
<table class="table table-condensed">
<table
class="table table-condensed table-hover table-striped sortable"
{# Initial sort: [(article.name,asc)] #}
data-sortlist="[[0,0]]">
<thead>
<tr>
<td>Article</td>
@ -36,25 +39,28 @@
<td>Erreur</td>
</tr>
</thead>
<tbody>
{% for inventoryart in inventoryarts %}
{% ifchanged inventoryart.article.category %}
<tr class="section">
<td colspan="4">{{ inventoryart.article.category.name }}</td>
</tr>
{% endifchanged %}
<tr>
<td>
<a href="{% url "kfet.article.read" inventoryart.article.id %}">
{{ inventoryart.article.name }}
</a>
</td>
<td>{{ inventoryart.stock_old }}</td>
<td>{{ inventoryart.stock_new }}</td>
<td>{{ inventoryart.stock_error }}</td>
{% regroup inventoryarts by article.category as category_list %}
{% for category in category_list %}
<tbody class="tablesorter-no-sort">
<tr class="section">
<td colspan="4">{{ category.grouper.name }}</td>
</tr>
{% endfor %}
</tbody>
</tbody>
<tbody>
{% for inventoryart in category.list %}
<tr>
<td>
<a href="{% url "kfet.article.read" inventoryart.article.id %}">
{{ inventoryart.article.name }}
</a>
</td>
<td>{{ inventoryart.stock_old }}</td>
<td>{{ inventoryart.stock_new }}</td>
<td>{{ inventoryart.stock_error }}</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>

View file

@ -55,11 +55,14 @@
<section>
<h2>Liste des commandes</h2>
<div class="table-responsive">
<table class="table table-hover table-condensed">
<table
class="table table-hover table-condensed sortable"
{# Initial sort: [(at,desc)] #}
data-sortlist="[[1,1]]">
<thead>
<tr>
<td></td>
<td>Date</td>
<td data-sorter="false"></td>
<td data-parser="shortDate">Date</td>
<td>Fournisseur</td>
<td>Inventaire</td>
</tr>
@ -74,9 +77,9 @@
</a>
{% endif %}
</td>
<td>
<td tile="{{ order.at }}">
<a href="{% url 'kfet.order.read' order.pk %}">
{{ order.at }}
{{ order.at|date:'d/m/Y H:i' }}
</a>
</td>
<td>{{ order.supplier }}</td>

View file

@ -11,60 +11,79 @@
<form action="" method="post">
{% csrf_token %}
<div class="table-responsive">
<table class="table table-hover table-condensed table-condensed-input text-center table-striped">
<table
class="table table-hover table-condensed table-condensed-input text-center table-striped sortable"
{# Initial sort: [(name,asc)] #}
data-sortlist="[[0,0]]">
<thead>
<tr>
<td rowspan="2">Article</td>
<td colspan="{{ scale|length }}">Ventes
<span class='glyphicon glyphicon-question-sign' title="Ventes des 5 dernières semaines" data-placement="bottom"></span>
</td>
<td rowspan="2">V. moy.<br>
<span class='glyphicon glyphicon-question-sign' title="Moyenne des ventes" data-placement="bottom"></span>
</td>
<td rowspan="2">E.T.<br>
<span class='glyphicon glyphicon-question-sign' title="Écart-type des ventes" data-placement="bottom"></span>
</td>
<td rowspan="2">Prév.<br>
<span class='glyphicon glyphicon-question-sign' title="Prévision de ventes" data-placement="bottom"></span>
</td>
<td colspan="{{ scale|length }}">
Ventes
<i class='glyphicon glyphicon-question-sign' title="Ventes des 5 dernières semaines" data-placement="bottom"></i>
</td>
<td rowspan="2">
V. moy.
<br>
<i class='glyphicon glyphicon-question-sign' title="Moyenne des ventes" data-placement="bottom"></i>
</td>
<td rowspan="2" data-sorter="false">
E.T.
<br>
<i class='glyphicon glyphicon-question-sign' title="Écart-type des ventes" data-placement="bottom"></i>
</td>
<td rowspan="2">
Prév.
<br>
<i class='glyphicon glyphicon-question-sign' title="Prévision de ventes" data-placement="bottom"></i>
</td>
<td rowspan="2">Stock</td>
<td rowspan="2">Box<br>
<span class='glyphicon glyphicon-question-sign' title="Capacité d'une boite" data-placement="bottom"></span>
</td>
<td rowspan="2">Rec.<br>
<span class='glyphicon glyphicon-question-sign' title="Quantité conseillée" data-placement="bottom"></span>
</td>
<td rowspan="2">Commande</td>
<td rowspan="2" data-sorter="false">
Box
<br>
<i class='glyphicon glyphicon-question-sign' title="Capacité d'une boite" data-placement="bottom"></i>
</td>
<td rowspan="2">
Rec.
<br>
<i class='glyphicon glyphicon-question-sign' title="Quantité conseillée" data-placement="bottom"></i>
</td>
<td rowspan="2" data-sorter="false" class="small-width">
Commande
</td>
</tr>
<tr>
{% for label in scale.get_labels %}
<td>{{ label }}</td>
<td class="sm-padding">{{ label }}</td>
{% endfor %}
</tr>
</thead>
<tbody>
{% for form in formset %}
{% ifchanged form.category %}
<tr class='section text-left'>
<td colspan="{{ scale|length|add:'8' }}">{{ form.category_name }}</td>
</tr>
{% endifchanged %}
<tr>
{{ form.article }}
<td class="text-left">{{ form.name }}</td>
{% for v_chunk in form.v_all %}
<td>{{ v_chunk }}</td>
{% endfor %}
<td>{{ form.v_moy }}</td>
<td>{{ form.v_et }}</td>
<td>{{ form.v_prev }}</td>
<td>{{ form.stock }}</td>
<td>{{ form.box_capacity|default:"" }}</td>
<td>{{ form.c_rec }}</td>
<td class="nopadding">{{ form.quantity_ordered | add_class:"form-control" }}</td>
{% regroup formset by category_name as category_list %}
{% for category in category_list %}
<tbody class="tablesorter-no-sort">
<tr class='section text-left'>
<td colspan="{{ scale|length|add:'8' }}">{{ category.grouper }}</td>
</tr>
{% endfor %}
</tbody>
</tbody>
<tbody>
{% for form in category.list %}
<tr>
{{ form.article }}
<td class="text-left">{{ form.name }}</td>
{% for v_chunk in form.v_all %}
<td>{{ v_chunk }}</td>
{% endfor %}
<td>{{ form.v_moy }}</td>
<td>{{ form.v_et }}</td>
<td>{{ form.v_prev }}</td>
<td>{{ form.stock }}</td>
<td>{{ form.box_capacity|default:"" }}</td>
<td>{{ form.c_rec }}</td>
<td class="nopadding">{{ form.quantity_ordered|add_class:"form-control" }}</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
{{ formset.management_form }}

View file

@ -42,7 +42,10 @@
<section>
<h2>Détails</h2>
<div class="table-responsive">
<table class="table table-condensed">
<table
class="table table-condensed table-hover table-striped sortable"
{# Initial sort: [(article.name,asc)] #}
data-sortlist="[[0,0]]">
<thead>
<tr>
<td>Article</td>
@ -51,32 +54,35 @@
<td>Reçu</td>
</tr>
</thead>
<tbody>
{% for orderart in orderarts %}
{% ifchanged orderart.article.category %}
<tr class="section">
<td colspan="4">{{ orderart.article.category.name }}</td>
</tr>
{% endifchanged %}
<tr>
<td>
<a href="{% url "kfet.article.read" orderart.article.id %}">
{{ orderart.article.name }}
</a>
</td>
<td>{{ orderart.quantity_ordered }}</td>
<td>
{% if orderart.article.box_capacity %}
{# c'est une division ! #}
{% widthratio orderart.quantity_ordered orderart.article.box_capacity 1 %}
{% endif %}
</td>
<td>
{{ orderart.quantity_received|default_if_none:'' }}
</td>
{% regroup orderarts by article.category as category_list %}
{% for category in category_list %}
<tbody class="tablesorter-no-sort">
<tr class="section">
<td colspan="4">{{ category.grouper.name }}</td>
</tr>
{% endfor %}
</tbody>
</tbody>
<tbody>
{% for orderart in category.list %}
<tr>
<td>
<a href="{% url "kfet.article.read" orderart.article.id %}">
{{ orderart.article.name }}
</a>
</td>
<td>{{ orderart.quantity_ordered }}</td>
<td>
{% if orderart.article.box_capacity %}
{# c'est une division ! #}
{% widthratio orderart.quantity_ordered orderart.article.box_capacity 1 %}
{% endif %}
</td>
<td>
{{ orderart.quantity_received|default_if_none:'' }}
</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
</section>

48
kfet/tests/test_forms.py Normal file
View file

@ -0,0 +1,48 @@
import datetime
from unittest import mock
from django.test import TestCase
from django.utils import timezone
from kfet.forms import KPsulCheckoutForm
from kfet.models import Checkout
from .utils import create_user
class KPsulCheckoutFormTests(TestCase):
def setUp(self):
self.now = timezone.now()
user = create_user()
self.c1 = Checkout.objects.create(
name='C1', balance=10,
created_by=user.profile.account_kfet,
valid_from=self.now,
valid_to=self.now + datetime.timedelta(days=1),
)
self.form = KPsulCheckoutForm()
def test_checkout(self):
checkout_f = self.form.fields['checkout']
self.assertListEqual(list(checkout_f.choices), [
('', '---------'),
(self.c1.pk, 'C1'),
])
@mock.patch('django.utils.timezone.now')
def test_checkout_valid(self, mock_now):
"""
Checkout are filtered using the current datetime.
Regression test for #184.
"""
self.now += datetime.timedelta(days=2)
mock_now.return_value = self.now
form = KPsulCheckoutForm()
checkout_f = form.fields['checkout']
self.assertListEqual(list(checkout_f.choices), [('', '---------')])

View file

@ -1,7 +1,12 @@
import datetime
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from kfet.models import Account
from kfet.models import Account, Checkout
from .utils import create_user
User = get_user_model()
@ -23,3 +28,33 @@ class AccountTests(TestCase):
with self.assertRaises(Account.DoesNotExist):
Account.objects.get_by_password('bernard')
class CheckoutTests(TestCase):
def setUp(self):
self.now = timezone.now()
self.u = create_user()
self.u_acc = self.u.profile.account_kfet
self.c = Checkout(
created_by=self.u_acc,
valid_from=self.now,
valid_to=self.now + datetime.timedelta(days=1),
)
def test_initial_statement(self):
"""A statement is added with initial balance on creation."""
self.c.balance = 10
self.c.save()
st = self.c.statements.get()
self.assertEqual(st.balance_new, 10)
self.assertEqual(st.amount_taken, 0)
self.assertEqual(st.amount_error, 0)
# Saving again doesn't create a new statement.
self.c.save()
self.assertEqual(self.c.statements.count(), 1)

View file

@ -746,12 +746,16 @@ class CheckoutReadViewTests(ViewTestCaseMixin, TestCase):
def setUp(self):
super().setUp()
self.checkout = Checkout.objects.create(
name='Checkout',
created_by=self.accounts['team'],
valid_from=self.now,
valid_to=self.now + timedelta(days=5),
)
with mock.patch('django.utils.timezone.now') as mock_now:
mock_now.return_value = self.now
self.checkout = Checkout.objects.create(
name='Checkout', balance=Decimal('10'),
created_by=self.accounts['team'],
valid_from=self.now,
valid_to=self.now + timedelta(days=1),
)
def test_ok(self):
r = self.client.get(self.url)
@ -794,7 +798,7 @@ class CheckoutUpdateViewTests(ViewTestCaseMixin, TestCase):
name='Checkout',
valid_from=self.now,
valid_to=self.now + timedelta(days=5),
balance='3.14',
balance=Decimal('3.14'),
is_protected=False,
created_by=self.accounts['team'],
)
@ -864,6 +868,7 @@ class CheckoutStatementListViewTests(ViewTestCaseMixin, TestCase):
self.assertQuerysetEqual(
r.context['checkoutstatements'],
map(repr, expected_statements),
ordered=False,
)

View file

@ -245,13 +245,7 @@ class ViewTestCaseMixin(TestCaseMixin):
self.register_user(label, user)
if self.auth_user:
# The wrapper is a sanity check.
self.assertTrue(
self.client.login(
username=self.auth_user,
password=self.auth_user,
)
)
self.client.force_login(self.users[self.auth_user])
def tearDown(self):
del self.users_base

View file

@ -526,15 +526,7 @@ class CheckoutCreate(SuccessMessageMixin, CreateView):
# Creating
form.instance.created_by = self.request.user.profile.account_kfet
checkout = form.save()
# Création d'un relevé avec balance initiale
CheckoutStatement.objects.create(
checkout = checkout,
by = self.request.user.profile.account_kfet,
balance_old = checkout.balance,
balance_new = checkout.balance,
amount_taken = 0)
form.save()
return super(CheckoutCreate, self).form_valid(form)
@ -712,6 +704,14 @@ class ArticleList(ListView):
)
template_name = 'kfet/article.html'
context_object_name = 'articles'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
articles = context[self.context_object_name]
context['nb_articles'] = len(articles)
context[self.context_object_name] = articles.filter(is_sold=True)
context['not_sold_articles'] = articles.filter(is_sold=False)
return context
# Article - Create
@ -1841,7 +1841,7 @@ def order_create(request, pk):
else:
formset = cls_formset(initial=initial)
scale.label_fmt = "S -{rev_i}"
scale.label_fmt = "S-{rev_i}"
return render(request, 'kfet/order_create.html', {
'supplier': supplier,

View file

@ -1,5 +1,8 @@
#!/bin/sh
# Stop if an error is encountered
set -e
# Configuration de la base de données. Le mot de passe est constant car c'est
# pour une installation de dév locale qui ne sera accessible que depuis la
# machine virtuelle.

View file

@ -1,5 +1,8 @@
#!/bin/bash
# Stop if an error is encountered.
set -e
python manage.py migrate
python manage.py loaddata gestion sites articles
python manage.py loaddevdata

View file

@ -10,7 +10,7 @@ icalendar
psycopg2
Pillow
unicodecsv
django-bootstrap-form==3.2.1
django-bootstrap-form==3.3
asgiref==1.1.1
daphne==1.3.0
asgi-redis==1.3.0
@ -22,6 +22,7 @@ channels==1.1.5
python-dateutil
wagtail==1.10.*
wagtailmenus==2.2.*
django-cors-headers==2.2.0
# Production tools
wheel

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

335
shared/tests/testcases.py Normal file
View file

@ -0,0 +1,335 @@
import csv
from unittest import mock
from urllib.parse import parse_qs, urlparse
from django.contrib.auth import get_user_model
from django.core.urlresolvers import reverse
from django.http import QueryDict
from django.test import Client
from django.utils import timezone
from django.utils.functional import cached_property
import icalendar
User = get_user_model()
class TestCaseMixin:
def assertForbidden(self, response):
"""
Test that the response (retrieved with a Client) is a denial of access.
The response should verify one of the following:
- its HTTP response code is 403,
- it redirects to the login page with a GET parameter named 'next'
whose value is the url of the requested page.
"""
request = response.wsgi_request
try:
try:
# Is this an HTTP Forbidden response ?
self.assertEqual(response.status_code, 403)
except AssertionError:
# A redirection to the login view is fine too.
# Let's build the login url with the 'next' param on current
# page.
full_path = request.get_full_path()
querystring = QueryDict(mutable=True)
querystring['next'] = full_path
login_url = '{}?{}'.format(
reverse('cof-login'), querystring.urlencode(safe='/'))
# We don't focus on what the login view does.
# So don't fetch the redirect.
self.assertRedirects(
response, login_url,
fetch_redirect_response=False,
)
except AssertionError:
raise AssertionError(
"%(http_method)s request at %(path)s should be forbidden for "
"%(username)s user.\n"
"Response isn't 403, nor a redirect to login view. Instead, "
"response code is %(code)d." % {
'http_method': request.method,
'path': request.get_full_path(),
'username': (
"'{}'".format(request.user)
if request.user.is_authenticated()
else 'anonymous'
),
'code': response.status_code,
}
)
def assertUrlsEqual(self, actual, expected):
"""
Test that the url 'actual' is as 'expected'.
Arguments:
actual (str): Url to verify.
expected: Two forms are accepted.
* (str): Expected url. Strings equality is checked.
* (dict): Its keys must be attributes of 'urlparse(actual)'.
Equality is checked for each present key, except for
'query' which must be a dict of the expected query string
parameters.
"""
if type(expected) == dict:
parsed = urlparse(actual)
for part, expected_part in expected.items():
if part == 'query':
self.assertDictEqual(
parse_qs(parsed.query),
expected.get('query', {}),
)
else:
self.assertEqual(getattr(parsed, part), expected_part)
else:
self.assertEqual(actual, expected)
def load_from_csv_response(self, r):
decoded = r.content.decode('utf-8')
return list(csv.reader(decoded.split('\n')[:-1]))
def _test_event_equal(self, event, exp):
for k, v_desc in exp.items():
if isinstance(v_desc, tuple):
v_getter = v_desc[0]
v = v_desc[1]
else:
v_getter = lambda v: v
v = v_desc
if v_getter(event[k.upper()]) != v:
return False
return True
def _find_event(self, ev, l):
for i, elt in enumerate(l):
if self._test_event_equal(ev, elt):
return elt, i
return False, -1
def assertCalEqual(self, ical_content, expected):
remaining = expected.copy()
unexpected = []
cal = icalendar.Calendar.from_ical(ical_content)
for ev in cal.walk('vevent'):
found, i_found = self._find_event(ev, remaining)
if found:
remaining.pop(i_found)
else:
unexpected.append(ev)
self.assertListEqual(unexpected, [])
self.assertListEqual(remaining, [])
class ViewTestCaseMixin(TestCaseMixin):
"""
TestCase extension to ease tests of kfet views.
Urls concerns
-------------
# Basic usage
Attributes:
url_name (str): Name of view under test, as given to 'reverse'
function.
url_args (list, optional): Will be given to 'reverse' call.
url_kwargs (dict, optional): Same.
url_expcted (str): What 'reverse' should return given previous
attributes.
View url can then be accessed at the 'url' attribute.
# Advanced usage
If multiple combinations of url name, args, kwargs can be used for a view,
it is possible to define 'urls_conf' attribute. It must be a list whose
each item is a dict defining arguments for 'reverse' call ('name', 'args',
'kwargs' keys) and its expected result ('expected' key).
The reversed urls can be accessed at the 't_urls' attribute.
Users concerns
--------------
During setup, the following users are created:
- 'user': a basic user without any permission,
- 'root': a superuser, account trigramme: 200.
Their password is their username.
One can create additionnal users with 'get_users_extra' method, or prevent
these users to be created with 'get_users_base' method. See these two
methods for further informations.
By using 'register_user' method, these users can then be accessed at
'users' attribute by their label.
A user label can be given to 'auth_user' attribute. The related user is
then authenticated on self.client during test setup. Its value defaults to
'None', meaning no user is authenticated.
Automated tests
---------------
# Url reverse
Based on url-related attributes/properties, the test 'test_urls' checks
that expected url is returned by 'reverse' (once with basic url usage and
each for advanced usage).
# Forbidden responses
The 'test_forbidden' test verifies that each user, from labels of
'auth_forbidden' attribute, can't access the url(s), i.e. response should
be a 403, or a redirect to login view.
Tested HTTP requests are given by 'http_methods' attribute. Additional data
can be given by defining an attribute '<method(lowercase)>_data'.
"""
url_name = None
url_expected = None
http_methods = ['GET']
auth_user = None
auth_forbidden = []
def setUp(self):
"""
Warning: Do not forget to call super().setUp() in subclasses.
"""
# Signals handlers on login/logout send messages.
# Due to the way the Django' test Client performs login, this raise an
# error. As workaround, we mock the Django' messages module.
patcher_messages = mock.patch('gestioncof.signals.messages')
patcher_messages.start()
self.addCleanup(patcher_messages.stop)
# A test can mock 'django.utils.timezone.now' and give this as return
# value. E.g. it is useful if the test checks values of 'auto_now' or
# 'auto_now_add' fields.
self.now = timezone.now()
# Register of User instances.
self.users = {}
for label, user in dict(self.users_base, **self.users_extra).items():
self.register_user(label, user)
if self.auth_user:
# The wrapper is a sanity check.
self.assertTrue(
self.client.login(
username=self.auth_user,
password=self.auth_user,
)
)
def tearDown(self):
del self.users_base
del self.users_extra
def get_users_base(self):
"""
Dict of <label: user instance>.
Note: Don't access yourself this property. Use 'users_base' attribute
which cache the returned value from here.
It allows to give functions calls, which creates users instances, as
values here.
"""
return {
'user': User.objects.create_user('user', '', 'user'),
'root': User.objects.create_superuser('root', '', 'root'),
}
@cached_property
def users_base(self):
return self.get_users_base()
def get_users_extra(self):
"""
Dict of <label: user instance>.
Note: Don't access yourself this property. Use 'users_base' attribute
which cache the returned value from here.
It allows to give functions calls, which create users instances, as
values here.
"""
return {}
@cached_property
def users_extra(self):
return self.get_users_extra()
def register_user(self, label, user):
self.users[label] = user
def get_user(self, label):
if self.auth_user is not None:
return self.auth_user
return self.auth_user_mapping.get(label)
@property
def urls_conf(self):
return [{
'name': self.url_name,
'args': getattr(self, 'url_args', []),
'kwargs': getattr(self, 'url_kwargs', {}),
'expected': self.url_expected,
}]
@property
def t_urls(self):
return [
reverse(
url_conf['name'],
args=url_conf.get('args', []),
kwargs=url_conf.get('kwargs', {}),
)
for url_conf in self.urls_conf]
@property
def url(self):
return self.t_urls[0]
def test_urls(self):
for url, conf in zip(self.t_urls, self.urls_conf):
self.assertEqual(url, conf['expected'])
def test_forbidden(self):
for method in self.http_methods:
for user in self.auth_forbidden:
for url in self.t_urls:
self.check_forbidden(method, url, user)
def check_forbidden(self, method, url, user=None):
method = method.lower()
client = Client()
if user is not None:
client.login(username=user, password=user)
send_request = getattr(client, method)
data = getattr(self, '{}_data'.format(method), {})
r = send_request(url, data)
self.assertForbidden(r)