From b8110c11a40454bd26479ebf2065159d1286ce35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Wed, 21 Jun 2017 07:08:28 +0200 Subject: [PATCH] kfet.open kfet.open app - Base data (raw_open, last_update...) is stored and shared through cache system. - 2 websockets groups: one for team users, one for other users. - UI is initialized and kept up-to-date with WS. - raw_open and force_close can be updated with standard HTTP requests. At this time, there isn't any restriction on raw_open view. Common sense tell us to change this behavior. Misc - Clean channels routing. - 'PermConsumerMixin': user who sent the message is available as argument in connection_groups method, which returns groups to which the user should be appended on websocket connection (and discarded on disconnection). - New kfet.utils module: should be used for mixins, whatever is useful and not concerns the kfet app. - Clean JS dependencies. --- cof/routing.py | 7 +- cof/settings/common.py | 4 +- kfet/consumers.py | 49 +--- kfet/context_processors.py | 12 - kfet/decorators.py | 3 - kfet/forms.py | 15 +- kfet/models.py | 2 +- kfet/open/__init__.py | 1 + kfet/open/consumers.py | 25 ++ kfet/open/open.py | 84 ++++++ kfet/open/routing.py | 8 + kfet/open/static/kfetopen/kfet-open.css | 48 ++++ kfet/open/static/kfetopen/kfet-open.js | 126 +++++++++ kfet/open/templates/kfetopen/init.html | 13 + kfet/open/tests.py | 303 ++++++++++++++++++++++ kfet/open/urls.py | 11 + kfet/open/views.py | 27 ++ kfet/routing.py | 14 +- kfet/signals.py | 1 + kfet/static/kfet/css/home.css | 9 - kfet/static/kfet/css/index.css | 9 + kfet/static/kfet/css/nav.css | 34 +-- kfet/static/kfet/js/kfet.js | 21 +- kfet/static/kfet/js/kfet_open.js | 126 --------- kfet/templates/kfet/account_read.html | 4 - kfet/templates/kfet/base.html | 27 +- kfet/templates/kfet/base_nav.html | 30 ++- kfet/templates/kfet/history.html | 7 - kfet/templates/kfet/home.html | 23 +- kfet/templates/kfet/inventory_create.html | 8 +- kfet/templates/kfet/kpsul.html | 4 - kfet/templates/kfet/transfers.html | 8 - kfet/templates/kfet/transfers_create.html | 1 - kfet/urls.py | 18 +- kfet/utils.py | 106 ++++++++ kfet/views.py | 64 +---- requirements.txt | 4 +- 37 files changed, 852 insertions(+), 404 deletions(-) create mode 100644 kfet/open/__init__.py create mode 100644 kfet/open/consumers.py create mode 100644 kfet/open/open.py create mode 100644 kfet/open/routing.py create mode 100644 kfet/open/static/kfetopen/kfet-open.css create mode 100644 kfet/open/static/kfetopen/kfet-open.js create mode 100644 kfet/open/templates/kfetopen/init.html create mode 100644 kfet/open/tests.py create mode 100644 kfet/open/urls.py create mode 100644 kfet/open/views.py delete mode 100644 kfet/static/kfet/js/kfet_open.js create mode 100644 kfet/utils.py diff --git a/cof/routing.py b/cof/routing.py index 7949ee3a..c604adf4 100644 --- a/cof/routing.py +++ b/cof/routing.py @@ -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'), +] diff --git a/cof/settings/common.py b/cof/settings/common.py index 3857cab5..2384cf87 100644 --- a/cof/settings/common.py +++ b/cof/settings/common.py @@ -53,6 +53,7 @@ INSTALLED_APPS = [ 'django_cas_ng', 'bootstrapform', 'kfet', + 'kfet.open', 'channels', 'widget_tweaks', 'custommail', @@ -91,7 +92,6 @@ TEMPLATES = [ 'djconfig.context_processors.config', 'gestioncof.shared.context_processor', 'kfet.context_processors.auth', - 'kfet.context_processors.kfet_open', 'kfet.context_processors.config', ], }, @@ -180,7 +180,7 @@ CHANNEL_LAYERS = { port=REDIS_PORT, db=REDIS_DB) )], }, - "ROUTING": "cof.routing.channel_routing", + "ROUTING": "cof.routing.routing", } } diff --git a/kfet/consumers.py b/kfet/consumers.py index 08b3de25..0f447d2d 100644 --- a/kfet/consumers.py +++ b/kfet/consumers.py @@ -1,53 +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() - - -class KfetOpen(JsonWebsocketConsumer): - def connection_groups(self, **kwargs): - return ['kfet.is_open'] - - def connect(self, message, **kwargs): - pass - - def receive(self, content, **kwargs): - pass - - def disconnect(self, message, **kwargs): - pass +from .utils import DjangoJsonWebsocketConsumer, PermConsumerMixin class KPsul(PermConsumerMixin, DjangoJsonWebsocketConsumer): diff --git a/kfet/context_processors.py b/kfet/context_processors.py index e4d4bcb5..4c7b4fe4 100644 --- a/kfet/context_processors.py +++ b/kfet/context_processors.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- from django.contrib.auth.context_processors import PermWrapper -from .views import KFET_OPEN, KFET_FORCE_CLOSE - from kfet.config import kfet_config @@ -16,15 +14,5 @@ def auth(request): return {} -def kfet_open(request): - (kfet_open, kfet_open_date) = KFET_OPEN() - kfet_force_close = KFET_FORCE_CLOSE() - return { - 'kfet_open': kfet_open, - 'kfet_open_date': kfet_open_date.isoformat(), - 'kfet_force_close': kfet_force_close, - } - - def config(request): return {'kfet_config': kfet_config} diff --git a/kfet/decorators.py b/kfet/decorators.py index 3dc76767..0c8a1a76 100644 --- a/kfet/decorators.py +++ b/kfet/decorators.py @@ -6,7 +6,4 @@ from django.contrib.auth.decorators import user_passes_test def kfet_is_team(user): return user.has_perm('kfet.is_team') -def can_force_close(user): - return user.has_perm('force_close_kfet') - teamkfet_required = user_passes_test(kfet_is_team) diff --git a/kfet/forms.py b/kfet/forms.py index 95e97d56..984932f2 100644 --- a/kfet/forms.py +++ b/kfet/forms.py @@ -25,18 +25,17 @@ from gestioncof.models import CofProfile # ----- class DateTimeWidget(forms.DateTimeInput): - def __init__(self, attrs = None): + def __init__(self, attrs=None): super(DateTimeWidget, self).__init__(attrs) self.attrs['format'] = '%Y-%m-%d %H:%M' + class Media: css = { - 'all': ('kfet/css/bootstrap-datetimepicker.min.css',) - } - js = ( - 'kfet/js/moment.js', - 'kfet/js/moment-fr.js', - 'kfet/js/bootstrap-datetimepicker.min.js', - ) + 'all': ('kfet/css/bootstrap-datetimepicker.min.css',) + } + js = ('kfet/js/bootstrap-datetimepicker.min.js',) + + # ----- # Account forms # ----- diff --git a/kfet/models.py b/kfet/models.py index 83a0f6b5..a8bf7608 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -73,7 +73,7 @@ class Account(models.Model): "Modifier le mot de passe d'une personne de l'équipe"), ('special_add_account', "Créer un compte avec une balance initiale"), - ('force_close_kfet', "Fermer manuelement la K-Fêt"), + ('can_force_close', "Fermer manuelement la K-Fêt"), ) def __str__(self): diff --git a/kfet/open/__init__.py b/kfet/open/__init__.py new file mode 100644 index 00000000..fb88af65 --- /dev/null +++ b/kfet/open/__init__.py @@ -0,0 +1 @@ +from .open import OpenKfet, kfet_open # noqa diff --git a/kfet/open/consumers.py b/kfet/open/consumers.py new file mode 100644 index 00000000..b28a4664 --- /dev/null +++ b/kfet/open/consumers.py @@ -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)) diff --git a/kfet/open/open.py b/kfet/open/open.py new file mode 100644 index 00000000..54eda9e4 --- /dev/null +++ b/kfet/open/open.py @@ -0,0 +1,84 @@ +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 multiple places management. + Current state persists through cache. + + """ + 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 _export(self): + """Export internal state. + + Used by WS initialization and updates. + + Returns: + (tuple): (base, team) + - team for team users. + - base for others. + + """ + base = { + 'is_open': self.is_open, + 'last_update': self.last_update, + } + restrict = { + 'raw_open': self.raw_open, + 'force_close': self.force_close, + } + return base, {**base, **restrict} + + def export(self, user=None): + """Export internal state. + + Returns: + (dict): Internal state. Only variables visible for the user are + exported, according to its permissions. If no user is given, it + returns all available values. + + """ + base, team = self._export() + return team if user is None or 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() diff --git a/kfet/open/routing.py b/kfet/open/routing.py new file mode 100644 index 00000000..681bfab2 --- /dev/null +++ b/kfet/open/routing.py @@ -0,0 +1,8 @@ +from channels.routing import route_class + +from . import consumers + + +routing = [ + route_class(consumers.OpenKfetConsumer) +] diff --git a/kfet/open/static/kfetopen/kfet-open.css b/kfet/open/static/kfetopen/kfet-open.css new file mode 100644 index 00000000..6698c50b --- /dev/null +++ b/kfet/open/static/kfetopen/kfet-open.css @@ -0,0 +1,48 @@ +.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; + 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; +} diff --git a/kfet/open/static/kfetopen/kfet-open.js b/kfet/open/static/kfetopen/kfet-open.js new file mode 100644 index 00000000..6edfeb7e --- /dev/null +++ b/kfet/open/static/kfetopen/kfet-open.js @@ -0,0 +1,126 @@ +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: 2, + // Maximum interval (seconds) between two UI refresh. + refresh_interval: 10, + + // 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.refresh_status(); + this.refresh_dom(); + }, + + refresh_status: function() { + // find new status + let status = this.UNKNOWN; + if (this.is_recent) + status = this.is_open ? this.OPENED : this.CLOSED; + this.status = status; + + // admin specific + if (this.admin) { + let admin_status = status; + if (status == this.CLOSED && this.raw_open) + admin_status = this.FAKE_CLOSED; + this.admin_status = admin_status; + } + }, + + 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(new_value, 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); + } +}; diff --git a/kfet/open/templates/kfetopen/init.html b/kfet/open/templates/kfetopen/init.html new file mode 100644 index 00000000..3834b32a --- /dev/null +++ b/kfet/open/templates/kfetopen/init.html @@ -0,0 +1,13 @@ +{% load static %} + + + + + diff --git a/kfet/open/tests.py b/kfet/open/tests.py new file mode 100644 index 00000000..8cf1b6d0 --- /dev/null +++ b/kfet/open/tests.py @@ -0,0 +1,303 @@ +import json + +from django.contrib.auth.models import AnonymousUser, Permission, User +from django.test import Client + +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_export_user(self): + """Export is limited for an anonymous user.""" + export = self.kfet_open.export(AnonymousUser()) + self.assertSetEqual( + set(['is_open', 'last_update']), + 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(['is_open', 'last_update', 'raw_open', 'force_close']), + set(export), + ) + + def test_export(self): + """Export all by default.""" + export = self.kfet_open.export() + self.assertSetEqual( + set(['is_open', 'last_update', 'raw_open', '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(['is_open', 'last_update']), + set(base), + ) + + recv_admin = self.get_next_message('test.open.team', require=True) + admin = json.loads(recv_admin['text']) + self.assertSetEqual( + set(['is_open', 'last_update', 'raw_open', '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}) + 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(['is_open', 'last_update']), + set(msg), + ) + + # test for root user + msg = self.ws_connect(self.r_c_ws) + self.assertSetEqual( + set(['is_open', 'last_update', 'raw_open', '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}) + + # anonymous user agree + msg = self.c_ws.receive(json=True) + self.assertTrue(msg['is_open']) + + # root user too + msg = self.r_c_ws.receive(json=True) + self.assertTrue(msg['is_open']) + self.assertTrue(msg['raw_open']) + + # 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.assertFalse(msg['is_open']) + + # root user too + msg = self.r_c_ws.receive(json=True) + self.assertFalse(msg['is_open']) + # but root knows things + self.assertTrue(msg['raw_open']) + 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.assertFalse(msg['is_open']) + + msg = self.ws_connect(self.r_c_ws) + self.assertFalse(msg['is_open']) + self.assertTrue(msg['raw_open']) + 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.assertTrue(msg['is_open']) + + msg = self.r_c_ws.receive(json=True) + self.assertTrue(msg['is_open']) + self.assertTrue(msg['raw_open']) + self.assertFalse(msg['force_close']) diff --git a/kfet/open/urls.py b/kfet/open/urls.py new file mode 100644 index 00000000..bd227b96 --- /dev/null +++ b/kfet/open/urls.py @@ -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'), +] diff --git a/kfet/open/views.py b/kfet/open/views.py new file mode 100644 index 00000000..5245b4c4 --- /dev/null +++ b/kfet/open/views.py @@ -0,0 +1,27 @@ +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): + 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() diff --git a/kfet/routing.py b/kfet/routing.py index 5db0101f..54de69ae 100644 --- a/kfet/routing.py +++ b/kfet/routing.py @@ -1,13 +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/$"), - route_class(consumers.KfetOpen, path=r"^/ws/k-fet/is_open/$"), + +routing = [ + route_class(consumers.KPsul, path=r'^/k-psul/$'), + include('kfet.open.routing.routing', path=r'^/open'), ] diff --git a/kfet/signals.py b/kfet/signals.py index e81f264a..12a1cd5f 100644 --- a/kfet/signals.py +++ b/kfet/signals.py @@ -10,6 +10,7 @@ from django.dispatch import receiver 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, diff --git a/kfet/static/kfet/css/home.css b/kfet/static/kfet/css/home.css index 2b831d9b..718159c3 100644 --- a/kfet/static/kfet/css/home.css +++ b/kfet/static/kfet/css/home.css @@ -52,12 +52,3 @@ li.carte-line { .unbreakable.carte-inverted { background: #FFDBC7; } - -#open_status { - color: white; -} - -#open_status_parent { - padding-left:0px; - padding-right:0px; -} diff --git a/kfet/static/kfet/css/index.css b/kfet/static/kfet/css/index.css index 15b425e2..5aa74e6e 100644 --- a/kfet/static/kfet/css/index.css +++ b/kfet/static/kfet/css/index.css @@ -30,6 +30,15 @@ textarea { border-radius:0 !important; } +.glyphicon.spinning { + animation: spin 1s infinite linear; +} + +@keyframes spin { + from { transform: scale(1) rotate(0deg); } + to { transform: scale(1) rotate(360deg); } +} + .table { margin-bottom:0; border-bottom:1px solid #ddd; diff --git a/kfet/static/kfet/css/nav.css b/kfet/static/kfet/css/nav.css index 4258e123..bec05ccf 100644 --- a/kfet/static/kfet/css/nav.css +++ b/kfet/static/kfet/css/nav.css @@ -6,7 +6,7 @@ } .navbar .navbar-brand { - padding: 3px 15px 3px 25px; + padding: 3px 25px; } .navbar .navbar-brand img { @@ -51,55 +51,37 @@ box-shadow: inset 0 5px 5px -5px #000; } -.navbar-nav .dropdown .dropdown-menu { +.navbar .dropdown .dropdown-menu { padding: 0; border: 0; border-radius: 0; background-color: #FFF; } -#kfet-open { - font-weight: bold; - font-size: 14px; - width:10px; - height:10px; - text-transform: uppercase; - border-radius: 50%; - background-color: white; - display: inline-block; -} - -#kfet-open-wrapper { - padding-top: 18px; - margin: 0px 10px; - display: inline-block; - line-height: 10px; -} - -.navbar-nav .dropdown .dropdown-menu > li > a { +.navbar .dropdown .dropdown-menu > li > a { padding: 8px 20px; color: #000; } -.navbar-nav .dropdown .dropdown-menu > li > a:hover, -.navbar-nav .dropdown .dropdown-meny > li > a:focus { +.navbar .dropdown .dropdown-menu > li > a:hover, +.navbar .dropdown .dropdown-meny > li > a:focus { color: #c8102e; background-color: transparent; } -.navbar-nav .dropdown .dropdown-menu .divider { +.navbar .dropdown .dropdown-menu .divider { margin: 0; } @media (min-width: 768px) { - .navbar-nav .dropdown .dropdown-menu { + .navbar .dropdown .dropdown-menu { display: block; visibility: hidden; opacity: 0; transition: opacity 0.15s; } - .nav .dropdown:hover .dropdown-menu { + .navbar .dropdown:hover .dropdown-menu { visibility: visible; opacity: 1; } diff --git a/kfet/static/kfet/js/kfet.js b/kfet/static/kfet/js/kfet.js index 72ae675a..b07bb0b1 100644 --- a/kfet/static/kfet/js/kfet.js +++ b/kfet/static/kfet/js/kfet.js @@ -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 ; + get 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':[]}, }); diff --git a/kfet/static/kfet/js/kfet_open.js b/kfet/static/kfet/js/kfet_open.js deleted file mode 100644 index 08a030a5..00000000 --- a/kfet/static/kfet/js/kfet_open.js +++ /dev/null @@ -1,126 +0,0 @@ -function kfet_open(init_date, init_status, init_force_close, force_close_url, force_open_url) { - // VARIABLES - var kfet_open_bullet = $('#kfet-open'); - var open_status = $('#open_status'); - var force_close_button = $('#force_close_button'); - // bullet - var open_color = "#73C252"; - var closed_color = "#B42B26"; - var unknown_color = "#ECD03E"; - // status bar - var open_color_status = "#73C252"; - var closed_color_status = "#B42B26"; - var unknown_color_status = "#D4BE4C"; - - var kfet_open_date = init_date; - var kfet_open = init_status; - var force_close = init_force_close; - - // EVENT - force_close_button.click(forceClose); - - // INITIALISATION - update_open(); - update_force_button(); - - // On recharge toute les 30sec - // (dans le cas où le statut deviendrait inconnu) - setInterval(function() { - update_open(); - }, 30 * 1000); // 30 * 1000 milsec - - // FONCTIONS - function forceClose(password = '') { - if (force_close) { - force_url = force_open_url; - } else { - force_url = force_close_url; - } - $.ajax({ - dataType: "html", - url : force_url, - method : "GET", - beforeSend: function ($xhr) { - if (password != '') - $xhr.setRequestHeader("KFetPassword", password); - }, - }) - .fail(function($xhr) { - var data = $xhr.responseJSON; - switch ($xhr.status) { - case 403: - requestAuth({'errors':{}}, forceClose); - break; - } - lock = 0; - }); - } - - function kfet_open_min() { - return moment().diff(kfet_open_date, 'minute'); - } - function do_kfet_close() { - kfet_open_bullet.css({'background-color': closed_color}); - open_status.parent().css({'background-color': closed_color_status}); - open_status.html("FERMÉE"); - } - function do_kfet_open() { - kfet_open_bullet.css({'background-color': open_color}); - open_status.parent().css({'background-color': open_color_status}); - open_status.html("OUVERTE"); - } - function do_kfet_unknown() { - kfet_open_bullet.css({'background-color': unknown_color}); - open_status.parent().css({'background-color': unknown_color_status}); - open_status.html("?????"); - } - function update_open() { - var nb_min = kfet_open_min(); - console.log("K-Fêt ouverte : " + (kfet_open&&(!force_close))); - console.log(nb_min + " minute(s) depuis la dernière mise à jour"); - if (force_close) { - do_kfet_close(); - } else { - if (nb_min > 15) { - do_kfet_unknown(); - } else if (kfet_open){ - do_kfet_open(); - } else { - do_kfet_close(); - } - } - } - function update_force_button() { - if (force_close) { - force_close_button.html('Réouvrir la K-Fêt'); - } else { - force_close_button.html('Fermer manuellement'); - } - } - // SYNCHRONIZATION - - 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; - var socket = new ReconnectingWebSocket(websocket_protocol+"://" + location_url + "/ws/k-fet/is_open/"); - - socket.onmessage = function(e) { - var data = JSON.parse(e.data); - - if (data['door_action']) { - console.log("* Message reçu de la part de la porte."); - - kfet_open_date = moment.utc(data['door_action']['kfet_open_date']); - kfet_open = data['door_action']['kfet_open']; - - update_open(); - } - if (data['force_action']) { - force_close = data['force_action']['force_close']; - console.log("* Message reçu de la part d'un-e utilisat-rice-eur. Close = " + force_close); - - update_open(); - update_force_button(); - } - } -} diff --git a/kfet/templates/kfet/account_read.html b/kfet/templates/kfet/account_read.html index 282e035f..fbdfd61f 100644 --- a/kfet/templates/kfet/account_read.html +++ b/kfet/templates/kfet/account_read.html @@ -4,10 +4,6 @@ {% load l10n %} {% block extra_head %} - - - - {% if account.user == request.user %} diff --git a/kfet/templates/kfet/base.html b/kfet/templates/kfet/base.html index 281e261d..a4df77cd 100644 --- a/kfet/templates/kfet/base.html +++ b/kfet/templates/kfet/base.html @@ -10,40 +10,23 @@ {# CSS #} + {# JS #} + + - - - + - {# K-Fêt open #} - + {% include "kfetopen/init.html" %} {% block extra_head %}{% endblock %} diff --git a/kfet/templates/kfet/base_nav.html b/kfet/templates/kfet/base_nav.html index f90eb004..512d32e7 100644 --- a/kfet/templates/kfet/base_nav.html +++ b/kfet/templates/kfet/base_nav.html @@ -12,7 +12,25 @@ - + + + diff --git a/kfet/templates/kfet/history.html b/kfet/templates/kfet/history.html index add461ab..0c5fb518 100644 --- a/kfet/templates/kfet/history.html +++ b/kfet/templates/kfet/history.html @@ -3,15 +3,8 @@ {% load l10n %} {% block extra_head %} - - - - - - - diff --git a/kfet/templates/kfet/home.html b/kfet/templates/kfet/home.html index 04174c3f..e5175dc3 100644 --- a/kfet/templates/kfet/home.html +++ b/kfet/templates/kfet/home.html @@ -1,4 +1,4 @@ -{% extends "kfet/base_col_2.html" %} +{% extends "kfet/base_col_1.html" %} {% load staticfiles %} {% load kfet_tags %} @@ -9,26 +9,7 @@ {% endblock %} -{% block fixed-size %}col-sm-2{% endblock %} -{% block main-size %}col-sm-10{% endblock %} - -{% block fixed-content %} - -
-
La K-Fêt est
-
-
-
?????
-
-{% if perms.kfet.is_team %} -
- - Fermer manuellement - -
-{% endif %} - -{% endblock %} +{% block main-size %}col-sm-10 col-sm-offset-1{% endblock %} {% block main-content %} diff --git a/kfet/templates/kfet/inventory_create.html b/kfet/templates/kfet/inventory_create.html index fd1f576f..6b9b533a 100644 --- a/kfet/templates/kfet/inventory_create.html +++ b/kfet/templates/kfet/inventory_create.html @@ -1,11 +1,5 @@ {% extends "kfet/base_col_1.html" %} -{% load staticfiles %} -{% load widget_tweaks %} - -{% block extra_head %} - - -{% endblock %} +{% load staticfiles widget_tweaks %} {% block title %}Nouvel inventaire{% endblock %} {% block header-title %}Création d'un inventaire{% endblock %} diff --git a/kfet/templates/kfet/kpsul.html b/kfet/templates/kfet/kpsul.html index df37a703..cdf50a36 100644 --- a/kfet/templates/kfet/kpsul.html +++ b/kfet/templates/kfet/kpsul.html @@ -2,12 +2,8 @@ {% load staticfiles %} {% block extra_head %} - - - - {% endblock %} diff --git a/kfet/templates/kfet/transfers.html b/kfet/templates/kfet/transfers.html index cbdf0fe3..2881e9e2 100644 --- a/kfet/templates/kfet/transfers.html +++ b/kfet/templates/kfet/transfers.html @@ -1,14 +1,6 @@ {% extends 'kfet/base.html' %} {% load staticfiles %} -{% block extra_head %} - - - - - -{% endblock %} - {% block title %}Transferts{% endblock %} {% block content-header-title %}Transferts{% endblock %} diff --git a/kfet/templates/kfet/transfers_create.html b/kfet/templates/kfet/transfers_create.html index ec9ddc2a..b2d53c96 100644 --- a/kfet/templates/kfet/transfers_create.html +++ b/kfet/templates/kfet/transfers_create.html @@ -3,7 +3,6 @@ {% block extra_head %} - {% endblock %} {% block title %}Nouveaux transferts{% endblock %} diff --git a/kfet/urls.py b/kfet/urls.py index c48766ae..b1c4152f 100644 --- a/kfet/urls.py +++ b/kfet/urls.py @@ -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 views from kfet import autocomplete @@ -198,17 +198,6 @@ urlpatterns = [ (views.SettingsUpdate.as_view()), name='kfet.settings.update'), - # ----- - # K-Fêt Open urls - # ----- - - url('^kfet_open/$', - views.UpdateKfetOpen.as_view(), - name='kfet.kfet_open'), - url('^kfet_close/$', - permission_required('kfet.can_force_close') - (views.UpdateForceClose.as_view()), - name='kfet.force_close'), # ----- # Transfers urls @@ -254,3 +243,8 @@ urlpatterns = [ url(r'^orders/(?P\d+)/to_inventory$', views.order_to_inventory, name='kfet.order.to_inventory'), ] + +urlpatterns += [ + # K-Fêt Open urls + url('^open/', include('kfet.open.urls')), +] diff --git a/kfet/utils.py b/kfet/utils.py new file mode 100644 index 00000000..56207096 --- /dev/null +++ b/kfet/utils.py @@ -0,0 +1,106 @@ +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 + + +# 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) diff --git a/kfet/views.py b/kfet/views.py index f7b2aac9..105cbbdc 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -6,9 +6,7 @@ from urllib.parse import urlencode from django.shortcuts import render, get_object_or_404, redirect from django.core.exceptions import PermissionDenied from django.core.cache import cache -from django.views.generic import ( - DetailView, FormView, ListView, TemplateView, View, -) +from django.views.generic import ListView, DetailView, TemplateView, FormView from django.views.generic.detail import BaseDetailView from django.views.generic.edit import CreateView, UpdateView from django.core.urlresolvers import reverse, reverse_lazy @@ -17,7 +15,7 @@ from django.contrib.messages.views import SuccessMessageMixin from django.contrib.auth import authenticate, login from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.models import User, Permission, Group -from django.http import JsonResponse, Http404, HttpResponse +from django.http import JsonResponse, Http404 from django.forms import formset_factory from django.db import transaction from django.db.models import F, Sum, Prefetch, Count @@ -81,64 +79,6 @@ class Home(TemplateView): return super(TemplateView, self).dispatch(*args, **kwargs) -def KFET_OPEN(): - kfet_open_date = cache.get('KFET_OPEN_DATE', None) - kfet_open = cache.get('KFET_OPEN', None) - if kfet_open_date is None: - kfet_open_date = timezone.now() - cache.set('KFET_OPEN_DATE', kfet_open_date) - if kfet_open is None: - kfet_open = False - cache.set('KFET_OPEN', kfet_open) - return (kfet_open, kfet_open_date) - - -def KFET_FORCE_CLOSE(): - kfet_force_close = cache.get('KFET_FORCE_CLOSE', None) - if kfet_force_close is None: - kfet_force_close = False - cache.set('KFET_FORCE_CLOSE', kfet_force_close) - return kfet_force_close - - -class UpdateKfetOpen(View): - def get(self, request, *args, **kwargs): - is_open = "open" in request.GET - cache.set('KFET_OPEN', is_open) - cache.set('KFET_OPEN_DATE', timezone.now()) - - # Websocket - websocket_data = { - 'door_action': { - 'kfet_open': is_open, - 'kfet_open_date': timezone.now().isoformat(), - }, - } - consumers.KfetOpen.group_send('kfet.is_open', websocket_data) - - (is_open_get, time) = KFET_OPEN() - return HttpResponse("%r at %s" % (is_open_get, time.isoformat())) - - -class UpdateForceClose(View): - def get(self, request, *args, **kwargs): - force_close = "close" in request.GET - cache.set('KFET_FORCE_CLOSE', force_close) - - # Websocket - websocket_data = { - 'force_action': { - 'force_close': force_close, - }, - } - consumers.KfetOpen.group_send('kfet.is_open', websocket_data) - - force_close_get = KFET_FORCE_CLOSE() - time = timezone.now() - return HttpResponse("closed : %r at %s" % (force_close_get, - time.isoformat())) - - @teamkfet_required def login_genericteam(request): # Check si besoin de déconnecter l'utilisateur de CAS diff --git a/requirements.txt b/requirements.txt index b4d83eec..d730bd6a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,12 +14,12 @@ 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