Merge pull request #3923 from ostcar/new-master

Merge OpenSlides-3 branch into the master branch
This commit is contained in:
Oskar Hahn 2018-10-13 07:46:31 +02:00 committed by GitHub
commit becdef26a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
429 changed files with 33186 additions and 3517 deletions

45
.gitignore vendored
View File

@ -6,7 +6,7 @@
*~ *~
# Virtual Environment # Virtual Environment
.virtualenv/* .virtualenv*/*
.venv/* .venv/*
# Javascript tools and libraries # Javascript tools and libraries
@ -30,6 +30,7 @@ debug/*
# Unit test and coverage reports # Unit test and coverage reports
.coverage .coverage
tests/file/* tests/file/*
.pytest_cache
# Plugin development # Plugin development
openslides_* openslides_*
@ -37,5 +38,43 @@ openslides_*
# Mypy cache for typechecking # Mypy cache for typechecking
.mypy_cache .mypy_cache
# Development of a new client. Easier to switch branches with this entry # OpenSlides 3 Client
client/*
# compiled output
client/dist
client/tmp
client/out-tsc
client/documentation
# dependencies
client/node_modules
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
# misc
Compodoc
Compodocmodules
client/.sass-cache
client/connect.lock
client/coverage
client/libpeerconnection.log
client/npm-debug.log
client/yarn-error.log
client/testem.log
client/typings
client/yarn.lock
package-lock.json
# System Files
client/.DS_Store
client/Thumbs.db

View File

@ -1,36 +1,66 @@
language: python
dist: xenial dist: xenial
sudo: true sudo: true
cache:
pip: true
yarn: true
python:
- "3.5"
- "3.6"
- "3.7"
env:
- TRAVIS_NODE_VERSION="10.5"
before_install:
- nvm install $TRAVIS_NODE_VERSION
- curl -o- -L https://yarnpkg.com/install.sh | bash
- export PATH="$HOME/.yarn/bin:$PATH"
install:
- pip install --upgrade setuptools pip
- pip install --upgrade --requirement requirements.txt
- pip freeze
- yarn
- node_modules/.bin/gulp --production
script:
- flake8 openslides tests
- isort --check-only --recursive openslides tests
- python -m mypy openslides/
- node_modules/.bin/gulp jshint
- node_modules/.bin/karma start --browsers PhantomJS tests/karma/karma.conf.js
- DJANGO_SETTINGS_MODULE='tests.settings' coverage run ./manage.py test tests.unit matrix:
- coverage report --fail-under=35 include:
- language: python
cache:
pip: true
python:
- "3.6"
install:
- python --version
- pip install --upgrade setuptools pip
- pip install --upgrade --requirement requirements/development.txt
- pip install --upgrade .[big_mode]
- pip freeze
script:
- flake8 openslides tests
- isort --check-only --diff --recursive openslides tests
- python -m mypy openslides/
- pytest tests/old/ tests/integration/ tests/unit/ --cov --cov-fail-under=76
- DJANGO_SETTINGS_MODULE='tests.settings' coverage run ./manage.py test tests.integration - language: python
- coverage report --fail-under=73 cache:
pip: true
python:
- "3.7"
install:
- python --version
- pip install --upgrade setuptools pip
- pip install --upgrade --requirement requirements/development.txt
- pip install --upgrade .[big_mode]
- pip freeze
script:
- flake8 openslides tests
- isort --check-only --diff --recursive openslides tests
- python -m mypy openslides/
- pytest tests/old/ tests/integration/ tests/unit/ --cov --cov-fail-under=76
- DJANGO_SETTINGS_MODULE='tests.settings' ./manage.py test tests.old - language: node_js
node_js:
- "9"
apt:
sources:
- google-chrome
packages:
- google-chrome-stable
cache:
yarn: true
directories:
- $HOME/.yarn-cache
- node_modules
before_install:
- sh -e /etc/init.d/xvfb start
- export CHROME_BIN=/usr/bin/google-chrome
- export DISPLAY=:99.0
- curl -o- -L https://yarnpkg.com/install.sh | bash
- export PATH="$HOME/.yarn/bin:$PATH"
- yarn global add @angular/cli
- ng --version
- cd client
install:
- yarn install
script:
- yarn run lint
- yarn run test --watch=false

View File

@ -4,6 +4,28 @@
https://openslides.org/ https://openslides.org/
Version 3.0 (unreleased)
========================
Core:
- Changed personal settings.py, updated to channels2, complete rework of
startup and caching system, dropped support for Geiss [#3796, #3789].
- Dropped support for Python 3.5 [#3805].
- Added a websocket protocol for server client communication using
JSON schema [#3807].
- Changed URL schema [#3798].
- Enabled docs for using OpenSlides with Gunicorn and Uvicorn in big
mode [#3799, #3817].
Motions:
- Option to customly sort motions [#3894].
- Added support for adding a statute [#3894].
User:
- Added new admin group which grants all permissions. Users of existing group
'Admin' or 'Staff' are move to the new group during migration [#3859].
Version 2.3 (2018-09-20) Version 2.3 (2018-09-20)
======================== ========================
`Release notes <https://github.com/OpenSlides/OpenSlides/wiki/OpenSlides-2.3>`_ · `Release notes <https://github.com/OpenSlides/OpenSlides/wiki/OpenSlides-2.3>`_ ·

View File

@ -15,7 +15,7 @@ Installation and start of the development version
a. Check requirements a. Check requirements
''''''''''''''''''''' '''''''''''''''''''''
Make sure that you have installed `Python (>= 3.5) <https://www.python.org/>`_, Make sure that you have installed `Python (>= 3.6) <https://www.python.org/>`_,
`Node.js (>=4.x) <https://nodejs.org/>`_, `Yarn <https://yarnpkg.com/>`_ and `Node.js (>=4.x) <https://nodejs.org/>`_, `Yarn <https://yarnpkg.com/>`_ and
`Git <http://git-scm.com/>`_ on your system. You also need build-essential `Git <http://git-scm.com/>`_ on your system. You also need build-essential
packages and header files and a static library for Python. packages and header files and a static library for Python.
@ -25,9 +25,6 @@ For Ubuntu 16.04 e. g. follow `Yarn installation instructions
$ sudo apt-get install git nodejs nodejs-legacy npm build-essential python3-dev $ sudo apt-get install git nodejs nodejs-legacy npm build-essential python3-dev
*Note: For Ubuntu 14.04 you have to update Node.js before. The distribution
version is 0.10.25 which is not sufficient.*
b. Get OpenSlides source code b. Get OpenSlides source code
''''''''''''''''''''''''''''' '''''''''''''''''''''''''''''
@ -78,7 +75,7 @@ To get help on the command line options run::
Later you might want to restart the server with one of the following commands. Later you might want to restart the server with one of the following commands.
To start OpenSlides with Daphne and one worker and to avoid opening new browser To start OpenSlides with Daphne and to avoid opening new browser
windows run:: windows run::
$ python manage.py start --no-browser $ python manage.py start --no-browser
@ -87,16 +84,10 @@ When debugging something email related change the email backend to console::
$ python manage.py start --debug-email $ python manage.py start --debug-email
To start OpenSlides with Daphne and four workers (avoid concurrent write To start OpenSlides with Daphne run::
requests or use PostgreSQL, see below) run::
$ python manage.py runserver $ python manage.py runserver
To start OpenSlides with Geiss and one worker and to avoid opening new browser
windows (download Geiss and setup Redis before, see below) run::
$ python manage.py start --no-browser --use-geiss
Use gulp watch in a second command-line interface:: Use gulp watch in a second command-line interface::
$ node_modules/.bin/gulp watch $ node_modules/.bin/gulp watch
@ -108,7 +99,7 @@ Use gulp watch in a second command-line interface::
Follow the instructions above (Installation on GNU/Linux or Mac OS X) but care Follow the instructions above (Installation on GNU/Linux or Mac OS X) but care
of the following variations. of the following variations.
To get Python download and run the latest `Python 3.5 32-bit (x86) executable To get Python download and run the latest `Python 3.7 32-bit (x86) executable
installer <https://www.python.org/downloads/windows/>`_. Note that the 32-bit installer <https://www.python.org/downloads/windows/>`_. Note that the 32-bit
installer is required even on a 64-bit Windows system. If you use the 64-bit installer is required even on a 64-bit Windows system. If you use the 64-bit
installer, step d. of the instruction might fail unless you installed some installer, step d. of the instruction might fail unless you installed some
@ -152,8 +143,7 @@ OpenSlides in big mode
In the so called big mode you should use OpenSlides with Redis, PostgreSQL and a In the so called big mode you should use OpenSlides with Redis, PostgreSQL and a
webserver like Apache HTTP Server or nginx as proxy server in front of your webserver like Apache HTTP Server or nginx as proxy server in front of your
OpenSlides interface server. Optionally you can use `Geiss OpenSlides interface server.
<https://github.com/ostcar/geiss/>`_ as interface server instead of Daphne.
1. Install and configure PostgreSQL and Redis 1. Install and configure PostgreSQL and Redis
@ -173,15 +163,8 @@ Then add database user and database. For Ubuntu 16.04 e. g. run::
$ sudo -u postgres createdb --owner=openslides openslides $ sudo -u postgres createdb --owner=openslides openslides
2. Install additional packages
------------------------------
Install some more required Python packages:: 2. Change OpenSlides settings
$ pip install -r requirements_big_mode.txt
3. Change OpenSlides settings
----------------------------- -----------------------------
Create OpenSlides settings file if it does not exist:: Create OpenSlides settings file if it does not exist::
@ -197,34 +180,26 @@ Populate your new database::
$ python manage.py migrate $ python manage.py migrate
4. Run OpenSlides 3. Run OpenSlides
----------------- -----------------
First start e. g. four workers (do not use the `--threads` option, because the threads will not spawn across all cores):: To start gunicorn with uvicorn as protocol server run::
$ python manage.py runworker&
$ python manage.py runworker&
$ python manage.py runworker&
$ python manage.py runworker&
To start Daphne as protocol server run::
$ export DJANGO_SETTINGS_MODULE=settings $ export DJANGO_SETTINGS_MODULE=settings
$ export PYTHONPATH=personal_data/var/ $ export PYTHONPATH=personal_data/var/
$ daphne openslides.asgi:channel_layer $ gunicorn -w 4 -k uvicorn.workers.UvicornWorker openslides.asgi:application
To use Geiss instead of Daphne, just download Geiss and start it:: This example uses 4 instances. The recommendation is to use CPU cores * 2.
$ python manage.py getgeiss
$ ./personal_data/var/geiss
5. Use Nginx (optional) 4. Use Nginx (optional)
-----------------------
When using Nginx as a proxy for delivering staticfiles the performance of the setup will increase very much. For delivering staticfiles you have to collect those:: When using Nginx as a proxy for delivering staticfiles the performance of the setup will increase very much. For delivering staticfiles you have to collect those::
$ python manage.py collectstatic $ python manage.py collectstatic
This is an example configuration for a single Daphne/Geiss listen on port 8000:: This is an example configuration for a single Daphne listen on port 8000::
server { server {
listen 80; listen 80;
@ -232,9 +207,6 @@ This is an example configuration for a single Daphne/Geiss listen on port 8000::
server_name _; server_name _;
location ~* ^/(?!ws|wss|webclient|core/servertime|core/version|users/whoami|users/login|users/logout|users/setpassword|motions/docxtemplate|agenda/docxtemplate|projector|real-projector|static|media|rest).*$ {
rewrite ^.*$ /static/templates/index.html;
}
location ~* ^/projector.*$ { location ~* ^/projector.*$ {
rewrite ^.*$ /static/templates/projector-container.html; rewrite ^.*$ /static/templates/projector-container.html;
} }
@ -247,6 +219,9 @@ This is an example configuration for a single Daphne/Geiss listen on port 8000::
location /static { location /static {
alias <your path to>/collected-static; alias <your path to>/collected-static;
} }
location ~* ^/(?!ws|wss|media|rest|views).*$ {
rewrite ^.*$ /static/templates/index.html;
}
location / { location / {
proxy_pass http://localhost:8000; proxy_pass http://localhost:8000;
@ -258,10 +233,3 @@ This is an example configuration for a single Daphne/Geiss listen on port 8000::
proxy_set_header X-Scheme $scheme; proxy_set_header X-Scheme $scheme;
} }
} }
Using Nginx as a load balancer is fairly easy. Just start multiple Daphnes/Geiss on different ports, change the `proxy_pass` to `http://openslides/` and add this on top of the Nginx configuration::
upstream openslides {
server localhost:2001;
server localhost:2002;
}

View File

@ -1,6 +1,7 @@
FROM python:3.5 FROM python:3.7-slim
RUN apt-get -y update && apt-get -y upgrade RUN apt-get -y update && \
RUN apt-get install -y libpq-dev supervisor curl vim apt-get -y upgrade && \
apt-get install -y libpq-dev supervisor curl wget xz-utils bzip2 git gcc
RUN useradd -m openslides RUN useradd -m openslides
## BUILD JS STUFF ## BUILD JS STUFF
@ -18,7 +19,7 @@ RUN node_modules/.bin/gulp --production
# INSTALL PYTHON DEPENDENCIES # INSTALL PYTHON DEPENDENCIES
USER root USER root
RUN pip install -r /app/requirements_big_mode.txt RUN pip install .[big_mode]
## Clean up ## Clean up
RUN apt-get remove -y python3-pip wget curl RUN apt-get remove -y python3-pip wget curl

View File

@ -2,8 +2,8 @@ include AUTHORS
include CHANGELOG include CHANGELOG
include LICENSE include LICENSE
include README.rst include README.rst
include requirements_production.txt include requirements/production.txt
include requirements_big_mode.txt include requirements/big_mode.txt
include bower.json include bower.json
recursive-include openslides *.* recursive-include openslides *.*
exclude openslides/__pycache__/* exclude openslides/__pycache__/*

View File

@ -26,7 +26,7 @@ Installation
a. Check requirements a. Check requirements
''''''''''''''''''''' '''''''''''''''''''''
Make sure that you have installed `Python (>= 3.5) <https://www.python.org/>`_ Make sure that you have installed `Python (>= 3.6) <https://www.python.org/>`_
on your system. on your system.
Additional you need build-essential packages, header files and a static Additional you need build-essential packages, header files and a static
@ -72,7 +72,7 @@ compressed tar archive and run::
$ pip install openslides-x.y.tar.gz $ pip install openslides-x.y.tar.gz
This will install all required Python packages (see This will install all required Python packages (see
``requirements_production.txt``). ``requirements/production.txt``).
d. Start OpenSlides d. Start OpenSlides
@ -139,23 +139,21 @@ file (usually called settings.py).
The configuration values that have to be altered are: The configuration values that have to be altered are:
* CACHES
* CHANNEL_LAYERS * CHANNEL_LAYERS
* DATABASES * DATABASES
* SESSION_ENGINE * SESSION_ENGINE
* REDIS_ADDRESS
You should use a webserver like Apache HTTP Server or nginx to serve the You should use a webserver like Apache HTTP Server or nginx to serve the
static and media files as proxy server in front of your OpenSlides static and media files as proxy server in front of your OpenSlides
interface server. You also should use a database like PostgreSQL and Redis interface server. You also should use a database like PostgreSQL and Redis
as channels backend, cache backend and session engine. Finally you should as channels backend, cache backend and session engine. Finally you should
start some WSGI workers and one or more interface servers (Daphne or Geiss). use gunicorn with uvicorn as interface server.
Please see the respective section in the `DEVELOPMENT.rst Please see the respective section in the `DEVELOPMENT.rst
<https://github.com/OpenSlides/OpenSlides/blob/master/DEVELOPMENT.rst>`_ and: <https://github.com/OpenSlides/OpenSlides/blob/master/DEVELOPMENT.rst>`_ and:
* https://channels.readthedocs.io/en/latest/deploying.html * https://channels.readthedocs.io/en/latest/deploying.html
* https://github.com/ostcar/geiss
* https://docs.djangoproject.com/en/1.10/topics/cache/
* https://github.com/sebleier/django-redis-cache * https://github.com/sebleier/django-redis-cache
* https://docs.djangoproject.com/en/1.10/ref/settings/#databases * https://docs.djangoproject.com/en/1.10/ref/settings/#databases
@ -165,7 +163,7 @@ Used software
OpenSlides uses the following projects or parts of them: OpenSlides uses the following projects or parts of them:
* Several Python packages (see ``requirements_production.txt``). * Several Python packages (see ``requirements/production.txt`` and ``requirements/big_mode.txt``).
* Several JavaScript packages (see ``bower.json``) * Several JavaScript packages (see ``bower.json``)

13
client/.editorconfig Normal file
View File

@ -0,0 +1,13 @@
# Editor configuration, see http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
max_line_length = off
trim_trailing_whitespace = false

8
client/.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"printWidth": 120,
"singleQuote": true,
"useTabs": false,
"tabWidth": 4,
"semi": true,
"bracketSpacing": true
}

56
client/README.md Normal file
View File

@ -0,0 +1,56 @@
# OpenSlides 3 Client
Prototype application for OpenSlides 3.0 (Client).
Currently under constant heavy maintenance.
## Development Info
As an Angular project, Angular CLI is highly recommended to create components and services.
See https://angular.io/guide/quickstart for details.
### Contribution Info
Please respect the code-style defined in `.editorconf` and `.pretierrc`.
Code alignment should be automatically corrected by the pre-commit hooks.
Adjust your editor to the `.editorconfig` to avoid surprises.
See https://editorconfig.org/ for details.
### Pre-Commit Hooks
Before commiting, new code will automatically be aligned to the definitions set in the
`.prettierrc`.
Furthermore, new code has to pass linting.
Our pre-commit hooks are:
`pretty-quick --staged` and `lint`
See `package.json` for details.
### Documentation Info
The documentation can be generated by running `npm run compodoc`.
A new web server will be started on http://localhost:8080
Once running, the documentation will be updated automatically.
You can run it on another port, with adding your local port after the
command. If no port specified, it will try to use 8080.
Please document new code using JSDoc tags.
See https://compodoc.app/guides/jsdoc-tags.html for details.
### Development server
Run `npm start` for a development server. Navigate to `http://localhost:4200/`.
The app will automatically reload if you change any of the source files.
A running OpenSlides (2.2 or higher) instance is expected on port 8000.
Start OpenSlides as usual using
`python manage.py start --no-browser --host 0.0.0.0`
### Translation
We are using ngx-translate for translation purposes.
Use `npm run extract` to extract strings and update elements an with translation functions.
Language files can be found in `/src/assets/i18n`.

115
client/angular.json Normal file
View File

@ -0,0 +1,115 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"client": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"prefix": "os",
"schematics": {
"@schematics/angular:component": {
"styleext": "scss"
}
},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/client",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss", "node_modules/material-design-icons/iconfont/material-icons.css"],
"scripts": []
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "client:build"
},
"configurations": {
"production": {
"browserTarget": "client:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "client:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.spec.json",
"karmaConfig": "src/karma.conf.js",
"styles": ["src/styles.css"],
"scripts": [],
"assets": ["src/favicon.ico", "src/assets"]
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": "src/tsconfig.spec.json",
"format": "stylish",
"exclude": ["**/node_modules/**"]
}
}
}
},
"client-e2e": {
"root": "e2e/",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "client:serve"
},
"configurations": {
"production": {
"devServerTarget": "client:serve:production"
}
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": "e2e/tsconfig.e2e.json",
"exclude": ["**/node_modules/**"]
}
}
}
}
},
"defaultProject": "client"
}

View File

@ -0,0 +1,26 @@
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter } = require('jasmine-spec-reporter');
exports.config = {
allScriptsTimeout: 11000,
specs: ['./src/**/*.e2e-spec.ts'],
capabilities: {
browserName: 'chrome'
},
directConnect: true,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
onPrepare() {
require('ts-node').register({
project: require('path').join(__dirname, './tsconfig.e2e.json')
});
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
}
};

