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 */}
+
+
+ );
+ }
+}
+
+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 && (
+
+ )}
+
+ );
+ }
+}
+
+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 @@