forked from DGNum/gestioCOF
Merge branch 'master' into aureplop/kfet_cms
This commit is contained in:
commit
455b730cc3
41 changed files with 1162 additions and 156 deletions
|
@ -0,0 +1 @@
|
|||
|
|
@ -7,11 +7,20 @@ from custommail.shortcuts import send_mass_custom_mail
|
|||
|
||||
from django.contrib.sites.models import Site
|
||||
from django.db import models
|
||||
from django.db.models import Count
|
||||
from django.contrib.auth.models import User
|
||||
from django.conf import settings
|
||||
from django.utils import timezone, formats
|
||||
|
||||
|
||||
def get_generic_user():
|
||||
generic, _ = User.objects.get_or_create(
|
||||
username="bda_generic",
|
||||
defaults={"email": "bda@ens.fr", "first_name": "Bureau des arts"}
|
||||
)
|
||||
return generic
|
||||
|
||||
|
||||
class Tirage(models.Model):
|
||||
title = models.CharField("Titre", max_length=300)
|
||||
ouverture = models.DateTimeField("Date et heure d'ouverture du tirage")
|
||||
|
@ -96,32 +105,29 @@ class Spectacle(models.Model):
|
|||
Envoie un mail de rappel à toutes les personnes qui ont une place pour
|
||||
ce spectacle.
|
||||
"""
|
||||
# On récupère la liste des participants
|
||||
members = {}
|
||||
for attr in Attribution.objects.filter(spectacle=self).all():
|
||||
member = attr.participant.user
|
||||
if member.id in members:
|
||||
members[member.id][1] = 2
|
||||
else:
|
||||
members[member.id] = [member, 1]
|
||||
# FIXME : faire quelque chose de ça, un utilisateur bda_generic ?
|
||||
# # Pour le BdA
|
||||
# members[0] = ['BdA', 1, 'bda@ens.fr']
|
||||
# members[-1] = ['BdA', 2, 'bda@ens.fr']
|
||||
# On récupère la liste des participants + le BdA
|
||||
members = list(
|
||||
User.objects
|
||||
.filter(participant__attributions=self)
|
||||
.annotate(nb_attr=Count("id")).order_by()
|
||||
)
|
||||
bda_generic = get_generic_user()
|
||||
bda_generic.nb_attr = 1
|
||||
members.append(bda_generic)
|
||||
# On écrit un mail personnalisé à chaque participant
|
||||
datatuple = [(
|
||||
'bda-rappel',
|
||||
{'member': member[0], 'nb_attr': member[1], 'show': self},
|
||||
{'member': member, "nb_attr": member.nb_attr, 'show': self},
|
||||
settings.MAIL_DATA['rappels']['FROM'],
|
||||
[member[0].email])
|
||||
for member in members.values()
|
||||
[member.email])
|
||||
for member in members
|
||||
]
|
||||
send_mass_custom_mail(datatuple)
|
||||
# On enregistre le fait que l'envoi a bien eu lieu
|
||||
self.rappel_sent = timezone.now()
|
||||
self.save()
|
||||
# On renvoie la liste des destinataires
|
||||
return members.values()
|
||||
return members
|
||||
|
||||
@property
|
||||
def is_past(self):
|
||||
|
|
|
@ -3,41 +3,46 @@
|
|||
{% block realcontent %}
|
||||
<h2>Mails de rappels</h2>
|
||||
{% if sent %}
|
||||
<h3>Les mails de rappel pour le spectacle {{ show.title }} ont bien été envoyés aux personnes suivantes</h3>
|
||||
<ul>
|
||||
{% for member in members %}
|
||||
<li>{{ member.get_full_name }} ({{ member.email }})</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<h3>Les mails de rappel pour le spectacle {{ show.title }} ont bien été envoyés aux personnes suivantes</h3>
|
||||
<ul>
|
||||
{% for member in members %}
|
||||
<li>{{ member.get_full_name }} ({{ member.email }})</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<h3>Voulez vous envoyer les mails de rappel pour le spectacle
|
||||
{{ show.title }} ?</h3>
|
||||
{% if show.rappel_sent %}
|
||||
<p class="error">Attention, les mails ont déjà été envoyés le
|
||||
{{ show.rappel_sent }}</p>
|
||||
{% endif %}
|
||||
<h3>Voulez vous envoyer les mails de rappel pour le spectacle {{ show.title }} ?</h3>
|
||||
{% endif %}
|
||||
|
||||
{% if not sent %}
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="pull-right">
|
||||
<input class="btn btn-primary" type="submit" value="Envoyer" />
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
<div class="empty-form">
|
||||
{% if not sent %}
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="pull-right">
|
||||
<input class="btn btn-primary" type="submit" value="Envoyer" />
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<hr \>
|
||||
|
||||
<p>
|
||||
<em>Note :</em> le template de ce mail peut être modifié à
|
||||
<a href="{% url 'admin:custommail_custommail_change' custommail.pk %}">cette adresse</a>
|
||||
</p>
|
||||
|
||||
<hr \>
|
||||
|
||||
<br/>
|
||||
<hr/>
|
||||
<h3>Forme des mails</h3>
|
||||
|
||||
<h4>Une seule place</h4>
|
||||
{% for part in exemple_mail_1place %}
|
||||
<pre>{{ part }}</pre>
|
||||
{% endfor %}
|
||||
{% for part in exemple_mail_1place %}
|
||||
<pre>{{ part }}</pre>
|
||||
{% endfor %}
|
||||
|
||||
<h4>Deux places</h4>
|
||||
{% for part in exemple_mail_2places %}
|
||||
<pre>{{ part }}</pre>
|
||||
<pre>{{ part }}</pre>
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -36,17 +36,26 @@
|
|||
</tbody>
|
||||
</table>
|
||||
<h3><a href="{% url "admin:bda_attribution_add" %}?spectacle={{spectacle.id}}"><span class="glyphicon glyphicon-plus-sign"></span> Ajouter une attribution</a></h3>
|
||||
<br>
|
||||
<button class="btn btn-default" type="button" onclick="toggle('export-mails')">Afficher/Cacher mails participants</button>
|
||||
<pre id="export-mails" style="display:none">
|
||||
{%for participant in participants %}{{participant.email}}, {%endfor%}
|
||||
</pre>
|
||||
<br>
|
||||
<button class="btn btn-default" type="button" onclick="toggle('export-salle')">Afficher/Cacher liste noms</button>
|
||||
<pre id="export-salle" style="display:none">
|
||||
<div>
|
||||
<div>
|
||||
<button class="btn btn-default" type="button" onclick="toggle('export-mails')">Afficher/Cacher mails participants</button>
|
||||
<pre id="export-mails" style="display:none">{% spaceless %}
|
||||
{% for participant in participants %}{{ participant.email }}, {% endfor %}
|
||||
{% endspaceless %}</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-default" type="button" onclick="toggle('export-salle')">Afficher/Cacher liste noms</button>
|
||||
<pre id="export-salle" style="display:none">{% spaceless %}
|
||||
{% for participant in participants %}{{participant.name}} : {{participant.nb_places}} places
|
||||
{% endfor %}
|
||||
</pre>
|
||||
{% endspaceless %}</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a href="{% url 'bda-rappels' spectacle.id %}">Page d'envoi manuel des mails de rappel</a>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript"
|
||||
src="{% static "js/joequery-Stupid-Table-Plugin/stupidtable.js" %}"></script>
|
||||
<script>
|
28
bda/tests.py
28
bda/tests.py
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase, Client
|
||||
from django.utils import timezone
|
||||
|
||||
|
@ -34,11 +35,36 @@ class TestBdAViews(TestCase):
|
|||
),
|
||||
])
|
||||
|
||||
self.bda_user = User.objects.create_user(
|
||||
username="bda_user", password="bda4ever"
|
||||
)
|
||||
self.bda_user.profile.is_cof = True
|
||||
self.bda_user.profile.is_buro = True
|
||||
self.bda_user.profile.save()
|
||||
|
||||
def bda_participants(self):
|
||||
"""The BdA participants views can be queried"""
|
||||
client = Client()
|
||||
show = self.tirage.spectacle_set.first()
|
||||
|
||||
client.login(self.bda_user.username, "bda4ever")
|
||||
tirage_resp = client.get("/bda/spectacles/{}".format(self.tirage.id))
|
||||
show_resp = client.get(
|
||||
"/bda/spectacles/{}/{}".format(self.tirage.id, show.id)
|
||||
)
|
||||
reminder_url = "/bda/mails-rappel/{}".format(show.id)
|
||||
reminder_get_resp = client.get(reminder_url)
|
||||
reminder_post_resp = client.post(reminder_url)
|
||||
self.assertEqual(200, tirage_resp.status_code)
|
||||
self.assertEqual(200, show_resp.status_code)
|
||||
self.assertEqual(200, reminder_get_resp.status_code)
|
||||
self.assertEqual(200, reminder_post_resp.status_code)
|
||||
|
||||
def test_catalogue(self):
|
||||
"""Test the catalogue JSON API"""
|
||||
client = Client()
|
||||
|
||||
# The `list` hooh
|
||||
# The `list` hook
|
||||
resp = client.get("/bda/catalogue/list")
|
||||
self.assertJSONEqual(
|
||||
resp.content.decode("utf-8"),
|
||||
|
|
|
@ -44,7 +44,10 @@ urlpatterns = [
|
|||
url(r'^revente-immediat/(?P<tirage_id>\d+)$',
|
||||
views.revente_shotgun,
|
||||
name="bda-shotgun"),
|
||||
url(r'^mails-rappel/(?P<spectacle_id>\d+)$', views.send_rappel),
|
||||
url(r'^mails-rappel/(?P<spectacle_id>\d+)$',
|
||||
views.send_rappel,
|
||||
name="bda-rappels"
|
||||
),
|
||||
url(r'^descriptions/(?P<tirage_id>\d+)$', views.descriptions_spectacles,
|
||||
name='bda-descriptions'),
|
||||
url(r'^catalogue/(?P<request_type>[a-z]+)$', views.catalogue,
|
||||
|
|
35
bda/views.py
35
bda/views.py
|
@ -1,15 +1,13 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from collections import defaultdict
|
||||
from functools import partial
|
||||
import random
|
||||
import hashlib
|
||||
import time
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from custommail.shortcuts import (
|
||||
send_mass_custom_mail, send_custom_mail, render_custom_mail
|
||||
)
|
||||
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
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib import messages
|
||||
|
@ -27,7 +25,7 @@ from django.views.generic.list import ListView
|
|||
from gestioncof.decorators import cof_required, buro_required
|
||||
from bda.models import (
|
||||
Spectacle, Participant, ChoixSpectacle, Attribution, Tirage,
|
||||
SpectacleRevente, Salle, Quote, CategorieSpectacle
|
||||
SpectacleRevente, Salle, CategorieSpectacle
|
||||
)
|
||||
from bda.algorithm import Algorithm
|
||||
from bda.forms import (
|
||||
|
@ -305,7 +303,8 @@ def do_tirage(tirage_elt, token):
|
|||
# On inscrit à BdA-Revente ceux qui n'ont pas eu les places voulues
|
||||
ChoixRevente = Participant.choicesrevente.through
|
||||
|
||||
# Suppression des reventes demandées/enregistrées (si le tirage est relancé)
|
||||
# Suppression des reventes demandées/enregistrées
|
||||
# (si le tirage est relancé)
|
||||
(
|
||||
ChoixRevente.objects
|
||||
.filter(spectacle__tirage=tirage_elt)
|
||||
|
@ -612,7 +611,7 @@ def spectacle(request, tirage_id, spectacle_id):
|
|||
|
||||
participants_info = sorted(participants.values(),
|
||||
key=lambda part: part['lastname'])
|
||||
return render(request, "bda-participants.html",
|
||||
return render(request, "bda/participants.html",
|
||||
{"spectacle": spectacle, "participants": participants_info})
|
||||
|
||||
|
||||
|
@ -651,20 +650,24 @@ def unpaid(request, tirage_id):
|
|||
def send_rappel(request, spectacle_id):
|
||||
show = get_object_or_404(Spectacle, id=spectacle_id)
|
||||
# Mails d'exemples
|
||||
exemple_mail_1place = render_custom_mail('bda-rappel', {
|
||||
custommail = CustomMail.objects.get(shortname="bda-rappel")
|
||||
exemple_mail_1place = custommail.render({
|
||||
'member': request.user,
|
||||
'show': show,
|
||||
'nb_attr': 1
|
||||
})
|
||||
exemple_mail_2places = render_custom_mail('bda-rappel', {
|
||||
exemple_mail_2places = custommail.render({
|
||||
'member': request.user,
|
||||
'show': show,
|
||||
'nb_attr': 2
|
||||
})
|
||||
# Contexte
|
||||
ctxt = {'show': show,
|
||||
'exemple_mail_1place': exemple_mail_1place,
|
||||
'exemple_mail_2places': exemple_mail_2places}
|
||||
ctxt = {
|
||||
'show': show,
|
||||
'exemple_mail_1place': exemple_mail_1place,
|
||||
'exemple_mail_2places': exemple_mail_2places,
|
||||
'custommail': custommail,
|
||||
}
|
||||
# Envoi confirmé
|
||||
if request.method == 'POST':
|
||||
members = show.send_rappel()
|
||||
|
@ -673,6 +676,14 @@ def send_rappel(request, spectacle_id):
|
|||
# Demande de confirmation
|
||||
else:
|
||||
ctxt['sent'] = False
|
||||
if show.rappel_sent:
|
||||
messages.warning(
|
||||
request,
|
||||
"Attention, un mail de rappel pour ce spectale a déjà été "
|
||||
"envoyé le {}".format(formats.localize(
|
||||
timezone.template_localtime(show.rappel_sent)
|
||||
))
|
||||
)
|
||||
return render(request, "bda/mails-rappel.html", ctxt)
|
||||
|
||||
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
from kfet.routing import channel_routing as kfet_channel_routings
|
||||
from channels.routing import include
|
||||
|
||||
channel_routing = kfet_channel_routings
|
||||
|
||||
routing = [
|
||||
include('kfet.routing.routing', path=r'^/ws/k-fet'),
|
||||
]
|
||||
|
|
|
@ -19,11 +19,12 @@ except ImportError:
|
|||
except KeyError:
|
||||
raise RuntimeError("Secrets missing")
|
||||
|
||||
|
||||
# Other secrets
|
||||
try:
|
||||
from .secret import (
|
||||
SECRET_KEY, RECAPTCHA_PUBLIC_KEY, RECAPTCHA_PRIVATE_KEY, ADMINS,
|
||||
REDIS_PASSWD, REDIS_DB, REDIS_HOST, REDIS_PORT
|
||||
REDIS_PASSWD, REDIS_DB, REDIS_HOST, REDIS_PORT, KFETOPEN_TOKEN,
|
||||
)
|
||||
except ImportError:
|
||||
raise RuntimeError("Secrets missing")
|
||||
|
@ -52,6 +53,7 @@ INSTALLED_APPS = [
|
|||
'django_cas_ng',
|
||||
'bootstrapform',
|
||||
'kfet',
|
||||
'kfet.open',
|
||||
'channels',
|
||||
'widget_tweaks',
|
||||
'custommail',
|
||||
|
@ -176,6 +178,18 @@ AUTHENTICATION_BACKENDS = (
|
|||
|
||||
RECAPTCHA_USE_SSL = True
|
||||
|
||||
# Cache settings
|
||||
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'redis_cache.RedisCache',
|
||||
'LOCATION': 'redis://:{passwd}@{host}:{port}/db'
|
||||
.format(passwd=REDIS_PASSWD, host=REDIS_HOST,
|
||||
port=REDIS_PORT, db=REDIS_DB),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Channels settings
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
|
@ -188,7 +202,7 @@ CHANNEL_LAYERS = {
|
|||
port=REDIS_PORT, db=REDIS_DB)
|
||||
)],
|
||||
},
|
||||
"ROUTING": "cof.routing.channel_routing",
|
||||
"ROUTING": "cof.routing.routing",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,3 +6,5 @@ REDIS_PORT = 6379
|
|||
REDIS_DB = 0
|
||||
REDIS_HOST = "127.0.0.1"
|
||||
ADMINS = None
|
||||
|
||||
KFETOPEN_TOKEN = "plop"
|
||||
|
|
|
@ -40,8 +40,9 @@ a {
|
|||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
|
||||
div.empty-form {
|
||||
padding-bottom: 2em;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #444;
|
||||
|
@ -341,10 +342,12 @@ fieldset legend {
|
|||
font-weight: 700;
|
||||
font-size: large;
|
||||
color:#DE826B;
|
||||
padding-bottom: .5em;
|
||||
}
|
||||
|
||||
#main-content h4 {
|
||||
color:#DE826B;
|
||||
padding-bottom: .5em;
|
||||
}
|
||||
|
||||
#main-content h2,
|
||||
|
|
|
@ -1,39 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from django.core.serializers.json import json, DjangoJSONEncoder
|
||||
|
||||
from channels.generic.websockets import JsonWebsocketConsumer
|
||||
|
||||
|
||||
class DjangoJsonWebsocketConsumer(JsonWebsocketConsumer):
|
||||
"""Custom Json Websocket Consumer.
|
||||
|
||||
Encode to JSON with DjangoJSONEncoder.
|
||||
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def encode_json(cls, content):
|
||||
return json.dumps(content, cls=DjangoJSONEncoder)
|
||||
|
||||
|
||||
class PermConsumerMixin(object):
|
||||
"""Add support to check permissions on Consumers.
|
||||
|
||||
Attributes:
|
||||
perms_connect (list): Required permissions to connect to this
|
||||
consumer.
|
||||
|
||||
"""
|
||||
http_user = True # Enable message.user
|
||||
perms_connect = []
|
||||
|
||||
def connect(self, message, **kwargs):
|
||||
"""Check permissions on connection."""
|
||||
if message.user.has_perms(self.perms_connect):
|
||||
super().connect(message, **kwargs)
|
||||
else:
|
||||
self.close()
|
||||
from .utils import DjangoJsonWebsocketConsumer, PermConsumerMixin
|
||||
|
||||
|
||||
class KPsul(PermConsumerMixin, DjangoJsonWebsocketConsumer):
|
||||
|
|
|
@ -33,12 +33,7 @@ class DateTimeWidget(forms.DateTimeInput):
|
|||
css = {
|
||||
'all': ('kfet/css/bootstrap-datetimepicker.min.css',)
|
||||
}
|
||||
js = (
|
||||
'kfet/js/moment.js',
|
||||
'kfet/js/moment-fr.js',
|
||||
'kfet/js/moment-timezone-with-data-2010-2020.js',
|
||||
'kfet/js/bootstrap-datetimepicker.min.js',
|
||||
)
|
||||
js = ('kfet/js/bootstrap-datetimepicker.min.js',)
|
||||
|
||||
|
||||
# -----
|
||||
|
|
|
@ -73,7 +73,8 @@ class Account(models.Model):
|
|||
('change_account_password',
|
||||
"Modifier le mot de passe d'une personne de l'équipe"),
|
||||
('special_add_account',
|
||||
"Créer un compte avec une balance initiale")
|
||||
"Créer un compte avec une balance initiale"),
|
||||
('can_force_close', "Fermer manuellement la K-Fêt"),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
|
|
1
kfet/open/__init__.py
Normal file
1
kfet/open/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .open import OpenKfet, kfet_open # noqa
|
25
kfet/open/consumers.py
Normal file
25
kfet/open/consumers.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
from ..decorators import kfet_is_team
|
||||
from ..utils import DjangoJsonWebsocketConsumer, PermConsumerMixin
|
||||
|
||||
from .open import kfet_open
|
||||
|
||||
|
||||
class OpenKfetConsumer(PermConsumerMixin, DjangoJsonWebsocketConsumer):
|
||||
"""Consumer for K-Fêt Open.
|
||||
|
||||
WS groups:
|
||||
kfet.open.base: Only carries the values visible for all users.
|
||||
kfet.open.team: Carries all values (raw status...).
|
||||
|
||||
"""
|
||||
|
||||
def connection_groups(self, user, **kwargs):
|
||||
"""Select which group the user should be connected."""
|
||||
if kfet_is_team(user):
|
||||
return ['kfet.open.team']
|
||||
return ['kfet.open.base']
|
||||
|
||||
def connect(self, message, *args, **kwargs):
|
||||
"""Send current status on connect."""
|
||||
super().connect(message, *args, **kwargs)
|
||||
self.send(kfet_open.export(message.user))
|
109
kfet/open/open.py
Normal file
109
kfet/open/open.py
Normal file
|
@ -0,0 +1,109 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from ..decorators import kfet_is_team
|
||||
from ..utils import CachedMixin
|
||||
|
||||
|
||||
class OpenKfet(CachedMixin, object):
|
||||
"""Manage "open" status of a place.
|
||||
|
||||
Stores raw data (e.g. sent by raspberry), and user-set values
|
||||
(as force_close).
|
||||
Setting differents `cache_prefix` allows different places management.
|
||||
Current state persists through cache.
|
||||
|
||||
"""
|
||||
# status is unknown after this duration
|
||||
time_unknown = timedelta(minutes=15)
|
||||
|
||||
# status
|
||||
OPENED = 'opened'
|
||||
CLOSED = 'closed'
|
||||
UNKNOWN = 'unknown'
|
||||
# admin status
|
||||
FAKE_CLOSED = 'fake_closed'
|
||||
|
||||
# cached attributes config
|
||||
cached = {
|
||||
'_raw_open': False,
|
||||
'_last_update': None,
|
||||
'force_close': False,
|
||||
}
|
||||
cache_prefix = 'kfetopen'
|
||||
|
||||
@property
|
||||
def raw_open(self):
|
||||
"""Defined as property to update `last_update` on `raw_open` update."""
|
||||
return self._raw_open
|
||||
|
||||
@raw_open.setter
|
||||
def raw_open(self, value):
|
||||
self._last_update = timezone.now()
|
||||
self._raw_open = value
|
||||
|
||||
@property
|
||||
def last_update(self):
|
||||
"""Prevent `last_update` to be set."""
|
||||
return self._last_update
|
||||
|
||||
@property
|
||||
def is_open(self):
|
||||
"""Take into account force_close."""
|
||||
return False if self.force_close else self.raw_open
|
||||
|
||||
def status(self):
|
||||
if (self.last_update is None or
|
||||
timezone.now() - self.last_update >= self.time_unknown):
|
||||
return self.UNKNOWN
|
||||
return self.OPENED if self.is_open else self.CLOSED
|
||||
|
||||
def admin_status(self, status=None):
|
||||
if status is None:
|
||||
status = self.status()
|
||||
if status == self.CLOSED and self.raw_open:
|
||||
return self.FAKE_CLOSED
|
||||
return status
|
||||
|
||||
def _export(self):
|
||||
"""Export internal state.
|
||||
|
||||
Used by WS initialization and updates.
|
||||
|
||||
Returns:
|
||||
(tuple): (base, team)
|
||||
- team for team users.
|
||||
- base for others.
|
||||
|
||||
"""
|
||||
status = self.status()
|
||||
base = {
|
||||
'status': status,
|
||||
}
|
||||
restrict = {
|
||||
'admin_status': self.admin_status(status),
|
||||
'force_close': self.force_close,
|
||||
}
|
||||
return base, {**base, **restrict}
|
||||
|
||||
def export(self, user):
|
||||
"""Export internal state for a given user.
|
||||
|
||||
Returns:
|
||||
(dict): Internal state. Only variables visible for the user are
|
||||
exported, according to its permissions.
|
||||
|
||||
"""
|
||||
base, team = self._export()
|
||||
return team if kfet_is_team(user) else base
|
||||
|
||||
def send_ws(self):
|
||||
"""Send internal state to websocket channels."""
|
||||
from .consumers import OpenKfetConsumer
|
||||
base, team = self._export()
|
||||
OpenKfetConsumer.group_send('kfet.open.base', base)
|
||||
OpenKfetConsumer.group_send('kfet.open.team', team)
|
||||
|
||||
|
||||
kfet_open = OpenKfet()
|
8
kfet/open/routing.py
Normal file
8
kfet/open/routing.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from channels.routing import route_class
|
||||
|
||||
from . import consumers
|
||||
|
||||
|
||||
routing = [
|
||||
route_class(consumers.OpenKfetConsumer)
|
||||
]
|
50
kfet/open/static/kfetopen/kfet-open.css
Normal file
50
kfet/open/static/kfetopen/kfet-open.css
Normal file
|
@ -0,0 +1,50 @@
|
|||
.kfetopen-st-opened .bullet { background: #73C252; }
|
||||
.kfetopen-st-closed .bullet { background: #B42B26; }
|
||||
.kfetopen-st-unknown .bullet { background: #D4BE4C; }
|
||||
.kfetopen-st-fake_closed .bullet {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
#73C252, #73C252 5px, #B42B26 5px, #B42B26 10px
|
||||
);
|
||||
}
|
||||
|
||||
.kfetopen {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.kfetopen .base {
|
||||
height: 50px;
|
||||
padding: 15px;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kfetopen .details {
|
||||
margin: 0;
|
||||
padding: 10px !important;
|
||||
min-width: 200px;
|
||||
font-family: "Roboto Slab";
|
||||
font-size: 16px;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.kfetopen .bullet {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.kfetopen .warning {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.kfetopen .status-text {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.kfetopen .force-close-btn {
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
}
|
113
kfet/open/static/kfetopen/kfet-open.js
Normal file
113
kfet/open/static/kfetopen/kfet-open.js
Normal file
|
@ -0,0 +1,113 @@
|
|||
var OpenWS = new KfetWebsocket({
|
||||
relative_url: "open/"
|
||||
});
|
||||
|
||||
var OpenKfet = function(force_close_url, admin) {
|
||||
this.force_close_url = force_close_url;
|
||||
this.admin = admin;
|
||||
|
||||
this.status = this.UNKNOWN;
|
||||
this.dom = {
|
||||
status_text: $('.kfetopen .status-text'),
|
||||
force_close_btn: $('.kfetopen .force-close-btn'),
|
||||
warning: $('.kfetopen .warning')
|
||||
},
|
||||
|
||||
this.dom.force_close_btn.click( () => this.toggle_force_close() );
|
||||
setInterval( () => this.refresh(), this.refresh_interval * 1000);
|
||||
OpenWS.add_handler( data => this.refresh(data) );
|
||||
|
||||
};
|
||||
|
||||
OpenKfet.prototype = {
|
||||
// Status is unknown after . minutes without update.
|
||||
time_unknown: 15,
|
||||
// Maximum interval (seconds) between two UI refresh.
|
||||
refresh_interval: 20,
|
||||
|
||||
// Prefix for classes describing place status.
|
||||
class_prefix: 'kfetopen-st-',
|
||||
// Set status-classes on this dom element.
|
||||
target: 'body',
|
||||
|
||||
// Status
|
||||
OPENED: "opened",
|
||||
CLOSED: "closed",
|
||||
UNKNOWN: "unknown",
|
||||
|
||||
// Admin status
|
||||
FAKE_CLOSED: "fake_closed",
|
||||
|
||||
// Display values
|
||||
status_text: {
|
||||
opened: "ouverte",
|
||||
closed: "fermée",
|
||||
unknown: "_____"
|
||||
},
|
||||
force_text: {
|
||||
activate: "Fermer manuellement",
|
||||
deactivate: "Réouvrir la K-Fêt"
|
||||
},
|
||||
|
||||
get is_recent() {
|
||||
return this.last_update && moment().diff(this.last_update, 'minute') <= this.time_unknown;
|
||||
},
|
||||
|
||||
refresh: function(data) {
|
||||
if (data) {
|
||||
$.extend(this, data);
|
||||
this.last_update = moment();
|
||||
}
|
||||
if (!this.is_recent)
|
||||
this.status = this.UNKNOWN;
|
||||
this.refresh_dom();
|
||||
},
|
||||
|
||||
refresh_dom: function() {
|
||||
let status = this.status;
|
||||
this.clear_class();
|
||||
|
||||
this.add_class(status);
|
||||
this.dom.status_text.html(this.status_text[status]);
|
||||
|
||||
// admin specific
|
||||
if (this.admin) {
|
||||
this.add_class(this.admin_status);
|
||||
if (this.force_close) {
|
||||
this.dom.warning.addClass('in');
|
||||
this.dom.force_close_btn.html(this.force_text['deactivate']);
|
||||
} else {
|
||||
this.dom.warning.removeClass('in');
|
||||
this.dom.force_close_btn.html(this.force_text['activate']);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
toggle_force_close: function(password) {
|
||||
$.post({
|
||||
url: this.force_close_url,
|
||||
data: {force_close: !this.force_close},
|
||||
beforeSend: function ($xhr) {
|
||||
$xhr.setRequestHeader("X-CSRFToken", csrftoken);
|
||||
if (password !== undefined)
|
||||
$xhr.setRequestHeader("KFetPassword", password);
|
||||
}
|
||||
})
|
||||
.fail(function($xhr) {
|
||||
switch ($xhr.status) {
|
||||
case 403:
|
||||
requestAuth({'errors': {}}, this.toggle_force_close);
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
clear_class: function() {
|
||||
let re = new RegExp('(^|\\s)' + this.class_prefix + '\\S+', 'g');
|
||||
$(this.target).attr('class', (i, c) => c ? c.replace(re, '') : '');
|
||||
},
|
||||
|
||||
add_class: function(status) {
|
||||
$(this.target).addClass(this.class_prefix + status);
|
||||
}
|
||||
};
|
13
kfet/open/templates/kfetopen/init.html
Normal file
13
kfet/open/templates/kfetopen/init.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
{% load static %}
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="{% static "kfetopen/kfet-open.css" %}">
|
||||
<script type="text/javascript" src="{% static "kfetopen/kfet-open.js" %}"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
$( function() {
|
||||
kfet_open = new OpenKfet(
|
||||
"{% url "kfet.open.edit_force_close" %}",
|
||||
{{ perms.kfet.is_team|yesno:"true,false" }}
|
||||
);
|
||||
});
|
||||
</script>
|
322
kfet/open/tests.py
Normal file
322
kfet/open/tests.py
Normal file
|
@ -0,0 +1,322 @@
|
|||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser, Permission, User
|
||||
from django.test import Client
|
||||
from django.utils import timezone
|
||||
|
||||
from channels.channel import Group
|
||||
from channels.test import ChannelTestCase, WSClient
|
||||
|
||||
from . import kfet_open, OpenKfet
|
||||
from .consumers import OpenKfetConsumer
|
||||
|
||||
|
||||
class OpenKfetTest(ChannelTestCase):
|
||||
"""OpenKfet object unit-tests suite."""
|
||||
|
||||
def setUp(self):
|
||||
self.kfet_open = OpenKfet()
|
||||
|
||||
def tearDown(self):
|
||||
self.kfet_open.clear_cache()
|
||||
|
||||
def test_defaults(self):
|
||||
"""Default values."""
|
||||
self.assertFalse(self.kfet_open.raw_open)
|
||||
self.assertIsNone(self.kfet_open.last_update)
|
||||
self.assertFalse(self.kfet_open.force_close)
|
||||
self.assertFalse(self.kfet_open.is_open)
|
||||
|
||||
def test_raw_open(self):
|
||||
"""Get and set raw_open; last_update is renewed."""
|
||||
for raw_open in [True, False]:
|
||||
prev_update = self.kfet_open.last_update
|
||||
self.kfet_open.raw_open = raw_open
|
||||
self.assertEqual(raw_open, self.kfet_open.raw_open)
|
||||
self.assertNotEqual(prev_update, self.kfet_open.last_update)
|
||||
|
||||
def test_force_close(self):
|
||||
"""Get and set force_close."""
|
||||
for force_close in [True, False]:
|
||||
self.kfet_open.force_close = force_close
|
||||
self.assertEqual(force_close, self.kfet_open.force_close)
|
||||
|
||||
def test_is_open(self):
|
||||
"""If force_close is disabled, is_open is raw_open."""
|
||||
self.kfet_open.force_close = False
|
||||
for raw_open in [True, False]:
|
||||
self.kfet_open.raw_open = raw_open
|
||||
self.assertEqual(raw_open, self.kfet_open.is_open)
|
||||
|
||||
def test_is_open_force_close(self):
|
||||
"""If force_close is enabled, is_open is False."""
|
||||
self.kfet_open.force_close = True
|
||||
for raw_open in [True, False]:
|
||||
self.kfet_open.raw_open = raw_open
|
||||
self.assertFalse(self.kfet_open.is_open)
|
||||
|
||||
def test_status(self):
|
||||
# (raw_open, force_close, expected status, expected admin)
|
||||
cases = [
|
||||
(False, False, OpenKfet.CLOSED, OpenKfet.CLOSED),
|
||||
(False, True, OpenKfet.CLOSED, OpenKfet.CLOSED),
|
||||
(True, False, OpenKfet.OPENED, OpenKfet.OPENED),
|
||||
(True, True, OpenKfet.CLOSED, OpenKfet.FAKE_CLOSED),
|
||||
]
|
||||
for raw_open, force_close, exp_stat, exp_adm_stat in cases:
|
||||
self.kfet_open.raw_open = raw_open
|
||||
self.kfet_open.force_close = force_close
|
||||
self.assertEqual(exp_stat, self.kfet_open.status())
|
||||
self.assertEqual(exp_adm_stat, self.kfet_open.admin_status())
|
||||
|
||||
def test_status_unknown(self):
|
||||
self.kfet_open.raw_open = True
|
||||
self.kfet_open._last_update = timezone.now() - timedelta(days=30)
|
||||
self.assertEqual(OpenKfet.UNKNOWN, self.kfet_open.status())
|
||||
|
||||
def test_export_user(self):
|
||||
"""Export is limited for an anonymous user."""
|
||||
export = self.kfet_open.export(AnonymousUser())
|
||||
self.assertSetEqual(
|
||||
set(['status']),
|
||||
set(export),
|
||||
)
|
||||
|
||||
def test_export_team(self):
|
||||
"""Export all values for a team member."""
|
||||
user = User.objects.create_user('team', '', 'team')
|
||||
user.user_permissions.add(Permission.objects.get(codename='is_team'))
|
||||
export = self.kfet_open.export(user)
|
||||
self.assertSetEqual(
|
||||
set(['status', 'admin_status', 'force_close']),
|
||||
set(export),
|
||||
)
|
||||
|
||||
def test_send_ws(self):
|
||||
Group('kfet.open.base').add('test.open.base')
|
||||
Group('kfet.open.team').add('test.open.team')
|
||||
|
||||
self.kfet_open.send_ws()
|
||||
|
||||
recv_base = self.get_next_message('test.open.base', require=True)
|
||||
base = json.loads(recv_base['text'])
|
||||
self.assertSetEqual(
|
||||
set(['status']),
|
||||
set(base),
|
||||
)
|
||||
|
||||
recv_admin = self.get_next_message('test.open.team', require=True)
|
||||
admin = json.loads(recv_admin['text'])
|
||||
self.assertSetEqual(
|
||||
set(['status', 'admin_status', 'force_close']),
|
||||
set(admin),
|
||||
)
|
||||
|
||||
|
||||
class OpenKfetViewsTest(ChannelTestCase):
|
||||
"""OpenKfet views unit-tests suite."""
|
||||
|
||||
def setUp(self):
|
||||
# get some permissions
|
||||
perms = {
|
||||
'kfet.is_team': Permission.objects.get(codename='is_team'),
|
||||
'kfet.can_force_close': Permission.objects.get(codename='can_force_close'),
|
||||
}
|
||||
|
||||
# authenticated user and its client
|
||||
self.u = User.objects.create_user('user', '', 'user')
|
||||
self.c = Client()
|
||||
self.c.login(username='user', password='user')
|
||||
|
||||
# team user and its clients
|
||||
self.t = User.objects.create_user('team', '', 'team')
|
||||
self.t.user_permissions.add(perms['kfet.is_team'])
|
||||
self.c_t = Client()
|
||||
self.c_t.login(username='team', password='team')
|
||||
|
||||
# admin user and its client
|
||||
self.a = User.objects.create_user('admin', '', 'admin')
|
||||
self.a.user_permissions.add(
|
||||
perms['kfet.is_team'], perms['kfet.can_force_close'],
|
||||
)
|
||||
self.c_a = Client()
|
||||
self.c_a.login(username='admin', password='admin')
|
||||
|
||||
def tearDown(self):
|
||||
kfet_open.clear_cache()
|
||||
|
||||
def test_door(self):
|
||||
"""Edit raw_status."""
|
||||
for sent, expected in [(1, True), (0, False)]:
|
||||
resp = Client().post('/k-fet/open/raw_open', {
|
||||
'raw_open': sent,
|
||||
'token': 'plop',
|
||||
})
|
||||
self.assertEqual(200, resp.status_code)
|
||||
self.assertEqual(expected, kfet_open.raw_open)
|
||||
|
||||
def test_force_close(self):
|
||||
"""Edit force_close."""
|
||||
for sent, expected in [(1, True), (0, False)]:
|
||||
resp = self.c_a.post('/k-fet/open/force_close', {'force_close': sent})
|
||||
self.assertEqual(200, resp.status_code)
|
||||
self.assertEqual(expected, kfet_open.force_close)
|
||||
|
||||
def test_force_close_forbidden(self):
|
||||
"""Can't edit force_close without kfet.can_force_close permission."""
|
||||
clients = [Client(), self.c, self.c_t]
|
||||
for client in clients:
|
||||
resp = client.post('/k-fet/open/force_close', {'force_close': 0})
|
||||
self.assertEqual(403, resp.status_code)
|
||||
|
||||
|
||||
class OpenKfetConsumerTest(ChannelTestCase):
|
||||
"""OpenKfet consumer unit-tests suite."""
|
||||
|
||||
def test_standard_user(self):
|
||||
"""Lambda user is added to kfet.open.base group."""
|
||||
# setup anonymous client
|
||||
c = WSClient()
|
||||
|
||||
# connect
|
||||
c.send_and_consume('websocket.connect', path='/ws/k-fet/open',
|
||||
fail_on_none=True)
|
||||
|
||||
# initialization data is replied on connection
|
||||
self.assertIsNotNone(c.receive())
|
||||
|
||||
# client belongs to the 'kfet.open' group...
|
||||
OpenKfetConsumer.group_send('kfet.open.base', {'test': 'plop'})
|
||||
self.assertEqual(c.receive(), {'test': 'plop'})
|
||||
|
||||
# ...but not to the 'kfet.open.admin' one
|
||||
OpenKfetConsumer.group_send('kfet.open.team', {'test': 'plop'})
|
||||
self.assertIsNone(c.receive())
|
||||
|
||||
def test_team_user(self):
|
||||
"""Team user is added to kfet.open.team group."""
|
||||
# setup team user and its client
|
||||
t = User.objects.create_user('team', '', 'team')
|
||||
t.user_permissions.add(
|
||||
Permission.objects.get(codename='is_team')
|
||||
)
|
||||
c = WSClient()
|
||||
c.force_login(t)
|
||||
|
||||
# connect
|
||||
c.send_and_consume('websocket.connect', path='/ws/k-fet/open',
|
||||
fail_on_none=True)
|
||||
|
||||
# initialization data is replied on connection
|
||||
self.assertIsNotNone(c.receive())
|
||||
|
||||
# client belongs to the 'kfet.open.admin' group...
|
||||
OpenKfetConsumer.group_send('kfet.open.team', {'test': 'plop'})
|
||||
self.assertEqual(c.receive(), {'test': 'plop'})
|
||||
|
||||
# ... but not to the 'kfet.open' one
|
||||
OpenKfetConsumer.group_send('kfet.open.base', {'test': 'plop'})
|
||||
self.assertIsNone(c.receive())
|
||||
|
||||
|
||||
class OpenKfetScenarioTest(ChannelTestCase):
|
||||
"""OpenKfet functionnal tests suite."""
|
||||
|
||||
def setUp(self):
|
||||
# anonymous client (for views)
|
||||
self.c = Client()
|
||||
# anonymous client (for websockets)
|
||||
self.c_ws = WSClient()
|
||||
|
||||
# root user
|
||||
self.r = User.objects.create_superuser('root', '', 'root')
|
||||
# its client (for views)
|
||||
self.r_c = Client()
|
||||
self.r_c.login(username='root', password='root')
|
||||
# its client (for websockets)
|
||||
self.r_c_ws = WSClient()
|
||||
self.r_c_ws.force_login(self.r)
|
||||
|
||||
def tearDown(self):
|
||||
kfet_open.clear_cache()
|
||||
|
||||
def ws_connect(self, ws_client):
|
||||
ws_client.send_and_consume(
|
||||
'websocket.connect', path='/ws/k-fet/open',
|
||||
fail_on_none=True,
|
||||
)
|
||||
return ws_client.receive(json=True)
|
||||
|
||||
def test_scenario_0(self):
|
||||
"""Clients connect."""
|
||||
# test for anonymous user
|
||||
msg = self.ws_connect(self.c_ws)
|
||||
self.assertSetEqual(
|
||||
set(['status']),
|
||||
set(msg),
|
||||
)
|
||||
|
||||
# test for root user
|
||||
msg = self.ws_connect(self.r_c_ws)
|
||||
self.assertSetEqual(
|
||||
set(['status', 'admin_status', 'force_close']),
|
||||
set(msg),
|
||||
)
|
||||
|
||||
def test_scenario_1(self):
|
||||
"""Clients connect, door opens, enable force close."""
|
||||
self.ws_connect(self.c_ws)
|
||||
self.ws_connect(self.r_c_ws)
|
||||
|
||||
# door sent "I'm open!"
|
||||
self.c.post('/k-fet/open/raw_open', {
|
||||
'raw_open': True,
|
||||
'token': 'plop',
|
||||
})
|
||||
|
||||
# anonymous user agree
|
||||
msg = self.c_ws.receive(json=True)
|
||||
self.assertEqual(OpenKfet.OPENED, msg['status'])
|
||||
|
||||
# root user too
|
||||
msg = self.r_c_ws.receive(json=True)
|
||||
self.assertEqual(OpenKfet.OPENED, msg['status'])
|
||||
self.assertEqual(OpenKfet.OPENED, msg['admin_status'])
|
||||
|
||||
# admin says "no it's closed"
|
||||
self.r_c.post('/k-fet/open/force_close', {'force_close': True})
|
||||
|
||||
# so anonymous user see it's closed
|
||||
msg = self.c_ws.receive(json=True)
|
||||
self.assertEqual(OpenKfet.CLOSED, msg['status'])
|
||||
|
||||
# root user too
|
||||
msg = self.r_c_ws.receive(json=True)
|
||||
self.assertEqual(OpenKfet.CLOSED, msg['status'])
|
||||
# but root knows things
|
||||
self.assertEqual(OpenKfet.FAKE_CLOSED, msg['admin_status'])
|
||||
self.assertTrue(msg['force_close'])
|
||||
|
||||
def test_scenario_2(self):
|
||||
"""Starting falsely closed, clients connect, disable force close."""
|
||||
kfet_open.raw_open = True
|
||||
kfet_open.force_close = True
|
||||
|
||||
msg = self.ws_connect(self.c_ws)
|
||||
self.assertEqual(OpenKfet.CLOSED, msg['status'])
|
||||
|
||||
msg = self.ws_connect(self.r_c_ws)
|
||||
self.assertEqual(OpenKfet.CLOSED, msg['status'])
|
||||
self.assertEqual(OpenKfet.FAKE_CLOSED, msg['admin_status'])
|
||||
self.assertTrue(msg['force_close'])
|
||||
|
||||
self.r_c.post('/k-fet/open/force_close', {'force_close': False})
|
||||
|
||||
msg = self.c_ws.receive(json=True)
|
||||
self.assertEqual(OpenKfet.OPENED, msg['status'])
|
||||
|
||||
msg = self.r_c_ws.receive(json=True)
|
||||
self.assertEqual(OpenKfet.OPENED, msg['status'])
|
||||
self.assertEqual(OpenKfet.OPENED, msg['admin_status'])
|
||||
self.assertFalse(msg['force_close'])
|
11
kfet/open/urls.py
Normal file
11
kfet/open/urls.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from django.conf.urls import url
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^raw_open$', views.raw_open,
|
||||
name='kfet.open.edit_raw_open'),
|
||||
url(r'^force_close$', views.force_close,
|
||||
name='kfet.open.edit_force_close'),
|
||||
]
|
32
kfet/open/views.py
Normal file
32
kfet/open/views.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.contrib.auth.decorators import permission_required
|
||||
from django.http import HttpResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from .open import kfet_open
|
||||
|
||||
|
||||
TRUE_STR = ['1', 'True', 'true']
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def raw_open(request):
|
||||
token = request.POST.get('token')
|
||||
if token != settings.KFETOPEN_TOKEN:
|
||||
raise PermissionDenied
|
||||
raw_open = request.POST.get('raw_open') in TRUE_STR
|
||||
kfet_open.raw_open = raw_open
|
||||
kfet_open.send_ws()
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@permission_required('kfet.can_force_close', raise_exception=True)
|
||||
@require_POST
|
||||
def force_close(request):
|
||||
force_close = request.POST.get('force_close') in TRUE_STR
|
||||
kfet_open.force_close = force_close
|
||||
kfet_open.send_ws()
|
||||
return HttpResponse()
|
|
@ -1,12 +1,11 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import (absolute_import, division,
|
||||
print_function, unicode_literals)
|
||||
from builtins import *
|
||||
from channels.routing import include, route_class
|
||||
|
||||
from channels.routing import route, route_class
|
||||
from kfet import consumers
|
||||
from . import consumers
|
||||
|
||||
channel_routing = [
|
||||
route_class(consumers.KPsul, path=r"^/ws/k-fet/k-psul/$"),
|
||||
|
||||
routing = [
|
||||
route_class(consumers.KPsul, path=r'^/k-psul/$'),
|
||||
include('kfet.open.routing.routing', path=r'^/open'),
|
||||
]
|
||||
|
|
|
@ -11,6 +11,7 @@ from django.utils.safestring import mark_safe
|
|||
def messages_on_login(sender, request, user, **kwargs):
|
||||
if (not user.username == 'kfet_genericteam' and
|
||||
user.has_perm('kfet.is_team') and
|
||||
hasattr(request, 'GET') and
|
||||
'k-fet' in request.GET.get('next', '')):
|
||||
messages.info(request, mark_safe(
|
||||
'<a href="{}" class="genericteam" target="_blank">'
|
||||
|
|
|
@ -105,3 +105,14 @@ ul {
|
|||
.toggle:hover .base {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Spinning animation -------------- */
|
||||
|
||||
.glyphicon.spinning {
|
||||
animation: spin 1s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: scale(1) rotate(0deg); }
|
||||
to { transform: scale(1) rotate(360deg); }
|
||||
}
|
||||
|
|
88
kfet/static/kfet/css/nav.css
Normal file
88
kfet/static/kfet/css/nav.css
Normal file
|
@ -0,0 +1,88 @@
|
|||
.navbar {
|
||||
background: #000;
|
||||
color: #DDD;
|
||||
font-family: Oswald;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.navbar .navbar-brand {
|
||||
padding: 3px 25px;
|
||||
}
|
||||
|
||||
.navbar .navbar-brand img {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.navbar .navbar-toggle .icon-bar {
|
||||
background-color: #FFF;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
margin: 0 -15px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.navbar-nav {
|
||||
margin: 0px;
|
||||
}
|
||||
.navbar-right {
|
||||
margin-right: -15px;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-nav a {
|
||||
transition: background-color, box-shadow, color;
|
||||
transition-duration: 0.15s;
|
||||
}
|
||||
|
||||
.navbar-nav > li > a {
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
.navbar-nav > li:hover > a,
|
||||
.navbar-nav > li > a:focus,
|
||||
.nav .open > a:hover,
|
||||
.nav .open > a:focus {
|
||||
background-color: #C8102E;
|
||||
color: #FFF;
|
||||
box-shadow: inset 0 5px 5px -5px #000;
|
||||
}
|
||||
|
||||
.navbar .dropdown .dropdown-menu {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background-color: #FFF;
|
||||
}
|
||||
|
||||
.navbar .dropdown .dropdown-menu > li > a {
|
||||
padding: 8px 20px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.navbar .dropdown .dropdown-menu > li > a:hover,
|
||||
.navbar .dropdown .dropdown-meny > li > a:focus {
|
||||
color: #c8102e;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.navbar .dropdown .dropdown-menu .divider {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.navbar .dropdown .dropdown-menu {
|
||||
display: block;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.navbar .dropdown:hover .dropdown-menu {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
|
@ -24,19 +24,24 @@ $(document).ready(function() {
|
|||
class KfetWebsocket {
|
||||
|
||||
static get defaults() {
|
||||
return {"relative_url": "", "default_msg": {}, "handlers": []};
|
||||
return {
|
||||
relative_url: '',
|
||||
default_msg: {},
|
||||
handlers: [],
|
||||
base_path: '/ws/k-fet/'
|
||||
};
|
||||
}
|
||||
|
||||
constructor(data) {
|
||||
$.extend(this, this.constructor.defaults, data);
|
||||
if (window.location.pathname.startsWith('/gestion/'))
|
||||
this.base_path = '/gestion' + this.base_path;
|
||||
}
|
||||
|
||||
get url() {
|
||||
var websocket_protocol = window.location.protocol == 'https:' ? 'wss' : 'ws';
|
||||
var location_host = window.location.host;
|
||||
var location_url = window.location.pathname.startsWith('/gestion/') ? location_host + '/gestion' : location_host;
|
||||
|
||||
return websocket_protocol+"://" + location_url + this.relative_url ;
|
||||
var protocol = window.location.protocol == 'https:' ? 'wss' : 'ws';
|
||||
var host = window.location.host;
|
||||
return protocol + "://" + host + this.base_path + this.relative_url;
|
||||
}
|
||||
|
||||
add_handler(handler) {
|
||||
|
@ -60,7 +65,7 @@ class KfetWebsocket {
|
|||
}
|
||||
|
||||
var OperationWebSocket = new KfetWebsocket({
|
||||
'relative_url': '/ws/k-fet/k-psul/',
|
||||
'relative_url': 'k-psul/',
|
||||
'default_msg': {'opegroups':[],'opes':[],'checkouts':[],'articles':[]},
|
||||
});
|
||||
|
||||
|
|
|
@ -4,10 +4,6 @@
|
|||
{% load l10n %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script type="text/javascript" src="{% static 'kfet/js/js.cookie.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>
|
||||
<script type="text/javascript" src="{% static 'kfet/js/moment-timezone-with-data-2010-2020.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'kfet/js/history.js' %}"></script>
|
||||
{% if account.user == request.user %}
|
||||
<script type="text/javascript" src="{% static 'kfet/js/Chart.bundle.js' %}"></script>
|
||||
|
|
|
@ -14,15 +14,24 @@
|
|||
{# CSS #}
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
|
||||
<link href='https://fonts.googleapis.com/css?family=Roboto:400,700|Roboto+Mono:400,700|Roboto+Slab:400,700' rel='stylesheet' type='text/css'>
|
||||
<link rel="stylesheet" style="text/css" href="{% static 'kfet/css/jquery-ui.min.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'kfet/css/jquery-confirm.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'kfet/css/index.css' %}">
|
||||
|
||||
{# JS #}
|
||||
<script type="text/javascript" src="{% static "kfet/js/js.cookie.js" %}"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.1.0.min.js" integrity="sha256-cCueBR6CsyA4/9szpPfrX3s49M9vUU5BgtiJj06wt/s=" crossorigin="anonymous"></script>
|
||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
|
||||
<script 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/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>
|
||||
<script type="text/javascript" src="{% static 'kfet/js/moment-timezone-with-data-2010-2020.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'kfet/js/kfet.js' %}"></script>
|
||||
|
||||
{% include "kfetopen/init.html" %}
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
|
||||
{# Vieux IE pas comprendre HTML5 et Media Queries #}
|
||||
|
|
|
@ -9,6 +9,25 @@
|
|||
</a>
|
||||
</div>
|
||||
<ul class="nav navbar-nav">
|
||||
<li class="kfetopen dropdown">
|
||||
<ul class="base nav dropdown-toggle" data-toggle="dropdown">
|
||||
<li class="bullet"></li>
|
||||
{% if perms.kfet.is_team %}
|
||||
<li class="warning fade">
|
||||
<span class="glyphicon glyphicon-warning-sign"></span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<div class="details dropdown-menu">
|
||||
La K-Fêt est
|
||||
<b><span class="status-text">
|
||||
<span class="glyphicon glyphicon-refresh spinning"></span>
|
||||
</span></b>.
|
||||
{% if perms.kfet.is_team %}
|
||||
<button class="btn btn-primary force-close-btn"> </button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% for item in menu_items %}
|
||||
{% if item.text == "Accueil" %}
|
||||
<li class="{{ item.active_class }} hidden-xs hidden-sm">
|
||||
|
|
|
@ -2,10 +2,7 @@
|
|||
{% load l10n staticfiles widget_tweaks %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'kfet/css/jquery-ui.min.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'kfet/css/multiple-select.css' %}">
|
||||
<script type="text/javascript" src="{% static 'kfet/js/js.cookie.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'kfet/js/jquery-ui.min.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'kfet/js/multiple-select.js' %}"></script>
|
||||
{{ filter_form.media }}
|
||||
<script type="text/javascript" src="{% static 'kfet/js/history.js' %}"></script>
|
||||
|
|
59
kfet/templates/kfet/home.html
Normal file
59
kfet/templates/kfet/home.html
Normal file
|
@ -0,0 +1,59 @@
|
|||
{% extends "kfet/base_col_1.html" %}
|
||||
{% load staticfiles %}
|
||||
{% load kfet_tags %}
|
||||
|
||||
{% block title %}Accueil{% endblock %}
|
||||
{% block header %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'kfet/css/home.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block main-size %}col-sm-10 col-sm-offset-1{% endblock %}
|
||||
|
||||
{% block main-content %}
|
||||
|
||||
<div class="content-right-block">
|
||||
<h2>Carte</h2>
|
||||
<div class="column-row">
|
||||
<div class="column-sm-1 column-md-2 column-lg-3">
|
||||
<div class="unbreakable carte-inverted">
|
||||
{% if pressions %}
|
||||
<h3>Pressions du moment</h3>
|
||||
<ul class="carte">
|
||||
{% for article in pressions %}
|
||||
<li class="carte-line">
|
||||
<div class="filler"></div>
|
||||
<span class="carte-label">{{ article.name }}</span>
|
||||
<span class="carte-ukf">{{ article.price | ukf:False}} UKF</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div><!-- endblock unbreakable -->
|
||||
{% for article in articles %}
|
||||
{% ifchanged article.category %}
|
||||
{% if not forloop.first %}
|
||||
</ul>
|
||||
</div><!-- endblock unbreakable -->
|
||||
{% endif %}
|
||||
<div class="unbreakable">
|
||||
<h3>{{ article.category.name }}</h3>
|
||||
<ul class="carte">
|
||||
{% endifchanged %}
|
||||
<li class="carte-line">
|
||||
<div class="filler"></div>
|
||||
<span class="carte-label">{{ article.name }}</span>
|
||||
<span class="carte-ukf">{{ article.price | ukf:False}} UKF</span>
|
||||
</li>
|
||||
{% if forloop.last %}
|
||||
</ul>
|
||||
</div><!-- endblock unbreakable -->
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -1,10 +1,6 @@
|
|||
{% extends "kfet/base_col_1.html" %}
|
||||
{% load static widget_tweaks %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script type="text/javascript" src="{% static 'kfet/js/reconnecting-websocket.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'kfet/js/jquery-confirm.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}Nouvel inventaire{% endblock %}
|
||||
{% block header-title %}Création d'un inventaire{% endblock %}
|
||||
|
|
|
@ -2,15 +2,8 @@
|
|||
{% load staticfiles %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" style="text/css" href="{% static 'kfet/css/jquery-ui.min.css' %}">
|
||||
<link rel="stylesheet" style="text/css" href="{% static 'kfet/css/kpsul_grid.css' %}">
|
||||
<script src="{% static "autocomplete_light/autocomplete.js" %}" type="text/javascript"></script>
|
||||
<script type="text/javascript" src="{% static 'kfet/js/js.cookie.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'kfet/js/reconnecting-websocket.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'kfet/js/jquery-ui.min.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>
|
||||
<script type="text/javascript" src="{% static 'kfet/js/moment-timezone-with-data-2010-2020.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'kfet/js/history.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
@ -1,14 +1,6 @@
|
|||
{% extends 'kfet/base_col_2.html' %}
|
||||
{% load staticfiles %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" style="text/css" href="{% static 'kfet/css/jquery-ui.min.css' %}">
|
||||
<script type="text/javascript" src="{% static 'kfet/js/js.cookie.js' %}"></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/js/kfet.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}Transferts{% endblock %}
|
||||
{% block header-title %}Transferts{% endblock %}
|
||||
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'kfet/css/transfers_form.css' %}">
|
||||
<script type="text/javascript" src="{% static 'kfet/js/js.cookie.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}Nouveaux transferts{% endblock %}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from django.conf.urls import url
|
||||
from django.conf.urls import include, url
|
||||
from django.contrib.auth.decorators import permission_required
|
||||
|
||||
from kfet import autocomplete, views
|
||||
|
@ -197,6 +197,7 @@ urlpatterns = [
|
|||
(views.SettingsUpdate.as_view()),
|
||||
name='kfet.settings.update'),
|
||||
|
||||
|
||||
# -----
|
||||
# Transfers urls
|
||||
# -----
|
||||
|
@ -241,3 +242,8 @@ urlpatterns = [
|
|||
url(r'^orders/(?P<pk>\d+)/to_inventory$', views.order_to_inventory,
|
||||
name='kfet.order.to_inventory'),
|
||||
]
|
||||
|
||||
urlpatterns += [
|
||||
# K-Fêt Open urls
|
||||
url('^open/', include('kfet.open.urls')),
|
||||
]
|
||||
|
|
105
kfet/utils.py
105
kfet/utils.py
|
@ -1,4 +1,11 @@
|
|||
import math
|
||||
import json
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
from channels.channel import Group
|
||||
from channels.generic.websockets import JsonWebsocketConsumer
|
||||
|
||||
from .config import kfet_config
|
||||
|
||||
|
@ -8,3 +15,101 @@ def to_ukf(balance, is_cof=False):
|
|||
subvention = kfet_config.subvention_cof
|
||||
grant = (1 + subvention / 100) if is_cof else 1
|
||||
return math.floor(balance * 10 * grant)
|
||||
|
||||
# Storage
|
||||
|
||||
class CachedMixin:
|
||||
"""Object with cached properties.
|
||||
|
||||
Attributes:
|
||||
cached (dict): Keys are cached properties. Associated value is the
|
||||
returned default by getters in case the key is missing from cache.
|
||||
cache_prefix (str): Used to prefix keys in cache.
|
||||
|
||||
"""
|
||||
cached = {}
|
||||
cache_prefix = ''
|
||||
|
||||
def __init__(self, cache_prefix=None, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if cache_prefix is not None:
|
||||
self.cache_prefix = cache_prefix
|
||||
|
||||
def cachekey(self, attr):
|
||||
return '{}__{}'.format(self.cache_prefix, attr)
|
||||
|
||||
def __getattr__(self, attr):
|
||||
if attr in self.cached:
|
||||
return cache.get(self.cachekey(attr), self.cached.get(attr))
|
||||
elif hasattr(super(), '__getattr__'):
|
||||
return super().__getattr__(attr)
|
||||
else:
|
||||
raise AttributeError("can't get attribute")
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
if attr in self.cached:
|
||||
cache.set(self.cachekey(attr), value)
|
||||
elif hasattr(super(), '__setattr__'):
|
||||
super().__setattr__(attr, value)
|
||||
else:
|
||||
raise AttributeError("can't set attribute")
|
||||
|
||||
def clear_cache(self):
|
||||
cache.delete_many([
|
||||
self.cachekey(attr) for attr in self.cached.keys()
|
||||
])
|
||||
|
||||
|
||||
# Consumers
|
||||
|
||||
class DjangoJsonWebsocketConsumer(JsonWebsocketConsumer):
|
||||
"""Custom Json Websocket Consumer.
|
||||
|
||||
Encode to JSON with DjangoJSONEncoder.
|
||||
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def encode_json(cls, content):
|
||||
return json.dumps(content, cls=DjangoJSONEncoder)
|
||||
|
||||
|
||||
class PermConsumerMixin:
|
||||
"""Add support to check permissions on consumers.
|
||||
|
||||
Attributes:
|
||||
perms_connect (list): Required permissions to connect to this
|
||||
consumer.
|
||||
|
||||
message.user is appended as argument to each connection_groups method call.
|
||||
|
||||
"""
|
||||
http_user = True # Enable message.user
|
||||
perms_connect = []
|
||||
|
||||
def connect(self, message, **kwargs):
|
||||
"""Check permissions on connection."""
|
||||
if message.user.has_perms(self.perms_connect):
|
||||
super().connect(message, **kwargs)
|
||||
else:
|
||||
self.close()
|
||||
|
||||
def raw_connect(self, message, **kwargs):
|
||||
# Same as original raw_connect method of JsonWebsocketConsumer
|
||||
# We add user to connection_groups call.
|
||||
groups = self.connection_groups(user=message.user, **kwargs)
|
||||
for group in groups:
|
||||
Group(group, channel_layer=message.channel_layer).add(message.reply_channel)
|
||||
self.connect(message, **kwargs)
|
||||
|
||||
def raw_disconnect(self, message, **kwargs):
|
||||
# Same as original raw_connect method of JsonWebsocketConsumer
|
||||
# We add user to connection_groups call.
|
||||
groups = self.connection_groups(user=message.user, **kwargs)
|
||||
for group in groups:
|
||||
Group(group, channel_layer=message.channel_layer).discard(message.reply_channel)
|
||||
self.disconnect(message, **kwargs)
|
||||
|
||||
def connection_groups(self, user, **kwargs):
|
||||
"""`message.user` is available as `user` arg. Original behavior."""
|
||||
super().connection_groups(user, user, **kwargs)
|
||||
|
|
|
@ -6,6 +6,7 @@ django-cas-ng==3.5.7
|
|||
django-djconfig==0.5.3
|
||||
django-grappelli==2.8.1
|
||||
django-recaptcha==1.0.5
|
||||
django-redis-cache==1.7.1
|
||||
mysqlclient==1.3.7
|
||||
Pillow==3.3.0
|
||||
six==1.10.0
|
||||
|
@ -13,14 +14,14 @@ unicodecsv==0.14.1
|
|||
icalendar==3.10
|
||||
django-bootstrap-form==3.2.1
|
||||
asgiref==1.1.1
|
||||
daphne==1.2.0
|
||||
daphne==1.3.0
|
||||
asgi-redis==1.3.0
|
||||
statistics==1.0.3.5
|
||||
future==0.15.2
|
||||
django-widget-tweaks==1.4.1
|
||||
git+https://git.eleves.ens.fr/cof-geek/django_custommail.git#egg=django_custommail
|
||||
ldap3
|
||||
channels==1.1.3
|
||||
channels==1.1.5
|
||||
python-dateutil
|
||||
wagtail==1.10.*
|
||||
wagtailmenus==2.2.*
|
||||
|
|
Loading…
Reference in a new issue