From 858f84aaadf7854b4f075944d2f5d1fdb38ea267 Mon Sep 17 00:00:00 2001 From: sonny Date: Mon, 28 Oct 2019 21:35:19 +0100 Subject: [PATCH] Refactor endpoint tests Replace force_login calls with login call from client class in setUp --- .babelrc | 10 +- .gitignore | 1 + .gitlab-ci.yml | 56 +- .prettierrc.json | 10 + Dockerfile | 16 +- docker-compose.yml | 26 +- gulp/babel.js | 28 + gulp/sass.js | 30 +- gulpfile.babel.js | 25 +- package-lock.json | 1367 ++++++++++++++++- package.json | 36 +- requirements/base.txt | 2 +- requirements/gitlab.txt | 5 +- src/newsreader/accounts/views.py | 4 +- src/newsreader/conf/base.py | 8 + src/newsreader/fixtures/default-fixture.json | 283 +++- .../js/components/LoadingIndicator.js | 13 + src/newsreader/js/homepage/App.js | 63 + .../js/homepage/actions/categories.js | 86 ++ src/newsreader/js/homepage/actions/posts.js | 119 ++ src/newsreader/js/homepage/actions/rules.js | 68 + .../js/homepage/actions/selected.js | 65 + .../js/homepage/components/PostModal.js | 84 + .../homepage/components/feedlist/FeedList.js | 102 ++ .../homepage/components/feedlist/PostItem.js | 50 + .../homepage/components/feedlist/RuleItem.js | 25 + .../homepage/components/feedlist/filters.js | 44 + .../components/sidebar/CategoryItem.js | 68 + .../homepage/components/sidebar/ReadButton.js | 36 + .../homepage/components/sidebar/RuleItem.js | 56 + .../js/homepage/components/sidebar/Sidebar.js | 52 + .../js/homepage/components/sidebar/filters.js | 7 + src/newsreader/js/homepage/configureStore.js | 18 + src/newsreader/js/homepage/index.js | 16 + .../js/homepage/reducers/categories.js | 116 ++ src/newsreader/js/homepage/reducers/index.js | 10 + src/newsreader/js/homepage/reducers/posts.js | 67 + src/newsreader/js/homepage/reducers/rules.js | 65 + .../js/homepage/reducers/selected.js | 70 + src/newsreader/js/utils.js | 14 + src/newsreader/news/collection/admin.py | 3 +- src/newsreader/news/collection/endpoints.py | 69 + src/newsreader/news/collection/serializers.py | 18 +- .../endpoints/{rules => rule}/__init__.py | 0 .../{rules => rule}/detail/__init__.py | 0 .../endpoints/{rules => rule}/detail/tests.py | 157 +- .../{rules => rule}/list/__init__.py | 0 .../tests/endpoints/rule/list/tests.py | 371 +++++ .../tests/endpoints/rules/list/tests.py | 210 --- src/newsreader/news/collection/urls.py | 14 +- src/newsreader/news/collection/views.py | 21 - src/newsreader/news/core/endpoints.py | 118 ++ src/newsreader/news/core/filters.py | 32 + .../news/core/migrations/0003_post_read.py | 14 + src/newsreader/news/core/models.py | 4 +- src/newsreader/news/core/serializers.py | 23 +- .../news/core/templates/core/main.html | 15 + .../tests/endpoints/category/detail/tests.py | 183 ++- .../tests/endpoints/category/list/tests.py | 538 ++++++- .../core/tests/endpoints/post/detail/tests.py | 57 +- .../core/tests/endpoints/post/list/tests.py | 60 +- src/newsreader/news/core/tests/factories.py | 2 + src/newsreader/news/core/urls.py | 34 +- src/newsreader/news/core/views.py | 58 +- .../scss/accounts/components/form/_form.scss | 2 + .../scss/accounts/components/form/index.scss | 0 .../scss/accounts/components/index.scss | 0 .../scss/accounts/components/main/_main.scss | 0 .../scss/accounts/components/main/index.scss | 0 .../{static/src => }/scss/accounts/index.scss | 2 + .../src => }/scss/components/body/_body.scss | 5 + .../src => }/scss/components/body/index.scss | 0 .../scss/components/button/_button.scss | 2 - .../scss/components/button/index.scss | 0 .../scss/components/error/_error.scss | 0 .../scss/components/error/_errorlist.scss | 0 .../src => }/scss/components/error/index.scss | 0 .../src => }/scss/components/form/_form.scss | 0 .../src => }/scss/components/form/index.scss | 0 .../src => }/scss/components/index.scss | 2 + .../src => }/scss/components/input/input.scss | 0 .../loading-indicator/_loading-indicator.scss | 41 + .../components/loading-indicator/index.scss | 1 + .../src => }/scss/components/main/_main.scss | 0 .../src => }/scss/components/main/index.scss | 0 .../scss/components/modal/_modal.scss | 9 + .../scss/components/modal/index.scss | 1 + .../scss/components/navbar/_navbar.scss | 1 + .../scss/components/navbar/index.scss | 0 .../components/categories/_categories.scss | 24 + .../homepage/components/categories/index.scss | 1 + .../components/category/_category.scss | 46 + .../homepage/components/category/index.scss | 1 + .../homepage/components/content/_content.scss | 7 + .../homepage/components/content/index.scss | 1 + .../scss/homepage/components/index.scss | 17 + .../scss/homepage/components/main/_main.scss | 12 + .../scss/homepage/components/main/index.scss | 1 + .../components/post-block/_post-block.scss | 12 + .../homepage/components/post-block/index.scss | 1 + .../post-message/_post-message.scss | 25 + .../components/post-message/index.scss | 1 + .../scss/homepage/components/post/_post.scss | 125 ++ .../scss/homepage/components/post/index.scss | 1 + .../posts-header/_posts-header.scss | 21 + .../components/posts-header/index.scss | 1 + .../components/posts-section/index.scss | 12 + .../homepage/components/posts/_posts.scss | 34 + .../scss/homepage/components/posts/index.scss | 1 + .../components/read-button/_read-button.scss | 10 + .../components/read-button/index.scss | 1 + .../scss/homepage/components/rule/_rule.scss | 10 + .../scss/homepage/components/rule/index.scss | 1 + .../homepage/components/rules/_rules.scss | 29 + .../scss/homepage/components/rules/index.scss | 1 + .../homepage/components/sidebar/_sidebar.scss | 11 + .../homepage/components/sidebar/index.scss | 1 + .../scss/homepage/elements/badge/_badge.scss | 15 + .../scss/homepage/elements/badge/index.scss | 1 + .../scss/homepage/elements/index.scss | 1 + src/newsreader/scss/homepage/index.scss | 7 + src/newsreader/scss/partials/_variables.scss | 42 + src/newsreader/static/icons/angle-down.svg | 1 + src/newsreader/static/icons/angle-right.svg | 1 + src/newsreader/static/icons/arrow-left.svg | 6 + src/newsreader/static/icons/chevron-down.svg | 6 + src/newsreader/static/icons/chevron-right.svg | 6 + src/newsreader/static/icons/link.svg | 1 + src/newsreader/static/icons/times.svg | 1 + .../static/src/scss/partials/_variables.scss | 26 - src/newsreader/templates/base.html | 6 +- src/newsreader/urls.py | 3 + 132 files changed, 5158 insertions(+), 661 deletions(-) create mode 100644 .prettierrc.json create mode 100644 gulp/babel.js create mode 100644 src/newsreader/js/components/LoadingIndicator.js create mode 100644 src/newsreader/js/homepage/App.js create mode 100644 src/newsreader/js/homepage/actions/categories.js create mode 100644 src/newsreader/js/homepage/actions/posts.js create mode 100644 src/newsreader/js/homepage/actions/rules.js create mode 100644 src/newsreader/js/homepage/actions/selected.js create mode 100644 src/newsreader/js/homepage/components/PostModal.js create mode 100644 src/newsreader/js/homepage/components/feedlist/FeedList.js create mode 100644 src/newsreader/js/homepage/components/feedlist/PostItem.js create mode 100644 src/newsreader/js/homepage/components/feedlist/RuleItem.js create mode 100644 src/newsreader/js/homepage/components/feedlist/filters.js create mode 100644 src/newsreader/js/homepage/components/sidebar/CategoryItem.js create mode 100644 src/newsreader/js/homepage/components/sidebar/ReadButton.js create mode 100644 src/newsreader/js/homepage/components/sidebar/RuleItem.js create mode 100644 src/newsreader/js/homepage/components/sidebar/Sidebar.js create mode 100644 src/newsreader/js/homepage/components/sidebar/filters.js create mode 100644 src/newsreader/js/homepage/configureStore.js create mode 100644 src/newsreader/js/homepage/index.js create mode 100644 src/newsreader/js/homepage/reducers/categories.js create mode 100644 src/newsreader/js/homepage/reducers/index.js create mode 100644 src/newsreader/js/homepage/reducers/posts.js create mode 100644 src/newsreader/js/homepage/reducers/rules.js create mode 100644 src/newsreader/js/homepage/reducers/selected.js create mode 100644 src/newsreader/js/utils.js create mode 100644 src/newsreader/news/collection/endpoints.py rename src/newsreader/news/collection/tests/endpoints/{rules => rule}/__init__.py (100%) rename src/newsreader/news/collection/tests/endpoints/{rules => rule}/detail/__init__.py (100%) rename src/newsreader/news/collection/tests/endpoints/{rules => rule}/detail/tests.py (59%) rename src/newsreader/news/collection/tests/endpoints/{rules => rule}/list/__init__.py (100%) create mode 100644 src/newsreader/news/collection/tests/endpoints/rule/list/tests.py delete mode 100644 src/newsreader/news/collection/tests/endpoints/rules/list/tests.py create mode 100644 src/newsreader/news/core/endpoints.py create mode 100644 src/newsreader/news/core/filters.py create mode 100644 src/newsreader/news/core/migrations/0003_post_read.py create mode 100644 src/newsreader/news/core/templates/core/main.html rename src/newsreader/{static/src => }/scss/accounts/components/form/_form.scss (94%) rename src/newsreader/{static/src => }/scss/accounts/components/form/index.scss (100%) rename src/newsreader/{static/src => }/scss/accounts/components/index.scss (100%) rename src/newsreader/{static/src => }/scss/accounts/components/main/_main.scss (100%) rename src/newsreader/{static/src => }/scss/accounts/components/main/index.scss (100%) rename src/newsreader/{static/src => }/scss/accounts/index.scss (72%) rename src/newsreader/{static/src => }/scss/components/body/_body.scss (61%) rename src/newsreader/{static/src => }/scss/components/body/index.scss (100%) rename src/newsreader/{static/src => }/scss/components/button/_button.scss (95%) rename src/newsreader/{static/src => }/scss/components/button/index.scss (100%) rename src/newsreader/{static/src => }/scss/components/error/_error.scss (100%) rename src/newsreader/{static/src => }/scss/components/error/_errorlist.scss (100%) rename src/newsreader/{static/src => }/scss/components/error/index.scss (100%) rename src/newsreader/{static/src => }/scss/components/form/_form.scss (100%) rename src/newsreader/{static/src => }/scss/components/form/index.scss (100%) rename src/newsreader/{static/src => }/scss/components/index.scss (70%) rename src/newsreader/{static/src => }/scss/components/input/input.scss (100%) create mode 100644 src/newsreader/scss/components/loading-indicator/_loading-indicator.scss create mode 100644 src/newsreader/scss/components/loading-indicator/index.scss rename src/newsreader/{static/src => }/scss/components/main/_main.scss (100%) rename src/newsreader/{static/src => }/scss/components/main/index.scss (100%) create mode 100644 src/newsreader/scss/components/modal/_modal.scss create mode 100644 src/newsreader/scss/components/modal/index.scss rename src/newsreader/{static/src => }/scss/components/navbar/_navbar.scss (96%) rename src/newsreader/{static/src => }/scss/components/navbar/index.scss (100%) create mode 100644 src/newsreader/scss/homepage/components/categories/_categories.scss create mode 100644 src/newsreader/scss/homepage/components/categories/index.scss create mode 100644 src/newsreader/scss/homepage/components/category/_category.scss create mode 100644 src/newsreader/scss/homepage/components/category/index.scss create mode 100644 src/newsreader/scss/homepage/components/content/_content.scss create mode 100644 src/newsreader/scss/homepage/components/content/index.scss create mode 100644 src/newsreader/scss/homepage/components/index.scss create mode 100644 src/newsreader/scss/homepage/components/main/_main.scss create mode 100644 src/newsreader/scss/homepage/components/main/index.scss create mode 100644 src/newsreader/scss/homepage/components/post-block/_post-block.scss create mode 100644 src/newsreader/scss/homepage/components/post-block/index.scss create mode 100644 src/newsreader/scss/homepage/components/post-message/_post-message.scss create mode 100644 src/newsreader/scss/homepage/components/post-message/index.scss create mode 100644 src/newsreader/scss/homepage/components/post/_post.scss create mode 100644 src/newsreader/scss/homepage/components/post/index.scss create mode 100644 src/newsreader/scss/homepage/components/posts-header/_posts-header.scss create mode 100644 src/newsreader/scss/homepage/components/posts-header/index.scss create mode 100644 src/newsreader/scss/homepage/components/posts-section/index.scss create mode 100644 src/newsreader/scss/homepage/components/posts/_posts.scss create mode 100644 src/newsreader/scss/homepage/components/posts/index.scss create mode 100644 src/newsreader/scss/homepage/components/read-button/_read-button.scss create mode 100644 src/newsreader/scss/homepage/components/read-button/index.scss create mode 100644 src/newsreader/scss/homepage/components/rule/_rule.scss create mode 100644 src/newsreader/scss/homepage/components/rule/index.scss create mode 100644 src/newsreader/scss/homepage/components/rules/_rules.scss create mode 100644 src/newsreader/scss/homepage/components/rules/index.scss create mode 100644 src/newsreader/scss/homepage/components/sidebar/_sidebar.scss create mode 100644 src/newsreader/scss/homepage/components/sidebar/index.scss create mode 100644 src/newsreader/scss/homepage/elements/badge/_badge.scss create mode 100644 src/newsreader/scss/homepage/elements/badge/index.scss create mode 100644 src/newsreader/scss/homepage/elements/index.scss create mode 100644 src/newsreader/scss/homepage/index.scss create mode 100644 src/newsreader/scss/partials/_variables.scss create mode 100644 src/newsreader/static/icons/angle-down.svg create mode 100644 src/newsreader/static/icons/angle-right.svg create mode 100644 src/newsreader/static/icons/arrow-left.svg create mode 100644 src/newsreader/static/icons/chevron-down.svg create mode 100644 src/newsreader/static/icons/chevron-right.svg create mode 100644 src/newsreader/static/icons/link.svg create mode 100644 src/newsreader/static/icons/times.svg delete mode 100644 src/newsreader/static/src/scss/partials/_variables.scss diff --git a/.babelrc b/.babelrc index 1320b9a..610dee0 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,11 @@ { - "presets": ["@babel/preset-env"] + "presets": ["@babel/preset-env"], + "plugins": [ + "@babel/plugin-transform-runtime", + "@babel/plugin-syntax-dynamic-import", + "@babel/plugin-transform-react-jsx", + "@babel/plugin-syntax-function-bind", + "@babel/plugin-proposal-function-bind", + ["@babel/plugin-proposal-class-properties", {loose: true}], + ] } diff --git a/.gitignore b/.gitignore index 0cc2b5c..a5aa72e 100644 --- a/.gitignore +++ b/.gitignore @@ -154,6 +154,7 @@ dmypy.json # Translations # Django stuff: +src/newsreader/fixtures/local # Flask stuff: diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 11fc181..c4ac909 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,13 +1,57 @@ -services: - - postgres:9.6 +stages: + - build + - test + - lint -variables: - POSTGRES_DB: newsreader - POSTGRES_USER: newsreader +javascript build: + image: node:12 + stage: build + cache: + key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG" + paths: + - node_modules/ + before_script: + - npm install --dev + script: + - npx gulp python tests: + services: + - postgres:11 image: python:3.7.4-slim-stretch stage: test + variables: + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + DJANGO_SETTINGS_MODULE: "newsreader.conf.gitlab" + POSTGRES_DB: newsreader + POSTGRES_USER: newsreader + cache: + key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG" + paths: + - .cache/pip + - env/ + before_script: + - python3 -m venv env + - source env/bin/activate + - pip install -r requirements/gitlab.txt + script: + - python src/manage.py test newsreader + +javascript linting: + image: node:12 + stage: lint + cache: + key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG" + paths: + - node_modules/ + before_script: + - npm install --dev + script: + - npm run lint + +python linting: + image: python:3.7.4-slim-stretch + stage: lint variables: PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" DJANGO_SETTINGS_MODULE: "newsreader.conf.gitlab" @@ -21,6 +65,6 @@ python tests: - source env/bin/activate - pip install -r requirements/gitlab.txt script: - - python src/manage.py test newsreader - isort -rc src/ --check-only - black -l 90 --check src/ + - autoflake -rc src/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..146a217 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 90, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "avoid" +} diff --git a/Dockerfile b/Dockerfile index bbdfe24..ac7ac5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,18 @@ FROM python:3.7-buster +# Run project binaries from the user's local bin folder +ENV PATH=/home/newsreader/.local/bin:$PATH + +# Set the default shell +RUN useradd -ms /bin/bash newsreader + RUN mkdir /app WORKDIR /app +RUN chown newsreader:newsreader /app +USER newsreader # Use a seperate layer for the project requirements -COPY ./requirements /app/requirements -RUN pip install -r requirements/dev.txt +COPY requirements /app/requirements +RUN pip install --user -r requirements/dev.txt COPY . /app/ - -# Set the default shell & add a home dir -RUN useradd -ms /bin/bash newsreader -USER newsreader diff --git a/docker-compose.yml b/docker-compose.yml index 182821d..7987022 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,11 +4,26 @@ services: db: # See https://hub.docker.com/_/postgres image: postgres + container_name: postgres environment: - POSTGRES_USER=newsreader - POSTGRES_DB=newsreader + rabbitmq: + image: rabbitmq:3.7 + container_name: rabbitmq + celery: + build: . + container_name: celery + command: celery -A newsreader worker --beat --scheduler django --workdir=/app/src/ + environment: + - DJANGO_SETTINGS_MODULE=newsreader.conf.docker + volumes: + - .:/app + depends_on: + - rabbitmq web: build: . + container_name: web command: src/entrypoint.sh environment: - DJANGO_SETTINGS_MODULE=newsreader.conf.docker @@ -18,14 +33,3 @@ services: - '8000:8000' depends_on: - db - rabbitmq: - image: rabbitmq:3.7 - celery: - build: . - command: celery -A newsreader worker --beat --scheduler django --loglevel=info --workdir=/app/src/ - environment: - - DJANGO_SETTINGS_MODULE=newsreader.conf.docker - volumes: - - .:/app - depends_on: - - rabbitmq diff --git a/gulp/babel.js b/gulp/babel.js new file mode 100644 index 0000000..8bff9fc --- /dev/null +++ b/gulp/babel.js @@ -0,0 +1,28 @@ +import path from 'path'; + +import { dest } from 'gulp'; +import babelify from 'babelify'; +import browserify from 'browserify'; +import source from 'vinyl-source-stream'; +import buffer from 'vinyl-buffer'; +import concat from 'gulp-concat'; + +const PROJECT_DIR = path.join('src', 'newsreader'); +const STATIC_DIR = path.join(PROJECT_DIR, 'js'); +const CORE_DIR = path.join(PROJECT_DIR, 'news', 'core', 'static'); + +const babelTask = () => { + const config = browserify({ + entries: `${STATIC_DIR}/homepage/index.js`, + debug: true, + }).transform(babelify); + + return config + .bundle() + .pipe(source('index.js')) + .pipe(buffer()) + .pipe(concat('homepage.js')) + .pipe(dest(`${CORE_DIR}/core/dist/js/`)); +}; + +export default babelTask; diff --git a/gulp/sass.js b/gulp/sass.js index 637817f..58cf4b0 100644 --- a/gulp/sass.js +++ b/gulp/sass.js @@ -1,17 +1,25 @@ -import { src, dest } from "gulp"; +import { src, dest } from 'gulp'; -import concat from "gulp-concat"; -import path from "path"; -import sass from "gulp-sass"; +import concat from 'gulp-concat'; +import path from 'path'; +import sass from 'gulp-sass'; -const PROJECT_DIR = path.join("src", "newsreader"); -const STATIC_DIR = path.join(PROJECT_DIR, "static"); +const PROJECT_DIR = path.join('src', 'newsreader'); +const STATIC_DIR = path.join(PROJECT_DIR, 'scss'); -export const ACCOUNTS_DIR = path.join(PROJECT_DIR, "accounts", "static"); +export const ACCOUNTS_DIR = path.join(PROJECT_DIR, 'accounts', 'static'); +export const CORE_DIR = path.join(PROJECT_DIR, 'news', 'core', 'static'); -export default function accountsTask(){ - return src(`${STATIC_DIR}/src/scss/accounts/index.scss`) - .pipe(sass().on("error", sass.logError)) - .pipe(concat("accounts.css")) +export const accountsTask = () => { + return src(`${STATIC_DIR}/accounts/index.scss`) + .pipe(sass().on('error', sass.logError)) + .pipe(concat('accounts.css')) .pipe(dest(`${ACCOUNTS_DIR}/accounts/dist/css`)); }; + +export const coreTask = () => { + return src(`${STATIC_DIR}/homepage/index.scss`) + .pipe(sass().on('error', sass.logError)) + .pipe(concat('core.css')) + .pipe(dest(`${CORE_DIR}/core/dist/css`)); +}; diff --git a/gulpfile.babel.js b/gulpfile.babel.js index 56a18ee..bea1785 100644 --- a/gulpfile.babel.js +++ b/gulpfile.babel.js @@ -1,22 +1,27 @@ -import { series, watch as _watch } from 'gulp'; +import { parallel, series, watch as _watch } from 'gulp'; -import path from "path"; -import del from "del"; +import path from 'path'; +import del from 'del'; -import buildSass, { ACCOUNTS_DIR } from "./gulp/sass"; +import { ACCOUNTS_DIR, CORE_DIR, accountsTask, coreTask } from './gulp/sass'; +import babelTask from './gulp/babel'; -const STATIC_DIR = path.join("src", "newsreader", "static"); +const PROJECT_DIR = path.join('src', 'newsreader'); +const sassTasks = [accountsTask, coreTask]; -function clean(){ +const clean = () => { return del([ `${ACCOUNTS_DIR}/accounts/dist/css/*`, + + `${CORE_DIR}/core/dist/css/*`, + `${CORE_DIR}/core/dist/js/*`, ]); }; -export function watch(){ - _watch(`${STATIC_DIR}/src/scss/**/*.scss`, (done) => { - series(clean, buildSass)(done); +export const watch = () => { + return _watch([`${PROJECT_DIR}/scss/**/*.scss`, `${PROJECT_DIR}/js/**/*.js`], done => { + series(clean, ...sassTasks, babelTask)(done); }); }; -export default series(clean, buildSass); +export default series(clean, ...sassTasks, babelTask); diff --git a/package-lock.json b/package-lock.json index c412ebf..2b98192 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,6 +84,16 @@ "@babel/types": "^7.0.0" } }, + "@babel/helper-builder-react-jsx": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-react-jsx/-/helper-builder-react-jsx-7.3.0.tgz", + "integrity": "sha512-MjA9KgwCuPEkQd9ncSXvSyJ5y+j2sICHyrI0M3L+6fnS4wMSNDc1ARXsbTfbb2cXHn17VisSnU/sHFTCxVxSMw==", + "dev": true, + "requires": { + "@babel/types": "^7.3.0", + "esutils": "^2.0.0" + } + }, "@babel/helper-call-delegate": { "version": "7.4.4", "resolved": "https://registry.npmjs.org/@babel/helper-call-delegate/-/helper-call-delegate-7.4.4.tgz", @@ -95,6 +105,114 @@ "@babel/types": "^7.4.4" } }, + "@babel/helper-create-class-features-plugin": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.5.5.tgz", + "integrity": "sha512-ZsxkyYiRA7Bg+ZTRpPvB6AbOFKTFFK4LrvTet8lInm0V468MWCaSYJE+I7v2z2r8KNLtYiV+K5kTCnR7dvyZjg==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.1.0", + "@babel/helper-member-expression-to-functions": "^7.5.5", + "@babel/helper-optimise-call-expression": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-replace-supers": "^7.5.5", + "@babel/helper-split-export-declaration": "^7.4.4" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.5.5.tgz", + "integrity": "sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw==", + "dev": true, + "requires": { + "@babel/highlight": "^7.0.0" + } + }, + "@babel/generator": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.5.5.tgz", + "integrity": "sha512-ETI/4vyTSxTzGnU2c49XHv2zhExkv9JHLTwDAFz85kmcwuShvYG2H08FwgIguQf4JC75CBnXAUM5PqeF4fj0nQ==", + "dev": true, + "requires": { + "@babel/types": "^7.5.5", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0", + "trim-right": "^1.0.1" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.5.5.tgz", + "integrity": "sha512-5qZ3D1uMclSNqYcXqiHoA0meVdv+xUEex9em2fqMnrk/scphGlGgg66zjMrPJESPwrFJ6sbfFQYUSa0Mz7FabA==", + "dev": true, + "requires": { + "@babel/types": "^7.5.5" + } + }, + "@babel/helper-replace-supers": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.5.5.tgz", + "integrity": "sha512-XvRFWrNnlsow2u7jXDuH4jDDctkxbS7gXssrP4q2nUD606ukXHRvydj346wmNg+zAgpFx4MWf4+usfC93bElJg==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.5.5", + "@babel/helper-optimise-call-expression": "^7.0.0", + "@babel/traverse": "^7.5.5", + "@babel/types": "^7.5.5" + } + }, + "@babel/parser": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.5.5.tgz", + "integrity": "sha512-E5BN68cqR7dhKan1SfqgPGhQ178bkVKpXTPEXnFJBrEt8/DKRZlybmy+IgYLTeN7tp1R5Ccmbm2rBk17sHYU3g==", + "dev": true + }, + "@babel/traverse": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.5.5.tgz", + "integrity": "sha512-MqB0782whsfffYfSjH4TM+LMjrJnhCNEDMDIjeTpl+ASaUvxcjoiVCo/sM1GhS1pHOXYfWVCYneLjMckuUxDaQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.5.5", + "@babel/generator": "^7.5.5", + "@babel/helper-function-name": "^7.1.0", + "@babel/helper-split-export-declaration": "^7.4.4", + "@babel/parser": "^7.5.5", + "@babel/types": "^7.5.5", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.5.5.tgz", + "integrity": "sha512-s63F9nJioLqOlW3UkyMd+BYhXt44YuaFm/VV0VwuteqjYwRrObkU7ra9pY4wAJR3oXi8hJrMcrcJdO/HH33vtw==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, "@babel/helper-define-map": { "version": "7.4.4", "resolved": "https://registry.npmjs.org/@babel/helper-define-map/-/helper-define-map-7.4.4.tgz", @@ -296,6 +414,16 @@ "@babel/plugin-syntax-async-generators": "^7.2.0" } }, + "@babel/plugin-proposal-class-properties": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.5.5.tgz", + "integrity": "sha512-AF79FsnWFxjlaosgdi421vmYG6/jg79bVD0dpD44QdgobzHKuLZ6S3vl8la9qIeSwGi8i1fS0O1mfuDAAdo1/A==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.5.5", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, "@babel/plugin-proposal-dynamic-import": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.5.0.tgz", @@ -306,6 +434,16 @@ "@babel/plugin-syntax-dynamic-import": "^7.2.0" } }, + "@babel/plugin-proposal-function-bind": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-function-bind/-/plugin-proposal-function-bind-7.2.0.tgz", + "integrity": "sha512-qOFJ/eX1Is78sywwTxDcsntLOdb5ZlHVVqUz5xznq8ldAfOVIyZzp1JE2rzHnaksZIhrqMrwIpQL/qcEprnVbw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-function-bind": "^7.2.0" + } + }, "@babel/plugin-proposal-json-strings": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.2.0.tgz", @@ -365,6 +503,15 @@ "@babel/helper-plugin-utils": "^7.0.0" } }, + "@babel/plugin-syntax-function-bind": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-function-bind/-/plugin-syntax-function-bind-7.2.0.tgz", + "integrity": "sha512-/WzU1lLU2l0wDfB42Wkg6tahrmtBbiD8C4H6EGSX0M4GAjzN6JiOpq/Uh8G6GSoR6lPMvhjM0MNiV6znj6y/zg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, "@babel/plugin-syntax-json-strings": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.2.0.tgz", @@ -374,6 +521,15 @@ "@babel/helper-plugin-utils": "^7.0.0" } }, + "@babel/plugin-syntax-jsx": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.2.0.tgz", + "integrity": "sha512-VyN4QANJkRW6lDBmENzRszvZf3/4AXaj9YR7GwrWeeN9tEBPuXbmDYVU9bYBN0D70zCWVwUy0HWq2553VCb6Hw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, "@babel/plugin-syntax-object-rest-spread": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.2.0.tgz", @@ -624,6 +780,17 @@ "@babel/helper-plugin-utils": "^7.0.0" } }, + "@babel/plugin-transform-react-jsx": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.3.0.tgz", + "integrity": "sha512-a/+aRb7R06WcKvQLOu4/TpjKOdvVEKRLWFpKcNuHhiREPgGRB4TQJxq07+EZLS8LFVYpfq1a5lDUnuMdcCpBKg==", + "dev": true, + "requires": { + "@babel/helper-builder-react-jsx": "^7.3.0", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-jsx": "^7.2.0" + } + }, "@babel/plugin-transform-regenerator": { "version": "7.4.5", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.4.5.tgz", @@ -642,6 +809,18 @@ "@babel/helper-plugin-utils": "^7.0.0" } }, + "@babel/plugin-transform-runtime": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.5.5.tgz", + "integrity": "sha512-6Xmeidsun5rkwnGfMOp6/z9nSzWpHFNVr2Jx7kwoq4mVatQfQx5S56drBgEHF+XQbKOdIaOiMIINvp/kAwMN+w==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0", + "resolve": "^1.8.1", + "semver": "^5.5.1" + } + }, "@babel/plugin-transform-shorthand-properties": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.2.0.tgz", @@ -772,6 +951,14 @@ "source-map-support": "^0.5.9" } }, + "@babel/runtime": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.5.5.tgz", + "integrity": "sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ==", + "requires": { + "regenerator-runtime": "^0.13.2" + } + }, "@babel/template": { "version": "7.4.4", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.4.4.tgz", @@ -883,12 +1070,52 @@ "integrity": "sha512-7TEYTQT1/6PP53NftXXabIZDaZfaoBdeBm8Md/i7zsWRoBe0YwOXguyK8vhHs8ehgB/w9U4K/6EWuTyp0W6nIA==", "dev": true }, + "JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "requires": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + } + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, + "acorn": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.2.1.tgz", + "integrity": "sha512-JD0xT5FCRDNyjDda3Lrg/IxFscp9q4tiYtxE1/nOzlKCk7hIRuYjhq1kCNkbPjMRMZuFq20HNQn1I9k8Oj0E+Q==", + "dev": true + }, + "acorn-dynamic-import": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/acorn-dynamic-import/-/acorn-dynamic-import-4.0.0.tgz", + "integrity": "sha512-d3OEjQV4ROpoflsnUA8HozoIR504TFxNivYEUi6uwz0IYhBkTDXGuWlNdMtybRt3nqVx/L6XqMt0FxkXuWKZhw==", + "dev": true + }, + "acorn-node": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.7.0.tgz", + "integrity": "sha512-XhahLSsCB6X6CJbe+uNu3Mn9sJBNFxtBN9NLgAOQovfS6Kh0lDUtmlclhjn9CvEK7A7YyRU13PXlNcpSiLI9Yw==", + "dev": true, + "requires": { + "acorn": "^6.1.1", + "acorn-dynamic-import": "^4.0.0", + "acorn-walk": "^6.1.1", + "xtend": "^4.0.1" + } + }, + "acorn-walk": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.2.0.tgz", + "integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==", + "dev": true + }, "ajv": { "version": "6.10.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", @@ -1029,6 +1256,12 @@ "integrity": "sha1-p5SvDAWrF1KEbudTofIRoFugxE8=", "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", @@ -1070,6 +1303,18 @@ } } }, + "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": "1.1.0", "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz", @@ -1116,6 +1361,44 @@ "safer-buffer": "~2.1.0" } }, + "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.5.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.0.tgz", + "integrity": "sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "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", @@ -1194,6 +1477,12 @@ "object.assign": "^4.1.0" } }, + "babelify": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/babelify/-/babelify-10.0.0.tgz", + "integrity": "sha512-X40FaxyH7t3X+JFAKvb1H9wooWKLRCi8pg3m8poqtdZaIng+bjzp9RvKQCvRjF9isHiPkXspbbXT/zwXLtwgwg==", + "dev": true + }, "bach": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/bach/-/bach-1.2.0.tgz", @@ -1272,6 +1561,12 @@ } } }, + "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 + }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -1287,6 +1582,16 @@ "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==", "dev": true }, + "bl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz", + "integrity": "sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==", + "dev": true, + "requires": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, "block-stream": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", @@ -1296,6 +1601,12 @@ "inherits": "~2.0.0" } }, + "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 + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1335,6 +1646,178 @@ } } }, + "brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=", + "dev": true + }, + "browser-pack": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/browser-pack/-/browser-pack-6.1.0.tgz", + "integrity": "sha512-erYug8XoqzU3IfcU8fUgyHqyOXqIE4tUTTQ+7mqUjQlvnXkOO6OlT9c/ZoJVHYoAaqGxr09CN53G7XIsO4KtWA==", + "dev": true, + "requires": { + "JSONStream": "^1.0.3", + "combine-source-map": "~0.8.0", + "defined": "^1.0.0", + "safe-buffer": "^5.1.1", + "through2": "^2.0.0", + "umd": "^3.0.0" + } + }, + "browser-resolve": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-1.11.3.tgz", + "integrity": "sha512-exDi1BYWB/6raKHmDTCicQfTkqwN5fioMFV4j8BsfMU4R2DK/QfZfK7kOVkmWCNANf0snkBzqGqAJBao9gZMdQ==", + "dev": true, + "requires": { + "resolve": "1.1.7" + }, + "dependencies": { + "resolve": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", + "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=", + "dev": true + } + } + }, + "browserify": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/browserify/-/browserify-16.3.0.tgz", + "integrity": "sha512-BWaaD7alyGZVEBBwSTYx4iJF5DswIGzK17o8ai9w4iKRbYpk3EOiprRHMRRA8DCZFmFeOdx7A385w2XdFvxWmg==", + "dev": true, + "requires": { + "JSONStream": "^1.0.3", + "assert": "^1.4.0", + "browser-pack": "^6.0.1", + "browser-resolve": "^1.11.0", + "browserify-zlib": "~0.2.0", + "buffer": "^5.0.2", + "cached-path-relative": "^1.0.0", + "concat-stream": "^1.6.0", + "console-browserify": "^1.1.0", + "constants-browserify": "~1.0.0", + "crypto-browserify": "^3.0.0", + "defined": "^1.0.0", + "deps-sort": "^2.0.0", + "domain-browser": "^1.2.0", + "duplexer2": "~0.1.2", + "events": "^2.0.0", + "glob": "^7.1.0", + "has": "^1.0.0", + "htmlescape": "^1.1.0", + "https-browserify": "^1.0.0", + "inherits": "~2.0.1", + "insert-module-globals": "^7.0.0", + "labeled-stream-splicer": "^2.0.0", + "mkdirp": "^0.5.0", + "module-deps": "^6.0.0", + "os-browserify": "~0.3.0", + "parents": "^1.0.1", + "path-browserify": "~0.0.0", + "process": "~0.11.0", + "punycode": "^1.3.2", + "querystring-es3": "~0.2.0", + "read-only-stream": "^2.0.0", + "readable-stream": "^2.0.2", + "resolve": "^1.1.4", + "shasum": "^1.0.0", + "shell-quote": "^1.6.1", + "stream-browserify": "^2.0.0", + "stream-http": "^2.0.0", + "string_decoder": "^1.1.1", + "subarg": "^1.0.0", + "syntax-error": "^1.1.1", + "through2": "^2.0.0", + "timers-browserify": "^1.0.1", + "tty-browserify": "0.0.1", + "url": "~0.11.0", + "util": "~0.10.1", + "vm-browserify": "^1.0.0", + "xtend": "^4.0.0" + }, + "dependencies": { + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "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": "4.6.6", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.6.6.tgz", @@ -1346,6 +1829,16 @@ "node-releases": "^1.1.25" } }, + "buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.2.1.tgz", + "integrity": "sha512-c+Ko0loDaFfuPWiL02ls9Xd3GO3cPVmUobQ6t3rXNUk304u6hGq+8N/kFi+QEIKhzK3uwolVhLzszmfLmMLnqg==", + "dev": true, + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, "buffer-equal": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.0.tgz", @@ -1358,6 +1851,18 @@ "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", "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-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 + }, "cache-base": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", @@ -1375,6 +1880,12 @@ "unset-value": "^1.0.0" } }, + "cached-path-relative": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.0.2.tgz", + "integrity": "sha512-5r2GqsoEb4qMTTN9J+WzXfjov+hjxT+j3u5K+kIVNIwAd99DLCJE9pBIMP1qVeybV6JiijL385Oz0DcYxfbOIg==", + "dev": true + }, "camelcase": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", @@ -1450,6 +1961,16 @@ } } }, + "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" + } + }, "class-utils": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", @@ -1561,6 +2082,26 @@ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", "dev": true }, + "combine-source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/combine-source-map/-/combine-source-map-0.8.0.tgz", + "integrity": "sha1-pY0N8ELBhvz4IqjoAV9UUNLXmos=", + "dev": true, + "requires": { + "convert-source-map": "~1.1.0", + "inline-source-map": "~0.6.0", + "lodash.memoize": "~3.0.3", + "source-map": "~0.5.3" + }, + "dependencies": { + "convert-source-map": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.1.3.tgz", + "integrity": "sha1-SCnId+n+SbMWHzvzZziI4gRpmGA=", + "dev": true + } + } + }, "combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1617,12 +2158,27 @@ } } }, + "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 + }, "convert-source-map": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.6.0.tgz", @@ -1685,6 +2241,62 @@ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true }, + "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" + } + }, + "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" + } + }, "currently-unhandled": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", @@ -1704,6 +2316,12 @@ "type": "^1.0.1" } }, + "dash-ast": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dash-ast/-/dash-ast-1.0.0.tgz", + "integrity": "sha512-Vy4dx7gquTeMcQR/hDkYLGUnwVil6vk4FOOct+djUnHOUWt+zJPJAaRIXaAFkPXtJjvlY7o3rfRu0/3hpnwoUA==", + "dev": true + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -1713,6 +2331,12 @@ "assert-plus": "^1.0.0" } }, + "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": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1734,6 +2358,11 @@ "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", "dev": true }, + "deep-diff": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-0.3.8.tgz", + "integrity": "sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ=" + }, "default-compare": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/default-compare/-/default-compare-1.0.0.tgz", @@ -1807,6 +2436,12 @@ } } }, + "defined": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", + "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=", + "dev": true + }, "del": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/del/-/del-5.0.0.tgz", @@ -1832,12 +2467,56 @@ "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "dev": true }, + "deps-sort": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/deps-sort/-/deps-sort-2.0.0.tgz", + "integrity": "sha1-CRckkC6EZYJg65EHSMzNGvbiH7U=", + "dev": true, + "requires": { + "JSONStream": "^1.0.3", + "shasum": "^1.0.0", + "subarg": "^1.0.0", + "through2": "^2.0.0" + } + }, + "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" + } + }, "detect-file": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", "integrity": "sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=", "dev": true }, + "detective": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", + "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==", + "dev": true, + "requires": { + "acorn-node": "^1.6.1", + "defined": "^1.0.0", + "minimist": "^1.1.1" + } + }, + "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": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -1855,6 +2534,21 @@ } } }, + "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 + }, + "duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=", + "dev": true, + "requires": { + "readable-stream": "^2.0.2" + } + }, "duplexify": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", @@ -1893,6 +2587,21 @@ "integrity": "sha512-jasjtY5RUy/TOyiUYM2fb4BDaPZfm6CXRFeJDMfFsXYADGxUN49RBqtgB7EL2RmJXeIRUk9lM1U6A5yk2YJMPQ==", "dev": true }, + "elliptic": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.0.tgz", + "integrity": "sha512-eFOJTMyCYb7xtE/caJ6JJu+bhi67WCYNbkGSknu20pmM8Ke/bqOfdnZWxyoGN26JgfxTbXrsCkEw4KheCT/KGg==", + "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" + } + }, "end-of-stream": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", @@ -1967,6 +2676,22 @@ "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", "dev": true }, + "events": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/events/-/events-2.1.0.tgz", + "integrity": "sha512-3Zmiobend8P9DjmKAty0Era4jV8oJ0yGYe2nJJAxgymF9+N8F2m0hhZiMoWtcfepExzNKZumFU3ksdQbInGWCg==", + "dev": true + }, + "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" + } + }, "expand-brackets": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", @@ -2979,6 +3704,12 @@ "globule": "^1.0.0" } }, + "get-assigned-identifiers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-assigned-identifiers/-/get-assigned-identifiers-1.2.0.tgz", + "integrity": "sha512-mBBwmeGTrxEMO4pMaaf/uUEFHnYtwr8FTe8Y/mer4rcV/bye0qGm6pw1bGZFGStxC5O76c5ZAVBGnqHmOaJpdQ==", + "dev": true + }, "get-caller-file": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", @@ -3264,6 +3995,15 @@ "har-schema": "^2.0.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", @@ -3331,6 +4071,45 @@ } } }, + "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.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "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" + } + }, + "hoist-non-react-statics": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz", + "integrity": "sha512-0XsbTXxgiaCDYDIWFcwkmerZPSwywfUqYmwT4jzewKTQSWoE6FCMoUVOeBJWK3E/CrWbxRG3m5GzY4lnIwGRBA==", + "requires": { + "react-is": "^16.7.0" + } + }, "homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -3346,6 +4125,12 @@ "integrity": "sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w==", "dev": true }, + "htmlescape": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/htmlescape/-/htmlescape-1.1.1.tgz", + "integrity": "sha1-OgPtwiFLyjtmQko+eVk0lQnLA1E=", + "dev": true + }, "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -3357,6 +4142,18 @@ "sshpk": "^1.7.0" } }, + "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 + }, + "ieee754": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==", + "dev": true + }, "ignore": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.2.tgz", @@ -3400,6 +4197,33 @@ "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", "dev": true }, + "inline-source-map": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/inline-source-map/-/inline-source-map-0.6.2.tgz", + "integrity": "sha1-+Tk0ccGKedFyT4Y/o4tYY3Ct4qU=", + "dev": true, + "requires": { + "source-map": "~0.5.3" + } + }, + "insert-module-globals": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/insert-module-globals/-/insert-module-globals-7.2.0.tgz", + "integrity": "sha512-VE6NlW+WGn2/AeOMd496AHFYmE7eLKkUY6Ty31k4og5vmA3Fjuwe9v6ifH6Xx/Hz27QvdoMoviw1/pqWRB09Sw==", + "dev": true, + "requires": { + "JSONStream": "^1.0.3", + "acorn-node": "^1.5.2", + "combine-source-map": "^0.8.0", + "concat-stream": "^1.6.1", + "is-buffer": "^1.1.0", + "path-is-absolute": "^1.0.1", + "process": "~0.11.0", + "through2": "^2.0.0", + "undeclared-identifiers": "^1.1.2", + "xtend": "^4.0.0" + } + }, "interpret": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.2.0.tgz", @@ -3410,7 +4234,6 @@ "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" } @@ -3678,6 +4501,11 @@ "integrity": "sha512-M7kLczedRMYX4L8Mdh4MzyAMM9O5osx+4FcOQuTvr3A9F2D9S5JXheN0ewNbrvK2UatkTRhL5ejGmGSjNMiZuw==", "dev": true }, + "js-cookie": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", + "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==" + }, "js-levenshtein": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", @@ -3687,8 +4515,7 @@ "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 + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "jsbn": { "version": "0.1.1", @@ -3714,6 +4541,15 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "json-stable-stringify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-0.0.1.tgz", + "integrity": "sha1-YRwj6BTbN1Un34URk9tZ3Sryf0U=", + "dev": true, + "requires": { + "jsonify": "~0.0.0" + } + }, "json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -3735,6 +4571,18 @@ "minimist": "^1.2.0" } }, + "jsonify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", + "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", + "dev": true + }, + "jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=", + "dev": true + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -3759,6 +4607,16 @@ "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", "dev": true }, + "labeled-stream-splicer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/labeled-stream-splicer/-/labeled-stream-splicer-2.0.2.tgz", + "integrity": "sha512-Ca4LSXFFZUjPScRaqOcFxneA0VpKZr4MMYCljyQr4LIewTLb3Y0IUTIsnBBsVubIeEfxeSZpSjSsRM8APEQaAw==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "stream-splicer": "^2.0.0" + } + }, "last-run": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/last-run/-/last-run-1.1.1.tgz", @@ -3844,10 +4702,9 @@ } }, "lodash": { - "version": "4.17.14", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.14.tgz", - "integrity": "sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw==", - "dev": true + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" }, "lodash.clonedeep": { "version": "4.5.0", @@ -3855,11 +4712,16 @@ "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", "dev": true }, + "lodash.memoize": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-3.0.4.tgz", + "integrity": "sha1-LcvSwofLwKVcxCMovQxzYVDVPj8=", + "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" } @@ -3949,6 +4811,17 @@ } } }, + "md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "dev": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, "meow": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", @@ -3994,6 +4867,16 @@ "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-db": { "version": "1.40.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", @@ -4009,6 +4892,18 @@ "mime-db": "1.40.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", @@ -4062,6 +4957,29 @@ } } }, + "module-deps": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/module-deps/-/module-deps-6.2.1.tgz", + "integrity": "sha512-UnEn6Ah36Tu4jFiBbJVUtt0h+iXqxpLqDvPS8nllbw5RZFmNJ1+Mz5BjYnM9ieH80zyxHkARGLnMIHlPK5bu6A==", + "dev": true, + "requires": { + "JSONStream": "^1.0.3", + "browser-resolve": "^1.7.0", + "cached-path-relative": "^1.0.2", + "concat-stream": "~1.6.0", + "defined": "^1.0.0", + "detective": "^5.0.2", + "duplexer2": "^0.1.2", + "inherits": "^2.0.1", + "parents": "^1.0.0", + "readable-stream": "^2.0.2", + "resolve": "^1.4.0", + "stream-combiner2": "^1.1.1", + "subarg": "^1.0.0", + "through2": "^2.0.0", + "xtend": "^4.0.0" + } + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -4307,8 +5225,7 @@ "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 + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-copy": { "version": "0.1.0", @@ -4427,6 +5344,12 @@ "readable-stream": "^2.0.1" } }, + "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", @@ -4488,6 +5411,35 @@ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true }, + "pako": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.10.tgz", + "integrity": "sha512-0DTvPVU3ed8+HNXOu5Bs+o//Mbdj9VNQMUOe9oKCwh8l0GNwpTDMKCWbRjgtD291AWnkAgkqA/LOnQS8AmS1tw==", + "dev": true + }, + "parents": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parents/-/parents-1.0.1.tgz", + "integrity": "sha1-/t1NK/GTp3dF/nHjcdc8MwfZx1E=", + "dev": true, + "requires": { + "path-platform": "~0.11.15" + } + }, + "parse-asn1": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.4.tgz", + "integrity": "sha512-Qs5duJcuvNExRfFZ99HDD3z4mAi3r9Wl/FOjEOijlxwCZs7E7mW2vjTpgQ4J8LpTF8x5v+1Vn5UQFejmWT11aw==", + "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", + "safe-buffer": "^5.1.1" + } + }, "parse-filepath": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", @@ -4526,6 +5478,12 @@ "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", "dev": true }, + "path-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz", + "integrity": "sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==", + "dev": true + }, "path-dirname": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", @@ -4556,6 +5514,12 @@ "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", "dev": true }, + "path-platform": { + "version": "0.11.15", + "resolved": "https://registry.npmjs.org/path-platform/-/path-platform-0.11.15.tgz", + "integrity": "sha1-6GQhf3TDaFDwhSt43Hv31KVyG/I=", + "dev": true + }, "path-root": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", @@ -4590,6 +5554,19 @@ } } }, + "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", @@ -4677,12 +5654,28 @@ "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==", "dev": true }, + "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.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } + }, "pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", @@ -4695,6 +5688,20 @@ "integrity": "sha512-GEn74ZffufCmkDDLNcl3uuyF/aSD6exEyh1v/ZSdAomB82t6G9hzJVRx0jBmLDW+VfZqks3aScmMw9DszwUalA==", "dev": true }, + "public-encrypt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", + "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", + "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", + "safe-buffer": "^5.1.2" + } + }, "pump": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", @@ -4728,6 +5735,88 @@ "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 + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "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" + } + }, + "react": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react/-/react-16.8.6.tgz", + "integrity": "sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.13.6" + } + }, + "react-dom": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.6.tgz", + "integrity": "sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.13.6" + } + }, + "react-is": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", + "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==" + }, + "react-redux": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.1.0.tgz", + "integrity": "sha512-hyu/PoFK3vZgdLTg9ozbt7WF3GgX5+Yn3pZm5/96/o4UueXA+zj08aiSC9Mfj2WtD1bvpIb3C5yvskzZySzzaw==", + "requires": { + "@babel/runtime": "^7.4.5", + "hoist-non-react-statics": "^3.3.0", + "invariant": "^2.2.4", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^16.8.6" + } + }, + "read-only-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-only-stream/-/read-only-stream-2.0.0.tgz", + "integrity": "sha1-JyT9aoET1zdkrCiNQ4YnDB2/F/A=", + "dev": true, + "requires": { + "readable-stream": "^2.0.2" + } + }, "read-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", @@ -4815,6 +5904,28 @@ "strip-indent": "^1.0.1" } }, + "redux": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.4.tgz", + "integrity": "sha512-vKv4WdiJxOWKxK0yRoaK3Y4pxxB0ilzVx6dszU2W8wLxlb2yikRph4iV/ymtdJ6ZxpBLFbyrxklnT5yBbQSl3Q==", + "requires": { + "loose-envify": "^1.4.0", + "symbol-observable": "^1.2.0" + } + }, + "redux-logger": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/redux-logger/-/redux-logger-3.0.6.tgz", + "integrity": "sha1-91VZZvMJjzyIYExEnPC69XeCdL8=", + "requires": { + "deep-diff": "^0.3.5" + } + }, + "redux-thunk": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz", + "integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw==" + }, "regenerate": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz", @@ -4830,6 +5941,11 @@ "regenerate": "^1.4.0" } }, + "regenerator-runtime": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz", + "integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==" + }, "regenerator-transform": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.14.0.tgz", @@ -5052,6 +6168,16 @@ "glob": "^7.1.3" } }, + "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-parallel": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.9.tgz", @@ -5233,6 +6359,16 @@ } } }, + "scheduler": { + "version": "0.13.6", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.6.tgz", + "integrity": "sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, "scss-tokenizer": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz", @@ -5298,12 +6434,50 @@ } } }, + "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" + } + }, + "shasum": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/shasum/-/shasum-1.0.2.tgz", + "integrity": "sha1-5wEjENj0F/TetXEhUOVni4euVl8=", + "dev": true, + "requires": { + "json-stable-stringify": "~0.0.0", + "sha.js": "~2.4.4" + } + }, + "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" + } + }, "signal-exit": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "dev": true }, + "simple-concat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.0.tgz", + "integrity": "sha1-c0TLuLbib7J9ZrL8hvn21Zl1IcY=", + "dev": true + }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -5560,18 +6734,61 @@ "readable-stream": "^2.0.1" } }, + "stream-browserify": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz", + "integrity": "sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==", + "dev": true, + "requires": { + "inherits": "~2.0.1", + "readable-stream": "^2.0.2" + } + }, + "stream-combiner2": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", + "integrity": "sha1-+02KFCDqNidk4hrUeAOXvry0HL4=", + "dev": true, + "requires": { + "duplexer2": "~0.1.0", + "readable-stream": "^2.0.2" + } + }, "stream-exhaust": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/stream-exhaust/-/stream-exhaust-1.0.2.tgz", "integrity": "sha512-b/qaq/GlBK5xaq1yrK9/zFcyRSTNxmcZwFLGSTG0mXgZl/4Z6GgiyYOXOvY7N3eEvFRAG1bkDRz5EPGSvPYQlw==", "dev": true }, + "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 }, + "stream-splicer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/stream-splicer/-/stream-splicer-2.0.1.tgz", + "integrity": "sha512-Xizh4/NPuYSyAXyT7g8IvdJ9HJpxIGL9PjyhtywCZvvP0OPIdqyrr4dMikeuvY8xahpdKEBlBTySe583totajg==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.2" + } + }, "string-width": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", @@ -5630,6 +6847,15 @@ "get-stdin": "^4.0.1" } }, + "subarg": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz", + "integrity": "sha1-9izxdYHplrSPyWVpn1TAauJouNI=", + "dev": true, + "requires": { + "minimist": "^1.1.0" + } + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -5649,6 +6875,20 @@ "es6-symbol": "^3.1.1" } }, + "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==" + }, + "syntax-error": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/syntax-error/-/syntax-error-1.4.0.tgz", + "integrity": "sha512-YPPlu67mdnHGTup2A8ff7BC2Pjq0e0Yp/IyTFN03zWO0RcK07uLcbi7C2KpGR2FvWbaB0+bfE27a+sBKebSo7w==", + "dev": true, + "requires": { + "acorn-node": "^1.2.0" + } + }, "tar": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.2.tgz", @@ -5660,6 +6900,12 @@ "inherits": "2" } }, + "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.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", @@ -5686,6 +6932,15 @@ "integrity": "sha1-dkpaEa9QVhkhsTPztE5hhofg9cM=", "dev": true }, + "timers-browserify": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-1.4.2.tgz", + "integrity": "sha1-ycWLV1voQHN1y14kYtrO50NZ9B0=", + "dev": true, + "requires": { + "process": "~0.11.0" + } + }, "to-absolute-glob": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz", @@ -5696,6 +6951,12 @@ "is-negated-glob": "^1.0.0" } }, + "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": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -5792,6 +7053,12 @@ "glob": "^7.1.2" } }, + "tty-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", + "integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==", + "dev": true + }, "tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -5819,12 +7086,31 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", "dev": true }, + "umd": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/umd/-/umd-3.0.3.tgz", + "integrity": "sha512-4IcGSufhFshvLNcMCV80UnQVlZ5pMOC8mvNPForqwA4+lzYQuetTESLDQkeLmihq8bRcnpbQa48Wb8Lh16/xow==", + "dev": true + }, "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 }, + "undeclared-identifiers": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/undeclared-identifiers/-/undeclared-identifiers-1.1.3.tgz", + "integrity": "sha512-pJOW4nxjlmfwKApE4zvxLScM/njmwj/DiUBv7EabwE4O8kRUy+HIwxQtZLBPll/jx1LJyBcqNfB3/cpv9EZwOw==", + "dev": true, + "requires": { + "acorn-node": "^1.3.0", + "dash-ast": "^1.0.0", + "get-assigned-identifiers": "^1.2.0", + "simple-concat": "^1.0.0", + "xtend": "^4.0.1" + } + }, "undertaker": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/undertaker/-/undertaker-1.2.1.tgz", @@ -5959,12 +7245,47 @@ "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 + } + } + }, "use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", "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" + }, + "dependencies": { + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + } + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6027,6 +7348,16 @@ "replace-ext": "^1.0.0" } }, + "vinyl-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vinyl-buffer/-/vinyl-buffer-1.0.1.tgz", + "integrity": "sha1-lsGjR5uMU5JULGEgKQE7Wyf4i78=", + "dev": true, + "requires": { + "bl": "^1.2.1", + "through2": "^2.0.3" + } + }, "vinyl-fs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-3.0.3.tgz", @@ -6052,6 +7383,16 @@ "vinyl-sourcemap": "^1.1.0" } }, + "vinyl-source-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/vinyl-source-stream/-/vinyl-source-stream-2.0.0.tgz", + "integrity": "sha1-84pa+53R6Ttl1VBGmsYYKsT1S44=", + "dev": true, + "requires": { + "through2": "^2.0.3", + "vinyl": "^2.1.0" + } + }, "vinyl-sourcemap": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/vinyl-sourcemap/-/vinyl-sourcemap-1.1.0.tgz", @@ -6076,6 +7417,12 @@ "source-map": "^0.5.1" } }, + "vm-browserify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.0.tgz", + "integrity": "sha512-iq+S7vZJE60yejDYM0ek6zg308+UZsdtPExWP9VZoCFCz1zkJoXFnAX7aZfd/ZwrkidzdUZL0C/ryW+JwAiIGw==", + "dev": true + }, "which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", diff --git a/package.json b/package.json index 5e339a4..5eca7dd 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "description": "Application for viewing RSS feeds", "main": "index.js", "scripts": { - "lint": "prettier \"src/newsreader/**/*.js\" --check", - "format": "prettier \"src/newsreader/**/*.js\" --write" + "lint": "prettier \"src/newsreader/js/**/*.js\" --check", + "format": "prettier \"src/newsreader/js/**/*.js\" --write" }, "repository": { "type": "git", @@ -13,21 +13,27 @@ }, "author": "Sonny", "license": "GPL-3.0-or-later", - "prettier": { - "semi": true, - "trailingComma": "es5", - "singleQuote": false, - "printWidth": 80, - "tabWidth": 2, - "useTabs": false, - "bracketSpacing": false, - "arrowParens": "always" + "dependencies": { + "js-cookie": "^2.2.1", + "lodash": "^4.17.15", + "react-redux": "^7.1.0", + "redux": "^4.0.4", + "redux-logger": "^3.0.6", + "redux-thunk": "^2.3.0" }, - "dependencies": {}, "devDependencies": { "@babel/core": "^7.5.4", + "@babel/plugin-proposal-class-properties": "^7.5.5", + "@babel/plugin-proposal-function-bind": "^7.2.0", + "@babel/plugin-syntax-dynamic-import": "^7.2.0", + "@babel/plugin-syntax-function-bind": "^7.2.0", + "@babel/plugin-transform-react-jsx": "^7.3.0", + "@babel/plugin-transform-runtime": "^7.5.5", "@babel/preset-env": "^7.5.4", "@babel/register": "^7.4.4", + "@babel/runtime": "^7.5.5", + "babelify": "^10.0.0", + "browserify": "^16.3.0", "del": "^5.0.0", "gulp": "^4.0.2", "gulp-babel": "^8.0.0-beta.2", @@ -35,6 +41,10 @@ "gulp-concat": "^2.6.1", "gulp-sass": "^4.0.2", "node-sass": "^4.12.0", - "prettier": "^1.18.2" + "prettier": "^1.18.2", + "react": "^16.8.6", + "react-dom": "^16.8.6", + "vinyl-buffer": "^1.0.1", + "vinyl-source-stream": "^2.0.0" } } diff --git a/requirements/base.txt b/requirements/base.txt index 25bf4ff..b3bcaf7 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -6,7 +6,7 @@ chardet==3.0.4 Django==2.2 django-celery-beat==1.5.0 djangorestframework==3.9.4 -django-rest-swagger-2.2.0 +django-rest-swagger==2.2.0 lxml==4.3.4 feedparser==5.2.1 idna==2.8 diff --git a/requirements/gitlab.txt b/requirements/gitlab.txt index a0b3eca..5e3e231 100644 --- a/requirements/gitlab.txt +++ b/requirements/gitlab.txt @@ -1,6 +1,5 @@ --r base.txt +-r testing.txt -factory-boy==2.12.0 -freezegun==0.3.12 black==19.3b0 isort==4.3.20 +autoflake==1.3 diff --git a/src/newsreader/accounts/views.py b/src/newsreader/accounts/views.py index ae33591..4957350 100644 --- a/src/newsreader/accounts/views.py +++ b/src/newsreader/accounts/views.py @@ -3,13 +3,11 @@ from django.contrib.auth.views import LogoutView as DjangoLogoutView from django.urls import reverse_lazy -# TODO redirect to homepage when logged in class LoginView(DjangoLoginView): template_name = "accounts/login.html" def get_success_url(self): - # TODO redirect to homepage - return reverse_lazy("admin:index") + return reverse_lazy("index") class LogoutView(DjangoLogoutView): diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 6d5e1b0..e8bcc68 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -112,6 +112,8 @@ USE_TZ = True # https://docs.djangoproject.com/en/2.2/howto/static-files/ STATIC_URL = "/static/" +STATICFILES_DIRS = ["src/newsreader/static/icons"] + # Third party settings REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( @@ -123,3 +125,9 @@ REST_FRAMEWORK = { ), "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), } + +SWAGGER_SETTINGS = { + "LOGIN_URL": "rest_framework:login", + "LOGOUT_URL": "rest_framework:logout", + "DOC_EXPANSION": "list", +} diff --git a/src/newsreader/fixtures/default-fixture.json b/src/newsreader/fixtures/default-fixture.json index 514930a..e0ed80f 100644 --- a/src/newsreader/fixtures/default-fixture.json +++ b/src/newsreader/fixtures/default-fixture.json @@ -1 +1,282 @@ -[{"model": "accounts.user", "pk": 1, "fields": {"password": "pbkdf2_sha256$150000$OeNoz2LRSpI5$jkkUf/BjTuWZULyldvNTt9f45/ErxaCmCQHfZwtzji8=", "last_login": "2019-08-10T18:07:27.224Z", "is_superuser": true, "first_name": "", "last_name": "", "is_staff": true, "is_active": true, "date_joined": "2019-08-10T18:07:19.699Z", "email": "sonny@newsreader.nl", "task": null, "task_interval": null, "groups": [], "user_permissions": []}}, {"model": "django_celery_beat.intervalschedule", "pk": 1, "fields": {"every": 5, "period": "minutes"}}, {"model": "django_celery_beat.intervalschedule", "pk": 2, "fields": {"every": 30, "period": "minutes"}}, {"model": "django_celery_beat.intervalschedule", "pk": 3, "fields": {"every": 3, "period": "hours"}}, {"model": "django_celery_beat.crontabschedule", "pk": 1, "fields": {"minute": "0", "hour": "4", "day_of_week": "*", "day_of_month": "*", "month_of_year": "*", "timezone": "UTC"}}, {"model": "django_celery_beat.periodictasks", "pk": 1, "fields": {"last_update": "2019-08-10T20:01:21.152Z"}}, {"model": "django_celery_beat.periodictask", "pk": 1, "fields": {"name": "celery.backend_cleanup", "task": "celery.backend_cleanup", "interval": null, "crontab": 1, "solar": null, "clocked": null, "args": "[]", "kwargs": "{}", "queue": null, "exchange": null, "routing_key": null, "headers": "{}", "priority": null, "expires": null, "one_off": false, "start_time": null, "enabled": true, "last_run_at": null, "total_run_count": 0, "date_changed": "2019-08-10T19:56:33.160Z", "description": ""}}, {"model": "django_celery_beat.periodictask", "pk": 3, "fields": {"name": "Collection testing task", "task": "newsreader.news.collection.tasks.collect", "interval": 3, "crontab": null, "solar": null, "clocked": null, "args": "[1]", "kwargs": "{}", "queue": null, "exchange": null, "routing_key": null, "headers": "{}", "priority": null, "expires": null, "one_off": false, "start_time": "2019-08-10T20:00:52Z", "enabled": true, "last_run_at": null, "total_run_count": 0, "date_changed": "2019-08-10T20:01:21.153Z", "description": ""}}, {"model": "core.category", "pk": 1, "fields": {"created": "2019-08-10T19:57:53Z", "modified": "2019-08-10T19:58:02.048Z", "name": "Tech", "user": 1}}, {"model": "core.category", "pk": 2, "fields": {"created": "2019-08-10T19:58:13Z", "modified": "2019-08-10T19:58:20.951Z", "name": "News", "user": 1}}, {"model": "collection.collectionrule", "pk": 1, "fields": {"created": "2019-08-10T18:08:10.520Z", "modified": "2019-08-10T19:59:54.547Z", "name": "Tweakers", "url": "http://feeds.feedburner.com/tweakers/mixed", "website_url": null, "favicon": null, "timezone": "Europe/Amsterdam", "category": 1, "last_suceeded": "2019-08-10T18:25:32.325Z", "succeeded": true, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 2, "fields": {"created": "2019-08-10T19:58:05.615Z", "modified": "2019-08-10T19:58:05.691Z", "name": "Hackers News", "url": "https://news.ycombinator.com/rss", "website_url": null, "favicon": null, "timezone": "UTC", "category": 1, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 3, "fields": {"created": "2019-08-10T19:58:23.441Z", "modified": "2019-08-10T19:58:23.449Z", "name": "BBC", "url": "http://feeds.bbci.co.uk/news/world/rss.xml", "website_url": null, "favicon": null, "timezone": "UTC", "category": 2, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 4, "fields": {"created": "2019-08-10T19:58:31.867Z", "modified": "2019-08-10T19:58:31.873Z", "name": "Ars Technica", "url": "http://feeds.arstechnica.com/arstechnica/index?fmt=xml", "website_url": null, "favicon": null, "timezone": "UTC", "category": 1, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 5, "fields": {"created": "2019-08-10T19:58:48.529Z", "modified": "2019-08-10T19:58:48.535Z", "name": "The Guardian", "url": "https://www.theguardian.com/world/rss", "website_url": null, "favicon": null, "timezone": "UTC", "category": 2, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 6, "fields": {"created": "2019-08-10T19:58:58.641Z", "modified": "2019-08-10T19:58:58.647Z", "name": "Engadget", "url": "https://www.engadget.com/rss.xml", "website_url": null, "favicon": null, "timezone": "UTC", "category": 1, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 7, "fields": {"created": "2019-08-10T19:59:29.909Z", "modified": "2019-08-10T19:59:29.917Z", "name": "The Verge", "url": "https://www.theverge.com/rss/index.xml", "website_url": null, "favicon": null, "timezone": "UTC", "category": 1, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}, {"model": "collection.collectionrule", "pk": 8, "fields": {"created": "2019-08-10T19:59:44.833Z", "modified": "2019-08-10T19:59:44.838Z", "name": "News", "url": "http://feeds.boingboing.net/boingboing/iBag", "website_url": null, "favicon": null, "timezone": "UTC", "category": 2, "last_suceeded": null, "succeeded": false, "error": null, "user": 1}}] \ No newline at end of file +[ + { + "fields" : { + "last_login" : "2019-08-10T18:07:27.224Z", + "last_name" : "", + "is_superuser" : true, + "is_staff" : true, + "task_interval" : null, + "email" : "sonny@newsreader.nl", + "user_permissions" : [], + "groups" : [], + "password" : "pbkdf2_sha256$150000$OeNoz2LRSpI5$jkkUf/BjTuWZULyldvNTt9f45/ErxaCmCQHfZwtzji8=", + "first_name" : "", + "task" : null, + "is_active" : true, + "date_joined" : "2019-08-10T18:07:19.699Z" + }, + "pk" : 1, + "model" : "accounts.user" + }, + { + "pk" : 1, + "model" : "django_celery_beat.intervalschedule", + "fields" : { + "period" : "minutes", + "every" : 5 + } + }, + { + "pk" : 2, + "model" : "django_celery_beat.intervalschedule", + "fields" : { + "every" : 30, + "period" : "minutes" + } + }, + { + "fields" : { + "every" : 3, + "period" : "hours" + }, + "model" : "django_celery_beat.intervalschedule", + "pk" : 3 + }, + { + "fields" : { + "minute" : "0", + "timezone" : "UTC", + "day_of_month" : "*", + "hour" : "4", + "month_of_year" : "*", + "day_of_week" : "*" + }, + "model" : "django_celery_beat.crontabschedule", + "pk" : 1 + }, + { + "fields" : { + "last_update" : "2019-08-10T20:01:21.152Z" + }, + "model" : "django_celery_beat.periodictasks", + "pk" : 1 + }, + { + "model" : "django_celery_beat.periodictask", + "pk" : 1, + "fields" : { + "description" : "", + "last_run_at" : null, + "routing_key" : null, + "kwargs" : "{}", + "queue" : null, + "name" : "celery.backend_cleanup", + "clocked" : null, + "solar" : null, + "task" : "celery.backend_cleanup", + "one_off" : false, + "expires" : null, + "total_run_count" : 0, + "date_changed" : "2019-08-10T19:56:33.160Z", + "args" : "[]", + "start_time" : null, + "priority" : null, + "crontab" : 1, + "interval" : null, + "enabled" : true, + "headers" : "{}", + "exchange" : null + } + }, + { + "pk" : 3, + "model" : "django_celery_beat.periodictask", + "fields" : { + "exchange" : null, + "headers" : "{}", + "enabled" : true, + "priority" : null, + "crontab" : null, + "interval" : 3, + "start_time" : "2019-08-10T20:00:52Z", + "args" : "[1]", + "date_changed" : "2019-08-10T20:01:21.153Z", + "total_run_count" : 0, + "expires" : null, + "task" : "newsreader.news.collection.tasks.collect", + "one_off" : false, + "clocked" : null, + "solar" : null, + "name" : "Collection testing task", + "queue" : null, + "kwargs" : "{}", + "routing_key" : null, + "last_run_at" : null, + "description" : "" + } + }, + { + "fields" : { + "user" : 1, + "created" : "2019-08-10T19:57:53Z", + "name" : "Tech", + "modified" : "2019-08-10T19:58:02.048Z" + }, + "pk" : 1, + "model" : "core.category" + }, + { + "fields" : { + "modified" : "2019-08-10T19:58:20.951Z", + "user" : 1, + "created" : "2019-08-10T19:58:13Z", + "name" : "News" + }, + "pk" : 2, + "model" : "core.category" + }, + { + "pk" : 1, + "model" : "collection.collectionrule", + "fields" : { + "timezone" : "Europe/Amsterdam", + "succeeded" : true, + "error" : null, + "website_url" : null, + "favicon" : null, + "category" : 1, + "created" : "2019-08-10T18:08:10.520Z", + "user" : 1, + "name" : "Tweakers", + "url" : "http://feeds.feedburner.com/tweakers/mixed", + "last_suceeded" : "2019-08-10T18:25:32.325Z", + "modified" : "2019-08-10T19:59:54.547Z" + } + }, + { + "fields" : { + "favicon" : null, + "category" : 1, + "timezone" : "UTC", + "error" : null, + "website_url" : null, + "succeeded" : false, + "modified" : "2019-08-10T19:58:05.691Z", + "last_suceeded" : null, + "name" : "Hackers News", + "created" : "2019-08-10T19:58:05.615Z", + "user" : 1, + "url" : "https://news.ycombinator.com/rss" + }, + "pk" : 2, + "model" : "collection.collectionrule" + }, + { + "fields" : { + "timezone" : "UTC", + "error" : null, + "website_url" : null, + "succeeded" : false, + "favicon" : null, + "category" : 2, + "name" : "BBC", + "created" : "2019-08-10T19:58:23.441Z", + "user" : 1, + "url" : "http://feeds.bbci.co.uk/news/world/rss.xml", + "modified" : "2019-08-10T19:58:23.449Z", + "last_suceeded" : null + }, + "pk" : 3, + "model" : "collection.collectionrule" + }, + { + "fields" : { + "favicon" : null, + "category" : 1, + "timezone" : "UTC", + "succeeded" : false, + "error" : null, + "website_url" : null, + "last_suceeded" : null, + "modified" : "2019-08-10T19:58:31.873Z", + "user" : 1, + "created" : "2019-08-10T19:58:31.867Z", + "name" : "Ars Technica", + "url" : "http://feeds.arstechnica.com/arstechnica/index?fmt=xml" + }, + "model" : "collection.collectionrule", + "pk" : 4 + }, + { + "pk" : 5, + "model" : "collection.collectionrule", + "fields" : { + "last_suceeded" : null, + "modified" : "2019-08-10T19:58:48.535Z", + "url" : "https://www.theguardian.com/world/rss", + "created" : "2019-08-10T19:58:48.529Z", + "user" : 1, + "name" : "The Guardian", + "category" : 2, + "favicon" : null, + "succeeded" : false, + "error" : null, + "website_url" : null, + "timezone" : "UTC" + } + }, + { + "model" : "collection.collectionrule", + "pk" : 6, + "fields" : { + "error" : null, + "website_url" : null, + "succeeded" : false, + "timezone" : "UTC", + "category" : 1, + "favicon" : null, + "url" : "https://www.engadget.com/rss.xml", + "name" : "Engadget", + "created" : "2019-08-10T19:58:58.641Z", + "user" : 1, + "modified" : "2019-08-10T19:58:58.647Z", + "last_suceeded" : null + } + }, + { + "fields" : { + "last_suceeded" : null, + "modified" : "2019-08-10T19:59:29.917Z", + "user" : 1, + "created" : "2019-08-10T19:59:29.909Z", + "name" : "The Verge", + "url" : "https://www.theverge.com/rss/index.xml", + "favicon" : null, + "category" : 1, + "timezone" : "UTC", + "succeeded" : false, + "error" : null, + "website_url" : null + }, + "model" : "collection.collectionrule", + "pk" : 7 + }, + { + "pk" : 8, + "model" : "collection.collectionrule", + "fields" : { + "modified" : "2019-08-10T19:59:44.838Z", + "last_suceeded" : null, + "name" : "News", + "user" : 1, + "created" : "2019-08-10T19:59:44.833Z", + "url" : "http://feeds.boingboing.net/boingboing/iBag", + "favicon" : null, + "category" : 2, + "timezone" : "UTC", + "website_url" : null, + "error" : null, + "succeeded" : false + } + } +] diff --git a/src/newsreader/js/components/LoadingIndicator.js b/src/newsreader/js/components/LoadingIndicator.js new file mode 100644 index 0000000..b3a3cb6 --- /dev/null +++ b/src/newsreader/js/components/LoadingIndicator.js @@ -0,0 +1,13 @@ +import React from 'react'; + +const LoadingIndicator = props => { + return ( +
+
+
+
+
+ ); +}; + +export default LoadingIndicator; diff --git a/src/newsreader/js/homepage/App.js b/src/newsreader/js/homepage/App.js new file mode 100644 index 0000000..fa9bc2c --- /dev/null +++ b/src/newsreader/js/homepage/App.js @@ -0,0 +1,63 @@ +import React from 'react'; + +import { connect } from 'react-redux'; +import { isEqual } from 'lodash'; + +import { fetchCategories } from './actions/categories'; + +import Sidebar from './components/sidebar/Sidebar.js'; +import FeedList from './components/feedlist/FeedList.js'; +import PostModal from './components/PostModal.js'; + +class App extends React.Component { + componentDidMount() { + this.props.fetchCategories(); + } + + render() { + return ( + <> +
+ + + + {!isEqual(this.props.post, {}) && ( + + )} +
+ + ); + } +} + +const mapStateToProps = state => { + if (!isEqual(state.selected.post, {})) { + const ruleId = state.selected.post.rule; + + const rule = state.rules.items[ruleId]; + const category = state.categories.items[rule.category]; + + return { + post: state.selected.post, + rule, + category, + }; + } + + return { + post: state.selected.post, + }; +}; + +const mapDispatchToProps = dispatch => ({ + fetchCategories: () => dispatch(fetchCategories()), +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(App); diff --git a/src/newsreader/js/homepage/actions/categories.js b/src/newsreader/js/homepage/actions/categories.js new file mode 100644 index 0000000..07a1066 --- /dev/null +++ b/src/newsreader/js/homepage/actions/categories.js @@ -0,0 +1,86 @@ +import { requestRules, receiveRules, fetchRulesByCategory } from './rules.js'; + +export const SELECT_CATEGORY = 'SELECT_CATEGORY'; + +export const RECEIVE_CATEGORY = 'RECEIVE_CATEGORY'; +export const RECEIVE_CATEGORIES = 'RECEIVE_CATEGORIES'; + +export const REQUEST_CATEGORY = 'REQUEST_CATEGORY'; +export const REQUEST_CATEGORIES = 'REQUEST_CATEGORIES'; + +export const selectCategory = category => ({ + type: SELECT_CATEGORY, + item: category, +}); + +export const receiveCategory = category => ({ + type: RECEIVE_CATEGORY, + category, +}); + +export const receiveCategories = json => ({ + type: RECEIVE_CATEGORIES, + categories: json, +}); + +export const requestCategory = () => ({ type: REQUEST_CATEGORY }); +export const requestCategories = () => ({ type: REQUEST_CATEGORIES }); + +export const fetchCategories = () => { + return dispatch => { + dispatch(requestCategories()); + + return fetch('/api/categories/') + .then(response => response.json()) + .then(json => { + const categories = {}; + + json.forEach(category => { + categories[category.id] = { ...category }; + }); + + dispatch(receiveCategories(categories)); + return json; + }) + .then(json => { + const promises = json.map(category => { + return fetch(`/api/categories/${category.id}/rules/`); + }); + + dispatch(requestRules()); + return Promise.all(promises); + }) + .then(responses => { + return Promise.all(responses.map(response => response.json())); + }) + .then(responseData => { + let rules = {}; + + responseData.forEach(json => { + const data = Object.values(json); + + data.forEach(item => { + rules = { ...rules, [item.id]: item }; + }); + }); + + setTimeout(dispatch, 500, receiveRules(rules)); + }); + }; +}; + +export const fetchCategory = category => { + return dispatch => { + dispatch(requestCategory()); + + return fetch(`/api/categories/${category.id}`) + .then(response => response.json()) + .then(json => { + dispatch(receiveCategory({ ...json })); + + if (category.unread === 0) { + dispatch(fetchRulesByCategory(category)); + } + }); + }; +}; diff --git a/src/newsreader/js/homepage/actions/posts.js b/src/newsreader/js/homepage/actions/posts.js new file mode 100644 index 0000000..9951859 --- /dev/null +++ b/src/newsreader/js/homepage/actions/posts.js @@ -0,0 +1,119 @@ +export const SELECT_POST = 'SELECT_POST'; +export const UNSELECT_POST = 'UNSELECT_POST'; + +export const RECEIVE_POSTS = 'RECEIVE_POSTS'; +export const RECEIVE_POST = 'RECEIVE_POST'; +export const REQUEST_POSTS = 'REQUEST_POSTS'; + +export const MARK_POST_READ = 'MARK_POST_READ'; + +export const selectPost = post => ({ + type: SELECT_POST, + post, +}); + +export const unSelectPost = () => ({ + type: UNSELECT_POST, +}); + +export const postRead = (post, rule, category) => ({ + type: MARK_POST_READ, + category: category, + post: post, + rule: rule, +}); + +export const markPostRead = (post, token) => { + return (dispatch, getState) => { + const { rules } = getState(); + const { categories } = getState(); + + const url = `/api/posts/${post.id}/`; + const options = { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': token, + }, + body: JSON.stringify({ read: true }), + }; + + const rule = rules.items[post.rule]; + const category = categories.items[rule.category]; + + return fetch(url, options) + .then(response => response.json()) + .then(updatedPost => { + const updatedRule = { ...rule, unread: rule.unread - 1 }; + const updatedCategory = { ...category, unread: category.unread - 1 }; + + dispatch(receivePost({ ...updatedPost })); + dispatch(postRead({ ...updatedPost }, updatedRule, updatedCategory)); + }); + }; +}; + +export const receivePosts = json => ({ + type: RECEIVE_POSTS, + posts: json.items, + next: json.next, +}); + +export const receivePost = post => ({ + type: RECEIVE_POST, + post, +}); + +export const requestPosts = () => ({ type: REQUEST_POSTS }); + +export const fetchPostsByCategory = (category, page = false) => { + return dispatch => { + dispatch(requestPosts()); + + const url = page ? page : `/api/categories/${category.id}/posts/?read=false`; + return fetch(url) + .then(response => response.json()) + .then(json => { + const posts = {}; + + json.results.forEach(post => { + posts[post.id] = post; + }); + + dispatch(receivePosts({ items: posts, next: json.next })); + }) + .catch(error => { + if (error instanceof TypeError) { + console.log(`Unable to parse posts from request: ${error}`); + } + + dispatch(receivePosts({ items: {}, next: null })); + }); + }; +}; + +export const fetchPostsByRule = (rule, page = false) => { + return dispatch => { + dispatch(requestPosts()); + + const url = page ? page : `/api/rules/${rule.id}/posts/?read=false`; + return fetch(url) + .then(response => response.json()) + .then(json => { + const posts = {}; + + json.results.forEach(post => { + posts[post.id] = post; + }); + + dispatch(receivePosts({ items: posts, next: json.next })); + }) + .catch(error => { + if (error instanceof TypeError) { + console.log(`Unable to parse posts from request: ${error}`); + } + + dispatch(receivePosts({ items: {}, next: null })); + }); + }; +}; diff --git a/src/newsreader/js/homepage/actions/rules.js b/src/newsreader/js/homepage/actions/rules.js new file mode 100644 index 0000000..0b843f6 --- /dev/null +++ b/src/newsreader/js/homepage/actions/rules.js @@ -0,0 +1,68 @@ +import { fetchCategory } from './categories.js'; + +export const SELECT_RULE = 'SELECT_RULE'; +export const SELECT_RULES = 'SELECT_RULES'; + +export const RECEIVE_RULE = 'RECEIVE_RULE'; +export const RECEIVE_RULES = 'RECEIVE_RULES'; + +export const REQUEST_RULE = 'REQUEST_RULE'; +export const REQUEST_RULES = 'REQUEST_RULES'; + +export const selectRule = rule => ({ + type: SELECT_RULE, + item: rule, +}); + +export const requestRule = () => ({ type: REQUEST_RULE }); +export const requestRules = () => ({ type: REQUEST_RULES }); + +export const receiveRule = rule => ({ + type: RECEIVE_RULE, + rule, +}); + +export const receiveRules = rules => ({ + type: RECEIVE_RULES, + rules, +}); + +export const fetchRule = rule => { + return (dispatch, getState) => { + dispatch(requestRule()); + + const { categories } = getState(); + const category = categories['items'][rule.category]; + + return fetch(`/api/rules/${rule.id}`) + .then(response => response.json()) + .then(receivedRule => { + dispatch(receiveRule({ ...receivedRule })); + + // fetch & update category info when the rule is read + if (rule.unread === 0) { + dispatch(fetchCategory({ ...category })); + } + }); + }; +}; + +export const fetchRulesByCategory = category => { + return (dispatch, getState) => { + dispatch(requestRules()); + + return fetch(`/api/categories/${category.id}/rules/`) + .then(response => response.json()) + .then(responseData => { + dispatch(receiveRules()); + + const rules = {}; + + responseData.forEach(rule => { + rules[rule.id] = { ...rule }; + }); + + setTimeout(dispatch, 500, receiveRules(rules)); + }); + }; +}; diff --git a/src/newsreader/js/homepage/actions/selected.js b/src/newsreader/js/homepage/actions/selected.js new file mode 100644 index 0000000..95b8603 --- /dev/null +++ b/src/newsreader/js/homepage/actions/selected.js @@ -0,0 +1,65 @@ +import { receiveCategory, requestCategory } from './categories.js'; +import { receiveRule, requestRule } from './rules.js'; + +export const MARK_SECTION_READ = 'MARK_SECTION_READ'; + +export const markSectionRead = (category, rule = {}) => ({ + category: category, + rule: rule, + type: MARK_SECTION_READ, +}); + +const markCategoryRead = (category, token) => { + return dispatch => { + dispatch(requestCategory(category)); + + const url = `/api/categories/${category.id}/read/`; + const options = { + method: 'POST', + headers: { + 'X-CSRFToken': token, + }, + }; + + return fetch(url, options) + .then(response => response.json()) + .then(updatedCategory => { + dispatch(receiveCategory({ ...updatedCategory })); + dispatch(markSectionRead({ ...category, ...updatedCategory })); + }); + }; +}; + +const markRuleRead = (rule, token) => { + return (dispatch, getState) => { + const { categories } = getState(); + const category = categories.items[rule.category]; + + dispatch(requestRule(rule)); + + const url = `/api/rules/${rule.id}/read/`; + const options = { + method: 'POST', + headers: { + 'X-CSRFToken': token, + }, + }; + + return fetch(url, options) + .then(response => response.json()) + .then(updatedRule => { + dispatch(receiveRule({ ...updatedRule })); + + // Use the old rule to decrement category with old unread count + dispatch(markSectionRead({ ...category }, { ...rule })); + }); + }; +}; + +export const markRead = (selected, token) => { + if ('category' in selected) { + return markRuleRead(selected, token); + } else { + return markCategoryRead(selected, token); + } +}; diff --git a/src/newsreader/js/homepage/components/PostModal.js b/src/newsreader/js/homepage/components/PostModal.js new file mode 100644 index 0000000..4749ccc --- /dev/null +++ b/src/newsreader/js/homepage/components/PostModal.js @@ -0,0 +1,84 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import Cookies from 'js-cookie'; + +import { unSelectPost, markPostRead } from '../actions/posts.js'; +import { formatDatetime } from '../../utils.js'; + +class PostModal extends React.Component { + readTimer = null; + + componentDidMount() { + const post = { ...this.props.post }; + const markPostRead = this.props.markPostRead; + const token = Cookies.get('csrftoken'); + + if (!post.read) { + this.readTimer = setTimeout(markPostRead, 30000, post, token); + } + } + + componentWillUnmount() { + if (this.readTimer) { + clearTimeout(this.readTimer); + } + + this.readTimer = null; + } + + render() { + const post = this.props.post; + const publicationDate = formatDatetime(post.publication_date); + const titleClassName = post.read ? 'post__title post__title--read' : 'post__title'; + + return ( +
+
+ +
+

+ {`${post.title} `} + + + +

+ {publicationDate} +
+ + + {/* HTML is sanitized by the collectors */} +
+
+
+ ); + } +} + +const mapDispatchToProps = dispatch => ({ + unSelectPost: () => dispatch(unSelectPost()), + markPostRead: (post, token) => dispatch(markPostRead(post, token)), +}); + +export default connect( + null, + mapDispatchToProps +)(PostModal); diff --git a/src/newsreader/js/homepage/components/feedlist/FeedList.js b/src/newsreader/js/homepage/components/feedlist/FeedList.js new file mode 100644 index 0000000..47fa5fb --- /dev/null +++ b/src/newsreader/js/homepage/components/feedlist/FeedList.js @@ -0,0 +1,102 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { isEqual } from 'lodash'; + +import { fetchPostsByRule, fetchPostsByCategory } from '../../actions/posts.js'; +import { filterPosts } from './filters.js'; + +import LoadingIndicator from '../../../components/LoadingIndicator.js'; +import RuleItem from './RuleItem.js'; + +class FeedList extends React.Component { + checkScrollHeight = ::this.checkScrollHeight; + + componentDidMount() { + window.addEventListener('scroll', this.checkScrollHeight); + } + + componentWillUnmount() { + window.removeEventListener('scroll', this.checkScrollHeight); + } + + checkScrollHeight(e) { + const currentHeight = window.scrollY + window.innerHeight; + const totalHeight = document.body.offsetHeight; + + const currentPercentage = (currentHeight / totalHeight) * 100; + + if (this.props.next && !this.props.lastReached) { + if (currentPercentage > 60 && !this.props.isFetching) { + this.paginate(); + } + } + } + + paginate() { + if ('category' in this.props.selected) { + return this.props.fetchPostsByRule(this.props.selected, this.props.next); + } else { + return this.props.fetchPostsByCategory(this.props.selected, this.props.next); + } + } + + render() { + const ruleItems = this.props.posts.map((item, index) => { + return ; + }); + + if (ruleItems.length > 0) { + return ( +
+ {ruleItems} + {this.props.isFetching && } +
+ ); + } else if (isEqual(this.props.selected, {})) { + return ( +
+
+ +

+ Select a category or rule to show its unread posts +

+
+
+ ); + } else if (ruleItems.length === 0) { + return ( +
+
+

+ No unread posts from the selected section at this moment, try again later +

+
+
+ ); + } else { + return ( +
{this.props.isFetching && }
+ ); + } + } +} + +const mapStateToProps = state => ({ + isFetching: state.posts.isFetching, + posts: filterPosts(state), + next: state.selected.next, + lastReached: state.selected.lastReached, + selected: state.selected.item, +}); + +const mapDispatchToProps = dispatch => ({ + fetchPostsByRule: (rule, page = false) => dispatch(fetchPostsByRule(rule, page)), + fetchPostsByCategory: (category, page = false) => { + dispatch(fetchPostsByCategory(category, page)); + }, +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(FeedList); diff --git a/src/newsreader/js/homepage/components/feedlist/PostItem.js b/src/newsreader/js/homepage/components/feedlist/PostItem.js new file mode 100644 index 0000000..6511830 --- /dev/null +++ b/src/newsreader/js/homepage/components/feedlist/PostItem.js @@ -0,0 +1,50 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import { selectPost } from '../../actions/posts.js'; + +import { formatDatetime } from '../../../utils.js'; + +class PostItem extends React.Component { + render() { + const post = this.props.post; + const publicationDate = formatDatetime(post.publication_date); + const titleClassName = post.read + ? 'posts-header__title posts-header__title--read' + : 'posts-header__title'; + + return ( +
  • { + this.props.selectPost(post); + }} + > +
    +
    + {post.title} +
    + + + + +
    + {publicationDate} +
  • + ); + } +} + +const mapDispatchToProps = dispatch => ({ + selectPost: post => dispatch(selectPost(post)), +}); + +export default connect( + null, + mapDispatchToProps +)(PostItem); diff --git a/src/newsreader/js/homepage/components/feedlist/RuleItem.js b/src/newsreader/js/homepage/components/feedlist/RuleItem.js new file mode 100644 index 0000000..08cb3aa --- /dev/null +++ b/src/newsreader/js/homepage/components/feedlist/RuleItem.js @@ -0,0 +1,25 @@ +import React from 'react'; + +import PostItem from './PostItem.js'; + +class RuleItem extends React.Component { + render() { + const posts = Object.values(this.props.posts).sort((firstEl, secondEl) => { + return new Date(secondEl.publication_date) - new Date(firstEl.publication_date); + }); + + const postItems = posts.map(post => { + return ; + }); + + return ( +
    +

    {this.props.rule.name}

    + {/* TODO: Add empty posts message */} +
      {postItems}
    +
    + ); + } +} + +export default RuleItem; diff --git a/src/newsreader/js/homepage/components/feedlist/filters.js b/src/newsreader/js/homepage/components/feedlist/filters.js new file mode 100644 index 0000000..c392775 --- /dev/null +++ b/src/newsreader/js/homepage/components/feedlist/filters.js @@ -0,0 +1,44 @@ +const isEmpty = (object = {}) => { + return Object.keys(object).length === 0; +}; + +export const filterPostsByRule = (rule = {}, posts = []) => { + const filteredPosts = posts.filter(post => { + return post.rule === rule.id && !post.read; + }); + + return filteredPosts.length > 0 ? [{ rule, posts: filteredPosts }] : []; +}; + +export const filterPostsByCategory = (category = {}, rules = [], posts = []) => { + const filteredRules = rules.filter(rule => { + return rule.category === category.id; + }); + + const filteredData = filteredRules.map(rule => { + const filteredPosts = posts.filter(post => { + return post.rule === rule.id && !post.read; + }); + + return { + rule: { ...rule }, + posts: filteredPosts, + }; + }); + + return filteredData.filter(rule => rule.posts.length > 0); +}; + +export const filterPosts = state => { + const posts = Object.values({ ...state.posts.items }); + + if (!isEmpty(state.selected.item) && !('category' in state.selected.item)) { + const rules = Object.values({ ...state.rules.items }); + + return filterPostsByCategory({ ...state.selected.item }, rules, posts); + } else if ('category' in state.selected.item) { + return filterPostsByRule({ ...state.selected.item }, posts); + } + + return []; +}; diff --git a/src/newsreader/js/homepage/components/sidebar/CategoryItem.js b/src/newsreader/js/homepage/components/sidebar/CategoryItem.js new file mode 100644 index 0000000..7644fd7 --- /dev/null +++ b/src/newsreader/js/homepage/components/sidebar/CategoryItem.js @@ -0,0 +1,68 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { isEqual } from 'lodash'; + +import { selectCategory, fetchCategory } from '../../actions/categories.js'; +import { fetchPostsByCategory } from '../../actions/posts.js'; +import RuleItem from './RuleItem.js'; + +class CategoryItem extends React.Component { + state = { open: false }; + + toggleRules() { + this.setState({ open: !this.state.open }); + } + + handleSelect() { + const category = this.props.category; + + this.props.selectCategory(category); + this.props.fetchPostsByCategory(category); + + if (category.unread === 0) { + this.props.fetchCategory(category); + } + } + + render() { + const imageSrc = this.state.open + ? '/static/chevron-down.svg' + : '/static/chevron-right.svg'; + const selected = isEqual(this.props.category, this.props.selected); + const className = selected ? 'category category--selected' : 'category'; + + const ruleItems = this.props.rules.map(rule => { + return ; + }); + + return ( +
  • +
    +
    this.toggleRules()}> + +
    + +
    this.handleSelect()}> +

    {this.props.category.name}

    + {this.props.category.unread} +
    +
    + + {ruleItems.length > 0 && this.state.open && ( +
      {ruleItems}
    + )} +
  • + ); + } +} + +const mapDispatchToProps = dispatch => ({ + selectCategory: category => dispatch(selectCategory(category)), + fetchPostsByCategory: category => dispatch(fetchPostsByCategory(category)), + fetchCategory: category => dispatch(fetchCategory(category)), +}); + +export default connect( + null, + mapDispatchToProps +)(CategoryItem); diff --git a/src/newsreader/js/homepage/components/sidebar/ReadButton.js b/src/newsreader/js/homepage/components/sidebar/ReadButton.js new file mode 100644 index 0000000..45e3781 --- /dev/null +++ b/src/newsreader/js/homepage/components/sidebar/ReadButton.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import Cookies from 'js-cookie'; + +import { markRead } from '../../actions/selected.js'; + +class ReadButton extends React.Component { + markSelectedRead = ::this.markSelectedRead; + + markSelectedRead() { + const token = Cookies.get('csrftoken'); + + if (this.props.selected.unread > 0) { + this.props.markRead({ ...this.props.selected }, token); + } + } + + render() { + return ( + + ); + } +} + +const mapDispatchToProps = dispatch => ({ + markRead: (selected, token) => dispatch(markRead(selected, token)), +}); + +const mapStateToProps = state => ({ selected: state.selected.item }); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(ReadButton); diff --git a/src/newsreader/js/homepage/components/sidebar/RuleItem.js b/src/newsreader/js/homepage/components/sidebar/RuleItem.js new file mode 100644 index 0000000..0a55aab --- /dev/null +++ b/src/newsreader/js/homepage/components/sidebar/RuleItem.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { isEqual } from 'lodash'; + +import { selectRule, fetchRule } from '../../actions/rules.js'; +import { fetchPostsByRule } from '../../actions/posts.js'; + +class RuleItem extends React.Component { + handleSelect() { + const rule = { ...this.props.rule }; + + this.props.selectRule(rule); + this.props.fetchPostsByRule(rule); + + if (rule.unread === 0) { + this.props.fetchRule(rule); + } + } + + render() { + const selected = isEqual(this.props.selected, this.props.rule); + const className = `rules__item ${selected ? 'rules__item--selected' : ''}`; + + return ( +
  • this.handleSelect()}> +
    + {this.props.rule.favicon && ( + + + + )} +
    + {this.props.rule.name} +
    +
    + {this.props.rule.unread} +
  • + ); + } +} + +const mapDispatchToProps = dispatch => ({ + selectRule: rule => dispatch(selectRule(rule)), + fetchPostsByRule: rule => dispatch(fetchPostsByRule(rule)), + fetchRule: rule => dispatch(fetchRule(rule)), +}); + +export default connect( + null, + mapDispatchToProps +)(RuleItem); diff --git a/src/newsreader/js/homepage/components/sidebar/Sidebar.js b/src/newsreader/js/homepage/components/sidebar/Sidebar.js new file mode 100644 index 0000000..55595ea --- /dev/null +++ b/src/newsreader/js/homepage/components/sidebar/Sidebar.js @@ -0,0 +1,52 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { isEqual } from 'lodash'; + +import { filterCategories, filterRules } from './filters.js'; + +import LoadingIndicator from '../../../components/LoadingIndicator.js'; +import CategoryItem from './CategoryItem.js'; +import ReadButton from './ReadButton.js'; + +// TODO: show empty category message +class Sidebar extends React.Component { + render() { + const items = this.props.categories.items.map(category => { + const rules = this.props.rules.items.filter(rule => { + return rule.category === category.id; + }); + + return ( + + ); + }); + + return ( +
    + + + {!isEqual(this.props.selected.item, {}) && } +
    + ); + } +} + +const mapStateToProps = state => ({ + categories: { ...state.categories, items: filterCategories(state.categories.items) }, + rules: { ...state.rules, items: filterRules(state.rules.items) }, + selected: state.selected, +}); + +export default connect(mapStateToProps)(Sidebar); diff --git a/src/newsreader/js/homepage/components/sidebar/filters.js b/src/newsreader/js/homepage/components/sidebar/filters.js new file mode 100644 index 0000000..5e51d6c --- /dev/null +++ b/src/newsreader/js/homepage/components/sidebar/filters.js @@ -0,0 +1,7 @@ +export const filterCategories = (categories = {}) => { + return Object.values({ ...categories }); +}; + +export const filterRules = (rules = {}) => { + return Object.values({ ...rules }); +}; diff --git a/src/newsreader/js/homepage/configureStore.js b/src/newsreader/js/homepage/configureStore.js new file mode 100644 index 0000000..e00952f --- /dev/null +++ b/src/newsreader/js/homepage/configureStore.js @@ -0,0 +1,18 @@ +import { createStore, applyMiddleware } from 'redux'; +import thunkMiddleware from 'redux-thunk'; + +import { createLogger } from 'redux-logger'; + +import rootReducer from './reducers/index.js'; + +const loggerMiddleware = createLogger(); + +const configureStore = preloadedState => { + return createStore( + rootReducer, + preloadedState, + applyMiddleware(thunkMiddleware, loggerMiddleware) + ); +}; + +export default configureStore; diff --git a/src/newsreader/js/homepage/index.js b/src/newsreader/js/homepage/index.js new file mode 100644 index 0000000..c6ce2a2 --- /dev/null +++ b/src/newsreader/js/homepage/index.js @@ -0,0 +1,16 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { Provider } from 'react-redux'; +import configureStore from './configureStore.js'; + +import App from './App.js'; + +const store = configureStore(); + +ReactDOM.render( + + + , + document.getElementsByClassName('content')[0] +); diff --git a/src/newsreader/js/homepage/reducers/categories.js b/src/newsreader/js/homepage/reducers/categories.js new file mode 100644 index 0000000..f34930e --- /dev/null +++ b/src/newsreader/js/homepage/reducers/categories.js @@ -0,0 +1,116 @@ +import { isEqual } from 'lodash'; + +import { + RECEIVE_CATEGORY, + RECEIVE_CATEGORIES, + REQUEST_CATEGORY, + REQUEST_CATEGORIES, +} from '../actions/categories.js'; + +import { RECEIVE_RULE, RECEIVE_RULES } from '../actions/rules.js'; + +import { MARK_POST_READ } from '../actions/posts.js'; +import { MARK_SECTION_READ } from '../actions/selected.js'; + +const defaultState = { items: {}, isFetching: false }; + +export const categories = (state = { ...defaultState }, action) => { + switch (action.type) { + case RECEIVE_CATEGORY: + return { + ...state, + items: { + ...state.items, + [action.category.id]: { ...action.category, rules: {} }, + }, + isFetching: false, + }; + case RECEIVE_CATEGORIES: + const receivedCategories = {}; + + Object.values({ ...action.categories }).forEach(category => { + receivedCategories[category.id] = { + ...category, + rules: {}, + }; + }); + + return { + ...state, + items: { ...state.items, ...receivedCategories }, + isFetching: false, + }; + case RECEIVE_RULE: + const category = { ...state.items[action.rule.category] }; + + category['rules'][action.rule.id] = { ...action.rule }; + + return { + ...state, + items: { + ...state.items, + [category.id]: { ...category }, + }, + }; + case RECEIVE_RULES: + const relevantCategories = {}; + + Object.values({ ...action.rules }).forEach(rule => { + if (!(rule.category in relevantCategories)) { + const category = { ...state.items[rule.category] }; + + relevantCategories[rule.category] = { + ...category, + rules: { + ...category.rules, + [rule.id]: { ...rule }, + }, + }; + } else { + relevantCategories[rule.category]['rules'][rule.id] = { ...rule }; + } + }); + + return { + ...state, + items: { + ...state.items, + ...relevantCategories, + }, + }; + case REQUEST_CATEGORIES: + case REQUEST_CATEGORY: + return { + ...state, + isFetching: true, + }; + case MARK_POST_READ: + return { + ...state, + items: { ...state.items, [action.category.id]: { ...action.category } }, + }; + case MARK_SECTION_READ: + if (!isEqual(action.rule, {})) { + return { + ...state, + items: { + ...state.items, + [action.category.id]: { + ...action.category, + unread: action.category.unread - action.rule.unread, + }, + }, + }; + } + + return { + ...state, + items: { + ...state.items, + [action.category.id]: { ...action.category, unread: 0 }, + }, + }; + default: + return state; + } +}; diff --git a/src/newsreader/js/homepage/reducers/index.js b/src/newsreader/js/homepage/reducers/index.js new file mode 100644 index 0000000..f70ca2a --- /dev/null +++ b/src/newsreader/js/homepage/reducers/index.js @@ -0,0 +1,10 @@ +import { combineReducers } from 'redux'; + +import { categories } from './categories.js'; +import { rules } from './rules.js'; +import { posts } from './posts.js'; +import { selected } from './selected.js'; + +const rootReducer = combineReducers({ categories, rules, posts, selected }); + +export default rootReducer; diff --git a/src/newsreader/js/homepage/reducers/posts.js b/src/newsreader/js/homepage/reducers/posts.js new file mode 100644 index 0000000..4f613ab --- /dev/null +++ b/src/newsreader/js/homepage/reducers/posts.js @@ -0,0 +1,67 @@ +import { isEqual } from 'lodash'; + +import { + SELECT_POST, + RECEIVE_POST, + RECEIVE_POSTS, + REQUEST_POSTS, +} from '../actions/posts.js'; +import { SELECT_CATEGORY } from '../actions/categories.js'; +import { SELECT_RULE } from '../actions/rules.js'; +import { MARK_SECTION_READ } from '../actions/selected.js'; + +const defaultState = { items: {}, isFetching: false }; + +export const posts = (state = { ...defaultState }, action) => { + switch (action.type) { + case RECEIVE_POSTS: + return { + ...state, + type: RECEIVE_POSTS, + isFetching: false, + items: { ...state.items, ...action.posts }, + }; + case REQUEST_POSTS: + return { + ...state, + type: REQUEST_POSTS, + isFetching: true, + }; + case RECEIVE_POST: + const items = { ...state.items, [action.post.id]: { ...action.post } }; + + return { + ...state, + items: items, + type: RECEIVE_POST, + }; + case MARK_SECTION_READ: + const updatedPosts = {}; + let relatedPosts = []; + + if (!isEqual(action.rule, {})) { + relatedPosts = Object.values({ ...state.items }).filter(post => { + return post.rule === action.rule.id; + }); + } else { + relatedPosts = Object.values({ ...state.items }).filter(post => { + return post.rule in { ...action.category.rules }; + }); + } + + relatedPosts.forEach(post => { + updatedPosts[post.id] = { ...post, read: true }; + }); + + return { + ...state, + items: { + ...state.items, + ...updatedPosts, + }, + }; + + default: + return state; + } +}; diff --git a/src/newsreader/js/homepage/reducers/rules.js b/src/newsreader/js/homepage/reducers/rules.js new file mode 100644 index 0000000..f23c98f --- /dev/null +++ b/src/newsreader/js/homepage/reducers/rules.js @@ -0,0 +1,65 @@ +import { isEqual } from 'lodash'; + +import { + REQUEST_RULES, + REQUEST_RULE, + RECEIVE_RULES, + RECEIVE_RULE, +} from '../actions/rules.js'; +import { MARK_POST_READ } from '../actions/posts.js'; +import { MARK_SECTION_READ } from '../actions/selected.js'; + +const defaultState = { items: {}, isFetching: false }; + +export const rules = (state = { ...defaultState }, action) => { + switch (action.type) { + case REQUEST_RULE: + case REQUEST_RULES: + return { + ...state, + isFetching: true, + }; + case RECEIVE_RULES: + return { + ...state, + items: { ...state.items, ...action.rules }, + isFetching: false, + }; + case RECEIVE_RULE: + return { + ...state, + items: { ...state.items, [action.rule.id]: { ...action.rule } }, + isFetching: false, + }; + case MARK_POST_READ: + case MARK_SECTION_READ: + if (!isEqual(action.rule, {})) { + return { + ...state, + items: { + ...state.items, + [action.rule.id]: { + ...action.rule, + unread: 0, + }, + }, + }; + } + + const updatedRules = {}; + Object.values({ ...state.items }).forEach(rule => { + if (rule.category === action.category.id) { + updatedRules[rule.id] = { + ...rule, + unread: 0, + }; + } else { + updatedRules[rule.id] = { ...rule }; + } + }); + + return { ...state, items: { ...updatedRules } }; + default: + return state; + } +}; diff --git a/src/newsreader/js/homepage/reducers/selected.js b/src/newsreader/js/homepage/reducers/selected.js new file mode 100644 index 0000000..dea13db --- /dev/null +++ b/src/newsreader/js/homepage/reducers/selected.js @@ -0,0 +1,70 @@ +import { isEqual } from 'lodash'; + +import { SELECT_CATEGORY } from '../actions/categories.js'; +import { SELECT_RULE } from '../actions/rules.js'; +import { + RECEIVE_POST, + RECEIVE_POSTS, + SELECT_POST, + UNSELECT_POST, +} from '../actions/posts.js'; + +import { MARK_SECTION_READ } from '../actions/selected.js'; + +const defaultState = { item: {}, next: false, lastReached: false, post: {} }; + +export const selected = (state = { ...defaultState }, action) => { + switch (action.type) { + case SELECT_CATEGORY: + case SELECT_RULE: + return { + ...state, + item: action.item, + next: false, + lastReached: false, + }; + case RECEIVE_POSTS: + return { + ...state, + next: action.next, + lastReached: !action.next, + }; + case RECEIVE_POST: + const isCurrentPost = !isEqual(state.post, {}) && state.post.id === action.post.id; + + if (isCurrentPost) { + return { + ...state, + post: { ...action.post }, + }; + } + + return { + ...state, + }; + case SELECT_POST: + return { + ...state, + post: action.post, + }; + case UNSELECT_POST: + return { + ...state, + post: {}, + }; + case MARK_SECTION_READ: + if (!isEqual(action.rule, {})) { + return { + ...state, + item: { ...action.rule, unread: 0 }, + }; + } + + return { + ...state, + item: { ...action.category }, + }; + default: + return state; + } +}; diff --git a/src/newsreader/js/utils.js b/src/newsreader/js/utils.js new file mode 100644 index 0000000..0794a1a --- /dev/null +++ b/src/newsreader/js/utils.js @@ -0,0 +1,14 @@ +export const formatDatetime = dateString => { + const locale = navigator.language ? navigator.language : 'en-US'; + const dateOptions = { + hour: '2-digit', + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }; + + const date = new Date(dateString); + + return date.toLocaleDateString(locale, dateOptions); +}; diff --git a/src/newsreader/news/collection/admin.py b/src/newsreader/news/collection/admin.py index 9727b69..e82dea5 100644 --- a/src/newsreader/news/collection/admin.py +++ b/src/newsreader/news/collection/admin.py @@ -4,9 +4,10 @@ from newsreader.news.collection.models import CollectionRule class CollectionRuleAdmin(admin.ModelAdmin): - fields = ("url", "name", "timezone", "category", "favicon") + fields = ("url", "name", "timezone", "category", "favicon", "user") list_display = ("name", "category", "url", "last_suceeded", "succeeded") + list_filter = ("user",) def save_model(self, request, obj, form, change): if not change: diff --git a/src/newsreader/news/collection/endpoints.py b/src/newsreader/news/collection/endpoints.py new file mode 100644 index 0000000..02ea917 --- /dev/null +++ b/src/newsreader/news/collection/endpoints.py @@ -0,0 +1,69 @@ +from django.db.models.query import QuerySet + +from rest_framework import status +from rest_framework.generics import ( + GenericAPIView, + ListAPIView, + ListCreateAPIView, + RetrieveUpdateDestroyAPIView, + get_object_or_404, +) +from rest_framework.response import Response +from rest_framework.serializers import Serializer + +from newsreader.core.pagination import LargeResultSetPagination, ResultSetPagination +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.serializers import RuleSerializer +from newsreader.news.core.filters import ReadFilter +from newsreader.news.core.models import Post +from newsreader.news.core.serializers import PostSerializer + + +class ListRuleView(ListCreateAPIView): + queryset = CollectionRule.objects.all() + serializer_class = RuleSerializer + pagination_class = ResultSetPagination + + def get_queryset(self) -> QuerySet: + user = self.request.user + return self.queryset.filter(user=user).order_by("-created") + + +class DetailRuleView(RetrieveUpdateDestroyAPIView): + queryset = CollectionRule.objects.all() + serializer_class = RuleSerializer + pagination_class = ResultSetPagination + + +class NestedRuleView(ListAPIView): + queryset = CollectionRule.objects.prefetch_related("posts").all() + serializer_class = PostSerializer + pagination_class = LargeResultSetPagination + filter_backends = [ReadFilter] + + def get_queryset(self) -> QuerySet: + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + + # Default permission is IsOwner, therefore there shouldn't have to be + # filtered on the user. + filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]} + + rule = get_object_or_404(self.queryset, **filter_kwargs) + self.check_object_permissions(self.request, rule) + + return rule.posts.order_by("-publication_date") + + +class RuleReadView(GenericAPIView): + queryset = CollectionRule.objects.all() + serializer_class = RuleSerializer + + def post(self, request, *args, **kwargs): + rule = self.get_object() + + Post.objects.filter(rule=rule).update(read=True) + + rule.refresh_from_db() + serializer_class = self.get_serializer_class() + serializer = serializer_class(rule) + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/src/newsreader/news/collection/serializers.py b/src/newsreader/news/collection/serializers.py index 4f7f3a5..640d16e 100644 --- a/src/newsreader/news/collection/serializers.py +++ b/src/newsreader/news/collection/serializers.py @@ -1,23 +1,15 @@ from rest_framework import serializers -from newsreader.news import core from newsreader.news.collection.models import CollectionRule -class CollectionRuleSerializer(serializers.HyperlinkedModelSerializer): - posts = serializers.SerializerMethodField() +class RuleSerializer(serializers.ModelSerializer): user = serializers.HiddenField(default=serializers.CurrentUserDefault()) + unread = serializers.SerializerMethodField() - def get_posts(self, instance): - request = self.context.get("request") - posts = instance.posts.order_by("-publication_date") - - serializer = core.serializers.PostSerializer( - posts, context={"request": request}, many=True - ) - return serializer.data + def get_unread(self, rule): + return rule.posts.filter(read=False).count() class Meta: model = CollectionRule - fields = ("id", "name", "url", "favicon", "category", "posts", "user") - extra_kwargs = {"category": {"view_name": "api:categories-detail"}} + fields = ("id", "name", "url", "favicon", "category", "user", "unread") diff --git a/src/newsreader/news/collection/tests/endpoints/rules/__init__.py b/src/newsreader/news/collection/tests/endpoints/rule/__init__.py similarity index 100% rename from src/newsreader/news/collection/tests/endpoints/rules/__init__.py rename to src/newsreader/news/collection/tests/endpoints/rule/__init__.py diff --git a/src/newsreader/news/collection/tests/endpoints/rules/detail/__init__.py b/src/newsreader/news/collection/tests/endpoints/rule/detail/__init__.py similarity index 100% rename from src/newsreader/news/collection/tests/endpoints/rules/detail/__init__.py rename to src/newsreader/news/collection/tests/endpoints/rule/detail/__init__.py diff --git a/src/newsreader/news/collection/tests/endpoints/rules/detail/tests.py b/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py similarity index 59% rename from src/newsreader/news/collection/tests/endpoints/rules/detail/tests.py rename to src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py index 6a85345..8dc75d0 100644 --- a/src/newsreader/news/collection/tests/endpoints/rules/detail/tests.py +++ b/src/newsreader/news/collection/tests/endpoints/rule/detail/tests.py @@ -1,26 +1,22 @@ import json -from urllib.parse import urljoin - from django.test import Client, TestCase from django.urls import reverse from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.core.tests.factories import CategoryFactory +from newsreader.news.core.models import Post +from newsreader.news.core.tests.factories import CategoryFactory, PostFactory class CollectionRuleDetailViewTestCase(TestCase): def setUp(self): - self.maxDiff = None - - self.user = UserFactory(is_staff=True, password="test") - self.client = Client() + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") def test_simple(self): rule = CollectionRuleFactory(user=self.user) - self.client.force_login(self.user) response = self.client.get(reverse("api:rules-detail", args=[rule.pk])) data = response.json() @@ -31,10 +27,8 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertTrue("url" in data) self.assertTrue("favicon" in data) self.assertTrue("category" in data) - self.assertTrue("posts" in data) def test_not_known(self): - self.client.force_login(self.user) response = self.client.get(reverse("api:rules-detail", args=[100])) data = response.json() @@ -44,7 +38,6 @@ class CollectionRuleDetailViewTestCase(TestCase): def test_post(self): rule = CollectionRuleFactory(user=self.user) - self.client.force_login(self.user) response = self.client.post(reverse("api:rules-detail", args=[rule.pk])) data = response.json() @@ -54,7 +47,6 @@ class CollectionRuleDetailViewTestCase(TestCase): def test_patch(self): rule = CollectionRuleFactory(name="BBC", user=self.user) - self.client.force_login(self.user) response = self.client.patch( reverse("api:rules-detail", args=[rule.pk]), data=json.dumps({"name": "The guardian"}), @@ -65,18 +57,12 @@ class CollectionRuleDetailViewTestCase(TestCase): self.assertEquals(response.status_code, 200) self.assertEquals(data["name"], "The guardian") - def test_category_change_with_absolute_url(self): + def test_category_change(self): old_category = CategoryFactory(user=self.user) new_category = CategoryFactory(user=self.user) - base_url = "http://testserver" - relative_url = reverse("api:categories-detail", args=[new_category.pk]) - - absolute_url = urljoin(base_url, relative_url) - rule = CollectionRuleFactory(name="BBC", category=old_category, user=self.user) - self.client.force_login(self.user) response = self.client.patch( reverse("api:rules-detail", args=[rule.pk]), data=json.dumps({"category": absolute_url}), @@ -85,34 +71,11 @@ class CollectionRuleDetailViewTestCase(TestCase): data = response.json() self.assertEquals(response.status_code, 200) - self.assertEquals(data["category"], absolute_url) - - def test_category_change_with_relative_url(self): - old_category = CategoryFactory(user=self.user) - new_category = CategoryFactory(user=self.user) - - base_url = "http://testserver" - relative_url = reverse("api:categories-detail", args=[new_category.pk]) - - absolute_url = urljoin(base_url, relative_url) - - rule = CollectionRuleFactory(name="BBC", category=old_category, user=self.user) - - self.client.force_login(self.user) - response = self.client.patch( - reverse("api:rules-detail", args=[rule.pk]), - data=json.dumps({"category": relative_url}), - content_type="application/json", - ) - data = response.json() - - self.assertEquals(response.status_code, 200) - self.assertEquals(data["category"], absolute_url) + self.assertEquals(data["category"], new_category.pk) def test_identifier_cannot_be_changed(self): rule = CollectionRuleFactory(user=self.user) - self.client.force_login(self.user) response = self.client.patch( reverse("api:rules-detail", args=[rule.pk]), data=json.dumps({"id": 44}), @@ -127,26 +90,20 @@ class CollectionRuleDetailViewTestCase(TestCase): rule = CollectionRuleFactory(user=self.user) category = CategoryFactory(user=self.user) - self.client.force_login(self.user) response = self.client.patch( reverse("api:rules-detail", args=[rule.pk]), - data=json.dumps( - {"category": reverse("api:categories-detail", args=[category.pk])} - ), + data=json.dumps({"category": category.pk}), content_type="application/json", ) data = response.json() - url = data["category"] + data["category"] self.assertEquals(response.status_code, 200) - self.assertTrue( - url.endswith(reverse("api:categories-detail", args=[category.pk])) - ) + self.assertEquals(data["category"], category.pk) def test_put(self): rule = CollectionRuleFactory(name="BBC", user=self.user) - self.client.force_login(self.user) response = self.client.put( reverse("api:rules-detail", args=[rule.pk]), data=json.dumps({"name": "BBC", "url": "https://www.bbc.co.uk"}), @@ -160,12 +117,13 @@ class CollectionRuleDetailViewTestCase(TestCase): def test_delete(self): rule = CollectionRuleFactory(user=self.user) - self.client.force_login(self.user) response = self.client.delete(reverse("api:rules-detail", args=[rule.pk])) self.assertEquals(response.status_code, 204) def test_rule_with_unauthenticated_user(self): + self.client.logout() + rule = CollectionRuleFactory(name="BBC", user=self.user) response = self.client.patch( @@ -180,7 +138,6 @@ class CollectionRuleDetailViewTestCase(TestCase): other_user = UserFactory() rule = CollectionRuleFactory(name="BBC", user=other_user) - self.client.force_login(self.user) response = self.client.patch( reverse("api:rules-detail", args=[rule.pk]), data=json.dumps({"name": "The guardian"}), @@ -188,3 +145,95 @@ class CollectionRuleDetailViewTestCase(TestCase): ) self.assertEquals(response.status_code, 403) + + def test_read_count(self): + rule = CollectionRuleFactory(user=self.user) + + PostFactory.create_batch(size=20, read=False, rule=rule) + PostFactory.create_batch(size=20, read=True, rule=rule) + + response = self.client.get(reverse("api:rules-detail", args=[rule.pk])) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["unread"], 20) + + +class CollectionRuleReadTestCase(TestCase): + def setUp(self): + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") + + def test_rule_read(self): + rule = CollectionRuleFactory(user=self.user) + + PostFactory.create_batch(size=20, read=False, rule=rule) + + response = self.client.post(reverse("api:rules-read", args=[rule.pk])) + data = response.json() + + self.assertEquals(response.status_code, 201) + self.assertEquals(data["unread"], 0) + + def test_rule_unknown(self): + response = self.client.post(reverse("api:rules-read", args=[101])) + + self.assertEquals(response.status_code, 404) + + def test_unauthenticated_user(self): + self.client.logout() + + rule = CollectionRuleFactory(user=self.user) + + PostFactory.create_batch(size=20, read=False, rule=rule) + + response = self.client.post(reverse("api:rules-read", args=[rule.pk])) + + self.assertEquals(response.status_code, 403) + + def test_unauthorized_user(self): + other_user = UserFactory() + rule = CollectionRuleFactory(user=other_user) + + PostFactory.create_batch(size=20, read=False, rule=rule) + + response = self.client.post(reverse("api:rules-read", args=[rule.pk])) + + self.assertEquals(response.status_code, 403) + self.assertEquals(Post.objects.filter(read=False).count(), 20) + + def test_get(self): + rule = CollectionRuleFactory(user=self.user) + + response = self.client.get(reverse("api:rules-read", args=[rule.pk])) + + self.assertEquals(response.status_code, 405) + + def test_patch(self): + rule = CollectionRuleFactory(name="BBC", user=self.user) + + response = self.client.patch( + reverse("api:rules-read", args=[rule.pk]), + data=json.dumps({"name": "Not possible"}), + content_type="application/json", + ) + + self.assertEquals(response.status_code, 405) + + def test_put(self): + rule = CollectionRuleFactory(name="BBC", user=self.user) + + response = self.client.put( + reverse("api:rules-read", args=[rule.pk]), + data=json.dumps({"name": "Not possible"}), + content_type="application/json", + ) + + self.assertEquals(response.status_code, 405) + + def test_delete(self): + rule = CollectionRuleFactory(user=self.user) + + response = self.client.delete(reverse("api:rules-read", args=[rule.pk])) + + self.assertEquals(response.status_code, 405) diff --git a/src/newsreader/news/collection/tests/endpoints/rules/list/__init__.py b/src/newsreader/news/collection/tests/endpoints/rule/list/__init__.py similarity index 100% rename from src/newsreader/news/collection/tests/endpoints/rules/list/__init__.py rename to src/newsreader/news/collection/tests/endpoints/rule/list/__init__.py diff --git a/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py new file mode 100644 index 0000000..4526bdd --- /dev/null +++ b/src/newsreader/news/collection/tests/endpoints/rule/list/tests.py @@ -0,0 +1,371 @@ +import json + +from datetime import date, datetime, time + +from django.test import Client, TestCase +from django.urls import reverse + +import pytz + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.core.tests.factories import CategoryFactory, PostFactory + + +class RuleListViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") + + def test_simple(self): + CollectionRuleFactory.create_batch(size=3, user=self.user) + + response = self.client.get(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 3) + + def test_ordering(self): + rules = [ + CollectionRuleFactory( + created=datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + user=self.user, + ), + CollectionRuleFactory( + created=datetime.combine( + date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc + ), + user=self.user, + ), + CollectionRuleFactory( + created=datetime.combine( + date(2019, 7, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + user=self.user, + ), + ] + + response = self.client.get(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 3) + + self.assertEquals(data["results"][0]["id"], rules[1].pk) + self.assertEquals(data["results"][1]["id"], rules[2].pk) + self.assertEquals(data["results"][2]["id"], rules[0].pk) + + def test_pagination_count(self): + CollectionRuleFactory.create_batch(size=80, user=self.user) + + response = self.client.get(reverse("api:rules-list"), {"count": 30}) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 80) + self.assertEquals(len(data["results"]), 30) + + def test_empty(self): + response = self.client.get(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + + self.assertEquals(data["count"], 0) + self.assertEquals(len(data["results"]), 0) + + def test_post(self): + category = CategoryFactory(user=self.user) + + data = {"name": "BBC", "url": "https://www.bbc.co.uk", "category": category.pk} + + response = self.client.post( + reverse("api:rules-list"), + data=json.dumps(data), + content_type="application/json", + ) + data = response.json() + data["category"] + + self.assertEquals(response.status_code, 201) + + self.assertEquals(data["name"], "BBC") + self.assertEquals(data["url"], "https://www.bbc.co.uk") + self.assertEquals(data["category"], category.pk) + + def test_patch(self): + response = self.client.patch(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') + + def test_put(self): + response = self.client.put(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PUT" not allowed.') + + def test_delete(self): + response = self.client.delete(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') + + def test_rules_with_unauthenticated_user(self): + self.client.logout() + + CollectionRuleFactory.create_batch(size=3, user=self.user) + + response = self.client.get(reverse("api:rules-list")) + + self.assertEquals(response.status_code, 403) + + def test_rules_with_unauthorized_user(self): + other_user = UserFactory() + CollectionRuleFactory.create_batch(size=3, user=other_user) + + response = self.client.get(reverse("api:rules-list")) + data = response.json() + + self.assertEquals(response.status_code, 200) + + self.assertEquals(data["count"], 0) + self.assertEquals(len(data["results"]), 0) + + +class NestedRuleListViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") + + def test_simple(self): + rule = CollectionRuleFactory.create(user=self.user) + PostFactory.create_batch(size=5, rule=rule) + + response = self.client.get( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}) + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 5) + + def test_pagination(self): + rule = CollectionRuleFactory.create(user=self.user) + PostFactory.create_batch(size=80, rule=rule) + + response = self.client.get( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), {"count": 30} + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 80) + self.assertEquals(len(data["results"]), 30) + + def test_empty(self): + rule = CollectionRuleFactory.create(user=self.user) + + response = self.client.get( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}) + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 0) + self.assertEquals(len(data["results"]), 0) + + def test_not_known(self): + response = self.client.get(reverse("api:rules-nested-posts", kwargs={"pk": 0})) + + self.assertEquals(response.status_code, 404) + + def test_post(self): + rule = CollectionRuleFactory.create(user=self.user) + + response = self.client.post( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), + data=json.dumps({}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "POST" not allowed.') + + def test_patch(self): + rule = CollectionRuleFactory.create(user=self.user) + + response = self.client.patch( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), + data=json.dumps({}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') + + def test_put(self): + rule = CollectionRuleFactory.create(user=self.user) + + response = self.client.put( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), + data=json.dumps({}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PUT" not allowed.') + + def test_delete(self): + rule = CollectionRuleFactory.create(user=self.user) + + response = self.client.delete( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), + data=json.dumps({}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') + + def test_rule_with_unauthenticated_user(self): + self.client.logout() + + rule = CollectionRuleFactory(user=self.user) + + response = self.client.get( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}) + ) + + self.assertEquals(response.status_code, 403) + + def test_rule_with_unauthorized_user(self): + other_user = UserFactory() + rule = CollectionRuleFactory(user=other_user) + + response = self.client.get( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}) + ) + + self.assertEquals(response.status_code, 403) + + def test_posts_ordering(self): + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) + + posts = [ + PostFactory( + title="I'm the first post", + rule=rule, + publication_date=datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + ), + PostFactory( + title="I'm the second post", + rule=rule, + publication_date=datetime.combine( + date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc + ), + ), + PostFactory( + title="I'm the third post", + rule=rule, + publication_date=datetime.combine( + date(2019, 7, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + ), + ] + + response = self.client.get( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}) + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 3) + + self.assertEquals(data["results"][0]["id"], posts[1].pk) + self.assertEquals(data["results"][1]["id"], posts[2].pk) + self.assertEquals(data["results"][2]["id"], posts[0].pk) + + def test_only_posts_from_rule_are_returned(self): + rule = CollectionRuleFactory.create(user=self.user) + other_rule = CollectionRuleFactory.create(user=self.user) + + PostFactory.create_batch(size=5, rule=rule) + PostFactory.create_batch(size=5, rule=other_rule) + + response = self.client.get( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}) + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + + self.assertTrue("results" in data) + self.assertTrue("count" in data) + self.assertEquals(data["count"], 5) + + for post in data["results"]: + self.assertEquals(post["rule"], rule.pk) + + def test_unread_posts(self): + rule = CollectionRuleFactory.create(user=self.user) + + PostFactory.create_batch(size=10, rule=rule, read=False) + PostFactory.create_batch(size=10, rule=rule, read=True) + + response = self.client.get( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), {"read": "false"} + ) + + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 10) + + for post in posts: + self.assertEquals(post["read"], False) + + def test_read_posts(self): + rule = CollectionRuleFactory.create(user=self.user) + + PostFactory.create_batch(size=20, rule=rule, read=False) + PostFactory.create_batch(size=10, rule=rule, read=True) + + response = self.client.get( + reverse("api:rules-nested-posts", kwargs={"pk": rule.pk}), {"read": "true"} + ) + + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 10) + + for post in posts: + self.assertEquals(post["read"], True) diff --git a/src/newsreader/news/collection/tests/endpoints/rules/list/tests.py b/src/newsreader/news/collection/tests/endpoints/rules/list/tests.py deleted file mode 100644 index 04c7b73..0000000 --- a/src/newsreader/news/collection/tests/endpoints/rules/list/tests.py +++ /dev/null @@ -1,210 +0,0 @@ -import json - -from datetime import date, datetime, time - -from django.test import Client, TestCase -from django.urls import reverse - -import pytz - -from newsreader.accounts.tests.factories import UserFactory -from newsreader.news.collection.tests.factories import CollectionRuleFactory -from newsreader.news.core.tests.factories import CategoryFactory, PostFactory - - -class CollectionRuleListViewTestCase(TestCase): - def setUp(self): - self.maxDiff = None - - self.user = UserFactory(is_staff=True, password="test") - self.client = Client() - - def test_simple(self): - CollectionRuleFactory.create_batch(size=3, user=self.user) - - self.client.force_login(self.user) - response = self.client.get(reverse("api:rules-list")) - data = response.json() - - self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - self.assertEquals(data["count"], 3) - - def test_ordering(self): - rules = [ - CollectionRuleFactory( - created=datetime.combine( - date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc - ), - user=self.user, - ), - CollectionRuleFactory( - created=datetime.combine( - date(2019, 7, 20), time(hour=18, minute=7, second=37), pytz.utc - ), - user=self.user, - ), - CollectionRuleFactory( - created=datetime.combine( - date(2019, 7, 20), time(hour=16, minute=7, second=37), pytz.utc - ), - user=self.user, - ), - ] - - self.client.force_login(self.user) - response = self.client.get(reverse("api:rules-list")) - data = response.json() - - self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - self.assertEquals(data["count"], 3) - - self.assertEquals(data["results"][0]["id"], rules[1].pk) - self.assertEquals(data["results"][1]["id"], rules[2].pk) - self.assertEquals(data["results"][2]["id"], rules[0].pk) - - def test_pagination_count(self): - CollectionRuleFactory.create_batch(size=80, user=self.user) - - self.client.force_login(self.user) - response = self.client.get(reverse("api:rules-list"), {"count": 30}) - data = response.json() - - self.assertEquals(response.status_code, 200) - self.assertEquals(data["count"], 80) - self.assertEquals(len(data["results"]), 30) - - def test_empty(self): - self.client.force_login(self.user) - response = self.client.get(reverse("api:rules-list")) - data = response.json() - - self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - - self.assertEquals(data["count"], 0) - self.assertEquals(len(data["results"]), 0) - - def test_post(self): - category = CategoryFactory(user=self.user) - - data = { - "name": "BBC", - "url": "https://www.bbc.co.uk", - "category": reverse("api:categories-detail", args=[category.pk]), - } - - self.client.force_login(self.user) - response = self.client.post( - reverse("api:rules-list"), - data=json.dumps(data), - content_type="application/json", - ) - data = response.json() - category_url = data["category"] - - self.assertEquals(response.status_code, 201) - - self.assertEquals(data["name"], "BBC") - self.assertEquals(data["url"], "https://www.bbc.co.uk") - - self.assertTrue( - category_url.endswith(reverse("api:categories-detail", args=[category.pk])) - ) - - def test_patch(self): - self.client.force_login(self.user) - response = self.client.patch(reverse("api:rules-list")) - data = response.json() - - self.assertEquals(response.status_code, 405) - self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') - - def test_put(self): - self.client.force_login(self.user) - response = self.client.put(reverse("api:rules-list")) - data = response.json() - - self.assertEquals(response.status_code, 405) - self.assertEquals(data["detail"], 'Method "PUT" not allowed.') - - def test_delete(self): - self.client.force_login(self.user) - response = self.client.delete(reverse("api:rules-list")) - data = response.json() - - self.assertEquals(response.status_code, 405) - self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') - - def test_rules_with_posts(self): - rules = { - rule: PostFactory.create_batch(size=5, rule=rule) - for rule in CollectionRuleFactory.create_batch(size=5, user=self.user) - } - - self.client.force_login(self.user) - response = self.client.get(reverse("api:rules-list")) - data = response.json() - - self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - self.assertEquals(data["count"], 5) - - self.assertEquals(len(data["results"]), 5) - - self.assertEquals(len(data["results"][0]["posts"]), 5) - - def test_rules_with_posts_ordered(self): - rules = { - rule: PostFactory.create_batch(size=5, rule=rule) - for rule in CollectionRuleFactory.create_batch(size=2, user=self.user) - } - - self.client.force_login(self.user) - response = self.client.get(reverse("api:rules-list")) - data = response.json() - - first_post_set = data["results"][0]["posts"] - second_post_set = data["results"][1]["posts"] - - self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - self.assertEquals(data["count"], 2) - - self.assertEquals(len(data["results"]), 2) - - for result_set in [first_post_set, second_post_set]: - for count, post in enumerate(result_set): - if count < 1: - continue - - self.assertTrue( - post["publication_date"] < result_set[count - 1]["publication_date"] - ) - - def test_rule_with_unauthenticated_user(self): - CollectionRuleFactory.create_batch(size=3, user=self.user) - - response = self.client.get(reverse("api:rules-list")) - response.json() - - self.assertEquals(response.status_code, 403) - - def test_rule_with_unauthorized_user(self): - other_user = UserFactory() - CollectionRuleFactory.create_batch(size=3, user=other_user) - - self.client.force_login(self.user) - response = self.client.get(reverse("api:rules-list")) - data = response.json() - - self.assertEquals(response.status_code, 200) - - self.assertEquals(data["count"], 0) - self.assertEquals(len(data["results"]), 0) diff --git a/src/newsreader/news/collection/urls.py b/src/newsreader/news/collection/urls.py index 4b59a09..606ec3a 100644 --- a/src/newsreader/news/collection/urls.py +++ b/src/newsreader/news/collection/urls.py @@ -1,12 +1,16 @@ from django.urls import path -from newsreader.news.collection.views import ( - CollectionRuleAPIListView, - CollectionRuleDetailView, +from newsreader.news.collection.endpoints import ( + DetailRuleView, + ListRuleView, + NestedRuleView, + RuleReadView, ) endpoints = [ - path("rules/", CollectionRuleDetailView.as_view(), name="rules-detail"), - path("rules/", CollectionRuleAPIListView.as_view(), name="rules-list"), + path("rules/", DetailRuleView.as_view(), name="rules-detail"), + path("rules//posts/", NestedRuleView.as_view(), name="rules-nested-posts"), + path("rules//read/", RuleReadView.as_view(), name="rules-read"), + path("rules/", ListRuleView.as_view(), name="rules-list"), ] diff --git a/src/newsreader/news/collection/views.py b/src/newsreader/news/collection/views.py index 2c08185..e69de29 100644 --- a/src/newsreader/news/collection/views.py +++ b/src/newsreader/news/collection/views.py @@ -1,21 +0,0 @@ -from rest_framework.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView - -from newsreader.core.pagination import ResultSetPagination -from newsreader.news.collection.models import CollectionRule -from newsreader.news.collection.serializers import CollectionRuleSerializer - - -class CollectionRuleAPIListView(ListCreateAPIView): - queryset = CollectionRule.objects.all() - serializer_class = CollectionRuleSerializer - pagination_class = ResultSetPagination - - def get_queryset(self): - user = self.request.user - return self.queryset.filter(user=user).order_by("-created") - - -class CollectionRuleDetailView(RetrieveUpdateDestroyAPIView): - queryset = CollectionRule.objects.all() - serializer_class = CollectionRuleSerializer - pagination_class = ResultSetPagination diff --git a/src/newsreader/news/core/endpoints.py b/src/newsreader/news/core/endpoints.py new file mode 100644 index 0000000..3f0207e --- /dev/null +++ b/src/newsreader/news/core/endpoints.py @@ -0,0 +1,118 @@ +from django.db.models import Q +from django.db.models.query import QuerySet + +from rest_framework import status +from rest_framework.generics import ( + GenericAPIView, + ListAPIView, + ListCreateAPIView, + RetrieveUpdateAPIView, + RetrieveUpdateDestroyAPIView, + get_object_or_404, +) +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from newsreader.accounts.permissions import IsPostOwner +from newsreader.core.pagination import LargeResultSetPagination, ResultSetPagination +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.serializers import RuleSerializer +from newsreader.news.core.filters import ReadFilter +from newsreader.news.core.models import Category, Post +from newsreader.news.core.serializers import CategorySerializer, PostSerializer + + +class ListPostView(ListAPIView): + queryset = Post.objects.all() + serializer_class = PostSerializer + pagination_class = LargeResultSetPagination + filter_backends = [ReadFilter] + + def get_queryset(self): + user = self.request.user + queryset = ( + self.queryset.filter(rule__user=user) + .filter(Q(rule__category=None) | Q(rule__category__user=user)) + .order_by("rule", "-publication_date", "-created") + ) + + return queryset + + +class DetailPostView(RetrieveUpdateAPIView): + queryset = Post.objects.all() + serializer_class = PostSerializer + permission_classes = (IsAuthenticated, IsPostOwner) + + +class ListCategoryView(ListCreateAPIView): + queryset = Category.objects.all() + serializer_class = CategorySerializer + + def get_queryset(self): + user = self.request.user + return self.queryset.filter(user=user).order_by("-created", "-modified") + + +class DetailCategoryView(RetrieveUpdateDestroyAPIView): + queryset = Category.objects.all() + serializer_class = CategorySerializer + + +class NestedRuleCategoryView(ListAPIView): + queryset = Category.objects.prefetch_related("rules").all() + serializer_class = RuleSerializer + + def get_queryset(self) -> QuerySet: + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + + # Default permission is IsOwner, therefore there shouldn't have to be + # filtered on the user. + filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]} + + category = get_object_or_404(self.queryset, **filter_kwargs) + self.check_object_permissions(self.request, category) + + return category.rules.order_by("name") + + +class NestedPostCategoryView(ListAPIView): + queryset = Category.objects.prefetch_related("rules", "rules__posts").all() + serializer_class = PostSerializer + pagination_class = LargeResultSetPagination + filter_backends = [ReadFilter] + + def get_queryset(self): + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + + # Default permission is IsOwner, therefore there shouldn't have to be + # filtered on the user. + filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]} + + category = get_object_or_404(self.queryset, **filter_kwargs) + self.check_object_permissions(self.request, category) + + queryset = Post.objects.filter( + rule__in=category.rules.values_list("id", flat=True) + ).order_by("rule__name", "-publication_date") + + return queryset + + +class CategoryReadView(GenericAPIView): + queryset = Category.objects.all() + serializer_class = CategorySerializer + + def post(self, request, *args, **kwargs): + category = self.get_object() + + ( + Post.objects.filter( + rule__in=category.rules.values_list("pk", flat=True) + ).update(read=True) + ) + + category.refresh_from_db() + serializer_class = self.get_serializer_class() + serializer = serializer_class(category) + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/src/newsreader/news/core/filters.py b/src/newsreader/news/core/filters.py new file mode 100644 index 0000000..d322d83 --- /dev/null +++ b/src/newsreader/news/core/filters.py @@ -0,0 +1,32 @@ +from django.utils.encoding import force_text +from django.utils.translation import ugettext_lazy as _ + +from rest_framework import filters +from rest_framework.compat import coreapi, coreschema + + +class ReadFilter(filters.BaseFilterBackend): + query_param = "read" + + def filter_queryset(self, request, queryset, view): + key = request.query_params.get(self.query_param, None) + available_values = {"True": True, "true": True, "False": False, "false": False} + + if not key or key not in available_values.keys(): + return queryset + + value = available_values[key] + return queryset.filter(read=value) + + def get_schema_fields(self, view): + return [ + coreapi.Field( + name=self.query_param, + required=False, + location="query", + schema=coreschema.String( + title=force_text(self.query_param), + description=force_text(_("Wether posts should be read or not")), + ), + ) + ] diff --git a/src/newsreader/news/core/migrations/0003_post_read.py b/src/newsreader/news/core/migrations/0003_post_read.py new file mode 100644 index 0000000..8306051 --- /dev/null +++ b/src/newsreader/news/core/migrations/0003_post_read.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2 on 2019-09-09 19:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("core", "0002_auto_20190714_1425")] + + operations = [ + migrations.AddField( + model_name="post", name="read", field=models.BooleanField(default=False) + ) + ] diff --git a/src/newsreader/news/core/models.py b/src/newsreader/news/core/models.py index ce5fa16..498d9fd 100644 --- a/src/newsreader/news/core/models.py +++ b/src/newsreader/news/core/models.py @@ -12,6 +12,8 @@ class Post(TimeStampedModel): publication_date = models.DateTimeField(blank=True, null=True) url = models.URLField(max_length=1024, blank=True, null=True) + read = models.BooleanField(default=False) + rule = models.ForeignKey( CollectionRule, on_delete=models.CASCADE, editable=False, related_name="posts" ) @@ -24,7 +26,7 @@ class Post(TimeStampedModel): class Category(TimeStampedModel): - name = models.CharField(max_length=50, unique=True) + name = models.CharField(max_length=50, unique=True) # TODO remove unique value user = models.ForeignKey("accounts.User", _("Owner"), related_name="categories") class Meta: diff --git a/src/newsreader/news/core/serializers.py b/src/newsreader/news/core/serializers.py index cb6eb12..791d873 100644 --- a/src/newsreader/news/core/serializers.py +++ b/src/newsreader/news/core/serializers.py @@ -4,7 +4,7 @@ from newsreader.news import collection from newsreader.news.core.models import Category, Post -class PostSerializer(serializers.HyperlinkedModelSerializer): +class PostSerializer(serializers.ModelSerializer): class Meta: model = Post fields = ( @@ -16,24 +16,19 @@ class PostSerializer(serializers.HyperlinkedModelSerializer): "publication_date", "url", "rule", + "read", ) - extra_kwargs = {"rule": {"view_name": "api:rules-detail"}} -class CategorySerializer(serializers.HyperlinkedModelSerializer): - rules = serializers.SerializerMethodField() +class CategorySerializer(serializers.ModelSerializer): user = serializers.HiddenField(default=serializers.CurrentUserDefault()) + unread = serializers.SerializerMethodField() - def get_rules(self, instance): - request = self.context.get("request") - rules = instance.rules.order_by("-modified", "-created") - - serializer = collection.serializers.CollectionRuleSerializer( - rules, context={"request": request}, many=True - ) - return serializer.data + def get_unread(self, category): + return Post.objects.filter( + rule__in=category.rules.values_list("pk", flat=True), read=False + ).count() class Meta: model = Category - fields = ("id", "name", "rules", "user") - extra_kwargs = {"rules": {"view_name": "api:rules-detail"}} + fields = ("id", "name", "user", "unread") diff --git a/src/newsreader/news/core/templates/core/main.html b/src/newsreader/news/core/templates/core/main.html new file mode 100644 index 0000000..9de45f4 --- /dev/null +++ b/src/newsreader/news/core/templates/core/main.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% load static %} + +{% block head %} + +{% endblock %} + +{% block content %} +
    +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py index 251023d..787d8a9 100644 --- a/src/newsreader/news/core/tests/endpoints/category/detail/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/detail/tests.py @@ -10,25 +10,20 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory class CategoryDetailViewTestCase(TestCase): def setUp(self): - self.maxDiff = None - - self.client = Client() - self.user = UserFactory(is_staff=True, password="test") + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") def test_simple(self): category = CategoryFactory(user=self.user) - self.client.force_login(self.user) response = self.client.get(reverse("api:categories-detail", args=[category.pk])) data = response.json() self.assertEquals(response.status_code, 200) self.assertTrue("id" in data) self.assertTrue("name" in data) - self.assertTrue("rules" in data) def test_not_known(self): - self.client.force_login(self.user) response = self.client.get(reverse("api:categories-detail", args=[100])) data = response.json() @@ -38,7 +33,6 @@ class CategoryDetailViewTestCase(TestCase): def test_post(self): category = CategoryFactory(user=self.user) - self.client.force_login(self.user) response = self.client.post(reverse("api:categories-detail", args=[category.pk])) data = response.json() @@ -48,7 +42,6 @@ class CategoryDetailViewTestCase(TestCase): def test_patch(self): category = CategoryFactory(name="Clickbait", user=self.user) - self.client.force_login(self.user) response = self.client.patch( reverse("api:categories-detail", args=[category.pk]), data=json.dumps({"name": "Interesting posts"}), @@ -62,7 +55,6 @@ class CategoryDetailViewTestCase(TestCase): def test_identifier_cannot_be_changed(self): category = CategoryFactory(user=self.user) - self.client.force_login(self.user) response = self.client.patch( reverse("api:categories-detail", args=[category.pk]), data=json.dumps({"id": 44}), @@ -76,7 +68,6 @@ class CategoryDetailViewTestCase(TestCase): def test_put(self): category = CategoryFactory(name="Clickbait", user=self.user) - self.client.force_login(self.user) response = self.client.put( reverse("api:categories-detail", args=[category.pk]), data=json.dumps({"name": "Interesting posts"}), @@ -90,74 +81,15 @@ class CategoryDetailViewTestCase(TestCase): def test_delete(self): category = CategoryFactory(user=self.user) - self.client.force_login(self.user) response = self.client.delete( reverse("api:categories-detail", args=[category.pk]) ) self.assertEquals(response.status_code, 204) - def test_rules(self): - category = CategoryFactory(user=self.user) - rules = CollectionRuleFactory.create_batch( - size=5, category=category, user=self.user - ) - - self.client.force_login(self.user) - response = self.client.get(reverse("api:categories-detail", args=[category.pk])) - data = response.json() - - self.assertEquals(response.status_code, 200) - - self.assertTrue("id" in data["rules"][0]) - self.assertTrue("name" in data["rules"][0]) - self.assertTrue("url" in data["rules"][0]) - - def test_rules_with_posts(self): - category = CategoryFactory(user=self.user) - - rules = { - rule.pk: PostFactory.create_batch(size=5, rule=rule) - for rule in CollectionRuleFactory.create_batch( - size=5, category=category, user=self.user - ) - } - - self.client.force_login(self.user) - response = self.client.get(reverse("api:categories-detail", args=[category.pk])) - data = response.json() - - self.assertEquals(response.status_code, 200) - - self.assertEquals(len(data["rules"][0]["posts"]), 5) - - def test_rules_with_posts_ordered(self): - category = CategoryFactory(user=self.user) - - rules = { - rule.pk: PostFactory.create_batch(size=5, rule=rule) - for rule in CollectionRuleFactory.create_batch( - size=5, category=category, user=self.user - ) - } - - self.client.force_login(self.user) - response = self.client.get(reverse("api:categories-detail", args=[category.pk])) - data = response.json() - - self.assertEquals(response.status_code, 200) - - posts = data["rules"][0]["posts"] - - for count, post in enumerate(posts): - if count < 1: - continue - - self.assertTrue( - post["publication_date"] < posts[count - 1]["publication_date"] - ) - def test_category_with_unauthenticated_user(self): + self.client.logout() + category = CategoryFactory(user=self.user) response = self.client.get(reverse("api:categories-detail", args=[category.pk])) @@ -168,7 +100,112 @@ class CategoryDetailViewTestCase(TestCase): other_user = UserFactory() category = CategoryFactory(user=other_user) - self.client.force_login(self.user) response = self.client.get(reverse("api:categories-detail", args=[category.pk])) self.assertEquals(response.status_code, 403) + + def test_read_count(self): + category = CategoryFactory(user=self.user) + unread_rule = CollectionRuleFactory(category=category) + read_rule = CollectionRuleFactory(category=category) + + PostFactory.create_batch(size=20, read=False, rule=unread_rule) + PostFactory.create_batch(size=20, read=True, rule=read_rule) + + response = self.client.get(reverse("api:categories-detail", args=[category.pk])) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["unread"], 20) + + +class CategoryReadTestCase(TestCase): + def setUp(self): + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") + + def test_category_read(self): + category = CategoryFactory(user=self.user) + rules = [ + PostFactory.create_batch(size=5, read=False, rule=rule) + for rule in CollectionRuleFactory.create_batch(size=5, category=category) + ] + + response = self.client.post(reverse("api:categories-read", args=[category.pk])) + + data = response.json() + + self.assertEquals(response.status_code, 201) + self.assertEquals(data["unread"], 0) + self.assertEquals(data["id"], category.pk) + + def test_category_unknown(self): + response = self.client.post(reverse("api:categories-read", args=[101])) + + self.assertEquals(response.status_code, 404) + + def test_unauthenticated_user(self): + self.client.logout() + + category = CategoryFactory(user=self.user) + rules = [ + PostFactory.create_batch(size=5, read=False, rule=rule) + for rule in CollectionRuleFactory.create_batch( + size=5, category=category, user=self.user + ) + ] + + response = self.client.post(reverse("api:categories-read", args=[category.pk])) + + self.assertEquals(response.status_code, 403) + + def test_unauthorized_user(self): + other_user = UserFactory() + category = CategoryFactory(user=other_user) + + rules = [ + PostFactory.create_batch(size=5, read=False, rule=rule) + for rule in CollectionRuleFactory.create_batch( + size=5, category=category, user=other_user + ) + ] + + response = self.client.post(reverse("api:categories-read", args=[category.pk])) + + self.assertEquals(response.status_code, 403) + + def test_get(self): + category = CategoryFactory(name="Clickbait", user=self.user) + + response = self.client.get(reverse("api:categories-read", args=[category.pk])) + + self.assertEquals(response.status_code, 405) + + def test_patch(self): + category = CategoryFactory(name="Clickbait", user=self.user) + + response = self.client.patch( + reverse("api:categories-read", args=[category.pk]), + data=json.dumps({"name": "Not possible"}), + content_type="application/json", + ) + + self.assertEquals(response.status_code, 405) + + def test_put(self): + category = CategoryFactory(name="Clickbait", user=self.user) + + response = self.client.put( + reverse("api:categories-read", args=[category.pk]), + data=json.dumps({"name": "Not possible"}), + content_type="application/json", + ) + + self.assertEquals(response.status_code, 405) + + def test_delete(self): + category = CategoryFactory(name="Clickbait", user=self.user) + + response = self.client.delete(reverse("api:categories-read", args=[category.pk])) + + self.assertEquals(response.status_code, 405) diff --git a/src/newsreader/news/core/tests/endpoints/category/list/tests.py b/src/newsreader/news/core/tests/endpoints/category/list/tests.py index 974645d..f97884b 100644 --- a/src/newsreader/news/core/tests/endpoints/category/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/category/list/tests.py @@ -14,22 +14,17 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory class CategoryListViewTestCase(TestCase): def setUp(self): - self.maxDiff = None - - self.client = Client() - self.user = UserFactory(is_staff=True, password="test") + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") def test_simple(self): CategoryFactory.create_batch(size=3, user=self.user) - self.client.force_login(self.user) response = self.client.get(reverse("api:categories-list")) data = response.json() self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - self.assertEquals(data["count"], 3) + self.assertEquals(len(data), 3) def test_ordering(self): categories = [ @@ -53,35 +48,25 @@ class CategoryListViewTestCase(TestCase): ), ] - self.client.force_login(self.user) response = self.client.get(reverse("api:categories-list")) data = response.json() self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - self.assertEquals(data["count"], 3) - self.assertEquals(data["results"][0]["id"], categories[1].pk) - self.assertEquals(data["results"][1]["id"], categories[2].pk) - self.assertEquals(data["results"][2]["id"], categories[0].pk) + self.assertEquals(data[0]["id"], categories[1].pk) + self.assertEquals(data[1]["id"], categories[2].pk) + self.assertEquals(data[2]["id"], categories[0].pk) def test_empty(self): - self.client.force_login(self.user) response = self.client.get(reverse("api:categories-list")) data = response.json() self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - - self.assertEquals(data["count"], 0) - self.assertEquals(len(data["results"]), 0) + self.assertEquals(len(data), 0) def test_post(self): data = {"name": "Tech"} - self.client.force_login(self.user) response = self.client.post( reverse("api:categories-list"), data=json.dumps(data), @@ -93,7 +78,6 @@ class CategoryListViewTestCase(TestCase): self.assertEquals(response_data["name"], "Tech") def test_patch(self): - self.client.force_login(self.user) response = self.client.patch(reverse("api:categories-list")) data = response.json() @@ -101,7 +85,6 @@ class CategoryListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') def test_put(self): - self.client.force_login(self.user) response = self.client.put(reverse("api:categories-list")) data = response.json() @@ -109,66 +92,15 @@ class CategoryListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "PUT" not allowed.') def test_delete(self): - self.client.force_login(self.user) response = self.client.delete(reverse("api:categories-list")) data = response.json() self.assertEquals(response.status_code, 405) self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') - def test_rules(self): - categories = { - category.pk: CollectionRuleFactory.create_batch( - size=5, category=category, user=self.user - ) - for category in CategoryFactory.create_batch(size=5, user=self.user) - } - - self.client.force_login(self.user) - response = self.client.get(reverse("api:categories-list")) - data = response.json() - - self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - self.assertEquals(data["count"], 5) - - self.assertEquals(len(data["results"]), 5) - - self.assertEquals(len(data["results"][0]["rules"]), 5) - - self.assertTrue("id" in data["results"][0]["rules"][0]) - self.assertTrue("name" in data["results"][0]["rules"][0]) - self.assertTrue("url" in data["results"][0]["rules"][0]) - self.assertTrue("posts" in data["results"][0]["rules"][0]) - - def test_rules_with_posts(self): - categories = { - category.pk: CollectionRuleFactory.create_batch( - size=5, category=category, user=self.user - ) - for category in CategoryFactory.create_batch(size=5, user=self.user) - } - - for category in categories: - for rule in categories[category]: - PostFactory.create_batch(size=5, rule=rule) - - self.client.force_login(self.user) - response = self.client.get(reverse("api:categories-list")) - data = response.json() - - self.assertEquals(response.status_code, 200) - self.assertTrue("results" in data) - self.assertTrue("count" in data) - self.assertEquals(data["count"], 5) - - self.assertEquals(len(data["results"]), 5) - - self.assertEquals(len(data["results"][0]["rules"]), 5) - self.assertEquals(len(data["results"][0]["rules"][0]["posts"]), 5) - def test_categories_with_unauthenticated_user(self): + self.client.logout() + CategoryFactory.create_batch(size=3, user=self.user) response = self.client.get(reverse("api:categories-list")) @@ -179,10 +111,458 @@ class CategoryListViewTestCase(TestCase): other_user = UserFactory() CategoryFactory.create_batch(size=3, user=other_user) - self.client.force_login(self.user) response = self.client.get(reverse("api:categories-list")) data = response.json() self.assertEquals(response.status_code, 200) - self.assertEquals(len(data["results"]), 0) + self.assertEquals(len(data), 0) + + +class NestedCategoryListViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") + + def test_simple(self): + category = CategoryFactory.create(user=self.user) + rules = CollectionRuleFactory.create_batch(size=5, category=category) + + response = self.client.get( + reverse("api:categories-nested-rules", kwargs={"pk": category.pk}) + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(len(data), 5) + + self.assertTrue("id" in data[0]) + self.assertTrue("name" in data[0]) + self.assertTrue("category" in data[0]) + self.assertTrue("url" in data[0]) + self.assertTrue("favicon" in data[0]) + + def test_empty(self): + category = CategoryFactory.create(user=self.user) + + response = self.client.get( + reverse("api:categories-nested-rules", kwargs={"pk": category.pk}) + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(len(data), 0) + self.assertEquals(data, []) + + def test_not_known(self): + response = self.client.get( + reverse("api:categories-nested-rules", kwargs={"pk": 100}) + ) + + self.assertEquals(response.status_code, 404) + + def test_post(self): + response = self.client.post( + reverse("api:categories-nested-rules", kwargs={"pk": 100}), + data=json.dumps({}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "POST" not allowed.') + + def test_patch(self): + category = CategoryFactory.create(user=self.user) + + response = self.client.patch( + reverse("api:categories-nested-rules", kwargs={"pk": category.pk}), + data=json.dumps({"name": "test"}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') + + def test_put(self): + category = CategoryFactory.create(user=self.user) + + response = self.client.put( + reverse("api:categories-nested-rules", kwargs={"pk": category.pk}), + data=json.dumps({"name": "test"}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PUT" not allowed.') + + def test_delete(self): + category = CategoryFactory.create(user=self.user) + + response = self.client.delete( + reverse("api:categories-nested-rules", kwargs={"pk": category.pk}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') + + def test_with_unauthenticated_user(self): + self.client.logout() + + category = CategoryFactory.create(user=self.user) + rules = CollectionRuleFactory.create_batch(size=5, category=category) + + response = self.client.get( + reverse("api:categories-nested-rules", kwargs={"pk": category.pk}) + ) + + self.assertEquals(response.status_code, 403) + + def test_with_unauthorized_user(self): + other_user = UserFactory.create() + + category = CategoryFactory.create(user=other_user) + rules = CollectionRuleFactory.create_batch(size=5, category=category) + + response = self.client.get( + reverse("api:categories-nested-rules", kwargs={"pk": category.pk}) + ) + + self.assertEquals(response.status_code, 403) + + def test_ordering(self): + category = CategoryFactory.create(user=self.user) + rules = [ + CollectionRuleFactory.create(category=category, name="Durp"), + CollectionRuleFactory.create(category=category, name="Slurp"), + CollectionRuleFactory.create(category=category, name="Burp"), + ] + + response = self.client.get( + reverse("api:categories-nested-rules", kwargs={"pk": category.pk}) + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(len(data), 3) + + self.assertEquals(data[0]["id"], rules[2].pk) + self.assertEquals(data[1]["id"], rules[0].pk) + self.assertEquals(data[2]["id"], rules[1].pk) + + def test_only_rules_from_category_are_returned(self): + other_category = CategoryFactory(user=self.user) + CollectionRuleFactory.create_batch(size=5, category=other_category) + + category = CategoryFactory.create(user=self.user) + rules = [ + CollectionRuleFactory.create(category=category, name="Durp"), + CollectionRuleFactory.create(category=category, name="Slurp"), + CollectionRuleFactory.create(category=category, name="Burp"), + ] + + response = self.client.get( + reverse("api:categories-nested-rules", kwargs={"pk": category.pk}) + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(len(data), 3) + + self.assertEquals(data[0]["id"], rules[2].pk) + self.assertEquals(data[1]["id"], rules[0].pk) + self.assertEquals(data[2]["id"], rules[1].pk) + + +class NestedCategoryPostView(TestCase): + def setUp(self): + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") + + def test_simple(self): + category = CategoryFactory.create(user=self.user) + rules = { + rule.pk: PostFactory.create_batch(size=5, rule=rule) + for rule in CollectionRuleFactory.create_batch( + size=5, category=category, user=self.user + ) + } + + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + ) + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 25) + + self.assertTrue("id" in posts[0]) + self.assertTrue("title" in posts[0]) + self.assertTrue("body" in posts[0]) + self.assertTrue("rule" in posts[0]) + self.assertTrue("url" in posts[0]) + + def test_no_rules(self): + category = CategoryFactory.create(user=self.user) + + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + ) + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) self.assertEquals(data["count"], 0) + self.assertEquals(posts, []) + + def test_no_posts(self): + category = CategoryFactory.create(user=self.user) + rules = CollectionRuleFactory.create_batch( + size=5, user=self.user, category=category + ) + + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + ) + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 0) + self.assertEquals(posts, []) + + def test_not_known(self): + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": 100}) + ) + + self.assertEquals(response.status_code, 404) + + def test_post(self): + response = self.client.post( + reverse("api:categories-nested-posts", kwargs={"pk": 100}), + data=json.dumps({}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "POST" not allowed.') + + def test_patch(self): + category = CategoryFactory.create(user=self.user) + + response = self.client.patch( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}), + data=json.dumps({}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') + + def test_put(self): + category = CategoryFactory.create(user=self.user) + + response = self.client.put( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}), + data=json.dumps({}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "PUT" not allowed.') + + def test_delete(self): + category = CategoryFactory.create(user=self.user) + + response = self.client.delete( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 405) + self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') + + def test_with_unauthenticated_user(self): + self.client.logout() + + category = CategoryFactory.create(user=self.user) + + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + ) + + self.assertEquals(response.status_code, 403) + + def test_with_unauthorized_user(self): + other_user = UserFactory.create() + category = CategoryFactory.create(user=other_user) + + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + ) + + self.assertEquals(response.status_code, 403) + + def test_ordering(self): + category = CategoryFactory.create(user=self.user) + + bbc_rule = CollectionRuleFactory.create( + name="BBC", category=category, user=self.user + ) + guardian_rule = CollectionRuleFactory.create( + name="The Guardian", category=category, user=self.user + ) + reuters_rule = CollectionRuleFactory.create( + name="Reuters", category=category, user=self.user + ) + + reuters_rule = [ + PostFactory.create( + title="Second Reuters post", + rule=reuters_rule, + publication_date=datetime.combine( + date(2019, 5, 21), time(hour=16, minute=7, second=37), pytz.utc + ), + ), + PostFactory.create( + title="First Reuters post", + rule=reuters_rule, + publication_date=datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + ), + ] + + guardian_posts = [ + PostFactory.create( + title="Second Guardian post", + rule=guardian_rule, + publication_date=datetime.combine( + date(2019, 5, 21), time(hour=16, minute=7, second=37), pytz.utc + ), + ), + PostFactory.create( + title="First Guardian post", + rule=guardian_rule, + publication_date=datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + ), + ] + + bbc_posts = [ + PostFactory.create( + title="Second BBC post", + rule=bbc_rule, + publication_date=datetime.combine( + date(2019, 5, 21), time(hour=16, minute=7, second=37), pytz.utc + ), + ), + PostFactory.create( + title="First BBC post", + rule=bbc_rule, + publication_date=datetime.combine( + date(2019, 5, 20), time(hour=16, minute=7, second=37), pytz.utc + ), + ), + ] + + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + ) + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 6) + + self.assertEquals(posts[0]["title"], "Second BBC post") + self.assertEquals(posts[1]["title"], "First BBC post") + + self.assertEquals(posts[2]["title"], "Second Reuters post") + self.assertEquals(posts[3]["title"], "First Reuters post") + + self.assertEquals(posts[4]["title"], "Second Guardian post") + self.assertEquals(posts[5]["title"], "First Guardian post") + + def test_only_posts_from_category_are_returned(self): + category = CategoryFactory.create(user=self.user) + other_category = CategoryFactory.create(user=self.user) + + guardian_rule = CollectionRuleFactory.create( + name="BBC", category=category, user=self.user + ) + other_rule = CollectionRuleFactory.create(name="The Guardian", user=self.user) + + guardian_posts = [ + PostFactory.create(rule=guardian_rule), + PostFactory.create(rule=guardian_rule), + ] + + other_posts = [ + PostFactory.create(rule=other_rule), + PostFactory.create(rule=other_rule), + ] + + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}) + ) + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 2) + + self.assertEquals(posts[0]["rule"], guardian_rule.pk) + self.assertEquals(posts[1]["rule"], guardian_rule.pk) + + def test_unread_posts(self): + category = CategoryFactory.create(user=self.user) + rule = CollectionRuleFactory(category=category) + + PostFactory.create_batch(size=10, rule=rule, read=False) + PostFactory.create_batch(size=10, rule=rule, read=True) + + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}), + {"read": "false"}, + ) + + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 10) + + for post in posts: + self.assertEquals(post["read"], False) + + def test_read_posts(self): + category = CategoryFactory.create(user=self.user) + rule = CollectionRuleFactory(category=category) + + PostFactory.create_batch(size=20, rule=rule, read=False) + PostFactory.create_batch(size=10, rule=rule, read=True) + + response = self.client.get( + reverse("api:categories-nested-posts", kwargs={"pk": category.pk}), + {"read": "true"}, + ) + + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 10) + + for post in posts: + self.assertEquals(post["read"], True) diff --git a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py index 465e2f2..acc4bd1 100644 --- a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py +++ b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py @@ -10,11 +10,8 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory class PostDetailViewTestCase(TestCase): def setUp(self): - self.maxDiff = None - self.client = Client() - - self.client = Client() - self.user = UserFactory(is_staff=True, password="test") + self.user = UserFactory(password="test") + self.client.login(email=self.user.email, password="test") def test_simple(self): rule = CollectionRuleFactory( @@ -22,7 +19,6 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(rule=rule) - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-detail", args=[post.pk])) data = response.json() @@ -38,7 +34,6 @@ class PostDetailViewTestCase(TestCase): self.assertTrue("remote_identifier" in data) def test_not_known(self): - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-detail", args=[100])) data = response.json() @@ -51,7 +46,6 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(rule=rule) - self.client.force_login(self.user) response = self.client.post(reverse("api:posts-detail", args=[post.pk])) data = response.json() @@ -64,7 +58,6 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(title="This is clickbait for sure", rule=rule) - self.client.force_login(self.user) response = self.client.patch( reverse("api:posts-detail", args=[post.pk]), data=json.dumps({"title": "This title is very accurate"}), @@ -81,7 +74,6 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(title="This is clickbait for sure", rule=rule) - self.client.force_login(self.user) response = self.client.patch( reverse("api:posts-detail", args=[post.pk]), data=json.dumps({"id": 44}), @@ -101,18 +93,16 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(title="This is clickbait for sure", rule=rule) - self.client.force_login(self.user) response = self.client.patch( reverse("api:posts-detail", args=[post.pk]), data=json.dumps({"rule": reverse("api:rules-detail", args=[new_rule.pk])}), content_type="application/json", ) data = response.json() - rule_url = data["rule"] self.assertEquals(response.status_code, 200) - self.assertTrue(rule_url.endswith(reverse("api:rules-detail", args=[rule.pk]))) + self.assertTrue(data["rule"], rule.pk) def test_put(self): rule = CollectionRuleFactory( @@ -120,7 +110,6 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(title="This is clickbait for sure", rule=rule) - self.client.force_login(self.user) response = self.client.put( reverse("api:posts-detail", args=[post.pk]), data=json.dumps({"title": "This title is very accurate"}), @@ -137,7 +126,6 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(rule=rule) - self.client.force_login(self.user) response = self.client.delete(reverse("api:posts-detail", args=[post.pk])) data = response.json() @@ -145,6 +133,8 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') def test_post_with_unauthenticated_user_without_category(self): + self.client.logout() + rule = CollectionRuleFactory(user=self.user, category=None) post = PostFactory(rule=rule) @@ -153,6 +143,8 @@ class PostDetailViewTestCase(TestCase): self.assertEquals(response.status_code, 403) def test_post_with_unauthenticated_user_with_category(self): + self.client.logout() + rule = CollectionRuleFactory( user=self.user, category=CategoryFactory(user=self.user) ) @@ -167,7 +159,6 @@ class PostDetailViewTestCase(TestCase): rule = CollectionRuleFactory(user=other_user, category=None) post = PostFactory(rule=rule) - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-detail", args=[post.pk])) self.assertEquals(response.status_code, 403) @@ -179,7 +170,6 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(rule=rule) - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-detail", args=[post.pk])) self.assertEquals(response.status_code, 403) @@ -191,7 +181,38 @@ class PostDetailViewTestCase(TestCase): ) post = PostFactory(rule=rule) - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-detail", args=[post.pk])) self.assertEquals(response.status_code, 403) + + def test_mark_read(self): + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) + post = PostFactory(rule=rule, read=False) + + response = self.client.patch( + reverse("api:posts-detail", args=[post.pk]), + data=json.dumps({"read": True}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["read"], True) + + def test_mark_unread(self): + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) + post = PostFactory(rule=rule, read=True) + + response = self.client.patch( + reverse("api:posts-detail", args=[post.pk]), + data=json.dumps({"read": False}), + content_type="application/json", + ) + data = response.json() + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["read"], False) diff --git a/src/newsreader/news/core/tests/endpoints/post/list/tests.py b/src/newsreader/news/core/tests/endpoints/post/list/tests.py index 724d8b2..013decd 100644 --- a/src/newsreader/news/core/tests/endpoints/post/list/tests.py +++ b/src/newsreader/news/core/tests/endpoints/post/list/tests.py @@ -12,10 +12,8 @@ from newsreader.news.core.tests.factories import CategoryFactory, PostFactory class PostListViewTestCase(TestCase): def setUp(self): - self.maxDiff = None - - self.client = Client() self.user = UserFactory(is_staff=True, password="test") + self.client.login(email=self.user.email, password="test") def test_simple(self): rule = CollectionRuleFactory( @@ -23,7 +21,6 @@ class PostListViewTestCase(TestCase): ) PostFactory.create_batch(size=3, rule=rule) - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-list")) data = response.json() @@ -61,7 +58,6 @@ class PostListViewTestCase(TestCase): ), ] - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-list")) data = response.json() @@ -81,7 +77,6 @@ class PostListViewTestCase(TestCase): PostFactory.create_batch(size=80, rule=rule) page_size = 50 - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-list"), {"count": 50}) data = response.json() @@ -90,7 +85,6 @@ class PostListViewTestCase(TestCase): self.assertEquals(len(data["results"]), page_size) def test_empty(self): - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-list")) data = response.json() @@ -102,7 +96,6 @@ class PostListViewTestCase(TestCase): self.assertEquals(len(data["results"]), 0) def test_post(self): - self.client.force_login(self.user) response = self.client.post(reverse("api:posts-list")) data = response.json() @@ -110,7 +103,6 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "POST" not allowed.') def test_patch(self): - self.client.force_login(self.user) response = self.client.patch(reverse("api:posts-list")) data = response.json() @@ -118,7 +110,6 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "PATCH" not allowed.') def test_put(self): - self.client.force_login(self.user) response = self.client.put(reverse("api:posts-list")) data = response.json() @@ -126,7 +117,6 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "PUT" not allowed.') def test_delete(self): - self.client.force_login(self.user) response = self.client.delete(reverse("api:posts-list")) data = response.json() @@ -134,6 +124,8 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["detail"], 'Method "DELETE" not allowed.') def test_posts_with_unauthenticated_user_without_category(self): + self.client.logout() + PostFactory.create_batch(size=3, rule=CollectionRuleFactory(user=self.user)) response = self.client.get(reverse("api:posts-list")) @@ -141,6 +133,8 @@ class PostListViewTestCase(TestCase): self.assertEquals(response.status_code, 403) def test_posts_with_unauthenticated_user_with_category(self): + self.client.logout() + category = CategoryFactory(user=self.user) PostFactory.create_batch( @@ -157,7 +151,6 @@ class PostListViewTestCase(TestCase): rule = CollectionRuleFactory(user=other_user, category=None) PostFactory.create_batch(size=3, rule=rule) - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-list")) data = response.json() @@ -173,7 +166,6 @@ class PostListViewTestCase(TestCase): size=3, rule=CollectionRuleFactory(user=other_user, category=category) ) - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-list")) data = response.json() @@ -191,7 +183,6 @@ class PostListViewTestCase(TestCase): ) PostFactory.create_batch(size=3, rule=rule) - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-list")) data = response.json() @@ -201,12 +192,9 @@ class PostListViewTestCase(TestCase): self.assertEquals(data["count"], 0) def test_posts_with_authorized_user_without_category(self): - UserFactory() - rule = CollectionRuleFactory(user=self.user, category=None) PostFactory.create_batch(size=3, rule=rule) - self.client.force_login(self.user) response = self.client.get(reverse("api:posts-list")) data = response.json() @@ -214,3 +202,41 @@ class PostListViewTestCase(TestCase): self.assertTrue("results" in data) self.assertTrue("count" in data) self.assertEquals(data["count"], 3) + + def test_unread_posts(self): + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) + + PostFactory.create_batch(size=10, rule=rule, read=False) + PostFactory.create_batch(size=10, rule=rule, read=True) + + response = self.client.get(reverse("api:posts-list"), {"read": "false"}) + + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 10) + + for post in posts: + self.assertEquals(post["read"], False) + + def test_read_posts(self): + rule = CollectionRuleFactory( + user=self.user, category=CategoryFactory(user=self.user) + ) + + PostFactory.create_batch(size=20, rule=rule, read=False) + PostFactory.create_batch(size=10, rule=rule, read=True) + + response = self.client.get(reverse("api:posts-list"), {"read": "true"}) + + data = response.json() + posts = data["results"] + + self.assertEquals(response.status_code, 200) + self.assertEquals(data["count"], 10) + + for post in posts: + self.assertEquals(post["read"], True) diff --git a/src/newsreader/news/core/tests/factories.py b/src/newsreader/news/core/tests/factories.py index 3ccf52d..46eeeae 100644 --- a/src/newsreader/news/core/tests/factories.py +++ b/src/newsreader/news/core/tests/factories.py @@ -25,5 +25,7 @@ class PostFactory(factory.django.DjangoModelFactory): "newsreader.news.collection.tests.factories.CollectionRuleFactory" ) + read = False + class Meta: model = Post diff --git a/src/newsreader/news/core/urls.py b/src/newsreader/news/core/urls.py index c5ccaa9..3255cee 100644 --- a/src/newsreader/news/core/urls.py +++ b/src/newsreader/news/core/urls.py @@ -1,18 +1,34 @@ +from django.contrib.auth.decorators import login_required from django.urls import path -from newsreader.news.core.views import ( - DetailCategoryAPIView, - DetailPostAPIView, - ListCategoryAPIView, - ListPostAPIView, +from newsreader.news.core.endpoints import ( + CategoryReadView, + DetailCategoryView, + DetailPostView, + ListCategoryView, + ListPostView, + NestedPostCategoryView, + NestedRuleCategoryView, ) +from newsreader.news.core.views import MainView +index_page = login_required(MainView.as_view()) + endpoints = [ - path("posts/", ListPostAPIView.as_view(), name="posts-list"), - path("posts//", DetailPostAPIView.as_view(), name="posts-detail"), - path("categories/", ListCategoryAPIView.as_view(), name="categories-list"), + path("posts/", ListPostView.as_view(), name="posts-list"), + path("posts//", DetailPostView.as_view(), name="posts-detail"), + path("categories/", ListCategoryView.as_view(), name="categories-list"), + path("categories//", DetailCategoryView.as_view(), name="categories-detail"), + path("categories//read/", CategoryReadView.as_view(), name="categories-read"), path( - "categories//", DetailCategoryAPIView.as_view(), name="categories-detail" + "categories//rules/", + NestedRuleCategoryView.as_view(), + name="categories-nested-rules", + ), + path( + "categories//posts/", + NestedPostCategoryView.as_view(), + name="categories-nested-posts", ), ] diff --git a/src/newsreader/news/core/views.py b/src/newsreader/news/core/views.py index 415816c..7832673 100644 --- a/src/newsreader/news/core/views.py +++ b/src/newsreader/news/core/views.py @@ -1,49 +1,25 @@ -from django.db.models import Q +from typing import Dict -from rest_framework.generics import ( - ListAPIView, - ListCreateAPIView, - RetrieveUpdateAPIView, - RetrieveUpdateDestroyAPIView, -) -from rest_framework.permissions import IsAuthenticated - -from newsreader.accounts.permissions import IsPostOwner -from newsreader.core.pagination import LargeResultSetPagination, ResultSetPagination -from newsreader.news.core.models import Category, Post -from newsreader.news.core.serializers import CategorySerializer, PostSerializer +from django.views.generic.base import TemplateView -class ListPostAPIView(ListAPIView): - queryset = Post.objects.all() - serializer_class = PostSerializer - pagination_class = LargeResultSetPagination - permission_classes = (IsAuthenticated, IsPostOwner) +class MainView(TemplateView): + template_name = "core/main.html" - def get_queryset(self): + # TODO serialize objects to show filled main page + def get_context_data(self, **kwargs) -> Dict: + context = super().get_context_data(**kwargs) user = self.request.user - initial_queryset = self.queryset.filter(rule__user=user) - return initial_queryset.filter( - Q(rule__category=None) | Q(rule__category__user=user) - ).order_by("rule", "-publication_date", "-created") + categories = { + category: category.rules.order_by("-created") + for category in user.categories.order_by("name") + } -class DetailPostAPIView(RetrieveUpdateAPIView): - queryset = Post.objects.all() - serializer_class = PostSerializer - permission_classes = (IsAuthenticated, IsPostOwner) + rules = { + rule: rule.posts.order_by("-publication_date")[:30] + for rule in user.rules.order_by("-created") + } - -class ListCategoryAPIView(ListCreateAPIView): - queryset = Category.objects.all() - serializer_class = CategorySerializer - pagination_class = ResultSetPagination - - def get_queryset(self): - user = self.request.user - return self.queryset.filter(user=user).order_by("-created", "-modified") - - -class DetailCategoryAPIView(RetrieveUpdateDestroyAPIView): - queryset = Category.objects.all() - serializer_class = CategorySerializer + context.update(categories=categories, rules=rules) + return context diff --git a/src/newsreader/static/src/scss/accounts/components/form/_form.scss b/src/newsreader/scss/accounts/components/form/_form.scss similarity index 94% rename from src/newsreader/static/src/scss/accounts/components/form/_form.scss rename to src/newsreader/scss/accounts/components/form/_form.scss index aa2fb6c..8e6cf7a 100644 --- a/src/newsreader/static/src/scss/accounts/components/form/_form.scss +++ b/src/newsreader/scss/accounts/components/form/_form.scss @@ -8,6 +8,8 @@ &__fieldset { @extend .form__fieldset; + + padding: 10px; } &__fieldset * { diff --git a/src/newsreader/static/src/scss/accounts/components/form/index.scss b/src/newsreader/scss/accounts/components/form/index.scss similarity index 100% rename from src/newsreader/static/src/scss/accounts/components/form/index.scss rename to src/newsreader/scss/accounts/components/form/index.scss diff --git a/src/newsreader/static/src/scss/accounts/components/index.scss b/src/newsreader/scss/accounts/components/index.scss similarity index 100% rename from src/newsreader/static/src/scss/accounts/components/index.scss rename to src/newsreader/scss/accounts/components/index.scss diff --git a/src/newsreader/static/src/scss/accounts/components/main/_main.scss b/src/newsreader/scss/accounts/components/main/_main.scss similarity index 100% rename from src/newsreader/static/src/scss/accounts/components/main/_main.scss rename to src/newsreader/scss/accounts/components/main/_main.scss diff --git a/src/newsreader/static/src/scss/accounts/components/main/index.scss b/src/newsreader/scss/accounts/components/main/index.scss similarity index 100% rename from src/newsreader/static/src/scss/accounts/components/main/index.scss rename to src/newsreader/scss/accounts/components/main/index.scss diff --git a/src/newsreader/static/src/scss/accounts/index.scss b/src/newsreader/scss/accounts/index.scss similarity index 72% rename from src/newsreader/static/src/scss/accounts/index.scss rename to src/newsreader/scss/accounts/index.scss index d0a748c..d155753 100644 --- a/src/newsreader/static/src/scss/accounts/index.scss +++ b/src/newsreader/scss/accounts/index.scss @@ -1,4 +1,6 @@ +// General imports @import "../partials/variables"; @import "../components/index"; +// Page specific @import "./components/index"; diff --git a/src/newsreader/static/src/scss/components/body/_body.scss b/src/newsreader/scss/components/body/_body.scss similarity index 61% rename from src/newsreader/static/src/scss/components/body/_body.scss rename to src/newsreader/scss/components/body/_body.scss index f0829bf..0e2dad3 100644 --- a/src/newsreader/static/src/scss/components/body/_body.scss +++ b/src/newsreader/scss/components/body/_body.scss @@ -2,4 +2,9 @@ margin: 0; padding: 0; background-color: $gainsboro; + + & * { + margin: 0; + padding: 0; + } } diff --git a/src/newsreader/static/src/scss/components/body/index.scss b/src/newsreader/scss/components/body/index.scss similarity index 100% rename from src/newsreader/static/src/scss/components/body/index.scss rename to src/newsreader/scss/components/body/index.scss diff --git a/src/newsreader/static/src/scss/components/button/_button.scss b/src/newsreader/scss/components/button/_button.scss similarity index 95% rename from src/newsreader/static/src/scss/components/button/_button.scss rename to src/newsreader/scss/components/button/_button.scss index 4047b6c..61ddc81 100644 --- a/src/newsreader/static/src/scss/components/button/_button.scss +++ b/src/newsreader/scss/components/button/_button.scss @@ -6,8 +6,6 @@ padding: 10px 50px; - width: 50px; - border: none; border-radius: 2px; diff --git a/src/newsreader/static/src/scss/components/button/index.scss b/src/newsreader/scss/components/button/index.scss similarity index 100% rename from src/newsreader/static/src/scss/components/button/index.scss rename to src/newsreader/scss/components/button/index.scss diff --git a/src/newsreader/static/src/scss/components/error/_error.scss b/src/newsreader/scss/components/error/_error.scss similarity index 100% rename from src/newsreader/static/src/scss/components/error/_error.scss rename to src/newsreader/scss/components/error/_error.scss diff --git a/src/newsreader/static/src/scss/components/error/_errorlist.scss b/src/newsreader/scss/components/error/_errorlist.scss similarity index 100% rename from src/newsreader/static/src/scss/components/error/_errorlist.scss rename to src/newsreader/scss/components/error/_errorlist.scss diff --git a/src/newsreader/static/src/scss/components/error/index.scss b/src/newsreader/scss/components/error/index.scss similarity index 100% rename from src/newsreader/static/src/scss/components/error/index.scss rename to src/newsreader/scss/components/error/index.scss diff --git a/src/newsreader/static/src/scss/components/form/_form.scss b/src/newsreader/scss/components/form/_form.scss similarity index 100% rename from src/newsreader/static/src/scss/components/form/_form.scss rename to src/newsreader/scss/components/form/_form.scss diff --git a/src/newsreader/static/src/scss/components/form/index.scss b/src/newsreader/scss/components/form/index.scss similarity index 100% rename from src/newsreader/static/src/scss/components/form/index.scss rename to src/newsreader/scss/components/form/index.scss diff --git a/src/newsreader/static/src/scss/components/index.scss b/src/newsreader/scss/components/index.scss similarity index 70% rename from src/newsreader/static/src/scss/components/index.scss rename to src/newsreader/scss/components/index.scss index 14ddc8e..a6c0511 100644 --- a/src/newsreader/static/src/scss/components/index.scss +++ b/src/newsreader/scss/components/index.scss @@ -4,3 +4,5 @@ @import "./main/index"; @import "./navbar/index"; @import "./error/index"; +@import "./loading-indicator/index"; +@import "./modal/index"; diff --git a/src/newsreader/static/src/scss/components/input/input.scss b/src/newsreader/scss/components/input/input.scss similarity index 100% rename from src/newsreader/static/src/scss/components/input/input.scss rename to src/newsreader/scss/components/input/input.scss diff --git a/src/newsreader/scss/components/loading-indicator/_loading-indicator.scss b/src/newsreader/scss/components/loading-indicator/_loading-indicator.scss new file mode 100644 index 0000000..0651d1d --- /dev/null +++ b/src/newsreader/scss/components/loading-indicator/_loading-indicator.scss @@ -0,0 +1,41 @@ +.loading-indicator { + display: inline-block; + position: relative; + width: 64px; + height: 64px; + + & div { + display: inline-block; + position: absolute; + left: 6px; + width: 13px; + background-color: $lavendal-pink; + animation: loading-indicator 1.2s cubic-bezier(0, 0.5, 0.5, 1) infinite; + + &:nth-child(1){ + left: 6px; + animation-delay: -0.24s; + } + + &:nth-child(2){ + left: 26px; + animation-delay: -0.12s; + } + + &:nth-child(3){ + left: 45px; + animation-delay: 0; + } + } +} + +@keyframes loading-indicator { + 0% { + top: 6px; + height: 51px; + } + 50%, 100% { + top: 19px; + height: 26px; + } +} diff --git a/src/newsreader/scss/components/loading-indicator/index.scss b/src/newsreader/scss/components/loading-indicator/index.scss new file mode 100644 index 0000000..c3a3bc3 --- /dev/null +++ b/src/newsreader/scss/components/loading-indicator/index.scss @@ -0,0 +1 @@ +@import "loading-indicator"; diff --git a/src/newsreader/static/src/scss/components/main/_main.scss b/src/newsreader/scss/components/main/_main.scss similarity index 100% rename from src/newsreader/static/src/scss/components/main/_main.scss rename to src/newsreader/scss/components/main/_main.scss diff --git a/src/newsreader/static/src/scss/components/main/index.scss b/src/newsreader/scss/components/main/index.scss similarity index 100% rename from src/newsreader/static/src/scss/components/main/index.scss rename to src/newsreader/scss/components/main/index.scss diff --git a/src/newsreader/scss/components/modal/_modal.scss b/src/newsreader/scss/components/modal/_modal.scss new file mode 100644 index 0000000..c4c951f --- /dev/null +++ b/src/newsreader/scss/components/modal/_modal.scss @@ -0,0 +1,9 @@ +.modal { + position: fixed; + + width: 100%; + height: 100%; + top: 0; + + background-color: $dark; +} diff --git a/src/newsreader/scss/components/modal/index.scss b/src/newsreader/scss/components/modal/index.scss new file mode 100644 index 0000000..bcb7d8e --- /dev/null +++ b/src/newsreader/scss/components/modal/index.scss @@ -0,0 +1 @@ +@import "modal"; diff --git a/src/newsreader/static/src/scss/components/navbar/_navbar.scss b/src/newsreader/scss/components/navbar/_navbar.scss similarity index 96% rename from src/newsreader/static/src/scss/components/navbar/_navbar.scss rename to src/newsreader/scss/components/navbar/_navbar.scss index d0ea3b9..b387be0 100644 --- a/src/newsreader/static/src/scss/components/navbar/_navbar.scss +++ b/src/newsreader/scss/components/navbar/_navbar.scss @@ -2,6 +2,7 @@ display: flex; justify-content: center; + padding: 15px 0; width: 100%; background-color: $white; diff --git a/src/newsreader/static/src/scss/components/navbar/index.scss b/src/newsreader/scss/components/navbar/index.scss similarity index 100% rename from src/newsreader/static/src/scss/components/navbar/index.scss rename to src/newsreader/scss/components/navbar/index.scss diff --git a/src/newsreader/scss/homepage/components/categories/_categories.scss b/src/newsreader/scss/homepage/components/categories/_categories.scss new file mode 100644 index 0000000..002a66a --- /dev/null +++ b/src/newsreader/scss/homepage/components/categories/_categories.scss @@ -0,0 +1,24 @@ +.categories { + display: flex; + flex-direction: column; + align-items: center; + + width: 90%; + + font-family: $sidebar-font; + border-radius: 2px; + + & ul { + margin: 0; + padding: 0; + + width: 100%; + + list-style: none; + border-radius: 5px; + } + + &__item { + padding: 2px 10px 5px 10px; + } +} diff --git a/src/newsreader/scss/homepage/components/categories/index.scss b/src/newsreader/scss/homepage/components/categories/index.scss new file mode 100644 index 0000000..0eebf91 --- /dev/null +++ b/src/newsreader/scss/homepage/components/categories/index.scss @@ -0,0 +1 @@ +@import "categories"; diff --git a/src/newsreader/scss/homepage/components/category/_category.scss b/src/newsreader/scss/homepage/components/category/_category.scss new file mode 100644 index 0000000..b272bb1 --- /dev/null +++ b/src/newsreader/scss/homepage/components/category/_category.scss @@ -0,0 +1,46 @@ +.category { + display: flex; + align-items: center; + + padding: 5px; + + border-radius: 5px; + + &__info { + display: flex; + justify-content: space-between; + + width: 100%; + padding: 0 0 0 20px; + + overflow: hidden; + white-space: nowrap; + + & h4 { + overflow: hidden; + text-overflow: ellipsis; + } + + &:hover { + cursor: pointer; + } + } + + &__menu { + display: flex; + align-items: center; + + &:hover { + cursor: pointer; + } + } + + &:hover { + background-color: darken($gainsboro, 10%); + } + + &--selected { + color: $white; + background-color: darken($gainsboro, 10%); + } +} diff --git a/src/newsreader/scss/homepage/components/category/index.scss b/src/newsreader/scss/homepage/components/category/index.scss new file mode 100644 index 0000000..702bb58 --- /dev/null +++ b/src/newsreader/scss/homepage/components/category/index.scss @@ -0,0 +1 @@ +@import "category"; diff --git a/src/newsreader/scss/homepage/components/content/_content.scss b/src/newsreader/scss/homepage/components/content/_content.scss new file mode 100644 index 0000000..9b9efb9 --- /dev/null +++ b/src/newsreader/scss/homepage/components/content/_content.scss @@ -0,0 +1,7 @@ +.content { + display: flex; + flex-direction: column; + align-items: center; + + margin: 2% 0 0 0; +} diff --git a/src/newsreader/scss/homepage/components/content/index.scss b/src/newsreader/scss/homepage/components/content/index.scss new file mode 100644 index 0000000..b424282 --- /dev/null +++ b/src/newsreader/scss/homepage/components/content/index.scss @@ -0,0 +1 @@ +@import "content"; diff --git a/src/newsreader/scss/homepage/components/index.scss b/src/newsreader/scss/homepage/components/index.scss new file mode 100644 index 0000000..b3891f8 --- /dev/null +++ b/src/newsreader/scss/homepage/components/index.scss @@ -0,0 +1,17 @@ +@import "content/index"; +@import "main/index"; + +@import "sidebar/index"; +@import "categories/index"; +@import "category/index"; + +@import "rules/index"; +@import "rule/index"; + +@import "post-block/index"; +@import "posts-section/index"; +@import "posts/index"; +@import "posts-header/index"; +@import "post/index"; +@import "post-message/index"; +@import "read-button/index"; diff --git a/src/newsreader/scss/homepage/components/main/_main.scss b/src/newsreader/scss/homepage/components/main/_main.scss new file mode 100644 index 0000000..42cb2d5 --- /dev/null +++ b/src/newsreader/scss/homepage/components/main/_main.scss @@ -0,0 +1,12 @@ +.main { + display: flex; + flex-direction: row; + width: 100%; + + margin: 0; + background-color: initial; + + &--centered { + justify-content: center; + } +} diff --git a/src/newsreader/scss/homepage/components/main/index.scss b/src/newsreader/scss/homepage/components/main/index.scss new file mode 100644 index 0000000..bdb4ce0 --- /dev/null +++ b/src/newsreader/scss/homepage/components/main/index.scss @@ -0,0 +1 @@ +@import "main"; diff --git a/src/newsreader/scss/homepage/components/post-block/_post-block.scss b/src/newsreader/scss/homepage/components/post-block/_post-block.scss new file mode 100644 index 0000000..e0d2dcd --- /dev/null +++ b/src/newsreader/scss/homepage/components/post-block/_post-block.scss @@ -0,0 +1,12 @@ +.post-block { + display: flex; + flex-direction: column; + + width: 60%; + margin: 0 0 2% 0; + + font-family: $article-font; + + border-radius: 2px; + background-color: $white; +} diff --git a/src/newsreader/scss/homepage/components/post-block/index.scss b/src/newsreader/scss/homepage/components/post-block/index.scss new file mode 100644 index 0000000..e17b7a9 --- /dev/null +++ b/src/newsreader/scss/homepage/components/post-block/index.scss @@ -0,0 +1 @@ +@import "post-block"; diff --git a/src/newsreader/scss/homepage/components/post-message/_post-message.scss b/src/newsreader/scss/homepage/components/post-message/_post-message.scss new file mode 100644 index 0000000..21c5603 --- /dev/null +++ b/src/newsreader/scss/homepage/components/post-message/_post-message.scss @@ -0,0 +1,25 @@ +.post-message { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + + width: 60%; + height: max-content; + border-radius: 2px; + + font-family: $article-font; + background-color: $white; + + &__message { + font-size: 16px; + } + + &__block { + display: flex; + flex-direction: row; + align-items: center; + + margin: 5px; + } +} diff --git a/src/newsreader/scss/homepage/components/post-message/index.scss b/src/newsreader/scss/homepage/components/post-message/index.scss new file mode 100644 index 0000000..03cf130 --- /dev/null +++ b/src/newsreader/scss/homepage/components/post-message/index.scss @@ -0,0 +1 @@ +@import "post-message"; diff --git a/src/newsreader/scss/homepage/components/post/_post.scss b/src/newsreader/scss/homepage/components/post/_post.scss new file mode 100644 index 0000000..2f8d581 --- /dev/null +++ b/src/newsreader/scss/homepage/components/post/_post.scss @@ -0,0 +1,125 @@ +.post { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + + width: 80%; + max-height: 90%; + + margin: 2% auto 5% auto; + + border-radius: 2px; + + overflow-y: auto; + + background-color: $white; + + &__header { + display: flex; + flex-direction: column; + padding: 20px 0 20px 0; + width: 75%; + + font-family: $header-font; + } + + &__title { + line-height: 1; + + &--read { + color: $gainsboro; + } + } + + &__link { + height: 100%; + + & img { + height: 50%; + } + } + + &__date { + margin: 10px 0 0 0; + font-size: small; + } + + &__body { + display:flex; + flex-direction: column; + + padding: 10px 0 30px 0; + width: 75%; + + line-height: 1.5; + font-family: $article-font; + + & p { + padding: 20px 0 0 0; + } + + & img { + padding: 10px 10px 30px 10px; + + max-width: 70%; + width: inherit; + height: 100%; + + align-self: center; + } + } + + &__close-button { + margin: 1% 2% 0 0; + align-self: flex-end; + + & span { + display: inline-flex; + align-items: center; + margin: 0 0 0 5px; + + & img { + width: 10px; + } + } + + &:hover { + background-color: lighten($gainsboro, +1%); + } + } + + &__meta-info { + display: flex; + flex-direction: column; + + align-self: flex-end; + position: absolute; + top: 25%; + width: 10%; + + font-family: $button-font; + color: $nickel; + + & h5 { + margin: 10px 0 0 0; + padding: 5px 1px 5px 5px; + + border-radius: 5px 0 0 5px; + + background-color: aquamarine; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; + } + + & h5:first-child { + background-color: $light-orange; + } + + & h5:last-child { + background-color: $light-green; + } + } +} diff --git a/src/newsreader/scss/homepage/components/post/index.scss b/src/newsreader/scss/homepage/components/post/index.scss new file mode 100644 index 0000000..b31e7bb --- /dev/null +++ b/src/newsreader/scss/homepage/components/post/index.scss @@ -0,0 +1 @@ +@import "post"; diff --git a/src/newsreader/scss/homepage/components/posts-header/_posts-header.scss b/src/newsreader/scss/homepage/components/posts-header/_posts-header.scss new file mode 100644 index 0000000..068c2b3 --- /dev/null +++ b/src/newsreader/scss/homepage/components/posts-header/_posts-header.scss @@ -0,0 +1,21 @@ +.posts-header { + display: flex; + padding: 0 5px 0 0; + + width: 80%; + + &__link { + padding: 0 0 0 5px; + } + + &__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: small; + + &--read { + color: $gainsboro; + } + } +} diff --git a/src/newsreader/scss/homepage/components/posts-header/index.scss b/src/newsreader/scss/homepage/components/posts-header/index.scss new file mode 100644 index 0000000..451a453 --- /dev/null +++ b/src/newsreader/scss/homepage/components/posts-header/index.scss @@ -0,0 +1 @@ +@import "posts-header"; diff --git a/src/newsreader/scss/homepage/components/posts-section/index.scss b/src/newsreader/scss/homepage/components/posts-section/index.scss new file mode 100644 index 0000000..dc0e29b --- /dev/null +++ b/src/newsreader/scss/homepage/components/posts-section/index.scss @@ -0,0 +1,12 @@ +.posts-section { + display: flex; + flex-direction: column; + + padding: 10px; + + &__name { + padding: 20px 0 10px 0; + + border-top: 4px solid $azureish-white; + } +} diff --git a/src/newsreader/scss/homepage/components/posts/_posts.scss b/src/newsreader/scss/homepage/components/posts/_posts.scss new file mode 100644 index 0000000..0734e77 --- /dev/null +++ b/src/newsreader/scss/homepage/components/posts/_posts.scss @@ -0,0 +1,34 @@ +.posts { + display: flex; + flex-direction: column; + + list-style: none; + + &__item { + display: flex; + justify-content: space-between; + + padding: 10px 0 0px 0; + + border-radius: 2px; + border-bottom: 2px solid $azureish-white; + + &:hover { + cursor: pointer; + background-color: lighten($gainsboro, 10%); + } + + & span { + width: 20%; + + font-size: small; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + &:last-child { + border-bottom: 0; + } + } +} diff --git a/src/newsreader/scss/homepage/components/posts/index.scss b/src/newsreader/scss/homepage/components/posts/index.scss new file mode 100644 index 0000000..66f1811 --- /dev/null +++ b/src/newsreader/scss/homepage/components/posts/index.scss @@ -0,0 +1 @@ +@import "posts"; diff --git a/src/newsreader/scss/homepage/components/read-button/_read-button.scss b/src/newsreader/scss/homepage/components/read-button/_read-button.scss new file mode 100644 index 0000000..a8eab4c --- /dev/null +++ b/src/newsreader/scss/homepage/components/read-button/_read-button.scss @@ -0,0 +1,10 @@ +.read-button { + margin: 20px 0 0 0; + + color: $white; + background-color: $confirm-green; + + &:hover { + background-color: darken($confirm-green, 10%); + } +} diff --git a/src/newsreader/scss/homepage/components/read-button/index.scss b/src/newsreader/scss/homepage/components/read-button/index.scss new file mode 100644 index 0000000..8e49454 --- /dev/null +++ b/src/newsreader/scss/homepage/components/read-button/index.scss @@ -0,0 +1 @@ +@import 'read-button'; diff --git a/src/newsreader/scss/homepage/components/rule/_rule.scss b/src/newsreader/scss/homepage/components/rule/_rule.scss new file mode 100644 index 0000000..dba7124 --- /dev/null +++ b/src/newsreader/scss/homepage/components/rule/_rule.scss @@ -0,0 +1,10 @@ +.rule { + display: flex; + width: 80%; + + &__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/src/newsreader/scss/homepage/components/rule/index.scss b/src/newsreader/scss/homepage/components/rule/index.scss new file mode 100644 index 0000000..7ec839a --- /dev/null +++ b/src/newsreader/scss/homepage/components/rule/index.scss @@ -0,0 +1 @@ +@import "rule"; diff --git a/src/newsreader/scss/homepage/components/rules/_rules.scss b/src/newsreader/scss/homepage/components/rules/_rules.scss new file mode 100644 index 0000000..c8b261e --- /dev/null +++ b/src/newsreader/scss/homepage/components/rules/_rules.scss @@ -0,0 +1,29 @@ +.rules { + &__item { + display: flex; + justify-content: space-between; + align-items: center; + + padding: 5px 5px 5px 20px; + + border-radius: 5px; + + & * { + padding: 0 2px 0 2px; + } + + & div { + padding: 0; + } + + &:hover { + cursor: pointer; + background-color: darken($gainsboro, 10%); + } + + &--selected { + color: $white; + background-color: darken($gainsboro, 10%); + } + } +} diff --git a/src/newsreader/scss/homepage/components/rules/index.scss b/src/newsreader/scss/homepage/components/rules/index.scss new file mode 100644 index 0000000..e6a0ebf --- /dev/null +++ b/src/newsreader/scss/homepage/components/rules/index.scss @@ -0,0 +1 @@ +@import "rules"; diff --git a/src/newsreader/scss/homepage/components/sidebar/_sidebar.scss b/src/newsreader/scss/homepage/components/sidebar/_sidebar.scss new file mode 100644 index 0000000..5c6575b --- /dev/null +++ b/src/newsreader/scss/homepage/components/sidebar/_sidebar.scss @@ -0,0 +1,11 @@ +.sidebar { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + align-self: flex-start; + + position: sticky; + top: 5%; + width: 20%; +} diff --git a/src/newsreader/scss/homepage/components/sidebar/index.scss b/src/newsreader/scss/homepage/components/sidebar/index.scss new file mode 100644 index 0000000..0abffa8 --- /dev/null +++ b/src/newsreader/scss/homepage/components/sidebar/index.scss @@ -0,0 +1 @@ +@import "sidebar"; diff --git a/src/newsreader/scss/homepage/elements/badge/_badge.scss b/src/newsreader/scss/homepage/elements/badge/_badge.scss new file mode 100644 index 0000000..6abab65 --- /dev/null +++ b/src/newsreader/scss/homepage/elements/badge/_badge.scss @@ -0,0 +1,15 @@ +.badge { + display: inline-block; + + padding-left: 8px; + padding-right: 8px; + border-radius: 20%; + + text-align: center; + + background-color: darken($pink, 10%); + color: $white; + + font-family: $button-font; + font-size: small; +} diff --git a/src/newsreader/scss/homepage/elements/badge/index.scss b/src/newsreader/scss/homepage/elements/badge/index.scss new file mode 100644 index 0000000..87110f0 --- /dev/null +++ b/src/newsreader/scss/homepage/elements/badge/index.scss @@ -0,0 +1 @@ +@import "badge"; diff --git a/src/newsreader/scss/homepage/elements/index.scss b/src/newsreader/scss/homepage/elements/index.scss new file mode 100644 index 0000000..66bce62 --- /dev/null +++ b/src/newsreader/scss/homepage/elements/index.scss @@ -0,0 +1 @@ +@import "badge/index"; diff --git a/src/newsreader/scss/homepage/index.scss b/src/newsreader/scss/homepage/index.scss new file mode 100644 index 0000000..aad2761 --- /dev/null +++ b/src/newsreader/scss/homepage/index.scss @@ -0,0 +1,7 @@ +// General imports +@import "../partials/variables"; +@import "../components/index"; + +// Page specific +@import "./components/index"; +@import "./elements/index"; diff --git a/src/newsreader/scss/partials/_variables.scss b/src/newsreader/scss/partials/_variables.scss new file mode 100644 index 0000000..f3a2c77 --- /dev/null +++ b/src/newsreader/scss/partials/_variables.scss @@ -0,0 +1,42 @@ +@import url("https://fonts.googleapis.com/css?family=IBM+Plex+Sans:600&display=swap"); +@import url("https://fonts.googleapis.com/css?family=Barlow&display=swap"); +@import url("https://fonts.googleapis.com/css?family=Open+Sans&display=swap"); +@import url("https://fonts.googleapis.com/css?family=Oswald&display=swap"); +@import url("https://fonts.googleapis.com/css?family=Nunito&display=swap"); + + +$button-font: "IBM Plex Sans", sans-serif; +$form-font: "Barlow", sans-serif; +$article-font: "Open Sans", sans-serif; +$header-font: "Oswald", sans-serif; +$sidebar-font: "Nunito", sans-serif; + +/* colors */ +$white: rgba(255, 255, 255, 1); + +$confirm-green: rgba(46,204,113, 1); +$error-red: rgba(231,76,60, 1); + +// light blue +$azureish-white: rgba(205, 230, 245, 1); + +// dark blue +$pewter-blue: rgba(141, 167, 190, 1); + +// light gray +$gainsboro: rgba(224, 221, 220, 1); + +// medium gray +$roman-silver: rgba(135, 145, 158, 1); + +//dark gray +$nickel: rgba(112, 112, 120, 1); + +$pink: rgba(235, 229, 229, 1); +$lavendal-pink: rgba(162, 155, 254, 1); + +$light-green: rgba(230, 247, 185, 1); +$light-orange: rgba(237, 212, 178, 1); +$light-red: rgba(255, 118, 117, 1); + +$dark: rgba(0, 0, 0, 0.4); diff --git a/src/newsreader/static/icons/angle-down.svg b/src/newsreader/static/icons/angle-down.svg new file mode 100644 index 0000000..1462342 --- /dev/null +++ b/src/newsreader/static/icons/angle-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/newsreader/static/icons/angle-right.svg b/src/newsreader/static/icons/angle-right.svg new file mode 100644 index 0000000..ec7fbe9 --- /dev/null +++ b/src/newsreader/static/icons/angle-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/newsreader/static/icons/arrow-left.svg b/src/newsreader/static/icons/arrow-left.svg new file mode 100644 index 0000000..88526e8 --- /dev/null +++ b/src/newsreader/static/icons/arrow-left.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/newsreader/static/icons/chevron-down.svg b/src/newsreader/static/icons/chevron-down.svg new file mode 100644 index 0000000..211fd17 --- /dev/null +++ b/src/newsreader/static/icons/chevron-down.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/newsreader/static/icons/chevron-right.svg b/src/newsreader/static/icons/chevron-right.svg new file mode 100644 index 0000000..ea2b601 --- /dev/null +++ b/src/newsreader/static/icons/chevron-right.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/newsreader/static/icons/link.svg b/src/newsreader/static/icons/link.svg new file mode 100644 index 0000000..57caa9f --- /dev/null +++ b/src/newsreader/static/icons/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/newsreader/static/icons/times.svg b/src/newsreader/static/icons/times.svg new file mode 100644 index 0000000..571a32a --- /dev/null +++ b/src/newsreader/static/icons/times.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/newsreader/static/src/scss/partials/_variables.scss b/src/newsreader/static/src/scss/partials/_variables.scss deleted file mode 100644 index d983d2a..0000000 --- a/src/newsreader/static/src/scss/partials/_variables.scss +++ /dev/null @@ -1,26 +0,0 @@ -@import url("https://fonts.googleapis.com/css?family=IBM+Plex+Sans:600&display=swap"); -@import url('https://fonts.googleapis.com/css?family=Barlow&display=swap'); - -$button-font: "IBM Plex Sans", sans-serif; -$form-font: "Barlow", sans-serif; - -/* colors */ -$white: rgba(255, 255, 255, 1); - -$confirm-green: rgba(46,204,113, 1); -$error-red: rgba(231,76,60, 1); - -// light blue -$azureish-white: rgba(205, 230, 245, 1); - -// dark blue -$pewter-blue: rgba(141, 167, 190, 1); - -// light gray -$gainsboro: rgba(224, 221, 220, 1); - -// medium gray -$roman-silver: rgba(135, 145, 158, 1); - -//dark gray -$nickel: rgba(112, 112, 120, 1); diff --git a/src/newsreader/templates/base.html b/src/newsreader/templates/base.html index 460ab8f..8163fa6 100644 --- a/src/newsreader/templates/base.html +++ b/src/newsreader/templates/base.html @@ -9,7 +9,9 @@