View File

@ -0,0 +1,14 @@
import { AppPage } from './app.po';
describe('workspace-project App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', () => {
page.navigateTo();
expect(page.getParagraphText()).toEqual('Welcome to client!');
});
});

11
client/e2e/src/app.po.ts Normal file
View File

@ -0,0 +1,11 @@
import { browser, by, element } from 'protractor';
export class AppPage {
navigateTo() {
return browser.get('/');
}
getParagraphText() {
return element(by.css('os-root h1')).getText();
}
}

View File

@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
"module": "commonjs",
"target": "es5",
"types": ["jasmine", "jasminewd2", "node"]
}
}

13266
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

68
client/package.json Normal file
View File

@ -0,0 +1,68 @@
{
"name": "OpenSlides3-Client",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json --host=0.0.0.0 --aot",
"build": "ng build",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e",
"compodoc": "./node_modules/.bin/compodoc --hideGenerator -p src/tsconfig.app.json -n 'OpenSlides Documentation' -d ../Compodoc -s -w -t -o --port",
"extract": "ngx-translate-extract -i ./src -o ./src/assets/i18n/{en,de,fr}.json --clean --sort --format-indentation ' ' --format namespaced-json",
"format:fix": "pretty-quick --staged",
"precommit": "run-s format:fix lint"
},
"private": true,
"dependencies": {
"@angular/animations": "^7.0.0-rc.0",
"@angular/cdk": "^7.0.0-beta.2",
"@angular/common": "^7.0.0-rc.0",
"@angular/compiler": "^7.0.0-rc.0",
"@angular/core": "^7.0.0-rc.0",
"@angular/forms": "^7.0.0-rc.0",
"@angular/http": "^7.0.0-rc.0",
"@angular/material": "^7.0.0-beta.2",
"@angular/platform-browser": "^7.0.0-rc.0",
"@angular/platform-browser-dynamic": "^7.0.0-rc.0",
"@angular/router": "^7.0.0-rc.0",
"@ngx-pwa/local-storage": "^6.1.1",
"@ngx-translate/core": "^10.0.2",
"@ngx-translate/http-loader": "^3.0.1",
"core-js": "^2.5.4",
"material-design-icons": "^3.0.1",
"ngx-mat-select-search": "^1.4.0",
"roboto-fontface": "^0.10.0",
"rxjs": "^6.3.3",
"uuid": "^3.3.2",
"zone.js": "^0.8.26"
},
"devDependencies": {
"@angular-devkit/build-angular": "^0.7.0",
"@angular/cli": "^7.0.0-beta.4",
"@angular/compiler-cli": "^7.0.0-rc.0",
"@angular/language-service": "^7.0.0-rc.0",
"@biesbjerg/ngx-translate-extract": "^2.3.4",
"@compodoc/compodoc": "^1.1.5",
"@types/jasmine": "~2.8.6",
"@types/jasminewd2": "^2.0.4",
"@types/node": "~8.9.4",
"codelyzer": "~4.2.1",
"husky": "^0.14.3",
"jasmine-core": "~2.99.1",
"jasmine-spec-reporter": "~4.2.1",
"karma": "^3.0.0",
"karma-chrome-launcher": "~2.2.0",
"karma-coverage-istanbul-reporter": "^2.0.4",
"karma-jasmine": "~1.1.1",
"karma-jasmine-html-reporter": "^0.2.2",
"npm-run-all": "^4.1.3",
"prettier": "^1.14.3",
"pretty-quick": "^1.7.0",
"protractor": "^5.4.1",
"ts-node": "~5.0.1",
"tslint": "~5.9.1",
"tsutils": "^3.0.0",
"typescript": "^3.1.1"
}
}

19
client/proxy.conf.json Normal file
View File

@ -0,0 +1,19 @@
{
"/apps/": {
"target": "http://localhost:8000",
"secure": false
},
"/media/": {
"target": "http://localhost:8000",
"secure": false
},
"/rest/": {
"target": "http://localhost:8000",
"secure": false
},
"/ws/site/": {
"target": "ws://localhost:8000",
"secure": false,
"ws": true
}
}

View File

@ -0,0 +1,13 @@
import { AppRoutingModule } from './app-routing.module';
describe('AppRoutingModule', () => {
let appRoutingModule: AppRoutingModule;
beforeEach(() => {
appRoutingModule = new AppRoutingModule();
});
it('should create an instance', () => {
expect(appRoutingModule).toBeTruthy();
});
});

View File

@ -0,0 +1,32 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { LoginComponent } from './site/login/components/login-wrapper/login.component';
import { LoginMaskComponent } from './site/login/components/login-mask/login-mask.component';
import { LoginLegalNoticeComponent } from './site/login/components/login-legal-notice/login-legal-notice.component';
import { LoginPrivacyPolicyComponent } from './site/login/components/login-privacy-policy/login-privacy-policy.component';
/**
* Global app routing
*/
const routes: Routes = [
{
path: 'login',
component: LoginComponent,
children: [
{ path: '', component: LoginMaskComponent },
{ path: 'legalnotice', component: LoginLegalNoticeComponent },
{ path: 'privacypolicy', component: LoginPrivacyPolicyComponent }
]
},
{ path: 'projector', loadChildren: './projector-container/projector-container.module#ProjectorContainerModule' },
{ path: '', loadChildren: './site/site.module#SiteModule' },
{ path: '**', redirectTo: '' }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule {}

View File

@ -0,0 +1,3 @@
<div class="content">
<router-outlet></router-outlet>
</div>

View File

@ -0,0 +1,3 @@
.content {
flex: 1;
}

View File

@ -0,0 +1,15 @@
import { TestBed, async } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { E2EImportsModule } from './../e2e-imports.module';
describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
it('should create the app', async(() => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
}));
});

View File

@ -0,0 +1,75 @@
import { Component } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { OperatorService } from './core/services/operator.service';
import { LoginDataService } from './core/services/login-data.service';
import { ConfigService } from './core/services/config.service';
import { ConstantsService } from './core/services/constants.service';
/**
* Angular's global App Component
*/
@Component({
selector: 'os-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
/**
* Master-component of all apps.
*
* Inits the translation service, the operator, the login data and the constants.
*
* Handles the altering of Array.toString()
*
* @param autoupdateService
* @param notifyService
* @param translate
*/
public constructor(
translate: TranslateService,
operator: OperatorService,
configService: ConfigService,
loginDataService: LoginDataService,
constantsService: ConstantsService // Needs to be started, so it can register itself to the WebsocketService
) {
// manually add the supported languages
translate.addLangs(['en', 'de', 'fr']);
// this language will be used as a fallback when a translation isn't found in the current language
translate.setDefaultLang('en');
// get the browsers default language
const browserLang = translate.getBrowserLang();
// try to use the browser language if it is available. If not, uses english.
translate.use(translate.getLangs().includes(browserLang) ? browserLang : 'en');
// change default JS functions
this.overloadArrayToString();
}
/**
* Function to alter the normal Array.toString - function
*
* Will add a whitespace after a comma and shorten the output to
* three strings.
*
* TODO: There might be a better place for overloading functions than app.component
* TODO: Overloading can be extended to more functions.
*/
private overloadArrayToString(): void {
Array.prototype.toString = function(): string {
let string = '';
const iterations = Math.min(this.length, 3);
for (let i = 0; i <= iterations; i++) {
if (i < iterations) {
string += this[i];
}
if (i < iterations - 1) {
string += ', ';
} else if (i === iterations && this.length > iterations) {
string += ', ...';
}
}
return string;
};
}
}

View File

@ -0,0 +1,61 @@
// angular modules
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { NgModule, APP_INITIALIZER } from '@angular/core';
import { HttpClientModule, HttpClient, HttpClientXsrfModule } from '@angular/common/http';
// Elementary App Components
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { CoreModule } from './core/core.module';
// translation module.
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { PruningTranslationLoader } from './core/pruning-loader';
import { LoginModule } from './site/login/login.module';
import { AppLoadService } from './core/services/app-load.service';
/**
* For the translation module. Loads a Custom 'translation loader' and provides it as loader.
* @param http Just the HttpClient to load stuff
*/
export function HttpLoaderFactory(http: HttpClient): PruningTranslationLoader {
return new PruningTranslationLoader(http);
}
/**
* Returns a function that returns a promis that will be resolved, if all apps are loaded.
* @param appLoadService The service that loads the apps.
*/
export function AppLoaderFactory(appLoadService: AppLoadService): () => Promise<void> {
return () => appLoadService.loadApps();
}
/**
* Global App Module. Keep it as clean as possible.
*/
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
HttpClientModule,
HttpClientXsrfModule.withOptions({
cookieName: 'OpenSlidesCsrfToken',
headerName: 'X-CSRFToken'
}),
BrowserAnimationsModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useFactory: HttpLoaderFactory,
deps: [HttpClient]
}
}),
AppRoutingModule,
CoreModule,
LoginModule
],
providers: [{ provide: APP_INITIALIZER, useFactory: AppLoaderFactory, deps: [AppLoadService], multi: true }],
bootstrap: [AppComponent]
})
export class AppModule {}

View File

@ -0,0 +1,36 @@
import { Title } from '@angular/platform-browser';
import { OpenSlidesComponent } from './openslides.component';
import { TranslateService } from '@ngx-translate/core';
/**
* Provides functionalities that will be used by most components
* currently able to set the title with the suffix ' - OpenSlides 3'
*
* A BaseComponent is an OpenSlides Component.
* Components in the 'Side'- or 'projector' Folder are BaseComponents
*/
export abstract class BaseComponent extends OpenSlidesComponent {
/**
* To manipulate the browser title bar, adds the Suffix "OpenSlides 3"
*
* Might be a config variable later at some point
*/
private titleSuffix = ' - OpenSlides 3';
/**
* Child constructor that implements the titleServices and calls Super from OpenSlidesComponent
*/
public constructor(protected titleService?: Title, protected translate?: TranslateService) {
super();
}
/**
* Set the title in web browser using angulars TitleService
* @param prefix The title prefix. Should be translated here.
* TODO Might translate the prefix here?
*/
public setTitle(prefix: string): void {
const translatedPrefix = this.translate.instant(prefix);
this.titleService.setTitle(translatedPrefix + this.titleSuffix);
}
}

View File

@ -0,0 +1,13 @@
import { CoreModule } from './core.module';
describe('CoreModule', () => {
let coreModule: CoreModule;
beforeEach(() => {
coreModule = new CoreModule(null);
});
it('should create an instance', () => {
expect(coreModule).toBeTruthy();
});
});

View File

@ -0,0 +1,52 @@
import { NgModule, Optional, SkipSelf } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Title } from '@angular/platform-browser';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
// Core Services, Directives
import { AuthGuard } from './services/auth-guard.service';
import { AuthService } from './services/auth.service';
import { AutoupdateService } from './services/autoupdate.service';
import { DataStoreService } from './services/data-store.service';
import { OperatorService } from './services/operator.service';
import { WebsocketService } from './services/websocket.service';
import { AddHeaderInterceptor } from './http-interceptor';
import { DataSendService } from './services/data-send.service';
import { ViewportService } from './services/viewport.service';
import { PromptDialogComponent } from '../shared/components/prompt-dialog/prompt-dialog.component';
/** Global Core Module. Contains all global (singleton) services
*
*/
@NgModule({
imports: [CommonModule],
providers: [
Title,
AuthGuard,
AuthService,
AutoupdateService,
DataStoreService,
DataSendService,
OperatorService,
ViewportService,
WebsocketService,
{
provide: HTTP_INTERCEPTORS,
useClass: AddHeaderInterceptor,
multi: true
}
],
entryComponents: [PromptDialogComponent]
})
export class CoreModule {
/** make sure CoreModule is imported only by one NgModule, the AppModule */
public constructor(
@Optional()
@SkipSelf()
parentModule: CoreModule
) {
if (parentModule) {
throw new Error('CoreModule is already loaded. Import only in AppModule');
}
}
}

View File

@ -0,0 +1,24 @@
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* Interceptor class for HTTP requests. Replaces all 'httpOptions' in all http.get or http.post requests.
*
* Should not need further adjustment.
*/
export class AddHeaderInterceptor implements HttpInterceptor {
/**
* Normal HttpInterceptor usage
*
* @param req Will clone the request and intercept it with our desired headers
* @param next HttpHandler will catch the response and forwards it to the original instance
*/
public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const clonedRequest = req.clone({
withCredentials: true,
headers: req.headers.set('Content-Type', 'application/json')
});
return next.handle(clonedRequest);
}
}

View File

@ -0,0 +1,55 @@
import { TranslateLoader } from '@ngx-translate/core';
import { HttpClient } from '@angular/common/http';
import { map } from 'rxjs/operators/';
/**
* Translation loader that replaces empty strings with nothing.
*
* ngx-translate-extract writes empty strings into json files.
* The problem is that these empty strings don't trigger
* the MissingTranslationHandler - they are simply empty strings...
*
*/
export class PruningTranslationLoader implements TranslateLoader {
/**
* Constructor to load the HttpClient
*
* @param http httpClient to load the translation files.
* @param prefix Path to the language files. Can be adjusted of needed
* @param suffix Suffix of the translation files. Usually '.json'.
*/
public constructor(
private http: HttpClient,
private prefix: string = '/assets/i18n/',
private suffix: string = '.json'
) {}
/**
* Loads a language file, stores the content, give it to the process function.
* @param lang language string (en, fr, de, ...)
*/
public getTranslation(lang: string): any {
return this.http.get(`${this.prefix}${lang}${this.suffix}`).pipe(map((res: Object) => this.process(res)));
}
/**
* Prevent to display empty strings as a translation.
* Falls back to the default language or simply copy the content of the key.
* @param any the content of any language file.
*/
private process(object: any): any {
const newObject = {};
for (const key in object) {
if (object.hasOwnProperty(key)) {
if (typeof object[key] === 'object') {
newObject[key] = this.process(object[key]);
} else if (typeof object[key] === 'string' && object[key] === '') {
// do not copy empty strings
} else {
newObject[key] = object[key];
}
}
}
return newObject;
}
}

View File

@ -0,0 +1,15 @@
import { TestBed, inject } from '@angular/core/testing';
import { AppLoadService } from './app-load.service';
import { E2EImportsModule } from '../../../e2e-imports.module';
describe('AppLoadService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [AppLoadService]
});
});
it('should be created', inject([AppLoadService], (service: AppLoadService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,59 @@
import { Injectable } from '@angular/core';
import { plugins } from '../../../plugins';
import { CommonAppConfig } from '../../site/common/common.config';
import { ModelConstructor, BaseModel } from '../../shared/models/base/base-model';
import { AppConfig } from '../../site/base/app-config';
import { CollectionStringModelMapperService } from './collectionStringModelMapper.service';
import { MediafileAppConfig } from '../../site/mediafiles/mediafile.config';
import { MotionsAppConfig } from '../../site/motions/motions.config';
import { ConfigAppConfig } from '../../site/config/config.config';
import { AgendaAppConfig } from '../../site/agenda/agenda.config';
import { AssignmentsAppConfig } from '../../site/assignments/assignments.config';
import { UsersAppConfig } from '../../site/users/users.config';
import { MainMenuService } from './main-menu.service';
/**
* A list of all app configurations of all delivered apps.
*/
const appConfigs: AppConfig[] = [
CommonAppConfig,
ConfigAppConfig,
AgendaAppConfig,
AssignmentsAppConfig,
MotionsAppConfig,
MediafileAppConfig,
UsersAppConfig
];
/**
* Handles all incoming and outgoing notify messages via {@link WebsocketService}.
*/
@Injectable({
providedIn: 'root'
})
export class AppLoadService {
public constructor(
private modelMapper: CollectionStringModelMapperService,
private mainMenuService: MainMenuService
) {}
public async loadApps(): Promise<void> {
if (plugins.length) {
console.log('plugins: ', plugins);
}
/*for (const pluginName of plugins) {
const plugin = await import('../../../../../plugins/' + pluginName + '/' + pluginName);
plugin.main();
}*/
appConfigs.forEach((config: AppConfig) => {
if (config.models) {
config.models.forEach(entry => {
this.modelMapper.registerCollectionElement(entry.collectionString, entry.model);
});
}
if (config.mainMenuEntries) {
this.mainMenuService.registerEntries(config.mainMenuEntries);
}
});
}
}

View File

@ -0,0 +1,48 @@
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, CanActivateChild } from '@angular/router';
import { OperatorService } from './operator.service';
/**
* Classical Auth-Guard. Checks if the user has to correct permissions to enter a page, and forwards to login if not.
*/
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate, CanActivateChild {
/**
* @param operator
*/
public constructor(private operator: OperatorService) {}
/**
* Checks of the operator has the required permission to see the state.
*
* One can set extra data to the state with `data: {basePerm: '<perm>'}` or
* `data: {basePerm: ['<perm1>', '<perm2>']}` to lock the access to users
* only with the given permission(s).
*
* @param route required by `canActivate()`
* @param state the state (URL) that the user want to access
*/
public canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
const basePerm: string | string[] = route.data.basePerm;
if (!basePerm) {
return true;
} else if (basePerm instanceof Array) {
return this.operator.hasPerms(...basePerm);
} else {
return this.operator.hasPerms(basePerm);
}
}
/**
* Calls {@method canActivate}. Should have the same logic.
* @param route
* @param state
*/
public canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
return this.canActivate(route, state);
}
}

View File

