0.2 release
This commit is contained in:
parent
747c6416d4
commit
18479a3f56
340 changed files with 27295 additions and 0 deletions
11
.babelrc
Normal file
11
.babelrc
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"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}],
|
||||
]
|
||||
}
|
||||
16
.coveragerc
Normal file
16
.coveragerc
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[run]
|
||||
source = ./src/newsreader/
|
||||
omit =
|
||||
**/tests/**
|
||||
**/migrations/**
|
||||
**/conf/**
|
||||
**/apps.py
|
||||
**/admin.py
|
||||
**/tests.py
|
||||
**/urls.py
|
||||
**/wsgi.py
|
||||
**/celery.py
|
||||
**/__init__.py
|
||||
|
||||
[html]
|
||||
directory = coverage
|
||||
207
.gitignore
vendored
Normal file
207
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
|
||||
# Created by https://www.gitignore.io/api/django,python
|
||||
# Edit at https://www.gitignore.io/?templates=django,python
|
||||
|
||||
### Django ###
|
||||
*.log
|
||||
*.pot
|
||||
*.pyc
|
||||
__pycache__/
|
||||
local_settings.py
|
||||
local.py
|
||||
db.sqlite3
|
||||
media
|
||||
|
||||
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
||||
# in your Git repository. Update and uncomment the following line accordingly.
|
||||
# <django-project-name>/staticfiles/
|
||||
|
||||
### Django.Python Stack ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
|
||||
lib/
|
||||
!src/newsreader/scss/lib
|
||||
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
coverage/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
|
||||
# Django stuff:
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don’t work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
|
||||
# C extensions
|
||||
|
||||
# Distribution / packaging
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
|
||||
# Installer logs
|
||||
|
||||
# Unit test / coverage reports
|
||||
|
||||
# Translations
|
||||
|
||||
# Django stuff:
|
||||
src/newsreader/fixtures/local
|
||||
|
||||
# Flask stuff:
|
||||
|
||||
# Scrapy stuff:
|
||||
|
||||
# Sphinx documentation
|
||||
|
||||
# PyBuilder
|
||||
|
||||
# Jupyter Notebook
|
||||
|
||||
# IPython
|
||||
|
||||
# pyenv
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don’t work, or not
|
||||
# install all needed dependencies.
|
||||
|
||||
# celery beat schedule file
|
||||
|
||||
# SageMath parsed files
|
||||
|
||||
# Environments
|
||||
|
||||
# Spyder project settings
|
||||
|
||||
# Rope project settings
|
||||
|
||||
# mkdocs documentation
|
||||
|
||||
# mypy
|
||||
|
||||
# Pyre type checker
|
||||
|
||||
# End of https://www.gitignore.io/api/django,python
|
||||
|
||||
# Javascript
|
||||
node_modules/
|
||||
|
||||
static/
|
||||
|
||||
# Css
|
||||
*.css
|
||||
28
.gitlab-ci.yml
Normal file
28
.gitlab-ci.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
stages:
|
||||
- build
|
||||
- test
|
||||
- lint
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
||||
DJANGO_SETTINGS_MODULE: "newsreader.conf.gitlab"
|
||||
POSTGRES_HOST: "$POSTGRES_HOST"
|
||||
POSTGRES_DB: "$POSTGRES_NAME"
|
||||
POSTGRES_NAME: "$POSTGRES_NAME"
|
||||
POSTGRES_USER: "$POSTGRES_USER"
|
||||
POSTGRES_PASSWORD: "$POSTGRES_PASSWORD"
|
||||
|
||||
cache:
|
||||
key: "$CI_COMMIT_REF_SLUG"
|
||||
paths:
|
||||
- .venv/
|
||||
- .cache/pip
|
||||
- .cache/poetry
|
||||
- node_modules/
|
||||
|
||||
include:
|
||||
- local: '/gitlab-ci/build.yml'
|
||||
- local: '/gitlab-ci/test.yml'
|
||||
- local: '/gitlab-ci/lint.yml'
|
||||
- local: '/gitlab-ci/deploy.yml'
|
||||
12
.isort.cfg
Normal file
12
.isort.cfg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[settings]
|
||||
include_trailing_comma = true
|
||||
line_length = 88
|
||||
multi_line_output = 3
|
||||
skip = env/, venv/
|
||||
default_section = THIRDPARTY
|
||||
known_first_party = newsreader
|
||||
known_django = django
|
||||
sections = FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
|
||||
lines_between_types=1
|
||||
lines_after_imports=2
|
||||
lines_between_types=1
|
||||
10
.prettierrc.json
Normal file
10
.prettierrc.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 90,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM python:3.7-buster
|
||||
|
||||
RUN pip install poetry
|
||||
|
||||
WORKDIR /app
|
||||
COPY poetry.lock pyproject.toml /app/
|
||||
|
||||
RUN poetry config virtualenvs.create false
|
||||
RUN poetry install --no-interaction
|
||||
|
||||
COPY . /app/
|
||||
52
docker-compose.yml
Normal file
52
docker-compose.yml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
version: '3'
|
||||
|
||||
services:
|
||||
db:
|
||||
# See https://hub.docker.com/_/postgres
|
||||
image: postgres
|
||||
container_name: postgres
|
||||
environment:
|
||||
- POSTGRES_DB=$POSTGRES_NAME
|
||||
- POSTGRES_USER=$POSTGRES_USER
|
||||
- POSTGRES_PASSWORD=$POSTGRES_PASSWORD
|
||||
rabbitmq:
|
||||
image: rabbitmq:3.7
|
||||
container_name: rabbitmq
|
||||
celery:
|
||||
build: .
|
||||
container_name: celery
|
||||
command: poetry run celery -A newsreader worker -l INFO --beat --scheduler django --workdir=/app/src/
|
||||
environment:
|
||||
- POSTGRES_HOST=$POSTGRES_HOST
|
||||
- POSTGRES_NAME=$POSTGRES_NAME
|
||||
- POSTGRES_USER=$POSTGRES_USER
|
||||
- POSTGRES_PASSWORD=$POSTGRES_PASSWORD
|
||||
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
|
||||
volumes:
|
||||
- .:/app
|
||||
depends_on:
|
||||
- rabbitmq
|
||||
memcached:
|
||||
image: memcached:1.5.22
|
||||
container_name: memcached
|
||||
ports:
|
||||
- "11211:11211"
|
||||
entrypoint:
|
||||
- memcached
|
||||
- -m 64
|
||||
web:
|
||||
build: .
|
||||
container_name: web
|
||||
command: src/entrypoint.sh
|
||||
environment:
|
||||
- POSTGRES_HOST=$POSTGRES_HOST
|
||||
- POSTGRES_NAME=$POSTGRES_NAME
|
||||
- POSTGRES_USER=$POSTGRES_USER
|
||||
- POSTGRES_PASSWORD=$POSTGRES_PASSWORD
|
||||
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
|
||||
volumes:
|
||||
- .:/app
|
||||
ports:
|
||||
- '8000:8000'
|
||||
depends_on:
|
||||
- db
|
||||
7
gitlab-ci/build.yml
Normal file
7
gitlab-ci/build.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
static:
|
||||
stage: build
|
||||
image: node:12
|
||||
before_script:
|
||||
- npm install
|
||||
script:
|
||||
- npm run build
|
||||
16
gitlab-ci/deploy.yml
Normal file
16
gitlab-ci/deploy.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
deploy:
|
||||
stage: deploy
|
||||
image: debian:buster
|
||||
environment:
|
||||
name: production
|
||||
url: rss.fudiggity.nl
|
||||
before_script:
|
||||
- apt-get update && apt-get install -y ansible git
|
||||
- git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@git.fudiggity.nl/sonny/ansible-playbooks.git deployment
|
||||
- mkdir /root/.ssh
|
||||
- echo "192.168.178.63 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILbtcdgJBhVCKsO88cV19EYefDTopdYejEQCp1pYr1Ga" > /root/.ssh/known_hosts
|
||||
- echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key
|
||||
script:
|
||||
- ansible-playbook deployment/playbook.yml --inventory deployment/apps.yml --limit newsreader --user ansible --private-key deployment/deploy_key
|
||||
only:
|
||||
- master
|
||||
22
gitlab-ci/lint.yml
Normal file
22
gitlab-ci/lint.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
python-linting:
|
||||
stage: lint
|
||||
allow_failure: true
|
||||
image: python:3.7.4-slim-stretch
|
||||
before_script:
|
||||
- pip install poetry
|
||||
- poetry config cache-dir ~/.cache/poetry
|
||||
- poetry config virtualenvs.in-project true
|
||||
- poetry install --no-interaction
|
||||
script:
|
||||
- poetry run isort src/ --check-only --recursive
|
||||
- poetry run black src/ --line-length 88 --check
|
||||
- poetry run autoflake src/ --check --recursive --remove-all-unused-imports --ignore-init-module-imports
|
||||
|
||||
javascript-linting:
|
||||
stage: lint
|
||||
allow_failure: true
|
||||
image: node:12
|
||||
before_script:
|
||||
- npm install
|
||||
script:
|
||||
- npm run lint
|
||||
23
gitlab-ci/test.yml
Normal file
23
gitlab-ci/test.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
python-tests:
|
||||
stage: test
|
||||
coverage: '/TOTAL\s+\d+\s+\d+\s+(\d+%)/'
|
||||
services:
|
||||
- postgres:11
|
||||
- memcached:1.5.22
|
||||
image: python:3.7.4-slim-stretch
|
||||
before_script:
|
||||
- pip install poetry
|
||||
- poetry config cache-dir .cache/poetry
|
||||
- poetry config virtualenvs.in-project true
|
||||
- poetry install --no-interaction
|
||||
script:
|
||||
- poetry run coverage run src/manage.py test newsreader
|
||||
- poetry run coverage report
|
||||
|
||||
javascript-tests:
|
||||
stage: test
|
||||
image: node:12
|
||||
before_script:
|
||||
- npm install
|
||||
script:
|
||||
- npm test
|
||||
188
jest.config.js
Normal file
188
jest.config.js
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
// For a detailed explanation regarding each configuration property, visit:
|
||||
// https://jestjs.io/docs/en/configuration.html
|
||||
|
||||
module.exports = {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// Respect "browser" field in package.json when resolving modules
|
||||
// browser: false,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "/tmp/jest_rs",
|
||||
|
||||
// Automatically clear mock calls and instances between every test
|
||||
clearMocks: true,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
// collectCoverage: false,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
// collectCoverageFrom: null,
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
coverageDirectory: 'coverage',
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// coveragePathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: null,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: null,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: null,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: null,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
// globals: {},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "json",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "node"
|
||||
// ],
|
||||
|
||||
// A map from regular expressions to module names that allow to stub out resources with a single module
|
||||
// moduleNameMapper: {},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
// preset: null,
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: null,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state between every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// resolver: null,
|
||||
|
||||
// Automatically restore mock state between every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
rootDir: 'src/newsreader/js/tests/',
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
// roots: [
|
||||
// "<rootDir>"
|
||||
// ],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: 'node',
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
// testMatch: [
|
||||
// "**/__tests__/**/*.[jt]s?(x)",
|
||||
// "**/?(*.)+(spec|test).[tj]s?(x)"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
// testPathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: null,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jasmine2",
|
||||
|
||||
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
|
||||
// testURL: "http://localhost",
|
||||
|
||||
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
|
||||
// timers: "real",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
// transform: null,
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// transformIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: null,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
};
|
||||
9443
package-lock.json
generated
Normal file
9443
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
61
package.json
Normal file
61
package.json
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"name": "newsreader",
|
||||
"version": "0.1.0",
|
||||
"description": "Application for viewing RSS feeds",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"lint": "npx prettier \"src/newsreader/js/**/*.js\" --check",
|
||||
"format": "npx prettier \"src/newsreader/js/**/*.js\" --write",
|
||||
"build": "npx webpack --config webpack.dev.babel.js",
|
||||
"build:watch": "npx webpack --config webpack.dev.babel.js --watch",
|
||||
"build:prod": "npx webpack --config webpack.prod.babel.js",
|
||||
"test": "npx jest",
|
||||
"test:watch": "npm test -- --watch"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "[git@git.fudiggity.nl:5000]:sonny/newsreader.git"
|
||||
},
|
||||
"author": "Sonny",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"css.gg": "^1.0.6",
|
||||
"js-cookie": "^2.2.1",
|
||||
"lodash": "^4.17.15",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-redux": "^7.1.3",
|
||||
"redux": "^4.0.5",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.7.7",
|
||||
"@babel/plugin-proposal-class-properties": "^7.7.4",
|
||||
"@babel/plugin-proposal-function-bind": "^7.7.4",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.7.4",
|
||||
"@babel/plugin-syntax-function-bind": "^7.7.4",
|
||||
"@babel/plugin-transform-react-jsx": "^7.7.7",
|
||||
"@babel/plugin-transform-runtime": "^7.7.6",
|
||||
"@babel/preset-env": "^7.7.7",
|
||||
"@babel/register": "^7.7.7",
|
||||
"@babel/runtime": "^7.7.7",
|
||||
"babel-jest": "^24.9.0",
|
||||
"babel-loader": "^8.1.0",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"css-loader": "^3.4.2",
|
||||
"fetch-mock": "^8.3.1",
|
||||
"jest": "^24.9.0",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"node-fetch": "^2.6.0",
|
||||
"node-sass": "^4.13.1",
|
||||
"prettier": "^1.19.1",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
"sass-loader": "^8.0.2",
|
||||
"style-loader": "^1.1.3",
|
||||
"webpack": "^4.42.1",
|
||||
"webpack-cli": "^3.3.11",
|
||||
"webpack-merge": "^4.2.2"
|
||||
}
|
||||
}
|
||||
1159
poetry.lock
generated
Normal file
1159
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
40
pyproject.toml
Normal file
40
pyproject.toml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
[tool.poetry]
|
||||
name = "newsreader"
|
||||
version = "0.2"
|
||||
description = "Webapplication for reading RSS feeds"
|
||||
authors = ["Sonny <sonnyba871@gmail.com>"]
|
||||
license = "GPL-3.0"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.7"
|
||||
bleach = "^3.1.4"
|
||||
Django = "^3.0.5"
|
||||
celery = "^4.4.2"
|
||||
beautifulsoup4 = "^4.9.0"
|
||||
django-axes = "^5.3.1"
|
||||
django-celery-beat = "^2.0.0"
|
||||
djangorestframework = "^3.11.0"
|
||||
drf-yasg = "^1.17.1"
|
||||
django-registration-redux = "^2.7"
|
||||
lxml = "^4.5.0"
|
||||
feedparser = "^5.2.1"
|
||||
python-memcached = "^1.59"
|
||||
requests = "^2.23.0"
|
||||
psycopg2-binary = "^2.8.5"
|
||||
gunicorn = "^20.0.4"
|
||||
python-dotenv = "^0.12.0"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
factory-boy = "^2.12.0"
|
||||
freezegun = "^0.3.15"
|
||||
django-debug-toolbar = "^2.2"
|
||||
django-extensions = "^2.2.9"
|
||||
black = "19.3b0"
|
||||
isort = "4.3.21"
|
||||
autoflake = "1.3.1"
|
||||
tblib = "1.6.0"
|
||||
coverage = "^5.1"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry>=0.12"]
|
||||
build-backend = "poetry.masonry.api"
|
||||
5
src/entrypoint.sh
Executable file
5
src/entrypoint.sh
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
#!/bin/bash
|
||||
# This file should only be used in conjuction with docker-compose
|
||||
|
||||
poetry run /app/src/manage.py migrate
|
||||
poetry run /app/src/manage.py runserver 0.0.0.0:8000
|
||||
21
src/manage.py
Executable file
21
src/manage.py
Executable file
|
|
@ -0,0 +1,21 @@
|
|||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
4
src/newsreader/__init__.py
Normal file
4
src/newsreader/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from .celery import app as celery_app
|
||||
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
0
src/newsreader/accounts/__init__.py
Normal file
0
src/newsreader/accounts/__init__.py
Normal file
1
src/newsreader/accounts/admin.py
Normal file
1
src/newsreader/accounts/admin.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Register your models here.
|
||||
5
src/newsreader/accounts/apps.py
Normal file
5
src/newsreader/accounts/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
name = "accounts"
|
||||
151
src/newsreader/accounts/migrations/0001_initial.py
Normal file
151
src/newsreader/accounts/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
# Generated by Django 2.2 on 2019-07-14 10:36
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.utils.timezone
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("auth", "0011_update_proxy_permissions"),
|
||||
("django_celery_beat", "0011_auto_20190508_0153"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="User",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_superuser",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||
verbose_name="superuser status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
error_messages={
|
||||
"unique": "A user with that username already exists."
|
||||
},
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||
],
|
||||
verbose_name="username",
|
||||
),
|
||||
),
|
||||
(
|
||||
"first_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=30, verbose_name="first name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="last name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_staff",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates whether the user can log into this admin site.",
|
||||
verbose_name="staff status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
verbose_name="active",
|
||||
),
|
||||
),
|
||||
(
|
||||
"date_joined",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="date joined"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
max_length=254, unique=True, verbose_name="email address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.Group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"task",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
editable=False,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
to="django_celery_beat.PeriodicTask",
|
||||
),
|
||||
),
|
||||
(
|
||||
"task_interval",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
to="django_celery_beat.IntervalSchedule",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_permissions",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this user.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.Permission",
|
||||
verbose_name="user permissions",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "user",
|
||||
"verbose_name_plural": "users",
|
||||
"abstract": False,
|
||||
},
|
||||
managers=[("objects", django.contrib.auth.models.UserManager())],
|
||||
)
|
||||
]
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# Generated by Django 2.2 on 2019-07-14 10:37
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0001_initial")]
|
||||
|
||||
operations = [migrations.RemoveField(model_name="user", name="username")]
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 2.2 on 2019-07-14 14:17
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
import newsreader.accounts.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0002_remove_user_username")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name="user",
|
||||
managers=[("objects", newsreader.accounts.models.UserManager())],
|
||||
)
|
||||
]
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# Generated by Django 2.2 on 2019-07-14 15:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0003_auto_20190714_1417")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="task",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
editable=False,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
to="django_celery_beat.PeriodicTask",
|
||||
),
|
||||
)
|
||||
]
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# Generated by Django 2.2.6 on 2019-11-16 11:28
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0004_auto_20190714_1501")]
|
||||
|
||||
operations = [migrations.RemoveField(model_name="user", name="task_interval")]
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 2.2.6 on 2019-11-16 11:53
|
||||
|
||||
import django.db.models.deletion
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0005_remove_user_task_interval")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="task",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
editable=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="django_celery_beat.PeriodicTask",
|
||||
),
|
||||
)
|
||||
]
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 2.2.6 on 2019-11-16 11:55
|
||||
|
||||
import django.db.models.deletion
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0006_auto_20191116_1253")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="task",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
editable=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="django_celery_beat.PeriodicTask",
|
||||
verbose_name="collection task",
|
||||
),
|
||||
)
|
||||
]
|
||||
0
src/newsreader/accounts/migrations/__init__.py
Normal file
0
src/newsreader/accounts/migrations/__init__.py
Normal file
80
src/newsreader/accounts/models.py
Normal file
80
src/newsreader/accounts/models.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import json
|
||||
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.auth.models import UserManager as DjangoUserManager
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
||||
|
||||
|
||||
class UserManager(DjangoUserManager):
|
||||
def _create_user(self, email, password, **extra_fields):
|
||||
"""
|
||||
Create and save a user with the given username, email, and password.
|
||||
"""
|
||||
if not email:
|
||||
raise ValueError("The given email must be set")
|
||||
email = self.normalize_email(email)
|
||||
user = self.model(email=email, **extra_fields)
|
||||
user.set_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_user(self, email, password=None, **extra_fields):
|
||||
extra_fields.setdefault("is_staff", False)
|
||||
extra_fields.setdefault("is_superuser", False)
|
||||
return self._create_user(email, password, **extra_fields)
|
||||
|
||||
def create_superuser(self, email, password, **extra_fields):
|
||||
extra_fields.setdefault("is_staff", True)
|
||||
extra_fields.setdefault("is_superuser", True)
|
||||
|
||||
if extra_fields.get("is_staff") is not True:
|
||||
raise ValueError("Superuser must have is_staff=True.")
|
||||
if extra_fields.get("is_superuser") is not True:
|
||||
raise ValueError("Superuser must have is_superuser=True.")
|
||||
|
||||
return self._create_user(email, password, **extra_fields)
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
email = models.EmailField(_("email address"), unique=True)
|
||||
|
||||
task = models.OneToOneField(
|
||||
PeriodicTask,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
editable=False,
|
||||
verbose_name="collection task",
|
||||
)
|
||||
|
||||
username = None
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
USERNAME_FIELD = "email"
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if not self.task:
|
||||
task_interval, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=1, period=IntervalSchedule.HOURS
|
||||
)
|
||||
|
||||
self.task, _ = PeriodicTask.objects.get_or_create(
|
||||
enabled=True,
|
||||
interval=task_interval,
|
||||
name=f"{self.email}-collection-task",
|
||||
task="newsreader.news.collection.tasks.collect",
|
||||
args=json.dumps([self.pk]),
|
||||
)
|
||||
|
||||
self.save()
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
self.task.delete()
|
||||
return super().delete(*args, **kwargs)
|
||||
23
src/newsreader/accounts/permissions.py
Normal file
23
src/newsreader/accounts/permissions.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from rest_framework.permissions import BasePermission
|
||||
|
||||
|
||||
class IsOwner(BasePermission):
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if hasattr(obj, "user"):
|
||||
return obj.user == request.user
|
||||
|
||||
|
||||
class IsPostOwner(BasePermission):
|
||||
def has_object_permission(self, request, view, obj):
|
||||
is_category_user = False
|
||||
is_rule_user = False
|
||||
rule = obj.rule
|
||||
|
||||
if rule and rule.user:
|
||||
is_rule_user = bool(rule.user == request.user)
|
||||
|
||||
if rule.category and rule.category.user:
|
||||
is_category_user = bool(rule.category.user == request.user)
|
||||
return bool(is_category_user and is_rule_user)
|
||||
|
||||
return is_rule_user
|
||||
24
src/newsreader/accounts/templates/accounts/login.html
Normal file
24
src/newsreader/accounts/templates/accounts/login.html
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<main id="login--page" class="main">
|
||||
<form class="form login-form" method="POST" action="{% url 'accounts:login' %}">
|
||||
{% csrf_token %}
|
||||
<div class="form__header">
|
||||
<h1 class="form__title">Login</h1>
|
||||
</div>
|
||||
|
||||
<fieldset class="login-form__fieldset">
|
||||
{{ form }}
|
||||
</fieldset>
|
||||
<fieldset class="login-form__fieldset">
|
||||
<button class="button button--confirm" type="submit">Login</button>
|
||||
<a class="link" href="{% url 'accounts:password-reset' %}">
|
||||
<small class="small">I forgot my password</small>
|
||||
</a>
|
||||
</fieldset>
|
||||
</form>
|
||||
</main>
|
||||
{% endblock %}
|
||||
0
src/newsreader/accounts/tests/__init__.py
Normal file
0
src/newsreader/accounts/tests/__init__.py
Normal file
39
src/newsreader/accounts/tests/factories.py
Normal file
39
src/newsreader/accounts/tests/factories.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import hashlib
|
||||
import string
|
||||
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
import factory
|
||||
|
||||
from registration.models import RegistrationProfile
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
|
||||
|
||||
def get_activation_key():
|
||||
random_string = get_random_string(length=32, allowed_chars=string.printable)
|
||||
return hashlib.sha1(random_string.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class UserFactory(factory.django.DjangoModelFactory):
|
||||
email = factory.Faker("email")
|
||||
password = factory.Faker("password")
|
||||
|
||||
is_staff = False
|
||||
is_active = True
|
||||
|
||||
@classmethod
|
||||
def _create(cls, model_class, *args, **kwargs):
|
||||
manager = cls._get_manager(model_class)
|
||||
return manager.create_user(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
|
||||
|
||||
class RegistrationProfileFactory(factory.django.DjangoModelFactory):
|
||||
user = factory.SubFactory(UserFactory)
|
||||
activation_key = factory.LazyFunction(get_activation_key)
|
||||
|
||||
class Meta:
|
||||
model = RegistrationProfile
|
||||
99
src/newsreader/accounts/tests/test_activation.py
Normal file
99
src/newsreader/accounts/tests/test_activation.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from registration.models import RegistrationProfile
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
|
||||
|
||||
class ActivationTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.register_url = reverse("accounts:register")
|
||||
self.register_success_url = reverse("accounts:register-complete")
|
||||
self.success_url = reverse("accounts:activate-complete")
|
||||
|
||||
def test_activation(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
self.assertRedirects(response, self.register_success_url)
|
||||
|
||||
register_profile = RegistrationProfile.objects.get()
|
||||
|
||||
kwargs = {"activation_key": register_profile.activation_key}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
def test_expired_key(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
|
||||
register_profile = RegistrationProfile.objects.get()
|
||||
user = register_profile.user
|
||||
|
||||
user.date_joined -= datetime.timedelta(days=settings.ACCOUNT_ACTIVATION_DAYS)
|
||||
user.save()
|
||||
|
||||
kwargs = {"activation_key": register_profile.activation_key}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertContains(response, _("Account activation failed"))
|
||||
|
||||
user.refresh_from_db()
|
||||
self.assertFalse(user.is_active)
|
||||
|
||||
def test_invalid_key(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
self.assertRedirects(response, self.register_success_url)
|
||||
|
||||
kwargs = {"activation_key": "not-a-valid-key"}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertContains(response, _("Account activation failed"))
|
||||
|
||||
user = User.objects.get()
|
||||
|
||||
self.assertEquals(user.is_active, False)
|
||||
|
||||
def test_activated_key(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
self.assertRedirects(response, self.register_success_url)
|
||||
|
||||
register_profile = RegistrationProfile.objects.get()
|
||||
|
||||
kwargs = {"activation_key": register_profile.activation_key}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
# try this a second time
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertRedirects(response, self.success_url)
|
||||
160
src/newsreader/accounts/tests/test_password_reset.py
Normal file
160
src/newsreader/accounts/tests/test_password_reset.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
from typing import Dict
|
||||
|
||||
from django.core import mail
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from newsreader.accounts.tests.factories import UserFactory
|
||||
|
||||
|
||||
class PasswordResetTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.url = reverse("accounts:password-reset")
|
||||
self.success_url = reverse("accounts:password-reset-done")
|
||||
self.user = UserFactory(email="test@test.com")
|
||||
|
||||
def test_simple(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
def test_password_change(self):
|
||||
data = {"email": "test@test.com"}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
self.assertEquals(len(mail.outbox), 1)
|
||||
|
||||
def test_unkown_email(self):
|
||||
data = {"email": "unknown@test.com"}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
self.assertEquals(len(mail.outbox), 0)
|
||||
|
||||
def test_repeatedly(self):
|
||||
data = {"email": "test@test.com"}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
self.assertEquals(len(mail.outbox), 2)
|
||||
|
||||
|
||||
class PasswordResetConfirmTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.success_url = reverse("accounts:password-reset-complete")
|
||||
self.user = UserFactory(email="test@test.com")
|
||||
|
||||
def _get_reset_credentials(self) -> Dict:
|
||||
data = {"email": self.user.email}
|
||||
|
||||
response = self.client.post(reverse("accounts:password-reset"), data)
|
||||
|
||||
return {
|
||||
"uidb64": response.context[0]["uid"],
|
||||
"token": response.context[0]["token"],
|
||||
}
|
||||
|
||||
def test_simple(self):
|
||||
kwargs = self._get_reset_credentials()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("accounts:password-reset-confirm", kwargs=kwargs)
|
||||
)
|
||||
|
||||
self.assertRedirects(
|
||||
response, f"/accounts/password-reset/{kwargs['uidb64']}/set-password/"
|
||||
)
|
||||
|
||||
def test_confirm_password(self):
|
||||
kwargs = self._get_reset_credentials()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("accounts:password-reset-confirm", kwargs=kwargs)
|
||||
)
|
||||
|
||||
data = {"new_password1": "jabbadabadoe", "new_password2": "jabbadabadoe"}
|
||||
|
||||
response = self.client.post(response.url, data)
|
||||
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
self.user.refresh_from_db()
|
||||
|
||||
self.assertTrue(self.user.check_password("jabbadabadoe"))
|
||||
|
||||
def test_wrong_uuid(self):
|
||||
correct_kwargs = self._get_reset_credentials()
|
||||
wrong_kwargs = {"uidb64": "burp", "token": correct_kwargs["token"]}
|
||||
|
||||
response = self.client.get(
|
||||
reverse("accounts:password-reset-confirm", kwargs=wrong_kwargs)
|
||||
)
|
||||
|
||||
self.assertContains(response, _("Password reset unsuccessful"))
|
||||
|
||||
def test_wrong_token(self):
|
||||
correct_kwargs = self._get_reset_credentials()
|
||||
wrong_kwargs = {"uidb64": correct_kwargs["uidb64"], "token": "token"}
|
||||
|
||||
response = self.client.get(
|
||||
reverse("accounts:password-reset-confirm", kwargs=wrong_kwargs)
|
||||
)
|
||||
|
||||
self.assertContains(response, _("Password reset unsuccessful"))
|
||||
|
||||
def test_wrong_url_args(self):
|
||||
kwargs = {"uidb64": "burp", "token": "token"}
|
||||
|
||||
response = self.client.get(
|
||||
reverse("accounts:password-reset-confirm", kwargs=kwargs)
|
||||
)
|
||||
|
||||
self.assertContains(response, _("Password reset unsuccessful"))
|
||||
|
||||
def test_token_repeatedly(self):
|
||||
kwargs = self._get_reset_credentials()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("accounts:password-reset-confirm", kwargs=kwargs)
|
||||
)
|
||||
|
||||
data = {"new_password1": "jabbadabadoe", "new_password2": "jabbadabadoe"}
|
||||
|
||||
self.client.post(response.url, data)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("accounts:password-reset-confirm", kwargs=kwargs)
|
||||
)
|
||||
|
||||
self.assertContains(response, _("Password reset unsuccessful"))
|
||||
|
||||
def test_change_form_repeatedly(self):
|
||||
kwargs = self._get_reset_credentials()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("accounts:password-reset-confirm", kwargs=kwargs)
|
||||
)
|
||||
|
||||
data = {"new_password1": "new-password", "new_password2": "new-password"}
|
||||
|
||||
self.client.post(response.url, data)
|
||||
|
||||
data = {"new_password1": "jabbadabadoe", "new_password2": "jabbadabadoe"}
|
||||
|
||||
response = self.client.post(
|
||||
reverse("accounts:password-reset-confirm", kwargs=kwargs)
|
||||
)
|
||||
|
||||
self.assertContains(response, _("Password reset unsuccessful"))
|
||||
|
||||
self.user.refresh_from_db()
|
||||
|
||||
self.assertTrue(self.user.check_password("new-password"))
|
||||
110
src/newsreader/accounts/tests/test_registration.py
Normal file
110
src/newsreader/accounts/tests/test_registration.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
from django.core import mail
|
||||
from django.test import TransactionTestCase as TestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from registration.models import RegistrationProfile
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
from newsreader.accounts.tests.factories import UserFactory
|
||||
|
||||
|
||||
class RegistrationTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.url = reverse("accounts:register")
|
||||
self.success_url = reverse("accounts:register-complete")
|
||||
self.disallowed_url = reverse("accounts:register-closed")
|
||||
|
||||
def test_simple(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
def test_registration(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
self.assertEquals(User.objects.count(), 1)
|
||||
self.assertEquals(RegistrationProfile.objects.count(), 1)
|
||||
|
||||
user = User.objects.get()
|
||||
|
||||
self.assertEquals(user.is_active, False)
|
||||
self.assertEquals(len(mail.outbox), 1)
|
||||
|
||||
def test_existing_email(self):
|
||||
UserFactory(email="test@test.com")
|
||||
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
self.assertEquals(User.objects.count(), 1)
|
||||
self.assertContains(response, _("User with this Email address already exists"))
|
||||
|
||||
def test_pending_registration(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
self.assertEquals(User.objects.count(), 1)
|
||||
self.assertEquals(RegistrationProfile.objects.count(), 1)
|
||||
|
||||
user = User.objects.get()
|
||||
|
||||
self.assertEquals(user.is_active, False)
|
||||
self.assertEquals(len(mail.outbox), 1)
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertEquals(response.status_code, 200)
|
||||
self.assertContains(response, _("User with this Email address already exists"))
|
||||
|
||||
def test_disabled_account(self):
|
||||
UserFactory(email="test@test.com", is_active=False)
|
||||
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
self.assertEquals(User.objects.count(), 1)
|
||||
self.assertContains(response, _("User with this Email address already exists"))
|
||||
|
||||
@override_settings(REGISTRATION_OPEN=False)
|
||||
def test_registration_closed(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertRedirects(response, self.disallowed_url)
|
||||
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertRedirects(response, self.disallowed_url)
|
||||
|
||||
self.assertEquals(User.objects.count(), 0)
|
||||
self.assertEquals(RegistrationProfile.objects.count(), 0)
|
||||
77
src/newsreader/accounts/tests/test_resend_activation.py
Normal file
77
src/newsreader/accounts/tests/test_resend_activation.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
from django.core import mail
|
||||
from django.test import TransactionTestCase as TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from registration.models import RegistrationProfile
|
||||
|
||||
from newsreader.accounts.tests.factories import RegistrationProfileFactory, UserFactory
|
||||
|
||||
|
||||
class ResendActivationTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.url = reverse("accounts:activate-resend")
|
||||
self.success_url = reverse("accounts:activate-complete")
|
||||
self.register_url = reverse("accounts:register")
|
||||
|
||||
def test_simple(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
def test_resent_form(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
|
||||
register_profile = RegistrationProfile.objects.get()
|
||||
original_kwargs = {"activation_key": register_profile.activation_key}
|
||||
|
||||
response = self.client.post(self.url, {"email": "test@test.com"})
|
||||
|
||||
self.assertContains(response, _("We have sent an email to"))
|
||||
|
||||
self.assertEquals(len(mail.outbox), 2)
|
||||
|
||||
register_profile.refresh_from_db()
|
||||
|
||||
kwargs = {"activation_key": register_profile.activation_key}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
register_profile.refresh_from_db()
|
||||
user = register_profile.user
|
||||
|
||||
self.assertEquals(user.is_active, True)
|
||||
|
||||
# test the old activation code
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=original_kwargs))
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
self.assertContains(response, _("Account activation failed"))
|
||||
|
||||
def test_existing_account(self):
|
||||
user = UserFactory(is_active=True)
|
||||
profile = RegistrationProfileFactory(user=user, activated=True)
|
||||
|
||||
response = self.client.post(self.url, {"email": user.email})
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
# default behaviour is to show success page but not send an email
|
||||
self.assertContains(response, _("We have sent an email to"))
|
||||
|
||||
self.assertEquals(len(mail.outbox), 0)
|
||||
|
||||
def test_no_account(self):
|
||||
response = self.client.post(self.url, {"email": "fake@mail.com"})
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
# default behaviour is to show success page but not send an email
|
||||
self.assertContains(response, _("We have sent an email to"))
|
||||
|
||||
self.assertEquals(len(mail.outbox), 0)
|
||||
22
src/newsreader/accounts/tests/tests.py
Normal file
22
src/newsreader/accounts/tests/tests.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from django.test import TestCase
|
||||
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
|
||||
|
||||
class UserTestCase(TestCase):
|
||||
def test_task_is_created(self):
|
||||
user = User.objects.create(email="durp@burp.nl", task=None)
|
||||
task = PeriodicTask.objects.get(name=f"{user.email}-collection-task")
|
||||
|
||||
user.refresh_from_db()
|
||||
|
||||
self.assertEquals(task, user.task)
|
||||
self.assertEquals(PeriodicTask.objects.count(), 1)
|
||||
|
||||
def test_task_is_deleted(self):
|
||||
user = User.objects.create(email="durp@burp.nl", task=None)
|
||||
user.delete()
|
||||
|
||||
self.assertEquals(PeriodicTask.objects.count(), 0)
|
||||
56
src/newsreader/accounts/urls.py
Normal file
56
src/newsreader/accounts/urls.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from django.urls import path
|
||||
|
||||
from newsreader.accounts.views import (
|
||||
ActivationCompleteView,
|
||||
ActivationResendView,
|
||||
ActivationView,
|
||||
LoginView,
|
||||
LogoutView,
|
||||
PasswordResetCompleteView,
|
||||
PasswordResetConfirmView,
|
||||
PasswordResetDoneView,
|
||||
PasswordResetView,
|
||||
RegistrationClosedView,
|
||||
RegistrationCompleteView,
|
||||
RegistrationView,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("login/", LoginView.as_view(), name="login"),
|
||||
path("logout/", LogoutView.as_view(), name="logout"),
|
||||
path("register/", RegistrationView.as_view(), name="register"),
|
||||
path(
|
||||
"register/complete/",
|
||||
RegistrationCompleteView.as_view(),
|
||||
name="register-complete",
|
||||
),
|
||||
path("register/closed/", RegistrationClosedView.as_view(), name="register-closed"),
|
||||
path(
|
||||
"activate/complete/", ActivationCompleteView.as_view(), name="activate-complete"
|
||||
),
|
||||
path("activate/resend/", ActivationResendView.as_view(), name="activate-resend"),
|
||||
path(
|
||||
# This URL should be placed after all activate/ url's (see arg)
|
||||
"activate/<str:activation_key>/",
|
||||
ActivationView.as_view(),
|
||||
name="activate",
|
||||
),
|
||||
path("password-reset/", PasswordResetView.as_view(), name="password-reset"),
|
||||
path(
|
||||
"password-reset/done/",
|
||||
PasswordResetDoneView.as_view(),
|
||||
name="password-reset-done",
|
||||
),
|
||||
path(
|
||||
"password-reset/<uidb64>/<token>/",
|
||||
PasswordResetConfirmView.as_view(),
|
||||
name="password-reset-confirm",
|
||||
),
|
||||
path(
|
||||
"password-reset/done/",
|
||||
PasswordResetCompleteView.as_view(),
|
||||
name="password-reset-complete",
|
||||
),
|
||||
# TODO: create password change views
|
||||
]
|
||||
91
src/newsreader/accounts/views.py
Normal file
91
src/newsreader/accounts/views.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
from django.contrib.auth import views as django_views
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from registration.backends.default import views as registration_views
|
||||
|
||||
|
||||
class LoginView(django_views.LoginView):
|
||||
template_name = "accounts/login.html"
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy("index")
|
||||
|
||||
|
||||
class LogoutView(django_views.LogoutView):
|
||||
next_page = reverse_lazy("accounts:login")
|
||||
|
||||
|
||||
# RegistrationView shows a registration form and sends the email
|
||||
# RegistrationCompleteView shows after filling in the registration form
|
||||
# ActivationView is send within the activation email and activates the account
|
||||
# ActivationCompleteView shows the success screen when activation was succesful
|
||||
# ActivationResendView can be used when activation links are expired
|
||||
# RegistrationClosedView shows when registration is disabled
|
||||
class RegistrationView(registration_views.RegistrationView):
|
||||
disallowed_url = reverse_lazy("accounts:register-closed")
|
||||
template_name = "registration/registration_form.html"
|
||||
success_url = reverse_lazy("accounts:register-complete")
|
||||
|
||||
|
||||
class RegistrationCompleteView(TemplateView):
|
||||
template_name = "registration/registration_complete.html"
|
||||
|
||||
|
||||
class RegistrationClosedView(TemplateView):
|
||||
template_name = "registration/registration_closed.html"
|
||||
|
||||
|
||||
# Redirects or renders failed activation template
|
||||
class ActivationView(registration_views.ActivationView):
|
||||
template_name = "registration/activation_failure.html"
|
||||
|
||||
def get_success_url(self, user):
|
||||
return ("accounts:activate-complete", (), {})
|
||||
|
||||
|
||||
class ActivationCompleteView(TemplateView):
|
||||
template_name = "registration/activation_complete.html"
|
||||
|
||||
|
||||
# Renders activation form resend or resend_activation_complete
|
||||
class ActivationResendView(registration_views.ResendActivationView):
|
||||
template_name = "registration/activation_resend_form.html"
|
||||
|
||||
def render_form_submitted_template(self, form):
|
||||
"""
|
||||
Renders resend activation complete template with the submitted email.
|
||||
|
||||
"""
|
||||
email = form.cleaned_data["email"]
|
||||
context = {"email": email}
|
||||
|
||||
return render(
|
||||
self.request, "registration/activation_resend_complete.html", context
|
||||
)
|
||||
|
||||
|
||||
# PasswordResetView sends the mail
|
||||
# PasswordResetDoneView shows a success message for the above
|
||||
# PasswordResetConfirmView checks the link the user clicked and
|
||||
# prompts for a new password
|
||||
# PasswordResetCompleteView shows a success message for the above
|
||||
class PasswordResetView(django_views.PasswordResetView):
|
||||
template_name = "password-reset/password_reset_form.html"
|
||||
subject_template_name = "password-reset/password_reset_subject.txt"
|
||||
email_template_name = "password-reset/password_reset_email.html"
|
||||
success_url = reverse_lazy("accounts:password-reset-done")
|
||||
|
||||
|
||||
class PasswordResetDoneView(django_views.PasswordResetDoneView):
|
||||
template_name = "password-reset/password_reset_done.html"
|
||||
|
||||
|
||||
class PasswordResetConfirmView(django_views.PasswordResetConfirmView):
|
||||
template_name = "password-reset/password_reset_confirm.html"
|
||||
success_url = reverse_lazy("accounts:password-reset-complete")
|
||||
|
||||
|
||||
class PasswordResetCompleteView(django_views.PasswordResetCompleteView):
|
||||
template_name = "password-reset/password_reset_complete.html"
|
||||
14
src/newsreader/celery.py
Normal file
14
src/newsreader/celery.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import os
|
||||
|
||||
from celery import Celery
|
||||
|
||||
|
||||
# note: this should be consistent with the setting from manage.py
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev")
|
||||
|
||||
# note: use the --workdir flag when running from different directories
|
||||
app = Celery("newsreader")
|
||||
|
||||
app.config_from_object("django.conf:settings")
|
||||
|
||||
app.autodiscover_tasks()
|
||||
0
src/newsreader/conf/__init__.py
Normal file
0
src/newsreader/conf/__init__.py
Normal file
160
src/newsreader/conf/base.py
Normal file
160
src/newsreader/conf/base.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import os
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
|
||||
DJANGO_PROJECT_DIR = os.path.join(BASE_DIR, "src", "newsreader")
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
|
||||
# SECURITY WARNING: don"t run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ["127.0.0.1"]
|
||||
INTERNAL_IPS = ["127.0.0.1"]
|
||||
|
||||
# Application definition
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
# third party apps
|
||||
"rest_framework",
|
||||
"drf_yasg",
|
||||
"celery",
|
||||
"django_celery_beat",
|
||||
"registration",
|
||||
"axes",
|
||||
# app modules
|
||||
"newsreader.accounts",
|
||||
"newsreader.news.core",
|
||||
"newsreader.news.collection",
|
||||
]
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"axes.backends.AxesBackend",
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"axes.middleware.AxesMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "newsreader.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "newsreader.wsgi.application"
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"HOST": os.environ.get("POSTGRES_HOST", ""),
|
||||
"NAME": os.environ.get("POSTGRES_NAME", "newsreader"),
|
||||
"USER": os.environ.get("POSTGRES_USER"),
|
||||
"PASSWORD": os.environ.get("POSTGRES_PASSWORD"),
|
||||
}
|
||||
}
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "localhost:11211",
|
||||
},
|
||||
"axes": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "localhost:11211",
|
||||
},
|
||||
}
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
|
||||
},
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
]
|
||||
|
||||
# Authentication user model
|
||||
AUTH_USER_MODEL = "accounts.User"
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/2.2/topics/i18n/
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "Europe/Amsterdam"
|
||||
USE_I18N = True
|
||||
USE_L10N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/2.2/howto/static-files/
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
||||
STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static")]
|
||||
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-STATICFILES_FINDERS
|
||||
STATICFILES_FINDERS = [
|
||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||
]
|
||||
|
||||
DEFAULT_FROM_EMAIL = "newsreader@rss.fudiggity.nl"
|
||||
|
||||
# Third party settings
|
||||
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
|
||||
AXES_CACHE = "axes"
|
||||
AXES_FAILURE_LIMIT = 5
|
||||
AXES_COOLOFF_TIME = 3 # in hours
|
||||
AXES_RESET_ON_SUCCESS = True
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
),
|
||||
"DEFAULT_PERMISSION_CLASSES": (
|
||||
"rest_framework.permissions.IsAuthenticated",
|
||||
"newsreader.accounts.permissions.IsOwner",
|
||||
),
|
||||
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
|
||||
}
|
||||
|
||||
SWAGGER_SETTINGS = {
|
||||
"LOGIN_URL": "rest_framework:login",
|
||||
"LOGOUT_URL": "rest_framework:logout",
|
||||
"DOC_EXPANSION": "list",
|
||||
}
|
||||
|
||||
REGISTRATION_OPEN = True
|
||||
REGISTRATION_AUTO_LOGIN = True
|
||||
ACCOUNT_ACTIVATION_DAYS = 7
|
||||
35
src/newsreader/conf/dev.py
Normal file
35
src/newsreader/conf/dev.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from .base import * # isort:skip
|
||||
|
||||
|
||||
SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl"
|
||||
|
||||
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"]
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Third party settings
|
||||
AXES_FAILURE_LIMIT = 50
|
||||
AXES_COOLOFF_TIME = None
|
||||
|
||||
try:
|
||||
from .local import * # noqa
|
||||
except ImportError:
|
||||
pass
|
||||
19
src/newsreader/conf/docker.py
Normal file
19
src/newsreader/conf/docker.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from .dev import * # isort:skip
|
||||
|
||||
|
||||
SECRET_KEY = "=q(ztyo)b6noom#a164g&s9vcj1aawa^g#ing_ir99=_zl4g&$"
|
||||
|
||||
# Celery
|
||||
# https://docs.celeryproject.org/en/latest/userguide/configuration.html
|
||||
BROKER_URL = "amqp://guest:guest@rabbitmq:5672//"
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
"axes": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
}
|
||||
19
src/newsreader/conf/gitlab.py
Normal file
19
src/newsreader/conf/gitlab.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from .base import * # isort:skip
|
||||
|
||||
|
||||
SECRET_KEY = "29%lkw+&n%^w4k#@_db2mo%*tc&xzb)x7xuq*(0$eucii%4r0c"
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
AXES_ENABLED = False
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
"axes": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
}
|
||||
45
src/newsreader/conf/production.py
Normal file
45
src/newsreader/conf/production.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
from .base import * # isort:skip
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DEBUG = False
|
||||
ALLOWED_HOSTS = ["rss.fudiggity.nl"]
|
||||
|
||||
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"HOST": os.environ["POSTGRES_HOST"],
|
||||
"PORT": os.environ["POSTGRES_PORT"],
|
||||
"NAME": os.environ["POSTGRES_NAME"],
|
||||
"USER": os.environ["POSTGRES_USER"],
|
||||
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
|
||||
}
|
||||
}
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Third party settings
|
||||
AXES_HANDLER = "axes.handlers.database.DatabaseHandler"
|
||||
|
||||
REGISTRATION_OPEN = False
|
||||
0
src/newsreader/core/__init__.py
Normal file
0
src/newsreader/core/__init__.py
Normal file
1
src/newsreader/core/admin.py
Normal file
1
src/newsreader/core/admin.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Register your models here.
|
||||
5
src/newsreader/core/apps.py
Normal file
5
src/newsreader/core/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
name = "core"
|
||||
0
src/newsreader/core/migrations/__init__.py
Normal file
0
src/newsreader/core/migrations/__init__.py
Normal file
15
src/newsreader/core/models.py
Normal file
15
src/newsreader/core/models.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class TimeStampedModel(models.Model):
|
||||
"""
|
||||
An abstract base class model that provides self-
|
||||
updating ``created`` and ``modified`` fields.
|
||||
"""
|
||||
|
||||
created = models.DateTimeField(default=timezone.now)
|
||||
modified = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
12
src/newsreader/core/pagination.py
Normal file
12
src/newsreader/core/pagination.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
|
||||
class ResultSetPagination(PageNumberPagination):
|
||||
page_size_query_param = "count"
|
||||
max_page_size = 50
|
||||
page_size = 30
|
||||
|
||||
|
||||
class LargeResultSetPagination(ResultSetPagination):
|
||||
max_page_size = 100
|
||||
page_size = 50
|
||||
6
src/newsreader/core/permissions.py
Normal file
6
src/newsreader/core/permissions.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from rest_framework.permissions import BasePermission
|
||||
|
||||
|
||||
class IsOwner(BasePermission):
|
||||
def has_object_permission(self, request, view, obj):
|
||||
return obj.user == request.user
|
||||
1
src/newsreader/core/tests.py
Normal file
1
src/newsreader/core/tests.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Create your tests here.
|
||||
1
src/newsreader/core/views.py
Normal file
1
src/newsreader/core/views.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Create your views here.
|
||||
298
src/newsreader/fixtures/default-fixture.json
Normal file
298
src/newsreader/fixtures/default-fixture.json
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
[
|
||||
{
|
||||
"model": "django_celery_beat.periodictask",
|
||||
"pk": 10,
|
||||
"fields": {
|
||||
"name": "sonny@bakker.nl-collection-task",
|
||||
"task": "newsreader.news.collection.tasks.collect",
|
||||
"interval": 4,
|
||||
"crontab": null,
|
||||
"solar": null,
|
||||
"clocked": null,
|
||||
"args": "[2]",
|
||||
"kwargs": "{}",
|
||||
"queue": null,
|
||||
"exchange": null,
|
||||
"routing_key": null,
|
||||
"headers": "{}",
|
||||
"priority": null,
|
||||
"expires": null,
|
||||
"one_off": false,
|
||||
"start_time": null,
|
||||
"enabled": true,
|
||||
"last_run_at": "2019-11-29T22:29:08.345Z",
|
||||
"total_run_count": 290,
|
||||
"date_changed": "2019-11-29T22:29:18.378Z",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "django_celery_beat.periodictask",
|
||||
"pk": 26,
|
||||
"fields": {
|
||||
"name": "sonnyba871@gmail.com-collection-task",
|
||||
"task": "newsreader.news.collection.tasks.collect",
|
||||
"interval": 4,
|
||||
"crontab": null,
|
||||
"solar": null,
|
||||
"clocked": null,
|
||||
"args": "[18]",
|
||||
"kwargs": "{}",
|
||||
"queue": null,
|
||||
"exchange": null,
|
||||
"routing_key": null,
|
||||
"headers": "{}",
|
||||
"priority": null,
|
||||
"expires": null,
|
||||
"one_off": false,
|
||||
"start_time": null,
|
||||
"enabled": true,
|
||||
"last_run_at": "2019-11-29T22:35:19.134Z",
|
||||
"total_run_count": 103,
|
||||
"date_changed": "2019-11-29T22:38:19.464Z",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"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.intervalschedule",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"every": 5,
|
||||
"period": "minutes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "django_celery_beat.intervalschedule",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"every": 15,
|
||||
"period": "minutes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "django_celery_beat.intervalschedule",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"every": 30,
|
||||
"period": "minutes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "django_celery_beat.intervalschedule",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"every": 1,
|
||||
"period": "hours"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "accounts.user",
|
||||
"fields": {
|
||||
"password": "pbkdf2_sha256$150000$5lBD7JemxYfE$B+lM5wWUW2n/ZulPFaWHtzWjyQ/QZ6iwjAC2I0R/VzU=",
|
||||
"last_login": "2019-11-27T18:57:36.686Z",
|
||||
"is_superuser": true,
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"is_staff": true,
|
||||
"is_active": true,
|
||||
"date_joined": "2019-07-18T18:52:36.080Z",
|
||||
"email": "sonny@bakker.nl",
|
||||
"task": 10,
|
||||
"groups": [],
|
||||
"user_permissions": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "accounts.user",
|
||||
"fields": {
|
||||
"password": "pbkdf2_sha256$150000$vUwxT8T25R8C$S+Eq2tMRbSDE31/X5KGJ/M+Nblh7kKfzuM/z7HraR/Q=",
|
||||
"last_login": null,
|
||||
"is_superuser": false,
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"is_staff": false,
|
||||
"is_active": false,
|
||||
"date_joined": "2019-11-25T15:35:14.051Z",
|
||||
"email": "sonnyba871@gmail.com",
|
||||
"task": 26,
|
||||
"groups": [],
|
||||
"user_permissions": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "core.category",
|
||||
"pk": 8,
|
||||
"fields": {
|
||||
"created": "2019-11-17T19:37:24.671Z",
|
||||
"modified": "2019-11-18T19:59:55.010Z",
|
||||
"name": "World news",
|
||||
"user": [
|
||||
"sonny@bakker.nl"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "core.category",
|
||||
"pk": 9,
|
||||
"fields": {
|
||||
"created": "2019-11-17T19:37:26.161Z",
|
||||
"modified": "2019-11-18T19:59:45.010Z",
|
||||
"name": "Tech",
|
||||
"user": [
|
||||
"sonny@bakker.nl"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "collection.collectionrule",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"created": "2019-07-14T13:08:10.374Z",
|
||||
"modified": "2019-11-29T22:35:20.346Z",
|
||||
"name": "Hackers News",
|
||||
"url": "https://news.ycombinator.com/rss",
|
||||
"website_url": "https://news.ycombinator.com/",
|
||||
"favicon": "https://news.ycombinator.com/favicon.ico",
|
||||
"timezone": "UTC",
|
||||
"category": 9,
|
||||
"last_suceeded": "2019-11-29T22:35:20.235Z",
|
||||
"succeeded": true,
|
||||
"error": null,
|
||||
"user": [
|
||||
"sonny@bakker.nl"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "collection.collectionrule",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"created": "2019-07-20T11:24:32.745Z",
|
||||
"modified": "2019-11-29T22:35:19.525Z",
|
||||
"name": "BBC",
|
||||
"url": "http://feeds.bbci.co.uk/news/world/rss.xml",
|
||||
"website_url": "https://www.bbc.co.uk/news/",
|
||||
"favicon": "https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png",
|
||||
"timezone": "UTC",
|
||||
"category": 8,
|
||||
"last_suceeded": "2019-11-29T22:35:19.241Z",
|
||||
"succeeded": true,
|
||||
"error": null,
|
||||
"user": [
|
||||
"sonny@bakker.nl"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "collection.collectionrule",
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"created": "2019-07-20T11:24:50.411Z",
|
||||
"modified": "2019-11-29T22:35:20.010Z",
|
||||
"name": "Ars Technica",
|
||||
"url": "http://feeds.arstechnica.com/arstechnica/index?fmt=xml",
|
||||
"website_url": "https://arstechnica.com",
|
||||
"favicon": "https://cdn.arstechnica.net/favicon.ico",
|
||||
"timezone": "UTC",
|
||||
"category": 9,
|
||||
"last_suceeded": "2019-11-29T22:35:19.808Z",
|
||||
"succeeded": true,
|
||||
"error": null,
|
||||
"user": [
|
||||
"sonny@bakker.nl"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "collection.collectionrule",
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"created": "2019-07-20T11:25:02.089Z",
|
||||
"modified": "2019-11-29T22:35:20.233Z",
|
||||
"name": "The Guardian",
|
||||
"url": "https://www.theguardian.com/world/rss",
|
||||
"website_url": "https://www.theguardian.com/world",
|
||||
"favicon": "https://assets.guim.co.uk/images/favicons/873381bf11d58e20f551905d51575117/72x72.png",
|
||||
"timezone": "UTC",
|
||||
"category": 8,
|
||||
"last_suceeded": "2019-11-29T22:35:20.076Z",
|
||||
"succeeded": true,
|
||||
"error": null,
|
||||
"user": [
|
||||
"sonny@bakker.nl"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "collection.collectionrule",
|
||||
"pk": 7,
|
||||
"fields": {
|
||||
"created": "2019-07-20T11:25:30.121Z",
|
||||
"modified": "2019-11-29T22:35:19.695Z",
|
||||
"name": "Tweakers",
|
||||
"url": "http://feeds.feedburner.com/tweakers/mixed?fmt=xml",
|
||||
"website_url": "https://tweakers.net/",
|
||||
"favicon": null,
|
||||
"timezone": "UTC",
|
||||
"category": 9,
|
||||
"last_suceeded": "2019-11-29T22:35:19.528Z",
|
||||
"succeeded": true,
|
||||
"error": null,
|
||||
"user": [
|
||||
"sonny@bakker.nl"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "collection.collectionrule",
|
||||
"pk": 8,
|
||||
"fields": {
|
||||
"created": "2019-07-20T11:25:46.256Z",
|
||||
"modified": "2019-11-29T22:35:20.074Z",
|
||||
"name": "The Verge",
|
||||
"url": "https://www.theverge.com/rss/index.xml",
|
||||
"website_url": "https://www.theverge.com/",
|
||||
"favicon": "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395367/favicon-16x16.0.png",
|
||||
"timezone": "UTC",
|
||||
"category": 9,
|
||||
"last_suceeded": "2019-11-29T22:35:20.012Z",
|
||||
"succeeded": true,
|
||||
"error": null,
|
||||
"user": [
|
||||
"sonny@bakker.nl"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "collection.collectionrule",
|
||||
"pk": 9,
|
||||
"fields": {
|
||||
"created": "2019-11-24T15:28:41.399Z",
|
||||
"modified": "2019-11-29T22:35:19.807Z",
|
||||
"name": "NOS",
|
||||
"url": "http://feeds.nos.nl/nosnieuwsalgemeen",
|
||||
"website_url": null,
|
||||
"favicon": null,
|
||||
"timezone": "Europe/Amsterdam",
|
||||
"category": 8,
|
||||
"last_suceeded": "2019-11-29T22:35:19.697Z",
|
||||
"succeeded": true,
|
||||
"error": null,
|
||||
"user": [
|
||||
"sonny@bakker.nl"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
168
src/newsreader/fixtures/local/fixture.json
Normal file
168
src/newsreader/fixtures/local/fixture.json
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
[
|
||||
{
|
||||
"fields" : {
|
||||
"is_active" : true,
|
||||
"is_superuser" : true,
|
||||
"task_interval" : null,
|
||||
"user_permissions" : [],
|
||||
"is_staff" : true,
|
||||
"last_name" : "",
|
||||
"first_name" : "",
|
||||
"groups" : [],
|
||||
"date_joined" : "2019-07-14T10:44:35.228Z",
|
||||
"password" : "pbkdf2_sha256$150000$vAOYP6XgN40C$bvW265Is2toKzEnbMmLVufd+DA6z1kIhUv/bhtUiDcA=",
|
||||
"task" : null,
|
||||
"last_login" : "2019-07-14T12:28:05.473Z",
|
||||
"email" : "sonnyba871@gmail.com"
|
||||
},
|
||||
"pk" : 1,
|
||||
"model" : "accounts.user"
|
||||
},
|
||||
{
|
||||
"model" : "accounts.user",
|
||||
"fields" : {
|
||||
"task" : null,
|
||||
"email" : "sonny@bakker.nl",
|
||||
"last_login" : "2019-07-20T07:52:59.491Z",
|
||||
"first_name" : "",
|
||||
"groups" : [],
|
||||
"last_name" : "",
|
||||
"password" : "pbkdf2_sha256$150000$SMI9E7GFkJQk$usX0YN3q0ArqAd6bUQ9sUm6Ugms3XRxaiizHGIa3Pk4=",
|
||||
"date_joined" : "2019-07-18T18:52:36.080Z",
|
||||
"is_staff" : true,
|
||||
"task_interval" : null,
|
||||
"user_permissions" : [],
|
||||
"is_active" : true,
|
||||
"is_superuser" : true
|
||||
},
|
||||
"pk" : 2
|
||||
},
|
||||
{
|
||||
"pk" : 3,
|
||||
"fields" : {
|
||||
"favicon" : null,
|
||||
"category" : null,
|
||||
"url" : "https://news.ycombinator.com/rss",
|
||||
"error" : null,
|
||||
"user" : 2,
|
||||
"succeeded" : true,
|
||||
"modified" : "2019-07-20T11:28:16.473Z",
|
||||
"last_suceeded" : "2019-07-20T11:28:16.316Z",
|
||||
"name" : "Hackers News",
|
||||
"website_url" : null,
|
||||
"created" : "2019-07-14T13:08:10.374Z",
|
||||
"timezone" : "UTC"
|
||||
},
|
||||
"model" : "collection.collectionrule"
|
||||
},
|
||||
{
|
||||
"model" : "collection.collectionrule",
|
||||
"pk" : 4,
|
||||
"fields" : {
|
||||
"favicon" : null,
|
||||
"category" : 2,
|
||||
"url" : "http://feeds.bbci.co.uk/news/world/rss.xml",
|
||||
"error" : null,
|
||||
"user" : 2,
|
||||
"succeeded" : true,
|
||||
"last_suceeded" : "2019-07-20T11:28:15.691Z",
|
||||
"name" : "BBC",
|
||||
"modified" : "2019-07-20T12:07:49.164Z",
|
||||
"timezone" : "UTC",
|
||||
"website_url" : null,
|
||||
"created" : "2019-07-20T11:24:32.745Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"pk" : 5,
|
||||
"fields" : {
|
||||
"error" : null,
|
||||
"category" : null,
|
||||
"url" : "http://feeds.arstechnica.com/arstechnica/index?fmt=xml",
|
||||
"favicon" : null,
|
||||
"timezone" : "UTC",
|
||||
"created" : "2019-07-20T11:24:50.411Z",
|
||||
"website_url" : null,
|
||||
"name" : "Ars Technica",
|
||||
"succeeded" : true,
|
||||
"last_suceeded" : "2019-07-20T11:28:15.986Z",
|
||||
"modified" : "2019-07-20T11:28:16.033Z",
|
||||
"user" : 2
|
||||
},
|
||||
"model" : "collection.collectionrule"
|
||||
},
|
||||
{
|
||||
"model" : "collection.collectionrule",
|
||||
"pk" : 6,
|
||||
"fields" : {
|
||||
"favicon" : null,
|
||||
"category" : 2,
|
||||
"url" : "https://www.theguardian.com/world/rss",
|
||||
"error" : null,
|
||||
"user" : 2,
|
||||
"name" : "The Guardian",
|
||||
"succeeded" : true,
|
||||
"last_suceeded" : "2019-07-20T11:28:16.078Z",
|
||||
"modified" : "2019-07-20T12:07:44.292Z",
|
||||
"created" : "2019-07-20T11:25:02.089Z",
|
||||
"website_url" : null,
|
||||
"timezone" : "UTC"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fields" : {
|
||||
"url" : "http://feeds.feedburner.com/tweakers/mixed?fmt=xml",
|
||||
"category" : 1,
|
||||
"error" : null,
|
||||
"favicon" : null,
|
||||
"timezone" : "UTC",
|
||||
"website_url" : null,
|
||||
"created" : "2019-07-20T11:25:30.121Z",
|
||||
"user" : 2,
|
||||
"last_suceeded" : "2019-07-20T11:28:15.860Z",
|
||||
"succeeded" : true,
|
||||
"modified" : "2019-07-20T12:07:28.473Z",
|
||||
"name" : "Tweakers"
|
||||
},
|
||||
"pk" : 7,
|
||||
"model" : "collection.collectionrule"
|
||||
},
|
||||
{
|
||||
"model" : "collection.collectionrule",
|
||||
"pk" : 8,
|
||||
"fields" : {
|
||||
"category" : 1,
|
||||
"url" : "https://www.theverge.com/rss/index.xml",
|
||||
"error" : null,
|
||||
"favicon" : null,
|
||||
"created" : "2019-07-20T11:25:46.256Z",
|
||||
"website_url" : null,
|
||||
"timezone" : "UTC",
|
||||
"user" : 2,
|
||||
"last_suceeded" : "2019-07-20T11:28:16.034Z",
|
||||
"succeeded" : true,
|
||||
"modified" : "2019-07-20T12:07:21.704Z",
|
||||
"name" : "The Verge"
|
||||
}
|
||||
},
|
||||
{
|
||||
"pk" : 1,
|
||||
"fields" : {
|
||||
"user" : 2,
|
||||
"name" : "Tech",
|
||||
"modified" : "2019-07-20T12:07:17.396Z",
|
||||
"created" : "2019-07-20T12:07:10Z"
|
||||
},
|
||||
"model" : "core.category"
|
||||
},
|
||||
{
|
||||
"model" : "core.category",
|
||||
"pk" : 2,
|
||||
"fields" : {
|
||||
"user" : 2,
|
||||
"modified" : "2019-07-20T12:07:42.329Z",
|
||||
"name" : "World News",
|
||||
"created" : "2019-07-20T12:07:34Z"
|
||||
}
|
||||
}
|
||||
]
|
||||
13
src/newsreader/js/components/Card.js
Normal file
13
src/newsreader/js/components/Card.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
|
||||
const Card = props => {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card__header">{props.header}</div>
|
||||
<div className="card__content">{props.content}</div>
|
||||
<div className="card__footer">{props.footer}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
13
src/newsreader/js/components/LoadingIndicator.js
Normal file
13
src/newsreader/js/components/LoadingIndicator.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
|
||||
const LoadingIndicator = props => {
|
||||
return (
|
||||
<div className="loading-indicator">
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingIndicator;
|
||||
29
src/newsreader/js/components/Messages.js
Normal file
29
src/newsreader/js/components/Messages.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
|
||||
class Messages extends React.Component {
|
||||
state = { messages: this.props.messages };
|
||||
|
||||
close = ::this.close;
|
||||
|
||||
close(index) {
|
||||
const newMessages = this.state.messages.filter((message, currentIndex) => {
|
||||
return currentIndex != index;
|
||||
});
|
||||
|
||||
this.setState({ messages: newMessages });
|
||||
}
|
||||
|
||||
render() {
|
||||
const messages = this.state.messages.map((message, index) => {
|
||||
return (
|
||||
<li key={index} className={`messages__item messages__item--${message.type}`}>
|
||||
{message.text} <i className="gg-close" onClick={() => this.close(index)} />
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
return <ul className="list messages">{messages}</ul>;
|
||||
}
|
||||
}
|
||||
|
||||
export default Messages;
|
||||
11
src/newsreader/js/components/Modal.js
Normal file
11
src/newsreader/js/components/Modal.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import React from 'react';
|
||||
|
||||
const Modal = props => {
|
||||
return (
|
||||
<div className="modal">
|
||||
<div className="modal__item">{props.content}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
3
src/newsreader/js/index.js
Normal file
3
src/newsreader/js/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import './pages/homepage/index.js';
|
||||
import './pages/rules/index.js';
|
||||
import './pages/categories/index.js';
|
||||
106
src/newsreader/js/pages/categories/App.js
Normal file
106
src/newsreader/js/pages/categories/App.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import React from 'react';
|
||||
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
import Card from '../../components/Card.js';
|
||||
import CategoryCard from './components/CategoryCard.js';
|
||||
import CategoryModal from './components/CategoryModal.js';
|
||||
import Messages from '../../components/Messages.js';
|
||||
|
||||
class App extends React.Component {
|
||||
selectCategory = ::this.selectCategory;
|
||||
deselectCategory = ::this.deselectCategory;
|
||||
deleteCategory = ::this.deleteCategory;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.token = Cookies.get('csrftoken');
|
||||
this.state = {
|
||||
categories: props.categories,
|
||||
selectedCategoryId: null,
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
selectCategory(categoryId) {
|
||||
this.setState({ selectedCategoryId: categoryId });
|
||||
}
|
||||
|
||||
deselectCategory() {
|
||||
this.setState({ selectedCategoryId: null });
|
||||
}
|
||||
|
||||
deleteCategory(categoryId) {
|
||||
const url = `/api/categories/${categoryId}/`;
|
||||
const options = {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': this.token,
|
||||
},
|
||||
};
|
||||
|
||||
fetch(url, options).then(response => {
|
||||
if (response.ok) {
|
||||
const categories = this.state.categories.filter(category => {
|
||||
return category.pk != categoryId;
|
||||
});
|
||||
|
||||
return this.setState({
|
||||
categories: categories,
|
||||
selectedCategoryId: null,
|
||||
message: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const message = {
|
||||
type: 'error',
|
||||
text: 'Unable to remove category, try again later',
|
||||
};
|
||||
return this.setState({ selectedCategoryId: null, message: message });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { categories } = this.state;
|
||||
const cards = categories.map(category => {
|
||||
return (
|
||||
<CategoryCard
|
||||
key={category.pk}
|
||||
category={category}
|
||||
showDialog={this.selectCategory}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const selectedCategory = categories.find(category => {
|
||||
return category.pk === this.state.selectedCategoryId;
|
||||
});
|
||||
|
||||
const pageHeader = (
|
||||
<>
|
||||
<h1 className="h1">Categories</h1>
|
||||
<a className="link button button--confirm" href="/categories/create/">
|
||||
Create category
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{this.state.message && <Messages messages={[this.state.message]} />}
|
||||
<Card header={pageHeader} />
|
||||
{cards}
|
||||
{selectedCategory && (
|
||||
<CategoryModal
|
||||
category={selectedCategory}
|
||||
handleCancel={this.deselectCategory}
|
||||
handleDelete={this.deleteCategory}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import React from 'react';
|
||||
|
||||
import Card from '../../../components/Card.js';
|
||||
|
||||
const CategoryCard = props => {
|
||||
const { category } = props;
|
||||
|
||||
const categoryRules = category.rules.map(rule => {
|
||||
let favicon = null;
|
||||
|
||||
if (rule.favicon) {
|
||||
favicon = <img className="favicon" src={rule.favicon} />;
|
||||
} else {
|
||||
favicon = <i className="gg-image" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={rule.pk} className="list__item">
|
||||
{favicon}
|
||||
{rule.name}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
const cardHeader = (
|
||||
<>
|
||||
<h2 className="h2">{category.name}</h2>
|
||||
<small className="small">{category.created}</small>
|
||||
</>
|
||||
);
|
||||
const cardContent = <>{category.rules && <ul className="list">{categoryRules}</ul>}</>;
|
||||
const cardFooter = (
|
||||
<>
|
||||
<a className="link button button--primary" href={`/categories/${category.pk}/`}>
|
||||
Edit
|
||||
</a>
|
||||
<button
|
||||
id="category-delete"
|
||||
className="button button--error"
|
||||
onClick={() => props.showDialog(category.pk)}
|
||||
data-id={`${category.pk}`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
return <Card header={cardHeader} content={cardContent} footer={cardFooter} />;
|
||||
};
|
||||
|
||||
export default CategoryCard;
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import React from 'react';
|
||||
|
||||
import Modal from '../../../components/Modal.js';
|
||||
|
||||
const CategoryModal = props => {
|
||||
const content = (
|
||||
<>
|
||||
<div className="modal__header">
|
||||
<h1 className="h1 modal__title">Delete category</h1>
|
||||
</div>
|
||||
|
||||
<div className="modal__content">
|
||||
<p className="p">Are you sure you want to delete {props.category.name}?</p>
|
||||
<small className="small">
|
||||
Collection rules coupled to this category will not be deleted but will have no
|
||||
category
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="modal__footer">
|
||||
<button className="button button--confirm" onClick={props.handleCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="button button--error"
|
||||
onClick={() => props.handleDelete(props.category.pk)}
|
||||
>
|
||||
Delete category
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return <Modal content={content} />;
|
||||
};
|
||||
|
||||
export default CategoryModal;
|
||||
13
src/newsreader/js/pages/categories/index.js
Normal file
13
src/newsreader/js/pages/categories/index.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import App from './App.js';
|
||||
|
||||
const page = document.getElementById('categories--page');
|
||||
|
||||
if (page) {
|
||||
const dataScript = document.getElementById('categories-data');
|
||||
const categories = JSON.parse(dataScript.textContent);
|
||||
|
||||
ReactDOM.render(<App categories={categories} />, page);
|
||||
}
|
||||
64
src/newsreader/js/pages/homepage/App.js
Normal file
64
src/newsreader/js/pages/homepage/App.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
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';
|
||||
import Messages from '../../components/Messages.js';
|
||||
|
||||
class App extends React.Component {
|
||||
componentDidMount() {
|
||||
this.props.fetchCategories();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<Sidebar />
|
||||
<FeedList />
|
||||
|
||||
{this.props.error && (
|
||||
<Messages messages={[{ type: 'error', text: this.props.error.message }]} />
|
||||
)}
|
||||
|
||||
{!isEqual(this.props.post, {}) && (
|
||||
<PostModal
|
||||
post={this.props.post}
|
||||
rule={this.props.rule}
|
||||
category={this.props.category}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const { error } = state.error;
|
||||
|
||||
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 {
|
||||
category,
|
||||
error,
|
||||
rule,
|
||||
post: state.selected.post,
|
||||
};
|
||||
}
|
||||
|
||||
return { error, post: state.selected.post };
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
fetchCategories: () => dispatch(fetchCategories()),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(App);
|
||||
87
src/newsreader/js/pages/homepage/actions/categories.js
Normal file
87
src/newsreader/js/pages/homepage/actions/categories.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { requestRules, receiveRules, fetchRulesByCategory } from './rules.js';
|
||||
import { handleAPIError } from './error.js';
|
||||
|
||||
import { CATEGORY_TYPE } from '../constants.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,
|
||||
section: { ...category, type: CATEGORY_TYPE },
|
||||
});
|
||||
|
||||
export const receiveCategory = category => ({
|
||||
type: RECEIVE_CATEGORY,
|
||||
category,
|
||||
});
|
||||
|
||||
export const receiveCategories = categories => ({
|
||||
type: RECEIVE_CATEGORIES,
|
||||
categories,
|
||||
});
|
||||
|
||||
export const requestCategory = () => ({ type: REQUEST_CATEGORY });
|
||||
export const requestCategories = () => ({ type: REQUEST_CATEGORIES });
|
||||
|
||||
export const fetchCategory = category => {
|
||||
return (dispatch, getState) => {
|
||||
const { selected } = getState();
|
||||
const selectedSection = { ...selected.item };
|
||||
|
||||
if (selectedSection.type === CATEGORY_TYPE && selectedSection.clicks <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(requestCategory());
|
||||
|
||||
return fetch(`/api/categories/${category.id}`)
|
||||
.then(response => response.json())
|
||||
.then(json => {
|
||||
dispatch(receiveCategory({ ...json }));
|
||||
|
||||
if (category.unread === 0) {
|
||||
return dispatch(fetchRulesByCategory(category));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(receiveCategory({}));
|
||||
dispatch(handleAPIError(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchCategories = () => {
|
||||
return dispatch => {
|
||||
dispatch(requestCategories());
|
||||
|
||||
return fetch('/api/categories/')
|
||||
.then(response => response.json())
|
||||
.then(categories => {
|
||||
dispatch(receiveCategories(categories));
|
||||
|
||||
return categories;
|
||||
})
|
||||
.then(categories => {
|
||||
dispatch(requestRules());
|
||||
|
||||
const promises = categories.map(category => {
|
||||
return fetch(`/api/categories/${category.id}/rules/`);
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
})
|
||||
.then(responses => Promise.all(responses.map(response => response.json())))
|
||||
.then(nestedRules => dispatch(receiveRules(nestedRules.flat())))
|
||||
.catch(error => {
|
||||
dispatch(receiveCategories([]));
|
||||
dispatch(receiveRules([]));
|
||||
dispatch(handleAPIError(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
6
src/newsreader/js/pages/homepage/actions/error.js
Normal file
6
src/newsreader/js/pages/homepage/actions/error.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export const RECEIVE_API_ERROR = 'RECEIVE_API_ERROR';
|
||||
|
||||
export const handleAPIError = error => ({
|
||||
type: RECEIVE_API_ERROR,
|
||||
error,
|
||||
});
|
||||
89
src/newsreader/js/pages/homepage/actions/posts.js
Normal file
89
src/newsreader/js/pages/homepage/actions/posts.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { handleAPIError } from './error.js';
|
||||
import { RULE_TYPE, CATEGORY_TYPE } from '../constants.js';
|
||||
|
||||
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 requestPosts = () => ({ type: REQUEST_POSTS });
|
||||
|
||||
export const receivePosts = (posts, next) => ({
|
||||
type: RECEIVE_POSTS,
|
||||
posts,
|
||||
next,
|
||||
});
|
||||
|
||||
export const receivePost = post => ({ type: RECEIVE_POST, post });
|
||||
|
||||
export const selectPost = post => ({ type: SELECT_POST, post });
|
||||
|
||||
export const unSelectPost = () => ({ type: UNSELECT_POST });
|
||||
|
||||
export const postRead = (post, section) => ({
|
||||
type: MARK_POST_READ,
|
||||
post,
|
||||
section,
|
||||
});
|
||||
|
||||
export const markPostRead = (post, token) => {
|
||||
return (dispatch, getState) => {
|
||||
const { selected } = 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 section = { ...selected.item };
|
||||
|
||||
return fetch(url, options)
|
||||
.then(response => response.json())
|
||||
.then(updatedPost => {
|
||||
dispatch(receivePost({ ...updatedPost }));
|
||||
dispatch(postRead({ ...updatedPost }, section));
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(receivePost({}));
|
||||
dispatch(handleAPIError(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchPostsBySection = (section, page = false) => {
|
||||
return dispatch => {
|
||||
if (section.unread === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(requestPosts());
|
||||
|
||||
let url = null;
|
||||
|
||||
switch (section.type) {
|
||||
case RULE_TYPE:
|
||||
url = page ? page : `/api/rules/${section.id}/posts/?read=false`;
|
||||
break;
|
||||
case CATEGORY_TYPE:
|
||||
url = page ? page : `/api/categories/${section.id}/posts/?read=false`;
|
||||
break;
|
||||
}
|
||||
|
||||
return fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(posts => dispatch(receivePosts(posts.results, posts.next)))
|
||||
.catch(error => {
|
||||
dispatch(receivePosts([]));
|
||||
dispatch(handleAPIError(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
75
src/newsreader/js/pages/homepage/actions/rules.js
Normal file
75
src/newsreader/js/pages/homepage/actions/rules.js
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { fetchCategory } from './categories.js';
|
||||
import { RULE_TYPE } from '../constants.js';
|
||||
import { handleAPIError } from './error.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,
|
||||
section: { ...rule, type: RULE_TYPE },
|
||||
});
|
||||
|
||||
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) => {
|
||||
const { selected } = getState();
|
||||
const selectedSection = { ...selected.item };
|
||||
|
||||
if (selectedSection.type === RULE_TYPE && selectedSection.clicks <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
return dispatch(fetchCategory({ ...category }));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(receiveRule({}));
|
||||
dispatch(handleAPIError(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchRulesByCategory = category => {
|
||||
return dispatch => {
|
||||
dispatch(requestRules());
|
||||
|
||||
return fetch(`/api/categories/${category.id}/rules/`)
|
||||
.then(response => response.json())
|
||||
.then(rules => dispatch(receiveRules(rules)))
|
||||
.catch(error => {
|
||||
dispatch(receiveRules([]));
|
||||
dispatch(handleAPIError(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
89
src/newsreader/js/pages/homepage/actions/selected.js
Normal file
89
src/newsreader/js/pages/homepage/actions/selected.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { handleAPIError } from './error.js';
|
||||
import { receiveCategory, requestCategory } from './categories.js';
|
||||
import { receiveRule, requestRule } from './rules.js';
|
||||
import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js';
|
||||
|
||||
export const MARK_SECTION_READ = 'MARK_SECTION_READ';
|
||||
|
||||
export const markSectionRead = section => ({
|
||||
type: MARK_SECTION_READ,
|
||||
section,
|
||||
});
|
||||
|
||||
const markCategoryRead = (category, token) => {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(requestCategory(category));
|
||||
|
||||
const { rules } = getState();
|
||||
const categoryRules = Object.values({ ...rules.items }).filter(rule => {
|
||||
return rule.category === category.id;
|
||||
});
|
||||
const ruleMapping = {};
|
||||
|
||||
categoryRules.forEach(rule => {
|
||||
ruleMapping[rule.id] = { ...rule };
|
||||
});
|
||||
|
||||
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 }));
|
||||
return dispatch(
|
||||
markSectionRead({
|
||||
...category,
|
||||
...updatedCategory,
|
||||
rules: ruleMapping,
|
||||
type: CATEGORY_TYPE,
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(receiveCategory({}));
|
||||
dispatch(handleAPIError(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const markRuleRead = (rule, token) => {
|
||||
return (dispatch, getState) => {
|
||||
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({ ...rule, type: RULE_TYPE }));
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(receiveRule({}));
|
||||
dispatch(handleAPIError(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const markRead = (section, token) => {
|
||||
switch (section.type) {
|
||||
case RULE_TYPE:
|
||||
return markRuleRead(section, token);
|
||||
case CATEGORY_TYPE:
|
||||
return markCategoryRead(section, token);
|
||||
}
|
||||
};
|
||||
91
src/newsreader/js/pages/homepage/components/PostModal.js
Normal file
91
src/newsreader/js/pages/homepage/components/PostModal.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
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 {
|
||||
modalListener = ::this.modalListener;
|
||||
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, 3000, post, token);
|
||||
}
|
||||
|
||||
window.addEventListener('click', this.modalListener);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.readTimer) {
|
||||
clearTimeout(this.readTimer);
|
||||
}
|
||||
|
||||
this.readTimer = null;
|
||||
|
||||
window.removeEventListener('click', this.modalListener);
|
||||
}
|
||||
|
||||
modalListener(e) {
|
||||
const targetClassName = e.target.className;
|
||||
|
||||
if (this.props.post && targetClassName == 'modal post-modal') {
|
||||
this.props.unSelectPost();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const post = this.props.post;
|
||||
const publicationDate = formatDatetime(post.publicationDate);
|
||||
const titleClassName = post.read ? 'post__title post__title--read' : 'post__title';
|
||||
|
||||
return (
|
||||
<div className="modal post-modal">
|
||||
<div className="post">
|
||||
<button
|
||||
className="button post__close-button"
|
||||
onClick={() => this.props.unSelectPost()}
|
||||
>
|
||||
Close <i className="gg-close"></i>
|
||||
</button>
|
||||
<div className="post__header">
|
||||
<h1 className={titleClassName}>{`${post.title} `}</h1>
|
||||
<div className="post__meta-info">
|
||||
<span className="post__date">{publicationDate}</span>
|
||||
<a
|
||||
className="post__link"
|
||||
href={post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i className="gg-link" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<aside className="post__section-info">
|
||||
{this.props.category && (
|
||||
<h5 title={this.props.category.name}>{this.props.category.name}</h5>
|
||||
)}
|
||||
|
||||
<h5 title={this.props.rule.name}>{this.props.rule.name}</h5>
|
||||
</aside>
|
||||
|
||||
{/* HTML is sanitized by the collectors */}
|
||||
<div className="post__body" dangerouslySetInnerHTML={{ __html: post.body }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
unSelectPost: () => dispatch(unSelectPost()),
|
||||
markPostRead: (post, token) => dispatch(markPostRead(post, token)),
|
||||
});
|
||||
|
||||
export default connect(null, mapDispatchToProps)(PostModal);
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import { fetchPostsBySection } 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() {
|
||||
this.props.fetchPostsBySection(this.props.selected, this.props.next);
|
||||
}
|
||||
|
||||
render() {
|
||||
const ruleItems = this.props.posts.map((item, index) => {
|
||||
return <RuleItem key={index} posts={item.posts} rule={item.rule} />;
|
||||
});
|
||||
|
||||
if (isEqual(this.props.selected, {})) {
|
||||
return (
|
||||
<div className="post-message">
|
||||
<div className="post-message__block">
|
||||
<i className="gg-arrow-left" />
|
||||
<p className="post-message__text">Select an item to show its unread posts</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (ruleItems.length === 0 && !this.props.isFetching) {
|
||||
return (
|
||||
<div className="post-message">
|
||||
<div className="post-message__block">
|
||||
<p className="post-message__text">
|
||||
No unread posts from the selected section at this moment, try again later
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="post-block">
|
||||
{ruleItems}
|
||||
{this.props.isFetching && <LoadingIndicator />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 => ({
|
||||
fetchPostsBySection: (rule, page = false) => dispatch(fetchPostsBySection(rule, page)),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(FeedList);
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
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.publicationDate);
|
||||
const titleClassName = post.read
|
||||
? 'posts-header__title posts-header__title--read'
|
||||
: 'posts-header__title';
|
||||
|
||||
return (
|
||||
<li
|
||||
className="posts__item"
|
||||
onClick={() => {
|
||||
this.props.selectPost(post);
|
||||
}}
|
||||
>
|
||||
<h5 className={titleClassName} title={post.title}>
|
||||
{post.title}
|
||||
</h5>
|
||||
|
||||
<div className="posts-info">
|
||||
<span className="posts-info__date" title={publicationDate}>
|
||||
{publicationDate}
|
||||
</span>
|
||||
<a
|
||||
className="posts-info__link"
|
||||
href={post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i className="gg-link" />
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
selectPost: post => dispatch(selectPost(post)),
|
||||
});
|
||||
|
||||
export default connect(null, mapDispatchToProps)(PostItem);
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
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.publicationDate) - new Date(firstEl.publicationDate);
|
||||
});
|
||||
|
||||
const postItems = posts.map(post => {
|
||||
return <PostItem key={post.id} post={post} />;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="posts-section">
|
||||
<h3 className="posts-section__name">{this.props.rule.name}</h3>
|
||||
<ul className="posts">{postItems}</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default RuleItem;
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { CATEGORY_TYPE, RULE_TYPE } from '../../constants.js';
|
||||
|
||||
const isEmpty = (object = {}) => {
|
||||
return Object.keys(object).length === 0;
|
||||
};
|
||||
|
||||
export const filterPostsByRule = (rule = {}, posts = []) => {
|
||||
const filteredPosts = posts.filter(post => {
|
||||
return post.rule === rule.id;
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
return {
|
||||
rule: { ...rule },
|
||||
posts: filteredPosts,
|
||||
};
|
||||
});
|
||||
|
||||
return filteredData.filter(rule => rule.posts.length > 0);
|
||||
};
|
||||
|
||||
export const filterPosts = state => {
|
||||
const posts = Object.values({ ...state.posts.items });
|
||||
|
||||
switch (state.selected.item.type) {
|
||||
case CATEGORY_TYPE:
|
||||
const rules = Object.values({ ...state.rules.items });
|
||||
return filterPostsByCategory({ ...state.selected.item }, rules, posts);
|
||||
case RULE_TYPE:
|
||||
return filterPostsByRule({ ...state.selected.item }, posts);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import { CATEGORY_TYPE } from '../../constants.js';
|
||||
import { selectCategory, fetchCategory } from '../../actions/categories.js';
|
||||
import { fetchPostsBySection } from '../../actions/posts.js';
|
||||
import { isSelected } from './functions.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.fetchPostsBySection({ ...category, type: CATEGORY_TYPE });
|
||||
this.props.fetchCategory(category);
|
||||
}
|
||||
|
||||
render() {
|
||||
const chevronClass = this.state.open ? 'gg-chevron-down' : 'gg-chevron-right';
|
||||
const selected = isSelected(this.props.category, this.props.selected, CATEGORY_TYPE);
|
||||
const className = selected ? 'category category--selected' : 'category';
|
||||
|
||||
const ruleItems = this.props.rules.map(rule => {
|
||||
return <RuleItem key={rule.id} rule={rule} selected={this.props.selected} />;
|
||||
});
|
||||
|
||||
return (
|
||||
<li className="sidebar__item">
|
||||
<div className={className}>
|
||||
<div className="category__menu" onClick={() => this.toggleRules()}>
|
||||
<i className={chevronClass} />
|
||||
</div>
|
||||
|
||||
<div className="category__info" onClick={() => this.handleSelect()}>
|
||||
<h4>{this.props.category.name}</h4>
|
||||
<span className="badge">{this.props.category.unread}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ruleItems.length > 0 && this.state.open && (
|
||||
<ul className="rules">{ruleItems}</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
selectCategory: category => dispatch(selectCategory(category)),
|
||||
fetchPostsBySection: section => dispatch(fetchPostsBySection(section)),
|
||||
fetchCategory: category => dispatch(fetchCategory(category)),
|
||||
});
|
||||
|
||||
export default connect(null, mapDispatchToProps)(CategoryItem);
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
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 (
|
||||
<button className="button read-button" onClick={this.markSelectedRead}>
|
||||
Mark selected read
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
markRead: (selected, token) => dispatch(markRead(selected, token)),
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({ selected: state.selected.item });
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ReadButton);
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import { RULE_TYPE } from '../../constants.js';
|
||||
import { selectRule, fetchRule } from '../../actions/rules.js';
|
||||
import { fetchPostsBySection } from '../../actions/posts.js';
|
||||
import { isSelected } from './functions.js';
|
||||
|
||||
class RuleItem extends React.Component {
|
||||
handleSelect() {
|
||||
const rule = { ...this.props.rule };
|
||||
|
||||
this.props.selectRule(rule);
|
||||
this.props.fetchPostsBySection({ ...rule, type: RULE_TYPE });
|
||||
this.props.fetchRule(rule);
|
||||
}
|
||||
|
||||
render() {
|
||||
const selected = isSelected(this.props.rule, this.props.selected, RULE_TYPE);
|
||||
const className = `rules__item ${selected ? 'rules__item--selected' : ''}`;
|
||||
let favicon = null;
|
||||
|
||||
if (this.props.rule.favicon) {
|
||||
favicon = <img className="favicon" width="20" src={this.props.rule.favicon} />;
|
||||
} else {
|
||||
favicon = <i className="gg-image" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={className} onClick={() => this.handleSelect()}>
|
||||
<div className="rules__info">
|
||||
{favicon}
|
||||
<h5 className="rules__title" title={this.props.rule.name}>
|
||||
{this.props.rule.name}
|
||||
</h5>
|
||||
</div>
|
||||
<span className="badge">{this.props.rule.unread}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
selectRule: rule => dispatch(selectRule(rule)),
|
||||
fetchPostsBySection: section => dispatch(fetchPostsBySection(section)),
|
||||
fetchRule: rule => dispatch(fetchRule(rule)),
|
||||
});
|
||||
|
||||
export default connect(null, mapDispatchToProps)(RuleItem);
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
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 (
|
||||
<CategoryItem
|
||||
key={category.id}
|
||||
category={category}
|
||||
rules={rules}
|
||||
selected={this.props.selected.item}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
{(this.props.categories.isFetching || this.props.rules.isFetching) && (
|
||||
<LoadingIndicator />
|
||||
)}
|
||||
|
||||
<ul className="sidebar__nav">{items}</ul>
|
||||
|
||||
{!isEqual(this.props.selected.item, {}) && <ReadButton />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export const filterCategories = (categories = {}) => {
|
||||
return Object.values({ ...categories });
|
||||
};
|
||||
|
||||
export const filterRules = (rules = {}) => {
|
||||
return Object.values({ ...rules });
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export const isSelected = (section, selected, type) => {
|
||||
if (!selected || selected.type != type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return section.id === selected.id;
|
||||
};
|
||||
18
src/newsreader/js/pages/homepage/configureStore.js
Normal file
18
src/newsreader/js/pages/homepage/configureStore.js
Normal file
|
|
@ -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;
|
||||
2
src/newsreader/js/pages/homepage/constants.js
Normal file
2
src/newsreader/js/pages/homepage/constants.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const RULE_TYPE = 'RULE';
|
||||
export const CATEGORY_TYPE = 'CATEGORY';
|
||||
20
src/newsreader/js/pages/homepage/index.js
Normal file
20
src/newsreader/js/pages/homepage/index.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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 page = document.getElementById('homepage--page');
|
||||
|
||||
if (page) {
|
||||
const store = configureStore();
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>,
|
||||
page
|
||||
);
|
||||
}
|
||||
93
src/newsreader/js/pages/homepage/reducers/categories.js
Normal file
93
src/newsreader/js/pages/homepage/reducers/categories.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { isEqual } from 'lodash';
|
||||
|
||||
import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js';
|
||||
|
||||
import { objectsFromArray } from '../../../utils.js';
|
||||
|
||||
import {
|
||||
RECEIVE_CATEGORY,
|
||||
RECEIVE_CATEGORIES,
|
||||
REQUEST_CATEGORY,
|
||||
REQUEST_CATEGORIES,
|
||||
} from '../actions/categories.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 },
|
||||
},
|
||||
isFetching: false,
|
||||
};
|
||||
case RECEIVE_CATEGORIES:
|
||||
const receivedCategories = objectsFromArray(action.categories, 'id');
|
||||
|
||||
return {
|
||||
...state,
|
||||
items: { ...state.items, ...receivedCategories },
|
||||
isFetching: false,
|
||||
};
|
||||
case REQUEST_CATEGORIES:
|
||||
case REQUEST_CATEGORY:
|
||||
return { ...state, isFetching: true };
|
||||
case MARK_POST_READ:
|
||||
let category = {};
|
||||
|
||||
switch (action.section.type) {
|
||||
case CATEGORY_TYPE:
|
||||
category = { ...state.items[action.section.id] };
|
||||
break;
|
||||
case RULE_TYPE:
|
||||
category = { ...state.items[action.section.category] };
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
items: {
|
||||
...state.items,
|
||||
[category.id]: { ...category, unread: category.unread - 1 },
|
||||
},
|
||||
};
|
||||
case MARK_SECTION_READ:
|
||||
category = {};
|
||||
|
||||
switch (action.section.type) {
|
||||
case CATEGORY_TYPE:
|
||||
category = { ...state.items[action.section.id] };
|
||||
|
||||
return {
|
||||
...state,
|
||||
items: {
|
||||
...state.items,
|
||||
[category.id]: { ...category, unread: 0 },
|
||||
},
|
||||
};
|
||||
case RULE_TYPE:
|
||||
category = { ...state.items[action.section.category] };
|
||||
|
||||
return {
|
||||
...state,
|
||||
items: {
|
||||
...state.items,
|
||||
[category.id]: {
|
||||
...category,
|
||||
unread: category.unread - action.section.unread,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
12
src/newsreader/js/pages/homepage/reducers/error.js
Normal file
12
src/newsreader/js/pages/homepage/reducers/error.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { RECEIVE_API_ERROR } from '../actions/error.js';
|
||||
|
||||
const defaultState = {};
|
||||
|
||||
export const error = (state = { ...defaultState }, action) => {
|
||||
switch (action.type) {
|
||||
case RECEIVE_API_ERROR:
|
||||
return { ...state, error: action.error };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
11
src/newsreader/js/pages/homepage/reducers/index.js
Normal file
11
src/newsreader/js/pages/homepage/reducers/index.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { combineReducers } from 'redux';
|
||||
|
||||
import { categories } from './categories.js';
|
||||
import { error } from './error.js';
|
||||
import { rules } from './rules.js';
|
||||
import { posts } from './posts.js';
|
||||
import { selected } from './selected.js';
|
||||
|
||||
const rootReducer = combineReducers({ categories, error, rules, posts, selected });
|
||||
|
||||
export default rootReducer;
|
||||
68
src/newsreader/js/pages/homepage/reducers/posts.js
Normal file
68
src/newsreader/js/pages/homepage/reducers/posts.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { isEqual } from 'lodash';
|
||||
|
||||
import { objectsFromArray } from '../../../utils.js';
|
||||
import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js';
|
||||
|
||||
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 REQUEST_POSTS:
|
||||
return { ...state, isFetching: true };
|
||||
case RECEIVE_POST:
|
||||
return {
|
||||
...state,
|
||||
items: { ...state.items, [action.post.id]: { ...action.post } },
|
||||
};
|
||||
case RECEIVE_POSTS:
|
||||
const receivedItems = objectsFromArray(action.posts, 'id');
|
||||
|
||||
return {
|
||||
...state,
|
||||
isFetching: false,
|
||||
items: { ...state.items, ...receivedItems },
|
||||
};
|
||||
case MARK_SECTION_READ:
|
||||
const updatedPosts = {};
|
||||
let relatedPosts = [];
|
||||
|
||||
switch (action.section.type) {
|
||||
case CATEGORY_TYPE:
|
||||
relatedPosts = Object.values({ ...state.items }).filter(post => {
|
||||
return post.rule in { ...action.section.rules };
|
||||
});
|
||||
|
||||
break;
|
||||
case RULE_TYPE:
|
||||
relatedPosts = Object.values({ ...state.items }).filter(post => {
|
||||
return post.rule === action.section.id;
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
relatedPosts.forEach(post => {
|
||||
updatedPosts[post.id] = { ...post, read: true };
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
items: {
|
||||
...state.items,
|
||||
...updatedPosts,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
82
src/newsreader/js/pages/homepage/reducers/rules.js
Normal file
82
src/newsreader/js/pages/homepage/reducers/rules.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { isEqual } from 'lodash';
|
||||
|
||||
import { objectsFromArray } from '../../../utils.js';
|
||||
|
||||
import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js';
|
||||
|
||||
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:
|
||||
const receivedItems = objectsFromArray(action.rules, 'id');
|
||||
|
||||
return {
|
||||
...state,
|
||||
items: { ...state.items, ...receivedItems },
|
||||
isFetching: false,
|
||||
};
|
||||
case RECEIVE_RULE:
|
||||
return {
|
||||
...state,
|
||||
items: { ...state.items, [action.rule.id]: { ...action.rule } },
|
||||
isFetching: false,
|
||||
};
|
||||
case MARK_POST_READ:
|
||||
const rule = { ...state.items[action.post.rule] };
|
||||
|
||||
return {
|
||||
...state,
|
||||
items: {
|
||||
...state.items,
|
||||
[rule.id]: { ...rule, unread: rule.unread - 1 },
|
||||
},
|
||||
};
|
||||
case MARK_SECTION_READ:
|
||||
switch (action.section.type) {
|
||||
case RULE_TYPE:
|
||||
const rule = { ...state.items[action.section.id] };
|
||||
|
||||
return {
|
||||
...state,
|
||||
items: {
|
||||
...state.items,
|
||||
[rule.id]: { ...rule, unread: 0 },
|
||||
},
|
||||
};
|
||||
case CATEGORY_TYPE:
|
||||
const updatedRules = {};
|
||||
const categoryRules = Object.values({ ...state.items }).filter(rule => {
|
||||
return rule.category === action.section.id;
|
||||
});
|
||||
|
||||
categoryRules.forEach(rule => {
|
||||
updatedRules[rule.id] = { ...rule, unread: 0 };
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
items: {
|
||||
...state.items,
|
||||
...updatedRules,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
86
src/newsreader/js/pages/homepage/reducers/selected.js
Normal file
86
src/newsreader/js/pages/homepage/reducers/selected.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
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';
|
||||
import { MARK_POST_READ } from '../actions/posts.js';
|
||||
|
||||
const defaultState = { item: {}, next: false, lastReached: false, post: {} };
|
||||
|
||||
export const selected = (state = { ...defaultState }, action) => {
|
||||
switch (action.type) {
|
||||
case SELECT_CATEGORY:
|
||||
case SELECT_RULE:
|
||||
if (state.item) {
|
||||
if (
|
||||
state.item.id === action.section.id &&
|
||||
state.item.type === action.section.type
|
||||
) {
|
||||
if (state.item.clicks >= 2) {
|
||||
return {
|
||||
...state,
|
||||
item: { ...action.section, clicks: 1 },
|
||||
next: false,
|
||||
lastReached: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
item: { ...action.section, clicks: state.item.clicks + 1 },
|
||||
next: false,
|
||||
lastReached: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
item: { ...action.section, clicks: 1 },
|
||||
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_POST_READ:
|
||||
return {
|
||||
...state,
|
||||
item: { ...action.section, unread: action.section.unread - 1 },
|
||||
};
|
||||
case MARK_SECTION_READ:
|
||||
return {
|
||||
...state,
|
||||
item: { ...action.section, clicks: 0, unread: 0 },
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
106
src/newsreader/js/pages/rules/App.js
Normal file
106
src/newsreader/js/pages/rules/App.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import React from 'react';
|
||||
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
import Card from '../../components/Card.js';
|
||||
import RuleCard from './components/RuleCard.js';
|
||||
import RuleModal from './components/RuleModal.js';
|
||||
import Messages from '../../components/Messages.js';
|
||||
|
||||
class App extends React.Component {
|
||||
selectRule = ::this.selectRule;
|
||||
deselectRule = ::this.deselectRule;
|
||||
deleteRule = ::this.deleteRule;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.token = Cookies.get('csrftoken');
|
||||
this.state = {
|
||||
rules: props.rules,
|
||||
selectedRuleId: null,
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
selectRule(ruleId) {
|
||||
this.setState({ selectedRuleId: ruleId });
|
||||
}
|
||||
|
||||
deselectRule() {
|
||||
this.setState({ selectedRuleId: null });
|
||||
}
|
||||
|
||||
deleteRule(ruleId) {
|
||||
const url = `/api/rules/${ruleId}/`;
|
||||
const options = {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': this.token,
|
||||
},
|
||||
};
|
||||
|
||||
fetch(url, options).then(response => {
|
||||
if (response.ok) {
|
||||
const rules = this.state.rules.filter(rule => {
|
||||
return rule.pk != ruleId;
|
||||
});
|
||||
|
||||
return this.setState({
|
||||
rules: rules,
|
||||
selectedRuleId: null,
|
||||
message: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const message = {
|
||||
type: 'error',
|
||||
text: 'Unable to remove rule, try again later',
|
||||
};
|
||||
return this.setState({ selectedRuleId: null, message: message });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { rules } = this.state;
|
||||
const cards = rules.map(rule => {
|
||||
return <RuleCard key={rule.pk} rule={rule} showDialog={this.selectRule} />;
|
||||
});
|
||||
|
||||
const selectedRule = rules.find(rule => {
|
||||
return rule.pk === this.state.selectedRuleId;
|
||||
});
|
||||
|
||||
const pageHeader = (
|
||||
<>
|
||||
<h1 className="h1">Rules</h1>
|
||||
|
||||
<div className="card__header--action">
|
||||
<a className="link button button--primary" href="/rules/import/">
|
||||
Import rules
|
||||
</a>
|
||||
<a className="link button button--confirm" href="/rules/create/">
|
||||
Create rule
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{this.state.message && <Messages messages={[this.state.message]} />}
|
||||
<Card header={pageHeader} />
|
||||
{cards}
|
||||
{selectedRule && (
|
||||
<RuleModal
|
||||
rule={selectedRule}
|
||||
handleCancel={this.deselectRule}
|
||||
handleDelete={this.deleteRule}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default App;
|
||||
65
src/newsreader/js/pages/rules/components/RuleCard.js
Normal file
65
src/newsreader/js/pages/rules/components/RuleCard.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import React from 'react';
|
||||
|
||||
import Card from '../../../components/Card.js';
|
||||
|
||||
const RuleCard = props => {
|
||||
const { rule } = props;
|
||||
let favicon = null;
|
||||
|
||||
if (rule.favicon) {
|
||||
favicon = <img className="favicon" src={rule.favicon} />;
|
||||
} else {
|
||||
favicon = <i className="gg-image" />;
|
||||
}
|
||||
|
||||
const stateIcon = !rule.error ? 'gg-check' : 'gg-danger';
|
||||
|
||||
const cardHeader = (
|
||||
<>
|
||||
<i className={stateIcon} />
|
||||
<h2 className="h2">{rule.name}</h2>
|
||||
{favicon}
|
||||
</>
|
||||
);
|
||||
|
||||
const cardContent = (
|
||||
<>
|
||||
<ul className="list rules">
|
||||
{rule.error && (
|
||||
<ul className="list errorlist">
|
||||
<li className="list__item errorlist__item">{rule.error}</li>
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{rule.category && <li className="list__item">{rule.category}</li>}
|
||||
<li className="list__item">
|
||||
<a className="link" target="_blank" rel="noopener noreferrer" href={rule.url}>
|
||||
{rule.url}
|
||||
</a>
|
||||
</li>
|
||||
<li className="list__item">{rule.created}</li>
|
||||
<li className="list__item">{rule.timezone}</li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
|
||||
const cardFooter = (
|
||||
<>
|
||||
<a className="link button button--primary" href={`/rules/${rule.pk}/`}>
|
||||
Edit
|
||||
</a>
|
||||
<button
|
||||
id="rule-delete"
|
||||
className="button button--error"
|
||||
onClick={() => props.showDialog(rule.pk)}
|
||||
data-id={`${rule.pk}`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
return <Card header={cardHeader} content={cardContent} footer={cardFooter} />;
|
||||
};
|
||||
|
||||
export default RuleCard;
|
||||
35
src/newsreader/js/pages/rules/components/RuleModal.js
Normal file
35
src/newsreader/js/pages/rules/components/RuleModal.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import React from 'react';
|
||||
|
||||
import Modal from '../../../components/Modal.js';
|
||||
|
||||
const RuleModal = props => {
|
||||
const content = (
|
||||
<>
|
||||
<div>
|
||||
<div className="modal__header">
|
||||
<h1 className="h1 modal__title">Delete rule</h1>
|
||||
</div>
|
||||
|
||||
<div className="modal__content">
|
||||
<p className="p">Are you sure you want to delete {props.rule.name}?</p>
|
||||
</div>
|
||||
|
||||
<div className="modal__footer">
|
||||
<button className="button button--confirm" onClick={props.handleCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="button button--error"
|
||||
onClick={() => props.handleDelete(props.rule.pk)}
|
||||
>
|
||||
Delete rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return <Modal content={content} />;
|
||||
};
|
||||
|
||||
export default RuleModal;
|
||||
13
src/newsreader/js/pages/rules/index.js
Normal file
13
src/newsreader/js/pages/rules/index.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import App from './App.js';
|
||||
|
||||
const page = document.getElementById('rules--page');
|
||||
|
||||
if (page) {
|
||||
const dataScript = document.getElementById('rules-data');
|
||||
const rules = JSON.parse(dataScript.textContent);
|
||||
|
||||
ReactDOM.render(<App rules={rules} />, page);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue