0.2 release

This commit is contained in:
Sonny 2020-04-15 22:07:12 +02:00
parent 747c6416d4
commit 18479a3f56
340 changed files with 27295 additions and 0 deletions

11
.babelrc Normal file
View 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
View 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
View 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 dont 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 dont 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

61
package.json Normal file
View 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

File diff suppressed because it is too large Load diff

40
pyproject.toml Normal file
View 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
View 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
View 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()

View file

@ -0,0 +1,4 @@
from .celery import app as celery_app
__all__ = ["celery_app"]

View file

View file

@ -0,0 +1 @@
# Register your models here.

View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = "accounts"

View 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())],
)
]

View file

@ -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")]

View file

@ -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())],
)
]

View file

@ -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",
),
)
]

View file

@ -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")]

View file

@ -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",
),
)
]

View file

@ -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",
),
)
]

View 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)

View 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

View 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 %}

View 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

View 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)

View 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"))

View 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)

View 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)

View 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)

View 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
]

View 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
View 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()

View file

160
src/newsreader/conf/base.py Normal file
View 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

View 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

View 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",
},
}

View 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",
},
}

View 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

View file

View file

@ -0,0 +1 @@
# Register your models here.

View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = "core"

View 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

View 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

View 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

View file

@ -0,0 +1 @@
# Create your tests here.

View file

@ -0,0 +1 @@
# Create your views here.

View 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"
]
}
}
]

View 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"
}
}
]

View 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;

View file

@ -0,0 +1,13 @@
import React from 'react';
const LoadingIndicator = props => {
return (
<div className="loading-indicator">
<div />
<div />
<div />
</div>
);
};
export default LoadingIndicator;

View 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;

View 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;

View file

@ -0,0 +1,3 @@
import './pages/homepage/index.js';
import './pages/rules/index.js';
import './pages/categories/index.js';

View 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;

View file

@ -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;

View file

@ -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;

View 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);
}

View 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);

View 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));
});
};
};

View file

@ -0,0 +1,6 @@
export const RECEIVE_API_ERROR = 'RECEIVE_API_ERROR';
export const handleAPIError = error => ({
type: RECEIVE_API_ERROR,
error,
});

View 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));
});
};
};

View 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));
});
};
};

View 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);
}
};

View 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);

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -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 [];
};

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -0,0 +1,7 @@
export const filterCategories = (categories = {}) => {
return Object.values({ ...categories });
};
export const filterRules = (rules = {}) => {
return Object.values({ ...rules });
};

View file

@ -0,0 +1,7 @@
export const isSelected = (section, selected, type) => {
if (!selected || selected.type != type) {
return false;
}
return section.id === selected.id;
};

View 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;

View file

@ -0,0 +1,2 @@
export const RULE_TYPE = 'RULE';
export const CATEGORY_TYPE = 'CATEGORY';

View 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
);
}

View 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;
}
};

View 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 {};
}
};

View 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;

View 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;
}
};

View 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;
}
};

View 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;
}
};

View 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;

View 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;

View 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;

View 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