@ -0,0 +1,17 @@
import { TestBed, inject } from '@angular/core/testing';
import { AuthService } from './auth.service';
import { E2EImportsModule } from '../../../e2e-imports.module';
describe('ConfigService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [AuthService]
});
});
it('should be created', inject([AuthService], (service: AuthService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,77 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { OperatorService } from 'app/core/services/operator.service';
import { OpenSlidesComponent } from '../../openslides.component';
import { environment } from 'environments/environment';
import { User } from '../../shared/models/users/user';
import { OpenSlidesService } from './openslides.service';
/**
* The data returned by a post request to the login route.
*/
interface LoginResponse {
user_id: number;
user: User;
}
/**
* Authenticates an OpenSlides user with username and password
*/
@Injectable({
providedIn: 'root'
})
export class AuthService extends OpenSlidesComponent {
/**
* Initializes the httpClient and the {@link OperatorService}.
*
* Calls `super()` from the parent class.
* @param http HttpClient
* @param operator who is using OpenSlides
*/
public constructor(
private http: HttpClient,
private operator: OperatorService,
private OpenSlides: OpenSlidesService
) {
super();
}
/**
* Try to log in a user.
*
* Returns an observable 'user' with the correct login information or an error.
* The user will then be stored in the {@link OperatorService},
* errors will be forwarded to the parents error function.
*
* @param username
* @param password
*/
public login(username: string, password: string): Observable<LoginResponse> {
const user = {
username: username,
password: password
};
return this.http.post<LoginResponse>(environment.urlPrefix + '/users/login/', user).pipe(
tap((response: LoginResponse) => {
this.operator.user = new User(response.user);
}),
catchError(this.handleError())
) as Observable<LoginResponse>;
}
/**
* Logout function for both the client and the server.
*
* Will clear the current {@link OperatorService} and
* send a `post`-request to `/apps/users/logout/'`
*/
public logout(): void {
this.operator.user = null;
this.http.post<any>(environment.urlPrefix + '/users/logout/', {}).subscribe(() => {
this.OpenSlides.reboot();
});
}
}

View File

@ -0,0 +1,17 @@
import { TestBed, inject } from '@angular/core/testing';
import { AutoupdateService } from './autoupdate.service';
import { E2EImportsModule } from '../../../e2e-imports.module';
describe('AutoupdateService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [AutoupdateService]
});
});
it('should be created', inject([AutoupdateService], (service: AutoupdateService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,88 @@
import { Injectable } from '@angular/core';
import { OpenSlidesComponent } from 'app/openslides.component';
import { WebsocketService } from './websocket.service';
import { CollectionStringModelMapperService } from './collectionStringModelMapper.service';
import { DataStoreService } from './data-store.service';
/**
* Handles the initial update and automatic updates using the {@link WebsocketService}
* Incoming objects, usually BaseModels, will be saved in the dataStore (`this.DS`)
* This service usually creates all models
*
* The dataStore will injected over the parent class: {@link OpenSlidesComponent}.
*/
@Injectable({
providedIn: 'root'
})
export class AutoupdateService extends OpenSlidesComponent {
/**
* Constructor to create the AutoupdateService. Calls the constructor of the parent class.
* @param websocketService
*/
public constructor(
websocketService: WebsocketService,
private DS: DataStoreService,
private modelMapper: CollectionStringModelMapperService
) {
super();
websocketService.getOberservable<any>('autoupdate').subscribe(response => {
this.storeResponse(response);
});
}
/**
* Handle the answer of incoming data via {@link WebsocketService}.
*
* Bundles the data per action and collection. THis speeds up the caching in the DataStore.
*
* Detects the Class of an incomming model, creates a new empty object and assigns
* the data to it using the deserialize function.
*
* Saves models in DataStore.
*/
public storeResponse(socketResponse: any): void {
// Reorganize the autoupdate: groupy by action, then by collection. The final
// entries are the single autoupdate objects.
const autoupdate = {
changed: {},
deleted: {}
};
// Reorganize them.
socketResponse.forEach(obj => {
if (!autoupdate[obj.action][obj.collection]) {
autoupdate[obj.action][obj.collection] = [];
}
autoupdate[obj.action][obj.collection].push(obj);
});
// Delete the removed objects from the DataStore
Object.keys(autoupdate.deleted).forEach(collection => {
this.DS.remove(collection, ...autoupdate.deleted[collection].map(_obj => _obj.id));
});
// Add the objects to the DataStore.
Object.keys(autoupdate.changed).forEach(collection => {
const targetClass = this.modelMapper.getModelConstructor(collection);
if (!targetClass) {
// TODO: throw an error later..
/*throw new Error*/ console.log(`Unregistered resource ${collection}`);
return;
}
this.DS.add(...autoupdate.changed[collection].map(_obj => new targetClass(_obj.data)));
});
}
/**
* Sends a WebSocket request to the Server with the maxChangeId of the DataStore.
* The server should return an autoupdate with all new data.
*
* TODO: Wait for changeIds to be implemented on the server.
*/
public requestChanges(): void {
console.log('requesting changed objects');
// this.websocketService.send('changeIdRequest', this.DS.maxChangeId);
}
}

View File

@ -0,0 +1,15 @@
import { TestBed, inject } from '@angular/core/testing';
import { CacheService } from './cache.service';
describe('WebsocketService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [CacheService]
});
});
it('should be created', inject([CacheService], (service: CacheService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,151 @@
import { Injectable } from '@angular/core';
import { LocalStorage } from '@ngx-pwa/local-storage';
import { Observable } from 'rxjs';
/**
* Container objects for the setQueue.
*/
interface SetContainer {
key: string;
item: any;
callback: (value: boolean) => void;
}
/**
* Container objects for the removeQueue.
*/
interface RemoveContainer {
key: string;
callback: (value: boolean) => void;
}
/**
* Provides an async API to an key-value store using ngx-pwa which is internally
* using IndexedDB or localStorage as a fallback.
*/
@Injectable({
providedIn: 'root'
})
export class CacheService {
/**
* The queue of waiting set requests. Just one request (with the same key, which is
* an often case) at the time can be handeled. The SetContainer encapsulates the key,
* item and callback.
*/
private setQueue: SetContainer[] = [];
/**
* The queue of waiting remove requests. Same reason for the queue es the @name _setQueue.
*/
private removeQueue: RemoveContainer[] = [];
/**
* Constructor to create the CacheService. Needs the localStorage service.
* @param localStorage
*/
public constructor(private localStorage: LocalStorage) {}
/**
* Sets the item into the store asynchronously.
* @param key
* @param item
* @param callback An optional callback that is called on success
*/
public set(key: string, item: any, callback?: (value: boolean) => void): void {
if (!callback) {
callback = () => {};
}
// Put the set request into the queue
const queueObj: SetContainer = {
key: key,
item: item,
callback: callback
};
this.setQueue.unshift(queueObj);
// If this is the only object, put it into the cache.
if (this.setQueue.length === 1) {
this.localStorage.setItem(key, item).subscribe(this._setCallback.bind(this), this._error);
}
}
/**
* gets called, if a set of the first item in the queue was successful.
* @param value success
*/
private _setCallback(success: boolean): void {
// Call the callback and remove the object from the queue
this.setQueue[0].callback(success);
this.setQueue.pop();
// If there are objects left, insert the first one into the cache.
if (this.setQueue.length > 0) {
const queueObj = this.setQueue[0];
this.localStorage.setItem(queueObj.key, queueObj.item).subscribe(this._setCallback.bind(this), this._error);
}
}
/**
* get a value from the store. You need to subscribe to the request to retrieve the value.
* @param key The key to get the value from
*/
public get<T>(key: string): Observable<T> {
return this.localStorage.getItem<T>(key);
}
/**
* Remove the key from the store.
* @param key The key to remove the value from
* @param callback An optional callback that is called on success
*/
public remove(key: string, callback?: (value: boolean) => void): void {
if (!callback) {
callback = () => {};
}
// Put the remove request into the queue
const queueObj: RemoveContainer = {
key: key,
callback: callback
};
this.removeQueue.unshift(queueObj);
// If this is the only object, remove it from the cache.
if (this.removeQueue.length === 1) {
this.localStorage.removeItem(key).subscribe(this._removeCallback.bind(this), this._error);
}
}
/**
* gets called, if a remove of the first item in the queue was successfull.
* @param value success
*/
private _removeCallback(success: boolean): void {
// Call the callback and remove the object from the queue
this.removeQueue[0].callback(success);
this.removeQueue.pop();
// If there are objects left, remove the first one from the cache.
if (this.removeQueue.length > 0) {
const queueObj = this.removeQueue[0];
this.localStorage.removeItem(queueObj.key).subscribe(this._removeCallback.bind(this), this._error);
}
}
/**
* Clear the whole cache
* @param callback An optional callback that is called on success
*/
public clear(callback?: (value: boolean) => void): void {
if (!callback) {
callback = () => {};
}
this.localStorage.clear().subscribe(callback, this._error);
}
/**
* First error catching function.
*/
private _error(): void {
console.error('caching error', arguments);
}
}

View File

@ -0,0 +1,3 @@
describe('CollectionStringModelMapperService', () => {
beforeEach(() => {});
});

View File

@ -0,0 +1,64 @@
import { Injectable } from '@angular/core';
import { ModelConstructor, BaseModel } from '../../shared/models/base/base-model';
/**
* Registeres the mapping of collection strings <--> actual types. Every Model should register itself here.
*/
@Injectable({
providedIn: 'root'
})
export class CollectionStringModelMapperService {
/**
* Mapps collection strings to model constructors. Accessed by {@method registerCollectionElement} and
* {@method getCollectionStringType}.
*/
private static collectionStringsTypeMapping: { [collectionString: string]: ModelConstructor<BaseModel> } = {};
/**
* Returns the collection string of a given ModelConstructor or undefined, if it is not registered.
* @param ctor
* @deprecated Should inject this service and don't use the static functions.
*/
public static getCollectionString(ctor: ModelConstructor<BaseModel>): string {
return Object.keys(CollectionStringModelMapperService.collectionStringsTypeMapping).find(
(collectionString: string) => {
return ctor === CollectionStringModelMapperService.collectionStringsTypeMapping[collectionString];
}
);
}
/**
* Constructor to create the NotifyService. Registers itself to the WebsocketService.
* @param websocketService
*/
public constructor() {}
/**
* Registers the type to the collection string
* @param collectionString
* @param type
*/
public registerCollectionElement(collectionString: string, type: ModelConstructor<BaseModel>): void {
CollectionStringModelMapperService.collectionStringsTypeMapping[collectionString] = type;
}
/**
* Returns the constructor of the requested collection or undefined, if it is not registered.
* @param collectionString the requested collection
*/
public getModelConstructor(collectionString: string): ModelConstructor<BaseModel> {
return CollectionStringModelMapperService.collectionStringsTypeMapping[collectionString];
}
/**
* Returns the collection string of a given ModelConstructor or undefined, if it is not registered.
* @param ctor
*/
public getCollectionString(ctor: ModelConstructor<BaseModel>): string {
return Object.keys(CollectionStringModelMapperService.collectionStringsTypeMapping).find(
(collectionString: string) => {
return ctor === CollectionStringModelMapperService.collectionStringsTypeMapping[collectionString];
}
);
}
}

View File

@ -0,0 +1,15 @@
import { TestBed, inject } from '@angular/core/testing';
import { ConfigService } from './config.service';
describe('ConfigService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ConfigService]
});
});
it('should be created', inject([ConfigService], (service: ConfigService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,76 @@
import { Injectable } from '@angular/core';
import { OpenSlidesComponent } from 'app/openslides.component';
import { Observable, BehaviorSubject } from 'rxjs';
import { Config } from '../../shared/models/core/config';
import { DataStoreService } from './data-store.service';
/**
* Handler for config variables.
*
* @example
* ```ts
* this.configService.get('general_event_name').subscribe(value => {
* console.log(value);
* });
* ```
*
* @example
* ```ts
* const value = this.configService.instant('general_event_name');
* ```
*/
@Injectable({
providedIn: 'root'
})
export class ConfigService extends OpenSlidesComponent {
/**
* Stores a subject per key. Values are published, if the DataStore gets an update.
*/
private configSubjects: { [key: string]: BehaviorSubject<any> } = {};
/**
* Listen for changes of config variables.
*/
public constructor(private DS: DataStoreService) {
super();
this.DS.changeObservable.subscribe(data => {
// on changes notify the observers for specific keys.
if (data instanceof Config && this.configSubjects[data.key]) {
this.configSubjects[data.key].next(data.value);
}
});
}
/**
* Get the constant named by key from the DataStore. If the DataStore isn't up to date or
* not filled via autoupdates the results may be wrong/empty. Use this with caution.
*
* Usefull for synchronos code, e.g. during generation of PDFs, when the DataStore is filled.
*
* @param key The config value to get from.
*/
public instant(key: string): any {
const values = this.DS.filter<Config>('core/config', value => value.key === key);
if (values.length > 1) {
throw new Error('More keys found then expected');
} else if (values.length === 1) {
return values[0].value;
} else {
return;
}
}
/**
* Get an observer for the config value given by the key.
*
* @param key The config value to get from.
*/
public get(key: string): Observable<any> {
if (!this.configSubjects[key]) {
this.configSubjects[key] = new BehaviorSubject<any>(this.instant(key));
}
return this.configSubjects[key].asObservable();
}
}

View File

@ -0,0 +1,17 @@
import { TestBed, inject } from '@angular/core/testing';
import { ConstantsService } from './constants.service';
import { E2EImportsModule } from '../../../e2e-imports.module';
describe('ConstantsService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [ConstantsService]
});
});
it('should be created', inject([ConstantsService], (service: ConstantsService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,97 @@
import { Injectable } from '@angular/core';
import { OpenSlidesComponent } from 'app/openslides.component';
import { WebsocketService } from './websocket.service';
import { Observable, of, Subject } from 'rxjs';
/**
* constants have a key associated with the data.
*/
interface Constants {
[key: string]: any;
}
/**
* Get constants from the server.
*
* @example
* ```ts
* this.constantsService.get('OpenSlidesSettings').subscribe(constant => {
* console.log(constant);
* });
* ```
*/
@Injectable({
providedIn: 'root'
})
export class ConstantsService extends OpenSlidesComponent {
/**
* The constants
*/
private constants: Constants;
/**
* Flag, if the websocket connection is open.
*/
private websocketOpen = false;
/**
* Flag, if constants are requested, but the server hasn't send them yet.
*/
private pending = false;
/**
* Pending requests will be notified by these subjects, one per key.
*/
private pendingSubject: { [key: string]: Subject<any> } = {};
/**
* @param websocketService
*/
public constructor(private websocketService: WebsocketService) {
super();
// The hook for recieving constants.
websocketService.getOberservable<Constants>('constants').subscribe(constants => {
this.constants = constants;
if (this.pending) {
// send constants to subscribers that await constants.
this.pending = false;
Object.keys(this.pendingSubject).forEach(key => {
this.pendingSubject[key].next(this.constants[key]);
});
}
});
// We can request constants, if the websocket connection opens.
websocketService.connectEvent.subscribe(() => {
if (!this.websocketOpen && this.pending) {
this.websocketService.send('constants', {});
}
this.websocketOpen = true;
});
}
/**
* Get the constant named by key.
* @param key The constant to get.
*/
public get(key: string): Observable<any> {
if (this.constants) {
return of(this.constants[key]);
} else {
// we have to request constants.
if (!this.pending) {
this.pending = true;
// if the connection is open, we directly can send the request.
if (this.websocketOpen) {
this.websocketService.send('constants', {});
}
}
if (!this.pendingSubject[key]) {
this.pendingSubject[key] = new Subject<any>();
}
return this.pendingSubject[key].asObservable();
}
}
}

View File

@ -0,0 +1,17 @@
import { TestBed, inject } from '@angular/core/testing';
import { DataSendService } from './data-send.service';
import { E2EImportsModule } from '../../../e2e-imports.module';
describe('DataSendService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [DataSendService]
});
});
it('should be created', inject([DataSendService], (service: DataSendService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,89 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BaseModel } from '../../shared/models/base/base-model';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
/**
* Send data back to server
*
* Contrast to dataStore service
*/
@Injectable({
providedIn: 'root'
})
export class DataSendService {
/**
* Construct a DataSendService
*
* @param http The HTTP Client
*/
public constructor(private http: HttpClient) {}
/**
* Sends a post request with the model to the server.
* Usually for new Models
*/
public createModel(model: BaseModel): Observable<BaseModel> {
return this.http.post<BaseModel>('rest/' + model.collectionString + '/', model).pipe(
tap(
response => {
// TODO: Message, Notify, Etc
console.log('New Model added. Response ::\n', response);
},
error => console.error('createModel has returned an Error:\n', error)
)
);
}
/**
* Function to change a model on the server.
*
* @param model the base model that is meant to be changed
* @param method the required http method. might be put or patch
*/
public updateModel(model: BaseModel, method: 'put' | 'patch'): Observable<BaseModel> {
const restPath = `rest/${model.collectionString}/${model.id}`;
let httpMethod;
if (method === 'patch') {
httpMethod = this.http.patch<BaseModel>(restPath, model);
} else if (method === 'put') {
httpMethod = this.http.put<BaseModel>(restPath, model);
}
return httpMethod.pipe(
tap(
response => {
// TODO: Message, Notify, Etc
console.log('Update model. Response ::\n', response);
},
error => console.error('updateModel has returned an Error:\n', error)
)
);
}
/**
* Deletes the given model on the server
*
* @param model the BaseModel that shall be removed
* @return Observable of BaseModel
*
* TODO Not tested
*/
public delete(model: BaseModel): Observable<BaseModel> {
if (model.id) {
return this.http.delete<BaseModel>('rest/' + model.collectionString + '/' + model.id).pipe(
tap(
response => {
// TODO: Message, Notify, Etc
console.log('the response: ', response);
},
error => console.error('error during delete: ', error)
)
);
} else {
console.error('No model ID to delete');
}
}
}

View File

@ -0,0 +1,11 @@
import { TestBed } from '@angular/core/testing';
import { DataStoreService } from './data-store.service';
describe('DS', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [DataStoreService]
});
});
});

