Merge pull request #2170 from ostcar/channels

Big Mode for OpenSlides
This commit is contained in:
Oskar Hahn 2016-06-09 11:20:25 +02:00
commit 9a88717dab
21 changed files with 333 additions and 317 deletions

View File

@ -4,12 +4,17 @@
https://openslides.org/ https://openslides.org/
Version 2.0.1 (unreleased) Version 2.1 (unreleased)
========================== ========================
[https://github.com/OpenSlides/OpenSlides/milestones/2.0.1] [https://github.com/OpenSlides/OpenSlides/milestones/2.1]
Core:
- Used Django Channels instead of Tornado.
- Added support for big assemblies with lots of users.
Other: Other:
- Remove config cache to support multiple threads or processes. - Removed config cache to support multiple threads or processes.
- Fixed bug, that the last change of a config value was not send via autoupdate.
Version 2.0 (2016-04-18) Version 2.0 (2016-04-18)

View File

@ -79,15 +79,14 @@ To start OpenSlides simply run::
$ openslides $ openslides
If you run this command the first time, a new database and the admin If you run this command the first time, a new database and the admin account
account (Username: `admin`, Password: `admin`) will be created. Please (Username: `admin`, Password: `admin`) will be created. Please change the
change the password after first login! password after first login!
OpenSlides will start using the integrated Tornado webserver. It will also OpenSlides will start a webserver. It will also try to open the webinterface in
try to open the webinterface in your default webbrowser. The server will your default webbrowser. The server will try to listen on the local ip address
try to listen on the local ip address on port 8000. That means that the on port 8000. That means that the server will be available to everyone on your
server will be available to everyone on your local network (at least for local network (at least for commonly used network configurations).
commonly used network configurations).
If you use a virtual environment (see step b.), do not forget to activate If you use a virtual environment (see step b.), do not forget to activate
the environment before restart after you have closed the terminal:: the environment before restart after you have closed the terminal::
@ -122,6 +121,32 @@ If you want to contribute to OpenSlides, have a look at `OpenSlides website
<https://github.com/OpenSlides/OpenSlides/blob/master/DEVELOPMENT.rst>`_. <https://github.com/OpenSlides/OpenSlides/blob/master/DEVELOPMENT.rst>`_.
Installation for big assemblies
===============================
The installation steps described above install OpenSlides in a way, that does
not support hundreds of concurrent clients. To install OpenSlides in for big
assemblies, some config variables have to be changed in the OpenSlides settings
usualy called settings.py.
The configuration values, that have to be altered are:
* CACHES
* CHANNEL_LAYERS
* DATABASES
Please see:
* http://channels.readthedocs.io/en/latest/deploying.html
* https://docs.djangoproject.com/en/1.9/topics/cache/
* https://github.com/sebleier/django-redis-cache
* https://docs.djangoproject.com/en/1.9/ref/settings/#databases
You should use a webserver like Apache HTTP Server or nginx to serve the static
and media files and as proxy server in front of OpenSlides server. You also
should use a database like PostgreSQL and Redis as channels backend and cache
backend.
Used software Used software
============= =============
@ -140,6 +165,8 @@ OpenSlides uses the following projects or parts of them:
* `html5lib <https://github.com/html5lib/html5lib-python>`_, License: MIT * `html5lib <https://github.com/html5lib/html5lib-python>`_, License: MIT
* `Django Channels <https://github.com/andrewgodwin/channels/>`_, License: MIT
* `django-jsonfield <https://github.com/bradjasper/django-jsonfield/>`_, * `django-jsonfield <https://github.com/bradjasper/django-jsonfield/>`_,
License: MIT License: MIT
@ -157,12 +184,6 @@ OpenSlides uses the following projects or parts of them:
* `Six <http://pythonhosted.org/six/>`_, License: MIT * `Six <http://pythonhosted.org/six/>`_, License: MIT
* `sockjs-tornado <https://github.com/mrjoes/sockjs-tornado>`_,
License: MIT
* `Tornado <http://www.tornadoweb.org/en/stable/>`_, License: Apache
License v2.0
* `Whoosh <https://bitbucket.org/mchaput/whoosh/wiki/Home>`_, License: BSD * `Whoosh <https://bitbucket.org/mchaput/whoosh/wiki/Home>`_, License: BSD
* Several JavaScript packages (see ``bower.json``) * Several JavaScript packages (see ``bower.json``)
@ -200,7 +221,6 @@ OpenSlides uses the following projects or parts of them:
* `open-sans-fontface <https://github.com/FontFaceKit/open-sans>`_, License: Apache License version 2.0 * `open-sans-fontface <https://github.com/FontFaceKit/open-sans>`_, License: Apache License version 2.0
* `pdfjs-dist <http://mozilla.github.io/pdf.js/>`_, License: Apache-2.0 * `pdfjs-dist <http://mozilla.github.io/pdf.js/>`_, License: Apache-2.0
* `roboto-condensed <https://github.com/davidcunningham/roboto-condensed>`_, License: Apache 2.0 * `roboto-condensed <https://github.com/davidcunningham/roboto-condensed>`_, License: Apache 2.0
* `sockjs <https://github.com/sockjs/sockjs-client>`_, License: MIT
* `tinymce <http://www.tinymce.com>`_, License: LGPL-2.1 * `tinymce <http://www.tinymce.com>`_, License: LGPL-2.1
* `tinymce-i18n <https://github.com/OpenSlides/tinymce-i18n>`_, License: LGPL-2.1 * `tinymce-i18n <https://github.com/OpenSlides/tinymce-i18n>`_, License: LGPL-2.1

View File

@ -31,7 +31,6 @@
"ngBootbox": "~0.1.3", "ngBootbox": "~0.1.3",
"open-sans-fontface": "https://github.com/OpenSlides/open-sans.git#1.4.2.post1", "open-sans-fontface": "https://github.com/OpenSlides/open-sans.git#1.4.2.post1",
"roboto-condensed": "~0.3.0", "roboto-condensed": "~0.3.0",
"sockjs": "~0.3.4",
"tinymce-i18n": "OpenSlides/tinymce-i18n#a186ad61e0aa30fdf657e88f405f966d790f0805" "tinymce-i18n": "OpenSlides/tinymce-i18n#a186ad61e0aa30fdf657e88f405f966d790f0805"
}, },
"overrides": { "overrides": {

View File

@ -185,7 +185,13 @@ def start(args):
# Start the webserver # Start the webserver
# Tell django not to reload. OpenSlides uses the reload method from tornado # Tell django not to reload. OpenSlides uses the reload method from tornado
execute_from_command_line(['manage.py', 'runserver', '%s:%s' % (args.host, args.port), '--noreload']) # Use insecure to serve static files, even when DEBUG is False.
execute_from_command_line([
'manage.py',
'runserver',
'{}:{}'.format(args.host, args.port),
'--noreload',
'--insecure'])
def createsettings(args): def createsettings(args):

View File

@ -12,7 +12,7 @@ def listen_to_related_object_post_save(sender, instance, created, **kwargs):
""" """
if created and hasattr(instance, 'get_agenda_title'): if created and hasattr(instance, 'get_agenda_title'):
Item.objects.create(content_object=instance) Item.objects.create(content_object=instance)
inform_changed_data(False, instance) inform_changed_data(instance)
def listen_to_related_object_post_delete(sender, instance, **kwargs): def listen_to_related_object_post_delete(sender, instance, **kwargs):

9
openslides/asgi.py Normal file
View File

@ -0,0 +1,9 @@
from channels.asgi import get_channel_layer
from .utils.main import setup_django_settings_module
# Loads the openslides setting. You can use your own settings by setting the
# environment variable DJANGO_SETTINGS_MODULE
setup_django_settings_module()
channel_layer = get_channel_layer()

View File

@ -1,82 +0,0 @@
import errno
import os
import socket
import sys
from datetime import datetime
from django.conf import settings
from django.core.management.commands.runserver import Command as _Command
from django.utils import six
from django.utils.encoding import force_text, get_system_encoding
from openslides.utils.autoupdate import run_tornado
class Command(_Command):
"""
Runserver command from django core, but starts the tornado webserver.
Only the line to run tornado has changed from the django default
implementation.
The Code is from django 1.9
"""
help = 'Starts the Tornado webserver.'
# TODO: do not start tornado when the settings says so
def inner_run(self, *args, **options):
# If an exception was silenced in ManagementUtility.execute in order
# to be raised in the child process, raise it now.
# OPENSLIDES: We do not use the django autoreload command
# autoreload.raise_last_exception()
# OPENSLIDES: This line is not needed by tornado
# threading = options.get('use_threading')
shutdown_message = options.get('shutdown_message', '')
quit_command = 'CTRL-BREAK' if sys.platform == 'win32' else 'CONTROL-C'
self.stdout.write("Performing system checks...\n\n")
self.check(display_num_errors=True)
self.check_migrations()
now = datetime.now().strftime('%B %d, %Y - %X')
if six.PY2:
now = now.decode(get_system_encoding())
self.stdout.write(now)
self.stdout.write((
"Django version %(version)s, using settings %(settings)r\n"
"Starting development server at http://%(addr)s:%(port)s/\n"
"Quit the server with %(quit_command)s.\n"
) % {
"version": self.get_version(),
"settings": settings.SETTINGS_MODULE,
"addr": '[%s]' % self.addr if self._raw_ipv6 else self.addr,
"port": self.port,
"quit_command": quit_command,
})
try:
handler = self.get_handler(*args, **options)
run_tornado(
self.addr,
int(self.port),
handler,
ipv6=self.use_ipv6)
except socket.error as e:
# Use helpful error messages instead of ugly tracebacks.
ERRORS = {
errno.EACCES: "You don't have permission to access that port.",
errno.EADDRINUSE: "That port is already in use.",
errno.EADDRNOTAVAIL: "That IP address can't be assigned to.",
}
try:
error_text = ERRORS[e.errno]
except KeyError:
error_text = force_text(e)
self.stderr.write("Error: %s" % error_text)
# Need to use an OS exit because sys.exit doesn't work in a thread
os._exit(1)
except KeyboardInterrupt:
if shutdown_message:
self.stdout.write(shutdown_message)
sys.exit(0)

View File

@ -0,0 +1,35 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('sessions', '0001_initial'),
('core', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Session',
fields=[
('session_ptr',
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to='sessions.Session')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': 'sessions',
'abstract': False,
'verbose_name': 'session',
},
bases=('sessions.session',),
),
]

View File

@ -1,5 +1,6 @@
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.sessions.models import Session as DjangoSession
from django.db import models from django.db import models
from jsonfield import JSONField from jsonfield import JSONField
@ -252,3 +253,19 @@ class ChatMessage(RESTModelMixin, models.Model):
def __str__(self): def __str__(self):
return 'Message {}'.format(self.timestamp) return 'Message {}'.format(self.timestamp)
class Session(DjangoSession):
"""
Model like the Django db session, which saves the user as ForeignKey instead
of an encoded value.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
null=True)
@classmethod
def get_session_store_class(cls):
from .session_backend import SessionStore
return SessionStore

View File

@ -0,0 +1,23 @@
from django.contrib.sessions.backends.db import \
SessionStore as DjangoSessionStore
class SessionStore(DjangoSessionStore):
"""
Like the Django db Session store, but saves the user into the db field.
"""
@classmethod
def get_model_class(cls):
# Avoids a circular import
from .models import Session
return Session
def create_model_instance(self, data):
"""
Set the user from data to the db field. Set to None, if its a session
from an anonymous user.
"""
model = super().create_model_instance(data)
model.user_id = data['_auth_user_id'] if '_auth_user_id' in data else None
return model

View File

@ -41,7 +41,7 @@ angular.module('OpenSlidesApp.core', [
function (DS, $rootScope) { function (DS, $rootScope) {
var socket = null; var socket = null;
var recInterval = null; var recInterval = null;
$rootScope.connected = true; $rootScope.connected = false;
var Autoupdate = { var Autoupdate = {
messageReceivers: [], messageReceivers: [],
@ -55,7 +55,7 @@ angular.module('OpenSlidesApp.core', [
} }
}; };
var newConnect = function () { var newConnect = function () {
socket = new SockJS(location.origin + "/sockjs"); socket = new WebSocket('ws://' + location.host + '/ws/');
clearInterval(recInterval); clearInterval(recInterval);
socket.onopen = function () { socket.onopen = function () {
$rootScope.connected = true; $rootScope.connected = true;
@ -168,22 +168,20 @@ angular.module('OpenSlidesApp.core', [
'autoupdate', 'autoupdate',
'dsEject', 'dsEject',
function (DS, autoupdate, dsEject) { function (DS, autoupdate, dsEject) {
autoupdate.onMessage(function(data) { autoupdate.onMessage(function(json) {
// TODO: when MODEL.find() is called after this // TODO: when MODEL.find() is called after this
// a new request is fired. This could be a bug in DS // a new request is fired. This could be a bug in DS
// TODO: Do not send the status code to the client, but make the decission var data = JSON.parse(json);
// on the server side. It is an implementation detail, that tornado
// sends request to wsgi, which should not concern the client.
console.log("Received object: " + data.collection + ", " + data.id); console.log("Received object: " + data.collection + ", " + data.id);
var instance = DS.get(data.collection, data.id); var instance = DS.get(data.collection, data.id);
if (data.status_code == 200) { if (data.action == 'changed') {
if (instance) { if (instance) {
// The instance is in the local db // The instance is in the local db
dsEject(data.collection, instance); dsEject(data.collection, instance);
} }
DS.inject(data.collection, data.data); DS.inject(data.collection, data.data);
} else if (data.status_code == 404) { } else if (data.action == 'deleted') {
if (instance) { if (instance) {
// The instance is in the local db // The instance is in the local db
dsEject(data.collection, instance); dsEject(data.collection, instance);

View File

@ -8,6 +8,9 @@ AUTH_USER_MODEL = 'users.User'
AUTHENTICATION_BACKENDS = ('openslides.users.auth.CustomizedModelBackend',) AUTHENTICATION_BACKENDS = ('openslides.users.auth.CustomizedModelBackend',)
# Uses a db session backend, that saves the user_id directly in the db
SESSION_ENGINE = 'openslides.core.session_backend'
SESSION_COOKIE_NAME = 'OpenSlidesSessionID' SESSION_COOKIE_NAME = 'OpenSlidesSessionID'
LANGUAGES = ( LANGUAGES = (
@ -74,6 +77,7 @@ INSTALLED_APPS = (
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.contrib.humanize', 'django.contrib.humanize',
'rest_framework', 'rest_framework',
'channels',
'openslides.poll', # TODO: try to remove this line 'openslides.poll', # TODO: try to remove this line
'openslides.agenda', 'openslides.agenda',
'openslides.motions', 'openslides.motions',
@ -93,26 +97,9 @@ CACHES = {
# See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts # See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts
ALLOWED_HOSTS = ['*'] ALLOWED_HOSTS = ['*']
# Use Haystack with Whoosh for full text search
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'haystack.backends.whoosh_backend.WhooshEngine'
},
}
# Haystack updates search index after each save/delete action by apps
HAYSTACK_SIGNAL_PROCESSOR = 'openslides.utils.haystack_processor.OpenSlidesProcessor'
# Adds all automaticly collected plugins # Adds all automaticly collected plugins
INSTALLED_PLUGINS = collect_plugins() INSTALLED_PLUGINS = collect_plugins()
# Set this True to use tornado as single wsgi server. Set this False to use
# other webserver like Apache or Nginx as wsgi server.
USE_TORNADO_AS_WSGI_SERVER = True
OPENSLIDES_WSGI_NETWORK_LOCATION = ''
TEST_RUNNER = 'openslides.utils.test.OpenSlidesDiscoverRunner' TEST_RUNNER = 'openslides.utils.test.OpenSlidesDiscoverRunner'
# Config for the REST Framework # Config for the REST Framework
@ -122,3 +109,11 @@ REST_FRAMEWORK = {
'openslides.users.auth.RESTFrameworkAnonymousAuthentication', 'openslides.users.auth.RESTFrameworkAnonymousAuthentication',
) )
} }
# Config for channels
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'asgiref.inmemory.ChannelLayer',
'ROUTING': 'openslides.routing.channel_routing',
},
}

9
openslides/routing.py Normal file
View File

@ -0,0 +1,9 @@
from channels.routing import route
from openslides.utils.autoupdate import send_data, ws_add, ws_disconnect
channel_routing = [
route("websocket.connect", ws_add, path='/ws/'),
route("websocket.disconnect", ws_disconnect),
route("autoupdate.send_data", send_data),
]

View File

@ -1,211 +1,132 @@
import itertools
import json import json
import os
import posixpath
from importlib import import_module
from urllib.parse import unquote
from django.conf import settings from asgiref.inmemory import ChannelLayer
from django.core.wsgi import get_wsgi_application from channels import Channel, Group
from sockjs.tornado import SockJSConnection, SockJSRouter from channels.auth import channel_session_user, channel_session_user_from_http
from tornado.httpserver import HTTPServer from django.apps import apps
from tornado.ioloop import IOLoop from django.utils import timezone
from tornado.options import parse_command_line
from tornado.web import (
Application,
FallbackHandler,
HTTPError,
StaticFileHandler,
)
from tornado.wsgi import WSGIContainer
from ..users.auth import AnonymousUser, get_user from ..users.auth import AnonymousUser
from ..users.models import User
from .access_permissions import BaseAccessPermissions from .access_permissions import BaseAccessPermissions
RUNNING_HOST = None
RUNNING_PORT = None
def get_logged_in_users():
class DjangoStaticFileHandler(StaticFileHandler):
""" """
Handels static data by using the django finders. Helper to get all logged in users.
Only needed in the "small" version with tornado as wsgi server. Only works with the OpenSlides session backend.
""" """
return User.objects.exclude(session=None).filter(session__expire_date__gte=timezone.now()).distinct()
def initialize(self):
"""Overwrite some attributes."""
# NOTE: root is never actually used and default_filename is not
# supported (must always be None)
self.root = ''
self.default_filename = None
@classmethod
def get_absolute_path(cls, root, path):
from django.contrib.staticfiles import finders
normalized_path = posixpath.normpath(unquote(path)).lstrip('/')
absolute_path = finders.find(normalized_path)
return absolute_path
def validate_absolute_path(self, root, absolute_path):
# differences from base implementation:
# - we ignore self.root since our files do not necessarily have
# a shared root prefix
# - we do not handle self.default_filename (we do not use it and it
# does not make much sense here anyway)
if absolute_path is None or not os.path.exists(absolute_path):
raise HTTPError(404)
if not os.path.isfile(absolute_path):
raise HTTPError(403, 'The requested resource is not a file.')
return absolute_path
class OpenSlidesSockJSConnection(SockJSConnection): def get_model_from_collection_string(collection_string):
""" """
SockJS connection for OpenSlides. Returns a model class which belongs to the argument collection_string.
""" """
waiters = set() def model_generator():
def on_open(self, info):
self.waiters.add(self)
self.connection_info = info
def on_close(self):
OpenSlidesSockJSConnection.waiters.remove(self)
@classmethod
def send_object(cls, json_container):
""" """
Sends an OpenSlides object to all connected clients (waiters). Yields all models of all apps.
""" """
# Load JSON for app_config in apps.get_app_configs():
container = json.loads(json_container) for model in app_config.get_models():
yield model
# Search our AccessPermission class. for model in model_generator():
for access_permissions in BaseAccessPermissions.get_all(): try:
if access_permissions.get_dispatch_uid() == container.get('dispatch_uid'): model_collection_string = model.get_collection_string()
break except AttributeError:
# Skip models which do not have the method get_collection_string.
pass
else: else:
raise ValueError('Invalid container. A valid dispatch_uid is missing.') if model_collection_string == collection_string:
# The model was found.
# Loop over all waiters break
for waiter in cls.waiters: else:
# Read waiter's former cookies and parse session cookie to get user instance. # No model was found in all apps.
try: raise ValueError('Invalid message. A valid collection_string is missing.')
session_cookie = waiter.connection_info.cookies[settings.SESSION_COOKIE_NAME] return model
except KeyError:
# There is no session cookie so use anonymous user here.
user = AnonymousUser()
else:
# Get session from session store and use it to retrieve the user.
engine = import_module(settings.SESSION_ENGINE)
session = engine.SessionStore(session_cookie.value)
fake_request = type('FakeRequest', (), {})()
fake_request.session = session
user = get_user(fake_request)
# Two cases: models instance was changed or deleted
if container.get('action') == 'changed':
data = access_permissions.get_restricted_data(container.get('full_data'), user)
if data is None:
# There are no data for the user so he can't see the object. Skip him.
break
output = {
'status_code': 200, # TODO: Refactor this. Use strings like 'change' or 'delete'.
'collection': container['collection_string'],
'id': container['rest_pk'],
'data': data}
elif container.get('action') == 'deleted':
output = {
'status_code': 404, # TODO: Refactor this. Use strings like 'change' or 'delete'.
'collection': container['collection_string'],
'id': container['rest_pk']}
else:
raise ValueError('Invalid container. A valid action is missing.')
# Send output to the waiter (client).
waiter.send(output)
def run_tornado(addr, port, *args, **kwargs): # Connected to websocket.connect
@channel_session_user_from_http
def ws_add(message):
""" """
Starts the tornado webserver as wsgi server for OpenSlides. Adds the websocket connection to a group specific to the connecting user.
It runs in one thread. The group with the name 'user-None' stands for all anonymous users.
""" """
# Save the port and the addr in a global var Group('user-{}'.format(message.user.id)).add(message.reply_channel)
global RUNNING_HOST, RUNNING_PORT
RUNNING_HOST = addr
RUNNING_PORT = port
# Don't try to read the command line args from openslides
parse_command_line(args=[])
# Setup WSGIContainer
app = WSGIContainer(get_wsgi_application())
# Collect urls
sock_js_router = SockJSRouter(OpenSlidesSockJSConnection, '/sockjs')
other_urls = [
(r'%s(.*)' % settings.STATIC_URL, DjangoStaticFileHandler),
(r'%s(.*)' % settings.MEDIA_URL, StaticFileHandler, {'path': settings.MEDIA_ROOT}),
('.*', FallbackHandler, dict(fallback=app))]
# Start the application
debug = settings.DEBUG
tornado_app = Application(sock_js_router.urls + other_urls, autoreload=debug, debug=debug)
server = HTTPServer(tornado_app)
server.listen(port=port, address=addr)
IOLoop.instance().start()
# Reset the global vars
RUNNING_HOST = None
RUNNING_PORT = None
def inform_changed_data(is_delete, *args): # Connected to websocket.disconnect
@channel_session_user
def ws_disconnect(message):
Group('user-{}'.format(message.user.id)).discard(message.reply_channel)
def send_data(message):
""" """
Informs all users about changed data. Informs all users about changed data.
The first argument is whether the object or the objects are deleted. The argument message has to be a dict with the keywords collection_string
The other arguments are the changed or deleted Django/OpenSlides model (string), pk (positive integer), id_deleted (boolean) and dispatch_uid
instances. (string).
""" """
if settings.USE_TORNADO_AS_WSGI_SERVER: for access_permissions in BaseAccessPermissions.get_all():
for instance in args: if access_permissions.get_dispatch_uid() == message['dispatch_uid']:
try: break
root_instance = instance.get_root_rest_element()
except AttributeError:
# Instance has no method get_root_rest_element. Just skip it.
pass
else:
access_permissions = root_instance.get_access_permissions()
container = {
'dispatch_uid': access_permissions.get_dispatch_uid(),
'collection_string': root_instance.get_collection_string(),
'rest_pk': root_instance.get_rest_pk()}
if is_delete and instance == root_instance:
# A root instance is deleted.
container['action'] = 'deleted'
else:
# A non root instance is deleted or any instance is just changed.
container['action'] = 'changed'
root_instance.refresh_from_db()
container['full_data'] = access_permissions.get_full_data(root_instance)
OpenSlidesSockJSConnection.send_object(json.dumps(container))
else: else:
raise ValueError('Invalid message. A valid dispatch_uid is missing.')
if not message['is_deleted']:
Model = get_model_from_collection_string(message['collection_string'])
instance = Model.objects.get(pk=message['pk'])
full_data = access_permissions.get_full_data(instance)
# Loop over all logged in users and the anonymous user.
for user in itertools.chain(get_logged_in_users(), [AnonymousUser()]):
channel = Group('user-{}'.format(user.id))
output = {
'collection': message['collection_string'],
'id': instance.get_rest_pk(),
'action': 'deleted' if message['is_deleted'] else 'changed'}
if not message['is_deleted']:
data = access_permissions.get_restricted_data(full_data, user)
if data is None:
# There are no data for the user so he can't see the object. Skip him.
continue
output['data'] = data
channel.send({'text': json.dumps(output)})
def inform_changed_data(instance, is_deleted=False):
try:
root_instance = instance.get_root_rest_element()
except AttributeError:
# Instance has no method get_root_rest_element. Just ignore it.
pass pass
# TODO: Implement big variant with Apache or Nginx as WSGI webserver. else:
try:
Channel('autoupdate.send_data').send({
'collection_string': root_instance.get_collection_string(),
'pk': root_instance.pk,
'is_deleted': is_deleted and instance == root_instance,
'dispatch_uid': root_instance.get_access_permissions().get_dispatch_uid()})
except ChannelLayer.ChannelFull:
pass
def inform_changed_data_receiver(sender, instance, **kwargs): def inform_changed_data_receiver(sender, instance, **kwargs):
""" """
Receiver for the inform_changed_data function to use in a signal. Receiver for the inform_changed_data function to use in a signal.
""" """
inform_changed_data(False, instance) inform_changed_data(instance)
def inform_deleted_data_receiver(sender, instance, **kwargs): def inform_deleted_data_receiver(sender, instance, **kwargs):
""" """
Receiver for the inform_changed_data function to use in a signal. Receiver for the inform_changed_data function to use in a signal.
""" """
inform_changed_data(True, instance) inform_changed_data(instance, is_deleted=True)

View File

@ -2,10 +2,10 @@
Settings file for OpenSlides Settings file for OpenSlides
For more information on this file, see For more information on this file, see
https://docs.djangoproject.com/en/1.7/topics/settings/ https://docs.djangoproject.com/en/1.9/topics/settings/
For the full list of settings and their values, see For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.7/ref/settings/ https://docs.djangoproject.com/en/1.9/ref/settings/
""" """
import os import os
@ -26,7 +26,6 @@ SECRET_KEY = %(secret_key)r
# SECURITY WARNING: Don't run with debug turned on in production! # SECURITY WARNING: Don't run with debug turned on in production!
DEBUG = %(debug)s DEBUG = %(debug)s
TEMPLATE_DEBUG = DEBUG
# OpenSlides plugins # OpenSlides plugins
@ -41,7 +40,7 @@ INSTALLED_APPS += INSTALLED_PLUGINS
# Database # Database
# Change this to use MySQL or PostgreSQL. # Change this to use MySQL or PostgreSQL.
# See https://docs.djangoproject.com/en/1.7/ref/settings/#databases # See https://docs.djangoproject.com/en/1.9/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { 'default': {
@ -51,6 +50,25 @@ DATABASES = {
} }
# Big Mode
# Uncomment the following lines to activate redis as channel and cache backend.
# You have to install a redis server and the python packages asgi_redis and
# django-redis for this to work.
# See https://channels.readthedocs.io/en/latest/backends.html#redis
# https://niwinz.github.io/django-redis/latest/#_user_guide
# CHANNEL_LAYERS['default']['BACKEND'] = 'asgi_redis.RedisChannelLayer'
# CACHES = {
# "default": {
# "BACKEND": "django_redis.cache.RedisCache",
# "LOCATION": "redis://127.0.0.1:6379/1",
# "OPTIONS": {
# "CLIENT_CLASS": "django_redis.client.DefaultClient",
# }
# }
# }
# Some other settings # Some other settings
TIME_ZONE = 'Europe/Berlin' TIME_ZONE = 'Europe/Berlin'
@ -63,5 +81,4 @@ TEMPLATE_DIRS = (
STATICFILES_DIRS = [os.path.join(OPENSLIDES_USER_DATA_PATH, 'static')] + STATICFILES_DIRS STATICFILES_DIRS = [os.path.join(OPENSLIDES_USER_DATA_PATH, 'static')] + STATICFILES_DIRS
SEARCH_INDEX = os.path.join(OPENSLIDES_USER_DATA_PATH, 'search_index') SEARCH_INDEX = os.path.join(OPENSLIDES_USER_DATA_PATH, 'search_index')

View File

@ -15,6 +15,7 @@
"gulp-jshint": "~2.0.0", "gulp-jshint": "~2.0.0",
"gulp-rename": "~1.2.2", "gulp-rename": "~1.2.2",
"gulp-uglify": "~1.5.2", "gulp-uglify": "~1.5.2",
"jshint": "~2.9.2",
"main-bower-files": "~2.11.1", "main-bower-files": "~2.11.1",
"po2json": "~0.4.1", "po2json": "~0.4.1",
"sprintf-js": "~1.0.3", "sprintf-js": "~1.0.3",

View File

@ -1,6 +1,7 @@
# Requirements for OpenSlides in production in alphabetical order # Requirements for OpenSlides in production in alphabetical order
Django>=1.8,<1.10 Django>=1.8,<1.10
beautifulsoup4>=4.4,<4.5 beautifulsoup4>=4.4,<4.5
channels>=0.14,<0.15
djangorestframework>=3.2.0,<3.4.0 djangorestframework>=3.2.0,<3.4.0
html5lib>=0.9,<1.0 html5lib>=0.9,<1.0
jsonfield>=0.9.19,<1.1 jsonfield>=0.9.19,<1.1

View File

@ -1,18 +1,15 @@
import json import json
from unittest.mock import patch
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from rest_framework import status from rest_framework import status
from rest_framework.test import APIClient from rest_framework.test import APIClient
from openslides import __version__ as version from openslides import __version__ as version
from openslides.core.config import ConfigHandler, ConfigVariable from openslides.core.config import ConfigVariable, config
from openslides.core.models import CustomSlide, Projector from openslides.core.models import CustomSlide, Projector
from openslides.utils.rest_api import ValidationError from openslides.utils.rest_api import ValidationError
from openslides.utils.test import TestCase from openslides.utils.test import TestCase
config = ConfigHandler()
class ProjectorAPI(TestCase): class ProjectorAPI(TestCase):
""" """
@ -75,18 +72,19 @@ class VersionView(TestCase):
'version': 'unknown'}]}) 'version': 'unknown'}]})
@patch('openslides.core.config.config', config)
@patch('openslides.core.views.config', config)
class ConfigViewSet(TestCase): class ConfigViewSet(TestCase):
""" """
Tests requests to deal with config variables. Tests requests to deal with config variables.
""" """
def setUp(self): def setUp(self):
# Save the old value of the config object and add the test values
# TODO: Can be changed to setUpClass when Django 1.8 is no longer supported
self._config_values = config.config_variables.copy()
config.update_config_variables(set_simple_config_view_integration_config_test()) config.update_config_variables(set_simple_config_view_integration_config_test())
def tearDown(self): def tearDown(self):
# Reset the config variables # Reset the config variables
config.config_variables = {} config.config_variables = self._config_values
def test_retrieve(self): def test_retrieve(self):
self.client.login(username='admin', password='admin') self.client.login(username='admin', password='admin')

View File

View File

@ -0,0 +1,46 @@
from datetime import timedelta
from django.utils import timezone
from openslides.core.models import Session
from openslides.users.models import User
from openslides.utils.autoupdate import get_logged_in_users
from openslides.utils.test import TestCase
class TestGetLoggedInUsers(TestCase):
def test_call(self):
"""
Test to call the function with:
* A user that session has not expired
* A user that session has expired
* A user that has no session
* An anonymous user that session hot not expired
Only the user with the session that has not expired should be returned
"""
user1 = User.objects.create(username='user1')
user2 = User.objects.create(username='user2')
User.objects.create(username='user3')
# Create a session with a user, that expires in 5 hours
Session.objects.create(user=user1, expire_data=timezone.now() + timedelta(hours=5))
# Create a session with a user, that is expired before 5 hours
Session.objects.create(user=user2, expire_data=timezone.now() + timedelta(hours=-5))
# Create a session with an anonymous user, that expires in 5 hours
Session.objects.create(user=None, expire_data=timezone.now() + timedelta(hours=5))
self.assertEqual(list(get_logged_in_users()), [user1])
def test_unique(self):
"""
Test the function with a user that has two not expired session.
The user should be returned only once.
"""
user1 = User.objects.create(username='user1')
Session.objects.create(user=user1, expire_data=timezone.now() + timedelta(hours=1))
Session.objects.create(user=user1, expire_data=timezone.now() + timedelta(hours=2))
self.assertEqual(list(get_logged_in_users()), [user1])

View File

@ -1,6 +1,4 @@
from unittest.mock import patch from openslides.core.config import ConfigVariable, config
from openslides.core.config import ConfigHandler, ConfigVariable
from openslides.core.exceptions import ConfigError, ConfigNotFound from openslides.core.exceptions import ConfigError, ConfigNotFound
from openslides.utils.test import TestCase from openslides.utils.test import TestCase
@ -8,12 +6,12 @@ from openslides.utils.test import TestCase
class TestConfigException(Exception): class TestConfigException(Exception):
pass pass
config = ConfigHandler()
@patch('openslides.core.config.config', config)
class HandleConfigTest(TestCase): class HandleConfigTest(TestCase):
def setUp(self): def setUp(self):
# Save the old value of the config object and add the test values
# TODO: Can be changed to setUpClass when Django 1.8 is no longer supported
self._config_values = config.config_variables.copy()
config.update_config_variables(set_grouped_config_view()) config.update_config_variables(set_grouped_config_view())
config.update_config_variables(set_simple_config_view()) config.update_config_variables(set_simple_config_view())
config.update_config_variables(set_simple_config_view_multiple_vars()) config.update_config_variables(set_simple_config_view_multiple_vars())
@ -22,7 +20,7 @@ class HandleConfigTest(TestCase):
def tearDown(self): def tearDown(self):
# Reset the config variables # Reset the config variables
config.config_variables = {} config.config_variables = self._config_values
def get_config_var(self, key): def get_config_var(self, key):
return config[key] return config[key]