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