View File

@ -0,0 +1,338 @@
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model';
import { CacheService } from './cache.service';
import { CollectionStringModelMapperService } from './collectionStringModelMapper.service';
/**
* Represents information about a deleted model.
*
* As the model doesn't exist anymore, just the former id and collection is known.
*/
export interface DeletedInformation {
collection: string;
id: number;
}
/**
* represents a collection on the Django server, uses an ID to access a {@link BaseModel}.
*
* Part of {@link DataStoreService}
*/
interface ModelCollection {
[id: number]: BaseModel;
}
/**
* Represents a serialized collection.
*/
interface JsonCollection {
[id: number]: string;
}
/**
* The actual storage that stores collections, accessible by strings.
*
* {@link DataStoreService}
*/
interface ModelStorage {
[collectionString: string]: ModelCollection;
}
/**
* A storage of serialized collection elements.
*/
interface JsonStorage {
[collectionString: string]: JsonCollection;
}
/**
* All mighty DataStore that comes with all OpenSlides components.
* Use this.DS in an OpenSlides Component to Access the store.
* Used by a lot of components, classes and services.
* Changes can be observed
*/
@Injectable({
providedIn: 'root'
})
export class DataStoreService {
private static cachePrefix = 'DS:';
/** We will store the data twice: One as instances of the actual models in the _store
* and one serialized version in the _serializedStore for the cache. Both should be updated in
* all cases equal!
*/
private modelStore: ModelStorage = {};
private JsonStore: JsonStorage = {};
/**
* Observable subject for changed models in the datastore.
*/
private changedSubject: Subject<BaseModel> = new Subject<BaseModel>();
/**
* Observe the datastore for changes.
*
* @return an observable for changed models
*/
public get changeObservable(): Observable<BaseModel> {
return this.changedSubject.asObservable();
}
/**
* Observable subject for changed models in the datastore.
*/
private deletedSubject: Subject<DeletedInformation> = new Subject<DeletedInformation>();
/**
* Observe the datastore for deletions.
*
* @return an observable for deleted objects.
*/
public get deletedObservable(): Observable<DeletedInformation> {
return this.deletedSubject.asObservable();
}
/**
* The maximal change id from this DataStore.
*/
private _maxChangeId = 0;
/**
* returns the maxChangeId of the DataStore.
*/
public get maxChangeId(): number {
return this._maxChangeId;
}
/**
* Empty constructor for dataStore
* @param cacheService use CacheService to cache the DataStore.
*/
public constructor(private cacheService: CacheService, private modelMapper: CollectionStringModelMapperService) {}
/**
* Gets the DataStore from cache and instantiate all models out of the serialized version.
*/
public initFromCache(): Promise<number> {
// This promise will be resolved with the maximal change id of the cache.
return new Promise<number>(resolve => {
this.cacheService.get<JsonStorage>(DataStoreService.cachePrefix + 'DS').subscribe((store: JsonStorage) => {
if (store != null) {
// There is a store. Deserialize it
this.JsonStore = store;
this.modelStore = this.deserializeJsonStore(this.JsonStore);
// Get the maxChangeId from the cache
this.cacheService
.get<number>(DataStoreService.cachePrefix + 'maxChangeId')
.subscribe((maxChangeId: number) => {
if (maxChangeId == null) {
maxChangeId = 0;
}
this._maxChangeId = maxChangeId;
resolve(maxChangeId);
});
} else {
// No store here, so get all data from the server.
resolve(0);
}
});
});
}
/**
* Deserialze the given serializedStorage and returns a Storage.
*/
private deserializeJsonStore(serializedStore: JsonStorage): ModelStorage {
const storage: ModelStorage = {};
Object.keys(serializedStore).forEach(collectionString => {
storage[collectionString] = {} as ModelCollection;
const target = this.modelMapper.getModelConstructor(collectionString);
if (target) {
Object.keys(serializedStore[collectionString]).forEach(id => {
const data = JSON.parse(serializedStore[collectionString][id]);
storage[collectionString][id] = new target(data);
});
}
});
return storage;
}
/**
* Clears the complete DataStore and Cache.
* @param callback
*/
public clear(callback?: (value: boolean) => void): void {
this.modelStore = {};
this.JsonStore = {};
this._maxChangeId = 0;
this.cacheService.remove(DataStoreService.cachePrefix + 'DS', () => {
this.cacheService.remove(DataStoreService.cachePrefix + 'maxChangeId', callback);
});
}
private getCollectionString<T extends BaseModel<T>>(collectionType: ModelConstructor<T> | string): string {
if (typeof collectionType === 'string') {
return collectionType;
} else {
return this.modelMapper.getCollectionString(collectionType);
}
}
/**
* Read one model based on the collection and id from the DataStore.
*
* @param collectionType The desired BaseModel or collectionString to be read from the dataStore
* @param ids One ID of the BaseModel
* @return The given BaseModel-subclass instance
* @example: this.DS.get(User, 1)
* @example: this.DS.get<Countdown>('core/countdown', 2)
*/
public get<T extends BaseModel<T>>(collectionType: ModelConstructor<T> | string, id: number): T {
const collectionString = this.getCollectionString<T>(collectionType);
const collection: ModelCollection = this.modelStore[collectionString];
if (!collection) {
return;
} else {
return collection[id] as T;
}
}
/**
* Read multiple ID's from dataStore.
*
* @param collectionType The desired BaseModel or collectionString to be read from the dataStore
* @param ids Multiple IDs as a list of IDs of BaseModel
* @return The BaseModel-list corresponding to the given ID(s)
* @example: this.DS.getMany(User, [1,2,3,4,5])
* @example: this.DS.getMany<User>('users/user', [1,2,3,4,5])
*/
public getMany<T extends BaseModel<T>>(collectionType: ModelConstructor<T> | string, ids: number[]): T[] {
const collectionString = this.getCollectionString<T>(collectionType);
const collection: ModelCollection = this.modelStore[collectionString];
if (!collection) {
return [];
}
const models = ids
.map(id => {
return collection[id];
})
.filter(model => !!model); // remove non valid models.
return models as T[];
}
/**
* Get all models of the given collection from the DataStore.
*
* @param collectionType The desired BaseModel or collectionString to be read from the dataStore
* @return The BaseModel-list of all instances of T
* @example: this.DS.getAll(User)
* @example: this.DS.getAll<User>('users/user')
*/
public getAll<T extends BaseModel<T>>(collectionType: ModelConstructor<T> | string): T[] {
const collectionString = this.getCollectionString<T>(collectionType);
const collection: ModelCollection = this.modelStore[collectionString];
if (!collection) {
return [];
} else {
return Object.values(collection);
}
}
/**
* Filters the dataStore by type.
*
* @param collectionType The desired BaseModel type to be read from the dataStore
* @param callback a filter function
* @return The BaseModel-list corresponding to the filter function
* @example this.DS.filter<User>(User, myUser => myUser.first_name === "Max")
*/
public filter<T extends BaseModel<T>>(
collectionType: ModelConstructor<T> | string,
callback: (model: T) => boolean
): T[] {
return this.getAll<T>(collectionType).filter(callback);
}
/**
* Add one or multiple models to dataStore.
*
* @param ...models The model(s) that shall be add use spread operator ("...")
* @example this.DS.add(new User(1))
* @example this.DS.add((new User(2), new User(3)))
* @example this.DS.add(...arrayWithUsers)
*/
public add(...models: BaseModel[]): void {
const maxChangeId = 0;
models.forEach(model => {
const collectionString = model.collectionString;
if (!model.id) {
throw new Error('The model must have an id!');
} else if (collectionString === 'invalid-collection-string') {
throw new Error('Cannot save a BaseModel');
}
if (this.modelStore[collectionString] === undefined) {
this.modelStore[collectionString] = {};
}
this.modelStore[collectionString][model.id] = model;
if (this.JsonStore[collectionString] === undefined) {
this.JsonStore[collectionString] = {};
}
this.JsonStore[collectionString][model.id] = JSON.stringify(model);
// if (model.changeId > maxChangeId) {maxChangeId = model.maxChangeId;}
this.changedSubject.next(model);
});
this.storeToCache(maxChangeId);
}
/**
* removes one or multiple models from dataStore.
*
* @param Type The desired BaseModel type to be read from the dataStore
* @param ...ids An or multiple IDs or a list of IDs of BaseModels. use spread operator ("...") for arrays
* @example this.DS.remove('users/user', myUser.id, 3, 4)
*/
public remove(collectionString: string, ...ids: number[]): void {
const maxChangeId = 0;
ids.forEach(id => {
if (this.modelStore[collectionString]) {
// get changeId from store
// if (model.changeId > maxChangeId) {maxChangeId = model.maxChangeId;}
delete this.modelStore[collectionString][id];
}
if (this.JsonStore[collectionString]) {
delete this.JsonStore[collectionString][id];
}
this.deletedSubject.next({
collection: collectionString,
id: id
});
});
this.storeToCache(maxChangeId);
}
/**
* Updates the cache by inserting the serialized DataStore. Also changes the chageId, if it's larger
* @param maxChangeId
*/
private storeToCache(maxChangeId: number): void {
this.cacheService.set(DataStoreService.cachePrefix + 'DS', this.JsonStore);
if (maxChangeId > this._maxChangeId) {
this._maxChangeId = maxChangeId;
this.cacheService.set(DataStoreService.cachePrefix + 'maxChangeId', maxChangeId);
}
}
/**
* Prints the whole dataStore
* @deprecated Shouldn't be used, will be removed later
*/
public printWhole(): void {
console.log('Everything in DataStore: ', this.modelStore);
}
}

View File

@ -0,0 +1,15 @@
import { TestBed, inject } from '@angular/core/testing';
import { LoginDataService } from './login-data.service';
describe('LoginDataService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [LoginDataService]
});
});
it('should be created', inject([LoginDataService], (service: LoginDataService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,70 @@
import { Injectable } from '@angular/core';
import { OpenSlidesComponent } from 'app/openslides.component';
import { ConfigService } from './config.service';
import { BehaviorSubject, Observable } from 'rxjs';
/**
* This service holds the privacy policy and the legal notice, so they are available
* even if the user is not logged in.
*/
@Injectable({
providedIn: 'root'
})
export class LoginDataService extends OpenSlidesComponent {
/**
* Holds the privacy policy
*/
private _privacy_policy = new BehaviorSubject<string>('');
/**
* Returns an observable for the privacy policy
*/
public get privacy_policy(): Observable<string> {
return this._privacy_policy.asObservable();
}
/**
* Holds the legal notice
*/
private _legal_notice = new BehaviorSubject<string>('');
/**
* Returns an observable for the legal notice
*/
public get legal_notice(): Observable<string> {
return this._legal_notice.asObservable();
}
/**
* Constructs this service. The config service is needed to update the privacy
* policy and legal notice, when their config values change.
* @param configService
*/
public constructor(private configService: ConfigService) {
super();
this.configService.get('general_event_privacy_policy').subscribe(value => {
this.setPrivacyPolicy(value);
});
this.configService.get('general_event_legal_notice').subscribe(value => {
this.setLegalNotice(value);
});
}
/**
* Setter for the privacy policy
* @param privacyPolicy The new privacy policy to set
*/
public setPrivacyPolicy(privacyPolicy: string): void {
this._privacy_policy.next(privacyPolicy);
}
/**
* Setter for the legal notice
* @param legalNotice The new legal notice to set
*/
public setLegalNotice(legalNotice: string): void {
this._legal_notice.next(legalNotice);
}
}

View File

@ -0,0 +1,15 @@
import { TestBed, inject } from '@angular/core/testing';
import { MainMenuService } from './main-menu.service';
describe('MainMenuService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [MainMenuService]
});
});
it('should be created', inject([MainMenuService], (service: MainMenuService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,61 @@
import { Injectable } from '@angular/core';
/**
* This represents one entry in the main menu
*/
export interface MainMenuEntry {
/**
* The route for the router to navigate to on click.
*/
route: string;
/**
* The display string to be shown.
*/
displayName: string;
/**
* The font awesom icon to display.
*/
icon: string;
/**
* For sorting the entries.
*/
weight: number;
/**
* The permission to see the entry.
*/
permission: string;
}
/**
* Collects main menu entries and provides them to the main menu component.
*/
@Injectable({
providedIn: 'root'
})
export class MainMenuService {
/**
* A list of sorted entries.
*/
private _entries: MainMenuEntry[] = [];
/**
* Make the entries public.
*/
public get entries(): MainMenuEntry[] {
return this._entries;
}
public constructor() {}
/**
* Adds entries to the mainmenu.
* @param entries The entries to add
*/
public registerEntries(entries: MainMenuEntry[]): void {
this._entries.push(...entries);
this._entries = this._entries.sort((a, b) => a.weight - b.weight);
}
}

View File

@ -0,0 +1,17 @@
import { TestBed, inject } from '@angular/core/testing';
import { NotifyService } from './notify.service';
import { E2EImportsModule } from '../../../e2e-imports.module';
describe('NotifyService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [NotifyService]
});
});
it('should be created', inject([NotifyService], (service: NotifyService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,42 @@
import { Injectable } from '@angular/core';
import { OpenSlidesComponent } from 'app/openslides.component';
import { WebsocketService } from './websocket.service';
interface NotifyFormat {
id: number; // Dummy
}
/**
* Handles all incoming and outgoing notify messages via {@link WebsocketService}.
*/
@Injectable({
providedIn: 'root'
})
export class NotifyService extends OpenSlidesComponent {
/**
* Constructor to create the NotifyService. Registers itself to the WebsocketService.
* @param websocketService
*/
public constructor(private websocketService: WebsocketService) {
super();
websocketService.getOberservable<any>('notify').subscribe(notify => {
this.receive(notify);
});
}
// TODO: Implement this
private receive(notify: NotifyFormat): void {
console.log('recv', notify);
// TODO: Use a Subject, so one can subscribe and get notifies.
}
// TODO: Make this api better: e.g. send(data, users?, projectors?, channel?, ...)
/**
* Sents a notify object to the server
* @param notify the notify objects
*/
public send(notify: NotifyFormat): void {
this.websocketService.send('notify', notify);
}
}

View File

@ -0,0 +1,17 @@
import { TestBed, inject } from '@angular/core/testing';
import { OpenSlidesService } from './openslides.service';
import { E2EImportsModule } from '../../../e2e-imports.module';
describe('OpenSlidesService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [OpenSlidesService]
});
});
it('should be created', inject([OpenSlidesService], (service: OpenSlidesService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,139 @@
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { OpenSlidesComponent } from 'app/openslides.component';
import { WebsocketService } from './websocket.service';
import { OperatorService } from './operator.service';
import { CacheService } from './cache.service';
import { AutoupdateService } from './autoupdate.service';
import { DataStoreService } from './data-store.service';
/**
* Handles the bootup/showdown of this application.
*/
@Injectable({
providedIn: 'root'
})
export class OpenSlidesService extends OpenSlidesComponent {
/**
* if the user tries to access a certain URL without being authenticated, the URL will be stored here
*/
public redirectUrl: string;
/**
* Constructor to create the NotifyService. Registers itself to the WebsocketService.
* @param cacheService
* @param operator
* @param websocketService
* @param router
* @param autoupdateService
*/
public constructor(
private cacheService: CacheService,
private operator: OperatorService,
private websocketService: WebsocketService,
private router: Router,
private autoupdateService: AutoupdateService,
private DS: DataStoreService
) {
super();
// Handler that gets called, if the websocket connection reconnects after a disconnection.
// There might have changed something on the server, so we check the operator, if he changed.
websocketService.reconnectEvent.subscribe(() => {
this.checkOperator();
});
this.bootup();
}
/**
* the bootup-sequence: Do a whoami request and if it was successful, do
* {@method afterLoginBootup}. If not, redirect the user to the login page.
*/
public bootup(): void {
// start autoupdate if the user is logged in:
this.operator.whoAmI().subscribe(resp => {
this.operator.guestsEnabled = resp.guest_enabled;
if (!resp.user && !resp.guest_enabled) {
this.redirectUrl = location.pathname;
// Goto login, if the user isn't login and guests are not allowed
this.router.navigate(['/login']);
} else {
this.afterLoginBootup(resp.user_id);
}
});
}
/**
* the login bootup-sequence: Check (and maybe clear) the cache und setup the DataStore
* and websocket.
* @param userId
*/
public afterLoginBootup(userId: number): void {
// Else, check, which user was logged in last time
this.cacheService.get<number>('lastUserLoggedIn').subscribe((id: number) => {
// if the user id changed, reset the cache.
if (userId !== id) {
this.DS.clear((value: boolean) => {
this.setupDataStoreAndWebSocket();
});
this.cacheService.set('lastUserLoggedIn', userId);
} else {
this.setupDataStoreAndWebSocket();
}
});
}
/**
* Init DS from cache and after this start the websocket service.
*/
private setupDataStoreAndWebSocket(): void {
this.DS.initFromCache().then((changeId: number) => {
this.websocketService.connect(
false,
changeId
);
});
}
/**
* SHuts down OpenSlides. The websocket is closed and the operator is not set.
*/
public shutdown(): void {
this.websocketService.close();
this.operator.user = null;
}
/**
* Shutdown and bootup.
*/
public reboot(): void {
this.shutdown();
this.bootup();
}
/**
* Verify that the operator is the same as it was before a reconnect.
*/
private checkOperator(): void {
this.operator.whoAmI().subscribe(resp => {
// User logged off.
if (!resp.user && !resp.guest_enabled) {
this.shutdown();
this.router.navigate(['/login']);
} else {
if (
(this.operator.user && this.operator.user.id !== resp.user_id) ||
(!this.operator.user && resp.user_id)
) {
// user changed
this.reboot();
} else {
// User is still the same, but check for missed autoupdates.
this.autoupdateService.requestChanges();
}
}
});
}
}

View File

@ -0,0 +1,17 @@
import { TestBed, inject } from '@angular/core/testing';
import { OperatorService } from './operator.service';
import { E2EImportsModule } from '../../../e2e-imports.module';
describe('OperatorService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [OperatorService]
});
});
it('should be created', inject([OperatorService], (service: OperatorService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,157 @@
import { Injectable } from '@angular/core';
import { Observable, BehaviorSubject } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { tap, catchError } from 'rxjs/operators';
import { OpenSlidesComponent } from 'app/openslides.component';
import { Group } from 'app/shared/models/users/group';
import { User } from '../../shared/models/users/user';
import { environment } from 'environments/environment';
import { DataStoreService } from './data-store.service';
/**
* Permissions on the client are just strings. This makes clear, that
* permissions instead of arbitrary strings should be given.
*/
export type Permission = string;
/**
* Response format of the WHoAMI request.
*/
interface WhoAmIResponse {
user_id: number;
guest_enabled: boolean;
user: User;
}
/**
* The operator represents the user who is using OpenSlides.
*
* Changes in operator can be observed, directives do so on order to show
* or hide certain information.
*
* The operator is an {@link OpenSlidesComponent}.
*/
@Injectable({
providedIn: 'root'
})
export class OperatorService extends OpenSlidesComponent {
/**
* The operator.
*/
private _user: User;
/**
* Get the user that corresponds to operator.
*/
public get user(): User {
return this._user;
}
/**
* Sets the current operator.
*
* The permissions are updated and the new user published.
*/
public set user(user: User) {
this._user = user;
this.updatePermissions();
}
/**
* Save, if quests are enabled.
*/
public guestsEnabled: boolean;
/**
* The permissions of the operator. Updated via {@method updatePermissions}.
*/
private permissions: Permission[] = [];
/**
* The subject that can be observed by other instances using observing functions.
*/
private operatorSubject: BehaviorSubject<User> = new BehaviorSubject<User>(null);
/**
* @param http HttpClient
*/
public constructor(private http: HttpClient, private DS: DataStoreService) {
super();
this.DS.changeObservable.subscribe(newModel => {
if (this._user) {
if (newModel instanceof Group) {
this.updatePermissions();
}
if (newModel instanceof User && this._user.id === newModel.id) {
this._user = newModel;
this.updatePermissions();
}
} else if (newModel instanceof Group && newModel.id === 1) {
// Group 1 (default) for anonymous changed
this.updatePermissions();
}
});
}
/**
* Calls `/apps/users/whoami` to find out the real operator.
*/
public whoAmI(): Observable<WhoAmIResponse> {
return this.http.get<WhoAmIResponse>(environment.urlPrefix + '/users/whoami/').pipe(
tap((response: WhoAmIResponse) => {
if (response && response.user_id) {
this.user = new User(response.user);
}
}),
catchError(this.handleError())
) as Observable<WhoAmIResponse>;
}
/**
* Returns the operatorSubject as an observable.
*
* Services an components can use it to get informed when something changes in
* the operator
*/
public getObservable(): Observable<User> {
return this.operatorSubject.asObservable();
}
/**
* Checks, if the operator has at least one of the given permissions.
* @param checkPerms The permissions to check, if at least one matches.
*/
public hasPerms(...checkPerms: Permission[]): boolean {
if (this._user && this._user.groups_id.includes(2)) {
return true;
}
return checkPerms.some(permission => {
return this.permissions.includes(permission);
});
}
/**
* Update the operators permissions and publish the operator afterwards.
*/
private updatePermissions(): void {
this.permissions = [];
if (!this.user) {
const defaultGroup = this.DS.get<Group>('users/group', 1);
if (defaultGroup && defaultGroup.permissions instanceof Array) {
this.permissions = defaultGroup.permissions;
}
} else {
const permissionSet = new Set();
this.DS.getMany(Group, this.user.groups_id).forEach(group => {
group.permissions.forEach(permission => {
permissionSet.add(permission);
});
});
this.permissions = Array.from(permissionSet.values());
}
// publish changes in the operator.
this.operatorSubject.next(this.user);
}
}

View File

@ -0,0 +1,17 @@
import { TestBed, inject } from '@angular/core/testing';
import { PromptService } from './prompt.service';
import { E2EImportsModule } from 'e2e-imports.module';
describe('PromptService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [PromptService]
});
});
it('should be created', inject([PromptService], (service: PromptService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
import { OpenSlidesComponent } from 'app/openslides.component';
import { PromptDialogComponent } from '../../shared/components/prompt-dialog/prompt-dialog.component';
import { MatDialog } from '@angular/material';
/**
* A general service for prompting 'yes' or 'cancel' thinks from the user.
*/
@Injectable({
providedIn: 'root'
})
export class PromptService extends OpenSlidesComponent {
public constructor(private dialog: MatDialog) {
super();
}
/**
* Opens the dialog. Returns true, if the user accepts.
* @param title The title to display in the dialog
* @param content The content in the dialog
*/
public async open(title: string, content: string): Promise<any> {
const dialogRef = this.dialog.open(PromptDialogComponent, {
width: '250px',
data: { title: title, content: content }
});
return dialogRef.afterClosed().toPromise();
}
}

View File

@ -0,0 +1,15 @@
import { TestBed, inject } from '@angular/core/testing';
import { ViewportService } from './viewport.service';
describe('ViewportService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ViewportService]
});
});
it('should be created', inject([ViewportService], (service: ViewportService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,58 @@
import { Injectable } from '@angular/core';
import { BreakpointObserver, Breakpoints, BreakpointState } from '@angular/cdk/layout';
/**
* Viewport Service
*
* Uses breakpoint observers to determine the size of the users/operators viewport size (the device)
*
* ## Example:
*
* Provide the service via constructor and just use it like
*
* ```html
* <div *ngIf="!vp.isMobile">Will only be shown of not mobile</div>
* ```
* or
* ```ts
* if (this.vp.isMobile) {
* ...
* }
* ```
*/
@Injectable({
providedIn: 'root'
})
export class ViewportService {
/**
* True if Viewport equals mobile or small resolution.
*/
private _isMobile = false;
/**
* Get the BreakpointObserver
*
* @param breakpointObserver
*/
public constructor(private breakpointObserver: BreakpointObserver) {}
/**
* Needs to be called (exactly) once.
* Will observe breakpoints and updates the _isMobile variable
*/
public checkForChange(): void {
this.breakpointObserver
.observe([Breakpoints.Small, Breakpoints.HandsetPortrait])
.subscribe((state: BreakpointState) => {
if (state.matches) {
this._isMobile = true;
} else {
this._isMobile = false;
}
});
}
public get isMobile(): boolean {
return this._isMobile;
}
}

View File

@ -0,0 +1,17 @@
import { TestBed, inject } from '@angular/core/testing';
import { WebsocketService } from './websocket.service';
import { E2EImportsModule } from '../../../e2e-imports.module';
describe('WebsocketService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [WebsocketService]
});
});
it('should be created', inject([WebsocketService], (service: WebsocketService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,239 @@
import { Injectable, NgZone, EventEmitter } from '@angular/core';
import { Router } from '@angular/router';
import { Observable, Subject } from 'rxjs';
import { MatSnackBar, MatSnackBarRef, SimpleSnackBar } from '@angular/material';
import { TranslateService } from '@ngx-translate/core';
/**
* A key value mapping for params, that should be appendet to the url on a new connection.
*/
interface QueryParams {
[key: string]: string;
}
/**
* The generic message format in which messages are send and recieved by the server.
*/
interface WebsocketMessage {
type: string;
content: any;
id: string;
}
/**
* Service that handles WebSocket connections. Other services can register themselfs
* with {@method getOberservable} for a specific type of messages. The content will be published.
*/
@Injectable({
providedIn: 'root'
})
export class WebsocketService {
/**
* The reference to the snackbar entry that is shown, if the connection is lost.
*/
private connectionErrorNotice: MatSnackBarRef<SimpleSnackBar>;
/**
* Subjects that will be called, if a reconnect was successful.
*/
private _reconnectEvent: EventEmitter<void> = new EventEmitter<void>();
/**
* Getter for the reconnect event.
*/
public get reconnectEvent(): EventEmitter<void> {
return this._reconnectEvent;
}
/**
* Listeners will be nofitied, if the wesocket connection is establiched.
*/
private _connectEvent: EventEmitter<void> = new EventEmitter<void>();
/**
* Getter for the connect event.
*/
public get connectEvent(): EventEmitter<void> {
return this._connectEvent;
}
/**
* The websocket.
*/
private websocket: WebSocket;
/**
* Subjects for types of websocket messages. A subscriber can get an Observable by {@function getOberservable}.
*/
private subjects: { [type: string]: Subject<any> } = {};
/**
* Constructor that handles the router
* @param router the URL Router
*/
public constructor(
private router: Router,
private matSnackBar: MatSnackBar,
private zone: NgZone,
public translate: TranslateService
) {}
/**
* Creates a new WebSocket connection and handles incomming events.
*
* Uses NgZone to let all callbacks run in the angular context.
*/
public connect(retry: boolean = false, changeId?: number): void {
if (this.websocket) {
return;
}
const queryParams: QueryParams = {};
// comment-in if changes IDs are supported on server side.
/*if (changeId !== undefined) {
queryParams.changeId = changeId.toString();
}*/
// Create the websocket
const socketProtocol = this.getWebSocketProtocol();
const socketServer = window.location.hostname + ':' + window.location.port;
const socketPath = this.getWebSocketPath(queryParams);
this.websocket = new WebSocket(socketProtocol + socketServer + socketPath);
// connection established. If this connect attept was a retry,
// The error notice will be removed and the reconnectSubject is published.
this.websocket.onopen = (event: Event) => {
this.zone.run(() => {
if (retry) {
if (this.connectionErrorNotice) {
this.connectionErrorNotice.dismiss();
this.connectionErrorNotice = null;
}
this._reconnectEvent.emit();
}
this._connectEvent.emit();
});
};
this.websocket.onmessage = (event: MessageEvent) => {
this.zone.run(() => {
const message: WebsocketMessage = JSON.parse(event.data);
const type: string = message.type;
if (type === 'error') {
console.error('Websocket error', message.content);
} else if (this.subjects[type]) {
// Pass the content to the registered subscribers.
this.subjects[type].next(message.content);
} else {
console.log(`Got unknown websocket message type "${type}" with content`, message.content);
}
});
};
this.websocket.onclose = (event: CloseEvent) => {
this.zone.run(() => {
this.websocket = null;
if (event.code !== 1000) {
// 1000 is a normal close, like the close on logout
if (!this.connectionErrorNotice) {
// So here we have a connection failure that wasn't intendet.
this.connectionErrorNotice = this.matSnackBar.open(
this.translate.instant('Offline mode: You can use OpenSlides but changes are not saved.'),
'',
{ duration: 0 }
);
}
// A random retry timeout between 2000 and 5000 ms.
const timeout = Math.floor(Math.random() * 3000 + 2000);
setTimeout(() => {
this.connect((retry = true));
}, timeout);
}
});
};
}
/**
* Closes the websocket connection.
*/
public close(): void {
if (this.websocket) {
this.websocket.close();
this.websocket = null;
}
}
/**
* Returns an observable for messages of the given type.
* @param type the message type
*/
public getOberservable<T>(type: string): Observable<T> {
if (!this.subjects[type]) {
this.subjects[type] = new Subject<T>();
}
return this.subjects[type].asObservable();
}
/**
* Sends a message to the server with the content and the given type.
*
* @param type the message type
* @param content the actual content
*/
public send<T>(type: string, content: T, id?: string): void {
if (!this.websocket) {
return;
}
const message: WebsocketMessage = {
type: type,
content: content,
id: id
};
// create message id if not given. Required by the server.
if (!message.id) {
message.id = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
for (let i = 0; i < 8; i++) {
message.id += possible.charAt(Math.floor(Math.random() * possible.length));
}
}
this.websocket.send(JSON.stringify(message));
}
/**
* Delegates to socket-path for either the side or projector websocket.
*/
private getWebSocketPath(queryParams: QueryParams = {}): string {
// currentRoute does not end with '/'
const currentRoute = this.router.url;
let path: string;
if (currentRoute.includes('/projector') || currentRoute.includes('/real-projector')) {
path = '/ws/projector/';
} else {
path = '/ws/site/';
}
const keys: string[] = Object.keys(queryParams);
if (keys.length > 0) {
path += keys
.map(key => {
return key + '=' + queryParams[key];
})
.join('&');
}
return path;
}
/**
* returns the desired websocket protocol
*/
private getWebSocketProtocol(): string {
if (location.protocol === 'https') {
return 'wss://';
} else {
return 'ws://';
}
}
}

View File

@ -0,0 +1,27 @@
import { Observable, of } from 'rxjs';
/**
* injects the {@link DataStoreService} to all its children and provides a generic function to catch errors
* should be abstract and a mere parent to all {@link DataStoreService} accessors
*/
export abstract class OpenSlidesComponent {
/**
* Empty constructor
*
* Static injection of {@link DataStoreService} in all child instances of OpenSlidesComponent
* Throws a warning even tho it is the new syntax. Ignored for now.
*/
public constructor() {}
/**
* Generic error handling for everything that makes HTTP Calls
* TODO: could have more features
* @return an observable error
*/
public handleError<T>(): (error: any) => Observable<T> {
return (error: any): Observable<T> => {
console.error(error);
return of(error);
};
}
}

View File

@ -0,0 +1,4 @@
<p>
projector-container works!
Here an iframe with the real-projector is needed
</p>

View File

@ -0,0 +1,24 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ProjectorContainerComponent } from './projector-container.component';
describe('ProjectorContainerComponent', () => {
let component: ProjectorContainerComponent;
let fixture: ComponentFixture<ProjectorContainerComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ProjectorContainerComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ProjectorContainerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,12 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'os-projector-container',
templateUrl: './projector-container.component.html',
styleUrls: ['./projector-container.component.css']
})
export class ProjectorContainerComponent implements OnInit {
public constructor() {}
public ngOnInit(): void {}
}

View File

@ -0,0 +1,13 @@
import { ProjectorContainerModule } from './projector-container.module';
describe('ProjectorContainerModule', () => {
let projectorContainerModule: ProjectorContainerModule;
beforeEach(() => {
projectorContainerModule = new ProjectorContainerModule();
});
it('should create an instance', () => {
expect(projectorContainerModule).toBeTruthy();
});
});

View File

@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ProjectorContainerComponent } from './projector-container.component';
import { SharedModule } from 'app/shared/shared.module';
import { ProjectorComponent } from './projector/projector.component';
import { ProjectorContainerRoutingModule } from './projector/projector-container.routing.module';
@NgModule({
imports: [CommonModule, ProjectorContainerRoutingModule, SharedModule],
declarations: [ProjectorContainerComponent, ProjectorComponent]
})
export class ProjectorContainerModule {}

View File

@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ProjectorContainerComponent } from '../projector-container.component';
import { ProjectorComponent } from './projector.component';
const routes: Routes = [
{ path: '', component: ProjectorContainerComponent },
{ path: 'real', component: ProjectorComponent }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ProjectorContainerRoutingModule {}

View File

@ -0,0 +1,3 @@
<p>
projector works!
</p>

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ProjectorComponent } from './projector.component';
import { E2EImportsModule } from '../../../e2e-imports.module';
describe('ProjectorComponent', () => {
let component: ProjectorComponent;
let fixture: ComponentFixture<ProjectorComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [ProjectorComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ProjectorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,19 @@
import { Component, OnInit } from '@angular/core';
import { BaseComponent } from 'app/base.component';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'os-projector',
templateUrl: './projector.component.html',
styleUrls: ['./projector.component.css']
})
export class ProjectorComponent extends BaseComponent implements OnInit {
public constructor(titleService: Title, translate: TranslateService) {
super(titleService, translate);
}
public ngOnInit(): void {
super.setTitle('Projector');
}
}

View File

@ -0,0 +1,116 @@
import { trigger, animate, transition, style, query, stagger, group } from '@angular/animations';
export const pageTransition = trigger('pageTransition', [
transition('* => *', [
/** this will avoid the dom-copy-effect */
query(':enter, :leave', style({ position: 'absolute', width: '100%' }), { optional: true }),
/** keep the dom clean - let all items "just" enter */
query(':enter mat-card', [style({ opacity: 0 })], { optional: true }),
query(':enter .on-transition-fade', [style({ opacity: 0 })], { optional: true }),
query(':enter mat-row', [style({ opacity: 0 })], { optional: true }),
query(':enter mat-expansion-panel', [style({ opacity: 0 })], { optional: true }),
/** parallel vanishing */
group([
/** animate fade out for the selected components */
query(
':leave .on-transition-fade',
[
style({ opacity: 1 }),
animate(
'200ms ease-in-out',
style({
transform: 'translateY(0%)',
opacity: 0
})
)
],
{ optional: true }
),
/** how the material cards are leaving */
query(
':leave mat-card',
[
style({ transform: 'translateY(0%)', opacity: 1 }),
animate(
'200ms ease-in-out',
style({
transform: 'translateY(0%)',
opacity: 0
})
)
],
{ optional: true }
),
query(
':leave mat-row',
[
style({ transform: 'translateY(0%)', opacity: 1 }),
animate(
'200ms ease-in-out',
style({
transform: 'translateY(0%)',
opacity: 0
})
)
],
{ optional: true }
),
query(
':leave mat-expansion-panel',
[
style({ transform: 'translateY(0%)', opacity: 1 }),
animate(
'200ms ease-in-out',
style({
transform: 'translateY(0%)',
opacity: 0
})
)
],
{ optional: true }
)
]),
/** parallel appearing */
group([
/** animate fade in for the selected components */
query(':enter .on-transition-fade', [style({ opacity: 0 }), animate('0.2s', style({ opacity: 1 }))], {
optional: true
}),
/** how the mat cards enters the scene */
query(
':enter mat-card',
/** stagger = "one after another" with a distance of 50ms" */
stagger(50, [
style({ transform: 'translateY(50px)' }),
animate('300ms ease-in-out', style({ transform: 'translateY(0px)', opacity: 1 }))
]),
{ optional: true }
),
query(
':enter mat-row',
/** stagger = "one after another" with a distance of 50ms" */
stagger(30, [
style({ transform: 'translateY(24px)' }),
animate('200ms ease-in-out', style({ transform: 'translateY(0px)', opacity: 1 }))
]),
{ optional: true }
),
query(
':enter mat-expansion-panel',
/** stagger = "one after another" with a distance of 50ms" */
stagger(100, [
style({ transform: 'translateY(50px)' }),
animate('300ms ease-in-out', style({ transform: 'translateY(0px)', opacity: 1 }))
]),
{ optional: true }
)
])
])
]);
export const navItemAnim = trigger('navItemAnim', [
transition(':enter', [style({ transform: 'translateX(-100%)' }), animate('500ms ease')]),
transition(':leave', [style({ transform: 'translateX(100%)' }), animate('500ms ease')])
]);

View File

@ -0,0 +1,12 @@
<mat-toolbar color='primary' class="footer">
<mat-toolbar-row>
<button mat-button class="footer-link" [routerLink]=legalNoticeUrl>
<span translate>Legal Notice</span>
</button>
<button mat-button class="footer-link" [routerLink]=privacyPolicyUrl>
<span translate>Privacy Policy</span>
</button>
<span class="footer-right">© <span translate>Copyright by</span>&#160;<a href='https://openslides.org/'>OpenSlides</a>
</span>
</mat-toolbar-row>
</mat-toolbar>

View File

@ -0,0 +1,11 @@
.footer-link,
.footer-right {
font-size: 12px;
z-index: inherit;
}
.footer-right {
a {
color: white;
}
}

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { FooterComponent } from './footer.component';
import { E2EImportsModule } from '../../../../e2e-imports.module';
describe('FooterComponent', () => {
let component: FooterComponent;
let fixture: ComponentFixture<FooterComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(FooterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,47 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
/**
* Reusable footer Apps.
*
* ## Examples:
*
* ### Usage of the selector:
*
* ```html
* <os-footer></os-footer>
* ```
*/
@Component({
selector: 'os-footer',
templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss']
})
export class FooterComponent implements OnInit {
/**
* Indicates to location of the legal notice
*/
public legalNoticeUrl = '/legalnotice';
/**
* Indicated the location of the privacy policy
*/
public privacyPolicyUrl = '/privacypolicy';
/**
* Empty constructor
*/
public constructor(private route: ActivatedRoute) {}
/**
* If on login page, redirect the legal notice and privacy policy not to /URL
* but to /login/URL
*/
public ngOnInit(): void {
if (this.route.snapshot.url[0] && this.route.snapshot.url[0].path === 'login') {
this.legalNoticeUrl = '/login/legalnotice';
this.privacyPolicyUrl = '/login/privacypolicy';
}
}
}

View File

@ -0,0 +1,27 @@
<mat-toolbar color='primary'>
<button *ngIf="plusButton" class='head-button on-transition-fade' (click)=clickPlusButton()
mat-fab>
<mat-icon>add</mat-icon>
</button>
<span class='app-name on-transition-fade'>
{{ appName | translate }}
</span>
<span class='spacer'></span>
<button *ngIf="menuList" class='on-transition-fade' [matMenuTriggerFor]="ellipsisMenu" mat-icon-button>
<mat-icon>more_vert</mat-icon>
</button>
</mat-toolbar>
<mat-menu #ellipsisMenu="matMenu">
<ng-container *ngFor="let item of menuList">
<button mat-menu-item *osPerms="item.perm" (click)=clickMenu(item)>
<mat-icon *ngIf="item.icon">{{ item.icon }}</mat-icon>
{{item.text | translate}}
</button>
</ng-container>
</mat-menu>

View File

@ -0,0 +1,4 @@
.head-button {
bottom: -30px;
z-index: 100;
}

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { HeadBarComponent } from './head-bar.component';
import { E2EImportsModule } from '../../../../e2e-imports.module';
describe('HeadBarComponent', () => {
let component: HeadBarComponent;
let fixture: ComponentFixture<HeadBarComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(HeadBarComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,110 @@
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
/**
* Reusable head bar component for Apps.
*
* Will translate the title automatically.
*
* Use `PlusButton=true` and `(plusButtonClicked)=myFunction()` if a plus button is needed
*
* Use `[menuLust]=myArray` and `(ellipsisMenuItem)=myFunction($event)` if a menu is needed
*
* ## Examples:
*
* ### Usage of the selector:
*
* ```html
* <os-head-bar
* appName="Files"
* plusButton=true
* [menuList]=myMenu
* (plusButtonClicked)=onPlusButton()
* (ellipsisMenuItem)=onEllipsisItem($event)>
* </os-head-bar>
* ```
*
* ### Declaration of a menu provided as `[menuList]=myMenu`:
*
* ```ts
* myMenu = [
* {
* text: 'Download All',
* icon: 'save_alt',
* action: 'downloadAllFiles'
* },
* ];
* ```
* The parent needs to react to `action` like the following.
* This will execute a function with the name provided in the
* `action` field.
* ```ts
* onEllipsisItem(event: any) {
* if (event.action) {
* this[event.action]();
* }
* }
* ```
*/
@Component({
selector: 'os-head-bar',
templateUrl: './head-bar.component.html',
styleUrls: ['./head-bar.component.scss']
})
export class HeadBarComponent implements OnInit {
/**
* Input declaration for the app name
*/
@Input()
public appName: string;
/**
* Determine if there should be a plus button.
*/
@Input()
public plusButton: false;
/**
* If not empty shows a ellipsis menu on the right side
*
* The parent needs to provide a menu, i.e `[menuList]=myMenu`.
*/
@Input()
public menuList: any[];
/**
* Emit a signal to the parent component if the plus button was clicked
*/
@Output()
public plusButtonClicked = new EventEmitter<boolean>();
/**
* Emit a signal to the parent of an item in the menuList was selected.
*/
@Output()
public ellipsisMenuItem = new EventEmitter<any>();
/**
* Empty constructor
*/
public constructor() {}
/**
* empty onInit
*/
public ngOnInit(): void {}
/**
* Emits a signal to the parent if an item in the menu was clicked.
* @param item
*/
public clickMenu(item: any): void {
this.ellipsisMenuItem.emit(item);
}
/**
* Emits a signal to the parent if
*/
public clickPlusButton(): void {
this.plusButtonClicked.emit(true);
}
}

View File

@ -0,0 +1,24 @@
<mat-card class="os-card on-transition-fade">
<div>
<div class='legal-notice-text' [innerHtml]='legalNotice'></div>
<mat-divider></mat-divider>
<div *ngIf="versionInfo" class='version-text'>
<a [attr.href]="versionInfo.openslides_url" target="_blank">
OpenSlides {{ versionInfo.openslides_version }}
</a>
(<span translate>License</span>: {{ versionInfo.openslides_license }})
<div *ngIf="versionInfo.plugins.length">
<div translate>Installed plugins</div>:
<div *ngFor="let plugin of versionInfo.plugins">
<a [attr.href]="plugin.url" target="_blank">
{{ plugin.verbose_name }} {{ plugin.version }}
</a>
<div *ngIf="plugin.license">
(<span translate>License</span>: {{ plugin.license }})
</div>
</div>
</div>
</div>
</div>
</mat-card>

View File

@ -0,0 +1,9 @@
.legal-notice-text {
display: block;
padding-bottom: 20px;
}
.version-text {
display: block;
padding-top: 20px;
}

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { LegalNoticeContentComponent } from './legal-notice-content.component';
import { E2EImportsModule } from '../../../../e2e-imports.module';
describe('LegalNoticeComponent', () => {
let component: LegalNoticeContentComponent;
let fixture: ComponentFixture<LegalNoticeContentComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LegalNoticeContentComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,106 @@
import { Component, OnInit } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { LoginDataService } from '../../../core/services/login-data.service';
import { environment } from 'environments/environment';
import { HttpClient } from '@angular/common/http';
/**
* Characterize a plugin. This data is retrieved from the server
*/
interface PluginDescription {
/**
* The name of the plugin
*/
verbose_name: string;
/**
* the version
*/
version: string;
/**
* The url to the main webpage of the plugin
*/
url: string;
/**
* The license
*/
license: string;
}
/**
* Represents metadata about the current installation.
*/
interface VersionResponse {
/**
* The lience string. Like 'MIT', 'GPLv2', ...
*/
openslides_license: string;
/**
* The URl to the main webpage of OpenSlides.
*/
openslides_url: string;
/**
* The current version.
*/
openslides_version: string;
/**
* A list of installed plugins.
*/
plugins: PluginDescription[];
}
/**
* Shared component to hold the content of the Legal Notice.
* Used in login and site container.
*/
@Component({
selector: 'os-legal-notice-content',
templateUrl: './legal-notice-content.component.html',
styleUrls: ['./legal-notice-content.component.scss']
})
export class LegalNoticeContentComponent implements OnInit {
/**
* The legal notive text for the ui.
*/
public legalNotice: string;
/**
* Holds the version info retrieved from the server for the ui.
*/
public versionInfo: VersionResponse;
/**
* Imports the LoginDataService, the translations and and HTTP Service
* @param loginDataService
* @param translate
* @param http
*/
public constructor(
private loginDataService: LoginDataService,
private translate: TranslateService,
private http: HttpClient
) {}
/**
* Subscribes for the legal notice text.
*/
public ngOnInit(): void {
this.loginDataService.legal_notice.subscribe(legalNotice => {
if (legalNotice) {
this.legalNotice = this.translate.instant(legalNotice);
}
});
// Query the version info.
this.http
.get<VersionResponse>(environment.urlPrefix + '/core/version/', {})
.subscribe((info: VersionResponse) => {
this.versionInfo = info;
});
}
}

View File

@ -0,0 +1,6 @@
<mat-card class='os-card on-transition-fade'>
<div *ngIf='privacyPolicy' [innerHtml]='privacyPolicy'></div>
<div *ngIf='!privacyPolicy' translate>
The event manager hasn't set up a privacy policy yet.
</div>
</mat-card>

View File

@ -0,0 +1,3 @@
mat-card {
height: 100%;
}

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { PrivacyPolicyContentComponent } from './privacy-policy-content.component';
import { E2EImportsModule } from '../../../../e2e-imports.module';
describe('PrivacyPolicyComponent', () => {
let component: PrivacyPolicyContentComponent;
let fixture: ComponentFixture<PrivacyPolicyContentComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PrivacyPolicyContentComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,37 @@
import { Component, OnInit } from '@angular/core';
import { LoginDataService } from '../../../core/services/login-data.service';
import { TranslateService } from '@ngx-translate/core';
/**
* Shared component to hold the content of the Privacy Policy.
* Used in login and site container.
*/
@Component({
selector: 'os-privacy-policy-content',
templateUrl: './privacy-policy-content.component.html',
styleUrls: ['./privacy-policy-content.component.scss']
})
export class PrivacyPolicyContentComponent implements OnInit {
/**
* The actual privacy policy as string
*/
public privacyPolicy: string;
/**
* Imports the loginDataService and the translation service
* @param loginDataService Login Data
* @param translate for the translation
*/
public constructor(private loginDataService: LoginDataService, private translate: TranslateService) {}
/**
* Subscribes for the privacy policy text
*/
public ngOnInit(): void {
this.loginDataService.privacy_policy.subscribe(privacyPolicy => {
if (privacyPolicy) {
this.privacyPolicy = this.translate.instant(privacyPolicy);
}
});
}
}

View File

@ -0,0 +1,6 @@
<h2 mat-dialog-title>{{ data.title | translate }}</h2>
<mat-dialog-content>{{ data.content | translate }}</mat-dialog-content>
<mat-dialog-actions>
<button mat-button [mat-dialog-close]="true" color="warn" translate>Yes</button>
<button mat-button [mat-dialog-close]="false" translate>Cancel</button>
</mat-dialog-actions>

View File

@ -0,0 +1,26 @@
import { async, TestBed } from '@angular/core/testing';
// import { PromptDialogComponent } from './prompt-dialog.component';
import { E2EImportsModule } from 'e2e-imports.module';
describe('PromptDialogComponent', () => {
// let component: PromptDialogComponent;
// let fixture: ComponentFixture<PromptDialogComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
// TODO: You cannot create this component in the standard way. Needs different testing.
beforeEach(() => {
/*fixture = TestBed.createComponent(PromptDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();*/
});
/*it('should create', () => {
expect(component).toBeTruthy();
});*/
});

View File

@ -0,0 +1,21 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
interface PromptDialogData {
title: string;
content: string;
}
/**
* A simple prompt dialog. Takes a title and content.
*/
@Component({
selector: 'os-prompt-dialog',
templateUrl: './prompt-dialog.component.html'
})
export class PromptDialogComponent {
public constructor(
public dialogRef: MatDialogRef<PromptDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: PromptDialogData
) {}
}

View File

@ -0,0 +1,22 @@
<mat-form-field [formGroup]="form">
<mat-select [formControl]="formControl" [placeholder]="listname" [multiple]="multiple" #thisSelector>
<ngx-mat-select-search [formControl]="filterControl"></ngx-mat-select-search>
<div *ngIf="!multiple">
<mat-option [value]="null"><span translate>None</span></mat-option>
<mat-divider></mat-divider>
</div>
<mat-option *ngFor="let selectedItem of filteredItems | async" [value]="selectedItem.id">
{{selectedItem.getTitle(translate)}}
</mat-option>
</mat-select>
</mat-form-field>
<div *ngIf="dispSelected">
<p>
<span translate>Selected Values</span>:
</p>
<mat-chip-list #chipList>
<mat-chip *ngFor="let selectedItem of thisSelector?.value" (removed)="remove(selectedItem)">{{selectedItem.name}}
<mat-icon (click)="remove(selectedItem)">cancel</mat-icon>
</mat-chip>
</mat-chip-list>
</div>

View File

@ -0,0 +1,48 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchValueSelectorComponent, Selectable } from './search-value-selector.component';
import { E2EImportsModule } from '../../../../e2e-imports.module';
import { ViewChild, Component } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { FormControl, FormBuilder } from '@angular/forms';
describe('SearchValueSelectorComponent', () => {
@Component({
selector: 'os-host-component',
template: '<os-search-value-selector></os-search-value-selector>'
})
class TestHostComponent {
@ViewChild(SearchValueSelectorComponent)
public searchValueSelectorComponent: SearchValueSelectorComponent;
}
let hostComponent: TestHostComponent;
let hostFixture: ComponentFixture<TestHostComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [TestHostComponent]
}).compileComponents();
}));
beforeEach(() => {
hostFixture = TestBed.createComponent(TestHostComponent);
hostComponent = hostFixture.componentInstance;
});
it('should create', () => {
const subject: BehaviorSubject<Selectable[]> = new BehaviorSubject([]);
hostComponent.searchValueSelectorComponent.InputListValues = subject;
const formBuilder: FormBuilder = TestBed.get(FormBuilder);
const formGroup = formBuilder.group({
testArray: []
});
hostComponent.searchValueSelectorComponent.form = formGroup;
hostComponent.searchValueSelectorComponent.formControl = <FormControl>formGroup.get('testArray');
hostFixture.detectChanges();
expect(hostComponent.searchValueSelectorComponent).toBeTruthy();
});
});

Some files were not shown because too many files have changed in this diff Show More