diff --git a/.gitignore b/.gitignore index 9460a3600..0b2b03452 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ *~ # Virtual Environment -.virtualenv/* +.virtualenv*/* .venv/* # Javascript tools and libraries @@ -30,6 +30,7 @@ debug/* # Unit test and coverage reports .coverage tests/file/* +.pytest_cache # Plugin development openslides_* @@ -37,5 +38,43 @@ openslides_* # Mypy cache for typechecking .mypy_cache -# Development of a new client. Easier to switch branches with this entry -client/* +# OpenSlides 3 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 diff --git a/.travis.yml b/.travis.yml index 29b18cb04..249826f5e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,36 +1,66 @@ -language: python dist: xenial 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 - - coverage report --fail-under=35 +matrix: + 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 - - coverage report --fail-under=73 + - language: python + 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 diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6f8667f29..a72dabebb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,28 @@ 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) ======================== `Release notes `_ ยท diff --git a/DEVELOPMENT.rst b/DEVELOPMENT.rst index 80c17e036..1f7f97a54 100644 --- a/DEVELOPMENT.rst +++ b/DEVELOPMENT.rst @@ -15,7 +15,7 @@ Installation and start of the development version a. Check requirements ''''''''''''''''''''' -Make sure that you have installed `Python (>= 3.5) `_, +Make sure that you have installed `Python (>= 3.6) `_, `Node.js (>=4.x) `_, `Yarn `_ and `Git `_ on your system. You also need build-essential 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 -*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 ''''''''''''''''''''''''''''' @@ -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. -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:: $ 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 -To start OpenSlides with Daphne and four workers (avoid concurrent write -requests or use PostgreSQL, see below) run:: +To start OpenSlides with Daphne run:: $ 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:: $ 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 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 `_. Note that the 32-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 @@ -152,8 +143,7 @@ OpenSlides in big mode 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 -OpenSlides interface server. Optionally you can use `Geiss -`_ as interface server instead of Daphne. +OpenSlides interface server. 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 -2. Install additional packages ------------------------------- -Install some more required Python packages:: - - $ pip install -r requirements_big_mode.txt - - -3. Change OpenSlides settings +2. Change OpenSlides settings ----------------------------- Create OpenSlides settings file if it does not exist:: @@ -197,34 +180,26 @@ Populate your new database:: $ 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):: - - $ python manage.py runworker& - $ python manage.py runworker& - $ python manage.py runworker& - $ python manage.py runworker& - -To start Daphne as protocol server run:: +To start gunicorn with uvicorn as protocol server run:: $ export DJANGO_SETTINGS_MODULE=settings $ 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:: $ 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 { listen 80; @@ -232,9 +207,6 @@ This is an example configuration for a single Daphne/Geiss listen on port 8000:: 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.*$ { 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 { alias /collected-static; } + location ~* ^/(?!ws|wss|media|rest|views).*$ { + rewrite ^.*$ /static/templates/index.html; + } location / { 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; } } - -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; - } diff --git a/Dockerfile b/Dockerfile index 2609d37b6..74799828a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ -FROM python:3.5 -RUN apt-get -y update && apt-get -y upgrade -RUN apt-get install -y libpq-dev supervisor curl vim +FROM python:3.7-slim +RUN apt-get -y update && \ + apt-get -y upgrade && \ + apt-get install -y libpq-dev supervisor curl wget xz-utils bzip2 git gcc RUN useradd -m openslides ## BUILD JS STUFF @@ -18,7 +19,7 @@ RUN node_modules/.bin/gulp --production # INSTALL PYTHON DEPENDENCIES USER root -RUN pip install -r /app/requirements_big_mode.txt +RUN pip install .[big_mode] ## Clean up RUN apt-get remove -y python3-pip wget curl diff --git a/MANIFEST.in b/MANIFEST.in index 4d0cf794e..420be0de1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,8 +2,8 @@ include AUTHORS include CHANGELOG include LICENSE include README.rst -include requirements_production.txt -include requirements_big_mode.txt +include requirements/production.txt +include requirements/big_mode.txt include bower.json recursive-include openslides *.* exclude openslides/__pycache__/* diff --git a/README.rst b/README.rst index ad24e5110..e75215773 100644 --- a/README.rst +++ b/README.rst @@ -26,7 +26,7 @@ Installation a. Check requirements ''''''''''''''''''''' -Make sure that you have installed `Python (>= 3.5) `_ +Make sure that you have installed `Python (>= 3.6) `_ on your system. 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 This will install all required Python packages (see -``requirements_production.txt``). +``requirements/production.txt``). d. Start OpenSlides @@ -139,23 +139,21 @@ file (usually called settings.py). The configuration values that have to be altered are: -* CACHES * CHANNEL_LAYERS * DATABASES * SESSION_ENGINE +* REDIS_ADDRESS 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 interface server. You also should use a database like PostgreSQL and Redis 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 `_ and: * 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://docs.djangoproject.com/en/1.10/ref/settings/#databases @@ -165,7 +163,7 @@ Used software 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``) diff --git a/client/.editorconfig b/client/.editorconfig new file mode 100644 index 000000000..9b7352176 --- /dev/null +++ b/client/.editorconfig @@ -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 diff --git a/client/.prettierrc b/client/.prettierrc new file mode 100644 index 000000000..2230fceab --- /dev/null +++ b/client/.prettierrc @@ -0,0 +1,8 @@ +{ + "printWidth": 120, + "singleQuote": true, + "useTabs": false, + "tabWidth": 4, + "semi": true, + "bracketSpacing": true +} diff --git a/client/README.md b/client/README.md new file mode 100644 index 000000000..b2b771660 --- /dev/null +++ b/client/README.md @@ -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`. diff --git a/client/angular.json b/client/angular.json new file mode 100644 index 000000000..fbe7a6140 --- /dev/null +++ b/client/angular.json @@ -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" +} diff --git a/client/e2e/protractor.conf.js b/client/e2e/protractor.conf.js new file mode 100644 index 000000000..532a87351 --- /dev/null +++ b/client/e2e/protractor.conf.js @@ -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 } })); + } +}; diff --git a/client/e2e/src/app.e2e-spec.ts b/client/e2e/src/app.e2e-spec.ts new file mode 100644 index 000000000..c54a6664b --- /dev/null +++ b/client/e2e/src/app.e2e-spec.ts @@ -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!'); + }); +}); diff --git a/client/e2e/src/app.po.ts b/client/e2e/src/app.po.ts new file mode 100644 index 000000000..a9f1cfbac --- /dev/null +++ b/client/e2e/src/app.po.ts @@ -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(); + } +} diff --git a/client/e2e/tsconfig.e2e.json b/client/e2e/tsconfig.e2e.json new file mode 100644 index 000000000..2eee41fa5 --- /dev/null +++ b/client/e2e/tsconfig.e2e.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/app", + "module": "commonjs", + "target": "es5", + "types": ["jasmine", "jasminewd2", "node"] + } +} diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 000000000..fb4b4ab03 --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,13266 @@ +{ + "name": "OpenSlides3-Client", + "version": "0.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@angular-devkit/architect": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.7.5.tgz", + "integrity": "sha512-zwCpGdx3JDE+Y+LiWh9ErRX+fpFPTRHtEd2PDJmfQsdlIWfjxSR5U9vi3+bSRW2n6IFiH2GCYMS31R64rfMwbg==", + "dev": true, + "requires": { + "@angular-devkit/core": "0.7.5", + "rxjs": "^6.0.0" + } + }, + "@angular-devkit/build-angular": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-0.7.5.tgz", + "integrity": "sha512-FYd2RigCbvm1i0aM1p+jO2145qm56iPgcW2TK3LBxllWFoz5v+wb086/aDzATG+2ETDZO1uENiVTWu5RSkYcSw==", + "dev": true, + "requires": { + "@angular-devkit/architect": "0.7.5", + "@angular-devkit/build-optimizer": "0.7.5", + "@angular-devkit/build-webpack": "0.7.5", + "@angular-devkit/core": "0.7.5", + "@ngtools/webpack": "6.1.5", + "ajv": "~6.4.0", + "autoprefixer": "^8.4.1", + "circular-dependency-plugin": "^5.0.2", + "clean-css": "^4.1.11", + "copy-webpack-plugin": "^4.5.2", + "file-loader": "^1.1.11", + "glob": "^7.0.3", + "html-webpack-plugin": "^3.0.6", + "istanbul": "^0.4.5", + "istanbul-instrumenter-loader": "^3.0.1", + "karma-source-map-support": "^1.2.0", + "less": "^3.7.1", + "less-loader": "^4.1.0", + "license-webpack-plugin": "^1.3.1", + "loader-utils": "^1.1.0", + "mini-css-extract-plugin": "~0.4.0", + "minimatch": "^3.0.4", + "node-sass": "^4.9.3", + "opn": "^5.1.0", + "parse5": "^4.0.0", + "portfinder": "^1.0.13", + "postcss": "^6.0.22", + "postcss-import": "^11.1.0", + "postcss-loader": "^2.1.5", + "postcss-url": "^7.3.2", + "raw-loader": "^0.5.1", + "rxjs": "^6.0.0", + "sass-loader": "~6.0.7", + "semver": "^5.5.0", + "source-map-loader": "^0.2.3", + "source-map-support": "^0.5.0", + "stats-webpack-plugin": "^0.6.2", + "style-loader": "^0.21.0", + "stylus": "^0.54.5", + "stylus-loader": "^3.0.2", + "tree-kill": "^1.2.0", + "uglifyjs-webpack-plugin": "^1.2.5", + "url-loader": "^1.0.1", + "webpack": "~4.9.2", + "webpack-dev-middleware": "^3.1.3", + "webpack-dev-server": "^3.1.4", + "webpack-merge": "^4.1.2", + "webpack-sources": "^1.1.0", + "webpack-subresource-integrity": "^1.1.0-rc.4" + } + }, + "@angular-devkit/build-optimizer": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-optimizer/-/build-optimizer-0.7.5.tgz", + "integrity": "sha512-iZYUjNax6epTA4JjnDxhs6MQUtmwM04ZkJkTE3tVc01e80+wJ/f3+ja22BBVul2MsqchOsTUSQIJY3HxbV5aWw==", + "dev": true, + "requires": { + "loader-utils": "^1.1.0", + "source-map": "^0.5.6", + "typescript": "~2.9.1", + "webpack-sources": "^1.1.0" + }, + "dependencies": { + "typescript": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-2.9.2.tgz", + "integrity": "sha512-Gr4p6nFNaoufRIY4NMdpQRNmgxVIGMs4Fcu/ujdYk3nAZqk7supzBE9idmvfZIlH/Cuj//dvi+019qEue9lV0w==", + "dev": true + } + } + }, + "@angular-devkit/build-webpack": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.7.5.tgz", + "integrity": "sha512-PSkhBwJBLRMiBUGlK15CaVwbU4RzfCdF/GFS/CZSCsA3plLDJy+vXAPrUiuGvqYt/sVKBRavsNaEBCbK1t+1ig==", + "dev": true, + "requires": { + "@angular-devkit/architect": "0.7.5", + "@angular-devkit/core": "0.7.5", + "rxjs": "^6.0.0" + } + }, + "@angular-devkit/core": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-0.7.5.tgz", + "integrity": "sha512-r99BZvvuNAqSRm05jXfx0sb3Ip0zvHPtAM6NReXzWPoqaVFpjVUdj/CKA+9HWG/Zt9meG9pEQt/HKK8UXaZDVA==", + "dev": true, + "requires": { + "ajv": "~6.4.0", + "chokidar": "^2.0.3", + "rxjs": "^6.0.0", + "source-map": "^0.5.6" + } + }, + "@angular-devkit/schematics": { + "version": "7.0.0-beta.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-7.0.0-beta.4.tgz", + "integrity": "sha512-zLUWeaZ9R/vbNjUbwyLU9QWsHpVojliT2+QeSstnXaCNDvdQ82rJF0munosqzQP5nx9uTLdB6Q7gnM6Ijox3Vw==", + "dev": true, + "requires": { + "@angular-devkit/core": "7.0.0-beta.4", + "rxjs": "6.2.2" + }, + "dependencies": { + "@angular-devkit/core": { + "version": "7.0.0-beta.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-7.0.0-beta.4.tgz", + "integrity": "sha512-Yk4+u1G3qQBTaYDR6yXkCAc1Woe+h1tWCbzXPWPmzvg53Ox/47cMwMl61lCMqEShVAS/x+Ss/9mVFlPci5YSNQ==", + "dev": true, + "requires": { + "ajv": "6.5.3", + "chokidar": "2.0.4", + "fast-json-stable-stringify": "2.0.0", + "rxjs": "6.2.2", + "source-map": "0.7.3" + } + }, + "ajv": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.5.3.tgz", + "integrity": "sha512-LqZ9wY+fx3UMiiPd741yB2pj3hhil+hQc8taf4o2QGRFpWgZ2V5C8HA165DY9sS3fJwsk7uT7ZlFEyC3Ig3lLg==", + "dev": true, + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "chokidar": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.0.4.tgz", + "integrity": "sha512-z9n7yt9rOvIJrMhvDtDictKrkFHeihkNl6uWMmZlmL6tJtX9Cs+87oK+teBx+JIgzvbX3yZHT3eF8vpbDxHJXQ==", + "dev": true, + "requires": { + "anymatch": "^2.0.0", + "async-each": "^1.0.0", + "braces": "^2.3.0", + "fsevents": "^1.2.2", + "glob-parent": "^3.1.0", + "inherits": "^2.0.1", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "lodash.debounce": "^4.0.8", + "normalize-path": "^2.1.1", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.0.0", + "upath": "^1.0.5" + } + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "rxjs": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.2.2.tgz", + "integrity": "sha512-0MI8+mkKAXZUF9vMrEoPnaoHkfzBPP4IGwUYRJhIRJF6/w3uByO1e91bEHn8zd43RdkTMKiooYKmwz7RH6zfOQ==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + } + } + }, + "@angular/animations": { + "version": "7.0.0-rc.0", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-7.0.0-rc.0.tgz", + "integrity": "sha512-NpFcuCfM11O/YIGl1piH3VufOlfnJSK6iyw19ElXjw4mr/jvK4vcg9fEXbqBvmQ6uregoeadRSVCp8tdRJHOyw==", + "requires": { + "tslib": "^1.9.0" + } + }, + "@angular/cdk": { + "version": "7.0.0-beta.2", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-7.0.0-beta.2.tgz", + "integrity": "sha512-txzcJtWYbnd+Gs5ah5KojmZaRR/k3WOKJNz0NKR2FK7rnX8rfYK65FMNniakqjDPd08mpgqWVkyhJRuAeSDfGQ==", + "requires": { + "parse5": "^5.0.0", + "tslib": "^1.7.1" + }, + "dependencies": { + "parse5": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.0.tgz", + "integrity": "sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==", + "optional": true + } + } + }, + "@angular/cli": { + "version": "7.0.0-beta.4", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-7.0.0-beta.4.tgz", + "integrity": "sha512-S7Dy13R7KWXjuI3UGCK0y2w2W0Ky/XphYstFvqeLW+O8exzBmFfzKAcaP/TRVWw/ZiyG9dk9mxtAP0RzzDCjlA==", + "dev": true, + "requires": { + "@angular-devkit/architect": "0.9.0-beta.4", + "@angular-devkit/core": "7.0.0-beta.4", + "@angular-devkit/schematics": "7.0.0-beta.4", + "@schematics/angular": "7.0.0-beta.4", + "@schematics/update": "0.9.0-beta.4", + "inquirer": "6.2.0", + "opn": "5.3.0", + "rxjs": "6.2.2", + "semver": "5.5.1", + "symbol-observable": "1.2.0" + }, + "dependencies": { + "@angular-devkit/architect": { + "version": "0.9.0-beta.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.9.0-beta.4.tgz", + "integrity": "sha512-4sVeaXVD+lidQtjFSARzjPuRFY4FuO2YQBEHoq0+2QPn2pq6gIEaJP5UX/g40SRH8p4CJeCeoS98gSGJQEwGXQ==", + "dev": true, + "requires": { + "@angular-devkit/core": "7.0.0-beta.4", + "rxjs": "6.2.2" + } + }, + "@angular-devkit/core": { + "version": "7.0.0-beta.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-7.0.0-beta.4.tgz", + "integrity": "sha512-Yk4+u1G3qQBTaYDR6yXkCAc1Woe+h1tWCbzXPWPmzvg53Ox/47cMwMl61lCMqEShVAS/x+Ss/9mVFlPci5YSNQ==", + "dev": true, + "requires": { + "ajv": "6.5.3", + "chokidar": "2.0.4", + "fast-json-stable-stringify": "2.0.0", + "rxjs": "6.2.2", + "source-map": "0.7.3" + } + }, + "ajv": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.5.3.tgz", + "integrity": "sha512-LqZ9wY+fx3UMiiPd741yB2pj3hhil+hQc8taf4o2QGRFpWgZ2V5C8HA165DY9sS3fJwsk7uT7ZlFEyC3Ig3lLg==", + "dev": true, + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "chokidar": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.0.4.tgz", + "integrity": "sha512-z9n7yt9rOvIJrMhvDtDictKrkFHeihkNl6uWMmZlmL6tJtX9Cs+87oK+teBx+JIgzvbX3yZHT3eF8vpbDxHJXQ==", + "dev": true, + "requires": { + "anymatch": "^2.0.0", + "async-each": "^1.0.0", + "braces": "^2.3.0", + "fsevents": "^1.2.2", + "glob-parent": "^3.1.0", + "inherits": "^2.0.1", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "lodash.debounce": "^4.0.8", + "normalize-path": "^2.1.1", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.0.0", + "upath": "^1.0.5" + } + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "rxjs": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.2.2.tgz", + "integrity": "sha512-0MI8+mkKAXZUF9vMrEoPnaoHkfzBPP4IGwUYRJhIRJF6/w3uByO1e91bEHn8zd43RdkTMKiooYKmwz7RH6zfOQ==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, + "semver": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.1.tgz", + "integrity": "sha512-PqpAxfrEhlSUWge8dwIp4tZnQ25DIOthpiaHNIthsjEFQD6EvqUKUDM7L8O2rShkFccYo1VjJR0coWfNkCubRw==", + "dev": true + }, + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + } + } + }, + "@angular/common": { + "version": "7.0.0-rc.0", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-7.0.0-rc.0.tgz", + "integrity": "sha512-YghYg9lFKF0cxaCiWfgByFbQ69dq521QDG93KX1mP+Tvc0jXXlbolDPYHGXx/VMUaoHq18VNzi7ZInpgc/pRBw==", + "requires": { + "tslib": "^1.9.0" + } + }, + "@angular/compiler": { + "version": "7.0.0-rc.0", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-7.0.0-rc.0.tgz", + "integrity": "sha512-ifVqB/xJtSzOlk8B39Ld2wMbYni6Ey7s5jc+u/0NMtdut+2Q61Ar+TKjJZ3vmta3df7QqHX5JcP0W6qICRHJ+w==", + "requires": { + "tslib": "^1.9.0" + } + }, + "@angular/compiler-cli": { + "version": "7.0.0-rc.0", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-7.0.0-rc.0.tgz", + "integrity": "sha512-Nkd5UgSt0NHVLE/U3FIUmSJxGW47+9B4hfR5oDhC7gkUNaRQzi+PzzVYj7jOdDJjgHV+Y0KS3msiXWhUSY4gpw==", + "dev": true, + "requires": { + "canonical-path": "0.0.2", + "chokidar": "^1.4.2", + "convert-source-map": "^1.5.1", + "dependency-graph": "^0.7.2", + "magic-string": "^0.25.0", + "minimist": "^1.2.0", + "reflect-metadata": "^0.1.2", + "shelljs": "^0.8.1", + "source-map": "^0.6.1", + "yargs": "9.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "anymatch": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-1.3.2.tgz", + "integrity": "sha512-0XNayC8lTHQ2OI8aljNCN3sSx6hsr/1+rlcDAotXJR7C1oZZHCNsfpbKwMjRA3Uqb5tF1Rae2oloTr4xpq+WjA==", + "dev": true, + "requires": { + "micromatch": "^2.1.5", + "normalize-path": "^2.0.0" + } + }, + "arr-diff": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz", + "integrity": "sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=", + "dev": true, + "requires": { + "arr-flatten": "^1.0.1" + } + }, + "array-unique": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz", + "integrity": "sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=", + "dev": true + }, + "braces": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", + "integrity": "sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=", + "dev": true, + "requires": { + "expand-range": "^1.8.1", + "preserve": "^0.2.0", + "repeat-element": "^1.1.2" + } + }, + "camelcase": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", + "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=", + "dev": true + }, + "chokidar": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.7.0.tgz", + "integrity": "sha1-eY5ol3gVHIB2tLNg5e3SjNortGg=", + "dev": true, + "requires": { + "anymatch": "^1.3.0", + "async-each": "^1.0.0", + "fsevents": "^1.0.0", + "glob-parent": "^2.0.0", + "inherits": "^2.0.1", + "is-binary-path": "^1.0.0", + "is-glob": "^2.0.0", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.0.0" + } + }, + "cliui": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", + "dev": true, + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wrap-ansi": "^2.0.0" + }, + "dependencies": { + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + } + } + }, + "expand-brackets": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", + "integrity": "sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=", + "dev": true, + "requires": { + "is-posix-bracket": "^0.1.0" + } + }, + "extglob": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz", + "integrity": "sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=", + "dev": true, + "requires": { + "is-extglob": "^1.0.0" + } + }, + "glob-parent": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz", + "integrity": "sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=", + "dev": true, + "requires": { + "is-glob": "^2.0.0" + } + }, + "is-extglob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", + "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=", + "dev": true + }, + "is-glob": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", + "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", + "dev": true, + "requires": { + "is-extglob": "^1.0.0" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + }, + "load-json-file": { + "version": "2.0.0", + "resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "strip-bom": "^3.0.0" + } + }, + "micromatch": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", + "integrity": "sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=", + "dev": true, + "requires": { + "arr-diff": "^2.0.0", + "array-unique": "^0.2.1", + "braces": "^1.8.2", + "expand-brackets": "^0.1.4", + "extglob": "^0.3.1", + "filename-regex": "^2.0.0", + "is-extglob": "^1.0.0", + "is-glob": "^2.0.1", + "kind-of": "^3.0.2", + "normalize-path": "^2.0.1", + "object.omit": "^2.0.0", + "parse-glob": "^3.0.4", + "regex-cache": "^0.4.2" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + }, + "os-locale": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-2.1.0.tgz", + "integrity": "sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA==", + "dev": true, + "requires": { + "execa": "^0.7.0", + "lcid": "^1.0.0", + "mem": "^1.1.0" + } + }, + "path-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz", + "integrity": "sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM=", + "dev": true, + "requires": { + "pify": "^2.0.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "read-pkg": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", + "integrity": "sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg=", + "dev": true, + "requires": { + "load-json-file": "^2.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^2.0.0" + } + }, + "read-pkg-up": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-2.0.0.tgz", + "integrity": "sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^2.0.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "y18n": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", + "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=", + "dev": true + }, + "yargs": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-9.0.1.tgz", + "integrity": "sha1-UqzCP+7Kw0BCB47njAwAf1CF20w=", + "dev": true, + "requires": { + "camelcase": "^4.1.0", + "cliui": "^3.2.0", + "decamelize": "^1.1.1", + "get-caller-file": "^1.0.1", + "os-locale": "^2.0.0", + "read-pkg-up": "^2.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1", + "yargs-parser": "^7.0.0" + } + }, + "yargs-parser": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-7.0.0.tgz", + "integrity": "sha1-jQrELxbqVd69MyyvTEA4s+P139k=", + "dev": true, + "requires": { + "camelcase": "^4.1.0" + } + } + } + }, + "@angular/core": { + "version": "7.0.0-rc.0", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-7.0.0-rc.0.tgz", + "integrity": "sha512-DXTUjk1tUdgxj0AHQR6wAKLF+i/vSsRCBxFEzcBa944UJoYBDd1n2PIREzDMW0tkGMtxfHy3Ti+trSpPBLiDTA==", + "requires": { + "tslib": "^1.9.0" + } + }, + "@angular/forms": { + "version": "7.0.0-rc.0", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-7.0.0-rc.0.tgz", + "integrity": "sha512-ZfD2n+DojwreeP0sF4GuFrihActssogDUGGeDHge5qmyCqE/5hsOUFnNkg1pk4mO9xeIggdYygH0nRHqvifmFQ==", + "requires": { + "tslib": "^1.9.0" + } + }, + "@angular/http": { + "version": "7.0.0-rc.0", + "resolved": "https://registry.npmjs.org/@angular/http/-/http-7.0.0-rc.0.tgz", + "integrity": "sha512-f7IaVuen/WuHIKcP9mO3Jz4oy8Qxdwo3PS750Bk5VVVNBF4TILRr+96j37C7965ZBxeJQzfcGfXew7d5nObJ/A==", + "requires": { + "tslib": "^1.9.0" + } + }, + "@angular/language-service": { + "version": "7.0.0-rc.0", + "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-7.0.0-rc.0.tgz", + "integrity": "sha512-FnmPxREsffWESAu2u5pUvR8ejR5SvqhKlClnm9ruqIu/pdwHpa/lDGp9ysTkI5trVu0lSRH39wTQvilzO+FdpA==", + "dev": true + }, + "@angular/material": { + "version": "7.0.0-beta.2", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-7.0.0-beta.2.tgz", + "integrity": "sha512-OgKGzcylyFDGSGY6GnZ6HmreKG6eTgjQtkSqC/Ngv0B7ilPlpvbiyk3yAcjXSOLiHjU0tfXI1stZJjxmlSCqjg==", + "requires": { + "parse5": "^5.0.0", + "tslib": "^1.7.1" + }, + "dependencies": { + "parse5": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.0.tgz", + "integrity": "sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==", + "optional": true + } + } + }, + "@angular/platform-browser": { + "version": "7.0.0-rc.0", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-7.0.0-rc.0.tgz", + "integrity": "sha512-N52E4TjX3AwMT0EMZTikxQz+4rkdx1C9WnBSIuBR5rYwZi391mxexvES8PqE4UqEarm08eHvfxUwtMZU/FwC+w==", + "requires": { + "tslib": "^1.9.0" + } + }, + "@angular/platform-browser-dynamic": { + "version": "7.0.0-rc.0", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-7.0.0-rc.0.tgz", + "integrity": "sha512-+SbuLnedoZNY6kfY5dV5p/+Rm4oj/DVwLhOWVFMtrqiaKRSrrEThH12FPKfQCqak51RjF4wDpJbqyWCGFDIbJA==", + "requires": { + "tslib": "^1.9.0" + } + }, + "@angular/router": { + "version": "7.0.0-rc.0", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-7.0.0-rc.0.tgz", + "integrity": "sha512-rT58TKCelP6BLw8Gzu6ZPeO86xzVFpDxVCLGmwEAmkWw8xG0gACkPYeVny4hsCkfx4nbz2w8upQksOKrudZt4w==", + "requires": { + "tslib": "^1.9.0" + } + }, + "@babel/code-frame": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0.tgz", + "integrity": "sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA==", + "dev": true, + "requires": { + "@babel/highlight": "^7.0.0" + } + }, + "@babel/generator": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.0.0.tgz", + "integrity": "sha512-/BM2vupkpbZXq22l1ALO7MqXJZH2k8bKVv8Y+pABFnzWdztDB/ZLveP5At21vLz5c2YtSE6p7j2FZEsqafMz5Q==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0", + "jsesc": "^2.5.1", + "lodash": "^4.17.10", + "source-map": "^0.5.0", + "trim-right": "^1.0.1" + }, + "dependencies": { + "jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha1-5CGiqOINawgZ3yiQj3glJrlt0f4=", + "dev": true + } + } + }, + "@babel/helper-function-name": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.0.0.tgz", + "integrity": "sha512-Zo+LGvfYp4rMtz84BLF3bavFTdf8y4rJtMPTe2J+rxYmnDOIeH8le++VFI/pRJU+rQhjqiXxE4LMaIau28Tv1Q==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.0.0", + "@babel/template": "^7.0.0", + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz", + "integrity": "sha512-r2DbJeg4svYvt3HOS74U4eWKsUAMRH01Z1ds1zx8KNTPtpTL5JAsdFv8BNyOpVqdFhHkkRDIg5B4AsxmkjAlmQ==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.0.0.tgz", + "integrity": "sha512-MXkOJqva62dfC0w85mEf/LucPPS/1+04nmmRMPEBUB++hiiThQ2zPtX/mEWQ3mtzCEjIJvPY8nuwxXtQeQwUag==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@babel/highlight": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.0.0.tgz", + "integrity": "sha512-UFMC4ZeFC48Tpvj7C8UgLvtkaUuovQX+5xNWrsIoMG8o2z+XFKjKaN9iVmS84dPwVN00W4wPmqvYoZF3EGAsfw==", + "dev": true, + "requires": { + "chalk": "^2.0.0", + "esutils": "^2.0.2", + "js-tokens": "^4.0.0" + }, + "dependencies": { + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + } + } + }, + "@babel/parser": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.0.0.tgz", + "integrity": "sha512-RgJhNdRinpO8zibnoHbzTTexNs4c8ROkXFBanNDZTLHjwbdLk8J5cJSKulx/bycWTLYmKVNCkxRtVCoJnqPk+g==", + "dev": true + }, + "@babel/template": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.0.0.tgz", + "integrity": "sha512-VLQZik/G5mjYJ6u19U3W2u7eM+rA/NGzH+GtHDFFkLTKLW66OasFrxZ/yK7hkyQcswrmvugFyZpDFRW0DjcjCw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.0.0", + "@babel/types": "^7.0.0" + } + }, + "@babel/traverse": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.0.0.tgz", + "integrity": "sha512-ka/lwaonJZTlJyn97C4g5FYjPOx+Oxd3ab05hbDr1Mx9aP1FclJ+SUHyLx3Tx40sGmOVJApDxE6puJhd3ld2kw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/generator": "^7.0.0", + "@babel/helper-function-name": "^7.0.0", + "@babel/helper-split-export-declaration": "^7.0.0", + "@babel/parser": "^7.0.0", + "@babel/types": "^7.0.0", + "debug": "^3.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.10" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "globals": { + "version": "11.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.7.0.tgz", + "integrity": "sha512-K8BNSPySfeShBQXsahYB/AbbWruVOTyVpgoIDnl8odPpeSfP2J5QO2oLFFdl2j7GfDCtZj2bMKar2T49itTPCg==", + "dev": true + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + } + } + }, + "@babel/types": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.0.0.tgz", + "integrity": "sha512-5tPDap4bGKTLPtci2SUl/B7Gv8RnuJFuQoWx26RJobS0fFrz4reUA3JnwIM+HVHEmWE0C1mzKhDtTp8NsWY02Q==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.10", + "to-fast-properties": "^2.0.0" + }, + "dependencies": { + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + } + } + }, + "@biesbjerg/ngx-translate-extract": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@biesbjerg/ngx-translate-extract/-/ngx-translate-extract-2.3.4.tgz", + "integrity": "sha512-FzOdm5Jr2TMgdzTW+c6CGIgMQMCAXCyN6JYzz+hfnYjcvPrYbyR05AhM08W70nXD3a2RnbqjImNjEEcXY9pZ/g==", + "dev": true, + "requires": { + "chalk": "2.0.1", + "cheerio": "1.0.0-rc.2", + "flat": "2.0.1", + "fs": "0.0.1-security", + "gettext-parser": "1.2.2", + "glob": "7.1.2", + "mkdirp": "0.5.1", + "path": "0.12.7", + "typescript": "2.4.1", + "yargs": "8.0.2" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "camelcase": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", + "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=", + "dev": true + }, + "chalk": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.0.1.tgz", + "integrity": "sha512-Mp+FXEI+FrwY/XYV45b2YD3E8i3HwnEAoFcM0qlZzq/RZ9RwWitt2Y/c7cqRAz70U7hfekqx6qNYthuKFO6K0g==", + "dev": true, + "requires": { + "ansi-styles": "^3.1.0", + "escape-string-regexp": "^1.0.5", + "supports-color": "^4.0.0" + } + }, + "cliui": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", + "dev": true, + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wrap-ansi": "^2.0.0" + }, + "dependencies": { + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + } + } + }, + "has-flag": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz", + "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=", + "dev": true + }, + "load-json-file": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "strip-bom": "^3.0.0" + } + }, + "os-locale": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-2.1.0.tgz", + "integrity": "sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA==", + "dev": true, + "requires": { + "execa": "^0.7.0", + "lcid": "^1.0.0", + "mem": "^1.1.0" + } + }, + "path-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz", + "integrity": "sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM=", + "dev": true, + "requires": { + "pify": "^2.0.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "read-pkg": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", + "integrity": "sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg=", + "dev": true, + "requires": { + "load-json-file": "^2.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^2.0.0" + } + }, + "read-pkg-up": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-2.0.0.tgz", + "integrity": "sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^2.0.0" + } + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "supports-color": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.5.0.tgz", + "integrity": "sha1-vnoN5ITexcXN34s9WRJQRJEvY1s=", + "dev": true, + "requires": { + "has-flag": "^2.0.0" + } + }, + "typescript": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-2.4.1.tgz", + "integrity": "sha1-w8yxbdqgsjFN4DHn5v7onlujRrw=", + "dev": true + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "y18n": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", + "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=", + "dev": true + }, + "yargs": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-8.0.2.tgz", + "integrity": "sha1-YpmpBVsc78lp/355wdkY3Osiw2A=", + "dev": true, + "requires": { + "camelcase": "^4.1.0", + "cliui": "^3.2.0", + "decamelize": "^1.1.1", + "get-caller-file": "^1.0.1", + "os-locale": "^2.0.0", + "read-pkg-up": "^2.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1", + "yargs-parser": "^7.0.0" + } + }, + "yargs-parser": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-7.0.0.tgz", + "integrity": "sha1-jQrELxbqVd69MyyvTEA4s+P139k=", + "dev": true, + "requires": { + "camelcase": "^4.1.0" + } + } + } + }, + "@compodoc/compodoc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@compodoc/compodoc/-/compodoc-1.1.5.tgz", + "integrity": "sha512-PwUa42qCYEIrbXX25oUVkWck+IhACXBRSScPSV0Fr6+Nd3d6BcxUx8hllzZRVMBVv2a85fojSF7Sk6BfLqraRg==", + "dev": true, + "requires": { + "@compodoc/ngd-transformer": "^2.0.0", + "chalk": "^2.4.1", + "cheerio": "^1.0.0-rc.2", + "chokidar": "^2.0.4", + "colors": "^1.3.2", + "commander": "2.17.1", + "cosmiconfig": "^5.0.6", + "fancy-log": "^1.3.2", + "findit2": "^2.2.3", + "fs-extra": "^7.0.0", + "glob": "^7.1.2", + "handlebars": "4.0.10", + "html-entities": "^1.2.1", + "i18next": "^11.6.0", + "inside": "^1.0.0", + "json5": "^2.0.1", + "live-server": "1.1.0", + "lodash": "^4.17.10", + "lunr": "2.3.2", + "marked": "^0.4.0", + "os-name": "^2.0.1", + "traverse": "^0.6.6", + "ts-simple-ast": "12.4.0", + "uuid": "^3.3.2" + }, + "dependencies": { + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "chokidar": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.0.4.tgz", + "integrity": "sha512-z9n7yt9rOvIJrMhvDtDictKrkFHeihkNl6uWMmZlmL6tJtX9Cs+87oK+teBx+JIgzvbX3yZHT3eF8vpbDxHJXQ==", + "dev": true, + "requires": { + "anymatch": "^2.0.0", + "async-each": "^1.0.0", + "braces": "^2.3.0", + "fsevents": "^1.2.2", + "glob-parent": "^3.1.0", + "inherits": "^2.0.1", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "lodash.debounce": "^4.0.8", + "normalize-path": "^2.1.1", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.0.0", + "upath": "^1.0.5" + } + }, + "colors": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.3.2.tgz", + "integrity": "sha512-rhP0JSBGYvpcNQj4s5AdShMeE5ahMop96cTeDl/v9qQQm2fYClE2QXZRi8wLzc+GmXSxdIqqbOIAhyObEXDbfQ==", + "dev": true + }, + "commander": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", + "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", + "dev": true + }, + "cosmiconfig": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.0.6.tgz", + "integrity": "sha512-6DWfizHriCrFWURP1/qyhsiFvYdlJzbCzmtFWh744+KyWsJo5+kPzUZZaMRSSItoYc0pxFX7gEO7ZC1/gN/7AQ==", + "dev": true, + "requires": { + "is-directory": "^0.3.1", + "js-yaml": "^3.9.0", + "parse-json": "^4.0.0" + } + }, + "handlebars": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.10.tgz", + "integrity": "sha1-PTDHGLCaPZbyPqTMH0A8TTup/08=", + "dev": true, + "requires": { + "async": "^1.4.0", + "optimist": "^0.6.1", + "source-map": "^0.4.4", + "uglify-js": "^2.6" + } + }, + "json5": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.0.1.tgz", + "integrity": "sha512-t6N/86QDIRYvOL259jR5c5TbtMnekl2Ib314mGeMh37zAwjgbWHieqijPH7pWaogmJq1F2I4Sphg19U1s+ZnXQ==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "source-map": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", + "dev": true, + "requires": { + "amdefine": ">=0.0.4" + } + }, + "uglify-js": { + "version": "2.8.29", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz", + "integrity": "sha1-KcVzMUgFe7Th913zW3qcty5qWd0=", + "dev": true, + "optional": true, + "requires": { + "source-map": "~0.5.1", + "uglify-to-browserify": "~1.0.0", + "yargs": "~3.10.0" + }, + "dependencies": { + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true, + "optional": true + } + } + }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==", + "dev": true + } + } + }, + "@compodoc/ngd-core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@compodoc/ngd-core/-/ngd-core-2.0.0.tgz", + "integrity": "sha512-6HpYvXRZBdIYFojWxW5EVNkhYPmblytCve62CNoYBSWfy++vTGH7Ypg2Bhjg2CsqeV8JOVxrPO7JM9M3MgWKEA==", + "dev": true, + "requires": { + "ansi-colors": "^1.0.1", + "fancy-log": "^1.3.2", + "typescript": "^2.4.2" + }, + "dependencies": { + "typescript": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-2.9.2.tgz", + "integrity": "sha512-Gr4p6nFNaoufRIY4NMdpQRNmgxVIGMs4Fcu/ujdYk3nAZqk7supzBE9idmvfZIlH/Cuj//dvi+019qEue9lV0w==", + "dev": true + } + } + }, + "@compodoc/ngd-transformer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@compodoc/ngd-transformer/-/ngd-transformer-2.0.0.tgz", + "integrity": "sha512-9J0KkmuuuvDHxH0oREgrgbqdEFqcltQXIBofeYdIyMKzI3A+pN1Ji4zfi7x1ql0Ax7qQKemp8XWP+cCpP0qY+w==", + "dev": true, + "requires": { + "@compodoc/ngd-core": "~2.0.0", + "dot": "^1.1.1", + "fs-extra": "^4.0.1", + "viz.js": "^1.8.0" + }, + "dependencies": { + "fs-extra": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.3.tgz", + "integrity": "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + } + } + }, + "@dsherret/to-absolute-glob": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@dsherret/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz", + "integrity": "sha1-H2R13IvZdM6gei2vOGSzF7HdMyw=", + "dev": true, + "requires": { + "is-absolute": "^1.0.0", + "is-negated-glob": "^1.0.0" + } + }, + "@fortawesome/angular-fontawesome": { + "version": "0.1.0-10", + "resolved": "https://registry.npmjs.org/@fortawesome/angular-fontawesome/-/angular-fontawesome-0.1.0-10.tgz", + "integrity": "sha512-YW1cCbNo+D3mCrLEpRzb3xQiS/XpPDbsezf5W3hluIPO/vo3XIeid/B334sE+Y0p7h8TnaQMSPtUx0JxOhQyXw==", + "requires": { + "tslib": "^1.7.1" + } + }, + "@fortawesome/fontawesome-common-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.4.tgz", + "integrity": "sha512-0qbIVm+MzkxMwKDx8V0C7w/6Nk+ZfBseOn2R1YK0f2DQP5pBcOQbu9NmaVaLzbJK6VJb1TuyTf0ZF97rc6iWJQ==" + }, + "@fortawesome/fontawesome-svg-core": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.4.tgz", + "integrity": "sha512-oGtnwcdhJomoDxbJcy6S0JxK6ItDhJLNOujm+qILPqajJ2a0P/YRomzBbixFjAPquCoyPUlA9g9ejA22P7TKNA==", + "requires": { + "@fortawesome/fontawesome-common-types": "^0.2.4" + } + }, + "@fortawesome/free-solid-svg-icons": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.3.1.tgz", + "integrity": "sha512-NkiLBFoiHtJ89cPJdM+W6cLvTVKkLh3j9t3MxkXyip0ncdD3lhCunSuzvFcrTHWeETEyoClGd8ZIWrr3HFZ3BA==", + "requires": { + "@fortawesome/fontawesome-common-types": "^0.2.4" + } + }, + "@mrmlnc/readdir-enhanced": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", + "integrity": "sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==", + "dev": true, + "requires": { + "call-me-maybe": "^1.0.1", + "glob-to-regexp": "^0.3.0" + } + }, + "@ngtools/webpack": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-6.1.5.tgz", + "integrity": "sha512-vrvFFvUqo4hlrLRBTG7a3gsAneitd0/tj2zHsiN97RmefxHSS+3m0pkVw8G3BMAagp2L42AiVfNV4wvYDe+TXA==", + "dev": true, + "requires": { + "@angular-devkit/core": "0.7.5", + "rxjs": "^6.0.0", + "tree-kill": "^1.0.0", + "webpack-sources": "^1.1.0" + } + }, + "@ngx-pwa/local-storage": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@ngx-pwa/local-storage/-/local-storage-6.1.1.tgz", + "integrity": "sha512-6SoKzNWZjWSEMZZS1jygRuDe7UoNYc6rOC5efnGuyqsQwm5LCuMCUqOEJ1xZl65ZFXOh6PREobMY6zSdTZa04g==", + "requires": { + "tslib": "^1.9.0" + } + }, + "@ngx-translate/core": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@ngx-translate/core/-/core-10.0.2.tgz", + "integrity": "sha512-7nM3DrJaqKswwtJlbu2kuKNl+hE8Isr18sKsKvGGpSxQk+G0gO0reDlx2PhUNus7TJTkA1C59vU/JoN8hIvZ4g==", + "requires": { + "tslib": "^1.9.0" + } + }, + "@ngx-translate/http-loader": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ngx-translate/http-loader/-/http-loader-3.0.1.tgz", + "integrity": "sha1-ILD5i8bCUyESnT4zAqs8xInApCo=", + "requires": { + "tslib": "^1.9.0" + } + }, + "@nodelib/fs.stat": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.2.tgz", + "integrity": "sha512-yprFYuno9FtNsSHVlSWd+nRlmGoAbqbeCwOryP6sC/zoCjhpArcRMYp19EvpSUSizJAlsXEwJv+wcWS9XaXdMw==", + "dev": true + }, + "@schematics/angular": { + "version": "7.0.0-beta.4", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-7.0.0-beta.4.tgz", + "integrity": "sha512-YJvTvAn3Dw0XFWCJhaMKk003cunkI6jLOcqU+BmEcdOTL/REs6ZSgiZueZdD7lmpq3DB44dUm8UXy3I4k7nZ6g==", + "dev": true, + "requires": { + "@angular-devkit/core": "7.0.0-beta.4", + "@angular-devkit/schematics": "7.0.0-beta.4", + "typescript": "3.0.1" + }, + "dependencies": { + "@angular-devkit/core": { + "version": "7.0.0-beta.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-7.0.0-beta.4.tgz", + "integrity": "sha512-Yk4+u1G3qQBTaYDR6yXkCAc1Woe+h1tWCbzXPWPmzvg53Ox/47cMwMl61lCMqEShVAS/x+Ss/9mVFlPci5YSNQ==", + "dev": true, + "requires": { + "ajv": "6.5.3", + "chokidar": "2.0.4", + "fast-json-stable-stringify": "2.0.0", + "rxjs": "6.2.2", + "source-map": "0.7.3" + } + }, + "ajv": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.5.3.tgz", + "integrity": "sha512-LqZ9wY+fx3UMiiPd741yB2pj3hhil+hQc8taf4o2QGRFpWgZ2V5C8HA165DY9sS3fJwsk7uT7ZlFEyC3Ig3lLg==", + "dev": true, + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "chokidar": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.0.4.tgz", + "integrity": "sha512-z9n7yt9rOvIJrMhvDtDictKrkFHeihkNl6uWMmZlmL6tJtX9Cs+87oK+teBx+JIgzvbX3yZHT3eF8vpbDxHJXQ==", + "dev": true, + "requires": { + "anymatch": "^2.0.0", + "async-each": "^1.0.0", + "braces": "^2.3.0", + "fsevents": "^1.2.2", + "glob-parent": "^3.1.0", + "inherits": "^2.0.1", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "lodash.debounce": "^4.0.8", + "normalize-path": "^2.1.1", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.0.0", + "upath": "^1.0.5" + } + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "rxjs": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.2.2.tgz", + "integrity": "sha512-0MI8+mkKAXZUF9vMrEoPnaoHkfzBPP4IGwUYRJhIRJF6/w3uByO1e91bEHn8zd43RdkTMKiooYKmwz7RH6zfOQ==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + }, + "typescript": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.0.1.tgz", + "integrity": "sha512-zQIMOmC+372pC/CCVLqnQ0zSBiY7HHodU7mpQdjiZddek4GMj31I3dUJ7gAs9o65X7mnRma6OokOkc6f9jjfBg==", + "dev": true + }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + } + } + }, + "@schematics/update": { + "version": "0.9.0-beta.4", + "resolved": "https://registry.npmjs.org/@schematics/update/-/update-0.9.0-beta.4.tgz", + "integrity": "sha512-SIbansJdvXoiiehfq9WHkfh8KooD2ZlJkxYkekx5nD0svup7GkCoDXaHQ3svrc5Ui/BuvffnKZH87RqhAta/ww==", + "dev": true, + "requires": { + "@angular-devkit/core": "7.0.0-beta.4", + "@angular-devkit/schematics": "7.0.0-beta.4", + "npm-registry-client": "8.6.0", + "rxjs": "6.2.2", + "semver": "5.5.1", + "semver-intersect": "1.4.0" + }, + "dependencies": { + "@angular-devkit/core": { + "version": "7.0.0-beta.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-7.0.0-beta.4.tgz", + "integrity": "sha512-Yk4+u1G3qQBTaYDR6yXkCAc1Woe+h1tWCbzXPWPmzvg53Ox/47cMwMl61lCMqEShVAS/x+Ss/9mVFlPci5YSNQ==", + "dev": true, + "requires": { + "ajv": "6.5.3", + "chokidar": "2.0.4", + "fast-json-stable-stringify": "2.0.0", + "rxjs": "6.2.2", + "source-map": "0.7.3" + } + }, + "ajv": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.5.3.tgz", + "integrity": "sha512-LqZ9wY+fx3UMiiPd741yB2pj3hhil+hQc8taf4o2QGRFpWgZ2V5C8HA165DY9sS3fJwsk7uT7ZlFEyC3Ig3lLg==", + "dev": true, + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "chokidar": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.0.4.tgz", + "integrity": "sha512-z9n7yt9rOvIJrMhvDtDictKrkFHeihkNl6uWMmZlmL6tJtX9Cs+87oK+teBx+JIgzvbX3yZHT3eF8vpbDxHJXQ==", + "dev": true, + "requires": { + "anymatch": "^2.0.0", + "async-each": "^1.0.0", + "braces": "^2.3.0", + "fsevents": "^1.2.2", + "glob-parent": "^3.1.0", + "inherits": "^2.0.1", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "lodash.debounce": "^4.0.8", + "normalize-path": "^2.1.1", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.0.0", + "upath": "^1.0.5" + } + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "rxjs": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.2.2.tgz", + "integrity": "sha512-0MI8+mkKAXZUF9vMrEoPnaoHkfzBPP4IGwUYRJhIRJF6/w3uByO1e91bEHn8zd43RdkTMKiooYKmwz7RH6zfOQ==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, + "semver": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.1.tgz", + "integrity": "sha512-PqpAxfrEhlSUWge8dwIp4tZnQ25DIOthpiaHNIthsjEFQD6EvqUKUDM7L8O2rShkFccYo1VjJR0coWfNkCubRw==", + "dev": true + }, + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + } + } + }, + "@types/jasmine": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-2.8.8.tgz", + "integrity": "sha512-OJSUxLaxXsjjhob2DBzqzgrkLmukM3+JMpRp0r0E4HTdT1nwDCWhaswjYxazPij6uOdzHCJfNbDjmQ1/rnNbCg==", + "dev": true + }, + "@types/jasminewd2": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/jasminewd2/-/jasminewd2-2.0.4.tgz", + "integrity": "sha512-G83fHoholqR7pmsY7ojHJqMAl4zD6ylKNaKCx7zH+GisCBQpnI5a7aUTFWVzv2wppIuWd+mJxyRqTASPfqcQ2w==", + "dev": true, + "requires": { + "@types/jasmine": "*" + } + }, + "@types/node": { + "version": "8.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-8.9.5.tgz", + "integrity": "sha512-jRHfWsvyMtXdbhnz5CVHxaBgnV6duZnPlQuRSo/dm/GnmikNcmZhxIES4E9OZjUmQ8C+HCl4KJux+cXN/ErGDQ==", + "dev": true + }, + "@types/q": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/@types/q/-/q-0.0.32.tgz", + "integrity": "sha1-vShOV8hPEyXacCur/IKlMoGQwMU=", + "dev": true + }, + "@types/selenium-webdriver": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/selenium-webdriver/-/selenium-webdriver-3.0.10.tgz", + "integrity": "sha512-ikB0JHv6vCR1KYUQAzTO4gi/lXLElT4Tx+6De2pc/OZwizE9LRNiTa+U8TBFKBD/nntPnr/MPSHSnOTybjhqNA==", + "dev": true + }, + "@webassemblyjs/ast": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.4.3.tgz", + "integrity": "sha512-S6npYhPcTHDYe9nlsKa9CyWByFi8Vj8HovcAgtmMAQZUOczOZbQ8CnwMYKYC5HEZzxEE+oY0jfQk4cVlI3J59Q==", + "dev": true, + "requires": { + "@webassemblyjs/helper-wasm-bytecode": "1.4.3", + "@webassemblyjs/wast-parser": "1.4.3", + "debug": "^3.1.0", + "webassemblyjs": "1.4.3" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + } + } + }, + "@webassemblyjs/floating-point-hex-parser": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.4.3.tgz", + "integrity": "sha512-3zTkSFswwZOPNHnzkP9ONq4bjJSeKVMcuahGXubrlLmZP8fmTIJ58dW7h/zOVWiFSuG2em3/HH3BlCN7wyu9Rw==", + "dev": true + }, + "@webassemblyjs/helper-buffer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.4.3.tgz", + "integrity": "sha512-e8+KZHh+RV8MUvoSRtuT1sFXskFnWG9vbDy47Oa166xX+l0dD5sERJ21g5/tcH8Yo95e9IN3u7Jc3NbhnUcSkw==", + "dev": true, + "requires": { + "debug": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + } + } + }, + "@webassemblyjs/helper-code-frame": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.4.3.tgz", + "integrity": "sha512-9FgHEtNsZQYaKrGCtsjswBil48Qp1agrzRcPzCbQloCoaTbOXLJ9IRmqT+uEZbenpULLRNFugz3I4uw18hJM8w==", + "dev": true, + "requires": { + "@webassemblyjs/wast-printer": "1.4.3" + } + }, + "@webassemblyjs/helper-fsm": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-fsm/-/helper-fsm-1.4.3.tgz", + "integrity": "sha512-JINY76U+702IRf7ePukOt037RwmtH59JHvcdWbTTyHi18ixmQ+uOuNhcdCcQHTquDAH35/QgFlp3Y9KqtyJsCQ==", + "dev": true + }, + "@webassemblyjs/helper-wasm-bytecode": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.4.3.tgz", + "integrity": "sha512-I7bS+HaO0K07Io89qhJv+z1QipTpuramGwUSDkwEaficbSvCcL92CUZEtgykfNtk5wb0CoLQwWlmXTwGbNZUeQ==", + "dev": true + }, + "@webassemblyjs/helper-wasm-section": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.4.3.tgz", + "integrity": "sha512-p0yeeO/h2r30PyjnJX9xXSR6EDcvJd/jC6xa/Pxg4lpfcNi7JUswOpqDToZQ55HMMVhXDih/yqkaywHWGLxqyQ==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.4.3", + "@webassemblyjs/helper-buffer": "1.4.3", + "@webassemblyjs/helper-wasm-bytecode": "1.4.3", + "@webassemblyjs/wasm-gen": "1.4.3", + "debug": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + } + } + }, + "@webassemblyjs/leb128": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.4.3.tgz", + "integrity": "sha512-4u0LJLSPzuRDWHwdqsrThYn+WqMFVqbI2ltNrHvZZkzFPO8XOZ0HFQ5eVc4jY/TNHgXcnwrHjONhPGYuuf//KQ==", + "dev": true, + "requires": { + "leb": "^0.3.0" + } + }, + "@webassemblyjs/validation": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@webassemblyjs/validation/-/validation-1.4.3.tgz", + "integrity": "sha512-R+rRMKfhd9mq0rj2mhU9A9NKI2l/Rw65vIYzz4lui7eTKPcCu1l7iZNi4b9Gen8D42Sqh/KGiaQNk/x5Tn/iBQ==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.4.3" + } + }, + "@webassemblyjs/wasm-edit": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.4.3.tgz", + "integrity": "sha512-qzuwUn771PV6/LilqkXcS0ozJYAeY/OKbXIWU3a8gexuqb6De2p4ya/baBeH5JQ2WJdfhWhSvSbu86Vienttpw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.4.3", + "@webassemblyjs/helper-buffer": "1.4.3", + "@webassemblyjs/helper-wasm-bytecode": "1.4.3", + "@webassemblyjs/helper-wasm-section": "1.4.3", + "@webassemblyjs/wasm-gen": "1.4.3", + "@webassemblyjs/wasm-opt": "1.4.3", + "@webassemblyjs/wasm-parser": "1.4.3", + "@webassemblyjs/wast-printer": "1.4.3", + "debug": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + } + } + }, + "@webassemblyjs/wasm-gen": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.4.3.tgz", + "integrity": "sha512-eR394T8dHZfpLJ7U/Z5pFSvxl1L63JdREebpv9gYc55zLhzzdJPAuxjBYT4XqevUdW67qU2s0nNA3kBuNJHbaQ==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.4.3", + "@webassemblyjs/helper-wasm-bytecode": "1.4.3", + "@webassemblyjs/leb128": "1.4.3" + } + }, + "@webassemblyjs/wasm-opt": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.4.3.tgz", + "integrity": "sha512-7Gp+nschuKiDuAL1xmp4Xz0rgEbxioFXw4nCFYEmy+ytynhBnTeGc9W9cB1XRu1w8pqRU2lbj2VBBA4cL5Z2Kw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.4.3", + "@webassemblyjs/helper-buffer": "1.4.3", + "@webassemblyjs/wasm-gen": "1.4.3", + "@webassemblyjs/wasm-parser": "1.4.3", + "debug": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + } + } + }, + "@webassemblyjs/wasm-parser": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.4.3.tgz", + "integrity": "sha512-KXBjtlwA3BVukR/yWHC9GF+SCzBcgj0a7lm92kTOaa4cbjaTaa47bCjXw6cX4SGQpkncB9PU2hHGYVyyI7wFRg==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.4.3", + "@webassemblyjs/helper-wasm-bytecode": "1.4.3", + "@webassemblyjs/leb128": "1.4.3", + "@webassemblyjs/wasm-parser": "1.4.3", + "webassemblyjs": "1.4.3" + } + }, + "@webassemblyjs/wast-parser": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-parser/-/wast-parser-1.4.3.tgz", + "integrity": "sha512-QhCsQzqV0CpsEkRYyTzQDilCNUZ+5j92f+g35bHHNqS22FppNTywNFfHPq8ZWZfYCgbectc+PoghD+xfzVFh1Q==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.4.3", + "@webassemblyjs/floating-point-hex-parser": "1.4.3", + "@webassemblyjs/helper-code-frame": "1.4.3", + "@webassemblyjs/helper-fsm": "1.4.3", + "long": "^3.2.0", + "webassemblyjs": "1.4.3" + } + }, + "@webassemblyjs/wast-printer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.4.3.tgz", + "integrity": "sha512-EgXk4anf8jKmuZJsqD8qy5bz2frEQhBvZruv+bqwNoLWUItjNSFygk8ywL3JTEz9KtxTlAmqTXNrdD1d9gNDtg==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.4.3", + "@webassemblyjs/wast-parser": "1.4.3", + "long": "^3.2.0" + } + }, + "abbrev": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.9.tgz", + "integrity": "sha1-kbR5JYinc4wl813W9jdSovh3YTU=", + "dev": true + }, + "accepts": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.5.tgz", + "integrity": "sha1-63d99gEXI6OxTopywIBcjoZ0a9I=", + "dev": true, + "requires": { + "mime-types": "~2.1.18", + "negotiator": "0.6.1" + } + }, + "acorn": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz", + "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==", + "dev": true + }, + "acorn-dynamic-import": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/acorn-dynamic-import/-/acorn-dynamic-import-3.0.0.tgz", + "integrity": "sha512-zVWV8Z8lislJoOKKqdNMOB+s6+XV5WERty8MnKBeFgwA+19XJjJHs2RP5dzM57FftIs+jQnRToLiWazKr6sSWg==", + "dev": true, + "requires": { + "acorn": "^5.0.0" + } + }, + "adm-zip": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.4.11.tgz", + "integrity": "sha512-L8vcjDTCOIJk7wFvmlEUN7AsSb8T+2JrdP7KINBjzr24TJ5Mwj590sLu3BC7zNZowvJWa/JtPmD8eJCzdtDWjA==", + "dev": true + }, + "after": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz", + "integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=", + "dev": true + }, + "agent-base": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.2.1.tgz", + "integrity": "sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg==", + "dev": true, + "requires": { + "es6-promisify": "^5.0.0" + } + }, + "ajv": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.4.0.tgz", + "integrity": "sha1-06/3jpJ3VJdx2vAWTP9ISCt1T8Y=", + "dev": true, + "requires": { + "fast-deep-equal": "^1.0.0", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.3.0", + "uri-js": "^3.0.2" + } + }, + "ajv-errors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.0.tgz", + "integrity": "sha1-7PAh+hCP0X37Xms4Py3SM+Mf/Fk=", + "dev": true + }, + "ajv-keywords": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.2.0.tgz", + "integrity": "sha1-6GuBnGAs+IIa1jdBNpjx3sAhhHo=", + "dev": true + }, + "align-text": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", + "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", + "dev": true, + "requires": { + "kind-of": "^3.0.2", + "longest": "^1.0.1", + "repeat-string": "^1.5.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "ambi": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ambi/-/ambi-2.5.0.tgz", + "integrity": "sha1-fI43K+SIkRV+fOoBy2+RQ9H3QiA=", + "dev": true, + "requires": { + "editions": "^1.1.1", + "typechecker": "^4.3.0" + } + }, + "amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", + "dev": true + }, + "ansi-colors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", + "integrity": "sha512-SFKX67auSNoVR38N3L+nvsPjOE0bybKTYbkf5tRvushrAPQ9V75huw0ZxBkKVeRU9kqH3d6HA4xTckbwZ4ixmA==", + "dev": true, + "requires": { + "ansi-wrap": "^0.1.0" + } + }, + "ansi-escapes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.1.0.tgz", + "integrity": "sha512-UgAb8H9D41AQnu/PbWlCofQVcnV4Gs2bBJi9eZPxfU/hgglFh3SMDMENRIqdr7H6XFnXdoknctFByVsCOotTVw==", + "dev": true + }, + "ansi-gray": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-gray/-/ansi-gray-0.1.1.tgz", + "integrity": "sha1-KWLPVOyXksSFEKPetSRDaGHvclE=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-html": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/ansi-html/-/ansi-html-0.0.7.tgz", + "integrity": "sha1-gTWEAhliqenm/QOflA0S9WynhZ4=", + "dev": true + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "ansi-wrap": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", + "integrity": "sha1-qCJQ3bABXponyoLoLqYDu/pF768=", + "dev": true + }, + "anymatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", + "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", + "dev": true, + "requires": { + "micromatch": "^3.1.4", + "normalize-path": "^2.1.1" + } + }, + "apache-crypt": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/apache-crypt/-/apache-crypt-1.1.2.tgz", + "integrity": "sha1-ggeCozu2pf0nEggvDtOiTjybAhQ=", + "dev": true, + "requires": { + "unix-crypt-td-js": "^1.0.0" + } + }, + "apache-md5": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/apache-md5/-/apache-md5-1.0.6.tgz", + "integrity": "sha1-RwI51AxU58Mt2dbrEbw1eOzJA8I=", + "dev": true + }, + "app-root-path": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-2.0.1.tgz", + "integrity": "sha1-zWLc+OT9WkF+/GZNLlsQZTxlG0Y=", + "dev": true + }, + "append-transform": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-1.0.0.tgz", + "integrity": "sha512-P009oYkeHyU742iSZJzZZywj4QRJdnTWffaKuJQLablCZ1uz6/cW4yaRgcDaoQ+uwOxxnt0gRUcwfsNP2ri0gw==", + "dev": true, + "requires": { + "default-require-extensions": "^2.0.0" + } + }, + "aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "dev": true + }, + "are-we-there-yet": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", + "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", + "dev": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true + }, + "arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", + "dev": true + }, + "arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", + "dev": true + }, + "array-differ": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz", + "integrity": "sha1-7/UuN1gknTO+QCuLuOVkuytdQDE=", + "dev": true + }, + "array-filter": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/array-filter/-/array-filter-0.0.1.tgz", + "integrity": "sha1-fajPLiZijtcygDWB/SH2fKzS7uw=", + "dev": true + }, + "array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=", + "dev": true, + "optional": true + }, + "array-flatten": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.1.tgz", + "integrity": "sha1-Qmu52oQJDBg42BLIFQryCoMx4pY=", + "dev": true + }, + "array-map": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/array-map/-/array-map-0.0.0.tgz", + "integrity": "sha1-iKK6tz0c97zVwbEYoAP2b2ZfpmI=", + "dev": true + }, + "array-reduce": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/array-reduce/-/array-reduce-0.0.0.tgz", + "integrity": "sha1-FziZ0//Rx9k4PkR5Ul2+J4yrXys=", + "dev": true + }, + "array-slice": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-0.2.3.tgz", + "integrity": "sha1-3Tz7gO15c6dRF82sabC5nshhhvU=", + "dev": true + }, + "array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=", + "dev": true, + "requires": { + "array-uniq": "^1.0.1" + } + }, + "array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true + }, + "arraybuffer.slice": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz", + "integrity": "sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==", + "dev": true + }, + "arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", + "dev": true + }, + "asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=", + "dev": true, + "optional": true + }, + "asn1": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", + "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=", + "dev": true + }, + "asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "dev": true, + "requires": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "assert": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/assert/-/assert-1.4.1.tgz", + "integrity": "sha1-mZEtWRg2tab1s0XA8H7vwI/GXZE=", + "dev": true, + "requires": { + "util": "0.10.3" + }, + "dependencies": { + "inherits": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=", + "dev": true + }, + "util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", + "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", + "dev": true, + "requires": { + "inherits": "2.0.1" + } + } + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + }, + "assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", + "dev": true + }, + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", + "dev": true + }, + "async-each": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.1.tgz", + "integrity": "sha1-GdOGodntxufByF04iu28xW0zYC0=", + "dev": true + }, + "async-foreach": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/async-foreach/-/async-foreach-0.1.3.tgz", + "integrity": "sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI=", + "dev": true, + "optional": true + }, + "async-limiter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz", + "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==", + "dev": true + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, + "atob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.1.tgz", + "integrity": "sha1-ri1acpR38onWDdf5amMUoi3Wwio=", + "dev": true + }, + "autoprefixer": { + "version": "8.6.5", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-8.6.5.tgz", + "integrity": "sha512-PLWJN3Xo/rycNkx+mp8iBDMTm3FeWe4VmYaZDSqL5QQB9sLsQkG5k8n+LNDFnhh9kdq2K+egL/icpctOmDHwig==", + "dev": true, + "requires": { + "browserslist": "^3.2.8", + "caniuse-lite": "^1.0.30000864", + "normalize-range": "^0.1.2", + "num2fraction": "^1.2.2", + "postcss": "^6.0.23", + "postcss-value-parser": "^3.2.3" + } + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", + "dev": true + }, + "aws4": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.7.0.tgz", + "integrity": "sha512-32NDda82rhwD9/JBCCkB+MRYDp0oSvlo2IL6rQWA10PQi7tDUM3eqMSltXmY+Oyl/7N3P3qNtAlv7X0d9bI28w==", + "dev": true + }, + "babel-code-frame": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", + "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", + "dev": true, + "requires": { + "chalk": "^1.1.3", + "esutils": "^2.0.2", + "js-tokens": "^3.0.2" + }, + "dependencies": { + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "babel-generator": { + "version": "6.26.1", + "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.26.1.tgz", + "integrity": "sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA==", + "dev": true, + "requires": { + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "detect-indent": "^4.0.0", + "jsesc": "^1.3.0", + "lodash": "^4.17.4", + "source-map": "^0.5.7", + "trim-right": "^1.0.1" + } + }, + "babel-messages": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz", + "integrity": "sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", + "dev": true, + "requires": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + } + }, + "babel-template": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.26.0.tgz", + "integrity": "sha1-3gPi0WOWsGn0bdn/+FIfsaDjXgI=", + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "babel-traverse": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "lodash": "^4.17.4" + } + }, + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "dev": true, + "requires": { + "babel-code-frame": "^6.26.0", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "debug": "^2.6.8", + "globals": "^9.18.0", + "invariant": "^2.2.2", + "lodash": "^4.17.4" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + }, + "babylon": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", + "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==", + "dev": true + }, + "backo2": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", + "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=", + "dev": true + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "base": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", + "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "dev": true, + "requires": { + "cache-base": "^1.0.1", + "class-utils": "^0.3.5", + "component-emitter": "^1.2.1", + "define-property": "^1.0.0", + "isobject": "^3.0.1", + "mixin-deep": "^1.2.0", + "pascalcase": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "base64-arraybuffer": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz", + "integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg=", + "dev": true + }, + "base64-js": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", + "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==", + "dev": true + }, + "base64id": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-1.0.0.tgz", + "integrity": "sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY=", + "dev": true + }, + "basic-auth": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.0.tgz", + "integrity": "sha1-AV2z81PgLlY3d1X5YnQuiYHnu7o=", + "dev": true, + "requires": { + "safe-buffer": "5.1.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", + "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==", + "dev": true + } + } + }, + "batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY=", + "dev": true + }, + "bcrypt-pbkdf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz", + "integrity": "sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40=", + "dev": true, + "optional": true, + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "better-assert": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz", + "integrity": "sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=", + "dev": true, + "requires": { + "callsite": "1.0.0" + } + }, + "big.js": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-3.2.0.tgz", + "integrity": "sha512-+hN/Zh2D08Mx65pZ/4g5bsmNiZUuChDiQfTUQ7qJr4/kuopCr88xZsAXv6mBoZEsUI4OuGHlX59qE94K2mMW8Q==", + "dev": true + }, + "binary-extensions": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.11.0.tgz", + "integrity": "sha1-RqoXUftqL5PuXmibsQh9SxTGwgU=", + "dev": true + }, + "blob": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.4.tgz", + "integrity": "sha1-vPEwUspURj8w+fx+lbmkdjCpSSE=", + "dev": true + }, + "block-stream": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", + "integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=", + "dev": true, + "optional": true, + "requires": { + "inherits": "~2.0.0" + } + }, + "blocking-proxy": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/blocking-proxy/-/blocking-proxy-1.0.1.tgz", + "integrity": "sha512-KE8NFMZr3mN2E0HcvCgRtX7DjhiIQrwle+nSVJVC/yqFb9+xznHl2ZcoBp2L9qzkI4t4cBFJ1efXF8Dwi132RA==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + } + } + }, + "bluebird": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz", + "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==", + "dev": true + }, + "bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", + "dev": true + }, + "body-parser": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.2.tgz", + "integrity": "sha1-h2eKGdhLR9hZuDGZvVm84iKxBFQ=", + "dev": true, + "requires": { + "bytes": "3.0.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.1", + "http-errors": "~1.6.2", + "iconv-lite": "0.4.19", + "on-finished": "~2.3.0", + "qs": "6.5.1", + "raw-body": "2.3.2", + "type-is": "~1.6.15" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "qs": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", + "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==", + "dev": true + } + } + }, + "bonjour": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/bonjour/-/bonjour-3.5.0.tgz", + "integrity": "sha1-jokKGD2O6aI5OzhExpGkK897yfU=", + "dev": true, + "requires": { + "array-flatten": "^2.1.0", + "deep-equal": "^1.0.1", + "dns-equal": "^1.0.0", + "dns-txt": "^2.0.2", + "multicast-dns": "^6.0.1", + "multicast-dns-service-types": "^1.1.0" + } + }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=", + "dev": true + }, + "browserify-aes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "dev": true, + "requires": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "browserify-cipher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", + "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", + "dev": true, + "requires": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" + } + }, + "browserify-des": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", + "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", + "dev": true, + "requires": { + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "browserify-rsa": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", + "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "randombytes": "^2.0.1" + } + }, + "browserify-sign": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.0.4.tgz", + "integrity": "sha1-qk62jl17ZYuqa/alfmMMvXqT0pg=", + "dev": true, + "requires": { + "bn.js": "^4.1.1", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.2", + "elliptic": "^6.0.0", + "inherits": "^2.0.1", + "parse-asn1": "^5.0.0" + } + }, + "browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "dev": true, + "requires": { + "pako": "~1.0.5" + } + }, + "browserslist": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-3.2.8.tgz", + "integrity": "sha512-WHVocJYavUwVgVViC0ORikPHQquXwVh939TaelZ4WDqpWgTX/FsGhl/+P4qBUAGcRvtOgDgC+xftNWWp2RUTAQ==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30000844", + "electron-to-chromium": "^1.3.47" + } + }, + "browserstack": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/browserstack/-/browserstack-1.5.1.tgz", + "integrity": "sha512-O8VMT64P9NOLhuIoD4YngyxBURefaSdR4QdhG8l6HZ9VxtU7jc3m6jLufFwKA5gaf7fetfB2TnRJnMxyob+heg==", + "dev": true, + "requires": { + "https-proxy-agent": "^2.2.1" + } + }, + "buffer": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", + "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", + "dev": true, + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "buffer-alloc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", + "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", + "dev": true, + "requires": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, + "buffer-alloc-unsafe": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", + "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", + "dev": true + }, + "buffer-fill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", + "integrity": "sha1-+PeLdniYiO858gXNY39o5wISKyw=", + "dev": true + }, + "buffer-from": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.0.tgz", + "integrity": "sha512-c5mRlguI/Pe2dSZmpER62rSCu0ryKmWddzRYsuXc50U2/g8jMOulc31VZMa4mYx31U5xsmSOpDCgH88Vl9cDGQ==", + "dev": true + }, + "buffer-indexof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-indexof/-/buffer-indexof-1.1.1.tgz", + "integrity": "sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g==", + "dev": true + }, + "buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", + "dev": true + }, + "builtin-modules": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", + "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=", + "dev": true + }, + "builtin-status-codes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", + "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=", + "dev": true + }, + "builtins": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz", + "integrity": "sha1-y5T662HIaWRR2zZTThQi+U8K7og=", + "dev": true + }, + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=", + "dev": true + }, + "cacache": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-10.0.4.tgz", + "integrity": "sha512-Dph0MzuH+rTQzGPNT9fAnrPmMmjKfST6trxJeK7NQuHRaVw24VzPRWTmg9MpcwOVQZO0E1FBICUlFeNaKPIfHA==", + "dev": true, + "requires": { + "bluebird": "^3.5.1", + "chownr": "^1.0.1", + "glob": "^7.1.2", + "graceful-fs": "^4.1.11", + "lru-cache": "^4.1.1", + "mississippi": "^2.0.0", + "mkdirp": "^0.5.1", + "move-concurrently": "^1.0.1", + "promise-inflight": "^1.0.1", + "rimraf": "^2.6.2", + "ssri": "^5.2.4", + "unique-filename": "^1.1.0", + "y18n": "^4.0.0" + } + }, + "cache-base": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", + "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", + "dev": true, + "requires": { + "collection-visit": "^1.0.0", + "component-emitter": "^1.2.1", + "get-value": "^2.0.6", + "has-value": "^1.0.0", + "isobject": "^3.0.1", + "set-value": "^2.0.0", + "to-object-path": "^0.3.0", + "union-value": "^1.0.0", + "unset-value": "^1.0.0" + } + }, + "call-me-maybe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz", + "integrity": "sha1-JtII6onje1y95gJQoV8DHBak1ms=", + "dev": true + }, + "callsite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", + "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA=", + "dev": true + }, + "camel-case": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-3.0.0.tgz", + "integrity": "sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=", + "dev": true, + "requires": { + "no-case": "^2.2.0", + "upper-case": "^1.1.1" + } + }, + "camelcase": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", + "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=", + "dev": true, + "optional": true + }, + "camelcase-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", + "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=", + "dev": true, + "optional": true, + "requires": { + "camelcase": "^2.0.0", + "map-obj": "^1.0.0" + }, + "dependencies": { + "camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=", + "dev": true, + "optional": true + } + } + }, + "caniuse-lite": { + "version": "1.0.30000887", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000887.tgz", + "integrity": "sha512-AHpONWuGFWO8yY9igdXH94tikM6ERS84286r0cAMAXYFtJBk76lhiMhtCxBJNBZsD6hzlvpWZ2AtbVFEkf4JQA==", + "dev": true + }, + "canonical-path": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/canonical-path/-/canonical-path-0.0.2.tgz", + "integrity": "sha1-4x65N6jJPuKgHfGDl5RyGQKHRXQ=", + "dev": true + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", + "dev": true + }, + "center-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz", + "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=", + "dev": true, + "optional": true, + "requires": { + "align-text": "^0.1.3", + "lazy-cache": "^1.0.3" + } + }, + "chalk": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.2.2.tgz", + "integrity": "sha512-LvixLAQ4MYhbf7hgL4o5PeK32gJKvVzDRiSNIApDofQvyhl8adgG2lJVXn4+ekQoK7HL9RF8lqxwerpe0x2pCw==", + "dev": true, + "requires": { + "ansi-styles": "^3.1.0", + "escape-string-regexp": "^1.0.5", + "supports-color": "^4.0.0" + }, + "dependencies": { + "has-flag": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz", + "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=", + "dev": true + }, + "supports-color": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.5.0.tgz", + "integrity": "sha1-vnoN5ITexcXN34s9WRJQRJEvY1s=", + "dev": true, + "requires": { + "has-flag": "^2.0.0" + } + } + } + }, + "chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true + }, + "cheerio": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.2.tgz", + "integrity": "sha1-S59TqBsn5NXawxwP/Qz6A8xoMNs=", + "dev": true, + "requires": { + "css-select": "~1.2.0", + "dom-serializer": "~0.1.0", + "entities": "~1.1.1", + "htmlparser2": "^3.9.1", + "lodash": "^4.15.0", + "parse5": "^3.0.1" + }, + "dependencies": { + "domhandler": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", + "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", + "dev": true, + "requires": { + "domelementtype": "1" + } + }, + "htmlparser2": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.9.2.tgz", + "integrity": "sha1-G9+HrMoPP55T+k/M6w9LTLsAszg=", + "dev": true, + "requires": { + "domelementtype": "^1.3.0", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "inherits": "^2.0.1", + "readable-stream": "^2.0.2" + } + }, + "parse5": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-3.0.3.tgz", + "integrity": "sha512-rgO9Zg5LLLkfJF9E6CCmXlSE4UVceloys8JrFqCcHloC3usd/kJCyPDwH2SOlzix2j3xaP9sUX3e8+kvkuleAA==", + "dev": true, + "requires": { + "@types/node": "*" + } + } + } + }, + "chokidar": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.0.3.tgz", + "integrity": "sha512-zW8iXYZtXMx4kux/nuZVXjkLP+CyIK5Al5FHnj1OgTKGZfp4Oy6/ymtMSKFv3GD8DviEmUPmJg9eFdJ/JzudMg==", + "dev": true, + "requires": { + "anymatch": "^2.0.0", + "async-each": "^1.0.0", + "braces": "^2.3.0", + "fsevents": "^1.1.2", + "glob-parent": "^3.1.0", + "inherits": "^2.0.1", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "normalize-path": "^2.1.1", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.0.0", + "upath": "^1.0.0" + } + }, + "chownr": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.1.tgz", + "integrity": "sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==", + "dev": true + }, + "chrome-trace-event": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-0.1.3.tgz", + "integrity": "sha512-sjndyZHrrWiu4RY7AkHgjn80GfAM2ZSzUkZLV/Js59Ldmh6JDThf0SUmOHU53rFu2rVxxfCzJ30Ukcfch3Gb/A==", + "dev": true + }, + "ci-info": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.1.3.tgz", + "integrity": "sha512-SK/846h/Rcy8q9Z9CAwGBLfCJ6EkjJWdpelWDufQpqVDYq2Wnnv8zlSO6AMQap02jvhVruKKpEtQOufo3pFhLg==", + "dev": true + }, + "cipher-base": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", + "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "circular-dependency-plugin": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/circular-dependency-plugin/-/circular-dependency-plugin-5.0.2.tgz", + "integrity": "sha512-oC7/DVAyfcY3UWKm0sN/oVoDedQDQiw/vIiAnuTWTpE5s0zWf7l3WY417Xw/Fbi/QbAjctAkxgMiS9P0s3zkmA==", + "dev": true + }, + "circular-json": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.5.5.tgz", + "integrity": "sha512-13YaR6kiz0kBNmIVM87Io8Hp7bWOo4r61vkEANy8iH9R9bc6avud/1FT0SBpqR1RpIQADOh/Q+yHZDA1iL6ysA==", + "dev": true + }, + "class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "clean-css": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.1.tgz", + "integrity": "sha512-4ZxI6dy4lrY6FHzfiy1aEOXgu4LIsW2MhwG0VBKdcoGoH/XLFgaHSdLTGr4O8Be6A8r3MOphEiI8Gc1n0ecf3g==", + "dev": true, + "requires": { + "source-map": "~0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dev": true, + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "cli-width": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz", + "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=", + "dev": true + }, + "cliui": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", + "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=", + "dev": true, + "optional": true, + "requires": { + "center-align": "^0.1.1", + "right-align": "^0.1.1", + "wordwrap": "0.0.2" + }, + "dependencies": { + "wordwrap": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", + "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8=", + "dev": true, + "optional": true + } + } + }, + "clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=", + "dev": true + }, + "clone-deep": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-2.0.2.tgz", + "integrity": "sha512-SZegPTKjCgpQH63E+eN6mVEEPdQBOUzjyJm5Pora4lrwWRFS8I0QAxV/KD6vV/i0WuijHZWQC1fMsPEdxfdVCQ==", + "dev": true, + "requires": { + "for-own": "^1.0.0", + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.0", + "shallow-clone": "^1.0.0" + } + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", + "dev": true + }, + "code-block-writer": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-7.2.1.tgz", + "integrity": "sha512-SYGE48EEUA1yeu7dcd93McZIc4+eLjMiw9uC9+Q3TMe8GibkgjVVLLz43kfTCSOA6wAAZKjdACEVZmcdW6m8ag==", + "dev": true + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", + "dev": true + }, + "codelyzer": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/codelyzer/-/codelyzer-4.2.1.tgz", + "integrity": "sha512-CKwfgpfkqi9dyzy4s6ELaxJ54QgJ6A8iTSsM4bzHbLuTpbKncvNc3DUlCvpnkHBhK47gEf4qFsWoYqLrJPhy6g==", + "dev": true, + "requires": { + "app-root-path": "^2.0.1", + "css-selector-tokenizer": "^0.7.0", + "cssauron": "^1.4.0", + "semver-dsl": "^1.0.1", + "source-map": "^0.5.6", + "sprintf-js": "^1.0.3" + } + }, + "collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=", + "dev": true, + "requires": { + "map-visit": "^1.0.0", + "object-visit": "^1.0.0" + } + }, + "color-convert": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.1.tgz", + "integrity": "sha512-mjGanIiwQJskCC18rPR6OmrZ6fm2Lc7PeGFYwCmy5J34wC6F1PzdGL6xeMfmgicfYcNLGuVFA3WzXtIDCQSZxQ==", + "dev": true, + "requires": { + "color-name": "^1.1.1" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true + }, + "colors": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz", + "integrity": "sha1-FopHAXVran9RoSzgyXv6KMCE7WM=", + "dev": true + }, + "combine-lists": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/combine-lists/-/combine-lists-1.0.1.tgz", + "integrity": "sha1-RYwH4J4NkA/Ci3Cj/sLazR0st/Y=", + "dev": true, + "requires": { + "lodash": "^4.5.0" + } + }, + "combined-stream": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", + "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "commander": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", + "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", + "dev": true + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", + "dev": true + }, + "compare-versions": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.4.0.tgz", + "integrity": "sha512-tK69D7oNXXqUW3ZNo/z7NXTEz22TCF0pTE+YF9cxvaAM9XnkLo1fV621xCLrRR6aevJlKxExkss0vWqUCUpqdg==", + "dev": true + }, + "component-bind": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz", + "integrity": "sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=", + "dev": true + }, + "component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=", + "dev": true + }, + "component-inherit": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz", + "integrity": "sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=", + "dev": true + }, + "compressible": { + "version": "2.0.15", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.15.tgz", + "integrity": "sha512-4aE67DL33dSW9gw4CI2H/yTxqHLNcxp0yS6jB+4h+wr3e43+1z7vm0HU9qXOH8j+qjKuL8+UtkOxYQSMq60Ylw==", + "dev": true, + "requires": { + "mime-db": ">= 1.36.0 < 2" + }, + "dependencies": { + "mime-db": { + "version": "1.36.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.36.0.tgz", + "integrity": "sha512-L+xvyD9MkoYMXb1jAmzI/lWYAxAMCPvIBSWur0PZ5nOf5euahRLVqH//FKW9mWp2lkqUgYiXPgkzfMUFi4zVDw==", + "dev": true + } + } + }, + "compression": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.3.tgz", + "integrity": "sha512-HSjyBG5N1Nnz7tF2+O7A9XUhyjru71/fwgNb7oIsEVHR0WShfs2tIS/EySLgiTe98aOK18YDlMXpzjCXY/n9mg==", + "dev": true, + "requires": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.14", + "debug": "2.6.9", + "on-headers": "~1.0.1", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "connect": { + "version": "3.6.6", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.6.6.tgz", + "integrity": "sha1-Ce/2xVr3I24TcTWnJXSFi2eG9SQ=", + "dev": true, + "requires": { + "debug": "2.6.9", + "finalhandler": "1.1.0", + "parseurl": "~1.3.2", + "utils-merge": "1.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "finalhandler": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.0.tgz", + "integrity": "sha1-zgtoVbRYU+eRsvzGgARtiCU91/U=", + "dev": true, + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.1", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "statuses": "~1.3.1", + "unpipe": "~1.0.0" + } + }, + "statuses": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz", + "integrity": "sha1-+vUbnrdKrvOzrPStX2Gr8ky3uT4=", + "dev": true + } + } + }, + "connect-history-api-fallback": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-1.5.0.tgz", + "integrity": "sha1-sGhzk0vF40T+9hGhlqb6rgruAVo=", + "dev": true + }, + "console-browserify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", + "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=", + "dev": true, + "requires": { + "date-now": "^0.1.4" + } + }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", + "dev": true + }, + "constants-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", + "integrity": "sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=", + "dev": true + }, + "content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ=", + "dev": true + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "dev": true + }, + "convert-source-map": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.5.1.tgz", + "integrity": "sha1-uCeAl7m8IpNl3lxiz1/K7YtVmeU=", + "dev": true + }, + "cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=", + "dev": true + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=", + "dev": true + }, + "copy-concurrently": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz", + "integrity": "sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A==", + "dev": true, + "requires": { + "aproba": "^1.1.1", + "fs-write-stream-atomic": "^1.0.8", + "iferr": "^0.1.5", + "mkdirp": "^0.5.1", + "rimraf": "^2.5.4", + "run-queue": "^1.0.0" + } + }, + "copy-descriptor": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", + "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", + "dev": true + }, + "copy-webpack-plugin": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-4.5.2.tgz", + "integrity": "sha512-zmC33E8FFSq3AbflTvqvPvBo621H36Afsxlui91d+QyZxPIuXghfnTsa1CuqiAaCPgJoSUWfTFbKJnadZpKEbQ==", + "dev": true, + "requires": { + "cacache": "^10.0.4", + "find-cache-dir": "^1.0.0", + "globby": "^7.1.1", + "is-glob": "^4.0.0", + "loader-utils": "^1.1.0", + "minimatch": "^3.0.4", + "p-limit": "^1.0.0", + "serialize-javascript": "^1.4.0" + } + }, + "core-js": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.7.tgz", + "integrity": "sha512-RszJCAxg/PP6uzXVXL6BsxSXx/B05oJAQ2vkJRjyjrEcNVycaqOmNb5OTxZPE3xa5gwZduqza6L9JOCenh/Ecw==" + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "cors": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.4.tgz", + "integrity": "sha1-K9OB8usgECAQXNUOpZ2mMJBpRoY=", + "dev": true, + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, + "cosmiconfig": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-4.0.0.tgz", + "integrity": "sha512-6e5vDdrXZD+t5v0L8CrurPeybg4Fmf+FCSYxXKYVAqLUtyCSbuyqE059d0kDthTNRzKVjL7QMgNpEUlsoYH3iQ==", + "dev": true, + "requires": { + "is-directory": "^0.3.1", + "js-yaml": "^3.9.0", + "parse-json": "^4.0.0", + "require-from-string": "^2.0.1" + }, + "dependencies": { + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + } + } + }, + "create-ecdh": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz", + "integrity": "sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "elliptic": "^6.0.0" + } + }, + "create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dev": true, + "requires": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dev": true, + "requires": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "crypto-browserify": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", + "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==", + "dev": true, + "requires": { + "browserify-cipher": "^1.0.0", + "browserify-sign": "^4.0.0", + "create-ecdh": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.0", + "diffie-hellman": "^5.0.0", + "inherits": "^2.0.1", + "pbkdf2": "^3.0.3", + "public-encrypt": "^4.0.0", + "randombytes": "^2.0.0", + "randomfill": "^1.0.3" + } + }, + "csextends": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/csextends/-/csextends-1.2.0.tgz", + "integrity": "sha512-S/8k1bDTJIwuGgQYmsRoE+8P+ohV32WhQ0l4zqrc0XDdxOhjQQD7/wTZwCzoZX53jSX3V/qwjT+OkPTxWQcmjg==", + "dev": true + }, + "css-parse": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/css-parse/-/css-parse-1.7.0.tgz", + "integrity": "sha1-Mh9s9zeCpv91ERE5D8BeLGV9jJs=", + "dev": true + }, + "css-select": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", + "integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=", + "dev": true, + "requires": { + "boolbase": "~1.0.0", + "css-what": "2.1", + "domutils": "1.5.1", + "nth-check": "~1.0.1" + } + }, + "css-selector-tokenizer": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.7.0.tgz", + "integrity": "sha1-5piEdK6MlTR3v15+/s/OzNnPTIY=", + "dev": true, + "requires": { + "cssesc": "^0.1.0", + "fastparse": "^1.1.1", + "regexpu-core": "^1.0.0" + } + }, + "css-what": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.0.tgz", + "integrity": "sha1-lGfQMsOM+u+58teVASUwYvh/ob0=", + "dev": true + }, + "cssauron": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/cssauron/-/cssauron-1.4.0.tgz", + "integrity": "sha1-pmAt/34EqDBtwNuaVR6S6LVmKtg=", + "dev": true, + "requires": { + "through": "X.X.X" + } + }, + "cssesc": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-0.1.0.tgz", + "integrity": "sha1-yBSQPkViM3GgR3tAEJqq++6t27Q=", + "dev": true + }, + "cuint": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/cuint/-/cuint-0.2.2.tgz", + "integrity": "sha1-QICG1AlVDCYxFVYZ6fp7ytw7mRs=", + "dev": true + }, + "currently-unhandled": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", + "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=", + "dev": true, + "optional": true, + "requires": { + "array-find-index": "^1.0.1" + } + }, + "custom-event": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", + "integrity": "sha1-XQKkaFCt8bSjF5RqOSj8y1v9BCU=", + "dev": true + }, + "cyclist": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-0.2.2.tgz", + "integrity": "sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA=", + "dev": true + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "date-format": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-1.2.0.tgz", + "integrity": "sha1-YV6CjiM90aubua4JUODOzPpuytg=", + "dev": true + }, + "date-now": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", + "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=", + "dev": true + }, + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "dev": true, + "requires": { + "ms": "^2.1.1" + }, + "dependencies": { + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + } + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true + }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", + "dev": true + }, + "deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=", + "dev": true + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true + }, + "default-gateway": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-2.7.2.tgz", + "integrity": "sha512-lAc4i9QJR0YHSDFdzeBQKfZ1SRDG3hsJNEkrpcZa8QhBfidLAilT60BDEIVUUGqosFp425KOgB3uYqcnQrWafQ==", + "dev": true, + "requires": { + "execa": "^0.10.0", + "ip-regex": "^2.1.0" + }, + "dependencies": { + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "execa": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-0.10.0.tgz", + "integrity": "sha512-7XOMnz8Ynx1gGo/3hyV9loYNPWM94jG3+3T3Y8tsfSstFmETmENCMU/A/zj8Lyaj1lkgEepKepvd6240tBRvlw==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^3.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + } + } + }, + "default-require-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-2.0.0.tgz", + "integrity": "sha1-9fj7sYp9bVCyH2QfZJ67Uiz+JPc=", + "dev": true, + "requires": { + "strip-bom": "^3.0.0" + }, + "dependencies": { + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + } + } + }, + "define-properties": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.2.tgz", + "integrity": "sha1-g6c/L+pWmJj7c3GTyPhzyvbUXJQ=", + "dev": true, + "requires": { + "foreach": "^2.0.5", + "object-keys": "^1.0.8" + } + }, + "define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "dev": true, + "requires": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "dependencies": { + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "del": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/del/-/del-2.2.2.tgz", + "integrity": "sha1-wSyYHQZ4RshLyvhiz/kw2Qf/0ag=", + "dev": true, + "requires": { + "globby": "^5.0.0", + "is-path-cwd": "^1.0.0", + "is-path-in-cwd": "^1.0.0", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "rimraf": "^2.2.8" + }, + "dependencies": { + "globby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-5.0.0.tgz", + "integrity": "sha1-69hGZ8oNuzMLmbz8aOrCvFQ3Dg0=", + "dev": true, + "requires": { + "array-union": "^1.0.1", + "arrify": "^1.0.0", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", + "dev": true + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=", + "dev": true + }, + "dependency-graph": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.7.2.tgz", + "integrity": "sha512-KqtH4/EZdtdfWX0p6MGP9jljvxSY6msy/pRUD4jgNwVpv3v1QmNLlsB3LDSSUg79BRVSn7jI1QPRtArGABovAQ==", + "dev": true + }, + "des.js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.0.tgz", + "integrity": "sha1-wHTS4qpqipoH29YfmhXCzYPsjsw=", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=", + "dev": true + }, + "detect-indent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-4.0.0.tgz", + "integrity": "sha1-920GQ1LN9Docts5hnE7jqUdd4gg=", + "dev": true, + "requires": { + "repeating": "^2.0.0" + } + }, + "detect-node": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.0.4.tgz", + "integrity": "sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==", + "dev": true + }, + "di": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", + "integrity": "sha1-gGZJMmzqp8qjMG112YXqJ0i6kTw=", + "dev": true + }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true + }, + "diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "dir-glob": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.0.0.tgz", + "integrity": "sha512-37qirFDz8cA5fimp9feo43fSuRo2gHwaIn6dXL8Ber1dGwUosDrGZeCCXq57WnIqE4aQ+u3eQZzsk1yOzhdwag==", + "dev": true, + "requires": { + "arrify": "^1.0.1", + "path-type": "^3.0.0" + } + }, + "dns-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", + "integrity": "sha1-s55/HabrCnW6nBcySzR1PEfgZU0=", + "dev": true + }, + "dns-packet": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-1.3.1.tgz", + "integrity": "sha512-0UxfQkMhYAUaZI+xrNZOz/as5KgDU0M/fQ9b6SpkyLbk3GEswDi6PADJVaYJradtRVsRIlF1zLyOodbcTCDzUg==", + "dev": true, + "requires": { + "ip": "^1.1.0", + "safe-buffer": "^5.0.1" + } + }, + "dns-txt": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/dns-txt/-/dns-txt-2.0.2.tgz", + "integrity": "sha1-uR2Ab10nGI5Ks+fRB9iBocxGQrY=", + "dev": true, + "requires": { + "buffer-indexof": "^1.0.0" + } + }, + "dom-converter": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.1.4.tgz", + "integrity": "sha1-pF71cnuJDJv/5tfIduexnLDhfzs=", + "dev": true, + "requires": { + "utila": "~0.3" + }, + "dependencies": { + "utila": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/utila/-/utila-0.3.3.tgz", + "integrity": "sha1-1+jn1+MJEHCSsF+NloiCTWM6QiY=", + "dev": true + } + } + }, + "dom-serialize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", + "integrity": "sha1-ViromZ9Evl6jB29UGdzVnrQ6yVs=", + "dev": true, + "requires": { + "custom-event": "~1.0.0", + "ent": "~2.2.0", + "extend": "^3.0.0", + "void-elements": "^2.0.0" + } + }, + "dom-serializer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz", + "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=", + "dev": true, + "requires": { + "domelementtype": "~1.1.1", + "entities": "~1.1.1" + }, + "dependencies": { + "domelementtype": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz", + "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=", + "dev": true + } + } + }, + "domain-browser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", + "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==", + "dev": true + }, + "domelementtype": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz", + "integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI=", + "dev": true + }, + "domhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.1.0.tgz", + "integrity": "sha1-0mRvXlf2w7qxHPbLBdPArPdBJZQ=", + "dev": true, + "requires": { + "domelementtype": "1" + } + }, + "domutils": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", + "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=", + "dev": true, + "requires": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "dot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/dot/-/dot-1.1.2.tgz", + "integrity": "sha1-xzdwGfxOVQeYkosrmv62ar+h8vk=", + "dev": true + }, + "duplexer": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", + "integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E=", + "dev": true + }, + "duplexify": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.6.0.tgz", + "integrity": "sha512-fO3Di4tBKJpYTFHAxTU00BcfWMY9w24r/x21a6rZRbsD/ToUgGxsMbiGRmB7uVAXeGKXD9MwiLZa5E97EVgIRQ==", + "dev": true, + "requires": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "eachr": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/eachr/-/eachr-3.2.0.tgz", + "integrity": "sha1-LDXkPqCGUW95l8+At6pk1VpKRIQ=", + "dev": true, + "requires": { + "editions": "^1.1.1", + "typechecker": "^4.3.0" + } + }, + "ecc-jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", + "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=", + "dev": true, + "optional": true, + "requires": { + "jsbn": "~0.1.0" + } + }, + "editions": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/editions/-/editions-1.3.4.tgz", + "integrity": "sha512-gzao+mxnYDzIysXKMQi/+M1mjy/rjestjg6OPoYTtI+3Izp23oiGZitsl9lPDPiTGXbcSIk1iJWhliSaglxnUg==", + "dev": true + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", + "dev": true + }, + "ejs": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-2.6.1.tgz", + "integrity": "sha512-0xy4A/twfrRCnkhfk8ErDi5DqdAsAqeGxht4xkCUrsvhhbQNs7E+4jV0CN7+NKIY0aHE72+XvqtBIXzD31ZbXQ==", + "dev": true + }, + "electron-to-chromium": { + "version": "1.3.70", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.70.tgz", + "integrity": "sha512-WYMjqCnPVS5JA+XvwEnpwucJpVi2+q9cdCFpbhxgWGsCtforFBEkuP9+nCyy/wnU/0SyLcLRIeZct9ayMGcXoQ==", + "dev": true + }, + "elliptic": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.1.tgz", + "integrity": "sha512-BsXLz5sqX8OHcsh7CqBMztyXARmGQ3LWPtGjJi6DiJHq5C/qvi9P3OqgswKSDftbu8+IoI/QDTAm2fFnQ9SZSQ==", + "dev": true, + "requires": { + "bn.js": "^4.4.0", + "brorand": "^1.0.1", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.0" + } + }, + "emojis-list": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.1.0.tgz", + "integrity": "sha1-TapNnbAPmBmIDHn6RXrlsJof04k=", + "dev": true + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", + "dev": true + }, + "encoding": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", + "integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=", + "dev": true, + "requires": { + "iconv-lite": "~0.4.13" + } + }, + "end-of-stream": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", + "integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, + "engine.io": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-3.2.0.tgz", + "integrity": "sha512-mRbgmAtQ4GAlKwuPnnAvXXwdPhEx+jkc0OBCLrXuD/CRvwNK3AxRSnqK4FSqmAMRRHryVJP8TopOvmEaA64fKw==", + "dev": true, + "requires": { + "accepts": "~1.3.4", + "base64id": "1.0.0", + "cookie": "0.3.1", + "debug": "~3.1.0", + "engine.io-parser": "~2.1.0", + "ws": "~3.3.1" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "engine.io-client": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.2.1.tgz", + "integrity": "sha512-y5AbkytWeM4jQr7m/koQLc5AxpRKC1hEVUb/s1FUAWEJq5AzJJ4NLvzuKPuxtDi5Mq755WuDvZ6Iv2rXj4PTzw==", + "dev": true, + "requires": { + "component-emitter": "1.2.1", + "component-inherit": "0.0.3", + "debug": "~3.1.0", + "engine.io-parser": "~2.1.1", + "has-cors": "1.1.0", + "indexof": "0.0.1", + "parseqs": "0.0.5", + "parseuri": "0.0.5", + "ws": "~3.3.1", + "xmlhttprequest-ssl": "~1.5.4", + "yeast": "0.1.2" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "engine.io-parser": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-2.1.2.tgz", + "integrity": "sha512-dInLFzr80RijZ1rGpx1+56/uFoH7/7InhH3kZt+Ms6hT8tNx3NGW/WNSA/f8As1WkOfkuyb3tnRyuXGxusclMw==", + "dev": true, + "requires": { + "after": "0.8.2", + "arraybuffer.slice": "~0.0.7", + "base64-arraybuffer": "0.1.5", + "blob": "0.0.4", + "has-binary2": "~1.0.2" + } + }, + "enhanced-resolve": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.1.0.tgz", + "integrity": "sha512-F/7vkyTtyc/llOIn8oWclcB25KdRaiPBpZYDgJHgh/UHtpgT2p2eldQgtQnLtUvfMKPKxbRaQM/hHkvLHt1Vng==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "memory-fs": "^0.4.0", + "tapable": "^1.0.0" + } + }, + "ent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz", + "integrity": "sha1-6WQhkyWiHQX0RGai9obtbOX13R0=", + "dev": true + }, + "entities": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz", + "integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA=", + "dev": true + }, + "errlop": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/errlop/-/errlop-1.0.3.tgz", + "integrity": "sha512-5VTnt0yikY4LlQEfCXVSqfE6oLj1HVM4zVSvAKMnoYjL/zrb6nqiLowZS4XlG7xENfyj7lpYWvT+wfSCr6dtlA==", + "dev": true, + "requires": { + "editions": "^1.3.4" + } + }, + "errno": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.7.tgz", + "integrity": "sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg==", + "dev": true, + "requires": { + "prr": "~1.0.1" + } + }, + "error-ex": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.1.tgz", + "integrity": "sha1-+FWobOYa3E6GIcPNoh56dhLDqNw=", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es-abstract": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.12.0.tgz", + "integrity": "sha512-C8Fx/0jFmV5IPoMOFPA9P9G5NtqW+4cOPit3MIuvR2t7Ag2K15EJTpxnHAYTzL+aYQJIESYeXZmDBfOBE1HcpA==", + "dev": true, + "requires": { + "es-to-primitive": "^1.1.1", + "function-bind": "^1.1.1", + "has": "^1.0.1", + "is-callable": "^1.1.3", + "is-regex": "^1.0.4" + } + }, + "es-to-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.1.1.tgz", + "integrity": "sha1-RTVSSKiJeQNLZ5Lhm7gfK3l13Q0=", + "dev": true, + "requires": { + "is-callable": "^1.1.1", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.1" + } + }, + "es6-promise": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.5.tgz", + "integrity": "sha512-n6wvpdE43VFtJq+lUDYDBFUwV8TZbuGXLV4D6wKafg13ldznKsyEvatubnmUe31zcvelSzOHF+XbaT+Bl9ObDg==", + "dev": true + }, + "es6-promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", + "dev": true, + "requires": { + "es6-promise": "^4.0.3" + } + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "escodegen": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.8.1.tgz", + "integrity": "sha1-WltTr0aTEQvrsIZ6o0MN07cKEBg=", + "dev": true, + "requires": { + "esprima": "^2.7.1", + "estraverse": "^1.9.1", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.2.0" + }, + "dependencies": { + "source-map": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz", + "integrity": "sha1-2rc/vPwrqBm03gO9b26qSBZLP50=", + "dev": true, + "optional": true, + "requires": { + "amdefine": ">=0.0.4" + } + } + } + }, + "eslint-scope": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-3.7.3.tgz", + "integrity": "sha512-W+B0SvF4gamyCTmUc+uITPY0989iXVfKvhwtmJocTaYoc/3khEHmEmvfY/Gn9HA9VV75jrQECsHizkNw1b68FA==", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + }, + "dependencies": { + "estraverse": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz", + "integrity": "sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=", + "dev": true + } + } + }, + "esprima": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", + "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE=", + "dev": true + }, + "esrecurse": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", + "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", + "dev": true, + "requires": { + "estraverse": "^4.1.0" + }, + "dependencies": { + "estraverse": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz", + "integrity": "sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=", + "dev": true + } + } + }, + "estraverse": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-1.9.3.tgz", + "integrity": "sha1-r2fy3JIlgkFZUJJgkaQAXSnJu0Q=", + "dev": true + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", + "dev": true + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", + "dev": true + }, + "event-stream": { + "version": "3.3.4", + "resolved": "http://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha1-SrTJoPWlTbkzi0w02Gv86PSzVXE=", + "dev": true, + "requires": { + "duplexer": "~0.1.1", + "from": "~0", + "map-stream": "~0.1.0", + "pause-stream": "0.0.11", + "split": "0.3", + "stream-combiner": "~0.0.4", + "through": "~2.3.1" + } + }, + "eventemitter3": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.0.tgz", + "integrity": "sha512-ivIvhpq/Y0uSjcHDcOIccjmYjGLcP09MFGE7ysAwkAvkXfpZlC985pH2/ui64DKazbTW/4kN3yqozUxlXzI6cA==", + "dev": true + }, + "events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=", + "dev": true + }, + "eventsource": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-0.1.6.tgz", + "integrity": "sha1-Cs7ehJ7X3RzMMsgRuxG5RNTykjI=", + "dev": true, + "requires": { + "original": ">=0.0.5" + } + }, + "evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "dev": true, + "requires": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, + "execa": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", + "integrity": "sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=", + "dev": true, + "requires": { + "cross-spawn": "^5.0.1", + "get-stream": "^3.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + } + } + }, + "exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", + "dev": true + }, + "expand-braces": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/expand-braces/-/expand-braces-0.1.2.tgz", + "integrity": "sha1-SIsdHSRRyz06axks/AMPRMWFX+o=", + "dev": true, + "requires": { + "array-slice": "^0.2.3", + "array-unique": "^0.2.1", + "braces": "^0.1.2" + }, + "dependencies": { + "array-unique": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz", + "integrity": "sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=", + "dev": true + }, + "braces": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/braces/-/braces-0.1.5.tgz", + "integrity": "sha1-wIVxEIUpHYt1/ddOqw+FlygHEeY=", + "dev": true, + "requires": { + "expand-range": "^0.1.0" + } + }, + "expand-range": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-0.1.1.tgz", + "integrity": "sha1-TLjtoJk8pW+k9B/ELzy7TMrf8EQ=", + "dev": true, + "requires": { + "is-number": "^0.1.1", + "repeat-string": "^0.2.2" + } + }, + "is-number": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-0.1.1.tgz", + "integrity": "sha1-aaevEWlj1HIG7JvZtIoUIW8eOAY=", + "dev": true + }, + "repeat-string": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-0.2.2.tgz", + "integrity": "sha1-x6jTI2BoNiBZp+RlH8aITosftK4=", + "dev": true + } + } + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "dev": true, + "requires": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "expand-range": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz", + "integrity": "sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc=", + "dev": true, + "requires": { + "fill-range": "^2.1.0" + }, + "dependencies": { + "fill-range": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.4.tgz", + "integrity": "sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q==", + "dev": true, + "requires": { + "is-number": "^2.1.0", + "isobject": "^2.0.0", + "randomatic": "^3.0.0", + "repeat-element": "^1.1.2", + "repeat-string": "^1.5.2" + } + }, + "is-number": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz", + "integrity": "sha1-Afy7s5NGOlSPL0ZszhbezknbkI8=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + } + }, + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "dev": true, + "requires": { + "isarray": "1.0.0" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "express": { + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.16.3.tgz", + "integrity": "sha1-avilAjUNsyRuzEvs9rWjTSL37VM=", + "dev": true, + "requires": { + "accepts": "~1.3.5", + "array-flatten": "1.1.1", + "body-parser": "1.18.2", + "content-disposition": "0.5.2", + "content-type": "~1.0.4", + "cookie": "0.3.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.1.1", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.3", + "qs": "6.5.1", + "range-parser": "~1.2.0", + "safe-buffer": "5.1.1", + "send": "0.16.2", + "serve-static": "1.13.2", + "setprototypeof": "1.1.0", + "statuses": "~1.4.0", + "type-is": "~1.6.16", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "qs": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", + "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==", + "dev": true + }, + "safe-buffer": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", + "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==", + "dev": true + } + } + }, + "extend": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz", + "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=", + "dev": true + }, + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "dev": true, + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "extendr": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/extendr/-/extendr-3.3.0.tgz", + "integrity": "sha512-BmBSu+KOX2XOo3XMECiekGY8VAr3O4aGYgOaHQDNg2ez5rOYW+SDfNStao4VNzr+6N27Vw3A7HJKJMrHmAAXvQ==", + "dev": true, + "requires": { + "editions": "^1.3.3", + "typechecker": "^4.4.1" + } + }, + "external-editor": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-bn71H9+qWoOQKyZDo25mOMVpSmXROAsTJVVVYzrrtol3d4y+AsKjf4Iwl2Q+IuT0kFSQ1qo166UuIwqYq7mGnA==", + "dev": true, + "requires": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "dependencies": { + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + } + } + }, + "extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "dev": true, + "requires": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "extract-opts": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/extract-opts/-/extract-opts-3.3.1.tgz", + "integrity": "sha1-WrvtyYwNUgLjJ4cn+Rktfghsa+E=", + "dev": true, + "requires": { + "eachr": "^3.2.0", + "editions": "^1.1.1", + "typechecker": "^4.3.0" + } + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", + "dev": true + }, + "fancy-log": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-1.3.2.tgz", + "integrity": "sha1-9BEl49hPLn2JpD0G2VjI94vha+E=", + "dev": true, + "requires": { + "ansi-gray": "^0.1.1", + "color-support": "^1.1.3", + "time-stamp": "^1.0.0" + } + }, + "fast-deep-equal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", + "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=", + "dev": true + }, + "fast-glob": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-2.2.2.tgz", + "integrity": "sha512-TR6zxCKftDQnUAPvkrCWdBgDq/gbqx8A3ApnBrR5rMvpp6+KMJI0Igw7fkWPgeVK0uhRXTXdvO3O+YP0CaUX2g==", + "dev": true, + "requires": { + "@mrmlnc/readdir-enhanced": "^2.2.1", + "@nodelib/fs.stat": "^1.0.1", + "glob-parent": "^3.1.0", + "is-glob": "^4.0.0", + "merge2": "^1.2.1", + "micromatch": "^3.1.10" + } + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "fastparse": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.1.tgz", + "integrity": "sha1-0eJkOzipTXWDtHkGDmxK/8lAcfg=", + "dev": true + }, + "faye-websocket": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.10.0.tgz", + "integrity": "sha1-TkkvjQTftviQA1B/btvy1QHnxvQ=", + "dev": true, + "requires": { + "websocket-driver": ">=0.5.1" + } + }, + "figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "file-loader": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-1.1.11.tgz", + "integrity": "sha512-TGR4HU7HUsGg6GCOPJnFk06RhWgEWFLAGWiT6rcD+GRC2keU3s9RGJ+b3Z6/U73jwwNb2gKLJ7YCrp+jvU4ALg==", + "dev": true, + "requires": { + "loader-utils": "^1.0.2", + "schema-utils": "^0.4.5" + } + }, + "filename-regex": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz", + "integrity": "sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY=", + "dev": true + }, + "fileset": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/fileset/-/fileset-2.0.3.tgz", + "integrity": "sha1-jnVIqW08wjJ+5eZ0FocjozO7oqA=", + "dev": true, + "requires": { + "glob": "^7.0.3", + "minimatch": "^3.0.3" + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "finalhandler": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", + "integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==", + "dev": true, + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "statuses": "~1.4.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "find-cache-dir": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-1.0.0.tgz", + "integrity": "sha1-kojj6ePMN0hxfTnq3hfPcfww7m8=", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^1.0.0", + "pkg-dir": "^2.0.0" + } + }, + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "dev": true, + "requires": { + "locate-path": "^2.0.0" + } + }, + "findit2": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/findit2/-/findit2-2.2.3.tgz", + "integrity": "sha1-WKRmaX34piBc39vzlVNri9d3pfY=", + "dev": true + }, + "flat": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/flat/-/flat-2.0.1.tgz", + "integrity": "sha1-cOKRiKdL4MPIlAnu0fqVd5B64y8=", + "dev": true, + "requires": { + "is-buffer": "~1.1.2" + } + }, + "flush-write-stream": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.0.3.tgz", + "integrity": "sha512-calZMC10u0FMUqoiunI2AiGIIUtUIvifNwkHhNupZH4cbNnW1Itkoh/Nf5HFYmDrwWPjrUxpkZT0KhuCq0jmGw==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.4" + } + }, + "follow-redirects": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.0.tgz", + "integrity": "sha512-fdrt472/9qQ6Kgjvb935ig6vJCuofpBUD14f9Vb+SLlm7xIe4Qva5gey8EKtv8lp7ahE1wilg3xL1znpVGtZIA==", + "dev": true, + "requires": { + "debug": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", + "dev": true + }, + "for-own": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", + "integrity": "sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs=", + "dev": true, + "requires": { + "for-in": "^1.0.1" + } + }, + "foreach": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", + "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=", + "dev": true + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", + "dev": true + }, + "form-data": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", + "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "1.0.6", + "mime-types": "^2.1.12" + } + }, + "forwarded": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", + "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=", + "dev": true + }, + "fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", + "dev": true, + "requires": { + "map-cache": "^0.2.2" + } + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=", + "dev": true + }, + "from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha1-g8YK/Fi5xWmXAH7Rp2izqzA6RP4=", + "dev": true + }, + "from2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", + "integrity": "sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8=", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.0" + } + }, + "fs": { + "version": "0.0.1-security", + "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz", + "integrity": "sha1-invTcYa23d84E/I4WLV+yq9eQdQ=", + "dev": true + }, + "fs-access": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fs-access/-/fs-access-1.0.1.tgz", + "integrity": "sha1-1qh/JiJxzv6+wwxVNAf7mV2od3o=", + "dev": true, + "requires": { + "null-check": "^1.0.0" + } + }, + "fs-extra": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.0.tgz", + "integrity": "sha512-EglNDLRpmaTWiD/qraZn6HREAEAHJcJOmxNEYwq6xeMKnVMAy3GUcFB+wXt2C6k4CNvB/mP1y/U3dzvKKj5OtQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "fs-write-stream-atomic": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz", + "integrity": "sha1-tH31NJPvkR33VzHnCp3tAYnbQMk=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "iferr": "^0.1.5", + "imurmurhash": "^0.1.4", + "readable-stream": "1 || 2" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "fsevents": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.4.tgz", + "integrity": "sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg==", + "dev": true, + "optional": true, + "requires": { + "nan": "^2.9.2", + "node-pre-gyp": "^0.10.0" + }, + "dependencies": { + "abbrev": { + "version": "1.1.1", + "resolved": false, + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "optional": true + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": false, + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "aproba": { + "version": "1.2.0", + "resolved": false, + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "dev": true, + "optional": true + }, + "are-we-there-yet": { + "version": "1.1.4", + "resolved": false, + "integrity": "sha1-u13KOCu5TwXhUZQ3PRb9O6HKEQ0=", + "dev": true, + "optional": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "balanced-match": { + "version": "1.0.0", + "resolved": false, + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": false, + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "chownr": { + "version": "1.0.1", + "resolved": false, + "integrity": "sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE=", + "dev": true, + "optional": true + }, + "code-point-at": { + "version": "1.1.0", + "resolved": false, + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": false, + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "console-control-strings": { + "version": "1.1.0", + "resolved": false, + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "resolved": false, + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true, + "optional": true + }, + "debug": { + "version": "2.6.9", + "resolved": false, + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "optional": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-extend": { + "version": "0.5.1", + "resolved": false, + "integrity": "sha512-N8vBdOa+DF7zkRrDCsaOXoCs/E2fJfx9B9MrKnnSiHNh4ws7eSys6YQE4KvT1cecKmOASYQBhbKjeuDD9lT81w==", + "dev": true, + "optional": true + }, + "delegates": { + "version": "1.0.0", + "resolved": false, + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", + "dev": true, + "optional": true + }, + "detect-libc": { + "version": "1.0.3", + "resolved": false, + "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", + "dev": true, + "optional": true + }, + "fs-minipass": { + "version": "1.2.5", + "resolved": false, + "integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==", + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": false, + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true, + "optional": true + }, + "gauge": { + "version": "2.7.4", + "resolved": false, + "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", + "dev": true, + "optional": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "glob": { + "version": "7.1.2", + "resolved": false, + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "optional": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-unicode": { + "version": "2.0.1", + "resolved": false, + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", + "dev": true, + "optional": true + }, + "iconv-lite": { + "version": "0.4.21", + "resolved": false, + "integrity": "sha512-En5V9za5mBt2oUA03WGD3TwDv0MKAruqsuxstbMUZaj9W9k/m1CV/9py3l0L5kw9Bln8fdHQmzHSYtvpvTLpKw==", + "dev": true, + "optional": true, + "requires": { + "safer-buffer": "^2.1.0" + } + }, + "ignore-walk": { + "version": "3.0.1", + "resolved": false, + "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==", + "dev": true, + "optional": true, + "requires": { + "minimatch": "^3.0.4" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": false, + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "optional": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": false, + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + }, + "ini": { + "version": "1.3.5", + "resolved": false, + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", + "dev": true, + "optional": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": false, + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "resolved": false, + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true, + "optional": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": false, + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": false, + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "minipass": { + "version": "2.2.4", + "resolved": false, + "integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.1", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.1.0", + "resolved": false, + "integrity": "sha512-4T6Ur/GctZ27nHfpt9THOdRZNgyJ9FZchYO1ceg5S8Q3DNLCKYy44nCZzgCJgcvx2UM8czmqak5BCxJMrq37lA==", + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "mkdirp": { + "version": "0.5.1", + "resolved": false, + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "resolved": false, + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true, + "optional": true + }, + "needle": { + "version": "2.2.0", + "resolved": false, + "integrity": "sha512-eFagy6c+TYayorXw/qtAdSvaUpEbBsDwDyxYFgLZ0lTojfH7K+OdBqAF7TAFwDokJaGpubpSGG0wO3iC0XPi8w==", + "dev": true, + "optional": true, + "requires": { + "debug": "^2.1.2", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + } + }, + "node-pre-gyp": { + "version": "0.10.0", + "resolved": false, + "integrity": "sha512-G7kEonQLRbcA/mOoFoxvlMrw6Q6dPf92+t/l0DFSMuSlDoWaI9JWIyPwK0jyE1bph//CUEL65/Fz1m2vJbmjQQ==", + "dev": true, + "optional": true, + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.0", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.1.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4" + } + }, + "nopt": { + "version": "4.0.1", + "resolved": false, + "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", + "dev": true, + "optional": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "npm-bundled": { + "version": "1.0.3", + "resolved": false, + "integrity": "sha512-ByQ3oJ/5ETLyglU2+8dBObvhfWXX8dtPZDMePCahptliFX2iIuhyEszyFk401PZUNQH20vvdW5MLjJxkwU80Ow==", + "dev": true, + "optional": true + }, + "npm-packlist": { + "version": "1.1.10", + "resolved": false, + "integrity": "sha512-AQC0Dyhzn4EiYEfIUjCdMl0JJ61I2ER9ukf/sLxJUcZHfo+VyEfz2rMJgLZSS1v30OxPQe1cN0LZA1xbcaVfWA==", + "dev": true, + "optional": true, + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1" + } + }, + "npmlog": { + "version": "4.1.2", + "resolved": false, + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "dev": true, + "optional": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": false, + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": false, + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true, + "optional": true + }, + "once": { + "version": "1.4.0", + "resolved": false, + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "os-homedir": { + "version": "1.0.2", + "resolved": false, + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", + "dev": true, + "optional": true + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": false, + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true, + "optional": true + }, + "osenv": { + "version": "0.1.5", + "resolved": false, + "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", + "dev": true, + "optional": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": false, + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true, + "optional": true + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": false, + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", + "dev": true, + "optional": true + }, + "rc": { + "version": "1.2.7", + "resolved": false, + "integrity": "sha512-LdLD8xD4zzLsAT5xyushXDNscEjB7+2ulnl8+r1pnESlYtlJtVSoCMBGr30eDRJ3+2Gq89jK9P9e4tCEH1+ywA==", + "dev": true, + "optional": true, + "requires": { + "deep-extend": "^0.5.1", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "resolved": false, + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true, + "optional": true + } + } + }, + "readable-stream": { + "version": "2.3.6", + "resolved": false, + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "dev": true, + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rimraf": { + "version": "2.6.2", + "resolved": false, + "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==", + "dev": true, + "optional": true, + "requires": { + "glob": "^7.0.5" + } + }, + "safe-buffer": { + "version": "5.1.1", + "resolved": false, + "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==", + "dev": true + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": false, + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "optional": true + }, + "sax": { + "version": "1.2.4", + "resolved": false, + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true, + "optional": true + }, + "semver": { + "version": "5.5.0", + "resolved": false, + "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==", + "dev": true, + "optional": true + }, + "set-blocking": { + "version": "2.0.0", + "resolved": false, + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "dev": true, + "optional": true + }, + "signal-exit": { + "version": "3.0.2", + "resolved": false, + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", + "dev": true, + "optional": true + }, + "string-width": { + "version": "1.0.2", + "resolved": false, + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": false, + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": false, + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": false, + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true, + "optional": true + }, + "tar": { + "version": "4.4.1", + "resolved": false, + "integrity": "sha512-O+v1r9yN4tOsvl90p5HAP4AEqbYhx4036AGMm075fH9F8Qwi3oJ+v4u50FkT/KkvywNGtwkk0zRI+8eYm1X/xg==", + "dev": true, + "optional": true, + "requires": { + "chownr": "^1.0.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.2.4", + "minizlib": "^1.1.0", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.1", + "yallist": "^3.0.2" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": false, + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true, + "optional": true + }, + "wide-align": { + "version": "1.1.2", + "resolved": false, + "integrity": "sha512-ijDLlyQ7s6x1JgCLur53osjm/UXUYD9+0PbYKrBsYisYXzCxN+HC3mYDNy/dWdmf3AwqwU3CXwDCvsNgGK1S0w==", + "dev": true, + "optional": true, + "requires": { + "string-width": "^1.0.2" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": false, + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "yallist": { + "version": "3.0.2", + "resolved": false, + "integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=", + "dev": true + } + } + }, + "fstream": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.11.tgz", + "integrity": "sha1-XB+x8RdHcRTwYyoOtLcbPLD9MXE=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + } + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", + "dev": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "gaze": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/gaze/-/gaze-1.1.3.tgz", + "integrity": "sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g==", + "dev": true, + "optional": true, + "requires": { + "globule": "^1.0.0" + } + }, + "get-caller-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.2.tgz", + "integrity": "sha1-9wLmMSfn4jHBYKgMFVSstw1QR+U=", + "dev": true + }, + "get-stdin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", + "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=", + "dev": true + }, + "get-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", + "dev": true + }, + "get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", + "dev": true + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "gettext-parser": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/gettext-parser/-/gettext-parser-1.2.2.tgz", + "integrity": "sha1-HvDadcHnWa4wicc++k0Z5AKYdI4=", + "dev": true, + "requires": { + "encoding": "0.1.12" + } + }, + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-base": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz", + "integrity": "sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=", + "dev": true, + "requires": { + "glob-parent": "^2.0.0", + "is-glob": "^2.0.0" + }, + "dependencies": { + "glob-parent": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz", + "integrity": "sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=", + "dev": true, + "requires": { + "is-glob": "^2.0.0" + } + }, + "is-extglob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", + "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=", + "dev": true + }, + "is-glob": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", + "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", + "dev": true, + "requires": { + "is-extglob": "^1.0.0" + } + } + } + }, + "glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", + "dev": true, + "requires": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + }, + "dependencies": { + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "requires": { + "is-extglob": "^2.1.0" + } + } + } + }, + "glob-to-regexp": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz", + "integrity": "sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs=", + "dev": true + }, + "globals": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz", + "integrity": "sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==", + "dev": true + }, + "globby": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/globby/-/globby-7.1.1.tgz", + "integrity": "sha1-+yzP+UAfhgCUXfral0QMypcrhoA=", + "dev": true, + "requires": { + "array-union": "^1.0.1", + "dir-glob": "^2.0.0", + "glob": "^7.1.2", + "ignore": "^3.3.5", + "pify": "^3.0.0", + "slash": "^1.0.0" + } + }, + "globule": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/globule/-/globule-1.2.1.tgz", + "integrity": "sha512-g7QtgWF4uYSL5/dn71WxubOrS7JVGCnFPEnoeChJmBnyR9Mw8nGoEwOgJL/RC2Te0WhbsEUCejfH8SZNJ+adYQ==", + "dev": true, + "optional": true, + "requires": { + "glob": "~7.1.1", + "lodash": "~4.17.10", + "minimatch": "~3.0.2" + } + }, + "graceful-fs": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", + "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=", + "dev": true + }, + "handle-thing": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-1.2.5.tgz", + "integrity": "sha1-/Xqtcmvxpf0W38KbL3pmAdJxOcQ=", + "dev": true + }, + "handlebars": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.11.tgz", + "integrity": "sha1-Ywo13+ApS8KB7a5v/F0yn8eYLcw=", + "dev": true, + "requires": { + "async": "^1.4.0", + "optimist": "^0.6.1", + "source-map": "^0.4.4", + "uglify-js": "^2.6" + }, + "dependencies": { + "source-map": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", + "dev": true, + "requires": { + "amdefine": ">=0.0.4" + } + }, + "uglify-js": { + "version": "2.8.29", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz", + "integrity": "sha1-KcVzMUgFe7Th913zW3qcty5qWd0=", + "dev": true, + "optional": true, + "requires": { + "source-map": "~0.5.1", + "uglify-to-browserify": "~1.0.0", + "yargs": "~3.10.0" + }, + "dependencies": { + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true, + "optional": true + } + } + } + } + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", + "dev": true + }, + "har-validator": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz", + "integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=", + "dev": true, + "requires": { + "ajv": "^5.1.0", + "har-schema": "^2.0.0" + }, + "dependencies": { + "ajv": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", + "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", + "dev": true, + "requires": { + "co": "^4.6.0", + "fast-deep-equal": "^1.0.0", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.3.0" + } + } + } + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "has-binary2": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-binary2/-/has-binary2-1.0.3.tgz", + "integrity": "sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==", + "dev": true, + "requires": { + "isarray": "2.0.1" + }, + "dependencies": { + "isarray": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz", + "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=", + "dev": true + } + } + }, + "has-cors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz", + "integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", + "dev": true + }, + "has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", + "dev": true, + "requires": { + "get-value": "^2.0.6", + "has-values": "^1.0.0", + "isobject": "^3.0.0" + } + }, + "has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "dependencies": { + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "hash-base": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz", + "integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "hash.js": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.5.tgz", + "integrity": "sha512-eWI5HG9Np+eHV1KQhisXWwM+4EPPYe5dFX1UZZH7k/E3JzDEazVH+VGlZi6R94ZqImq+A3D1mCEtrFIfg/E7sA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", + "dev": true + }, + "hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", + "dev": true, + "requires": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "hosted-git-info": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.6.0.tgz", + "integrity": "sha512-lIbgIIQA3lz5XaB6vxakj6sDHADJiZadYEJB+FgA+C4nubM1NwcuvUr9EJPmnH1skZqpqUzWborWo8EIUi0Sdw==", + "dev": true + }, + "hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "html-entities": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.2.1.tgz", + "integrity": "sha1-DfKTUfByEWNRXfueVUPl9u7VFi8=", + "dev": true + }, + "html-minifier": { + "version": "3.5.20", + "resolved": "https://registry.npmjs.org/html-minifier/-/html-minifier-3.5.20.tgz", + "integrity": "sha512-ZmgNLaTp54+HFKkONyLFEfs5dd/ZOtlquKaTnqIWFmx3Av5zG6ZPcV2d0o9XM2fXOTxxIf6eDcwzFFotke/5zA==", + "dev": true, + "requires": { + "camel-case": "3.0.x", + "clean-css": "4.2.x", + "commander": "2.17.x", + "he": "1.1.x", + "param-case": "2.1.x", + "relateurl": "0.2.x", + "uglify-js": "3.4.x" + }, + "dependencies": { + "commander": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", + "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", + "dev": true + } + } + }, + "html-webpack-plugin": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-3.2.0.tgz", + "integrity": "sha1-sBq71yOsqqeze2r0SS69oD2d03s=", + "dev": true, + "requires": { + "html-minifier": "^3.2.3", + "loader-utils": "^0.2.16", + "lodash": "^4.17.3", + "pretty-error": "^2.0.2", + "tapable": "^1.0.0", + "toposort": "^1.0.0", + "util.promisify": "1.0.0" + }, + "dependencies": { + "loader-utils": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-0.2.17.tgz", + "integrity": "sha1-+G5jdNQyBabmxg6RlvF8Apm/s0g=", + "dev": true, + "requires": { + "big.js": "^3.1.3", + "emojis-list": "^2.0.0", + "json5": "^0.5.0", + "object-assign": "^4.0.1" + } + } + } + }, + "htmlparser2": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.3.0.tgz", + "integrity": "sha1-zHDQWln2VC5D8OaFyYLhTJJKnv4=", + "dev": true, + "requires": { + "domelementtype": "1", + "domhandler": "2.1", + "domutils": "1.1", + "readable-stream": "1.0" + }, + "dependencies": { + "domutils": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.1.6.tgz", + "integrity": "sha1-vdw94Jm5ou+sxRxiPyj0FuzFdIU=", + "dev": true, + "requires": { + "domelementtype": "1" + } + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, + "readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", + "dev": true + } + } + }, + "http-auth": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/http-auth/-/http-auth-2.4.11.tgz", + "integrity": "sha1-YfAkpuDnxIk0lEiVyHoTlVCcYZs=", + "dev": true, + "requires": { + "apache-crypt": "1.1.2", + "apache-md5": "1.0.6", + "node-uuid": "^1.4.7" + }, + "dependencies": { + "node-uuid": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.8.tgz", + "integrity": "sha1-sEDrCSOWivq/jTL7HxfxFn/auQc=", + "dev": true + } + } + }, + "http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=", + "dev": true + }, + "http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", + "dev": true, + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + } + }, + "http-parser-js": { + "version": "0.4.13", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.4.13.tgz", + "integrity": "sha1-O9bW/ebjFyyTNMOzO2wZPYD+ETc=", + "dev": true + }, + "http-proxy": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.17.0.tgz", + "integrity": "sha512-Taqn+3nNvYRfJ3bGvKfBSRwy1v6eePlm3oc/aWVxZp57DQr5Eq3xhKJi7Z4hZpS8PC3H4qI+Yly5EmFacGuA/g==", + "dev": true, + "requires": { + "eventemitter3": "^3.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + } + }, + "http-proxy-middleware": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-0.18.0.tgz", + "integrity": "sha512-Fs25KVMPAIIcgjMZkVHJoKg9VcXcC1C8yb9JUgeDvVXY0S/zgVIhMb+qVswDIgtJe2DfckMSY2d6TuTEutlk6Q==", + "dev": true, + "requires": { + "http-proxy": "^1.16.2", + "is-glob": "^4.0.0", + "lodash": "^4.17.5", + "micromatch": "^3.1.9" + } + }, + "https-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", + "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", + "dev": true + }, + "https-proxy-agent": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz", + "integrity": "sha512-HPCTS1LW51bcyMYbxUIOO4HEOlQ1/1qRaFWcyxvwaqUS9TY88aoEuHUY33kuAh1YhVVaDQhLZsnPd+XNARWZlQ==", + "dev": true, + "requires": { + "agent-base": "^4.1.0", + "debug": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + } + } + }, + "husky": { + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/husky/-/husky-0.14.3.tgz", + "integrity": "sha512-e21wivqHpstpoiWA/Yi8eFti8E+sQDSS53cpJsPptPs295QTOQR0ZwnHo2TXy1XOpZFD9rPOd3NpmqTK6uMLJA==", + "dev": true, + "requires": { + "is-ci": "^1.0.10", + "normalize-path": "^1.0.0", + "strip-indent": "^2.0.0" + }, + "dependencies": { + "normalize-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-1.0.0.tgz", + "integrity": "sha1-MtDkcvkf80VwHBWoMRAY07CpA3k=", + "dev": true + }, + "strip-indent": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-2.0.0.tgz", + "integrity": "sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g=", + "dev": true + } + } + }, + "i18next": { + "version": "11.7.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-11.7.0.tgz", + "integrity": "sha512-k4nu76PQMUnN4W8OGFQHK3/FWwG8k/GwXdemzMmYUVLPnpBdF6VfWbpMOpTT3DOFmFw7D6Ndj46EH1EbNNtMjw==", + "dev": true + }, + "iconv-lite": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", + "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==", + "dev": true + }, + "ieee754": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.12.tgz", + "integrity": "sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA==", + "dev": true + }, + "iferr": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/iferr/-/iferr-0.1.5.tgz", + "integrity": "sha1-xg7taebY/bazEEofy8ocGS3FtQE=", + "dev": true + }, + "ignore": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.8.tgz", + "integrity": "sha512-pUh+xUQQhQzevjRHHFqqcTy0/dP/kS9I8HSrUydhihjuD09W6ldVWFtIrwhXdUJHis3i2rZNqEHpZH/cbinFbg==", + "dev": true + }, + "ignorefs": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ignorefs/-/ignorefs-1.2.0.tgz", + "integrity": "sha1-2ln7hYl25KXkNwLM0fKC/byeV1Y=", + "dev": true, + "requires": { + "editions": "^1.3.3", + "ignorepatterns": "^1.1.0" + } + }, + "ignorepatterns": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ignorepatterns/-/ignorepatterns-1.1.0.tgz", + "integrity": "sha1-rI9DbyI5td+2bV8NOpBKh6xnzF4=", + "dev": true + }, + "image-size": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", + "integrity": "sha1-Cd/Uq50g4p6xw+gLiZA3jfnjy5w=", + "dev": true, + "optional": true + }, + "immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=", + "dev": true + }, + "import-cwd": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz", + "integrity": "sha1-qmzzbnInYShcs3HsZRn1PiQ1sKk=", + "dev": true, + "requires": { + "import-from": "^2.1.0" + } + }, + "import-from": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-from/-/import-from-2.1.0.tgz", + "integrity": "sha1-M1238qev/VOqpHHUuAId7ja387E=", + "dev": true, + "requires": { + "resolve-from": "^3.0.0" + } + }, + "import-local": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-2.0.0.tgz", + "integrity": "sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ==", + "dev": true, + "requires": { + "pkg-dir": "^3.0.0", + "resolve-cwd": "^2.0.0" + }, + "dependencies": { + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-limit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.0.0.tgz", + "integrity": "sha512-fl5s52lI5ahKCernzzIyAP0QAZbGIovtVHGwpcu1Jr/EpzLVDI2myISHwGqK7m8uQFugVWSrbxH7XnhGtvEc+A==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.0.0.tgz", + "integrity": "sha512-hMp0onDKIajHfIkdRk3P4CdCmErkYAxxDtP3Wx/4nZ3aGlau2VKh3mZpcuFkH27WQkL/3WBCPOktzA9ZOAnMQQ==", + "dev": true + }, + "pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + } + } + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "in-publish": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/in-publish/-/in-publish-2.0.0.tgz", + "integrity": "sha1-4g/146KvwmkDILbcVSaCqcf631E=", + "dev": true, + "optional": true + }, + "indent-string": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", + "integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=", + "dev": true, + "optional": true, + "requires": { + "repeating": "^2.0.0" + } + }, + "indexof": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", + "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + }, + "ini": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", + "dev": true + }, + "inquirer": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-6.2.0.tgz", + "integrity": "sha512-QIEQG4YyQ2UYZGDC4srMZ7BjHOmNk1lR2JQj5UknBapklm6WHA+VVH7N+sUdX3A7NeCfGF8o4X1S3Ao7nAcIeg==", + "dev": true, + "requires": { + "ansi-escapes": "^3.0.0", + "chalk": "^2.0.0", + "cli-cursor": "^2.1.0", + "cli-width": "^2.0.0", + "external-editor": "^3.0.0", + "figures": "^2.0.0", + "lodash": "^4.17.10", + "mute-stream": "0.0.7", + "run-async": "^2.2.0", + "rxjs": "^6.1.0", + "string-width": "^2.1.0", + "strip-ansi": "^4.0.0", + "through": "^2.3.6" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "inside": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/inside/-/inside-1.0.0.tgz", + "integrity": "sha1-20Xpk1c82z23C5gy6ChbrUZCR3A=", + "dev": true + }, + "internal-ip": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-3.0.1.tgz", + "integrity": "sha512-NXXgESC2nNVtU+pqmC9e6R8B1GpKxzsAQhffvh5AL79qKnodd+L7tnEQmTiUAVngqLalPbSqRA7XGIEL5nCd0Q==", + "dev": true, + "requires": { + "default-gateway": "^2.6.0", + "ipaddr.js": "^1.5.2" + } + }, + "interpret": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz", + "integrity": "sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ=", + "dev": true + }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dev": true, + "requires": { + "loose-envify": "^1.0.0" + } + }, + "invert-kv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", + "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=", + "dev": true + }, + "ip": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", + "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=", + "dev": true + }, + "ip-regex": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz", + "integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=", + "dev": true + }, + "ipaddr.js": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.8.0.tgz", + "integrity": "sha1-6qM9bd16zo9/b+DJygRA5wZzix4=", + "dev": true + }, + "is-absolute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", + "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", + "dev": true, + "requires": { + "is-relative": "^1.0.0", + "is-windows": "^1.0.1" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-binary-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", + "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=", + "dev": true, + "requires": { + "binary-extensions": "^1.0.0" + } + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, + "is-builtin-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz", + "integrity": "sha1-VAVy0096wxGfj3bDDLwbHgN6/74=", + "dev": true, + "requires": { + "builtin-modules": "^1.0.0" + } + }, + "is-callable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.3.tgz", + "integrity": "sha1-hut1OSgF3cM69xySoO7fdO52BLI=", + "dev": true + }, + "is-ci": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-1.1.0.tgz", + "integrity": "sha512-c7TnwxLePuqIlxHgr7xtxzycJPegNHFuIrBkwbf8hc58//+Op1CqFkyS+xnIMkwn9UsJIwc174BIjkyBmSpjKg==", + "dev": true, + "requires": { + "ci-info": "^1.0.0" + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-date-object": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz", + "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=", + "dev": true + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "is-directory": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz", + "integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=", + "dev": true + }, + "is-dotfile": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.3.tgz", + "integrity": "sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=", + "dev": true + }, + "is-equal-shallow": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz", + "integrity": "sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ=", + "dev": true, + "requires": { + "is-primitive": "^2.0.0" + } + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-finite": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz", + "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "is-glob": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.0.tgz", + "integrity": "sha1-lSHHaEXMJhCoUgPd8ICpWML/q8A=", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-negated-glob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-negated-glob/-/is-negated-glob-1.0.0.tgz", + "integrity": "sha1-aRC8pdqMleeEtXUbl2z1oQ/uNtI=", + "dev": true + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-odd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-odd/-/is-odd-2.0.0.tgz", + "integrity": "sha512-OTiixgpZAT1M4NHgS5IguFp/Vz2VI3U7Goh4/HA1adtwyLtSBrxYlcSYkhpAE07s4fKEcjrFxyvtQBND4vFQyQ==", + "dev": true, + "requires": { + "is-number": "^4.0.0" + }, + "dependencies": { + "is-number": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz", + "integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==", + "dev": true + } + } + }, + "is-path-cwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz", + "integrity": "sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0=", + "dev": true + }, + "is-path-in-cwd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz", + "integrity": "sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ==", + "dev": true, + "requires": { + "is-path-inside": "^1.0.0" + } + }, + "is-path-inside": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz", + "integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=", + "dev": true, + "requires": { + "path-is-inside": "^1.0.1" + } + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "is-posix-bracket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz", + "integrity": "sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q=", + "dev": true + }, + "is-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-2.0.0.tgz", + "integrity": "sha1-IHurkWOEmcB7Kt8kCkGochADRXU=", + "dev": true + }, + "is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", + "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=", + "dev": true + }, + "is-regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", + "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=", + "dev": true, + "requires": { + "has": "^1.0.1" + } + }, + "is-relative": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", + "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", + "dev": true, + "requires": { + "is-unc-path": "^1.0.0" + } + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + }, + "is-symbol": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.1.tgz", + "integrity": "sha1-PMWfAAJRlLarLjjbrmaJJWtmBXI=", + "dev": true + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true + }, + "is-unc-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", + "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", + "dev": true, + "requires": { + "unc-path-regex": "^0.1.2" + } + }, + "is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=", + "dev": true + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true + }, + "is-wsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=", + "dev": true + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "isbinaryfile": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-3.0.3.tgz", + "integrity": "sha512-8cJBL5tTd2OS0dM4jz07wQd5g0dCCqIhUxPIGtZfa5L6hWlvV5MHTITy/DBAsF+Oe2LS1X3krBUhNwaGUWpWxw==", + "dev": true, + "requires": { + "buffer-alloc": "^1.2.0" + } + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", + "dev": true + }, + "istanbul": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/istanbul/-/istanbul-0.4.5.tgz", + "integrity": "sha1-ZcfXPUxNqE1POsMQuRj7C4Azczs=", + "dev": true, + "requires": { + "abbrev": "1.0.x", + "async": "1.x", + "escodegen": "1.8.x", + "esprima": "2.7.x", + "glob": "^5.0.15", + "handlebars": "^4.0.1", + "js-yaml": "3.x", + "mkdirp": "0.5.x", + "nopt": "3.x", + "once": "1.x", + "resolve": "1.1.x", + "supports-color": "^3.1.0", + "which": "^1.1.1", + "wordwrap": "^1.0.0" + }, + "dependencies": { + "glob": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", + "integrity": "sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=", + "dev": true, + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", + "dev": true + }, + "resolve": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", + "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=", + "dev": true + }, + "supports-color": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", + "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", + "dev": true, + "requires": { + "has-flag": "^1.0.0" + } + } + } + }, + "istanbul-api": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/istanbul-api/-/istanbul-api-2.0.6.tgz", + "integrity": "sha512-8W5oeAGWXhtTJjAyVfvavOLVyZCTNCKsyF6GON/INKlBdO7uJ/bv3qnPj5M6ERKzmMCJS1kntnjjGuJ86fn3rQ==", + "dev": true, + "requires": { + "async": "^2.6.1", + "compare-versions": "^3.2.1", + "fileset": "^2.0.3", + "istanbul-lib-coverage": "^2.0.1", + "istanbul-lib-hook": "^2.0.1", + "istanbul-lib-instrument": "^3.0.0", + "istanbul-lib-report": "^2.0.2", + "istanbul-lib-source-maps": "^2.0.1", + "istanbul-reports": "^2.0.1", + "js-yaml": "^3.12.0", + "make-dir": "^1.3.0", + "once": "^1.4.0" + }, + "dependencies": { + "async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.1.tgz", + "integrity": "sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==", + "dev": true, + "requires": { + "lodash": "^4.17.10" + } + }, + "istanbul-lib-coverage": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", + "integrity": "sha512-nPvSZsVlbG9aLhZYaC3Oi1gT/tpyo3Yt5fNyf6NmcKIayz4VV/txxJFFKAK/gU4dcNn8ehsanBbVHVl0+amOLA==", + "dev": true + }, + "istanbul-lib-instrument": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-3.0.0.tgz", + "integrity": "sha512-eQY9vN9elYjdgN9Iv6NS/00bptm02EBBk70lRMaVjeA6QYocQgenVrSgC28TJurdnZa80AGO3ASdFN+w/njGiQ==", + "dev": true, + "requires": { + "@babel/generator": "^7.0.0", + "@babel/parser": "^7.0.0", + "@babel/template": "^7.0.0", + "@babel/traverse": "^7.0.0", + "@babel/types": "^7.0.0", + "istanbul-lib-coverage": "^2.0.1", + "semver": "^5.5.0" + } + } + } + }, + "istanbul-instrumenter-loader": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-instrumenter-loader/-/istanbul-instrumenter-loader-3.0.1.tgz", + "integrity": "sha512-a5SPObZgS0jB/ixaKSMdn6n/gXSrK2S6q/UfRJBT3e6gQmVjwZROTODQsYW5ZNwOu78hG62Y3fWlebaVOL0C+w==", + "dev": true, + "requires": { + "convert-source-map": "^1.5.0", + "istanbul-lib-instrument": "^1.7.3", + "loader-utils": "^1.1.0", + "schema-utils": "^0.3.0" + }, + "dependencies": { + "ajv": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", + "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", + "dev": true, + "requires": { + "co": "^4.6.0", + "fast-deep-equal": "^1.0.0", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.3.0" + } + }, + "schema-utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.3.0.tgz", + "integrity": "sha1-9YdyIs4+kx7a4DnxfrNxbnE3+M8=", + "dev": true, + "requires": { + "ajv": "^5.0.0" + } + } + } + }, + "istanbul-lib-coverage": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-1.2.1.tgz", + "integrity": "sha512-PzITeunAgyGbtY1ibVIUiV679EFChHjoMNRibEIobvmrCRaIgwLxNucOSimtNWUhEib/oO7QY2imD75JVgCJWQ==", + "dev": true + }, + "istanbul-lib-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-2.0.1.tgz", + "integrity": "sha512-ufiZoiJ8CxY577JJWEeFuxXZoMqiKpq/RqZtOAYuQLvlkbJWscq9n3gc4xrCGH9n4pW0qnTxOz1oyMmVtk8E1w==", + "dev": true, + "requires": { + "append-transform": "^1.0.0" + } + }, + "istanbul-lib-instrument": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-1.10.2.tgz", + "integrity": "sha512-aWHxfxDqvh/ZlxR8BBaEPVSWDPUkGD63VjGQn3jcw8jCp7sHEMKcrj4xfJn/ABzdMEHiQNyvDQhqm5o8+SQg7A==", + "dev": true, + "requires": { + "babel-generator": "^6.18.0", + "babel-template": "^6.16.0", + "babel-traverse": "^6.18.0", + "babel-types": "^6.18.0", + "babylon": "^6.18.0", + "istanbul-lib-coverage": "^1.2.1", + "semver": "^5.3.0" + } + }, + "istanbul-lib-report": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-2.0.2.tgz", + "integrity": "sha512-rJ8uR3peeIrwAxoDEbK4dJ7cqqtxBisZKCuwkMtMv0xYzaAnsAi3AHrHPAAtNXzG/bcCgZZ3OJVqm1DTi9ap2Q==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^2.0.1", + "make-dir": "^1.3.0", + "supports-color": "^5.4.0" + }, + "dependencies": { + "istanbul-lib-coverage": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", + "integrity": "sha512-nPvSZsVlbG9aLhZYaC3Oi1gT/tpyo3Yt5fNyf6NmcKIayz4VV/txxJFFKAK/gU4dcNn8ehsanBbVHVl0+amOLA==", + "dev": true + } + } + }, + "istanbul-lib-source-maps": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-2.0.1.tgz", + "integrity": "sha512-30l40ySg+gvBLcxTrLzR4Z2XTRj3HgRCA/p2rnbs/3OiTaoj054gAbuP5DcLOtwqmy4XW8qXBHzrmP2/bQ9i3A==", + "dev": true, + "requires": { + "debug": "^3.1.0", + "istanbul-lib-coverage": "^2.0.1", + "make-dir": "^1.3.0", + "rimraf": "^2.6.2", + "source-map": "^0.6.1" + }, + "dependencies": { + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "istanbul-lib-coverage": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", + "integrity": "sha512-nPvSZsVlbG9aLhZYaC3Oi1gT/tpyo3Yt5fNyf6NmcKIayz4VV/txxJFFKAK/gU4dcNn8ehsanBbVHVl0+amOLA==", + "dev": true + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "istanbul-reports": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-2.0.1.tgz", + "integrity": "sha512-CT0QgMBJqs6NJLF678ZHcquUAZIoBIUNzdJrRJfpkI9OnzG6MkUfHxbJC3ln981dMswC7/B1mfX3LNkhgJxsuw==", + "dev": true, + "requires": { + "handlebars": "^4.0.11" + } + }, + "jasmine": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-2.8.0.tgz", + "integrity": "sha1-awicChFXax8W3xG4AUbZHU6Lij4=", + "dev": true, + "requires": { + "exit": "^0.1.2", + "glob": "^7.0.6", + "jasmine-core": "~2.8.0" + }, + "dependencies": { + "jasmine-core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-2.8.0.tgz", + "integrity": "sha1-vMl5rh+f0FcB5F5S5l06XWPxok4=", + "dev": true + } + } + }, + "jasmine-core": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-2.99.1.tgz", + "integrity": "sha1-5kAN8ea1bhMLYcS80JPap/boyhU=", + "dev": true + }, + "jasmine-spec-reporter": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jasmine-spec-reporter/-/jasmine-spec-reporter-4.2.1.tgz", + "integrity": "sha512-FZBoZu7VE5nR7Nilzy+Np8KuVIOxF4oXDPDknehCYBDE080EnlPu0afdZNmpGDBRCUBv3mj5qgqCRmk6W/K8vg==", + "dev": true, + "requires": { + "colors": "1.1.2" + } + }, + "jasminewd2": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/jasminewd2/-/jasminewd2-2.2.0.tgz", + "integrity": "sha1-43zwsX8ZnM4jvqcbIDk5Uka07E4=", + "dev": true + }, + "js-base64": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.4.9.tgz", + "integrity": "sha512-xcinL3AuDJk7VSzsHgb9DvvIXayBbadtMZ4HFPx8rUszbW1MuNMlwYVC4zzCZ6e1sqZpnNS5ZFYOhXqA39T7LQ==", + "dev": true, + "optional": true + }, + "js-tokens": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", + "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=", + "dev": true + }, + "js-yaml": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.12.0.tgz", + "integrity": "sha512-PIt2cnwmPfL4hKNwqeiuz4bKfnzHTBv6HyVgjahA6mPLwPDzjDWrplJBMjHUFxku/N3FlmrbyPclad+I+4mJ3A==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "dependencies": { + "esprima": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.0.tgz", + "integrity": "sha512-oftTcaMu/EGrEIu904mWteKIv8vMuOgGYo7EhVJJN00R/EED9DCua/xxHRdYnKtcECzVg7xOWhflvJMnqcFZjw==", + "dev": true + } + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "dev": true, + "optional": true + }, + "jsesc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz", + "integrity": "sha1-RsP+yMGJKxKwgz25vHYiF226s0s=", + "dev": true + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", + "dev": true + }, + "json-schema-traverse": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", + "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=", + "dev": true + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", + "dev": true + }, + "json3": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.2.tgz", + "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=", + "dev": true + }, + "json5": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", + "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=", + "dev": true + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "jsonify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", + "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", + "dev": true + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "dev": true, + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "jszip": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.1.5.tgz", + "integrity": "sha512-5W8NUaFRFRqTOL7ZDDrx5qWHJyBXy6velVudIzQUSoqAAYqzSh2Z7/m0Rf1QbmQJccegD0r+YZxBjzqoBiEeJQ==", + "dev": true, + "requires": { + "core-js": "~2.3.0", + "es6-promise": "~3.0.2", + "lie": "~3.1.0", + "pako": "~1.0.2", + "readable-stream": "~2.0.6" + }, + "dependencies": { + "core-js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.3.0.tgz", + "integrity": "sha1-+rg/uwstjchfpjbEudNMdUIMbWU=", + "dev": true + }, + "es6-promise": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.0.2.tgz", + "integrity": "sha1-AQ1YWEI6XxGJeWZfRkhqlcbuK7Y=", + "dev": true + }, + "process-nextick-args": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=", + "dev": true + }, + "readable-stream": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz", + "integrity": "sha1-j5A0HmilPMySh4jaz80Rs265t44=", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "~1.0.0", + "process-nextick-args": "~1.0.6", + "string_decoder": "~0.10.x", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", + "dev": true + } + } + }, + "karma": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/karma/-/karma-3.0.0.tgz", + "integrity": "sha512-ZTjyuDXVXhXsvJ1E4CnZzbCjSxD6sEdzEsFYogLuZM0yqvg/mgz+O+R1jb0J7uAQeuzdY8kJgx6hSNXLwFuHIQ==", + "dev": true, + "requires": { + "bluebird": "^3.3.0", + "body-parser": "^1.16.1", + "chokidar": "^2.0.3", + "colors": "^1.1.0", + "combine-lists": "^1.0.0", + "connect": "^3.6.0", + "core-js": "^2.2.0", + "di": "^0.0.1", + "dom-serialize": "^2.2.0", + "expand-braces": "^0.1.1", + "glob": "^7.1.1", + "graceful-fs": "^4.1.2", + "http-proxy": "^1.13.0", + "isbinaryfile": "^3.0.0", + "lodash": "^4.17.4", + "log4js": "^3.0.0", + "mime": "^2.3.1", + "minimatch": "^3.0.2", + "optimist": "^0.6.1", + "qjobs": "^1.1.4", + "range-parser": "^1.2.0", + "rimraf": "^2.6.0", + "safe-buffer": "^5.0.1", + "socket.io": "2.1.1", + "source-map": "^0.6.1", + "tmp": "0.0.33", + "useragent": "2.2.1" + }, + "dependencies": { + "mime": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.3.1.tgz", + "integrity": "sha512-OEUllcVoydBHGN1z84yfQDimn58pZNNNXgZlHXSboxMlFvgI6MXSWpWKpFRra7H1HxpVhHTkrghfRW49k6yjeg==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "karma-chrome-launcher": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-2.2.0.tgz", + "integrity": "sha512-uf/ZVpAabDBPvdPdveyk1EPgbnloPvFFGgmRhYLTDH7gEB4nZdSBk8yTU47w1g/drLSx5uMOkjKk7IWKfWg/+w==", + "dev": true, + "requires": { + "fs-access": "^1.0.0", + "which": "^1.2.1" + } + }, + "karma-coverage-istanbul-reporter": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/karma-coverage-istanbul-reporter/-/karma-coverage-istanbul-reporter-2.0.4.tgz", + "integrity": "sha512-xJS7QSQIVU6VK9HuJ/ieE5yynxKhjCCkd96NLY/BX/HXsx0CskU9JJiMQbd4cHALiddMwI4OWh1IIzeWrsavJw==", + "dev": true, + "requires": { + "istanbul-api": "^2.0.5", + "minimatch": "^3.0.4" + } + }, + "karma-jasmine": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-1.1.2.tgz", + "integrity": "sha1-OU8rJf+0pkS5rabyLUQ+L9CIhsM=", + "dev": true + }, + "karma-jasmine-html-reporter": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-0.2.2.tgz", + "integrity": "sha1-SKjl7xiAdhfuK14zwRlMNbQ5Ukw=", + "dev": true, + "requires": { + "karma-jasmine": "^1.0.2" + } + }, + "karma-source-map-support": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.3.0.tgz", + "integrity": "sha512-HcPqdAusNez/ywa+biN4EphGz62MmQyPggUsDfsHqa7tSe4jdsxgvTKuDfIazjL+IOxpVWyT7Pr4dhAV+sxX5Q==", + "dev": true, + "requires": { + "source-map-support": "^0.5.5" + } + }, + "killable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", + "integrity": "sha512-LzqtLKlUwirEUyl/nicirVmNiPvYs7l5n8wOPP7fyJVpUPkvCnW/vuiXGpylGUlnPDnB7311rARzAt3Mhswpjg==", + "dev": true + }, + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + }, + "lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4=", + "dev": true, + "optional": true + }, + "lcid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", + "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=", + "dev": true, + "requires": { + "invert-kv": "^1.0.0" + } + }, + "leb": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/leb/-/leb-0.3.0.tgz", + "integrity": "sha1-Mr7p+tFoMo1q6oUi2DP0GA7tHaM=", + "dev": true + }, + "less": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/less/-/less-3.8.1.tgz", + "integrity": "sha512-8HFGuWmL3FhQR0aH89escFNBQH/nEiYPP2ltDFdQw2chE28Yx2E3lhAIq9Y2saYwLSwa699s4dBVEfCY8Drf7Q==", + "dev": true, + "requires": { + "clone": "^2.1.2", + "errno": "^0.1.1", + "graceful-fs": "^4.1.2", + "image-size": "~0.5.0", + "mime": "^1.4.1", + "mkdirp": "^0.5.0", + "promise": "^7.1.1", + "request": "^2.83.0", + "source-map": "~0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true + } + } + }, + "less-loader": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-4.1.0.tgz", + "integrity": "sha512-KNTsgCE9tMOM70+ddxp9yyt9iHqgmSs0yTZc5XH5Wo+g80RWRIYNqE58QJKm/yMud5wZEvz50ugRDuzVIkyahg==", + "dev": true, + "requires": { + "clone": "^2.1.1", + "loader-utils": "^1.1.0", + "pify": "^3.0.0" + } + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "license-webpack-plugin": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-1.5.0.tgz", + "integrity": "sha512-Of/H79rZqm2aeg4RnP9SMSh19qkKemoLT5VaJV58uH5AxeYWEcBgGFs753JEJ/Hm6BPvQVfIlrrjoBwYj8p7Tw==", + "dev": true, + "requires": { + "ejs": "^2.5.7" + } + }, + "lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=", + "dev": true, + "requires": { + "immediate": "~3.0.5" + } + }, + "live-server": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/live-server/-/live-server-1.1.0.tgz", + "integrity": "sha1-pp8ObKWB4DkapXlBlw4XwwjdSGk=", + "dev": true, + "requires": { + "colors": "^1.3.2", + "connect": "3.4.x", + "cors": "^2.8.4", + "event-stream": "^3.3.5", + "faye-websocket": "0.11.x", + "http-auth": "2.4.x", + "morgan": "^1.6.1", + "object-assign": "^4.1.1", + "opn": "^5.3.0", + "proxy-middleware": "^0.15.0", + "send": "^0.16.2", + "serve-index": "^1.7.2", + "watchr": "2.6.x" + }, + "dependencies": { + "colors": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.3.2.tgz", + "integrity": "sha512-rhP0JSBGYvpcNQj4s5AdShMeE5ahMop96cTeDl/v9qQQm2fYClE2QXZRi8wLzc+GmXSxdIqqbOIAhyObEXDbfQ==", + "dev": true + }, + "connect": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.4.1.tgz", + "integrity": "sha1-ohNh0/QJnvdhzabcSpc7seuwo00=", + "dev": true, + "requires": { + "debug": "~2.2.0", + "finalhandler": "0.4.1", + "parseurl": "~1.3.1", + "utils-merge": "1.0.0" + } + }, + "debug": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz", + "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=", + "dev": true, + "requires": { + "ms": "0.7.1" + } + }, + "event-stream": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.5.tgz", + "integrity": "sha512-vyibDcu5JL20Me1fP734QBH/kenBGLZap2n0+XXM7mvuUPzJ20Ydqj1aKcIeMdri1p+PU+4yAKugjN8KCVst+g==", + "dev": true, + "requires": { + "duplexer": "^0.1.1", + "from": "^0.1.7", + "map-stream": "0.0.7", + "pause-stream": "^0.0.11", + "split": "^1.0.1", + "stream-combiner": "^0.2.2", + "through": "^2.3.8" + } + }, + "faye-websocket": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.1.tgz", + "integrity": "sha1-8O/hjE9W5PQK/H4Gxxn9XuYYjzg=", + "dev": true, + "requires": { + "websocket-driver": ">=0.5.1" + } + }, + "finalhandler": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-0.4.1.tgz", + "integrity": "sha1-haF8bFmpRxfSYtYSMNSw6+PUoU0=", + "dev": true, + "requires": { + "debug": "~2.2.0", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "unpipe": "~1.0.0" + } + }, + "map-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.7.tgz", + "integrity": "sha1-ih8HiW2CsQkmvTdEokIACfiJdKg=", + "dev": true + }, + "mime": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz", + "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==", + "dev": true + }, + "ms": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz", + "integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg=", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true + }, + "opn": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/opn/-/opn-5.3.0.tgz", + "integrity": "sha512-bYJHo/LOmoTd+pfiYhfZDnf9zekVJrY+cnS2a5F2x+w5ppvTqObojTP7WiFG+kVZs9Inw+qQ/lw7TroWwhdd2g==", + "dev": true, + "requires": { + "is-wsl": "^1.1.0" + } + }, + "send": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz", + "integrity": "sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==", + "dev": true, + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.6.2", + "mime": "1.4.1", + "ms": "2.0.0", + "on-finished": "~2.3.0", + "range-parser": "~1.2.0", + "statuses": "~1.4.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "split": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", + "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "dev": true, + "requires": { + "through": "2" + } + }, + "stream-combiner": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz", + "integrity": "sha1-rsjLrBd7Vrb0+kec7YwZEs7lKFg=", + "dev": true, + "requires": { + "duplexer": "~0.1.1", + "through": "~2.3.4" + } + }, + "utils-merge": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz", + "integrity": "sha1-ApT7kiu5N1FTVBxPcJYjHyh8ivg=", + "dev": true + } + } + }, + "load-json-file": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", + "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "strip-bom": "^2.0.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "loader-runner": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.3.1.tgz", + "integrity": "sha512-By6ZFY7ETWOc9RFaAIb23IjJVcM4dvJC/N57nmdz9RSkMXvAXGI7SyVlAw3v8vjtDRlqThgVDVmTnr9fqMlxkw==", + "dev": true + }, + "loader-utils": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.1.0.tgz", + "integrity": "sha1-yYrvSIvM7aL/teLeZG1qdUQp9c0=", + "dev": true, + "requires": { + "big.js": "^3.1.3", + "emojis-list": "^2.0.0", + "json5": "^0.5.0" + } + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "dev": true, + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + } + }, + "lodash": { + "version": "4.17.10", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", + "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==", + "dev": true + }, + "lodash.assign": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz", + "integrity": "sha1-DZnzzNem0mHRm9rrkkUAXShYCOc=", + "dev": true, + "optional": true + }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", + "dev": true + }, + "lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", + "dev": true + }, + "lodash.mergewith": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz", + "integrity": "sha512-eWw5r+PYICtEBgrBE5hhlT6aAa75f411bgDz/ZL2KZqYV03USvucsxcHUIlGTDTECs1eunpI7HOV7U+WLDvNdQ==", + "dev": true, + "optional": true + }, + "lodash.tail": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.tail/-/lodash.tail-4.1.1.tgz", + "integrity": "sha1-0jM6NtnncXyK0vfKyv7HwytERmQ=", + "dev": true + }, + "log4js": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-3.0.5.tgz", + "integrity": "sha512-IX5c3G/7fuTtdr0JjOT2OIR12aTESVhsH6cEsijloYwKgcPRlO6DgOU72v0UFhWcoV1HN6+M3dwT89qVPLXm0w==", + "dev": true, + "requires": { + "circular-json": "^0.5.5", + "date-format": "^1.2.0", + "debug": "^3.1.0", + "rfdc": "^1.1.2", + "streamroller": "0.7.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "loglevel": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.6.1.tgz", + "integrity": "sha1-4PyVEztu8nbNyIh82vJKpvFW+Po=", + "dev": true + }, + "long": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", + "integrity": "sha1-2CG3E4yhy1gcFymQ7xTbIAtcR0s=", + "dev": true + }, + "longest": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", + "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", + "dev": true + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "loud-rejection": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", + "integrity": "sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=", + "dev": true, + "optional": true, + "requires": { + "currently-unhandled": "^0.4.1", + "signal-exit": "^3.0.0" + } + }, + "lower-case": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-1.1.4.tgz", + "integrity": "sha1-miyr0bno4K6ZOkv31YdcOcQujqw=", + "dev": true + }, + "lru-cache": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.3.tgz", + "integrity": "sha512-fFEhvcgzuIoJVUF8fYr5KR0YqxD238zgObTps31YdADwPPAp82a4M8TrckkWyx7ekNlf9aBcVn81cFwwXngrJA==", + "dev": true, + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "lunr": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.2.tgz", + "integrity": "sha512-3wO9shK+cBcJ260ibDtw3JGY+xE0so0y90Dt5YY4e+VmhZO8z8l4cwdau09Fiud0nZHdSgNsIKFHUy3MbXm00A==", + "dev": true + }, + "macos-release": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-1.1.0.tgz", + "integrity": "sha512-mmLbumEYMi5nXReB9js3WGsB8UE6cDBWyIO62Z4DNx6GbRhDxHNjA1MlzSpJ2S2KM1wyiPRA0d19uHWYYvMHjA==", + "dev": true + }, + "magic-string": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.1.tgz", + "integrity": "sha512-sCuTz6pYom8Rlt4ISPFn6wuFodbKMIHUMv4Qko9P17dpxb7s52KJTmRuZZqHdGmLCK9AOcDare039nRIcfdkEg==", + "dev": true, + "requires": { + "sourcemap-codec": "^1.4.1" + } + }, + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "make-error": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.4.tgz", + "integrity": "sha512-0Dab5btKVPhibSalc9QGXb559ED7G7iLjFXBaj9Wq8O3vorueR5K5jaE3hkG6ZQINyhA/JgG6Qk4qdFQjsYV6g==", + "dev": true + }, + "map-age-cleaner": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.2.tgz", + "integrity": "sha512-UN1dNocxQq44IhJyMI4TU8phc2m9BddacHRPRjKGLYaF0jqd3xLz0jS0skpAU9WgYyoR4gHtUpzytNBS385FWQ==", + "dev": true, + "requires": { + "p-defer": "^1.0.0" + } + }, + "map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=", + "dev": true + }, + "map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=", + "dev": true + }, + "map-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ=", + "dev": true + }, + "map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", + "dev": true, + "requires": { + "object-visit": "^1.0.0" + } + }, + "marked": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-0.4.0.tgz", + "integrity": "sha512-tMsdNBgOsrUophCAFQl0XPe6Zqk/uy9gnue+jIIKhykO51hxyu6uNx7zBPy0+y/WKYVZZMspV9YeXLNdKk+iYw==", + "dev": true + }, + "material-design-icons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/material-design-icons/-/material-design-icons-3.0.1.tgz", + "integrity": "sha1-mnHEh0chjrylHlGmbaaCA4zct78=" + }, + "math-random": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.1.tgz", + "integrity": "sha1-izqsWIuKZuSXXjzepn97sylgH6w=", + "dev": true + }, + "md5.js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.4.tgz", + "integrity": "sha1-6b296UogpawYsENA/Fdk1bCdkB0=", + "dev": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", + "dev": true + }, + "mem": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-1.1.0.tgz", + "integrity": "sha1-Xt1StIXKHZAP5kiVUFOZoN+kX3Y=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "memory-fs": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz", + "integrity": "sha1-OpoguEYlI+RHz7x+i7gO1me/xVI=", + "dev": true, + "requires": { + "errno": "^0.1.3", + "readable-stream": "^2.0.1" + } + }, + "memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha1-htcJCzDORV1j+64S3aUaR93K+bI=", + "dev": true + }, + "meow": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", + "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=", + "dev": true, + "optional": true, + "requires": { + "camelcase-keys": "^2.0.0", + "decamelize": "^1.1.2", + "loud-rejection": "^1.0.0", + "map-obj": "^1.0.1", + "minimist": "^1.1.3", + "normalize-package-data": "^2.3.4", + "object-assign": "^4.0.1", + "read-pkg-up": "^1.0.1", + "redent": "^1.0.0", + "trim-newlines": "^1.0.0" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true, + "optional": true + } + } + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=", + "dev": true + }, + "merge2": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.2.2.tgz", + "integrity": "sha512-bgM8twH86rWni21thii6WCMQMRMmwqqdW3sGWi9IipnVAszdLXRjwDwAnyrVXo6DuP3AjRMMttZKUB48QWIFGg==", + "dev": true + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", + "dev": true + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "dev": true, + "requires": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true + }, + "mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "dev": true + }, + "mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dev": true, + "requires": { + "mime-db": "~1.33.0" + } + }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true + }, + "mini-css-extract-plugin": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.4.3.tgz", + "integrity": "sha512-Mxs0nxzF1kxPv4TRi2NimewgXlJqh0rGE30vviCU2WHrpbta6wklnUV9dr9FUtoAHmB3p3LeXEC+ZjgHvB0Dzg==", + "dev": true, + "requires": { + "loader-utils": "^1.1.0", + "schema-utils": "^1.0.0", + "webpack-sources": "^1.1.0" + }, + "dependencies": { + "schema-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", + "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-errors": "^1.0.0", + "ajv-keywords": "^3.1.0" + } + } + } + }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, + "minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "mississippi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-2.0.0.tgz", + "integrity": "sha512-zHo8v+otD1J10j/tC+VNoGK9keCuByhKovAvdn74dmxJl9+mWHnx6EMsDN4lgRoMI/eYo2nchAxniIbUPb5onw==", + "dev": true, + "requires": { + "concat-stream": "^1.5.0", + "duplexify": "^3.4.2", + "end-of-stream": "^1.1.0", + "flush-write-stream": "^1.0.0", + "from2": "^2.1.0", + "parallel-transform": "^1.1.0", + "pump": "^2.0.1", + "pumpify": "^1.3.3", + "stream-each": "^1.1.0", + "through2": "^2.0.0" + } + }, + "mixin-deep": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.1.tgz", + "integrity": "sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ==", + "dev": true, + "requires": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "mixin-object": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", + "integrity": "sha1-T7lJRB2rGCVA8f4DW6YOGUel5X4=", + "dev": true, + "requires": { + "for-in": "^0.1.3", + "is-extendable": "^0.1.1" + }, + "dependencies": { + "for-in": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", + "integrity": "sha1-2Hc5COMSVhCZUrH9ubP6hn0ndeE=", + "dev": true + } + } + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "morgan": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.9.0.tgz", + "integrity": "sha1-0B+mxlhZt2/PMbPLU6OCGjEdgFE=", + "dev": true, + "requires": { + "basic-auth": "~2.0.0", + "debug": "2.6.9", + "depd": "~1.1.1", + "on-finished": "~2.3.0", + "on-headers": "~1.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "move-concurrently": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", + "integrity": "sha1-viwAX9oy4LKa8fBdfEszIUxwH5I=", + "dev": true, + "requires": { + "aproba": "^1.1.1", + "copy-concurrently": "^1.0.0", + "fs-write-stream-atomic": "^1.0.8", + "mkdirp": "^0.5.1", + "rimraf": "^2.5.4", + "run-queue": "^1.0.3" + } + }, + "mri": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.1.tgz", + "integrity": "sha1-haom09ru7t+A3FmEr5XMXKXK2fE=", + "dev": true + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "multicast-dns": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-6.2.3.tgz", + "integrity": "sha512-ji6J5enbMyGRHIAkAOu3WdV8nggqviKCEKtXcOqfphZZtQrmHKycfynJ2V7eVPUA4NhJ6V7Wf4TmGbTwKE9B6g==", + "dev": true, + "requires": { + "dns-packet": "^1.3.1", + "thunky": "^1.0.2" + } + }, + "multicast-dns-service-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz", + "integrity": "sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE=", + "dev": true + }, + "multimatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-2.1.0.tgz", + "integrity": "sha1-nHkGoi+0wCkZ4vX3UWG0zb1LKis=", + "dev": true, + "requires": { + "array-differ": "^1.0.0", + "array-union": "^1.0.1", + "arrify": "^1.0.0", + "minimatch": "^3.0.0" + } + }, + "mute-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", + "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=", + "dev": true + }, + "nan": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz", + "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==", + "dev": true, + "optional": true + }, + "nanomatch": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.9.tgz", + "integrity": "sha512-n8R9bS8yQ6eSXaV6jHUpKzD8gLsin02w1HSFiegwrs9E098Ylhw5jdyKPaYqvHknHaSCKTPp7C8dGCQ0q9koXA==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-odd": "^2.0.0", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + } + }, + "negotiator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz", + "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=", + "dev": true + }, + "neo-async": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.5.2.tgz", + "integrity": "sha512-vdqTKI9GBIYcAEbFAcpKPErKINfPF5zIuz3/niBfq8WUZjpT2tytLlFVrBgWdOtqI4uaA/Rb6No0hux39XXDuw==", + "dev": true + }, + "ngx-mat-select-search": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ngx-mat-select-search/-/ngx-mat-select-search-1.4.0.tgz", + "integrity": "sha512-neh3RPDyZ6lvOVRl/TTFMDp/d/AqLL2qS/jK1ACPhFLBLoq4HhfWmjPSTwc4oTmBw9Fn3axh7d88KNBPeMOqfg==", + "requires": { + "tslib": "^1.9.0" + } + }, + "nice-try": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.4.tgz", + "integrity": "sha512-2NpiFHqC87y/zFke0fC0spBXL3bBsoh/p5H1EFhshxjCR5+0g2d6BiXbUFz9v1sAcxsk2htp2eQnNIci2dIYcA==", + "dev": true + }, + "no-case": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-2.3.2.tgz", + "integrity": "sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==", + "dev": true, + "requires": { + "lower-case": "^1.1.1" + } + }, + "node-forge": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.7.5.tgz", + "integrity": "sha512-MmbQJ2MTESTjt3Gi/3yG1wGpIMhUfcIypUCGtTizFR9IiccFwxSpfp0vtIZlkFclEqERemxfnSdZEMR9VqqEFQ==", + "dev": true + }, + "node-gyp": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-3.8.0.tgz", + "integrity": "sha512-3g8lYefrRRzvGeSowdJKAKyks8oUpLEd/DyPV4eMhVlhJ0aNaZqIrNUIPuEWWTAoPqyFkfGrM67MC69baqn6vA==", + "dev": true, + "optional": true, + "requires": { + "fstream": "^1.0.0", + "glob": "^7.0.3", + "graceful-fs": "^4.1.2", + "mkdirp": "^0.5.0", + "nopt": "2 || 3", + "npmlog": "0 || 1 || 2 || 3 || 4", + "osenv": "0", + "request": "^2.87.0", + "rimraf": "2", + "semver": "~5.3.0", + "tar": "^2.0.0", + "which": "1" + }, + "dependencies": { + "semver": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=", + "dev": true, + "optional": true + } + } + }, + "node-libs-browser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.1.0.tgz", + "integrity": "sha512-5AzFzdoIMb89hBGMZglEegffzgRg+ZFoUmisQ8HI4j1KDdpx13J0taNp2y9xPbur6W61gepGDDotGBVQ7mfUCg==", + "dev": true, + "requires": { + "assert": "^1.1.1", + "browserify-zlib": "^0.2.0", + "buffer": "^4.3.0", + "console-browserify": "^1.1.0", + "constants-browserify": "^1.0.0", + "crypto-browserify": "^3.11.0", + "domain-browser": "^1.1.1", + "events": "^1.0.0", + "https-browserify": "^1.0.0", + "os-browserify": "^0.3.0", + "path-browserify": "0.0.0", + "process": "^0.11.10", + "punycode": "^1.2.4", + "querystring-es3": "^0.2.0", + "readable-stream": "^2.3.3", + "stream-browserify": "^2.0.1", + "stream-http": "^2.7.2", + "string_decoder": "^1.0.0", + "timers-browserify": "^2.0.4", + "tty-browserify": "0.0.0", + "url": "^0.11.0", + "util": "^0.10.3", + "vm-browserify": "0.0.4" + }, + "dependencies": { + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + } + } + }, + "node-sass": { + "version": "4.9.3", + "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.9.3.tgz", + "integrity": "sha512-XzXyGjO+84wxyH7fV6IwBOTrEBe2f0a6SBze9QWWYR/cL74AcQUks2AsqcCZenl/Fp/JVbuEaLpgrLtocwBUww==", + "dev": true, + "optional": true, + "requires": { + "async-foreach": "^0.1.3", + "chalk": "^1.1.1", + "cross-spawn": "^3.0.0", + "gaze": "^1.0.0", + "get-stdin": "^4.0.1", + "glob": "^7.0.3", + "in-publish": "^2.0.0", + "lodash.assign": "^4.2.0", + "lodash.clonedeep": "^4.3.2", + "lodash.mergewith": "^4.6.0", + "meow": "^3.7.0", + "mkdirp": "^0.5.1", + "nan": "^2.10.0", + "node-gyp": "^3.8.0", + "npmlog": "^4.0.0", + "request": "2.87.0", + "sass-graph": "^2.2.4", + "stdout-stream": "^1.4.0", + "true-case-path": "^1.0.2" + }, + "dependencies": { + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true, + "optional": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "optional": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "cross-spawn": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-3.0.1.tgz", + "integrity": "sha1-ElYDfsufDF9549bvE14wdwGEuYI=", + "dev": true, + "optional": true, + "requires": { + "lru-cache": "^4.0.1", + "which": "^1.2.9" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true, + "optional": true + } + } + }, + "nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", + "dev": true, + "requires": { + "abbrev": "1" + } + }, + "normalize-package-data": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.4.0.tgz", + "integrity": "sha512-9jjUFbTPfEy3R/ad/2oNbKtW9Hgovl5O1FvFWKkKblNXoN/Oou6+9+KKohPK13Yc3/TyunyWhJp6gvRNR/PPAw==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "is-builtin-module": "^1.0.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "dev": true, + "requires": { + "remove-trailing-separator": "^1.0.1" + } + }, + "normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=", + "dev": true + }, + "npm-package-arg": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-6.1.0.tgz", + "integrity": "sha512-zYbhP2k9DbJhA0Z3HKUePUgdB1x7MfIfKssC+WLPFMKTBZKpZh5m13PgexJjCq6KW7j17r0jHWcCpxEqnnncSA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.6.0", + "osenv": "^0.1.5", + "semver": "^5.5.0", + "validate-npm-package-name": "^3.0.0" + } + }, + "npm-registry-client": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/npm-registry-client/-/npm-registry-client-8.6.0.tgz", + "integrity": "sha512-Qs6P6nnopig+Y8gbzpeN/dkt+n7IyVd8f45NTMotGk6Qo7GfBmzwYx6jRLoOOgKiMnaQfYxsuyQlD8Mc3guBhg==", + "dev": true, + "requires": { + "concat-stream": "^1.5.2", + "graceful-fs": "^4.1.6", + "normalize-package-data": "~1.0.1 || ^2.0.0", + "npm-package-arg": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", + "npmlog": "2 || ^3.1.0 || ^4.0.0", + "once": "^1.3.3", + "request": "^2.74.0", + "retry": "^0.10.0", + "safe-buffer": "^5.1.1", + "semver": "2 >=2.2.1 || 3.x || 4 || 5", + "slide": "^1.1.3", + "ssri": "^5.2.4" + } + }, + "npm-run-all": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.3.tgz", + "integrity": "sha512-aOG0N3Eo/WW+q6sUIdzcV2COS8VnTZCmdji0VQIAZF3b+a3YWb0AD0vFIyjKec18A7beLGbaQ5jFTNI2bPt9Cg==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "chalk": "^2.1.0", + "cross-spawn": "^6.0.4", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "ps-tree": "^1.1.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + } + } + }, + "npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "dev": true, + "requires": { + "path-key": "^2.0.0" + } + }, + "npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "dev": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "nth-check": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.1.tgz", + "integrity": "sha1-mSms32KPwsQQmN6rgqxYDPFJquQ=", + "dev": true, + "requires": { + "boolbase": "~1.0.0" + } + }, + "null-check": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/null-check/-/null-check-1.0.0.tgz", + "integrity": "sha1-l33/1xdgErnsMNKjnbXPcqBDnt0=", + "dev": true + }, + "num2fraction": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz", + "integrity": "sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4=", + "dev": true + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "dev": true + }, + "oauth-sign": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", + "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true + }, + "object-component": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/object-component/-/object-component-0.0.3.tgz", + "integrity": "sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=", + "dev": true + }, + "object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", + "dev": true, + "requires": { + "copy-descriptor": "^0.1.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "object-keys": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.0.11.tgz", + "integrity": "sha1-xUYBd4rVYPEULODgG8yotW0TQm0=", + "dev": true + }, + "object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", + "dev": true, + "requires": { + "isobject": "^3.0.0" + } + }, + "object.getownpropertydescriptors": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz", + "integrity": "sha1-h1jIRvW0B62rDyNuCYbxSwUcqhY=", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.5.1" + } + }, + "object.omit": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/object.omit/-/object.omit-2.0.1.tgz", + "integrity": "sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo=", + "dev": true, + "requires": { + "for-own": "^0.1.4", + "is-extendable": "^0.1.1" + }, + "dependencies": { + "for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=", + "dev": true, + "requires": { + "for-in": "^1.0.1" + } + } + } + }, + "object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "dev": true, + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz", + "integrity": "sha1-ko9dD0cNSTQmUepnlLCFfBAGk/c=", + "dev": true + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "opn": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/opn/-/opn-5.3.0.tgz", + "integrity": "sha512-bYJHo/LOmoTd+pfiYhfZDnf9zekVJrY+cnS2a5F2x+w5ppvTqObojTP7WiFG+kVZs9Inw+qQ/lw7TroWwhdd2g==", + "dev": true, + "requires": { + "is-wsl": "^1.1.0" + } + }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "dev": true, + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + }, + "dependencies": { + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", + "dev": true + } + } + }, + "optionator": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", + "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", + "dev": true, + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.4", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "wordwrap": "~1.0.0" + } + }, + "original": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/original/-/original-1.0.2.tgz", + "integrity": "sha512-hyBVl6iqqUOJ8FqRe+l/gS8H+kKYjrEndd5Pm1MfBtsEKA038HkkdbAl/72EAXGyonD/PFsvmVG+EvcIpliMBg==", + "dev": true, + "requires": { + "url-parse": "^1.4.3" + } + }, + "os-browserify": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", + "integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=", + "dev": true + }, + "os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", + "dev": true + }, + "os-locale": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", + "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", + "dev": true, + "optional": true, + "requires": { + "lcid": "^1.0.0" + } + }, + "os-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/os-name/-/os-name-2.0.1.tgz", + "integrity": "sha1-uaOGNhwXrjohc27wWZQFyajF3F4=", + "dev": true, + "requires": { + "macos-release": "^1.0.0", + "win-release": "^1.0.0" + } + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, + "osenv": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", + "dev": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "p-defer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", + "integrity": "sha1-n26xgvbJqozXQwBKfU+WsZaw+ww=", + "dev": true + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true + }, + "p-is-promise": { + "version": "1.1.0", + "resolved": "http://registry.npmjs.org/p-is-promise/-/p-is-promise-1.1.0.tgz", + "integrity": "sha1-nJRWmJ6fZYgBewQ01WCXZ1w9oF4=", + "dev": true + }, + "p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "requires": { + "p-try": "^1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "dev": true, + "requires": { + "p-limit": "^1.1.0" + } + }, + "p-map": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-1.2.0.tgz", + "integrity": "sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA==", + "dev": true + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", + "dev": true + }, + "pako": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.6.tgz", + "integrity": "sha512-lQe48YPsMJAig+yngZ87Lus+NF+3mtu7DVOBu6b/gHO1YpKwIj5AWjZ/TOS7i46HD/UixzWb1zeWDZfGZ3iYcg==", + "dev": true + }, + "parallel-transform": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.1.0.tgz", + "integrity": "sha1-1BDwZbBdojCB/NEPKIVMKb2jOwY=", + "dev": true, + "requires": { + "cyclist": "~0.2.2", + "inherits": "^2.0.3", + "readable-stream": "^2.1.5" + } + }, + "param-case": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz", + "integrity": "sha1-35T9jPZTHs915r75oIWPvHK+Ikc=", + "dev": true, + "requires": { + "no-case": "^2.2.0" + } + }, + "parse-asn1": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.1.tgz", + "integrity": "sha512-KPx7flKXg775zZpnp9SxJlz00gTd4BmJ2yJufSc44gMCRrRQ7NSzAcSJQfifuOLgW6bEi+ftrALtsgALeB2Adw==", + "dev": true, + "requires": { + "asn1.js": "^4.0.0", + "browserify-aes": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.0", + "pbkdf2": "^3.0.3" + } + }, + "parse-glob": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz", + "integrity": "sha1-ssN2z7EfNVE7rdFz7wu246OIORw=", + "dev": true, + "requires": { + "glob-base": "^0.3.0", + "is-dotfile": "^1.0.0", + "is-extglob": "^1.0.0", + "is-glob": "^2.0.0" + }, + "dependencies": { + "is-extglob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", + "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=", + "dev": true + }, + "is-glob": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", + "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", + "dev": true, + "requires": { + "is-extglob": "^1.0.0" + } + } + } + }, + "parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", + "dev": true, + "requires": { + "error-ex": "^1.2.0" + } + }, + "parse5": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-4.0.0.tgz", + "integrity": "sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==", + "dev": true + }, + "parseqs": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz", + "integrity": "sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=", + "dev": true, + "requires": { + "better-assert": "~1.0.0" + } + }, + "parseuri": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.5.tgz", + "integrity": "sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=", + "dev": true, + "requires": { + "better-assert": "~1.0.0" + } + }, + "parseurl": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.2.tgz", + "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M=", + "dev": true + }, + "pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", + "dev": true + }, + "path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha1-1NwqUGxM4hl+tIHr/NWzbAFAsQ8=", + "dev": true, + "requires": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, + "path-browserify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.0.tgz", + "integrity": "sha1-oLhwcpquIUAFt9UDLsLLuw+0RRo=", + "dev": true + }, + "path-dirname": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", + "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=", + "dev": true + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", + "dev": true + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true + }, + "path-parse": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.5.tgz", + "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME=", + "dev": true + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=", + "dev": true + }, + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha1-/lo0sMvOErWqaitAPuLnO2AvFEU=", + "dev": true, + "requires": { + "through": "~2.3" + } + }, + "pbkdf2": { + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.0.17.tgz", + "integrity": "sha512-U/il5MsrZp7mGg3mSQfn742na2T+1/vHDCG5/iTI3X9MKUuYUZVLQhyRsg06mCgDBTd57TxzgZt7P+fYfjRLtA==", + "dev": true, + "requires": { + "create-hash": "^1.1.2", + "create-hmac": "^1.1.4", + "ripemd160": "^2.0.1", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", + "dev": true + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "requires": { + "pinkie": "^2.0.0" + } + }, + "pkg-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz", + "integrity": "sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=", + "dev": true, + "requires": { + "find-up": "^2.1.0" + } + }, + "portfinder": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.17.tgz", + "integrity": "sha512-syFcRIRzVI1BoEFOCaAiizwDolh1S1YXSodsVhncbhjzjZQulhczNRbqnUl9N31Q4dKGOXsNDqxC2BWBgSMqeQ==", + "dev": true, + "requires": { + "async": "^1.5.2", + "debug": "^2.2.0", + "mkdirp": "0.5.x" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", + "dev": true + }, + "postcss": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", + "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + }, + "dependencies": { + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "postcss-import": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-11.1.0.tgz", + "integrity": "sha512-5l327iI75POonjxkXgdRCUS+AlzAdBx4pOvMEhTKTCjb1p8IEeVR9yx3cPbmN7LIWJLbfnIXxAhoB4jpD0c/Cw==", + "dev": true, + "requires": { + "postcss": "^6.0.1", + "postcss-value-parser": "^3.2.3", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + } + }, + "postcss-load-config": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-2.0.0.tgz", + "integrity": "sha512-V5JBLzw406BB8UIfsAWSK2KSwIJ5yoEIVFb4gVkXci0QdKgA24jLmHZ/ghe/GgX0lJ0/D1uUK1ejhzEY94MChQ==", + "dev": true, + "requires": { + "cosmiconfig": "^4.0.0", + "import-cwd": "^2.0.0" + } + }, + "postcss-loader": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-2.1.6.tgz", + "integrity": "sha512-hgiWSc13xVQAq25cVw80CH0l49ZKlAnU1hKPOdRrNj89bokRr/bZF2nT+hebPPF9c9xs8c3gw3Fr2nxtmXYnNg==", + "dev": true, + "requires": { + "loader-utils": "^1.1.0", + "postcss": "^6.0.0", + "postcss-load-config": "^2.0.0", + "schema-utils": "^0.4.0" + } + }, + "postcss-url": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/postcss-url/-/postcss-url-7.3.2.tgz", + "integrity": "sha512-QMV5mA+pCYZQcUEPQkmor9vcPQ2MT+Ipuu8qdi1gVxbNiIiErEGft+eny1ak19qALoBkccS5AHaCaCDzh7b9MA==", + "dev": true, + "requires": { + "mime": "^1.4.1", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.0", + "postcss": "^6.0.1", + "xxhashjs": "^0.2.1" + } + }, + "postcss-value-parser": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.0.tgz", + "integrity": "sha1-h/OPnxj3dKSrTIojL1xc6IcqnRU=", + "dev": true + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, + "preserve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/preserve/-/preserve-0.2.0.tgz", + "integrity": "sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks=", + "dev": true + }, + "prettier": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.14.3.tgz", + "integrity": "sha512-qZDVnCrnpsRJJq5nSsiHCE3BYMED2OtsI+cmzIzF1QIfqm5ALf8tEJcO27zV1gKNKRPdhjO0dNWnrzssDQ1tFg==", + "dev": true + }, + "pretty-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-2.1.1.tgz", + "integrity": "sha1-X0+HyPkeWuPzuoerTPXgOxoX8aM=", + "dev": true, + "requires": { + "renderkid": "^2.0.1", + "utila": "~0.4" + } + }, + "pretty-quick": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pretty-quick/-/pretty-quick-1.7.0.tgz", + "integrity": "sha512-bKoLGOy2rvPKcypkzYqlyqBBAtf0yKV7VK0C/7E4m541dY98rxZsbBt4GDRa/mc74EBPCeuiFe1fkKiyqjUKVg==", + "dev": true, + "requires": { + "chalk": "^2.3.0", + "execa": "^0.8.0", + "find-up": "^2.1.0", + "ignore": "^3.3.7", + "mri": "^1.1.0" + }, + "dependencies": { + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "execa": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-0.8.0.tgz", + "integrity": "sha1-2NdrvBtVIX7RkP1t1J08d07PyNo=", + "dev": true, + "requires": { + "cross-spawn": "^5.0.1", + "get-stream": "^3.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + } + } + }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", + "dev": true + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", + "dev": true + }, + "promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "dev": true, + "optional": true, + "requires": { + "asap": "~2.0.3" + } + }, + "promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=", + "dev": true + }, + "protractor": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/protractor/-/protractor-5.4.1.tgz", + "integrity": "sha512-ORey5ewQMYiXQxcQohsqEiKYOg/r5yJoJbt0tuROmmgajdg/CA3gTOZNIFJncUVMAJIk5YFqBBLUjKVmQO6tfA==", + "dev": true, + "requires": { + "@types/node": "^6.0.46", + "@types/q": "^0.0.32", + "@types/selenium-webdriver": "^3.0.0", + "blocking-proxy": "^1.0.0", + "browserstack": "^1.5.1", + "chalk": "^1.1.3", + "glob": "^7.0.3", + "jasmine": "2.8.0", + "jasminewd2": "^2.1.0", + "optimist": "~0.6.0", + "q": "1.4.1", + "saucelabs": "^1.5.0", + "selenium-webdriver": "3.6.0", + "source-map-support": "~0.4.0", + "webdriver-js-extender": "2.1.0", + "webdriver-manager": "^12.0.6" + }, + "dependencies": { + "@types/node": { + "version": "6.0.117", + "resolved": "https://registry.npmjs.org/@types/node/-/node-6.0.117.tgz", + "integrity": "sha512-sihk0SnN8PpiS5ihu5xJQ5ddnURNq4P+XPmW+nORlKkHy21CoZO/IVHK/Wq/l3G8fFW06Fkltgnqx229uPlnRg==", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + }, + "source-map-support": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz", + "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==", + "dev": true, + "requires": { + "source-map": "^0.5.6" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + }, + "webdriver-manager": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/webdriver-manager/-/webdriver-manager-12.1.0.tgz", + "integrity": "sha512-oEc5fmkpz6Yh6udhwir5m0eN5mgRPq9P/NU5YWuT3Up5slt6Zz+znhLU7q4+8rwCZz/Qq3Fgpr/4oao7NPCm2A==", + "dev": true, + "requires": { + "adm-zip": "^0.4.9", + "chalk": "^1.1.1", + "del": "^2.2.0", + "glob": "^7.0.3", + "ini": "^1.3.4", + "minimist": "^1.2.0", + "q": "^1.4.1", + "request": "^2.87.0", + "rimraf": "^2.5.2", + "semver": "^5.3.0", + "xml2js": "^0.4.17" + } + } + } + }, + "proxy-addr": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.4.tgz", + "integrity": "sha512-5erio2h9jp5CHGwcybmxmVqHmnCBZeewlfJ0pex+UW7Qny7OOZXTtH56TGNyBizkgiOwhJtMKrVzDTeKcySZwA==", + "dev": true, + "requires": { + "forwarded": "~0.1.2", + "ipaddr.js": "1.8.0" + } + }, + "proxy-middleware": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/proxy-middleware/-/proxy-middleware-0.15.0.tgz", + "integrity": "sha1-o/3xvvtzD5UZZYcqwvYHTGFHelY=", + "dev": true + }, + "prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=", + "dev": true + }, + "ps-tree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.1.0.tgz", + "integrity": "sha1-tCGyQUDWID8e08dplrRCewjowBQ=", + "dev": true, + "requires": { + "event-stream": "~3.3.0" + } + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", + "dev": true + }, + "public-encrypt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.2.tgz", + "integrity": "sha512-4kJ5Esocg8X3h8YgJsKAuoesBgB7mqH3eowiDzMUPKiRDDE7E/BqqZD1hnTByIaAFiwAw246YEltSq7tdrOH0Q==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1" + } + }, + "pump": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", + "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "pumpify": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-1.5.1.tgz", + "integrity": "sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==", + "dev": true, + "requires": { + "duplexify": "^3.6.0", + "inherits": "^2.0.3", + "pump": "^2.0.0" + } + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + }, + "q": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.4.1.tgz", + "integrity": "sha1-VXBbzZPF82c1MMLCy8DCs63cKG4=", + "dev": true + }, + "qjobs": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", + "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", + "dev": true + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", + "dev": true + }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=", + "dev": true + }, + "querystring-es3": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", + "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=", + "dev": true + }, + "querystringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.0.0.tgz", + "integrity": "sha512-eTPo5t/4bgaMNZxyjWx6N2a6AuE0mq51KWvpc7nU/MAqixcI6v6KrGUKES0HaomdnolQBBXU/++X6/QQ9KL4tw==", + "dev": true + }, + "randomatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-3.1.0.tgz", + "integrity": "sha512-KnGPVE0lo2WoXxIZ7cPR8YBpiol4gsSuOwDSg410oHh80ZMp5EiypNqL2K4Z77vJn6lB5rap7IkAmcUlalcnBQ==", + "dev": true, + "requires": { + "is-number": "^4.0.0", + "kind-of": "^6.0.0", + "math-random": "^1.0.1" + }, + "dependencies": { + "is-number": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz", + "integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==", + "dev": true + } + } + }, + "randombytes": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.0.6.tgz", + "integrity": "sha512-CIQ5OFxf4Jou6uOKe9t1AOgqpeU5fd70A8NPdHSGeYXqXsPe6peOwI0cUl88RWZ6sP1vPMV3avd/R6cZ5/sP1A==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "dev": true, + "requires": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, + "range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=", + "dev": true + }, + "raw-body": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.2.tgz", + "integrity": "sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k=", + "dev": true, + "requires": { + "bytes": "3.0.0", + "http-errors": "1.6.2", + "iconv-lite": "0.4.19", + "unpipe": "1.0.0" + }, + "dependencies": { + "depd": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.1.tgz", + "integrity": "sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k=", + "dev": true + }, + "http-errors": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.2.tgz", + "integrity": "sha1-CgAsyFcHGSp+eUbO7cERVfYOxzY=", + "dev": true, + "requires": { + "depd": "1.1.1", + "inherits": "2.0.3", + "setprototypeof": "1.0.3", + "statuses": ">= 1.3.1 < 2" + } + }, + "setprototypeof": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz", + "integrity": "sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ=", + "dev": true + } + } + }, + "raw-loader": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-0.5.1.tgz", + "integrity": "sha1-DD0L6u2KAclm2Xh793goElKpeao=", + "dev": true + }, + "read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha1-5mTvMRYRZsl1HNvo28+GtftY93Q=", + "dev": true, + "requires": { + "pify": "^2.3.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "read-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", + "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=", + "dev": true, + "requires": { + "load-json-file": "^1.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^1.0.0" + }, + "dependencies": { + "path-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", + "integrity": "sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "read-pkg-up": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", + "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", + "dev": true, + "requires": { + "find-up": "^1.0.0", + "read-pkg": "^1.0.0" + }, + "dependencies": { + "find-up": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", + "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", + "dev": true, + "requires": { + "path-exists": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "path-exists": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", + "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", + "dev": true, + "requires": { + "pinkie-promise": "^2.0.0" + } + } + } + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "readdirp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.1.0.tgz", + "integrity": "sha1-TtCtBg3zBzMAxIRANz9y0cxkLXg=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "minimatch": "^3.0.2", + "readable-stream": "^2.0.2", + "set-immediate-shim": "^1.0.1" + } + }, + "rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", + "dev": true, + "requires": { + "resolve": "^1.1.6" + } + }, + "redent": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz", + "integrity": "sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=", + "dev": true, + "optional": true, + "requires": { + "indent-string": "^2.1.0", + "strip-indent": "^1.0.1" + } + }, + "reflect-metadata": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.12.tgz", + "integrity": "sha512-n+IyV+nGz3+0q3/Yf1ra12KpCyi001bi4XFxSjbiWWjfqb52iTTtpGXmCCAOWWIAn9KEuFZKGqBERHmrtScZ3A==", + "dev": true + }, + "regenerate": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz", + "integrity": "sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==", + "dev": true + }, + "regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", + "dev": true + }, + "regex-cache": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/regex-cache/-/regex-cache-0.4.4.tgz", + "integrity": "sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ==", + "dev": true, + "requires": { + "is-equal-shallow": "^0.1.3" + } + }, + "regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + } + }, + "regexpu-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-1.0.0.tgz", + "integrity": "sha1-hqdj9Y7k18L2sQLkdkBQ3n7ZDGs=", + "dev": true, + "requires": { + "regenerate": "^1.2.1", + "regjsgen": "^0.2.0", + "regjsparser": "^0.1.4" + } + }, + "regjsgen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.2.0.tgz", + "integrity": "sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc=", + "dev": true + }, + "regjsparser": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.1.5.tgz", + "integrity": "sha1-fuj4Tcb6eS0/0K4ijSS9lJ6tIFw=", + "dev": true, + "requires": { + "jsesc": "~0.5.0" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", + "dev": true + } + } + }, + "relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=", + "dev": true + }, + "remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", + "dev": true + }, + "renderkid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-2.0.1.tgz", + "integrity": "sha1-iYyr/Ivt5Le5ETWj/9Mj5YwNsxk=", + "dev": true, + "requires": { + "css-select": "^1.1.0", + "dom-converter": "~0.1", + "htmlparser2": "~3.3.0", + "strip-ansi": "^3.0.0", + "utila": "~0.3" + }, + "dependencies": { + "utila": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/utila/-/utila-0.3.3.tgz", + "integrity": "sha1-1+jn1+MJEHCSsF+NloiCTWM6QiY=", + "dev": true + } + } + }, + "repeat-element": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.2.tgz", + "integrity": "sha1-7wiaF40Ug7quTZPrmLT55OEdmQo=", + "dev": true + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", + "dev": true + }, + "repeating": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", + "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", + "dev": true, + "requires": { + "is-finite": "^1.0.0" + } + }, + "request": { + "version": "2.87.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.87.0.tgz", + "integrity": "sha512-fcogkm7Az5bsS6Sl0sibkbhcKsnyon/jV1kF3ajGmF0c8HrttdKTPRT9hieOaQHA5HEq6r8OyWOo/o781C1tNw==", + "dev": true, + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.6.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.5", + "extend": "~3.0.1", + "forever-agent": "~0.6.1", + "form-data": "~2.3.1", + "har-validator": "~5.0.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.17", + "oauth-sign": "~0.8.2", + "performance-now": "^2.1.0", + "qs": "~6.5.1", + "safe-buffer": "^5.1.1", + "tough-cookie": "~2.3.3", + "tunnel-agent": "^0.6.0", + "uuid": "^3.1.0" + }, + "dependencies": { + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + } + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "dev": true + }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true + }, + "require-main-filename": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", + "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=", + "dev": true + }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", + "dev": true + }, + "resolve": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.7.1.tgz", + "integrity": "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw==", + "dev": true, + "requires": { + "path-parse": "^1.0.5" + } + }, + "resolve-cwd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-2.0.0.tgz", + "integrity": "sha1-AKn3OHVW4nA46uIyyqNypqWbZlo=", + "dev": true, + "requires": { + "resolve-from": "^3.0.0" + } + }, + "resolve-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", + "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=", + "dev": true + }, + "resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", + "dev": true + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dev": true, + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } + }, + "ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true + }, + "retry": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.10.1.tgz", + "integrity": "sha1-52OI0heZLCUnUCQdPTlW/tmNj/Q=", + "dev": true + }, + "rfdc": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.1.2.tgz", + "integrity": "sha512-92ktAgvZhBzYTIK0Mja9uen5q5J3NRVMoDkJL2VMwq6SXjVCgqvQeVP2XAaUY6HT+XpQYeLSjb3UoitBryKmdA==", + "dev": true + }, + "right-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", + "integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=", + "dev": true, + "optional": true, + "requires": { + "align-text": "^0.1.1" + } + }, + "rimraf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz", + "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==", + "dev": true, + "requires": { + "glob": "^7.0.5" + } + }, + "ripemd160": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", + "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "dev": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, + "run-async": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz", + "integrity": "sha1-A3GrSuC91yDUFm19/aZP96RFpsA=", + "dev": true, + "requires": { + "is-promise": "^2.1.0" + } + }, + "run-queue": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/run-queue/-/run-queue-1.0.3.tgz", + "integrity": "sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec=", + "dev": true, + "requires": { + "aproba": "^1.1.1" + } + }, + "rxjs": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.3.3.tgz", + "integrity": "sha512-JTWmoY9tWCs7zvIk/CvRjhjGaOd+OVBM987mxFo+OW66cGpdKjZcpmc74ES1sB//7Kl/PAe8+wEakuhG4pcgOw==", + "requires": { + "tslib": "^1.9.0" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", + "dev": true, + "requires": { + "ret": "~0.1.10" + } + }, + "safefs": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/safefs/-/safefs-4.1.0.tgz", + "integrity": "sha1-+CrrS9165R9lPrIPZyizBYyNZEU=", + "dev": true, + "requires": { + "editions": "^1.1.1", + "graceful-fs": "^4.1.4" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "sass-graph": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-2.2.4.tgz", + "integrity": "sha1-E/vWPNHK8JCLn9k0dq1DpR0eC0k=", + "dev": true, + "optional": true, + "requires": { + "glob": "^7.0.0", + "lodash": "^4.0.0", + "scss-tokenizer": "^0.2.3", + "yargs": "^7.0.0" + }, + "dependencies": { + "camelcase": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", + "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=", + "dev": true, + "optional": true + }, + "cliui": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", + "dev": true, + "optional": true, + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wrap-ansi": "^2.0.0" + } + }, + "y18n": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", + "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=", + "dev": true, + "optional": true + }, + "yargs": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-7.1.0.tgz", + "integrity": "sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg=", + "dev": true, + "optional": true, + "requires": { + "camelcase": "^3.0.0", + "cliui": "^3.2.0", + "decamelize": "^1.1.1", + "get-caller-file": "^1.0.1", + "os-locale": "^1.4.0", + "read-pkg-up": "^1.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^1.0.2", + "which-module": "^1.0.0", + "y18n": "^3.2.1", + "yargs-parser": "^5.0.0" + } + } + } + }, + "sass-loader": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-6.0.7.tgz", + "integrity": "sha512-JoiyD00Yo1o61OJsoP2s2kb19L1/Y2p3QFcCdWdF6oomBGKVYuZyqHWemRBfQ2uGYsk+CH3eCguXNfpjzlcpaA==", + "dev": true, + "requires": { + "clone-deep": "^2.0.1", + "loader-utils": "^1.0.1", + "lodash.tail": "^4.1.1", + "neo-async": "^2.5.0", + "pify": "^3.0.0" + } + }, + "saucelabs": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/saucelabs/-/saucelabs-1.5.0.tgz", + "integrity": "sha512-jlX3FGdWvYf4Q3LFfFWS1QvPg3IGCGWxIc8QBFdPTbpTJnt/v17FHXYVAn7C8sHf1yUXo2c7yIM0isDryfYtHQ==", + "dev": true, + "requires": { + "https-proxy-agent": "^2.2.1" + } + }, + "sax": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/sax/-/sax-0.5.8.tgz", + "integrity": "sha1-1HLbIo6zMcJQaw6MFVJK25OdEsE=", + "dev": true + }, + "scandirectory": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/scandirectory/-/scandirectory-2.5.0.tgz", + "integrity": "sha1-bOA/VKCQtmjjy+2/IO354xBZPnI=", + "dev": true, + "requires": { + "ignorefs": "^1.0.0", + "safefs": "^3.1.2", + "taskgroup": "^4.0.5" + }, + "dependencies": { + "safefs": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/safefs/-/safefs-3.2.2.tgz", + "integrity": "sha1-gXDBRE1wOOCMrqBaN0+uL6NJ4Vw=", + "dev": true, + "requires": { + "graceful-fs": "*" + } + }, + "taskgroup": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/taskgroup/-/taskgroup-4.3.1.tgz", + "integrity": "sha1-feGT/r12gnPEV3MElwJNUSwnkVo=", + "dev": true, + "requires": { + "ambi": "^2.2.0", + "csextends": "^1.0.3" + } + } + } + }, + "schema-utils": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.7.tgz", + "integrity": "sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-keywords": "^3.1.0" + } + }, + "scss-tokenizer": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz", + "integrity": "sha1-jrBtualyMzOCTT9VMGQRSYR85dE=", + "dev": true, + "optional": true, + "requires": { + "js-base64": "^2.1.8", + "source-map": "^0.4.2" + }, + "dependencies": { + "source-map": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", + "dev": true, + "optional": true, + "requires": { + "amdefine": ">=0.0.4" + } + } + } + }, + "select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=", + "dev": true + }, + "selenium-webdriver": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-3.6.0.tgz", + "integrity": "sha512-WH7Aldse+2P5bbFBO4Gle/nuQOdVwpHMTL6raL3uuBj/vPG07k6uzt3aiahu352ONBr5xXh0hDlM3LhtXPOC4Q==", + "dev": true, + "requires": { + "jszip": "^3.1.3", + "rimraf": "^2.5.4", + "tmp": "0.0.30", + "xml2js": "^0.4.17" + }, + "dependencies": { + "tmp": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.30.tgz", + "integrity": "sha1-ckGdSovn1s51FI/YsyTlk6cRwu0=", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.1" + } + } + } + }, + "selfsigned": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-1.10.3.tgz", + "integrity": "sha512-vmZenZ+8Al3NLHkWnhBQ0x6BkML1eCP2xEi3JE+f3D9wW9fipD9NNJHYtE9XJM4TsPaHGZJIamrSI6MTg1dU2Q==", + "dev": true, + "requires": { + "node-forge": "0.7.5" + } + }, + "semver": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz", + "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==", + "dev": true + }, + "semver-dsl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/semver-dsl/-/semver-dsl-1.0.1.tgz", + "integrity": "sha1-02eN5VVeimH2Ke7QJTZq5fJzQKA=", + "dev": true, + "requires": { + "semver": "^5.3.0" + } + }, + "semver-intersect": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/semver-intersect/-/semver-intersect-1.4.0.tgz", + "integrity": "sha512-d8fvGg5ycKAq0+I6nfWeCx6ffaWJCsBYU0H2Rq56+/zFePYfT8mXkB3tWBSjR5BerkHNZ5eTPIk1/LBYas35xQ==", + "dev": true, + "requires": { + "semver": "^5.0.0" + } + }, + "send": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz", + "integrity": "sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==", + "dev": true, + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.6.2", + "mime": "1.4.1", + "ms": "2.0.0", + "on-finished": "~2.3.0", + "range-parser": "~1.2.0", + "statuses": "~1.4.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "mime": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz", + "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==", + "dev": true + } + } + }, + "serialize-javascript": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-1.5.0.tgz", + "integrity": "sha512-Ga8c8NjAAp46Br4+0oZ2WxJCwIzwP60Gq1YPgU+39PiTVxyed/iKE/zyZI6+UlVYH5Q4PaQdHhcegIFPZTUfoQ==", + "dev": true + }, + "serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha1-03aNabHn2C5c4FD/9bRTvqEqkjk=", + "dev": true, + "requires": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "serve-static": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.13.2.tgz", + "integrity": "sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw==", + "dev": true, + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.2", + "send": "0.16.2" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "dev": true + }, + "set-immediate-shim": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz", + "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=", + "dev": true + }, + "set-value": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.0.tgz", + "integrity": "sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg==", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=", + "dev": true + }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "dev": true + }, + "sha.js": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "shallow-clone": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-1.0.0.tgz", + "integrity": "sha512-oeXreoKR/SyNJtRJMAKPDSvd28OqEwG4eR/xc856cRGBII7gX9lvAqDxusPm0846z/w/hWYjI1NpKwJ00NHzRA==", + "dev": true, + "requires": { + "is-extendable": "^0.1.1", + "kind-of": "^5.0.0", + "mixin-object": "^2.0.1" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, + "shell-quote": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.6.1.tgz", + "integrity": "sha1-9HgZSczkAmlxJ0MOo7PFR29IF2c=", + "dev": true, + "requires": { + "array-filter": "~0.0.0", + "array-map": "~0.0.0", + "array-reduce": "~0.0.0", + "jsonify": "~0.0.0" + } + }, + "shelljs": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.2.tgz", + "integrity": "sha512-pRXeNrCA2Wd9itwhvLp5LZQvPJ0wU6bcjaTMywHHGX5XWhVN2nzSu7WV0q+oUY7mGK3mgSkDDzP3MgjqdyIgbQ==", + "dev": true, + "requires": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + } + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", + "dev": true + }, + "slash": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", + "integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=", + "dev": true + }, + "slide": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz", + "integrity": "sha1-VusCfWW00tzmyy4tMsTUr8nh1wc=", + "dev": true + }, + "snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "dev": true, + "requires": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "dev": true, + "requires": { + "define-property": "^1.0.0", + "isobject": "^3.0.0", + "snapdragon-util": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "dev": true, + "requires": { + "kind-of": "^3.2.0" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "socket.io": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-2.1.1.tgz", + "integrity": "sha512-rORqq9c+7W0DAK3cleWNSyfv/qKXV99hV4tZe+gGLfBECw3XEhBy7x85F3wypA9688LKjtwO9pX9L33/xQI8yA==", + "dev": true, + "requires": { + "debug": "~3.1.0", + "engine.io": "~3.2.0", + "has-binary2": "~1.0.2", + "socket.io-adapter": "~1.1.0", + "socket.io-client": "2.1.1", + "socket.io-parser": "~3.2.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "socket.io-adapter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-1.1.1.tgz", + "integrity": "sha1-KoBeihTWNyEk3ZFZrUUC+MsH8Gs=", + "dev": true + }, + "socket.io-client": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-2.1.1.tgz", + "integrity": "sha512-jxnFyhAuFxYfjqIgduQlhzqTcOEQSn+OHKVfAxWaNWa7ecP7xSNk2Dx/3UEsDcY7NcFafxvNvKPmmO7HTwTxGQ==", + "dev": true, + "requires": { + "backo2": "1.0.2", + "base64-arraybuffer": "0.1.5", + "component-bind": "1.0.0", + "component-emitter": "1.2.1", + "debug": "~3.1.0", + "engine.io-client": "~3.2.0", + "has-binary2": "~1.0.2", + "has-cors": "1.1.0", + "indexof": "0.0.1", + "object-component": "0.0.3", + "parseqs": "0.0.5", + "parseuri": "0.0.5", + "socket.io-parser": "~3.2.0", + "to-array": "0.1.4" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "socket.io-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.2.0.tgz", + "integrity": "sha512-FYiBx7rc/KORMJlgsXysflWx/RIvtqZbyGLlHZvjfmPTPeuD/I8MaW7cfFrj5tRltICJdgwflhfZ3NVVbVLFQA==", + "dev": true, + "requires": { + "component-emitter": "1.2.1", + "debug": "~3.1.0", + "isarray": "2.0.1" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "isarray": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz", + "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=", + "dev": true + } + } + }, + "sockjs": { + "version": "0.3.19", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.19.tgz", + "integrity": "sha512-V48klKZl8T6MzatbLlzzRNhMepEys9Y4oGFpypBFFn1gLI/QQ9HtLLyWJNbPlwGLelOVOEijUbTTJeLLI59jLw==", + "dev": true, + "requires": { + "faye-websocket": "^0.10.0", + "uuid": "^3.0.1" + } + }, + "sockjs-client": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.1.5.tgz", + "integrity": "sha1-G7fA9yIsQPQq3xT0RCy9Eml3GoM=", + "dev": true, + "requires": { + "debug": "^2.6.6", + "eventsource": "0.1.6", + "faye-websocket": "~0.11.0", + "inherits": "^2.0.1", + "json3": "^3.3.2", + "url-parse": "^1.1.8" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "faye-websocket": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.1.tgz", + "integrity": "sha1-8O/hjE9W5PQK/H4Gxxn9XuYYjzg=", + "dev": true, + "requires": { + "websocket-driver": ">=0.5.1" + } + } + } + }, + "source-list-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.0.tgz", + "integrity": "sha512-I2UmuJSRr/T8jisiROLU3A3ltr+swpniSmNPI4Ml3ZCX6tVnDsuZzK7F2hl5jTqbZBWCEKlj5HRQiPExXLgE8A==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + }, + "source-map-loader": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-0.2.4.tgz", + "integrity": "sha512-OU6UJUty+i2JDpTItnizPrlpOIBLmQbWMuBg9q5bVtnHACqw1tn9nNwqJLbv0/00JjnJb/Ee5g5WS5vrRv7zIQ==", + "dev": true, + "requires": { + "async": "^2.5.0", + "loader-utils": "^1.1.0" + }, + "dependencies": { + "async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.1.tgz", + "integrity": "sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==", + "dev": true, + "requires": { + "lodash": "^4.17.10" + } + } + } + }, + "source-map-resolve": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.2.tgz", + "integrity": "sha512-MjqsvNwyz1s0k81Goz/9vRBe9SZdB09Bdw+/zYyO+3CuPk6fouTaxscHkgtE8jKvf01kVfl8riHzERQ/kefaSA==", + "dev": true, + "requires": { + "atob": "^2.1.1", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" + } + }, + "source-map-support": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.6.tgz", + "integrity": "sha512-N4KXEz7jcKqPf2b2vZF11lQIz9W5ZMuUcIOGj243lduidkf2fjkVKJS9vNxVWn3u/uxX38AcE8U9nnH9FPcq+g==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "source-map-url": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", + "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=", + "dev": true + }, + "sourcemap-codec": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.3.tgz", + "integrity": "sha512-vFrY/x/NdsD7Yc8mpTJXuao9S8lq08Z/kOITHz6b7YbfI9xL8Spe5EvSQUHOI7SbpY8bRPr0U3kKSsPuqEGSfA==", + "dev": true + }, + "spdx-correct": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.0.0.tgz", + "integrity": "sha512-N19o9z5cEyc8yQQPukRCZ9EUmb4HUpnrmaL/fxS2pBo2jbfcFRVuFZ/oFC+vZz0MNNk0h80iMn5/S6qGZOL5+g==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.1.0.tgz", + "integrity": "sha512-4K1NsmrlCU1JJgUrtgEeTVyfx8VaYea9J9LvARxhbHtVtohPs/gFGG5yy49beySjlIMhhXZ4QqujIZEfS4l6Cg==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz", + "integrity": "sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.0.tgz", + "integrity": "sha512-2+EPwgbnmOIl8HjGBXXMd9NAu02vLjOO1nWw4kmeRDFyHn+M/ETfHxQUK0oXg8ctgVnl9t3rosNVsZ1jG61nDA==", + "dev": true + }, + "spdy": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-3.4.7.tgz", + "integrity": "sha1-Qv9B7OXMD5mjpsKKq7c/XDsDrLw=", + "dev": true, + "requires": { + "debug": "^2.6.8", + "handle-thing": "^1.2.5", + "http-deceiver": "^1.2.7", + "safe-buffer": "^5.0.1", + "select-hose": "^2.0.0", + "spdy-transport": "^2.0.18" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "spdy-transport": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-2.1.0.tgz", + "integrity": "sha512-bpUeGpZcmZ692rrTiqf9/2EUakI6/kXX1Rpe0ib/DyOzbiexVfXkw6GnvI9hVGvIwVaUhkaBojjCZwLNRGQg1g==", + "dev": true, + "requires": { + "debug": "^2.6.8", + "detect-node": "^2.0.3", + "hpack.js": "^2.1.6", + "obuf": "^1.1.1", + "readable-stream": "^2.2.9", + "safe-buffer": "^5.0.1", + "wbuf": "^1.7.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "split": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha1-zQ7qXmOiEd//frDwkcQTPi0N0o8=", + "dev": true, + "requires": { + "through": "2" + } + }, + "split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.0" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "sshpk": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.2.tgz", + "integrity": "sha1-xvxhZIo9nE52T9P8306hBeSSupg=", + "dev": true, + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, + "ssri": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-5.3.0.tgz", + "integrity": "sha512-XRSIPqLij52MtgoQavH/x/dU1qVKtWUAAZeOHsR9c2Ddi4XerFy3mc1alf+dLJKl9EUIm/Ht+EowFkTUOA6GAQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.1" + } + }, + "static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", + "dev": true, + "requires": { + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "stats-webpack-plugin": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/stats-webpack-plugin/-/stats-webpack-plugin-0.6.2.tgz", + "integrity": "sha1-LFlJtTHgf4eojm6k3PrFOqjHWis=", + "dev": true, + "requires": { + "lodash": "^4.17.4" + } + }, + "statuses": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", + "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==", + "dev": true + }, + "stdout-stream": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/stdout-stream/-/stdout-stream-1.4.1.tgz", + "integrity": "sha512-j4emi03KXqJWcIeF8eIXkjMFN1Cmb8gUlDYGeBALLPo5qdyTfA9bOtl8m33lRoC+vFMkP3gl0WsDr6+gzxbbTA==", + "dev": true, + "optional": true, + "requires": { + "readable-stream": "^2.0.1" + } + }, + "stream-browserify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.1.tgz", + "integrity": "sha1-ZiZu5fm9uZQKTkUUyvtDu3Hlyds=", + "dev": true, + "requires": { + "inherits": "~2.0.1", + "readable-stream": "^2.0.2" + } + }, + "stream-combiner": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha1-TV5DPBhSYd3mI8o/RMWGvPXErRQ=", + "dev": true, + "requires": { + "duplexer": "~0.1.1" + } + }, + "stream-each": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/stream-each/-/stream-each-1.2.3.tgz", + "integrity": "sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "stream-shift": "^1.0.0" + } + }, + "stream-http": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz", + "integrity": "sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==", + "dev": true, + "requires": { + "builtin-status-codes": "^3.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.3.6", + "to-arraybuffer": "^1.0.0", + "xtend": "^4.0.0" + } + }, + "stream-shift": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.0.tgz", + "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=", + "dev": true + }, + "streamroller": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-0.7.0.tgz", + "integrity": "sha512-WREzfy0r0zUqp3lGO096wRuUp7ho1X6uo/7DJfTlEi0Iv/4gT7YHqXDjKC2ioVGBZtE8QzsQD9nx1nIuoZ57jQ==", + "dev": true, + "requires": { + "date-format": "^1.2.0", + "debug": "^3.1.0", + "mkdirp": "^0.5.1", + "readable-stream": "^2.3.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string.prototype.padend": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.0.0.tgz", + "integrity": "sha1-86rvfBcZ8XDF6rHDK/eA2W4h8vA=", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "es-abstract": "^1.4.3", + "function-bind": "^1.0.2" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "dev": true, + "requires": { + "is-utf8": "^0.2.0" + } + }, + "strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", + "dev": true + }, + "strip-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz", + "integrity": "sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=", + "dev": true, + "optional": true, + "requires": { + "get-stdin": "^4.0.1" + } + }, + "style-loader": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-0.21.0.tgz", + "integrity": "sha512-T+UNsAcl3Yg+BsPKs1vd22Fr8sVT+CJMtzqc6LEw9bbJZb43lm9GoeIfUcDEefBSWC0BhYbcdupV1GtI4DGzxg==", + "dev": true, + "requires": { + "loader-utils": "^1.1.0", + "schema-utils": "^0.4.5" + } + }, + "stylus": { + "version": "0.54.5", + "resolved": "https://registry.npmjs.org/stylus/-/stylus-0.54.5.tgz", + "integrity": "sha1-QrlWCTHKcJDOhRWnmLqeaqPW3Hk=", + "dev": true, + "requires": { + "css-parse": "1.7.x", + "debug": "*", + "glob": "7.0.x", + "mkdirp": "0.5.x", + "sax": "0.5.x", + "source-map": "0.1.x" + }, + "dependencies": { + "glob": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.0.6.tgz", + "integrity": "sha1-IRuvr0nlJbjNkyYNFKsTYVKz9Xo=", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.2", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "source-map": { + "version": "0.1.43", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz", + "integrity": "sha1-wkvBRspRfBRx9drL4lcbK3+eM0Y=", + "dev": true, + "requires": { + "amdefine": ">=0.0.4" + } + } + } + }, + "stylus-loader": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/stylus-loader/-/stylus-loader-3.0.2.tgz", + "integrity": "sha512-+VomPdZ6a0razP+zinir61yZgpw2NfljeSsdUF5kJuEzlo3khXhY19Fn6l8QQz1GRJGtMCo8nG5C04ePyV7SUA==", + "dev": true, + "requires": { + "loader-utils": "^1.0.2", + "lodash.clonedeep": "^4.5.0", + "when": "~3.6.x" + } + }, + "supports-color": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", + "dev": true + }, + "tapable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.0.tgz", + "integrity": "sha512-IlqtmLVaZA2qab8epUXbVWRn3aB1imbDMJtjB3nu4X0NqPkcY/JH9ZtCBWKHWPxs8Svi9tyo8w2dBoi07qZbBA==", + "dev": true + }, + "tar": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz", + "integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=", + "dev": true, + "optional": true, + "requires": { + "block-stream": "*", + "fstream": "^1.0.2", + "inherits": "2" + } + }, + "taskgroup": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/taskgroup/-/taskgroup-5.3.0.tgz", + "integrity": "sha512-++j3Yi3XZGYgAvmGzRtNa+BnDvkPbdroyMffCY+Gj9A4iH2IJ1S7/g6LewGVXQkVw/KOzlfE1TimARYXvOEsgQ==", + "dev": true, + "requires": { + "ambi": "^3.0.0", + "eachr": "^3.2.0", + "editions": "^1.3.4", + "extendr": "^3.2.2", + "unbounded": "^1.1.0" + }, + "dependencies": { + "ambi": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/ambi/-/ambi-3.1.1.tgz", + "integrity": "sha512-aObUKDykDPXOvovML+jDLJMw1cRS3/VhYb7vI3GmtQB7hmWeILWDzMYwC/9pljYL5gK3ZMb2QzUA9qQn5VAx7A==", + "dev": true, + "requires": { + "editions": "^2.0.2", + "typechecker": "^4.3.0" + }, + "dependencies": { + "editions": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/editions/-/editions-2.0.2.tgz", + "integrity": "sha512-0B8aSTWUu9+JW99zHoeogavCi+lkE5l35FK0OKe0pCobixJYoeof3ZujtqYzSsU2MskhRadY5V9oWUuyG4aJ3A==", + "dev": true, + "requires": { + "errlop": "^1.0.2", + "semver": "^5.5.0" + } + } + } + } + } + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, + "through2": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz", + "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=", + "dev": true, + "requires": { + "readable-stream": "^2.1.5", + "xtend": "~4.0.1" + } + }, + "thunky": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.0.2.tgz", + "integrity": "sha1-qGLgGOP7HqLsP85dVWBc9X8kc3E=", + "dev": true + }, + "time-stamp": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/time-stamp/-/time-stamp-1.1.0.tgz", + "integrity": "sha1-dkpaEa9QVhkhsTPztE5hhofg9cM=", + "dev": true + }, + "timers-browserify": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.10.tgz", + "integrity": "sha512-YvC1SV1XdOUaL6gx5CoGroT3Gu49pK9+TZ38ErPldOWW4j49GI1HKs9DV+KGq/w6y+LZ72W1c8cKz2vzY+qpzg==", + "dev": true, + "requires": { + "setimmediate": "^1.0.4" + } + }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.2" + } + }, + "to-array": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz", + "integrity": "sha1-F+bBH3PdTz10zaek/zI46a2b+JA=", + "dev": true + }, + "to-arraybuffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", + "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=", + "dev": true + }, + "to-fast-properties": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz", + "integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=", + "dev": true + }, + "to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "dev": true, + "requires": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + }, + "toposort": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-1.0.7.tgz", + "integrity": "sha1-LmhELZ9k7HILjMieZEOsbKqVACk=", + "dev": true + }, + "tough-cookie": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", + "integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==", + "dev": true, + "requires": { + "punycode": "^1.4.1" + }, + "dependencies": { + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + } + } + }, + "traverse": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz", + "integrity": "sha1-y99WD9e5r2MlAv7UD5GMFX6pcTc=", + "dev": true + }, + "tree-kill": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.0.tgz", + "integrity": "sha512-DlX6dR0lOIRDFxI0mjL9IYg6OTncLm/Zt+JiBhE5OlFcAR8yc9S7FFXU9so0oda47frdM/JFsk7UjNt9vscKcg==", + "dev": true + }, + "trim-newlines": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", + "integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=", + "dev": true, + "optional": true + }, + "trim-right": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz", + "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=", + "dev": true + }, + "true-case-path": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/true-case-path/-/true-case-path-1.0.3.tgz", + "integrity": "sha512-m6s2OdQe5wgpFMC+pAJ+q9djG82O2jcHPOI6RNg1yy9rCYR+WD6Nbpl32fDpfC56nirdRy+opFa/Vk7HYhqaew==", + "dev": true, + "optional": true, + "requires": { + "glob": "^7.1.2" + } + }, + "ts-node": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-5.0.1.tgz", + "integrity": "sha512-XK7QmDcNHVmZkVtkiwNDWiERRHPyU8nBqZB1+iv2UhOG0q3RQ9HsZ2CMqISlFbxjrYFGfG2mX7bW4dAyxBVzUw==", + "dev": true, + "requires": { + "arrify": "^1.0.0", + "chalk": "^2.3.0", + "diff": "^3.1.0", + "make-error": "^1.1.1", + "minimist": "^1.2.0", + "mkdirp": "^0.5.1", + "source-map-support": "^0.5.3", + "yn": "^2.0.0" + }, + "dependencies": { + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + } + } + }, + "ts-simple-ast": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/ts-simple-ast/-/ts-simple-ast-12.4.0.tgz", + "integrity": "sha512-7GJFZlyTZY7uMAEhX62ZLxdwOpGDJzc/nwpi1nRPZ7N2ICcqqrMjDtRnki15IUBv2ZjIGu6KBqk/pUqJFODFsg==", + "dev": true, + "requires": { + "@dsherret/to-absolute-glob": "^2.0.2", + "code-block-writer": "^7.2.0", + "fs-extra": "^6.0.1", + "glob-parent": "^3.1.0", + "globby": "^8.0.1", + "is-negated-glob": "^1.0.0", + "multimatch": "^2.1.0", + "object-assign": "^4.1.1", + "tslib": "^1.9.0", + "typescript": "2.9.1" + }, + "dependencies": { + "fs-extra": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-6.0.1.tgz", + "integrity": "sha512-GnyIkKhhzXZUWFCaJzvyDLEEgDkPfb4/TPvJCJVuS8MWZgoSsErf++QpiAlDnKFcqhRlm+tIOcencCjyJE6ZCA==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "globby": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/globby/-/globby-8.0.1.tgz", + "integrity": "sha512-oMrYrJERnKBLXNLVTqhm3vPEdJ/b2ZE28xN4YARiix1NOIOBPEpOUnm844K1iu/BkphCaf2WNFwMszv8Soi1pw==", + "dev": true, + "requires": { + "array-union": "^1.0.1", + "dir-glob": "^2.0.0", + "fast-glob": "^2.0.2", + "glob": "^7.1.2", + "ignore": "^3.3.5", + "pify": "^3.0.0", + "slash": "^1.0.0" + } + }, + "typescript": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-2.9.1.tgz", + "integrity": "sha512-h6pM2f/GDchCFlldnriOhs1QHuwbnmj6/v7499eMHqPeW4V2G0elua2eIc2nu8v2NdHV0Gm+tzX83Hr6nUFjQA==", + "dev": true + } + } + }, + "tslib": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.2.tgz", + "integrity": "sha512-AVP5Xol3WivEr7hnssHDsaM+lVrVXWUvd1cfXTRkTj80b//6g2wIFEH6hZG0muGZRnHGrfttpdzRk3YlBkWjKw==" + }, + "tslint": { + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.9.1.tgz", + "integrity": "sha1-ElX4ej/1frCw4fDmEKi0dIBGya4=", + "dev": true, + "requires": { + "babel-code-frame": "^6.22.0", + "builtin-modules": "^1.1.1", + "chalk": "^2.3.0", + "commander": "^2.12.1", + "diff": "^3.2.0", + "glob": "^7.1.1", + "js-yaml": "^3.7.0", + "minimatch": "^3.0.4", + "resolve": "^1.3.2", + "semver": "^5.3.0", + "tslib": "^1.8.0", + "tsutils": "^2.12.1" + }, + "dependencies": { + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "tsutils": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz", + "integrity": "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + } + } + }, + "tsutils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.0.0.tgz", + "integrity": "sha512-LjHBWR0vWAUHWdIAoTjoqi56Kz+FDKBgVEuL+gVPG/Pv7QW5IdaDDeK9Txlr6U0Cmckp5EgCIq1T25qe3J6hyw==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + }, + "tty-browserify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz", + "integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=", + "dev": true + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "dev": true, + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "dev": true, + "optional": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2" + } + }, + "type-is": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.16.tgz", + "integrity": "sha512-HRkVv/5qY2G6I8iab9cI7v1bOIdhm94dVjQCPFElW9W+3GeDOSHmy2EBYe4VTApuzolPcmgFTN3ftVJRKR2J9Q==", + "dev": true, + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.18" + } + }, + "typechecker": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/typechecker/-/typechecker-4.5.0.tgz", + "integrity": "sha512-bqPE/ck3bVIaXP7gMKTKSHrypT32lpYTpiqzPYeYzdSQnmaGvaGhy7TnN/M/+5R+2rs/kKcp9ZLPRp/Q9Yj+4w==", + "dev": true, + "requires": { + "editions": "^1.3.4" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", + "dev": true + }, + "typescript": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.1.1.tgz", + "integrity": "sha512-Veu0w4dTc/9wlWNf2jeRInNodKlcdLgemvPsrNpfu5Pq39sgfFjvIIgTsvUHCoLBnMhPoUA+tFxsXjU6VexVRQ==", + "dev": true + }, + "uglify-js": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.9.tgz", + "integrity": "sha512-8CJsbKOtEbnJsTyv6LE6m6ZKniqMiFWmm9sRbopbkGs3gMPPfd3Fh8iIA4Ykv5MgaTbqHr4BaoGLJLZNhsrW1Q==", + "dev": true, + "requires": { + "commander": "~2.17.1", + "source-map": "~0.6.1" + }, + "dependencies": { + "commander": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", + "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "uglify-to-browserify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz", + "integrity": "sha1-bgkk1r2mta/jSeOabWMoUKD4grc=", + "dev": true, + "optional": true + }, + "uglifyjs-webpack-plugin": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-1.3.0.tgz", + "integrity": "sha512-ovHIch0AMlxjD/97j9AYovZxG5wnHOPkL7T1GKochBADp/Zwc44pEWNqpKl1Loupp1WhFg7SlYmHZRUfdAacgw==", + "dev": true, + "requires": { + "cacache": "^10.0.4", + "find-cache-dir": "^1.0.0", + "schema-utils": "^0.4.5", + "serialize-javascript": "^1.4.0", + "source-map": "^0.6.1", + "uglify-es": "^3.3.4", + "webpack-sources": "^1.1.0", + "worker-farm": "^1.5.2" + }, + "dependencies": { + "commander": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.13.0.tgz", + "integrity": "sha512-MVuS359B+YzaWqjCL/c+22gfryv+mCBPHAv3zyVI2GN8EY6IRP8VwtasXn8jyyhvvq84R4ImN1OKRtcbIasjYA==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "uglify-es": { + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/uglify-es/-/uglify-es-3.3.9.tgz", + "integrity": "sha512-r+MU0rfv4L/0eeW3xZrd16t4NZfK8Ld4SWVglYBb7ez5uXFWHuVRs6xCTrf1yirs9a4j4Y27nn7SRfO6v67XsQ==", + "dev": true, + "requires": { + "commander": "~2.13.0", + "source-map": "~0.6.1" + } + } + } + }, + "ultron": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz", + "integrity": "sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==", + "dev": true + }, + "unbounded": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbounded/-/unbounded-1.1.0.tgz", + "integrity": "sha512-kmPrjST7m53WbxoMqk6QUFvWOp/ZGssCA0Zls63pbt+7cZqST4i0YIVLNX97ZlsMv/ml+0CPBVN15sVdSi/yZA==", + "dev": true, + "requires": { + "editions": "^1.3.4" + } + }, + "unc-path-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", + "integrity": "sha1-5z3T17DXxe2G+6xrCufYxqadUPo=", + "dev": true + }, + "union-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz", + "integrity": "sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ=", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^0.4.3" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "set-value": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-0.4.3.tgz", + "integrity": "sha1-fbCPnT0i3H945Trzw79GZuzfzPE=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.1", + "to-object-path": "^0.3.0" + } + } + } + }, + "unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "dev": true, + "requires": { + "unique-slug": "^2.0.0" + } + }, + "unique-slug": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.1.tgz", + "integrity": "sha512-n9cU6+gITaVu7VGj1Z8feKMmfAjEAQGhwD9fE3zvpRRa0wEIx8ODYkVGfSc94M2OX00tUFV8wH3zYbm1I8mxFg==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4" + } + }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true + }, + "unix-crypt-td-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unix-crypt-td-js/-/unix-crypt-td-js-1.0.0.tgz", + "integrity": "sha1-HAgkFQSBvHoB1J6Y8exmjYJBLzs=", + "dev": true + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "dev": true + }, + "unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", + "dev": true, + "requires": { + "has-value": "^0.3.1", + "isobject": "^3.0.0" + }, + "dependencies": { + "has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", + "dev": true, + "requires": { + "get-value": "^2.0.3", + "has-values": "^0.1.4", + "isobject": "^2.0.0" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "dev": true, + "requires": { + "isarray": "1.0.0" + } + } + } + }, + "has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=", + "dev": true + } + } + }, + "upath": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.1.0.tgz", + "integrity": "sha512-bzpH/oBhoS/QI/YtbkqCg6VEiPYjSZtrHQM6/QnJS6OL9pKUFLqb3aFh4Scvwm45+7iAgiMkLhSbaZxUqmrprw==", + "dev": true + }, + "upper-case": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-1.1.3.tgz", + "integrity": "sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=", + "dev": true + }, + "uri-js": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-3.0.2.tgz", + "integrity": "sha1-+QuFhQf4HepNz7s8TD2/orVX+qo=", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", + "dev": true + }, + "url": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", + "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=", + "dev": true, + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + }, + "dependencies": { + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=", + "dev": true + } + } + }, + "url-loader": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-1.1.1.tgz", + "integrity": "sha512-vugEeXjyYFBCUOpX+ZuaunbK3QXMKaQ3zUnRfIpRBlGkY7QizCnzyyn2ASfcxsvyU3ef+CJppVywnl3Kgf13Gg==", + "dev": true, + "requires": { + "loader-utils": "^1.1.0", + "mime": "^2.0.3", + "schema-utils": "^1.0.0" + }, + "dependencies": { + "mime": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.3.1.tgz", + "integrity": "sha512-OEUllcVoydBHGN1z84yfQDimn58pZNNNXgZlHXSboxMlFvgI6MXSWpWKpFRra7H1HxpVhHTkrghfRW49k6yjeg==", + "dev": true + }, + "schema-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", + "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-errors": "^1.0.0", + "ajv-keywords": "^3.1.0" + } + } + } + }, + "url-parse": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.4.3.tgz", + "integrity": "sha512-rh+KuAW36YKo0vClhQzLLveoj8FwPJNu65xLb7Mrt+eZht0IPT0IXgSv8gcMegZ6NvjJUALf6Mf25POlMwD1Fw==", + "dev": true, + "requires": { + "querystringify": "^2.0.0", + "requires-port": "^1.0.0" + } + }, + "use": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/use/-/use-3.1.0.tgz", + "integrity": "sha512-6UJEQM/L+mzC3ZJNM56Q4DFGLX/evKGRg15UJHGB9X5j5Z3AFbgZvjUh2yq/UJUY4U5dh7Fal++XbNg1uzpRAw==", + "dev": true, + "requires": { + "kind-of": "^6.0.2" + } + }, + "useragent": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/useragent/-/useragent-2.2.1.tgz", + "integrity": "sha1-z1k+9PLRdYdei7ZY6pLhik/QbY4=", + "dev": true, + "requires": { + "lru-cache": "2.2.x", + "tmp": "0.0.x" + }, + "dependencies": { + "lru-cache": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.2.4.tgz", + "integrity": "sha1-bGWGGb7PFAMdDQtZSxYELOTcBj0=", + "dev": true + } + } + }, + "util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dev": true, + "requires": { + "inherits": "2.0.3" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "util.promisify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.0.tgz", + "integrity": "sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA==", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "object.getownpropertydescriptors": "^2.0.3" + } + }, + "utila": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", + "integrity": "sha1-ihagXURWV6Oupe7MWxKk+lN5dyw=", + "dev": true + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", + "dev": true + }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + }, + "validate-npm-package-license": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.3.tgz", + "integrity": "sha512-63ZOUnL4SIXj4L0NixR3L1lcjO38crAbgrTpl28t8jjrfuiOBL5Iygm+60qPs/KsZGzPNg6Smnc/oY16QTjF0g==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "validate-npm-package-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz", + "integrity": "sha1-X6kS2B630MdK/BQN5zF/DKffQ34=", + "dev": true, + "requires": { + "builtins": "^1.0.3" + } + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "dev": true + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "viz.js": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/viz.js/-/viz.js-1.8.2.tgz", + "integrity": "sha512-W+1+N/hdzLpQZEcvz79n2IgUE9pfx6JLdHh3Kh8RGvLL8P1LdJVQmi2OsDcLdY4QVID4OUy+FPelyerX0nJxIQ==", + "dev": true + }, + "vm-browserify": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz", + "integrity": "sha1-XX6kW7755Kb/ZflUOOCofDV9WnM=", + "dev": true, + "requires": { + "indexof": "0.0.1" + } + }, + "void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", + "integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=", + "dev": true + }, + "watchpack": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.6.0.tgz", + "integrity": "sha512-i6dHe3EyLjMmDlU1/bGQpEw25XSjkJULPuAVKCbNRefQVq48yXKUpwg538F7AZTf9kyr57zj++pQFltUa5H7yA==", + "dev": true, + "requires": { + "chokidar": "^2.0.2", + "graceful-fs": "^4.1.2", + "neo-async": "^2.5.0" + } + }, + "watchr": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/watchr/-/watchr-2.6.0.tgz", + "integrity": "sha1-51xCOxC+eSZ6DD73bi6hBP4CZ6U=", + "dev": true, + "requires": { + "eachr": "^3.2.0", + "extendr": "^3.2.2", + "extract-opts": "^3.3.1", + "ignorefs": "^1.1.1", + "safefs": "^4.1.0", + "scandirectory": "^2.5.0", + "taskgroup": "^5.0.1", + "typechecker": "^4.3.0" + } + }, + "wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, + "requires": { + "minimalistic-assert": "^1.0.0" + } + }, + "webassemblyjs": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/webassemblyjs/-/webassemblyjs-1.4.3.tgz", + "integrity": "sha512-4lOV1Lv6olz0PJkDGQEp82HempAn147e6BXijWDzz9g7/2nSebVP9GVg62Fz5ZAs55mxq13GA0XLyvY8XkyDjg==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.4.3", + "@webassemblyjs/validation": "1.4.3", + "@webassemblyjs/wasm-parser": "1.4.3", + "@webassemblyjs/wast-parser": "1.4.3", + "long": "^3.2.0" + } + }, + "webdriver-js-extender": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/webdriver-js-extender/-/webdriver-js-extender-2.1.0.tgz", + "integrity": "sha512-lcUKrjbBfCK6MNsh7xaY2UAUmZwe+/ib03AjVOpFobX4O7+83BUveSrLfU0Qsyb1DaKJdQRbuU+kM9aZ6QUhiQ==", + "dev": true, + "requires": { + "@types/selenium-webdriver": "^3.0.0", + "selenium-webdriver": "^3.0.1" + } + }, + "webpack": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.9.2.tgz", + "integrity": "sha512-jlWrCrJDU3sdWFprel6jHH8esN2C++Q8ehedRo74u7MWLTUJn9SD7RSgsCTEZCSRpVpMascDylAqPoldauOMfA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.4.3", + "@webassemblyjs/wasm-edit": "1.4.3", + "@webassemblyjs/wasm-parser": "1.4.3", + "acorn": "^5.0.0", + "acorn-dynamic-import": "^3.0.0", + "ajv": "^6.1.0", + "ajv-keywords": "^3.1.0", + "chrome-trace-event": "^0.1.1", + "enhanced-resolve": "^4.0.0", + "eslint-scope": "^3.7.1", + "json-parse-better-errors": "^1.0.2", + "loader-runner": "^2.3.0", + "loader-utils": "^1.1.0", + "memory-fs": "~0.4.1", + "micromatch": "^3.1.8", + "mkdirp": "~0.5.0", + "neo-async": "^2.5.0", + "node-libs-browser": "^2.0.0", + "schema-utils": "^0.4.4", + "tapable": "^1.0.0", + "uglifyjs-webpack-plugin": "^1.2.4", + "watchpack": "^1.5.0", + "webpack-sources": "^1.0.1" + } + }, + "webpack-core": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/webpack-core/-/webpack-core-0.6.9.tgz", + "integrity": "sha1-/FcViMhVjad76e+23r3Fo7FyvcI=", + "dev": true, + "requires": { + "source-list-map": "~0.1.7", + "source-map": "~0.4.1" + }, + "dependencies": { + "source-list-map": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-0.1.8.tgz", + "integrity": "sha1-xVCyq1Qn9rPyH1r+rYjE9Vh7IQY=", + "dev": true + }, + "source-map": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", + "dev": true, + "requires": { + "amdefine": ">=0.0.4" + } + } + } + }, + "webpack-dev-middleware": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-3.4.0.tgz", + "integrity": "sha512-Q9Iyc0X9dP9bAsYskAVJ/hmIZZQwf/3Sy4xCAZgL5cUkjZmUZLt4l5HpbST/Pdgjn3u6pE7u5OdGd1apgzRujA==", + "dev": true, + "requires": { + "memory-fs": "~0.4.1", + "mime": "^2.3.1", + "range-parser": "^1.0.3", + "webpack-log": "^2.0.0" + }, + "dependencies": { + "mime": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.3.1.tgz", + "integrity": "sha512-OEUllcVoydBHGN1z84yfQDimn58pZNNNXgZlHXSboxMlFvgI6MXSWpWKpFRra7H1HxpVhHTkrghfRW49k6yjeg==", + "dev": true + } + } + }, + "webpack-dev-server": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-3.1.9.tgz", + "integrity": "sha512-fqPkuNalLuc/hRC2QMkVYJkgNmRvxZQo7ykA2e1XRg/tMJm3qY7ZaD6d89/Fqjxtj9bOrn5wZzLD2n84lJdvWg==", + "dev": true, + "requires": { + "ansi-html": "0.0.7", + "bonjour": "^3.5.0", + "chokidar": "^2.0.0", + "compression": "^1.5.2", + "connect-history-api-fallback": "^1.3.0", + "debug": "^3.1.0", + "del": "^3.0.0", + "express": "^4.16.2", + "html-entities": "^1.2.0", + "http-proxy-middleware": "~0.18.0", + "import-local": "^2.0.0", + "internal-ip": "^3.0.1", + "ip": "^1.1.5", + "killable": "^1.0.0", + "loglevel": "^1.4.1", + "opn": "^5.1.0", + "portfinder": "^1.0.9", + "schema-utils": "^1.0.0", + "selfsigned": "^1.9.1", + "serve-index": "^1.7.2", + "sockjs": "0.3.19", + "sockjs-client": "1.1.5", + "spdy": "^3.4.1", + "strip-ansi": "^3.0.0", + "supports-color": "^5.1.0", + "webpack-dev-middleware": "3.4.0", + "webpack-log": "^2.0.0", + "yargs": "12.0.2" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "camelcase": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", + "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=", + "dev": true + }, + "cliui": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-4.1.0.tgz", + "integrity": "sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ==", + "dev": true, + "requires": { + "string-width": "^2.1.1", + "strip-ansi": "^4.0.0", + "wrap-ansi": "^2.0.0" + }, + "dependencies": { + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "decamelize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-2.0.0.tgz", + "integrity": "sha512-Ikpp5scV3MSYxY39ymh45ZLEecsTdv/Xj2CaQfI8RLMuwi7XvjX9H/fhraiSuU+C5w5NTDu4ZU72xNiZnurBPg==", + "dev": true, + "requires": { + "xregexp": "4.0.0" + } + }, + "del": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/del/-/del-3.0.0.tgz", + "integrity": "sha1-U+z2mf/LyzljdpGrE7rxYIGXZuU=", + "dev": true, + "requires": { + "globby": "^6.1.0", + "is-path-cwd": "^1.0.0", + "is-path-in-cwd": "^1.0.0", + "p-map": "^1.1.1", + "pify": "^3.0.0", + "rimraf": "^2.2.8" + } + }, + "execa": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-0.10.0.tgz", + "integrity": "sha512-7XOMnz8Ynx1gGo/3hyV9loYNPWM94jG3+3T3Y8tsfSstFmETmENCMU/A/zj8Lyaj1lkgEepKepvd6240tBRvlw==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^3.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "globby": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", + "integrity": "sha1-9abXDoOV4hyFj7BInWTfAkJNUGw=", + "dev": true, + "requires": { + "array-union": "^1.0.1", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "invert-kv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-2.0.0.tgz", + "integrity": "sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "lcid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz", + "integrity": "sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==", + "dev": true, + "requires": { + "invert-kv": "^2.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "mem": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-4.0.0.tgz", + "integrity": "sha512-WQxG/5xYc3tMbYLXoXPm81ET2WDULiU5FxbuIoNbJqLOOI8zehXFdZuiUEgfdrU2mVB1pxBZUGlYORSrpuJreA==", + "dev": true, + "requires": { + "map-age-cleaner": "^0.1.1", + "mimic-fn": "^1.0.0", + "p-is-promise": "^1.1.0" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + }, + "os-locale": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-3.0.1.tgz", + "integrity": "sha512-7g5e7dmXPtzcP4bgsZ8ixDVqA7oWYuEz4lOSujeWyliPai4gfVDiFIcwBg3aGCPnmSGfzOKTK3ccPn0CKv3DBw==", + "dev": true, + "requires": { + "execa": "^0.10.0", + "lcid": "^2.0.0", + "mem": "^4.0.0" + } + }, + "p-limit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.0.0.tgz", + "integrity": "sha512-fl5s52lI5ahKCernzzIyAP0QAZbGIovtVHGwpcu1Jr/EpzLVDI2myISHwGqK7m8uQFugVWSrbxH7XnhGtvEc+A==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.0.0.tgz", + "integrity": "sha512-hMp0onDKIajHfIkdRk3P4CdCmErkYAxxDtP3Wx/4nZ3aGlau2VKh3mZpcuFkH27WQkL/3WBCPOktzA9ZOAnMQQ==", + "dev": true + }, + "schema-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", + "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-errors": "^1.0.0", + "ajv-keywords": "^3.1.0" + } + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "dependencies": { + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "yargs": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-12.0.2.tgz", + "integrity": "sha512-e7SkEx6N6SIZ5c5H22RTZae61qtn3PYUE8JYbBFlK9sYmh3DMQ6E5ygtaG/2BW0JZi4WGgTR2IV5ChqlqrDGVQ==", + "dev": true, + "requires": { + "cliui": "^4.0.0", + "decamelize": "^2.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^1.0.1", + "os-locale": "^3.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1 || ^4.0.0", + "yargs-parser": "^10.1.0" + } + }, + "yargs-parser": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-10.1.0.tgz", + "integrity": "sha512-VCIyR1wJoEBZUqk5PA+oOBF6ypbwh5aNB3I50guxAL/quggdfs4TtNHQrSazFA3fYZ+tEqfs0zIGlv0c/rgjbQ==", + "dev": true, + "requires": { + "camelcase": "^4.1.0" + } + } + } + }, + "webpack-log": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/webpack-log/-/webpack-log-2.0.0.tgz", + "integrity": "sha512-cX8G2vR/85UYG59FgkoMamwHUIkSSlV3bBMRsbxVXVUk2j6NleCKjQ/WE9eYg9WY4w25O9w8wKP4rzNZFmUcUg==", + "dev": true, + "requires": { + "ansi-colors": "^3.0.0", + "uuid": "^3.3.2" + }, + "dependencies": { + "ansi-colors": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.0.6.tgz", + "integrity": "sha512-rY3B55KSBMMARmGXtzaG5o+kqnCrEF99rngBq5fV+cbwJepVGhDT8eB7UhSDwsJxNsMzSQDLQAyWmgi9pfzssQ==", + "dev": true + } + } + }, + "webpack-merge": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-4.1.4.tgz", + "integrity": "sha512-TmSe1HZKeOPey3oy1Ov2iS3guIZjWvMT2BBJDzzT5jScHTjVC3mpjJofgueEzaEd6ibhxRDD6MIblDr8tzh8iQ==", + "dev": true, + "requires": { + "lodash": "^4.17.5" + } + }, + "webpack-sources": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.3.0.tgz", + "integrity": "sha512-OiVgSrbGu7NEnEvQJJgdSFPl2qWKkWq5lHMhgiToIiN9w34EBnjYzSYs+VbL5KoYiLNtFFa7BZIKxRED3I32pA==", + "dev": true, + "requires": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "webpack-subresource-integrity": { + "version": "1.1.0-rc.6", + "resolved": "https://registry.npmjs.org/webpack-subresource-integrity/-/webpack-subresource-integrity-1.1.0-rc.6.tgz", + "integrity": "sha512-Az7y8xTniNhaA0620AV1KPwWOqawurVVDzQSpPAeR5RwNbL91GoBSJAAo9cfd+GiFHwsS5bbHepBw1e6Hzxy4w==", + "dev": true, + "requires": { + "webpack-core": "^0.6.8" + } + }, + "websocket-driver": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.0.tgz", + "integrity": "sha1-DK+dLXVdk67gSdS90NP+LMoqJOs=", + "dev": true, + "requires": { + "http-parser-js": ">=0.4.0", + "websocket-extensions": ">=0.1.1" + } + }, + "websocket-extensions": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.3.tgz", + "integrity": "sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg==", + "dev": true + }, + "when": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/when/-/when-3.6.4.tgz", + "integrity": "sha1-RztRfsFZ4rhQBUl6E5g/CVQS404=", + "dev": true + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz", + "integrity": "sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=", + "dev": true, + "optional": true + }, + "wide-align": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", + "dev": true, + "requires": { + "string-width": "^1.0.2 || 2" + } + }, + "win-release": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/win-release/-/win-release-1.1.1.tgz", + "integrity": "sha1-X6VeAr58qTTt/BJmVjLoSbcuUgk=", + "dev": true, + "requires": { + "semver": "^5.0.1" + } + }, + "window-size": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", + "integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0=", + "dev": true, + "optional": true + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + }, + "worker-farm": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/worker-farm/-/worker-farm-1.6.0.tgz", + "integrity": "sha512-6w+3tHbM87WnSWnENBUvA2pxJPLhQUg5LKwUQHq3r+XPhIM+Gh2R5ycbwPCyuGbNg+lPgdcnQUhuC02kJCvffQ==", + "dev": true, + "requires": { + "errno": "~0.1.7" + } + }, + "wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", + "dev": true, + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "ws": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-3.3.3.tgz", + "integrity": "sha512-nnWLa/NwZSt4KQJu51MYlCcSQ5g7INpOrOMt4XV8j4dqTXdmlUmSHQ8/oLC069ckre0fRsgfvsKwbTdtKLCDkA==", + "dev": true, + "requires": { + "async-limiter": "~1.0.0", + "safe-buffer": "~5.1.0", + "ultron": "~1.1.0" + } + }, + "xml2js": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", + "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", + "dev": true, + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~9.0.1" + }, + "dependencies": { + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true + } + } + }, + "xmlbuilder": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", + "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=", + "dev": true + }, + "xmlhttprequest-ssl": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz", + "integrity": "sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=", + "dev": true + }, + "xregexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.0.0.tgz", + "integrity": "sha512-PHyM+sQouu7xspQQwELlGwwd05mXUFqwFYfqPO0cC7x4fxyHnnuetmQr6CjJiafIDoH4MogHb9dOoJzR/Y4rFg==", + "dev": true + }, + "xtend": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=", + "dev": true + }, + "xxhashjs": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/xxhashjs/-/xxhashjs-0.2.2.tgz", + "integrity": "sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==", + "dev": true, + "requires": { + "cuint": "^0.2.2" + } + }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", + "dev": true + }, + "yargs": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", + "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=", + "dev": true, + "optional": true, + "requires": { + "camelcase": "^1.0.2", + "cliui": "^2.1.0", + "decamelize": "^1.0.0", + "window-size": "0.1.0" + } + }, + "yargs-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.0.tgz", + "integrity": "sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo=", + "dev": true, + "optional": true, + "requires": { + "camelcase": "^3.0.0" + }, + "dependencies": { + "camelcase": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", + "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=", + "dev": true, + "optional": true + } + } + }, + "yeast": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz", + "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk=", + "dev": true + }, + "yn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yn/-/yn-2.0.0.tgz", + "integrity": "sha1-5a2ryKz0CPY4X8dklWhMiOavaJo=", + "dev": true + }, + "zone.js": { + "version": "0.8.26", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.8.26.tgz", + "integrity": "sha512-W9Nj+UmBJG251wkCacIkETgra4QgBo/vgoEkb4a2uoLzpQG7qF9nzwoLXWU5xj3Fg2mxGvEDh47mg24vXccYjA==" + } + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 000000000..3015f7e34 --- /dev/null +++ b/client/package.json @@ -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" + } +} diff --git a/client/proxy.conf.json b/client/proxy.conf.json new file mode 100644 index 000000000..2373248d9 --- /dev/null +++ b/client/proxy.conf.json @@ -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 + } +} diff --git a/client/src/app/app-routing.module.spec.ts b/client/src/app/app-routing.module.spec.ts new file mode 100644 index 000000000..5d58963ae --- /dev/null +++ b/client/src/app/app-routing.module.spec.ts @@ -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(); + }); +}); diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts new file mode 100644 index 000000000..3ab3739b0 --- /dev/null +++ b/client/src/app/app-routing.module.ts @@ -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 {} diff --git a/client/src/app/app.component.html b/client/src/app/app.component.html new file mode 100644 index 000000000..8b7e40f66 --- /dev/null +++ b/client/src/app/app.component.html @@ -0,0 +1,3 @@ +
+ +
\ No newline at end of file diff --git a/client/src/app/app.component.scss b/client/src/app/app.component.scss new file mode 100644 index 000000000..687fbe074 --- /dev/null +++ b/client/src/app/app.component.scss @@ -0,0 +1,3 @@ +.content { + flex: 1; +} diff --git a/client/src/app/app.component.spec.ts b/client/src/app/app.component.spec.ts new file mode 100644 index 000000000..b5d3914cb --- /dev/null +++ b/client/src/app/app.component.spec.ts @@ -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(); + })); +}); diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts new file mode 100644 index 000000000..012d17361 --- /dev/null +++ b/client/src/app/app.component.ts @@ -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; + }; + } +} diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts new file mode 100644 index 000000000..d6b279638 --- /dev/null +++ b/client/src/app/app.module.ts @@ -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 { + 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 {} diff --git a/client/src/app/base.component.ts b/client/src/app/base.component.ts new file mode 100644 index 000000000..6f8746fca --- /dev/null +++ b/client/src/app/base.component.ts @@ -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); + } +} diff --git a/client/src/app/core/core.module.spec.ts b/client/src/app/core/core.module.spec.ts new file mode 100644 index 000000000..f85eee064 --- /dev/null +++ b/client/src/app/core/core.module.spec.ts @@ -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(); + }); +}); diff --git a/client/src/app/core/core.module.ts b/client/src/app/core/core.module.ts new file mode 100644 index 000000000..ba50e38cd --- /dev/null +++ b/client/src/app/core/core.module.ts @@ -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'); + } + } +} diff --git a/client/src/app/core/http-interceptor.ts b/client/src/app/core/http-interceptor.ts new file mode 100644 index 000000000..7c4e80f26 --- /dev/null +++ b/client/src/app/core/http-interceptor.ts @@ -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, next: HttpHandler): Observable> { + const clonedRequest = req.clone({ + withCredentials: true, + headers: req.headers.set('Content-Type', 'application/json') + }); + + return next.handle(clonedRequest); + } +} diff --git a/client/src/app/core/pruning-loader.ts b/client/src/app/core/pruning-loader.ts new file mode 100644 index 000000000..412a9d009 --- /dev/null +++ b/client/src/app/core/pruning-loader.ts @@ -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; + } +} diff --git a/client/src/app/core/services/app-load.service.spec.ts b/client/src/app/core/services/app-load.service.spec.ts new file mode 100644 index 000000000..9c620678f --- /dev/null +++ b/client/src/app/core/services/app-load.service.spec.ts @@ -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(); + })); +}); diff --git a/client/src/app/core/services/app-load.service.ts b/client/src/app/core/services/app-load.service.ts new file mode 100644 index 000000000..f0ef8a4c1 --- /dev/null +++ b/client/src/app/core/services/app-load.service.ts @@ -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 { + 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); + } + }); + } +} diff --git a/client/src/app/core/services/auth-guard.service.ts b/client/src/app/core/services/auth-guard.service.ts new file mode 100644 index 000000000..0127d7d82 --- /dev/null +++ b/client/src/app/core/services/auth-guard.service.ts @@ -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: ''}` or + * `data: {basePerm: ['', '']}` 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); + } +} diff --git a/client/src/app/core/services/auth.service.spec.ts b/client/src/app/core/services/auth.service.spec.ts new file mode 100644 index 000000000..ace8870fe --- /dev/null +++ b/client/src/app/core/services/auth.service.spec.ts @@ -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(); + })); +}); diff --git a/client/src/app/core/services/auth.service.ts b/client/src/app/core/services/auth.service.ts new file mode 100644 index 000000000..77fd75c70 --- /dev/null +++ b/client/src/app/core/services/auth.service.ts @@ -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 { + const user = { + username: username, + password: password + }; + return this.http.post(environment.urlPrefix + '/users/login/', user).pipe( + tap((response: LoginResponse) => { + this.operator.user = new User(response.user); + }), + catchError(this.handleError()) + ) as Observable; + } + + /** + * 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(environment.urlPrefix + '/users/logout/', {}).subscribe(() => { + this.OpenSlides.reboot(); + }); + } +} diff --git a/client/src/app/core/services/autoupdate.service.spec.ts b/client/src/app/core/services/autoupdate.service.spec.ts new file mode 100644 index 000000000..2b8de2be9 --- /dev/null +++ b/client/src/app/core/services/autoupdate.service.spec.ts @@ -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(); + })); +}); diff --git a/client/src/app/core/services/autoupdate.service.ts b/client/src/app/core/services/autoupdate.service.ts new file mode 100644 index 000000000..b88165f94 --- /dev/null +++ b/client/src/app/core/services/autoupdate.service.ts @@ -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('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); + } +} diff --git a/client/src/app/core/services/cache.service.spec.ts b/client/src/app/core/services/cache.service.spec.ts new file mode 100644 index 000000000..2c03bdba4 --- /dev/null +++ b/client/src/app/core/services/cache.service.spec.ts @@ -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(); + })); +}); diff --git a/client/src/app/core/services/cache.service.ts b/client/src/app/core/services/cache.service.ts new file mode 100644 index 000000000..93ac04fdd --- /dev/null +++ b/client/src/app/core/services/cache.service.ts @@ -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(key: string): Observable { + return this.localStorage.getItem(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); + } +} diff --git a/client/src/app/core/services/collectionStringModelMapper.service.spec.ts b/client/src/app/core/services/collectionStringModelMapper.service.spec.ts new file mode 100644 index 000000000..ae051e756 --- /dev/null +++ b/client/src/app/core/services/collectionStringModelMapper.service.spec.ts @@ -0,0 +1,3 @@ +describe('CollectionStringModelMapperService', () => { + beforeEach(() => {}); +}); diff --git a/client/src/app/core/services/collectionStringModelMapper.service.ts b/client/src/app/core/services/collectionStringModelMapper.service.ts new file mode 100644 index 000000000..0cea0511f --- /dev/null +++ b/client/src/app/core/services/collectionStringModelMapper.service.ts @@ -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 } = {}; + + /** + * 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): 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): 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 { + 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): string { + return Object.keys(CollectionStringModelMapperService.collectionStringsTypeMapping).find( + (collectionString: string) => { + return ctor === CollectionStringModelMapperService.collectionStringsTypeMapping[collectionString]; + } + ); + } +} diff --git a/client/src/app/core/services/config.service.spec.ts b/client/src/app/core/services/config.service.spec.ts new file mode 100644 index 000000000..90426ee20 --- /dev/null +++ b/client/src/app/core/services/config.service.spec.ts @@ -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(); + })); +}); diff --git a/client/src/app/core/services/config.service.ts b/client/src/app/core/services/config.service.ts new file mode 100644 index 000000000..b47b8de65 --- /dev/null +++ b/client/src/app/core/services/config.service.ts @@ -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 } = {}; + + /** + * 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('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 { + if (!this.configSubjects[key]) { + this.configSubjects[key] = new BehaviorSubject(this.instant(key)); + } + return this.configSubjects[key].asObservable(); + } +} diff --git a/client/src/app/core/services/constants.service.spec.ts b/client/src/app/core/services/constants.service.spec.ts new file mode 100644 index 000000000..fe64f02fb --- /dev/null +++ b/client/src/app/core/services/constants.service.spec.ts @@ -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(); + })); +}); diff --git a/client/src/app/core/services/constants.service.ts b/client/src/app/core/services/constants.service.ts new file mode 100644 index 000000000..6b08ba1c9 --- /dev/null +++ b/client/src/app/core/services/constants.service.ts @@ -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 } = {}; + + /** + * @param websocketService + */ + public constructor(private websocketService: WebsocketService) { + super(); + + // The hook for recieving constants. + websocketService.getOberservable('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 { + 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(); + } + return this.pendingSubject[key].asObservable(); + } + } +} diff --git a/client/src/app/core/services/data-send.service.spec.ts b/client/src/app/core/services/data-send.service.spec.ts new file mode 100644 index 000000000..ab16545ac --- /dev/null +++ b/client/src/app/core/services/data-send.service.spec.ts @@ -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(); + })); +}); diff --git a/client/src/app/core/services/data-send.service.ts b/client/src/app/core/services/data-send.service.ts new file mode 100644 index 000000000..fcfbbcbc6 --- /dev/null +++ b/client/src/app/core/services/data-send.service.ts @@ -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 { + return this.http.post('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 { + const restPath = `rest/${model.collectionString}/${model.id}`; + let httpMethod; + + if (method === 'patch') { + httpMethod = this.http.patch(restPath, model); + } else if (method === 'put') { + httpMethod = this.http.put(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 { + if (model.id) { + return this.http.delete('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'); + } + } +} diff --git a/client/src/app/core/services/data-store.service.spec.ts b/client/src/app/core/services/data-store.service.spec.ts new file mode 100644 index 000000000..b6b722b85 --- /dev/null +++ b/client/src/app/core/services/data-store.service.spec.ts @@ -0,0 +1,11 @@ +import { TestBed } from '@angular/core/testing'; + +import { DataStoreService } from './data-store.service'; + +describe('DS', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [DataStoreService] + }); + }); +}); diff --git a/client/src/app/core/services/data-store.service.ts b/client/src/app/core/services/data-store.service.ts new file mode 100644 index 000000000..408fe8a68 --- /dev/null +++ b/client/src/app/core/services/data-store.service.ts @@ -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 = new Subject(); + + /** + * Observe the datastore for changes. + * + * @return an observable for changed models + */ + public get changeObservable(): Observable { + return this.changedSubject.asObservable(); + } + + /** + * Observable subject for changed models in the datastore. + */ + private deletedSubject: Subject = new Subject(); + + /** + * Observe the datastore for deletions. + * + * @return an observable for deleted objects. + */ + public get deletedObservable(): Observable { + 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 { + // This promise will be resolved with the maximal change id of the cache. + return new Promise(resolve => { + this.cacheService.get(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(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>(collectionType: ModelConstructor | 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('core/countdown', 2) + */ + public get>(collectionType: ModelConstructor | string, id: number): T { + const collectionString = this.getCollectionString(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('users/user', [1,2,3,4,5]) + */ + public getMany>(collectionType: ModelConstructor | string, ids: number[]): T[] { + const collectionString = this.getCollectionString(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('users/user') + */ + public getAll>(collectionType: ModelConstructor | string): T[] { + const collectionString = this.getCollectionString(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, myUser => myUser.first_name === "Max") + */ + public filter>( + collectionType: ModelConstructor | string, + callback: (model: T) => boolean + ): T[] { + return this.getAll(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); + } +} diff --git a/client/src/app/core/services/login-data.service.spec.ts b/client/src/app/core/services/login-data.service.spec.ts new file mode 100644 index 000000000..7dcf55ab6 --- /dev/null +++ b/client/src/app/core/services/login-data.service.spec.ts @@ -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(); + })); +}); diff --git a/client/src/app/core/services/login-data.service.ts b/client/src/app/core/services/login-data.service.ts new file mode 100644 index 000000000..c8900840b --- /dev/null +++ b/client/src/app/core/services/login-data.service.ts @@ -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(''); + + /** + * Returns an observable for the privacy policy + */ + public get privacy_policy(): Observable { + return this._privacy_policy.asObservable(); + } + + /** + * Holds the legal notice + */ + private _legal_notice = new BehaviorSubject(''); + + /** + * Returns an observable for the legal notice + */ + public get legal_notice(): Observable { + 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); + } +} diff --git a/client/src/app/core/services/main-menu.service.spec.ts b/client/src/app/core/services/main-menu.service.spec.ts new file mode 100644 index 000000000..7b6ce8a2c --- /dev/null +++ b/client/src/app/core/services/main-menu.service.spec.ts @@ -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(); + })); +}); diff --git a/client/src/app/core/services/main-menu.service.ts b/client/src/app/core/services/main-menu.service.ts new file mode 100644 index 000000000..cd61bcbe1 --- /dev/null +++ b/client/src/app/core/services/main-menu.service.ts @@ -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); + } +} diff --git a/client/src/app/core/services/notify.service.spec.ts b/client/src/app/core/services/notify.service.spec.ts new file mode 100644 index 000000000..7c8250d01 --- /dev/null +++ b/client/src/app/core/services/notify.service.spec.ts @@ -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(); + })); +}); diff --git a/client/src/app/core/services/notify.service.ts b/client/src/app/core/services/notify.service.ts new file mode 100644 index 000000000..d3d715aa1 --- /dev/null +++ b/client/src/app/core/services/notify.service.ts @@ -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('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); + } +} diff --git a/client/src/app/core/services/openslides.service.spec.ts b/client/src/app/core/services/openslides.service.spec.ts new file mode 100644 index 000000000..972373b2f --- /dev/null +++ b/client/src/app/core/services/openslides.service.spec.ts @@ -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(); + })); +}); diff --git a/client/src/app/core/services/openslides.service.ts b/client/src/app/core/services/openslides.service.ts new file mode 100644 index 000000000..b71e2eaaa --- /dev/null +++ b/client/src/app/core/services/openslides.service.ts @@ -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('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(); + } + } + }); + } +} diff --git a/client/src/app/core/services/operator.service.spec.ts b/client/src/app/core/services/operator.service.spec.ts new file mode 100644 index 000000000..50c1757bf --- /dev/null +++ b/client/src/app/core/services/operator.service.spec.ts @@ -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(); + })); +}); diff --git a/client/src/app/core/services/operator.service.ts b/client/src/app/core/services/operator.service.ts new file mode 100644 index 000000000..ebc356184 --- /dev/null +++ b/client/src/app/core/services/operator.service.ts @@ -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 = new BehaviorSubject(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 { + return this.http.get(environment.urlPrefix + '/users/whoami/').pipe( + tap((response: WhoAmIResponse) => { + if (response && response.user_id) { + this.user = new User(response.user); + } + }), + catchError(this.handleError()) + ) as Observable; + } + + /** + * Returns the operatorSubject as an observable. + * + * Services an components can use it to get informed when something changes in + * the operator + */ + public getObservable(): Observable { + 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('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); + } +} diff --git a/client/src/app/core/services/prompt.service.spec.ts b/client/src/app/core/services/prompt.service.spec.ts new file mode 100644 index 000000000..a13720e8e --- /dev/null +++ b/client/src/app/core/services/prompt.service.spec.ts @@ -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(); + })); +}); diff --git a/client/src/app/core/services/prompt.service.ts b/client/src/app/core/services/prompt.service.ts new file mode 100644 index 000000000..4ea605d40 --- /dev/null +++ b/client/src/app/core/services/prompt.service.ts @@ -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 { + const dialogRef = this.dialog.open(PromptDialogComponent, { + width: '250px', + data: { title: title, content: content } + }); + + return dialogRef.afterClosed().toPromise(); + } +} diff --git a/client/src/app/core/services/viewport.service.spec.ts b/client/src/app/core/services/viewport.service.spec.ts new file mode 100644 index 000000000..9bf530451 --- /dev/null +++ b/client/src/app/core/services/viewport.service.spec.ts @@ -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(); + })); +}); diff --git a/client/src/app/core/services/viewport.service.ts b/client/src/app/core/services/viewport.service.ts new file mode 100644 index 000000000..cf7ad80b8 --- /dev/null +++ b/client/src/app/core/services/viewport.service.ts @@ -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 + *
Will only be shown of not mobile
+ * ``` + * 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; + } +} diff --git a/client/src/app/core/services/websocket.service.spec.ts b/client/src/app/core/services/websocket.service.spec.ts new file mode 100644 index 000000000..4bf7316e4 --- /dev/null +++ b/client/src/app/core/services/websocket.service.spec.ts @@ -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(); + })); +}); diff --git a/client/src/app/core/services/websocket.service.ts b/client/src/app/core/services/websocket.service.ts new file mode 100644 index 000000000..aee2dde51 --- /dev/null +++ b/client/src/app/core/services/websocket.service.ts @@ -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; + + /** + * Subjects that will be called, if a reconnect was successful. + */ + private _reconnectEvent: EventEmitter = new EventEmitter(); + + /** + * Getter for the reconnect event. + */ + public get reconnectEvent(): EventEmitter { + return this._reconnectEvent; + } + + /** + * Listeners will be nofitied, if the wesocket connection is establiched. + */ + private _connectEvent: EventEmitter = new EventEmitter(); + + /** + * Getter for the connect event. + */ + public get connectEvent(): EventEmitter { + 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 } = {}; + + /** + * 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(type: string): Observable { + if (!this.subjects[type]) { + this.subjects[type] = new Subject(); + } + 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(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://'; + } + } +} diff --git a/client/src/app/openslides.component.ts b/client/src/app/openslides.component.ts new file mode 100644 index 000000000..b2dfb4529 --- /dev/null +++ b/client/src/app/openslides.component.ts @@ -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(): (error: any) => Observable { + return (error: any): Observable => { + console.error(error); + return of(error); + }; + } +} diff --git a/client/src/app/projector-container/projector-container.component.css b/client/src/app/projector-container/projector-container.component.css new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/projector-container/projector-container.component.html b/client/src/app/projector-container/projector-container.component.html new file mode 100644 index 000000000..89674c3e5 --- /dev/null +++ b/client/src/app/projector-container/projector-container.component.html @@ -0,0 +1,4 @@ +

+ projector-container works! + Here an iframe with the real-projector is needed +

diff --git a/client/src/app/projector-container/projector-container.component.spec.ts b/client/src/app/projector-container/projector-container.component.spec.ts new file mode 100644 index 000000000..ce8a1256a --- /dev/null +++ b/client/src/app/projector-container/projector-container.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ProjectorContainerComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ProjectorContainerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/projector-container/projector-container.component.ts b/client/src/app/projector-container/projector-container.component.ts new file mode 100644 index 000000000..4e83019b3 --- /dev/null +++ b/client/src/app/projector-container/projector-container.component.ts @@ -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 {} +} diff --git a/client/src/app/projector-container/projector-container.module.spec.ts b/client/src/app/projector-container/projector-container.module.spec.ts new file mode 100644 index 000000000..8c33ae21c --- /dev/null +++ b/client/src/app/projector-container/projector-container.module.spec.ts @@ -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(); + }); +}); diff --git a/client/src/app/projector-container/projector-container.module.ts b/client/src/app/projector-container/projector-container.module.ts new file mode 100644 index 000000000..444277914 --- /dev/null +++ b/client/src/app/projector-container/projector-container.module.ts @@ -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 {} diff --git a/client/src/app/projector-container/projector/projector-container.routing.module.ts b/client/src/app/projector-container/projector/projector-container.routing.module.ts new file mode 100644 index 000000000..7d032420f --- /dev/null +++ b/client/src/app/projector-container/projector/projector-container.routing.module.ts @@ -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 {} diff --git a/client/src/app/projector-container/projector/projector.component.css b/client/src/app/projector-container/projector/projector.component.css new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/projector-container/projector/projector.component.html b/client/src/app/projector-container/projector/projector.component.html new file mode 100644 index 000000000..71ac28cd0 --- /dev/null +++ b/client/src/app/projector-container/projector/projector.component.html @@ -0,0 +1,3 @@ +

+ projector works! +

\ No newline at end of file diff --git a/client/src/app/projector-container/projector/projector.component.spec.ts b/client/src/app/projector-container/projector/projector.component.spec.ts new file mode 100644 index 000000000..5b878f9fd --- /dev/null +++ b/client/src/app/projector-container/projector/projector.component.spec.ts @@ -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; + + 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(); + }); +}); diff --git a/client/src/app/projector-container/projector/projector.component.ts b/client/src/app/projector-container/projector/projector.component.ts new file mode 100644 index 000000000..4555de083 --- /dev/null +++ b/client/src/app/projector-container/projector/projector.component.ts @@ -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'); + } +} diff --git a/client/src/app/shared/animations.ts b/client/src/app/shared/animations.ts new file mode 100644 index 000000000..fc40c68b8 --- /dev/null +++ b/client/src/app/shared/animations.ts @@ -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')]) +]); diff --git a/client/src/app/shared/components/footer/footer.component.html b/client/src/app/shared/components/footer/footer.component.html new file mode 100644 index 000000000..6341b5818 --- /dev/null +++ b/client/src/app/shared/components/footer/footer.component.html @@ -0,0 +1,12 @@ + + + + + ยฉ Copyright by OpenSlides + + + diff --git a/client/src/app/shared/components/footer/footer.component.scss b/client/src/app/shared/components/footer/footer.component.scss new file mode 100644 index 000000000..381793a75 --- /dev/null +++ b/client/src/app/shared/components/footer/footer.component.scss @@ -0,0 +1,11 @@ +.footer-link, +.footer-right { + font-size: 12px; + z-index: inherit; +} + +.footer-right { + a { + color: white; + } +} diff --git a/client/src/app/shared/components/footer/footer.component.spec.ts b/client/src/app/shared/components/footer/footer.component.spec.ts new file mode 100644 index 000000000..9c1ee2166 --- /dev/null +++ b/client/src/app/shared/components/footer/footer.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FooterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/footer/footer.component.ts b/client/src/app/shared/components/footer/footer.component.ts new file mode 100644 index 000000000..a2e0ff87b --- /dev/null +++ b/client/src/app/shared/components/footer/footer.component.ts @@ -0,0 +1,47 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +/** + * Reusable footer Apps. + * + * ## Examples: + * + * ### Usage of the selector: + * + * ```html + * + * ``` + */ +@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'; + } + } +} diff --git a/client/src/app/shared/components/head-bar/head-bar.component.html b/client/src/app/shared/components/head-bar/head-bar.component.html new file mode 100644 index 000000000..a90aea36f --- /dev/null +++ b/client/src/app/shared/components/head-bar/head-bar.component.html @@ -0,0 +1,27 @@ + + + + + {{ appName | translate }} + + + + + + + + + + + + + + diff --git a/client/src/app/shared/components/head-bar/head-bar.component.scss b/client/src/app/shared/components/head-bar/head-bar.component.scss new file mode 100644 index 000000000..f1b01bb55 --- /dev/null +++ b/client/src/app/shared/components/head-bar/head-bar.component.scss @@ -0,0 +1,4 @@ +.head-button { + bottom: -30px; + z-index: 100; +} diff --git a/client/src/app/shared/components/head-bar/head-bar.component.spec.ts b/client/src/app/shared/components/head-bar/head-bar.component.spec.ts new file mode 100644 index 000000000..e86cea296 --- /dev/null +++ b/client/src/app/shared/components/head-bar/head-bar.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HeadBarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/head-bar/head-bar.component.ts b/client/src/app/shared/components/head-bar/head-bar.component.ts new file mode 100644 index 000000000..3cbe54cac --- /dev/null +++ b/client/src/app/shared/components/head-bar/head-bar.component.ts @@ -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 + * + * + * ``` + * + * ### 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(); + + /** + * Emit a signal to the parent of an item in the menuList was selected. + */ + @Output() + public ellipsisMenuItem = new EventEmitter(); + + /** + * 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); + } +} diff --git a/client/src/app/shared/components/legal-notice-content/legal-notice-content.component.html b/client/src/app/shared/components/legal-notice-content/legal-notice-content.component.html new file mode 100644 index 000000000..0195e7320 --- /dev/null +++ b/client/src/app/shared/components/legal-notice-content/legal-notice-content.component.html @@ -0,0 +1,24 @@ + +
+ + +
+ + OpenSlides {{ versionInfo.openslides_version }} + + (License: {{ versionInfo.openslides_license }}) + +
+
Installed plugins
: +
+ + {{ plugin.verbose_name }} {{ plugin.version }} + +
+ (License: {{ plugin.license }}) +
+
+
+
+
+
diff --git a/client/src/app/shared/components/legal-notice-content/legal-notice-content.component.scss b/client/src/app/shared/components/legal-notice-content/legal-notice-content.component.scss new file mode 100644 index 000000000..f42a956f5 --- /dev/null +++ b/client/src/app/shared/components/legal-notice-content/legal-notice-content.component.scss @@ -0,0 +1,9 @@ +.legal-notice-text { + display: block; + padding-bottom: 20px; +} + +.version-text { + display: block; + padding-top: 20px; +} diff --git a/client/src/app/shared/components/legal-notice-content/legal-notice-content.component.spec.ts b/client/src/app/shared/components/legal-notice-content/legal-notice-content.component.spec.ts new file mode 100644 index 000000000..b2a10a9a3 --- /dev/null +++ b/client/src/app/shared/components/legal-notice-content/legal-notice-content.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LegalNoticeContentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/legal-notice-content/legal-notice-content.component.ts b/client/src/app/shared/components/legal-notice-content/legal-notice-content.component.ts new file mode 100644 index 000000000..39e0cd738 --- /dev/null +++ b/client/src/app/shared/components/legal-notice-content/legal-notice-content.component.ts @@ -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(environment.urlPrefix + '/core/version/', {}) + .subscribe((info: VersionResponse) => { + this.versionInfo = info; + }); + } +} diff --git a/client/src/app/shared/components/privacy-policy-content/privacy-policy-content.component.html b/client/src/app/shared/components/privacy-policy-content/privacy-policy-content.component.html new file mode 100644 index 000000000..d8ac6470b --- /dev/null +++ b/client/src/app/shared/components/privacy-policy-content/privacy-policy-content.component.html @@ -0,0 +1,6 @@ + +
+
+ The event manager hasn't set up a privacy policy yet. +
+
diff --git a/client/src/app/shared/components/privacy-policy-content/privacy-policy-content.component.scss b/client/src/app/shared/components/privacy-policy-content/privacy-policy-content.component.scss new file mode 100644 index 000000000..7caed3739 --- /dev/null +++ b/client/src/app/shared/components/privacy-policy-content/privacy-policy-content.component.scss @@ -0,0 +1,3 @@ +mat-card { + height: 100%; +} diff --git a/client/src/app/shared/components/privacy-policy-content/privacy-policy-content.component.spec.ts b/client/src/app/shared/components/privacy-policy-content/privacy-policy-content.component.spec.ts new file mode 100644 index 000000000..a98f71297 --- /dev/null +++ b/client/src/app/shared/components/privacy-policy-content/privacy-policy-content.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PrivacyPolicyContentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/privacy-policy-content/privacy-policy-content.component.ts b/client/src/app/shared/components/privacy-policy-content/privacy-policy-content.component.ts new file mode 100644 index 000000000..effb17627 --- /dev/null +++ b/client/src/app/shared/components/privacy-policy-content/privacy-policy-content.component.ts @@ -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); + } + }); + } +} diff --git a/client/src/app/shared/components/prompt-dialog/prompt-dialog.component.html b/client/src/app/shared/components/prompt-dialog/prompt-dialog.component.html new file mode 100644 index 000000000..0c7be3b64 --- /dev/null +++ b/client/src/app/shared/components/prompt-dialog/prompt-dialog.component.html @@ -0,0 +1,6 @@ +

{{ data.title | translate }}

+{{ data.content | translate }} + + + + diff --git a/client/src/app/shared/components/prompt-dialog/prompt-dialog.component.spec.ts b/client/src/app/shared/components/prompt-dialog/prompt-dialog.component.spec.ts new file mode 100644 index 000000000..5555c0e08 --- /dev/null +++ b/client/src/app/shared/components/prompt-dialog/prompt-dialog.component.spec.ts @@ -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; + + 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(); + });*/ +}); diff --git a/client/src/app/shared/components/prompt-dialog/prompt-dialog.component.ts b/client/src/app/shared/components/prompt-dialog/prompt-dialog.component.ts new file mode 100644 index 000000000..6b5369668 --- /dev/null +++ b/client/src/app/shared/components/prompt-dialog/prompt-dialog.component.ts @@ -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, + @Inject(MAT_DIALOG_DATA) public data: PromptDialogData + ) {} +} diff --git a/client/src/app/shared/components/search-value-selector/search-value-selector.component.html b/client/src/app/shared/components/search-value-selector/search-value-selector.component.html new file mode 100644 index 000000000..eb2a1711f --- /dev/null +++ b/client/src/app/shared/components/search-value-selector/search-value-selector.component.html @@ -0,0 +1,22 @@ + + + +
+ None + +
+ + {{selectedItem.getTitle(translate)}} + +
+
+
+

+ Selected Values: +

+ + {{selectedItem.name}} + cancel + + +
diff --git a/client/src/app/shared/components/search-value-selector/search-value-selector.component.scss b/client/src/app/shared/components/search-value-selector/search-value-selector.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/shared/components/search-value-selector/search-value-selector.component.spec.ts b/client/src/app/shared/components/search-value-selector/search-value-selector.component.spec.ts new file mode 100644 index 000000000..b53f37067 --- /dev/null +++ b/client/src/app/shared/components/search-value-selector/search-value-selector.component.spec.ts @@ -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: '' + }) + class TestHostComponent { + @ViewChild(SearchValueSelectorComponent) + public searchValueSelectorComponent: SearchValueSelectorComponent; + } + + let hostComponent: TestHostComponent; + let hostFixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [TestHostComponent] + }).compileComponents(); + })); + + beforeEach(() => { + hostFixture = TestBed.createComponent(TestHostComponent); + hostComponent = hostFixture.componentInstance; + }); + + it('should create', () => { + const subject: BehaviorSubject = new BehaviorSubject([]); + hostComponent.searchValueSelectorComponent.InputListValues = subject; + + const formBuilder: FormBuilder = TestBed.get(FormBuilder); + const formGroup = formBuilder.group({ + testArray: [] + }); + hostComponent.searchValueSelectorComponent.form = formGroup; + hostComponent.searchValueSelectorComponent.formControl = formGroup.get('testArray'); + + hostFixture.detectChanges(); + expect(hostComponent.searchValueSelectorComponent).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/search-value-selector/search-value-selector.component.ts b/client/src/app/shared/components/search-value-selector/search-value-selector.component.ts new file mode 100644 index 000000000..308320644 --- /dev/null +++ b/client/src/app/shared/components/search-value-selector/search-value-selector.component.ts @@ -0,0 +1,170 @@ +import { Component, OnInit, Input, ViewChild } from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; +import { Subject, ReplaySubject, BehaviorSubject } from 'rxjs'; +import { MatSelect } from '@angular/material'; +import { takeUntil } from 'rxjs/operators'; +import { Displayable } from '../../models/base/displayable'; +import { TranslateService } from '@ngx-translate/core'; +import { Identifiable } from '../../models/base/identifiable'; + +export type Selectable = Displayable & Identifiable; + +/** + * Reusable Searchable Value Selector + * + * Use `multiple="true"`, `[InputListValues]=myValues`,`[formControl]="myformcontrol"`, `[form]="myform_name"` and `placeholder={{listname}}` to pass the Values and Listname + * + * ## Examples: + * + * ### Usage of the selector: + * + * ngDefaultControl: https://stackoverflow.com/a/39053470 + * + * ```html + * + * + * ``` + * + */ + +@Component({ + selector: 'os-search-value-selector', + templateUrl: './search-value-selector.component.html', + styleUrls: ['./search-value-selector.component.scss'] +}) +export class SearchValueSelectorComponent implements OnInit { + /** + * ngModel variable - Deprecated with Angular 7 + * DO NOT USE: READ AT remove() FUNCTION! + */ + public myModel = []; + + /** + * Control for the filtering of the list + */ + public filterControl = new FormControl(); + + /** + * List of the filtered content, when entering something in the search bar + */ + public filteredItems: ReplaySubject = new ReplaySubject(1); + + /** + * Decide if this should be a single or multi-select-field + */ + @Input() + public multiple: boolean; + + /** + * The Input List Values + */ + @Input() + public InputListValues: BehaviorSubject; + + /** + * Placeholder of the List + */ + @Input() + public listname: String; + + /** + * Form Group + */ + @Input() + public form: FormGroup; + + /** + * Name of the Form + */ + @Input() + public formControl: FormControl; + + /** + * DO NOT USE UNTIL BUG IN UPSTREAM ARE RESOLVED! + * READ AT FUNCTION remove() + * + * Displayes the selected Items as Chip-List + */ + // @Input() + public dispSelected = false; + + /** + * The MultiSelect Component + */ + @ViewChild('thisSelector') + public thisSelector: MatSelect; + + /** + * Subject that emits when the component has been destroyed + */ + private _onDestroy = new Subject(); + + /** + * Empty constructor + */ + public constructor(public translate: TranslateService) {} + + /** + * onInit with filter ans subscription on filter + */ + public ngOnInit(): void { + this.filteredItems.next(this.InputListValues.getValue()); + // listen to value changes + this.filterControl.valueChanges.pipe(takeUntil(this._onDestroy)).subscribe(() => { + this.filterItems(); + }); + } + + /** + * the filter function itself + */ + private filterItems(): void { + if (!this.InputListValues) { + return; + } + // get the search keyword + let search = this.filterControl.value; + if (!search) { + this.filteredItems.next(this.InputListValues.getValue()); + return; + } else { + search = search.toLowerCase(); + } + // filter the values + this.filteredItems.next( + this.InputListValues.getValue().filter( + selectedItem => + selectedItem + .toString() + .toLowerCase() + .indexOf(search) > -1 + ) + ); + } + + /** + * If the dispSelected value is marked as true, a chipList should be shown below the + * selection list. Unfortunately it is not possible (yet) to change the datamodel in the backend + * https://github.com/angular/material2/issues/10085 - therefore you can display the values in two + * places, but can't reflect the changes in both places. Until this can be done this will be unused code + * @param item the selected item to be removed + */ + public remove(item: Selectable): void { + const myArr = this.thisSelector.value; + const index = myArr.indexOf(item, 0); + // my model was the form according to fix + // https://github.com/angular/material2/issues/10044 + // but this causes bad behaviour and will be depricated in Angular 7 + this.myModel = this.myModel.slice(index, 1); + if (index > -1) { + myArr.splice(index, 1); + } + this.thisSelector.value = myArr; + } +} diff --git a/client/src/app/shared/date-adapter.ts b/client/src/app/shared/date-adapter.ts new file mode 100644 index 000000000..bcda23226 --- /dev/null +++ b/client/src/app/shared/date-adapter.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import { NativeDateAdapter } from '@angular/material'; + +/** + * A custom DateAdapter for the datetimepicker in the config. This is still not fully working and needs to be done later. + * See comments in PR #3895. + */ +@Injectable() +export class OpenSlidesDateAdapter extends NativeDateAdapter { + public format(date: Date, displayFormat: Object): string { + if (displayFormat === 'input') { + return this.toFullIso8601(date); + } else { + return date.toDateString(); + } + } + + private to2digit(n: number): string { + return ('00' + n).slice(-2); + } + + public toFullIso8601(date: Date): string { + return ( + [date.getUTCFullYear(), this.to2digit(date.getUTCMonth() + 1), this.to2digit(date.getUTCDate())].join('-') + + 'T' + + [this.to2digit(date.getUTCHours()), this.to2digit(date.getUTCMinutes())].join(':') + ); + } +} diff --git a/client/src/app/shared/directives/dom-change.directive.spec.ts b/client/src/app/shared/directives/dom-change.directive.spec.ts new file mode 100644 index 000000000..bca371005 --- /dev/null +++ b/client/src/app/shared/directives/dom-change.directive.spec.ts @@ -0,0 +1,6 @@ +describe('DomChangeDirective', () => { + it('should create an instance', () => { + // const directive = new DomChangeDirective(); + // expect(directive).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/directives/dom-change.directive.ts b/client/src/app/shared/directives/dom-change.directive.ts new file mode 100644 index 000000000..60938ff74 --- /dev/null +++ b/client/src/app/shared/directives/dom-change.directive.ts @@ -0,0 +1,33 @@ +import { Directive, Output, EventEmitter, ElementRef, OnDestroy } from '@angular/core'; + +/** + * detects changes in DOM and emits a signal on changes. + * + * @example (appDomChange)="onChange($event)" + */ +@Directive({ + selector: '[osDomChange]' +}) +export class DomChangeDirective implements OnDestroy { + private changes: MutationObserver; + + @Output() public domChange = new EventEmitter(); + + public constructor(private elementRef: ElementRef) { + const element = this.elementRef.nativeElement; + + this.changes = new MutationObserver((mutations: MutationRecord[]) => { + mutations.forEach((mutation: MutationRecord) => this.domChange.emit(mutation)); + }); + + this.changes.observe(element, { + attributes: true, + childList: true, + characterData: true + }); + } + + public ngOnDestroy(): void { + this.changes.disconnect(); + } +} diff --git a/client/src/app/shared/directives/perms.directive.spec.ts b/client/src/app/shared/directives/perms.directive.spec.ts new file mode 100644 index 000000000..80363f8ed --- /dev/null +++ b/client/src/app/shared/directives/perms.directive.spec.ts @@ -0,0 +1,6 @@ +describe('PermsDirective', () => { + it('should create an instance', () => { + // const directive = new OsPermsDirective(); + // expect(directive).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/directives/perms.directive.ts b/client/src/app/shared/directives/perms.directive.ts new file mode 100644 index 000000000..866b05785 --- /dev/null +++ b/client/src/app/shared/directives/perms.directive.ts @@ -0,0 +1,113 @@ +import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core'; + +import { OperatorService, Permission } from 'app/core/services/operator.service'; +import { OpenSlidesComponent } from 'app/openslides.component'; + +/** + * Directive to check if the {@link OperatorService} has the correct permissions to access certain functions + * + * Successor of os-perms in OpenSlides 2.2 + * @example
... < /div> + * @example
... < /div> + */ +@Directive({ + selector: '[osPerms]' +}) +export class PermsDirective extends OpenSlidesComponent { + /** + * Holds the required permissions the access a feature + */ + private permissions: Permission[] = []; + + /** + * Holds the value of the last permission check. Therefore one can check, if the + * permission has changes, to save unnecessary view updates, if not. + */ + private lastPermissionCheckResult = false; + + /** + * Alternative to the permissions. Used in special case where a combination + * with *ngIf would be required. + * + * # Example: + * + * The div will render if the permission `user.can_manage` is set + * or if `this.ownPage` is `true` + * ```html + *
something
+ * ``` + */ + private alternative: boolean; + + /** + * Constructs the directive once. Observes the operator for it's groups so the + * directive can perform changes dynamically + * + * @param template inner part of the HTML container + * @param viewContainer outer part of the HTML container (for example a `
`) + * @param operator OperatorService + */ + public constructor( + private template: TemplateRef, + private viewContainer: ViewContainerRef, + private operator: OperatorService + ) { + super(); + + // observe groups of operator, so the directive can actively react to changes + this.operator.getObservable().subscribe(content => { + this.updateView(); + }); + } + + /** + * Comes directly from the view. + * The value defines the requires permissions as an array or a single permission. + */ + @Input() + public set osPerms(value: string | string[]) { + if (!value) { + value = []; + } else if (typeof value === 'string') { + value = [value]; + } + this.permissions = value; + this.updateView(); + } + + /** + * Comes from the view. + * `;or:` turns into osPermsOr during runtime. + */ + @Input('osPermsOr') + public set osPermsAlt(value: boolean) { + this.alternative = value; + this.updateView(); + } + + /** + * Shows or hides certain content in the view. + */ + private updateView(): void { + const hasPerms = this.checkPermissions(); + const permsChanged = hasPerms !== this.lastPermissionCheckResult; + + if ((hasPerms && permsChanged) || this.alternative) { + // clean up and add the template + this.viewContainer.clear(); + this.viewContainer.createEmbeddedView(this.template); + } else if (!hasPerms) { + // will remove the content of the container + this.viewContainer.clear(); + } + this.lastPermissionCheckResult = hasPerms; + } + + /** + * Compare the required permissions with the users permissions. + * Returns true if the users permissions fit. + */ + private checkPermissions(): boolean { + return this.permissions.length === 0 || this.operator.hasPerms(...this.permissions); + } +} diff --git a/client/src/app/shared/models/agenda/item.ts b/client/src/app/shared/models/agenda/item.ts new file mode 100644 index 000000000..1cc600885 --- /dev/null +++ b/client/src/app/shared/models/agenda/item.ts @@ -0,0 +1,58 @@ +import { ProjectableBaseModel } from '../base/projectable-base-model'; +import { Speaker } from './speaker'; + +/** + * The representation of the content object for agenda items. The unique combination + * of the collection and id is given. + */ +interface ContentObject { + id: number; + collection: string; +} + +/** + * Representations of agenda Item + * @ignore + */ +export class Item extends ProjectableBaseModel { + public id: number; + public item_number: string; + public title: string; + public title_with_type: string; + public comment: string; + public closed: boolean; + public type: number; + public is_hidden: boolean; + public duration: number; + public speakers: Speaker[]; + public speaker_list_closed: boolean; + public content_object: ContentObject; + public weight: number; + public parent_id: number; + + public constructor(input?: any) { + super('agenda/item', input); + } + + public deserialize(input: any): void { + Object.assign(this, input); + + if (input.speakers instanceof Array) { + this.speakers = input.speakers.map(speakerData => { + return new Speaker(speakerData); + }); + } + } + + public getTitle(): string { + return this.title; + } + + public getListTitle(): string { + return this.title_with_type; + } + + public getProjectorTitle(): string { + return this.getListTitle(); + } +} diff --git a/client/src/app/shared/models/agenda/speaker.ts b/client/src/app/shared/models/agenda/speaker.ts new file mode 100644 index 000000000..0348a3e1d --- /dev/null +++ b/client/src/app/shared/models/agenda/speaker.ts @@ -0,0 +1,25 @@ +import { Deserializer } from '../base/deserializer'; + +/** + * Representation of a speaker in an agenda item + * + * Part of the 'speakers' list. + * @ignore + */ +export class Speaker extends Deserializer { + public id: number; + public user_id: number; + public begin_time: string; // TODO this is a time object + public end_time: string; // TODO this is a time object + public weight: number; + public marked: boolean; + public item_id: number; + + /** + * Needs to be completely optional because agenda has (yet) the optional parameter 'speaker' + * @param input + */ + public constructor(input?: any) { + super(input); + } +} diff --git a/client/src/app/shared/models/assignments/assignment-user.ts b/client/src/app/shared/models/assignments/assignment-user.ts new file mode 100644 index 000000000..f2904552a --- /dev/null +++ b/client/src/app/shared/models/assignments/assignment-user.ts @@ -0,0 +1,21 @@ +import { Deserializer } from '../base/deserializer'; + +/** + * Content of the 'assignment_related_users' property + * @ignore + */ +export class AssignmentUser extends Deserializer { + public id: number; + public user_id: number; + public elected: boolean; + public assignment_id: number; + public weight: number; + + /** + * Needs to be completely optional because assignment has (yet) the optional parameter 'assignment_related_users' + * @param input + */ + public constructor(input?: any) { + super(input); + } +} diff --git a/client/src/app/shared/models/assignments/assignment.ts b/client/src/app/shared/models/assignments/assignment.ts new file mode 100644 index 000000000..40931e2b6 --- /dev/null +++ b/client/src/app/shared/models/assignments/assignment.ts @@ -0,0 +1,58 @@ +import { AssignmentUser } from './assignment-user'; +import { Poll } from './poll'; +import { AgendaBaseModel } from '../base/agenda-base-model'; + +/** + * Representation of an assignment. + * @ignore + */ +export class Assignment extends AgendaBaseModel { + public id: number; + public title: string; + public description: string; + public open_posts: number; + public phase: number; + public assignment_related_users: AssignmentUser[]; + public poll_description_default: number; + public polls: Poll[]; + public agenda_item_id: number; + public tags_id: number[]; + + public constructor(input?: any) { + super('assignments/assignment', 'Assignment', input); + } + + public get candidateIds(): number[] { + return this.assignment_related_users + .sort((a: AssignmentUser, b: AssignmentUser) => { + return a.weight - b.weight; + }) + .map((candidate: AssignmentUser) => candidate.user_id); + } + + public deserialize(input: any): void { + Object.assign(this, input); + + this.assignment_related_users = []; + if (input.assignment_related_users instanceof Array) { + input.assignment_related_users.forEach(assignmentUserData => { + this.assignment_related_users.push(new AssignmentUser(assignmentUserData)); + }); + } + + this.polls = []; + if (input.polls instanceof Array) { + input.polls.forEach(pollData => { + this.polls.push(new Poll(pollData)); + }); + } + } + + public getTitle(): string { + return this.title; + } + + public getDetailStateURL(): string { + return 'TODO'; + } +} diff --git a/client/src/app/shared/models/assignments/poll-option.ts b/client/src/app/shared/models/assignments/poll-option.ts new file mode 100644 index 000000000..ddbc21cf7 --- /dev/null +++ b/client/src/app/shared/models/assignments/poll-option.ts @@ -0,0 +1,24 @@ +import { Deserializer } from '../base/deserializer'; + +/** + * Representation of a poll option + * + * part of the 'polls-options'-array in poll + * @ignore + */ +export class PollOption extends Deserializer { + public id: number; + public candidate_id: number; + public is_elected: boolean; + public votes: number[]; + public poll_id: number; + public weight: number; + + /** + * Needs to be completely optional because poll has (yet) the optional parameter 'poll-options' + * @param input + */ + public constructor(input?: any) { + super(input); + } +} diff --git a/client/src/app/shared/models/assignments/poll.ts b/client/src/app/shared/models/assignments/poll.ts new file mode 100644 index 000000000..9ec0ab57b --- /dev/null +++ b/client/src/app/shared/models/assignments/poll.ts @@ -0,0 +1,38 @@ +import { PollOption } from './poll-option'; +import { Deserializer } from '../base/deserializer'; + +/** + * Content of the 'polls' property of assignments + * @ignore + */ +export class Poll extends Deserializer { + public id: number; + public pollmethod: string; + public description: string; + public published: boolean; + public options: PollOption[]; + public votesvalid: number; + public votesinvalid: number; + public votescast: number; + public has_votes: boolean; + public assignment_id: number; + + /** + * Needs to be completely optional because assignment has (yet) the optional parameter 'polls' + * @param input + */ + public constructor(input?: any) { + super(input); + } + + public deserialize(input: any): void { + Object.assign(this, input); + + this.options = []; + if (input.options instanceof Array) { + input.options.forEach(pollOptionData => { + this.options.push(new PollOption(pollOptionData)); + }); + } + } +} diff --git a/client/src/app/shared/models/base/agenda-base-model.ts b/client/src/app/shared/models/base/agenda-base-model.ts new file mode 100644 index 000000000..755a7bfa7 --- /dev/null +++ b/client/src/app/shared/models/base/agenda-base-model.ts @@ -0,0 +1,37 @@ +import { AgendaInformation } from './agenda-information'; +import { ProjectableBaseModel } from './projectable-base-model'; + +/** + * A base model for models, that can be content objects in the agenda. Provides title and navigation + * information for the agenda. + */ +export abstract class AgendaBaseModel extends ProjectableBaseModel implements AgendaInformation { + protected verboseName: string; + + /** + * A Model that inherits from this class should provide a verbose name. It's used by creating + * the agenda title with type. + * @param collectionString + * @param verboseName + * @param input + */ + protected constructor(collectionString: string, verboseName: string, input?: any) { + super(collectionString, input); + this.verboseName = verboseName; + } + + public getAgendaTitle(): string { + return this.getTitle(); + } + + public getAgendaTitleWithType(): string { + // Return the agenda title with the model's verbose name appended + return this.getAgendaTitle() + ' (' + this.verboseName + ')'; + } + + /** + * Should return the URL to the detail view. Used for the agenda, that the + * user can navigate to the content object. + */ + public abstract getDetailStateURL(): string; +} diff --git a/client/src/app/shared/models/base/agenda-information.ts b/client/src/app/shared/models/base/agenda-information.ts new file mode 100644 index 000000000..832f1bd45 --- /dev/null +++ b/client/src/app/shared/models/base/agenda-information.ts @@ -0,0 +1,19 @@ +/** + * An Interface for all extra information needed for content objects of items. + */ +export interface AgendaInformation { + /** + * Should return the title for the agenda list view. + */ + getAgendaTitle(): string; + + /** + * Should return the title for the list of speakers view. + */ + getAgendaTitleWithType(): string; + + /** + * Get the url for the detail view, so in the agenda the user can navigate to it. + */ + getDetailStateURL(): string; +} diff --git a/client/src/app/shared/models/base/base-model.ts b/client/src/app/shared/models/base/base-model.ts new file mode 100644 index 000000000..26d90f84d --- /dev/null +++ b/client/src/app/shared/models/base/base-model.ts @@ -0,0 +1,87 @@ +import { OpenSlidesComponent } from 'app/openslides.component'; +import { Deserializable } from './deserializable'; +import { Displayable } from './displayable'; +import { Identifiable } from './identifiable'; + +export interface ModelConstructor> { + new (...args: any[]): T; +} + +/** + * Abstract parent class to set rules and functions for all models. + * When inherit from this class, give the subclass as the type. E.g. `class Motion extends BaseModel` + */ +export abstract class BaseModel extends OpenSlidesComponent + implements Deserializable, Displayable, Identifiable { + /** + * force children of BaseModel to have a collectionString. + * + * Has a getter but no setter. + */ + protected _collectionString: string; + + /** + * force children of BaseModel to have an id + */ + public abstract id: number; + + /** + * constructor that calls super from parent class + */ + protected constructor(collectionString: string, input?: any) { + super(); + this._collectionString = collectionString; + + if (input) { + this.changeNullValuesToUndef(input); + this.deserialize(input); + } + } + + /** + * Prevent to send literally "null" if should be send + * @param input object to deserialize + */ + public changeNullValuesToUndef(input: any): void { + Object.keys(input).forEach(key => { + if (input[key] === null) { + input[key] = undefined; + } + }); + } + + /** + * update the values of the base model with new values + */ + public patchValues(update: Partial): void { + Object.assign(this, update); + } + + public abstract getTitle(): string; + + public getListTitle(): string { + return this.getTitle(); + } + + public toString(): string { + return this.getTitle(); + } + + /** + * returns the collectionString. + * + * The server and the dataStore use it to identify the collection. + */ + public get collectionString(): string { + return this._collectionString; + } + + /** + * Most simple and most commonly used deserialize function. + * Inherited to children, can be overwritten for special use cases + * @param input JSON data for deserialization. + */ + public deserialize(input: any): void { + Object.assign(this, input); + } +} diff --git a/client/src/app/shared/models/base/deserializable.ts b/client/src/app/shared/models/base/deserializable.ts new file mode 100644 index 000000000..503d8ad5c --- /dev/null +++ b/client/src/app/shared/models/base/deserializable.ts @@ -0,0 +1,17 @@ +/** + * Interface tells models to offer a 'deserialize' function + * + * Also nested objects and arrays have have to be handled. + * @example + * ``` ts + * const myUser = new User(); + * myUser.deserialize(jsonData); + * ``` + */ +export interface Deserializable { + /** + * should be used to assign JSON values to the object itself. + * @param input + */ + deserialize(input: object): void; +} diff --git a/client/src/app/shared/models/base/deserializer.ts b/client/src/app/shared/models/base/deserializer.ts new file mode 100644 index 000000000..a21b40373 --- /dev/null +++ b/client/src/app/shared/models/base/deserializer.ts @@ -0,0 +1,38 @@ +import { Deserializable } from './deserializable'; + +/** + * Abstract base class for a basic implementation of Deserializable. + * The constructor also gives the possibility to give data that should be serialized. + */ +export abstract class Deserializer implements Deserializable { + /** + * Basic constructor with the possibility to give data to deserialize. + * @param input If data is given, {@method deserialize} will be called with that data + */ + protected constructor(input?: any) { + if (input) { + this.changeNullValuesToUndef(input); + this.deserialize(input); + } + } + + /** + * should be used to assign JSON values to the object itself. + * @param input + */ + public deserialize(input: any): void { + Object.assign(this, input); + } + + /** + * Prevent to send literally "null" if should be send + * @param input object to deserialize + */ + public changeNullValuesToUndef(input: any): void { + Object.keys(input).forEach(key => { + if (input[key] === null) { + input[key] = undefined; + } + }); + } +} diff --git a/client/src/app/shared/models/base/displayable.ts b/client/src/app/shared/models/base/displayable.ts new file mode 100644 index 000000000..9be35f9f4 --- /dev/null +++ b/client/src/app/shared/models/base/displayable.ts @@ -0,0 +1,14 @@ +/** + * Every displayble object should have the given functions to give the object's title. + */ +export interface Displayable { + /** + * Should return the title. Alway used except for list view, the agenda and in the projector. + */ + getTitle(): string; + + /** + * Should return the title for the list view. + */ + getListTitle(): string; +} diff --git a/client/src/app/shared/models/base/identifiable.ts b/client/src/app/shared/models/base/identifiable.ts new file mode 100644 index 000000000..962b8184f --- /dev/null +++ b/client/src/app/shared/models/base/identifiable.ts @@ -0,0 +1,9 @@ +/** + * Every object implementing this interface has an id. + */ +export interface Identifiable { + /** + * The objects id. + */ + id: number; +} diff --git a/client/src/app/shared/models/base/projectable-base-model.ts b/client/src/app/shared/models/base/projectable-base-model.ts new file mode 100644 index 000000000..afc57c66e --- /dev/null +++ b/client/src/app/shared/models/base/projectable-base-model.ts @@ -0,0 +1,17 @@ +import { BaseModel } from './base-model'; +import { Projectable } from './projectable'; + +export abstract class ProjectableBaseModel extends BaseModel implements Projectable { + protected constructor(collectionString: string, input?: any) { + super(collectionString, input); + } + + /** + * This is a Dummy, which should be changed if the projector gets implemented. + */ + public project(): void {} + + public getProjectorTitle(): string { + return this.getTitle(); + } +} diff --git a/client/src/app/shared/models/base/projectable.ts b/client/src/app/shared/models/base/projectable.ts new file mode 100644 index 000000000..d2f002f76 --- /dev/null +++ b/client/src/app/shared/models/base/projectable.ts @@ -0,0 +1,14 @@ +/** + * Interface for every model, that should be projectable. + */ +export interface Projectable { + /** + * Should return the title for the projector. + */ + getProjectorTitle(): string; + + /** + * Dummy. I don't know how the projctor system will be, so this function may change + */ + project(): void; +} diff --git a/client/src/app/shared/models/core/chat-message.ts b/client/src/app/shared/models/core/chat-message.ts new file mode 100644 index 000000000..c18835264 --- /dev/null +++ b/client/src/app/shared/models/core/chat-message.ts @@ -0,0 +1,20 @@ +import { BaseModel } from '../base/base-model'; + +/** + * Representation of chat messages. + * @ignore + */ +export class ChatMessage extends BaseModel { + public id: number; + public message: string; + public timestamp: string; // TODO: Type for timestamp + public user_id: number; + + public constructor(input?: any) { + super('core/chat-message', input); + } + + public getTitle(): string { + return 'Chatmessage'; + } +} diff --git a/client/src/app/shared/models/core/config.ts b/client/src/app/shared/models/core/config.ts new file mode 100644 index 000000000..402235585 --- /dev/null +++ b/client/src/app/shared/models/core/config.ts @@ -0,0 +1,19 @@ +import { BaseModel } from '../base/base-model'; + +/** + * Representation of a config variable + * @ignore + */ +export class Config extends BaseModel { + public id: number; + public key: string; + public value: Object; + + public constructor(input?: any) { + super('core/config', input); + } + + public getTitle(): string { + return this.key; + } +} diff --git a/client/src/app/shared/models/core/countdown.ts b/client/src/app/shared/models/core/countdown.ts new file mode 100644 index 000000000..309ebedee --- /dev/null +++ b/client/src/app/shared/models/core/countdown.ts @@ -0,0 +1,21 @@ +import { ProjectableBaseModel } from '../base/projectable-base-model'; + +/** + * Representation of a countdown + * @ignore + */ +export class Countdown extends ProjectableBaseModel { + public id: number; + public description: string; + public default_time: number; + public countdown_time: number; + public running: boolean; + + public constructor(input?: any) { + super('core/countdown'); + } + + public getTitle(): string { + return this.description; + } +} diff --git a/client/src/app/shared/models/core/projector-message.ts b/client/src/app/shared/models/core/projector-message.ts new file mode 100644 index 000000000..d1d4f7208 --- /dev/null +++ b/client/src/app/shared/models/core/projector-message.ts @@ -0,0 +1,18 @@ +import { ProjectableBaseModel } from '../base/projectable-base-model'; + +/** + * Representation of a projector message. + * @ignore + */ +export class ProjectorMessage extends ProjectableBaseModel { + public id: number; + public message: string; + + public constructor(input?: any) { + super('core/projector-message', input); + } + + public getTitle(): string { + return 'Projectormessage'; + } +} diff --git a/client/src/app/shared/models/core/projector.ts b/client/src/app/shared/models/core/projector.ts new file mode 100644 index 000000000..e3686278a --- /dev/null +++ b/client/src/app/shared/models/core/projector.ts @@ -0,0 +1,25 @@ +import { BaseModel } from '../base/base-model'; + +/** + * Representation of a projector. Has the nested property "projectiondefaults" + * @ignore + */ +export class Projector extends BaseModel { + public id: number; + public elements: Object; + public scale: number; + public scroll: number; + public name: string; + public blank: boolean; + public width: number; + public height: number; + public projectiondefaults: Object[]; + + public constructor(input?: any) { + super('core/projector', input); + } + + public getTitle(): string { + return this.name; + } +} diff --git a/client/src/app/shared/models/core/tag.ts b/client/src/app/shared/models/core/tag.ts new file mode 100644 index 000000000..79b00d027 --- /dev/null +++ b/client/src/app/shared/models/core/tag.ts @@ -0,0 +1,18 @@ +import { BaseModel } from '../base/base-model'; + +/** + * Representation of a tag. + * @ignore + */ +export class Tag extends BaseModel { + public id: number; + public name: string; + + public constructor(input?: any) { + super('core/tag', input); + } + + public getTitle(): string { + return this.name; + } +} diff --git a/client/src/app/shared/models/mediafiles/file.ts b/client/src/app/shared/models/mediafiles/file.ts new file mode 100644 index 000000000..e9a7f812b --- /dev/null +++ b/client/src/app/shared/models/mediafiles/file.ts @@ -0,0 +1,19 @@ +import { Deserializer } from '../base/deserializer'; + +/** + * The name and the type of a mediaFile. + * @ignore + */ +export class File extends Deserializer { + public name: string; + public type: string; + + /** + * Needs to be fully optional, because the 'mediafile'-property in the mediaFile class is optional as well + * @param name The name of the file + * @param type The tape (jpg, png, pdf) + */ + public constructor(input?: any) { + super(input); + } +} diff --git a/client/src/app/shared/models/mediafiles/mediafile.ts b/client/src/app/shared/models/mediafiles/mediafile.ts new file mode 100644 index 000000000..846cac425 --- /dev/null +++ b/client/src/app/shared/models/mediafiles/mediafile.ts @@ -0,0 +1,30 @@ +import { File } from './file'; +import { ProjectableBaseModel } from '../base/projectable-base-model'; + +/** + * Representation of MediaFile. Has the nested property "File" + * @ignore + */ +export class Mediafile extends ProjectableBaseModel { + public id: number; + public title: string; + public mediafile: File; + public media_url_prefix: string; + public uploader_id: number; + public filesize: string; + public hidden: boolean; + public timestamp: string; + + public constructor(input?: any) { + super('mediafiles/mediafile', input); + } + + public deserialize(input: any): void { + Object.assign(this, input); + this.mediafile = new File(input.mediafile); + } + + public getTitle(): string { + return this.title; + } +} diff --git a/client/src/app/shared/models/motions/category.ts b/client/src/app/shared/models/motions/category.ts new file mode 100644 index 000000000..ffbaeaef3 --- /dev/null +++ b/client/src/app/shared/models/motions/category.ts @@ -0,0 +1,19 @@ +import { BaseModel } from '../base/base-model'; + +/** + * Representation of a motion category. Has the nested property "File" + * @ignore + */ +export class Category extends BaseModel { + public id: number; + public name: string; + public prefix: string; + + public constructor(input?: any) { + super('motions/category', input); + } + + public getTitle(): string { + return this.prefix + ' - ' + this.name; + } +} diff --git a/client/src/app/shared/models/motions/motion-block.ts b/client/src/app/shared/models/motions/motion-block.ts new file mode 100644 index 000000000..d63010638 --- /dev/null +++ b/client/src/app/shared/models/motions/motion-block.ts @@ -0,0 +1,23 @@ +import { AgendaBaseModel } from '../base/agenda-base-model'; + +/** + * Representation of a motion block. + * @ignore + */ +export class MotionBlock extends AgendaBaseModel { + public id: number; + public title: string; + public agenda_item_id: number; + + public constructor(input?: any) { + super('motions/motion-block', 'Motion block', input); + } + + public getTitle(): string { + return this.title; + } + + public getDetailStateURL(): string { + return 'TODO'; + } +} diff --git a/client/src/app/shared/models/motions/motion-change-reco.ts b/client/src/app/shared/models/motions/motion-change-reco.ts new file mode 100644 index 000000000..24035b643 --- /dev/null +++ b/client/src/app/shared/models/motions/motion-change-reco.ts @@ -0,0 +1,25 @@ +import { BaseModel } from '../base/base-model'; + +/** + * Representation of a motion change recommendation. + * @ignore + */ +export class MotionChangeReco extends BaseModel { + public id: number; + public motion_version_id: number; + public rejected: boolean; + public type: number; + public other_description: string; + public line_from: number; + public line_to: number; + public text: string; + public creation_time: string; + + public constructor(input?: any) { + super('motions/motion-change-recommendation', input); + } + + public getTitle(): string { + return 'Changerecommendation'; + } +} diff --git a/client/src/app/shared/models/motions/motion-comment-section.ts b/client/src/app/shared/models/motions/motion-comment-section.ts new file mode 100644 index 000000000..7b38f191f --- /dev/null +++ b/client/src/app/shared/models/motions/motion-comment-section.ts @@ -0,0 +1,20 @@ +import { BaseModel } from '../base/base-model'; + +/** + * Representation of a motion category. Has the nested property "File" + * @ignore + */ +export class MotionCommentSection extends BaseModel { + public id: number; + public name: string; + public read_groups_id: number[]; + public write_groups_id: number[]; + + public constructor(input?: any) { + super('motions/motion-comment-section', input); + } + + public getTitle(): string { + return this.name; + } +} diff --git a/client/src/app/shared/models/motions/motion-comment.ts b/client/src/app/shared/models/motions/motion-comment.ts new file mode 100644 index 000000000..69c1379ee --- /dev/null +++ b/client/src/app/shared/models/motions/motion-comment.ts @@ -0,0 +1,15 @@ +import { Deserializer } from '../base/deserializer'; + +/** + * Representation of a Motion Comment. + */ +export class MotionComment extends Deserializer { + public id: number; + public comment: string; + public section_id: number; + public read_groups_id: number[]; + + public constructor(input?: any) { + super(input); + } +} diff --git a/client/src/app/shared/models/motions/motion-log.ts b/client/src/app/shared/models/motions/motion-log.ts new file mode 100644 index 000000000..1dc1fd15d --- /dev/null +++ b/client/src/app/shared/models/motions/motion-log.ts @@ -0,0 +1,17 @@ +import { Deserializer } from '../base/deserializer'; + +/** + * Representation of a Motion Log. + * + * @ignore + */ +export class MotionLog extends Deserializer { + public message_list: string[]; + public person_id: number; + public time: string; + public message: string; + + public constructor(input?: any) { + super(input); + } +} diff --git a/client/src/app/shared/models/motions/motion-submitter.ts b/client/src/app/shared/models/motions/motion-submitter.ts new file mode 100644 index 000000000..d5bb9e8ac --- /dev/null +++ b/client/src/app/shared/models/motions/motion-submitter.ts @@ -0,0 +1,27 @@ +import { Deserializer } from '../base/deserializer'; +import { User } from '../users/user'; + +/** + * Representation of a Motion Submitter. + * + * @ignore + */ +export class MotionSubmitter extends Deserializer { + public id: number; + public user_id: number; + public motion_id: number; + public weight: number; + + public constructor(input?: any, motion_id?: number, weight?: number) { + super(); + this.id = input.id; + if (input instanceof User) { + const user_obj = input as User; + this.user_id = user_obj.id; + this.motion_id = motion_id; + this.weight = weight; + } else { + this.deserialize(input); + } + } +} diff --git a/client/src/app/shared/models/motions/motion.ts b/client/src/app/shared/models/motions/motion.ts new file mode 100644 index 000000000..d16643837 --- /dev/null +++ b/client/src/app/shared/models/motions/motion.ts @@ -0,0 +1,101 @@ +import { MotionSubmitter } from './motion-submitter'; +import { MotionLog } from './motion-log'; +import { MotionComment } from './motion-comment'; +import { AgendaBaseModel } from '../base/agenda-base-model'; + +/** + * Representation of Motion. + * + * Slightly Defined cause heavy maintaince on server side. + * + * @ignore + */ +export class Motion extends AgendaBaseModel { + public id: number; + public identifier: string; + public title: string; + public text: string; + public reason: string; + public amendment_paragraphs: string[]; + public modified_final_version: string; + public parent_id: number; + public category_id: number; + public motion_block_id: number; + public origin: string; + public submitters: MotionSubmitter[]; + public submitters_id: number[]; + public supporters_id: number[]; + public comments: MotionComment[]; + public workflow_id: number; + public state_id: number; + public state_extension: string; + public state_required_permission_to_see: string; + public recommendation_id: number; + public recommendation_extension: string; + public tags_id: number[]; + public attachments_id: number[]; + public polls: Object[]; + public agenda_item_id: number; + public log_messages: MotionLog[]; + public weight: number; + public sort_parent_id: number; + + public constructor(input?: any) { + super('motions/motion', 'Motion', input); + } + + /** + * returns the motion submitters userIDs + */ + public get submitterIds(): number[] { + return this.submitters + .sort((a: MotionSubmitter, b: MotionSubmitter) => { + return a.weight - b.weight; + }) + .map((submitter: MotionSubmitter) => submitter.user_id); + } + + public getTitle(): string { + return this.title; + } + + public getAgendaTitle(): string { + // if the identifier is set, the title will be 'Motion '. + if (this.identifier) { + return 'Motion ' + this.identifier; + } else { + return this.getTitle(); + } + } + + public getAgendaTitleWithType(): string { + // Append the verbose name only, if not the special format 'Motion ' is used. + if (this.identifier) { + return 'Motion ' + this.identifier; + } else { + return this.getTitle() + ' (' + this.verboseName + ')'; + } + } + + public getDetailStateURL(): string { + return `/motions/${this.id}`; + } + + public deserialize(input: any): void { + Object.assign(this, input); + + this.log_messages = []; + if (input.log_messages instanceof Array) { + input.log_messages.forEach(logData => { + this.log_messages.push(new MotionLog(logData)); + }); + } + + this.comments = []; + if (input.comments instanceof Array) { + input.comments.forEach(commentData => { + this.comments.push(new MotionComment(commentData)); + }); + } + } +} diff --git a/client/src/app/shared/models/motions/statute-paragraph.ts b/client/src/app/shared/models/motions/statute-paragraph.ts new file mode 100644 index 000000000..7fbb814a9 --- /dev/null +++ b/client/src/app/shared/models/motions/statute-paragraph.ts @@ -0,0 +1,20 @@ +import { BaseModel } from '../base/base-model'; + +/** + * Representation of a statute paragraph. + * @ignore + */ +export class StatuteParagraph extends BaseModel { + public id: number; + public title: string; + public text: string; + public weight: number; + + public constructor(input?: any) { + super('motions/statute-paragraph', input); + } + + public getTitle(): string { + return this.title; + } +} diff --git a/client/src/app/shared/models/motions/workflow-state.ts b/client/src/app/shared/models/motions/workflow-state.ts new file mode 100644 index 000000000..75e9c5d13 --- /dev/null +++ b/client/src/app/shared/models/motions/workflow-state.ts @@ -0,0 +1,51 @@ +import { Deserializer } from '../base/deserializer'; +import { Workflow } from './workflow'; + +/** + * Representation of a workflow state + * + * Part of the 'states'-array in motion/workflow + * @ignore + */ +export class WorkflowState extends Deserializer { + public id: number; + public name: string; + public action_word: string; + public recommendation_label: string; + public css_class: string; + public required_permission_to_see: string; + public allow_support: boolean; + public allow_create_poll: boolean; + public allow_submitter_edit: boolean; + public dont_set_identifier: boolean; + public show_state_extension_field: boolean; + public show_recommendation_extension_field: boolean; + public next_states_id: number[]; + public workflow_id: number; + + /** + * Needs to be completely optional because Workflow has (yet) the optional parameter 'states' + * @param input If given, it will be deserialized + */ + public constructor(input?: any) { + super(input); + } + + /** + * return a list of the next possible states. + * Also adds the current state. + */ + public getNextStates(workflow: Workflow): WorkflowState[] { + const nextStates = []; + workflow.states.forEach(state => { + if (this.next_states_id.includes(state.id)) { + nextStates.push(state as WorkflowState); + } + }); + return nextStates; + } + + public toString = (): string => { + return this.name; + }; +} diff --git a/client/src/app/shared/models/motions/workflow.ts b/client/src/app/shared/models/motions/workflow.ts new file mode 100644 index 000000000..866563fd9 --- /dev/null +++ b/client/src/app/shared/models/motions/workflow.ts @@ -0,0 +1,60 @@ +import { BaseModel } from '../base/base-model'; +import { WorkflowState } from './workflow-state'; + +/** + * Representation of a motion workflow. Has the nested property 'states' + * @ignore + */ +export class Workflow extends BaseModel { + public id: number; + public name: string; + public states: WorkflowState[]; + public first_state: number; + + public constructor(input?: any) { + super('motions/workflow', input); + } + + /** + * Check if the containing @link{WorkflowState}s contain a given ID + * @param id The State ID + */ + public isStateContained(obj: number | WorkflowState): boolean { + let id: number; + if (obj instanceof WorkflowState) { + id = obj.id; + } else { + id = obj; + } + + return this.states.some(state => { + if (state.id === id) { + return true; + } + }); + } + + public getStateById(id: number): WorkflowState { + let targetState; + this.states.forEach(state => { + if (id === state.id) { + targetState = state; + } + }); + return targetState as WorkflowState; + } + + public deserialize(input: any): void { + Object.assign(this, input); + if (input.states instanceof Array) { + this.states = []; + input.states.forEach(workflowStateData => { + this.states.push(new WorkflowState(workflowStateData)); + }); + } + } + + public getTitle(): string { + return this.name; + } +} diff --git a/client/src/app/shared/models/topics/topic.ts b/client/src/app/shared/models/topics/topic.ts new file mode 100644 index 000000000..cf4255cf8 --- /dev/null +++ b/client/src/app/shared/models/topics/topic.ts @@ -0,0 +1,30 @@ +import { AgendaBaseModel } from '../base/agenda-base-model'; + +/** + * Representation of a topic. + * @ignore + */ +export class Topic extends AgendaBaseModel { + public id: number; + public title: string; + public text: string; + public attachments_id: number[]; + public agenda_item_id: number; + + public constructor(input?: any) { + super('topics/topic', 'Topic', input); + } + + public getTitle(): string { + return this.title; + } + + public getAgendaTitleWithType(): string { + // Do not append ' (Topic)' to the title. + return this.getAgendaTitle(); + } + + public getDetailStateURL(): string { + return 'TODO'; + } +} diff --git a/client/src/app/shared/models/users/group.ts b/client/src/app/shared/models/users/group.ts new file mode 100644 index 000000000..14b790ab5 --- /dev/null +++ b/client/src/app/shared/models/users/group.ts @@ -0,0 +1,23 @@ +import { BaseModel } from '../base/base-model'; + +/** + * Representation of user group. + * @ignore + */ +export class Group extends BaseModel { + public id: number; + public name: string; + public permissions: string[]; + + public constructor(input?: any) { + super('users/group', input); + if (!input) { + // permissions are required for new groups + this.permissions = []; + } + } + + public getTitle(): string { + return this.name; + } +} diff --git a/client/src/app/shared/models/users/personal-note.ts b/client/src/app/shared/models/users/personal-note.ts new file mode 100644 index 000000000..bb229fced --- /dev/null +++ b/client/src/app/shared/models/users/personal-note.ts @@ -0,0 +1,19 @@ +import { BaseModel } from '../base/base-model'; + +/** + * Representation of users personal note. + * @ignore + */ +export class PersonalNote extends BaseModel { + public id: number; + public user_id: number; + public notes: Object; + + public constructor(input: any) { + super('users/personal-note', input); + } + + public getTitle(): string { + return 'Personal note'; + } +} diff --git a/client/src/app/shared/models/users/user.ts b/client/src/app/shared/models/users/user.ts new file mode 100644 index 000000000..11eed4b02 --- /dev/null +++ b/client/src/app/shared/models/users/user.ts @@ -0,0 +1,91 @@ +import { ProjectableBaseModel } from '../base/projectable-base-model'; + +/** + * Representation of a user in contrast to the operator. + * @ignore + */ +export class User extends ProjectableBaseModel { + public id: number; + public username: string; + public title: string; + public first_name: string; + public last_name: string; + public structure_level: string; + public number: string; + public about_me: string; + public groups_id: number[]; + public is_present: boolean; + public is_committee: boolean; + public email: string; + public last_email_send?: string; + public comment: string; + public is_active: boolean; + public default_password: string; + + public constructor(input?: any) { + super('users/user', input); + } + + public get full_name(): string { + let name = this.short_name; + const addition: string[] = []; + + // addition: add number and structure level + const structure_level = this.structure_level.trim(); + if (structure_level) { + addition.push(structure_level); + } + + const number = this.number.trim(); + if (number) { + // TODO Translate + addition.push('No.' + ' ' + number); + } + + if (addition.length > 0) { + name += ' (' + addition.join(' ยท ') + ')'; + } + return name.trim(); + } + + public containsGroupId(id: number): boolean { + return this.groups_id.some(groupId => groupId === id); + } + + // TODO read config values for "users_sort_by" + public get short_name(): string { + const title = this.title.trim(); + const firstName = this.first_name.trim(); + const lastName = this.last_name.trim(); + let shortName = ''; + + // TODO need DS adjustment first first + // if (this.DS.getConfig('users_sort_by').value === 'last_name') { + // if (lastName && firstName) { + // shortName += `${lastName}, ${firstName}`; + // } else { + // shortName += lastName || firstName; + // } + // } + + shortName += `${firstName} ${lastName}`; + + if (shortName.trim() === '') { + shortName = this.username; + } + + if (title) { + shortName = `${title} ${shortName}`; + } + + return shortName.trim(); + } + + public getTitle(): string { + return this.full_name; + } + + public getListViewTitle(): string { + return this.short_name; + } +} diff --git a/client/src/app/shared/parent-error-state-matcher.ts b/client/src/app/shared/parent-error-state-matcher.ts new file mode 100644 index 000000000..c1cd9e0eb --- /dev/null +++ b/client/src/app/shared/parent-error-state-matcher.ts @@ -0,0 +1,22 @@ +import { ErrorStateMatcher } from '@angular/material'; +import { FormControl, FormGroupDirective, NgForm } from '@angular/forms'; + +/** + * Custom state matcher for mat-errors. Enables the error for an input, if one has set the error + * with `setError` on the parent element. + */ +export class ParentErrorStateMatcher implements ErrorStateMatcher { + public isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const isSubmitted = !!(form && form.submitted); + const controlTouched = !!(control && (control.dirty || control.touched)); + const controlInvalid = !!(control && control.invalid); + const parentInvalid = !!( + control && + control.parent && + control.parent.invalid && + (control.parent.dirty || control.parent.touched) + ); + + return (isSubmitted || controlTouched) && (controlInvalid || parentInvalid); + } +} diff --git a/client/src/app/shared/shared.module.spec.ts b/client/src/app/shared/shared.module.spec.ts new file mode 100644 index 000000000..471db0a9d --- /dev/null +++ b/client/src/app/shared/shared.module.spec.ts @@ -0,0 +1,13 @@ +import { SharedModule } from './shared.module'; + +describe('SharedModule', () => { + let sharedModule: SharedModule; + + beforeEach(() => { + sharedModule = new SharedModule(); + }); + + it('should create an instance', () => { + expect(sharedModule).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts new file mode 100644 index 000000000..c7d3e0a2e --- /dev/null +++ b/client/src/app/shared/shared.module.ts @@ -0,0 +1,143 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +// MaterialUI modules +import { + MatButtonModule, + MatCheckboxModule, + MatToolbarModule, + MatCardModule, + MatInputModule, + MatProgressSpinnerModule, + MatSidenavModule, + MatSnackBarModule, + MatTableModule, + MatPaginatorModule, + MatSortModule, + MatTooltipModule, + MatDatepickerModule, + MatNativeDateModule, + DateAdapter, + MatIconModule +} from '@angular/material'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatChipsModule } from '@angular/material'; +import { NgxMatSelectSearchModule } from 'ngx-mat-select-search'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatListModule } from '@angular/material/list'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; + +// ngx-translate +import { TranslateModule } from '@ngx-translate/core'; + +// directives +import { PermsDirective } from './directives/perms.directive'; +import { DomChangeDirective } from './directives/dom-change.directive'; + +// components +import { HeadBarComponent } from './components/head-bar/head-bar.component'; +import { FooterComponent } from './components/footer/footer.component'; +import { LegalNoticeContentComponent } from './components/legal-notice-content/legal-notice-content.component'; +import { PrivacyPolicyContentComponent } from './components/privacy-policy-content/privacy-policy-content.component'; +import { SearchValueSelectorComponent } from './components/search-value-selector/search-value-selector.component'; +import { OpenSlidesDateAdapter } from './date-adapter'; +import { PromptDialogComponent } from './components/prompt-dialog/prompt-dialog.component'; + +/** + * Share Module for all "dumb" components and pipes. + * + * These components don not import and inject services from core or other features + * in their constructors. + * + * Should receive all data though attributes in the template of the component using them. + * No dependency to the rest of our application. + */ +@NgModule({ + imports: [ + CommonModule, + FormsModule, + MatFormFieldModule, + MatSelectModule, + ReactiveFormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCheckboxModule, + MatToolbarModule, + MatDatepickerModule, + MatNativeDateModule, + MatCardModule, + MatInputModule, + MatTableModule, + MatSortModule, + MatPaginatorModule, + MatProgressSpinnerModule, + MatSidenavModule, + MatListModule, + MatExpansionModule, + MatMenuModule, + MatDialogModule, + MatSnackBarModule, + MatChipsModule, + MatTooltipModule, + // TODO: there is an error with missing icons + // we either wait or include a fixed version manually (dirty) + // https://github.com/google/material-design-icons/issues/786 + MatIconModule, + TranslateModule.forChild(), + RouterModule, + NgxMatSelectSearchModule + ], + exports: [ + FormsModule, + MatAutocompleteModule, + MatFormFieldModule, + MatSelectModule, + ReactiveFormsModule, + MatButtonModule, + MatCheckboxModule, + MatToolbarModule, + MatCardModule, + MatDatepickerModule, + MatInputModule, + MatTableModule, + MatSortModule, + MatPaginatorModule, + MatProgressSpinnerModule, + MatSidenavModule, + MatListModule, + MatExpansionModule, + MatMenuModule, + MatDialogModule, + MatSnackBarModule, + MatChipsModule, + MatTooltipModule, + MatIconModule, + NgxMatSelectSearchModule, + TranslateModule, + PermsDirective, + DomChangeDirective, + FooterComponent, + HeadBarComponent, + SearchValueSelectorComponent, + LegalNoticeContentComponent, + PrivacyPolicyContentComponent, + PromptDialogComponent + ], + declarations: [ + PermsDirective, + DomChangeDirective, + HeadBarComponent, + FooterComponent, + LegalNoticeContentComponent, + PrivacyPolicyContentComponent, + SearchValueSelectorComponent, + PromptDialogComponent + ], + providers: [{ provide: DateAdapter, useClass: OpenSlidesDateAdapter }] +}) +export class SharedModule {} diff --git a/client/src/app/site/agenda/agenda-list/agenda-list.component.css b/client/src/app/site/agenda/agenda-list/agenda-list.component.css new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/site/agenda/agenda-list/agenda-list.component.html b/client/src/app/site/agenda/agenda-list/agenda-list.component.html new file mode 100644 index 000000000..bdbbb0964 --- /dev/null +++ b/client/src/app/site/agenda/agenda-list/agenda-list.component.html @@ -0,0 +1,18 @@ + + + + + + Topic + {{item.getListTitle()}} + + + + Duration + {{item.duration}} + + + + + + diff --git a/client/src/app/site/agenda/agenda-list/agenda-list.component.spec.ts b/client/src/app/site/agenda/agenda-list/agenda-list.component.spec.ts new file mode 100644 index 000000000..652f5b1a8 --- /dev/null +++ b/client/src/app/site/agenda/agenda-list/agenda-list.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AgendaListComponent } from './agenda-list.component'; +import { E2EImportsModule } from '../../../../e2e-imports.module'; + +describe('AgendaListComponent', () => { + let component: AgendaListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [AgendaListComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AgendaListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/agenda/agenda-list/agenda-list.component.ts b/client/src/app/site/agenda/agenda-list/agenda-list.component.ts new file mode 100644 index 000000000..75249527c --- /dev/null +++ b/client/src/app/site/agenda/agenda-list/agenda-list.component.ts @@ -0,0 +1,65 @@ +import { Component, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { TranslateService } from '@ngx-translate/core'; +import { ViewItem } from '../models/view-item'; +import { ListViewBaseComponent } from '../../base/list-view-base'; +import { AgendaRepositoryService } from '../services/agenda-repository.service'; +import { Router } from '@angular/router'; + +/** + * List view for the agenda. + * + * TODO: Not yet implemented + */ +@Component({ + selector: 'os-agenda-list', + templateUrl: './agenda-list.component.html', + styleUrls: ['./agenda-list.component.css'] +}) +export class AgendaListComponent extends ListViewBaseComponent implements OnInit { + /** + * The usual constructor for components + * @param titleService + * @param translate + */ + public constructor( + titleService: Title, + translate: TranslateService, + private router: Router, + private repo: AgendaRepositoryService + ) { + super(titleService, translate); + } + + /** + * Init function. + * Sets the title, initializes the table and calls the repository. + */ + public ngOnInit(): void { + super.setTitle('Agenda'); + this.initTable(); + this.repo.getViewModelListObservable().subscribe(newAgendaItem => { + this.dataSource.data = newAgendaItem; + }); + } + + /** + * Handler for click events on agenda item rows + * Links to the content object if any + */ + public selectAgendaItem(item: ViewItem): void { + if (item.contentObject) { + this.router.navigate([item.contentObject.getDetailStateURL()]); + } else { + console.error(`The selected item ${item} has no content object`); + } + } + + /** + * Handler for the plus button. + * Comes from the HeadBar Component + */ + public onPlusButton(): void { + console.log('create new motion'); + } +} diff --git a/client/src/app/site/agenda/agenda-routing.module.ts b/client/src/app/site/agenda/agenda-routing.module.ts new file mode 100644 index 000000000..92d540953 --- /dev/null +++ b/client/src/app/site/agenda/agenda-routing.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { AgendaListComponent } from './agenda-list/agenda-list.component'; + +const routes: Routes = [{ path: '', component: AgendaListComponent }]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class AgendaRoutingModule {} diff --git a/client/src/app/site/agenda/agenda.config.ts b/client/src/app/site/agenda/agenda.config.ts new file mode 100644 index 000000000..43c549bbb --- /dev/null +++ b/client/src/app/site/agenda/agenda.config.ts @@ -0,0 +1,17 @@ +import { AppConfig } from '../base/app-config'; +import { Item } from '../../shared/models/agenda/item'; +import { Topic } from '../../shared/models/topics/topic'; + +export const AgendaAppConfig: AppConfig = { + name: 'agenda', + models: [{ collectionString: 'agenda/item', model: Item }, { collectionString: 'topics/topic', model: Topic }], + mainMenuEntries: [ + { + route: '/agenda', + displayName: 'Agenda', + icon: 'today', // 'calendar_today' aligns wrong! + weight: 200, + permission: 'agenda.can_see' + } + ] +}; diff --git a/client/src/app/site/agenda/agenda.module.spec.ts b/client/src/app/site/agenda/agenda.module.spec.ts new file mode 100644 index 000000000..f183764e4 --- /dev/null +++ b/client/src/app/site/agenda/agenda.module.spec.ts @@ -0,0 +1,13 @@ +import { AgendaModule } from './agenda.module'; + +describe('AgendaModule', () => { + let agendaModule: AgendaModule; + + beforeEach(() => { + agendaModule = new AgendaModule(); + }); + + it('should create an instance', () => { + expect(agendaModule).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/agenda/agenda.module.ts b/client/src/app/site/agenda/agenda.module.ts new file mode 100644 index 000000000..723439c93 --- /dev/null +++ b/client/src/app/site/agenda/agenda.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { AgendaRoutingModule } from './agenda-routing.module'; +import { SharedModule } from '../../shared/shared.module'; +import { AgendaListComponent } from './agenda-list/agenda-list.component'; + +@NgModule({ + imports: [CommonModule, AgendaRoutingModule, SharedModule], + declarations: [AgendaListComponent] +}) +export class AgendaModule {} diff --git a/client/src/app/site/agenda/models/view-item.ts b/client/src/app/site/agenda/models/view-item.ts new file mode 100644 index 000000000..b1731ea67 --- /dev/null +++ b/client/src/app/site/agenda/models/view-item.ts @@ -0,0 +1,53 @@ +import { BaseViewModel } from '../../base/base-view-model'; +import { Item } from '../../../shared/models/agenda/item'; +import { AgendaBaseModel } from '../../../shared/models/base/agenda-base-model'; + +export class ViewItem extends BaseViewModel { + private _item: Item; + private _contentObject: AgendaBaseModel; + + public get item(): Item { + return this._item; + } + + public get contentObject(): AgendaBaseModel { + return this._contentObject; + } + + public get id(): number { + return this.item ? this.item.id : null; + } + + public get duration(): number { + return this.item ? this.item.duration : null; + } + + public constructor(item: Item, contentObject: AgendaBaseModel) { + super(); + this._item = item; + this._contentObject = contentObject; + } + + public getTitle(): string { + if (this.contentObject) { + return this.contentObject.getAgendaTitle(); + } else { + return this.item ? this.item.title : null; + } + } + + public getListTitle(): string { + const contentObject: AgendaBaseModel = this.contentObject; + if (contentObject) { + return contentObject.getAgendaTitleWithType(); + } else { + return this.item ? this.item.title_with_type : null; + } + } + + public updateValues(update: Item): void { + if (this.id === update.id) { + this._item = update; + } + } +} diff --git a/client/src/app/site/agenda/services/agenda-repository.service.spec.ts b/client/src/app/site/agenda/services/agenda-repository.service.spec.ts new file mode 100644 index 000000000..870d49958 --- /dev/null +++ b/client/src/app/site/agenda/services/agenda-repository.service.spec.ts @@ -0,0 +1,15 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { AgendaRepositoryService } from './agenda-repository.service'; + +describe('AgendaRepositoryService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [AgendaRepositoryService] + }); + }); + + it('should be created', inject([AgendaRepositoryService], (service: AgendaRepositoryService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/site/agenda/services/agenda-repository.service.ts b/client/src/app/site/agenda/services/agenda-repository.service.ts new file mode 100644 index 000000000..fb01cfd70 --- /dev/null +++ b/client/src/app/site/agenda/services/agenda-repository.service.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { BaseRepository } from '../../base/base-repository'; +import { DataStoreService } from '../../../core/services/data-store.service'; +import { Item } from '../../../shared/models/agenda/item'; +import { ViewItem } from '../models/view-item'; +import { AgendaBaseModel } from '../../../shared/models/base/agenda-base-model'; +import { BaseModel } from '../../../shared/models/base/base-model'; + +/** + * Repository service for users + * + * Documentation partially provided in {@link BaseRepository} + */ +@Injectable({ + providedIn: 'root' +}) +export class AgendaRepositoryService extends BaseRepository { + public constructor(DS: DataStoreService) { + super(DS, Item); + } + + /** + * Returns the corresponding content object to a given {@link Item} as an {@link AgendaBaseModel} + * @param agendaItem + */ + private getContentObject(agendaItem: Item): AgendaBaseModel { + const contentObject = this.DS.get( + agendaItem.content_object.collection, + agendaItem.content_object.id + ); + if (!contentObject) { + return null; + } + if (contentObject instanceof AgendaBaseModel) { + return contentObject as AgendaBaseModel; + } else { + throw new Error( + `The content object (${agendaItem.content_object.collection}, ${ + agendaItem.content_object.id + }) of item ${agendaItem.id} is not a BaseProjectableModel.` + ); + } + } + + /** + * @ignore + * + * TODO: used over not-yet-existing detail view + */ + public update(item: Partial, viewUser: ViewItem): Observable { + return null; + } + + /** + * @ignore + * + * TODO: used over not-yet-existing detail view + */ + public delete(item: ViewItem): Observable { + return null; + } + + /** + * @ignore + * + * TODO: used over not-yet-existing detail view + */ + public create(item: Item): Observable { + return null; + } + + public createViewModel(item: Item): ViewItem { + const contentObject = this.getContentObject(item); + + return new ViewItem(item, contentObject); + } +} diff --git a/client/src/app/site/assignments/assignment-list/assignment-list.component.css b/client/src/app/site/assignments/assignment-list/assignment-list.component.css new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/site/assignments/assignment-list/assignment-list.component.html b/client/src/app/site/assignments/assignment-list/assignment-list.component.html new file mode 100644 index 000000000..fdf63fa7a --- /dev/null +++ b/client/src/app/site/assignments/assignment-list/assignment-list.component.html @@ -0,0 +1,32 @@ + + + + + + + Title + {{assignment.getTitle()}} + + + Phase + + + {{assignment.phase}} + + + + + Candidates + + + {{assignment.candidateAmount}} + + + + + + + + + diff --git a/client/src/app/site/assignments/assignment-list/assignment-list.component.spec.ts b/client/src/app/site/assignments/assignment-list/assignment-list.component.spec.ts new file mode 100644 index 000000000..a059f7e7b --- /dev/null +++ b/client/src/app/site/assignments/assignment-list/assignment-list.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AssignmentListComponent } from './assignment-list.component'; +import { E2EImportsModule } from '../../../../e2e-imports.module'; + +describe('AssignmentListComponent', () => { + let component: AssignmentListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [AssignmentListComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AssignmentListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/assignments/assignment-list/assignment-list.component.ts b/client/src/app/site/assignments/assignment-list/assignment-list.component.ts new file mode 100644 index 000000000..09aa13de6 --- /dev/null +++ b/client/src/app/site/assignments/assignment-list/assignment-list.component.ts @@ -0,0 +1,75 @@ +import { Component, OnInit } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { Title } from '@angular/platform-browser'; +import { ViewAssignment } from '../models/view-assignment'; +import { ListViewBaseComponent } from '../../base/list-view-base'; +import { AssignmentRepositoryService } from '../services/assignment-repository.service'; + +/** + * Listview for the assignments + * + */ +@Component({ + selector: 'os-assignment-list', + templateUrl: './assignment-list.component.html', + styleUrls: ['./assignment-list.component.css'] +}) +export class AssignmentListComponent extends ListViewBaseComponent implements OnInit { + /** + * Define the content of the ellipsis menu. + * Give it to the HeadBar to display them. + */ + public assignmentMenu = [ + { + text: 'Download All', + icon: 'save_alt', + action: 'downloadAssignmentButton' + } + ]; + + /** + * Constructor. + * + * @param repo the repository + * @param titleService + * @param translate + */ + public constructor(private repo: AssignmentRepositoryService, titleService: Title, translate: TranslateService) { + super(titleService, translate); + } + + /** + * Init function. + * Sets the title, inits the table and calls the repo. + */ + public ngOnInit(): void { + super.setTitle('Assignments'); + this.initTable(); + this.repo.getViewModelListObservable().subscribe(newAssignments => { + this.dataSource.data = newAssignments; + }); + } + + /** + * Click on the plus button delegated from head-bar + */ + public onPlusButton(): void { + console.log('create new assignments'); + } + + /** + * Select an row in the table + * @param assignment + */ + public selectAssignment(assignment: ViewAssignment): void { + console.log('select assignment list: ', assignment); + } + + /** + * Function to download the assignment list + * TODO: Not yet implemented + */ + public downloadAssignmentButton(): void { + console.log('Hello World'); + } +} diff --git a/client/src/app/site/assignments/assignments-routing.module.ts b/client/src/app/site/assignments/assignments-routing.module.ts new file mode 100644 index 000000000..9aef9d8a6 --- /dev/null +++ b/client/src/app/site/assignments/assignments-routing.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { AssignmentListComponent } from './assignment-list/assignment-list.component'; + +const routes: Routes = [{ path: '', component: AssignmentListComponent }]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class AssignmentsRoutingModule {} diff --git a/client/src/app/site/assignments/assignments.config.ts b/client/src/app/site/assignments/assignments.config.ts new file mode 100644 index 000000000..1e161c217 --- /dev/null +++ b/client/src/app/site/assignments/assignments.config.ts @@ -0,0 +1,16 @@ +import { AppConfig } from '../base/app-config'; +import { Assignment } from '../../shared/models/assignments/assignment'; + +export const AssignmentsAppConfig: AppConfig = { + name: 'assignments', + models: [{ collectionString: 'assignments/assignment', model: Assignment }], + mainMenuEntries: [ + { + route: '/assignments', + displayName: 'Elections', + icon: 'poll', // TODO not yet available: 'how_to_vote', + weight: 400, + permission: 'assignments.can_see' + } + ] +}; diff --git a/client/src/app/site/assignments/assignments.module.spec.ts b/client/src/app/site/assignments/assignments.module.spec.ts new file mode 100644 index 000000000..1fa6d47df --- /dev/null +++ b/client/src/app/site/assignments/assignments.module.spec.ts @@ -0,0 +1,13 @@ +import { AssignmentsModule } from './assignments.module'; + +describe('AssignmentsModule', () => { + let assignmentsModule: AssignmentsModule; + + beforeEach(() => { + assignmentsModule = new AssignmentsModule(); + }); + + it('should create an instance', () => { + expect(assignmentsModule).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/assignments/assignments.module.ts b/client/src/app/site/assignments/assignments.module.ts new file mode 100644 index 000000000..4b3f4751f --- /dev/null +++ b/client/src/app/site/assignments/assignments.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { AssignmentsRoutingModule } from './assignments-routing.module'; +import { SharedModule } from '../../shared/shared.module'; +import { AssignmentListComponent } from './assignment-list/assignment-list.component'; + +@NgModule({ + imports: [CommonModule, AssignmentsRoutingModule, SharedModule], + declarations: [AssignmentListComponent] +}) +export class AssignmentsModule {} diff --git a/client/src/app/site/assignments/models/view-assignment.ts b/client/src/app/site/assignments/models/view-assignment.ts new file mode 100644 index 000000000..561569136 --- /dev/null +++ b/client/src/app/site/assignments/models/view-assignment.ts @@ -0,0 +1,57 @@ +import { BaseViewModel } from '../../base/base-view-model'; +import { Assignment } from '../../../shared/models/assignments/assignment'; +import { Tag } from '../../../shared/models/core/tag'; +import { User } from '../../../shared/models/users/user'; +import { Item } from '../../../shared/models/agenda/item'; + +export class ViewAssignment extends BaseViewModel { + private _assignment: Assignment; + private _relatedUser: User[]; + private _agendaItem: Item; + private _tags: Tag[]; + + public get id(): number { + return this._assignment ? this._assignment.id : null; + } + + public get assignment(): Assignment { + return this._assignment; + } + + public get candidates(): User[] { + return this._relatedUser; + } + + public get agendaItem(): Item { + return this._agendaItem; + } + + public get tags(): Tag[] { + return this._tags; + } + + /** + * unknown where the identifier to the phase is get + */ + public get phase(): number { + return this.assignment ? this.assignment.phase : null; + } + + public get candidateAmount(): number { + return this.candidates ? this.candidates.length : 0; + } + + public constructor(assignment: Assignment, relatedUser: User[], agendaItem?: Item, tags?: Tag[]) { + super(); + this._assignment = assignment; + this._relatedUser = relatedUser; + this._agendaItem = agendaItem; + this._tags = tags; + } + + public updateValues(): void {} + + public getTitle(): string { + return this.assignment ? this.assignment.title : null; + } +} diff --git a/client/src/app/site/assignments/services/assignment-repository.service.spec.ts b/client/src/app/site/assignments/services/assignment-repository.service.spec.ts new file mode 100644 index 000000000..c694cfae7 --- /dev/null +++ b/client/src/app/site/assignments/services/assignment-repository.service.spec.ts @@ -0,0 +1,12 @@ +import { TestBed } from '@angular/core/testing'; + +import { AssignmentRepositoryService } from './assignment-repository.service'; + +describe('AssignmentRepositoryService', () => { + beforeEach(() => TestBed.configureTestingModule({})); + + it('should be created', () => { + const service: AssignmentRepositoryService = TestBed.get(AssignmentRepositoryService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/assignments/services/assignment-repository.service.ts b/client/src/app/site/assignments/services/assignment-repository.service.ts new file mode 100644 index 000000000..98a7ffb16 --- /dev/null +++ b/client/src/app/site/assignments/services/assignment-repository.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@angular/core'; +import { ViewAssignment } from '../models/view-assignment'; +import { Assignment } from '../../../shared/models/assignments/assignment'; +import { User } from '../../../shared/models/users/user'; +import { Tag } from '../../../shared/models/core/tag'; +import { Item } from '../../../shared/models/agenda/item'; +import { Observable } from 'rxjs'; +import { BaseRepository } from '../../base/base-repository'; +import { DataStoreService } from '../../../core/services/data-store.service'; + +/** + * Repository Service for Assignments. + * + * Documentation partially provided in {@link BaseRepository} + */ +@Injectable({ + providedIn: 'root' +}) +export class AssignmentRepositoryService extends BaseRepository { + /** + * Constructor for the Assignment Repository. + * + */ + public constructor(DS: DataStoreService) { + super(DS, Assignment, [User, Item, Tag]); + } + + public update(assignment: Partial, viewAssignment: ViewAssignment): Observable { + return null; + } + + public delete(viewAssignment: ViewAssignment): Observable { + return null; + } + + public create(assignment: Assignment): Observable { + return null; + } + + public createViewModel(assignment: Assignment): ViewAssignment { + const relatedUser = this.DS.getMany(User, assignment.candidateIds); + const agendaItem = this.DS.get(Item, assignment.agenda_item_id); + const tags = this.DS.getMany(Tag, assignment.tags_id); + + return new ViewAssignment(assignment, relatedUser, agendaItem, tags); + } +} diff --git a/client/src/app/site/base/app-config.ts b/client/src/app/site/base/app-config.ts new file mode 100644 index 000000000..e2ba1bd34 --- /dev/null +++ b/client/src/app/site/base/app-config.ts @@ -0,0 +1,25 @@ +import { ModelConstructor, BaseModel } from '../../shared/models/base/base-model'; +import { MainMenuEntry } from '../../core/services/main-menu.service'; + +/** + * The configuration of an app. + */ +export interface AppConfig { + /** + * The name. + */ + name: string; + + /** + * All models, that should be registered. + */ + models?: { + collectionString: string; + model: ModelConstructor; + }[]; + + /** + * Main menu entries. + */ + mainMenuEntries?: MainMenuEntry[]; +} diff --git a/client/src/app/site/base/base-repository.ts b/client/src/app/site/base/base-repository.ts new file mode 100644 index 000000000..5120f4fe1 --- /dev/null +++ b/client/src/app/site/base/base-repository.ts @@ -0,0 +1,161 @@ +import { OpenSlidesComponent } from '../../openslides.component'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { BaseViewModel } from './base-view-model'; +import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model'; +import { CollectionStringModelMapperService } from '../../core/services/collectionStringModelMapper.service'; +import { DataStoreService } from '../../core/services/data-store.service'; + +export abstract class BaseRepository extends OpenSlidesComponent { + /** + * Stores all the viewModel in an object + */ + protected viewModelStore: { [modelId: number]: V } = {}; + + /** + * Stores subjects to viewModels in a list + */ + protected viewModelSubjects: { [modelId: number]: BehaviorSubject } = {}; + + /** + * Observable subject for the whole list + */ + protected viewModelListSubject: BehaviorSubject = new BehaviorSubject(null); + + /** + * + * @param baseModelCtor The model constructor of which this repository is about. + * @param depsModelCtors A list of constructors that are used in the view model. + * If one of those changes, the view models will be updated. + */ + public constructor( + protected DS: DataStoreService, + protected baseModelCtor: ModelConstructor, + protected depsModelCtors?: ModelConstructor[] + ) { + super(); + this.setup(); + } + + protected setup(): void { + // Populate the local viewModelStore with ViewModel Objects. + this.DS.getAll(this.baseModelCtor).forEach((model: M) => { + this.viewModelStore[model.id] = this.createViewModel(model); + this.updateViewModelObservable(model.id); + }); + this.updateViewModelListObservable(); + + // Could be raise in error if the root injector is not known + this.DS.changeObservable.subscribe(model => { + if (model instanceof this.baseModelCtor) { + // Add new and updated motions to the viewModelStore + this.viewModelStore[model.id] = this.createViewModel(model as M); + this.updateAllObservables(model.id); + } else if (this.depsModelCtors) { + const dependencyChanged: boolean = this.depsModelCtors.some(ctor => { + return model instanceof ctor; + }); + if (dependencyChanged) { + // if an domain object we need was added or changed, update viewModelStore + this.getViewModelList().forEach(viewModel => { + viewModel.updateValues(model); + }); + this.updateAllObservables(model.id); + } + } + }); + + // Watch the Observables for deleting + this.DS.deletedObservable.subscribe(model => { + if (model.collection === CollectionStringModelMapperService.getCollectionString(this.baseModelCtor)) { + delete this.viewModelStore[model.id]; + this.updateAllObservables(model.id); + } + }); + } + + /** + * Saves the update to an existing model. So called "update"-function + * @param update the update that should be created + * @param viewModel the view model that the update is based on + */ + public abstract update(update: Partial, viewModel: V): Observable; + + /** + * Deletes a given Model + * @param update the update that should be created + * @param viewModel the view model that the update is based on + */ + public abstract delete(viewModel: V): Observable; + + /** + * Creates a new model + * @param update the update that should be created + * @param viewModel the view model that the update is based on + * TODO: remove the viewModel + */ + public abstract create(update: M): Observable; + + /** + * Creates a view model out of a base model. + * + * Should read all necessary objects from the datastore + * that the viewmodel needs + * @param model + */ + protected abstract createViewModel(model: M): V; + + /** + * helper function to return one viewModel + */ + protected getViewModel(id: number): V { + return this.viewModelStore[id]; + } + + /** + * helper function to return the viewModel as array + */ + protected getViewModelList(): V[] { + return Object.values(this.viewModelStore); + } + + /** + * returns the current observable for one viewModel + */ + public getViewModelObservable(id: number): Observable { + if (!this.viewModelSubjects[id]) { + this.viewModelSubjects[id] = new BehaviorSubject(this.viewModelStore[id]); + } + return this.viewModelSubjects[id].asObservable(); + } + + /** + * return the Observable of the whole store + */ + public getViewModelListObservable(): Observable { + return this.viewModelListSubject.asObservable(); + } + + /** + * Updates the ViewModel observable using a ViewModel corresponding to the id + */ + protected updateViewModelObservable(id: number): void { + if (this.viewModelSubjects[id]) { + this.viewModelSubjects[id].next(this.viewModelStore[id]); + } + } + + /** + * update the observable of the list + */ + protected updateViewModelListObservable(): void { + this.viewModelListSubject.next(this.getViewModelList()); + } + + /** + * Triggers both the observable update routines + */ + protected updateAllObservables(id: number): void { + this.updateViewModelListObservable(); + this.updateViewModelObservable(id); + } +} diff --git a/client/src/app/site/base/base-view-model.ts b/client/src/app/site/base/base-view-model.ts new file mode 100644 index 000000000..d223e416d --- /dev/null +++ b/client/src/app/site/base/base-view-model.ts @@ -0,0 +1,25 @@ +import { BaseModel } from '../../shared/models/base/base-model'; +import { Displayable } from '../../shared/models/base/displayable'; +import { Identifiable } from '../../shared/models/base/identifiable'; + +/** + * Base class for view models. alls view models should have titles. + */ +export abstract class BaseViewModel implements Displayable, Identifiable { + /** + * Force children to have an id. + */ + public abstract id: number; + + public abstract updateValues(update: BaseModel): void; + + public abstract getTitle(): string; + + public getListTitle(): string { + return this.getTitle(); + } + + public toString(): string { + return this.getTitle(); + } +} diff --git a/client/src/app/site/base/list-view-base.ts b/client/src/app/site/base/list-view-base.ts new file mode 100644 index 000000000..b02b545bf --- /dev/null +++ b/client/src/app/site/base/list-view-base.ts @@ -0,0 +1,63 @@ +import { ViewChild } from '@angular/core'; +import { BaseComponent } from '../../base.component'; +import { Title } from '@angular/platform-browser'; +import { TranslateService } from '@ngx-translate/core'; +import { MatTableDataSource, MatTable, MatSort, MatPaginator } from '@angular/material'; +import { BaseViewModel } from './base-view-model'; + +export abstract class ListViewBaseComponent extends BaseComponent { + /** + * The data source for a table. Requires to be initialised with a BaseViewModel + */ + public dataSource: MatTableDataSource; + + /** + * The table itself + */ + @ViewChild(MatTable) + protected table: MatTable; + + /** + * Table paginator + */ + @ViewChild(MatPaginator) + protected paginator: MatPaginator; + + /** + * Sorter for a table + */ + @ViewChild(MatSort) + protected sort: MatSort; + + /** + * Constructor for list view bases + * @param titleService the title serivce + * @param translate the translate service + */ + public constructor(titleService: Title, translate: TranslateService) { + super(titleService, translate); + } + + /** + * Children need to call this in their init-function. + * Calling these three functions in the constructor of this class + * would be too early, resulting in non-paginated tables + */ + public initTable(): void { + this.dataSource = new MatTableDataSource(); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + } + + /** + * handler function for clicking on items in the ellipsis menu. + * Ellipsis menu comes from the HeadBarComponent is is implemented by most ListViews + * + * @param event clicked entry from ellipsis menu + */ + public onEllipsisItem(event: any): void { + if (event.action) { + this[event.action](); + } + } +} diff --git a/client/src/app/site/common/common-routing.module.ts b/client/src/app/site/common/common-routing.module.ts new file mode 100644 index 000000000..87f463e52 --- /dev/null +++ b/client/src/app/site/common/common-routing.module.ts @@ -0,0 +1,26 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { PrivacyPolicyComponent } from './components/privacy-policy/privacy-policy.component'; +import { StartComponent } from './components/start/start.component'; +import { LegalNoticeComponent } from './components/legal-notice/legal-notice.component'; + +const routes: Routes = [ + { + path: '', + component: StartComponent + }, + { + path: 'legalnotice', + component: LegalNoticeComponent + }, + { + path: 'privacypolicy', + component: PrivacyPolicyComponent + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class CommonRoutingModule {} diff --git a/client/src/app/site/common/common.config.ts b/client/src/app/site/common/common.config.ts new file mode 100644 index 000000000..9b52d460b --- /dev/null +++ b/client/src/app/site/common/common.config.ts @@ -0,0 +1,26 @@ +import { AppConfig } from '../base/app-config'; +import { Projector } from '../../shared/models/core/projector'; +import { Countdown } from '../../shared/models/core/countdown'; +import { ChatMessage } from '../../shared/models/core/chat-message'; +import { ProjectorMessage } from '../../shared/models/core/projector-message'; +import { Tag } from '../../shared/models/core/tag'; + +export const CommonAppConfig: AppConfig = { + name: 'common', + models: [ + { collectionString: 'core/projector', model: Projector }, + { collectionString: 'core/chat-message', model: ChatMessage }, + { collectionString: 'core/countdown', model: Countdown }, + { collectionString: 'core/projector-message', model: ProjectorMessage }, + { collectionString: 'core/tag', model: Tag } + ], + mainMenuEntries: [ + { + route: '/', + displayName: 'Home', + icon: 'home', + weight: 100, + permission: 'core.can_see_frontpage' + } + ] +}; diff --git a/client/src/app/site/common/common.module.spec.ts b/client/src/app/site/common/common.module.spec.ts new file mode 100644 index 000000000..06175936f --- /dev/null +++ b/client/src/app/site/common/common.module.spec.ts @@ -0,0 +1,13 @@ +import { CommonModule } from './common.module'; + +describe('CommonModule', () => { + let commonModule: CommonModule; + + beforeEach(() => { + commonModule = new CommonModule(); + }); + + it('should create an instance', () => { + expect(commonModule).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/common/common.module.ts b/client/src/app/site/common/common.module.ts new file mode 100644 index 000000000..b5fb513ce --- /dev/null +++ b/client/src/app/site/common/common.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { CommonModule as AngularCommonModule } from '@angular/common'; + +import { CommonRoutingModule } from './common-routing.module'; +import { SharedModule } from '../../shared/shared.module'; +import { PrivacyPolicyComponent } from './components/privacy-policy/privacy-policy.component'; +import { StartComponent } from './components/start/start.component'; +import { LegalNoticeComponent } from './components/legal-notice/legal-notice.component'; + +@NgModule({ + imports: [AngularCommonModule, CommonRoutingModule, SharedModule], + declarations: [PrivacyPolicyComponent, StartComponent, LegalNoticeComponent] +}) +export class CommonModule {} diff --git a/client/src/app/site/common/components/legal-notice/legal-notice.component.html b/client/src/app/site/common/components/legal-notice/legal-notice.component.html new file mode 100644 index 000000000..6fa049b37 --- /dev/null +++ b/client/src/app/site/common/components/legal-notice/legal-notice.component.html @@ -0,0 +1,3 @@ + + + diff --git a/client/src/app/site/common/components/legal-notice/legal-notice.component.scss b/client/src/app/site/common/components/legal-notice/legal-notice.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/site/common/components/legal-notice/legal-notice.component.spec.ts b/client/src/app/site/common/components/legal-notice/legal-notice.component.spec.ts new file mode 100644 index 000000000..ff6df19d7 --- /dev/null +++ b/client/src/app/site/common/components/legal-notice/legal-notice.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LegalNoticeComponent } from './legal-notice.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; + +describe('LegalNoticeComponent', () => { + let component: LegalNoticeComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [LegalNoticeComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LegalNoticeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/common/components/legal-notice/legal-notice.component.ts b/client/src/app/site/common/components/legal-notice/legal-notice.component.ts new file mode 100644 index 000000000..a0e874f95 --- /dev/null +++ b/client/src/app/site/common/components/legal-notice/legal-notice.component.ts @@ -0,0 +1,12 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'os-legal-notice', + templateUrl: './legal-notice.component.html', + styleUrls: ['./legal-notice.component.scss'] +}) +export class LegalNoticeComponent implements OnInit { + public constructor() {} + + public ngOnInit(): void {} +} diff --git a/client/src/app/site/common/components/privacy-policy/privacy-policy.component.html b/client/src/app/site/common/components/privacy-policy/privacy-policy.component.html new file mode 100644 index 000000000..98737ff13 --- /dev/null +++ b/client/src/app/site/common/components/privacy-policy/privacy-policy.component.html @@ -0,0 +1,3 @@ + + + diff --git a/client/src/app/site/common/components/privacy-policy/privacy-policy.component.scss b/client/src/app/site/common/components/privacy-policy/privacy-policy.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/site/common/components/privacy-policy/privacy-policy.component.spec.ts b/client/src/app/site/common/components/privacy-policy/privacy-policy.component.spec.ts new file mode 100644 index 000000000..3215242e0 --- /dev/null +++ b/client/src/app/site/common/components/privacy-policy/privacy-policy.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PrivacyPolicyComponent } from './privacy-policy.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; + +describe('PrivacyPolicyComponent', () => { + let component: PrivacyPolicyComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [PrivacyPolicyComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PrivacyPolicyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/common/components/privacy-policy/privacy-policy.component.ts b/client/src/app/site/common/components/privacy-policy/privacy-policy.component.ts new file mode 100644 index 000000000..fb81e8f37 --- /dev/null +++ b/client/src/app/site/common/components/privacy-policy/privacy-policy.component.ts @@ -0,0 +1,12 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'os-privacy-policy', + templateUrl: './privacy-policy.component.html', + styleUrls: ['./privacy-policy.component.scss'] +}) +export class PrivacyPolicyComponent implements OnInit { + public constructor() {} + + public ngOnInit(): void {} +} diff --git a/client/src/app/site/common/components/start/start.component.css b/client/src/app/site/common/components/start/start.component.css new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/site/common/components/start/start.component.html b/client/src/app/site/common/components/start/start.component.html new file mode 100644 index 000000000..4dcd7ac8f --- /dev/null +++ b/client/src/app/site/common/components/start/start.component.html @@ -0,0 +1,17 @@ + + + + +
+ +

{{welcomeTitle | translate}}

+ {{welcomeText | translate}} + + +
+ +
+ + +
+
diff --git a/client/src/app/site/common/components/start/start.component.spec.ts b/client/src/app/site/common/components/start/start.component.spec.ts new file mode 100644 index 000000000..252f5fa27 --- /dev/null +++ b/client/src/app/site/common/components/start/start.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { StartComponent } from './start.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; + +describe('StartComponent', () => { + let component: StartComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [StartComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(StartComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/common/components/start/start.component.ts b/client/src/app/site/common/components/start/start.component.ts new file mode 100644 index 000000000..afc7ffd87 --- /dev/null +++ b/client/src/app/site/common/components/start/start.component.ts @@ -0,0 +1,146 @@ +import { Component, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { BaseComponent } from 'app/base.component'; + +import { TranslateService } from '@ngx-translate/core'; // showcase + +// for testing the DS and BaseModel +import { Config } from '../../../../shared/models/core/config'; +import { Motion } from '../../../../shared/models/motions/motion'; +import { MotionSubmitter } from '../../../../shared/models/motions/motion-submitter'; +import { DataStoreService } from '../../../../core/services/data-store.service'; + +@Component({ + selector: 'os-start', + templateUrl: './start.component.html', + styleUrls: ['./start.component.css'] +}) +export class StartComponent extends BaseComponent implements OnInit { + public welcomeTitle: string; + public welcomeText: string; + + /** + * Constructor of the StartComponent + * + * @param titleService the title serve + * @param translate to translation module + */ + public constructor(titleService: Title, protected translate: TranslateService, private DS: DataStoreService) { + super(titleService, translate); + } + + /** + * Init the component. + * + * Sets the welcomeTitle and welcomeText. + * Tries to read them from the DataStore (which will fail initially) + * And observes DataStore for changes + * Set title and observe DataStore for changes. + */ + public ngOnInit(): void { + // required dummy translation, cause translations for config values were never set + // tslint:disable-next-line + const welcomeTitleTranslateDummy = this.translate.instant('Welcome to OpenSlides'); + super.setTitle('Home'); + // set welcome title and text + const welcomeTitleConfig = this.DS.filter( + Config, + config => config.key === 'general_event_welcome_title' + )[0] as Config; + + if (welcomeTitleConfig) { + this.welcomeTitle = welcomeTitleConfig.value as string; + } + + const welcomeTextConfig = this.DS.filter( + Config, + config => config.key === 'general_event_welcome_text' + )[0] as Config; + + if (welcomeTextConfig) { + this.welcomeText = welcomeTextConfig.value as string; + } + + // observe title and text in DS + this.DS.changeObservable.subscribe(newModel => { + if (newModel instanceof Config) { + if (newModel.key === 'general_event_welcome_title') { + this.welcomeTitle = newModel.value as string; + } else if (newModel.key === 'general_event_welcome_text') { + this.welcomeText = newModel.value as string; + } + } + }); + } + + /** + * function to print datastore + */ + public giveDataStore(): void { + this.DS.printWhole(); + } + + /** + * test translations in component + */ + public TranslateTest(): void { + console.log('lets translate the word "motion" in the current in the current lang'); + console.log('Motions in ' + this.translate.currentLang + ' is ' + this.translate.instant('Motions')); + } + + /** + * Adds random generated motions + */ + public createMotions(requiredMotions: number): void { + console.log('adding ' + requiredMotions + ' Motions.'); + const newMotionsArray = []; + + const longMotionText = ` + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. + + Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. + + Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. + + Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. + + Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis. + + At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, At accusam aliquyam diam diam dolore dolores duo eirmod eos erat, et nonumy sed tempor et et invidunt justo labore Stet clita ea et gubergren, kasd magna no rebum. sanctus sea sed takimata ut vero voluptua. est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat. + + Consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus. + + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. + + Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. + + Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. + + Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo + `; + + for (let i = 1; i <= requiredMotions; ++i) { + // submitter + const newMotionSubmitter = new MotionSubmitter({ + id: 1, + user_id: 1, + motion_id: 200 + i, + weight: 0 + }); + // motion + const newMotion = new Motion({ + id: 200 + i, + identifier: 'GenMo ' + i, + title: 'title', + text: longMotionText, + reason: longMotionText, + origin: 'Generated', + submitters: [newMotionSubmitter], + state_id: 1 + }); + newMotionsArray.push(newMotion); + } + this.DS.add(...newMotionsArray); + console.log('Done adding motions'); + } +} diff --git a/client/src/app/site/config/components/config-field/config-field.component.html b/client/src/app/site/config/components/config-field/config-field.component.html new file mode 100644 index 000000000..db4d457c3 --- /dev/null +++ b/client/src/app/site/config/components/config-field/config-field.component.html @@ -0,0 +1,66 @@ +
+ + + + + + + + + + + + + + + + + + + + {{ configItem.label }} + {{ configItem.helpText }} + + check_circle + + + {{ error }} + + + + + + + + + + + + + {{ choice.display_name | translate }} + + + + + + + + + + + + + +
+
+ + {{ configItem.label | translate }} + +
+ {{ configItem.helpText | translate }} +
+
+ {{ error }} +
+
diff --git a/client/src/app/site/config/components/config-field/config-field.component.scss b/client/src/app/site/config/components/config-field/config-field.component.scss new file mode 100644 index 000000000..4ebea5d50 --- /dev/null +++ b/client/src/app/site/config/components/config-field/config-field.component.scss @@ -0,0 +1,26 @@ +/* Full width form fields */ +.mat-form-field { + width: 100%; +} + +/* limit the color bar of color inputs */ +input[type='color'] { + max-width: 100px; +} + +/* Spacing between config entries */ +.config-form-group { + margin-bottom: 15px; +} + +/* Custom hint and error classes for the checkbox. Same values as .mat-hint and -mat-error */ +.hint, +.error { + font-size: 75%; +} +.hint { + color: rgba(0, 0, 0, 0.54); +} +.error { + color: #f44336; +} diff --git a/client/src/app/site/config/components/config-field/config-field.component.spec.ts b/client/src/app/site/config/components/config-field/config-field.component.spec.ts new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/site/config/components/config-field/config-field.component.ts b/client/src/app/site/config/components/config-field/config-field.component.ts new file mode 100644 index 000000000..964b8f471 --- /dev/null +++ b/client/src/app/site/config/components/config-field/config-field.component.ts @@ -0,0 +1,180 @@ +import { Component, OnInit, Input, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { TranslateService } from '@ngx-translate/core'; +import { ViewConfig } from '../../models/view-config'; +import { BaseComponent } from '../../../../base.component'; +import { FormGroup, FormBuilder } from '@angular/forms'; +import { ConfigRepositoryService } from '../../services/config-repository.service'; +import { tap } from 'rxjs/operators'; +import { ParentErrorStateMatcher } from '../../../../shared/parent-error-state-matcher'; + +/** + * List view for the categories. + * + * TODO: Creation of new Categories + */ +@Component({ + selector: 'os-config-field', + templateUrl: './config-field.component.html', + styleUrls: ['./config-field.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ConfigFieldComponent extends BaseComponent implements OnInit { + public configItem: ViewConfig; + + /** + * Option to show a green check-icon. + */ + public updateSuccessIcon = false; + + /** + * The timeout for the success icon to hide. + */ + private updateSuccessIconTimeout: number | null = null; + + /** + * The debounce timeout for inputs request delay. + */ + private debounceTimeout: number | null = null; + + /** + * A possible error send by the server. + */ + public error: string | null = null; + + /** + * The config item for this component. Just accept components with already populated constants-info. + */ + @Input() + public set item(value: ViewConfig) { + if (value.hasConstantsInfo) { + this.configItem = value; + + if (this.form) { + this.form.patchValue( + { + value: this.configItem.value + }, + { emitEvent: false } + ); + } + } + } + + /** + * The form for this configItem. + */ + public form: FormGroup; + + /** + * The matcher for custom (request) errors. + */ + public matcher = new ParentErrorStateMatcher(); + + /** + * The usual component constructor + * @param titleService + * @param translate + */ + public constructor( + protected titleService: Title, + protected translate: TranslateService, + private formBuilder: FormBuilder, + private cdRef: ChangeDetectorRef, + public repo: ConfigRepositoryService + ) { + super(titleService, translate); + } + + /** + * Sets up the form for this config field. + */ + public ngOnInit(): void { + this.form = this.formBuilder.group({ + value: [''] + }); + this.form.patchValue({ + value: this.configItem.value + }); + this.form.valueChanges.subscribe(form => { + this.onChange(form.value); + }); + } + + /** + * Trigger an update of the data + */ + private onChange(value: any): void { + if (this.debounceTimeout !== null) { + clearTimeout(this.debounceTimeout); + } + this.debounceTimeout = setTimeout(() => { + this.update(value); + }, this.configItem.getDebouncingTimeout()); + this.cdRef.detectChanges(); + } + + /** + * Updates the this config field. + */ + private update(value: any): void { + // TODO: Fix the Datetimepicker parser and formatter. + if (this.configItem.inputType === 'datetimepicker') { + value = Date.parse(value); + } + this.debounceTimeout = null; + this.repo + .update({ value: value }, this.configItem) + .pipe( + tap( + response => { + this.error = null; + this.showSuccessIcon(); + }, + error => { + this.setError(error.error.detail); + } + ) + ) + .subscribe(); + } + + /** + * Show the green success icon on the component. The icon gets automatically cleared. + */ + private showSuccessIcon(): void { + if (this.updateSuccessIconTimeout !== null) { + clearTimeout(this.updateSuccessIconTimeout); + } + this.updateSuccessIconTimeout = setTimeout(() => { + this.updateSuccessIcon = false; + this.cdRef.detectChanges(); + }, 2000); + this.updateSuccessIcon = true; + this.cdRef.detectChanges(); + } + + /** + * Sets the error on this field. + */ + private setError(error: string): void { + this.error = error; + this.form.setErrors({ error: true }); + this.cdRef.detectChanges(); + } + + /** + * Uses the configItem to determine the kind of interation: + * input, textarea, choice or date + */ + public formType(type: string): string { + switch (type) { + case 'integer': + return 'number'; + case 'colorpicker': + return 'color'; + default: + return 'text'; + } + } +} diff --git a/client/src/app/site/config/components/config-list/config-list.component.html b/client/src/app/site/config/components/config-list/config-list.component.html new file mode 100644 index 000000000..64e250fed --- /dev/null +++ b/client/src/app/site/config/components/config-list/config-list.component.html @@ -0,0 +1,26 @@ + + + + + + + + {{ group.name | translate }} + + +
+ + {{ subgroup.name | translate }} + +
+ +
+
+
+
+
+ +
+
+
+
diff --git a/client/src/app/site/config/components/config-list/config-list.component.scss b/client/src/app/site/config/components/config-list/config-list.component.scss new file mode 100644 index 000000000..6571d4d16 --- /dev/null +++ b/client/src/app/site/config/components/config-list/config-list.component.scss @@ -0,0 +1,3 @@ +mat-card { + margin-bottom: 10px; +} diff --git a/client/src/app/site/config/components/config-list/config-list.component.spec.ts b/client/src/app/site/config/components/config-list/config-list.component.spec.ts new file mode 100644 index 000000000..14a13fee2 --- /dev/null +++ b/client/src/app/site/config/components/config-list/config-list.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from '../../../../../e2e-imports.module'; +import { ConfigListComponent } from './config-list.component'; +import { ConfigFieldComponent } from '../config-field/config-field.component'; + +describe('ConfigListComponent', () => { + let component: ConfigListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [ConfigListComponent, ConfigFieldComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfigListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/config/components/config-list/config-list.component.ts b/client/src/app/site/config/components/config-list/config-list.component.ts new file mode 100644 index 000000000..25175a68d --- /dev/null +++ b/client/src/app/site/config/components/config-list/config-list.component.ts @@ -0,0 +1,36 @@ +import { Component, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { TranslateService } from '@ngx-translate/core'; +import { ConfigRepositoryService, ConfigGroup } from '../../services/config-repository.service'; +import { BaseComponent } from '../../../../base.component'; + +/** + * List view for the global settings + */ +@Component({ + selector: 'os-config-list', + templateUrl: './config-list.component.html', + styleUrls: ['./config-list.component.scss'] +}) +export class ConfigListComponent extends BaseComponent implements OnInit { + public configs: ConfigGroup[]; + + public constructor( + protected titleService: Title, + protected translate: TranslateService, + private repo: ConfigRepositoryService + ) { + super(titleService, translate); + } + + /** + * Sets the title, inits the table and calls the repo + */ + public ngOnInit(): void { + super.setTitle('Settings'); + + this.repo.getConfigListObservable().subscribe(configs => { + this.configs = configs; + }); + } +} diff --git a/client/src/app/site/config/config-routing.module.ts b/client/src/app/site/config/config-routing.module.ts new file mode 100644 index 000000000..27d6cda20 --- /dev/null +++ b/client/src/app/site/config/config-routing.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { ConfigListComponent } from './components/config-list/config-list.component'; + +const routes: Routes = [{ path: '', component: ConfigListComponent }]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class ConfigRoutingModule {} diff --git a/client/src/app/site/config/config.config.ts b/client/src/app/site/config/config.config.ts new file mode 100644 index 000000000..9d613b778 --- /dev/null +++ b/client/src/app/site/config/config.config.ts @@ -0,0 +1,16 @@ +import { AppConfig } from '../base/app-config'; +import { Config } from '../../shared/models/core/config'; + +export const ConfigAppConfig: AppConfig = { + name: 'settings', + models: [{ collectionString: 'core/config', model: Config }], + mainMenuEntries: [ + { + route: '/settings', + displayName: 'Settings', + icon: 'settings', + weight: 700, + permission: 'core.can_manage_config' + } + ] +}; diff --git a/client/src/app/site/config/config.module.spec.ts b/client/src/app/site/config/config.module.spec.ts new file mode 100644 index 000000000..a6b2dc697 --- /dev/null +++ b/client/src/app/site/config/config.module.spec.ts @@ -0,0 +1,13 @@ +import { ConfigModule } from './config.module'; + +describe('SettingsModule', () => { + let settingsModule: ConfigModule; + + beforeEach(() => { + settingsModule = new ConfigModule(); + }); + + it('should create an instance', () => { + expect(settingsModule).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/config/config.module.ts b/client/src/app/site/config/config.module.ts new file mode 100644 index 000000000..2230cba92 --- /dev/null +++ b/client/src/app/site/config/config.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../../shared/shared.module'; +import { ConfigRoutingModule } from './config-routing.module'; +import { ConfigListComponent } from './components/config-list/config-list.component'; +import { ConfigFieldComponent } from './components/config-field/config-field.component'; + +@NgModule({ + imports: [CommonModule, ConfigRoutingModule, SharedModule], + declarations: [ConfigListComponent, ConfigFieldComponent] +}) +export class ConfigModule {} diff --git a/client/src/app/site/config/models/view-config.ts b/client/src/app/site/config/models/view-config.ts new file mode 100644 index 000000000..271f12a77 --- /dev/null +++ b/client/src/app/site/config/models/view-config.ts @@ -0,0 +1,133 @@ +import { BaseViewModel } from '../../base/base-view-model'; +import { Config } from '../../../shared/models/core/config'; + +interface ConfigChoice { + value: string; + displayName: string; +} + +/** + * All valid input types for config variables. + */ +type ConfigInputType = + | 'text' + | 'string' + | 'boolean' + | 'markupText' + | 'integer' + | 'majorityMethod' + | 'choice' + | 'datetimepicker' + | 'colorpicker' + | 'translations'; + +/** + * Represents all information that is given in the constant. + */ +interface ConfigConstant { + default_value?: string; + help_text?: string; + input_type: ConfigInputType; + key: string; + label: string; + choices?: ConfigChoice[]; +} + +/** + * The view model for configs. + */ +export class ViewConfig extends BaseViewModel { + /** + * The underlying config. + */ + private _config: Config; + + /* This private members are set by setConstantsInfo. */ + private _helpText: string; + private _inputType: ConfigInputType; + private _label: string; + private _choices: ConfigChoice[]; + + /** + * Saves, if this config already got constants information. + */ + private _hasConstantsInfo = false; + + public get hasConstantsInfo(): boolean { + return this._hasConstantsInfo; + } + + public get config(): Config { + return this._config; + } + + public get id(): number { + return this.config ? this.config.id : null; + } + + public get key(): string { + return this.config ? this.config.key : null; + } + + public get value(): Object { + return this.config ? this.config.value : null; + } + + public get label(): string { + return this._label; + } + + public get inputType(): ConfigInputType { + return this._inputType; + } + + public get helpText(): string { + return this._helpText; + } + + public get choices(): Object { + return this._choices; + } + + public constructor(config: Config) { + super(); + this._config = config; + } + + public getTitle(): string { + return this.label; + } + + public updateValues(update: Config): void { + this._config = update; + } + + /** + * Returns the time this config field needs to debounce before sending a request to the server. + * A little debounce time for all inputs is given here and is usefull, if inputs sends multiple onChange-events, + * like the type="color" input... + */ + public getDebouncingTimeout(): number { + if (this.inputType === 'string' || this.inputType === 'text' || this.inputType === 'markupText') { + return 1000; + } else { + return 100; + } + } + + /** + * This should be called, if the constants are loaded, so all extra info can be updated. + * @param constant The constant info + */ + public setConstantsInfo(constant: ConfigConstant): void { + this._label = constant.label; + this._helpText = constant.help_text; + this._inputType = constant.input_type; + this._choices = constant.choices; + this._hasConstantsInfo = true; + } + + public copy(): ViewConfig { + return new ViewConfig(this._config); + } +} diff --git a/client/src/app/site/config/services/config-repository.service.spec.ts b/client/src/app/site/config/services/config-repository.service.spec.ts new file mode 100644 index 000000000..978ab6abd --- /dev/null +++ b/client/src/app/site/config/services/config-repository.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { ConfigRepositoryService } from './config-repository.service'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('ConfigRepositoryService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [ConfigRepositoryService] + }); + }); + + it('should be created', inject([ConfigRepositoryService], (service: ConfigRepositoryService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/site/config/services/config-repository.service.ts b/client/src/app/site/config/services/config-repository.service.ts new file mode 100644 index 000000000..4c2906e37 --- /dev/null +++ b/client/src/app/site/config/services/config-repository.service.ts @@ -0,0 +1,263 @@ +import { Injectable } from '@angular/core'; + +import { BaseRepository } from '../../base/base-repository'; +import { ViewConfig } from '../models/view-config'; +import { Config } from '../../../shared/models/core/config'; +import { Observable, BehaviorSubject } from 'rxjs'; +import { DataStoreService } from '../../../core/services/data-store.service'; +import { ConstantsService } from '../../../core/services/constants.service'; +import { HttpClient } from '@angular/common/http'; + +/** + * Holds a single config item. + */ +interface ConfigItem { + /** + * The key of this config variable. + */ + key: string; + + /** + * The actual view config for this variable. + */ + config: ViewConfig; + + /** + * The config variable data given in the constants. This is hold here, so the view + * config can be updated with this data. + */ + data: any; +} + +/** + * Represents a config subgroup. It can only holds items and no further groups. + */ +interface ConfigSubgroup { + /** + * The name. + */ + name: string; + + /** + * All items in this sub group. + */ + items: ConfigItem[]; +} + +/** + * Represents a config group with its name, subgroups and direct items. + */ +export interface ConfigGroup { + /** + * The name. + */ + name: string; + + /** + * A list of subgroups. + */ + subgroups: ConfigSubgroup[]; + + /** + * A list of config items that are not in any subgroup. + */ + items: ConfigItem[]; +} + +/** + * Repository for Configs. It overrides some functions of the BaseRepository. So do not use the + * observables given by the base repository, but the {@method getConfigListObservable}. + */ +@Injectable({ + providedIn: 'root' +}) +export class ConfigRepositoryService extends BaseRepository { + /** + * Own store for config groups. + */ + private configs: ConfigGroup[] = null; + + /** + * Own subject for config groups. + */ + protected configListSubject: BehaviorSubject = new BehaviorSubject(null); + + /** + * Constructor for ConfigRepositoryService. Requests the constants from the server and creates the config group structure. + */ + public constructor(DS: DataStoreService, private constantsService: ConstantsService, private http: HttpClient) { + super(DS, Config); + + this.constantsService.get('OpenSlidesConfigVariables').subscribe(constant => { + this.createConfigStructure(constant); + this.updateConfigStructure(...Object.values(this.viewModelStore)); + this.updateConfigListObservable(); + }); + } + + /** + * Overwritten setup. Does only care about the custom list observable and inserts changed configs into the + * config group structure. + */ + protected setup(): void { + if (!this.configListSubject) { + this.configListSubject = new BehaviorSubject(null); + } + + this.DS.getAll(Config).forEach((config: Config) => { + this.viewModelStore[config.id] = this.createViewModel(config); + this.updateConfigStructure(this.viewModelStore[config.id]); + }); + this.updateConfigListObservable(); + + // Could be raise in error if the root injector is not known + this.DS.changeObservable.subscribe(model => { + if (model instanceof Config) { + this.viewModelStore[model.id] = this.createViewModel(model as Config); + this.updateConfigStructure(this.viewModelStore[model.id]); + this.updateConfigListObservable(); + } + }); + } + + /** + * Custom observer for the config + */ + public getConfigListObservable(): Observable { + return this.configListSubject.asObservable(); + } + + /** + * Custom notification for the observers. + */ + protected updateConfigListObservable(): void { + if (this.configs) { + this.configListSubject.next(this.configs); + } + } + + /** + * Getter for the config structure + */ + public getConfigStructure(): ConfigGroup[] { + return this.configs; + } + + /** + * With a given (and maybe partially filled) config structure, all given view configs are put into it. + * @param viewConfigs All view configs to put into the structure + */ + protected updateConfigStructure(...viewConfigs: ViewConfig[]): void { + if (!this.configs) { + return; + } + + // Map the viewConfigs to their keys. + const keyConfigMap: { [key: string]: ViewConfig } = {}; + viewConfigs.forEach(viewConfig => { + keyConfigMap[viewConfig.key] = viewConfig; + }); + + // traverse through configs structure and replace all given viewConfigs + for (const group of this.configs) { + for (const subgroup of group.subgroups) { + for (const item of subgroup.items) { + if (keyConfigMap[item.key]) { + keyConfigMap[item.key].setConstantsInfo(item.data); + item.config = keyConfigMap[item.key]; + } + } + } + for (const item of group.items) { + if (keyConfigMap[item.key]) { + keyConfigMap[item.key].setConstantsInfo(item.data); + item.config = keyConfigMap[item.key]; + } + } + } + } + + /** + * Saves a config value. + */ + public update(config: Partial, viewConfig: ViewConfig): Observable { + const updatedConfig = new Config(); + updatedConfig.patchValues(viewConfig.config); + updatedConfig.patchValues(config); + // TODO: Use datasendService, if it can switch correctly between put, post and patch + return this.http.put( + 'rest/' + updatedConfig.collectionString + '/' + updatedConfig.key + '/', + updatedConfig + ); + } + + /** + * This particular function should never be necessary since the creation of config + * values is not planed. + * + * Function exists solely to correctly implement {@link BaseRepository} + */ + public delete(config: ViewConfig): Observable { + throw new Error('Config variables cannot be deleted'); + } + + /** + * This particular function should never be necessary since the creation of config + * values is not planed. + * + * Function exists solely to correctly implement {@link BaseRepository} + */ + public create(config: Config): Observable { + throw new Error('Config variables cannot be created'); + } + + /** + * Creates a new ViewConfig of a given Config object + * @param config + */ + public createViewModel(config: Config): ViewConfig { + const vm = new ViewConfig(config); + return vm; + } + + /** + * initially create the config structure from the given constant. + * @param constant + */ + private createConfigStructure(constant: any): void { + this.configs = []; + for (const group of constant) { + const _group: ConfigGroup = { + name: group.name, + subgroups: [], + items: [] + }; + // The server always sends subgroups. But if it has an empty name, there is no subgroup.. + for (const subgroup of group.subgroups) { + if (subgroup.name) { + const _subgroup: ConfigSubgroup = { + name: subgroup.name, + items: [] + }; + for (const item of subgroup.items) { + _subgroup.items.push({ + key: item.key, + config: null, + data: item + }); + } + _group.subgroups.push(_subgroup); + } else { + for (const item of subgroup.items) { + _group.items.push({ + key: item.key, + config: null, + data: item + }); + } + } + } + this.configs.push(_group); + } + } +} diff --git a/client/src/app/site/login/assets/login-info-pages.scss b/client/src/app/site/login/assets/login-info-pages.scss new file mode 100644 index 000000000..cdc4eb7c1 --- /dev/null +++ b/client/src/app/site/login/assets/login-info-pages.scss @@ -0,0 +1,15 @@ +.title-center { + margin: 0 auto; +} + +mat-toolbar { + text-align: center; + display: grid; +} + +button { + display: block; + margin-left: auto; + margin-right: auto; + margin-top: 25px; +} diff --git a/client/src/app/site/login/components/login-legal-notice/login-legal-notice.component.html b/client/src/app/site/login/components/login-legal-notice/login-legal-notice.component.html new file mode 100644 index 000000000..2de984b0f --- /dev/null +++ b/client/src/app/site/login/components/login-legal-notice/login-legal-notice.component.html @@ -0,0 +1,10 @@ +
+ + +

Legal Notice

+
+ + + + +
diff --git a/client/src/app/site/login/components/login-legal-notice/login-legal-notice.component.scss b/client/src/app/site/login/components/login-legal-notice/login-legal-notice.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/site/login/components/login-legal-notice/login-legal-notice.component.spec.ts b/client/src/app/site/login/components/login-legal-notice/login-legal-notice.component.spec.ts new file mode 100644 index 000000000..bc643b025 --- /dev/null +++ b/client/src/app/site/login/components/login-legal-notice/login-legal-notice.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoginLegalNoticeComponent } from './login-legal-notice.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; + +describe('LoginLegalNoticeComponent', () => { + let component: LoginLegalNoticeComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginLegalNoticeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/login/components/login-legal-notice/login-legal-notice.component.ts b/client/src/app/site/login/components/login-legal-notice/login-legal-notice.component.ts new file mode 100644 index 000000000..44d9032b0 --- /dev/null +++ b/client/src/app/site/login/components/login-legal-notice/login-legal-notice.component.ts @@ -0,0 +1,22 @@ +import { Component, OnInit } from '@angular/core'; + +/** + * Container to display the legal notice on the login page. + * Uses the corresponding shared component + */ +@Component({ + selector: 'os-login-legal-notice', + templateUrl: './login-legal-notice.component.html', + styleUrls: ['./login-legal-notice.component.scss', '../../assets/login-info-pages.scss'] +}) +export class LoginLegalNoticeComponent implements OnInit { + /** + * Empty constructor + */ + public constructor() {} + + /** + * Empty onInit + */ + public ngOnInit(): void {} +} diff --git a/client/src/app/site/login/components/login-mask/login-mask.component.html b/client/src/app/site/login/components/login-mask/login-mask.component.html new file mode 100644 index 000000000..2361d97dc --- /dev/null +++ b/client/src/app/site/login/components/login-mask/login-mask.component.html @@ -0,0 +1,27 @@ +
+ + +
diff --git a/client/src/app/site/login/components/login-mask/login-mask.component.scss b/client/src/app/site/login/components/login-mask/login-mask.component.scss new file mode 100644 index 000000000..8aa5bee02 --- /dev/null +++ b/client/src/app/site/login/components/login-mask/login-mask.component.scss @@ -0,0 +1,34 @@ +mat-form-field { + width: 100%; +} + +.forgot-password-button { + float: right; + padding: 0; + text-align: right; +} + +.login-button { + margin-top: 30px; + width: 100%; + margin-left: auto; + margin-right: auto; +} + +.form-wrapper { + padding-left: 30px; + padding-right: 30px; + mat-spinner { + position: absolute; + left: 0; + right: 0; + margin-left: auto; + margin-right: auto; + } +} + +.login-form { + padding-top: 60px; + margin: 0 auto; + max-width: 400px; +} diff --git a/client/src/app/site/login/components/login-mask/login-mask.component.spec.ts b/client/src/app/site/login/components/login-mask/login-mask.component.spec.ts new file mode 100644 index 000000000..d4c1ad3ef --- /dev/null +++ b/client/src/app/site/login/components/login-mask/login-mask.component.spec.ts @@ -0,0 +1,31 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoginMaskComponent } from './login-mask.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; + +describe('LoginMaskComponent', () => { + let component: LoginMaskComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginMaskComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + // TODO: mock HTTPClient + /*it('should have an forget password button', async(() => { + const compiled = fixture.debugElement.nativeElement; + expect(compiled.querySelector('.forgot-password-button').textContent).toContain('Forgot Password?'); + }));*/ +}); diff --git a/client/src/app/site/login/components/login-mask/login-mask.component.ts b/client/src/app/site/login/components/login-mask/login-mask.component.ts new file mode 100644 index 000000000..d5d352cf1 --- /dev/null +++ b/client/src/app/site/login/components/login-mask/login-mask.component.ts @@ -0,0 +1,166 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { Router } from '@angular/router'; + +import { BaseComponent } from 'app/base.component'; +import { AuthService } from 'app/core/services/auth.service'; +import { OperatorService } from 'app/core/services/operator.service'; +import { MatSnackBar, MatSnackBarRef, SimpleSnackBar } from '@angular/material'; +import { FormGroup, Validators, FormBuilder } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { HttpErrorResponse, HttpClient } from '@angular/common/http'; +import { environment } from 'environments/environment'; +import { OpenSlidesService } from '../../../../core/services/openslides.service'; +import { LoginDataService } from '../../../../core/services/login-data.service'; +import { ParentErrorStateMatcher } from '../../../../shared/parent-error-state-matcher'; + +/** + * Login mask component. + * + * Handles user and guest login + */ +@Component({ + selector: 'os-login-mask', + templateUrl: './login-mask.component.html', + styleUrls: ['./login-mask.component.scss'] +}) +export class LoginMaskComponent extends BaseComponent implements OnInit, OnDestroy { + /** + * Show or hide password and change the indicator accordingly + */ + public hide: boolean; + + /** + * Reference to the SnackBarEntry for the installation notice send by the server. + */ + private installationNotice: MatSnackBarRef; + + /** + * Login Error Message if any + */ + public loginErrorMsg = ''; + + /** + * Form group for the login form + */ + public loginForm: FormGroup; + + /** + * Custom Form validation + */ + public parentErrorStateMatcher = new ParentErrorStateMatcher(); + + /** + * Show the Spinner if validation is in process + */ + public inProcess = false; + + /** + * Constructor for the login component + * + * @param authService Authenticating the user + * @param operator The representation of the current user + * @param router forward to start page + * @param formBuilder To build the form and validate + * @param http used to get information before the login + * @param matSnackBar Display information + * @param OpenSlides The Service for OpenSlides + * @param loginDataService provide information about the legal notice and privacy policy + */ + public constructor( + protected translate: TranslateService, + private authService: AuthService, + private operator: OperatorService, + private router: Router, + private formBuilder: FormBuilder, + private http: HttpClient, + private matSnackBar: MatSnackBar, + private OpenSlides: OpenSlidesService, + private loginDataService: LoginDataService + ) { + super(); + this.createForm(); + } + + /** + * Init. + * + * Set the title to "Log In" + * Observes the operator, if a user was already logged in, recreate to user and skip the login + */ + public ngOnInit(): void { + // Get the login data. Save information to the login data service + this.http.get(environment.urlPrefix + '/users/login/', {}).subscribe(response => { + if (response.info_text) { + this.installationNotice = this.matSnackBar.open(response.info_text, this.translate.instant('OK'), { + duration: 5000 + }); + } + this.loginDataService.setPrivacyPolicy(response.privacy_policy); + this.loginDataService.setLegalNotice(response.legal_notice); + }); + } + + public ngOnDestroy(): void { + if (this.installationNotice) { + this.installationNotice.dismiss(); + } + } + + /** + * Create the login Form + */ + public createForm(): void { + this.loginForm = this.formBuilder.group({ + username: ['', [Validators.required, Validators.maxLength(128)]], + password: ['', [Validators.required, Validators.maxLength(128)]] + }); + } + + /** + * Actual login function triggered by the form. + * + * Send username and password to the {@link AuthService} + */ + public formLogin(): void { + this.loginErrorMsg = ''; + this.inProcess = true; + this.authService.login(this.loginForm.value.username, this.loginForm.value.password).subscribe(res => { + this.inProcess = false; + + if (res instanceof HttpErrorResponse) { + this.loginForm.setErrors({ + notFound: true + }); + this.loginErrorMsg = res.error.detail; + } else { + this.OpenSlides.afterLoginBootup(res.user_id); + let redirect = this.OpenSlides.redirectUrl ? this.OpenSlides.redirectUrl : '/'; + if (redirect.includes('login')) { + redirect = '/'; + } + this.router.navigate([redirect]); + } + }); + } + + /** + * TODO, should open an edit view for the users password. + */ + public resetPassword(): void { + console.log('TODO'); + } + + /** + * returns if the anonymous is enabled. + */ + public areGuestsEnabled(): boolean { + return this.operator.guestsEnabled; + } + + /** + * Guests (if enabled) can navigate directly to the main page. + */ + public guestLogin(): void { + this.router.navigate(['/']); + } +} diff --git a/client/src/app/site/login/components/login-privacy-policy/login-privacy-policy.component.html b/client/src/app/site/login/components/login-privacy-policy/login-privacy-policy.component.html new file mode 100644 index 000000000..9f2be700c --- /dev/null +++ b/client/src/app/site/login/components/login-privacy-policy/login-privacy-policy.component.html @@ -0,0 +1,10 @@ +
+ + +

Privacy Policy

+
+ + + + +
diff --git a/client/src/app/site/login/components/login-privacy-policy/login-privacy-policy.component.scss b/client/src/app/site/login/components/login-privacy-policy/login-privacy-policy.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/site/login/components/login-privacy-policy/login-privacy-policy.component.spec.ts b/client/src/app/site/login/components/login-privacy-policy/login-privacy-policy.component.spec.ts new file mode 100644 index 000000000..68be4f21c --- /dev/null +++ b/client/src/app/site/login/components/login-privacy-policy/login-privacy-policy.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoginPrivacyPolicyComponent } from './login-privacy-policy.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; + +describe('LoginPrivacyPolicyComponent', () => { + let component: LoginPrivacyPolicyComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginPrivacyPolicyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/login/components/login-privacy-policy/login-privacy-policy.component.ts b/client/src/app/site/login/components/login-privacy-policy/login-privacy-policy.component.ts new file mode 100644 index 000000000..213c70243 --- /dev/null +++ b/client/src/app/site/login/components/login-privacy-policy/login-privacy-policy.component.ts @@ -0,0 +1,22 @@ +import { Component, OnInit } from '@angular/core'; + +/** + * Container to display the privacy policy on the login page. + * Uses the corresponding shared component + */ +@Component({ + selector: 'os-login-privacy-policy', + templateUrl: './login-privacy-policy.component.html', + styleUrls: ['./login-privacy-policy.component.scss', '../../assets/login-info-pages.scss'] +}) +export class LoginPrivacyPolicyComponent implements OnInit { + /** + * Empty Constructor + */ + public constructor() {} + + /** + * Empty onInit + */ + public ngOnInit(): void {} +} diff --git a/client/src/app/site/login/components/login-wrapper/login.component.html b/client/src/app/site/login/components/login-wrapper/login.component.html new file mode 100644 index 000000000..29826247f --- /dev/null +++ b/client/src/app/site/login/components/login-wrapper/login.component.html @@ -0,0 +1,12 @@ + +
+ +
+
+ +
+
+ +
diff --git a/client/src/app/site/login/components/login-wrapper/login.component.scss b/client/src/app/site/login/components/login-wrapper/login.component.scss new file mode 100644 index 000000000..44bf6c513 --- /dev/null +++ b/client/src/app/site/login/components/login-wrapper/login.component.scss @@ -0,0 +1,16 @@ +header { + width: 100%; + flex: 1; + mat-toolbar { + min-height: 200px !important; + } + .login-logo-bar { + img { + margin-left: auto; + margin-right: auto; + width: 90%; + height: 90%; + max-width: 400px; + } + } +} diff --git a/client/src/app/site/login/components/login-wrapper/login.component.spec.ts b/client/src/app/site/login/components/login-wrapper/login.component.spec.ts new file mode 100644 index 000000000..bf0455456 --- /dev/null +++ b/client/src/app/site/login/components/login-wrapper/login.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoginComponent } from './login.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/login/components/login-wrapper/login.component.ts b/client/src/app/site/login/components/login-wrapper/login.component.ts new file mode 100644 index 000000000..254a4e60f --- /dev/null +++ b/client/src/app/site/login/components/login-wrapper/login.component.ts @@ -0,0 +1,34 @@ +import { Component, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { TranslateService } from '@ngx-translate/core'; + +import { BaseComponent } from '../../../../base.component'; + +/** + * Login component. + * + * Serves as container for the login mask, legal notice and privacy policy + */ +@Component({ + selector: 'os-login', + templateUrl: './login.component.html', + styleUrls: ['./login.component.scss'] +}) +export class LoginComponent extends BaseComponent implements OnInit { + /** + * Imports the title service and the translate service + * + * @param titleService to set the title + * @param translate just needed because super.setTitle depends in the `translator.instant` function + */ + public constructor(protected titleService: Title, protected translate: TranslateService) { + super(titleService, translate); + } + + /** + * sets the title of the page + */ + public ngOnInit(): void { + super.setTitle('Login'); + } +} diff --git a/client/src/app/site/login/login.module.spec.ts b/client/src/app/site/login/login.module.spec.ts new file mode 100644 index 000000000..abd766545 --- /dev/null +++ b/client/src/app/site/login/login.module.spec.ts @@ -0,0 +1,13 @@ +import { LoginModule } from './login.module'; + +describe('LoginModule', () => { + let loginModule: LoginModule; + + beforeEach(() => { + loginModule = new LoginModule(); + }); + + it('should create an instance', () => { + expect(loginModule).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/login/login.module.ts b/client/src/app/site/login/login.module.ts new file mode 100644 index 000000000..499c95e1e --- /dev/null +++ b/client/src/app/site/login/login.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; + +import { LoginComponent } from './components/login-wrapper/login.component'; +import { SharedModule } from '../../shared/shared.module'; +import { LoginMaskComponent } from './components/login-mask/login-mask.component'; +import { LoginLegalNoticeComponent } from './components/login-legal-notice/login-legal-notice.component'; +import { LoginPrivacyPolicyComponent } from './components/login-privacy-policy/login-privacy-policy.component'; + +@NgModule({ + imports: [CommonModule, RouterModule, SharedModule], + declarations: [LoginComponent, LoginMaskComponent, LoginLegalNoticeComponent, LoginPrivacyPolicyComponent] +}) +export class LoginModule {} diff --git a/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.css b/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.css new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.html b/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.html new file mode 100644 index 000000000..f6551c7ff --- /dev/null +++ b/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.html @@ -0,0 +1,33 @@ + + + + + + + + Name + {{file.title}} + + + + + Group + + {{file.type}} +
+ {{file.size}} +
+
+ + + Download + + save_alt + + + + + +
+ + diff --git a/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.spec.ts b/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.spec.ts new file mode 100644 index 000000000..a73b7b4dc --- /dev/null +++ b/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MediafileListComponent } from './mediafile-list.component'; +import { E2EImportsModule } from '../../../../e2e-imports.module'; + +describe('MediafileListComponent', () => { + let component: MediafileListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [MediafileListComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MediafileListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.ts b/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.ts new file mode 100644 index 000000000..dfc6249e1 --- /dev/null +++ b/client/src/app/site/mediafiles/mediafile-list/mediafile-list.component.ts @@ -0,0 +1,91 @@ +import { Component, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; + +import { ViewMediafile } from '../models/view-mediafile'; +import { MediafileRepositoryService } from '../services/mediafile-repository.service'; +import { ListViewBaseComponent } from '../../base/list-view-base'; + +/** + * Lists all the uploaded files. + * + */ +@Component({ + selector: 'os-mediafile-list', + templateUrl: './mediafile-list.component.html', + styleUrls: ['./mediafile-list.component.css'] +}) +export class MediafileListComponent extends ListViewBaseComponent implements OnInit { + /** + * Define the content of the ellipsis menu. + * Give it to the HeadBar to display them. + */ + public extraMenu = [ + { + text: 'Download', + icon: 'save_alt', + action: 'downloadAllFiles' + } + ]; + + /** + * Constructor + * + * @param repo the repository for files + * @param titleService + * @param translate + */ + public constructor( + private repo: MediafileRepositoryService, + protected titleService: Title, + protected translate: TranslateService + ) { + super(titleService, translate); + } + + /** + * Init. + * Set the title + */ + public ngOnInit(): void { + super.setTitle('Files'); + this.initTable(); + this.repo.getViewModelListObservable().subscribe(newUsers => { + this.dataSource.data = newUsers; + }); + } + + /** + * Click on the plus button delegated from head-bar + */ + public onPlusButton(): void { + console.log('clicked plus (mediafile)'); + } + + /** + * function to Download all files + * (serves as example to use functions on head bar) + * + * TODO: Not yet implemented, might not even be required + */ + public deleteAllFiles(): void { + console.log('do download'); + } + + /** + * Clicking on a list row + * @param file the selected file + */ + public selectFile(file: ViewMediafile): void { + console.log('The file: ', file); + } + + /** + * Directly download a mediafile using the download button on the table + * @param file + */ + public download(file: ViewMediafile): void { + window.open(file.downloadUrl); + } +} diff --git a/client/src/app/site/mediafiles/mediafile.config.ts b/client/src/app/site/mediafiles/mediafile.config.ts new file mode 100644 index 000000000..91c19130a --- /dev/null +++ b/client/src/app/site/mediafiles/mediafile.config.ts @@ -0,0 +1,16 @@ +import { AppConfig } from '../base/app-config'; +import { Mediafile } from '../../shared/models/mediafiles/mediafile'; + +export const MediafileAppConfig: AppConfig = { + name: 'mediafiles', + models: [{ collectionString: 'mediafiles/mediafile', model: Mediafile }], + mainMenuEntries: [ + { + route: '/mediafiles', + displayName: 'Files', + icon: 'attach_file', + weight: 600, + permission: 'mediafiles.can_see' + } + ] +}; diff --git a/client/src/app/site/mediafiles/mediafiles-routing.module.ts b/client/src/app/site/mediafiles/mediafiles-routing.module.ts new file mode 100644 index 000000000..47e99d6a1 --- /dev/null +++ b/client/src/app/site/mediafiles/mediafiles-routing.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { MediafileListComponent } from './mediafile-list/mediafile-list.component'; + +const routes: Routes = [{ path: '', component: MediafileListComponent }]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class MediafilesRoutingModule {} diff --git a/client/src/app/site/mediafiles/mediafiles.module.spec.ts b/client/src/app/site/mediafiles/mediafiles.module.spec.ts new file mode 100644 index 000000000..80c559f7f --- /dev/null +++ b/client/src/app/site/mediafiles/mediafiles.module.spec.ts @@ -0,0 +1,13 @@ +import { MediafilesModule } from './mediafiles.module'; + +describe('MediafilesModule', () => { + let mediafilesModule: MediafilesModule; + + beforeEach(() => { + mediafilesModule = new MediafilesModule(); + }); + + it('should create an instance', () => { + expect(mediafilesModule).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/mediafiles/mediafiles.module.ts b/client/src/app/site/mediafiles/mediafiles.module.ts new file mode 100644 index 000000000..cb645cf69 --- /dev/null +++ b/client/src/app/site/mediafiles/mediafiles.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { MediafilesRoutingModule } from './mediafiles-routing.module'; +import { SharedModule } from '../../shared/shared.module'; +import { MediafileListComponent } from './mediafile-list/mediafile-list.component'; + +@NgModule({ + imports: [CommonModule, MediafilesRoutingModule, SharedModule], + declarations: [MediafileListComponent] +}) +export class MediafilesModule {} diff --git a/client/src/app/site/mediafiles/models/view-mediafile.ts b/client/src/app/site/mediafiles/models/view-mediafile.ts new file mode 100644 index 000000000..725cf2c94 --- /dev/null +++ b/client/src/app/site/mediafiles/models/view-mediafile.ts @@ -0,0 +1,60 @@ +import { BaseViewModel } from '../../base/base-view-model'; +import { Mediafile } from '../../../shared/models/mediafiles/mediafile'; +import { User } from '../../../shared/models/users/user'; + +export class ViewMediafile extends BaseViewModel { + private _mediafile: Mediafile; + private _uploader: User; + + public get id(): number { + return this._mediafile ? this._mediafile.id : null; + } + + public get mediafile(): Mediafile { + return this._mediafile; + } + + public get uploader(): User { + return this._uploader; + } + + public get title(): string { + return this.mediafile ? this.mediafile.title : null; + } + + public get size(): string { + return this.mediafile ? this.mediafile.filesize : null; + } + + public get type(): string { + return this.mediafile && this.mediafile.mediafile ? this.mediafile.mediafile.type : null; + } + + public get prefix(): string { + return this.mediafile ? this.mediafile.media_url_prefix : null; + } + + public get fileName(): string { + return this.mediafile && this.mediafile.mediafile ? this.mediafile.mediafile.name : null; + } + + public get downloadUrl(): string { + return this.mediafile && this.mediafile.mediafile ? `${this.prefix}${this.fileName}` : null; + } + + public constructor(mediafile?: Mediafile, uploader?: User) { + super(); + this._mediafile = mediafile; + this._uploader = uploader; + } + + public getTitle(): string { + return this.title; + } + + public updateValues(update: Mediafile): void { + if (this.mediafile.id === update.id) { + this._mediafile = update; + } + } +} diff --git a/client/src/app/site/mediafiles/services/mediafile-repository.service.spec.ts b/client/src/app/site/mediafiles/services/mediafile-repository.service.spec.ts new file mode 100644 index 000000000..a65a72d1f --- /dev/null +++ b/client/src/app/site/mediafiles/services/mediafile-repository.service.spec.ts @@ -0,0 +1,12 @@ +import { TestBed } from '@angular/core/testing'; + +import { MediafileRepositoryService } from './mediafile-repository.service'; + +describe('FileRepositoryService', () => { + beforeEach(() => TestBed.configureTestingModule({})); + + it('should be created', () => { + const service: MediafileRepositoryService = TestBed.get(MediafileRepositoryService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/mediafiles/services/mediafile-repository.service.ts b/client/src/app/site/mediafiles/services/mediafile-repository.service.ts new file mode 100644 index 000000000..c4a03adee --- /dev/null +++ b/client/src/app/site/mediafiles/services/mediafile-repository.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@angular/core'; + +import { BaseRepository } from '../../base/base-repository'; +import { ViewMediafile } from '../models/view-mediafile'; +import { Mediafile } from '../../../shared/models/mediafiles/mediafile'; +import { User } from '../../../shared/models/users/user'; +import { Observable } from 'rxjs'; +import { DataStoreService } from '../../../core/services/data-store.service'; + +/** + * Repository for files + */ +@Injectable({ + providedIn: 'root' +}) +export class MediafileRepositoryService extends BaseRepository { + /** + * Consturctor for the file repo + * @param DS the DataStore + */ + public constructor(DS: DataStoreService) { + super(DS, Mediafile, [User]); + } + + /** + * Saves a config value. + * + * TODO: used over not-yet-existing detail view + */ + public update(file: Partial, viewFile: ViewMediafile): Observable { + return null; + } + + /** + * Saves a config value. + * + * TODO: used over not-yet-existing detail view + */ + public delete(file: ViewMediafile): Observable { + return null; + } + + /** + * Saves a config value. + * + * TODO: used over not-yet-existing detail view + */ + public create(file: Mediafile): Observable { + return null; + } + + public createViewModel(file: Mediafile): ViewMediafile { + const uploader = this.DS.get(User, file.uploader_id); + return new ViewMediafile(file, uploader); + } +} diff --git a/client/src/app/site/motions/components/category-list/category-list.component.html b/client/src/app/site/motions/components/category-list/category-list.component.html new file mode 100644 index 000000000..f34e1ba02 --- /dev/null +++ b/client/src/app/site/motions/components/category-list/category-list.component.html @@ -0,0 +1,58 @@ + + +
+ +
+ + + + + {{category.name}} + + + {{this.formGroup.get('name').value}} + + + {{category.prefix}} + + + {{this.formGroup.get('prefix').value}} + + +
+ Edit category details:
+ + + + Required + + + + + + Required + + +
+ + + + + + +
+
diff --git a/client/src/app/site/motions/components/category-list/category-list.component.scss b/client/src/app/site/motions/components/category-list/category-list.component.scss new file mode 100644 index 000000000..1826997c7 --- /dev/null +++ b/client/src/app/site/motions/components/category-list/category-list.component.scss @@ -0,0 +1,44 @@ +.button-side { + right: 0; + top: 0px; + float: right; +} + +.text-side { + size: 50%; +} + +.content-row { + size: 100%; +} + +.new { + // put in theme later + background-color: lightblue; +} + +.mini-button { + top: 0px; + width: 20px; + height: 20px; + min-height: 20px; + font-size: 10px; + box-shadow: none; + vertical-align: top; + padding: 0 0; + margin: 0; +} + +.onethird { + width: 33%; +} + +.custom-table-header { + // display: none; + width: 100%; + height: 60px; + line-height: 60px; + text-align: right; + background: white; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); +} diff --git a/client/src/app/site/motions/components/category-list/category-list.component.spec.ts b/client/src/app/site/motions/components/category-list/category-list.component.spec.ts new file mode 100644 index 000000000..3c301e2a4 --- /dev/null +++ b/client/src/app/site/motions/components/category-list/category-list.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CategoryListComponent } from './category-list.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; + +describe('CategoryListComponent', () => { + let component: CategoryListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [CategoryListComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CategoryListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/components/category-list/category-list.component.ts b/client/src/app/site/motions/components/category-list/category-list.component.ts new file mode 100644 index 000000000..64f5ae8c6 --- /dev/null +++ b/client/src/app/site/motions/components/category-list/category-list.component.ts @@ -0,0 +1,226 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; + +import { BaseComponent } from '../../../../base.component'; +import { Category } from '../../../../shared/models/motions/category'; +import { CategoryRepositoryService } from '../../services/category-repository.service'; +import { ViewCategory } from '../../models/view-category'; +import { FormGroup, FormBuilder, Validators } from '@angular/forms'; + +/** + * List view for the categories. + */ +@Component({ + selector: 'os-category-list', + templateUrl: './category-list.component.html', + styleUrls: ['./category-list.component.scss'] +}) +export class CategoryListComponent extends BaseComponent implements OnInit, OnDestroy { + /** + * States the edit mode + */ + public editMode = false; + + /** + * Source of the Data + */ + public dataSource: Array; + + /** + * The current focussed formgroup + */ + public formGroup: FormGroup; + + /** + * The usual component constructor + * @param titleService + * @param translate + * @param repo + * @param formBuilder + */ + public constructor( + protected titleService: Title, + protected translate: TranslateService, + private repo: CategoryRepositoryService, + private formBuilder: FormBuilder + ) { + super(titleService, translate); + this.formGroup = this.formBuilder.group({ + name: ['', Validators.required], + prefix: ['', Validators.required] + }); + } + + /** + * On Destroy Function + * + * Saves the edits + */ + public ngOnDestroy(): void { + this.dataSource.forEach(viewCategory => { + if (viewCategory.edit && viewCategory.opened) { + const nameControl = this.formGroup.get('name'); + const prefixControl = this.formGroup.get('prefix'); + const nameValue = nameControl.value; + const prefixValue = prefixControl.value; + viewCategory.name = nameValue; + viewCategory.prefix = prefixValue; + this.saveCategory(viewCategory); + } + }); + } + + /** + * Event on Key Down in form + */ + public keyDownFunction(event: KeyboardEvent, viewCategory: ViewCategory): void { + if (event.keyCode === 13) { + this.onSaveButton(viewCategory); + } + } + + /** + * Stores the Datamodel in the repo + * @param viewCategory + */ + private saveCategory(viewCategory: ViewCategory): void { + if (this.repo.osInDataStore(viewCategory)) { + this.repo.update(viewCategory.category).subscribe(); + } else { + this.repo.create(viewCategory.category, viewCategory).subscribe(); + } + viewCategory.edit = false; + } + /** + * Init function. + * + * Sets the title and gets/observes categories from DataStore + */ + public ngOnInit(): void { + super.setTitle('Category'); + this.repo.getViewModelListObservable().subscribe(newViewCategories => { + this.dataSource = newViewCategories; + }); + this.sortDataSource(); + } + + /** + * Add a new Category. + */ + public onPlusButton(): void { + let noNewOnes = true; + this.dataSource.forEach(viewCategory => { + if (viewCategory.id === undefined) { + noNewOnes = false; + } + }); + if (noNewOnes) { + const newCategory = new Category(); + newCategory.id = undefined; + newCategory.name = this.translate.instant('Name'); + newCategory.prefix = this.translate.instant('Prefix'); + const newViewCategory = new ViewCategory(newCategory); + newViewCategory.opened = true; + this.dataSource.reverse(); + this.dataSource.push(newViewCategory); + this.dataSource.reverse(); + this.editMode = true; + } + } + + /** + * Executed on edit button + * @param viewCategory + */ + public onEditButton(viewCategory: ViewCategory): void { + viewCategory.edit = true; + viewCategory.synced = false; + this.editMode = true; + const nameControl = this.formGroup.get('name'); + const prefixControl = this.formGroup.get('prefix'); + nameControl.setValue(viewCategory.name); + prefixControl.setValue(viewCategory.prefix); + } + + /** + * Saves the categories + */ + public onSaveButton(viewCategory: ViewCategory): void { + if (this.formGroup.controls.name.valid && this.formGroup.controls.prefix.valid) { + this.editMode = false; + const nameControl = this.formGroup.get('name'); + const prefixControl = this.formGroup.get('prefix'); + const nameValue = nameControl.value; + const prefixValue = prefixControl.value; + if ( + viewCategory.id === undefined || + nameValue !== viewCategory.name || + prefixValue !== viewCategory.prefix + ) { + viewCategory.prefix = prefixValue; + viewCategory.name = nameValue; + this.saveCategory(viewCategory); + } + } + this.sortDataSource(); + } + + /** + * sorts the datasource by prefix alphabetically + */ + protected sortDataSource(): void { + this.dataSource.sort((viewCategory1, viewCategory2) => { + if (viewCategory1.prefix > viewCategory2.prefix) { + return 1; + } + if (viewCategory1.prefix < viewCategory2.prefix) { + return -1; + } + }); + } + + /** + * executed on cancel button + * @param viewCategory + */ + public onCancelButton(viewCategory: ViewCategory): void { + viewCategory.edit = false; + this.editMode = false; + } + + /** + * is executed, when the delete button is pressed + */ + public onDeleteButton(viewCategory: ViewCategory): void { + if (this.repo.osInDataStore(viewCategory) && viewCategory.id !== undefined) { + this.repo.delete(viewCategory).subscribe(); + } + const index = this.dataSource.indexOf(viewCategory, 0); + if (index > -1) { + this.dataSource.splice(index, 1); + } + // if no category is there, we setill have to be able to create one + if (this.dataSource.length < 1) { + this.editMode = false; + } + } + + /** + * Is executed when a mat-extension-panel is opened or closed + * @param open true if opened, false if being closed + * @param category the category in the panel + */ + public panelOpening(open: boolean, category: ViewCategory): void { + category.opened = open as boolean; + if (category.edit === true) { + this.onSaveButton(category); + this.onCancelButton(category); + } + if (!open) { + category.edit = false; + this.editMode = false; + } + } +} diff --git a/client/src/app/site/motions/components/motion-comment-section-list/motion-comment-section-list.component.html b/client/src/app/site/motions/components/motion-comment-section-list/motion-comment-section-list.component.html new file mode 100644 index 000000000..5d08698a5 --- /dev/null +++ b/client/src/app/site/motions/components/motion-comment-section-list/motion-comment-section-list.component.html @@ -0,0 +1,108 @@ + + +
+ + Create new comment section + +
+

+ + + + Required + + +

+

+ +

+

+ +

+
+
+ + + + +
+ + + + +
+
+ {{ section.name }} +
+
+ visibility + {{ section.read_groups }} + + – + +
+
+ add + {{ section.write_groups }} + + – + +
+
+
+
+
+ Edit section details: +

+ + + + Required + + +

+

+ +

+

+ +

+
+ +

Name

+
{{ section.name }}
+

Groups with read permissions

+
    +
  • {{ group.getTitle() }}
  • +
+
No groups selected
+

Groups with write permissions

+
    +
  • {{ group.getTitle() }}
  • +
+
No groups selected
+
+ + + + + + +
+
diff --git a/client/src/app/site/motions/components/motion-comment-section-list/motion-comment-section-list.component.scss b/client/src/app/site/motions/components/motion-comment-section-list/motion-comment-section-list.component.scss new file mode 100644 index 000000000..326f0bbde --- /dev/null +++ b/client/src/app/site/motions/components/motion-comment-section-list/motion-comment-section-list.component.scss @@ -0,0 +1,49 @@ +.head-spacer { + width: 100%; + height: 60px; + line-height: 60px; + text-align: right; + background: white; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); +} + +mat-card { + margin-bottom: 20px; +} + +.header-container { + display: grid; + grid-template-rows: auto; + grid-template-columns: 33.333% 33.333% 33.333%; + width: 100%; + + > div { + grid-row-start: 1; + grid-row-end: span 1; + grid-column-end: span 1; + } + + .title { + grid-column-start: 1; + } + + .read { + grid-column-start: 2; + } + + .write { + grid-column-start: 3; + } +} + +h3 { + display: block; + margin-top: 12px; //distance between heading and text + margin-bottom: 3px; //distance between heading and text + font-size: 90%; + color: gray; +} + +.spacer-left { + margin-left: 40px; +} diff --git a/client/src/app/site/motions/components/motion-comment-section-list/motion-comment-section-list.component.spec.ts b/client/src/app/site/motions/components/motion-comment-section-list/motion-comment-section-list.component.spec.ts new file mode 100644 index 000000000..a89c54d39 --- /dev/null +++ b/client/src/app/site/motions/components/motion-comment-section-list/motion-comment-section-list.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MotionCommentSectionListComponent } from './motion-comment-section-list.component'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('MotionCommentSectionListComponent', () => { + let component: MotionCommentSectionListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [MotionCommentSectionListComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MotionCommentSectionListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/components/motion-comment-section-list/motion-comment-section-list.component.ts b/client/src/app/site/motions/components/motion-comment-section-list/motion-comment-section-list.component.ts new file mode 100644 index 000000000..b1b118bd6 --- /dev/null +++ b/client/src/app/site/motions/components/motion-comment-section-list/motion-comment-section-list.component.ts @@ -0,0 +1,170 @@ +import { Component, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; + +import { BaseComponent } from '../../../../base.component'; +import { FormGroup, FormBuilder, Validators } from '@angular/forms'; +import { MotionCommentSection } from '../../../../shared/models/motions/motion-comment-section'; +import { ViewMotionCommentSection } from '../../models/view-motion-comment-section'; +import { MotionCommentSectionRepositoryService } from '../../services/motion-comment-section-repository.service'; +import { PromptService } from '../../../../core/services/prompt.service'; +import { BehaviorSubject } from 'rxjs'; +import { Group } from '../../../../shared/models/users/group'; +import { DataStoreService } from '../../../../core/services/data-store.service'; + +/** + * List view for the categories. + */ +@Component({ + selector: 'os-motion-comment-section-list', + templateUrl: './motion-comment-section-list.component.html', + styleUrls: ['./motion-comment-section-list.component.scss'] +}) +export class MotionCommentSectionListComponent extends BaseComponent implements OnInit { + public commentSectionToCreate: MotionCommentSection | null; + + /** + * Source of the Data + */ + public commentSections: ViewMotionCommentSection[] = []; + + /** + * The current focussed formgroup + */ + public updateForm: FormGroup; + + public createForm: FormGroup; + + public openId: number | null; + public editId: number | null; + + public groups: BehaviorSubject>; + + /** + * The usual component constructor + * @param titleService + * @param translate + * @param repo + * @param formBuilder + */ + public constructor( + protected titleService: Title, + protected translate: TranslateService, + private repo: MotionCommentSectionRepositoryService, + private formBuilder: FormBuilder, + private promptService: PromptService, + private DS: DataStoreService + ) { + super(titleService, translate); + const form = { + name: ['', Validators.required], + read_groups_id: [[]], + write_groups_id: [[]] + }; + this.createForm = this.formBuilder.group(form); + this.updateForm = this.formBuilder.group(form); + } + + /** + * Event on Key Down in update or create form. Do not provide the viewSection for the create form. + */ + public keyDownFunction(event: KeyboardEvent, viewSection?: ViewMotionCommentSection): void { + if (event.keyCode === 13) { + if (viewSection) { + this.onSaveButton(viewSection); + } else { + this.create(); + } + } + } + + /** + * Init function. + * + * Sets the title and gets/observes categories from DataStore + */ + public ngOnInit(): void { + super.setTitle('Comment Sections'); + this.groups = new BehaviorSubject(this.DS.getAll(Group)); + this.DS.changeObservable.subscribe(model => { + if (model instanceof Group) { + this.groups.next(this.DS.getAll(Group)); + } + }); + this.repo.getViewModelListObservable().subscribe(newViewSections => { + this.commentSections = newViewSections; + }); + } + + /** + * Add a new Section. + */ + public onPlusButton(): void { + if (!this.commentSectionToCreate) { + this.commentSectionToCreate = new MotionCommentSection(); + this.createForm.setValue({ + name: '', + read_groups_id: [], + write_groups_id: [] + }); + } + } + + public create(): void { + if (this.createForm.valid) { + this.commentSectionToCreate.patchValues(this.createForm.value as MotionCommentSection); + this.repo.create(this.commentSectionToCreate).subscribe(resp => { + this.commentSectionToCreate = null; + }); + } + } + + /** + * Executed on edit button + * @param viewSection + */ + public onEditButton(viewSection: ViewMotionCommentSection): void { + this.editId = viewSection.id; + + this.updateForm.setValue({ + name: viewSection.name, + read_groups_id: viewSection.read_groups_id, + write_groups_id: viewSection.write_groups_id + }); + } + + /** + * Saves the categories + */ + public onSaveButton(viewSection: ViewMotionCommentSection): void { + if (this.updateForm.valid) { + this.repo.update(this.updateForm.value as Partial, viewSection).subscribe(resp => { + this.openId = this.editId = null; + }); + } + } + + /** + * is executed, when the delete button is pressed + */ + public async onDeleteButton(viewSection: ViewMotionCommentSection): Promise { + const content = this.translate.instant('Delete') + ` ${viewSection.name}?`; + if (await this.promptService.open('Are you sure?', content)) { + this.repo.delete(viewSection).subscribe(resp => { + this.openId = this.editId = null; + }); + } + } + + /** + * Is executed when a mat-extension-panel is closed + * @param viewSection the category in the panel + */ + public panelClosed(viewSection: ViewMotionCommentSection): void { + this.openId = null; + if (this.editId) { + this.onSaveButton(viewSection); + } + } +} diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.html b/client/src/app/site/motions/components/motion-detail/motion-detail.component.html new file mode 100644 index 000000000..ec17111f6 --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.html @@ -0,0 +1,313 @@ + + + + +
+ New + Motion + {{motion.identifier}} + {{metaInfoForm.get('identifier').value}} + : + {{motion.title}} + {{contentForm.get('title').value}} +
+
+ by {{motion.submitters}} +
+
+ + + +
+ +
+
+ +
+ + + + + + + + + +
+ + + + + + + + + + + info + Meta information + + + + +
+ +
+
+ + + + + + speaker_notes + Personal note + + + TEST + + + + + + + format_align_left + Content + + + +
+ +
+
+
+
+ + +
+
+ + +
+ +
+ + +
+ + + + Personal Note +
+ add + more_vert +
+ +
+
+ + Hier kรถnnte ihre Werbung stehen. 1 2 3 4 5 6 Hier kรถnnte ihre Werbung stehen. 1 2 3 4 5 6 + +
+
+ +
+
+ + + + + +
+
+
+ + +
+ + +
+ +
+

Identifier

+ {{motion.identifier}} +
+ + + +
+ + +
+
+
+ +
+
+
+

Submitters

+
    +
  • {{submitter.full_name}}
  • +
+
+
+ + +
+ +
+
+ +
+
+
+

Supporters

+
    +
  • {{supporter.full_name}}
  • +
+
+
+ + +
+
+

State

+ {{motion.state}} +
+ + + {{motionCopy.state}} + + {{state}} + + + replayReset State + + + +
+ + + +
+
+

{{motion.recommender}}

+ {{motion.recommendation}} +
+ + + {{state}} + + + + + replay + Reset recommendation + + + +
+ + +
+
+

Category

+ {{motion.category}} +
+
+ +
+
+ + +
+
+

Origin

+ {{motion.origin}} +
+ + + +
+ + + +
+
+ + +
+ + +
+ + +
+ + +
+
+

{{motion.title}}

+
+ + + + +
+ + + +

The assembly may decide:

+
+
+
+ + + + + + +
+
+

Reason

+
+
+ + + +
+ +
+
+ + + + + + + + + + + + + + + diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss b/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss new file mode 100644 index 000000000..efe9ac80c --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.scss @@ -0,0 +1,162 @@ +span { + margin: 0; +} + +.motion-title { + padding-left: 20px; + line-height: 100%; +} + +.motion-content { + display: flow-root; +} + +.motion-text-controls { + float: right; + button { + font-size: 115%; + } +} + +.motion-submitter { + display: inline; + font-weight: bold; + font-size: 70%; +} + +mat-panel-title { + mat-icon { + margin-right: 35px; //on line with text + } +} + +.meta-info-block { + h3 { + display: block; + margin-top: 12px; //distance between heading and text + margin-bottom: 3px; //distance between heading and text + font-size: 80%; + color: gray; + + mat-icon { + margin-left: 5px; + } + } + + mat-form-field { + margin-top: 12px; //distance between heading and text + } + + .mat-form-field-label { + font-size: 12pt; + color: gray; + } + + .mat-form-field-label-wrapper { + mat-icon { + margin-left: 5px; + } + } +} + +.wide-form { + textarea { + height: 25vh; + } + + ::ng-deep { + width: 100%; + } +} + +.meta-info-panel { + padding-top: 25px; + + a:hover { + cursor: pointer; + } +} + +mat-expansion-panel { + .expansion-panel-custom-body { + padding-left: 55px; + } +} + +.content-panel { + h2 { + display: block; + font-weight: bold; + font-size: 120%; + } + + h3 { + display: block; + font-weight: initial; + font-size: 100%; + } + + h4 { + display: block; + font-weight: bold; + font-size: 100%; + } +} + +.desktop-view { + .desktop-left { + width: 30%; + float: left; + + .meta-info-desktop { + padding: 40px 20px 10px 20px; + } + + .personal-note { + mat-card { + padding: 0px; + margin: 20px; + min-width: 10hv; + min-width: 200px; + + .mat-card-header-text { + width: 100%; + } + + mat-card-header { + display: inherit; + padding: 15px; + margin: 0; + background-color: #eee; + + .title-right { + float: right; + mat-icon { + padding-left: 10px; + } + } + + mat-card-title { + font-weight: bold; + display: inline; + } + } + + mat-card-content { + padding: 30px 15px 15px 15px; + } + } + } + } + + .desktop-right { + display: grid; + + min-width: 70%; + + mat-card { + display: inline; + margin: 20px; + } + } +} diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.spec.ts b/client/src/app/site/motions/components/motion-detail/motion-detail.component.spec.ts new file mode 100644 index 000000000..717bfca45 --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MotionDetailComponent } from './motion-detail.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; + +describe('MotionDetailComponent', () => { + let component: MotionDetailComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [MotionDetailComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MotionDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts b/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts new file mode 100644 index 000000000..613d978ec --- /dev/null +++ b/client/src/app/site/motions/components/motion-detail/motion-detail.component.ts @@ -0,0 +1,283 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormGroup, FormBuilder, Validators } from '@angular/forms'; +import { MatExpansionPanel } from '@angular/material'; + +import { BaseComponent } from '../../../../base.component'; +import { Category } from '../../../../shared/models/motions/category'; +import { ViewportService } from '../../../../core/services/viewport.service'; +import { MotionRepositoryService } from '../../services/motion-repository.service'; +import { ViewMotion } from '../../models/view-motion'; +import { User } from '../../../../shared/models/users/user'; +import { DataStoreService } from '../../../../core/services/data-store.service'; +import { TranslateService } from '@ngx-translate/core'; +import { Motion } from '../../../../shared/models/motions/motion'; +import { BehaviorSubject } from 'rxjs'; + +/** + * Component for the motion detail view + */ +@Component({ + selector: 'os-motion-detail', + templateUrl: './motion-detail.component.html', + styleUrls: ['./motion-detail.component.scss'] +}) +export class MotionDetailComponent extends BaseComponent implements OnInit { + /** + * MatExpansionPanel for the meta info + * Only relevant in mobile view + */ + @ViewChild('metaInfoPanel') + public metaInfoPanel: MatExpansionPanel; + + /** + * MatExpansionPanel for the content panel + * Only relevant in mobile view + */ + @ViewChild('contentPanel') + public contentPanel: MatExpansionPanel; + + /** + * Motions meta-info + */ + public metaInfoForm: FormGroup; + + /** + * Motion content. Can be a new version + */ + public contentForm: FormGroup; + + /** + * Determine if the motion is edited + */ + public editMotion = false; + + /** + * Determine if the motion is new + */ + public newMotion = false; + + /** + * Target motion. Might be new or old + */ + public motion: ViewMotion; + + /** + * Copy of the motion that the user might edit + */ + public motionCopy: ViewMotion; + + /** + * Subject for the Categories + */ + public categoryObserver: BehaviorSubject>; + + /** + * Subject for the Submitters + */ + public submitterObserver: BehaviorSubject>; + + /** + * Subject for the Supporters + */ + public supporterObserver: BehaviorSubject>; + + /** + * Constuct the detail view. + * + * @param vp the viewport service + * @param router to navigate back to the motion list and to an existing motion + * @param route determine if this is a new or an existing motion + * @param formBuilder For reactive forms. Form Group and Form Control + * @param repo: Motion Repository + * @param translate: Translation Service + */ + public constructor( + public vp: ViewportService, + private router: Router, + private route: ActivatedRoute, + private formBuilder: FormBuilder, + private repo: MotionRepositoryService, + private DS: DataStoreService, + protected translate: TranslateService + ) { + super(); + this.createForm(); + + if (route.snapshot.url[0] && route.snapshot.url[0].path === 'new') { + this.newMotion = true; + this.editMotion = true; + + // Both are (temporarily) necessary until submitter and supporters are implemented + // TODO new Motion and ViewMotion + this.motion = new ViewMotion(); + this.motionCopy = new ViewMotion(); + } else { + // load existing motion + this.route.params.subscribe(params => { + this.repo.getViewModelObservable(params.id).subscribe(newViewMotion => { + this.motion = newViewMotion; + }); + }); + } + // Initial Filling of the Subjects + this.submitterObserver = new BehaviorSubject(DS.getAll(User)); + this.supporterObserver = new BehaviorSubject(DS.getAll(User)); + this.categoryObserver = new BehaviorSubject(DS.getAll(Category)); + + // Make sure the subjects are updated, when a new Model for the type arrives + this.DS.changeObservable.subscribe(newModel => { + if (newModel instanceof User) { + this.submitterObserver.next(DS.getAll(User)); + this.supporterObserver.next(DS.getAll(User)); + } + if (newModel instanceof Category) { + this.categoryObserver.next(DS.getAll(Category)); + } + }); + } + + /** + * Async load the values of the motion in the Form. + */ + public patchForm(formMotion: ViewMotion): void { + this.metaInfoForm.patchValue({ + category_id: formMotion.categoryId, + supporters_id: formMotion.supporterIds, + submitters_id: formMotion.submitterIds, + state_id: formMotion.stateId, + recommendation_id: formMotion.recommendationId, + identifier: formMotion.identifier, + origin: formMotion.origin + }); + this.contentForm.patchValue({ + title: formMotion.title, + text: formMotion.text, + reason: formMotion.reason + }); + } + + /** + * Creates the forms for the Motion and the MotionVersion + * + * TODO: Build a custom form validator + */ + public createForm(): void { + this.metaInfoForm = this.formBuilder.group({ + identifier: [''], + category_id: [''], + state_id: [''], + recommendation_id: [''], + submitters_id: [], + supporters_id: [], + origin: [''] + }); + this.contentForm = this.formBuilder.group({ + title: ['', Validators.required], + text: ['', Validators.required], + reason: [''] + }); + } + + /** + * Save a motion. Calls the "patchValues" function in the MotionObject + * + * http:post the motion to the server. + * The AutoUpdate-Service should see a change once it arrives and show it + * in the list view automatically + * + * TODO: state is not yet saved. Need a special "put" command + * + * TODO: Repo should handle + */ + public saveMotion(): void { + const newMotionValues = { ...this.metaInfoForm.value, ...this.contentForm.value }; + const fromForm = new Motion(); + fromForm.deserialize(newMotionValues); + + if (this.newMotion) { + this.repo.create(fromForm).subscribe(response => { + if (response.id) { + this.router.navigate(['./motions/' + response.id]); + } + }); + } else { + this.repo.update(fromForm, this.motionCopy).subscribe(response => { + // if the motion was successfully updated, change the edit mode. + // TODO: Show errors if there appear here + if (response.id) { + this.editMotion = false; + } + }); + } + } + + /** + * get the formated motion text from the repository. + */ + public getFormatedText(): string { + return this.repo.formatMotion(this.motion.id, this.motion.lnMode, this.motion.crMode); + } + + /** + * Click on the edit button (pen-symbol) + */ + public editMotionButton(): void { + if (this.editMotion) { + this.saveMotion(); + } else { + this.editMotion = true; + this.motionCopy = this.motion.copy(); + this.patchForm(this.motionCopy); + if (this.vp.isMobile) { + this.metaInfoPanel.open(); + this.contentPanel.open(); + } + } + } + + /** + * Cancel the editing process + * + * If a new motion was created, return to the list. + */ + public cancelEditMotionButton(): void { + if (this.newMotion) { + this.router.navigate(['./motions/']); + } else { + this.editMotion = false; + } + } + + /** + * Trigger to delete the motion + * + * TODO: Repo should handle + */ + public deleteMotionButton(): void { + this.repo.delete(this.motion).subscribe(answer => { + this.router.navigate(['./motions/']); + }); + } + + /** + * Sets the motions line numbering mode + * @param mode Needs to fot to the enum defined in ViewMotion + */ + public setLineNumberingMode(mode: number): void { + this.motion.lnMode = mode; + } + + /** + * Sets the motions change reco mode + * @param mode Needs to fot to the enum defined in ViewMotion + */ + public setChangeRecoMode(mode: number): void { + this.motion.crMode = mode; + } + + /** + * Init. Does nothing here. + */ + public ngOnInit(): void {} +} diff --git a/client/src/app/site/motions/components/motion-list/motion-list.component.html b/client/src/app/site/motions/components/motion-list/motion-list.component.html new file mode 100644 index 000000000..640a74507 --- /dev/null +++ b/client/src/app/site/motions/components/motion-list/motion-list.component.html @@ -0,0 +1,54 @@ + + + +
+ + +
+ + + + + Identifier + +
+ {{motion.identifier}} +
+
+
+ + + + Title + +
+ {{motion.title}} +
+ + by + {{motion.submitters}} + +
+
+
+ + + + State + +
+ {{getStateIcon(motion.state)}}> +
+
+
+ + + +
+ + diff --git a/client/src/app/site/motions/components/motion-list/motion-list.component.scss b/client/src/app/site/motions/components/motion-list/motion-list.component.scss new file mode 100644 index 000000000..39b83d56e --- /dev/null +++ b/client/src/app/site/motions/components/motion-list/motion-list.component.scss @@ -0,0 +1,45 @@ +/** css hacks https://codepen.io/edge0703/pen/iHJuA */ +.innerTable { + display: inline-block; + vertical-align: middle; + line-height: normal; +} + +.os-listview-table { + /** identifier */ + .mat-column-identifier { + padding-left: 10px; + padding-right: 30px; + flex: 0 0 40px; + line-height: 60px; // set the text in the vertical middle, since vertical-align will not work + display: initial; // reset display + text-align: center; // center text + } + + /** Title */ + .mat-column-title { + width: 100%; + flex: 1 0 200px; + padding-left: 10px; + + .motion-list-title { + font-weight: bold; + } + + .motion-list-from { + margin-top: 5px; + color: rgba(0, 0, 0, 0.5); + font-size: 90%; + } + } + + /** State */ + .mat-column-state { + flex: 0 0 30px; + text-align: center; + + mat-icon { + font-size: 150%; + } + } +} diff --git a/client/src/app/site/motions/components/motion-list/motion-list.component.spec.ts b/client/src/app/site/motions/components/motion-list/motion-list.component.spec.ts new file mode 100644 index 000000000..6b598239e --- /dev/null +++ b/client/src/app/site/motions/components/motion-list/motion-list.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MotionListComponent } from './motion-list.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; + +describe('MotionListComponent', () => { + let component: MotionListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [MotionListComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MotionListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/components/motion-list/motion-list.component.ts b/client/src/app/site/motions/components/motion-list/motion-list.component.ts new file mode 100644 index 000000000..cbd15e520 --- /dev/null +++ b/client/src/app/site/motions/components/motion-list/motion-list.component.ts @@ -0,0 +1,152 @@ +import { Component, OnInit } from '@angular/core'; +import { Router, ActivatedRoute } from '@angular/router'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; + +import { MotionRepositoryService } from '../../services/motion-repository.service'; +import { ViewMotion } from '../../models/view-motion'; +import { WorkflowState } from '../../../../shared/models/motions/workflow-state'; +import { ListViewBaseComponent } from '../../../base/list-view-base'; + +/** + * Component that displays all the motions in a Table using DataSource. + */ +@Component({ + selector: 'os-motion-list', + templateUrl: './motion-list.component.html', + styleUrls: ['./motion-list.component.scss'] +}) +export class MotionListComponent extends ListViewBaseComponent implements OnInit { + /** + * Use for minimal width + */ + public columnsToDisplayMinWidth = ['identifier', 'title', 'state']; + + /** + * Use for maximal width + * + * TODO: Needs vp.desktop check + */ + public columnsToDisplayFullWidth = ['identifier', 'title', 'meta', 'state']; + + /** + * content of the ellipsis menu + */ + public motionMenuList = [ + { + text: 'Download', + icon: 'save_alt', + action: 'downloadMotions' + }, + { + text: 'Categories', + action: 'toCategories' + }, + { + text: 'Motion comment sections', + action: 'toMotionCommentSections' + } + ]; + + /** + * Constructor implements title and translation Module. + * + * @param titleService Title + * @param translate Translation + * @param router Router + * @param route Current route + * @param repo Motion Repository + */ + public constructor( + titleService: Title, + translate: TranslateService, + private router: Router, + private route: ActivatedRoute, + private repo: MotionRepositoryService + ) { + super(titleService, translate); + } + + /** + * Init function. + * + * Sets the title, inits the table and calls the repository + */ + public ngOnInit(): void { + super.setTitle('Motions'); + this.initTable(); + this.repo.getViewModelListObservable().subscribe(newMotions => { + this.dataSource.data = newMotions; + }); + } + + /** + * Select a motion from list. Executed via click. + * + * @param motion The row the user clicked at + */ + public selectMotion(motion: ViewMotion): void { + this.router.navigate(['./' + motion.id], { relativeTo: this.route }); + } + + /** + * Get the icon to the corresponding Motion Status + * TODO Needs to be more accessible (Motion workflow needs adjustment on the server) + * @param state the name of the state + */ + public getStateIcon(state: WorkflowState): string { + const stateName = state.name; + if (stateName === 'accepted') { + return 'thumb_up'; + } else if (stateName === 'rejected') { + return 'thumb_down'; + } else if (stateName === 'not decided') { + return 'help'; + } else { + return ''; + } + } + + /** + * Determines if an icon should be shown in the list view + * @param state + */ + public isDisplayIcon(state: WorkflowState): boolean { + if (state) { + return state.name === 'accepted' || state.name === 'rejected' || state.name === 'not decided'; + } else { + return false; + } + } + + /** + * Handler for the plus button + */ + public onPlusButton(): void { + this.router.navigate(['./new'], { relativeTo: this.route }); + } + + /** + * navigate to 'motion/category' + */ + public toCategories(): void { + this.router.navigate(['./category'], { relativeTo: this.route }); + } + + /** + * navigate to 'motion/comment-section' + */ + public toMotionCommentSections(): void { + this.router.navigate(['./comment-section'], { relativeTo: this.route }); + } + + /** + * Download all motions As PDF and DocX + * + * TODO: Currently does nothing + */ + public downloadMotions(): void { + console.log('Download Motions Button'); + } +} diff --git a/client/src/app/site/motions/models/view-category.ts b/client/src/app/site/motions/models/view-category.ts new file mode 100644 index 000000000..b9a1a7f73 --- /dev/null +++ b/client/src/app/site/motions/models/view-category.ts @@ -0,0 +1,98 @@ +import { Category } from '../../../shared/models/motions/category'; +import { TranslateService } from '@ngx-translate/core'; +import { BaseViewModel } from '../../base/base-view-model'; + +/** + * Category class for the View + * + * Stores a Category including all (implicit) references + * Provides "safe" access to variables and functions in {@link Category} + * @ignore + */ +export class ViewCategory extends BaseViewModel { + private _category: Category; + private _edit: boolean; + private _synced: boolean; + private _opened: boolean; + + public get category(): Category { + return this._category; + } + + public get id(): number { + return this.category ? this.category.id : null; + } + + public get name(): string { + return this.category ? this.category.name : null; + } + + public get prefix(): string { + return this.category ? this.category.prefix : null; + } + + public set synced(bol: boolean) { + this._synced = bol; + } + + public set edit(bol: boolean) { + this._edit = bol; + } + + public set opened(bol: boolean) { + this._opened = bol; + } + + public set prefix(pref: string) { + this._category.prefix = pref; + } + + public set name(nam: string) { + this._category.name = nam; + } + + public get opened(): boolean { + return this._opened; + } + + public get synced(): boolean { + return this._synced; + } + + public get edit(): boolean { + return this._edit; + } + + public constructor(category?: Category, id?: number, prefix?: string, name?: string) { + super(); + if (!category) { + category = new Category(); + category.id = id; + category.name = name; + category.prefix = prefix; + } + this._category = category; + this._edit = false; + this._synced = true; + this._opened = false; + } + + public getTitle(translate?: TranslateService): string { + return this.name; + } + + /** + * Updates the local objects if required + * @param update + */ + public updateValues(update: Category): void { + this._category = update; + } + + /** + * Duplicate this motion into a copy of itself + */ + public copy(): ViewCategory { + return new ViewCategory(this._category); + } +} diff --git a/client/src/app/site/motions/models/view-motion-comment-section.ts b/client/src/app/site/motions/models/view-motion-comment-section.ts new file mode 100644 index 000000000..cf7d98bd4 --- /dev/null +++ b/client/src/app/site/motions/models/view-motion-comment-section.ts @@ -0,0 +1,90 @@ +import { TranslateService } from '@ngx-translate/core'; +import { BaseViewModel } from '../../base/base-view-model'; +import { MotionCommentSection } from '../../../shared/models/motions/motion-comment-section'; +import { Group } from '../../../shared/models/users/group'; +import { BaseModel } from '../../../shared/models/base/base-model'; + +/** + * Motion comment section class for the View + * + * Stores a motion comment section including all (implicit) references + * Provides "safe" access to variables and functions in {@link MotionCommentSection} + * @ignore + */ +export class ViewMotionCommentSection extends BaseViewModel { + private _section: MotionCommentSection; + + private _read_groups: Group[]; + private _write_groups: Group[]; + + public edit = false; + public open = false; + + public get section(): MotionCommentSection { + return this._section; + } + + public get id(): number { + return this.section ? this.section.id : null; + } + + public get name(): string { + return this.section ? this.section.name : null; + } + + public get read_groups_id(): number[] { + return this.section ? this.section.read_groups_id : []; + } + + public get write_groups_id(): number[] { + return this.section ? this.section.write_groups_id : []; + } + + public get read_groups(): Group[] { + return this._read_groups; + } + + public get write_groups(): Group[] { + return this._write_groups; + } + + public set name(name: string) { + this._section.name = name; + } + + public constructor(section: MotionCommentSection, read_groups: Group[], write_groups: Group[]) { + super(); + this._section = section; + this._read_groups = read_groups; + this._write_groups = write_groups; + } + + public getTitle(translate?: TranslateService): string { + return this.name; + } + + /** + * Updates the local objects if required + * @param section + */ + public updateValues(update: BaseModel): void { + if (update instanceof MotionCommentSection) { + this._section = update as MotionCommentSection; + } + if (update instanceof Group) { + this.updateGroup(update as Group); + } + } + + // TODO: Implement updating of groups + public updateGroup(group: Group): void { + console.log(this._section, group); + } + + /** + * Duplicate this motion into a copy of itself + */ + public copy(): ViewMotionCommentSection { + return new ViewMotionCommentSection(this._section, this._read_groups, this._write_groups); + } +} diff --git a/client/src/app/site/motions/models/view-motion.ts b/client/src/app/site/motions/models/view-motion.ts new file mode 100644 index 000000000..b6e714110 --- /dev/null +++ b/client/src/app/site/motions/models/view-motion.ts @@ -0,0 +1,238 @@ +import { Motion } from '../../../shared/models/motions/motion'; +import { Category } from '../../../shared/models/motions/category'; +import { User } from '../../../shared/models/users/user'; +import { Workflow } from '../../../shared/models/motions/workflow'; +import { WorkflowState } from '../../../shared/models/motions/workflow-state'; +import { BaseModel } from '../../../shared/models/base/base-model'; +import { BaseViewModel } from '../../base/base-view-model'; +import { TranslateService } from '@ngx-translate/core'; + +enum LineNumbering { + None, + Inside, + Outside +} + +enum ChangeReco { + Original, + Change, + Diff, + Final +} + +/** + * Motion class for the View + * + * Stores a motion including all (implicit) references + * Provides "safe" access to variables and functions in {@link Motion} + * @ignore + */ +export class ViewMotion extends BaseViewModel { + private _motion: Motion; + private _category: Category; + private _submitters: User[]; + private _supporters: User[]; + private _workflow: Workflow; + private _state: WorkflowState; + + /** + * Indicates the LineNumbering Mode. + * Needs to be accessed from outside + */ + public lnMode: LineNumbering; + + /** + * Indicates the Change reco Mode. + * Needs to be accessed from outside + */ + public crMode: ChangeReco; + + public get motion(): Motion { + return this._motion; + } + + public get id(): number { + return this.motion ? this.motion.id : null; + } + + public get identifier(): string { + return this.motion ? this.motion.identifier : null; + } + + public get title(): string { + return this.motion ? this.motion.title : null; + } + + public get text(): string { + return this.motion ? this.motion.text : null; + } + + public get reason(): string { + return this.motion ? this.motion.reason : null; + } + + public get category(): Category { + return this._category; + } + + public get categoryId(): number { + return this.motion && this.category ? this.motion.category_id : null; + } + + public get submitters(): User[] { + return this._submitters; + } + + public get submitterIds(): number[] { + return this.motion ? this.motion.submitters_id : null; + } + + public get supporters(): User[] { + return this._supporters; + } + + public get supporterIds(): number[] { + return this.motion ? this.motion.supporters_id : null; + } + + public get workflow(): Workflow { + return this._workflow; + } + + public get state(): WorkflowState { + return this._state; + } + + public get stateId(): number { + return this.motion && this.motion.state_id ? this.motion.state_id : null; + } + + public get recommendationId(): number { + return this.motion && this.motion.recommendation_id ? this.motion.recommendation_id : null; + } + + /** + * FIXME: + * name of recommender exist in a config + * previously solved using `this.DS.filter(Config)` + * and checking: motionsRecommendationsByConfig.value + * + */ + public get recommender(): string { + return null; + } + + public get recommendation(): WorkflowState { + return this.recommendationId && this.workflow ? this.workflow.getStateById(this.recommendationId) : null; + } + + public get origin(): string { + return this.motion ? this.motion.origin : null; + } + + public get nextStates(): WorkflowState[] { + return this.state && this.workflow ? this.state.getNextStates(this.workflow) : null; + } + + public set supporters(users: User[]) { + const userIDArr: number[] = []; + users.forEach(user => { + userIDArr.push(user.id); + }); + this._supporters = users; + this._motion.supporters_id = userIDArr; + } + + public set submitters(users: User[]) { + // For the newer backend with weight: + // const submitterArr: MotionSubmitter[] = [] + // users.forEach(user => { + // const motionSub = new MotionSubmitter(); + // submitterArr.push(motionSub); + // }); + // this._motion.submitters = submitterArr; + this._submitters = users; + const submitterIDArr: number[] = []; + // for the older backend: + users.forEach(user => { + submitterIDArr.push(user.id); + }); + this._motion.submitters_id = submitterIDArr; + } + + public constructor( + motion?: Motion, + category?: Category, + submitters?: User[], + supporters?: User[], + workflow?: Workflow, + state?: WorkflowState + ) { + super(); + + this._motion = motion; + this._category = category; + this._submitters = submitters; + this._supporters = supporters; + this._workflow = workflow; + this._state = state; + + // TODO: Should be set using a a config variable + this.lnMode = LineNumbering.None; + this.crMode = ChangeReco.Original; + } + + public getTitle(translate?: TranslateService): string { + return this.title; + } + + /** + * Updates the local objects if required + * @param update + */ + public updateValues(update: BaseModel): void { + if (update instanceof Workflow) { + this.updateWorkflow(update as Workflow); + } else if (update instanceof Category) { + this.updateCategory(update as Category); + } + // TODO: There is no way (yet) to add Submitters to a motion + // Thus, this feature could not be tested + } + + /** + * Updates the Category + */ + public updateCategory(update: Category): void { + if (this.motion && update.id === this.motion.category_id) { + this._category = update as Category; + } + } + + /** + * updates the Workflow + */ + public updateWorkflow(update: Workflow): void { + if (this.motion && update.id === this.motion.workflow_id) { + this._workflow = update as Workflow; + } + } + + public hasSupporters(): boolean { + return !!(this.supporters && this.supporters.length > 0); + } + + /** + * Duplicate this motion into a copy of itself + */ + public copy(): ViewMotion { + return new ViewMotion( + this._motion, + this._category, + this._submitters, + this._supporters, + this._workflow, + this._state + ); + } +} diff --git a/client/src/app/site/motions/motions-routing.module.ts b/client/src/app/site/motions/motions-routing.module.ts new file mode 100644 index 000000000..0d90247bc --- /dev/null +++ b/client/src/app/site/motions/motions-routing.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { MotionListComponent } from './components/motion-list/motion-list.component'; +import { MotionDetailComponent } from './components/motion-detail/motion-detail.component'; +import { CategoryListComponent } from './components/category-list/category-list.component'; +import { MotionCommentSectionListComponent } from './components/motion-comment-section-list/motion-comment-section-list.component'; + +const routes: Routes = [ + { path: '', component: MotionListComponent }, + { path: 'category', component: CategoryListComponent }, + { path: 'comment-section', component: MotionCommentSectionListComponent }, + { path: 'new', component: MotionDetailComponent }, + { path: ':id', component: MotionDetailComponent } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class MotionsRoutingModule {} diff --git a/client/src/app/site/motions/motions.config.ts b/client/src/app/site/motions/motions.config.ts new file mode 100644 index 000000000..59a042e6b --- /dev/null +++ b/client/src/app/site/motions/motions.config.ts @@ -0,0 +1,30 @@ +import { AppConfig } from '../base/app-config'; +import { Motion } from '../../shared/models/motions/motion'; +import { Category } from '../../shared/models/motions/category'; +import { Workflow } from '../../shared/models/motions/workflow'; +import { MotionCommentSection } from '../../shared/models/motions/motion-comment-section'; +import { MotionChangeReco } from '../../shared/models/motions/motion-change-reco'; +import { MotionBlock } from '../../shared/models/motions/motion-block'; +import { StatuteParagraph } from '../../shared/models/motions/statute-paragraph'; + +export const MotionsAppConfig: AppConfig = { + name: 'motions', + models: [ + { collectionString: 'motions/motion', model: Motion }, + { collectionString: 'motions/category', model: Category }, + { collectionString: 'motions/workflow', model: Workflow }, + { collectionString: 'motions/motion-comment-section', model: MotionCommentSection }, + { collectionString: 'motions/motion-change-recommendation', model: MotionChangeReco }, + { collectionString: 'motions/motion-block', model: MotionBlock }, + { collectionString: 'motions/statute-paragraph', model: StatuteParagraph } + ], + mainMenuEntries: [ + { + route: '/motions', + displayName: 'Motions', + icon: 'assignment', + weight: 300, + permission: 'motions.can_see' + } + ] +}; diff --git a/client/src/app/site/motions/motions.module.spec.ts b/client/src/app/site/motions/motions.module.spec.ts new file mode 100644 index 000000000..90e65b249 --- /dev/null +++ b/client/src/app/site/motions/motions.module.spec.ts @@ -0,0 +1,13 @@ +import { MotionsModule } from './motions.module'; + +describe('MotionsModule', () => { + let motionsModule: MotionsModule; + + beforeEach(() => { + motionsModule = new MotionsModule(); + }); + + it('should create an instance', () => { + expect(motionsModule).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/motions.module.ts b/client/src/app/site/motions/motions.module.ts new file mode 100644 index 000000000..e615a0bcb --- /dev/null +++ b/client/src/app/site/motions/motions.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { MotionsRoutingModule } from './motions-routing.module'; +import { SharedModule } from '../../shared/shared.module'; +import { MotionListComponent } from './components/motion-list/motion-list.component'; +import { MotionDetailComponent } from './components/motion-detail/motion-detail.component'; +import { CategoryListComponent } from './components/category-list/category-list.component'; +import { MotionCommentSectionListComponent } from './components/motion-comment-section-list/motion-comment-section-list.component'; + +@NgModule({ + imports: [CommonModule, MotionsRoutingModule, SharedModule], + declarations: [MotionListComponent, MotionDetailComponent, CategoryListComponent, MotionCommentSectionListComponent] +}) +export class MotionsModule {} diff --git a/client/src/app/site/motions/services/category-repository.service.spec.ts b/client/src/app/site/motions/services/category-repository.service.spec.ts new file mode 100644 index 000000000..2ba9befb7 --- /dev/null +++ b/client/src/app/site/motions/services/category-repository.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { CategoryRepositoryService } from './category-repository.service'; +import { E2EImportsModule } from '../../../../e2e-imports.module'; + +describe('CategoryRepositoryService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [CategoryRepositoryService] + }); + }); + + it('should be created', inject([CategoryRepositoryService], (service: CategoryRepositoryService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/site/motions/services/category-repository.service.ts b/client/src/app/site/motions/services/category-repository.service.ts new file mode 100644 index 000000000..40cfee6cd --- /dev/null +++ b/client/src/app/site/motions/services/category-repository.service.ts @@ -0,0 +1,74 @@ +import { Injectable } from '@angular/core'; +import { Category } from '../../../shared/models/motions/category'; +import { ViewCategory } from '../models/view-category'; +import { DataSendService } from '../../../core/services/data-send.service'; +import { Observable } from 'rxjs'; +import { DataStoreService } from '../../../core/services/data-store.service'; +import { BaseRepository } from '../../base/base-repository'; + +/** + * Repository Services for Categories + * + * The repository is meant to process domain objects (those found under + * shared/models), so components can display them and interact with them. + * + * Rather than manipulating models directly, the repository is meant to + * inform the {@link DataSendService} about changes which will send + * them to the Server. + */ +@Injectable({ + providedIn: 'root' +}) +export class CategoryRepositoryService extends BaseRepository { + /** + * Creates a CategoryRepository + * Converts existing and incoming category to ViewCategories + * Handles CRUD using an observer to the DataStore + * @param DataSend + */ + public constructor(protected DS: DataStoreService, private dataSend: DataSendService) { + super(DS, Category); + } + + protected createViewModel(category: Category): ViewCategory { + return new ViewCategory(category); + } + + public create(update: Category, viewCategory?: ViewCategory): Observable { + console.log('update: ', update); + console.log('viewCategory: ', viewCategory); + if (this.osInDataStore(viewCategory)) { + return this.update(update, viewCategory); + } else { + return this.dataSend.createModel(viewCategory.category); + } + } + + public update(update: Category, viewCategory?: ViewCategory): Observable { + let updateCategory: Category; + if (viewCategory) { + updateCategory = viewCategory.category; + } else { + updateCategory = new Category(); + } + updateCategory.patchValues(update); + return this.dataSend.updateModel(updateCategory, 'put'); + } + + public delete(viewCategory: ViewCategory): Observable { + const category = viewCategory.category; + return this.dataSend.delete(category); + } + + /** + * Checks if a Catagory is on the server already + * @param viewCategory the category to check if it is already on the server + */ + public osInDataStore(viewCategory: ViewCategory): boolean { + const serverCategoryArray = this.DS.getAll(Category); + if (serverCategoryArray.find(cat => cat.id === viewCategory.id)) { + return true; + } + return false; + } +} diff --git a/client/src/app/site/motions/services/motion-comment-section-repository.service.spec.ts b/client/src/app/site/motions/services/motion-comment-section-repository.service.spec.ts new file mode 100644 index 000000000..ec2a984ad --- /dev/null +++ b/client/src/app/site/motions/services/motion-comment-section-repository.service.spec.ts @@ -0,0 +1,20 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { MotionCommentSectionRepositoryService } from './motion-comment-section-repository.service'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('MotionCommentSectionRepositoryService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [MotionCommentSectionRepositoryService] + }); + }); + + it('should be created', inject( + [MotionCommentSectionRepositoryService], + (service: MotionCommentSectionRepositoryService) => { + expect(service).toBeTruthy(); + } + )); +}); diff --git a/client/src/app/site/motions/services/motion-comment-section-repository.service.ts b/client/src/app/site/motions/services/motion-comment-section-repository.service.ts new file mode 100644 index 000000000..78b143096 --- /dev/null +++ b/client/src/app/site/motions/services/motion-comment-section-repository.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@angular/core'; +import { DataSendService } from '../../../core/services/data-send.service'; +import { Observable } from 'rxjs'; +import { DataStoreService } from '../../../core/services/data-store.service'; +import { BaseRepository } from '../../base/base-repository'; +import { ViewMotionCommentSection } from '../models/view-motion-comment-section'; +import { MotionCommentSection } from '../../../shared/models/motions/motion-comment-section'; +import { Group } from '../../../shared/models/users/group'; + +/** + * Repository Services for Categories + * + * The repository is meant to process domain objects (those found under + * shared/models), so components can display them and interact with them. + * + * Rather than manipulating models directly, the repository is meant to + * inform the {@link DataSendService} about changes which will send + * them to the Server. + */ +@Injectable({ + providedIn: 'root' +}) +export class MotionCommentSectionRepositoryService extends BaseRepository< + ViewMotionCommentSection, + MotionCommentSection +> { + /** + * Creates a CategoryRepository + * Converts existing and incoming category to ViewCategories + * Handles CRUD using an observer to the DataStore + * @param DataSend + */ + public constructor(protected DS: DataStoreService, private dataSend: DataSendService) { + super(DS, MotionCommentSection, [Group]); + } + + protected createViewModel(section: MotionCommentSection): ViewMotionCommentSection { + const read_groups = this.DS.getMany(Group, section.read_groups_id); + const write_groups = this.DS.getMany(Group, section.write_groups_id); + return new ViewMotionCommentSection(section, read_groups, write_groups); + } + + public create(section: MotionCommentSection): Observable { + return this.dataSend.createModel(section); + } + + public update(section: Partial, viewSection?: ViewMotionCommentSection): Observable { + let updateSection: MotionCommentSection; + if (viewSection) { + updateSection = viewSection.section; + } else { + updateSection = new MotionCommentSection(); + } + updateSection.patchValues(section); + return this.dataSend.updateModel(updateSection, 'put'); + } + + public delete(viewSection: ViewMotionCommentSection): Observable { + return this.dataSend.delete(viewSection.section); + } +} diff --git a/client/src/app/site/motions/services/motion-repository.service.spec.ts b/client/src/app/site/motions/services/motion-repository.service.spec.ts new file mode 100644 index 000000000..7f2fbf3ee --- /dev/null +++ b/client/src/app/site/motions/services/motion-repository.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { MotionRepositoryService } from './motion-repository.service'; +import { E2EImportsModule } from '../../../../e2e-imports.module'; + +describe('MotionRepositoryService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [MotionRepositoryService] + }); + }); + + it('should be created', inject([MotionRepositoryService], (service: MotionRepositoryService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/site/motions/services/motion-repository.service.ts b/client/src/app/site/motions/services/motion-repository.service.ts new file mode 100644 index 000000000..0e2b2f902 --- /dev/null +++ b/client/src/app/site/motions/services/motion-repository.service.ts @@ -0,0 +1,152 @@ +import { Injectable } from '@angular/core'; + +import { DataSendService } from '../../../core/services/data-send.service'; +import { Motion } from '../../../shared/models/motions/motion'; +import { User } from '../../../shared/models/users/user'; +import { Category } from '../../../shared/models/motions/category'; +import { Workflow } from '../../../shared/models/motions/workflow'; +import { WorkflowState } from '../../../shared/models/motions/workflow-state'; +import { ViewMotion } from '../models/view-motion'; +import { Observable } from 'rxjs'; +import { BaseRepository } from '../../base/base-repository'; +import { DataStoreService } from '../../../core/services/data-store.service'; + +/** + * Repository Services for motions (and potentially categories) + * + * The repository is meant to process domain objects (those found under + * shared/models), so components can display them and interact with them. + * + * Rather than manipulating models directly, the repository is meant to + * inform the {@link DataSendService} about changes which will send + * them to the Server. + */ +@Injectable({ + providedIn: 'root' +}) +export class MotionRepositoryService extends BaseRepository { + /** + * Creates a MotionRepository + * + * Converts existing and incoming motions to ViewMotions + * Handles CRUD using an observer to the DataStore + * @param DataSend + */ + public constructor(DS: DataStoreService, private dataSend: DataSendService) { + super(DS, Motion, [Category, User, Workflow]); + } + + /** + * Converts a motion to a ViewMotion and adds it to the store. + * + * Foreign references of the motion will be resolved (e.g submitters to users) + * Expandable to all (server side) changes that might occur on the motion object. + * + * @param motion blank motion domain object + */ + protected createViewModel(motion: Motion): ViewMotion { + const category = this.DS.get(Category, motion.category_id); + const submitters = this.DS.getMany(User, motion.submitterIds); + const supporters = this.DS.getMany(User, motion.supporters_id); + const workflow = this.DS.get(Workflow, motion.workflow_id); + let state: WorkflowState = null; + if (workflow) { + state = workflow.getStateById(motion.state_id); + } + return new ViewMotion(motion, category, submitters, supporters, workflow, state); + } + + /** + * Creates a motion + * Creates a (real) motion with patched data and delegate it + * to the {@link DataSendService} + * + * @param update the form data containing the update values + * @param viewMotion The View Motion. If not present, a new motion will be created + * TODO: Remove the viewMotion and make it actually distignuishable from save() + */ + public create(motion: Motion): Observable { + if (!motion.supporters_id) { + delete motion.supporters_id; + } + return this.dataSend.createModel(motion); + } + + /** + * updates a motion + * + * Creates a (real) motion with patched data and delegate it + * to the {@link DataSendService} + * + * @param update the form data containing the update values + * @param viewMotion The View Motion. If not present, a new motion will be created + */ + public update(update: Partial, viewMotion: ViewMotion): Observable { + const motion = viewMotion.motion; + motion.patchValues(update); + return this.dataSend.updateModel(motion, 'patch'); + } + + /** + * Deleting a motion. + * + * Extract the motion out of the motionView and delegate + * to {@link DataSendService} + * @param viewMotion + */ + public delete(viewMotion: ViewMotion): Observable { + return this.dataSend.delete(viewMotion.motion); + } + + /** + * Format the motion text using the line numbering and change + * reco algorithm. + * + * TODO: Call DiffView and LineNumbering Service here. + * + * Can be called from detail view and exporter + * @param id Motion ID - will be pulled from the repository + * @param lnMode indicator for the line numbering mode + * @param crMode indicator for the change reco mode + */ + public formatMotion(id: number, lnMode: number, crMode: number): string { + const targetMotion = this.getViewModel(id); + + if (targetMotion && targetMotion.text) { + let motionText = targetMotion.text; + + // TODO : Use Line numbering service here + switch (lnMode) { + case 0: // no line numbers + break; + case 1: // line number inside + motionText = 'Get line numbers outside'; + break; + case 2: // line number outside + motionText = 'Get line numbers inside'; + break; + } + + // TODO : Use Diff Service here. + // this will(currently) append the previous changes. + // update + switch (crMode) { + case 0: // Original + break; + case 1: // Changed Version + motionText += ' and get changed version'; + break; + case 2: // Diff Version + motionText += ' and get diff version'; + break; + case 3: // Final Version + motionText += ' and final version'; + break; + } + + return motionText; + } else { + return null; + } + } +} diff --git a/client/src/app/site/site-routing.module.ts b/client/src/app/site/site-routing.module.ts new file mode 100644 index 000000000..af4feb22b --- /dev/null +++ b/client/src/app/site/site-routing.module.ts @@ -0,0 +1,55 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +import { SiteComponent } from './site.component'; + +import { AuthGuard } from '../core/services/auth-guard.service'; + +/** + * Routung to all OpenSlides apps + * + * TODO: Plugins will have to append to the Routes-Array + */ +const routes: Routes = [ + { + path: '', + component: SiteComponent, + children: [ + { + path: '', + loadChildren: './common/common.module#CommonModule' + }, + { + path: 'agenda', + loadChildren: './agenda/agenda.module#AgendaModule' + }, + { + path: 'assignments', + loadChildren: './assignments/assignments.module#AssignmentsModule' + }, + { + path: 'mediafiles', + loadChildren: './mediafiles/mediafiles.module#MediafilesModule' + }, + { + path: 'motions', + loadChildren: './motions/motions.module#MotionsModule' + }, + { + path: 'settings', + loadChildren: './config/config.module#ConfigModule' + }, + { + path: 'users', + loadChildren: './users/users.module#UsersModule' + } + ], + canActivateChild: [AuthGuard] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class SiteRoutingModule {} diff --git a/client/src/app/site/site.component.html b/client/src/app/site/site.component.html new file mode 100644 index 000000000..c1b4aa564 --- /dev/null +++ b/client/src/app/site/site.component.html @@ -0,0 +1,89 @@ + + + + + + + + + + + + + {{username}} + + + + language + {{getLangName(this.translate.currentLang)}} + + + person + Edit Profile + + + vpn_key + Change Password + + + + exit_to_app + Logout + + + exit_to_app + Login + + + + + + + + + + + + + + + {{entry.icon}}{{ entry.displayName | translate}} + + + + + videocam + Projector + + + +
+
+ + + + + + + + + + +
+ +
+
+ +
+
+ +
+
+
+
diff --git a/client/src/app/site/site.component.scss b/client/src/app/site/site.component.scss new file mode 100644 index 000000000..e2f2ea3d2 --- /dev/null +++ b/client/src/app/site/site.component.scss @@ -0,0 +1,45 @@ +.projector-button { + position: fixed; + bottom: 10px; + right: 20px; +} + +.os-logo-container { + min-width: 240px; + background-image: url(/assets/img/openslides-logo-h-dark-transparent.svg); + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +.side-panel { + box-shadow: 3px 0px 10px 0px rgba(0, 0, 0, 0.2); +} + +.content { + min-height: 100%; + position: relative; +} + +mat-sidenav-container { + height: 100vh; + width: 100%; +} + +.relax { + position: initial; + padding-bottom: 70px; +} + +main { + display: flex; + flex-direction: column; + width: 100%; + position: relative; + z-index: 50; + flex: 1; + > *:not(router-outlet) { + flex: 1; + display: block; + } +} diff --git a/client/src/app/site/site.component.scss-theme.scss b/client/src/app/site/site.component.scss-theme.scss new file mode 100644 index 000000000..dfc4c5329 --- /dev/null +++ b/client/src/app/site/site.component.scss-theme.scss @@ -0,0 +1,84 @@ +@import '~@angular/material/theming'; + +/** Custom component theme. Only lives in a specific scope */ +@mixin os-site-theme($theme) { + $primary: map-get($theme, primary); + $accent: map-get($theme, accent); + $warn: map-get($theme, accent); + $foreground: map-get($theme, foreground); + $background: map-get($theme, background); + + /** the name of the selector */ + os-site { + mat-sidenav-container { + /** nav panel on the left */ + mat-sidenav { + /** rules for icons in the whole site-view */ + mat-icon { + min-width: 20px; //puts the text to the right on the same level + margin-right: 10px; // the distance from the icon to the text + } + } + } + + /** adjust the color of the main container to our theme */ + .main-container { + background-color: mat-color($background, background); + } + + /** change the nav-toolbar to the darker nuance of the current theme*/ + .nav-toolbar { + height: 80px; + background-color: mat-color($primary, darker); + + mat-toolbar-row { + height: 80px; + } + } + + /** make the .user-menu expansion panel look like the nav-toolbar above */ + .user-menu { + background-color: mat-color($primary, darker); + color: mat-color($background, raised-button); + min-height: 48px; + + /** color of the divider just above the log out button */ + mat-divider { + border-top-color: rgba(255, 255, 255, 0.25); + } + + mat-icon { + color: mat-color($background, raised-button); + } + + span { + color: mat-color($background, raised-button); + } + + .mat-expansion-indicator:after { + color: mat-color($background, raised-button); + } + } + + /** style and align the nav icons the icons*/ + .main-nav { + mat-icon { + color: mat-color($foreground, icon); + } + span { + font-weight: bold; + color: mat-color($foreground, text); + } + } + + /** style the active link */ + .active { + mat-icon { + color: mat-color($primary); + } + span { + color: mat-color($primary); + } + } + } +} diff --git a/client/src/app/site/site.component.spec.ts b/client/src/app/site/site.component.spec.ts new file mode 100644 index 000000000..122b07186 --- /dev/null +++ b/client/src/app/site/site.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SiteComponent } from './site.component'; +import { E2EImportsModule } from '../../e2e-imports.module'; + +describe('SiteComponent', () => { + let component: SiteComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [SiteComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SiteComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/site.component.ts b/client/src/app/site/site.component.ts new file mode 100644 index 000000000..8487f4e19 --- /dev/null +++ b/client/src/app/site/site.component.ts @@ -0,0 +1,126 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { Router } from '@angular/router'; + +import { AuthService } from 'app/core/services/auth.service'; +import { OperatorService } from 'app/core/services/operator.service'; + +import { TranslateService } from '@ngx-translate/core'; +import { BaseComponent } from 'app/base.component'; +import { pageTransition, navItemAnim } from 'app/shared/animations'; +import { MatDialog, MatSidenav } from '@angular/material'; +import { ViewportService } from '../core/services/viewport.service'; +import { MainMenuService } from '../core/services/main-menu.service'; + +@Component({ + selector: 'os-site', + animations: [pageTransition, navItemAnim], + templateUrl: './site.component.html', + styleUrls: ['./site.component.scss'] +}) +export class SiteComponent extends BaseComponent implements OnInit { + /** + * HTML element of the side panel + */ + @ViewChild('sideNav') + public sideNav: MatSidenav; + + /** + * Get the username from the operator (should be known already) + */ + public username: string; + + /** + * is the user logged in, or the anonymous is active. + */ + public isLoggedIn: boolean; + + /** + * Constructor + * + * @param authService + * @param router + * @param operator + * @param vp + * @param translate + * @param dialog + */ + public constructor( + private authService: AuthService, + private router: Router, + public operator: OperatorService, + public vp: ViewportService, + public translate: TranslateService, + public dialog: MatDialog, + public mainMenuService: MainMenuService // used in the component + ) { + super(); + + this.operator.getObservable().subscribe(user => { + if (user) { + this.username = user.full_name; + } else { + this.username = translate.instant('Guest'); + } + this.isLoggedIn = !!user; + }); + } + + /** + * Initialize the site component + */ + public ngOnInit(): void { + this.vp.checkForChange(); + + // get a translation via code: use the translation service + // this.translate.get('Motions').subscribe((res: string) => { + // console.log('translation of motions in the target language: ' + res); + // }); + } + + /** + * Closes the sidenav in mobile view + */ + public toggleSideNav(): void { + if (this.vp.isMobile) { + this.sideNav.toggle(); + } + } + + /** + * Let the user change the language + * @param lang the desired language (en, de, fr, ...) + */ + public selectLang(selection: string): void { + this.translate.use(selection).subscribe(); + } + + /** + * Get the name of a Language by abbreviation. + */ + public getLangName(abbreviation: string): string { + if (abbreviation === 'en') { + return this.translate.instant('English'); + } else if (abbreviation === 'de') { + return this.translate.instant('German'); + } else if (abbreviation === 'fr') { + return this.translate.instant('French'); + } + } + + // TODO: Implement this + public editProfile(): void { + if (this.operator.user) { + this.router.navigate([`./users/${this.operator.user.id}`]); + } + } + + // TODO: Implement this + public changePassword(): void {} + + /** + * Function to log out the current user + */ + public logout(): void { + this.authService.logout(); + } +} diff --git a/client/src/app/site/site.module.spec.ts b/client/src/app/site/site.module.spec.ts new file mode 100644 index 000000000..94f6f47d9 --- /dev/null +++ b/client/src/app/site/site.module.spec.ts @@ -0,0 +1,13 @@ +import { SiteModule } from './site.module'; + +describe('SiteModule', () => { + let siteModule: SiteModule; + + beforeEach(() => { + siteModule = new SiteModule(); + }); + + it('should create an instance', () => { + expect(siteModule).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/site.module.ts b/client/src/app/site/site.module.ts new file mode 100644 index 000000000..bd6438e60 --- /dev/null +++ b/client/src/app/site/site.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { SharedModule } from 'app/shared/shared.module'; + +import { SiteComponent } from './site.component'; +import { SiteRoutingModule } from './site-routing.module'; + +@NgModule({ + imports: [CommonModule, SharedModule, SiteRoutingModule], + declarations: [SiteComponent] +}) +export class SiteModule {} diff --git a/client/src/app/site/users/components/group-list/group-list.component.html b/client/src/app/site/users/components/group-list/group-list.component.html new file mode 100644 index 000000000..c006e69b4 --- /dev/null +++ b/client/src/app/site/users/components/group-list/group-list.component.html @@ -0,0 +1,90 @@ + + + +
+ Groups +
+ + +
+ +
+
+ + + + + +
+
+ +
+
+ + + + + + + + + +
+
+ +
+ All your changes are saved immediately. +
+ + + + + + {{ app.name }} + + + +
+ + + Permissions + + {{ perm.display_name }} + + + +
+ + +
+ {{ group.name }} +
+
+ +
+ + +
+
+
+
+ + + +
+
+
+
diff --git a/client/src/app/site/users/components/group-list/group-list.component.scss b/client/src/app/site/users/components/group-list/group-list.component.scss new file mode 100644 index 000000000..e42a0e912 --- /dev/null +++ b/client/src/app/site/users/components/group-list/group-list.component.scss @@ -0,0 +1,39 @@ +table { + width: 100%; + + .mat-cell { + min-width: 80px; + } + + .mat-column-perm { + min-width: 130px; + } + + .inner-table { + width: 100%; + text-align: center; + } + + .group-head-table-cell { + cursor: pointer; + } +} + +.hint-text { + padding-top: 30px; + padding-left: 25px; + background-color: #ffffff; // put in theme later +} + +.new-group-form { + text-align: center; + padding-top: 10px; + background-color: #ffffff; // put in theme later + button { + margin-left: 10px; + } +} + +.scrollable-perm-matrix { + overflow: auto; +} diff --git a/client/src/app/site/users/components/group-list/group-list.component.spec.ts b/client/src/app/site/users/components/group-list/group-list.component.spec.ts new file mode 100644 index 000000000..23ac7194b --- /dev/null +++ b/client/src/app/site/users/components/group-list/group-list.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GroupListComponent } from './group-list.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; + +describe('GroupListComponent', () => { + let component: GroupListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [GroupListComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GroupListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/users/components/group-list/group-list.component.ts b/client/src/app/site/users/components/group-list/group-list.component.ts new file mode 100644 index 000000000..d11867868 --- /dev/null +++ b/client/src/app/site/users/components/group-list/group-list.component.ts @@ -0,0 +1,192 @@ +import { Component, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { TranslateService } from '@ngx-translate/core'; +import { MatTableDataSource } from '@angular/material'; +import { FormGroup } from '@angular/forms'; + +import { GroupRepositoryService } from '../../services/group-repository.service'; +import { ViewGroup } from '../../models/view-group'; +import { Group } from '../../../../shared/models/users/group'; +import { BaseComponent } from '../../../../base.component'; + +/** + * Component for the Group-List and permission matrix + */ +@Component({ + selector: 'os-group-list', + templateUrl: './group-list.component.html', + styleUrls: ['./group-list.component.scss'] +}) +export class GroupListComponent extends BaseComponent implements OnInit { + /** + * Holds all Groups + */ + public groups: ViewGroup[]; + + /** + * The header rows that the table should show + */ + public headerRowDef: string[] = []; + + /** + * Show or hide the new groups box + */ + public newGroup = false; + + /** + * Show or hide edit Group features + */ + public editGroup = false; + + /** + * Store the group to edit + */ + public selectedGroup: ViewGroup; + + /** + * Constructor + * + * @param titleService Title Service + * @param translate Translations + * @param DS The Data Store + * @param constants Constants + */ + public constructor(titleService: Title, translate: TranslateService, public repo: GroupRepositoryService) { + super(titleService, translate); + } + + /** + * Trigger for the new Group button + */ + public newGroupButton(): void { + this.editGroup = false; + this.newGroup = !this.newGroup; + } + + /** + * Saves a newly created group. + * @param form form data given by the group + */ + public submitNewGroup(form: FormGroup): void { + if (form.value) { + this.repo.create(form.value).subscribe(response => { + if (response) { + form.reset(); + // commenting the next line would allow to create multiple groups without reopening the form + this.newGroup = false; + } + }); + } + } + + /** + * Saves an edited group. + * @param form form data given by the group + */ + public submitEditedGroup(form: FormGroup): void { + if (form.value) { + const updateData = new Group({ name: form.value.name }); + + this.repo.update(updateData, this.selectedGroup).subscribe(response => { + if (response) { + this.cancelEditing(); + } + }); + } + } + + /** + * Deletes the selected Group + */ + public deleteSelectedGroup(): void { + this.repo.delete(this.selectedGroup).subscribe(response => this.cancelEditing()); + } + + /** + * Cancel the editing + */ + public cancelEditing(): void { + this.editGroup = false; + } + + /** + * Select group in head bar + */ + public selectGroup(group: ViewGroup): void { + this.newGroup = false; + this.selectedGroup = group; + this.editGroup = true; + } + + /** + * Triggers when a permission was toggled + * @param group + * @param perm + */ + public togglePerm(viewGroup: ViewGroup, perm: string): void { + const updateData = new Group({ permissions: viewGroup.getAlteredPermissions(perm) }); + this.repo.update(updateData, viewGroup).subscribe(); + } + + /** + * Update the rowDefinition after Reloading or changes + */ + public updateRowDef(): void { + // reset the rowDef list first + this.headerRowDef = ['perm']; + this.groups.forEach(viewGroup => { + this.headerRowDef.push('' + viewGroup.name); + }); + } + + /** + * Required to detect changes in *ngFor loops + * + * @param group Corresponding group that was changed + */ + public trackGroupArray(group: ViewGroup): number { + return group.id; + } + + /** + * Converts a permission string into MatTableDataSource + * @param permissions + */ + public getTableDataSource(permissions: string[]): MatTableDataSource { + const dataSource = new MatTableDataSource(); + dataSource.data = permissions; + return dataSource; + } + + /** + * Determine if a group is protected from deletion + * @param group ViewGroup + */ + public isProtected(group: ViewGroup): boolean { + return group.id === 1 || group.id === 2; + } + + /** + * Clicking escape while in #newGroupForm should toggle newGroup. + */ + public keyDownFunction(event: KeyboardEvent): void { + if (event.keyCode === 27) { + this.newGroup = false; + } + } + + /** + * Init function. + * + * Monitor the repository for changes and update the local groups array + */ + public ngOnInit(): void { + super.setTitle('Groups'); + this.repo.getViewModelListObservable().subscribe(newViewGroups => { + if (newViewGroups) { + this.groups = newViewGroups; + this.updateRowDef(); + } + }); + } +} diff --git a/client/src/app/site/users/components/user-detail/user-detail.component.html b/client/src/app/site/users/components/user-detail/user-detail.component.html new file mode 100644 index 000000000..ecde987b2 --- /dev/null +++ b/client/src/app/site/users/components/user-detail/user-detail.component.html @@ -0,0 +1,152 @@ + + + + +
+
+ {{personalInfoForm.get('title').value}} + {{personalInfoForm.get('first_name').value}} + {{personalInfoForm.get('last_name').value}} +
+ +
+ {{user.fullName}} +
+
+ + + + +
+ +
+
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + + + + + + +
+ +
+ + + + + Please enter a valid email address + + +
+ +
+ + + + + + + + + +
+ +
+ + + + {{group}} + + +
+ +
+ + + + Generate + + +
+ +
+ + + + + +
+ +
+ + + + +
+ +
+ + + + Only for internal notes. + +
+ +
+ + + Is Present + + + + Is Active + + + + Is a committee + +
+
+ +
diff --git a/client/src/app/site/users/components/user-detail/user-detail.component.scss b/client/src/app/site/users/components/user-detail/user-detail.component.scss new file mode 100644 index 000000000..6af3b7be9 --- /dev/null +++ b/client/src/app/site/users/components/user-detail/user-detail.component.scss @@ -0,0 +1,67 @@ +// hide certain stuff whem editing is disabled +.mat-form-field-disabled { + ::ng-deep { + .mat-input-element { + color: currentColor; + } + + .mat-select-value { + color: currentColor; + } + + .mat-form-field-underline { + display: none; + } + + .mat-hint { + display: none; + } + + .mat-select-value { + display: table-cell; + } + + button { + display: none; + } + } +} + +// angular material does not have this class. This is virtually set using ngClass +.mat-form-field-enabled { + .form100 { + ::ng-deep { + width: 100%; + } + } + + .form70 { + ::ng-deep { + width: 70%; + } + } + + .force-min-with { + min-width: 150px; + } + + .form30 { + ::ng-deep { + width: 30%; + } + } + + .form20 { + ::ng-deep { + width: 25%; + } + } + + .distance { + padding-right: 5%; + } +} + +mat-checkbox { + margin-right: 10px; +} diff --git a/client/src/app/site/users/components/user-detail/user-detail.component.spec.ts b/client/src/app/site/users/components/user-detail/user-detail.component.spec.ts new file mode 100644 index 000000000..204d236f8 --- /dev/null +++ b/client/src/app/site/users/components/user-detail/user-detail.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UserDetailComponent } from './user-detail.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; + +describe('UserDetailComponent', () => { + let component: UserDetailComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [UserDetailComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/users/components/user-detail/user-detail.component.ts b/client/src/app/site/users/components/user-detail/user-detail.component.ts new file mode 100644 index 000000000..c26a28717 --- /dev/null +++ b/client/src/app/site/users/components/user-detail/user-detail.component.ts @@ -0,0 +1,332 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormGroup, FormBuilder, Validators } from '@angular/forms'; + +import { ViewUser } from '../../models/view-user'; +import { UserRepositoryService } from '../../services/user-repository.service'; +import { Group } from '../../../../shared/models/users/group'; +import { DataStoreService } from '../../../../core/services/data-store.service'; +import { OperatorService } from '../../../../core/services/operator.service'; + +/** + * Users detail component for both new and existing users + */ +@Component({ + selector: 'os-user-detail', + templateUrl: './user-detail.component.html', + styleUrls: ['./user-detail.component.scss'] +}) +export class UserDetailComponent implements OnInit { + /** + * Info form object + */ + public personalInfoForm: FormGroup; + + /** + * if this is the own page + */ + public ownPage = false; + + /** + * Editing a user + */ + public editUser = false; + + /** + * True if a new user is created + */ + public newUser = false; + + /** + * True if the operator has manage permissions + */ + public canManage = false; + + /** + * ViewUser model + */ + public user: ViewUser; + + /** + * Should contain all Groups, loaded or observed from DataStore + */ + public groups: Group[]; + + /** + * Constructor for user + */ + public constructor( + private formBuilder: FormBuilder, + private route: ActivatedRoute, + private router: Router, + private repo: UserRepositoryService, + private DS: DataStoreService, + private op: OperatorService + ) { + this.user = new ViewUser(); + if (route.snapshot.url[0] && route.snapshot.url[0].path === 'new') { + this.newUser = true; + this.setEditMode(true); + } else { + this.route.params.subscribe(params => { + this.loadViewUser(params.id); + + // will fail after reload - observable required + this.ownPage = this.opOwnsPage(Number(params.id)); + + // observe operator to find out if we see our own page or not + this.op.getObservable().subscribe(newOp => { + if (newOp) { + this.ownPage = this.opOwnsPage(Number(params.id)); + } + }); + }); + } + this.createForm(); + } + + /** + * sets the ownPage variable if the operator owns the page + */ + public opOwnsPage(userId: number): boolean { + return this.op.user && this.op.user.id === userId; + } + + /** + * Should determine if the user (Operator) has the + * correct permission to perform the given action. + * + * actions might be: + * - seeName (title, 1st, last) (user.can_see_name or ownPage) + * - seeExtra (checkboxes, comment) (user.can_see_extra_data) + * - seePersonal (mail, username, about) (user.can_see_extra_data or ownPage) + * - manage (everything) (user.can_manage) + * - changePersonal (mail, username, about) (user.can_manage or ownPage) + * + * @param action the action the user tries to perform + */ + public isAllowed(action: string): boolean { + switch (action) { + case 'manage': + return this.op.hasPerms('users.can_manage'); + case 'seeName': + return this.op.hasPerms('users.can_see_name', 'users.can_manage') || this.ownPage; + case 'seeExtra': + return this.op.hasPerms('users.can_see_extra_data', 'users.can_manage'); + case 'seePersonal': + return this.op.hasPerms('users.can_see_extra_data', 'users.can_manage') || this.ownPage; + case 'changePersonal': + return this.op.hasPerms('user.cans_manage') || this.ownPage; + default: + return false; + } + } + + /** + * Loads a user from users repository + * @param id the required ID + */ + public loadViewUser(id: number): void { + this.repo.getViewModelObservable(id).subscribe(newViewUser => { + // repo sometimes delivers undefined values + // also ensures edition cannot be interrupted by autpupdate + if (newViewUser && !this.editUser) { + this.user = newViewUser; + // personalInfoForm is undefined during 'new' and directly after reloading + if (this.personalInfoForm) { + this.patchFormValues(); + } + } + }); + } + + /** + * initialize the form with default values + */ + public createForm(): void { + this.personalInfoForm = this.formBuilder.group({ + username: [''], + title: [''], + first_name: [''], + last_name: [''], + structure_level: [''], + number: [''], + about_me: [''], + groups_id: [''], + is_present: [true], + is_committee: [false], + email: ['', Validators.email], + last_email_send: [''], + comment: [''], + is_active: [true], + default_password: [''] + }); + + // per default disable the whole form: + + this.patchFormValues(); + } + + /** + * Loads values that require external references + * And allows async reading + */ + public patchFormValues(): void { + this.personalInfoForm.patchValue({ + username: this.user.username, + groups_id: this.user.groupIds, + title: this.user.title, + first_name: this.user.firstName, + last_name: this.user.lastName + }); + } + + /** + * Makes the form editable + * @param editable + */ + public makeFormEditable(editable: boolean): void { + if (this.personalInfoForm) { + const formControlNames = Object.keys(this.personalInfoForm.controls); + const allowedFormFields = []; + + if (this.isAllowed('manage')) { + // editable content with manage rights + allowedFormFields.push( + this.personalInfoForm.get('username'), + this.personalInfoForm.get('title'), + this.personalInfoForm.get('first_name'), + this.personalInfoForm.get('last_name'), + this.personalInfoForm.get('email'), + this.personalInfoForm.get('structure_level'), + this.personalInfoForm.get('number'), + this.personalInfoForm.get('groups_id'), + this.personalInfoForm.get('comment'), + this.personalInfoForm.get('is_present'), + this.personalInfoForm.get('is_active'), + this.personalInfoForm.get('is_committee'), + this.personalInfoForm.get('about_me') + ); + } else if (this.isAllowed('changePersonal')) { + // changeable personal data + // FIXME: Own E-Mail and Password is hidden (server?) + allowedFormFields.push( + this.personalInfoForm.get('username'), + this.personalInfoForm.get('email'), + this.personalInfoForm.get('about_me') + ); + } + + // treatment for the initial password field + if (!editable || this.newUser) { + allowedFormFields.push(this.personalInfoForm.get('default_password')); + } + + if (editable) { + allowedFormFields.forEach(formElement => { + formElement.enable(); + }); + } else { + formControlNames.forEach(formControlName => { + this.personalInfoForm.get(formControlName).disable(); + }); + } + } + } + + /** + * Handler for the generate Password button. + * Generates a password using 8 pseudo-random letters + * from the `characters` const. + * + * Removed the letter 'O' from the alphabet cause it's easy to confuse + * with the number '0'. + */ + public generatePassword(): void { + let pw = ''; + const characters = 'ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const amount = 8; + for (let i = 0; i < amount; i++) { + pw += characters.charAt(Math.floor(Math.random() * characters.length)); + } + this.personalInfoForm.patchValue({ + default_password: pw + }); + } + + /** + * Save / Submit a user + */ + public saveUser(): void { + if (this.newUser) { + this.repo.create(this.personalInfoForm.value).subscribe( + response => { + this.newUser = false; + this.router.navigate([`./users/${response.id}`]); + // this.setEditMode(false); + // this.loadViewUser(response.id); + }, + error => console.error('Creation of the user failed: ', error.error) + ); + } else { + this.repo.update(this.personalInfoForm.value, this.user).subscribe( + response => { + this.setEditMode(false); + this.loadViewUser(response.id); + }, + error => console.error('Update of the user failed: ', error.error) + ); + } + } + + /** + * sets editUser variable and editable form + * @param edit + */ + public setEditMode(edit: boolean): void { + this.editUser = edit; + this.makeFormEditable(edit); + } + + /** + * click on the edit button + */ + public editUserButton(): void { + if (this.editUser) { + this.saveUser(); + } else { + this.setEditMode(true); + } + } + + public cancelEditMotionButton(): void { + if (this.newUser) { + this.router.navigate(['./users/']); + } else { + this.setEditMode(false); + this.loadViewUser(this.user.id); + } + } + + /** + * click on the delete user button + */ + public deleteUserButton(): void { + this.repo.delete(this.user).subscribe(response => { + this.router.navigate(['./users/']); + }); + } + + /** + * Init function. + */ + public ngOnInit(): void { + this.makeFormEditable(this.editUser); + this.groups = this.DS.filter(Group, group => group.id !== 1); + this.DS.changeObservable.subscribe(model => { + if (model instanceof Group && model.id !== 1) { + this.groups.push(model as Group); + } + }); + } +} diff --git a/client/src/app/site/users/components/user-list/user-list.component.html b/client/src/app/site/users/components/user-list/user-list.component.html new file mode 100644 index 000000000..270082a3f --- /dev/null +++ b/client/src/app/site/users/components/user-list/user-list.component.html @@ -0,0 +1,54 @@ + + + +
+ + +
+ + + + + Name + {{user.fullName}} + + + + + Group + +
+ + people + {{user.groups}} + +
+ + flag + {{user.structureLevel}} + +
+
+
+ + + + Presence + +
+ check_box + Present +
+
+
+ + + +
+ + diff --git a/client/src/app/site/users/components/user-list/user-list.component.scss b/client/src/app/site/users/components/user-list/user-list.component.scss new file mode 100644 index 000000000..f857d282c --- /dev/null +++ b/client/src/app/site/users/components/user-list/user-list.component.scss @@ -0,0 +1,32 @@ +.groupsCell { + display: inline-block; + vertical-align: middle; + line-height: normal; + + mat-icon { + font-size: 80%; + } +} + +.os-listview-table { + .mat-column-name { + flex: 1 0 200px; + } + + .mat-column-group { + flex: 2 0 60px; + } + + .mat-column-presence { + flex: 0 0 60px; + + mat-icon { + font-size: 100%; + margin-right: 5px; + } + + div { + display: inherit; + } + } +} diff --git a/client/src/app/site/users/components/user-list/user-list.component.spec.ts b/client/src/app/site/users/components/user-list/user-list.component.spec.ts new file mode 100644 index 000000000..46c0d7274 --- /dev/null +++ b/client/src/app/site/users/components/user-list/user-list.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UserListComponent } from './user-list.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; + +describe('UserListComponent', () => { + let component: UserListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [UserListComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/users/components/user-list/user-list.component.ts b/client/src/app/site/users/components/user-list/user-list.component.ts new file mode 100644 index 000000000..3a9d5cbf3 --- /dev/null +++ b/client/src/app/site/users/components/user-list/user-list.component.ts @@ -0,0 +1,102 @@ +import { Component, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { TranslateService } from '@ngx-translate/core'; + +import { ViewUser } from '../../models/view-user'; +import { UserRepositoryService } from '../../services/user-repository.service'; +import { ListViewBaseComponent } from '../../../base/list-view-base'; +import { Router, ActivatedRoute } from '@angular/router'; + +/** + * Component for the user list view. + * + */ +@Component({ + selector: 'os-user-list', + templateUrl: './user-list.component.html', + styleUrls: ['./user-list.component.scss'] +}) +export class UserListComponent extends ListViewBaseComponent implements OnInit { + /** + * content of the ellipsis menu + */ + public userMenuList = [ + { + text: 'Groups', + icon: 'people', + action: 'toGroups', + perm: 'users.can_manage' + }, + { + text: 'Import', + icon: 'save_alt', + action: 'toGroups' + }, + { + text: 'Export', + icon: 'archive', + action: 'toGroups' + } + ]; + + /** + * The usual constructor for components + * @param repo the user repository + * @param titleService + * @param translate + */ + public constructor( + private repo: UserRepositoryService, + protected titleService: Title, + protected translate: TranslateService, + private router: Router, + private route: ActivatedRoute + ) { + super(titleService, translate); + } + + /** + * Init function + * + * sets the title, inits the table and calls the repo + */ + public ngOnInit(): void { + super.setTitle('Users'); + this.initTable(); + this.repo.getViewModelListObservable().subscribe(newUsers => { + this.dataSource.data = newUsers; + }); + } + + /** + * Navigate to import page or do it inline + * + * TODO: implement importing of users + */ + public import(): void { + console.log('click on Import'); + } + + /** + * Navigate to groups page + * TODO: implement + */ + public toGroups(): void { + this.router.navigate(['./groups'], { relativeTo: this.route }); + } + + /** + * Handles the click on a user row + * @param row selected row + */ + public selectUser(row: ViewUser): void { + this.router.navigate([`./${row.id}`], { relativeTo: this.route }); + } + + /** + * Handles the click on the plus button + */ + public onPlusButton(): void { + this.router.navigate(['./new'], { relativeTo: this.route }); + } +} diff --git a/client/src/app/site/users/models/view-group.ts b/client/src/app/site/users/models/view-group.ts new file mode 100644 index 000000000..926ce23d6 --- /dev/null +++ b/client/src/app/site/users/models/view-group.ts @@ -0,0 +1,76 @@ +import { BaseViewModel } from '../../base/base-view-model'; +import { Group } from '../../../shared/models/users/group'; +import { BaseModel } from '../../../shared/models/base/base-model'; + +export class ViewGroup extends BaseViewModel { + private _group: Group; + + public get group(): Group { + return this._group ? this._group : null; + } + + public get id(): number { + return this.group ? this.group.id : null; + } + + public get name(): string { + return this.group ? this.group.name : null; + } + + /** + * required for renaming purpose + */ + public set name(newName: string) { + if (this.group) { + this.group.name = newName; + } + } + + public get permissions(): string[] { + return this.group ? this.group.permissions : null; + } + + public constructor(group?: Group) { + super(); + this._group = group; + } + + /** + * Returns an array of permissions where the given perm is included + * or removed. + * + * Avoids touching the local DataStore. + * + * @param perm + */ + public getAlteredPermissions(perm: string): string[] { + // clone the array, avoids altering the local dataStore + const currentPermissions = this.permissions.slice(); + + if (this.hasPermission(perm)) { + // remove the permission from currentPermissions-List + const indexOfPerm = currentPermissions.indexOf(perm); + if (indexOfPerm !== -1) { + currentPermissions.splice(indexOfPerm, 1); + return currentPermissions; + } else { + return currentPermissions; + } + } else { + currentPermissions.push(perm); + return currentPermissions; + } + } + + public hasPermission(perm: string): boolean { + return this.permissions.includes(perm); + } + + public getTitle(): string { + return this.name; + } + + public updateValues(update: BaseModel): void { + console.log('ViewGroups wants to update Values with : ', update); + } +} diff --git a/client/src/app/site/users/models/view-user.ts b/client/src/app/site/users/models/view-user.ts new file mode 100644 index 000000000..d16d07b1e --- /dev/null +++ b/client/src/app/site/users/models/view-user.ts @@ -0,0 +1,135 @@ +import { BaseViewModel } from '../../base/base-view-model'; +import { User } from '../../../shared/models/users/user'; +import { Group } from '../../../shared/models/users/group'; +import { BaseModel } from '../../../shared/models/base/base-model'; + +export class ViewUser extends BaseViewModel { + private _user: User; + private _groups: Group[]; + + public get user(): User { + return this._user ? this._user : null; + } + + public get groups(): Group[] { + return this._groups; + } + + public get id(): number { + return this.user ? this.user.id : null; + } + + public get username(): string { + return this.user ? this.user.username : null; + } + + public get title(): string { + return this.user ? this.user.title : null; + } + + public get firstName(): string { + return this.user ? this.user.first_name : null; + } + + public get lastName(): string { + return this.user ? this.user.last_name : null; + } + + public get fullName(): string { + return this.user ? this.user.full_name : null; + } + + public get email(): string { + return this.user ? this.user.email : null; + } + + public get structureLevel(): string { + return this.user ? this.user.structure_level : null; + } + + public get participantNumber(): string { + return this.user ? this.user.number : null; + } + + public get groupIds(): number[] { + return this.user ? this.user.groups_id : null; + } + + /** + * Required by the input selector + */ + public set groupIds(ids: number[]) { + if (this.user) { + this.user.groups_id = ids; + } + } + + public get initialPassword(): string { + return this.user ? this.user.default_password : null; + } + + public get comment(): string { + return this.user ? this.user.comment : null; + } + + public get isPresent(): boolean { + return this.user ? this.user.is_present : null; + } + + public get isActive(): boolean { + return this.user ? this.user.is_active : null; + } + + public get isCommittee(): boolean { + return this.user ? this.user.is_committee : null; + } + + public get about(): string { + return this.user ? this.user.about_me : null; + } + + public constructor(user?: User, groups?: Group[]) { + super(); + this._user = user; + this._groups = groups; + } + + /** + * required by BaseViewModel. Don't confuse with the users title. + */ + public getTitle(): string { + return this.user ? this.user.toString() : null; + } + + /** + * TODO: Implement + */ + public replaceGroup(newGroup: Group): void {} + + public updateValues(update: BaseModel): void { + if (update instanceof Group) { + this.updateGroup(update as Group); + } + if (update instanceof User) { + this.updateUser(update as User); + } + } + + public updateGroup(update: Group): void { + if (this.user && this.user.groups_id) { + if (this.user.containsGroupId(update.id)) { + this.replaceGroup(update); + } + } + } + /** + * Updates values. Triggered through observables. + * + * @param update a new User or Group + */ + public updateUser(update: User): void { + if (this.user.id === update.id) { + this._user = update; + } + } +} diff --git a/client/src/app/site/users/services/group-repository.service.spec.ts b/client/src/app/site/users/services/group-repository.service.spec.ts new file mode 100644 index 000000000..8a7047055 --- /dev/null +++ b/client/src/app/site/users/services/group-repository.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { GroupRepositoryService } from './group-repository.service'; +import { E2EImportsModule } from '../../../../e2e-imports.module'; + +describe('GroupRepositoryService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [GroupRepositoryService] + })); + + it('should be created', inject([GroupRepositoryService], (service: GroupRepositoryService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/site/users/services/group-repository.service.ts b/client/src/app/site/users/services/group-repository.service.ts new file mode 100644 index 000000000..55b4e6093 --- /dev/null +++ b/client/src/app/site/users/services/group-repository.service.ts @@ -0,0 +1,140 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { ViewGroup } from '../models/view-group'; +import { BaseRepository } from '../../base/base-repository'; +import { Group } from '../../../shared/models/users/group'; +import { DataStoreService } from '../../../core/services/data-store.service'; +import { DataSendService } from '../../../core/services/data-send.service'; +import { ConstantsService } from '../../../core/services/constants.service'; + +/** + * Set rules to define the shape of an app permission + */ +interface AppPermission { + name: string; + permissions: string[]; +} + +/** + * Repository service for Groups + * + * Documentation partially provided in {@link BaseRepository} + */ +@Injectable({ + providedIn: 'root' +}) +export class GroupRepositoryService extends BaseRepository { + /** + * holds sorted permissions per app. + */ + public appPermissions: AppPermission[] = []; + + /** + * Constructor calls the parent constructor + * @param DS Store + * @param dataSend Sending Data + */ + public constructor(DS: DataStoreService, private dataSend: DataSendService, private constants: ConstantsService) { + super(DS, Group); + this.sortPermsPerApp(); + } + + /** + * Add an entry to appPermissions + * + * @param appId number that indicates the app + * @param perm certain permission as string + * @param appName Indicates the header in the Permission Matrix + */ + private addAppPerm(appId: number, perm: string, appName: string): void { + if (!this.appPermissions[appId]) { + this.appPermissions[appId] = { + name: appName, + permissions: [] + }; + } + this.appPermissions[appId].permissions.push(perm); + } + + /** + * read the constants, add them to an array of apps + */ + private sortPermsPerApp(): void { + this.constants.get('permissions').subscribe(perms => { + perms.forEach(perm => { + // extract the apps name + const permApp = perm.value.split('.')[0]; + switch (permApp) { + case 'core': + if (perm.value.indexOf('projector') > -1) { + this.addAppPerm(0, perm, 'Projector'); + } else { + this.addAppPerm(6, perm, 'General'); + } + break; + case 'agenda': + this.addAppPerm(1, perm, 'Agenda'); + break; + case 'motions': + this.addAppPerm(2, perm, 'Motions'); + break; + case 'assignments': + this.addAppPerm(3, perm, 'Assignments'); + break; + case 'mediafiles': + this.addAppPerm(4, perm, 'Mediafiles'); + break; + case 'users': + this.addAppPerm(5, perm, 'Users'); + break; + default: + // plugins + const displayName = `${permApp.charAt(0).toUpperCase}${permApp.slice(1)}`; + // check if the plugin exists as app + const result = this.appPermissions.findIndex(app => { + return app.name === displayName; + }); + const pluginId = result === -1 ? this.appPermissions.length : result; + this.addAppPerm(pluginId, perm, displayName); + break; + } + }); + }); + } + + /** + * creates and saves a new user + * + * @param groupData form value. Usually not yet a real user + */ + public create(groupData: Partial): Observable { + const newGroup = new Group(); + newGroup.patchValues(groupData); + return this.dataSend.createModel(newGroup); + } + + /** + * Updates the given Group with the new permission + * + * @param permission the new permission + * @param viewGroup the selected Group + */ + public update(groupData: Partial, viewGroup: ViewGroup): Observable { + const updateGroup = new Group(); + updateGroup.patchValues(viewGroup.group); + updateGroup.patchValues(groupData); + return this.dataSend.updateModel(updateGroup, 'put'); + } + + /** + * Deletes a given group + */ + public delete(viewGroup: ViewGroup): Observable { + return this.dataSend.delete(viewGroup.group); + } + + public createViewModel(group: Group): ViewGroup { + return new ViewGroup(group); + } +} diff --git a/client/src/app/site/users/services/user-repository.service.spec.ts b/client/src/app/site/users/services/user-repository.service.spec.ts new file mode 100644 index 000000000..33dcadfeb --- /dev/null +++ b/client/src/app/site/users/services/user-repository.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { UserRepositoryService } from './user-repository.service'; +import { E2EImportsModule } from '../../../../e2e-imports.module'; + +describe('UserRepositoryService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [UserRepositoryService] + }); + }); + + it('should be created', inject([UserRepositoryService], (service: UserRepositoryService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/site/users/services/user-repository.service.ts b/client/src/app/site/users/services/user-repository.service.ts new file mode 100644 index 000000000..2d14e5c9a --- /dev/null +++ b/client/src/app/site/users/services/user-repository.service.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@angular/core'; + +import { BaseRepository } from '../../base/base-repository'; +import { ViewUser } from '../models/view-user'; +import { User } from '../../../shared/models/users/user'; +import { Group } from '../../../shared/models/users/group'; +import { Observable } from 'rxjs'; +import { DataStoreService } from '../../../core/services/data-store.service'; +import { DataSendService } from '../../../core/services/data-send.service'; + +/** + * Repository service for users + * + * Documentation partially provided in {@link BaseRepository} + */ +@Injectable({ + providedIn: 'root' +}) +export class UserRepositoryService extends BaseRepository { + /** + * Constructor calls the parent constructor + */ + public constructor(DS: DataStoreService, private dataSend: DataSendService) { + super(DS, User, [Group]); + } + + /** + * Updates a the selected user with the form values. + * + * @param update the forms values + * @param viewUser + */ + public update(update: Partial, viewUser: ViewUser): Observable { + const updateUser = new User(); + // copy the ViewUser to avoid manipulation of parameters + updateUser.patchValues(viewUser.user); + updateUser.patchValues(update); + + // if the user deletes the username, reset + // prevents the server of generating ' +1' as username + if (updateUser.username === '') { + updateUser.username = viewUser.username; + } + + return this.dataSend.updateModel(updateUser, 'put'); + } + + /** + * Deletes a given user + */ + public delete(viewUser: ViewUser): Observable { + return this.dataSend.delete(viewUser.user); + } + + /** + * creates and saves a new user + * + * TODO: used over not-yet-existing detail view + * @param userData blank form value. Usually not yet a real user + */ + public create(userData: Partial): Observable { + const newUser = new User(); + // collectionString of userData is still empty + newUser.patchValues(userData); + + // if the username is not present, delete. + // The server will generate a one + if (!newUser.username) { + delete newUser.username; + } + + // title must not be "null" during creation + if (!newUser.title) { + delete newUser.title; + } + + // null values will not be accepted for group_id + if (!newUser.groups_id) { + delete newUser.groups_id; + } + + return this.dataSend.createModel(newUser); + } + + public createViewModel(user: User): ViewUser { + const groups = this.DS.getMany(Group, user.groups_id); + return new ViewUser(user, groups); + } +} diff --git a/client/src/app/site/users/users-routing.module.ts b/client/src/app/site/users/users-routing.module.ts new file mode 100644 index 000000000..879acea2e --- /dev/null +++ b/client/src/app/site/users/users-routing.module.ts @@ -0,0 +1,37 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { UserListComponent } from './components/user-list/user-list.component'; +import { UserDetailComponent } from './components/user-detail/user-detail.component'; +import { GroupListComponent } from './components/group-list/group-list.component'; + +const routes: Routes = [ + { + path: '', + component: UserListComponent + }, + { + path: 'new', + component: UserDetailComponent + }, + { + path: 'groups', + component: GroupListComponent + /** + * FIXME: CRITICAL: + * Refreshing the page, even while having the required permission, will navigate you back to "/" + * Makes developing protected areas impossible. + * Has the be (temporarily) removed if this page should be edited. + */ + // data: { basePerm: 'users.can_manage' } + }, + { + path: ':id', + component: UserDetailComponent + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class UsersRoutingModule {} diff --git a/client/src/app/site/users/users.config.ts b/client/src/app/site/users/users.config.ts new file mode 100644 index 000000000..956b42bc1 --- /dev/null +++ b/client/src/app/site/users/users.config.ts @@ -0,0 +1,22 @@ +import { AppConfig } from '../base/app-config'; +import { User } from '../../shared/models/users/user'; +import { Group } from '../../shared/models/users/group'; +import { PersonalNote } from '../../shared/models/users/personal-note'; + +export const UsersAppConfig: AppConfig = { + name: 'users', + models: [ + { collectionString: 'users/user', model: User }, + { collectionString: 'users/group', model: Group }, + { collectionString: 'users/personal-note', model: PersonalNote } + ], + mainMenuEntries: [ + { + route: '/users', + displayName: 'Participants', + icon: 'people', + weight: 500, + permission: 'users.can_see_name' + } + ] +}; diff --git a/client/src/app/site/users/users.module.spec.ts b/client/src/app/site/users/users.module.spec.ts new file mode 100644 index 000000000..a187e5f83 --- /dev/null +++ b/client/src/app/site/users/users.module.spec.ts @@ -0,0 +1,13 @@ +import { UsersModule } from './users.module'; + +describe('UsersModule', () => { + let usersModule: UsersModule; + + beforeEach(() => { + usersModule = new UsersModule(); + }); + + it('should create an instance', () => { + expect(usersModule).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/users/users.module.ts b/client/src/app/site/users/users.module.ts new file mode 100644 index 000000000..9306149c0 --- /dev/null +++ b/client/src/app/site/users/users.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { UsersRoutingModule } from './users-routing.module'; +import { SharedModule } from '../../shared/shared.module'; +import { UserListComponent } from './components/user-list/user-list.component'; +import { UserDetailComponent } from './components/user-detail/user-detail.component'; +import { GroupListComponent } from './components/group-list/group-list.component'; + +@NgModule({ + imports: [CommonModule, UsersRoutingModule, SharedModule], + declarations: [UserListComponent, UserDetailComponent, GroupListComponent] +}) +export class UsersModule {} diff --git a/client/src/assets/.gitkeep b/client/src/assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/assets/i18n/de.json b/client/src/assets/i18n/de.json new file mode 100644 index 000000000..893fcaf5a --- /dev/null +++ b/client/src/assets/i18n/de.json @@ -0,0 +1,95 @@ +{ + "Cancel": "", + "About Me": "", + "Category": "", + "Change Password": "Passwort รคndern", + "Changed version": "", + "Comment": "", + "Content": "", + "Copyright by": "Copyright by", + "Delete User": "", + "DeleteMotion": "", + "Designates whether this user is in the room": { + "0": "" + }, + "Designates whether this user should be treated as a committee": { + "0": "" + }, + "Designates whether this user should be treated as active": { + " Unselect this instead of deleting the account": { + "0": "" + } + }, + "Diff version": "", + "EMail": "", + "Edit Profile": "Profil bearbeiten", + "Edit category details:": "", + "English": "Englisch", + "Export As": { + "0": { + "0": { + "0": "" + } + } + }, + "FILTER": "", + "Final version": "", + "First Name": "", + "French": "Franzรถsisch", + "German": "Deutsch", + "Groups": "", + "Identifier": "", + "Initial Password": "", + "Inline": "", + "Installed plugins": "", + "Is Active": "", + "Is Present": "", + "Is a committee": "", + "Last Name": "", + "Legal Notice": "Impressum", + "License": "", + "Login": "", + "Login as Guest": "", + "Logout": "Abmelden", + "Meta information": "", + "Motion": "", + "Motions": "Antrรคge", + "Name": "", + "None": "", + "OK": "", + "Offline mode: You can use OpenSlides but changes are not saved": { + "0": "" + }, + "Only for internal notes": { + "0": "" + }, + "Origin": "", + "Original version": "", + "Outside": "", + "Participant Number": "", + "Personal Note": "", + "Personal note": "", + "Prefix": "", + "Present": "", + "Privacy Policy": "Datenschutz", + "Project": "", + "Projector": "", + "Reason": "", + "Required": "", + "Reset State": "", + "Reset recommendation": "", + "SORT": "", + "Selected Values": "", + "State": "", + "Structure Level": "", + "Submitters": "", + "Supporters": "", + "The assembly may decide:": "", + "The event manager hasn't set up a privacy policy yet": { + "0": "" + }, + "Title": "", + "Username": "", + "Welcome to OpenSlides": "Willkommen bei OpenSlides", + "by": "" +} diff --git a/client/src/assets/i18n/en.json b/client/src/assets/i18n/en.json new file mode 100644 index 000000000..614913f37 --- /dev/null +++ b/client/src/assets/i18n/en.json @@ -0,0 +1,95 @@ +{ + "Cancel": "", + "About Me": "", + "Category": "", + "Change Password": "", + "Changed version": "", + "Comment": "", + "Content": "", + "Copyright by": "", + "Delete User": "", + "DeleteMotion": "", + "Designates whether this user is in the room": { + "0": "" + }, + "Designates whether this user should be treated as a committee": { + "0": "" + }, + "Designates whether this user should be treated as active": { + " Unselect this instead of deleting the account": { + "0": "" + } + }, + "Diff version": "", + "EMail": "", + "Edit Profile": "", + "Edit category details:": "", + "English": "", + "Export As": { + "0": { + "0": { + "0": "" + } + } + }, + "FILTER": "", + "Final version": "", + "First Name": "", + "French": "", + "German": "", + "Groups": "", + "Identifier": "", + "Initial Password": "", + "Inline": "", + "Installed plugins": "", + "Is Active": "", + "Is Present": "", + "Is a committee": "", + "Last Name": "", + "Legal Notice": "", + "License": "", + "Login": "", + "Login as Guest": "", + "Logout": "", + "Meta information": "", + "Motion": "", + "Motions": "", + "Name": "", + "None": "", + "OK": "", + "Offline mode: You can use OpenSlides but changes are not saved": { + "0": "" + }, + "Only for internal notes": { + "0": "" + }, + "Origin": "", + "Original version": "", + "Outside": "", + "Participant Number": "", + "Personal Note": "", + "Personal note": "", + "Prefix": "", + "Present": "", + "Privacy Policy": "", + "Project": "", + "Projector": "", + "Reason": "", + "Required": "", + "Reset State": "", + "Reset recommendation": "", + "SORT": "", + "Selected Values": "", + "State": "", + "Structure Level": "", + "Submitters": "", + "Supporters": "", + "The assembly may decide:": "", + "The event manager hasn't set up a privacy policy yet": { + "0": "" + }, + "Title": "", + "Username": "", + "Welcome to OpenSlides": "", + "by": "" +} diff --git a/client/src/assets/i18n/fr.json b/client/src/assets/i18n/fr.json new file mode 100644 index 000000000..614913f37 --- /dev/null +++ b/client/src/assets/i18n/fr.json @@ -0,0 +1,95 @@ +{ + "Cancel": "", + "About Me": "", + "Category": "", + "Change Password": "", + "Changed version": "", + "Comment": "", + "Content": "", + "Copyright by": "", + "Delete User": "", + "DeleteMotion": "", + "Designates whether this user is in the room": { + "0": "" + }, + "Designates whether this user should be treated as a committee": { + "0": "" + }, + "Designates whether this user should be treated as active": { + " Unselect this instead of deleting the account": { + "0": "" + } + }, + "Diff version": "", + "EMail": "", + "Edit Profile": "", + "Edit category details:": "", + "English": "", + "Export As": { + "0": { + "0": { + "0": "" + } + } + }, + "FILTER": "", + "Final version": "", + "First Name": "", + "French": "", + "German": "", + "Groups": "", + "Identifier": "", + "Initial Password": "", + "Inline": "", + "Installed plugins": "", + "Is Active": "", + "Is Present": "", + "Is a committee": "", + "Last Name": "", + "Legal Notice": "", + "License": "", + "Login": "", + "Login as Guest": "", + "Logout": "", + "Meta information": "", + "Motion": "", + "Motions": "", + "Name": "", + "None": "", + "OK": "", + "Offline mode: You can use OpenSlides but changes are not saved": { + "0": "" + }, + "Only for internal notes": { + "0": "" + }, + "Origin": "", + "Original version": "", + "Outside": "", + "Participant Number": "", + "Personal Note": "", + "Personal note": "", + "Prefix": "", + "Present": "", + "Privacy Policy": "", + "Project": "", + "Projector": "", + "Reason": "", + "Required": "", + "Reset State": "", + "Reset recommendation": "", + "SORT": "", + "Selected Values": "", + "State": "", + "Structure Level": "", + "Submitters": "", + "Supporters": "", + "The assembly may decide:": "", + "The event manager hasn't set up a privacy policy yet": { + "0": "" + }, + "Title": "", + "Username": "", + "Welcome to OpenSlides": "", + "by": "" +} diff --git a/client/src/assets/img/favicon.png b/client/src/assets/img/favicon.png new file mode 100644 index 000000000..026653c14 Binary files /dev/null and b/client/src/assets/img/favicon.png differ diff --git a/client/src/assets/img/logo-projector.png b/client/src/assets/img/logo-projector.png new file mode 100644 index 000000000..6b94d9098 Binary files /dev/null and b/client/src/assets/img/logo-projector.png differ diff --git a/client/src/assets/img/nav_active.png b/client/src/assets/img/nav_active.png new file mode 100644 index 000000000..48be90846 Binary files /dev/null and b/client/src/assets/img/nav_active.png differ diff --git a/client/src/assets/img/nav_dark-bg.png b/client/src/assets/img/nav_dark-bg.png new file mode 100644 index 000000000..f53619aff Binary files /dev/null and b/client/src/assets/img/nav_dark-bg.png differ diff --git a/client/src/assets/img/nav_projector_sidebar_min.png b/client/src/assets/img/nav_projector_sidebar_min.png new file mode 100644 index 000000000..d8d3e371f Binary files /dev/null and b/client/src/assets/img/nav_projector_sidebar_min.png differ diff --git a/client/src/assets/img/openslides-logo-dark.png b/client/src/assets/img/openslides-logo-dark.png new file mode 100644 index 000000000..52e165828 Binary files /dev/null and b/client/src/assets/img/openslides-logo-dark.png differ diff --git a/client/src/assets/img/openslides-logo-h-dark-transparent.svg b/client/src/assets/img/openslides-logo-h-dark-transparent.svg new file mode 100644 index 000000000..9cfb5fbca --- /dev/null +++ b/client/src/assets/img/openslides-logo-h-dark-transparent.svg @@ -0,0 +1,636 @@ + +image/svg+xmlOpenSlides Teamimage/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/src/assets/img/openslides-logo.png b/client/src/assets/img/openslides-logo.png new file mode 100644 index 000000000..b39a3f5ed Binary files /dev/null and b/client/src/assets/img/openslides-logo.png differ diff --git a/client/src/assets/styles/openslides-theme.scss b/client/src/assets/styles/openslides-theme.scss new file mode 100644 index 000000000..f9dfdcf60 --- /dev/null +++ b/client/src/assets/styles/openslides-theme.scss @@ -0,0 +1,42 @@ +// define a real custom palette (using http://mcg.mbitson.com) +$openslides-blue: ( + 50: #e6eff2, + 100: #c1d6e0, + 200: #98bbcb, + 300: #6fa0b6, + 400: #508ba6, + 500: #317796, + 600: #2c6f8e, + 700: #002a42, + 800: #00253c, + 900: #001f33, + A100: #9fd7ff, + A200: #6cc2ff, + A400: #39acff, + A700: #1fa2ff, + contrast: ( + 50: #ffffff, + 100: #ffffff, + 200: #ffffff, + 300: #ffffff, + 400: #ffffff, + 500: #ffffff, + 600: #ffffff, + 700: #ffffff, + 800: #ffffff, + 900: #ffffff, + A100: #000000, + A200: #000000, + A400: #000000, + A700: #000000 + ) +); + +// Generate paletes using: https://material.io/design/color/ +// default values fir mat-palette: $default: 500, $lighter: 100, $darker: 700. +$openslides-primary: mat-palette($openslides-blue); +$openslides-accent: mat-palette($mat-pink, A200, A100, A400); +$openslides-warn: mat-palette($mat-red); + +// Create the theme object (a Sass map containing all of the palettes). +$openslides-theme: mat-light-theme($openslides-primary, $openslides-accent, $openslides-warn); diff --git a/client/src/browserslist b/client/src/browserslist new file mode 100644 index 000000000..8e09ab492 --- /dev/null +++ b/client/src/browserslist @@ -0,0 +1,9 @@ +# This file is currently used by autoprefixer to adjust CSS to support the below specified browsers +# For additional information regarding the format and rule options, please see: +# https://github.com/browserslist/browserslist#queries +# For IE 9-11 support, please uncomment the last line of the file and adjust as needed +> 0.5% +last 2 versions +Firefox ESR +not dead +# IE 9-11 \ No newline at end of file diff --git a/client/src/e2e-imports.module.ts b/client/src/e2e-imports.module.ts new file mode 100644 index 000000000..f24c12518 --- /dev/null +++ b/client/src/e2e-imports.module.ts @@ -0,0 +1,41 @@ +import { NgModule } from '@angular/core'; +import { APP_BASE_HREF, CommonModule } from '@angular/common'; +import { HttpClient, HttpClientModule } from '@angular/common/http'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { SharedModule } from 'app/shared/shared.module'; +import { AppModule, HttpLoaderFactory } from 'app/app.module'; +import { AppRoutingModule } from 'app/app-routing.module'; +import { LoginModule } from 'app/site/login/login.module'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +/** + * Share Module for all "dumb" components and pipes. + * + * These components don not import and inject services from core or other features + * in their constructors. + * + * Should receive all data though attributes in the template of the component using them. + * No dependency to the rest of our application. + */ + +@NgModule({ + imports: [ + AppModule, + CommonModule, + SharedModule, + HttpClientModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useFactory: HttpLoaderFactory, + deps: [HttpClient] + } + }), + LoginModule, + BrowserAnimationsModule, + AppRoutingModule + ], + exports: [CommonModule, SharedModule, HttpClientModule, TranslateModule, AppRoutingModule], + providers: [{ provide: APP_BASE_HREF, useValue: '/' }] +}) +export class E2EImportsModule {} diff --git a/client/src/environments/environment.prod.ts b/client/src/environments/environment.prod.ts new file mode 100644 index 000000000..5d0833162 --- /dev/null +++ b/client/src/environments/environment.prod.ts @@ -0,0 +1,3 @@ +export const environment = { + production: true +}; diff --git a/client/src/environments/environment.ts b/client/src/environments/environment.ts new file mode 100644 index 000000000..cbc0b92a1 --- /dev/null +++ b/client/src/environments/environment.ts @@ -0,0 +1,16 @@ +// This file can be replaced during build by using the `fileReplacements` array. +// `ng build ---prod` replaces `environment.ts` with `environment.prod.ts`. +// The list of file replacements can be found in `angular.json`. + +export const environment = { + production: false, + urlPrefix: '/apps' +}; + +/* + * In development mode, to ignore zone related error stack frames such as + * `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can + * import the following file, but please comment it out in production mode + * because it will have performance impact when throw error + */ +// import 'zone.js/dist/zone-error'; // Included with Angular CLI. diff --git a/client/src/index.html b/client/src/index.html new file mode 100644 index 000000000..2677afdef --- /dev/null +++ b/client/src/index.html @@ -0,0 +1,17 @@ + + + + + + OpenSlides 3 + + + + + + + + + + + diff --git a/client/src/karma.conf.js b/client/src/karma.conf.js new file mode 100644 index 000000000..f905be2e3 --- /dev/null +++ b/client/src/karma.conf.js @@ -0,0 +1,31 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function(config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage-istanbul-reporter'), + require('@angular-devkit/build-angular/plugins/karma') + ], + client: { + clearContext: false // leave Jasmine Spec Runner output visible in browser + }, + coverageIstanbulReporter: { + dir: require('path').join(__dirname, '../coverage'), + reports: ['html', 'lcovonly'], + fixWebpackSourcePaths: true + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['Chrome'], + singleRun: false + }); +}; diff --git a/client/src/main.ts b/client/src/main.ts new file mode 100644 index 000000000..cbf340b02 --- /dev/null +++ b/client/src/main.ts @@ -0,0 +1,13 @@ +import { enableProdMode, NgModuleRef } from '@angular/core'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app/app.module'; +import { environment } from './environments/environment'; + +if (environment.production) { + enableProdMode(); +} + +platformBrowserDynamic() + .bootstrapModule(AppModule) + .catch(err => console.log(err)); diff --git a/client/src/plugins.ts b/client/src/plugins.ts new file mode 100644 index 000000000..c20b522e2 --- /dev/null +++ b/client/src/plugins.ts @@ -0,0 +1 @@ +export const plugins: string[] = []; diff --git a/client/src/polyfills.ts b/client/src/polyfills.ts new file mode 100644 index 000000000..cf6e73d0c --- /dev/null +++ b/client/src/polyfills.ts @@ -0,0 +1,76 @@ +/** + * This file includes polyfills needed by Angular and is loaded before the app. + * You can add your own extra polyfills to this file. + * + * This file is divided into 2 sections: + * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. + * 2. Application imports. Files imported after ZoneJS that should be loaded before your main + * file. + * + * The current setup is for so-called "evergreen" browsers; the last versions of browsers that + * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), + * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. + * + * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html + */ + +/*************************************************************************************************** + * BROWSER POLYFILLS + */ + +/* IE9, IE10 and IE11 requires all of the following polyfills. */ +// import 'core-js/es6/symbol'; +// import 'core-js/es6/object'; +// import 'core-js/es6/function'; +// import 'core-js/es6/parse-int'; +// import 'core-js/es6/parse-float'; +// import 'core-js/es6/number'; +// import 'core-js/es6/math'; +// import 'core-js/es6/string'; +// import 'core-js/es6/date'; +// import 'core-js/es6/array'; +// import 'core-js/es6/regexp'; +// import 'core-js/es6/map'; +// import 'core-js/es6/weak-map'; +// import 'core-js/es6/set'; + +/* IE10 and IE11 requires the following for NgClass support on SVG elements */ +// import 'classlist.js'; // Run `npm install --save classlist.js`. + +/* IE10 and IE11 requires the following for the Reflect API. */ +// import 'core-js/es6/reflect'; + +/* Evergreen browsers require these. */ +// Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove. +import 'core-js/es7/reflect'; + +/** + * Web Animations `@angular/platform-browser/animations` + * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. + * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). + */ +// import 'web-animations-js'; // Run `npm install --save web-animations-js`. + +/** + * By default, zone.js will patch all possible macroTask and DomEvents + * user can disable parts of macroTask/DomEvents patch by setting following flags + */ + +// (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame +// (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick +// (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames + +/** + * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js + * with the following flag, it will bypass `zone.js` patch for IE/Edge + */ +// (window as any).__Zone_enable_cross_context_check = true; + +/*************************************************************************************************** + * Zone JS is required by default for Angular itself. + */ +import 'zone.js/dist/zone'; // Included with Angular CLI. + +/*************************************************************************************************** + * APPLICATION IMPORTS + */ diff --git a/client/src/styles.scss b/client/src/styles.scss new file mode 100644 index 000000000..3c227a245 --- /dev/null +++ b/client/src/styles.scss @@ -0,0 +1,122 @@ +@import '~@angular/material/theming'; +@include mat-core(); + +/** Import brand theme and (new) component themes */ +@import './assets/styles/openslides-theme'; +@import './app/site/site.component.scss-theme'; +@import '../node_modules/roboto-fontface/css/roboto/roboto-fontface.css'; +@mixin openslides-components-theme($theme) { + @include os-site-theme($theme); + /** More components are added here */ +} + +@include angular-material-theme($openslides-theme); +@include openslides-components-theme($openslides-theme); + +* { + font-family: Roboto, Arial, Helvetica, sans-serif; +} +mat-icon { + font-family: MaterialIcons-Regular; +} + +body { + // background: #e8eaed; + margin: 0 auto; + padding: 0; +} + +.generic-mini-button { + bottom: -28px; + z-index: 100; +} + +.save-button { + // needs to be important or will be overwritten locally + background-color: rgb(77, 243, 86) !important; +} + +.text-success { + color: rgb(77, 243, 86); +} + +.red-warning-text { + color: red; +} + +.icon-text-distance { + margin-left: 5px; +} + +.os-card { + max-width: 90%; + margin-top: 10px; + margin-left: auto; + margin-right: auto; +} + +//custom table header for search button, filtering and more. Used in ListViews +.custom-table-header { + width: 100%; + height: 60px; + line-height: 60px; + text-align: right; + background: white; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); +} + +.os-listview-table { + width: 100%; + + /** hide mat header row */ + .mat-header-row { + display: none; + } + + /** size of the mat row */ + mat-row { + height: 60px; + } + + mat-row:hover { + cursor: pointer; + background-color: rgba(0, 0, 0, 0.025); + } +} + +.card-plus-distance { + margin-top: 40px; +} + +/**title of an app page*/ +.app-name { + margin-left: 20px; +} + +/**content of an app page*/ +.app-content { + margin: 20px; + margin-top: 50px; +} + +/**use to push content to the right side*/ +.spacer { + flex: 1 1 auto; +} + +/** helper classes for animation */ +.on-transition-fade { + z-index: 100; +} + +footer { + position: absolute; + left: 0; + bottom: 0; + width: 100%; + z-index: 1; +} + +mat-expansion-panel { + border-radius: 0 !important; +} diff --git a/client/src/test.ts b/client/src/test.ts new file mode 100644 index 000000000..da9049dc6 --- /dev/null +++ b/client/src/test.ts @@ -0,0 +1,14 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + +import 'zone.js/dist/zone-testing'; +import { getTestBed } from '@angular/core/testing'; +import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; + +declare const require: any; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); +// Then we find all the tests. +const context = require.context('./', true, /\.spec\.ts$/); +// And load the modules. +context.keys().map(context); diff --git a/client/src/tsconfig.app.json b/client/src/tsconfig.app.json new file mode 100644 index 000000000..29f396aac --- /dev/null +++ b/client/src/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/app", + "module": "es2015", + "types": [] + }, + "exclude": ["src/test.ts", "**/*.spec.ts"] +} diff --git a/client/src/tsconfig.spec.json b/client/src/tsconfig.spec.json new file mode 100644 index 000000000..1fa1cc978 --- /dev/null +++ b/client/src/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/spec", + "module": "commonjs", + "types": ["jasmine", "node"] + }, + "files": ["test.ts", "polyfills.ts"], + "include": ["**/*.spec.ts", "**/*.d.ts"] +} diff --git a/client/src/tslint.json b/client/src/tslint.json new file mode 100644 index 000000000..bca4a513c --- /dev/null +++ b/client/src/tslint.json @@ -0,0 +1,28 @@ +{ + "extends": "../tslint.json", + "rules": { + "directive-selector": [true, "attribute", "os", "camelCase"], + "component-selector": [true, "element", "os", "kebab-case"], + "member-access": [true, "check-accessor", "check-constructor", "check-parameter-property"], + "comment-format": [true, "check-space"], + "curly": true, + "no-string-literal": true, + "jsdoc-format": true, + "no-trailing-whitespace": true, + "member-ordering": [ + true, + { + "order": ["static-field", "static-method", "instance-field", "constructor", "instance-method"] + } + ], + "no-unused-variable": true, + "typedef": [ + true, + "call-signature", + "property-declaration", + "parameter", + "object-destructuring", + "array-destructuring" + ] + } +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 000000000..83f8ab6fd --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "baseUrl": "./src", + "outDir": "./dist/out-tsc", + "sourceMap": true, + "declaration": false, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "target": "es5", + "typeRoots": ["node_modules/@types"], + "lib": ["es2017", "dom"] + } +} diff --git a/client/tslint.json b/client/tslint.json new file mode 100644 index 000000000..21820b563 --- /dev/null +++ b/client/tslint.json @@ -0,0 +1,59 @@ +{ + "rulesDirectory": ["node_modules/codelyzer"], + "rules": { + "arrow-return-shorthand": true, + "callable-types": true, + "class-name": true, + "deprecation": { + "severity": "warn" + }, + "forin": true, + "import-blacklist": [true, "rxjs/Rx"], + "interface-over-type-literal": true, + "label-position": true, + "member-access": false, + "member-ordering": [ + true, + { + "order": ["static-field", "instance-field", "static-method", "instance-method"] + } + ], + "no-arg": true, + "no-bitwise": true, + "no-console": [true, "debug", "info", "time", "timeEnd", "trace"], + "no-construct": true, + "no-debugger": true, + "no-duplicate-super": true, + "no-empty": false, + "no-empty-interface": true, + "no-eval": true, + "no-inferrable-types": [true, "ignore-params"], + "no-misused-new": true, + "no-non-null-assertion": true, + "no-shadowed-variable": true, + "no-string-literal": false, + "no-string-throw": true, + "no-switch-case-fall-through": true, + "no-unnecessary-initializer": true, + "no-unused-expression": true, + "no-use-before-declare": true, + "no-var-keyword": true, + "object-literal-sort-keys": false, + "prefer-const": true, + "radix": true, + "triple-equals": [true, "allow-null-check"], + "unified-signatures": true, + "variable-name": false, + "whitespace": [true, "check-branch", "check-decl", "check-operator", "check-separator", "check-type"], + "no-output-on-prefix": true, + "use-input-property-decorator": true, + "use-output-property-decorator": true, + "use-host-property-decorator": true, + "no-input-rename": true, + "no-output-rename": true, + "use-life-cycle-interface": true, + "use-pipe-transform-interface": true, + "component-class-suffix": true, + "directive-class-suffix": true + } +} diff --git a/make/commands.py b/make/commands.py index 1f03dd323..3e34be355 100644 --- a/make/commands.py +++ b/make/commands.py @@ -1,42 +1,10 @@ -import re - from parser import command, argument, call +import yaml +import requirements FAIL = '\033[91m' SUCCESS = '\033[92m' -RESET = '\033[0m' - - -@argument('module', nargs='?', default='') -@command('test', help='runs the tests') -def test(args=None): - """ - Runs the tests. - """ - module = getattr(args, 'module', '') - if module == '': - module = 'tests' - else: - module = 'tests.{}'.format(module) - return call("DJANGO_SETTINGS_MODULE='tests.settings' coverage run " - "./manage.py test {}".format(module)) - - -@argument('--plain', action='store_true') -@command('coverage', help='Runs all tests and builds the coverage html files') -def coverage(args=None, plain=None): - """ - Runs the tests and creates a coverage report. - - By default it creates a html report. With the argument --plain, it creates - a plain report and fails under a certain amount of untested lines. - """ - if plain is None: - plain = getattr(args, 'plain', False) - if plain: - return call('coverage report -m --fail-under=80') - else: - return call('coverage html') +RESET = '\033[0m' @command('check', help='Checks for pep8 errors in openslides and tests') @@ -54,17 +22,10 @@ def travis(args=None): """ return_codes = [] with open('.travis.yml') as f: - script_lines = False - for line in (line.strip() for line in f.readlines()): - if line == 'script:': - script_lines = True - continue - if not script_lines or not line: - continue - - match = re.search(r'"(.*)"', line) - print('Run: %s' % match.group(1)) - return_code = call(match.group(1)) + travis = yaml.load(f) + for line in travis['script']: + print('Run: {}'.format(line)) + return_code = call(line) return_codes.append(return_code) if return_code: print(FAIL + 'fail!\n' + RESET) @@ -76,7 +37,7 @@ def travis(args=None): @argument('-r', '--requirements', nargs='?', - default='requirements_production.txt') + default='requirements.txt') @command('min_requirements', help='Prints a pip line to install the minimum supported versions of ' 'the requirements.') @@ -85,23 +46,19 @@ def min_requirements(args=None): Prints a pip install command to install the minimal supported versions of a requirement file. - Uses requirements_production.txt by default. + Uses requirements.txt by default. The following line will install the version: pip install $(python make min_requirements) """ - import pip - def get_lowest_versions(requirements_file): - for line in pip.req.parse_requirements(requirements_file, session=pip.download.PipSession()): - for specifier in line.req.specifier: - if specifier.operator == '>=': - min_version = specifier.version - break - else: - raise ValueError('Not supported line {}'.format(line)) - yield '%s==%s' % (line.req.name, min_version) + with open(requirements_file) as f: + for req in requirements.parse(f): + if req.specifier: + for spec, version in req.specs: + if spec == ">=": + yield "{}=={}".format(req.name, version) print(' '.join(get_lowest_versions(args.requirements))) diff --git a/make/requirements.txt b/make/requirements.txt new file mode 100644 index 000000000..d0dd531a7 --- /dev/null +++ b/make/requirements.txt @@ -0,0 +1,4 @@ +# Requirements for the make scripts + +requirements-parser +PyYAML diff --git a/openslides/__init__.py b/openslides/__init__.py index 4f0ab8578..8ada5daea 100644 --- a/openslides/__init__.py +++ b/openslides/__init__.py @@ -1,6 +1,6 @@ __author__ = 'OpenSlides Team ' __description__ = 'Presentation and assembly system' -__version__ = '2.3.1-dev' +__version__ = '3.0-dev' __license__ = 'MIT' __url__ = 'https://openslides.org' diff --git a/openslides/__main__.py b/openslides/__main__.py index d162cebb2..805bf4635 100644 --- a/openslides/__main__.py +++ b/openslides/__main__.py @@ -1,20 +1,19 @@ #!/usr/bin/env python import os -import subprocess import sys -from typing import Dict # noqa +from typing import Dict import django from django.core.management import call_command, execute_from_command_line import openslides from openslides.utils.arguments import arguments +from openslides.utils.exceptions import OpenSlidesError from openslides.utils.main import ( ExceptionArgumentParser, UnknownCommand, get_default_settings_dir, - get_geiss_path, get_local_settings_dir, is_local_installation, open_browser, @@ -145,10 +144,6 @@ def get_parser(): '--local-installation', action='store_true', help='Store settings and user files in a local directory.') - subcommand_start.add_argument( - '--use-geiss', - action='store_true', - help='Use Geiss instead of Daphne as ASGI protocol server.') # Subcommand createsettings createsettings_help = 'Creates the settings file.' @@ -193,6 +188,8 @@ def start(args): """ Starts OpenSlides: Runs migrations and runs runserver. """ + raise OpenSlidesError('The start command does not work anymore. ' + + 'Please use `createsettings`, `migrate` and `runserver`.') settings_dir = args.settings_dir settings_filename = args.settings_filename local_installation = is_local_installation() @@ -220,56 +217,23 @@ def start(args): # Migrate database call_command('migrate') - if args.use_geiss: - # Make sure Redis is used. - if settings.CHANNEL_LAYERS['default']['BACKEND'] != 'asgi_redis.RedisChannelLayer': - raise RuntimeError("You have to use the ASGI Redis backend in the settings to use Geiss.") + # Open the browser + if not args.no_browser: + open_browser(args.host, args.port) - # Download Geiss and collect the static files. - call_command('getgeiss') - call_command('collectstatic', interactive=False) - - # Open the browser - if not args.no_browser: - open_browser(args.host, args.port) - - # Start Geiss in its own thread - subprocess.Popen([ - get_geiss_path(), - '--host', args.host, - '--port', args.port, - '--static', '/static/:{}'.format(settings.STATIC_ROOT), - '--static', '/media/:{}'.format(settings.MEDIA_ROOT), - ]) - - # Start one worker in this thread. There can be only one worker as - # long as SQLite3 is used. - call_command('runworker') - - else: - # Open the browser - if not args.no_browser: - open_browser(args.host, args.port) - - # Start Daphne and one worker - # - # Use flag --noreload to tell Django not to reload the server. - # Therefor we have to set the keyword noreload to False because Django - # parses this directly to the use_reloader keyword. - # - # Use flag --insecure to serve static files even if DEBUG is False. - # - # Use flag --nothreading to tell Django Channels to run in single - # thread mode with one worker only. Therefor we have to set the keyword - # nothreading to False because Django parses this directly to - # use_threading keyword. - call_command( - 'runserver', - '{}:{}'.format(args.host, args.port), - noreload=False, # Means True, see above. - insecure=True, - nothreading=False, # Means True, see above. - ) + # Start Daphne + # + # Use flag --noreload to tell Django not to reload the server. + # Therefor we have to set the keyword noreload to False because Django + # parses this directly to the use_reloader keyword. + # + # Use flag --insecure to serve static files even if DEBUG is False. + call_command( + 'runserver', + '{}:{}'.format(args.host, args.port), + noreload=False, # Means True, see above. + insecure=True, + ) def createsettings(args): @@ -278,7 +242,7 @@ def createsettings(args): """ settings_dir = args.settings_dir local_installation = is_local_installation() - context = {} # type: Dict[str, str] + context: Dict[str, str] = {} if local_installation: if settings_dir is None: diff --git a/openslides/agenda/access_permissions.py b/openslides/agenda/access_permissions.py index 30010cd5f..28da16c93 100644 --- a/openslides/agenda/access_permissions.py +++ b/openslides/agenda/access_permissions.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Iterable, List, Optional # noqa +from typing import Any, Dict, Iterable, List, Optional from ..utils.access_permissions import BaseAccessPermissions from ..utils.auth import has_perm @@ -73,7 +73,7 @@ class ItemAccessPermissions(BaseAccessPermissions): # In non internal case managers see everything and non managers see # everything but comments. if has_perm(user, 'agenda.can_manage'): - blocked_keys_non_internal_hidden_case = [] # type: Iterable[str] + blocked_keys_non_internal_hidden_case: Iterable[str] = [] can_see_hidden = True else: blocked_keys_non_internal_hidden_case = ('comment',) diff --git a/openslides/agenda/apps.py b/openslides/agenda/apps.py index a2550cbfa..ed5434341 100644 --- a/openslides/agenda/apps.py +++ b/openslides/agenda/apps.py @@ -1,6 +1,5 @@ from django.apps import AppConfig -from ..utils.collection import Collection from ..utils.projector import register_projector_elements @@ -13,10 +12,8 @@ class AgendaAppConfig(AppConfig): def ready(self): # Import all required stuff. from django.db.models.signals import pre_delete, post_save - from ..core.config import config from ..core.signals import permission_change, user_data_required from ..utils.rest_api import router - from .config_variables import get_config_variables from .projector import get_projector_elements from .signals import ( get_permission_change_data, @@ -25,8 +22,7 @@ class AgendaAppConfig(AppConfig): required_users) from .views import ItemViewSet - # Define config variables and projector elements. - config.update_config_variables(get_config_variables()) + # Define projector elements. register_projector_elements(get_projector_elements()) # Connect signals. @@ -46,9 +42,13 @@ class AgendaAppConfig(AppConfig): # Register viewsets. router.register(self.get_model('Item').get_collection_string(), ItemViewSet) + def get_config_variables(self): + from .config_variables import get_config_variables + return get_config_variables() + def get_startup_elements(self): """ - Yields all collections required on startup i. e. opening the websocket + Yields all Cachables required on startup i. e. opening the websocket connection. """ - yield Collection(self.get_model('Item').get_collection_string()) + yield self.get_model('Item') diff --git a/openslides/agenda/migrations/0003_auto_20170818_1202.py b/openslides/agenda/migrations/0003_auto_20170818_1202.py index 30a872214..940051b43 100644 --- a/openslides/agenda/migrations/0003_auto_20170818_1202.py +++ b/openslides/agenda/migrations/0003_auto_20170818_1202.py @@ -4,8 +4,9 @@ from __future__ import unicode_literals from django.db import migrations -from openslides.utils.migrations import \ - add_permission_to_groups_based_on_existing_permission +from openslides.utils.migrations import ( + add_permission_to_groups_based_on_existing_permission, +) class Migration(migrations.Migration): diff --git a/openslides/agenda/migrations/0005_auto_20180815_1109.py b/openslides/agenda/migrations/0005_auto_20180815_1109.py index 8438d24c4..841ee9104 100644 --- a/openslides/agenda/migrations/0005_auto_20180815_1109.py +++ b/openslides/agenda/migrations/0005_auto_20180815_1109.py @@ -5,8 +5,9 @@ from __future__ import unicode_literals from django.contrib.auth.models import Permission from django.db import migrations, models -from openslides.utils.migrations import \ - add_permission_to_groups_based_on_existing_permission +from openslides.utils.migrations import ( + add_permission_to_groups_based_on_existing_permission, +) def delete_old_can_see_hidden_permission(apps, schema_editor): diff --git a/openslides/agenda/models.py b/openslides/agenda/models.py index fc403a116..45ecae422 100644 --- a/openslides/agenda/models.py +++ b/openslides/agenda/models.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import Dict, List, Set # noqa +from typing import Dict, List, Set from django.conf import settings from django.contrib.auth.models import AnonymousUser @@ -7,8 +7,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models, transaction from django.utils import timezone -from django.utils.translation import ugettext as _ -from django.utils.translation import ugettext_lazy +from django.utils.translation import ugettext as _, ugettext_lazy from openslides.core.config import config from openslides.core.models import Countdown, Projector @@ -66,7 +65,7 @@ class ItemManager(models.Manager): all of their children. """ queryset = self.order_by('weight') - item_children = defaultdict(list) # type: Dict[int, List[Item]] + item_children: Dict[int, List[Item]] = defaultdict(list) root_items = [] for item in queryset: if only_item_type is not None and item.type != only_item_type: @@ -122,7 +121,7 @@ class ItemManager(models.Manager): yield (element['id'], parent, weight) yield from walk_items(element.get('children', []), element['id']) - touched_items = set() # type: Set[int] + touched_items: Set[int] = set() db_items = dict((item.pk, item) for item in Item.objects.all()) for item_id, parent_id, weight in walk_items(tree): # Check that the item is only once in the tree to prevent invalid trees @@ -297,14 +296,14 @@ class Item(RESTModelMixin, models.Model): 'method on your related model.') @property - def list_view_title(self): + def title_with_type(self): """ - Return get_agenda_list_view_title() from the content_object. + Return get_agenda_title_with_type() from the content_object. """ try: - return self.content_object.get_agenda_list_view_title() + return self.content_object.get_agenda_title_with_type() except AttributeError: - raise NotImplementedError('You have to provide a get_agenda_list_view_title ' + raise NotImplementedError('You have to provide a get_agenda_title_with_type ' 'method on your related model.') def is_internal(self): diff --git a/openslides/agenda/serializers.py b/openslides/agenda/serializers.py index 13dfebc63..edcb49ea1 100644 --- a/openslides/agenda/serializers.py +++ b/openslides/agenda/serializers.py @@ -45,7 +45,7 @@ class ItemSerializer(ModelSerializer): 'id', 'item_number', 'title', - 'list_view_title', + 'title_with_type', 'comment', 'closed', 'type', diff --git a/openslides/agenda/signals.py b/openslides/agenda/signals.py index 1b3e2e787..36208d139 100644 --- a/openslides/agenda/signals.py +++ b/openslides/agenda/signals.py @@ -1,4 +1,4 @@ -from typing import Set # noqa +from typing import Set from django.apps import apps from django.contrib.contenttypes.models import ContentType @@ -72,7 +72,7 @@ def required_users(sender, request_user, **kwargs): if request_user can see the agenda. This function may return an empty set. """ - speakers = set() # type: Set[int] + speakers: Set[int] = set() if has_perm(request_user, 'agenda.can_see'): for item_collection_element in Collection(Item.get_collection_string()).element_generator(): full_data = item_collection_element.get_full_data() diff --git a/openslides/agenda/urls.py b/openslides/agenda/urls.py index 959fbd02f..2589aa2c9 100644 --- a/openslides/agenda/urls.py +++ b/openslides/agenda/urls.py @@ -2,6 +2,7 @@ from django.conf.urls import url from . import views + urlpatterns = [ url(r'^docxtemplate/$', views.AgendaDocxTemplateView.as_view(), diff --git a/openslides/asgi.py b/openslides/asgi.py index 441a46beb..10799265a 100644 --- a/openslides/asgi.py +++ b/openslides/asgi.py @@ -1,12 +1,16 @@ -from channels.asgi import get_channel_layer +""" +ASGI entrypoint. Configures Django and then runs the application +defined in the ASGI_APPLICATION setting. +""" + +import django +from channels.routing import get_default_application from .utils.main import setup_django_settings_module + # Loads the openslides setting. You can use your own settings by setting the # environment variable DJANGO_SETTINGS_MODULE setup_django_settings_module() - -channel_layer = get_channel_layer() - -# Use native twisted mode -channel_layer.extensions.append("twisted") +django.setup() +application = get_default_application() diff --git a/openslides/assignments/access_permissions.py b/openslides/assignments/access_permissions.py index c3dac3029..7c0f883e7 100644 --- a/openslides/assignments/access_permissions.py +++ b/openslides/assignments/access_permissions.py @@ -1,6 +1,6 @@ from typing import Any, Dict, List, Optional -from ..utils.access_permissions import BaseAccessPermissions # noqa +from ..utils.access_permissions import BaseAccessPermissions from ..utils.auth import has_perm from ..utils.collection import CollectionElement diff --git a/openslides/assignments/apps.py b/openslides/assignments/apps.py index 41b9018d3..301aaca79 100644 --- a/openslides/assignments/apps.py +++ b/openslides/assignments/apps.py @@ -1,9 +1,8 @@ -from typing import Dict, List, Union # noqa +from typing import List from django.apps import AppConfig from mypy_extensions import TypedDict -from ..utils.collection import Collection from ..utils.projector import register_projector_elements @@ -15,16 +14,13 @@ class AssignmentsAppConfig(AppConfig): def ready(self): # Import all required stuff. - from ..core.config import config from ..core.signals import permission_change, user_data_required from ..utils.rest_api import router - from .config_variables import get_config_variables from .projector import get_projector_elements from .signals import get_permission_change_data, required_users from .views import AssignmentViewSet, AssignmentPollViewSet - # Define config variables and projector elements. - config.update_config_variables(get_config_variables()) + # Define projector elements. register_projector_elements(get_projector_elements()) # Connect signals. @@ -39,23 +35,24 @@ class AssignmentsAppConfig(AppConfig): router.register(self.get_model('Assignment').get_collection_string(), AssignmentViewSet) router.register('assignments/poll', AssignmentPollViewSet) + def get_config_variables(self): + from .config_variables import get_config_variables + return get_config_variables() + def get_startup_elements(self): """ - Yields all collections required on startup i. e. opening the websocket + Yields all Cachables required on startup i. e. opening the websocket connection. """ - yield Collection(self.get_model('Assignment').get_collection_string()) + yield self.get_model('Assignment') def get_angular_constants(self): assignment = self.get_model('Assignment') - InnerItem = TypedDict('InnerItem', {'value': int, 'display_name': str}) - Item = TypedDict('Item', {'name': str, 'value': List[InnerItem]}) # noqa - data = { - 'name': 'AssignmentPhases', - 'value': []} # type: Item + Item = TypedDict('Item', {'value': int, 'display_name': str}) + phases: List[Item] = [] for phase in assignment.PHASES: - data['value'].append({ + phases.append({ 'value': phase[0], 'display_name': phase[1], }) - return [data] + return {'AssignmentPhases': phases} diff --git a/openslides/assignments/migrations/0005_auto_20180822_1042.py b/openslides/assignments/migrations/0005_auto_20180822_1042.py index c53bf1b82..2618075c5 100644 --- a/openslides/assignments/migrations/0005_auto_20180822_1042.py +++ b/openslides/assignments/migrations/0005_auto_20180822_1042.py @@ -1,6 +1,7 @@ # Generated by Django 2.1 on 2018-08-22 08:42 from decimal import Decimal + import django.core.validators from django.db import migrations, models diff --git a/openslides/assignments/models.py b/openslides/assignments/models.py index 54c491dfd..b970f8e56 100644 --- a/openslides/assignments/models.py +++ b/openslides/assignments/models.py @@ -1,13 +1,12 @@ from collections import OrderedDict from decimal import Decimal -from typing import Any, Dict, List, Optional # noqa +from typing import Any, Dict, List from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.core.validators import MinValueValidator from django.db import models -from django.utils.translation import ugettext as _ -from django.utils.translation import ugettext_noop +from django.utils.translation import ugettext as _, ugettext_noop from openslides.agenda.models import Item, Speaker from openslides.core.config import config @@ -304,14 +303,14 @@ class Assignment(RESTModelMixin, models.Model): Returns a table represented as a list with all candidates from all related polls and their vote results. """ - vote_results_dict = OrderedDict() # type: Dict[Any, List[AssignmentVote]] + vote_results_dict: Dict[Any, List[AssignmentVote]] = OrderedDict() polls = self.polls.all() if only_published: polls = polls.filter(published=True) # All PollOption-Objects related to this assignment - options = [] # type: List[AssignmentOption] + options: List[AssignmentOption] = [] for poll in polls: options += poll.get_options() @@ -321,7 +320,7 @@ class Assignment(RESTModelMixin, models.Model): continue vote_results_dict[candidate] = [] for poll in polls: - votes = {} # type: Any + votes: Any = {} try: # candidate related to this poll poll_option = poll.get_options().get(candidate=candidate) @@ -336,19 +335,20 @@ class Assignment(RESTModelMixin, models.Model): """ Container for runtime information for agenda app (on create or update of this instance). """ - agenda_item_update_information = {} # type: Dict[str, Any] + agenda_item_update_information: Dict[str, Any] = {} def get_agenda_title(self): + """ + Returns the title for the agenda. + """ return str(self) - def get_agenda_list_view_title(self): + def get_agenda_title_with_type(self): """ - Return a title string for the agenda list view. - - Contains agenda item number, title and assignment verbose name. + Return a title for the agenda with the appended assignment verbose name. Note: It has to be the same return value like in JavaScript. """ - return '%s (%s)' % (self.title, _(self._meta.verbose_name)) + return '%s (%s)' % (self.get_agenda_title(), _(self._meta.verbose_name)) @property def agenda_item(self): diff --git a/openslides/assignments/signals.py b/openslides/assignments/signals.py index d36589243..48bc9cdb8 100644 --- a/openslides/assignments/signals.py +++ b/openslides/assignments/signals.py @@ -1,4 +1,4 @@ -from typing import Any, Set # noqa +from typing import Any, Set from django.apps import apps @@ -24,7 +24,7 @@ def required_users(sender, request_user, **kwargs): options) in any assignment if request_user can see assignments. This function may return an empty set. """ - candidates = set() # type: Set[Any] # TODO: Replace Any + candidates: Set[Any] = set() if has_perm(request_user, 'assignments.can_see'): for assignment_collection_element in Collection(Assignment.get_collection_string()).element_generator(): full_data = assignment_collection_element.get_full_data() diff --git a/openslides/core/apps.py b/openslides/core/apps.py index 2a3a885d6..c643c3074 100644 --- a/openslides/core/apps.py +++ b/openslides/core/apps.py @@ -1,12 +1,13 @@ from collections import OrderedDict from operator import attrgetter -from typing import Any, List # noqa +from typing import Any, Dict, List from django.apps import AppConfig from django.conf import settings +from django.core.exceptions import ImproperlyConfigured from django.db.models.signals import post_migrate +from django.db.utils import OperationalError -from ..utils.collection import Collection from ..utils.projector import register_projector_elements @@ -20,7 +21,7 @@ class CoreAppConfig(AppConfig): # Import all required stuff. from .config import config from ..utils.rest_api import router - from .config_variables import get_config_variables + from ..utils.cache import element_cache from .projector import get_projector_elements from .signals import ( delete_django_app_permissions, @@ -38,9 +39,19 @@ class CoreAppConfig(AppConfig): ProjectorViewSet, TagViewSet, ) + from ..utils.constants import set_constants, get_constants_from_apps - # Define config variables and projector elements. - config.update_config_variables(get_config_variables()) + # Collect all config variables before getting the constants. + config.collect_config_variables_from_apps() + + # Set constants + try: + set_constants(get_constants_from_apps()) + except (ImproperlyConfigured, OperationalError): + # Database is not loaded. This happens in tests and migrations. + pass + + # Define projector elements. register_projector_elements(get_projector_elements()) # Connect signals. @@ -64,17 +75,30 @@ class CoreAppConfig(AppConfig): router.register(self.get_model('ProjectorMessage').get_collection_string(), ProjectorMessageViewSet) router.register(self.get_model('Countdown').get_collection_string(), CountdownViewSet) + # Sets the cache + try: + element_cache.ensure_cache() + except (ImproperlyConfigured, OperationalError): + # This happens in the tests or in migrations. Do nothing + pass + + def get_config_variables(self): + from .config_variables import get_config_variables + return get_config_variables() + def get_startup_elements(self): """ - Yields all collections required on startup i. e. opening the websocket + Yields all Cachables required on startup i. e. opening the websocket connection. """ - for model in ('Projector', 'ChatMessage', 'Tag', 'ProjectorMessage', 'Countdown', 'ConfigStore'): - yield Collection(self.get_model(model).get_collection_string()) + for model_name in ('Projector', 'ChatMessage', 'Tag', 'ProjectorMessage', 'Countdown', 'ConfigStore'): + yield self.get_model(model_name) def get_angular_constants(self): from .config import config + constants: Dict[str, Any] = {} + # Client settings client_settings_keys = [ 'MOTION_IDENTIFIER_MIN_DIGITS', @@ -89,12 +113,10 @@ class CoreAppConfig(AppConfig): # Settings key does not exist. Do nothing. The client will # treat this as undefined. pass - client_settings = { - 'name': 'OpenSlidesSettings', - 'value': client_settings_dict} + constants['OpenSlidesSettings'] = client_settings_dict # Config variables - config_groups = [] # type: List[Any] # TODO: Replace Any by correct type + config_groups: List[Any] = [] for config_variable in sorted(config.config_variables.values(), key=attrgetter('weight')): if config_variable.is_hidden(): # Skip hidden config variables. Do not even check groups and subgroups. @@ -111,17 +133,9 @@ class CoreAppConfig(AppConfig): items=[])) # Add the config variable to the current group and subgroup. config_groups[-1]['subgroups'][-1]['items'].append(config_variable.data) - config_variables = { - 'name': 'OpenSlidesConfigVariables', - 'value': config_groups} + constants['OpenSlidesConfigVariables'] = config_groups - # Send the privacy policy to the client. A user should view them, even he is - # not logged in (so does not have the config values yet). - privacy_policy = { - 'name': 'PrivacyPolicy', - 'value': config['general_event_privacy_policy']} - - return [client_settings, config_variables, privacy_policy] + return constants def call_save_default_values(**kwargs): diff --git a/openslides/core/config.py b/openslides/core/config.py index e3129eb55..dce49330a 100644 --- a/openslides/core/config.py +++ b/openslides/core/config.py @@ -1,13 +1,26 @@ -from typing import Any, Callable, Dict, Iterable, Optional, TypeVar, Union +from typing import ( + Any, + Callable, + Dict, + Iterable, + Optional, + TypeVar, + Union, + cast, +) +from asgiref.sync import async_to_sync +from django.apps import apps from django.core.exceptions import ValidationError as DjangoValidationError from django.utils.translation import ugettext as _ from mypy_extensions import TypedDict +from ..utils.cache import element_cache from ..utils.collection import CollectionElement from .exceptions import ConfigError, ConfigNotFound from .models import ConfigStore + INPUT_TYPE_MAPPING = { 'string': str, 'text': str, @@ -15,7 +28,6 @@ INPUT_TYPE_MAPPING = { 'integer': int, 'boolean': bool, 'choice': str, - 'comments': dict, 'colorpicker': str, 'datetimepicker': int, 'majorityMethod': str, @@ -34,24 +46,45 @@ class ConfigHandler: def __init__(self) -> None: # Dict, that keeps all ConfigVariable objects. Has to be set at statup. # See the ready() method in openslides.core.apps. - self.config_variables = {} # type: Dict[str, ConfigVariable] + self.config_variables: Dict[str, ConfigVariable] = {} # Index to get the database id from a given config key - self.key_to_id = {} # type: Dict[str, int] + self.key_to_id: Optional[Dict[str, int]] = None def __getitem__(self, key: str) -> Any: """ Returns the value of the config variable. """ - # Build the key_to_id dict - self.save_default_values() - if not self.exists(key): raise ConfigNotFound(_('The config variable {} was not found.').format(key)) return CollectionElement.from_values( self.get_collection_string(), - self.key_to_id[key]).get_full_data()['value'] + self.get_key_to_id()[key]).get_full_data()['value'] + + def get_key_to_id(self) -> Dict[str, int]: + """ + Returns the key_to_id dict. Builds it, if it does not exist. + """ + if self.key_to_id is None: + async_to_sync(self.build_key_to_id)() + self.key_to_id = cast(Dict[str, int], self.key_to_id) + return self.key_to_id + + async def build_key_to_id(self) -> None: + """ + Build the key_to_id dict. + + Recreates it, if it does not exists. + + This uses the element_cache. It expects, that the config values are in the database + before this is called. + """ + self.key_to_id = {} + all_data = await element_cache.get_all_full_data() + elements = all_data[self.get_collection_string()] + for element in elements: + self.key_to_id[element['key']] = element['id'] def exists(self, key: str) -> bool: """ @@ -100,31 +133,6 @@ class ConfigHandler: except DjangoValidationError as e: raise ConfigError(e.messages[0]) - if config_variable.input_type == 'comments': - if not isinstance(value, dict): - raise ConfigError(_('motions_comments has to be a dict.')) - valuecopy = dict() - for id, commentsfield in value.items(): - try: - id = int(id) - except ValueError: - raise ConfigError(_('Each id has to be an int.')) - - if id < 1: - raise ConfigError(_('Each id has to be greater then 0.')) - # Deleted commentsfields are saved as None to block the used ids - if commentsfield is not None: - if not isinstance(commentsfield, dict): - raise ConfigError(_('Each commentsfield in motions_comments has to be a dict.')) - if commentsfield.get('name') is None or commentsfield.get('public') is None: - raise ConfigError(_('A name and a public property have to be given.')) - if not isinstance(commentsfield['name'], str): - raise ConfigError(_('name has to be string.')) - if not isinstance(commentsfield['public'], bool): - raise ConfigError(_('public property has to be bool.')) - valuecopy[id] = commentsfield - value = valuecopy - if config_variable.input_type == 'static': if not isinstance(value, dict): raise ConfigError(_('This has to be a dict.')) @@ -163,6 +171,17 @@ class ConfigHandler: if config_variable.on_change: config_variable.on_change() + def collect_config_variables_from_apps(self) -> None: + for app in apps.get_app_configs(): + try: + # Each app can deliver config variables when implementing the + # get_config_variables method. + get_config_variables = app.get_config_variables + except AttributeError: + # The app doesn't have this method. Continue to next app. + continue + self.update_config_variables(get_config_variables()) + def update_config_variables(self, items: Iterable['ConfigVariable']) -> None: """ Updates the config_variables dict. @@ -183,19 +202,17 @@ class ConfigHandler: Saves the default values to the database. Does also build the dictonary key_to_id. - - Does nothing on a second run. """ - if not self.key_to_id: - for item in self.config_variables.values(): - try: - db_value = ConfigStore.objects.get(key=item.name) - except ConfigStore.DoesNotExist: - db_value = ConfigStore() - db_value.key = item.name - db_value.value = item.default_value - db_value.save(skip_autoupdate=True) - self.key_to_id[item.name] = db_value.pk + self.key_to_id = {} + for item in self.config_variables.values(): + try: + db_value = ConfigStore.objects.get(key=item.name) + except ConfigStore.DoesNotExist: + db_value = ConfigStore() + db_value.key = item.name + db_value.value = item.default_value + db_value.save(skip_autoupdate=True) + self.key_to_id[db_value.key] = db_value.id def get_collection_string(self) -> str: """ @@ -219,7 +236,6 @@ OnChangeType = Callable[[], None] ConfigVariableDict = TypedDict('ConfigVariableDict', { 'key': str, 'default_value': Any, - 'value': Any, 'input_type': str, 'label': str, 'help_text': str, @@ -286,7 +302,6 @@ class ConfigVariable: return ConfigVariableDict( key=self.name, default_value=self.default_value, - value=config[self.name], input_type=self.input_type, label=self.label, help_text=self.help_text, diff --git a/openslides/core/management/commands/collectstatic.py b/openslides/core/management/commands/collectstatic.py index 330cc28fc..0cc2785a2 100644 --- a/openslides/core/management/commands/collectstatic.py +++ b/openslides/core/management/commands/collectstatic.py @@ -2,8 +2,9 @@ import os from typing import Any, Dict from django.conf import settings -from django.contrib.staticfiles.management.commands.collectstatic import \ - Command as CollectStatic +from django.contrib.staticfiles.management.commands.collectstatic import ( + Command as CollectStatic, +) from django.contrib.staticfiles.utils import matches_patterns from django.core.management.base import CommandError from django.db.utils import OperationalError diff --git a/openslides/core/management/commands/getgeiss.py b/openslides/core/management/commands/getgeiss.py deleted file mode 100644 index cbb9510dc..000000000 --- a/openslides/core/management/commands/getgeiss.py +++ /dev/null @@ -1,91 +0,0 @@ -import distutils -import json -import os -import stat -import sys -from urllib.request import urlopen, urlretrieve - -from django.core.management.base import BaseCommand, CommandError - -from openslides.utils.main import get_geiss_path - - -class Command(BaseCommand): - """ - Command to get the latest release of Geiss from GitHub. - """ - help = 'Get the latest Geiss release from GitHub.' - - FIRST_NOT_SUPPORTED_VERSION = '1.0.0' - - def handle(self, *args, **options): - geiss_github_name = self.get_geiss_github_name() - download_file = get_geiss_path() - - if os.path.isfile(download_file): - # Geiss does probably exist. Do nothing. - # TODO: Add an update flag, that Geiss is downloaded anyway. - return - - release = self.get_release() - download_url = None - for asset in release['assets']: - if asset['name'] == geiss_github_name: - download_url = asset['browser_download_url'] - break - if download_url is None: - raise CommandError("Could not find download URL in release.") - - urlretrieve(download_url, download_file) - - # Set the executable bit on the file. This will do nothing on windows - st = os.stat(download_file) - os.chmod(download_file, st.st_mode | stat.S_IEXEC) - - self.stdout.write(self.style.SUCCESS('Geiss {} successfully downloaded.'.format(release['tag_name']))) - - def get_release(self): - """ - Returns API data for the latest supported Geiss release. - """ - response = urlopen(self.get_geiss_url()).read() - releases = json.loads(response.decode()) - for release in releases: - version = distutils.version.StrictVersion(release['tag_name']) # type: ignore - if version < self.FIRST_NOT_SUPPORTED_VERSION: - break - else: - raise CommandError('Could not find Geiss release.') - return release - - def get_geiss_url(self): - """ - Returns the URL to the API which gives the information which Geiss - binary has to be downloaded. - - Currently this is a static GitHub URL to the repository where Geiss - is hosted at the moment. - """ - # TODO: Use a settings variable or a command line flag in the future. - return 'https://api.github.com/repos/ostcar/geiss/releases' - - def get_geiss_github_name(self): - """ - Returns the name of the Geiss executable for the current operating - system. - - For example geiss_windows_64 on a windows64 platform. - """ - # This will be 32 if the current python interpreter has only - # 32 bit, even if it is run on a 64 bit operating sysem. - bits = '64' if sys.maxsize > 2**32 else '32' - - geiss_names = { - 'linux': 'geiss_linux_{bits}', - 'win32': 'geiss_windows_{bits}.exe', # Yes, it is win32, even on a win64 system! - 'darwin': 'geiss_mac_{bits}'} - - try: - return geiss_names[sys.platform].format(bits=bits) - except KeyError: - raise CommandError("Plattform {} is not supported by Geiss".format(sys.platform)) diff --git a/openslides/core/migrations/0005_auto_20170412_1258.py b/openslides/core/migrations/0005_auto_20170412_1258.py index 68871ce08..fc7e615eb 100644 --- a/openslides/core/migrations/0005_auto_20170412_1258.py +++ b/openslides/core/migrations/0005_auto_20170412_1258.py @@ -4,8 +4,9 @@ from __future__ import unicode_literals from django.db import migrations -from openslides.utils.migrations import \ - add_permission_to_groups_based_on_existing_permission +from openslides.utils.migrations import ( + add_permission_to_groups_based_on_existing_permission, +) class Migration(migrations.Migration): diff --git a/openslides/core/signals.py b/openslides/core/signals.py index ca6f0595e..663b41852 100644 --- a/openslides/core/signals.py +++ b/openslides/core/signals.py @@ -8,6 +8,7 @@ from ..utils.auth import has_perm from ..utils.collection import Collection from .models import ChatMessage + # This signal is send when the migrate command is done. That means it is sent # after post_migrate sending and creating all Permission objects. Don't use it # for other things than dealing with Permission objects. @@ -38,18 +39,18 @@ def delete_django_app_permissions(sender, **kwargs): def get_permission_change_data(sender, permissions, **kwargs): """ - Yields all necessary collections if the respective permissions change. + Yields all necessary Cachables if the respective permissions change. """ core_app = apps.get_app_config(app_label='core') for permission in permissions: if permission.content_type.app_label == core_app.label: if permission.codename == 'can_see_projector': - yield Collection(core_app.get_model('Projector').get_collection_string()) + yield core_app.get_model('Projector') elif permission.codename == 'can_manage_projector': - yield Collection(core_app.get_model('ProjectorMessage').get_collection_string()) - yield Collection(core_app.get_model('Countdown').get_collection_string()) + yield core_app.get_model('ProjectorMessage') + yield core_app.get_model('Countdown') elif permission.codename == 'can_use_chat': - yield Collection(core_app.get_model('ChatMessage').get_collection_string()) + yield core_app.get_model('ChatMessage') def required_users(sender, request_user, **kwargs): diff --git a/openslides/core/static/js/core/base.js b/openslides/core/static/js/core/base.js index 60acb14fa..4f1992ed5 100644 --- a/openslides/core/static/js/core/base.js +++ b/openslides/core/static/js/core/base.js @@ -117,11 +117,11 @@ angular.module('OpenSlidesApp.core', [ $timeout(runRetryConnectCallbacks, getTimeoutTime()); }; socket.onmessage = function (event) { - var dataList = []; + var data; try { - dataList = JSON.parse(event.data); + data = JSON.parse(event.data); _.forEach(Autoupdate.messageReceivers, function (receiver) { - receiver(dataList); + receiver(data); }); } catch(err) { console.error(err); @@ -133,10 +133,23 @@ angular.module('OpenSlidesApp.core', [ ErrorMessage.clearConnectionError(); }; }; - Autoupdate.send = function (message) { - if (socket) { - socket.send(JSON.stringify(message)); + Autoupdate.send = function (type, content) { + if (!socket) { + return; } + + var message = { + type: type, + content: content, + id: '', + }; + + // Generate random id + var possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + for (var i = 0; i < 8; i++) { + message.id += possible.charAt(Math.floor(Math.random() * possible.length)); + } + socket.send(JSON.stringify(message)); }; Autoupdate.closeConnection = function () { if (socket) { @@ -369,7 +382,12 @@ angular.module('OpenSlidesApp.core', [ 'dsEject', function (DS, autoupdate, dsEject) { // Handler for normal autoupdate messages. - autoupdate.onMessage(function(dataList) { + autoupdate.onMessage(function(data) { + if (data.type !== 'autoupdate') { + return; + } + + var dataList = data.content; var dataListByCollection = _.groupBy(dataList, 'collection'); _.forEach(dataListByCollection, function (list, key) { var changedElements = []; @@ -379,22 +397,19 @@ angular.module('OpenSlidesApp.core', [ // Uncomment this line for debugging to log all autoupdates: // console.log("Received object: " + data.collection + ", " + data.id); - // Now handle autoupdate message but do not handle notify messages. - if (data.collection !== 'notify') { - // remove (=eject) object from local DS store - var instance = DS.get(data.collection, data.id); - if (instance) { - dsEject(data.collection, instance); - } - // check if object changed or deleted - if (data.action === 'changed') { - changedElements.push(data.data); - } else if (data.action === 'deleted') { - deletedElements.push(data.id); - } else { - console.error('Error: Undefined action for received object' + - '(' + data.collection + ', ' + data.id + ')'); - } + // remove (=eject) object from local DS store + var instance = DS.get(data.collection, data.id); + if (instance) { + dsEject(data.collection, instance); + } + // check if object changed or deleted + if (data.action === 'changed') { + changedElements.push(data.data); + } else if (data.action === 'deleted') { + deletedElements.push(data.id); + } else { + console.error('Error: Undefined action for received object' + + '(' + data.collection + ', ' + data.id + ')'); } }); // add (=inject) all given objects into local DS store @@ -418,7 +433,12 @@ angular.module('OpenSlidesApp.core', [ var anonymousTrackId; // Handler for notify messages. - autoupdate.onMessage(function(dataList) { + autoupdate.onMessage(function(data) { + if (data.type !== 'notify') { + return; + } + + var dataList = data.content; var dataListByCollection = _.groupBy(dataList, 'collection'); _.forEach(dataListByCollection.notify, function (notifyItem) { // Check, if this current user (or anonymous instance) has send this notify. @@ -505,7 +525,7 @@ angular.module('OpenSlidesApp.core', [ } notifyItem.anonymousTrackId = anonymousTrackId; } - autoupdate.send([notifyItem]); + autoupdate.send('notify', [notifyItem]); } else { throw 'eventName should only consist of [a-zA-Z0-9_-]'; } @@ -514,6 +534,18 @@ angular.module('OpenSlidesApp.core', [ } ]) +.run([ + 'autoupdate', + function (autoupdate) { + // Handler for normal autoupdate messages. + autoupdate.onMessage(function (data) { + if (data.type === 'error') { + console.error("Websocket error", data.content); + } + }); + } +]) + // Save the server time to the rootscope. .run([ '$http', diff --git a/openslides/core/urls.py b/openslides/core/urls.py index 351c239d7..b087a5bbf 100644 --- a/openslides/core/urls.py +++ b/openslides/core/urls.py @@ -2,27 +2,13 @@ from django.conf.urls import url from . import views + urlpatterns = [ - url(r'^core/servertime/$', + url(r'^servertime/$', views.ServerTime.as_view(), name='core_servertime'), - url(r'^core/version/$', + url(r'^version/$', views.VersionView.as_view(), name='core_version'), - - url(r'^webclient/(?Psite|projector)/$', - views.WebclientJavaScriptView.as_view(), - name='core_webclient_javascript'), - - # View for the projectors are handled by angular. - url(r'^projector/(\d+)/$', views.ProjectorView.as_view()), - - # Original view without resolutioncontrol for the projectors are handled by angular. - url(r'^real-projector/(\d+)/$', views.RealProjectorView.as_view()), - - # Main entry point for all angular pages. - # Has to be the last entry in the urls.py - url(r'^.*$', views.IndexView.as_view(), name="index"), - ] diff --git a/openslides/core/views.py b/openslides/core/views.py index ba2b1f1b6..081d2dbc7 100644 --- a/openslides/core/views.py +++ b/openslides/core/views.py @@ -1,7 +1,7 @@ import json import uuid from textwrap import dedent -from typing import Any, Dict, List, cast # noqa +from typing import Any, Dict, List, cast from django.apps import apps from django.conf import settings @@ -11,12 +11,11 @@ from django.utils.timezone import now from django.utils.translation import ugettext as _ from mypy_extensions import TypedDict -from .. import __license__ as license -from .. import __url__ as url -from .. import __version__ as version +from .. import __license__ as license, __url__ as url, __version__ as version from ..utils import views as utils_views from ..utils.auth import anonymous_is_enabled, has_perm from ..utils.autoupdate import inform_changed_data, inform_deleted_data +from ..utils.constants import get_constants from ..utils.plugins import ( get_plugin_description, get_plugin_license, @@ -93,7 +92,7 @@ class WebclientJavaScriptView(utils_views.View): AngularJS app for the requested realm (site or projector). Also code for plugins is appended. The result is not uglified. """ - cache = {} # type: Dict[str, str] + cache: Dict[str, str] = {} def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) @@ -104,8 +103,8 @@ class WebclientJavaScriptView(utils_views.View): self.init_cache('projector') def init_cache(self, realm: str) -> None: - angular_modules = [] # type: List[str] - js_files = [] # type: List[str] + angular_modules: List[str] = [] + js_files: List[str] = [] for app_config in apps.get_app_configs(): # Add the angular app if the module has one. if getattr(app_config, 'angular_{}_module'.format(realm), False): @@ -135,18 +134,9 @@ class WebclientJavaScriptView(utils_views.View): # angular constants angular_constants = '' - for app in apps.get_app_configs(): - try: - # Each app can deliver values to angular when implementing this method. - # It should return a list with dicts containing the 'name' and 'value'. - get_angular_constants = app.get_angular_constants - except AttributeError: - # The app doesn't have this method. Continue to next app. - continue - for constant in get_angular_constants(): - value = json.dumps(constant['value']) - name = constant['name'] - angular_constants += ".constant('{}', {})".format(name, value) + for key, value in get_constants().items(): + value = json.dumps(value) + angular_constants += ".constant('{}', {})".format(key, value) # Use JavaScript loadScript function from # http://balpha.de/2011/10/jquery-script-insertion-and-its-consequences-for-debugging/ @@ -838,16 +828,16 @@ class VersionView(utils_views.APIView): http_method_names = ['get'] def get_context_data(self, **context): - Result = TypedDict('Result', { # noqa + Result = TypedDict('Result', { 'openslides_version': str, 'openslides_license': str, 'openslides_url': str, 'plugins': List[Dict[str, str]]}) - result = dict( + result: Result = dict( openslides_version=version, openslides_license=license, openslides_url=url, - plugins=[]) # type: Result + plugins=[]) # Versions of plugins. for plugin in settings.INSTALLED_PLUGINS: result['plugins'].append({ diff --git a/openslides/global_settings.py b/openslides/global_settings.py index 1704e9c7e..4235ebba1 100644 --- a/openslides/global_settings.py +++ b/openslides/global_settings.py @@ -2,6 +2,7 @@ import os from openslides.utils.plugins import collect_plugins + MODULE_DIR = os.path.realpath(os.path.dirname(os.path.abspath(__file__))) @@ -98,6 +99,8 @@ STATICFILES_DIRS = [ AUTH_USER_MODEL = 'users.User' +AUTH_GROUP_MODEL = 'users.Group' + SESSION_COOKIE_NAME = 'OpenSlidesSessionID' SESSION_EXPIRE_AT_BROWSER_CLOSE = True @@ -121,31 +124,14 @@ PASSWORD_HASHERS = [ MEDIA_URL = '/media/' -# Cache -# https://docs.djangoproject.com/en/1.10/topics/cache/ - -CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': 'openslides-cache', - 'OPTIONS': { - 'MAX_ENTRIES': 10000 - } - } -} - - # Django Channels # http://channels.readthedocs.io/en/latest/ -# https://github.com/ostcar/geiss + +ASGI_APPLICATION = 'openslides.routing.application' CHANNEL_LAYERS = { 'default': { - 'BACKEND': 'asgiref.inmemory.ChannelLayer', - 'ROUTING': 'openslides.routing.channel_routing', - 'CONFIG': { - 'capacity': 1000, - }, + 'BACKEND': 'channels.layers.InMemoryChannelLayer', }, } diff --git a/openslides/mediafiles/access_permissions.py b/openslides/mediafiles/access_permissions.py index ab23be913..93c84c8b4 100644 --- a/openslides/mediafiles/access_permissions.py +++ b/openslides/mediafiles/access_permissions.py @@ -1,6 +1,6 @@ from typing import Any, Dict, List, Optional -from ..utils.access_permissions import BaseAccessPermissions # noqa +from ..utils.access_permissions import BaseAccessPermissions from ..utils.auth import has_perm from ..utils.collection import CollectionElement diff --git a/openslides/mediafiles/apps.py b/openslides/mediafiles/apps.py index fae0cf32b..90d16b3d3 100644 --- a/openslides/mediafiles/apps.py +++ b/openslides/mediafiles/apps.py @@ -1,6 +1,5 @@ from django.apps import AppConfig -from ..utils.collection import Collection from ..utils.projector import register_projector_elements @@ -34,7 +33,7 @@ class MediafilesAppConfig(AppConfig): def get_startup_elements(self): """ - Yields all collections required on startup i. e. opening the websocket + Yields all Cachables required on startup i. e. opening the websocket connection. """ - yield Collection(self.get_model('Mediafile').get_collection_string()) + yield self.get_model('Mediafile') diff --git a/openslides/motions/access_permissions.py b/openslides/motions/access_permissions.py index 57ccba291..1abf1651d 100644 --- a/openslides/motions/access_permissions.py +++ b/openslides/motions/access_permissions.py @@ -1,9 +1,8 @@ from copy import deepcopy from typing import Any, Dict, List, Optional -from ..core.config import config -from ..utils.access_permissions import BaseAccessPermissions # noqa -from ..utils.auth import has_perm +from ..utils.access_permissions import BaseAccessPermissions +from ..utils.auth import has_perm, in_some_groups from ..utils.collection import CollectionElement @@ -32,7 +31,7 @@ class MotionAccessPermissions(BaseAccessPermissions): """ Returns the restricted serialized data for the instance prepared for the user. Removes motion if the user has not the permission to see - the motion in this state. Removes non public comment fields for + the motion in this state. Removes comments sections for some unauthorized users. Ensures that a user can only see his own personal notes. """ @@ -59,21 +58,12 @@ class MotionAccessPermissions(BaseAccessPermissions): # Parse single motion. if permission: - if has_perm(user, 'motions.can_see_comments') or not full.get('comments'): - # Provide access to all fields. - motion = full - else: - # Set private comment fields to None. - full_copy = deepcopy(full) - for i, field in config['motions_comments'].items(): - if field is None or not field.get('public'): - try: - full_copy['comments'][i] = None - except IndexError: - # No data in range. Just do nothing. - pass - motion = full_copy - data.append(motion) + full_copy = deepcopy(full) + full_copy['comments'] = [] + for comment in full['comments']: + if in_some_groups(user, comment['read_groups_id']): + full_copy['comments'].append(comment) + data.append(full_copy) else: data = [] @@ -82,25 +72,13 @@ class MotionAccessPermissions(BaseAccessPermissions): def get_projector_data(self, full_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ Returns the restricted serialized data for the instance prepared - for the projector. Removes several comment fields. + for the projector. Removes all comments. """ - # Parse data. data = [] for full in full_data: - # Set private comment fields to None. - if full.get('comments') is not None: - full_copy = deepcopy(full) - for i, field in config['motions_comments'].items(): - if field is None or not field.get('public'): - try: - full_copy['comments'][i] = None - except IndexError: - # No data in range. Just do nothing. - pass - data.append(full_copy) - else: - data.append(full) - + full_copy = deepcopy(full) + full_copy['comments'] = [] + data.append(full_copy) return data @@ -123,6 +101,62 @@ class MotionChangeRecommendationAccessPermissions(BaseAccessPermissions): return MotionChangeRecommendationSerializer +class MotionCommentSectionAccessPermissions(BaseAccessPermissions): + """ + Access permissions container for MotionCommentSection and MotionCommentSectionViewSet. + """ + def check_permissions(self, user): + """ + Returns True if the user has read access model instances. + """ + return has_perm(user, 'motions.can_see') + + def get_serializer_class(self, user=None): + """ + Returns serializer class. + """ + from .serializers import MotionCommentSectionSerializer + + return MotionCommentSectionSerializer + + def get_restricted_data( + self, + full_data: List[Dict[str, Any]], + user: Optional[CollectionElement]) -> List[Dict[str, Any]]: + """ + If the user has manage rights, he can see all sections. If not all sections + will be removed, when the user is not in at least one of the read_groups. + """ + data: List[Dict[str, Any]] = [] + if has_perm(user, 'motions.can_manage'): + data = full_data + else: + for full in full_data: + read_groups = full.get('read_groups_id', []) + if in_some_groups(user, read_groups): + data.append(full) + return data + + +class StatuteParagraphAccessPermissions(BaseAccessPermissions): + """ + Access permissions container for StatuteParagraph and StatuteParagraphViewSet. + """ + def check_permissions(self, user): + """ + Returns True if the user has read access model instances. + """ + return has_perm(user, 'motions.can_see') + + def get_serializer_class(self, user=None): + """ + Returns serializer class. + """ + from .serializers import StatuteParagraphSerializer + + return StatuteParagraphSerializer + + class CategoryAccessPermissions(BaseAccessPermissions): """ Access permissions container for Category and CategoryViewSet. diff --git a/openslides/motions/apps.py b/openslides/motions/apps.py index 5969c8ff3..c5ee14299 100644 --- a/openslides/motions/apps.py +++ b/openslides/motions/apps.py @@ -1,7 +1,6 @@ from django.apps import AppConfig from django.db.models.signals import post_migrate -from ..utils.collection import Collection from ..utils.projector import register_projector_elements @@ -13,10 +12,8 @@ class MotionsAppConfig(AppConfig): def ready(self): # Import all required stuff. - from openslides.core.config import config from openslides.core.signals import permission_change, user_data_required from openslides.utils.rest_api import router - from .config_variables import get_config_variables from .projector import get_projector_elements from .signals import ( create_builtin_workflows, @@ -25,7 +22,9 @@ class MotionsAppConfig(AppConfig): ) from .views import ( CategoryViewSet, + StatuteParagraphViewSet, MotionViewSet, + MotionCommentSectionViewSet, MotionBlockViewSet, MotionPollViewSet, MotionChangeRecommendationViewSet, @@ -33,8 +32,7 @@ class MotionsAppConfig(AppConfig): WorkflowViewSet, ) - # Define config variables and projector elements. - config.update_config_variables(get_config_variables()) + # Define projector elements. register_projector_elements(get_projector_elements()) # Connect signals. @@ -50,18 +48,25 @@ class MotionsAppConfig(AppConfig): # Register viewsets. router.register(self.get_model('Category').get_collection_string(), CategoryViewSet) + router.register(self.get_model('StatuteParagraph').get_collection_string(), StatuteParagraphViewSet) router.register(self.get_model('Motion').get_collection_string(), MotionViewSet) router.register(self.get_model('MotionBlock').get_collection_string(), MotionBlockViewSet) + router.register(self.get_model('MotionCommentSection').get_collection_string(), MotionCommentSectionViewSet) router.register(self.get_model('Workflow').get_collection_string(), WorkflowViewSet) router.register(self.get_model('MotionChangeRecommendation').get_collection_string(), MotionChangeRecommendationViewSet) router.register(self.get_model('MotionPoll').get_collection_string(), MotionPollViewSet) router.register(self.get_model('State').get_collection_string(), StateViewSet) + def get_config_variables(self): + from .config_variables import get_config_variables + return get_config_variables() + def get_startup_elements(self): """ - Yields all collections required on startup i. e. opening the websocket + Yields all Cachables required on startup i. e. opening the websocket connection. """ - for model in ('Category', 'Motion', 'MotionBlock', 'Workflow', 'MotionChangeRecommendation'): - yield Collection(self.get_model(model).get_collection_string()) + for model_name in ('Category', 'StatuteParagraph', 'Motion', 'MotionBlock', + 'Workflow', 'MotionChangeRecommendation', 'MotionCommentSection'): + yield self.get_model(model_name) diff --git a/openslides/motions/config_variables.py b/openslides/motions/config_variables.py index 3428ebae5..992edafb5 100644 --- a/openslides/motions/config_variables.py +++ b/openslides/motions/config_variables.py @@ -106,15 +106,6 @@ def get_config_variables(): group='Motions', subgroup='General') - yield ConfigVariable( - name='motions_allow_disable_versioning', - default_value=False, - input_type='boolean', - label='Allow to disable versioning', - weight=329, - group='Motions', - subgroup='General') - yield ConfigVariable( name='motions_stop_submitting', default_value=False, @@ -210,17 +201,6 @@ def get_config_variables(): group='Motions', subgroup='Supporters') - # Comments - - yield ConfigVariable( - name='motions_comments', - default_value={}, - input_type='comments', - label='Comment fields for motions', - weight=353, - group='Motions', - subgroup='Comments') - # Voting and ballot papers yield ConfigVariable( diff --git a/openslides/motions/migrations/0010_auto_20180822_1042.py b/openslides/motions/migrations/0010_auto_20180822_1042.py index fa9fb0522..fbbc7f1cd 100644 --- a/openslides/motions/migrations/0010_auto_20180822_1042.py +++ b/openslides/motions/migrations/0010_auto_20180822_1042.py @@ -1,6 +1,7 @@ # Generated by Django 2.1 on 2018-08-22 08:42 from decimal import Decimal + import django.core.validators from django.db import migrations, models diff --git a/openslides/motions/migrations/0011_motion_version.py b/openslides/motions/migrations/0011_motion_version.py new file mode 100644 index 000000000..01758ffb0 --- /dev/null +++ b/openslides/motions/migrations/0011_motion_version.py @@ -0,0 +1,131 @@ +# Generated by Django 2.1 on 2018-08-31 13:17 + +import django.db.models.deletion +import jsonfield.encoder +import jsonfield.fields +from django.db import migrations, models + + +def copy_motion_version_content_to_motion(apps, schema_editor): + """ + Move all motion version content of the active version to the motion. + """ + Motion = apps.get_model('motions', 'Motion') + + for motion in Motion.objects.all(): + motion.title = motion.active_version.title + motion.text = motion.active_version.text + motion.reason = motion.active_version.reason + motion.modified_final_version = motion.active_version.modified_final_version + motion.amendment_paragraphs = motion.active_version.amendment_paragraphs + motion.save(skip_autoupdate=True) + + +def migrate_active_change_recommendations(apps, schema_editor): + """ + Delete all change recommendation of motion versions, that are not active. For active + change recommendations the motion id will be set. + """ + MotionChangeRecommendation = apps.get_model('motions', 'MotionChangeRecommendation') + to_delete = [] + for cr in MotionChangeRecommendation.objects.all(): + # chack if version id matches the active version of the motion + if cr.motion_version.id == cr.motion_version.motion.active_version.id: + cr.motion = cr.motion_version.motion + cr.save(skip_autoupdate=True) + else: + to_delete.append(cr) + + # delete non active change recommendations + for cr in to_delete: + cr.delete(skip_autoupdate=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('motions', '0010_auto_20180822_1042'), + ] + + operations = [ + # Create new fields. Title and Text have empty defaults, but the values + # should be overwritten by copy_motion_version_content_to_motion. In the next + # migration file these defaults are removed. + migrations.AddField( + model_name='motion', + name='title', + field=models.CharField(max_length=255, default=''), + ), + migrations.AddField( + model_name='motion', + name='text', + field=models.TextField(default=''), + ), + migrations.AddField( + model_name='motion', + name='reason', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='motion', + name='modified_final_version', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='motion', + name='amendment_paragraphs', + field=jsonfield.fields.JSONField( + dump_kwargs={ + 'cls': jsonfield.encoder.JSONEncoder, + 'separators': (',', ':') + }, + load_kwargs={}, + null=True), + ), + + # Copy old motion version data + migrations.RunPython(copy_motion_version_content_to_motion), + + # Change recommendations + migrations.AddField( + model_name='motionchangerecommendation', + name='motion', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + null=True, # This is reverted in the next migration + related_name='change_recommendations', + to='motions.Motion'), + ), + migrations.RunPython(migrate_active_change_recommendations), + migrations.RemoveField( + model_name='motionchangerecommendation', + name='motion_version', + ), + + # remove motion version references from motion and state. + migrations.RemoveField( + model_name='motion', + name='active_version', + ), + migrations.AlterUniqueTogether( + name='motionversion', + unique_together=set(), + ), + migrations.RemoveField( + model_name='motionversion', + name='motion', + ), + migrations.RemoveField( + model_name='state', + name='leave_old_version_active', + ), + migrations.RemoveField( + model_name='state', + name='versioning', + ), + + # Delete motion version. + migrations.DeleteModel( + name='MotionVersion', + ), + ] diff --git a/openslides/motions/migrations/0012_motion_comments.py b/openslides/motions/migrations/0012_motion_comments.py new file mode 100644 index 000000000..3e1c5a5fc --- /dev/null +++ b/openslides/motions/migrations/0012_motion_comments.py @@ -0,0 +1,218 @@ +# Generated by Django 2.1 on 2018-08-31 13:17 + +import django.db.models.deletion +from django.conf import settings +from django.contrib.auth.models import Permission +from django.db import migrations, models + +import openslides + + +def create_comment_sections_from_config_and_move_comments_to_own_model(apps, schema_editor): + ConfigStore = apps.get_model('core', 'ConfigStore') + Motion = apps.get_model('motions', 'Motion') + MotionComment = apps.get_model('motions', 'MotionComment') + MotionCommentSection = apps.get_model('motions', 'MotionCommentSection') + Group = apps.get_model(settings.AUTH_GROUP_MODEL) + + # try to get old motions_comments config variable, where all comment fields are saved + try: + motions_comments = ConfigStore.objects.get(key='motions_comments') + except ConfigStore.DoesNotExist: + return + comments_sections = motions_comments.value + + # Delete config value + motions_comments.delete() + + # Get can_see_comments and can_manage_comments permissions and the associated groups + can_see_comments = Permission.objects.filter(codename='can_see_comments') + if len(can_see_comments) == 1: + # Save groups. list() is necessary to evaluate the database query right now. + can_see_groups = list(can_see_comments.get().group_set.all()) + else: + can_see_groups = Group.objects.all() + + can_manage_comments = Permission.objects.filter(codename='can_manage_comments') + if len(can_manage_comments) == 1: + # Save groups. list() is necessary to evaluate the database query right now. + can_manage_groups = list(can_manage_comments.get().group_set.all()) + else: + can_manage_groups = Group.objects.all() + + # Create comment sections. Map them to the old ids, so we can find the right section + # when creating actual comments + old_id_mapping = {} + # Keep track of the special comment sections "forState" and "forRecommendation". If a + # comment is found, the comment value will be assigned to new motion fields and not comments. + forStateId = None + forRecommendationId = None + for id, section in comments_sections.items(): + if section is None: + continue + if section.get('forState', False): + forStateId = id + elif section.get('forRecommendation', False): + forRecommendationId = id + else: + comment_section = MotionCommentSection(name=section['name']) + comment_section.save(skip_autoupdate=True) + comment_section.read_groups.add(*[group.id for group in can_see_groups]) + comment_section.write_groups.add(*[group.id for group in can_manage_groups]) + old_id_mapping[id] = comment_section + + # Create all comments objects + comments = [] + for motion in Motion.objects.all(): + if not isinstance(motion.comments, dict): + continue + + for section_id, comment_value in motion.comments.items(): + # Skip empty sections. + comment_value = comment_value.strip() + if comment_value == '': + continue + # Special comments will be moved to separate fields. + if section_id == forStateId: + motion.state_extension = comment_value + motion.save(skip_autoupdate=True) + elif section_id == forRecommendationId: + motion.recommendation_extension = comment_value + motion.save(skip_autoupdate=True) + else: + comment = MotionComment( + comment=comment_value, + motion=motion, + section=old_id_mapping[section_id]) + comments.append(comment) + MotionComment.objects.bulk_create(comments) + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0006_user_email'), + ('motions', '0011_motion_version'), + ] + + operations = [ + # Cleanup from last migration. Somehow cannot be done there. + migrations.AlterField( # remove default='' + model_name='motion', + name='text', + field=models.TextField(), + ), + migrations.AlterField( # remove default='' + model_name='motion', + name='title', + field=models.CharField(max_length=255), + ), + migrations.AlterField( # remove null=True + model_name='motionchangerecommendation', + name='motion', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='change_recommendations', + to='motions.Motion'), + ), + + # Add extension fields for former "special comments". No hack anymore.. + migrations.AddField( + model_name='motion', + name='recommendation_extension', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='motion', + name='state_extension', + field=models.TextField(blank=True, null=True), + ), + + migrations.AlterModelOptions( + name='motion', + options={ + 'default_permissions': (), + 'ordering': ('identifier',), + 'permissions': ( + ('can_see', 'Can see motions'), + ('can_create', 'Can create motions'), + ('can_support', 'Can support motions'), + ('can_manage', 'Can manage motions')), + 'verbose_name': 'Motion'}, + ), + # Comments and CommentsSection models + migrations.CreateModel( + name='MotionComment', + fields=[ + ('id', models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID')), + ('comment', models.TextField()), + ], + options={ + 'default_permissions': (), + }, + bases=(openslides.utils.models.RESTModelMixin, models.Model), # type: ignore + ), + migrations.CreateModel( + name='MotionCommentSection', + fields=[ + ('id', models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('read_groups', models.ManyToManyField( + blank=True, + related_name='read_comments', + to=settings.AUTH_GROUP_MODEL)), + ('write_groups', models.ManyToManyField( + blank=True, + related_name='write_comments', + to=settings.AUTH_GROUP_MODEL)), + ], + options={ + 'default_permissions': (), + }, + bases=(openslides.utils.models.RESTModelMixin, models.Model), # type: ignore + ), + migrations.AddField( + model_name='motioncomment', + name='section', + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name='comments', + to='motions.MotionCommentSection'), + ), + migrations.AddField( + model_name='motioncomment', + name='motion', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='motions.Motion'), + ), + migrations.AlterUniqueTogether( + name='motioncomment', + unique_together={('motion', 'section')}, + ), + + # Move the comments and sections + migrations.RunPython(create_comment_sections_from_config_and_move_comments_to_own_model), + + # Remove old comment field from motion, use the new model instead + migrations.RemoveField( + model_name='motion', + name='comments', + ), + migrations.AlterField( + model_name='motioncomment', + name='motion', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='comments', + to='motions.Motion'), + ), + ] diff --git a/openslides/motions/migrations/0013_motion_sorting_and_statute.py b/openslides/motions/migrations/0013_motion_sorting_and_statute.py new file mode 100644 index 000000000..92f4ac7af --- /dev/null +++ b/openslides/motions/migrations/0013_motion_sorting_and_statute.py @@ -0,0 +1,65 @@ +# Generated by Django 2.1.1 on 2018-09-24 08:26 + +import django.db.models.deletion +from django.db import migrations, models + +import openslides.utils.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('motions', '0012_motion_comments'), + ] + + operations = [ + migrations.AlterModelOptions( + name='motionblock', + options={ + 'default_permissions': (), + 'verbose_name': 'Motion block'}, + ), + migrations.AddField( + model_name='motion', + name='sort_parent', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='children', + to='motions.Motion'), + ), + migrations.AddField( + model_name='motion', + name='weight', + field=models.IntegerField(default=10000), + ), + migrations.CreateModel( + name='StatuteParagraph', + fields=[ + ('id', models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('text', models.TextField()), + ('weight', models.IntegerField(default=10000)), + ], + options={ + 'ordering': ['weight', 'title'], + 'default_permissions': (), + }, + bases=(openslides.utils.models.RESTModelMixin, models.Model), + ), + migrations.AddField( + model_name='motion', + name='statute_paragraph', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='motions', + to='motions.StatuteParagraph'), + ), + ] diff --git a/openslides/motions/models.py b/openslides/motions/models.py index ae058553d..3b8928eb0 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -1,4 +1,4 @@ -from typing import Any, Dict # noqa +from typing import Any, Dict from django.conf import settings from django.contrib.auth.models import AnonymousUser @@ -7,8 +7,7 @@ from django.core.exceptions import ImproperlyConfigured, ValidationError from django.db import IntegrityError, models, transaction from django.db.models import Max from django.utils import formats, timezone -from django.utils.translation import ugettext as _ -from django.utils.translation import ugettext_lazy, ugettext_noop +from django.utils.translation import ugettext as _, ugettext_noop from jsonfield import JSONField from openslides.agenda.models import Item @@ -30,11 +29,38 @@ from .access_permissions import ( MotionAccessPermissions, MotionBlockAccessPermissions, MotionChangeRecommendationAccessPermissions, + MotionCommentSectionAccessPermissions, + StatuteParagraphAccessPermissions, WorkflowAccessPermissions, ) from .exceptions import WorkflowError +class StatuteParagraph(RESTModelMixin, models.Model): + """ + Model for parts of the statute + """ + access_permissions = StatuteParagraphAccessPermissions() + + title = models.CharField(max_length=255) + """Title of the statute paragraph.""" + + text = models.TextField() + """Content of the statute paragraph.""" + + weight = models.IntegerField(default=10000) + """ + A weight field to sort statute paragraphs. + """ + + class Meta: + default_permissions = () + ordering = ['weight', 'title'] + + def __str__(self): + return self.title + + class MotionManager(models.Manager): """ Customized model manager to support our get_full_queryset method. @@ -45,9 +71,12 @@ class MotionManager(models.Manager): join and prefetch all related models. """ return (self.get_queryset() - .select_related('active_version', 'state') + .select_related('state') .prefetch_related( - 'versions', + 'state__workflow', + 'comments', + 'comments__section', + 'comments__section__read_groups', 'agenda_items', 'log_messages', 'polls', @@ -67,18 +96,26 @@ class Motion(RESTModelMixin, models.Model): objects = MotionManager() - active_version = models.ForeignKey( - 'MotionVersion', - on_delete=models.SET_NULL, - null=True, - related_name="active_version") - """ - Points to a specific version. + title = models.CharField(max_length=255) + """The title of a motion.""" - Used be the permitted-version-system to deside which version is the active - version. Could also be used to only choose a specific version as a default - version. Like the sighted versions on Wikipedia. + text = models.TextField() + """The text of a motion.""" + + amendment_paragraphs = JSONField(null=True) """ + If paragraph-based, diff-enabled amendment style is used, this field stores an array of strings or null values. + Each entry corresponds to a paragraph of the text of the original motion. + If the entry is null, then the paragraph remains unchanged. + If the entry is a string, this is the new text of the paragraph. + amendment_paragraphs and text are mutually exclusive. + """ + + modified_final_version = models.TextField(null=True, blank=True) + """A field to copy in the final version of the motion and edit it there.""" + + reason = models.TextField(null=True, blank=True) + """The reason for a motion.""" state = models.ForeignKey( 'State', @@ -91,6 +128,11 @@ class Motion(RESTModelMixin, models.Model): This attribute is to get the current state of the motion. """ + state_extension = models.TextField(blank=True, null=True) + """ + A text field fo additional information about the state. + """ + recommendation = models.ForeignKey( 'State', related_name='+', @@ -100,6 +142,11 @@ class Motion(RESTModelMixin, models.Model): The recommendation of a person or committee for this motion. """ + recommendation_extension = models.TextField(blank=True, null=True) + """ + A text field fo additional information about the recommendation. + """ + identifier = models.CharField(max_length=255, null=True, blank=True, unique=True) """ @@ -113,6 +160,21 @@ class Motion(RESTModelMixin, models.Model): Needed to find the next free motion identifier. """ + weight = models.IntegerField(default=10000) + """ + A weight field to sort motions. + """ + + sort_parent = models.ForeignKey( + 'self', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='children') + """ + A parent field for multi-depth sorting of motions. + """ + category = models.ForeignKey( 'Category', on_delete=models.SET_NULL, @@ -154,6 +216,19 @@ class Motion(RESTModelMixin, models.Model): Null if the motion is not an amendment. """ + statute_paragraph = models.ForeignKey( + StatuteParagraph, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='motions') + """ + Field to reference to a statute paragraph if this motion is a + statute-amendment. + + Null if the motion is not a statute-amendment. + """ + tags = models.ManyToManyField(Tag, blank=True) """ Tags to categorise motions. @@ -164,11 +239,6 @@ class Motion(RESTModelMixin, models.Model): Users who support this motion. """ - comments = JSONField(null=True) - """ - Configurable fields for comments. - """ - # In theory there could be one then more agenda_item. But we support only # one. See the property agenda_item. agenda_items = GenericRelation(Item, related_name='motions') @@ -179,8 +249,6 @@ class Motion(RESTModelMixin, models.Model): ('can_see', 'Can see motions'), ('can_create', 'Can create motions'), ('can_support', 'Can support motions'), - ('can_see_comments', 'Can see comments'), - ('can_manage_comments', 'Can manage comments'), ('can_manage', 'Can manage motions'), ) ordering = ('identifier', ) @@ -193,34 +261,13 @@ class Motion(RESTModelMixin, models.Model): return self.title # TODO: Use transaction - def save(self, use_version=None, skip_autoupdate=False, *args, **kwargs): + def save(self, skip_autoupdate=False, *args, **kwargs): """ Save the motion. 1. Set the state of a new motion to the default state. 2. Ensure that the identifier is not an empty string. 3. Save the motion object. - 4. Save the version data. - 5. Set the active version for the motion if a new version object was saved. - - The version data is *not* saved, if - 1. the django-feature 'update_fields' is used or - 2. the argument use_version is False (differ to None). - - The argument use_version is choose the version object into which the - version data is saved. - * If use_version is False, no version data is saved. - * If use_version is None, the last version is used. - * Else the given version is used. - - To create and use a new version object, you have to set it via the - use_version argument. You have to set the title, text/amendment_paragraphs, - modified final version and reason into this version object before giving it - to this save method. The properties motion.title, motion.text, - motion.amendment_paragraphs, motion.modified_final_version and motion.reason will be ignored. - - text and amendment_paragraphs are mutually exclusive; if both are given, - amendment_paragraphs takes precedence. """ if not self.state: self.reset_state() @@ -252,55 +299,6 @@ class Motion(RESTModelMixin, models.Model): # Save was successful. End loop. break - if 'update_fields' in kwargs: - # Do not save the version data if only some motion fields are updated. - if not skip_autoupdate: - inform_changed_data(self) - return - - if use_version is False: - # We do not need to save the version. - if not skip_autoupdate: - inform_changed_data(self) - return - elif use_version is None: - use_version = self.get_last_version() - # Save title, text, amendment paragraphs, modified final version and reason into the version object. - for attr in ['title', 'text', 'amendment_paragraphs', 'modified_final_version', 'reason']: - _attr = '_%s' % attr - data = getattr(self, _attr, None) - if data is not None: - setattr(use_version, attr, data) - delattr(self, _attr) - - # If version is not in the database, test if it has new data and set - # the version_number. - if use_version.id is None: - if not self.version_data_changed(use_version): - # We do not need to save the version. - if not skip_autoupdate: - inform_changed_data(self) - return - version_number = self.versions.aggregate(Max('version_number'))['version_number__max'] or 0 - use_version.version_number = version_number + 1 - - # Necessary line if the version was set before the motion got an id. - use_version.motion = use_version.motion - - # Always skip autoupdate. Maybe we run it later in this method. - use_version.save(skip_autoupdate=True) - - # Set the active version of this motion. This has to be done after the - # version is saved in the database. - # TODO: Move parts of these last lines of code outside the save method - # when other versions than the last one should be edited later on. - if self.active_version is None or not self.state.leave_old_version_active: - # TODO: Don't call this if it was not a new version - self.active_version = use_version - # Always skip autoupdate. Maybe we run it later in this method. - self.save(update_fields=['active_version'], skip_autoupdate=True) - - # Finally run autoupdate if it is not skipped by caller. if not skip_autoupdate: inform_changed_data(self) @@ -315,24 +313,6 @@ class Motion(RESTModelMixin, models.Model): id=self.pk) return super().delete(skip_autoupdate=skip_autoupdate, *args, **kwargs) # type: ignore - def version_data_changed(self, version): - """ - Compare the version with the last version of the motion. - - Returns True if the version data (title, text, amendment_paragraphs, - modified_final_version, reason) is different, - else returns False. - """ - if not self.versions.exists(): - # If there is no version in the database, the data has always changed. - return True - - last_version = self.get_last_version() - for attr in ['title', 'text', 'amendment_paragraphs', 'modified_final_version', 'reason']: - if getattr(last_version, attr) != getattr(version, attr): - return True - return False - def set_identifier(self): """ Sets the motion identifier automaticly according to the config value if @@ -417,188 +397,6 @@ class Motion(RESTModelMixin, models.Model): result = '0' * (settings.MOTION_IDENTIFIER_MIN_DIGITS - len(str(number))) + result return result - def get_title(self): - """ - Get the title of the motion. - - The title is taken from motion.version. - """ - try: - return self._title - except AttributeError: - return self.get_active_version().title - - def set_title(self, title): - """ - Set the title of the motion. - - The title will be saved in the version object, when motion.save() is - called. - """ - self._title = title - - title = property(get_title, set_title) - """ - The title of the motion. - - Is saved in a MotionVersion object. - """ - - def get_text(self): - """ - Get the text of the motion. - - Simular to get_title(). - """ - try: - return self._text - except AttributeError: - return self.get_active_version().text - - def set_text(self, text): - """ - Set the text of the motion. - - Simular to set_title(). - """ - self._text = text - - text = property(get_text, set_text) - """ - The text of a motion. - - Is saved in a MotionVersion object. - """ - - def get_amendment_paragraphs(self): - """ - Get the paragraphs of the amendment. - Returns an array of entries that are either null (paragraph is not changed) - or a string (the new version of this paragraph). - """ - try: - return self._amendment_paragraphs - except AttributeError: - return self.get_active_version().amendment_paragraphs - - def set_amendment_paragraphs(self, text): - """ - Set the paragraphs of the amendment. - Has to be an array of entries that are either null (paragraph is not changed) - or a string (the new version of this paragraph). - """ - self._amendment_paragraphs = text - - amendment_paragraphs = property(get_amendment_paragraphs, set_amendment_paragraphs) - """ - The paragraphs of the amendment. - - Is saved in a MotionVersion object. - """ - - def get_modified_final_version(self): - """ - Get the modified_final_version of the motion. - - Simular to get_title(). - """ - try: - return self._modified_final_version - except AttributeError: - return self.get_active_version().modified_final_version - - def set_modified_final_version(self, modified_final_version): - """ - Set the modified_final_version of the motion. - - Simular to set_title(). - """ - self._modified_final_version = modified_final_version - - modified_final_version = property(get_modified_final_version, set_modified_final_version) - """ - The modified_final_version for the motion. - - Is saved in a MotionVersion object. - """ - - def get_reason(self): - """ - Get the reason of the motion. - - Simular to get_title(). - """ - try: - return self._reason - except AttributeError: - return self.get_active_version().reason - - def set_reason(self, reason): - """ - Set the reason of the motion. - - Simular to set_title(). - """ - self._reason = reason - - reason = property(get_reason, set_reason) - """ - The reason for the motion. - - Is saved in a MotionVersion object. - """ - - def get_new_version(self, **kwargs): - """ - Return a version object, not saved in the database. - - The version data of the new version object is populated with the data - set via motion.title, motion.text, motion.amendment_paragraphs, - motion.modified_final_version and motion.reason if these data are not - given as keyword arguments. If the data is not set in the motion - attributes, it is populated with the data from the last version - object if such object exists. - """ - if self.pk is None: - # Do not reference the MotionVersion object to an unsaved motion - new_version = MotionVersion(**kwargs) - else: - new_version = MotionVersion(motion=self, **kwargs) - if self.versions.exists(): - last_version = self.get_last_version() - else: - last_version = None - for attr in ['title', 'text', 'amendment_paragraphs', 'modified_final_version', 'reason']: - if attr in kwargs: - continue - _attr = '_%s' % attr - data = getattr(self, _attr, None) - if data is None and last_version is not None: - data = getattr(last_version, attr) - if data is not None: - setattr(new_version, attr, data) - return new_version - - def get_active_version(self): - """ - Returns the active version of the motion. - - If no active version is set by now, the last_version is used. - """ - if self.active_version: - return self.active_version - else: - return self.get_last_version() - - def get_last_version(self): - """ - Return the newest version of the motion. - """ - try: - return self.versions.order_by('-version_number')[0] - except IndexError: - return self.get_new_version() - def is_submitter(self, user): """ Returns True if user is a submitter of this motion, else False. @@ -626,11 +424,10 @@ class Motion(RESTModelMixin, models.Model): raise WorkflowError('You can not create a poll in state %s.' % self.state.name) @property - def workflow(self): + def workflow_id(self): """ Returns the id of the workflow of the motion. """ - # TODO: Rename to workflow_id return self.state.workflow.pk def set_state(self, state): @@ -689,30 +486,34 @@ class Motion(RESTModelMixin, models.Model): """ Container for runtime information for agenda app (on create or update of this instance). """ - agenda_item_update_information = {} # type: Dict[str, Any] + agenda_item_update_information: Dict[str, Any] = {} def get_agenda_title(self): """ - Return a simple title string for the agenda. + Return the title string for the agenda. - Returns only the motion title so that you have only agenda item number - and title in the agenda. - """ - return str(self) - - def get_agenda_list_view_title(self): - """ - Return a title string for the agenda list view. - - Returns only the motion title so that you have agenda item number, - title and motion identifier in the agenda. + If the identifier is given, the title consists of the motion verbose name + and the identifier. Note: It has to be the same return value like in JavaScript. """ if self.identifier: - string = '%s %s' % (_(self._meta.verbose_name), self.identifier) + title = '%s %s' % (_(self._meta.verbose_name), self.identifier) else: - string = '%s (%s)' % (_(self._meta.verbose_name), self.title) - return string + title = self.title + return title + + def get_agenda_title_with_type(self): + """ + Return a title for the agenda with the type or the modified title if the + identifier is set.. + + Note: It has to be the same return value like in JavaScript. + """ + if self.identifier: + title = '%s %s' % (_(self._meta.verbose_name), self.identifier) + else: + title = '%s (%s)' % (self.title, _(self._meta.verbose_name)) + return title @property def agenda_item(self): @@ -774,6 +575,76 @@ class Motion(RESTModelMixin, models.Model): return list(filter(lambda amend: amend.is_paragraph_based_amendment(), self.amendments.all())) +class MotionCommentSection(RESTModelMixin, models.Model): + """ + The model for comment sections for motions. Each comment is related to one section, so + each motions has the ability to have comments from the same section. + """ + access_permissions = MotionCommentSectionAccessPermissions() + + name = models.CharField(max_length=255) + """ + The name of the section. + """ + + read_groups = models.ManyToManyField( + settings.AUTH_GROUP_MODEL, + blank=True, + related_name='read_comments') + """ + These groups have read-access to the section. + """ + + write_groups = models.ManyToManyField( + settings.AUTH_GROUP_MODEL, + blank=True, + related_name='write_comments') + """ + These groups have write-access to the section. + """ + + class Meta: + default_permissions = () + + +class MotionComment(RESTModelMixin, models.Model): + """ + Represents a motion comment. A comment is always related to a motion and a comment + section. The section determinates the title of the category. + """ + + comment = models.TextField() + """ + The comment. + """ + + motion = models.ForeignKey( + Motion, + on_delete=models.CASCADE, + related_name='comments') + """ + The motion where this comment belongs to. + """ + + section = models.ForeignKey( + MotionCommentSection, + on_delete=models.PROTECT, + related_name='comments') + """ + The section of the comment. + """ + + class Meta: + default_permissions = () + unique_together = ('motion', 'section') + + def get_root_rest_element(self): + """ + Returns the motion to this instance which is the root REST element. + """ + return self.motion + + class SubmitterManager(models.Manager): """ Manager for Submitter model. Provides a customized add method. @@ -837,68 +708,6 @@ class Submitter(RESTModelMixin, models.Model): return self.motion -class MotionVersion(RESTModelMixin, models.Model): - """ - A MotionVersion object saves some date of the motion. - """ - - motion = models.ForeignKey( - Motion, - on_delete=models.CASCADE, - related_name='versions') - """The motion to which the version belongs.""" - - version_number = models.PositiveIntegerField(default=1) - """An id for this version in realation to a motion. - - Is unique for each motion. - """ - - title = models.CharField(max_length=255) - """The title of a motion.""" - - text = models.TextField() - """The text of a motion.""" - - amendment_paragraphs = JSONField(null=True) - """ - If paragraph-based, diff-enabled amendment style is used, this field stores an array of strings or null values. - Each entry corresponds to a paragraph of the text of the original motion. - If the entry is null, then the paragraph remains unchanged. - If the entry is a string, this is the new text of the paragraph. - amendment_paragraphs and text are mutually exclusive. - """ - - modified_final_version = models.TextField(null=True, blank=True) - """A field to copy in the final version of the motion and edit it there.""" - - reason = models.TextField(null=True, blank=True) - """The reason for a motion.""" - - creation_time = models.DateTimeField(auto_now=True) - """Time when the version was saved.""" - - class Meta: - default_permissions = () - unique_together = ("motion", "version_number") - - def __str__(self): - """Return a string, representing this object.""" - counter = self.version_number or ugettext_lazy('new') - return "Motion %s, Version %s" % (self.motion_id, counter) - - @property - def active(self): - """Return True, if the version is the active version of a motion. Else: False.""" - return self.active_version.exists() - - def get_root_rest_element(self): - """ - Returns the motion to this instance which is the root REST element. - """ - return self.motion - - class MotionChangeRecommendationManager(models.Manager): """ Customized model manager to support our get_full_queryset method. @@ -913,18 +722,18 @@ class MotionChangeRecommendationManager(models.Manager): class MotionChangeRecommendation(RESTModelMixin, models.Model): """ - A MotionChangeRecommendation object saves change recommendations for a specific MotionVersion + A MotionChangeRecommendation object saves change recommendations for a specific Motion """ access_permissions = MotionChangeRecommendationAccessPermissions() objects = MotionChangeRecommendationManager() - motion_version = models.ForeignKey( - MotionVersion, + motion = models.ForeignKey( + Motion, on_delete=models.CASCADE, related_name='change_recommendations') - """The motion version to which the change recommendation belongs.""" + """The motion to which the change recommendation belongs.""" rejected = models.BooleanField(default=False) """If true, this change recommendation has been rejected""" @@ -942,7 +751,7 @@ class MotionChangeRecommendation(RESTModelMixin, models.Model): """The number or the last affected line (inclusive)""" text = models.TextField(blank=True) - """The replacement for the section of the original text specified by version, line_from and line_to""" + """The replacement for the section of the original text specified by motion, line_from and line_to""" author = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -963,7 +772,7 @@ class MotionChangeRecommendation(RESTModelMixin, models.Model): def save(self, *args, **kwargs): recommendations = (MotionChangeRecommendation.objects - .filter(motion_version=self.motion_version) + .filter(motion=self.motion) .exclude(pk=self.pk)) if self.collides_with_other_recommendation(recommendations): @@ -977,7 +786,7 @@ class MotionChangeRecommendation(RESTModelMixin, models.Model): def __str__(self): """Return a string, representing this object.""" - return "Recommendation for Version %s, line %s - %s" % (self.motion_version_id, self.line_from, self.line_to) + return "Recommendation for Motion %s, line %s - %s" % (self.motion_id, self.line_from, self.line_to) class Category(RESTModelMixin, models.Model): @@ -1030,6 +839,7 @@ class MotionBlock(RESTModelMixin, models.Model): agenda_items = GenericRelation(Item, related_name='topics') class Meta: + verbose_name = ugettext_noop('Motion block') default_permissions = () def __str__(self): @@ -1049,7 +859,7 @@ class MotionBlock(RESTModelMixin, models.Model): """ Container for runtime information for agenda app (on create or update of this instance). """ - agenda_item_update_information = {} # type: Dict[str, Any] + agenda_item_update_information: Dict[str, Any] = {} @property def agenda_item(self): @@ -1070,8 +880,8 @@ class MotionBlock(RESTModelMixin, models.Model): def get_agenda_title(self): return self.title - def get_agenda_list_view_title(self): - return self.title + def get_agenda_title_with_type(self): + return '%s (%s)' % (self.get_agenda_title(), _(self._meta.verbose_name)) class MotionLog(RESTModelMixin, models.Model): @@ -1269,16 +1079,6 @@ class State(RESTModelMixin, models.Model): allow_submitter_edit = models.BooleanField(default=False) """If true, the submitter can edit the motion in this state.""" - versioning = models.BooleanField(default=False) - """ - If true, editing the motion will create a new version by default. - This behavior can be changed by the form and view, e. g. via the - MotionDisableVersioningMixin. - """ - - leave_old_version_active = models.BooleanField(default=False) - """If true, new versions are not automaticly set active.""" - dont_set_identifier = models.BooleanField(default=False) """ Decides if the motion gets an identifier. diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py index 529762823..f9484430f 100644 --- a/openslides/motions/serializers.py +++ b/openslides/motions/serializers.py @@ -1,17 +1,19 @@ -from typing import Dict, Optional # noqa +from typing import Dict, Optional from django.db import transaction from django.utils.translation import ugettext as _ from ..poll.serializers import default_votes_validator +from ..utils.auth import get_group_model +from ..utils.autoupdate import inform_changed_data from ..utils.rest_api import ( CharField, DecimalField, DictField, Field, + IdPrimaryKeyRelatedField, IntegerField, ModelSerializer, - PrimaryKeyRelatedField, SerializerMethodField, ValidationError, ) @@ -21,10 +23,12 @@ from .models import ( Motion, MotionBlock, MotionChangeRecommendation, + MotionComment, + MotionCommentSection, MotionLog, MotionPoll, - MotionVersion, State, + StatuteParagraph, Submitter, Workflow, ) @@ -38,6 +42,15 @@ def validate_workflow_field(value): raise ValidationError({'detail': _('Workflow %(pk)d does not exist.') % {'pk': value}}) +class StatuteParagraphSerializer(ModelSerializer): + """ + Serializer for motion.models.StatuteParagraph objects. + """ + class Meta: + model = StatuteParagraph + fields = ('id', 'title', 'text', 'weight') + + class CategorySerializer(ModelSerializer): """ Serializer for motion.models.Category objects. @@ -88,8 +101,6 @@ class StateSerializer(ModelSerializer): 'allow_support', 'allow_create_poll', 'allow_submitter_edit', - 'versioning', - 'leave_old_version_active', 'dont_set_identifier', 'show_state_extension_field', 'show_recommendation_extension_field', @@ -128,32 +139,6 @@ class WorkflowSerializer(ModelSerializer): return workflow -class MotionCommentsJSONSerializerField(Field): - """ - Serializer for motions's comments JSONField. - """ - def to_representation(self, obj): - """ - Returns the value of the field. - """ - return obj - - def to_internal_value(self, data): - """ - Checks that data is a list of strings. - """ - if type(data) is not dict: - raise ValidationError({'detail': 'Data must be a dict.'}) - for id, comment in data.items(): - try: - id = int(id) - except ValueError: - raise ValidationError({'detail': 'Id must be an int.'}) - if type(comment) is not str: - raise ValidationError({'detail': 'Comment must be a string.'}) - return data - - class AmendmentParagraphsJSONSerializerField(Field): """ Serializer for motions's amendment_paragraphs JSONField. @@ -222,26 +207,26 @@ class MotionPollSerializer(ModelSerializer): def __init__(self, *args, **kwargs): # The following dictionary is just a cache for several votes. - self._votes_dicts = {} # type: Dict[int, Dict[int, int]] + self._votes_dicts: Dict[int, Dict[int, int]] = {} return super().__init__(*args, **kwargs) def get_yes(self, obj): try: - result = str(self.get_votes_dict(obj)['Yes']) # type: Optional[str] + result: Optional[str] = str(self.get_votes_dict(obj)['Yes']) except KeyError: result = None return result def get_no(self, obj): try: - result = str(self.get_votes_dict(obj)['No']) # type: Optional[str] + result: Optional[str] = str(self.get_votes_dict(obj)['No']) except KeyError: result = None return result def get_abstain(self, obj): try: - result = str(self.get_votes_dict(obj)['Abstain']) # type: Optional[str] + result: Optional[str] = str(self.get_votes_dict(obj)['Abstain']) except KeyError: result = None return result @@ -291,25 +276,6 @@ class MotionPollSerializer(ModelSerializer): return instance -class MotionVersionSerializer(ModelSerializer): - amendment_paragraphs = AmendmentParagraphsJSONSerializerField(required=False) - - """ - Serializer for motion.models.MotionVersion objects. - """ - class Meta: - model = MotionVersion - fields = ( - 'id', - 'version_number', - 'creation_time', - 'title', - 'text', - 'amendment_paragraphs', - 'modified_final_version', - 'reason',) - - class MotionChangeRecommendationSerializer(ModelSerializer): """ Serializer for motion.models.MotionChangeRecommendation objects. @@ -318,7 +284,7 @@ class MotionChangeRecommendationSerializer(ModelSerializer): model = MotionChangeRecommendation fields = ( 'id', - 'motion_version', + 'motion', 'rejected', 'type', 'other_description', @@ -337,6 +303,53 @@ class MotionChangeRecommendationSerializer(ModelSerializer): return data +class MotionCommentSectionSerializer(ModelSerializer): + """ + Serializer for motion.models.MotionCommentSection objects. + """ + read_groups = IdPrimaryKeyRelatedField( + many=True, + required=False, + queryset=get_group_model().objects.all()) + + write_groups = IdPrimaryKeyRelatedField( + many=True, + required=False, + queryset=get_group_model().objects.all()) + + class Meta: + model = MotionCommentSection + fields = ( + 'id', + 'name', + 'read_groups', + 'write_groups',) + + def create(self, validated_data): + """ Call inform_changed_data on creation, so the cache includes the groups. """ + section = super().create(validated_data) + inform_changed_data(section) + return section + + +class MotionCommentSerializer(ModelSerializer): + """ + Serializer for motion.models.MotionComment objects. + """ + read_groups_id = SerializerMethodField() + + class Meta: + model = MotionComment + fields = ( + 'id', + 'comment', + 'section', + 'read_groups_id',) + + def get_read_groups_id(self, comment): + return [group.id for group in comment.section.read_groups.all()] + + class SubmitterSerializer(ModelSerializer): """ Serializer for motion.models.Submitter objects. @@ -355,22 +368,19 @@ class MotionSerializer(ModelSerializer): """ Serializer for motion.models.Motion objects. """ - active_version = PrimaryKeyRelatedField(read_only=True) - comments = MotionCommentsJSONSerializerField(required=False) + comments = MotionCommentSerializer(many=True, read_only=True) log_messages = MotionLogSerializer(many=True, read_only=True) polls = MotionPollSerializer(many=True, read_only=True) - modified_final_version = CharField(allow_blank=True, required=False, write_only=True) - reason = CharField(allow_blank=True, required=False, write_only=True) + modified_final_version = CharField(allow_blank=True, required=False) + reason = CharField(allow_blank=True, required=False) state_required_permission_to_see = SerializerMethodField() - text = CharField(write_only=True, allow_blank=True) - title = CharField(max_length=255, write_only=True) - amendment_paragraphs = AmendmentParagraphsJSONSerializerField(required=False, write_only=True) - versions = MotionVersionSerializer(many=True, read_only=True) + text = CharField(allow_blank=True) + title = CharField(max_length=255) + amendment_paragraphs = AmendmentParagraphsJSONSerializerField(required=False) workflow_id = IntegerField( min_value=1, required=False, - validators=[validate_workflow_field], - write_only=True) + validators=[validate_workflow_field]) agenda_type = IntegerField(write_only=True, required=False, min_value=1, max_value=3) agenda_parent_id = IntegerField(write_only=True, required=False, min_value=1) submitters = SubmitterSerializer(many=True, read_only=True) @@ -385,26 +395,28 @@ class MotionSerializer(ModelSerializer): 'amendment_paragraphs', 'modified_final_version', 'reason', - 'versions', - 'active_version', 'parent', 'category', + 'comments', 'motion_block', 'origin', 'submitters', 'supporters', - 'comments', 'state', + 'state_extension', 'state_required_permission_to_see', 'workflow_id', 'recommendation', + 'recommendation_extension', 'tags', 'attachments', 'polls', 'agenda_item_id', 'agenda_type', 'agenda_parent_id', - 'log_messages',) + 'log_messages', + 'sort_parent', + 'weight',) read_only_fields = ('state', 'recommendation',) # Some other fields are also read_only. See definitions above. def validate(self, data): @@ -417,11 +429,6 @@ class MotionSerializer(ModelSerializer): if 'reason' in data: data['reason'] = validate_html(data['reason']) - validated_comments = dict() - for id, comment in data.get('comments', {}).items(): - validated_comments[id] = validate_html(comment) - data['comments'] = validated_comments - if 'amendment_paragraphs' in data: data['amendment_paragraphs'] = list(map(lambda entry: validate_html(entry) if type(entry) is str else None, data['amendment_paragraphs'])) @@ -452,7 +459,6 @@ class MotionSerializer(ModelSerializer): motion.category = validated_data.get('category') motion.motion_block = validated_data.get('motion_block') motion.origin = validated_data.get('origin', '') - motion.comments = validated_data.get('comments') motion.parent = validated_data.get('parent') motion.reset_state(validated_data.get('workflow_id')) motion.agenda_item_update_information['type'] = validated_data.get('agenda_type') @@ -468,38 +474,17 @@ class MotionSerializer(ModelSerializer): """ Customized method to update a motion. """ - # Identifier, category, motion_block, origin and comments. - for key in ('identifier', 'category', 'motion_block', 'origin', 'comments'): - if key in validated_data.keys(): - setattr(motion, key, validated_data[key]) + workflow_id = None + if 'workflow_id' in validated_data: + workflow_id = validated_data.pop('workflow_id') - # Workflow. - workflow_id = validated_data.get('workflow_id') - if workflow_id is not None and workflow_id != motion.workflow: + result = super().update(motion, validated_data) + + if workflow_id is not None and workflow_id != motion.workflow_id: motion.reset_state(workflow_id) + motion.save() - # Decide if a new version is saved to the database. - if (motion.state.versioning and - not validated_data.get('disable_versioning', False)): # TODO - version = motion.get_new_version() - else: - version = motion.get_last_version() - - # Title, text, reason, ... - for key in ('title', 'text', 'amendment_paragraphs', 'modified_final_version', 'reason'): - if key in validated_data.keys(): - setattr(version, key, validated_data[key]) - - motion.save(use_version=version) - - # Submitters, supporters, attachments and tags - for key in ('submitters', 'supporters', 'attachments', 'tags'): - if key in validated_data.keys(): - attr = getattr(motion, key) - attr.clear() - attr.add(*validated_data[key]) - - return motion + return result def get_state_required_permission_to_see(self, motion): """ diff --git a/openslides/motions/signals.py b/openslides/motions/signals.py index 4bc838522..e7abc8032 100644 --- a/openslides/motions/signals.py +++ b/openslides/motions/signals.py @@ -1,4 +1,4 @@ -from typing import Set # noqa +from typing import Set from django.apps import apps from django.utils.translation import ugettext_noop @@ -54,54 +54,44 @@ def create_builtin_workflows(sender, **kwargs): action_word='Permit', recommendation_label='Permission', allow_create_poll=True, - allow_submitter_edit=True, - versioning=True, - leave_old_version_active=True) + allow_submitter_edit=True) state_2_3 = State.objects.create(name=ugettext_noop('accepted'), workflow=workflow_2, action_word='Accept', recommendation_label='Acceptance', - versioning=True, css_class='success') state_2_4 = State.objects.create(name=ugettext_noop('rejected'), workflow=workflow_2, action_word='Reject', recommendation_label='Rejection', - versioning=True, css_class='danger') state_2_5 = State.objects.create(name=ugettext_noop('withdrawed'), workflow=workflow_2, action_word='Withdraw', - versioning=True, css_class='default') state_2_6 = State.objects.create(name=ugettext_noop('adjourned'), workflow=workflow_2, action_word='Adjourn', recommendation_label='Adjournment', - versioning=True, css_class='default') state_2_7 = State.objects.create(name=ugettext_noop('not concerned'), workflow=workflow_2, action_word='Do not concern', recommendation_label='No concernment', - versioning=True, css_class='default') state_2_8 = State.objects.create(name=ugettext_noop('refered to committee'), workflow=workflow_2, action_word='Refer to committee', recommendation_label='Referral to committee', - versioning=True, css_class='default') state_2_9 = State.objects.create(name=ugettext_noop('needs review'), workflow=workflow_2, action_word='Needs review', - versioning=True, css_class='default') state_2_10 = State.objects.create(name=ugettext_noop('rejected (not authorized)'), workflow=workflow_2, action_word='Reject (not authorized)', recommendation_label='Rejection (not authorized)', - versioning=True, css_class='default') state_2_1.next_states.add(state_2_2, state_2_5, state_2_10) state_2_2.next_states.add(state_2_3, state_2_4, state_2_5, state_2_6, state_2_7, state_2_8, state_2_9) @@ -126,7 +116,7 @@ def required_users(sender, request_user, **kwargs): any motion if request_user can see motions. This function may return an empty set. """ - submitters_supporters = set() # type: Set[int] + submitters_supporters: Set[int] = set() if has_perm(request_user, 'motions.can_see'): for motion_collection_element in Collection(Motion.get_collection_string()).element_generator(): full_data = motion_collection_element.get_full_data() diff --git a/openslides/motions/urls.py b/openslides/motions/urls.py index 6e7c41473..277e22dce 100644 --- a/openslides/motions/urls.py +++ b/openslides/motions/urls.py @@ -2,6 +2,7 @@ from django.conf.urls import url from . import views + urlpatterns = [ url(r'^docxtemplate/$', views.MotionDocxTemplateView.as_view(), diff --git a/openslides/motions/views.py b/openslides/motions/views.py index 33a84916b..1e1ca0f6a 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -1,19 +1,17 @@ import re -from typing import Optional # noqa +from typing import List, Optional from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError as DjangoValidationError from django.db import IntegrityError, transaction from django.db.models.deletion import ProtectedError -from django.http import Http404 from django.http.request import QueryDict -from django.utils.translation import ugettext as _ -from django.utils.translation import ugettext_noop +from django.utils.translation import ugettext as _, ugettext_noop from rest_framework import status from ..core.config import config -from ..utils.auth import has_perm +from ..utils.auth import has_perm, in_some_groups from ..utils.autoupdate import inform_changed_data from ..utils.collection import CollectionElement from ..utils.exceptions import OpenSlidesError @@ -26,6 +24,7 @@ from ..utils.rest_api import ( UpdateModelMixin, ValidationError, detail_route, + list_route, ) from ..utils.views import BinaryTemplateView from .access_permissions import ( @@ -33,6 +32,8 @@ from .access_permissions import ( MotionAccessPermissions, MotionBlockAccessPermissions, MotionChangeRecommendationAccessPermissions, + MotionCommentSectionAccessPermissions, + StatuteParagraphAccessPermissions, WorkflowAccessPermissions, ) from .exceptions import WorkflowError @@ -41,9 +42,11 @@ from .models import ( Motion, MotionBlock, MotionChangeRecommendation, + MotionComment, + MotionCommentSection, MotionPoll, - MotionVersion, State, + StatuteParagraph, Submitter, Workflow, ) @@ -57,7 +60,7 @@ class MotionViewSet(ModelViewSet): API endpoint for motions. There are the following views: metadata, list, retrieve, create, - partial_update, update, destroy, manage_version, support, set_state and + partial_update, update, destroy, support, set_state and create_poll. """ access_permissions = MotionAccessPermissions() @@ -78,7 +81,7 @@ class MotionViewSet(ModelViewSet): has_perm(self.request.user, 'motions.can_create') and (not config['motions_stop_submitting'] or has_perm(self.request.user, 'motions.can_manage'))) - elif self.action in ('manage_version', 'set_state', 'set_recommendation', + elif self.action in ('set_state', 'sort', 'manage_comments', 'set_recommendation', 'follow_recommendation', 'create_poll', 'manage_submitters', 'sort_submitters'): result = (has_perm(self.request.user, 'motions.can_see') and @@ -114,9 +117,9 @@ class MotionViewSet(ModelViewSet): # Check if parent motion exists. if request.data.get('parent_id') is not None: try: - parent_motion = CollectionElement.from_values( + parent_motion: Optional[CollectionElement] = CollectionElement.from_values( Motion.get_collection_string(), - request.data['parent_id']) # type: Optional[CollectionElement] + request.data['parent_id']) except Motion.DoesNotExist: raise ValidationError({'detail': _('The parent motion does not exist.')}) else: @@ -131,7 +134,6 @@ class MotionViewSet(ModelViewSet): 'title', 'text', 'reason', - 'comments', # This is checked later. ] if parent_motion is not None: # For creating amendments. @@ -147,16 +149,6 @@ class MotionViewSet(ModelViewSet): if key not in whitelist: del request.data[key] - # Check permission to send comment data. - if (not has_perm(request.user, 'motions.can_see_comments') or - not has_perm(request.user, 'motions.can_manage_comments')): - try: - # Ignore comments data if user is not allowed to send comments. - del request.data['comments'] - except KeyError: - # No comments here. Just do nothing. - pass - # Validate data and create motion. serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -224,9 +216,7 @@ class MotionViewSet(ModelViewSet): # Check permissions. if (not has_perm(request.user, 'motions.can_manage') and - not (motion.is_submitter(request.user) and motion.state.allow_submitter_edit) and - not (has_perm(request.user, 'motions.can_see_comments') and - has_perm(request.user, 'motions.can_manage_comments'))): + not (motion.is_submitter(request.user) and motion.state.allow_submitter_edit)): self.permission_denied(request) # Check permission to send only some data. @@ -234,9 +224,7 @@ class MotionViewSet(ModelViewSet): # Remove fields that the user is not allowed to change. # The list() is required because we want to use del inside the loop. keys = list(request.data.keys()) - whitelist = [ - 'comments', # This is checked later. - ] + whitelist: List[str] = [] # Add title, text and reason to the whitelist only, if the user is the submitter. if motion.is_submitter(request.user) and motion.state.allow_submitter_edit: whitelist.extend(( @@ -248,70 +236,22 @@ class MotionViewSet(ModelViewSet): if key not in whitelist: del request.data[key] - # Check comments - # "normal" comments only can be changed if the user has can_see_comments and - # can_manage_comments. - # "special" comments (for state and recommendation) can only be changed, if - # the user has the can_manage permission - if 'comments' in request.data: - request_comments = {} # Here, all valid comments are saved. - for id, value in request.data['comments'].items(): - field = config['motions_comments'].get(id) - if field: - is_special_comment = field.get('forRecommendation') or field.get('forState') - if (is_special_comment and has_perm(request.user, 'motions.can_manage')) or ( - not is_special_comment and has_perm(request.user, 'motions.can_see_comments') and - has_perm(request.user, 'motions.can_manage_comments')): - # The user has the required permission for at least one case - # Save the comment! - request_comments[id] = value - - # two possibilities here: Either the comments dict is empty: then delete it, so - # the serializer will skip it. If we leave it empty, everything will be deleted :( - # Second, just special or normal comments are in the comments dict. Fill the original - # data, so it won't be delete. - - if len(request_comments) == 0: - del request.data['comments'] - else: - if motion.comments: - for id, value in motion.comments.items(): - if id not in request_comments: - # populate missing comments with original ones. - request_comments[id] = value - request.data['comments'] = request_comments - - # get changed comment fields - changed_comment_fields = [] - comments = request.data.get('comments', {}) - for id, value in comments.items(): - if not motion.comments or motion.comments.get(id) != value: - field = config['motions_comments'].get(id) - if field: - name = field['name'] - changed_comment_fields.append(name) - # Validate data and update motion. serializer = self.get_serializer( motion, data=request.data, partial=kwargs.get('partial', False)) serializer.is_valid(raise_exception=True) - updated_motion = serializer.save(disable_versioning=request.data.get('disable_versioning')) + updated_motion = serializer.save() # Write the log message, check removal of supporters and initiate response. - # TODO: Log if a version was updated. + # TODO: Log if a motion was updated. updated_motion.write_log([ugettext_noop('Motion updated')], request.user) if (config['motions_remove_supporters'] and updated_motion.state.allow_support and not has_perm(request.user, 'motions.can_manage')): updated_motion.supporters.clear() updated_motion.write_log([ugettext_noop('All supporters removed')], request.user) - if len(changed_comment_fields) > 0: - updated_motion.write_log( - [ugettext_noop('Comment {} updated').format(', '.join(changed_comment_fields))], - request.user) - # Send new supporters via autoupdate because users # without permission to see users may not have them but can get it now. new_users = list(updated_motion.supporters.all()) @@ -319,48 +259,98 @@ class MotionViewSet(ModelViewSet): return Response(serializer.data) - @detail_route(methods=['put', 'delete']) - def manage_version(self, request, pk=None): + @list_route(methods=['post']) + def sort(self, request): """ - Special view endpoint to permit and delete a version of a motion. + Sort motions. Also checks sort_parent field to prevent hierarchical loops. - Send PUT {'version_number': } to permit and DELETE - {'version_number': } to delete a version. Deleting the - active version is not allowed. Only managers can use this view. + Note: This view is not tested! Maybe needs to be refactored. Add documentation + abou the data to be send. + """ + raise ValidationError({'detail': _('This view needs testing and refactoring!')}) + nodes = request.data.get('nodes', []) + sort_parent_id = request.data.get('sort_parent_id') + motions = [] + with transaction.atomic(): + for index, node in enumerate(nodes): + motion = Motion.objects.get(pk=node['id']) + motion.sort_parent_id = sort_parent_id + motion.weight = index + motion.save(skip_autoupdate=True) + motions.append(motion) + + # Now check consistency. TODO: Try to use less DB queries. + motion = Motion.objects.get(pk=node['id']) + ancestor = motion.sort_parent + while ancestor is not None: + if ancestor == motion: + raise ValidationError({'detail': _( + 'There must not be a hierarchical loop.')}) + ancestor = ancestor.sort_parent + + inform_changed_data(motions) + return Response({'detail': _('The motions has been sorted.')}) + + @detail_route(methods=['POST', 'DELETE']) + def manage_comments(self, request, pk=None): + """ + Create, update and delete motin comments. + Send a post request with {'section_id': , 'comment': ''} to create + a new comment or update an existing comment. + Send a delete request with just {'section_id': } to delete the comment. + For ever request, the user must have read and write permission for the given field. """ - # Retrieve motion and version. motion = self.get_object() - version_number = request.data.get('version_number') + + # Get the comment section + section_id = request.data.get('section_id') + if not section_id or not isinstance(section_id, int): + raise ValidationError({'detail': _('You have to provide a section_id of type int.')}) + try: - version = motion.versions.get(version_number=version_number) - except MotionVersion.DoesNotExist: - raise Http404('Version %s not found.' % version_number) + section = MotionCommentSection.objects.get(pk=section_id) + except MotionCommentSection.DoesNotExist: + raise ValidationError({'detail': _('A comment section with id {} does not exist').format(section_id)}) - # Permit or delete version. - if request.method == 'PUT': - # Permit version. - motion.active_version = version - motion.save(update_fields=['active_version']) - motion.write_log( - message_list=[ugettext_noop('Version'), - ' %d ' % version.version_number, - ugettext_noop('permitted')], - person=self.request.user) - message = _('Version %d permitted successfully.') % version.version_number - else: - # Delete version. - # request.method == 'DELETE' - if version == motion.active_version: - raise ValidationError({'detail': _('You can not delete the active version of a motion.')}) - version.delete() - motion.write_log( - message_list=[ugettext_noop('Version'), - ' %d ' % version.version_number, - ugettext_noop('deleted')], - person=self.request.user) - message = _('Version %d deleted successfully.') % version.version_number + # the request user needs to see and write to the comment section + if (not in_some_groups(request.user, list(section.read_groups.values_list('pk', flat=True))) or + not in_some_groups(request.user, list(section.write_groups.values_list('pk', flat=True)))): + raise ValidationError({'detail': _('You are not allowed to see or write to the comment section.')}) + + if request.method == 'POST': # Create or update + # validate comment + comment_value = request.data.get('comment', '') + if not isinstance(comment_value, str): + raise ValidationError({'detail': _('The comment should be a string.')}) + + comment, created = MotionComment.objects.get_or_create( + motion=motion, + section=section, + defaults={ + 'comment': comment_value}) + if not created: + comment.comment = comment_value + comment.save() + + # write log + motion.write_log( + [ugettext_noop('Comment {} updated').format(section.name)], + request.user) + message = _('Comment {} updated').format(section.name) + else: # DELETE + try: + comment = MotionComment.objects.get(motion=motion, section=section) + except MotionComment.DoesNotExist: + # Be silent about not existing comments, but do not create a log entry. + pass + else: + comment.delete() + + motion.write_log( + [ugettext_noop('Comment {} deleted').format(section.name)], + request.user) + message = _('Comment {} deleted').format(section.name) - # Initiate response. return Response({'detail': message}) @detail_route(methods=['POST', 'DELETE']) @@ -558,7 +548,7 @@ class MotionViewSet(ModelViewSet): recommendation_state_id = int(recommendation_state) except ValueError: raise ValidationError({'detail': _('Invalid data. Recommendation must be an integer.')}) - recommendable_states = State.objects.filter(workflow=motion.workflow, recommendation_label__isnull=False) + recommendable_states = State.objects.filter(workflow=motion.workflow_id, recommendation_label__isnull=False) if recommendation_state_id not in [item.id for item in recommendable_states]: raise ValidationError( {'detail': _('You can not set the recommendation to {recommendation_state_id}.').format( @@ -589,17 +579,13 @@ class MotionViewSet(ModelViewSet): motion.set_state(motion.recommendation) # Set the special state comment. - extension = request.data.get('recommendationExtension') + extension = request.data.get('state_extension') if extension is not None: - # Find the special "state" comment field. - for id, field in config['motions_comments'].items(): - if isinstance(field, dict) and 'forState' in field and field['forState'] is True: - motion.comments[id] = extension - break + motion.state_extension = extension # Save and write log. motion.save( - update_fields=['state', 'identifier', 'identifier_number', 'comments'], + update_fields=['state', 'identifier', 'identifier_number', 'state_extension'], skip_autoupdate=True) motion.write_log( message_list=[ugettext_noop('State set to'), ' ', motion.state.name], @@ -701,6 +687,75 @@ class MotionChangeRecommendationViewSet(ModelViewSet): return Response({'detail': err.message}, status=400) +class MotionCommentSectionViewSet(ModelViewSet): + """ + API endpoint for motion comment fields. + """ + access_permissions = MotionCommentSectionAccessPermissions() + queryset = MotionCommentSection.objects.all() + + def check_view_permissions(self): + """ + Returns True if the user has required permissions. + """ + if self.action in ('list', 'retrieve'): + result = self.get_access_permissions().check_permissions(self.request.user) + elif self.action in ('create', 'destroy', 'update', 'partial_update'): + result = (has_perm(self.request.user, 'motions.can_see') and + has_perm(self.request.user, 'motions.can_manage')) + else: + result = False + return result + + def destroy(self, *args, **kwargs): + """ + Customized view endpoint to delete a motion comment section. Will return + an error for the user, if still comments for this section exist. + """ + try: + result = super().destroy(*args, **kwargs) + except ProtectedError as e: + # The protected objects can just be motion comments. + motions = ['"' + str(comment.motion) + '"' for comment in e.protected_objects.all()] + count = len(motions) + motions_verbose = ', '.join(motions[:3]) + if count > 3: + motions_verbose += ', ...' + + if count == 1: + msg = _('This section has still comments in motion {}.').format(motions_verbose) + else: + msg = _('This section has still comments in motions {}.').format(motions_verbose) + + msg += ' ' + _('Please remove all comments before deletion.') + raise ValidationError({'detail': msg}) + return result + + +class StatuteParagraphViewSet(ModelViewSet): + """ + API endpoint for statute paragraphs. + + There are the following views: list, retrieve, create, + partial_update, update and destroy. + """ + access_permissions = StatuteParagraphAccessPermissions() + queryset = StatuteParagraph.objects.all() + + def check_view_permissions(self): + """ + Returns True if the user has required permissions. + """ + if self.action in ('list', 'retrieve'): + result = self.get_access_permissions().check_permissions(self.request.user) + elif self.action in ('create', 'partial_update', 'update', 'destroy'): + result = (has_perm(self.request.user, 'motions.can_see') and + has_perm(self.request.user, 'motions.can_manage')) + else: + result = False + return result + + class CategoryViewSet(ModelViewSet): """ API endpoint for categories. @@ -921,7 +976,7 @@ class WorkflowViewSet(ModelViewSet, ProtectedErrorMessageMixin): def destroy(self, *args, **kwargs): """ - Customized view endpoint to delete a motion poll. + Customized view endpoint to delete a workflow. """ try: result = super().destroy(*args, **kwargs) @@ -950,7 +1005,7 @@ class StateViewSet(CreateModelMixin, UpdateModelMixin, DestroyModelMixin, Generi def destroy(self, *args, **kwargs): """ - Customized view endpoint to delete a motion poll. + Customized view endpoint to delete a state. """ state = self.get_object() if state.workflow.first_state.pk == state.pk: # is this the first state of the workflow? diff --git a/openslides/old_urls.py b/openslides/old_urls.py new file mode 100644 index 000000000..428e0347b --- /dev/null +++ b/openslides/old_urls.py @@ -0,0 +1,37 @@ +from django.conf import settings +from django.conf.urls import include, url +from django.contrib.staticfiles.urls import urlpatterns +from django.views.generic import RedirectView + +from openslides.core import views as core_views +from openslides.mediafiles.views import protected_serve +from openslides.utils.plugins import get_all_plugin_urlpatterns +from openslides.utils.rest_api import router + + +urlpatterns += get_all_plugin_urlpatterns() + +urlpatterns += [ + url(r'^%s(?P.*)$' % settings.MEDIA_URL.lstrip('/'), protected_serve, {'document_root': settings.MEDIA_ROOT}), + url(r'^(?P.*[^/])$', RedirectView.as_view(url='/%(url)s/', permanent=True)), + url(r'^rest/', include(router.urls)), + url(r'^agenda/', include('openslides.agenda.urls')), + url(r'^motions/', include('openslides.motions.urls')), + url(r'^users/', include('openslides.users.urls')), + url(r'^core/', include('openslides.core.urls')), + # The old angular webclient + # TODO: Change me or at least my comment + url(r'^webclient/(?Psite|projector)/$', + core_views.WebclientJavaScriptView.as_view(), + name='core_webclient_javascript'), + + # View for the projectors are handled by angular. + url(r'^projector/(\d+)/$', core_views.ProjectorView.as_view()), + + # Original view without resolutioncontrol for the projectors are handled by angular. + url(r'^real-projector/(\d+)/$', core_views.RealProjectorView.as_view()), + + # Main entry point for all angular pages. + # Has to be the last entry in the urls.py + url(r'^.*$', core_views.IndexView.as_view(), name="index"), +] diff --git a/openslides/poll/models.py b/openslides/poll/models.py index aec301cb3..643447692 100644 --- a/openslides/poll/models.py +++ b/openslides/poll/models.py @@ -1,6 +1,6 @@ import locale from decimal import Decimal -from typing import Type # noqa +from typing import Optional, Type from django.core.exceptions import ObjectDoesNotExist from django.core.validators import MinValueValidator @@ -17,7 +17,7 @@ class BaseOption(models.Model): which has to be a subclass of BaseVote. Otherwise you have to override the get_vote_class method. """ - vote_class = None # type: Type[BaseVote] + vote_class: Optional[Type['BaseVote']] = None class Meta: abstract = True diff --git a/openslides/routing.py b/openslides/routing.py index a40ffd311..29457c7c3 100644 --- a/openslides/routing.py +++ b/openslides/routing.py @@ -1,31 +1,16 @@ -from channels.routing import include, route +from channels.routing import ProtocolTypeRouter, URLRouter +from django.conf.urls import url -from openslides.utils.autoupdate import ( - send_data_projector, - send_data_site, - ws_add_projector, - ws_add_site, - ws_disconnect_projector, - ws_disconnect_site, - ws_receive_projector, - ws_receive_site, -) +from openslides.utils.consumers import ProjectorConsumer, SiteConsumer +from openslides.utils.middleware import AuthMiddlewareStack -projector_routing = [ - route("websocket.connect", ws_add_projector), - route("websocket.disconnect", ws_disconnect_projector), - route("websocket.receive", ws_receive_projector), -] -site_routing = [ - route("websocket.connect", ws_add_site), - route("websocket.disconnect", ws_disconnect_site), - route("websocket.receive", ws_receive_site), -] - -channel_routing = [ - include(projector_routing, path=r'^/ws/projector/(?P\d+)/$'), - include(site_routing, path=r'^/ws/site/$'), - route("autoupdate.send_data_projector", send_data_projector), - route("autoupdate.send_data_site", send_data_site), -] +application = ProtocolTypeRouter({ + # WebSocket chat handler + "websocket": AuthMiddlewareStack( + URLRouter([ + url(r"^ws/site/$", SiteConsumer), + url(r"^ws/projector/(?P\d+)/$", ProjectorConsumer), + ]) + ) +}) diff --git a/openslides/topics/apps.py b/openslides/topics/apps.py index 6f4f0b075..edc9f572e 100644 --- a/openslides/topics/apps.py +++ b/openslides/topics/apps.py @@ -1,6 +1,5 @@ from django.apps import AppConfig -from ..utils.collection import Collection from ..utils.projector import register_projector_elements @@ -31,7 +30,7 @@ class TopicsAppConfig(AppConfig): def get_startup_elements(self): """ - Yields all collections required on startup i. e. opening the websocket + Yields all Cachables required on startup i. e. opening the websocket connection. """ - yield Collection(self.get_model('Topic').get_collection_string()) + yield self.get_model('Topic') diff --git a/openslides/topics/models.py b/openslides/topics/models.py index 1f869304a..ff9185bc3 100644 --- a/openslides/topics/models.py +++ b/openslides/topics/models.py @@ -1,4 +1,4 @@ -from typing import Any, Dict # noqa +from typing import Any, Dict from django.contrib.contenttypes.fields import GenericRelation from django.db import models @@ -59,7 +59,7 @@ class Topic(RESTModelMixin, models.Model): """ Container for runtime information for agenda app (on create or update of this instance). """ - agenda_item_update_information = {} # type: Dict[str, Any] + agenda_item_update_information: Dict[str, Any] = {} @property def agenda_item(self): @@ -78,7 +78,13 @@ class Topic(RESTModelMixin, models.Model): return self.agenda_item.pk def get_agenda_title(self): + """ + Returns the title for the agenda. + """ return self.title - def get_agenda_list_view_title(self): - return self.title + def get_agenda_title_with_type(self): + """ + Returns the agenda title. Topicy should not get a type postfix. + """ + return self.get_agenda_title() diff --git a/openslides/urls.py b/openslides/urls.py index ad0f03792..d3da9d09b 100644 --- a/openslides/urls.py +++ b/openslides/urls.py @@ -1,22 +1,43 @@ from django.conf import settings from django.conf.urls import include, url +from django.contrib.staticfiles.urls import urlpatterns from django.views.generic import RedirectView from openslides.mediafiles.views import protected_serve -from openslides.utils.plugins import get_all_plugin_urlpatterns from openslides.utils.rest_api import router -urlpatterns = get_all_plugin_urlpatterns() +from .core import views as core_views + + +# Urls for /static/ are already in urlpatterns urlpatterns += [ + # URLs for /media/ url(r'^%s(?P.*)$' % settings.MEDIA_URL.lstrip('/'), protected_serve, {'document_root': settings.MEDIA_ROOT}), - url(r'^(?P.*[^/])$', RedirectView.as_view(url='/%(url)s/', permanent=True)), - url(r'^rest/', include(router.urls)), - url(r'^agenda/', include('openslides.agenda.urls')), - url(r'^motions/', include('openslides.motions.urls')), - url(r'^users/', include('openslides.users.urls')), - # The urls.py of the core app has to be the last entry. It contains the - # main entry points for OpenSlides' browser clients. - url(r'^', include('openslides.core.urls')), + # When a url without a leading slash is requested, redirect to the url with + # the slash. This line has to be after static and media files. + url(r'^(?P.*[^/])$', RedirectView.as_view(url='/%(url)s/', permanent=True)), + + # URLs for the rest system + url(r'^rest/', include(router.urls)), + + # Other urls defined by modules and plugins + url(r'^apps/', include('openslides.urls_apps')), + + # The old angular webclient + # TODO: Change me or at least my comment + url(r'^webclient/(?Psite|projector)/$', + core_views.WebclientJavaScriptView.as_view(), + name='core_webclient_javascript'), + + # View for the projectors are handled by angular. + url(r'^projector/(\d+)/$', core_views.ProjectorView.as_view()), + + # Original view without resolutioncontrol for the projectors are handled by angular. + url(r'^real-projector/(\d+)/$', core_views.RealProjectorView.as_view()), + + # Main entry point for all angular pages. + # Has to be the last entry in the urls.py + url(r'^.*$', core_views.IndexView.as_view(), name="index"), ] diff --git a/openslides/urls_apps.py b/openslides/urls_apps.py new file mode 100644 index 000000000..8ef1ef6ab --- /dev/null +++ b/openslides/urls_apps.py @@ -0,0 +1,13 @@ +from django.conf.urls import include, url + +from openslides.utils.plugins import get_all_plugin_urlpatterns + + +urlpatterns = get_all_plugin_urlpatterns() + +urlpatterns += [ + url(r'^core/', include('openslides.core.urls')), + url(r'^agenda/', include('openslides.agenda.urls')), + url(r'^motions/', include('openslides.motions.urls')), + url(r'^users/', include('openslides.users.urls')), +] diff --git a/openslides/users/access_permissions.py b/openslides/users/access_permissions.py index c1a5ec904..3648ca991 100644 --- a/openslides/users/access_permissions.py +++ b/openslides/users/access_permissions.py @@ -3,7 +3,7 @@ from typing import Any, Dict, List, Optional from django.contrib.auth.models import AnonymousUser from ..core.signals import user_data_required -from ..utils.access_permissions import BaseAccessPermissions # noqa +from ..utils.access_permissions import BaseAccessPermissions from ..utils.auth import anonymous_is_enabled, has_perm from ..utils.collection import CollectionElement @@ -43,16 +43,20 @@ class UserAccessPermissions(BaseAccessPermissions): """ return {key: full_data[key] for key in whitelist} - # We have four sets of data to be sent: - # * full data i. e. all fields, - # * many data i. e. all fields but not the default password, - # * little data i. e. all fields but not the default password, comments and active status, + # We have five sets of data to be sent: + # * full data i. e. all fields (including session_auth_hash), + # * all data i. e. all fields but not session_auth_hash, + # * many data i. e. all fields but not the default password and session_auth_hash, + # * little data i. e. all fields but not the default password, session_auth_hash, comments and active status, # * no data. - # Prepare field set for users with "many" data and with "little" data. - many_data_fields = set(USERCANSEEEXTRASERIALIZER_FIELDS) - many_data_fields.add('groups_id') - many_data_fields.discard('groups') + # Prepare field set for users with "all" data, "many" data and with "little" data. + all_data_fields = set(USERCANSEEEXTRASERIALIZER_FIELDS) + all_data_fields.add('groups_id') + all_data_fields.discard('groups') + all_data_fields.add('default_password') + many_data_fields = all_data_fields.copy() + many_data_fields.discard('default_password') litte_data_fields = set(USERCANSEESERIALIZER_FIELDS) litte_data_fields.add('groups_id') litte_data_fields.discard('groups') @@ -61,7 +65,7 @@ class UserAccessPermissions(BaseAccessPermissions): if has_perm(user, 'users.can_see_name'): if has_perm(user, 'users.can_see_extra_data'): if has_perm(user, 'users.can_manage'): - data = full_data + data = [filtered_data(full, all_data_fields) for full in full_data] else: data = [filtered_data(full, many_data_fields) for full in full_data] else: @@ -168,7 +172,7 @@ class PersonalNoteAccessPermissions(BaseAccessPermissions): """ # Parse data. if user is None: - data = [] # type: List[Dict[str, Any]] + data: List[Dict[str, Any]] = [] else: for full in full_data: if full['user_id'] == user.id: diff --git a/openslides/users/apps.py b/openslides/users/apps.py index 6c1a5adcf..eff921cf9 100644 --- a/openslides/users/apps.py +++ b/openslides/users/apps.py @@ -2,7 +2,6 @@ from django.apps import AppConfig from django.conf import settings from django.contrib.auth.signals import user_logged_in -from ..utils.collection import Collection from ..utils.projector import register_projector_elements @@ -14,16 +13,13 @@ class UsersAppConfig(AppConfig): def ready(self): # Import all required stuff. - from ..core.config import config from ..core.signals import post_permission_creation, permission_change from ..utils.rest_api import router - from .config_variables import get_config_variables from .projector import get_projector_elements from .signals import create_builtin_groups_and_admin, get_permission_change_data from .views import GroupViewSet, PersonalNoteViewSet, UserViewSet - # Define config variables and projector elements. - config.update_config_variables(get_config_variables()) + # Define projector elements. register_projector_elements(get_projector_elements()) # Connect signals. @@ -43,13 +39,17 @@ class UsersAppConfig(AppConfig): router.register(self.get_model('Group').get_collection_string(), GroupViewSet) router.register(self.get_model('PersonalNote').get_collection_string(), PersonalNoteViewSet) + def get_config_variables(self): + from .config_variables import get_config_variables + return get_config_variables() + def get_startup_elements(self): """ - Yields all collections required on startup i. e. opening the websocket + Yields all Cachables required on startup i. e. opening the websocket connection. """ - for model in ('User', 'Group', 'PersonalNote'): - yield Collection(self.get_model(model).get_collection_string()) + for model_name in ('User', 'Group', 'PersonalNote'): + yield self.get_model(model_name) def get_angular_constants(self): from django.contrib.auth.models import Permission @@ -59,7 +59,4 @@ class UsersAppConfig(AppConfig): permissions.append({ 'display_name': permission.name, 'value': '.'.join((permission.content_type.app_label, permission.codename,))}) - permission_settings = { - 'name': 'permissions', - 'value': permissions} - return [permission_settings] + return {'permissions': permissions} diff --git a/openslides/users/migrations/0007_superadmin.py b/openslides/users/migrations/0007_superadmin.py new file mode 100644 index 000000000..57a83a168 --- /dev/null +++ b/openslides/users/migrations/0007_superadmin.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations + + +def create_superadmin_group(apps, schema_editor): + """ + Migrates the groups to create an admin group with all permissions + granted. Replaces the delegate group to get pk=2. + + - Create new delegate group. Move users and permissions to it. + - Rename the old delegate group to Admin and remove all permissions. + - If a group with the name 'Admin' (probably with pk = 4) exists, move all + users from it to the new superadmin group and delete it. If not, check for + the staff group and assign all users to the superadmin group. + """ + Group = apps.get_model('users', 'Group') + + # If no groups exists at all, skip this migration + if Group.objects.count() == 0: + return + + # Get the new superadmin group (or the old delegates) + superadmin, created_superadmin_group = Group.objects.get_or_create(pk=2, defaults={'name': '__temp__'}) + + if not created_superadmin_group: + new_delegate = Group.objects.create(name='Delegates2') + new_delegate.permissions.set(superadmin.permissions.all()) + superadmin.permissions.set([]) + + for user in superadmin.user_set.all(): + user.groups.add(new_delegate) + user.groups.remove(superadmin) + + finished_moving_users = False + try: + admin = Group.objects.get(name='Admin') + for user in admin.user_set.all(): + user.groups.add(superadmin) + user.groups.remove(admin) + admin.delete(skip_autoupdate=True) + finished_moving_users = True + except Group.DoesNotExist: + pass + + if not finished_moving_users: + try: + staff = Group.objects.get(name='Staff') + for user in staff.user_set.all(): + user.groups.add(superadmin) + except Group.DoesNotExist: + pass + + superadmin.name = 'Admin' + superadmin.save(skip_autoupdate=True) + if not created_superadmin_group: + new_delegate.name = 'Delegates' + new_delegate.save(skip_autoupdate=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0006_user_email'), + ] + + operations = [ + migrations.RunPython(create_superadmin_group), + ] diff --git a/openslides/users/models.py b/openslides/users/models.py index 1e9592a8f..b7e68eaf7 100644 --- a/openslides/users/models.py +++ b/openslides/users/models.py @@ -2,23 +2,24 @@ import smtplib from random import choice from django.contrib.auth.hashers import make_password -from django.contrib.auth.models import Group as DjangoGroup -from django.contrib.auth.models import GroupManager as _GroupManager from django.contrib.auth.models import ( AbstractBaseUser, BaseUserManager, + Group as DjangoGroup, + GroupManager as _GroupManager, Permission, PermissionsMixin, ) from django.core import mail from django.core.exceptions import ValidationError from django.db import models -from django.db.models import Prefetch, Q +from django.db.models import Prefetch from django.utils import timezone from jsonfield import JSONField from ..core.config import config from ..core.models import Projector +from ..utils.auth import GROUP_ADMIN_PK from ..utils.collection import CollectionElement from ..utils.models import RESTModelMixin from .access_permissions import ( @@ -60,24 +61,15 @@ class UserManager(BaseUserManager): """ Creates an user with the username 'admin'. If such a user already exists, resets it. The password is (re)set to 'admin'. The user - becomes member of the group 'Staff'. The two important permissions - 'users.can_see_name' and 'users.can_manage' are added to this group, - so that the admin can manage all other permissions. + becomes member of the group 'Admin'. """ - query_can_see_name = Q(content_type__app_label='users') & Q(codename='can_see_name') - query_can_manage = Q(content_type__app_label='users') & Q(codename='can_manage') - - admin_group, _ = Group.objects.get_or_create(name='Admin') - admin_group.permissions.add(Permission.objects.get(query_can_see_name)) - admin_group.permissions.add(Permission.objects.get(query_can_manage)) - admin, created = self.get_or_create( username='admin', defaults={'last_name': 'Administrator'}) admin.default_password = 'admin' admin.password = make_password(admin.default_password) admin.save() - admin.groups.add(admin_group) + admin.groups.add(GROUP_ADMIN_PK) return created def generate_username(self, first_name, last_name): @@ -286,6 +278,15 @@ class User(RESTModelMixin, PermissionsMixin, AbstractBaseUser): return False + @property + def session_auth_hash(self): + """ + Returns the session auth hash of a user as attribute. + + Needed for the django rest framework. + """ + return self.get_session_auth_hash() + class GroupManager(_GroupManager): """ diff --git a/openslides/users/serializers.py b/openslides/users/serializers.py index 65fea361b..d35fb5674 100644 --- a/openslides/users/serializers.py +++ b/openslides/users/serializers.py @@ -1,7 +1,6 @@ from django.contrib.auth.hashers import make_password from django.contrib.auth.models import Permission -from django.utils.translation import ugettext as _ -from django.utils.translation import ugettext_lazy +from django.utils.translation import ugettext as _, ugettext_lazy from ..utils.autoupdate import inform_changed_data from ..utils.rest_api import ( @@ -13,6 +12,7 @@ from ..utils.rest_api import ( ) from .models import Group, PersonalNote, User + USERCANSEESERIALIZER_FIELDS = ( 'id', 'username', @@ -52,7 +52,7 @@ class UserFullSerializer(ModelSerializer): class Meta: model = User - fields = USERCANSEEEXTRASERIALIZER_FIELDS + ('default_password',) + fields = USERCANSEEEXTRASERIALIZER_FIELDS + ('default_password', 'session_auth_hash') read_only_fields = ('last_email_send',) def validate(self, data): diff --git a/openslides/users/signals.py b/openslides/users/signals.py index a5f72d561..ac762f391 100644 --- a/openslides/users/signals.py +++ b/openslides/users/signals.py @@ -2,6 +2,7 @@ from django.apps import apps from django.contrib.auth.models import Permission from django.db.models import Q +from ..utils.auth import GROUP_ADMIN_PK, GROUP_DEFAULT_PK from ..utils.autoupdate import inform_changed_data from .models import Group, User @@ -53,8 +54,6 @@ def create_builtin_groups_and_admin(**kwargs): 'motions.can_create', 'motions.can_manage', 'motions.can_see', - 'motions.can_see_comments', - 'motions.can_manage_comments', 'motions.can_support', 'users.can_manage', 'users.can_see_extra_data', @@ -71,7 +70,7 @@ def create_builtin_groups_and_admin(**kwargs): permission_string = '.'.join((permission.content_type.app_label, permission.codename)) permission_dict[permission_string] = permission - # Default (pk 1) + # Default (pk 1 == GROUP_DEFAULT_PK) base_permissions = ( permission_dict['agenda.can_see'], permission_dict['agenda.can_see_internal_items'], @@ -81,10 +80,13 @@ def create_builtin_groups_and_admin(**kwargs): permission_dict['mediafiles.can_see'], permission_dict['motions.can_see'], permission_dict['users.can_see_name'], ) - group_default = Group.objects.create(name='Default') + group_default = Group.objects.create(pk=GROUP_DEFAULT_PK, name='Default') group_default.permissions.add(*base_permissions) - # Delegates (pk 2) + # Admin (pk 2 == GROUP_ADMIN_PK) + group_admin = Group.objects.create(pk=GROUP_ADMIN_PK, name='Admin') + + # Delegates (pk 3) delegates_permissions = ( permission_dict['agenda.can_see'], permission_dict['agenda.can_see_internal_items'], @@ -99,10 +101,10 @@ def create_builtin_groups_and_admin(**kwargs): permission_dict['motions.can_create'], permission_dict['motions.can_support'], permission_dict['users.can_see_name'], ) - group_delegates = Group.objects.create(name='Delegates') + group_delegates = Group.objects.create(pk=3, name='Delegates') group_delegates.permissions.add(*delegates_permissions) - # Staff (pk 3) + # Staff (pk 4) staff_permissions = ( permission_dict['agenda.can_see'], permission_dict['agenda.can_see_internal_items'], @@ -124,49 +126,13 @@ def create_builtin_groups_and_admin(**kwargs): permission_dict['motions.can_see'], permission_dict['motions.can_create'], permission_dict['motions.can_manage'], - permission_dict['motions.can_see_comments'], - permission_dict['motions.can_manage_comments'], permission_dict['users.can_see_name'], permission_dict['users.can_manage'], permission_dict['users.can_see_extra_data'], permission_dict['mediafiles.can_see_hidden'],) - group_staff = Group.objects.create(name='Staff') + group_staff = Group.objects.create(pk=4, name='Staff') group_staff.permissions.add(*staff_permissions) - # Admin (pk 4) - admin_permissions = ( - permission_dict['agenda.can_see'], - permission_dict['agenda.can_see_internal_items'], - permission_dict['agenda.can_be_speaker'], - permission_dict['agenda.can_manage'], - permission_dict['agenda.can_manage_list_of_speakers'], - permission_dict['assignments.can_see'], - permission_dict['assignments.can_manage'], - permission_dict['assignments.can_nominate_other'], - permission_dict['assignments.can_nominate_self'], - permission_dict['core.can_see_frontpage'], - permission_dict['core.can_see_projector'], - permission_dict['core.can_manage_config'], - permission_dict['core.can_manage_logos_and_fonts'], - permission_dict['core.can_manage_projector'], - permission_dict['core.can_manage_tags'], - permission_dict['core.can_use_chat'], - permission_dict['core.can_manage_chat'], - permission_dict['mediafiles.can_see'], - permission_dict['mediafiles.can_manage'], - permission_dict['mediafiles.can_upload'], - permission_dict['motions.can_see'], - permission_dict['motions.can_create'], - permission_dict['motions.can_manage'], - permission_dict['motions.can_see_comments'], - permission_dict['motions.can_manage_comments'], - permission_dict['users.can_see_name'], - permission_dict['users.can_manage'], - permission_dict['users.can_see_extra_data'], - permission_dict['mediafiles.can_see_hidden'],) - group_admin = Group.objects.create(name='Admin') - group_admin.permissions.add(*admin_permissions) - # Add users.can_see_name permission to staff/admin # group to ensure proper management possibilities # TODO: Remove this redundancy after cleanup of the permission system. @@ -187,7 +153,7 @@ def create_builtin_groups_and_admin(**kwargs): permission_dict['motions.can_create'], permission_dict['motions.can_support'], permission_dict['users.can_see_name'], ) - group_committee = Group.objects.create(name='Committees') + group_committee = Group.objects.create(pk=5, name='Committees') group_committee.permissions.add(*committees_permissions) # Create or reset admin user @@ -196,4 +162,4 @@ def create_builtin_groups_and_admin(**kwargs): # After each group was created, the permissions (many to many fields) where # added to the group. So we have to update the cache by calling # inform_changed_data(). - inform_changed_data((group_default, group_delegates, group_staff, group_admin, group_committee)) + inform_changed_data((group_default, group_admin, group_delegates, group_staff, group_committee)) diff --git a/openslides/users/urls.py b/openslides/users/urls.py index 5b1bbeddb..872b9245d 100644 --- a/openslides/users/urls.py +++ b/openslides/users/urls.py @@ -2,6 +2,7 @@ from django.conf.urls import url from . import views + urlpatterns = [ # Auth url(r'^login/$', diff --git a/openslides/users/views.py b/openslides/users/views.py index 4077d3c4a..84fa779bf 100644 --- a/openslides/users/views.py +++ b/openslides/users/views.py @@ -1,10 +1,13 @@ import smtplib -from typing import List # noqa +from typing import List +from asgiref.sync import async_to_sync from django.conf import settings -from django.contrib.auth import login as auth_login -from django.contrib.auth import logout as auth_logout -from django.contrib.auth import update_session_auth_hash +from django.contrib.auth import ( + login as auth_login, + logout as auth_logout, + update_session_auth_hash, +) from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.password_validation import validate_password from django.core import mail @@ -15,13 +18,19 @@ from django.utils.translation import ugettext as _ from ..core.config import config from ..core.signals import permission_change -from ..utils.auth import anonymous_is_enabled, has_perm +from ..utils.auth import ( + GROUP_ADMIN_PK, + GROUP_DEFAULT_PK, + anonymous_is_enabled, + has_perm, + user_to_collection_user, +) from ..utils.autoupdate import ( inform_changed_data, inform_data_collection_element_list, ) -from ..utils.cache import restricted_data_cache -from ..utils.collection import CollectionElement +from ..utils.cache import element_cache +from ..utils.collection import Collection, CollectionElement from ..utils.rest_api import ( ModelViewSet, Response, @@ -103,7 +112,7 @@ class UserViewSet(ModelViewSet): del request.data[key] response = super().update(request, *args, **kwargs) # Maybe some group assignments have changed. Better delete the restricted user cache - restricted_data_cache.del_user(user.id) + async_to_sync(element_cache.del_user)(user_to_collection_user(user)) return response def destroy(self, request, *args, **kwargs): @@ -303,7 +312,7 @@ class GroupViewSet(ModelViewSet): # Delete the user chaches of all affected users for user in group.user_set.all(): - restricted_data_cache.del_user(user.id) + async_to_sync(element_cache.del_user)(user_to_collection_user(user)) def diff(full, part): """ @@ -318,11 +327,11 @@ class GroupViewSet(ModelViewSet): # Some permissions are added. if len(new_permissions) > 0: - collection_elements = [] # type: List[CollectionElement] + collection_elements: List[CollectionElement] = [] signal_results = permission_change.send(None, permissions=new_permissions, action='added') for receiver, signal_collections in signal_results: - for collection in signal_collections: - collection_elements.extend(collection.element_generator()) + for cachable in signal_collections: + collection_elements.extend(Collection(cachable.get_collection_string()).element_generator()) inform_data_collection_element_list(collection_elements) # TODO: Some permissions are deleted. @@ -331,10 +340,10 @@ class GroupViewSet(ModelViewSet): def destroy(self, request, *args, **kwargs): """ - Protects builtin groups 'Default' (pk=1) from being deleted. + Protects builtin groups 'Default' (pk=1) and 'Admin' (pk=2) from being deleted. """ instance = self.get_object() - if instance.pk == 1: + if instance.pk in (GROUP_DEFAULT_PK, GROUP_ADMIN_PK): self.permission_denied(request) # The list() is required to evaluate the query affected_users_ids = list(instance.user_set.values_list('pk', flat=True)) @@ -446,6 +455,10 @@ class UserLoginView(APIView): password='admin') else: context['info_text'] = '' + # Add the privacy policy and legal notice, so the client can display it + # even, it is not logged in. + context['privacy_policy'] = config['general_event_privacy_policy'] + context['legal_notice'] = config['general_event_legal_notice'] else: # self.request.method == 'POST' context['user_id'] = self.user.pk diff --git a/openslides/utils/arguments.py b/openslides/utils/arguments.py index cfd0d99f3..74321fa46 100644 --- a/openslides/utils/arguments.py +++ b/openslides/utils/arguments.py @@ -1,9 +1,9 @@ from argparse import Namespace -from typing import Any, Union # noqa +from typing import Any, Optional class OpenSlidesArguments(): - args = None # type: Union[None, Namespace] + args: Optional[Namespace] = None def __getitem__(self, key: str) -> Any: if not self.args: diff --git a/openslides/utils/auth.py b/openslides/utils/auth.py index 0fe46c71e..a957907e4 100644 --- a/openslides/utils/auth.py +++ b/openslides/utils/auth.py @@ -1,12 +1,34 @@ -from typing import Optional, Union +from typing import Dict, List, Optional, Union, cast +from django.apps import apps +from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import ImproperlyConfigured from django.db.models import Model +from .cache import element_cache from .collection import CollectionElement +GROUP_DEFAULT_PK = 1 # This is the hard coded pk for the default group. +GROUP_ADMIN_PK = 2 # This is the hard coded pk for the admin group. + + +def get_group_model() -> Model: + """ + Return the Group model that is active in this project. + """ + try: + return apps.get_model(settings.AUTH_GROUP_MODEL, require_ready=False) + except ValueError: + raise ImproperlyConfigured("AUTH_GROUP_MODEL must be of the form 'app_label.model_name'") + except LookupError: + raise ImproperlyConfigured( + "AUTH_GROUP_MODEL refers to model '%s' that has not been installed" % settings.AUTH_GROUP_MODEL + ) + + def has_perm(user: Optional[CollectionElement], perm: str) -> bool: """ Checks that user has a specific permission. @@ -21,13 +43,16 @@ def has_perm(user: Optional[CollectionElement], perm: str) -> bool: if user is None and not anonymous_is_enabled(): has_perm = False elif user is None: - # Use the permissions from the default group with id 1. - default_group = CollectionElement.from_values(group_collection_string, 1) + # Use the permissions from the default group. + default_group = CollectionElement.from_values(group_collection_string, GROUP_DEFAULT_PK) has_perm = perm in default_group.get_full_data()['permissions'] + elif GROUP_ADMIN_PK in user.get_full_data()['groups_id']: + # User in admin group (pk 2) grants all permissions. + has_perm = True else: # Get all groups of the user and then see, if one group has the required - # permission. If the user has no groups, then use group 1. - group_ids = user.get_full_data()['groups_id'] or [1] + # permission. If the user has no groups, then use the default group. + group_ids = user.get_full_data()['groups_id'] or [GROUP_DEFAULT_PK] for group_id in group_ids: group = CollectionElement.from_values(group_collection_string, group_id) if perm in group.get_full_data()['permissions']: @@ -38,6 +63,42 @@ def has_perm(user: Optional[CollectionElement], perm: str) -> bool: return has_perm +def in_some_groups(user: Optional[CollectionElement], groups: List[int]) -> bool: + """ + Checks that user is in at least one given group. Groups can be given as a list + of ids or group instances. If the user is in the admin group (pk = 2) the result + is always true. + + User can be a CollectionElement of a user or None. + """ + + if len(groups) == 0: + return False # early end here, if no groups are given. + + # Convert user to right type + # TODO: Remove this and make use, that user has always the right type + user = user_to_collection_user(user) + if user is None and not anonymous_is_enabled(): + in_some_groups = False + elif user is None: + # Use the permissions from the default group. + in_some_groups = GROUP_DEFAULT_PK in groups + elif GROUP_ADMIN_PK in user.get_full_data()['groups_id']: + # User in admin group (pk 2) grants all permissions. + in_some_groups = True + else: + # Get all groups of the user and then see, if one group has the required + # permission. If the user has no groups, then use the default group. + group_ids = user.get_full_data()['groups_id'] or [GROUP_DEFAULT_PK] + for group_id in group_ids: + if group_id in groups: + in_some_groups = True + break + else: + in_some_groups = False + return in_some_groups + + def anonymous_is_enabled() -> bool: """ Returns True if the anonymous user is enabled in the settings. @@ -46,6 +107,18 @@ def anonymous_is_enabled() -> bool: return config['general_system_enable_anonymous'] +async def async_anonymous_is_enabled() -> bool: + """ + Like anonymous_is_enabled but async. + """ + from ..core.config import config + if config.key_to_id is None: + await config.build_key_to_id() + config.key_to_id = cast(Dict[str, int], config.key_to_id) + element = await element_cache.get_element_full_data(config.get_collection_string(), config.key_to_id['general_system_enable_anonymous']) + return False if element is None else element['value'] + + AnyUser = Union[Model, CollectionElement, int, AnonymousUser, None] @@ -75,7 +148,11 @@ def user_to_collection_user(user: AnyUser) -> Optional[CollectionElement]: "Unsupported type for user. Only CollectionElements for users can be" "used. Not {}".format(user.collection_string)) elif isinstance(user, int): - user = CollectionElement.from_values(User.get_collection_string(), user) + # user 0 means anonymous + if user == 0: + user = None + else: + user = CollectionElement.from_values(User.get_collection_string(), user) elif isinstance(user, AnonymousUser): user = None elif isinstance(user, User): diff --git a/openslides/utils/autoupdate.py b/openslides/utils/autoupdate.py index 5f926c954..ee5c59ae8 100644 --- a/openslides/utils/autoupdate.py +++ b/openslides/utils/autoupdate.py @@ -1,366 +1,13 @@ -import json import threading -import time -import warnings -from collections import OrderedDict, defaultdict -from typing import Any, Dict, Generator, Iterable, List, Optional, Tuple, Union +from collections import OrderedDict +from typing import Any, Dict, Iterable, List, Optional, Tuple, Union -from channels import Channel, Group -from channels.asgi import get_channel_layer -from channels.auth import channel_session_user, channel_session_user_from_http -from django.apps import apps -from django.core.exceptions import ObjectDoesNotExist -from django.db import transaction +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer from django.db.models import Model -from ..core.config import config -from ..core.models import Projector -from .auth import anonymous_is_enabled, has_perm, user_to_collection_user -from .cache import restricted_data_cache, websocket_user_cache -from .collection import AutoupdateFormat # noqa -from .collection import ( - ChannelMessageFormat, - Collection, - CollectionElement, - format_for_autoupdate, - from_channel_message, - to_channel_message, -) - - -def send_or_wait(send_func: Any, *args: Any, **kwargs: Any) -> None: - """ - Wrapper for channels' send() method. - - If the method send() raises ChannelFull exception the worker waits for 20 - milliseconds and tries again. After 5 secondes it gives up, drops the - channel message and writes a warning to stderr. - - Django channels' consumer atomicity feature is disabled. - """ - kwargs['immediately'] = True - for i in range(250): - try: - send_func(*args, **kwargs) - except get_channel_layer().ChannelFull: - time.sleep(0.02) - else: - break - else: - warnings.warn( - 'Channel layer is full. Channel message dropped.', - RuntimeWarning - ) - - -@channel_session_user_from_http -def ws_add_site(message: Any) -> None: - """ - Adds the websocket connection to a group specific to the connecting user. - - The group with the name 'user-None' stands for all anonymous users. - - Send all "startup-data" through the connection. - """ - if not anonymous_is_enabled() and not message.user.id: - send_or_wait(message.reply_channel.send, {'accept': False}) - return - - Group('site').add(message.reply_channel) - message.channel_session['user_id'] = message.user.id - # Saves the reply channel to the user. Uses 0 for anonymous users. - websocket_user_cache.add(message.user.id or 0, message.reply_channel.name) - - # Open the websocket connection. - send_or_wait(message.reply_channel.send, {'accept': True}) - - # Collect all elements that shoud be send to the client when the websocket - # connection is established. - user = user_to_collection_user(message.user.id) - user_id = user.id if user is not None else 0 - if restricted_data_cache.exists_for_user(user_id): - output = restricted_data_cache.get_data(user_id) - else: - output = [] - for collection in get_startup_collections(): - access_permissions = collection.get_access_permissions() - restricted_data = access_permissions.get_restricted_data(collection.get_full_data(), user) - - for data in restricted_data: - if data is None: - # We do not want to send 'deleted' objects on startup. - # That's why we skip such data. - continue - - formatted_data = format_for_autoupdate( - collection_string=collection.collection_string, - id=data['id'], - action='changed', - data=data) - - output.append(formatted_data) - # Cache restricted data for user - restricted_data_cache.add_element( - user_id, - collection.collection_string, - data['id'], - formatted_data) - - # Send all data. - if output: - send_or_wait(message.reply_channel.send, {'text': json.dumps(output)}) - - -@channel_session_user -def ws_disconnect_site(message: Any) -> None: - """ - This function is called, when a client on the site disconnects. - """ - Group('site').discard(message.reply_channel) - websocket_user_cache.remove(message.user.id or 0, message.reply_channel.name) - - -@channel_session_user -def ws_receive_site(message: Any) -> None: - """ - If we recieve something from the client we currently just interpret this - as a notify message. - - The server adds the sender's user id (0 for anonymous) and reply - channel name so that a receiver client may reply to the sender or to all - sender's instances. - """ - try: - incomming = json.loads(message.content['text']) - except ValueError: - # Message content is invalid. Just do nothing. - pass - else: - if isinstance(incomming, list): - notify( - incomming, - senderReplyChannelName=message.reply_channel.name, - senderUserId=message.user.id or 0) - - -def notify(incomming: List[Dict[str, Any]], **attributes: Any) -> None: - """ - The incomming should be a list of notify elements. Every item is broadcasted - to the given users, channels or projectors. If none is given, the message is - send to each site client. - """ - # Parse all items - receivers_users = defaultdict(list) # type: Dict[int, List[Any]] - receivers_projectors = defaultdict(list) # type: Dict[int, List[Any]] - receivers_reply_channels = defaultdict(list) # type: Dict[str, List[Any]] - items_for_all = [] - for item in incomming: - if item.get('collection') == 'notify': - use_receivers_dict = False - - for key, value in attributes.items(): - item[key] = value - - # Force the params to be a dict - if not isinstance(item.get('params'), dict): - item['params'] = {} - - users = item.get('users') - if isinstance(users, list): - # Send this item only to all reply channels of some site users. - for user_id in users: - receivers_users[user_id].append(item) - use_receivers_dict = True - - projectors = item.get('projectors') - if isinstance(projectors, list): - # Send this item only to all reply channels of some site users. - for projector_id in projectors: - receivers_projectors[projector_id].append(item) - use_receivers_dict = True - - reply_channels = item.get('replyChannels') - if isinstance(reply_channels, list): - # Send this item only to some reply channels. - for reply_channel_name in reply_channels: - receivers_reply_channels[reply_channel_name].append(item) - use_receivers_dict = True - - if not use_receivers_dict: - # Send this item to all reply channels. - items_for_all.append(item) - - # Send all items - for user_id, channel_names in websocket_user_cache.get_all().items(): - output = receivers_users[user_id] - if len(output) > 0: - for channel_name in channel_names: - send_or_wait(Channel(channel_name).send, {'text': json.dumps(output)}) - - for channel_name, output in receivers_reply_channels.items(): - if len(output) > 0: - send_or_wait(Channel(channel_name).send, {'text': json.dumps(output)}) - - for projector_id, output in receivers_projectors.items(): - if len(output) > 0: - send_or_wait(Group('projector-{}'.format(projector_id)).send, {'text': json.dumps(output)}) - - if len(items_for_all) > 0: - send_or_wait(Group('site').send, {'text': json.dumps(items_for_all)}) - - -@channel_session_user_from_http -def ws_add_projector(message: Any, projector_id: int) -> None: - """ - Adds the websocket connection to a group specific to the projector with the given id. - Also sends all data that are shown on the projector. - """ - user = user_to_collection_user(message.user.id) - - if not has_perm(user, 'core.can_see_projector'): - send_or_wait(message.reply_channel.send, {'text': 'No permissions to see this projector.'}) - else: - try: - projector = Projector.objects.get(pk=projector_id) - except Projector.DoesNotExist: - send_or_wait(message.reply_channel.send, {'text': 'The projector {} does not exist.'.format(projector_id)}) - else: - # At first, the client is added to the projector group, so it is - # informed if the data change. - Group('projector-{}'.format(projector_id)).add(message.reply_channel) - - # Then it is also added to the global projector group which is - # used for broadcasting data. - Group('projector-all').add(message.reply_channel) - - # Now check whether broadcast is active at the moment. If yes, - # change the local projector variable. - if config['projector_broadcast'] > 0: - projector = Projector.objects.get(pk=config['projector_broadcast']) - - # Collect all elements that are on the projector. - output = [] # type: List[AutoupdateFormat] - for requirement in projector.get_all_requirements(): - required_collection_element = CollectionElement.from_instance(requirement) - output.append(required_collection_element.as_autoupdate_for_projector()) - - # Collect all config elements. - config_collection = Collection(config.get_collection_string()) - projector_data = (config_collection.get_access_permissions() - .get_projector_data(config_collection.get_full_data())) - for data in projector_data: - output.append(format_for_autoupdate( - config_collection.collection_string, - data['id'], - 'changed', - data)) - - # Collect the projector instance. - collection_element = CollectionElement.from_instance(projector) - output.append(collection_element.as_autoupdate_for_projector()) - - # Send all the data that were only collected before. - send_or_wait(message.reply_channel.send, {'text': json.dumps(output)}) - - -def ws_disconnect_projector(message: Any, projector_id: int) -> None: - """ - This function is called, when a client on the projector disconnects. - """ - Group('projector-{}'.format(projector_id)).discard(message.reply_channel) - Group('projector-all').discard(message.reply_channel) - - -def ws_receive_projector(message: Any, projector_id: int) -> None: - """ - If we recieve something from the client we currently just interpret this - as a notify message. - - The server adds the sender's projector id and reply channel name so that - a receiver client may reply to the sender or to all sender's instances. - """ - try: - incomming = json.loads(message.content['text']) - except ValueError: - # Message content is invalid. Just do nothing. - pass - else: - if isinstance(incomming, list): - notify( - incomming, - senderReplyChannelName=message.reply_channel.name, - senderProjectorId=projector_id) - - -def send_data_projector(message: ChannelMessageFormat) -> None: - """ - Informs all projector clients about changed data. - """ - collection_elements = from_channel_message(message) - - # Check whether broadcast is active at the moment and set the local - # projector queryset. - if config['projector_broadcast'] > 0: - queryset = Projector.objects.filter(pk=config['projector_broadcast']) - else: - queryset = Projector.objects.all() - - # Loop over all projectors and send data that they need. - for projector in queryset: - output = [] - for collection_element in collection_elements: - if collection_element.is_deleted(): - output.append(collection_element.as_autoupdate_for_projector()) - else: - for element in projector.get_collection_elements_required_for_this(collection_element): - output.append(element.as_autoupdate_for_projector()) - if output: - if config['projector_broadcast'] > 0: - send_or_wait( - Group('projector-all').send, - {'text': json.dumps(output)}) - else: - send_or_wait( - Group('projector-{}'.format(projector.pk)).send, - {'text': json.dumps(output)}) - - -def send_data_site(message: ChannelMessageFormat) -> None: - """ - Informs all site users about changed data. - """ - collection_elements = from_channel_message(message) - - # Send data to site users. - for user_id, channel_names in websocket_user_cache.get_all().items(): - if not user_id: - # Anonymous user - user = None - else: - try: - user = user_to_collection_user(user_id) - except ObjectDoesNotExist: - # The user does not exist. Skip him/her. - continue - - output = [] - for collection_element in collection_elements: - formatted_data = collection_element.as_autoupdate_for_user(user) - if formatted_data['action'] == 'changed': - restricted_data_cache.update_element( - user_id or 0, - collection_element.collection_string, - collection_element.id, - formatted_data) - else: - restricted_data_cache.del_element( - user_id or 0, - collection_element.collection_string, - collection_element.id) - output.append(formatted_data) - - for channel_name in channel_names: - send_or_wait(Channel(channel_name).send, {'text': json.dumps(output)}) +from .cache import element_cache, get_element_id +from .collection import CollectionElement, to_channel_message def to_ordered_dict(d: Optional[Dict]) -> Optional[OrderedDict]: @@ -368,7 +15,7 @@ def to_ordered_dict(d: Optional[Dict]) -> Optional[OrderedDict]: Little helper to hash information dict in inform_*_data. """ if isinstance(d, dict): - result = OrderedDict([(key, to_ordered_dict(d[key])) for key in sorted(d.keys())]) # type: Optional[OrderedDict] + result: Optional[OrderedDict] = OrderedDict([(key, to_ordered_dict(d[key])) for key in sorted(d.keys())]) else: result = d return result @@ -377,7 +24,7 @@ def to_ordered_dict(d: Optional[Dict]) -> Optional[OrderedDict]: def inform_changed_data(instances: Union[Iterable[Model], Model], information: Dict[str, Any] = None) -> None: """ Informs the autoupdate system and the caching system about the creation or - update of an element. This is done via the AutoupdateBundleMiddleware. + update of an element. The argument instances can be one instance or an iterable over instances. """ @@ -392,37 +39,47 @@ def inform_changed_data(instances: Union[Iterable[Model], Model], information: D # Instance has no method get_root_rest_element. Just ignore it. pass - # Put all collection elements into the autoupdate_bundle. + collection_elements = {} + for root_instance in root_instances: + collection_element = CollectionElement.from_instance( + root_instance, + information=information) + key = root_instance.get_collection_string() + str(root_instance.get_rest_pk()) + str(to_ordered_dict(information)) + collection_elements[key] = collection_element + bundle = autoupdate_bundle.get(threading.get_ident()) if bundle is not None: - # Run autoupdate only if the bundle exists because we are in a request-response-cycle. - for root_instance in root_instances: - collection_element = CollectionElement.from_instance( - root_instance, - information=information) - key = root_instance.get_collection_string() + str(root_instance.get_rest_pk()) + str(to_ordered_dict(information)) - bundle[key] = collection_element + # Put all collection elements into the autoupdate_bundle. + bundle.update(collection_elements) + else: + # Send autoupdate directly + async_to_sync(send_autoupdate)(collection_elements.values()) def inform_deleted_data(elements: Iterable[Tuple[str, int]], information: Dict[str, Any] = None) -> None: """ Informs the autoupdate system and the caching system about the deletion of - elements. This is done via the AutoupdateBundleMiddleware. + elements. The argument information is added to each collection element. """ - # Put all stuff to be deleted into the autoupdate_bundle. + collection_elements: Dict[str, Any] = {} + for element in elements: + collection_element = CollectionElement.from_values( + collection_string=element[0], + id=element[1], + deleted=True, + information=information) + key = element[0] + str(element[1]) + str(to_ordered_dict(information)) + collection_elements[key] = collection_element + bundle = autoupdate_bundle.get(threading.get_ident()) if bundle is not None: - # Run autoupdate only if the bundle exists because we are in a request-response-cycle. - for element in elements: - collection_element = CollectionElement.from_values( - collection_string=element[0], - id=element[1], - deleted=True, - information=information) - key = element[0] + str(element[1]) + str(to_ordered_dict(information)) - bundle[key] = collection_element + # Put all collection elements into the autoupdate_bundle. + bundle.update(collection_elements) + else: + # Send autoupdate directly + async_to_sync(send_autoupdate)(collection_elements.values()) def inform_data_collection_element_list(collection_elements: List[CollectionElement], @@ -431,19 +88,24 @@ def inform_data_collection_element_list(collection_elements: List[CollectionElem Informs the autoupdate system about some collection elements. This is used just to send some data to all users. """ - # Put all stuff into the autoupdate_bundle. + elements = {} + for collection_element in collection_elements: + key = collection_element.collection_string + str(collection_element.id) + str(to_ordered_dict(information)) + elements[key] = collection_element + bundle = autoupdate_bundle.get(threading.get_ident()) if bundle is not None: - # Run autoupdate only if the bundle exists because we are in a request-response-cycle. - for collection_element in collection_elements: - key = collection_element.collection_string + str(collection_element.id) + str(to_ordered_dict(information)) - bundle[key] = collection_element + # Put all collection elements into the autoupdate_bundle. + bundle.update(elements) + else: + # Send autoupdate directly + async_to_sync(send_autoupdate)(elements.values()) """ Global container for autoupdate bundles """ -autoupdate_bundle = {} # type: Dict[int, Dict[str, CollectionElement]] +autoupdate_bundle: Dict[int, Dict[str, CollectionElement]] = {} class AutoupdateBundleMiddleware: @@ -460,42 +122,44 @@ class AutoupdateBundleMiddleware: response = self.get_response(request) - bundle = autoupdate_bundle.pop(thread_id) # type: Dict[str, CollectionElement] - # If currently there is an open database transaction, then the - # send_autoupdate function is only called, when the transaction is - # commited. If there is currently no transaction, then the function - # is called immediately. - transaction.on_commit(lambda: send_autoupdate(bundle.values())) + bundle: Dict[str, CollectionElement] = autoupdate_bundle.pop(thread_id) + async_to_sync(send_autoupdate)(bundle.values()) return response -def send_autoupdate(collection_elements: Iterable[CollectionElement]) -> None: +async def send_autoupdate(collection_elements: Iterable[CollectionElement]) -> None: """ Helper function, that sends collection_elements through a channel to the autoupdate system. + Also updates the redis cache. + Does nothing if collection_elements is empty. """ if collection_elements: - send_or_wait( - Channel('autoupdate.send_data_projector').send, - to_channel_message(collection_elements)) - send_or_wait( - Channel('autoupdate.send_data_site').send, - to_channel_message(collection_elements)) + cache_elements: Dict[str, Optional[Dict[str, Any]]] = {} + for element in collection_elements: + element_id = get_element_id(element.collection_string, element.id) + if element.is_deleted(): + cache_elements[element_id] = None + else: + cache_elements[element_id] = element.get_full_data() + change_id = await element_cache.change_elements(cache_elements) -def get_startup_collections() -> Generator[Collection, None, None]: - """ - Returns all Collections that should be send to the user at startup - """ - for app in apps.get_app_configs(): - try: - # Get the method get_startup_elements() from an app. - # This method has to return an iterable of Collection objects. - get_startup_elements = app.get_startup_elements - except AttributeError: - # Skip apps that do not implement get_startup_elements. - continue - - yield from get_startup_elements() + channel_layer = get_channel_layer() + # TODO: don't await. They can be send in parallel + await channel_layer.group_send( + "projector", + { + "type": "send_data", + "message": to_channel_message(collection_elements), + }, + ) + await channel_layer.group_send( + "site", + { + "type": "send_data", + "change_id": change_id, + }, + ) diff --git a/openslides/utils/cache.py b/openslides/utils/cache.py index a69447caa..ce25e2e5f 100644 --- a/openslides/utils/cache.py +++ b/openslides/utils/cache.py @@ -1,506 +1,411 @@ +import asyncio import json from collections import defaultdict -from typing import ( # noqa +from datetime import datetime +from time import sleep +from typing import ( TYPE_CHECKING, Any, Callable, Dict, - Generator, - Iterable, List, Optional, - Set, + Tuple, Type, - Union, ) -from channels import Group -from channels.sessions import session_for_reply_channel +from asgiref.sync import async_to_sync, sync_to_async from django.conf import settings -from django.core.cache import cache, caches + +from .cache_providers import ( + BaseCacheProvider, + Cachable, + MemmoryCacheProvider, + RedisCacheProvider, + get_all_cachables, + no_redis_dependency, +) +from .utils import get_element_id, get_user_id, split_element_id + if TYPE_CHECKING: - # Dummy import Collection for mypy - from .collection import Collection # noqa - -UserCacheDataType = Dict[int, Set[str]] + # Dummy import Collection for mypy, can be fixed with python 3.7 + from .collection import CollectionElement # noqa -class BaseWebsocketUserCache: +class ElementCache: """ - Caches the reply channel names of all open websocket connections. The id of - the user that that opened the connection is used as reference. + Cache for the CollectionElements. - This is the Base cache that has to be overriden. - """ - cache_key = 'current_websocket_users' + Saves the full_data and if enabled the restricted data. - def add(self, user_id: int, channel_name: str) -> None: - """ - Adds a channel name to an user id. - """ - raise NotImplementedError() + There is one redis Hash (simular to python dict) for the full_data and one + Hash for every user. - def remove(self, user_id: int, channel_name: str) -> None: - """ - Removes one channel name from the cache. - """ - raise NotImplementedError() + The key of the Hashes is COLLECTIONSTRING:ID where COLLECTIONSTRING is the + collection_string of a collection and id the id of an element. - def get_all(self) -> UserCacheDataType: - """ - Returns all data using a dict where the key is a user id and the value - is a set of channel_names. - """ - raise NotImplementedError() + All elements have to be in the cache. If one element is missing, the cache + is invalid, but this can not be detected. When a plugin with a new + collection is added to OpenSlides, then the cache has to be rebuild manualy. - def save_data(self, data: UserCacheDataType) -> None: - """ - Saves the full data set (like created with build_data) to the cache. - """ - raise NotImplementedError() + There is an sorted set in redis with the change id as score. The values are + COLLETIONSTRING:ID for the elements that have been changed with that change + id. With this key it is possible, to get all elements as full_data or as + restricted_data that are newer then a specific change id. - def build_data(self) -> UserCacheDataType: - """ - Creates all the data, saves it to the cache and returns it. - """ - websocket_user_ids = defaultdict(set) # type: UserCacheDataType - for channel_name in Group('site').channel_layer.group_channels('site'): - session = session_for_reply_channel(channel_name) - user_id = session.get('user_id', None) - websocket_user_ids[user_id or 0].add(channel_name) - self.save_data(websocket_user_ids) - return websocket_user_ids - - def get_cache_key(self) -> str: - """ - Returns the cache key. - """ - return self.cache_key - - -class RedisWebsocketUserCache(BaseWebsocketUserCache): - """ - Implementation of the WebsocketUserCache that uses redis. - - This uses one cache key to store all connected user ids in a set and - for each user another set to save the channel names. + All method of this class are async. You either have to call them with + await in an async environment or use asgiref.sync.async_to_sync(). """ - def add(self, user_id: int, channel_name: str) -> None: + def __init__( + self, + redis: str, + use_restricted_data_cache: bool = False, + cache_provider_class: Type[BaseCacheProvider] = RedisCacheProvider, + cachable_provider: Callable[[], List[Cachable]] = get_all_cachables, + start_time: int = None) -> None: """ - Adds a channel name to an user id. + Initializes the cache. + + When restricted_data_cache is false, no restricted data is saved. """ - redis = get_redis_connection() - pipe = redis.pipeline() - pipe.sadd(self.get_cache_key(), user_id) - pipe.sadd(self.get_user_cache_key(user_id), channel_name) - pipe.execute() + self.use_restricted_data_cache = use_restricted_data_cache + self.cache_provider = cache_provider_class(redis) + self.cachable_provider = cachable_provider + self._cachables: Optional[Dict[str, Cachable]] = None - def remove(self, user_id: int, channel_name: str) -> None: + # Start time is used as first change_id if there is non in redis + if start_time is None: + start_time = int((datetime.utcnow() - datetime(1970, 1, 1)).total_seconds()) + self.start_time = start_time + + # Contains Futures to controll, that only one client updates the restricted_data. + self.restricted_data_cache_updater: Dict[int, asyncio.Future] = {} + + # Tells if self.ensure_cache was called. + self.ensured = False + + @property + def cachables(self) -> Dict[str, Cachable]: """ - Removes one channel name from the cache. + Returns all Cachables as a dict where the key is the collection_string of the cachable. """ - redis = get_redis_connection() - redis.srem(self.get_user_cache_key(user_id), channel_name) + # This method is neccessary to lazy load the cachables + if self._cachables is None: + self._cachables = {cachable.get_collection_string(): cachable for cachable in self.cachable_provider()} + return self._cachables - def get_all(self) -> UserCacheDataType: + def ensure_cache(self, reset: bool = False) -> None: """ - Returns all data using a dict where the key is a user id and the value - is a set of channel_names. + Makes sure that the cache exist. + + Builds the cache if not. If reset is True, it will be reset in any case. + + This method is sync, so it can be run when OpenSlides starts. """ - redis = get_redis_connection() - user_ids = redis.smembers(self.get_cache_key()) # type: Optional[List[str]] - if user_ids is None: - websocket_user_ids = self.build_data() - else: - websocket_user_ids = dict() - for redis_user_id in user_ids: - # Redis returns the id as string. So we have to convert it - user_id = int(redis_user_id) - channel_names = redis.smembers(self.get_user_cache_key(user_id)) # type: Optional[List[str]] - if channel_names is not None: - # If channel name is empty, then we can assume, that the user - # has no active connection. - websocket_user_ids[user_id] = set(channel_names) - return websocket_user_ids + cache_exists = async_to_sync(self.cache_provider.data_exists)() - def save_data(self, data: UserCacheDataType) -> None: + if reset or not cache_exists: + lock_name = 'ensure_cache' + # Set a lock so only one process builds the cache + if async_to_sync(self.cache_provider.set_lock)(lock_name): + try: + mapping = {} + for collection_string, cachable in self.cachables.items(): + for element in cachable.get_elements(): + mapping.update( + {get_element_id(collection_string, element['id']): + json.dumps(element)}) + async_to_sync(self.cache_provider.reset_full_cache)(mapping) + finally: + async_to_sync(self.cache_provider.del_lock)(lock_name) + else: + while async_to_sync(self.cache_provider.get_lock)(lock_name): + sleep(0.01) + + self.ensured = True + + async def change_elements( + self, elements: Dict[str, Optional[Dict[str, Any]]]) -> int: """ - Saves the full data set (like created with the method build_data()) to - the cache. + Changes elements in the cache. + + elements is a list of the changed elements as dict. When the value is None, + it is interpreded as deleted. The key has to be an element_id. + + Returns the new generated change_id. """ - redis = get_redis_connection() - pipe = redis.pipeline() + deleted_elements = [] + changed_elements = [] + for element_id, data in elements.items(): + if data: + # The arguments for redis.hset is pairs of key value + changed_elements.append(element_id) + changed_elements.append(json.dumps(data)) + else: + deleted_elements.append(element_id) - # Save all user ids - pipe.delete(self.get_cache_key()) - pipe.sadd(self.get_cache_key(), *data.keys()) + if changed_elements: + await self.cache_provider.add_elements(changed_elements) + if deleted_elements: + await self.cache_provider.del_elements(deleted_elements) - for user_id, channel_names in data.items(): - pipe.delete(self.get_user_cache_key(user_id)) - pipe.sadd(self.get_user_cache_key(user_id), *channel_names) - pipe.execute() + # TODO: The provider has to define the new change_id with lua. In other + # case it is possible, that two changes get the same id (which + # would not be a big problem). + change_id = await self.get_next_change_id() - def get_cache_key(self) -> str: + await self.cache_provider.add_changed_elements(change_id, elements.keys()) + return change_id + + async def get_all_full_data(self) -> Dict[str, List[Dict[str, Any]]]: """ - Returns the cache key. + Returns all full_data. If it does not exist, it is created. + + The returned value is a dict where the key is the collection_string and + the value is a list of data. """ - return cache.make_key(self.cache_key) + out: Dict[str, List[Dict[str, Any]]] = defaultdict(list) + full_data = await self.cache_provider.get_all_data() + for element_id, data in full_data.items(): + collection_string, __ = split_element_id(element_id) + out[collection_string].append(json.loads(data.decode())) + return dict(out) - def get_user_cache_key(self, user_id: int) -> str: + async def get_full_data( + self, change_id: int = 0) -> Tuple[Dict[str, List[Dict[str, Any]]], List[str]]: """ - Returns a cache key to save the channel names for a specific user. + Returns all full_data since change_id. If it does not exist, it is created. + + Returns two values inside a tuple. The first value is a dict where the + key is the collection_string and the value is a list of data. The second + is a list of element_ids with deleted elements. + + Only returns elements with the change_id or newer. When change_id is 0, + all elements are returned. + + Raises a RuntimeError when the lowest change_id in redis is higher then + the requested change_id. In this case the method has to be rerun with + change_id=0. This is importend because there could be deleted elements + that the cache does not know about. """ - return cache.make_key('{}:{}'.format(self.cache_key, user_id)) + if change_id == 0: + return (await self.get_all_full_data(), []) + lowest_change_id = await self.get_lowest_change_id() + if change_id < lowest_change_id: + # When change_id is lower then the lowest change_id in redis, we can + # not inform the user about deleted elements. + raise RuntimeError( + "change_id {} is lower then the lowest change_id in redis {}. " + "Catch this exception and rerun the method with change_id=0." + .format(change_id, lowest_change_id)) -class DjangoCacheWebsocketUserCache(BaseWebsocketUserCache): - """ - Implementation of the WebsocketUserCache that uses the django cache. + raw_changed_elements, deleted_elements = await self.cache_provider.get_data_since(change_id) + return ( + {collection_string: [json.loads(value.decode()) for value in value_list] + for collection_string, value_list in raw_changed_elements.items()}, + deleted_elements) - If you use this with the inmemory cache, then you should only use one - worker. - - This uses only one cache key to save a dict where the key is the user id and - the value is a set of channel names. - """ - - def add(self, user_id: int, channel_name: str) -> None: + async def get_element_full_data(self, collection_string: str, id: int) -> Optional[Dict[str, Any]]: """ - Adds a channel name for a user using the django cache. + Returns one element as full data. + + If the cache is empty, it is created. + + Returns None if the element does not exist. """ - websocket_user_ids = cache.get(self.get_cache_key()) - if websocket_user_ids is None: - websocket_user_ids = dict() + element = await self.cache_provider.get_element(get_element_id(collection_string, id)) - if user_id in websocket_user_ids: - websocket_user_ids[user_id].add(channel_name) - else: - websocket_user_ids[user_id] = set([channel_name]) - cache.set(self.get_cache_key(), websocket_user_ids) - - def remove(self, user_id: int, channel_name: str) -> None: - """ - Removes one channel name from the django cache. - """ - websocket_user_ids = cache.get(self.get_cache_key()) - if websocket_user_ids is not None and user_id in websocket_user_ids: - websocket_user_ids[user_id].discard(channel_name) - cache.set(self.get_cache_key(), websocket_user_ids) - - def get_all(self) -> UserCacheDataType: - """ - Returns the data using the django cache. - """ - websocket_user_ids = cache.get(self.get_cache_key()) - if websocket_user_ids is None: - return self.build_data() - return websocket_user_ids - - def save_data(self, data: UserCacheDataType) -> None: - """ - Saves the data using the django cache. - """ - cache.set(self.get_cache_key(), data) - - -class FullDataCache: - """ - Caches all data as full data. - - Helps to get all data from one collection. - """ - - base_cache_key = 'full_data_cache' - - def build_for_collection(self, collection_string: str) -> None: - """ - Build the cache for collection from a django model. - - Rebuilds the cache for that collection, if it already exists. - """ - redis = get_redis_connection() - pipe = redis.pipeline() - - # Clear the cache for collection - pipe.delete(self.get_cache_key(collection_string)) - - # Save all elements - from .collection import get_model_from_collection_string - model = get_model_from_collection_string(collection_string) - try: - query = model.objects.get_full_queryset() - except AttributeError: - # If the model des not have to method get_full_queryset(), then use - # the default queryset from django. - query = model.objects - - # Build a dict from the instance id to the full_data - mapping = {instance.pk: json.dumps(model.get_access_permissions().get_full_data(instance)) - for instance in query.all()} - - if mapping: - # Save the dict into a redis map, if there is at least one value - pipe.hmset( - self.get_cache_key(collection_string), - mapping) - - pipe.execute() - - def add_element(self, collection_string: str, id: int, data: Dict[str, Any]) -> None: - """ - Adds one element to the cache. If the cache does not exists for the collection, - it is created. - """ - redis = get_redis_connection() - - # If the cache does not exist for the collection, then create it first. - if not self.exists_for_collection(collection_string): - self.build_for_collection(collection_string) - - redis.hset( - self.get_cache_key(collection_string), - id, - json.dumps(data)) - - def del_element(self, collection_string: str, id: int) -> None: - """ - Removes one element from the cache. - - Does nothing if the cache does not exist. - """ - redis = get_redis_connection() - redis.hdel( - self.get_cache_key(collection_string), - id) - - def exists_for_collection(self, collection_string: str) -> bool: - """ - Returns True if the cache for the collection exists, else False. - """ - redis = get_redis_connection() - return redis.exists(self.get_cache_key(collection_string)) - - def get_data(self, collection_string: str) -> List[Dict[str, Any]]: - """ - Returns all data for the collection. - """ - redis = get_redis_connection() - return [json.loads(element.decode()) for element in redis.hvals(self.get_cache_key(collection_string))] - - def get_element(self, collection_string: str, id: int) -> Dict[str, Any]: - """ - Returns one element from the collection. - - Raises model.DoesNotExist if the element is not in the cache. - """ - redis = get_redis_connection() - element = redis.hget(self.get_cache_key(collection_string), id) if element is None: - from .collection import get_model_from_collection_string - model = get_model_from_collection_string(collection_string) - raise model.DoesNotExist(collection_string, id) + return None return json.loads(element.decode()) - def get_cache_key(self, collection_string: str) -> str: + async def exists_restricted_data(self, user: Optional['CollectionElement']) -> bool: """ - Returns the cache key for a collection. + Returns True, if the restricted_data exists for the user. """ - return cache.make_key('{}:{}'.format(self.base_cache_key, collection_string)) + if not self.use_restricted_data_cache: + return False + + return await self.cache_provider.data_exists(get_user_id(user)) + + async def del_user(self, user: Optional['CollectionElement']) -> None: + """ + Removes one user from the resticted_data_cache. + """ + await self.cache_provider.del_restricted_data(get_user_id(user)) + + async def update_restricted_data( + self, user: Optional['CollectionElement']) -> None: + """ + Updates the restricted data for an user from the full_data_cache. + """ + # TODO: When elements are changed at the same time then this method run + # this could make the cache invalid. + # This could be fixed when get_full_data would be used with a + # max change_id. + if not self.use_restricted_data_cache: + # If the restricted_data_cache is not used, there is nothing to do + return + + # Try to write a special key. + # If this succeeds, there is noone else currently updating the cache. + # TODO: Make a timeout. Else this could block forever + lock_name = "restricted_data_{}".format(get_user_id(user)) + if await self.cache_provider.set_lock(lock_name): + future: asyncio.Future = asyncio.Future() + self.restricted_data_cache_updater[get_user_id(user)] = future + # Get change_id for this user + value = await self.cache_provider.get_change_id_user(get_user_id(user)) + # If the change id is not in the cache yet, use -1 to get all data since 0 + user_change_id = int(value) if value else -1 + change_id = await self.get_current_change_id() + if change_id > user_change_id: + try: + full_data_elements, deleted_elements = await self.get_full_data(user_change_id + 1) + except RuntimeError: + # The user_change_id is lower then the lowest change_id in the cache. + # The whole restricted_data for that user has to be recreated. + full_data_elements = await self.get_all_full_data() + await self.cache_provider.del_restricted_data(get_user_id(user)) + else: + # Remove deleted elements + if deleted_elements: + await self.cache_provider.del_elements(deleted_elements, get_user_id(user)) + + mapping = {} + for collection_string, full_data in full_data_elements.items(): + restricter = self.cachables[collection_string].restrict_elements + elements = await sync_to_async(restricter)(user, full_data) + for element in elements: + mapping.update( + {get_element_id(collection_string, element['id']): + json.dumps(element)}) + mapping['_config:change_id'] = str(change_id) + await self.cache_provider.update_restricted_data(get_user_id(user), mapping) + # Unset the lock + await self.cache_provider.del_lock(lock_name) + future.set_result(1) + else: + # Wait until the update if finshed + if get_user_id(user) in self.restricted_data_cache_updater: + # The active worker is on the same asgi server, we can use the future + await self.restricted_data_cache_updater[get_user_id(user)] + else: + while await self.cache_provider.get_lock(lock_name): + await asyncio.sleep(0.01) + + async def get_all_restricted_data(self, user: Optional['CollectionElement']) -> Dict[str, List[Dict[str, Any]]]: + """ + Like get_all_full_data but with restricted_data for an user. + """ + if not self.use_restricted_data_cache: + all_restricted_data = {} + for collection_string, full_data in (await self.get_all_full_data()).items(): + restricter = self.cachables[collection_string].restrict_elements + elements = await sync_to_async(restricter)(user, full_data) + all_restricted_data[collection_string] = elements + return all_restricted_data + + await self.update_restricted_data(user) + + out: Dict[str, List[Dict[str, Any]]] = defaultdict(list) + restricted_data = await self.cache_provider.get_all_data(get_user_id(user)) + for element_id, data in restricted_data.items(): + if element_id.decode().startswith('_config'): + continue + collection_string, __ = split_element_id(element_id) + out[collection_string].append(json.loads(data.decode())) + return dict(out) + + async def get_restricted_data( + self, + user: Optional['CollectionElement'], + change_id: int = 0) -> Tuple[Dict[str, List[Dict[str, Any]]], List[str]]: + """ + Like get_full_data but with restricted_data for an user. + """ + if change_id == 0: + # Return all data + return (await self.get_all_restricted_data(user), []) + + if not self.use_restricted_data_cache: + changed_elements, deleted_elements = await self.get_full_data(change_id) + restricted_data = {} + for collection_string, full_data in changed_elements.items(): + restricter = self.cachables[collection_string].restrict_elements + elements = await sync_to_async(restricter)(user, full_data) + restricted_data[collection_string] = elements + return restricted_data, deleted_elements + + lowest_change_id = await self.get_lowest_change_id() + if change_id < lowest_change_id: + # When change_id is lower then the lowest change_id in redis, we can + # not inform the user about deleted elements. + raise RuntimeError( + "change_id {} is lower then the lowest change_id in redis {}. " + "Catch this exception and rerun the method with change_id=0." + .format(change_id, lowest_change_id)) + + # If another coroutine or another daphne server also updates the restricted + # data, this waits until it is done. + await self.update_restricted_data(user) + + raw_changed_elements, deleted_elements = await self.cache_provider.get_data_since(change_id, get_user_id(user)) + return ( + {collection_string: [json.loads(value.decode()) for value in value_list] + for collection_string, value_list in raw_changed_elements.items()}, + deleted_elements) + + async def get_current_change_id(self) -> int: + """ + Returns the current change id. + + Returns start_time if there is no change id yet. + """ + value = await self.cache_provider.get_current_change_id() + if not value: + return self.start_time + # Return the score (second element) of the first (and only) element + return value[0][1] + + async def get_next_change_id(self) -> int: + """ + Returns the next change_id. + + Returns the start time in seconds + 1, if there is no change_id in yet. + """ + current_id = await self.get_current_change_id() + return current_id + 1 + + async def get_lowest_change_id(self) -> int: + """ + Returns the lowest change id. + + Raises a RuntimeError if there is no change_id. + """ + value = await self.cache_provider.get_lowest_change_id() + if not value: + raise RuntimeError('There is no known change_id.') + # Return the score (second element) of the first (and only) element + return value -class DummyFullDataCache: +def load_element_cache(redis_addr: str = '', restricted_data: bool = True) -> ElementCache: """ - Dummy FullDataCache that does nothing. + Generates an element cache instance. """ - def build_for_collection(self, collection_string: str) -> None: - pass + if not redis_addr: + return ElementCache(redis='', cache_provider_class=MemmoryCacheProvider) - def add_element(self, collection_string: str, id: int, data: Dict[str, Any]) -> None: - pass - - def del_element(self, collection_string: str, id: int) -> None: - pass - - def exists_for_collection(self, collection_string: str) -> bool: - return False - - def get_data(self, collection_string: str) -> List[Dict[str, Any]]: - from .collection import get_model_from_collection_string - model = get_model_from_collection_string(collection_string) - try: - query = model.objects.get_full_queryset() - except AttributeError: - # If the model des not have to method get_full_queryset(), then use - # the default queryset from django. - query = model.objects - - return [model.get_access_permissions().get_full_data(instance) - for instance in query.all()] - - def get_element(self, collection_string: str, id: int) -> Dict[str, Any]: - from .collection import get_model_from_collection_string - model = get_model_from_collection_string(collection_string) - try: - query = model.objects.get_full_queryset() - except AttributeError: - # If the model des not have to method get_full_queryset(), then use - # the default queryset from django. - query = model.objects - - return model.get_access_permissions().get_full_data(query.get(pk=id)) + if no_redis_dependency: + raise ImportError("OpenSlides is configured to use redis as cache backend, but aioredis is not installed.") + return ElementCache(redis=redis_addr, use_restricted_data_cache=restricted_data) -class RestrictedDataCache: - """ - Caches all data for a specific users. - - Helps to get all data from all collections for a specific user. - - The cached values are expected to be formatted for outout via websocket. - """ - - base_cache_key = 'restricted_user_cache' - - def update_element(self, user_id: int, collection_string: str, id: int, data: object) -> None: - """ - Adds on element to the cache only if the cache exists for the user. - - Note: This method is not atomic. So in very rare cases it is possible - that the restricted date cache can become corrupt. The best solution would be to - use a lua script instead. See also #3427. - """ - if self.exists_for_user(user_id): - self.add_element(user_id, collection_string, id, data) - - def add_element(self, user_id: int, collection_string: str, id: int, data: object) -> None: - """ - Adds one element to the cache. If the cache does not exists for the user, - it is created. - """ - redis = get_redis_connection() - redis.hset( - self.get_cache_key(user_id), - "{}/{}".format(collection_string, id), - json.dumps(data)) - - def del_element(self, user_id: int, collection_string: str, id: int) -> None: - """ - Removes one element from the cache. - - Does nothing if the cache does not exist. - """ - redis = get_redis_connection() - redis.hdel( - self.get_cache_key(user_id), - "{}/{}".format(collection_string, id)) - - def del_user(self, user_id: int) -> None: - """ - Removes all elements for one user from the cache. - """ - redis = get_redis_connection() - redis.delete(self.get_cache_key(user_id)) - - def del_all(self) -> None: - """ - Deletes all elements from the cache. - - This method uses the redis command SCAN. See - https://redis.io/commands/scan#scan-guarantees for its limitations. If - an element is added to the cache while del_all() is in process, it is - possible, that it is not deleted. - """ - redis = get_redis_connection() - - # Get all keys that start with self.base_cache_key and delete them - match = cache.make_key('{}:*'.format(self.base_cache_key)) - cursor = 0 - while True: - cursor, keys = redis.scan(cursor, match) - for key in keys: - redis.delete(key) - if cursor == 0: - return - - def exists_for_user(self, user_id: int) -> bool: - """ - Returns True if the cache for the user exists, else False. - """ - redis = get_redis_connection() - return redis.exists(self.get_cache_key(user_id)) - - def get_data(self, user_id: int) -> List[object]: - """ - Returns all data for the user. - - The returned value is a list of the elements. - """ - redis = get_redis_connection() - return [json.loads(element.decode()) for element in redis.hvals(self.get_cache_key(user_id))] - - def get_cache_key(self, user_id: int) -> str: - """ - Returns the cache key for a user. - """ - return cache.make_key('{}:{}'.format(self.base_cache_key, user_id)) - - -class DummyRestrictedDataCache: - """ - Dummy RestrictedDataCache that does nothing. - """ - - def update_element(self, user_id: int, collection_string: str, id: int, data: object) -> None: - pass - - def add_element(self, user_id: int, collection_string: str, id: int, data: object) -> None: - pass - - def del_element(self, user_id: int, collection_string: str, id: int) -> None: - pass - - def del_user(self, user_id: int) -> None: - pass - - def del_all(self) -> None: - pass - - def exists_for_user(self, user_id: int) -> bool: - return False - - def get_data(self, user_id: int) -> List[object]: - pass - - -def use_redis_cache() -> bool: - """ - Returns True if Redis is used als caching backend. - """ - try: - from django_redis.cache import RedisCache - except ImportError: - return False - return isinstance(caches['default'], RedisCache) - - -def get_redis_connection() -> Any: - """ - Returns an object that can be used to talk directly to redis. - """ - from django_redis import get_redis_connection - return get_redis_connection("default") - - -if use_redis_cache(): - websocket_user_cache = RedisWebsocketUserCache() # type: BaseWebsocketUserCache - if settings.DISABLE_USER_CACHE: - restricted_data_cache = DummyRestrictedDataCache() # type: Union[RestrictedDataCache, DummyRestrictedDataCache] - else: - restricted_data_cache = RestrictedDataCache() - full_data_cache = FullDataCache() # type: Union[FullDataCache, DummyFullDataCache] -else: - websocket_user_cache = DjangoCacheWebsocketUserCache() - restricted_data_cache = DummyRestrictedDataCache() - full_data_cache = DummyFullDataCache() +# Set the element_cache +redis_address = getattr(settings, 'REDIS_ADDRESS', '') +use_restricted_data = getattr(settings, 'RESTRICTED_DATA_CACHE', True) +element_cache = load_element_cache(redis_addr=redis_address, restricted_data=use_restricted_data) diff --git a/openslides/utils/cache_providers.py b/openslides/utils/cache_providers.py new file mode 100644 index 000000000..5aba38741 --- /dev/null +++ b/openslides/utils/cache_providers.py @@ -0,0 +1,532 @@ +from collections import defaultdict +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Generator, + Iterable, + List, + Optional, + Set, + Tuple, + Union, +) + +from django.apps import apps + +from .utils import split_element_id, str_dict_to_bytes + + +if TYPE_CHECKING: + # Dummy import Collection for mypy, can be fixed with python 3.7 + from .collection import CollectionElement # noqa + +try: + import aioredis +except ImportError: + no_redis_dependency = True +else: + no_redis_dependency = False + + +class BaseCacheProvider: + """ + Base class for cache provider. + + See RedisCacheProvider as reverence implementation. + """ + full_data_cache_key = 'full_data_cache' + restricted_user_cache_key = 'restricted_data_cache:{user_id}' + change_id_cache_key = 'change_id_cache' + lock_key = '_config:updating' + + def __init__(self, *args: Any) -> None: + pass + + def get_full_data_cache_key(self) -> str: + return self.full_data_cache_key + + def get_restricted_data_cache_key(self, user_id: int) -> str: + return self.restricted_user_cache_key.format(user_id=user_id) + + def get_change_id_cache_key(self) -> str: + return self.change_id_cache_key + + async def clear_cache(self) -> None: + raise NotImplementedError("CacheProvider has to implement the method clear_cache().") + + async def reset_full_cache(self, data: Dict[str, str]) -> None: + raise NotImplementedError("CacheProvider has to implement the method reset_full_cache().") + + async def data_exists(self, user_id: Optional[int] = None) -> bool: + raise NotImplementedError("CacheProvider has to implement the method exists_full_data().") + + async def add_elements(self, elements: List[str]) -> None: + raise NotImplementedError("CacheProvider has to implement the method add_elements().") + + async def del_elements(self, elements: List[str], user_id: Optional[int] = None) -> None: + raise NotImplementedError("CacheProvider has to implement the method del_elements().") + + async def add_changed_elements(self, change_id: int, element_ids: Iterable[str]) -> None: + raise NotImplementedError("CacheProvider has to implement the method add_changed_elements().") + + async def get_all_data(self, user_id: Optional[int] = None) -> Dict[bytes, bytes]: + raise NotImplementedError("CacheProvider has to implement the method get_all_data().") + + async def get_data_since(self, change_id: int, user_id: Optional[int] = None) -> Tuple[Dict[str, List[bytes]], List[str]]: + raise NotImplementedError("CacheProvider has to implement the method get_data_since().") + + async def get_element(self, element_id: str) -> Optional[bytes]: + raise NotImplementedError("CacheProvider has to implement the method get_element().") + + async def del_restricted_data(self, user_id: int) -> None: + raise NotImplementedError("CacheProvider has to implement the method del_restricted_data().") + + async def set_lock(self, lock_name: str) -> bool: + raise NotImplementedError("CacheProvider has to implement the method set_lock().") + + async def get_lock(self, lock_name: str) -> bool: + raise NotImplementedError("CacheProvider has to implement the method get_lock().") + + async def del_lock(self, lock_name: str) -> None: + raise NotImplementedError("CacheProvider has to implement the method del_lock().") + + async def get_change_id_user(self, user_id: int) -> Optional[int]: + raise NotImplementedError("CacheProvider has to implement the method get_change_id_user().") + + async def update_restricted_data(self, user_id: int, data: Dict[str, str]) -> None: + raise NotImplementedError("CacheProvider has to implement the method update_restricted_data().") + + async def get_current_change_id(self) -> List[Tuple[str, int]]: + raise NotImplementedError("CacheProvider has to implement the method get_current_change_id().") + + async def get_lowest_change_id(self) -> Optional[int]: + raise NotImplementedError("CacheProvider has to implement the method get_lowest_change_id().") + + +class RedisConnectionContextManager: + """ + Async context manager for connections + """ + # TODO: contextlib.asynccontextmanager can be used in python 3.7 + + def __init__(self, redis_address: str) -> None: + self.redis_address = redis_address + + async def __aenter__(self) -> 'aioredis.RedisConnection': + self.conn = await aioredis.create_redis(self.redis_address) + return self.conn + + async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + self.conn.close() + + +class RedisCacheProvider(BaseCacheProvider): + """ + Cache provider that loads and saves the data to redis. + """ + redis_pool: Optional[aioredis.RedisConnection] = None + + def __init__(self, redis: str) -> None: + self.redis_address = redis + + def get_connection(self) -> RedisConnectionContextManager: + """ + Returns contextmanager for a redis connection. + """ + return RedisConnectionContextManager(self.redis_address) + + async def clear_cache(self) -> None: + """ + Deleted all cache entries created with this element cache. + """ + async with self.get_connection() as redis: + # TODO: Fix me. Do only delete keys, that are created with this cache. + await redis.flushall() + + async def reset_full_cache(self, data: Dict[str, str]) -> None: + """ + Deletes the cache and write new data in it. + """ + # TODO: lua or transaction + async with self.get_connection() as redis: + await redis.delete(self.get_full_data_cache_key()) + await redis.hmset_dict(self.get_full_data_cache_key(), data) + + async def data_exists(self, user_id: Optional[int] = None) -> bool: + """ + Returns True, when there is data in the cache. + + If user_id is None, the method tests for full_data. If user_id is an int, it tests + for the restricted_data_cache for the user with the user_id. 0 is for anonymous. + """ + async with self.get_connection() as redis: + if user_id is None: + cache_key = self.get_full_data_cache_key() + else: + cache_key = self.get_restricted_data_cache_key(user_id) + return await redis.exists(cache_key) + + async def add_elements(self, elements: List[str]) -> None: + """ + Add or change elements to the cache. + + elements is a list with an even len. the odd values are the element_ids and the even + values are the elements. The elements have to be encoded, for example with json. + """ + async with self.get_connection() as redis: + await redis.hmset( + self.get_full_data_cache_key(), + *elements) + + async def del_elements(self, elements: List[str], user_id: Optional[int] = None) -> None: + """ + Deletes elements from the cache. + + elements has to be a list of element_ids. + + If user_id is None, the elements are deleted from the full_data cache. If user_id is an + int, the elements are deleted one restricted_data_cache. 0 is for anonymous. + """ + async with self.get_connection() as redis: + if user_id is None: + cache_key = self.get_full_data_cache_key() + else: + cache_key = self.get_restricted_data_cache_key(user_id) + await redis.hdel( + cache_key, + *elements) + + async def add_changed_elements(self, change_id: int, element_ids: Iterable[str]) -> None: + """ + Saves which elements are change with a change_id. + + args has to be an even iterable. The odd values have to be a change id (int) and the + even values have to be element_ids. + """ + def zadd_args(change_id: int) -> Generator[Union[int, str], None, None]: + """ + Small helper to generates the arguments for the redis command zadd. + """ + for element_id in element_ids: + yield change_id + yield element_id + + async with self.get_connection() as redis: + await redis.zadd(self.get_change_id_cache_key(), *zadd_args(change_id)) + # Saves the lowest_change_id if it does not exist + await redis.zadd(self.get_change_id_cache_key(), change_id, '_config:lowest_change_id', exist='ZSET_IF_NOT_EXIST') + + async def get_all_data(self, user_id: Optional[int] = None) -> Dict[bytes, bytes]: + """ + Returns all data from a cache. + + if user_id is None, then the data is returned from the full_data_cache. If it is and + int, it is returned from a restricted_data_cache. 0 is for anonymous. + """ + if user_id is None: + cache_key = self.get_full_data_cache_key() + else: + cache_key = self.get_restricted_data_cache_key(user_id) + async with self.get_connection() as redis: + return await redis.hgetall(cache_key) + + async def get_element(self, element_id: str) -> Optional[bytes]: + """ + Returns one element from the full_data_cache. + + Returns None, when the element does not exist. + """ + async with self.get_connection() as redis: + return await redis.hget( + self.get_full_data_cache_key(), + element_id) + + async def get_data_since(self, change_id: int, user_id: Optional[int] = None) -> Tuple[Dict[str, List[bytes]], List[str]]: + """ + Returns all elements since a change_id. + + The returend value is a two element tuple. The first value is a dict the elements where + the key is the collection_string and the value a list of (json-) encoded elements. The + second element is a list of element_ids, that have been deleted since the change_id. + + if user_id is None, the full_data is returned. If user_id is an int, the restricted_data + for an user is used. 0 is for the anonymous user. + """ + # TODO: rewrite with lua to get all elements with one request + async with self.get_connection() as redis: + changed_elements: Dict[str, List[bytes]] = defaultdict(list) + deleted_elements: List[str] = [] + for element_id in await redis.zrangebyscore(self.get_change_id_cache_key(), min=change_id): + if element_id.startswith(b'_config'): + continue + element_json = await redis.hget(self.get_full_data_cache_key(), element_id) # Optional[bytes] + if element_json is None: + # The element is not in the cache. It has to be deleted. + deleted_elements.append(element_id) + else: + collection_string, id = split_element_id(element_id) + changed_elements[collection_string].append(element_json) + return changed_elements, deleted_elements + + async def del_restricted_data(self, user_id: int) -> None: + """ + Deletes all restricted_data for an user. 0 is for the anonymous user. + """ + async with self.get_connection() as redis: + await redis.delete(self.get_restricted_data_cache_key(user_id)) + + async def set_lock(self, lock_name: str) -> bool: + """ + Tries to sets a lock. + + Returns True when the lock could be set. + + Returns False when the lock was already set. + """ + async with self.get_connection() as redis: + return await redis.hsetnx("lock_{}".format(lock_name), self.lock_key, 1) + + async def get_lock(self, lock_name: str) -> bool: + """ + Returns True, when the lock for the restricted_data of an user is set. Else False. + """ + async with self.get_connection() as redis: + return await redis.hget("lock_{}".format(lock_name), self.lock_key) + + async def del_lock(self, lock_name: str) -> None: + """ + Deletes the lock for the restricted_data of an user. Does nothing when the + lock is not set. + """ + async with self.get_connection() as redis: + await redis.hdel("lock_{}".format(lock_name), self.lock_key) + + async def get_change_id_user(self, user_id: int) -> Optional[int]: + """ + Get the change_id for the restricted_data of an user. + + This is the change_id where the restricted_data was last calculated. + """ + async with self.get_connection() as redis: + return await redis.hget(self.get_restricted_data_cache_key(user_id), '_config:change_id') + + async def update_restricted_data(self, user_id: int, data: Dict[str, str]) -> None: + """ + Updates the restricted_data for an user. + + data has to be a dict where the key is an element_id and the value the (json-) encoded + element. + """ + async with self.get_connection() as redis: + await redis.hmset_dict(self.get_restricted_data_cache_key(user_id), data) + + async def get_current_change_id(self) -> List[Tuple[str, int]]: + """ + Get the highest change_id from redis. + """ + async with self.get_connection() as redis: + return await redis.zrevrangebyscore( + self.get_change_id_cache_key(), + withscores=True, + count=1, + offset=0) + + async def get_lowest_change_id(self) -> Optional[int]: + """ + Get the lowest change_id from redis. + + Returns None if lowest score does not exist. + """ + async with self.get_connection() as redis: + return await redis.zscore( + self.get_change_id_cache_key(), + '_config:lowest_change_id') + + +class MemmoryCacheProvider(BaseCacheProvider): + """ + CacheProvider for the ElementCache that uses only the memory. + + See the RedisCacheProvider for a description of the methods. + + This provider supports only one process. It saves the data into the memory. + When you use different processes they will use diffrent data. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.set_data_dicts() + + def set_data_dicts(self) -> None: + self.full_data: Dict[str, str] = {} + self.restricted_data: Dict[int, Dict[str, str]] = {} + self.change_id_data: Dict[int, Set[str]] = {} + self.locks: Dict[str, str] = {} + + async def clear_cache(self) -> None: + self.set_data_dicts() + + async def reset_full_cache(self, data: Dict[str, str]) -> None: + self.full_data = data + + async def data_exists(self, user_id: Optional[int] = None) -> bool: + if user_id is None: + cache_dict = self.full_data + else: + cache_dict = self.restricted_data.get(user_id, {}) + + return bool(cache_dict) + + async def add_elements(self, elements: List[str]) -> None: + if len(elements) % 2: + raise ValueError("The argument elements of add_elements has to be a list with an even number of elements.") + + for i in range(0, len(elements), 2): + self.full_data[elements[i]] = elements[i+1] + + async def del_elements(self, elements: List[str], user_id: Optional[int] = None) -> None: + if user_id is None: + cache_dict = self.full_data + else: + cache_dict = self.restricted_data.get(user_id, {}) + + for element in elements: + try: + del cache_dict[element] + except KeyError: + pass + + async def add_changed_elements(self, change_id: int, element_ids: Iterable[str]) -> None: + element_ids = list(element_ids) + + for element_id in element_ids: + if change_id in self.change_id_data: + self.change_id_data[change_id].add(element_id) + else: + self.change_id_data[change_id] = {element_id} + + async def get_all_data(self, user_id: Optional[int] = None) -> Dict[bytes, bytes]: + if user_id is None: + cache_dict = self.full_data + else: + cache_dict = self.restricted_data.get(user_id, {}) + + return str_dict_to_bytes(cache_dict) + + async def get_element(self, element_id: str) -> Optional[bytes]: + value = self.full_data.get(element_id, None) + return value.encode() if value is not None else None + + async def get_data_since( + self, change_id: int, user_id: Optional[int] = None) -> Tuple[Dict[str, List[bytes]], List[str]]: + changed_elements: Dict[str, List[bytes]] = defaultdict(list) + deleted_elements: List[str] = [] + if user_id is None: + cache_dict = self.full_data + else: + cache_dict = self.restricted_data.get(user_id, {}) + + for data_change_id, element_ids in self.change_id_data.items(): + if data_change_id < change_id: + continue + for element_id in element_ids: + element_json = cache_dict.get(element_id, None) + if element_json is None: + deleted_elements.append(element_id) + else: + collection_string, id = split_element_id(element_id) + changed_elements[collection_string].append(element_json.encode()) + return changed_elements, deleted_elements + + async def del_restricted_data(self, user_id: int) -> None: + try: + del self.restricted_data[user_id] + except KeyError: + pass + + async def set_lock(self, lock_name: str) -> bool: + if lock_name in self.locks: + return False + self.locks[lock_name] = "1" + return True + + async def get_lock(self, lock_name: str) -> bool: + return lock_name in self.locks + + async def del_lock(self, lock_name: str) -> None: + try: + del self.locks[lock_name] + except KeyError: + pass + + async def get_change_id_user(self, user_id: int) -> Optional[int]: + data = self.restricted_data.get(user_id, {}) + change_id = data.get('_config:change_id', None) + return int(change_id) if change_id is not None else None + + async def update_restricted_data(self, user_id: int, data: Dict[str, str]) -> None: + redis_data = self.restricted_data.setdefault(user_id, {}) + redis_data.update(data) + + async def get_current_change_id(self) -> List[Tuple[str, int]]: + change_data = self.change_id_data + if change_data: + return [('no_usefull_value', max(change_data.keys()))] + return [] + + async def get_lowest_change_id(self) -> Optional[int]: + change_data = self.change_id_data + if change_data: + return min(change_data.keys()) + return None + + +class Cachable: + """ + A Cachable is an object that returns elements that can be cached. + + It needs at least the methods defined here. + """ + + def get_collection_string(self) -> str: + """ + Returns the string representing the name of the cachable. + """ + raise NotImplementedError("Cachable has to implement the method get_collection_string().") + + def get_elements(self) -> List[Dict[str, Any]]: + """ + Returns all elements of the cachable. + """ + raise NotImplementedError("Cachable has to implement the method get_collection_string().") + + def restrict_elements( + self, + user: Optional['CollectionElement'], + elements: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Converts full_data to restricted_data. + + elements can be an empty list, a list with some elements of the cachable or with all + elements of the cachable. + + The default implementation returns the full_data. + """ + return elements + + +def get_all_cachables() -> List[Cachable]: + """ + Returns all element of OpenSlides. + """ + out: List[Cachable] = [] + for app in apps.get_app_configs(): + try: + # Get the method get_startup_elements() from an app. + # This method has to return an iterable of Collection objects. + get_startup_elements = app.get_startup_elements + except AttributeError: + # Skip apps that do not implement get_startup_elements. + continue + out.extend(get_startup_elements()) + return out diff --git a/openslides/utils/collection.py b/openslides/utils/collection.py index 90d75bdd2..0f4817525 100644 --- a/openslides/utils/collection.py +++ b/openslides/utils/collection.py @@ -10,14 +10,17 @@ from typing import ( cast, ) +from asgiref.sync import async_to_sync from django.apps import apps from django.db.models import Model from mypy_extensions import TypedDict -from .cache import full_data_cache +from .cache import element_cache +from .cache_providers import Cachable + if TYPE_CHECKING: - from .access_permissions import BaseAccessPermissions # noqa + from .access_permissions import BaseAccessPermissions AutoupdateFormat = TypedDict( @@ -74,19 +77,12 @@ class CollectionElement: 'CollectionElement.from_values() but not CollectionElement() ' 'directly.') - if self.is_deleted(): - # Delete the element from the cache, if self.is_deleted() is True: - full_data_cache.del_element(self.collection_string, self.id) - else: - # The call to get_full_data() has some sideeffects. When the object - # was created with from_instance() or the object is not in the cache - # then get_full_data() will save the object into the cache. - # This will also raise a DoesNotExist error, if the object does - # neither exist in the cache nor in the database. - self.get_full_data() + if not self.deleted: + self.get_full_data() # This raises DoesNotExist, if the element does not exist. @classmethod - def from_instance(cls, instance: Model, deleted: bool = False, information: Dict[str, Any] = None) -> 'CollectionElement': + def from_instance( + cls, instance: Model, deleted: bool = False, information: Dict[str, Any] = None) -> 'CollectionElement': """ Returns a collection element from a database instance. @@ -175,6 +171,20 @@ class CollectionElement: """ return self.get_model().get_access_permissions() + def get_element_from_db(self) -> Optional[Dict[str, Any]]: + # Hack for django 2.0 and channels 2.1 to stay in the same thread. + # This is needed for the tests. + try: + query = self.get_model().objects.get_full_queryset() + except AttributeError: + # If the model des not have to method get_full_queryset(), then use + # the default queryset from django. + query = self.get_model().objects + try: + return self.get_access_permissions().get_full_data(query.get(pk=self.id)) + except self.get_model().DoesNotExist: + return None + def get_full_data(self) -> Dict[str, Any]: """ Returns the full_data of this collection_element from with all other @@ -188,14 +198,15 @@ class CollectionElement: # else: use the cache. if self.full_data is None: if self.instance is None: - # Make sure the cache exists - if not full_data_cache.exists_for_collection(self.collection_string): - # Build the cache if it does not exists. - full_data_cache.build_for_collection(self.collection_string) - self.full_data = full_data_cache.get_element(self.collection_string, self.id) + # The type of data has to be set for mypy + data: Optional[Dict[str, Any]] = None + data = async_to_sync(element_cache.get_element_full_data)(self.collection_string, self.id) + if data is None: + raise self.get_model().DoesNotExist( + "Collection {} with id {} does not exist".format(self.collection_string, self.id)) + self.full_data = data else: self.full_data = self.get_access_permissions().get_full_data(self.instance) - full_data_cache.add_element(self.collection_string, self.id, self.full_data) return self.full_data def is_deleted(self) -> bool: @@ -205,7 +216,7 @@ class CollectionElement: return self.deleted -class Collection: +class Collection(Cachable): """ Represents all elements of one collection. """ @@ -242,18 +253,28 @@ class Collection: full_data['id'], full_data=full_data) + def get_elements_from_db(self) ->Dict[str, List[Dict[str, Any]]]: + # Hack for django 2.0 and channels 2.1 to stay in the same thread. + # This is needed for the tests. + try: + query = self.get_model().objects.get_full_queryset() + except AttributeError: + # If the model des not have to method get_full_queryset(), then use + # the default queryset from django. + query = self.get_model().objects + return {self.collection_string: [self.get_model().get_access_permissions().get_full_data(instance) for instance in query.all()]} + def get_full_data(self) -> List[Dict[str, Any]]: """ Returns a list of dictionaries with full_data of all collection elements. """ if self.full_data is None: - # Build the cache, if it does not exists. - if not full_data_cache.exists_for_collection(self.collection_string): - full_data_cache.build_for_collection(self.collection_string) - - self.full_data = full_data_cache.get_data(self.collection_string) - return self.full_data + # The type of all_full_data has to be set for mypy + all_full_data: Dict[str, List[Dict[str, Any]]] = {} + all_full_data = async_to_sync(element_cache.get_all_full_data)() + self.full_data = all_full_data.get(self.collection_string, []) + return self.full_data # type: ignore def as_list_for_user(self, user: Optional[CollectionElement]) -> List[Dict[str, Any]]: """ @@ -262,8 +283,29 @@ class Collection: """ return self.get_access_permissions().get_restricted_data(self.get_full_data(), user) + def get_collection_string(self) -> str: + """ + Returns the collection_string. + """ + return self.collection_string -_models_to_collection_string = {} # type: Dict[str, Type[Model]] + def get_elements(self) -> List[Dict[str, Any]]: + """ + Returns all elements of the Collection as full_data. + """ + return self.get_full_data() + + def restrict_elements( + self, + user: Optional['CollectionElement'], + elements: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Converts the full_data to restricted data. + """ + return self.get_model().get_access_permissions().get_restricted_data(user, elements) + + +_models_to_collection_string: Dict[str, Type[Model]] = {} def get_model_from_collection_string(collection_string: str) -> Type[Model]: @@ -295,7 +337,8 @@ def get_model_from_collection_string(collection_string: str) -> Type[Model]: return model -def format_for_autoupdate(collection_string: str, id: int, action: str, data: Dict[str, Any] = None) -> AutoupdateFormat: +def format_for_autoupdate( + collection_string: str, id: int, action: str, data: Dict[str, Any] = None) -> AutoupdateFormat: """ Returns a dict that can be used for autoupdate. """ diff --git a/openslides/utils/constants.py b/openslides/utils/constants.py new file mode 100644 index 000000000..d0717aef4 --- /dev/null +++ b/openslides/utils/constants.py @@ -0,0 +1,40 @@ +from typing import Any, Dict + +from django.apps import apps + + +def get_constants_from_apps() -> Dict[str, Any]: + out: Dict[str, Any] = {} + for app in apps.get_app_configs(): + try: + # Each app can deliver values to angular when implementing this method. + # It should return a list with dicts containing the 'name' and 'value'. + get_angular_constants = app.get_angular_constants + except AttributeError: + # The app doesn't have this method. Continue to next app. + continue + out.update(get_angular_constants()) + return out + + +constants = None + + +def get_constants() -> Dict[str, Any]: + """ + Returns the constants. + + This method only returns a static dict, so it is fast and can be used in a + async context. + """ + if constants is None: + raise RuntimeError("Constants are not set.") + return constants + + +def set_constants(value: Dict[str, Any]) -> None: + """ + Sets the constants variable. + """ + global constants + constants = value diff --git a/openslides/utils/consumers.py b/openslides/utils/consumers.py new file mode 100644 index 000000000..1fbe5edc7 --- /dev/null +++ b/openslides/utils/consumers.py @@ -0,0 +1,392 @@ +from typing import Any, Dict, List, Optional + +import jsonschema +from asgiref.sync import sync_to_async +from channels.db import database_sync_to_async +from channels.generic.websocket import AsyncJsonWebsocketConsumer + +from ..core.config import config +from ..core.models import Projector +from .auth import async_anonymous_is_enabled, has_perm +from .cache import element_cache, split_element_id +from .collection import ( + Collection, + CollectionElement, + format_for_autoupdate, + from_channel_message, +) +from .constants import get_constants + + +class ProtocollAsyncJsonWebsocketConsumer(AsyncJsonWebsocketConsumer): + """ + Mixin for JSONWebsocketConsumers, that speaks the a special protocol. + """ + schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "OpenSlidesWebsocketProtocol", + "description": "The base packages that OpenSlides sends between the server and the client.", + "type": "object", + "properties": { + "type": { + "description": "Defines what kind of packages is packed.", + "type": "string", + "pattern": "notify|constants", # The server can sent other types + }, + "content": { + "description": "The content of the package.", + }, + "id": { + "description": "An identifier of the package.", + "type": "string", + }, + "in_response": { + "description": "The id of another package that the other part sent before.", + "type": "string", + } + }, + "required": ["type", "content", "id"], + } + + async def send_json(self, type: str, content: Any, id: Optional[str] = None, in_response: Optional[str] = None) -> None: + """ + Sends the data with the type. + """ + out = {'type': type, 'content': content} + if id: + out['id'] = id + if in_response: + out['in_response'] = in_response + await super().send_json(out) + + async def receive_json(self, content: Any) -> None: + """ + Receives the json data, parses it and calls receive_content. + """ + try: + jsonschema.validate(content, self.schema) + except jsonschema.ValidationError as err: + try: + in_response = content['id'] + except (TypeError, KeyError): + # content is not a dict (TypeError) or has not the key id (KeyError) + in_response = None + + await self.send_json( + type='error', + content=str(err), + in_response=in_response) + return + + await self.receive_content(type=content['type'], content=content['content'], id=content['id']) + + async def receive_content(self, type: str, content: object, id: str) -> None: + raise NotImplementedError("ProtocollAsyncJsonWebsocketConsumer needs the method receive_content()") + + +class SiteConsumer(ProtocollAsyncJsonWebsocketConsumer): + """ + Websocket Consumer for the site. + """ + groups = ['site'] + + async def connect(self) -> None: + """ + A user connects to the site. + + If it is an anonymous user and anonymous is disabled, the connection is closed. + + Sends the startup data to the user. + """ + # TODO: add a way to ask for the data since a change_id and send only data that is newer + if not await async_anonymous_is_enabled() and self.scope['user'].id is None: + await self.close() + else: + await self.accept() + data = await startup_data(self.scope['user']) + await self.send_json(type='autoupdate', content=data) + + async def receive_content(self, type: str, content: Any, id: str) -> None: + """ + If we recieve something from the client we currently just interpret this + as a notify message. + + The server adds the sender's user id (0 for anonymous) and reply + channel name so that a receiver client may reply to the sender or to all + sender's instances. + """ + if type == 'notify': + if notify_message_is_valid(content): + await self.channel_layer.group_send( + "projector", + { + "type": "send_notify", + "incomming": content, + "senderReplyChannelName": self.channel_name, + "senderUserId": self.scope['user'].id or 0, + }, + ) + await self.channel_layer.group_send( + "site", + { + "type": "send_notify", + "incomming": content, + "senderReplyChannelName": self.channel_name, + "senderUserId": self.scope['user'].id or 0, + }, + ) + else: + await self.send_json(type='error', content='Invalid notify message', in_response=id) + + elif type == 'constants': + # Return all constants to the client. + await self.send_json(type='constants', content=get_constants(), in_response=id) + + async def send_notify(self, event: Dict[str, Any]) -> None: + """ + Send a notify message to the user. + """ + user_id = self.scope['user'].id or 0 + + out = [] + for item in event['incomming']: + users = item.get('users') + reply_channels = item.get('replyChannels') + projectors = item.get('projectors') + if ((isinstance(users, list) and user_id in users) + or (isinstance(reply_channels, list) and self.channel_name in reply_channels) + or (users is None and reply_channels is None and projectors is None)): + item['senderReplyChannelName'] = event.get('senderReplyChannelName') + item['senderUserId'] = event.get('senderUserId') + item['senderProjectorId'] = event.get('senderProjectorId') + out.append(item) + + if out: + await self.send_json(type='notify', content=out) + + async def send_data(self, event: Dict[str, Any]) -> None: + """ + Send changed or deleted elements to the user. + """ + change_id = event['change_id'] + output = [] + changed_elements, deleted_elements = await element_cache.get_restricted_data(self.scope['user'], change_id) + for collection_string, elements in changed_elements.items(): + for element in elements: + output.append(format_for_autoupdate( + collection_string=collection_string, + id=element['id'], + action='changed', + data=element)) + for element_id in deleted_elements: + collection_string, id = split_element_id(element_id) + output.append(format_for_autoupdate( + collection_string=collection_string, + id=id, + action='deleted')) + await self.send_json(type='autoupdate', content=output) + + +class ProjectorConsumer(ProtocollAsyncJsonWebsocketConsumer): + """ + Websocket Consumer for the projector. + """ + + groups = ['projector'] + + async def connect(self) -> None: + """ + Adds the websocket connection to a group specific to the projector with the given id. + Also sends all data that are shown on the projector. + """ + user = self.scope['user'] + projector_id = self.scope["url_route"]["kwargs"]["projector_id"] + await self.accept() + + if not await database_sync_to_async(has_perm)(user, 'core.can_see_projector'): + await self.send_json(type='error', content='No permissions to see this projector.') + # TODO: Shouldend we just close the websocket connection with an error message? + # self.close(code=4403) + else: + out = await sync_to_async(projector_startup_data)(projector_id) + await self.send_json(type='autoupdate', content=out) + + async def receive_content(self, type: str, content: Any, id: str) -> None: + """ + If we recieve something from the client we currently just interpret this + as a notify message. + + The server adds the sender's user id (0 for anonymous) and reply + channel name so that a receiver client may reply to the sender or to all + sender's instances. + """ + projector_id = self.scope["url_route"]["kwargs"]["projector_id"] + await self.channel_layer.group_send( + "projector", + { + "type": "send_notify", + "incomming": content, + "senderReplyChannelName": self.channel_name, + "senderProjectorId": projector_id, + }, + ) + await self.channel_layer.group_send( + "site", + { + "type": "send_notify", + "incomming": content, + "senderReplyChannelName": self.channel_name, + "senderProjectorId": projector_id, + }, + ) + + async def send_notify(self, event: Dict[str, Any]) -> None: + """ + Send a notify message to the projector. + """ + projector_id = self.scope["url_route"]["kwargs"]["projector_id"] + + out = [] + for item in event['incomming']: + users = item.get('users') + reply_channels = item.get('replyChannels') + projectors = item.get('projectors') + if ((isinstance(projectors, list) and projector_id in projectors) + or (isinstance(reply_channels, list) and self.channel_name in reply_channels) + or (users is None and reply_channels is None and projectors is None)): + item['senderReplyChannelName'] = event.get('senderReplyChannelName') + item['senderUserId'] = event.get('senderUserId') + item['senderProjectorId'] = event.get('senderProjectorId') + out.append(item) + + if out: + await self.send_json(type='notify', content=out) + + async def send_data(self, event: Dict[str, Any]) -> None: + """ + Informs all projector clients about changed data. + """ + projector_id = self.scope["url_route"]["kwargs"]["projector_id"] + collection_elements = from_channel_message(event['message']) + + output = await projector_sync_send_data(projector_id, collection_elements) + if output: + await self.send_json(type='autoupdate', content=output) + + +async def startup_data(user: Optional[CollectionElement], change_id: int = 0) -> List[Any]: + """ + Returns all data for startup. + """ + # TODO: use the change_id argument + output = [] + restricted_data = await element_cache.get_all_restricted_data(user) + for collection_string, elements in restricted_data.items(): + for element in elements: + formatted_data = format_for_autoupdate( + collection_string=collection_string, + id=element['id'], + action='changed', + data=element) + + output.append(formatted_data) + return output + + +def projector_startup_data(projector_id: int) -> Any: + """ + Generate the startup data for a projector. + """ + try: + projector = Projector.objects.get(pk=projector_id) + except Projector.DoesNotExist: + return {'text': 'The projector {} does not exist.'.format(projector_id)} + else: + # Now check whether broadcast is active at the moment. If yes, + # change the local projector variable. + if config['projector_broadcast'] > 0: + projector = Projector.objects.get(pk=config['projector_broadcast']) + + # Collect all elements that are on the projector. + output = [] + for requirement in projector.get_all_requirements(): + required_collection_element = CollectionElement.from_instance(requirement) + output.append(required_collection_element.as_autoupdate_for_projector()) + + # Collect all config elements. + config_collection = Collection(config.get_collection_string()) + projector_data = (config_collection.get_access_permissions() + .get_projector_data(config_collection.get_full_data())) + for data in projector_data: + output.append(format_for_autoupdate( + config_collection.collection_string, + data['id'], + 'changed', + data)) + + # Collect the projector instance. + collection_element = CollectionElement.from_instance(projector) + output.append(collection_element.as_autoupdate_for_projector()) + + # Send all the data that were only collected before. + return output + + +@sync_to_async +def projector_sync_send_data(projector_id: int, collection_elements: List[CollectionElement]) -> List[Any]: + """ + sync function that generates the elements for an projector. + """ + # Load the projector object. If broadcast is on, use the broadcast projector + # instead. + if config['projector_broadcast'] > 0: + projector_id = config['projector_broadcast'] + + projector = Projector.objects.get(pk=projector_id) + + # TODO: This runs once for every open projector tab. Either use + # caching or something else, so this is only called once + output = [] + for collection_element in collection_elements: + if collection_element.is_deleted(): + output.append(collection_element.as_autoupdate_for_projector()) + else: + for element in projector.get_collection_elements_required_for_this(collection_element): + output.append(element.as_autoupdate_for_projector()) + return output + + +def notify_message_is_valid(message: object) -> bool: + """ + Returns True, when the message is a valid notify_message. + """ + schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Notify elements.", + "description": "Elements that one client can send to one or many other clients.", + "type": "array", + "items": { + "type": "object", + "properties": { + "projectors": { + "type": "array", + "items": {"type": "integer"}, + }, + "reply_channels": { + "type": "array", + "items": {"type": "string"}, + }, + "users": { + "type": "array", + "items": {"type": "integer"}, + } + } + }, + "minItems": 1, + } + try: + jsonschema.validate(message, schema) + except jsonschema.ValidationError: + return False + else: + return True diff --git a/openslides/utils/main.py b/openslides/utils/main.py index efa575d13..2f784233c 100644 --- a/openslides/utils/main.py +++ b/openslides/utils/main.py @@ -13,6 +13,7 @@ from django.core.exceptions import ImproperlyConfigured from django.utils.crypto import get_random_string from mypy_extensions import NoReturn + DEVELOPMENT_VERSION = 'Development Version' UNIX_VERSION = 'Unix Version' WINDOWS_VERSION = 'Windows Version' @@ -327,16 +328,6 @@ def is_local_installation() -> bool: return True if '--local-installation' in sys.argv or 'manage.py' in sys.argv[0] else False -def get_geiss_path() -> str: - """ - Returns the path and file to the Geiss binary. - """ - from django.conf import settings - download_dir = getattr(settings, 'OPENSLIDES_USER_DATA_PATH', '') - bin_name = 'geiss.exe' if is_windows() else 'geiss' - return os.path.join(download_dir, bin_name) - - def is_windows() -> bool: """ Returns True if the current system is Windows. Returns False otherwise. diff --git a/openslides/utils/middleware.py b/openslides/utils/middleware.py new file mode 100644 index 000000000..152ffae40 --- /dev/null +++ b/openslides/utils/middleware.py @@ -0,0 +1,63 @@ +from typing import Any, Dict, Union + +from channels.auth import ( + AuthMiddleware, + CookieMiddleware, + SessionMiddleware, + _get_user_session_key, +) +from django.conf import settings +from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY +from django.contrib.auth.models import AnonymousUser +from django.utils.crypto import constant_time_compare + +from .cache import element_cache +from .collection import CollectionElement + + +class CollectionAuthMiddleware(AuthMiddleware): + """ + Like the channels AuthMiddleware but returns a CollectionElement instead of + a django Model as user. + """ + + async def resolve_scope(self, scope: Dict[str, Any]) -> None: + scope["user"]._wrapped = await get_user(scope) + + +async def get_user(scope: Dict[str, Any]) -> Union[CollectionElement, AnonymousUser]: + """ + Returns a User-CollectionElement from a channels-scope-session. + + If no user is retrieved, return AnonymousUser. + """ + # This can not return None because a LazyObject can not become None + + # This code is basicly from channels.auth: + # https://github.com/django/channels/blob/d5e81a78e96770127da79248349808b6ee6ec2a7/channels/auth.py#L16 + if "session" not in scope: + raise ValueError("Cannot find session in scope. You should wrap your consumer in SessionMiddleware.") + session = scope["session"] + user = None + try: + user_id = _get_user_session_key(session) + backend_path = session[BACKEND_SESSION_KEY] + except KeyError: + pass + else: + if backend_path in settings.AUTHENTICATION_BACKENDS: + user = await element_cache.get_element_full_data("users/user", user_id) + if user is not None: + # Verify the session + session_hash = session.get(HASH_SESSION_KEY) + session_hash_verified = session_hash and constant_time_compare( + session_hash, + user['session_auth_hash']) + if not session_hash_verified: + session.flush() + user = None + return CollectionElement.from_values("users/user", user_id, full_data=user) if user else AnonymousUser() + + +# Handy shortcut for applying all three layers at once +AuthMiddlewareStack = lambda inner: CookieMiddleware(SessionMiddleware(CollectionAuthMiddleware(inner))) # noqa diff --git a/openslides/utils/migrations.py b/openslides/utils/migrations.py index 371265db7..303e6baed 100644 --- a/openslides/utils/migrations.py +++ b/openslides/utils/migrations.py @@ -1,4 +1,4 @@ -from typing import Any, Callable # noqa +from typing import Any, Callable from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType diff --git a/openslides/utils/models.py b/openslides/utils/models.py index 725825f74..8cdb92396 100644 --- a/openslides/utils/models.py +++ b/openslides/utils/models.py @@ -1,12 +1,17 @@ -from typing import Any, Dict +from typing import TYPE_CHECKING, Any, Dict, List, Optional from django.core.exceptions import ImproperlyConfigured from django.db import models -from .access_permissions import BaseAccessPermissions # noqa +from .access_permissions import BaseAccessPermissions from .utils import convert_camel_case_to_pseudo_snake_case +if TYPE_CHECKING: + # Dummy import Collection for mypy, can be fixed with python 3.7 + from .collection import CollectionElement # noqa + + class MinMaxIntegerField(models.IntegerField): """ IntegerField with options to set a min- and a max-value. @@ -27,7 +32,7 @@ class RESTModelMixin: Mixin for Django models which are used in our REST API. """ - access_permissions = None # type: BaseAccessPermissions + access_permissions: Optional[BaseAccessPermissions] = None def get_root_rest_element(self) -> models.Model: """ @@ -117,3 +122,29 @@ class RESTModelMixin: else: inform_deleted_data([(self.get_collection_string(), instance_pk)], information=information) return return_value + + @classmethod + def get_elements(cls) -> List[Dict[str, Any]]: + """ + Returns all elements as full_data. + """ + # Get the query to receive all data from the database. + try: + query = cls.objects.get_full_queryset() # type: ignore + except AttributeError: + # If the model des not have to method get_full_queryset(), then use + # the default queryset from django. + query = cls.objects # type: ignore + + # Build a dict from the instance id to the full_data + return [cls.get_access_permissions().get_full_data(instance) for instance in query.all()] + + @classmethod + def restrict_elements( + cls, + user: Optional['CollectionElement'], + elements: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Converts a list of elements from full_data to restricted_data. + """ + return cls.get_access_permissions().get_restricted_data(elements, user) diff --git a/openslides/utils/plugins.py b/openslides/utils/plugins.py index 987d00b6f..6719415f4 100644 --- a/openslides/utils/plugins.py +++ b/openslides/utils/plugins.py @@ -146,7 +146,7 @@ def get_all_plugin_urlpatterns() -> List[Any]: Helper function to return all urlpatterns of all plugins listed in settings.INSTALLED_PLUGINS. """ - urlpatterns = [] # type: List[Any] + urlpatterns: List[Any] = [] for plugin in settings.INSTALLED_PLUGINS: plugin_urlpatterns = get_plugin_urlpatterns(plugin) if plugin_urlpatterns: diff --git a/openslides/utils/projector.py b/openslides/utils/projector.py index c46153d33..14db3d7da 100644 --- a/openslides/utils/projector.py +++ b/openslides/utils/projector.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Generator, Iterable, List, Type +from typing import Any, Dict, Generator, Iterable, List, Optional, Type from .collection import CollectionElement @@ -11,7 +11,7 @@ class ProjectorElement: subclassing from this base class with different names. The name attribute has to be set. """ - name = None # type: str + name: Optional[str] = None def check_and_update_data(self, projector_object: Any, config_entry: Any) -> Any: """ @@ -84,7 +84,7 @@ class ProjectorElement: return output -projector_elements = {} # type: Dict[str, ProjectorElement] +projector_elements: Dict[str, ProjectorElement] = {} def register_projector_elements(elements: Generator[Type[ProjectorElement], None, None]) -> None: @@ -95,7 +95,7 @@ def register_projector_elements(elements: Generator[Type[ProjectorElement], None """ for Element in elements: element = Element() - projector_elements[element.name] = element + projector_elements[element.name] = element # type: ignore def get_all_projector_elements() -> Dict[str, ProjectorElement]: diff --git a/openslides/utils/rest_api.py b/openslides/utils/rest_api.py index dad1b61a5..f1886bfdd 100644 --- a/openslides/utils/rest_api.py +++ b/openslides/utils/rest_api.py @@ -1,22 +1,21 @@ from collections import OrderedDict -from typing import Any, Dict, Iterable, Optional, Type # noqa +from typing import Any, Dict, Iterable, Optional, Type from django.http import Http404 -from rest_framework import status # noqa -from rest_framework.decorators import detail_route, list_route # noqa -from rest_framework.metadata import SimpleMetadata # noqa -from rest_framework.mixins import ListModelMixin as _ListModelMixin -from rest_framework.mixins import RetrieveModelMixin as _RetrieveModelMixin -from rest_framework.mixins import ( # noqa +from rest_framework import status +from rest_framework.decorators import detail_route, list_route +from rest_framework.metadata import SimpleMetadata +from rest_framework.mixins import ( CreateModelMixin, DestroyModelMixin, + ListModelMixin as _ListModelMixin, + RetrieveModelMixin as _RetrieveModelMixin, UpdateModelMixin, ) from rest_framework.relations import MANY_RELATION_KWARGS from rest_framework.response import Response from rest_framework.routers import DefaultRouter -from rest_framework.serializers import ModelSerializer as _ModelSerializer -from rest_framework.serializers import ( # noqa +from rest_framework.serializers import ( CharField, DecimalField, DictField, @@ -27,20 +26,30 @@ from rest_framework.serializers import ( # noqa ListField, ListSerializer, ManyRelatedField, + ModelSerializer as _ModelSerializer, PrimaryKeyRelatedField, RelatedField, Serializer, SerializerMethodField, ValidationError, ) -from rest_framework.viewsets import GenericViewSet as _GenericViewSet # noqa -from rest_framework.viewsets import ModelViewSet as _ModelViewSet # noqa -from rest_framework.viewsets import ViewSet as _ViewSet # noqa +from rest_framework.viewsets import ( + GenericViewSet as _GenericViewSet, + ModelViewSet as _ModelViewSet, + ViewSet as _ViewSet, +) from .access_permissions import BaseAccessPermissions from .auth import user_to_collection_user from .collection import Collection, CollectionElement + +__all__ = ['detail_route', 'DecimalField', 'list_route', 'SimpleMetadata', 'CreateModelMixin', + 'DestroyModelMixin', 'UpdateModelMixin', 'CharField', 'DictField', 'FileField', + 'IntegerField', 'JSONField', 'ListField', 'ListSerializer', 'status', 'RelatedField', + 'SerializerMethodField', 'ValidationError'] + + router = DefaultRouter() @@ -110,7 +119,7 @@ class PermissionMixin: Also connects container to handle access permissions for model and viewset. """ - access_permissions = None # type: Optional[BaseAccessPermissions] + access_permissions: Optional[BaseAccessPermissions] = None def get_permissions(self) -> Iterable[str]: """ @@ -163,7 +172,7 @@ class ModelSerializer(_ModelSerializer): """ Returns all fields of the serializer. """ - fields = OrderedDict() # type: Dict[str, Field] + fields: Dict[str, Field] = OrderedDict() for field_name, field in super().get_fields().items(): try: diff --git a/openslides/utils/settings.py.tpl b/openslides/utils/settings.py.tpl index c4d2252d5..6dec3787b 100644 --- a/openslides/utils/settings.py.tpl +++ b/openslides/utils/settings.py.tpl @@ -79,59 +79,38 @@ DATABASES = { use_redis = False if use_redis: - # Redis configuration for django-redis-session. Keep this synchronized to - # the caching settings + # Django Channels + # https://channels.readthedocs.io/en/latest/topics/channel_layers.html#configuration + + CHANNEL_LAYERS['default']['BACKEND'] = 'channels_redis.core.RedisChannelLayer' + CHANNEL_LAYERS['default']['CONFIG'] = {"capacity": 100000} + + # Collection Cache + + # Can be: + # a Redis URI โ€” "redis://host:6379/0?encoding=utf-8"; + # a (host, port) tuple โ€” ('localhost', 6379); + # or a unix domain socket path string โ€” "/path/to/redis.sock". + REDIS_ADDRESS = "redis://127.0.0.1" + + # When use_redis is True, the restricted data cache caches the data individuel + # for each user. This requires a lot of memory if there are a lot of active + # users. + RESTRICTED_DATA_CACHE = True + + # Session backend + + # Redis configuration for django-redis-sessions. + # https://github.com/martinrusev/django-redis-sessions + + SESSION_ENGINE = 'redis_sessions.session' SESSION_REDIS = { 'host': '127.0.0.1', 'post': 6379, 'db': 0, } - # Django Channels - - # Unless you have only a small assembly uncomment the following lines to - # activate Redis as backend for Django Channels and Cache. You have to install - # a Redis server and the python packages asgi_redis and django-redis. - - # https://channels.readthedocs.io/en/latest/backends.html#redis - - CHANNEL_LAYERS['default']['BACKEND'] = 'asgi_redis.RedisChannelLayer' - CHANNEL_LAYERS['default']['CONFIG']['prefix'] = 'asgi:' - - - # Caching - - # Django uses a inmemory cache at default. This supports only one thread. If - # you use more then one thread another caching backend is required. We recommand - # django-redis: https://niwinz.github.io/django-redis/latest/#_user_guide - - CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379/0", - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - }, - "KEY_PREFIX": "openslides-cache", - } - } - - # Session backend - - # Per default django uses the database as session backend. This can be slow. - # One possibility is to use the cache session backend with redis as cache backend - # Another possibility is to use a native redis session backend. For example: - # https://github.com/martinrusev/django-redis-sessions - - # SESSION_ENGINE = 'django.contrib.sessions.backends.cache' - SESSION_ENGINE = 'redis_sessions.session' - - -# When use_redis is True, the restricted data cache caches the data individuel -# for each user. This requires a lot of memory if there are a lot of active -# users. If use_redis is False, this setting has no effect. -DISABLE_USER_CACHE = False # Internationalization # https://docs.djangoproject.com/en/1.10/topics/i18n/ diff --git a/openslides/utils/test.py b/openslides/utils/test.py index 5c714b611..abc47ca12 100644 --- a/openslides/utils/test.py +++ b/openslides/utils/test.py @@ -1,37 +1,12 @@ from django.test import TestCase as _TestCase -from django.test.runner import DiscoverRunner from ..core.config import config -class OpenSlidesDiscoverRunner(DiscoverRunner): - def run_tests(self, test_labels, extra_tests=None, **kwargs): # type: ignore - """ - Test Runner which does not create a database, if only unittest are run. - """ - if len(test_labels) == 1 and test_labels[0].startswith('tests.unit'): - # Do not create a test database, if only unittests are tested - create_database = False - else: - create_database = True - - self.setup_test_environment() - suite = self.build_suite(test_labels, extra_tests) - if create_database: - old_config = self.setup_databases() - result = self.run_suite(suite) - if create_database: - self.teardown_databases(old_config) - self.teardown_test_environment() - return self.suite_result(suite, result) - - class TestCase(_TestCase): """ Resets the config object after each test. """ def tearDown(self) -> None: - from django_redis import get_redis_connection - config.key_to_id = {} - get_redis_connection("default").flushall() + config.save_default_values() diff --git a/openslides/utils/utils.py b/openslides/utils/utils.py index 884e74e55..1c64c4ec5 100644 --- a/openslides/utils/utils.py +++ b/openslides/utils/utils.py @@ -1,7 +1,13 @@ import re +from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union import roman + +if TYPE_CHECKING: + # Dummy import Collection for mypy, can be fixed with python 3.7 + from .collection import CollectionElement # noqa + CAMEL_CASE_TO_PSEUDO_SNAKE_CASE_CONVERSION_REGEX_1 = re.compile('(.)([A-Z][a-z]+)') CAMEL_CASE_TO_PSEUDO_SNAKE_CASE_CONVERSION_REGEX_2 = re.compile('([a-z0-9])([A-Z])') @@ -29,3 +35,43 @@ def to_roman(number: int) -> str: return roman.toRoman(number) except (roman.NotIntegerError, roman.OutOfRangeError): return str(number) + + +def get_element_id(collection_string: str, id: int) -> str: + """ + Returns a combined string from the collection_string and an id. + """ + return "{}:{}".format(collection_string, id) + + +def split_element_id(element_id: Union[str, bytes]) -> Tuple[str, int]: + """ + Splits a combined element_id into the collection_string and the id. + """ + if isinstance(element_id, bytes): + element_id = element_id.decode() + collection_str, id = element_id.rsplit(":", 1) + return (collection_str, int(id)) + + +def get_user_id(user: Optional['CollectionElement']) -> int: + """ + Returns the user id for an CollectionElement user. + + Returns 0 for anonymous. + """ + if user is None: + user_id = 0 + else: + user_id = user.id + return user_id + + +def str_dict_to_bytes(str_dict: Dict[str, str]) -> Dict[bytes, bytes]: + """ + Converts the key and the value of a dict from str to bytes. + """ + out = {} + for key, value in str_dict.items(): + out[key.encode()] = value.encode() + return out diff --git a/openslides/utils/validate.py b/openslides/utils/validate.py index e0e5bbb0e..5d3620720 100644 --- a/openslides/utils/validate.py +++ b/openslides/utils/validate.py @@ -1,5 +1,6 @@ import bleach + allowed_tags = [ 'a', 'img', # links and images 'br', 'p', 'span', 'blockquote', # text layout diff --git a/openslides/utils/views.py b/openslides/utils/views.py index dc8411ba5..02e4efd4f 100644 --- a/openslides/utils/views.py +++ b/openslides/utils/views.py @@ -1,5 +1,5 @@ import base64 -from typing import Any, Dict, List # noqa +from typing import Any, Dict, List, Optional from django.contrib.staticfiles import finders from django.core.exceptions import ImproperlyConfigured @@ -28,7 +28,7 @@ class APIView(_APIView): The Django Rest framework APIView with improvements for OpenSlides. """ - http_method_names = [] # type: List[str] + http_method_names: List[str] = [] """ The allowed actions have to be explicitly defined. @@ -60,8 +60,8 @@ class TemplateView(View): is not allowed to change. So the State has to be saved in this dict. Search for 'Borg design pattern' for more information. """ - template_name = None # type: str - state = {} # type: Dict[str, str] + template_name: Optional[str] = None + state: Dict[str, str] = {} def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) @@ -78,7 +78,7 @@ class TemplateView(View): return template.read() def get(self, *args: Any, **kwargs: Any) -> HttpResponse: - return HttpResponse(self.state[self.template_name]) + return HttpResponse(self.state[self.template_name]) # type: ignore class BinaryTemplateView(TemplateView): diff --git a/requirements.txt b/requirements.txt index b18647db0..952727e81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,6 @@ # Requirements for OpenSlides in production --r requirements_big_mode.txt +-r requirements/production.txt +-r requirements/big_mode.txt # Requirements for development and tests in alphabetical order -coverage -#flake8 -# Use master of flake8 until flake8 3.6 is released that supports python3.7 -git+https://gitlab.com/pycqa/flake8.git -isort==4.2.5 -mypy<=0.620 -fakeredis +-r requirements/development.txt diff --git a/requirements/big_mode.txt b/requirements/big_mode.txt new file mode 100644 index 000000000..3bbdb1793 --- /dev/null +++ b/requirements/big_mode.txt @@ -0,0 +1,9 @@ +# Requirements for Redis and PostgreSQL support +channels-redis>=2.2,<2.3 +django-redis-sessions>=0.6.1,<0.7 +psycopg2-binary>=2.7.3.2,<2.8 +aioredis>=1.1.0,<1.2 + +# Requirements for fast asgi server +gunicorn>=19.9<20 +uvicorn>=0.3.2<1.1 diff --git a/requirements/development.txt b/requirements/development.txt new file mode 100644 index 000000000..bc6290118 --- /dev/null +++ b/requirements/development.txt @@ -0,0 +1,10 @@ +coverage +#flake8 +# Use master of flake8 until flake8 3.6 is released that supports python3.7 +git+https://gitlab.com/pycqa/flake8.git +isort +mypy +pytest +pytest-django +pytest-asyncio +pytest-cov diff --git a/requirements_production.txt b/requirements/production.txt similarity index 65% rename from requirements_production.txt rename to requirements/production.txt index edd944dc5..bcd320383 100644 --- a/requirements_production.txt +++ b/requirements/production.txt @@ -1,11 +1,12 @@ # Requirements for OpenSlides in production in alphabetical order bleach>=1.5.0,<2.2 -channels>=1.1,<1.2 -daphne<2 -Django>=1.10.4,<2.2 +channels>=2.1.2,<2.2 +daphne>=2.2,<2.3 +Django>=1.11,<2.2 djangorestframework>=3.4,<3.9 jsonfield2>=3.0,<3.1 -mypy_extensions>=0.3,<0.4 +jsonschema>=2.6.0<2.7 +mypy_extensions>=0.4,<0.5 PyPDF2>=1.26,<1.27 roman>=2.0,<3.1 setuptools>=29.0,<41.0 diff --git a/requirements_big_mode.txt b/requirements_big_mode.txt deleted file mode 100644 index bc4ea3d4d..000000000 --- a/requirements_big_mode.txt +++ /dev/null @@ -1,9 +0,0 @@ -# Requirements for OpenSlides in production --r requirements_production.txt - -# Requirements for Redis and PostgreSQL support -asgi-redis>=1.3,<1.5 -django-redis>=4.7.0,<4.10 -django-redis-sessions>=0.6.1,<0.7 -psycopg2-binary>=2.7,<2.8 -txredisapi==1.4.4 diff --git a/setup.cfg b/setup.cfg index 86bcf93e6..815a1050d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,6 +13,10 @@ max_line_length = 150 [isort] include_trailing_comma = true multi_line_output = 3 +lines_after_imports = 2 +combine_as_imports = true +known_first_party = openslides +known_third_party = pytest [mypy] ignore_missing_imports = true @@ -25,5 +29,8 @@ disallow_untyped_defs = true [mypy-openslides.core.config] disallow_untyped_defs = true -[mypy-tests.*] -ignore_errors = true +[tool:pytest] +DJANGO_SETTINGS_MODULE = tests.settings +testpaths = tests/ +filterwarnings = + ignore:RemovedInDjango30Warning diff --git a/setup.py b/setup.py index b769b8d49..cc1b853cb 100644 --- a/setup.py +++ b/setup.py @@ -11,9 +11,12 @@ from openslides import __url__ as openslides_url with open('README.rst') as readme: long_description = readme.read() -with open('requirements_production.txt') as requirements_production: +with open('requirements/production.txt') as requirements_production: install_requires = requirements_production.readlines() +with open('requirements/big_mode.txt') as requirements_big_mode: + extras_requires = requirements_big_mode.readlines() + setup( name='openslides', author=openslides_author, @@ -33,10 +36,10 @@ setup( 'Framework :: Django', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', ], packages=find_packages(exclude=['tests', 'tests.*']), include_package_data=True, install_requires=install_requires, + extras_require={'big_mode': extras_requires}, entry_points={'console_scripts': ['openslides = openslides.__main__:main']}) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..cc7364fc2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,71 @@ +import pytest +from django.test import TestCase, TransactionTestCase +from pytest_django.django_compat import is_django_unittest +from pytest_django.plugin import validate_django_db + +from openslides.utils.cache import element_cache + + +def pytest_collection_modifyitems(items): + """ + Helper until https://github.com/pytest-dev/pytest-django/issues/214 is fixed. + """ + def get_marker_transaction(test): + marker = test.get_closest_marker('django_db') + if marker: + validate_django_db(marker) + return marker.kwargs['transaction'] + + return None + + def has_fixture(test, fixture): + funcargnames = getattr(test, 'funcargnames', None) + return funcargnames and fixture in funcargnames + + def weight_test_case(test): + """ + Key function for ordering test cases like the Django test runner. + """ + is_test_case_subclass = test.cls and issubclass(test.cls, TestCase) + is_transaction_test_case_subclass = test.cls and issubclass(test.cls, TransactionTestCase) + + if is_test_case_subclass or get_marker_transaction(test) is False: + return 0 + elif has_fixture(test, 'db'): + return 0 + + if is_transaction_test_case_subclass or get_marker_transaction(test) is True: + return 1 + elif has_fixture(test, 'transactional_db'): + return 1 + + return 0 + + items.sort(key=weight_test_case) + + +@pytest.fixture(autouse=True) +def constants(request): + """ + Resets the constants on every test. + + Uses fake constants, if the db is not in use. + """ + from openslides.utils.constants import set_constants, get_constants_from_apps + + if 'django_db' in request.node.keywords or is_django_unittest(request): + # When the db is created, use the original constants + set_constants(get_constants_from_apps()) + else: + # Else: Use fake constants + set_constants({'constant1': 'value1', 'constant2': 'value2'}) + + +@pytest.fixture(autouse=True) +def reset_cache(request): + """ + Resetts the cache for every test + """ + if 'django_db' in request.node.keywords or is_django_unittest(request): + # When the db is created, use the original cachables + element_cache.ensure_cache(reset=True) diff --git a/tests/example_data_generator/management/commands/create-example-data.py b/tests/example_data_generator/management/commands/create-example-data.py index a7755cc42..060b765cd 100644 --- a/tests/example_data_generator/management/commands/create-example-data.py +++ b/tests/example_data_generator/management/commands/create-example-data.py @@ -12,6 +12,7 @@ from openslides.motions.models import Motion from openslides.topics.models import Topic from openslides.users.models import Group, User + MOTION_NUMBER_OF_PARAGRAPHS = 4 LOREM_IPSUM = [ diff --git a/tests/integration/agenda/test_viewset.py b/tests/integration/agenda/test_viewset.py index 0e4792309..ec7232d60 100644 --- a/tests/integration/agenda/test_viewset.py +++ b/tests/integration/agenda/test_viewset.py @@ -1,8 +1,8 @@ +import pytest from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.urls import reverse from django.utils.translation import ugettext -from django_redis import get_redis_connection from rest_framework import status from rest_framework.test import APIClient @@ -12,10 +12,13 @@ from openslides.core.config import config from openslides.core.models import Countdown from openslides.motions.models import Motion from openslides.topics.models import Topic -from openslides.users.models import User +from openslides.users.models import Group +from openslides.utils.autoupdate import inform_changed_data from openslides.utils.collection import CollectionElement from openslides.utils.test import TestCase +from ..helpers import count_queries + class RetrieveItem(TestCase): """ @@ -42,20 +45,22 @@ class RetrieveItem(TestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_hidden_by_anonymous_with_manage_perms(self): - group = get_user_model().groups.field.related_model.objects.get(pk=1) # Group with pk 1 is for anonymous users. + group = Group.objects.get(pk=1) # Group with pk 1 is for anonymous users. permission_string = 'agenda.can_manage' app_label, codename = permission_string.split('.') permission = Permission.objects.get(content_type__app_label=app_label, codename=codename) group.permissions.add(permission) + inform_changed_data(group) response = self.client.get(reverse('item-detail', args=[self.item.pk])) self.assertEqual(response.status_code, status.HTTP_200_OK) def test_internal_by_anonymous_without_perm_to_see_internal_items(self): - group = get_user_model().groups.field.related_model.objects.get(pk=1) # Group with pk 1 is for anonymous users. + group = Group.objects.get(pk=1) # Group with pk 1 is for anonymous users. permission_string = 'agenda.can_see_internal_items' app_label, codename = permission_string.split('.') permission = group.permissions.get(content_type__app_label=app_label, codename=codename) group.permissions.remove(permission) + inform_changed_data(group) self.item.type = Item.INTERNAL_ITEM self.item.save() response = self.client.get(reverse('item-detail', args=[self.item.pk])) @@ -68,7 +73,7 @@ class RetrieveItem(TestCase): 'content_object',))) forbidden_keys = ( 'item_number', - 'list_view_title', + 'title_with_type', 'comment', 'closed', 'type', @@ -89,63 +94,28 @@ class RetrieveItem(TestCase): self.assertTrue(response.data.get('comment') is None) -class TestDBQueries(TestCase): +@pytest.mark.django_db(transaction=False) +def test_agenda_item_db_queries(): """ - Tests that receiving elements only need the required db queries. + Tests that only the following db queries are done: + * 1 requests to get the list of all agenda items, + * 1 request to get all speakers, + * 3 requests to get the assignments, motions and topics and - Therefore in setup some agenda items are created and received with different - user accounts. + * 1 request to get an agenda item (why?) + TODO: The last three request are a bug. """ + for index in range(10): + Topic.objects.create(title='topic{}'.format(index)) + parent = Topic.objects.create(title='parent').agenda_item + child = Topic.objects.create(title='child').agenda_item + child.parent = parent + child.save() + Motion.objects.create(title='motion1') + Motion.objects.create(title='motion2') + Assignment.objects.create(title='assignment', open_posts=5) - def setUp(self): - self.client = APIClient() - config['general_system_enable_anonymous'] = True - for index in range(10): - Topic.objects.create(title='topic{}'.format(index)) - parent = Topic.objects.create(title='parent').agenda_item - child = Topic.objects.create(title='child').agenda_item - child.parent = parent - child.save() - Motion.objects.create(title='motion1') - Motion.objects.create(title='motion2') - Assignment.objects.create(title='assignment', open_posts=5) - - def test_admin(self): - """ - Tests that only the following db queries are done: - * 7 requests to get the session an the request user with its permissions, - * 1 requests to get the list of all agenda items, - * 1 request to get all speakers, - * 3 requests to get the assignments, motions and topics and - - * 1 request to get an agenda item (why?) - - * 2 requests for the motionsversions. - - TODO: The last two request for the motionsversions are a bug. - """ - self.client.force_login(User.objects.get(pk=1)) - get_redis_connection("default").flushall() - with self.assertNumQueries(15): - self.client.get(reverse('item-list')) - - def test_anonymous(self): - """ - Tests that only the following db queries are done: - * 3 requests to get the permission for anonymous, - * 1 requests to get the list of all agenda items, - * 1 request to get all speakers, - * 3 requests to get the assignments, motions and topics and - - * 1 request to get an agenda item (why?) - - * 2 requests for the motionsversions. - - TODO: The last two request for the motionsversions are a bug. - """ - get_redis_connection("default").flushall() - with self.assertNumQueries(11): - self.client.get(reverse('item-list')) + assert count_queries(Item.get_elements) == 6 class ManageSpeaker(TestCase): @@ -228,6 +198,7 @@ class ManageSpeaker(TestCase): group_delegates = type(group_admin).objects.get(name='Delegates') admin.groups.add(group_delegates) admin.groups.remove(group_admin) + inform_changed_data(admin) CollectionElement.from_instance(admin) response = self.client.post( @@ -265,7 +236,7 @@ class ManageSpeaker(TestCase): group_delegates = type(group_admin).objects.get(name='Delegates') admin.groups.add(group_delegates) admin.groups.remove(group_admin) - CollectionElement.from_instance(admin) + inform_changed_data(admin) speaker = Speaker.objects.add(self.user, self.item) response = self.client.delete( @@ -293,7 +264,7 @@ class ManageSpeaker(TestCase): group_delegates = type(group_admin).objects.get(name='Delegates') admin.groups.add(group_delegates) admin.groups.remove(group_admin) - CollectionElement.from_instance(admin) + inform_changed_data(admin) Speaker.objects.add(self.user, self.item) response = self.client.patch( diff --git a/tests/integration/assignments/test_viewset.py b/tests/integration/assignments/test_viewset.py index dad256b6a..3dba1d558 100644 --- a/tests/integration/assignments/test_viewset.py +++ b/tests/integration/assignments/test_viewset.py @@ -1,66 +1,34 @@ +import pytest from django.contrib.auth import get_user_model from django.urls import reverse -from django_redis import get_redis_connection from rest_framework import status from rest_framework.test import APIClient from openslides.assignments.models import Assignment -from openslides.core.config import config -from openslides.users.models import User +from openslides.utils.autoupdate import inform_changed_data from openslides.utils.test import TestCase +from ..helpers import count_queries -class TestDBQueries(TestCase): + +@pytest.mark.django_db(transaction=False) +def test_assignment_db_queries(): """ - Tests that receiving elements only need the required db queries. + Tests that only the following db queries are done: + * 1 requests to get the list of all assignments, + * 1 request to get all related users, + * 1 request to get the agenda item, + * 1 request to get the polls, + * 1 request to get the tags and - Therefore in setup some assignments are created and received with different - user accounts. + * 10 request to fetch each related user again. + + TODO: The last request are a bug. """ + for index in range(10): + Assignment.objects.create(title='assignment{}'.format(index), open_posts=1) - def setUp(self): - self.client = APIClient() - config['general_system_enable_anonymous'] = True - config.save_default_values() - for index in range(10): - Assignment.objects.create(title='motion{}'.format(index), open_posts=1) - - def test_admin(self): - """ - Tests that only the following db queries are done: - * 7 requests to get the session an the request user with its permissions, - * 1 requests to get the list of all assignments, - * 1 request to get all related users, - * 1 request to get the agenda item, - * 1 request to get the polls, - * 1 request to get the tags and - - * 10 request to fetch each related user again. - - TODO: The last request are a bug. - """ - self.client.force_login(User.objects.get(pk=1)) - get_redis_connection("default").flushall() - with self.assertNumQueries(22): - self.client.get(reverse('assignment-list')) - - def test_anonymous(self): - """ - Tests that only the following db queries are done: - * 3 requests to get the permission for anonymous, - * 1 requests to get the list of all assignments, - * 1 request to get all related users, - * 1 request to get the agenda item, - * 1 request to get the polls, - * 1 request to get the tags and - - * 10 request to fetch each related user again. - - TODO: The last 10 requests are an bug. - """ - get_redis_connection("default").flushall() - with self.assertNumQueries(18): - self.client.get(reverse('assignment-list')) + assert count_queries(Assignment.get_elements) == 15 class CanidatureSelf(TestCase): @@ -110,7 +78,7 @@ class CanidatureSelf(TestCase): group_delegates = type(group_admin).objects.get(name='Delegates') admin.groups.add(group_delegates) admin.groups.remove(group_admin) - get_redis_connection('default').flushall() + inform_changed_data(admin) response = self.client.post(reverse('assignment-candidature-self', args=[self.assignment.pk])) @@ -157,7 +125,7 @@ class CanidatureSelf(TestCase): group_delegates = type(group_admin).objects.get(name='Delegates') admin.groups.add(group_delegates) admin.groups.remove(group_admin) - get_redis_connection('default').flushall() + inform_changed_data(admin) response = self.client.delete(reverse('assignment-candidature-self', args=[self.assignment.pk])) @@ -238,7 +206,7 @@ class CandidatureOther(TestCase): group_delegates = type(group_admin).objects.get(name='Delegates') admin.groups.add(group_delegates) admin.groups.remove(group_admin) - get_redis_connection('default').flushall() + inform_changed_data(admin) response = self.client.post( reverse('assignment-candidature-other', args=[self.assignment.pk]), @@ -294,7 +262,7 @@ class CandidatureOther(TestCase): group_delegates = type(group_admin).objects.get(name='Delegates') admin.groups.add(group_delegates) admin.groups.remove(group_admin) - get_redis_connection('default').flushall() + inform_changed_data(admin) response = self.client.delete( reverse('assignment-candidature-other', args=[self.assignment.pk]), diff --git a/tests/integration/core/test_views.py b/tests/integration/core/test_views.py index 944e1f092..cb4eda5e8 100644 --- a/tests/integration/core/test_views.py +++ b/tests/integration/core/test_views.py @@ -5,9 +5,11 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient -from openslides import __license__ as license -from openslides import __url__ as url -from openslides import __version__ as version +from openslides import ( + __license__ as license, + __url__ as url, + __version__ as version, +) from openslides.core.config import ConfigVariable, config from openslides.core.models import Projector from openslides.topics.models import Topic @@ -92,17 +94,17 @@ class WebclientJavaScriptView(TestCase): response = self.client.get(reverse('core_webclient_javascript', args=['site'])) content = response.content.decode() constants = self.get_angular_constants_from_apps() - for constant in constants: - self.assertTrue(json.dumps(constant['value']) in content) + for key, constant in constants.items(): + self.assertTrue(json.dumps(constant) in content) def get_angular_constants_from_apps(self): - constants = [] + constants = {} for app in apps.get_app_configs(): try: get_angular_constants = app.get_angular_constants except AttributeError: continue - constants.extend(get_angular_constants()) + constants.update(get_angular_constants()) return constants @@ -114,7 +116,7 @@ class ConfigViewSet(TestCase): # Save the old value of the config object and add the test values # TODO: Can be changed to setUpClass when Django 1.8 is no longer supported self._config_values = config.config_variables.copy() - config.key_to_id = {} + config.key_to_id = None config.update_config_variables(set_simple_config_view_integration_config_test()) config.save_default_values() diff --git a/tests/integration/core/test_viewset.py b/tests/integration/core/test_viewset.py index 47e046ac3..874b50746 100644 --- a/tests/integration/core/test_viewset.py +++ b/tests/integration/core/test_viewset.py @@ -1,150 +1,62 @@ +import pytest from django.urls import reverse -from django_redis import get_redis_connection from rest_framework import status -from rest_framework.test import APIClient from openslides.core.config import config from openslides.core.models import ChatMessage, Projector, Tag from openslides.users.models import User from openslides.utils.test import TestCase +from ..helpers import count_queries -class TestProjectorDBQueries(TestCase): + +@pytest.mark.django_db(transaction=False) +def test_projector_db_queries(): """ - Tests that receiving elements only need the required db queries. - - Therefore in setup some objects are created and received with different - user accounts. + Tests that only the following db queries are done: + * 1 requests to get the list of all projectors, + * 1 request to get the list of the projector defaults. """ + for index in range(10): + Projector.objects.create(name="Projector{}".format(index)) - def setUp(self): - self.client = APIClient() - config['general_system_enable_anonymous'] = True - config.save_default_values() - for index in range(10): - Projector.objects.create(name="Projector{}".format(index)) - - def test_admin(self): - """ - Tests that only the following db queries are done: - * 7 requests to get the session an the request user with its permissions, - * 1 requests to get the list of all projectors, - * 1 request to get the list of the projector defaults. - """ - self.client.force_login(User.objects.get(pk=1)) - get_redis_connection("default").flushall() - with self.assertNumQueries(9): - self.client.get(reverse('projector-list')) - - def test_anonymous(self): - """ - Tests that only the following db queries are done: - * 3 requests to get the permission for anonymous, - * 1 requests to get the list of all projectors, - * 1 request to get the list of the projector defaults and - """ - get_redis_connection("default").flushall() - with self.assertNumQueries(5): - self.client.get(reverse('projector-list')) + assert count_queries(Projector.get_elements) == 2 -class TestCharmessageDBQueries(TestCase): +@pytest.mark.django_db(transaction=False) +def test_chat_message_db_queries(): """ - Tests that receiving elements only need the required db queries. - - Therefore in setup some objects are created and received with different - user accounts. + Tests that only the following db queries are done: + * 1 requests to get the list of all chatmessages. """ + user = User.objects.get(username='admin') + for index in range(10): + ChatMessage.objects.create(user=user) - def setUp(self): - self.client = APIClient() - config['general_system_enable_anonymous'] = True - config.save_default_values() - user = User.objects.get(pk=1) - for index in range(10): - ChatMessage.objects.create(user=user) - - def test_admin(self): - """ - Tests that only the following db queries are done: - * 7 requests to get the session an the request user with its permissions, - * 1 requests to get the list of all chatmessages, - """ - self.client.force_login(User.objects.get(pk=1)) - get_redis_connection("default").flushall() - with self.assertNumQueries(8): - self.client.get(reverse('chatmessage-list')) + assert count_queries(ChatMessage.get_elements) == 1 -class TestTagDBQueries(TestCase): +@pytest.mark.django_db(transaction=False) +def test_tag_db_queries(): """ - Tests that receiving elements only need the required db queries. - - Therefore in setup some objects are created and received with different - user accounts. + Tests that only the following db queries are done: + * 1 requests to get the list of all tags. """ + for index in range(10): + Tag.objects.create(name='tag{}'.format(index)) - def setUp(self): - self.client = APIClient() - config['general_system_enable_anonymous'] = True - config.save_default_values() - for index in range(10): - Tag.objects.create(name='tag{}'.format(index)) - - def test_admin(self): - """ - Tests that only the following db queries are done: - * 5 requests to get the session an the request user with its permissions, - * 1 requests to get the list of all tags, - """ - self.client.force_login(User.objects.get(pk=1)) - get_redis_connection("default").flushall() - with self.assertNumQueries(6): - self.client.get(reverse('tag-list')) - - def test_anonymous(self): - """ - Tests that only the following db queries are done: - * 1 requests to see if anonyomus is enabled - * 1 requests to get the list of all projectors, - """ - get_redis_connection("default").flushall() - with self.assertNumQueries(2): - self.client.get(reverse('tag-list')) + assert count_queries(Tag.get_elements) == 1 -class TestConfigDBQueries(TestCase): +@pytest.mark.django_db(transaction=False) +def test_config_db_queries(): """ - Tests that receiving elements only need the required db queries. - - Therefore in setup some objects are created and received with different - user accounts. + Tests that only the following db queries are done: + * 1 requests to get the list of all config values """ + config.save_default_values() - def setUp(self): - self.client = APIClient() - config['general_system_enable_anonymous'] = True - config.save_default_values() - - def test_admin(self): - """ - Tests that only the following db queries are done: - * 5 requests to get the session an the request user with its permissions and - * 1 requests to get the list of all config values - """ - self.client.force_login(User.objects.get(pk=1)) - get_redis_connection("default").flushall() - with self.assertNumQueries(6): - self.client.get(reverse('config-list')) - - def test_anonymous(self): - """ - Tests that only the following db queries are done: - * 1 requests to see if anonymous is enabled and get all config values - """ - get_redis_connection("default").flushall() - with self.assertNumQueries(1): - self.client.get(reverse('config-list')) + assert count_queries(Tag.get_elements) == 1 class ChatMessageViewSet(TestCase): @@ -152,7 +64,7 @@ class ChatMessageViewSet(TestCase): Tests requests to deal with chat messages. """ def setUp(self): - admin = User.objects.get(pk=1) + admin = User.objects.get(username='admin') self.client.force_login(admin) ChatMessage.objects.create(message='test_message_peechiel8IeZoohaem9e', user=admin) diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py new file mode 100644 index 000000000..2edfb4567 --- /dev/null +++ b/tests/integration/helpers.py @@ -0,0 +1,70 @@ +from typing import Any, Dict, List + +from asgiref.sync import sync_to_async +from django.db import DEFAULT_DB_ALIAS, connections +from django.test.utils import CaptureQueriesContext + +from openslides.core.config import config +from openslides.users.models import User +from openslides.utils.autoupdate import inform_data_collection_element_list +from openslides.utils.cache import element_cache, get_element_id +from openslides.utils.cache_providers import Cachable +from openslides.utils.collection import CollectionElement + + +class TConfig(Cachable): + """ + Cachable, that fills the cache with the default values of the config variables. + """ + + def get_collection_string(self) -> str: + return config.get_collection_string() + + def get_elements(self) -> List[Dict[str, Any]]: + elements = [] + config.key_to_id = {} + for id, item in enumerate(config.config_variables.values()): + elements.append({'id': id+1, 'key': item.name, 'value': item.default_value}) + config.key_to_id[item.name] = id+1 + return elements + + +class TUser(Cachable): + """ + Cachable, that fills the cache with the default values of the config variables. + """ + + def get_collection_string(self) -> str: + return User.get_collection_string() + + def get_elements(self) -> List[Dict[str, Any]]: + return [ + {'id': 1, 'username': 'admin', 'title': '', 'first_name': '', + 'last_name': 'Administrator', 'structure_level': '', 'number': '', 'about_me': '', + 'groups_id': [4], 'is_present': False, 'is_committee': False, 'email': '', + 'last_email_send': None, 'comment': '', 'is_active': True, 'default_password': 'admin', + 'session_auth_hash': '362d4f2de1463293cb3aaba7727c967c35de43ee'}] + + +async def set_config(key, value): + """ + Set a config variable in the element_cache without hitting the database. + """ + collection_string = config.get_collection_string() + config_id = config.key_to_id[key] # type: ignore + full_data = {'id': config_id, 'key': key, 'value': value} + await element_cache.change_elements({get_element_id(collection_string, config_id): full_data}) + await sync_to_async(inform_data_collection_element_list)([ + CollectionElement.from_values(collection_string, config_id, full_data=full_data)]) + + +def count_queries(func, *args, **kwargs) -> int: + context = CaptureQueriesContext(connections[DEFAULT_DB_ALIAS]) + with context: + func(*args, **kwargs) + + print("%d queries executed\nCaptured queries were:\n%s" % ( + len(context), + '\n'.join( + '%d. %s' % (i, query['sql']) for i, query in enumerate(context.captured_queries, start=1)))) + return len(context) diff --git a/tests/integration/mediafiles/test_viewset.py b/tests/integration/mediafiles/test_viewset.py index 922c49498..47b662c80 100644 --- a/tests/integration/mediafiles/test_viewset.py +++ b/tests/integration/mediafiles/test_viewset.py @@ -1,50 +1,22 @@ +import pytest from django.core.files.uploadedfile import SimpleUploadedFile -from django.urls import reverse -from django_redis import get_redis_connection -from rest_framework.test import APIClient -from openslides.core.config import config from openslides.mediafiles.models import Mediafile -from openslides.users.models import User -from openslides.utils.test import TestCase + +from ..helpers import count_queries -class TestDBQueries(TestCase): +@pytest.mark.django_db(transaction=False) +def test_mediafiles_db_queries(): """ - Tests that receiving elements only need the required db queries. - - Therefore in setup some objects are created and received with different - user accounts. + Tests that only the following db queries are done: + * 1 requests to get the list of all files. """ + for index in range(10): + Mediafile.objects.create( + title='some_file{}'.format(index), + mediafile=SimpleUploadedFile( + 'some_file{}'.format(index), + b'some content.')) - def setUp(self): - self.client = APIClient() - config['general_system_enable_anonymous'] = True - config.save_default_values() - for index in range(10): - Mediafile.objects.create( - title='some_file{}'.format(index), - mediafile=SimpleUploadedFile( - 'some_file{}'.format(index), - b'some content.')) - - def test_admin(self): - """ - Tests that only the following db queries are done: - * 7 requests to get the session an the request user with its permissions and - * 1 requests to get the list of all files. - """ - self.client.force_login(User.objects.get(pk=1)) - get_redis_connection('default').flushall() - with self.assertNumQueries(8): - self.client.get(reverse('mediafile-list')) - - def test_anonymous(self): - """ - Tests that only the following db queries are done: - * 3 requests to get the permission for anonymous and - * 1 requests to get the list of all projectors. - """ - get_redis_connection('default').flushall() - with self.assertNumQueries(4): - self.client.get(reverse('mediafile-list')) + assert count_queries(Mediafile.get_elements) == 1 diff --git a/tests/integration/motions/test_viewset.py b/tests/integration/motions/test_viewset.py index 800e5e3ea..d2437209b 100644 --- a/tests/integration/motions/test_viewset.py +++ b/tests/integration/motions/test_viewset.py @@ -1,155 +1,210 @@ import json +import pytest from django.contrib.auth import get_user_model -from django.contrib.auth.models import Permission from django.urls import reverse -from django_redis import get_redis_connection from rest_framework import status from rest_framework.test import APIClient from openslides.core.config import config -from openslides.core.models import ConfigStore, Tag +from openslides.core.models import Tag from openslides.motions.models import ( Category, Motion, MotionBlock, + MotionComment, + MotionCommentSection, MotionLog, State, + StatuteParagraph, Submitter, Workflow, ) -from openslides.users.models import Group -from openslides.utils.collection import CollectionElement +from openslides.utils.auth import get_group_model +from openslides.utils.autoupdate import inform_changed_data from openslides.utils.test import TestCase +from ..helpers import count_queries -class TestMotionDBQueries(TestCase): + +GROUP_DEFAULT_PK = 1 +GROUP_ADMIN_PK = 2 +GROUP_DELEGATE_PK = 3 +GROUP_STAFF_PK = 4 + + +@pytest.mark.django_db(transaction=False) +def test_motion_db_queries(): """ - Tests that receiving elements only need the required db queries. + Tests that only the following db queries are done: + * 1 requests to get the list of all motions, + * 1 request to get the associated workflow + * 1 request for all motion comments + * 1 request for all motion comment sections required for the comments + * 1 request for all users required for the read_groups of the sections + * 1 request to get the agenda item, + * 1 request to get the motion log, + * 1 request to get the polls, + * 1 request to get the attachments, + * 1 request to get the tags, + * 2 requests to get the submitters and supporters. - Therefore in setup some objects are created and received with different - user accounts. + Two comment sections are created and for each motions two comments. + """ + section1 = MotionCommentSection.objects.create(name='test_section') + section2 = MotionCommentSection.objects.create(name='test_section') + + for index in range(10): + motion = Motion.objects.create(title='motion{}'.format(index)) + + MotionComment.objects.create( + comment='test_comment', + motion=motion, + section=section1) + MotionComment.objects.create( + comment='test_comment2', + motion=motion, + section=section2) + + get_user_model().objects.create_user( + username='user_{}'.format(index), + password='password') + # TODO: Create some polls etc. + + assert count_queries(Motion.get_elements) == 12 + + +@pytest.mark.django_db(transaction=False) +def test_category_db_queries(): + """ + Tests that only the following db queries are done: + * 1 requests to get the list of all categories. + """ + for index in range(10): + Category.objects.create(name='category{}'.format(index)) + + assert count_queries(Category.get_elements) == 1 + + +@pytest.mark.django_db(transaction=False) +def test_statute_paragraph_db_queries(): + """ + Tests that only the following db queries are done: + * 1 requests to get the list of all statute paragraphs. + """ + for index in range(10): + StatuteParagraph.objects.create( + title='statute_paragraph{}'.format(index), + text='text{}'.format(index)) + + assert count_queries(StatuteParagraph.get_elements) == 1 + + +@pytest.mark.django_db(transaction=False) +def test_workflow_db_queries(): + """ + Tests that only the following db queries are done: + * 1 requests to get the list of all workflows, + * 1 request to get all states and + * 1 request to get the next states of all states. """ + assert count_queries(Workflow.get_elements) == 3 + + +class TestStatuteParagraphs(TestCase): + """ + Tests all CRUD operations of statute paragraphs. + """ def setUp(self): self.client = APIClient() - config['general_system_enable_anonymous'] = True - config.save_default_values() - for index in range(10): - Motion.objects.create(title='motion{}'.format(index)) - get_user_model().objects.create_user( - username='user_{}'.format(index), - password='password') - # TODO: Create some polls etc. + self.client.login(username='admin', password='admin') - def test_admin(self): - """ - Tests that only the following db queries are done: - * 7 requests to get the session an the request user with its permissions, - * 1 requests to get the list of all motions, - * 1 request to get the motion versions, - * 1 request to get the agenda item, - * 1 request to get the motion log, - * 1 request to get the polls, - * 1 request to get the attachments, - * 1 request to get the tags, - * 2 requests to get the submitters and supporters. - """ - self.client.force_login(get_user_model().objects.get(pk=1)) - get_redis_connection('default').flushall() - with self.assertNumQueries(16): - self.client.get(reverse('motion-list')) + def create_statute_paragraph(self): + self.title = 'test_title_fiWs82D0D)2kje3KDm2s' + self.text = 'test_text_3jfjoDqm,S;cmor3DJwk' + self.cp = StatuteParagraph.objects.create( + title=self.title, + text=self.text) - def test_anonymous(self): - """ - Tests that only the following db queries are done: - * 3 requests to get the permission for anonymous, - * 1 requests to get the list of all motions, - * 1 request to get the motion versions, - * 1 request to get the agenda item, - * 1 request to get the motion log, - * 1 request to get the polls, - * 1 request to get the attachments, - * 1 request to get the tags, - * 2 requests to get the submitters and supporters. - """ - get_redis_connection('default').flushall() - with self.assertNumQueries(12): - self.client.get(reverse('motion-list')) + def test_create_simple(self): + response = self.client.post( + reverse('statuteparagraph-list'), + {'title': 'test_title_f3FM328cq)tzdU238df2', + 'text': 'test_text_2fb)BEjwdI38=kfemiRkcOW'}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + cp = StatuteParagraph.objects.get() + self.assertEqual(cp.title, 'test_title_f3FM328cq)tzdU238df2') + self.assertEqual(cp.text, 'test_text_2fb)BEjwdI38=kfemiRkcOW') + def test_create_without_data(self): + response = self.client.post(reverse('statuteparagraph-list'), {}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data, {'title': ['This field is required.'], 'text': ['This field is required.']}) -class TestCategoryDBQueries(TestCase): - """ - Tests that receiving elements only need the required db queries. + def test_create_non_admin(self): + self.admin = get_user_model().objects.get(username='admin') + self.admin.groups.add(GROUP_DELEGATE_PK) + self.admin.groups.remove(GROUP_ADMIN_PK) + inform_changed_data(self.admin) - Therefore in setup some objects are created and received with different - user accounts. - """ + response = self.client.post( + reverse('statuteparagraph-list'), + {'title': 'test_title_f3(Dj2jdP39fjW2kdcwe', + 'text': 'test_text_vlC)=fwWmcwcpWMvnuw('}) - def setUp(self): - self.client = APIClient() - config.save_default_values() - config['general_system_enable_anonymous'] = True - for index in range(10): - Category.objects.create(name='category{}'.format(index)) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_admin(self): - """ - Tests that only the following db queries are done: - * 7 requests to get the session an the request user with its permissions and - * 1 requests to get the list of all categories. - """ - self.client.force_login(get_user_model().objects.get(pk=1)) - get_redis_connection('default').flushall() - with self.assertNumQueries(8): - self.client.get(reverse('category-list')) + def test_retrieve_simple(self): + self.create_statute_paragraph() + response = self.client.get(reverse('statuteparagraph-detail', args=[self.cp.pk])) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(sorted(response.data.keys()), sorted(( + 'id', + 'title', + 'text', + 'weight',))) - def test_anonymous(self): - """ - Tests that only the following db queries are done: - * 3 requests to get the permission for anonymous (config and permissions) - * 1 requests to get the list of all motions and - """ - get_redis_connection('default').flushall() - with self.assertNumQueries(4): - self.client.get(reverse('category-list')) + def test_update_simple(self): + self.create_statute_paragraph() + response = self.client.patch( + reverse('statuteparagraph-detail', args=[self.cp.pk]), + {'text': 'test_text_ke(czr/cwk1Sl2seeFwE'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + cp = StatuteParagraph.objects.get() + self.assertEqual(cp.title, self.title) + self.assertEqual(cp.text, 'test_text_ke(czr/cwk1Sl2seeFwE') + def test_update_non_admin(self): + self.admin = get_user_model().objects.get(username='admin') + self.admin.groups.add(GROUP_DELEGATE_PK) + self.admin.groups.remove(GROUP_ADMIN_PK) + inform_changed_data(self.admin) -class TestWorkflowDBQueries(TestCase): - """ - Tests that receiving elements only need the required db queries. - """ + self.create_statute_paragraph() + response = self.client.patch( + reverse('statuteparagraph-detail', args=[self.cp.pk]), + {'text': 'test_text_ke(czr/cwk1Sl2seeFwE'}) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + cp = StatuteParagraph.objects.get() + self.assertEqual(cp.text, self.text) - def setUp(self): - self.client = APIClient() - config.save_default_values() - config['general_system_enable_anonymous'] = True - # There do not need to be more workflows + def test_delete_simple(self): + self.create_statute_paragraph() + response = self.client.delete(reverse('statuteparagraph-detail', args=[self.cp.pk])) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(StatuteParagraph.objects.count(), 0) - def test_admin(self): - """ - Tests that only the following db queries are done: - * 7 requests to get the session an the request user with its permissions, - * 1 requests to get the list of all workflows, - * 1 request to get all states and - * 1 request to get the next states of all states. - """ - self.client.force_login(get_user_model().objects.get(pk=1)) - get_redis_connection('default').flushall() - with self.assertNumQueries(10): - self.client.get(reverse('workflow-list')) + def test_delete_non_admin(self): + self.admin = get_user_model().objects.get(username='admin') + self.admin.groups.add(GROUP_DELEGATE_PK) + self.admin.groups.remove(GROUP_ADMIN_PK) + inform_changed_data(self.admin) - def test_anonymous(self): - """ - Tests that only the following db queries are done: - * 3 requests to get the permission for anonymous, - * 1 requests to get the list of all workflows, - * 1 request to get all states and - * 1 request to get the next states of all states. - """ - get_redis_connection('default').flushall() - with self.assertNumQueries(6): - self.client.get(reverse('workflow-list')) + self.create_statute_paragraph() + response = self.client.delete(reverse('statuteparagraph-detail', args=[self.cp.pk])) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(StatuteParagraph.objects.count(), 1) class CreateMotion(TestCase): @@ -248,45 +303,6 @@ class CreateMotion(TestCase): motion = Motion.objects.get() self.assertEqual(motion.tags.get().name, 'test_tag_iRee3kiecoos4rorohth') - def test_with_multiple_comments(self): - comments = { - '1': 'comemnt1_sdpoiuffo3%7dwDwW)', - '2': 'comment2_iusd_D/TdskDWH(5DWas46WAd078'} - response = self.client.post( - reverse('motion-list'), - {'title': 'title_test_sfdAaufd56HR7sd5FDq7av', - 'text': 'text_test_fiuhefF86()ew1Ef346AF6W', - 'comments': comments}, - format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - motion = Motion.objects.get() - self.assertEqual(motion.comments, comments) - - def test_wrong_comment_format(self): - comments = [ - 'comemnt1_wpcjlwgj$ยงks)skj2LdmwKDWSLw6', - 'comment2_dq2Wd)Jwdlmm:,w82DjwQWSSiwjd'] - response = self.client.post( - reverse('motion-list'), - {'title': 'title_test_sfdAaufd56HR7sd5FDq7av', - 'text': 'text_test_fiuhefF86()ew1Ef346AF6W', - 'comments': comments}, - format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data, {'comments': {'detail': 'Data must be a dict.'}}) - - def test_wrong_comment_id(self): - comment = { - 'string': 'comemnt1_wpcjlwgj$ยงks)skj2LdmwKDWSLw6'} - response = self.client.post( - reverse('motion-list'), - {'title': 'title_test_sfdAaufd56HR7sd5FDq7av', - 'text': 'text_test_fiuhefF86()ew1Ef346AF6W', - 'comments': comment}, - format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data, {'comments': {'detail': 'Id must be an int.'}}) - def test_with_workflow(self): """ Test to create a motion with a specific workflow. @@ -305,8 +321,9 @@ class CreateMotion(TestCase): Test to create a motion by a delegate, non staff user. """ self.admin = get_user_model().objects.get(username='admin') - self.admin.groups.add(2) - self.admin.groups.remove(3) + self.admin.groups.add(GROUP_DELEGATE_PK) + self.admin.groups.remove(GROUP_ADMIN_PK) + inform_changed_data(self.admin) response = self.client.post( reverse('motion-list'), @@ -315,31 +332,6 @@ class CreateMotion(TestCase): self.assertEqual(response.status_code, status.HTTP_201_CREATED) - def test_non_admin_with_comment_data(self): - """ - Test to create a motion by a non staff user that has permission to - manage motion comments and sends some additional fields. - """ - self.admin = get_user_model().objects.get(username='admin') - self.admin.groups.add(2) - self.admin.groups.remove(4) - group_delegate = self.admin.groups.get() - group_delegate.permissions.add(Permission.objects.get( - content_type__app_label='motions', - codename='can_manage_comments', - )) - - response = self.client.post( - reverse('motion-list'), - {'title': 'test_title_peiJozae0luew9EeL8bo', - 'text': 'test_text_eHohS8ohr5ahshoah8Oh', - 'comments': {'1': 'comment_for_field_one__xiek1Euhae9xah2wuuraaaa'}}, - format='json', - ) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(Motion.objects.get().comments, {'1': 'comment_for_field_one__xiek1Euhae9xah2wuuraaaa'}) - def test_amendment_motion(self): """ Test to create a motion with a parent motion as staff user. @@ -381,9 +373,9 @@ class CreateMotion(TestCase): parent_motion.save() self.admin = get_user_model().objects.get(username='admin') - self.admin.groups.add(2) - self.admin.groups.remove(4) - get_redis_connection('default').flushall() + self.admin.groups.add(GROUP_DELEGATE_PK) + self.admin.groups.remove(GROUP_ADMIN_PK) + inform_changed_data(self.admin) response = self.client.post( reverse('motion-list'), @@ -424,24 +416,6 @@ class RetrieveMotion(TestCase): username='user_{}'.format(index), password='password') - def test_number_of_queries(self): - """ - Tests that only the following db queries are done: - * 7 requests to get the session and the request user with its permissions (3 of them are possibly a bug) - * 1 request to get the motion, - * 1 request to get the version, - * 1 request to get the agenda item, - * 1 request to get the log, - * 3 request to get the polls (1 of them is possibly a bug), - * 1 request to get the attachments, - * 1 request to get the tags, - * 2 requests to get the submitters and supporters. - TODO: Fix all bugs. - """ - get_redis_connection('default').flushall() - with self.assertNumQueries(18): - self.client.get(reverse('motion-detail', args=[self.motion.pk])) - def test_guest_state_with_required_permission_to_see(self): config['general_system_enable_anonymous'] = True guest_client = APIClient() @@ -450,7 +424,8 @@ class RetrieveMotion(TestCase): state.save() # The cache has to be cleared, see: # https://github.com/OpenSlides/OpenSlides/issues/3396 - get_redis_connection('default').flushall() + inform_changed_data(self.motion) + response = guest_client.get(reverse('motion-detail', args=[self.motion.pk])) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @@ -477,14 +452,14 @@ class RetrieveMotion(TestCase): def test_user_without_can_see_user_permission_to_see_motion_and_submitter_data(self): admin = get_user_model().objects.get(username='admin') Submitter.objects.add(admin, self.motion) - group = Group.objects.get(pk=1) # Group with pk 1 is for anonymous and default users. + group = get_group_model().objects.get(pk=GROUP_DEFAULT_PK) # Group with pk 1 is for anonymous and default users. permission_string = 'users.can_see_name' app_label, codename = permission_string.split('.') permission = group.permissions.get(content_type__app_label=app_label, codename=codename) group.permissions.remove(permission) config['general_system_enable_anonymous'] = True guest_client = APIClient() - get_redis_connection('default').flushall() + inform_changed_data(group) response_1 = guest_client.get(reverse('motion-detail', args=[self.motion.pk])) self.assertEqual(response_1.status_code, status.HTTP_200_OK) @@ -495,7 +470,7 @@ class RetrieveMotion(TestCase): extra_user = get_user_model().objects.create_user( username='username_wequePhieFoom0hai3wa', password='password_ooth7taechai5Oocieya') - get_redis_connection('default').flushall() + response_3 = guest_client.get(reverse('user-detail', args=[extra_user.pk])) self.assertEqual(response_3.status_code, status.HTTP_403_FORBIDDEN) @@ -532,7 +507,7 @@ class UpdateMotion(TestCase): motion = Motion.objects.get() self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(motion.title, 'test_title_aeng7ahChie3waiR8xoh') - self.assertEqual(motion.workflow, 2) + self.assertEqual(motion.workflow_id, 2) def test_patch_supporters(self): supporter = get_user_model().objects.create_user( @@ -576,7 +551,7 @@ class UpdateMotion(TestCase): self.motion.supporters.add(supporter) config['motions_remove_supporters'] = True self.assertEqual(self.motion.supporters.count(), 1) - get_redis_connection('default').flushall() + inform_changed_data((admin, self.motion)) response = self.client.patch( reverse('motion-detail', args=[self.motion.pk]), @@ -587,56 +562,6 @@ class UpdateMotion(TestCase): self.assertEqual(motion.title, 'new_title_ohph1aedie5Du8sai2ye') self.assertEqual(motion.supporters.count(), 0) - def test_with_new_version(self): - self.motion.set_state(State.objects.get(name='permitted')) - self.motion.save() - response = self.client.patch( - reverse('motion-detail', args=[self.motion.pk]), - {'text': 'test_text_aeb1iaghahChong5od3a'}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - motion = Motion.objects.get() - self.assertEqual(motion.versions.count(), 2) - - def test_without_new_version(self): - self.motion.set_state(State.objects.get(name='permitted')) - self.motion.save() - response = self.client.patch( - reverse('motion-detail', args=[self.motion.pk]), - {'text': 'test_text_aeThaeroneiroo7Iophu', - 'disable_versioning': True}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - motion = Motion.objects.get() - self.assertEqual(motion.versions.count(), 1) - - def test_update_comment_creates_log_entry(self): - field_name = 'comment_field_name_texl2i7%sookqerpl29a' - config['motions_comments'] = { - '1': { - 'name': field_name, - 'public': False - } - } - - # Update Config cache - CollectionElement.from_instance( - ConfigStore.objects.get(key='motions_comments') - ) - - response = self.client.patch( - reverse('motion-detail', args=[self.motion.pk]), - {'title': 'title_test_sfdAaufd56HR7sd5FDq7av', - 'text': 'text_test_fiuhefF86()ew1Ef346AF6W', - 'comments': {'1': 'comment1_sdpoiuffo3%7dwDwW)'} - }, - format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - - motion_logs = MotionLog.objects.filter(motion=self.motion) - self.assertEqual(motion_logs.count(), 2) - - motion_log = motion_logs.order_by('-time').first() - self.assertTrue(field_name in motion_log.message_list[0]) - class DeleteMotion(TestCase): """ @@ -659,11 +584,9 @@ class DeleteMotion(TestCase): self.assertEqual(motions, 0) def make_admin_delegate(self): - group_admin = self.admin.groups.get(name='Admin') - group_delegates = Group.objects.get(name='Delegates') - self.admin.groups.remove(group_admin) - self.admin.groups.add(group_delegates) - CollectionElement.from_instance(self.admin) + self.admin.groups.remove(GROUP_ADMIN_PK) + self.admin.groups.add(GROUP_DELEGATE_PK) + inform_changed_data(self.admin) def put_motion_in_complex_workflow(self): workflow = Workflow.objects.get(name='Complex Workflow') @@ -690,51 +613,6 @@ class DeleteMotion(TestCase): self.assertEqual(motions, 0) -class ManageVersion(TestCase): - """ - Tests permitting and deleting versions. - """ - def setUp(self): - self.client = APIClient() - self.client.login(username='admin', password='admin') - self.motion = Motion( - title='test_title_InieJ5HieZieg1Meid7K', - text='test_text_daePhougho7EenguWe4g') - self.motion.save() - self.version_2 = self.motion.get_new_version(title='new_title_fee7tef0seechazeefiW') - self.motion.save(use_version=self.version_2) - - def test_permit(self): - self.assertEqual(Motion.objects.get(pk=self.motion.pk).active_version.version_number, 2) - response = self.client.put( - reverse('motion-manage-version', args=[self.motion.pk]), - {'version_number': '1'}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, {'detail': 'Version 1 permitted successfully.'}) - self.assertEqual(Motion.objects.get(pk=self.motion.pk).active_version.version_number, 1) - - def test_permit_invalid_version(self): - response = self.client.put( - reverse('motion-manage-version', args=[self.motion.pk]), - {'version_number': '3'}) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_delete(self): - response = self.client.delete( - reverse('motion-manage-version', args=[self.motion.pk]), - {'version_number': '1'}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, {'detail': 'Version 1 deleted successfully.'}) - self.assertEqual(Motion.objects.get(pk=self.motion.pk).versions.count(), 1) - - def test_delete_active_version(self): - response = self.client.delete( - reverse('motion-manage-version', args=[self.motion.pk]), - {'version_number': '2'}) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data, {'detail': 'You can not delete the active version of a motion.'}) - - class ManageSubmitters(TestCase): """ Tests adding and removing of submitters. @@ -788,11 +666,9 @@ class ManageSubmitters(TestCase): def test_add_without_permission(self): admin = get_user_model().objects.get(username='admin') - group_admin = admin.groups.get(name='Admin') - group_delegates = type(group_admin).objects.get(name='Delegates') - admin.groups.add(group_delegates) - admin.groups.remove(group_admin) - CollectionElement.from_instance(admin) + admin.groups.add(GROUP_DELEGATE_PK) + admin.groups.remove(GROUP_ADMIN_PK) + inform_changed_data(admin) response = self.client.post( reverse('motion-manage-submitters', args=[self.motion.pk]), @@ -847,6 +723,518 @@ class ManageSubmitters(TestCase): self.assertEqual(self.motion.submitters.count(), 0) +class ManageComments(TestCase): + """ + Tests the manage_comment view. + + Tests creation/updating and deletion of motion comments. + """ + def setUp(self): + self.client = APIClient() + self.client.login(username='admin', password='admin') + + self.admin = get_user_model().objects.get() + self.group_out = get_group_model().objects.get(pk=GROUP_DELEGATE_PK) # The admin should not be in this group + + # Put the admin into the staff group, becaust in the admin group, he has all permissions for + # every single comment section. + self.admin.groups.add(GROUP_STAFF_PK) + self.admin.groups.remove(GROUP_ADMIN_PK) + inform_changed_data(self.admin) + self.group_in = get_group_model().objects.get(pk=GROUP_STAFF_PK) + + self.motion = Motion( + title='test_title_SlqfMw(waso0saWMPqcZ', + text='test_text_f30skclqS9wWF=xdfaSL') + self.motion.save() + + self.section_no_groups = MotionCommentSection(name='test_name_gj4Fยง(fj"(edm"ยงF3f3fs') + self.section_no_groups.save() + + self.section_read = MotionCommentSection(name='test_name_2wv30(d2S&kvelkakl39') + self.section_read.save() + self.section_read.read_groups.add(self.group_in, self.group_out) # Group out for testing multiple groups + self.section_read.write_groups.add(self.group_out) + + self.section_read_write = MotionCommentSection(name='test_name_a3m9sd0(Mw2%slkrv30,') + self.section_read_write.save() + self.section_read_write.read_groups.add(self.group_in) + self.section_read_write.write_groups.add(self.group_in) + + def test_retrieve_comment(self): + comment = MotionComment( + motion=self.motion, + section=self.section_read_write, + comment='test_comment_gwic37Csc&3lf3eo2') + comment.save() + + response = self.client.get(reverse('motion-detail', args=[self.motion.pk])) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue('comments' in response.data) + comments = response.data['comments'] + self.assertTrue(isinstance(comments, list)) + self.assertEqual(len(comments), 1) + self.assertEqual(comments[0]['comment'], 'test_comment_gwic37Csc&3lf3eo2') + + def test_retrieve_comment_no_read_permission(self): + comment = MotionComment( + motion=self.motion, + section=self.section_no_groups, + comment='test_comment_fgkj3C7veo3ijWE(j2DJ') + comment.save() + + response = self.client.get(reverse('motion-detail', args=[self.motion.pk])) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue('comments' in response.data) + comments = response.data['comments'] + self.assertTrue(isinstance(comments, list)) + self.assertEqual(len(comments), 0) + + def test_wrong_data_type(self): + response = self.client.post( + reverse('motion-manage-comments', args=[self.motion.pk]), + None, + format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data['detail'], + 'You have to provide a section_id of type int.') + + def test_wrong_comment_data_type(self): + response = self.client.post( + reverse('motion-manage-comments', args=[self.motion.pk]), + { + 'section_id': self.section_read_write.id, + 'comment': [32, 'no_correct_data'] + }, + format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data['detail'], + 'The comment should be a string.') + + def test_non_existing_section(self): + response = self.client.post( + reverse('motion-manage-comments', args=[self.motion.pk]), + { + 'section_id': 42, + }, + format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data['detail'], + 'A comment section with id 42 does not exist') + + def test_create_comment(self): + response = self.client.post( + reverse('motion-manage-comments', args=[self.motion.pk]), + { + 'section_id': self.section_read_write.pk, + 'comment': 'test_comment_fk3jrnfwsdg%fj=feijf' + }, + format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(MotionComment.objects.count(), 1) + comment = MotionComment.objects.get() + self.assertEqual(comment.comment, 'test_comment_fk3jrnfwsdg%fj=feijf') + + # Check for a log entry + motion_logs = MotionLog.objects.filter(motion=self.motion) + self.assertEqual(motion_logs.count(), 1) + comment_log = motion_logs.get() + self.assertTrue(self.section_read_write.name in comment_log.message_list[0]) + + def test_update_comment(self): + comment = MotionComment( + motion=self.motion, + section=self.section_read_write, + comment='test_comment_fji387fqwdf&ff=)Fe3j') + comment.save() + + response = self.client.post( + reverse('motion-manage-comments', args=[self.motion.pk]), + { + 'section_id': self.section_read_write.pk, + 'comment': 'test_comment_fk3jrnfwsdg%fj=feijf' + }, + format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + comment = MotionComment.objects.get() + self.assertEqual(comment.comment, 'test_comment_fk3jrnfwsdg%fj=feijf') + + # Check for a log entry + motion_logs = MotionLog.objects.filter(motion=self.motion) + self.assertEqual(motion_logs.count(), 1) + comment_log = motion_logs.get() + self.assertTrue(self.section_read_write.name in comment_log.message_list[0]) + + def test_delete_comment(self): + comment = MotionComment( + motion=self.motion, + section=self.section_read_write, + comment='test_comment_5CJ"8f23jd3j2,r93keZ') + comment.save() + + response = self.client.delete( + reverse('motion-manage-comments', args=[self.motion.pk]), + { + 'section_id': self.section_read_write.pk + }, + format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(MotionComment.objects.count(), 0) + + # Check for a log entry + motion_logs = MotionLog.objects.filter(motion=self.motion) + self.assertEqual(motion_logs.count(), 1) + comment_log = motion_logs.get() + self.assertTrue(self.section_read_write.name in comment_log.message_list[0]) + + def test_delete_not_existing_comment(self): + """ + This should fail silently; no error, if the user wants to delete + a not existing comment. + """ + response = self.client.delete( + reverse('motion-manage-comments', args=[self.motion.pk]), + { + 'section_id': self.section_read_write.pk + }, + format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(MotionComment.objects.count(), 0) + + # Check that no log entry was created + motion_logs = MotionLog.objects.filter(motion=self.motion) + self.assertEqual(motion_logs.count(), 0) + + def test_create_comment_no_write_permission(self): + response = self.client.post( + reverse('motion-manage-comments', args=[self.motion.pk]), + { + 'section_id': self.section_read.pk, + 'comment': 'test_comment_f38jfwqfj830fj4j(FU3' + }, + format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(MotionComment.objects.count(), 0) + self.assertEqual( + response.data['detail'], + 'You are not allowed to see or write to the comment section.') + + def test_update_comment_no_write_permission(self): + comment = MotionComment( + motion=self.motion, + section=self.section_read, + comment='test_comment_jg38dwiej2D832(Dยงdk)') + comment.save() + + response = self.client.post( + reverse('motion-manage-comments', args=[self.motion.pk]), + { + 'section_id': self.section_read.pk, + 'comment': 'test_comment_fk3jrnfwsdg%fj=feijf' + }, + format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + comment = MotionComment.objects.get() + self.assertEqual(comment.comment, 'test_comment_jg38dwiej2D832(Dยงdk)') + + def test_delete_comment_no_write_permission(self): + comment = MotionComment( + motion=self.motion, + section=self.section_read, + comment='test_comment_fej(NFยงkfePOF383o8DN') + comment.save() + + response = self.client.delete( + reverse('motion-manage-comments', args=[self.motion.pk]), + { + 'section_id': self.section_read.pk + }, + format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(MotionComment.objects.count(), 1) + comment = MotionComment.objects.get() + self.assertEqual(comment.comment, 'test_comment_fej(NFยงkfePOF383o8DN') + + +class TestMotionCommentSection(TestCase): + """ + Tests creating, updating and deletion of comment sections. + """ + def setUp(self): + self.client = APIClient() + self.client.login(username='admin', password='admin') + + self.admin = get_user_model().objects.get() + self.admin.groups.add(GROUP_STAFF_PK) # Put the admin in a groiup with limited permissions for testing. + self.admin.groups.remove(GROUP_ADMIN_PK) + inform_changed_data(self.admin) + self.group_in = get_group_model().objects.get(pk=GROUP_STAFF_PK) + self.group_out = get_group_model().objects.get(pk=GROUP_DELEGATE_PK) # The admin should not be in this group + + def test_retrieve(self): + """ + Checks, if the sections can be seen by a manager. + """ + section = MotionCommentSection(name='test_name_f3jOF3m8fp.New test

', 'type': '0'}) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -881,7 +1269,7 @@ class CreateMotionChangeRecommendation(TestCase): reverse('motionchangerecommendation-list'), {'line_from': '5', 'line_to': '7', - 'motion_version_id': '1', + 'motion_id': '1', 'text': '

New test

', 'type': '0'}) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -890,7 +1278,7 @@ class CreateMotionChangeRecommendation(TestCase): reverse('motionchangerecommendation-list'), {'line_from': '3', 'line_to': '6', - 'motion_version_id': '1', + 'motion_id': '1', 'text': '

New test

', 'type': '0'}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -909,7 +1297,7 @@ class CreateMotionChangeRecommendation(TestCase): reverse('motionchangerecommendation-list'), {'line_from': '5', 'line_to': '7', - 'motion_version_id': '1', + 'motion_id': '1', 'text': '

New test

', 'type': '0'}) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -918,7 +1306,7 @@ class CreateMotionChangeRecommendation(TestCase): reverse('motionchangerecommendation-list'), {'line_from': '3', 'line_to': '6', - 'motion_version_id': '2', + 'motion_id': '2', 'text': '

New test

', 'type': '0'}) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -930,7 +1318,8 @@ class SupportMotion(TestCase): """ def setUp(self): self.admin = get_user_model().objects.get(username='admin') - self.admin.groups.add(2) + self.admin.groups.add(GROUP_DELEGATE_PK) + inform_changed_data(self.admin) self.client.login(username='admin', password='admin') self.motion = Motion( title='test_title_chee7ahCha6bingaew4e', @@ -939,7 +1328,7 @@ class SupportMotion(TestCase): def test_support(self): config['motions_min_supporters'] = 1 - get_redis_connection('default').flushall() + response = self.client.post(reverse('motion-support', args=[self.motion.pk])) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, {'detail': 'You have supported this motion successfully.'}) diff --git a/tests/integration/topics/test_viewset.py b/tests/integration/topics/test_viewset.py index 5acab19c2..fc0bd1406 100644 --- a/tests/integration/topics/test_viewset.py +++ b/tests/integration/topics/test_viewset.py @@ -1,54 +1,26 @@ -from django.contrib.auth import get_user_model +import pytest from django.urls import reverse -from django_redis import get_redis_connection from rest_framework import status -from rest_framework.test import APIClient from openslides.agenda.models import Item -from openslides.core.config import config from openslides.topics.models import Topic from openslides.utils.test import TestCase +from ..helpers import count_queries -class TestDBQueries(TestCase): + +@pytest.mark.django_db(transaction=False) +def test_topic_item_db_queries(): """ - Tests that receiving elements only need the required db queries. - - Therefore in setup some topics are created and received with different - user accounts. + Tests that only the following db queries are done: + * 1 requests to get the list of all topics, + * 1 request to get attachments, + * 1 request to get the agenda item """ + for index in range(10): + Topic.objects.create(title='topic-{}'.format(index)) - def setUp(self): - self.client = APIClient() - config['general_system_enable_anonymous'] = True - config.save_default_values() - for index in range(10): - Topic.objects.create(title='topic-{}'.format(index)) - - def test_admin(self): - """ - Tests that only the following db queries are done: - * 7 requests to get the session an the request user with its permissions, - * 1 requests to get the list of all topics, - * 1 request to get attachments, - * 1 request to get the agenda item - """ - self.client.force_login(get_user_model().objects.get(pk=1)) - get_redis_connection('default').flushall() - with self.assertNumQueries(10): - self.client.get(reverse('topic-list')) - - def test_anonymous(self): - """ - Tests that only the following db queries are done: - * 3 requests to get the permission for anonymous, - * 1 requests to get the list of all topics, - * 1 request to get attachments, - * 1 request to get the agenda item, - """ - get_redis_connection('default').flushall() - with self.assertNumQueries(6): - self.client.get(reverse('topic-list')) + assert count_queries(Topic.get_elements) == 3 class TopicCreate(TestCase): diff --git a/tests/integration/users/test_views.py b/tests/integration/users/test_views.py index 8e006aa7a..c70a62ae9 100644 --- a/tests/integration/users/test_views.py +++ b/tests/integration/users/test_views.py @@ -1,12 +1,13 @@ import json +from django.urls import reverse from rest_framework.test import APIClient from openslides.utils.test import TestCase class TestWhoAmIView(TestCase): - url = '/users/whoami/' + url = reverse('user_whoami') def test_get_anonymous(self): response = self.client.get(self.url) @@ -32,7 +33,7 @@ class TestWhoAmIView(TestCase): class TestUserLogoutView(TestCase): - url = '/users/logout/' + url = reverse('user_logout') def test_get(self): response = self.client.get(self.url) @@ -55,7 +56,7 @@ class TestUserLogoutView(TestCase): class TestUserLoginView(TestCase): - url = '/users/login/' + url = reverse('user_login') def setUp(self): self.client = APIClient() diff --git a/tests/integration/users/test_viewset.py b/tests/integration/users/test_viewset.py index 3d151786e..6eb270465 100644 --- a/tests/integration/users/test_viewset.py +++ b/tests/integration/users/test_viewset.py @@ -1,92 +1,42 @@ +import pytest from django.core import mail from django.urls import reverse -from django_redis import get_redis_connection from rest_framework import status from rest_framework.test import APIClient from openslides.core.config import config from openslides.users.models import Group, PersonalNote, User from openslides.users.serializers import UserFullSerializer +from openslides.utils.autoupdate import inform_changed_data from openslides.utils.test import TestCase +from ..helpers import count_queries -class TestUserDBQueries(TestCase): + +@pytest.mark.django_db(transaction=False) +def test_user_db_queries(): """ - Tests that receiving elements only need the required db queries. - - Therefore in setup some objects are created and received with different - user accounts. + Tests that only the following db queries are done: + * 2 requests to get the list of all users and + * 1 requests to get the list of all groups. """ + for index in range(10): + User.objects.create(username='user{}'.format(index)) - def setUp(self): - self.client = APIClient() - config['general_system_enable_anonymous'] = True - config.save_default_values() - for index in range(10): - User.objects.create(username='user{}'.format(index)) - - def test_admin(self): - """ - Tests that only the following db queries are done: - * 2 requests to get the session and the request user with its permissions, - * 2 requests to get the list of all users and - * 1 requests to get the list of all groups. - """ - self.client.force_login(User.objects.get(pk=1)) - get_redis_connection('default').flushall() - with self.assertNumQueries(7): - self.client.get(reverse('user-list')) - - def test_anonymous(self): - """ - Tests that only the following db queries are done: - * 3 requests to get the permission for anonymous, - * 1 requests to get the list of all users and - * 2 request to get all groups (needed by the user serializer). - """ - get_redis_connection('default').flushall() - with self.assertNumQueries(6): - self.client.get(reverse('user-list')) + assert count_queries(User.get_elements) == 3 -class TestGroupDBQueries(TestCase): +@pytest.mark.django_db(transaction=False) +def test_group_db_queries(): """ - Tests that receiving elements only need the required db queries. - - Therefore in setup some objects are created and received with different - user accounts. + Tests that only the following db queries are done: + * 1 request to get the list of all groups. + * 1 request to get the permissions """ + for index in range(10): + Group.objects.create(name='group{}'.format(index)) - def setUp(self): - self.client = APIClient() - config['general_system_enable_anonymous'] = True - config.save_default_values() - for index in range(10): - Group.objects.create(name='group{}'.format(index)) - - def test_admin(self): - """ - Tests that only the following db queries are done: - * 6 requests to get the session an the request user with its permissions and - * 1 request to get the list of all groups. - - The data of the groups where loaded when the admin was authenticated. So - only the list of all groups has be fetched from the db. - """ - self.client.force_login(User.objects.get(pk=1)) - get_redis_connection('default').flushall() - with self.assertNumQueries(7): - self.client.get(reverse('group-list')) - - def test_anonymous(self): - """ - Tests that only the following db queries are done: - * 1 requests to find out if anonymous is enabled - * 2 request to get the list of all groups and - """ - get_redis_connection('default').flushall() - with self.assertNumQueries(3): - self.client.get(reverse('group-list')) + assert count_queries(Group.get_elements) == 2 class UserGetTest(TestCase): @@ -98,7 +48,7 @@ class UserGetTest(TestCase): It is invalid, that a user is in the group with the pk 1. But if the database is invalid, the user should nevertheless be received. """ - admin = User.objects.get(pk=1) + admin = User.objects.get(username='admin') group1 = Group.objects.get(pk=1) admin.groups.add(group1) self.client.login(username='admin', password='admin') @@ -113,6 +63,7 @@ class UserGetTest(TestCase): app_label, codename = permission_string.split('.') permission = group.permissions.get(content_type__app_label=app_label, codename=codename) group.permissions.remove(permission) + inform_changed_data(group) config['general_system_enable_anonymous'] = True guest_client = APIClient() @@ -178,7 +129,7 @@ class UserUpdate(TestCase): admin_client = APIClient() admin_client.login(username='admin', password='admin') # This is the builtin user 'Administrator' with username 'admin'. The pk is valid. - user_pk = 1 + user_pk = User.objects.get(username='admin').pk response = admin_client.patch( reverse('user-detail', args=[user_pk]), @@ -198,14 +149,14 @@ class UserUpdate(TestCase): admin_client = APIClient() admin_client.login(username='admin', password='admin') # This is the builtin user 'Administrator'. The pk is valid. - user_pk = 1 + user_pk = User.objects.get(username='admin').pk response = admin_client.put( reverse('user-detail', args=[user_pk]), {'last_name': 'New name Ohy4eeyei5'}) self.assertEqual(response.status_code, 200) - self.assertEqual(User.objects.get(pk=1).username, 'New name Ohy4eeyei5') + self.assertEqual(User.objects.get(pk=user_pk).username, 'New name Ohy4eeyei5') def test_update_deactivate_yourselfself(self): """ @@ -214,7 +165,7 @@ class UserUpdate(TestCase): admin_client = APIClient() admin_client.login(username='admin', password='admin') # This is the builtin user 'Administrator'. The pk is valid. - user_pk = 1 + user_pk = User.objects.get(username='admin').pk response = admin_client.patch( reverse('user-detail', args=[user_pk]), @@ -531,8 +482,6 @@ class GroupUpdate(TestCase): 'motions.can_create', 'motions.can_manage', 'motions.can_see', - 'motions.can_manage_comments', - 'motions.can_see_comments', 'motions.can_support', 'users.can_manage', 'users.can_see_extra_data', @@ -581,7 +530,7 @@ class PersonalNoteTest(TestCase): Tests for PersonalNote model. """ def test_anonymous_without_personal_notes(self): - admin = User.objects.get(pk=1) + admin = User.objects.get(username='admin') personal_note = PersonalNote.objects.create(user=admin, notes='["admin_personal_note_OoGh8choro0oosh0roob"]') config['general_system_enable_anonymous'] = True guest_client = APIClient() diff --git a/tests/integration/utils/test_collection.py b/tests/integration/utils/test_collection.py index 202c9a3c1..873d43fc8 100644 --- a/tests/integration/utils/test_collection.py +++ b/tests/integration/utils/test_collection.py @@ -1,24 +1,9 @@ -from channels.tests import ChannelTestCase as TestCase -from django_redis import get_redis_connection - from openslides.topics.models import Topic from openslides.utils import collection +from openslides.utils.test import TestCase class TestCollectionElementCache(TestCase): - def test_clean_cache(self): - """ - Tests that the data is retrieved from the database. - """ - topic = Topic.objects.create(title='test topic') - get_redis_connection("default").flushall() - - with self.assertNumQueries(3): - collection_element = collection.CollectionElement.from_values('topics/topic', 1) - instance = collection_element.get_full_data() - - self.assertEqual(topic.title, instance['title']) - def test_with_cache(self): """ Tests that no db query is used when the valie is in the cache. @@ -44,25 +29,10 @@ class TestCollectionElementCache(TestCase): class TestCollectionCache(TestCase): - def test_clean_cache(self): - """ - Tests that the instances are retrieved from the database. - """ - Topic.objects.create(title='test topic1') - Topic.objects.create(title='test topic2') - Topic.objects.create(title='test topic3') - topic_collection = collection.Collection('topics/topic') - get_redis_connection("default").flushall() - - with self.assertNumQueries(3): - instance_list = list(topic_collection.get_full_data()) - self.assertEqual(len(instance_list), 3) - def test_with_cache(self): """ Tests that no db query is used when the list is received twice. """ - get_redis_connection("default").flushall() Topic.objects.create(title='test topic1') Topic.objects.create(title='test topic2') Topic.objects.create(title='test topic3') diff --git a/tests/integration/utils/test_consumers.py b/tests/integration/utils/test_consumers.py new file mode 100644 index 000000000..89c947948 --- /dev/null +++ b/tests/integration/utils/test_consumers.py @@ -0,0 +1,258 @@ +from importlib import import_module + +import pytest +from asgiref.sync import sync_to_async +from channels.testing import WebsocketCommunicator +from django.conf import settings +from django.contrib.auth import ( + BACKEND_SESSION_KEY, + HASH_SESSION_KEY, + SESSION_KEY, +) + +from openslides.asgi import application +from openslides.core.config import config +from openslides.utils.autoupdate import inform_deleted_data +from openslides.utils.cache import element_cache + +from ...unit.utils.cache_provider import ( + Collection1, + Collection2, + get_cachable_provider, +) +from ..helpers import TConfig, TUser, set_config + + +@pytest.fixture(autouse=True) +async def prepare_element_cache(settings): + """ + Resets the element cache. + + Uses a cacheable_provider for tests with example data. + """ + await element_cache.cache_provider.clear_cache() + orig_cachable_provider = element_cache.cachable_provider + element_cache.cachable_provider = get_cachable_provider([Collection1(), Collection2(), TConfig(), TUser()]) + element_cache._cachables = None + await sync_to_async(element_cache.ensure_cache)() + yield + # Reset the cachable_provider + element_cache.cachable_provider = orig_cachable_provider + element_cache._cachables = None + await element_cache.cache_provider.clear_cache() + + +@pytest.fixture +async def communicator(request, event_loop): + communicator = WebsocketCommunicator(application, "/ws/site/") + yield communicator + await communicator.disconnect() + + +@pytest.mark.asyncio +async def test_normal_connection(communicator): + await set_config('general_system_enable_anonymous', True) + await communicator.connect() + + response = await communicator.receive_json_from() + + type = response.get('type') + content = response.get('content') + assert type == 'autoupdate' + # Test, that both example objects are returned + assert len(content) > 10 + + +@pytest.mark.asyncio +async def test_receive_changed_data(communicator): + await set_config('general_system_enable_anonymous', True) + await communicator.connect() + await communicator.receive_json_from() + + # Change a config value after the startup data has been received + await set_config('general_event_name', 'Test Event') + response = await communicator.receive_json_from() + + id = config.get_key_to_id()['general_event_name'] + type = response.get('type') + content = response.get('content') + assert type == 'autoupdate' + assert content == [ + {'action': 'changed', + 'collection': 'core/config', + 'data': {'id': id, 'key': 'general_event_name', 'value': 'Test Event'}, + 'id': id}] + + +@pytest.mark.asyncio +async def test_anonymous_disabled(communicator): + connected, __ = await communicator.connect() + + assert not connected + + +@pytest.mark.asyncio +async def test_with_user(): + # login user with id 1 + engine = import_module(settings.SESSION_ENGINE) + session = engine.SessionStore() # type: ignore + session[SESSION_KEY] = '1' + session[HASH_SESSION_KEY] = '362d4f2de1463293cb3aaba7727c967c35de43ee' # see helpers.TUser + session[BACKEND_SESSION_KEY] = 'django.contrib.auth.backends.ModelBackend' + session.save() + scn = settings.SESSION_COOKIE_NAME + cookies = (b'cookie', '{}={}'.format(scn, session.session_key).encode()) + communicator = WebsocketCommunicator(application, "/ws/site/", headers=[cookies]) + + connected, __ = await communicator.connect() + + assert connected + + await communicator.disconnect() + + +@pytest.mark.asyncio +async def test_receive_deleted_data(communicator): + await set_config('general_system_enable_anonymous', True) + await communicator.connect() + await communicator.receive_json_from() + + # Delete test element + await sync_to_async(inform_deleted_data)([(Collection1().get_collection_string(), 1)]) + response = await communicator.receive_json_from() + + type = response.get('type') + content = response.get('content') + assert type == 'autoupdate' + assert content == [{'action': 'deleted', 'collection': Collection1().get_collection_string(), 'id': 1}] + + +@pytest.mark.asyncio +async def test_send_invalid_notify_not_a_list(communicator): + await set_config('general_system_enable_anonymous', True) + await communicator.connect() + # Await the startup data + await communicator.receive_json_from() + + await communicator.send_json_to({'type': 'notify', 'content': {'testmessage': 'foobar, what else.'}, 'id': 'test_send_invalid_notify_not_a_list'}) + response = await communicator.receive_json_from() + + assert response['type'] == 'error' + assert response['content'] == 'Invalid notify message' + assert response['in_response'] == 'test_send_invalid_notify_not_a_list' + + +@pytest.mark.asyncio +async def test_send_invalid_notify_no_elements(communicator): + await set_config('general_system_enable_anonymous', True) + await communicator.connect() + # Await the startup data + await communicator.receive_json_from() + + await communicator.send_json_to({'type': 'notify', 'content': [], 'id': 'test_send_invalid_notify_no_elements'}) + response = await communicator.receive_json_from() + + assert response['type'] == 'error' + assert response['content'] == 'Invalid notify message' + assert response['in_response'] == 'test_send_invalid_notify_no_elements' + + +@pytest.mark.asyncio +async def test_send_invalid_notify_str_in_list(communicator): + await set_config('general_system_enable_anonymous', True) + await communicator.connect() + # Await the startup data + await communicator.receive_json_from() + + await communicator.send_json_to({'type': 'notify', 'content': [{}, 'testmessage'], 'id': 'test_send_invalid_notify_str_in_list'}) + response = await communicator.receive_json_from() + + assert response['type'] == 'error' + assert response['content'] == 'Invalid notify message' + assert response['in_response'] == 'test_send_invalid_notify_str_in_list' + + +@pytest.mark.asyncio +async def test_send_valid_notify(communicator): + await set_config('general_system_enable_anonymous', True) + await communicator.connect() + # Await the startup data + await communicator.receive_json_from() + + await communicator.send_json_to({'type': 'notify', 'content': [{'testmessage': 'foobar, what else.'}], 'id': 'test'}) + response = await communicator.receive_json_from() + + content = response['content'] + assert isinstance(content, list) + assert len(content) == 1 + assert content[0]['testmessage'] == 'foobar, what else.' + assert 'senderReplyChannelName' in content[0] + assert content[0]['senderUserId'] == 0 + + +@pytest.mark.asyncio +async def test_invalid_websocket_message_type(communicator): + await set_config('general_system_enable_anonymous', True) + await communicator.connect() + # Await the startup data + await communicator.receive_json_from() + + await communicator.send_json_to([]) + + response = await communicator.receive_json_from() + assert response['type'] == 'error' + + +@pytest.mark.asyncio +async def test_invalid_websocket_message_no_id(communicator): + await set_config('general_system_enable_anonymous', True) + await communicator.connect() + # Await the startup data + await communicator.receive_json_from() + + await communicator.send_json_to({'type': 'test', 'content': 'foobar'}) + + response = await communicator.receive_json_from() + assert response['type'] == 'error' + + +@pytest.mark.asyncio +async def test_invalid_websocket_message_no_content(communicator): + await set_config('general_system_enable_anonymous', True) + await communicator.connect() + # Await the startup data + await communicator.receive_json_from() + + await communicator.send_json_to({'type': 'test', 'id': 'test_id'}) + + response = await communicator.receive_json_from() + assert response['type'] == 'error' + + +@pytest.mark.asyncio +async def test_send_unknown_type(communicator): + await set_config('general_system_enable_anonymous', True) + await communicator.connect() + # Await the startup data + await communicator.receive_json_from() + + await communicator.send_json_to({'type': 'if_you_add_this_type_to_openslides_I_will_be_sad', 'content': True, 'id': 'test_id'}) + + response = await communicator.receive_json_from() + assert response['type'] == 'error' + assert response['in_response'] == 'test_id' + + +@pytest.mark.asyncio +async def test_request_constants(communicator, settings): + await set_config('general_system_enable_anonymous', True) + await communicator.connect() + # Await the startup data + await communicator.receive_json_from() + + await communicator.send_json_to({'type': 'constants', 'content': '', 'id': 'test_id'}) + + response = await communicator.receive_json_from() + assert response['type'] == 'constants' + # See conftest.py for the content of 'content' + assert response['content'] == {'constant1': 'value1', 'constant2': 'value2'} diff --git a/tests/old/config/test_config.py b/tests/old/config/test_config.py index c7d7e0301..c57f17f3c 100644 --- a/tests/old/config/test_config.py +++ b/tests/old/config/test_config.py @@ -1,11 +1,11 @@ -from django_redis import get_redis_connection + from openslides.core.config import ConfigVariable, config -from openslides.core.exceptions import ConfigError, ConfigNotFound +from openslides.core.exceptions import ConfigError from openslides.utils.test import TestCase -class TestConfigException(Exception): +class TTestConfigException(Exception): pass @@ -32,17 +32,6 @@ class HandleConfigTest(TestCase): def set_config_var(self, key, value): config[key] = value - def test_get_config_default_value(self): - self.assertEqual(config['string_var'], 'default_string_rien4ooCZieng6ah') - self.assertTrue(config['bool_var']) - self.assertEqual(config['integer_var'], 3) - self.assertEqual(config['choices_var'], '1') - self.assertEqual(config['none_config_var'], None) - with self.assertRaisesMessage( - ConfigNotFound, - 'The config variable unknown_config_var was not found.'): - self.get_config_var('unknown_config_var') - def test_get_multiple_config_var_error(self): with self.assertRaisesMessage( ConfigError, @@ -54,15 +43,6 @@ class HandleConfigTest(TestCase): self.assertRaises(TypeError, ConfigVariable, name='foo') self.assertRaises(TypeError, ConfigVariable, default_value='foo') - def test_change_config_value(self): - self.assertEqual(config['string_var'], 'default_string_rien4ooCZieng6ah') - config['string_var'] = 'other_special_unique_string dauTex9eAiy7jeen' - get_redis_connection('default').flushall() - self.assertEqual(config['string_var'], 'other_special_unique_string dauTex9eAiy7jeen') - - def test_missing_cache_(self): - self.assertEqual(config['string_var'], 'default_string_rien4ooCZieng6ah') - def test_config_exists(self): self.assertTrue(config.exists('string_var')) self.assertFalse(config.exists('unknown_config_var')) @@ -79,7 +59,7 @@ class HandleConfigTest(TestCase): message. """ with self.assertRaisesMessage( - TestConfigException, + TTestConfigException, 'Change callback dhcnfg34dlg06kdg successfully called.'): self.set_config_var( key='var_with_callback_ghvnfjd5768gdfkwg0hm2', @@ -155,7 +135,7 @@ def set_simple_config_collection_disabled_view(): def set_simple_config_collection_with_callback(): def callback(): - raise TestConfigException('Change callback dhcnfg34dlg06kdg successfully called.') + raise TTestConfigException('Change callback dhcnfg34dlg06kdg successfully called.') yield ConfigVariable( name='var_with_callback_ghvnfjd5768gdfkwg0hm2', default_value='', diff --git a/tests/old/motions/test_models.py b/tests/old/motions/test_models.py index 0869c6cdd..9f742553c 100644 --- a/tests/old/motions/test_models.py +++ b/tests/old/motions/test_models.py @@ -1,5 +1,3 @@ -from django_redis import get_redis_connection - from openslides.core.config import config from openslides.motions.exceptions import WorkflowError from openslides.motions.models import Motion, State, Workflow @@ -14,48 +12,6 @@ class ModelTest(TestCase): # Use the simple workflow self.workflow = Workflow.objects.get(pk=1) - def test_create_new_version(self): - motion = self.motion - self.assertEqual(motion.versions.count(), 1) - - # new data, but no new version - motion.title = 'new title' - motion.save() - self.assertEqual(motion.versions.count(), 1) - - # new data and new version - motion.text = 'new text' - motion.save(use_version=motion.get_new_version()) - self.assertEqual(motion.versions.count(), 2) - self.assertEqual(motion.title, 'new title') - self.assertEqual(motion.text, 'new text') - - def test_version_data(self): - motion = Motion() - self.assertEqual(motion.title, '') - with self.assertRaises(AttributeError): - self._title - - motion.title = 'title' - self.assertEqual(motion._title, 'title') - - motion.text = 'text' - self.assertEqual(motion._text, 'text') - - motion.reason = 'reason' - self.assertEqual(motion._reason, 'reason') - - def test_version(self): - motion = self.motion - - motion.title = 'v2' - motion.save(use_version=motion.get_new_version()) - motion.title = 'v3' - motion.save(use_version=motion.get_new_version()) - with self.assertRaises(AttributeError): - self._title - self.assertEqual(motion.title, 'v3') - def test_supporter(self): self.assertFalse(self.motion.is_supporter(self.test_user)) self.motion.supporters.add(self.test_user) @@ -99,40 +55,8 @@ class ModelTest(TestCase): Motion.objects.create(title='foo', text='bar', identifier='') Motion.objects.create(title='foo2', text='bar2', identifier='') - def test_do_not_create_new_version_when_permit_old_version(self): - motion = Motion() - motion.title = 'foo' - motion.text = 'bar' - motion.save() - first_version = motion.get_last_version() - - motion = Motion.objects.get(pk=motion.pk) - motion.title = 'New Title' - motion.save(use_version=motion.get_new_version()) - new_version = motion.get_last_version() - self.assertEqual(motion.versions.count(), 2) - - motion.active_version = new_version - motion.save() - self.assertEqual(motion.versions.count(), 2) - - motion.active_version = first_version - motion.save(use_version=False) - self.assertEqual(motion.versions.count(), 2) - - def test_unicode_with_no_active_version(self): - motion = Motion.objects.create( - title='test_title_Koowoh1ISheemeey1air', - text='test_text_zieFohph0doChi1Uiyoh', - identifier='test_identifier_VohT1hu9uhiSh6ooVBFS') - motion.active_version = None - motion.save(update_fields=['active_version']) - # motion.__unicode__() raised an AttributeError - self.assertEqual(str(motion), 'test_title_Koowoh1ISheemeey1air') - def test_is_amendment(self): config['motions_amendments_enabled'] = True - get_redis_connection('default').flushall() amendment = Motion.objects.create(title='amendment', parent=self.motion) self.assertTrue(amendment.is_amendment()) @@ -153,7 +77,6 @@ class ModelTest(TestCase): If the config is set to manually, the method does nothing. """ config['motions_identifier'] = 'manually' - get_redis_connection("default").flushall() motion = Motion() motion.set_identifier() @@ -169,7 +92,6 @@ class ModelTest(TestCase): config['motions_amendments_enabled'] = True self.motion.identifier = 'Parent identifier' self.motion.save() - get_redis_connection("default").flushall() motion = Motion(parent=self.motion) motion.set_identifier() @@ -184,7 +106,6 @@ class ModelTest(TestCase): config['motions_amendments_enabled'] = True self.motion.identifier = 'Parent identifier' self.motion.save() - get_redis_connection("default").flushall() Motion.objects.create(title='Amendment1', parent=self.motion) motion = Motion(parent=self.motion) diff --git a/tests/settings.py b/tests/settings.py index 92bfecd69..fa2083111 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -6,6 +6,7 @@ import os from openslides.global_settings import * # noqa + # Path to the directory for user specific data files OPENSLIDES_USER_DATA_PATH = os.path.realpath(os.path.dirname(os.path.abspath(__file__))) @@ -41,11 +42,8 @@ DATABASES = { 'ENGINE': 'django.db.backends.sqlite3', } } - -# When use_redis is True, the restricted data cache caches the data individuel -# for each user. This requires a lot of memory if there are a lot of active -# users. If use_redis is False, this setting has no effect. -DISABLE_USER_CACHE = False +REDIS_ADDRESS = "redis://127.0.0.1" +SESSION_ENGINE = "django.contrib.sessions.backends.cache" # Internationalization # https://docs.djangoproject.com/en/1.10/topics/i18n/ @@ -72,20 +70,8 @@ MOTION_IDENTIFIER_MIN_DIGITS = 1 # Special settings only for testing -TEST_RUNNER = 'openslides.utils.test.OpenSlidesDiscoverRunner' - # Use a faster password hasher. PASSWORD_HASHERS = [ 'django.contrib.auth.hashers.MD5PasswordHasher', ] - -CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379/0", - "OPTIONS": { - "REDIS_CLIENT_CLASS": "fakeredis.FakeStrictRedis", - } - } -} diff --git a/tests/unit/motions/test_models.py b/tests/unit/motions/test_models.py index f8c2bddba..42fb72d04 100644 --- a/tests/unit/motions/test_models.py +++ b/tests/unit/motions/test_models.py @@ -1,6 +1,6 @@ from unittest import TestCase -from openslides.motions.models import MotionChangeRecommendation, MotionVersion +from openslides.motions.models import Motion, MotionChangeRecommendation class MotionChangeRecommendationTest(TestCase): @@ -8,12 +8,12 @@ class MotionChangeRecommendationTest(TestCase): """ Tests that a change recommendation directly before another one can be created """ - version = MotionVersion() + motion = Motion() existing_recommendation = MotionChangeRecommendation() existing_recommendation.line_from = 5 existing_recommendation.line_to = 7 existing_recommendation.rejected = False - existing_recommendation.motion_version = version + existing_recommendation.motion = motion other_recommendations = [existing_recommendation] new_recommendation1 = MotionChangeRecommendation() diff --git a/tests/unit/motions/test_views.py b/tests/unit/motions/test_views.py index 06275498a..e50734d7d 100644 --- a/tests/unit/motions/test_views.py +++ b/tests/unit/motions/test_views.py @@ -22,33 +22,9 @@ class MotionViewSetUpdate(TestCase): @patch('openslides.motions.views.config') def test_simple_update(self, mock_config, mock_has_perm, mock_icd): self.request.user = 1 - self.request.data.get.return_value = versioning_mock = MagicMock() + self.request.data.get.return_value = MagicMock() mock_has_perm.return_value = True self.view_instance.update(self.request) - self.mock_serializer.save.assert_called_with(disable_versioning=versioning_mock) - - -class MotionViewSetManageVersion(TestCase): - """ - Tests views of MotionViewSet to manage versions. - """ - def setUp(self): - self.request = MagicMock() - self.view_instance = MotionViewSet() - self.view_instance.request = self.request - self.view_instance.get_object = get_object_mock = MagicMock() - get_object_mock.return_value = self.mock_motion = MagicMock() - - def test_activate_version(self): - self.request.method = 'PUT' - self.request.user.has_perm.return_value = True - self.view_instance.manage_version(self.request) - self.mock_motion.save.assert_called_with(update_fields=['active_version']) - - def test_delete_version(self): - self.request.method = 'DELETE' - self.request.user.has_perm.return_value = True - self.view_instance.manage_version(self.request) - self.mock_motion.versions.get.return_value.delete.assert_called_with() + self.mock_serializer.save.assert_called() diff --git a/tests/unit/users/test_models.py b/tests/unit/users/test_models.py index 0d640fec5..5f20a1872 100644 --- a/tests/unit/users/test_models.py +++ b/tests/unit/users/test_models.py @@ -123,19 +123,15 @@ class UserManagerGeneratePassword(TestCase): class UserManagerCreateOrResetAdminUser(TestCase): def test_add_admin_group(self, mock_group, mock_permission): """ - Tests that the Group with name='Staff' is added to the admin. + Tests that the Group with pk=2 (Admin group) is added to the admin. """ admin_user = MagicMock() manager = UserManager() manager.get_or_create = MagicMock(return_value=(admin_user, False)) - staff_group = MagicMock(name="Staff") - mock_group.objects.get_or_create = MagicMock(return_value=(staff_group, True)) - mock_permission.get = MagicMock() - manager.create_or_reset_admin_user() - admin_user.groups.add.assert_called_once_with(staff_group) + admin_user.groups.add.assert_called_once_with(2) # the admin should be added to the admin group with pk=2 def test_password_set_to_admin(self, mock_group, mock_permission): """ @@ -200,24 +196,3 @@ class UserManagerCreateOrResetAdminUser(TestCase): admin_user.last_name, 'Administrator', "The last_name of a new created admin should be 'Administrator'.") - - def test_get_permissions(self, mock_group, mock_permission): - """ - Tests if two permissions are get - """ - admin_user = MagicMock() - manager = UserManager() - manager.get_or_create = MagicMock(return_value=(admin_user, True)) - - staff_group = MagicMock(name="Staff") - mock_group.objects.get_or_create = MagicMock(return_value=(staff_group, True)) - - permission_mock = MagicMock(name="test permission") - mock_permission.objects.get = MagicMock(return_value=permission_mock) - - manager.create_or_reset_admin_user() - - self.assertEqual( - mock_permission.objects.get.call_count, - 2, - "Permission.get should be called two times") diff --git a/tests/unit/utils/cache_provider.py b/tests/unit/utils/cache_provider.py new file mode 100644 index 000000000..4530d042a --- /dev/null +++ b/tests/unit/utils/cache_provider.py @@ -0,0 +1,82 @@ +import asyncio +from typing import Any, Callable, Dict, List, Optional + +from openslides.utils.cache_providers import Cachable, MemmoryCacheProvider +from openslides.utils.collection import CollectionElement + + +def restrict_elements( + user: Optional[CollectionElement], + elements: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Adds the prefix 'restricted_' to all values except id. + """ + out = [] + for element in elements: + restricted_element = {} + for key, value in element.items(): + if key == 'id': + restricted_element[key] = value + else: + restricted_element[key] = 'restricted_{}'.format(value) + out.append(restricted_element) + return out + + +class Collection1(Cachable): + def get_collection_string(self) -> str: + return 'app/collection1' + + def get_elements(self) -> List[Dict[str, Any]]: + return [ + {'id': 1, 'value': 'value1'}, + {'id': 2, 'value': 'value2'}] + + def restrict_elements(self, user: Optional[CollectionElement], elements: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return restrict_elements(user, elements) + + +class Collection2(Cachable): + def get_collection_string(self) -> str: + return 'app/collection2' + + def get_elements(self) -> List[Dict[str, Any]]: + return [ + {'id': 1, 'key': 'value1'}, + {'id': 2, 'key': 'value2'}] + + def restrict_elements(self, user: Optional[CollectionElement], elements: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return restrict_elements(user, elements) + + +def get_cachable_provider(cachables: List[Cachable] = [Collection1(), Collection2()]) -> Callable[[], List[Cachable]]: + """ + Returns a cachable_provider. + """ + return lambda: cachables + + +def example_data(): + return { + 'app/collection1': [ + {'id': 1, 'value': 'value1'}, + {'id': 2, 'value': 'value2'}], + 'app/collection2': [ + {'id': 1, 'key': 'value1'}, + {'id': 2, 'key': 'value2'}]} + + +class TTestCacheProvider(MemmoryCacheProvider): + """ + CacheProvider simular to the MemmoryCacheProvider with special methods for + testing. + """ + + async def del_lock_after_wait(self, lock_name: str, future: asyncio.Future = None) -> None: + if future is None: + asyncio.ensure_future(self.del_lock(lock_name)) + else: + async def set_future() -> None: + await self.del_lock(lock_name) + future.set_result(1) # type: ignore + asyncio.ensure_future(set_future()) diff --git a/tests/unit/utils/test_cache.py b/tests/unit/utils/test_cache.py new file mode 100644 index 000000000..f90ac7623 --- /dev/null +++ b/tests/unit/utils/test_cache.py @@ -0,0 +1,443 @@ +import asyncio +import json +from typing import Any, Dict, List + +import pytest + +from openslides.utils.cache import ElementCache + +from .cache_provider import ( + TTestCacheProvider, + example_data, + get_cachable_provider, +) + + +def decode_dict(encoded_dict: Dict[str, str]) -> Dict[str, Any]: + """ + Helper function that loads the json values of a dict. + """ + return {key: json.loads(value) for key, value in encoded_dict.items()} + + +def sort_dict(encoded_dict: Dict[str, List[Dict[str, Any]]]) -> Dict[str, List[Dict[str, Any]]]: + """ + Helper function that sorts the value of a dict. + """ + return {key: sorted(value, key=lambda x: x['id']) for key, value in encoded_dict.items()} + + +@pytest.fixture +def element_cache(): + element_cache = ElementCache( + 'test_redis', + cache_provider_class=TTestCacheProvider, + cachable_provider=get_cachable_provider(), + start_time=0) + element_cache.ensure_cache() + return element_cache + + +@pytest.mark.asyncio +async def test_change_elements(element_cache): + input_data = { + 'app/collection1:1': {"id": 1, "value": "updated"}, + 'app/collection1:2': {"id": 2, "value": "new"}, + 'app/collection2:1': {"id": 1, "key": "updated"}, + 'app/collection2:2': None} + + element_cache.cache_provider.full_data = { + 'app/collection1:1': '{"id": 1, "value": "old"}', + 'app/collection2:1': '{"id": 1, "key": "old"}', + 'app/collection2:2': '{"id": 2, "key": "old"}'} + + result = await element_cache.change_elements(input_data) + + assert result == 1 # first change_id + assert decode_dict(element_cache.cache_provider.full_data) == decode_dict({ + 'app/collection1:1': '{"id": 1, "value": "updated"}', + 'app/collection1:2': '{"id": 2, "value": "new"}', + 'app/collection2:1': '{"id": 1, "key": "updated"}'}) + assert element_cache.cache_provider.change_id_data == { + 1: { + 'app/collection1:1', + 'app/collection1:2', + 'app/collection2:1', + 'app/collection2:2'}} + + +@pytest.mark.asyncio +async def test_change_elements_with_no_data_in_redis(element_cache): + input_data = { + 'app/collection1:1': {"id": 1, "value": "updated"}, + 'app/collection1:2': {"id": 2, "value": "new"}, + 'app/collection2:1': {"id": 1, "key": "updated"}, + 'app/collection2:2': None} + + result = await element_cache.change_elements(input_data) + + assert result == 1 # first change_id + assert decode_dict(element_cache.cache_provider.full_data) == decode_dict({ + 'app/collection1:1': '{"id": 1, "value": "updated"}', + 'app/collection1:2': '{"id": 2, "value": "new"}', + 'app/collection2:1': '{"id": 1, "key": "updated"}'}) + assert element_cache.cache_provider.change_id_data == { + 1: { + 'app/collection1:1', + 'app/collection1:2', + 'app/collection2:1', + 'app/collection2:2'}} + + +@pytest.mark.asyncio +async def test_get_all_full_data_from_db(element_cache): + result = await element_cache.get_all_full_data() + + assert result == example_data() + # Test that elements are written to redis + assert decode_dict(element_cache.cache_provider.full_data) == decode_dict({ + 'app/collection1:1': '{"id": 1, "value": "value1"}', + 'app/collection1:2': '{"id": 2, "value": "value2"}', + 'app/collection2:1': '{"id": 1, "key": "value1"}', + 'app/collection2:2': '{"id": 2, "key": "value2"}'}) + + +@pytest.mark.asyncio +async def test_get_all_full_data_from_redis(element_cache): + element_cache.cache_provider.full_data = { + 'app/collection1:1': '{"id": 1, "value": "value1"}', + 'app/collection1:2': '{"id": 2, "value": "value2"}', + 'app/collection2:1': '{"id": 1, "key": "value1"}', + 'app/collection2:2': '{"id": 2, "key": "value2"}'} + + result = await element_cache.get_all_full_data() + + # The output from redis has to be the same then the db_data + assert sort_dict(result) == sort_dict(example_data()) + + +@pytest.mark.asyncio +async def test_get_full_data_change_id_0(element_cache): + element_cache.cache_provider.full_data = { + 'app/collection1:1': '{"id": 1, "value": "value1"}', + 'app/collection1:2': '{"id": 2, "value": "value2"}', + 'app/collection2:1': '{"id": 1, "key": "value1"}', + 'app/collection2:2': '{"id": 2, "key": "value2"}'} + + result = await element_cache.get_full_data(0) + + assert sort_dict(result[0]) == sort_dict(example_data()) + + +@pytest.mark.asyncio +async def test_get_full_data_change_id_lower_then_in_redis(element_cache): + element_cache.cache_provider.full_data = { + 'app/collection1:1': '{"id": 1, "value": "value1"}', + 'app/collection1:2': '{"id": 2, "value": "value2"}', + 'app/collection2:1': '{"id": 1, "key": "value1"}', + 'app/collection2:2': '{"id": 2, "key": "value2"}'} + element_cache.cache_provider.change_id_data = { + 2: {'app/collection1:1'}} + with pytest.raises(RuntimeError): + await element_cache.get_full_data(1) + + +@pytest.mark.asyncio +async def test_get_full_data_change_id_data_in_redis(element_cache): + element_cache.cache_provider.full_data = { + 'app/collection1:1': '{"id": 1, "value": "value1"}', + 'app/collection1:2': '{"id": 2, "value": "value2"}', + 'app/collection2:1': '{"id": 1, "key": "value1"}', + 'app/collection2:2': '{"id": 2, "key": "value2"}'} + element_cache.cache_provider.change_id_data = { + 1: {'app/collection1:1', 'app/collection1:3'}} + + result = await element_cache.get_full_data(1) + + assert result == ( + {'app/collection1': [{"id": 1, "value": "value1"}]}, + ['app/collection1:3']) + + +@pytest.mark.asyncio +async def test_get_full_data_change_id_data_in_db(element_cache): + element_cache.cache_provider.change_id_data = { + 1: {'app/collection1:1', 'app/collection1:3'}} + + result = await element_cache.get_full_data(1) + + assert result == ( + {'app/collection1': [{"id": 1, "value": "value1"}]}, + ['app/collection1:3']) + + +@pytest.mark.asyncio +async def test_get_full_data_change_id_data_in_db_empty_change_id(element_cache): + with pytest.raises(RuntimeError): + await element_cache.get_full_data(1) + + +@pytest.mark.asyncio +async def test_get_element_full_data_empty_redis(element_cache): + result = await element_cache.get_element_full_data('app/collection1', 1) + + assert result == {'id': 1, 'value': 'value1'} + + +@pytest.mark.asyncio +async def test_get_element_full_data_empty_redis_does_not_exist(element_cache): + result = await element_cache.get_element_full_data('app/collection1', 3) + + assert result is None + + +@pytest.mark.asyncio +async def test_get_element_full_data_full_redis(element_cache): + element_cache.cache_provider.full_data = { + 'app/collection1:1': '{"id": 1, "value": "value1"}', + 'app/collection1:2': '{"id": 2, "value": "value2"}', + 'app/collection2:1': '{"id": 1, "key": "value1"}', + 'app/collection2:2': '{"id": 2, "key": "value2"}'} + + result = await element_cache.get_element_full_data('app/collection1', 1) + + assert result == {'id': 1, 'value': 'value1'} + + +@pytest.mark.asyncio +async def test_exists_restricted_data(element_cache): + element_cache.use_restricted_data_cache = True + element_cache.cache_provider.restricted_data = {0: { + 'app/collection1:1': '{"id": 1, "value": "value1"}', + 'app/collection1:2': '{"id": 2, "value": "value2"}', + 'app/collection2:1': '{"id": 1, "key": "value1"}', + 'app/collection2:2': '{"id": 2, "key": "value2"}'}} + + result = await element_cache.exists_restricted_data(None) + + assert result + + +@pytest.mark.asyncio +async def test_exists_restricted_data_do_not_use_restricted_data(element_cache): + element_cache.use_restricted_data_cache = False + element_cache.cache_provider.restricted_data = {0: { + 'app/collection1:1': '{"id": 1, "value": "value1"}', + 'app/collection1:2': '{"id": 2, "value": "value2"}', + 'app/collection2:1': '{"id": 1, "key": "value1"}', + 'app/collection2:2': '{"id": 2, "key": "value2"}'}} + + result = await element_cache.exists_restricted_data(None) + + assert not result + + +@pytest.mark.asyncio +async def test_del_user(element_cache): + element_cache.use_restricted_data_cache = True + element_cache.cache_provider.restricted_data = {0: { + 'app/collection1:1': '{"id": 1, "value": "value1"}', + 'app/collection1:2': '{"id": 2, "value": "value2"}', + 'app/collection2:1': '{"id": 1, "key": "value1"}', + 'app/collection2:2': '{"id": 2, "key": "value2"}'}} + + await element_cache.del_user(None) + + assert not element_cache.cache_provider.restricted_data + + +@pytest.mark.asyncio +async def test_del_user_for_empty_user(element_cache): + element_cache.use_restricted_data_cache = True + + await element_cache.del_user(None) + + assert not element_cache.cache_provider.restricted_data + + +@pytest.mark.asyncio +async def test_update_restricted_data(element_cache): + element_cache.use_restricted_data_cache = True + + await element_cache.update_restricted_data(None) + + assert decode_dict(element_cache.cache_provider.restricted_data[0]) == decode_dict({ + 'app/collection1:1': '{"id": 1, "value": "restricted_value1"}', + 'app/collection1:2': '{"id": 2, "value": "restricted_value2"}', + 'app/collection2:1': '{"id": 1, "key": "restricted_value1"}', + 'app/collection2:2': '{"id": 2, "key": "restricted_value2"}', + '_config:change_id': '0'}) + # Make sure the lock is deleted + assert not await element_cache.cache_provider.get_lock("restricted_data_0") + # And the future is done + assert element_cache.restricted_data_cache_updater[0].done() + + +@pytest.mark.asyncio +async def test_update_restricted_data_disabled_restricted_data(element_cache): + element_cache.use_restricted_data_cache = False + + await element_cache.update_restricted_data(None) + + assert not element_cache.cache_provider.restricted_data + + +@pytest.mark.asyncio +async def test_update_restricted_data_to_low_change_id(element_cache): + element_cache.use_restricted_data_cache = True + element_cache.cache_provider.restricted_data[0] = { + '_config:change_id': '1'} + element_cache.cache_provider.change_id_data = { + 3: {'app/collection1:1'}} + + await element_cache.update_restricted_data(None) + + assert decode_dict(element_cache.cache_provider.restricted_data[0]) == decode_dict({ + 'app/collection1:1': '{"id": 1, "value": "restricted_value1"}', + 'app/collection1:2': '{"id": 2, "value": "restricted_value2"}', + 'app/collection2:1': '{"id": 1, "key": "restricted_value1"}', + 'app/collection2:2': '{"id": 2, "key": "restricted_value2"}', + '_config:change_id': '3'}) + + +@pytest.mark.asyncio +async def test_update_restricted_data_with_same_id(element_cache): + element_cache.use_restricted_data_cache = True + element_cache.cache_provider.restricted_data[0] = { + '_config:change_id': '1'} + element_cache.cache_provider.change_id_data = { + 1: {'app/collection1:1'}} + + await element_cache.update_restricted_data(None) + + # Same id means, there is nothing to do + assert element_cache.cache_provider.restricted_data[0] == { + '_config:change_id': '1'} + + +@pytest.mark.asyncio +async def test_update_restricted_data_with_deleted_elements(element_cache): + element_cache.use_restricted_data_cache = True + element_cache.cache_provider.restricted_data[0] = { + 'app/collection1:3': '{"id": 1, "value": "restricted_value1"}', + '_config:change_id': '1'} + element_cache.cache_provider.change_id_data = { + 2: {'app/collection1:3'}} + + await element_cache.update_restricted_data(None) + + assert element_cache.cache_provider.restricted_data[0] == { + '_config:change_id': '2'} + + +@pytest.mark.asyncio +async def test_update_restricted_data_second_worker_on_different_server(element_cache): + """ + Test, that if another worker is updating the data, noting is done. + + This tests makes use of the redis key as it would on different daphne servers. + """ + element_cache.use_restricted_data_cache = True + element_cache.cache_provider.restricted_data = {0: {}} + await element_cache.cache_provider.set_lock("restricted_data_0") + await element_cache.cache_provider.del_lock_after_wait("restricted_data_0") + + await element_cache.update_restricted_data(None) + + # Restricted_data_should not be set on second worker + assert element_cache.cache_provider.restricted_data == {0: {}} + + +@pytest.mark.asyncio +async def test_update_restricted_data_second_worker_on_same_server(element_cache): + """ + Test, that if another worker is updating the data, noting is done. + + This tests makes use of the future as it would on the same daphne server. + """ + element_cache.use_restricted_data_cache = True + element_cache.cache_provider.restricted_data = {0: {}} + future: asyncio.Future = asyncio.Future() + element_cache.restricted_data_cache_updater[0] = future + await element_cache.cache_provider.set_lock("restricted_data_0") + await element_cache.cache_provider.del_lock_after_wait("restricted_data_0", future) + + await element_cache.update_restricted_data(None) + + # Restricted_data_should not be set on second worker + assert element_cache.cache_provider.restricted_data == {0: {}} + + +@pytest.mark.asyncio +async def test_get_all_restricted_data(element_cache): + element_cache.use_restricted_data_cache = True + + result = await element_cache.get_all_restricted_data(None) + + assert sort_dict(result) == sort_dict({ + 'app/collection1': [{"id": 1, "value": "restricted_value1"}, {"id": 2, "value": "restricted_value2"}], + 'app/collection2': [{"id": 1, "key": "restricted_value1"}, {"id": 2, "key": "restricted_value2"}]}) + + +@pytest.mark.asyncio +async def test_get_all_restricted_data_disabled_restricted_data_cache(element_cache): + element_cache.use_restricted_data_cache = False + result = await element_cache.get_all_restricted_data(None) + + assert sort_dict(result) == sort_dict({ + 'app/collection1': [{"id": 1, "value": "restricted_value1"}, {"id": 2, "value": "restricted_value2"}], + 'app/collection2': [{"id": 1, "key": "restricted_value1"}, {"id": 2, "key": "restricted_value2"}]}) + + +@pytest.mark.asyncio +async def test_get_restricted_data_change_id_0(element_cache): + element_cache.use_restricted_data_cache = True + + result = await element_cache.get_restricted_data(None, 0) + + assert sort_dict(result[0]) == sort_dict({ + 'app/collection1': [{"id": 1, "value": "restricted_value1"}, {"id": 2, "value": "restricted_value2"}], + 'app/collection2': [{"id": 1, "key": "restricted_value1"}, {"id": 2, "key": "restricted_value2"}]}) + + +@pytest.mark.asyncio +async def test_get_restricted_data_disabled_restricted_data_cache(element_cache): + element_cache.use_restricted_data_cache = False + element_cache.cache_provider.change_id_data = {1: {'app/collection1:1', 'app/collection1:3'}} + + result = await element_cache.get_restricted_data(None, 1) + + assert result == ( + {'app/collection1': [{"id": 1, "value": "restricted_value1"}]}, + ['app/collection1:3']) + + +@pytest.mark.asyncio +async def test_get_restricted_data_change_id_lower_then_in_redis(element_cache): + element_cache.use_restricted_data_cache = True + element_cache.cache_provider.change_id_data = {2: {'app/collection1:1'}} + + with pytest.raises(RuntimeError): + await element_cache.get_restricted_data(None, 1) + + +@pytest.mark.asyncio +async def test_get_restricted_data_change_with_id(element_cache): + element_cache.use_restricted_data_cache = True + element_cache.cache_provider.change_id_data = {2: {'app/collection1:1'}} + + result = await element_cache.get_restricted_data(None, 2) + + assert result == ({'app/collection1': [{"id": 1, "value": "restricted_value1"}]}, []) + + +@pytest.mark.asyncio +async def test_lowest_change_id_after_updating_lowest_element(element_cache): + await element_cache.change_elements({'app/collection1:1': {"id": 1, "value": "updated1"}}) + first_lowest_change_id = await element_cache.get_lowest_change_id() + # Alter same element again + await element_cache.change_elements({'app/collection1:1': {"id": 1, "value": "updated2"}}) + second_lowest_change_id = await element_cache.get_lowest_change_id() + + assert first_lowest_change_id == 1 + assert second_lowest_change_id == 1 # The lowest_change_id should not change diff --git a/yarn.lock b/yarn.lock index 9ab2f16ff..6a53663fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3,13 +3,13 @@ "@gulp-sourcemaps/identity-map@1.X": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/identity-map/-/identity-map-1.0.1.tgz#cfa23bc5840f9104ce32a65e74db7e7a974bbee1" + version "1.0.2" + resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/identity-map/-/identity-map-1.0.2.tgz#1e6fe5d8027b1f285dc0d31762f566bccd73d5a9" dependencies: acorn "^5.0.3" css "^2.2.1" normalize-path "^2.1.1" - source-map "^0.5.6" + source-map "^0.6.0" through2 "^2.0.3" "@gulp-sourcemaps/map-sources@1.X": @@ -31,8 +31,8 @@ accepts@1.3.3: negotiator "0.6.1" acorn@5.X, acorn@^5.0.3: - version "5.6.2" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.6.2.tgz#b1da1d7be2ac1b4a327fb9eab851702c5045b4e7" + version "5.7.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.1.tgz#f095829297706a7c9776958c0afc8930a9b9d9d8" after@0.8.2: version "0.8.2" @@ -71,8 +71,8 @@ amdefine@>=0.0.4: resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" angular-gettext-tools@^2.2.0: - version "2.3.9" - resolved "https://registry.yarnpkg.com/angular-gettext-tools/-/angular-gettext-tools-2.3.9.tgz#e8fd6692171b863370b6db031e6cdbf24673f102" + version "2.3.11" + resolved "https://registry.yarnpkg.com/angular-gettext-tools/-/angular-gettext-tools-2.3.11.tgz#61920670eb3e34ee8f958dd8a4a3c327ee4e5de4" dependencies: babylon "^6.11.4" binary-search "^1.2.0" @@ -400,8 +400,8 @@ base@^0.11.1: pascalcase "^0.1.1" bcrypt-pbkdf@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz#63bc5dcb61331b92bc05fd528953c33462a06f8d" + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" dependencies: tweetnacl "^0.14.3" @@ -420,8 +420,8 @@ binary-extensions@^1.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205" binary-search@^1.2.0: - version "1.3.3" - resolved "https://registry.yarnpkg.com/binary-search/-/binary-search-1.3.3.tgz#b5adb6fb279a197be51b1ee8b0fb76fcdc61b429" + version "1.3.4" + resolved "https://registry.yarnpkg.com/binary-search/-/binary-search-1.3.4.tgz#d15f44ff9226ef309d85247fa0dbfbf659955f56" blob@0.0.4: version "0.0.4" @@ -572,12 +572,8 @@ caniuse-api@^1.5.2: lodash.uniq "^4.5.0" caniuse-db@^1.0.30000529, caniuse-db@^1.0.30000634, caniuse-db@^1.0.30000639: - version "1.0.30000852" - resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000852.tgz#c37a706048f8d81f87946a7c13f39ed636876659" - -caseless@~0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.11.0.tgz#715b96ea9841593cc33067923f5ec60ebda4f7d7" + version "1.0.30000871" + resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000871.tgz#f1995c1fe31892649a7605957a80c92518423d4d" caseless@~0.12.0: version "0.12.0" @@ -626,8 +622,8 @@ chokidar@^1.4.1: fsevents "^1.0.0" chokidar@^2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.0.3.tgz#dcbd4f6cbb2a55b4799ba8a840ac527e5f4b1176" + version "2.0.4" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.0.4.tgz#356ff4e2b0e8e43e322d18a372460bbcf3accd26" dependencies: anymatch "^2.0.0" async-each "^1.0.0" @@ -636,12 +632,13 @@ chokidar@^2.0.0: inherits "^2.0.1" is-binary-path "^1.0.0" is-glob "^4.0.0" + lodash.debounce "^4.0.8" normalize-path "^2.1.1" path-is-absolute "^1.0.0" readdirp "^2.0.0" - upath "^1.0.0" + upath "^1.0.5" optionalDependencies: - fsevents "^1.1.2" + fsevents "^1.2.2" chownr@^1.0.1: version "1.0.1" @@ -783,8 +780,8 @@ colormin@^1.0.5: has "^1.0.1" colors@^1.1.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/colors/-/colors-1.3.0.tgz#5f20c9fef6945cb1134260aab33bfbdc8295e04e" + version "1.3.1" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.3.1.tgz#4accdb89cf2cabc7f982771925e9468784f32f3d" colors@~1.1.2: version "1.1.2" @@ -802,10 +799,6 @@ combined-stream@1.0.6, combined-stream@^1.0.5, combined-stream@~1.0.5: dependencies: delayed-stream "~1.0.0" -commander@^2.9.0: - version "2.15.1" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.15.1.tgz#df46e867d0fc2aec66a34662b406a9ccafff5b0f" - component-bind@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1" @@ -1196,8 +1189,8 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" electron-to-chromium@^1.2.7: - version "1.3.48" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.48.tgz#d3b0d8593814044e092ece2108fc3ac9aea4b900" + version "1.3.52" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.52.tgz#d2d9f1270ba4a3b967b831c40ef71fb4d9ab5ce0" encodeurl@~1.0.1: version "1.0.2" @@ -1261,8 +1254,8 @@ entities@~1.1.1: resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0" error-ex@^1.2.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.1.tgz#f855a86ce61adc4e8621c3cda21e7a7612c3a8dc" + version "1.3.2" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" dependencies: is-arrayish "^0.2.1" @@ -1406,12 +1399,12 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2: is-extendable "^1.0.1" extend@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/extend/-/extend-2.0.1.tgz#1ee8010689e7395ff9448241c98652bc759a8260" + version "2.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-2.0.2.tgz#1b74985400171b85554894459c978de6ef453ab7" extend@^3.0.0, extend@~3.0.0, extend@~3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444" + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" extglob@^0.3.1: version "0.3.2" @@ -1552,8 +1545,8 @@ flush-write-stream@^1.0.2: readable-stream "^2.0.4" follow-redirects@^1.0.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.0.tgz#234f49cf770b7f35b40e790f636ceba0c3a0ab77" + version "1.5.1" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.1.tgz#67a8f14f5a1f67f962c2c46469c79eaec0a90291" dependencies: debug "^3.1.0" @@ -1642,7 +1635,7 @@ fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" -fsevents@^1.0.0, fsevents@^1.1.2: +fsevents@^1.0.0, fsevents@^1.2.2: version "1.2.4" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.4.tgz#f41dcb1af2582af3692da36fc55cbd8e1041c426" dependencies: @@ -1681,19 +1674,9 @@ gaze@^1.0.0: dependencies: globule "^1.0.0" -generate-function@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.0.0.tgz#6858fe7c0969b7d4e9093337647ac79f60dfbe74" - -generate-object-property@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/generate-object-property/-/generate-object-property-1.2.0.tgz#9c0e1c40308ce804f4783618b937fa88f99d50d0" - dependencies: - is-property "^1.0.0" - get-caller-file@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5" + version "1.0.3" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" get-stdin@^4.0.1: version "4.0.1" @@ -1954,8 +1937,8 @@ gulp-match@^1.0.3: minimatch "^3.0.3" gulp-rename@^1.2.2: - version "1.3.0" - resolved "https://registry.yarnpkg.com/gulp-rename/-/gulp-rename-1.3.0.tgz#2e789d8f563ab0c924eeb62967576f37ff4cb826" + version "1.4.0" + resolved "https://registry.yarnpkg.com/gulp-rename/-/gulp-rename-1.4.0.tgz#de1c718e7c4095ae861f7296ef4f3248648240bd" gulp-sass@^3.1.0: version "3.2.1" @@ -2052,15 +2035,6 @@ har-schema@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" -har-validator@~2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-2.0.6.tgz#cdcbc08188265ad119b6a5a7c8ab70eecfb5d27d" - dependencies: - chalk "^1.1.1" - commander "^2.9.0" - is-my-json-valid "^2.12.4" - pinkie-promise "^2.0.0" - har-validator@~4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-4.2.1.tgz#33481d0f1bbff600dd203d75812a6a5fba002e2a" @@ -2169,8 +2143,8 @@ homedir-polyfill@^1.0.1: parse-passwd "^1.0.0" hosted-git-info@^2.1.4: - version "2.6.0" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.6.0.tgz#23235b29ab230c576aab0d4f13fc046b0b038222" + version "2.7.1" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.7.1.tgz#97f236977bd6e125408930ff6de3eec6281ec047" html-comment-regex@^1.1.0: version "1.1.1" @@ -2405,20 +2379,6 @@ is-glob@^4.0.0: dependencies: is-extglob "^2.1.1" -is-my-ip-valid@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-my-ip-valid/-/is-my-ip-valid-1.0.0.tgz#7b351b8e8edd4d3995d4d066680e664d94696824" - -is-my-json-valid@^2.12.4: - version "2.17.2" - resolved "https://registry.yarnpkg.com/is-my-json-valid/-/is-my-json-valid-2.17.2.tgz#6b2103a288e94ef3de5cf15d29dd85fc4b78d65c" - dependencies: - generate-function "^2.0.0" - generate-object-property "^1.1.0" - is-my-ip-valid "^1.0.0" - jsonpointer "^4.0.0" - xtend "^4.0.0" - is-negated-glob@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-negated-glob/-/is-negated-glob-1.0.0.tgz#6910bca5da8c95e784b5751b976cf5a10fee36d2" @@ -2443,12 +2403,6 @@ is-number@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff" -is-odd@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-odd/-/is-odd-2.0.0.tgz#7646624671fd7ea558ccd9a2795182f2958f1b24" - dependencies: - is-number "^4.0.0" - is-plain-obj@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" @@ -2471,10 +2425,6 @@ is-promise@^2.1: version "2.1.0" resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" -is-property@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" - is-relative@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-relative/-/is-relative-1.0.0.tgz#a1bb6935ce8c5dba1e8b9754b9b2dcc020e2260d" @@ -2552,8 +2502,8 @@ jasmine-core@^2.6.1: resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.99.1.tgz#e6400df1e6b56e130b61c4bcd093daa7f6e8ca15" js-base64@^2.1.8, js-base64@^2.1.9: - version "2.4.5" - resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.4.5.tgz#e293cd3c7c82f070d700fc7a1ca0a2e69f101f92" + version "2.4.8" + resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.4.8.tgz#57a9b130888f956834aa40c5b165ba59c758f033" js-yaml@~3.7.0: version "3.7.0" @@ -2615,10 +2565,6 @@ jsonify@~0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" -jsonpointer@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.0.1.tgz#4fd92cb34e0e9db3c89c8622ecf51f9b978c6cb9" - jsprim@^1.2.2: version "1.4.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" @@ -2812,6 +2758,10 @@ lodash.clonedeep@^4.3.2: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" +lodash.debounce@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" + lodash.escape@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/lodash.escape/-/lodash.escape-3.2.0.tgz#995ee0dc18c1b48cc92effae71a10aab5b487698" @@ -3084,15 +3034,15 @@ micromatch@^3.0.4, micromatch@^3.1.4: snapdragon "^0.8.1" to-regex "^3.0.2" -mime-db@~1.33.0: - version "1.33.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.33.0.tgz#a3492050a5cb9b63450541e39d9788d2272783db" +mime-db@~1.35.0: + version "1.35.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.35.0.tgz#0569d657466491283709663ad379a99b90d9ab47" mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.17, mime-types@~2.1.18, mime-types@~2.1.7: - version "2.1.18" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.18.tgz#6f323f60a83d11146f831ff11fd66e2fe5503bb8" + version "2.1.19" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.19.tgz#71e464537a7ef81c15f2db9d97e913fc0ff606f0" dependencies: - mime-db "~1.33.0" + mime-db "~1.35.0" mime@^1.3.4: version "1.6.0" @@ -3178,15 +3128,14 @@ nan@^2.10.0, nan@^2.9.2: resolved "https://registry.yarnpkg.com/nan/-/nan-2.10.0.tgz#96d0cd610ebd58d4b4de9cc0c6828cda99c7548f" nanomatch@^1.2.9: - version "1.2.9" - resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.9.tgz#879f7150cb2dab7a471259066c104eee6e0fa7c2" + version "1.2.13" + resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" dependencies: arr-diff "^4.0.0" array-unique "^0.3.2" define-property "^2.0.2" extend-shallow "^3.0.2" fragment-cache "^0.2.1" - is-odd "^2.0.0" is-windows "^1.0.2" kind-of "^6.0.2" object.pick "^1.3.0" @@ -3194,7 +3143,7 @@ nanomatch@^1.2.9: snapdragon "^0.8.1" to-regex "^3.0.1" -needle@^2.2.0: +needle@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/needle/-/needle-2.2.1.tgz#b5e325bd3aae8c2678902fa296f729455d1d3a7d" dependencies: @@ -3228,23 +3177,23 @@ node-gyp@^3.3.1: which "1" node-pre-gyp@^0.10.0: - version "0.10.0" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.0.tgz#6e4ef5bb5c5203c6552448828c852c40111aac46" + version "0.10.3" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz#3070040716afdc778747b61b6887bf78880b80fc" dependencies: detect-libc "^1.0.2" mkdirp "^0.5.1" - needle "^2.2.0" + needle "^2.2.1" nopt "^4.0.1" npm-packlist "^1.1.6" npmlog "^4.0.2" - rc "^1.1.7" + rc "^1.2.7" rimraf "^2.6.1" semver "^5.3.0" tar "^4" node-sass@^4.8.3: - version "4.9.0" - resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.9.0.tgz#d1b8aa855d98ed684d6848db929a20771cc2ae52" + version "4.9.2" + resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.9.2.tgz#5e63fe6bd0f2ae3ac9d6c14ede8620e2b8bdb437" dependencies: async-foreach "^0.1.3" chalk "^1.1.1" @@ -3261,7 +3210,7 @@ node-sass@^4.8.3: nan "^2.10.0" node-gyp "^3.3.1" npmlog "^4.0.0" - request "~2.79.0" + request "2.87.0" sass-graph "^2.2.4" stdout-stream "^1.4.0" "true-case-path" "^1.0.2" @@ -3318,8 +3267,8 @@ npm-bundled@^1.0.1: resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.3.tgz#7e71703d973af3370a9591bafe3a63aca0be2308" npm-packlist@^1.1.6: - version "1.1.10" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.10.tgz#1039db9e985727e464df066f4cf0ab6ef85c398a" + version "1.1.11" + resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.11.tgz#84e8c683cbe7867d34b1d357d893ce29e28a02de" dependencies: ignore-walk "^3.0.1" npm-bundled "^1.0.1" @@ -3380,8 +3329,8 @@ object-copy@^0.1.0: kind-of "^3.0.3" object-keys@^1.0.11, object-keys@^1.0.8: - version "1.0.11" - resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.11.tgz#c54601778ad560f1142ce0e01bcca8b56d13426d" + version "1.0.12" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.12.tgz#09c53855377575310cca62f55bb334abff7b3ed2" object-visit@^1.0.0: version "1.0.1" @@ -3926,10 +3875,6 @@ qs@6.5.2, qs@~6.5.1: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" -qs@~6.3.0: - version "6.3.2" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.3.2.tgz#e75bd5f6e268122a2a0e0bda630b2550c166502c" - qs@~6.4.0: version "6.4.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" @@ -3962,7 +3907,7 @@ raw-body@2.3.3: iconv-lite "0.4.23" unpipe "1.0.0" -rc@^1.1.7: +rc@^1.2.7: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" dependencies: @@ -4148,6 +4093,31 @@ request-progress@^2.0.1: dependencies: throttleit "^1.0.0" +request@2.87.0, request@^2.81.0: + version "2.87.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.87.0.tgz#32f00235cd08d482b4d0d68db93a829c0ed5756e" + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.6.0" + caseless "~0.12.0" + combined-stream "~1.0.5" + extend "~3.0.1" + forever-agent "~0.6.1" + form-data "~2.3.1" + har-validator "~5.0.3" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.17" + oauth-sign "~0.8.2" + performance-now "^2.1.0" + qs "~6.5.1" + safe-buffer "^5.1.1" + tough-cookie "~2.3.3" + tunnel-agent "^0.6.0" + uuid "^3.1.0" + "request@>=2.9.0 <2.82.0": version "2.81.0" resolved "https://registry.yarnpkg.com/request/-/request-2.81.0.tgz#c6928946a0e06c5f8d6f8a9333469ffda46298a0" @@ -4175,56 +4145,6 @@ request-progress@^2.0.1: tunnel-agent "^0.6.0" uuid "^3.0.0" -request@^2.81.0: - version "2.87.0" - resolved "https://registry.yarnpkg.com/request/-/request-2.87.0.tgz#32f00235cd08d482b4d0d68db93a829c0ed5756e" - dependencies: - aws-sign2 "~0.7.0" - aws4 "^1.6.0" - caseless "~0.12.0" - combined-stream "~1.0.5" - extend "~3.0.1" - forever-agent "~0.6.1" - form-data "~2.3.1" - har-validator "~5.0.3" - http-signature "~1.2.0" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.17" - oauth-sign "~0.8.2" - performance-now "^2.1.0" - qs "~6.5.1" - safe-buffer "^5.1.1" - tough-cookie "~2.3.3" - tunnel-agent "^0.6.0" - uuid "^3.1.0" - -request@~2.79.0: - version "2.79.0" - resolved "https://registry.yarnpkg.com/request/-/request-2.79.0.tgz#4dfe5bf6be8b8cdc37fcf93e04b65577722710de" - dependencies: - aws-sign2 "~0.6.0" - aws4 "^1.2.1" - caseless "~0.11.0" - combined-stream "~1.0.5" - extend "~3.0.0" - forever-agent "~0.6.1" - form-data "~2.1.1" - har-validator "~2.0.6" - hawk "~3.1.3" - http-signature "~1.1.0" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.7" - oauth-sign "~0.8.1" - qs "~6.3.0" - stringstream "~0.0.4" - tough-cookie "~2.3.0" - tunnel-agent "~0.4.1" - uuid "^3.0.0" - require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -4255,8 +4175,8 @@ resolve-url@^0.2.1: resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" resolve@^1.1.6, resolve@^1.1.7, resolve@^1.4.0: - version "1.7.1" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.7.1.tgz#aadd656374fd298aee895bc026b8297418677fd3" + version "1.8.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26" dependencies: path-parse "^1.0.5" @@ -4479,7 +4399,7 @@ source-map@^0.5.1, source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.1: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" -source-map@^0.6.1, source-map@~0.6.0: +source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" @@ -4836,10 +4756,6 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" -tunnel-agent@~0.4.1: - version "0.4.3" - resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.4.3.tgz#6373db76909fe570e08d73583365ed828a74eeeb" - tweetnacl@^0.14.3, tweetnacl@~0.14.0: version "0.14.5" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" @@ -4944,7 +4860,7 @@ unset-value@^1.0.0: has-value "^0.3.1" isobject "^3.0.0" -upath@^1.0.0: +upath@^1.0.5: version "1.1.0" resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.0.tgz#35256597e46a581db4793d0ce47fa9aebfc9fabd" @@ -4953,10 +4869,8 @@ urix@^0.1.0: resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" use@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/use/-/use-3.1.0.tgz#14716bf03fdfefd03040aef58d8b4b85f3a7c544" - dependencies: - kind-of "^6.0.2" + version "3.1.1" + resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" useragent@^2.1.12: version "2.3.0" @@ -4974,8 +4888,8 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" uuid@^3.0.0, uuid@^3.1.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.2.1.tgz#12c528bb9d58d0b9265d9a2f6f0fe8be17ff1f14" + version "3.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" v8flags@^3.0.1: version "3.1.1" @@ -5089,8 +5003,8 @@ vinyl@^1.0.0: replace-ext "0.0.1" vinyl@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.1.0.tgz#021f9c2cf951d6b939943c89eb5ee5add4fd924c" + version "2.2.0" + resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.0.tgz#d85b07da96e458d25b2ffe19fece9f2caa13ed86" dependencies: clone "^2.1.1" clone-buffer "^1.0.0" @@ -5161,7 +5075,7 @@ xmlhttprequest-ssl@1.5.3: version "1.5.3" resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.3.tgz#185a888c04eca46c3e4070d99f7b49de3528992d" -"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1: +"xtend@>=4.0.0 <4.1.0-0", xtend@~4.0.0, xtend@~4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"