Compare commits
No commits in common. "main" and "0.4.0" have entirely different histories.
269 changed files with 43359 additions and 12447 deletions
11
.babelrc
Normal file
11
.babelrc
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"presets": ["@babel/preset-env"],
|
||||||
|
"plugins": [
|
||||||
|
"@babel/plugin-transform-runtime",
|
||||||
|
"@babel/plugin-syntax-dynamic-import",
|
||||||
|
"@babel/plugin-transform-react-jsx",
|
||||||
|
"@babel/plugin-syntax-function-bind",
|
||||||
|
"@babel/plugin-proposal-function-bind",
|
||||||
|
["@babel/plugin-proposal-class-properties", {loose: true}],
|
||||||
|
]
|
||||||
|
}
|
||||||
16
.coveragerc
Normal file
16
.coveragerc
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[run]
|
||||||
|
source = ./src/newsreader/
|
||||||
|
omit =
|
||||||
|
**/tests/**
|
||||||
|
**/migrations/**
|
||||||
|
**/conf/**
|
||||||
|
**/apps.py
|
||||||
|
**/admin.py
|
||||||
|
**/tests.py
|
||||||
|
**/urls.py
|
||||||
|
**/wsgi.py
|
||||||
|
**/celery.py
|
||||||
|
**/__init__.py
|
||||||
|
|
||||||
|
[html]
|
||||||
|
directory = coverage
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
# https://editorconfig.org
|
|
||||||
|
|
||||||
# top-most EditorConfig file
|
|
||||||
root = true
|
|
||||||
|
|
||||||
# Unix-style newlines with a newline ending every file
|
|
||||||
[*]
|
|
||||||
end_of_line = lf
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
|
|
||||||
[*.py]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 4
|
|
||||||
|
|
||||||
[*.{yaml,yml,toml,md}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[Dockerfile*]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 4
|
|
||||||
|
|
||||||
[*.json]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
27
.gitlab-ci.yml
Normal file
27
.gitlab-ci.yml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- test
|
||||||
|
- lint
|
||||||
|
- release
|
||||||
|
|
||||||
|
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:
|
||||||
|
- env/
|
||||||
|
- .cache/pip
|
||||||
|
- node_modules/
|
||||||
|
|
||||||
|
include:
|
||||||
|
- local: '/gitlab-ci/build.yml'
|
||||||
|
- local: '/gitlab-ci/test.yml'
|
||||||
|
- local: '/gitlab-ci/lint.yml'
|
||||||
|
- local: '/gitlab-ci/release.yml'
|
||||||
12
.isort.cfg
Normal file
12
.isort.cfg
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[settings]
|
||||||
|
include_trailing_comma = true
|
||||||
|
line_length = 88
|
||||||
|
multi_line_output = 3
|
||||||
|
skip = env/, venv/
|
||||||
|
default_section = THIRDPARTY
|
||||||
|
known_first_party = newsreader
|
||||||
|
known_django = django
|
||||||
|
sections = FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
|
||||||
|
lines_between_types=1
|
||||||
|
lines_after_imports=2
|
||||||
|
lines_between_types=1
|
||||||
1
.nvmrc
1
.nvmrc
|
|
@ -1 +0,0 @@
|
||||||
lts/*
|
|
||||||
10
.prettierrc.json
Normal file
10
.prettierrc.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 90,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "avoid"
|
||||||
|
}
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
when:
|
|
||||||
- event: push
|
|
||||||
- event: pull_request
|
|
||||||
- event: manual
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- image: node:lts-alpine
|
|
||||||
commands:
|
|
||||||
- npm install
|
|
||||||
- npm run build:prod
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
when:
|
|
||||||
- event: push
|
|
||||||
- event: pull_request
|
|
||||||
- event: manual
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: python linting
|
|
||||||
image: ghcr.io/astral-sh/uv:python3.11-alpine
|
|
||||||
commands:
|
|
||||||
- uv sync --group ci
|
|
||||||
- uv run --no-sync -- ruff check src/
|
|
||||||
- uv run --no-sync -- ruff format --check src/
|
|
||||||
|
|
||||||
- name: javascript linting
|
|
||||||
image: node:lts-alpine
|
|
||||||
commands:
|
|
||||||
- npm ci
|
|
||||||
- npm run lint
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
when:
|
|
||||||
- event: push
|
|
||||||
- event: pull_request
|
|
||||||
- event: manual
|
|
||||||
|
|
||||||
services:
|
|
||||||
- name: postgres
|
|
||||||
image: postgres:15
|
|
||||||
environment:
|
|
||||||
POSTGRES_NAME: &db-name newsreader
|
|
||||||
POSTGRES_USER: &db-user newsreader
|
|
||||||
POSTGRES_PASSWORD: &db-password sekrit
|
|
||||||
- name: memcached
|
|
||||||
image: memcached:1.5.22
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: python tests
|
|
||||||
image: ghcr.io/astral-sh/uv:python3.11-alpine
|
|
||||||
environment:
|
|
||||||
DJANGO_SETTINGS_MODULE: "newsreader.conf.ci"
|
|
||||||
DJANGO_SECRET_KEY: sekrit
|
|
||||||
POSTGRES_HOST: postgres
|
|
||||||
POSTGRES_PORT: 5432
|
|
||||||
POSTGRES_DB: *db-name
|
|
||||||
POSTGRES_USER: *db-user
|
|
||||||
POSTGRES_PASSWORD: *db-password
|
|
||||||
commands:
|
|
||||||
- pip install uv
|
|
||||||
- uv sync --group ci
|
|
||||||
- uv run --no-sync -- coverage run ./src/manage.py test newsreader
|
|
||||||
- uv run --no-sync -- coverage report --show-missing
|
|
||||||
|
|
||||||
- name: javascript tests
|
|
||||||
image: node:lts-alpine
|
|
||||||
commands:
|
|
||||||
- npm ci
|
|
||||||
- npm test
|
|
||||||
39
CHANGELOG.md
39
CHANGELOG.md
|
|
@ -1,44 +1,5 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.5.3
|
|
||||||
|
|
||||||
- Apply query optimizations for retrieving posts
|
|
||||||
|
|
||||||
## 0.5.2
|
|
||||||
|
|
||||||
- Add missing `VERSION` environment variable
|
|
||||||
|
|
||||||
## 0.5.1
|
|
||||||
|
|
||||||
- Use line-through styling for read posts
|
|
||||||
- Use full height for post layout
|
|
||||||
|
|
||||||
## 0.5.0
|
|
||||||
|
|
||||||
- Upgrade python to 3.11
|
|
||||||
- Upgrade django to 4.2
|
|
||||||
- Migrate from pip-tools to uv
|
|
||||||
- Migrate from black to ruff for formatting
|
|
||||||
- Upgrade webpack to 5.9 (with various tooling)
|
|
||||||
- Styling refactor
|
|
||||||
- Mobile/tablet layout added
|
|
||||||
|
|
||||||
## 0.4.4
|
|
||||||
|
|
||||||
- Sort posts before storing in redux store
|
|
||||||
|
|
||||||
## 0.4.3
|
|
||||||
|
|
||||||
- Use `IntersectionObserver` to paginate
|
|
||||||
|
|
||||||
## 0.4.2
|
|
||||||
|
|
||||||
- Set `SECURE_PROXY_SSL_HEADER` setting for production
|
|
||||||
|
|
||||||
## 0.4.1
|
|
||||||
|
|
||||||
- Add missing env variables
|
|
||||||
|
|
||||||
## 0.4.0
|
## 0.4.0
|
||||||
|
|
||||||
- Add Makefile & use `pip-tools` to generate dependencies
|
- Add Makefile & use `pip-tools` to generate dependencies
|
||||||
|
|
|
||||||
84
Dockerfile
84
Dockerfile
|
|
@ -1,84 +0,0 @@
|
||||||
# stage 1
|
|
||||||
FROM python:3.11-alpine AS backend
|
|
||||||
|
|
||||||
ARG USER_ID=1000
|
|
||||||
ARG GROUP_ID=1000
|
|
||||||
ARG UV_LINK_MODE=copy
|
|
||||||
|
|
||||||
RUN apk update \
|
|
||||||
&& apk add --no-cache \
|
|
||||||
vim \
|
|
||||||
curl \
|
|
||||||
gettext
|
|
||||||
|
|
||||||
RUN addgroup -g $USER_ID newsreader && adduser -Du $GROUP_ID -G newsreader newsreader
|
|
||||||
|
|
||||||
RUN mkdir --parents /app/src /app/logs /app/media /app/bin /app/static \
|
|
||||||
&& chown -R newsreader:newsreader /app
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
USER newsreader
|
|
||||||
|
|
||||||
COPY --chown=newsreader:newsreader uv.lock pyproject.toml /app/
|
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:python3.11-alpine /usr/local/bin/uv /bin/uv
|
|
||||||
|
|
||||||
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
|
|
||||||
uv sync --frozen --no-default-groups --no-install-project
|
|
||||||
|
|
||||||
COPY --chown=newsreader:newsreader ./bin/docker-entrypoint.sh /app/bin/docker-entrypoint.sh
|
|
||||||
|
|
||||||
VOLUME ["/app/logs", "/app/media", "/app/static"]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# stage 2
|
|
||||||
FROM node:lts-alpine AS frontend-build
|
|
||||||
|
|
||||||
ARG BUILD_ARG=prod
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN chown node:node /app
|
|
||||||
|
|
||||||
USER node
|
|
||||||
|
|
||||||
COPY --chown=node:node ./package*.json ./webpack.*.js ./babel.config.js /app/
|
|
||||||
|
|
||||||
RUN --mount=type=cache,uid=1000,gid=1000,target=/home/node/.npm \
|
|
||||||
npm ci
|
|
||||||
|
|
||||||
COPY --chown=node:node ./src /app/src
|
|
||||||
|
|
||||||
RUN npm run build:$BUILD_ARG
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# stage 3
|
|
||||||
FROM backend AS production
|
|
||||||
|
|
||||||
COPY --from=frontend-build --chown=newsreader:newsreader \
|
|
||||||
/app/src/newsreader/static /app/src/newsreader/static
|
|
||||||
|
|
||||||
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
|
|
||||||
uv sync --frozen --only-group production --extra sentry
|
|
||||||
|
|
||||||
COPY --chown=newsreader:newsreader ./src /app/src
|
|
||||||
|
|
||||||
ENV DJANGO_SETTINGS_MODULE=newsreader.conf.production
|
|
||||||
|
|
||||||
# Note that the static volume will have to be recreated to be pre-populated
|
|
||||||
# correctly with the latest static files. See
|
|
||||||
# https://docs.docker.com/storage/volumes/#populate-a-volume-using-a-container
|
|
||||||
RUN uv run --no-sync -- src/manage.py collectstatic --noinput
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# (optional) stage 4
|
|
||||||
FROM backend AS development
|
|
||||||
|
|
||||||
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
|
|
||||||
uv sync --frozen --group development
|
|
||||||
|
|
||||||
ENV DJANGO_SETTINGS_MODULE=newsreader.conf.docker
|
|
||||||
45
Makefile
Normal file
45
Makefile
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Note: run this file from within your virtualenv!
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# Build dependencies
|
||||||
|
build:
|
||||||
|
pip-compile \
|
||||||
|
--resolver=backtracking \
|
||||||
|
--output-file=requirements/base.txt \
|
||||||
|
pyproject.toml
|
||||||
|
|
||||||
|
# testing
|
||||||
|
pip-compile \
|
||||||
|
--resolver=backtracking \
|
||||||
|
--extra=testing \
|
||||||
|
--output-file=requirements/testing.txt \
|
||||||
|
requirements/base.txt \
|
||||||
|
pyproject.toml
|
||||||
|
|
||||||
|
# development
|
||||||
|
pip-compile \
|
||||||
|
--resolver=backtracking \
|
||||||
|
--extra=testing \
|
||||||
|
--extra=development \
|
||||||
|
--output-file=requirements/development.txt \
|
||||||
|
requirements/base.txt \
|
||||||
|
requirements/testing.txt \
|
||||||
|
pyproject.toml
|
||||||
|
|
||||||
|
# ci
|
||||||
|
pip-compile \
|
||||||
|
--resolver=backtracking \
|
||||||
|
--extra=testing \
|
||||||
|
--extra=ci \
|
||||||
|
--output-file=requirements/ci.txt \
|
||||||
|
requirements/base.txt \
|
||||||
|
requirements/testing.txt \
|
||||||
|
pyproject.toml
|
||||||
|
|
||||||
|
# production
|
||||||
|
pip-compile \
|
||||||
|
--resolver=backtracking \
|
||||||
|
--extra=production \
|
||||||
|
--output-file=requirements/production.txt \
|
||||||
|
requirements/base.txt \
|
||||||
|
pyproject.toml
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
module.exports = api => {
|
|
||||||
const isTest = api.env('test');
|
|
||||||
|
|
||||||
const preset = [
|
|
||||||
"@babel/preset-env", { targets: 'defaults' }
|
|
||||||
];
|
|
||||||
const testPreset = [
|
|
||||||
"@babel/preset-env", { targets: { node: process.versions.node } }
|
|
||||||
];
|
|
||||||
|
|
||||||
const plugins = [
|
|
||||||
"@babel/plugin-syntax-dynamic-import",
|
|
||||||
"@babel/plugin-transform-react-jsx",
|
|
||||||
"@babel/plugin-proposal-class-properties"
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"presets": [isTest ? testPreset : preset],
|
|
||||||
"plugins": plugins
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
uv run --no-sync -- /app/src/manage.py migrate
|
python /app/src/manage.py migrate
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,9 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
proxy_pass http://gunicorn;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_pass http://gunicorn;
|
proxy_redirect off;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
|
version: "3.6"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
static-files:
|
static-files:
|
||||||
|
node-modules:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
django:
|
celery:
|
||||||
build: &app-development-build
|
build:
|
||||||
target: development
|
target: development
|
||||||
command: uv run --no-sync -- /app/src/manage.py runserver 0.0.0.0:8000
|
volumes:
|
||||||
environment: &django-env
|
- ./src/:/app/src
|
||||||
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-newsreader.conf.docker}
|
|
||||||
|
django:
|
||||||
|
build:
|
||||||
|
target: development
|
||||||
|
command: python /app/src/manage.py runserver 0.0.0.0:8000
|
||||||
ports:
|
ports:
|
||||||
- "${DJANGO_PORT:-8000}:8000"
|
- "${DJANGO_PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -16,21 +23,12 @@ services:
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
|
|
||||||
celery:
|
|
||||||
build:
|
|
||||||
<<: *app-development-build
|
|
||||||
environment:
|
|
||||||
<<: *django-env
|
|
||||||
volumes:
|
|
||||||
- ./src/:/app/src
|
|
||||||
|
|
||||||
webpack:
|
webpack:
|
||||||
build:
|
build:
|
||||||
target: frontend-build
|
|
||||||
context: .
|
context: .
|
||||||
args:
|
dockerfile: ./docker/webpack
|
||||||
BUILD_ARG: "dev"
|
|
||||||
command: npm run build:watch
|
command: npm run build:watch
|
||||||
volumes:
|
volumes:
|
||||||
- ./src/:/app/src
|
- ./src/:/app/src
|
||||||
- static-files:/app/src/newsreader/static
|
- static-files:/app/src/newsreader/static
|
||||||
|
- node-modules:/app/node_modules
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
version: "3.6"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
logs:
|
logs:
|
||||||
static-files:
|
static-files:
|
||||||
|
|
@ -9,6 +11,7 @@ services:
|
||||||
django:
|
django:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
|
# Note that --env-file should be used to set these correctly
|
||||||
- "${NGINX_HTTP_PORT:-80}:80"
|
- "${NGINX_HTTP_PORT:-80}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/nginx/conf.d:/etc/nginx/conf.d
|
- ./config/nginx/conf.d:/etc/nginx/conf.d
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,22 @@
|
||||||
|
version: "3.6"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
logs:
|
logs:
|
||||||
media:
|
media:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
static-files:
|
static-files:
|
||||||
|
|
||||||
x-db-connection-env: &db-connection-env
|
|
||||||
POSTGRES_HOST: ${POSTGRES_HOST:-db}
|
|
||||||
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
|
|
||||||
POSTGRES_DB: &pg-database ${POSTGRES_DB:-newsreader}
|
|
||||||
POSTGRES_USER: &pg-user ${POSTGRES_USER:-newsreader}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-newsreader}
|
|
||||||
|
|
||||||
x-db-env: &db-env
|
x-db-env: &db-env
|
||||||
<<: *db-connection-env
|
POSTGRES_HOST:
|
||||||
PGUSER: *pg-user
|
POSTGRES_PORT:
|
||||||
PGDATABASE: *pg-database
|
POSTGRES_DB:
|
||||||
|
POSTGRES_USER:
|
||||||
|
POSTGRES_PASSWORD:
|
||||||
|
|
||||||
x-django-env: &django-env
|
x-django-env: &django-env
|
||||||
<<: *db-connection-env
|
<<: *db-env
|
||||||
|
DJANGO_SECRET_KEY:
|
||||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,django}
|
DJANGO_SETTINGS_MODULE:
|
||||||
INTERNAL_IPS: ${INTERNAL_IPS:-localhost,127.0.0.1,django}
|
|
||||||
|
|
||||||
# see token_urlsafe from python's secret module to generate one
|
|
||||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-Ojg68lYsP3kq2r5JgozUzKVSRFywm17BTMS5iwpLM44}
|
|
||||||
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-newsreader.conf.production}
|
|
||||||
|
|
||||||
ADMINS: ${ADMINS:-""}
|
|
||||||
|
|
||||||
VERSION: ${VERSION:-""}
|
|
||||||
|
|
||||||
# Email
|
|
||||||
EMAIL_HOST: ${EMAIL_HOST:-localhost}
|
|
||||||
EMAIL_PORT: ${EMAIL_PORT:-25}
|
|
||||||
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-""}
|
|
||||||
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-""}
|
|
||||||
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-no}
|
|
||||||
EMAIL_USE_SSL: ${EMAIL_USE_SSL:-no}
|
|
||||||
EMAIL_DEFAULT_FROM: ${EMAIL_DEFAULT_FROM:-webmaster@localhost}
|
|
||||||
|
|
||||||
# Sentry
|
|
||||||
SENTRY_DSN: ${SENTRY_DSN:-""}
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
|
|
@ -48,8 +24,8 @@ services:
|
||||||
<<: *db-env
|
<<: *db-env
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: /usr/bin/pg_isready
|
# Note that --env-file should be used to set these correctly
|
||||||
start_period: 10s
|
test: /usr/bin/pg_isready --username="${POSTGRES_USER}" --dbname="${POSTGRES_DB}"
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
@ -57,7 +33,7 @@ services:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: rabbitmq:4
|
image: rabbitmq:3.12
|
||||||
|
|
||||||
memcached:
|
memcached:
|
||||||
image: memcached:1.6
|
image: memcached:1.6
|
||||||
|
|
@ -65,26 +41,56 @@ services:
|
||||||
- memcached
|
- memcached
|
||||||
- -m 64
|
- -m 64
|
||||||
|
|
||||||
django:
|
celery:
|
||||||
build: &app-build
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
dockerfile: ./docker/django
|
||||||
target: production
|
target: production
|
||||||
|
args:
|
||||||
|
<<: *django-env
|
||||||
environment:
|
environment:
|
||||||
<<: *django-env
|
<<: *django-env
|
||||||
entrypoint: ["/bin/sh", "/app/bin/docker-entrypoint.sh"]
|
|
||||||
command: |
|
command: |
|
||||||
uv run --no-sync --
|
celery --app newsreader
|
||||||
gunicorn
|
--workdir /app/src/
|
||||||
--bind 0.0.0.0:8000
|
worker --loglevel INFO
|
||||||
|
--concurrency 2
|
||||||
|
--beat
|
||||||
|
--scheduler django
|
||||||
|
-n worker1@%h
|
||||||
|
-n worker2@%h
|
||||||
|
depends_on:
|
||||||
|
rabbitmq:
|
||||||
|
condition: service_started
|
||||||
|
memcached:
|
||||||
|
condition: service_started
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
django:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- logs:/app/logs
|
||||||
|
|
||||||
|
django:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./docker/django
|
||||||
|
target: production
|
||||||
|
args:
|
||||||
|
<<: *django-env
|
||||||
|
environment:
|
||||||
|
<<: *django-env
|
||||||
|
entrypoint: /app/bin/docker-entrypoint.sh
|
||||||
|
command: |
|
||||||
|
gunicorn --bind 0.0.0.0:8000
|
||||||
--workers 3
|
--workers 3
|
||||||
--chdir /app/src/
|
--chdir /app/src/
|
||||||
newsreader.wsgi:application
|
newsreader.wsgi:application
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: /usr/bin/curl --fail http://django:8000 || exit 1
|
test: /usr/bin/curl --fail http://django:8000 || exit 1
|
||||||
start_period: 10s
|
interval: 30s
|
||||||
interval: 10s
|
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 10
|
||||||
depends_on:
|
depends_on:
|
||||||
memcached:
|
memcached:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|
@ -94,33 +100,3 @@ services:
|
||||||
- logs:/app/logs
|
- logs:/app/logs
|
||||||
- media:/app/media
|
- media:/app/media
|
||||||
- static-files:/app/static
|
- static-files:/app/static
|
||||||
|
|
||||||
celery:
|
|
||||||
build:
|
|
||||||
<<: *app-build
|
|
||||||
environment:
|
|
||||||
<<: *django-env
|
|
||||||
command: |
|
|
||||||
uv run --no-sync --
|
|
||||||
celery
|
|
||||||
--app newsreader
|
|
||||||
--workdir /app/src/
|
|
||||||
worker --loglevel INFO
|
|
||||||
--concurrency 2
|
|
||||||
--beat
|
|
||||||
--scheduler django
|
|
||||||
-n worker1@%h
|
|
||||||
-n worker2@%h
|
|
||||||
healthcheck:
|
|
||||||
test: uv run --no-sync -- celery --app newsreader status || exit 1
|
|
||||||
start_period: 10s
|
|
||||||
interval: 10s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
depends_on:
|
|
||||||
rabbitmq:
|
|
||||||
condition: service_started
|
|
||||||
django:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
|
||||||
- logs:/app/logs
|
|
||||||
|
|
|
||||||
108
docker/django
Normal file
108
docker/django
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
# stage 1
|
||||||
|
FROM python:3.9-bullseye as backend
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
vim \
|
||||||
|
curl \
|
||||||
|
gettext \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN mkdir /app/src
|
||||||
|
RUN mkdir /app/logs
|
||||||
|
RUN mkdir /app/media
|
||||||
|
|
||||||
|
COPY ./requirements /app/requirements
|
||||||
|
|
||||||
|
RUN pip install -r requirements/base.txt
|
||||||
|
|
||||||
|
|
||||||
|
# stage 2
|
||||||
|
FROM node:16-bullseye AS frontend-build
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY ./*.json ./*.js ./.babelrc /app/
|
||||||
|
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY ./src /app/src
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
# stage 3
|
||||||
|
FROM python:3.9-bullseye as production
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
postgresql-client \
|
||||||
|
vim \
|
||||||
|
curl \
|
||||||
|
gettext \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN mkdir /app/logs
|
||||||
|
RUN mkdir /app/media
|
||||||
|
RUN mkdir /app/bin
|
||||||
|
|
||||||
|
COPY --from=backend /usr/local/lib/python3.9 /usr/local/lib/python3.9
|
||||||
|
COPY --from=backend /usr/local/bin/celery /usr/local/bin/celery
|
||||||
|
|
||||||
|
COPY ./bin/docker-entrypoint.sh /app/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
COPY --from=frontend-build /app/src/newsreader/static /app/src/newsreader/static
|
||||||
|
|
||||||
|
COPY ./src /app/src
|
||||||
|
|
||||||
|
COPY ./requirements /app/requirements
|
||||||
|
|
||||||
|
RUN pip install -r requirements/production.txt
|
||||||
|
|
||||||
|
RUN useradd -M -u 1000 newsreader
|
||||||
|
RUN chown -R newsreader:newsreader /app
|
||||||
|
|
||||||
|
USER newsreader
|
||||||
|
|
||||||
|
ARG POSTGRES_HOST
|
||||||
|
ARG POSTGRES_PORT
|
||||||
|
ARG POSTGRES_DB
|
||||||
|
ARG POSTGRES_USER
|
||||||
|
ARG POSTGRES_PASSWORD
|
||||||
|
ARG DJANGO_SECRET_KEY
|
||||||
|
ARG DJANGO_SETTINGS_MODULE
|
||||||
|
|
||||||
|
RUN python src/manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
|
||||||
|
# (optional) stage 4
|
||||||
|
FROM python:3.9-bullseye as development
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
vim \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN mkdir /app/logs
|
||||||
|
RUN mkdir /app/media
|
||||||
|
RUN mkdir /app/bin
|
||||||
|
|
||||||
|
COPY ./requirements /app/requirements
|
||||||
|
COPY ./bin/docker-entrypoint.sh /app/bin/docker-entrypoint.sh
|
||||||
|
COPY --from=backend /usr/local/lib/python3.9 /usr/local/lib/python3.9
|
||||||
|
COPY --from=backend /usr/local/bin/celery /usr/local/bin/celery
|
||||||
|
COPY --from=backend /app/src/ /app/src/
|
||||||
|
|
||||||
|
RUN pip install -r requirements/development.txt
|
||||||
|
|
||||||
|
RUN useradd -M -u 1000 newsreader
|
||||||
|
RUN chown -R newsreader:newsreader /app
|
||||||
|
|
||||||
|
USER newsreader
|
||||||
10
docker/webpack
Normal file
10
docker/webpack
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
FROM node:16-bullseye
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN mkdir /app/src
|
||||||
|
|
||||||
|
COPY package*.json webpack.*.js .babelrc /app/
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY ./src /app/src
|
||||||
7
gitlab-ci/build.yml
Normal file
7
gitlab-ci/build.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
static:
|
||||||
|
stage: build
|
||||||
|
image: node:16-bullseye
|
||||||
|
before_script:
|
||||||
|
- npm install
|
||||||
|
script:
|
||||||
|
- npm run build
|
||||||
25
gitlab-ci/lint.yml
Normal file
25
gitlab-ci/lint.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
python-linting:
|
||||||
|
stage: lint
|
||||||
|
image: python:3.9-bullseye
|
||||||
|
before_script:
|
||||||
|
- pip install -r requirements/ci.txt
|
||||||
|
script:
|
||||||
|
- isort src/ --check-only
|
||||||
|
- black src/ --line-length 88 --check
|
||||||
|
- autoflake src/ --check --recursive --remove-all-unused-imports --ignore-init-module-imports
|
||||||
|
only:
|
||||||
|
refs:
|
||||||
|
- development
|
||||||
|
- merge_requests
|
||||||
|
|
||||||
|
javascript-linting:
|
||||||
|
stage: lint
|
||||||
|
image: node:16-bullseye
|
||||||
|
before_script:
|
||||||
|
- npm install
|
||||||
|
script:
|
||||||
|
- npm run lint
|
||||||
|
only:
|
||||||
|
refs:
|
||||||
|
- development
|
||||||
|
- merge_requests
|
||||||
12
gitlab-ci/release.yml
Normal file
12
gitlab-ci/release.yml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
release:
|
||||||
|
stage: release
|
||||||
|
image: registry.gitlab.com/gitlab-org/release-cli:latest
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_TAG
|
||||||
|
script:
|
||||||
|
- echo 'running release job'
|
||||||
|
release:
|
||||||
|
name: 'Release $CI_COMMIT_TAG'
|
||||||
|
description: './CHANGELOG.md'
|
||||||
|
tag_name: '$CI_COMMIT_TAG'
|
||||||
|
ref: '$CI_COMMIT_TAG'
|
||||||
19
gitlab-ci/test.yml
Normal file
19
gitlab-ci/test.yml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
python-tests:
|
||||||
|
stage: test
|
||||||
|
coverage: '/TOTAL\s+\d+\s+\d+\s+(\d+%)/'
|
||||||
|
services:
|
||||||
|
- postgres:15
|
||||||
|
- memcached:1.5.22
|
||||||
|
image: python:3.9-bullseye
|
||||||
|
before_script:
|
||||||
|
- pip install -r requirements/ci.txt
|
||||||
|
script:
|
||||||
|
- coverage run ./src/manage.py test newsreader
|
||||||
|
|
||||||
|
javascript-tests:
|
||||||
|
stage: test
|
||||||
|
image: node:16-bullseye
|
||||||
|
before_script:
|
||||||
|
- npm install
|
||||||
|
script:
|
||||||
|
- npm test
|
||||||
188
jest.config.js
Normal file
188
jest.config.js
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
// For a detailed explanation regarding each configuration property, visit:
|
||||||
|
// https://jestjs.io/docs/en/configuration.html
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// All imported modules in your tests should be mocked automatically
|
||||||
|
// automock: false,
|
||||||
|
|
||||||
|
// Stop running tests after `n` failures
|
||||||
|
// bail: 0,
|
||||||
|
|
||||||
|
// Respect "browser" field in package.json when resolving modules
|
||||||
|
// browser: false,
|
||||||
|
|
||||||
|
// The directory where Jest should store its cached dependency information
|
||||||
|
// cacheDirectory: "/tmp/jest_rs",
|
||||||
|
|
||||||
|
// Automatically clear mock calls and instances between every test
|
||||||
|
clearMocks: true,
|
||||||
|
|
||||||
|
// Indicates whether the coverage information should be collected while executing the test
|
||||||
|
// collectCoverage: false,
|
||||||
|
|
||||||
|
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||||
|
// collectCoverageFrom: null,
|
||||||
|
|
||||||
|
// The directory where Jest should output its coverage files
|
||||||
|
coverageDirectory: 'coverage',
|
||||||
|
|
||||||
|
// An array of regexp pattern strings used to skip coverage collection
|
||||||
|
// coveragePathIgnorePatterns: [
|
||||||
|
// "/node_modules/"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// A list of reporter names that Jest uses when writing coverage reports
|
||||||
|
// coverageReporters: [
|
||||||
|
// "json",
|
||||||
|
// "text",
|
||||||
|
// "lcov",
|
||||||
|
// "clover"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// An object that configures minimum threshold enforcement for coverage results
|
||||||
|
// coverageThreshold: null,
|
||||||
|
|
||||||
|
// A path to a custom dependency extractor
|
||||||
|
// dependencyExtractor: null,
|
||||||
|
|
||||||
|
// Make calling deprecated APIs throw helpful error messages
|
||||||
|
// errorOnDeprecated: false,
|
||||||
|
|
||||||
|
// Force coverage collection from ignored files using an array of glob patterns
|
||||||
|
// forceCoverageMatch: [],
|
||||||
|
|
||||||
|
// A path to a module which exports an async function that is triggered once before all test suites
|
||||||
|
// globalSetup: null,
|
||||||
|
|
||||||
|
// A path to a module which exports an async function that is triggered once after all test suites
|
||||||
|
// globalTeardown: null,
|
||||||
|
|
||||||
|
// A set of global variables that need to be available in all test environments
|
||||||
|
// globals: {},
|
||||||
|
|
||||||
|
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||||
|
// maxWorkers: "50%",
|
||||||
|
|
||||||
|
// An array of directory names to be searched recursively up from the requiring module's location
|
||||||
|
// moduleDirectories: [
|
||||||
|
// "node_modules"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// An array of file extensions your modules use
|
||||||
|
// moduleFileExtensions: [
|
||||||
|
// "js",
|
||||||
|
// "json",
|
||||||
|
// "jsx",
|
||||||
|
// "ts",
|
||||||
|
// "tsx",
|
||||||
|
// "node"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// A map from regular expressions to module names that allow to stub out resources with a single module
|
||||||
|
// moduleNameMapper: {},
|
||||||
|
|
||||||
|
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||||
|
// modulePathIgnorePatterns: [],
|
||||||
|
|
||||||
|
// Activates notifications for test results
|
||||||
|
// notify: false,
|
||||||
|
|
||||||
|
// An enum that specifies notification mode. Requires { notify: true }
|
||||||
|
// notifyMode: "failure-change",
|
||||||
|
|
||||||
|
// A preset that is used as a base for Jest's configuration
|
||||||
|
// preset: null,
|
||||||
|
|
||||||
|
// Run tests from one or more projects
|
||||||
|
// projects: null,
|
||||||
|
|
||||||
|
// Use this configuration option to add custom reporters to Jest
|
||||||
|
// reporters: undefined,
|
||||||
|
|
||||||
|
// Automatically reset mock state between every test
|
||||||
|
// resetMocks: false,
|
||||||
|
|
||||||
|
// Reset the module registry before running each individual test
|
||||||
|
// resetModules: false,
|
||||||
|
|
||||||
|
// A path to a custom resolver
|
||||||
|
// resolver: null,
|
||||||
|
|
||||||
|
// Automatically restore mock state between every test
|
||||||
|
// restoreMocks: false,
|
||||||
|
|
||||||
|
// The root directory that Jest should scan for tests and modules within
|
||||||
|
rootDir: 'src/newsreader/js/tests/',
|
||||||
|
|
||||||
|
// A list of paths to directories that Jest should use to search for files in
|
||||||
|
// roots: [
|
||||||
|
// "<rootDir>"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// Allows you to use a custom runner instead of Jest's default test runner
|
||||||
|
// runner: "jest-runner",
|
||||||
|
|
||||||
|
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||||
|
// setupFiles: [],
|
||||||
|
|
||||||
|
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||||
|
// setupFilesAfterEnv: [],
|
||||||
|
|
||||||
|
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||||
|
// snapshotSerializers: [],
|
||||||
|
|
||||||
|
// The test environment that will be used for testing
|
||||||
|
testEnvironment: 'node',
|
||||||
|
|
||||||
|
// Options that will be passed to the testEnvironment
|
||||||
|
// testEnvironmentOptions: {},
|
||||||
|
|
||||||
|
// Adds a location field to test results
|
||||||
|
// testLocationInResults: false,
|
||||||
|
|
||||||
|
// The glob patterns Jest uses to detect test files
|
||||||
|
// testMatch: [
|
||||||
|
// "**/__tests__/**/*.[jt]s?(x)",
|
||||||
|
// "**/?(*.)+(spec|test).[tj]s?(x)"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||||
|
// testPathIgnorePatterns: [
|
||||||
|
// "/node_modules/"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||||
|
// testRegex: [],
|
||||||
|
|
||||||
|
// This option allows the use of a custom results processor
|
||||||
|
// testResultsProcessor: null,
|
||||||
|
|
||||||
|
// This option allows use of a custom test runner
|
||||||
|
// testRunner: "jasmine2",
|
||||||
|
|
||||||
|
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
|
||||||
|
// testURL: "http://localhost",
|
||||||
|
|
||||||
|
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
|
||||||
|
// timers: "real",
|
||||||
|
|
||||||
|
// A map from regular expressions to paths to transformers
|
||||||
|
// transform: null,
|
||||||
|
|
||||||
|
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||||
|
// transformIgnorePatterns: [
|
||||||
|
// "/node_modules/"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||||
|
// unmockedModulePathPatterns: undefined,
|
||||||
|
|
||||||
|
// Indicates whether each individual test should be reported during the run
|
||||||
|
// verbose: null,
|
||||||
|
|
||||||
|
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||||
|
// watchPathIgnorePatterns: [],
|
||||||
|
|
||||||
|
// Whether to use watchman for file crawling
|
||||||
|
// watchman: true,
|
||||||
|
};
|
||||||
29985
package-lock.json
generated
29985
package-lock.json
generated
File diff suppressed because it is too large
Load diff
42
package.json
42
package.json
|
|
@ -1,19 +1,20 @@
|
||||||
{
|
{
|
||||||
"name": "newsreader",
|
"name": "newsreader",
|
||||||
"version": "0.5.3",
|
"version": "0.3.13.8",
|
||||||
"description": "Application for viewing RSS feeds",
|
"description": "Application for viewing RSS feeds",
|
||||||
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "npx prettier \"src/newsreader/js/**/*.js\" --check",
|
"lint": "npx prettier \"src/newsreader/js/**/*.js\" --check",
|
||||||
"format": "npx prettier \"src/newsreader/js/**/*.js\" --write",
|
"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:watch": "npx webpack --config webpack.dev.babel.js --watch",
|
||||||
"build:dev": "npx webpack --config webpack.dev.babel.js",
|
|
||||||
"build:prod": "npx webpack --config webpack.prod.babel.js",
|
"build:prod": "npx webpack --config webpack.prod.babel.js",
|
||||||
"test": "npx jest",
|
"test": "npx jest",
|
||||||
"test:watch": "npm test -- --watch"
|
"test:watch": "npm test -- --watch"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "forgejo.fudiggity.nl:sonny/newsreader"
|
"url": "[git@git.fudiggity.nl:5000]:sonny/newsreader.git"
|
||||||
},
|
},
|
||||||
"author": "Sonny",
|
"author": "Sonny",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
|
|
@ -31,17 +32,21 @@
|
||||||
"@babel/core": "^7.12.13",
|
"@babel/core": "^7.12.13",
|
||||||
"@babel/plugin-proposal-class-properties": "^7.12.13",
|
"@babel/plugin-proposal-class-properties": "^7.12.13",
|
||||||
"@babel/plugin-proposal-function-bind": "^7.12.13",
|
"@babel/plugin-proposal-function-bind": "^7.12.13",
|
||||||
|
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||||
|
"@babel/plugin-syntax-function-bind": "^7.12.13",
|
||||||
"@babel/plugin-transform-react-jsx": "^7.12.13",
|
"@babel/plugin-transform-react-jsx": "^7.12.13",
|
||||||
|
"@babel/plugin-transform-runtime": "^7.12.15",
|
||||||
"@babel/preset-env": "^7.12.13",
|
"@babel/preset-env": "^7.12.13",
|
||||||
"@babel/register": "^7.12.13",
|
"@babel/register": "^7.12.13",
|
||||||
"@babel/runtime": "^7.12.13",
|
"@babel/runtime": "^7.12.13",
|
||||||
"babel-jest": "^29.7.0",
|
"babel-jest": "^24.9.0",
|
||||||
"babel-loader": "^8.2.2",
|
"babel-loader": "^8.2.2",
|
||||||
"clean-webpack-plugin": "^3.0.0",
|
"clean-webpack-plugin": "^3.0.0",
|
||||||
"css-loader": "^7.1.2",
|
"css-loader": "^3.6.0",
|
||||||
"fetch-mock": "^8.3.2",
|
"fetch-mock": "^8.3.2",
|
||||||
"jest": "^29.7.0",
|
"file-loader": "^6.2.0",
|
||||||
"mini-css-extract-plugin": "^2.9.1",
|
"jest": "^24.9.0",
|
||||||
|
"mini-css-extract-plugin": "^0.9.0",
|
||||||
"node-fetch": "^2.6.1",
|
"node-fetch": "^2.6.1",
|
||||||
"prettier": "^1.19.1",
|
"prettier": "^1.19.1",
|
||||||
"react": "^16.14.0",
|
"react": "^16.14.0",
|
||||||
|
|
@ -49,27 +54,10 @@
|
||||||
"redux-mock-store": "^1.5.4",
|
"redux-mock-store": "^1.5.4",
|
||||||
"sass": "^1.52.1",
|
"sass": "^1.52.1",
|
||||||
"sass-loader": "^8.0.2",
|
"sass-loader": "^8.0.2",
|
||||||
"style-loader": "^2.0.0",
|
"style-loader": "^1.3.0",
|
||||||
"url-loader": "^4.1.1",
|
"url-loader": "^4.1.1",
|
||||||
"webpack": "^5.94.0",
|
"webpack": "^4.46.0",
|
||||||
"webpack-cli": "^5.1.4",
|
"webpack-cli": "^3.3.12",
|
||||||
"webpack-merge": "^4.2.2"
|
"webpack-merge": "^4.2.2"
|
||||||
},
|
|
||||||
"prettier": {
|
|
||||||
"semi": true,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"singleQuote": true,
|
|
||||||
"printWidth": 90,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"useTabs": false,
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"arrowParens": "avoid"
|
|
||||||
},
|
|
||||||
"jest": {
|
|
||||||
"roots": [
|
|
||||||
"src/newsreader/js/tests/"
|
|
||||||
],
|
|
||||||
"clearMocks": true,
|
|
||||||
"coverageDirectory": "coverage"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
113
pyproject.toml
113
pyproject.toml
|
|
@ -1,81 +1,50 @@
|
||||||
[project]
|
[project]
|
||||||
name = "newsreader"
|
name = 'newsreader'
|
||||||
version = "0.5.3"
|
version = '0.4.0.0'
|
||||||
authors = [{ name = "Sonny" }]
|
authors = [{name = 'Sonny', email= 'sonnyba871@gmail.com'}]
|
||||||
license = { text = "GPL-3.0" }
|
license = {text = 'GPL-3.0'}
|
||||||
requires-python = ">=3.11"
|
requires-python = '>=3.11'
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"django~=4.2",
|
'django~=3.2',
|
||||||
"celery~=5.4",
|
'celery~=5.0',
|
||||||
"psycopg[binary]",
|
'psycopg2',
|
||||||
"django-axes",
|
|
||||||
"django-celery-beat~=2.7.0",
|
|
||||||
"django-rest-framework",
|
|
||||||
"djangorestframework-camel-case",
|
|
||||||
"pymemcache",
|
|
||||||
"python-dotenv~=1.0.1",
|
|
||||||
"ftfy~=6.2",
|
|
||||||
"requests",
|
|
||||||
"feedparser",
|
|
||||||
"bleach",
|
|
||||||
"beautifulsoup4",
|
|
||||||
"lxml",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependency-groups]
|
'django-axes',
|
||||||
test-tools = ["ruff", "factory_boy", "freezegun"]
|
'django-celery-beat~=2.5.0',
|
||||||
development = [
|
'django-registration-redux~=2.7',
|
||||||
"django-debug-toolbar",
|
'django-rest-framework',
|
||||||
"django-stubs",
|
'drf-yasg',
|
||||||
"django-extensions",
|
|
||||||
|
'python-memcached',
|
||||||
|
'python-dotenv~=0.12',
|
||||||
|
|
||||||
|
'ftfy~=5.8',
|
||||||
|
|
||||||
|
'requests',
|
||||||
|
'requests_oauthlib',
|
||||||
|
|
||||||
|
'feedparser',
|
||||||
|
'bleach',
|
||||||
|
'beautifulsoup4',
|
||||||
|
'lxml'
|
||||||
]
|
]
|
||||||
ci = ["coverage~=7.6.1"]
|
|
||||||
production = ["gunicorn~=23.0"]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
sentry = ["sentry-sdk~=2.0"]
|
testing = [
|
||||||
|
'factory-boy',
|
||||||
[tool.uv]
|
'freezegun',
|
||||||
environments = ["sys_platform == 'linux'"]
|
'black',
|
||||||
default-groups = ["test-tools"]
|
'isort',
|
||||||
|
'autoflake',
|
||||||
[tool.ruff]
|
'tblib',
|
||||||
include = ["pyproject.toml", "src/**/*.py"]
|
|
||||||
|
|
||||||
line-length = 88
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
select = ["E4", "E7", "E9", "F", "I"]
|
|
||||||
|
|
||||||
[tool.ruff.lint.isort]
|
|
||||||
lines-between-types=1
|
|
||||||
lines-after-imports=2
|
|
||||||
|
|
||||||
default-section = "third-party"
|
|
||||||
known-first-party = ["newsreader"]
|
|
||||||
section-order = [
|
|
||||||
"future",
|
|
||||||
"standard-library",
|
|
||||||
"django",
|
|
||||||
"third-party",
|
|
||||||
"first-party",
|
|
||||||
"local-folder",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint.isort.sections]
|
development = [
|
||||||
django = ["django"]
|
'pip-tools>=6.13.0',
|
||||||
|
'django-debug-toolbar',
|
||||||
[tool.coverage.run]
|
'django-extensions',
|
||||||
source = ["./src/newsreader/"]
|
|
||||||
omit = [
|
|
||||||
"**/tests/**",
|
|
||||||
"**/migrations/**",
|
|
||||||
"**/conf/**",
|
|
||||||
"**/apps.py",
|
|
||||||
"**/admin.py",
|
|
||||||
"**/tests.py",
|
|
||||||
"**/urls.py",
|
|
||||||
"**/wsgi.py",
|
|
||||||
"**/celery.py",
|
|
||||||
"**/__init__.py"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
ci = ['coverage>=5.3.1']
|
||||||
|
|
||||||
|
production = ['gunicorn~=20.0', 'sentry-sdk~=1.0']
|
||||||
|
|
|
||||||
143
requirements/base.txt
Normal file
143
requirements/base.txt
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with Python 3.9
|
||||||
|
# by the following command:
|
||||||
|
#
|
||||||
|
# pip-compile --output-file=requirements/base.txt --resolver=backtracking pyproject.toml
|
||||||
|
#
|
||||||
|
amqp==5.1.1
|
||||||
|
# via kombu
|
||||||
|
asgiref==3.7.2
|
||||||
|
# via django
|
||||||
|
beautifulsoup4==4.12.2
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
billiard==4.1.0
|
||||||
|
# via celery
|
||||||
|
bleach==6.0.0
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
celery==5.3.1
|
||||||
|
# via
|
||||||
|
# django-celery-beat
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
certifi==2023.5.7
|
||||||
|
# via requests
|
||||||
|
charset-normalizer==3.1.0
|
||||||
|
# via requests
|
||||||
|
click==8.1.3
|
||||||
|
# via
|
||||||
|
# celery
|
||||||
|
# click-didyoumean
|
||||||
|
# click-plugins
|
||||||
|
# click-repl
|
||||||
|
click-didyoumean==0.3.0
|
||||||
|
# via celery
|
||||||
|
click-plugins==1.1.1
|
||||||
|
# via celery
|
||||||
|
click-repl==0.3.0
|
||||||
|
# via celery
|
||||||
|
cron-descriptor==1.4.0
|
||||||
|
# via django-celery-beat
|
||||||
|
django==3.2.19
|
||||||
|
# via
|
||||||
|
# django-axes
|
||||||
|
# django-celery-beat
|
||||||
|
# django-timezone-field
|
||||||
|
# djangorestframework
|
||||||
|
# drf-yasg
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-axes==6.0.4
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
django-celery-beat==2.5.0
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
django-registration-redux==2.12
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
django-rest-framework==0.1.0
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
django-timezone-field==5.1
|
||||||
|
# via django-celery-beat
|
||||||
|
djangorestframework==3.14.0
|
||||||
|
# via
|
||||||
|
# django-rest-framework
|
||||||
|
# drf-yasg
|
||||||
|
drf-yasg==1.21.6
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
feedparser==6.0.10
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
ftfy==5.9
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
idna==3.4
|
||||||
|
# via requests
|
||||||
|
inflection==0.5.1
|
||||||
|
# via drf-yasg
|
||||||
|
kombu==5.3.1
|
||||||
|
# via celery
|
||||||
|
lxml==4.9.2
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
oauthlib==3.2.2
|
||||||
|
# via requests-oauthlib
|
||||||
|
packaging==23.1
|
||||||
|
# via drf-yasg
|
||||||
|
prompt-toolkit==3.0.38
|
||||||
|
# via click-repl
|
||||||
|
psycopg2==2.9.6
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
python-crontab==2.7.1
|
||||||
|
# via django-celery-beat
|
||||||
|
python-dateutil==2.8.2
|
||||||
|
# via
|
||||||
|
# celery
|
||||||
|
# python-crontab
|
||||||
|
python-dotenv==0.21.1
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
python-memcached==1.59
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
pytz==2023.3
|
||||||
|
# via
|
||||||
|
# django
|
||||||
|
# django-timezone-field
|
||||||
|
# djangorestframework
|
||||||
|
# drf-yasg
|
||||||
|
pyyaml==6.0
|
||||||
|
# via drf-yasg
|
||||||
|
requests==2.31.0
|
||||||
|
# via
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
# requests-oauthlib
|
||||||
|
requests-oauthlib==1.3.1
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
sgmllib3k==1.0.0
|
||||||
|
# via feedparser
|
||||||
|
six==1.16.0
|
||||||
|
# via
|
||||||
|
# bleach
|
||||||
|
# python-dateutil
|
||||||
|
# python-memcached
|
||||||
|
soupsieve==2.4.1
|
||||||
|
# via beautifulsoup4
|
||||||
|
sqlparse==0.4.4
|
||||||
|
# via django
|
||||||
|
typing-extensions==4.6.3
|
||||||
|
# via
|
||||||
|
# asgiref
|
||||||
|
# kombu
|
||||||
|
tzdata==2023.3
|
||||||
|
# via
|
||||||
|
# celery
|
||||||
|
# django-celery-beat
|
||||||
|
uritemplate==4.1.1
|
||||||
|
# via drf-yasg
|
||||||
|
urllib3==2.0.3
|
||||||
|
# via requests
|
||||||
|
vine==5.0.0
|
||||||
|
# via
|
||||||
|
# amqp
|
||||||
|
# celery
|
||||||
|
# kombu
|
||||||
|
wcwidth==0.2.6
|
||||||
|
# via
|
||||||
|
# ftfy
|
||||||
|
# prompt-toolkit
|
||||||
|
webencodings==0.5.1
|
||||||
|
# via bleach
|
||||||
|
|
||||||
|
# The following packages are considered to be unsafe in a requirements file:
|
||||||
|
# setuptools
|
||||||
320
requirements/ci.txt
Normal file
320
requirements/ci.txt
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with Python 3.9
|
||||||
|
# by the following command:
|
||||||
|
#
|
||||||
|
# pip-compile --extra=ci --extra=testing --output-file=requirements/ci.txt --resolver=backtracking pyproject.toml requirements/base.txt requirements/testing.txt
|
||||||
|
#
|
||||||
|
amqp==5.1.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# kombu
|
||||||
|
asgiref==3.7.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# django
|
||||||
|
autoflake==2.2.0
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
beautifulsoup4==4.12.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
billiard==4.1.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# celery
|
||||||
|
black==23.3.0
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
bleach==6.0.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
celery==5.3.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django-celery-beat
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
certifi==2023.5.7
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# requests
|
||||||
|
charset-normalizer==3.1.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# requests
|
||||||
|
click==8.1.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# black
|
||||||
|
# celery
|
||||||
|
# click-didyoumean
|
||||||
|
# click-plugins
|
||||||
|
# click-repl
|
||||||
|
click-didyoumean==0.3.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# celery
|
||||||
|
click-plugins==1.1.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# celery
|
||||||
|
click-repl==0.3.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# celery
|
||||||
|
coverage==7.2.7
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
cron-descriptor==1.4.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# django-celery-beat
|
||||||
|
django==3.2.19
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django-axes
|
||||||
|
# django-celery-beat
|
||||||
|
# django-timezone-field
|
||||||
|
# djangorestframework
|
||||||
|
# drf-yasg
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-axes==6.0.4
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-celery-beat==2.5.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-registration-redux==2.12
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-rest-framework==0.1.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-timezone-field==5.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# django-celery-beat
|
||||||
|
djangorestframework==3.14.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# django-rest-framework
|
||||||
|
# drf-yasg
|
||||||
|
drf-yasg==1.21.6
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
factory-boy==3.2.1
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
faker==18.11.2
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# factory-boy
|
||||||
|
feedparser==6.0.10
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
freezegun==1.2.2
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
ftfy==5.9
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
idna==3.4
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# requests
|
||||||
|
inflection==0.5.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# drf-yasg
|
||||||
|
isort==5.12.0
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
kombu==5.3.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# celery
|
||||||
|
lxml==4.9.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
mypy-extensions==1.0.0
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# black
|
||||||
|
oauthlib==3.2.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# requests-oauthlib
|
||||||
|
packaging==23.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# black
|
||||||
|
# drf-yasg
|
||||||
|
pathspec==0.11.1
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# black
|
||||||
|
platformdirs==3.8.0
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# black
|
||||||
|
prompt-toolkit==3.0.38
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# click-repl
|
||||||
|
psycopg2==2.9.6
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
pyflakes==3.0.1
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# autoflake
|
||||||
|
python-crontab==2.7.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# django-celery-beat
|
||||||
|
python-dateutil==2.8.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# celery
|
||||||
|
# faker
|
||||||
|
# freezegun
|
||||||
|
# python-crontab
|
||||||
|
python-dotenv==0.21.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
python-memcached==1.59
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
pytz==2023.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# django
|
||||||
|
# django-timezone-field
|
||||||
|
# djangorestframework
|
||||||
|
# drf-yasg
|
||||||
|
pyyaml==6.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# drf-yasg
|
||||||
|
requests==2.31.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
# requests-oauthlib
|
||||||
|
requests-oauthlib==1.3.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
sgmllib3k==1.0.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# feedparser
|
||||||
|
six==1.16.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# bleach
|
||||||
|
# python-dateutil
|
||||||
|
# python-memcached
|
||||||
|
soupsieve==2.4.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# beautifulsoup4
|
||||||
|
sqlparse==0.4.4
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# django
|
||||||
|
tblib==2.0.0
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
tomli==2.0.1
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# autoflake
|
||||||
|
# black
|
||||||
|
typing-extensions==4.6.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# asgiref
|
||||||
|
# black
|
||||||
|
# kombu
|
||||||
|
tzdata==2023.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# celery
|
||||||
|
# django-celery-beat
|
||||||
|
uritemplate==4.1.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# drf-yasg
|
||||||
|
urllib3==2.0.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# requests
|
||||||
|
vine==5.0.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# amqp
|
||||||
|
# celery
|
||||||
|
# kombu
|
||||||
|
wcwidth==0.2.6
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# ftfy
|
||||||
|
# prompt-toolkit
|
||||||
|
webencodings==0.5.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# bleach
|
||||||
|
|
||||||
|
# The following packages are considered to be unsafe in a requirements file:
|
||||||
|
# setuptools
|
||||||
338
requirements/development.txt
Normal file
338
requirements/development.txt
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with Python 3.9
|
||||||
|
# by the following command:
|
||||||
|
#
|
||||||
|
# pip-compile --extra=development --extra=testing --output-file=requirements/development.txt --resolver=backtracking pyproject.toml requirements/base.txt requirements/testing.txt
|
||||||
|
#
|
||||||
|
amqp==5.1.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# kombu
|
||||||
|
asgiref==3.7.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# django
|
||||||
|
autoflake==2.2.0
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
beautifulsoup4==4.12.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
billiard==4.1.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# celery
|
||||||
|
black==23.3.0
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
bleach==6.0.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
build==0.10.0
|
||||||
|
# via pip-tools
|
||||||
|
celery==5.3.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django-celery-beat
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
certifi==2023.5.7
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# requests
|
||||||
|
charset-normalizer==3.1.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# requests
|
||||||
|
click==8.1.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# black
|
||||||
|
# celery
|
||||||
|
# click-didyoumean
|
||||||
|
# click-plugins
|
||||||
|
# click-repl
|
||||||
|
# pip-tools
|
||||||
|
click-didyoumean==0.3.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# celery
|
||||||
|
click-plugins==1.1.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# celery
|
||||||
|
click-repl==0.3.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# celery
|
||||||
|
cron-descriptor==1.4.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# django-celery-beat
|
||||||
|
django==3.2.19
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django-axes
|
||||||
|
# django-celery-beat
|
||||||
|
# django-debug-toolbar
|
||||||
|
# django-extensions
|
||||||
|
# django-timezone-field
|
||||||
|
# djangorestframework
|
||||||
|
# drf-yasg
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-axes==6.0.4
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-celery-beat==2.5.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-debug-toolbar==4.1.0
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
django-extensions==3.2.3
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
django-registration-redux==2.12
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-rest-framework==0.1.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-timezone-field==5.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# django-celery-beat
|
||||||
|
djangorestframework==3.14.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# django-rest-framework
|
||||||
|
# drf-yasg
|
||||||
|
drf-yasg==1.21.6
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
factory-boy==3.2.1
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
faker==18.11.2
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# factory-boy
|
||||||
|
feedparser==6.0.10
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
freezegun==1.2.2
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
ftfy==5.9
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
idna==3.4
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# requests
|
||||||
|
inflection==0.5.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# drf-yasg
|
||||||
|
isort==5.12.0
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
kombu==5.3.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# celery
|
||||||
|
lxml==4.9.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
mypy-extensions==1.0.0
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# black
|
||||||
|
oauthlib==3.2.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# requests-oauthlib
|
||||||
|
packaging==23.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# black
|
||||||
|
# build
|
||||||
|
# drf-yasg
|
||||||
|
pathspec==0.11.1
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# black
|
||||||
|
pip-tools==6.13.0
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
platformdirs==3.8.0
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# black
|
||||||
|
prompt-toolkit==3.0.38
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# click-repl
|
||||||
|
psycopg2==2.9.6
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
pyflakes==3.0.1
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# autoflake
|
||||||
|
pyproject-hooks==1.0.0
|
||||||
|
# via build
|
||||||
|
python-crontab==2.7.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# django-celery-beat
|
||||||
|
python-dateutil==2.8.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# celery
|
||||||
|
# faker
|
||||||
|
# freezegun
|
||||||
|
# python-crontab
|
||||||
|
python-dotenv==0.21.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
python-memcached==1.59
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
pytz==2023.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# django
|
||||||
|
# django-timezone-field
|
||||||
|
# djangorestframework
|
||||||
|
# drf-yasg
|
||||||
|
pyyaml==6.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# drf-yasg
|
||||||
|
requests==2.31.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
# requests-oauthlib
|
||||||
|
requests-oauthlib==1.3.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
sgmllib3k==1.0.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# feedparser
|
||||||
|
six==1.16.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# bleach
|
||||||
|
# python-dateutil
|
||||||
|
# python-memcached
|
||||||
|
soupsieve==2.4.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# beautifulsoup4
|
||||||
|
sqlparse==0.4.4
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# django
|
||||||
|
# django-debug-toolbar
|
||||||
|
tblib==2.0.0
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
tomli==2.0.1
|
||||||
|
# via
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# autoflake
|
||||||
|
# black
|
||||||
|
# build
|
||||||
|
# pyproject-hooks
|
||||||
|
typing-extensions==4.6.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# asgiref
|
||||||
|
# black
|
||||||
|
# kombu
|
||||||
|
tzdata==2023.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# celery
|
||||||
|
# django-celery-beat
|
||||||
|
uritemplate==4.1.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# drf-yasg
|
||||||
|
urllib3==2.0.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# requests
|
||||||
|
vine==5.0.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# amqp
|
||||||
|
# celery
|
||||||
|
# kombu
|
||||||
|
wcwidth==0.2.6
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# ftfy
|
||||||
|
# prompt-toolkit
|
||||||
|
webencodings==0.5.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# -r requirements/testing.txt
|
||||||
|
# bleach
|
||||||
|
wheel==0.40.0
|
||||||
|
# via pip-tools
|
||||||
|
|
||||||
|
# The following packages are considered to be unsafe in a requirements file:
|
||||||
|
# pip
|
||||||
|
# setuptools
|
||||||
237
requirements/production.txt
Normal file
237
requirements/production.txt
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with Python 3.9
|
||||||
|
# by the following command:
|
||||||
|
#
|
||||||
|
# pip-compile --extra=production --output-file=requirements/production.txt --resolver=backtracking pyproject.toml requirements/base.txt
|
||||||
|
#
|
||||||
|
amqp==5.1.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# kombu
|
||||||
|
asgiref==3.7.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django
|
||||||
|
beautifulsoup4==4.12.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
billiard==4.1.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# celery
|
||||||
|
bleach==6.0.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
celery==5.3.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django-celery-beat
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
certifi==2023.5.7
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# requests
|
||||||
|
# sentry-sdk
|
||||||
|
charset-normalizer==3.1.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# requests
|
||||||
|
click==8.1.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# celery
|
||||||
|
# click-didyoumean
|
||||||
|
# click-plugins
|
||||||
|
# click-repl
|
||||||
|
click-didyoumean==0.3.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# celery
|
||||||
|
click-plugins==1.1.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# celery
|
||||||
|
click-repl==0.3.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# celery
|
||||||
|
cron-descriptor==1.4.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django-celery-beat
|
||||||
|
django==3.2.19
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django-axes
|
||||||
|
# django-celery-beat
|
||||||
|
# django-timezone-field
|
||||||
|
# djangorestframework
|
||||||
|
# drf-yasg
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-axes==6.0.4
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-celery-beat==2.5.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-registration-redux==2.12
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-rest-framework==0.1.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-timezone-field==5.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django-celery-beat
|
||||||
|
djangorestframework==3.14.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django-rest-framework
|
||||||
|
# drf-yasg
|
||||||
|
drf-yasg==1.21.6
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
feedparser==6.0.10
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
ftfy==5.9
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
gunicorn==20.1.0
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
idna==3.4
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# requests
|
||||||
|
inflection==0.5.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# drf-yasg
|
||||||
|
kombu==5.3.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# celery
|
||||||
|
lxml==4.9.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
oauthlib==3.2.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# requests-oauthlib
|
||||||
|
packaging==23.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# drf-yasg
|
||||||
|
prompt-toolkit==3.0.38
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# click-repl
|
||||||
|
psycopg2==2.9.6
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
python-crontab==2.7.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django-celery-beat
|
||||||
|
python-dateutil==2.8.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# celery
|
||||||
|
# python-crontab
|
||||||
|
python-dotenv==0.21.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
python-memcached==1.59
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
pytz==2023.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django
|
||||||
|
# django-timezone-field
|
||||||
|
# djangorestframework
|
||||||
|
# drf-yasg
|
||||||
|
pyyaml==6.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# drf-yasg
|
||||||
|
requests==2.31.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
# requests-oauthlib
|
||||||
|
requests-oauthlib==1.3.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
sentry-sdk==1.26.0
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
sgmllib3k==1.0.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# feedparser
|
||||||
|
six==1.16.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# bleach
|
||||||
|
# python-dateutil
|
||||||
|
# python-memcached
|
||||||
|
soupsieve==2.4.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# beautifulsoup4
|
||||||
|
sqlparse==0.4.4
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django
|
||||||
|
typing-extensions==4.6.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# asgiref
|
||||||
|
# kombu
|
||||||
|
tzdata==2023.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# celery
|
||||||
|
# django-celery-beat
|
||||||
|
uritemplate==4.1.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# drf-yasg
|
||||||
|
urllib3==2.0.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# requests
|
||||||
|
# sentry-sdk
|
||||||
|
vine==5.0.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# amqp
|
||||||
|
# celery
|
||||||
|
# kombu
|
||||||
|
wcwidth==0.2.6
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# ftfy
|
||||||
|
# prompt-toolkit
|
||||||
|
webencodings==0.5.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# bleach
|
||||||
|
|
||||||
|
# The following packages are considered to be unsafe in a requirements file:
|
||||||
|
# setuptools
|
||||||
262
requirements/testing.txt
Normal file
262
requirements/testing.txt
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with Python 3.9
|
||||||
|
# by the following command:
|
||||||
|
#
|
||||||
|
# pip-compile --extra=testing --output-file=requirements/testing.txt --resolver=backtracking pyproject.toml requirements/base.txt
|
||||||
|
#
|
||||||
|
amqp==5.1.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# kombu
|
||||||
|
asgiref==3.7.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django
|
||||||
|
autoflake==2.2.0
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
beautifulsoup4==4.12.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
billiard==4.1.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# celery
|
||||||
|
black==23.3.0
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
bleach==6.0.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
celery==5.3.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django-celery-beat
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
certifi==2023.5.7
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# requests
|
||||||
|
charset-normalizer==3.1.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# requests
|
||||||
|
click==8.1.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# black
|
||||||
|
# celery
|
||||||
|
# click-didyoumean
|
||||||
|
# click-plugins
|
||||||
|
# click-repl
|
||||||
|
click-didyoumean==0.3.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# celery
|
||||||
|
click-plugins==1.1.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# celery
|
||||||
|
click-repl==0.3.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# celery
|
||||||
|
cron-descriptor==1.4.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django-celery-beat
|
||||||
|
django==3.2.19
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django-axes
|
||||||
|
# django-celery-beat
|
||||||
|
# django-timezone-field
|
||||||
|
# djangorestframework
|
||||||
|
# drf-yasg
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-axes==6.0.4
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-celery-beat==2.5.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-registration-redux==2.12
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-rest-framework==0.1.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
django-timezone-field==5.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django-celery-beat
|
||||||
|
djangorestframework==3.14.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django-rest-framework
|
||||||
|
# drf-yasg
|
||||||
|
drf-yasg==1.21.6
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
factory-boy==3.2.1
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
faker==18.11.2
|
||||||
|
# via factory-boy
|
||||||
|
feedparser==6.0.10
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
freezegun==1.2.2
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
ftfy==5.9
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
idna==3.4
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# requests
|
||||||
|
inflection==0.5.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# drf-yasg
|
||||||
|
isort==5.12.0
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
kombu==5.3.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# celery
|
||||||
|
lxml==4.9.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
mypy-extensions==1.0.0
|
||||||
|
# via black
|
||||||
|
oauthlib==3.2.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# requests-oauthlib
|
||||||
|
packaging==23.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# black
|
||||||
|
# drf-yasg
|
||||||
|
pathspec==0.11.1
|
||||||
|
# via black
|
||||||
|
platformdirs==3.8.0
|
||||||
|
# via black
|
||||||
|
prompt-toolkit==3.0.38
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# click-repl
|
||||||
|
psycopg2==2.9.6
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
pyflakes==3.0.1
|
||||||
|
# via autoflake
|
||||||
|
python-crontab==2.7.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django-celery-beat
|
||||||
|
python-dateutil==2.8.2
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# celery
|
||||||
|
# faker
|
||||||
|
# freezegun
|
||||||
|
# python-crontab
|
||||||
|
python-dotenv==0.21.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
python-memcached==1.59
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
pytz==2023.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django
|
||||||
|
# django-timezone-field
|
||||||
|
# djangorestframework
|
||||||
|
# drf-yasg
|
||||||
|
pyyaml==6.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# drf-yasg
|
||||||
|
requests==2.31.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
# requests-oauthlib
|
||||||
|
requests-oauthlib==1.3.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# newsreader (pyproject.toml)
|
||||||
|
sgmllib3k==1.0.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# feedparser
|
||||||
|
six==1.16.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# bleach
|
||||||
|
# python-dateutil
|
||||||
|
# python-memcached
|
||||||
|
soupsieve==2.4.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# beautifulsoup4
|
||||||
|
sqlparse==0.4.4
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# django
|
||||||
|
tblib==2.0.0
|
||||||
|
# via newsreader (pyproject.toml)
|
||||||
|
tomli==2.0.1
|
||||||
|
# via
|
||||||
|
# autoflake
|
||||||
|
# black
|
||||||
|
typing-extensions==4.6.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# asgiref
|
||||||
|
# black
|
||||||
|
# kombu
|
||||||
|
tzdata==2023.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# celery
|
||||||
|
# django-celery-beat
|
||||||
|
uritemplate==4.1.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# drf-yasg
|
||||||
|
urllib3==2.0.3
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# requests
|
||||||
|
vine==5.0.0
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# amqp
|
||||||
|
# celery
|
||||||
|
# kombu
|
||||||
|
wcwidth==0.2.6
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# ftfy
|
||||||
|
# prompt-toolkit
|
||||||
|
webencodings==0.5.1
|
||||||
|
# via
|
||||||
|
# -r requirements/base.txt
|
||||||
|
# bleach
|
||||||
|
|
||||||
|
# The following packages are considered to be unsafe in a requirements file:
|
||||||
|
# setuptools
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
"""Django's command-line utility for administrative tasks."""
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from django import forms
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
||||||
from django.contrib.auth.forms import UserChangeForm
|
from django.contrib.auth.forms import UserChangeForm
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from newsreader.accounts.models import User
|
from newsreader.accounts.models import User
|
||||||
|
|
||||||
|
|
@ -11,6 +11,18 @@ class UserAdminForm(UserChangeForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
widgets = {
|
widgets = {
|
||||||
"email": forms.EmailInput(attrs={"size": "50"}),
|
"email": forms.EmailInput(attrs={"size": "50"}),
|
||||||
|
"reddit_access_token": forms.PasswordInput(
|
||||||
|
attrs={"size": "90"}, render_value=True
|
||||||
|
),
|
||||||
|
"reddit_refresh_token": forms.PasswordInput(
|
||||||
|
attrs={"size": "90"}, render_value=True
|
||||||
|
),
|
||||||
|
"twitter_oauth_token": forms.PasswordInput(
|
||||||
|
attrs={"size": "90"}, render_value=True
|
||||||
|
),
|
||||||
|
"twitter_oauth_token_secret": forms.PasswordInput(
|
||||||
|
attrs={"size": "90"}, render_value=True
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -28,6 +40,14 @@ class UserAdmin(DjangoUserAdmin):
|
||||||
_("User settings"),
|
_("User settings"),
|
||||||
{"fields": ("email", "password", "first_name", "last_name", "is_active")},
|
{"fields": ("email", "password", "first_name", "last_name", "is_active")},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
_("Reddit settings"),
|
||||||
|
{"fields": ("reddit_access_token", "reddit_refresh_token")},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_("Twitter settings"),
|
||||||
|
{"fields": ("twitter_oauth_token", "twitter_oauth_token_secret")},
|
||||||
|
),
|
||||||
(
|
(
|
||||||
_("Permission settings"),
|
_("Permission settings"),
|
||||||
{"classes": ("collapse",), "fields": ("is_staff", "is_superuser")},
|
{"classes": ("collapse",), "fields": ("is_staff", "is_superuser")},
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
# Generated by Django 3.2.25 on 2024-09-06 07:14
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("accounts", "0016_alter_user_first_name"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="user",
|
|
||||||
name="twitter_oauth_token",
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="user",
|
|
||||||
name="twitter_oauth_token_secret",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
# Generated by Django 4.2.16 on 2025-03-26 08:46
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("accounts", "0017_auto_20240906_0914"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="user",
|
|
||||||
name="reddit_access_token",
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="user",
|
|
||||||
name="reddit_refresh_token",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -39,6 +39,14 @@ class UserManager(DjangoUserManager):
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
email = models.EmailField(_("email address"), unique=True)
|
email = models.EmailField(_("email address"), unique=True)
|
||||||
|
|
||||||
|
# reddit settings
|
||||||
|
reddit_refresh_token = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
reddit_access_token = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
|
||||||
|
# twitter settings
|
||||||
|
twitter_oauth_token = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
twitter_oauth_token_secret = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
|
||||||
# settings
|
# settings
|
||||||
auto_mark_read = models.BooleanField(
|
auto_mark_read = models.BooleanField(
|
||||||
_("Auto read marking"),
|
_("Auto read marking"),
|
||||||
|
|
@ -60,3 +68,7 @@ class User(AbstractUser):
|
||||||
tasks.delete()
|
tasks.delete()
|
||||||
|
|
||||||
return super().delete(*args, **kwargs)
|
return super().delete(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_twitter_auth(self):
|
||||||
|
return self.twitter_oauth_token and self.twitter_oauth_token_secret
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@
|
||||||
{% trans "Fetch favicons" %}
|
{% trans "Fetch favicons" %}
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<a class="link button button--primary" href="{% url 'accounts:settings:integrations' %}">
|
||||||
|
{% trans "Third party integrations" %}
|
||||||
|
</a>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</section>
|
</section>
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main id="integrations--page" class="main">
|
||||||
|
<section class="section">
|
||||||
|
{% include "components/header/header.html" with title="Integrations" only %}
|
||||||
|
|
||||||
|
<div class="integrations">
|
||||||
|
<h3 class="integrations__title">Reddit</h3>
|
||||||
|
<div class="integrations__controls">
|
||||||
|
{% if reddit_authorization_url %}
|
||||||
|
<a class="link button button--reddit" href="{{ reddit_authorization_url }}">
|
||||||
|
{% trans "Authorize account" %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<button class="button button--reddit button--disabled" disabled>
|
||||||
|
{% trans "Authorize account" %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if reddit_refresh_url %}
|
||||||
|
<a class="link button button--reddit" href="{{ reddit_refresh_url }}">
|
||||||
|
{% trans "Refresh token" %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<button class="button button--reddit button--disabled" disabled>
|
||||||
|
{% trans "Refresh token" %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if reddit_revoke_url %}
|
||||||
|
<a class="link button button--reddit" href="{{ reddit_revoke_url }}">
|
||||||
|
{% trans "Deauthorize account" %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<button class="button button--reddit button--disabled" disabled>
|
||||||
|
{% trans "Deauthorize account" %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="integrations">
|
||||||
|
<h3 class="integrations__title">Twitter</h3>
|
||||||
|
<div class="integrations__controls">
|
||||||
|
{% if twitter_auth_url %}
|
||||||
|
<a class="link button button--twitter" href="{{ twitter_auth_url }}">
|
||||||
|
{% trans "Authorize account" %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<button class="button button--twitter button--disabled" disabled>
|
||||||
|
{% trans "Authorize account" %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if twitter_revoke_url %}
|
||||||
|
<a class="link button button--twitter" href="{{ twitter_revoke_url }}">
|
||||||
|
{% trans "Deauthorize account" %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<button class="button button--twitter button--disabled" disabled>
|
||||||
|
{% trans "Deauthorize account" %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
{% extends "sidebar.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main id="login--page" class="main" data-render-sidebar=true>
|
<main id="login--page" class="main">
|
||||||
<div class="main__container">
|
|
||||||
{% include "accounts/components/login-form.html" with form=form title="Login" confirm_text="Login" %}
|
{% include "accounts/components/login-form.html" with form=form title="Login" confirm_text="Login" %}
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
{% extends "sidebar.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<main id="password-change--page" class="main">
|
||||||
{% url 'accounts:settings:home' as cancel_url %}
|
{% url 'accounts:settings:home' as cancel_url %}
|
||||||
|
|
||||||
<main id="password-change--page" class="main" data-render-sidebar=true>
|
|
||||||
<div class="main__container">
|
|
||||||
{% include "components/form/form.html" with form=form title="Change password" confirm_text="Change password" cancel_url=cancel_url %}
|
{% include "components/form/form.html" with form=form title="Change password" confirm_text="Change password" cancel_url=cancel_url %}
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
20
src/newsreader/accounts/templates/accounts/views/reddit.html
Normal file
20
src/newsreader/accounts/templates/accounts/views/reddit.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main id="reddit--page" class="main">
|
||||||
|
<section class="section text-section">
|
||||||
|
{% if error %}
|
||||||
|
<h1 class="h1">{% trans "Reddit authorization failed" %}</h1>
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
{% elif access_token and refresh_token %}
|
||||||
|
<h1 class="h1">{% trans "Reddit account is linked" %}</h1>
|
||||||
|
<p>{% trans "Your reddit account was successfully linked." %}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a class="link" href="{% url 'accounts:settings:integrations' %}">{% trans "Return to integrations page" %}</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
{% extends "sidebar.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main id="settings--page" class="main" data-render-sidebar=true>
|
<main id="settings--page" class="main">
|
||||||
<div class="main__container">
|
|
||||||
{% include "accounts/components/settings-form.html" with form=form title="User profile" confirm_text="Save" %}
|
{% include "accounts/components/settings-form.html" with form=form title="User profile" confirm_text="Save" %}
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main id="twitter--page" class="main">
|
||||||
|
<section class="section text-section">
|
||||||
|
{% if error %}
|
||||||
|
<h1 class="h1">{% trans "Twitter authorization failed" %}</h1>
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
{% elif authorized %}
|
||||||
|
<h1 class="h1">{% trans "Twitter account is linked" %}</h1>
|
||||||
|
<p>{% trans "Your Twitter account was successfully linked." %}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a class="link" href="{% url 'accounts:settings:integrations' %}">{% trans "Return to integrations page" %}</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -5,6 +5,8 @@ from django.utils.crypto import get_random_string
|
||||||
|
|
||||||
import factory
|
import factory
|
||||||
|
|
||||||
|
from registration.models import RegistrationProfile
|
||||||
|
|
||||||
from newsreader.accounts.models import User
|
from newsreader.accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -27,3 +29,11 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationProfileFactory(factory.django.DjangoModelFactory):
|
||||||
|
user = factory.SubFactory(UserFactory)
|
||||||
|
activation_key = factory.LazyFunction(get_activation_key)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = RegistrationProfile
|
||||||
|
|
|
||||||
99
src/newsreader/accounts/tests/test_activation.py
Normal file
99
src/newsreader/accounts/tests/test_activation.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from registration.models import RegistrationProfile
|
||||||
|
|
||||||
|
from newsreader.accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class ActivationTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.register_url = reverse("accounts:register")
|
||||||
|
self.register_success_url = reverse("accounts:register-complete")
|
||||||
|
self.success_url = reverse("accounts:activate-complete")
|
||||||
|
|
||||||
|
def test_activation(self):
|
||||||
|
data = {
|
||||||
|
"email": "test@test.com",
|
||||||
|
"password1": "test12456",
|
||||||
|
"password2": "test12456",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(self.register_url, data)
|
||||||
|
self.assertRedirects(response, self.register_success_url)
|
||||||
|
|
||||||
|
register_profile = RegistrationProfile.objects.get()
|
||||||
|
|
||||||
|
kwargs = {"activation_key": register_profile.activation_key}
|
||||||
|
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||||
|
|
||||||
|
self.assertRedirects(response, self.success_url)
|
||||||
|
|
||||||
|
def test_expired_key(self):
|
||||||
|
data = {
|
||||||
|
"email": "test@test.com",
|
||||||
|
"password1": "test12456",
|
||||||
|
"password2": "test12456",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(self.register_url, data)
|
||||||
|
|
||||||
|
register_profile = RegistrationProfile.objects.get()
|
||||||
|
user = register_profile.user
|
||||||
|
|
||||||
|
user.date_joined -= datetime.timedelta(days=settings.ACCOUNT_ACTIVATION_DAYS)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
kwargs = {"activation_key": register_profile.activation_key}
|
||||||
|
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
self.assertContains(response, _("Account activation failed"))
|
||||||
|
|
||||||
|
user.refresh_from_db()
|
||||||
|
self.assertFalse(user.is_active)
|
||||||
|
|
||||||
|
def test_invalid_key(self):
|
||||||
|
data = {
|
||||||
|
"email": "test@test.com",
|
||||||
|
"password1": "test12456",
|
||||||
|
"password2": "test12456",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(self.register_url, data)
|
||||||
|
self.assertRedirects(response, self.register_success_url)
|
||||||
|
|
||||||
|
kwargs = {"activation_key": "not-a-valid-key"}
|
||||||
|
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||||
|
|
||||||
|
self.assertContains(response, _("Account activation failed"))
|
||||||
|
|
||||||
|
user = User.objects.get()
|
||||||
|
|
||||||
|
self.assertEquals(user.is_active, False)
|
||||||
|
|
||||||
|
def test_activated_key(self):
|
||||||
|
data = {
|
||||||
|
"email": "test@test.com",
|
||||||
|
"password1": "test12456",
|
||||||
|
"password2": "test12456",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(self.register_url, data)
|
||||||
|
self.assertRedirects(response, self.register_success_url)
|
||||||
|
|
||||||
|
register_profile = RegistrationProfile.objects.get()
|
||||||
|
|
||||||
|
kwargs = {"activation_key": register_profile.activation_key}
|
||||||
|
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||||
|
|
||||||
|
self.assertRedirects(response, self.success_url)
|
||||||
|
|
||||||
|
# try this a second time
|
||||||
|
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||||
|
|
||||||
|
self.assertRedirects(response, self.success_url)
|
||||||
537
src/newsreader/accounts/tests/test_integrations.py
Normal file
537
src/newsreader/accounts/tests/test_integrations.py
Normal file
|
|
@ -0,0 +1,537 @@
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from newsreader.accounts.tests.factories import UserFactory
|
||||||
|
from newsreader.news.collection.exceptions import (
|
||||||
|
StreamException,
|
||||||
|
StreamTooManyException,
|
||||||
|
)
|
||||||
|
from newsreader.news.collection.twitter import TWITTER_AUTH_URL
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationsViewTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = UserFactory(email="test@test.nl", password="test")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
self.url = reverse("accounts:settings:integrations")
|
||||||
|
|
||||||
|
|
||||||
|
class RedditIntegrationsTestCase(IntegrationsViewTestCase):
|
||||||
|
def test_reddit_authorization(self):
|
||||||
|
self.user.reddit_refresh_token = None
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.content, features="lxml")
|
||||||
|
button = soup.find("a", class_="link button button--reddit")
|
||||||
|
|
||||||
|
self.assertEquals(button.text.strip(), "Authorize account")
|
||||||
|
|
||||||
|
def test_reddit_refresh_token(self):
|
||||||
|
self.user.reddit_refresh_token = "jadajadajada"
|
||||||
|
self.user.reddit_access_token = None
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.content, features="lxml")
|
||||||
|
button = soup.find("a", class_="link button button--reddit")
|
||||||
|
|
||||||
|
self.assertEquals(button.text.strip(), "Refresh token")
|
||||||
|
|
||||||
|
def test_reddit_revoke(self):
|
||||||
|
self.user.reddit_refresh_token = "jadajadajada"
|
||||||
|
self.user.reddit_access_token = None
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.content, features="lxml")
|
||||||
|
buttons = soup.find_all("a", class_="link button button--reddit")
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"Deauthorize account", [button.text.strip() for button in buttons]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RedditTemplateViewTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = UserFactory(email="test@test.nl", password="test")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
self.base_url = reverse("accounts:settings:reddit-template")
|
||||||
|
self.state = str(uuid4())
|
||||||
|
|
||||||
|
self.patch = patch("newsreader.news.collection.reddit.post")
|
||||||
|
self.mocked_post = self.patch.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
patch.stopall()
|
||||||
|
|
||||||
|
def test_simple(self):
|
||||||
|
response = self.client.get(self.base_url)
|
||||||
|
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Return to integrations page")
|
||||||
|
|
||||||
|
def test_successful_authorization(self):
|
||||||
|
self.mocked_post.return_value.json.return_value = {
|
||||||
|
"access_token": "1001010412",
|
||||||
|
"refresh_token": "134510143",
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(f"{self.user.email}-reddit-auth", self.state)
|
||||||
|
|
||||||
|
params = {"state": self.state, "code": "Valid code"}
|
||||||
|
url = f"{self.base_url}?{urlencode(params)}"
|
||||||
|
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
self.mocked_post.assert_called_once()
|
||||||
|
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Your reddit account was successfully linked.")
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEquals(self.user.reddit_access_token, "1001010412")
|
||||||
|
self.assertEquals(self.user.reddit_refresh_token, "134510143")
|
||||||
|
|
||||||
|
self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), None)
|
||||||
|
|
||||||
|
def test_error(self):
|
||||||
|
params = {"error": "Denied authorization"}
|
||||||
|
|
||||||
|
url = f"{self.base_url}?{urlencode(params)}"
|
||||||
|
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Denied authorization")
|
||||||
|
|
||||||
|
def test_invalid_state(self):
|
||||||
|
cache.set(f"{self.user.email}-reddit-auth", str(uuid4()))
|
||||||
|
|
||||||
|
params = {"code": "Valid code", "state": "Invalid state"}
|
||||||
|
|
||||||
|
url = f"{self.base_url}?{urlencode(params)}"
|
||||||
|
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
self.assertContains(
|
||||||
|
response, "The saved state for Reddit authorization did not match"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stream_error(self):
|
||||||
|
self.mocked_post.side_effect = StreamTooManyException
|
||||||
|
|
||||||
|
cache.set(f"{self.user.email}-reddit-auth", self.state)
|
||||||
|
|
||||||
|
params = {"state": self.state, "code": "Valid code"}
|
||||||
|
url = f"{self.base_url}?{urlencode(params)}"
|
||||||
|
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
self.mocked_post.assert_called_once()
|
||||||
|
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Too many requests")
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEquals(self.user.reddit_access_token, None)
|
||||||
|
self.assertEquals(self.user.reddit_refresh_token, None)
|
||||||
|
|
||||||
|
self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state)
|
||||||
|
|
||||||
|
def test_unexpected_json(self):
|
||||||
|
self.mocked_post.return_value.json.return_value = {"message": "Happy eastern"}
|
||||||
|
|
||||||
|
cache.set(f"{self.user.email}-reddit-auth", self.state)
|
||||||
|
|
||||||
|
params = {"state": self.state, "code": "Valid code"}
|
||||||
|
url = f"{self.base_url}?{urlencode(params)}"
|
||||||
|
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
self.mocked_post.assert_called_once()
|
||||||
|
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Access and refresh token not found in response")
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEquals(self.user.reddit_access_token, None)
|
||||||
|
self.assertEquals(self.user.reddit_refresh_token, None)
|
||||||
|
|
||||||
|
self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state)
|
||||||
|
|
||||||
|
|
||||||
|
class RedditTokenRedirectViewTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = UserFactory(email="test@test.nl", password="test")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
self.patch = patch("newsreader.accounts.views.integrations.RedditTokenTask")
|
||||||
|
self.mocked_task = self.patch.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
def test_simple(self):
|
||||||
|
response = self.client.get(reverse("accounts:settings:reddit-refresh"))
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
||||||
|
|
||||||
|
self.mocked_task.delay.assert_called_once_with(self.user.pk)
|
||||||
|
|
||||||
|
self.assertEquals(1, cache.get(f"{self.user.email}-reddit-refresh"))
|
||||||
|
|
||||||
|
def test_not_active(self):
|
||||||
|
cache.set(f"{self.user.email}-reddit-refresh", 1)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("accounts:settings:reddit-refresh"))
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
||||||
|
|
||||||
|
self.mocked_task.delay.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class RedditRevokeRedirectViewTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = UserFactory(email="test@test.nl", password="test")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
self.patch = patch("newsreader.accounts.views.integrations.revoke_reddit_token")
|
||||||
|
self.mocked_revoke = self.patch.start()
|
||||||
|
|
||||||
|
def test_simple(self):
|
||||||
|
self.user.reddit_access_token = "jadajadajada"
|
||||||
|
self.user.reddit_refresh_token = "jadajadajada"
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
self.mocked_revoke.return_value = True
|
||||||
|
|
||||||
|
response = self.client.get(reverse("accounts:settings:reddit-revoke"))
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
||||||
|
|
||||||
|
self.mocked_revoke.assert_called_once_with(self.user)
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEquals(self.user.reddit_access_token, None)
|
||||||
|
self.assertEquals(self.user.reddit_refresh_token, None)
|
||||||
|
|
||||||
|
def test_no_refresh_token(self):
|
||||||
|
self.user.reddit_refresh_token = None
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("accounts:settings:reddit-revoke"))
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
||||||
|
|
||||||
|
self.mocked_revoke.assert_not_called()
|
||||||
|
|
||||||
|
def test_unsuccessful_response(self):
|
||||||
|
self.user.reddit_access_token = "jadajadajada"
|
||||||
|
self.user.reddit_refresh_token = "jadajadajada"
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
self.mocked_revoke.return_value = False
|
||||||
|
|
||||||
|
response = self.client.get(reverse("accounts:settings:reddit-revoke"))
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEquals(self.user.reddit_access_token, "jadajadajada")
|
||||||
|
self.assertEquals(self.user.reddit_refresh_token, "jadajadajada")
|
||||||
|
|
||||||
|
def test_stream_exception(self):
|
||||||
|
self.user.reddit_access_token = "jadajadajada"
|
||||||
|
self.user.reddit_refresh_token = "jadajadajada"
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
self.mocked_revoke.side_effect = StreamException
|
||||||
|
|
||||||
|
response = self.client.get(reverse("accounts:settings:reddit-revoke"))
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEquals(self.user.reddit_access_token, "jadajadajada")
|
||||||
|
self.assertEquals(self.user.reddit_refresh_token, "jadajadajada")
|
||||||
|
|
||||||
|
|
||||||
|
class TwitterRevokeRedirectView(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = UserFactory(email="test@test.nl", password="test")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
self.patch = patch("newsreader.accounts.views.integrations.post")
|
||||||
|
self.mocked_post = self.patch.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
patch.stopall()
|
||||||
|
|
||||||
|
def test_simple(self):
|
||||||
|
self.user.twitter_oauth_token = "jadajadajada"
|
||||||
|
self.user.twitter_oauth_token_secret = "jadajadajada"
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("accounts:settings:twitter-revoke"))
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertIsNone(self.user.twitter_oauth_token)
|
||||||
|
self.assertIsNone(self.user.twitter_oauth_token_secret)
|
||||||
|
|
||||||
|
def test_no_authorized_account(self):
|
||||||
|
self.user.twitter_oauth_token = None
|
||||||
|
self.user.twitter_oauth_token_secret = None
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("accounts:settings:twitter-revoke"))
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
||||||
|
|
||||||
|
self.mocked_post.assert_not_called()
|
||||||
|
|
||||||
|
def test_stream_exception(self):
|
||||||
|
self.user.twitter_oauth_token = "jadajadajada"
|
||||||
|
self.user.twitter_oauth_token_secret = "jadajadajada"
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
self.mocked_post.side_effect = StreamException
|
||||||
|
|
||||||
|
response = self.client.get(reverse("accounts:settings:twitter-revoke"))
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEquals(self.user.twitter_oauth_token, "jadajadajada")
|
||||||
|
self.assertEquals(self.user.twitter_oauth_token_secret, "jadajadajada")
|
||||||
|
|
||||||
|
|
||||||
|
class TwitterAuthRedirectViewTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = UserFactory(email="test@test.nl", password="test")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
self.patch = patch("newsreader.accounts.views.integrations.post")
|
||||||
|
self.mocked_post = self.patch.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
def test_simple(self):
|
||||||
|
self.mocked_post.return_value = Mock(
|
||||||
|
text="oauth_token=foo&oauth_token_secret=bar"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("accounts:settings:twitter-auth"))
|
||||||
|
|
||||||
|
self.assertRedirects(
|
||||||
|
response,
|
||||||
|
f"{TWITTER_AUTH_URL}/?oauth_token=foo",
|
||||||
|
fetch_redirect_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
cached_token = cache.get(f"twitter-{self.user.email}-token")
|
||||||
|
cached_secret = cache.get(f"twitter-{self.user.email}-secret")
|
||||||
|
|
||||||
|
self.assertEquals(cached_token, "foo")
|
||||||
|
self.assertEquals(cached_secret, "bar")
|
||||||
|
|
||||||
|
def test_stream_exception(self):
|
||||||
|
self.mocked_post.side_effect = StreamException
|
||||||
|
|
||||||
|
response = self.client.get(reverse("accounts:settings:twitter-auth"))
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
||||||
|
|
||||||
|
cached_token = cache.get(f"twitter-{self.user.email}-token")
|
||||||
|
cached_secret = cache.get(f"twitter-{self.user.email}-secret")
|
||||||
|
|
||||||
|
self.assertIsNone(cached_token)
|
||||||
|
self.assertIsNone(cached_secret)
|
||||||
|
|
||||||
|
def test_unexpected_contents(self):
|
||||||
|
self.mocked_post.return_value = Mock(text="foo=bar&oauth_token_secret=bar")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("accounts:settings:twitter-auth"))
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
||||||
|
|
||||||
|
cached_token = cache.get(f"twitter-{self.user.email}-token")
|
||||||
|
cached_secret = cache.get(f"twitter-{self.user.email}-secret")
|
||||||
|
|
||||||
|
self.assertIsNone(cached_token)
|
||||||
|
self.assertIsNone(cached_secret)
|
||||||
|
|
||||||
|
|
||||||
|
class TwitterTemplateViewTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = UserFactory(email="test@test.nl", password="test")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
self.patch = patch("newsreader.accounts.views.integrations.post")
|
||||||
|
self.mocked_post = self.patch.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
def test_simple(self):
|
||||||
|
cache.set_many(
|
||||||
|
{
|
||||||
|
f"twitter-{self.user.email}-token": "foo",
|
||||||
|
f"twitter-{self.user.email}-secret": "bar",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {"denied": "", "oauth_token": "foo", "oauth_verifier": "barfoo"}
|
||||||
|
|
||||||
|
self.mocked_post.return_value = Mock(
|
||||||
|
text="oauth_token=realtoken&oauth_token_secret=realsecret"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(response, _("Twitter account is linked"))
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEquals(self.user.twitter_oauth_token, "realtoken")
|
||||||
|
self.assertEquals(self.user.twitter_oauth_token_secret, "realsecret")
|
||||||
|
|
||||||
|
self.assertIsNone(cache.get(f"twitter-{self.user.email}-token"))
|
||||||
|
self.assertIsNone(cache.get(f"twitter-{self.user.email}-secret"))
|
||||||
|
|
||||||
|
def test_denied(self):
|
||||||
|
params = {"denied": "true", "oauth_token": "foo", "oauth_verifier": "barfoo"}
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(response, _("Twitter authorization failed"))
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertIsNone(self.user.twitter_oauth_token)
|
||||||
|
self.assertIsNone(self.user.twitter_oauth_token_secret)
|
||||||
|
|
||||||
|
self.mocked_post.assert_not_called()
|
||||||
|
|
||||||
|
def test_mismatched_token(self):
|
||||||
|
cache.set_many(
|
||||||
|
{
|
||||||
|
f"twitter-{self.user.email}-token": "foo",
|
||||||
|
f"twitter-{self.user.email}-secret": "bar",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {"denied": "", "oauth_token": "boo", "oauth_verifier": "barfoo"}
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(response, _("OAuth tokens failed to match"))
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertIsNone(self.user.twitter_oauth_token)
|
||||||
|
self.assertIsNone(self.user.twitter_oauth_token_secret)
|
||||||
|
|
||||||
|
self.mocked_post.assert_not_called()
|
||||||
|
|
||||||
|
def test_missing_secret(self):
|
||||||
|
cache.set_many({f"twitter-{self.user.email}-token": "foo"})
|
||||||
|
|
||||||
|
params = {"denied": "", "oauth_token": "foo", "oauth_verifier": "barfoo"}
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(response, _("No matching tokens found for this user"))
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertIsNone(self.user.twitter_oauth_token_secret)
|
||||||
|
|
||||||
|
self.mocked_post.assert_not_called()
|
||||||
|
|
||||||
|
def test_stream_exception(self):
|
||||||
|
cache.set_many(
|
||||||
|
{
|
||||||
|
f"twitter-{self.user.email}-token": "foo",
|
||||||
|
f"twitter-{self.user.email}-secret": "bar",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {"denied": "", "oauth_token": "foo", "oauth_verifier": "barfoo"}
|
||||||
|
|
||||||
|
self.mocked_post.side_effect = StreamException
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(response, _("Failed requesting access token"))
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertIsNone(self.user.twitter_oauth_token)
|
||||||
|
self.assertIsNone(self.user.twitter_oauth_token_secret)
|
||||||
|
|
||||||
|
self.assertIsNotNone(cache.get(f"twitter-{self.user.email}-token"))
|
||||||
|
self.assertIsNotNone(cache.get(f"twitter-{self.user.email}-secret"))
|
||||||
|
|
||||||
|
def test_unexpected_contents(self):
|
||||||
|
cache.set_many(
|
||||||
|
{
|
||||||
|
f"twitter-{self.user.email}-token": "foo",
|
||||||
|
f"twitter-{self.user.email}-secret": "bar",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {"denied": "", "oauth_token": "foo", "oauth_verifier": "barfoo"}
|
||||||
|
|
||||||
|
self.mocked_post.return_value = Mock(
|
||||||
|
text="foobar=boo&oauth_token_secret=realsecret"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(response, _("No credentials found in Twitter response"))
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertIsNone(self.user.twitter_oauth_token)
|
||||||
|
self.assertIsNone(self.user.twitter_oauth_token_secret)
|
||||||
|
|
||||||
|
self.assertIsNotNone(cache.get(f"twitter-{self.user.email}-token"))
|
||||||
|
self.assertIsNotNone(cache.get(f"twitter-{self.user.email}-secret"))
|
||||||
110
src/newsreader/accounts/tests/test_registration.py
Normal file
110
src/newsreader/accounts/tests/test_registration.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
from django.core import mail
|
||||||
|
from django.test import TransactionTestCase as TestCase
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from registration.models import RegistrationProfile
|
||||||
|
|
||||||
|
from newsreader.accounts.models import User
|
||||||
|
from newsreader.accounts.tests.factories import UserFactory
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.url = reverse("accounts:register")
|
||||||
|
self.success_url = reverse("accounts:register-complete")
|
||||||
|
self.disallowed_url = reverse("accounts:register-closed")
|
||||||
|
|
||||||
|
def test_simple(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_registration(self):
|
||||||
|
data = {
|
||||||
|
"email": "test@test.com",
|
||||||
|
"password1": "test12456",
|
||||||
|
"password2": "test12456",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(self.url, data)
|
||||||
|
self.assertRedirects(response, self.success_url)
|
||||||
|
|
||||||
|
self.assertEquals(User.objects.count(), 1)
|
||||||
|
self.assertEquals(RegistrationProfile.objects.count(), 1)
|
||||||
|
|
||||||
|
user = User.objects.get()
|
||||||
|
|
||||||
|
self.assertEquals(user.is_active, False)
|
||||||
|
self.assertEquals(len(mail.outbox), 1)
|
||||||
|
|
||||||
|
def test_existing_email(self):
|
||||||
|
UserFactory(email="test@test.com")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"email": "test@test.com",
|
||||||
|
"password1": "test12456",
|
||||||
|
"password2": "test12456",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(self.url, data)
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
|
||||||
|
self.assertEquals(User.objects.count(), 1)
|
||||||
|
self.assertContains(response, _("User with this Email address already exists"))
|
||||||
|
|
||||||
|
def test_pending_registration(self):
|
||||||
|
data = {
|
||||||
|
"email": "test@test.com",
|
||||||
|
"password1": "test12456",
|
||||||
|
"password2": "test12456",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(self.url, data)
|
||||||
|
self.assertRedirects(response, self.success_url)
|
||||||
|
|
||||||
|
self.assertEquals(User.objects.count(), 1)
|
||||||
|
self.assertEquals(RegistrationProfile.objects.count(), 1)
|
||||||
|
|
||||||
|
user = User.objects.get()
|
||||||
|
|
||||||
|
self.assertEquals(user.is_active, False)
|
||||||
|
self.assertEquals(len(mail.outbox), 1)
|
||||||
|
|
||||||
|
response = self.client.post(self.url, data)
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
self.assertContains(response, _("User with this Email address already exists"))
|
||||||
|
|
||||||
|
def test_disabled_account(self):
|
||||||
|
UserFactory(email="test@test.com", is_active=False)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"email": "test@test.com",
|
||||||
|
"password1": "test12456",
|
||||||
|
"password2": "test12456",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(self.url, data)
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
|
||||||
|
self.assertEquals(User.objects.count(), 1)
|
||||||
|
self.assertContains(response, _("User with this Email address already exists"))
|
||||||
|
|
||||||
|
@override_settings(REGISTRATION_OPEN=False)
|
||||||
|
def test_registration_closed(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
self.assertRedirects(response, self.disallowed_url)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"email": "test@test.com",
|
||||||
|
"password1": "test12456",
|
||||||
|
"password2": "test12456",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(self.url, data)
|
||||||
|
self.assertRedirects(response, self.disallowed_url)
|
||||||
|
|
||||||
|
self.assertEquals(User.objects.count(), 0)
|
||||||
|
self.assertEquals(RegistrationProfile.objects.count(), 0)
|
||||||
77
src/newsreader/accounts/tests/test_resend_activation.py
Normal file
77
src/newsreader/accounts/tests/test_resend_activation.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
from django.core import mail
|
||||||
|
from django.test import TransactionTestCase as TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from registration.models import RegistrationProfile
|
||||||
|
|
||||||
|
from newsreader.accounts.tests.factories import RegistrationProfileFactory, UserFactory
|
||||||
|
|
||||||
|
|
||||||
|
class ResendActivationTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.url = reverse("accounts:activate-resend")
|
||||||
|
self.success_url = reverse("accounts:activate-complete")
|
||||||
|
self.register_url = reverse("accounts:register")
|
||||||
|
|
||||||
|
def test_simple(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_resent_form(self):
|
||||||
|
data = {
|
||||||
|
"email": "test@test.com",
|
||||||
|
"password1": "test12456",
|
||||||
|
"password2": "test12456",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(self.register_url, data)
|
||||||
|
|
||||||
|
register_profile = RegistrationProfile.objects.get()
|
||||||
|
original_kwargs = {"activation_key": register_profile.activation_key}
|
||||||
|
|
||||||
|
response = self.client.post(self.url, {"email": "test@test.com"})
|
||||||
|
|
||||||
|
self.assertContains(response, _("We have sent an email to"))
|
||||||
|
|
||||||
|
self.assertEquals(len(mail.outbox), 2)
|
||||||
|
|
||||||
|
register_profile.refresh_from_db()
|
||||||
|
|
||||||
|
kwargs = {"activation_key": register_profile.activation_key}
|
||||||
|
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||||
|
|
||||||
|
self.assertRedirects(response, self.success_url)
|
||||||
|
|
||||||
|
register_profile.refresh_from_db()
|
||||||
|
user = register_profile.user
|
||||||
|
|
||||||
|
self.assertEquals(user.is_active, True)
|
||||||
|
|
||||||
|
# test the old activation code
|
||||||
|
response = self.client.get(reverse("accounts:activate", kwargs=original_kwargs))
|
||||||
|
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
self.assertContains(response, _("Account activation failed"))
|
||||||
|
|
||||||
|
def test_existing_account(self):
|
||||||
|
user = UserFactory(is_active=True)
|
||||||
|
profile = RegistrationProfileFactory(user=user, activated=True)
|
||||||
|
|
||||||
|
response = self.client.post(self.url, {"email": user.email})
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
|
||||||
|
# default behaviour is to show success page but not send an email
|
||||||
|
self.assertContains(response, _("We have sent an email to"))
|
||||||
|
|
||||||
|
self.assertEquals(len(mail.outbox), 0)
|
||||||
|
|
||||||
|
def test_no_account(self):
|
||||||
|
response = self.client.post(self.url, {"email": "fake@mail.com"})
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
|
||||||
|
# default behaviour is to show success page but not send an email
|
||||||
|
self.assertContains(response, _("We have sent an email to"))
|
||||||
|
|
||||||
|
self.assertEquals(len(mail.outbox), 0)
|
||||||
|
|
@ -15,6 +15,9 @@ class UserTestCase(TestCase):
|
||||||
PeriodicTask.objects.create(
|
PeriodicTask.objects.create(
|
||||||
name=f"{user.email}-feed", task="FeedTask", interval=interval
|
name=f"{user.email}-feed", task="FeedTask", interval=interval
|
||||||
)
|
)
|
||||||
|
PeriodicTask.objects.create(
|
||||||
|
name=f"{user.email}-timeline", task="TwitterTimelineTask", interval=interval
|
||||||
|
)
|
||||||
|
|
||||||
user.delete()
|
user.delete()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,11 @@ from django.contrib.auth.decorators import login_required
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
from newsreader.accounts.views import (
|
from newsreader.accounts.views import (
|
||||||
|
ActivationCompleteView,
|
||||||
|
ActivationResendView,
|
||||||
|
ActivationView,
|
||||||
FaviconRedirectView,
|
FaviconRedirectView,
|
||||||
|
IntegrationsView,
|
||||||
LoginView,
|
LoginView,
|
||||||
LogoutView,
|
LogoutView,
|
||||||
PasswordChangeView,
|
PasswordChangeView,
|
||||||
|
|
@ -10,11 +14,54 @@ from newsreader.accounts.views import (
|
||||||
PasswordResetConfirmView,
|
PasswordResetConfirmView,
|
||||||
PasswordResetDoneView,
|
PasswordResetDoneView,
|
||||||
PasswordResetView,
|
PasswordResetView,
|
||||||
|
RedditRevokeRedirectView,
|
||||||
|
RedditTemplateView,
|
||||||
|
RedditTokenRedirectView,
|
||||||
|
RegistrationClosedView,
|
||||||
|
RegistrationCompleteView,
|
||||||
|
RegistrationView,
|
||||||
SettingsView,
|
SettingsView,
|
||||||
|
TwitterAuthRedirectView,
|
||||||
|
TwitterRevokeRedirectView,
|
||||||
|
TwitterTemplateView,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
settings_patterns = [
|
settings_patterns = [
|
||||||
|
# Integrations
|
||||||
|
path(
|
||||||
|
"integrations/reddit/callback/",
|
||||||
|
login_required(RedditTemplateView.as_view()),
|
||||||
|
name="reddit-template",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"integrations/reddit/refresh/",
|
||||||
|
login_required(RedditTokenRedirectView.as_view()),
|
||||||
|
name="reddit-refresh",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"integrations/reddit/revoke/",
|
||||||
|
login_required(RedditRevokeRedirectView.as_view()),
|
||||||
|
name="reddit-revoke",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"integrations/twitter/auth/",
|
||||||
|
login_required(TwitterAuthRedirectView.as_view()),
|
||||||
|
name="twitter-auth",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"integrations/twitter/callback/",
|
||||||
|
login_required(TwitterTemplateView.as_view()),
|
||||||
|
name="twitter-template",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"integrations/twitter/revoke/",
|
||||||
|
login_required(TwitterRevokeRedirectView.as_view()),
|
||||||
|
name="twitter-revoke",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"integrations/", login_required(IntegrationsView.as_view()), name="integrations"
|
||||||
|
),
|
||||||
# Misc
|
# Misc
|
||||||
path("favicon/", login_required(FaviconRedirectView.as_view()), name="favicon"),
|
path("favicon/", login_required(FaviconRedirectView.as_view()), name="favicon"),
|
||||||
path("", login_required(SettingsView.as_view()), name="home"),
|
path("", login_required(SettingsView.as_view()), name="home"),
|
||||||
|
|
@ -24,6 +71,24 @@ urlpatterns = [
|
||||||
# Auth
|
# Auth
|
||||||
path("login/", LoginView.as_view(), name="login"),
|
path("login/", LoginView.as_view(), name="login"),
|
||||||
path("logout/", LogoutView.as_view(), name="logout"),
|
path("logout/", LogoutView.as_view(), name="logout"),
|
||||||
|
# Register
|
||||||
|
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",
|
||||||
|
),
|
||||||
# Password
|
# Password
|
||||||
path("password-reset/", PasswordResetView.as_view(), name="password-reset"),
|
path("password-reset/", PasswordResetView.as_view(), name="password-reset"),
|
||||||
path(
|
path(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
from newsreader.accounts.views.auth import LoginView, LogoutView
|
from newsreader.accounts.views.auth import LoginView, LogoutView
|
||||||
from newsreader.accounts.views.favicon import FaviconRedirectView
|
from newsreader.accounts.views.favicon import FaviconRedirectView
|
||||||
|
from newsreader.accounts.views.integrations import (
|
||||||
|
IntegrationsView,
|
||||||
|
RedditRevokeRedirectView,
|
||||||
|
RedditTemplateView,
|
||||||
|
RedditTokenRedirectView,
|
||||||
|
TwitterAuthRedirectView,
|
||||||
|
TwitterRevokeRedirectView,
|
||||||
|
TwitterTemplateView,
|
||||||
|
)
|
||||||
from newsreader.accounts.views.password import (
|
from newsreader.accounts.views.password import (
|
||||||
PasswordChangeView,
|
PasswordChangeView,
|
||||||
PasswordResetCompleteView,
|
PasswordResetCompleteView,
|
||||||
|
|
@ -7,17 +16,12 @@ from newsreader.accounts.views.password import (
|
||||||
PasswordResetDoneView,
|
PasswordResetDoneView,
|
||||||
PasswordResetView,
|
PasswordResetView,
|
||||||
)
|
)
|
||||||
|
from newsreader.accounts.views.registration import (
|
||||||
|
ActivationCompleteView,
|
||||||
|
ActivationResendView,
|
||||||
|
ActivationView,
|
||||||
|
RegistrationClosedView,
|
||||||
|
RegistrationCompleteView,
|
||||||
|
RegistrationView,
|
||||||
|
)
|
||||||
from newsreader.accounts.views.settings import SettingsView
|
from newsreader.accounts.views.settings import SettingsView
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"LoginView",
|
|
||||||
"LogoutView",
|
|
||||||
"FaviconRedirectView",
|
|
||||||
"PasswordChangeView",
|
|
||||||
"PasswordResetCompleteView",
|
|
||||||
"PasswordResetConfirmView",
|
|
||||||
"PasswordResetDoneView",
|
|
||||||
"PasswordResetView",
|
|
||||||
"SettingsView",
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
from django.contrib.auth import views as django_views
|
from django.contrib.auth import views as django_views
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
|
|
||||||
from newsreader.utils.views import NavListMixin
|
|
||||||
|
|
||||||
|
class LoginView(django_views.LoginView):
|
||||||
class LoginView(NavListMixin, django_views.LoginView):
|
|
||||||
template_name = "accounts/views/login.html"
|
template_name = "accounts/views/login.html"
|
||||||
success_url = reverse_lazy("index")
|
success_url = reverse_lazy("index")
|
||||||
|
|
||||||
|
|
|
||||||
343
src/newsreader/accounts/views/integrations.py
Normal file
343
src/newsreader/accounts/views/integrations.py
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from urllib.parse import parse_qs, urlencode
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
from django.views.generic import RedirectView, TemplateView
|
||||||
|
|
||||||
|
from requests_oauthlib import OAuth1 as OAuth
|
||||||
|
|
||||||
|
from newsreader.news.collection.exceptions import StreamException
|
||||||
|
from newsreader.news.collection.reddit import (
|
||||||
|
get_reddit_access_token,
|
||||||
|
get_reddit_authorization_url,
|
||||||
|
revoke_reddit_token,
|
||||||
|
)
|
||||||
|
from newsreader.news.collection.tasks import RedditTokenTask
|
||||||
|
from newsreader.news.collection.twitter import (
|
||||||
|
TWITTER_ACCESS_TOKEN_URL,
|
||||||
|
TWITTER_AUTH_URL,
|
||||||
|
TWITTER_REQUEST_TOKEN_URL,
|
||||||
|
TWITTER_REVOKE_URL,
|
||||||
|
)
|
||||||
|
from newsreader.news.collection.utils import post
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationsView(TemplateView):
|
||||||
|
template_name = "accounts/views/integrations.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return {
|
||||||
|
**super().get_context_data(**kwargs),
|
||||||
|
**self.get_reddit_context(**kwargs),
|
||||||
|
**self.get_twitter_context(**kwargs),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_reddit_context(self, **kwargs):
|
||||||
|
user = self.request.user
|
||||||
|
reddit_authorization_url = None
|
||||||
|
reddit_refresh_url = None
|
||||||
|
|
||||||
|
reddit_task_active = cache.get(f"{user.email}-reddit-refresh")
|
||||||
|
|
||||||
|
if (
|
||||||
|
user.reddit_refresh_token
|
||||||
|
and not user.reddit_access_token
|
||||||
|
and not reddit_task_active
|
||||||
|
):
|
||||||
|
reddit_refresh_url = reverse_lazy("accounts:settings:reddit-refresh")
|
||||||
|
|
||||||
|
if not user.reddit_refresh_token:
|
||||||
|
reddit_authorization_url = get_reddit_authorization_url(user)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"reddit_authorization_url": reddit_authorization_url,
|
||||||
|
"reddit_refresh_url": reddit_refresh_url,
|
||||||
|
"reddit_revoke_url": (
|
||||||
|
reverse_lazy("accounts:settings:reddit-revoke")
|
||||||
|
if not reddit_authorization_url
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_twitter_context(self, **kwargs):
|
||||||
|
twitter_revoke_url = None
|
||||||
|
|
||||||
|
if self.request.user.has_twitter_auth:
|
||||||
|
twitter_revoke_url = reverse_lazy("accounts:settings:twitter-revoke")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"twitter_auth_url": reverse_lazy("accounts:settings:twitter-auth"),
|
||||||
|
"twitter_revoke_url": twitter_revoke_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RedditTemplateView(TemplateView):
|
||||||
|
template_name = "accounts/views/reddit.html"
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
context = self.get_context_data(**kwargs)
|
||||||
|
|
||||||
|
error = request.GET.get("error", None)
|
||||||
|
state = request.GET.get("state", None)
|
||||||
|
code = request.GET.get("code", None)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
return self.render_to_response({**context, "error": error})
|
||||||
|
|
||||||
|
if not code or not state:
|
||||||
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
cached_state = cache.get(f"{request.user.email}-reddit-auth")
|
||||||
|
|
||||||
|
if state != cached_state:
|
||||||
|
return self.render_to_response(
|
||||||
|
{
|
||||||
|
**context,
|
||||||
|
"error": _(
|
||||||
|
"The saved state for Reddit authorization did not match"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
access_token, refresh_token = get_reddit_access_token(code, request.user)
|
||||||
|
|
||||||
|
return self.render_to_response(
|
||||||
|
{
|
||||||
|
**context,
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except StreamException as e:
|
||||||
|
return self.render_to_response({**context, "error": str(e)})
|
||||||
|
except KeyError:
|
||||||
|
return self.render_to_response(
|
||||||
|
{
|
||||||
|
**context,
|
||||||
|
"error": _("Access and refresh token not found in response"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RedditTokenRedirectView(RedirectView):
|
||||||
|
url = reverse_lazy("accounts:settings:integrations")
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
response = super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
task_active = cache.get(f"{user.email}-reddit-refresh")
|
||||||
|
|
||||||
|
if not task_active:
|
||||||
|
RedditTokenTask.delay(user.pk)
|
||||||
|
messages.success(request, _("Access token is being retrieved"))
|
||||||
|
cache.set(f"{user.email}-reddit-refresh", 1, 300)
|
||||||
|
return response
|
||||||
|
|
||||||
|
messages.error(request, _("Unable to retrieve token"))
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class RedditRevokeRedirectView(RedirectView):
|
||||||
|
url = reverse_lazy("accounts:settings:integrations")
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
response = super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if not user.reddit_refresh_token:
|
||||||
|
messages.error(request, _("No reddit account is linked to this account"))
|
||||||
|
return response
|
||||||
|
|
||||||
|
try:
|
||||||
|
is_revoked = revoke_reddit_token(user)
|
||||||
|
except StreamException:
|
||||||
|
logger.exception(f"Unable to revoke reddit token for {user.pk}")
|
||||||
|
|
||||||
|
messages.error(request, _("Unable to revoke reddit token"))
|
||||||
|
return response
|
||||||
|
|
||||||
|
if not is_revoked:
|
||||||
|
messages.error(request, _("Unable to revoke reddit token"))
|
||||||
|
return response
|
||||||
|
|
||||||
|
user.reddit_access_token = None
|
||||||
|
user.reddit_refresh_token = None
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
messages.success(request, _("Reddit account deathorized"))
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class TwitterRevokeRedirectView(RedirectView):
|
||||||
|
url = reverse_lazy("accounts:settings:integrations")
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
if not request.user.has_twitter_auth:
|
||||||
|
messages.error(request, _("No twitter credentials found"))
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
oauth = OAuth(
|
||||||
|
settings.TWITTER_CONSUMER_ID,
|
||||||
|
client_secret=settings.TWITTER_CONSUMER_SECRET,
|
||||||
|
resource_owner_key=request.user.twitter_oauth_token,
|
||||||
|
resource_owner_secret=request.user.twitter_oauth_token_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
post(TWITTER_REVOKE_URL, auth=oauth)
|
||||||
|
except StreamException:
|
||||||
|
logger.exception("Failed revoking Twitter account")
|
||||||
|
|
||||||
|
messages.error(request, _("Unable revoke Twitter account"))
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
request.user.twitter_oauth_token = None
|
||||||
|
request.user.twitter_oauth_token_secret = None
|
||||||
|
request.user.save()
|
||||||
|
|
||||||
|
messages.success(request, _("Twitter account revoked"))
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class TwitterAuthRedirectView(RedirectView):
|
||||||
|
url = reverse_lazy("accounts:settings:integrations")
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
oauth = OAuth(
|
||||||
|
settings.TWITTER_CONSUMER_ID,
|
||||||
|
client_secret=settings.TWITTER_CONSUMER_SECRET,
|
||||||
|
callback_uri=settings.TWITTER_REDIRECT_URL,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = post(TWITTER_REQUEST_TOKEN_URL, auth=oauth)
|
||||||
|
except StreamException:
|
||||||
|
logger.exception("Failed requesting Twitter authentication token")
|
||||||
|
|
||||||
|
messages.error(request, _("Unable to retrieve initial Twitter token"))
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
params = parse_qs(response.text)
|
||||||
|
|
||||||
|
try:
|
||||||
|
request_oauth_token = params["oauth_token"][0]
|
||||||
|
request_oauth_secret = params["oauth_token_secret"][0]
|
||||||
|
except KeyError:
|
||||||
|
logger.exception("No credentials found in response")
|
||||||
|
|
||||||
|
messages.error(request, _("Unable to retrieve initial Twitter token"))
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
cache.set_many(
|
||||||
|
{
|
||||||
|
f"twitter-{request.user.email}-token": request_oauth_token,
|
||||||
|
f"twitter-{request.user.email}-secret": request_oauth_secret,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
request_params = urlencode({"oauth_token": request_oauth_token})
|
||||||
|
return redirect(f"{TWITTER_AUTH_URL}/?{request_params}")
|
||||||
|
|
||||||
|
|
||||||
|
class TwitterTemplateView(TemplateView):
|
||||||
|
template_name = "accounts/views/twitter.html"
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
context = self.get_context_data(**kwargs)
|
||||||
|
|
||||||
|
denied = request.GET.get("denied", False)
|
||||||
|
oauth_token = request.GET.get("oauth_token")
|
||||||
|
oauth_verifier = request.GET.get("oauth_verifier")
|
||||||
|
|
||||||
|
if denied:
|
||||||
|
return self.render_to_response(
|
||||||
|
{
|
||||||
|
**context,
|
||||||
|
"error": _("Twitter authorization failed"),
|
||||||
|
"authorized": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
cached_token = cache.get(f"twitter-{request.user.email}-token")
|
||||||
|
|
||||||
|
if oauth_token != cached_token:
|
||||||
|
return self.render_to_response(
|
||||||
|
{
|
||||||
|
**context,
|
||||||
|
"error": _("OAuth tokens failed to match"),
|
||||||
|
"authorized": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
cached_secret = cache.get(f"twitter-{request.user.email}-secret")
|
||||||
|
|
||||||
|
if not cached_token or not cached_secret:
|
||||||
|
return self.render_to_response(
|
||||||
|
{
|
||||||
|
**context,
|
||||||
|
"error": _("No matching tokens found for this user"),
|
||||||
|
"authorized": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
oauth = OAuth(
|
||||||
|
settings.TWITTER_CONSUMER_ID,
|
||||||
|
client_secret=settings.TWITTER_CONSUMER_SECRET,
|
||||||
|
resource_owner_key=cached_token,
|
||||||
|
resource_owner_secret=cached_secret,
|
||||||
|
verifier=oauth_verifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = post(TWITTER_ACCESS_TOKEN_URL, auth=oauth)
|
||||||
|
except StreamException:
|
||||||
|
logger.exception("Failed requesting Twitter access token")
|
||||||
|
|
||||||
|
return self.render_to_response(
|
||||||
|
{
|
||||||
|
**context,
|
||||||
|
"error": _("Failed requesting access token"),
|
||||||
|
"authorized": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
params = parse_qs(response.text)
|
||||||
|
|
||||||
|
try:
|
||||||
|
oauth_token = params["oauth_token"][0]
|
||||||
|
oauth_secret = params["oauth_token_secret"][0]
|
||||||
|
except KeyError:
|
||||||
|
logger.exception("No credentials in Twitter response")
|
||||||
|
|
||||||
|
return self.render_to_response(
|
||||||
|
{
|
||||||
|
**context,
|
||||||
|
"error": _("No credentials found in Twitter response"),
|
||||||
|
"authorized": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
request.user.twitter_oauth_token = oauth_token
|
||||||
|
request.user.twitter_oauth_token_secret = oauth_secret
|
||||||
|
request.user.save()
|
||||||
|
|
||||||
|
cache.delete_many(
|
||||||
|
[
|
||||||
|
f"twitter-{request.user.email}-token",
|
||||||
|
f"twitter-{request.user.email}-secret",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.render_to_response({**context, "error": None, "authorized": True})
|
||||||
|
|
@ -1,34 +1,32 @@
|
||||||
from django.contrib.auth import views as django_views
|
from django.contrib.auth import views as django_views
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
|
|
||||||
from newsreader.utils.views import NavListMixin
|
|
||||||
|
|
||||||
|
|
||||||
# PasswordResetView sends the mail
|
# PasswordResetView sends the mail
|
||||||
# PasswordResetDoneView shows a success message for the above
|
# PasswordResetDoneView shows a success message for the above
|
||||||
# PasswordResetConfirmView checks the link the user clicked and
|
# PasswordResetConfirmView checks the link the user clicked and
|
||||||
# prompts for a new password
|
# prompts for a new password
|
||||||
# PasswordResetCompleteView shows a success message for the above
|
# PasswordResetCompleteView shows a success message for the above
|
||||||
class PasswordResetView(NavListMixin, django_views.PasswordResetView):
|
class PasswordResetView(django_views.PasswordResetView):
|
||||||
template_name = "password-reset/password-reset.html"
|
template_name = "password-reset/password-reset.html"
|
||||||
subject_template_name = "password-reset/password-reset-subject.txt"
|
subject_template_name = "password-reset/password-reset-subject.txt"
|
||||||
email_template_name = "password-reset/password-reset-email.html"
|
email_template_name = "password-reset/password-reset-email.html"
|
||||||
success_url = reverse_lazy("accounts:password-reset-done")
|
success_url = reverse_lazy("accounts:password-reset-done")
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetDoneView(NavListMixin, django_views.PasswordResetDoneView):
|
class PasswordResetDoneView(django_views.PasswordResetDoneView):
|
||||||
template_name = "password-reset/password-reset-done.html"
|
template_name = "password-reset/password-reset-done.html"
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetConfirmView(NavListMixin, django_views.PasswordResetConfirmView):
|
class PasswordResetConfirmView(django_views.PasswordResetConfirmView):
|
||||||
template_name = "password-reset/password-reset-confirm.html"
|
template_name = "password-reset/password-reset-confirm.html"
|
||||||
success_url = reverse_lazy("accounts:password-reset-complete")
|
success_url = reverse_lazy("accounts:password-reset-complete")
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetCompleteView(NavListMixin, django_views.PasswordResetCompleteView):
|
class PasswordResetCompleteView(django_views.PasswordResetCompleteView):
|
||||||
template_name = "password-reset/password-reset-complete.html"
|
template_name = "password-reset/password-reset-complete.html"
|
||||||
|
|
||||||
|
|
||||||
class PasswordChangeView(NavListMixin, django_views.PasswordChangeView):
|
class PasswordChangeView(django_views.PasswordChangeView):
|
||||||
template_name = "accounts/views/password-change.html"
|
template_name = "accounts/views/password-change.html"
|
||||||
success_url = reverse_lazy("accounts:settings")
|
success_url = reverse_lazy("accounts:settings")
|
||||||
|
|
|
||||||
54
src/newsreader/accounts/views/registration.py
Normal file
54
src/newsreader/accounts/views/registration.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
||||||
|
)
|
||||||
|
|
@ -4,10 +4,9 @@ from django.views.generic.edit import FormView, ModelFormMixin
|
||||||
|
|
||||||
from newsreader.accounts.forms import UserSettingsForm
|
from newsreader.accounts.forms import UserSettingsForm
|
||||||
from newsreader.accounts.models import User
|
from newsreader.accounts.models import User
|
||||||
from newsreader.utils.views import NavListMixin
|
|
||||||
|
|
||||||
|
|
||||||
class SettingsView(NavListMixin, ModelFormMixin, FormView):
|
class SettingsView(ModelFormMixin, FormView):
|
||||||
template_name = "accounts/views/settings.html"
|
template_name = "accounts/views/settings.html"
|
||||||
success_url = reverse_lazy("accounts:settings:home")
|
success_url = reverse_lazy("accounts:settings:home")
|
||||||
form_class = UserSettingsForm
|
form_class = UserSettingsForm
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
101
src/newsreader/assets/fonts/METADATA.pb
Executable file
101
src/newsreader/assets/fonts/METADATA.pb
Executable file
|
|
@ -0,0 +1,101 @@
|
||||||
|
name: "Rubik"
|
||||||
|
designer: "Hubert and Fischer, Meir Sadan, Cyreal"
|
||||||
|
license: "OFL"
|
||||||
|
category: "SANS_SERIF"
|
||||||
|
date_added: "2015-07-22"
|
||||||
|
fonts {
|
||||||
|
name: "Rubik"
|
||||||
|
style: "normal"
|
||||||
|
weight: 300
|
||||||
|
filename: "Rubik-Light.ttf"
|
||||||
|
post_script_name: "Rubik-Light"
|
||||||
|
full_name: "Rubik Light"
|
||||||
|
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||||
|
}
|
||||||
|
fonts {
|
||||||
|
name: "Rubik"
|
||||||
|
style: "italic"
|
||||||
|
weight: 300
|
||||||
|
filename: "Rubik-LightItalic.ttf"
|
||||||
|
post_script_name: "Rubik-LightItalic"
|
||||||
|
full_name: "Rubik Light Italic"
|
||||||
|
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||||
|
}
|
||||||
|
fonts {
|
||||||
|
name: "Rubik"
|
||||||
|
style: "normal"
|
||||||
|
weight: 400
|
||||||
|
filename: "Rubik-Regular.ttf"
|
||||||
|
post_script_name: "Rubik-Regular"
|
||||||
|
full_name: "Rubik Regular"
|
||||||
|
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||||
|
}
|
||||||
|
fonts {
|
||||||
|
name: "Rubik"
|
||||||
|
style: "italic"
|
||||||
|
weight: 400
|
||||||
|
filename: "Rubik-Italic.ttf"
|
||||||
|
post_script_name: "Rubik-Italic"
|
||||||
|
full_name: "Rubik Italic"
|
||||||
|
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||||
|
}
|
||||||
|
fonts {
|
||||||
|
name: "Rubik"
|
||||||
|
style: "normal"
|
||||||
|
weight: 500
|
||||||
|
filename: "Rubik-Medium.ttf"
|
||||||
|
post_script_name: "Rubik-Medium"
|
||||||
|
full_name: "Rubik Medium"
|
||||||
|
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||||
|
}
|
||||||
|
fonts {
|
||||||
|
name: "Rubik"
|
||||||
|
style: "italic"
|
||||||
|
weight: 500
|
||||||
|
filename: "Rubik-MediumItalic.ttf"
|
||||||
|
post_script_name: "Rubik-MediumItalic"
|
||||||
|
full_name: "Rubik Medium Italic"
|
||||||
|
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||||
|
}
|
||||||
|
fonts {
|
||||||
|
name: "Rubik"
|
||||||
|
style: "normal"
|
||||||
|
weight: 700
|
||||||
|
filename: "Rubik-Bold.ttf"
|
||||||
|
post_script_name: "Rubik-Bold"
|
||||||
|
full_name: "Rubik Bold"
|
||||||
|
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||||
|
}
|
||||||
|
fonts {
|
||||||
|
name: "Rubik"
|
||||||
|
style: "italic"
|
||||||
|
weight: 700
|
||||||
|
filename: "Rubik-BoldItalic.ttf"
|
||||||
|
post_script_name: "Rubik-BoldItalic"
|
||||||
|
full_name: "Rubik Bold Italic"
|
||||||
|
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||||
|
}
|
||||||
|
fonts {
|
||||||
|
name: "Rubik"
|
||||||
|
style: "normal"
|
||||||
|
weight: 900
|
||||||
|
filename: "Rubik-Black.ttf"
|
||||||
|
post_script_name: "Rubik-Black"
|
||||||
|
full_name: "Rubik Black"
|
||||||
|
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||||
|
}
|
||||||
|
fonts {
|
||||||
|
name: "Rubik"
|
||||||
|
style: "italic"
|
||||||
|
weight: 900
|
||||||
|
filename: "Rubik-BlackItalic.ttf"
|
||||||
|
post_script_name: "Rubik-BlackItalic"
|
||||||
|
full_name: "Rubik Black Italic"
|
||||||
|
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||||
|
}
|
||||||
|
subsets: "cyrillic"
|
||||||
|
subsets: "cyrillic-ext"
|
||||||
|
subsets: "hebrew"
|
||||||
|
subsets: "latin"
|
||||||
|
subsets: "latin-ext"
|
||||||
|
subsets: "menu"
|
||||||
BIN
src/newsreader/assets/fonts/Rubik-Black.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-Black.ttf
Executable file
Binary file not shown.
BIN
src/newsreader/assets/fonts/Rubik-BlackItalic.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-BlackItalic.ttf
Executable file
Binary file not shown.
BIN
src/newsreader/assets/fonts/Rubik-Bold.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-Bold.ttf
Executable file
Binary file not shown.
BIN
src/newsreader/assets/fonts/Rubik-BoldItalic.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-BoldItalic.ttf
Executable file
Binary file not shown.
BIN
src/newsreader/assets/fonts/Rubik-Italic.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-Italic.ttf
Executable file
Binary file not shown.
BIN
src/newsreader/assets/fonts/Rubik-Light.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-Light.ttf
Executable file
Binary file not shown.
BIN
src/newsreader/assets/fonts/Rubik-LightItalic.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-LightItalic.ttf
Executable file
Binary file not shown.
BIN
src/newsreader/assets/fonts/Rubik-Medium.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-Medium.ttf
Executable file
Binary file not shown.
BIN
src/newsreader/assets/fonts/Rubik-MediumItalic.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-MediumItalic.ttf
Executable file
Binary file not shown.
BIN
src/newsreader/assets/fonts/Rubik-Regular.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-Regular.ttf
Executable file
Binary file not shown.
|
|
@ -1,6 +1,8 @@
|
||||||
from dotenv import load_dotenv
|
import os
|
||||||
|
|
||||||
from newsreader.conf.utils import get_env, get_root_dir
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
@ -13,13 +15,16 @@ except ImportError:
|
||||||
DjangoIntegration = None
|
DjangoIntegration = None
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = get_root_dir()
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
DJANGO_PROJECT_DIR = BASE_DIR / "src" / "newsreader"
|
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 = False
|
DEBUG = False
|
||||||
|
|
||||||
ALLOWED_HOSTS = get_env("ALLOWED_HOSTS", split=",", default=["127.0.0.1", "localhost"])
|
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
|
||||||
INTERNAL_IPS = get_env("INTERNAL_IPS", split=",", default=["127.0.0.1", "localhost"])
|
INTERNAL_IPS = ["127.0.0.1", "localhost"]
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
|
|
@ -32,8 +37,10 @@ INSTALLED_APPS = [
|
||||||
"django.forms",
|
"django.forms",
|
||||||
# third party apps
|
# third party apps
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
|
"drf_yasg",
|
||||||
"celery",
|
"celery",
|
||||||
"django_celery_beat",
|
"django_celery_beat",
|
||||||
|
"registration",
|
||||||
"axes",
|
"axes",
|
||||||
# app modules
|
# app modules
|
||||||
"newsreader.accounts",
|
"newsreader.accounts",
|
||||||
|
|
@ -43,7 +50,7 @@ INSTALLED_APPS = [
|
||||||
"newsreader.news.collection",
|
"newsreader.news.collection",
|
||||||
]
|
]
|
||||||
|
|
||||||
SECRET_KEY = get_env("DJANGO_SECRET_KEY", default="")
|
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
"axes.backends.AxesBackend",
|
"axes.backends.AxesBackend",
|
||||||
|
|
@ -68,10 +75,11 @@ FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
"DIRS": [DJANGO_PROJECT_DIR / "templates"],
|
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
|
||||||
"APP_DIRS": True,
|
"APP_DIRS": True,
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"context_processors": [
|
"context_processors": [
|
||||||
|
"django.template.context_processors.debug",
|
||||||
"django.template.context_processors.request",
|
"django.template.context_processors.request",
|
||||||
"django.contrib.auth.context_processors.auth",
|
"django.contrib.auth.context_processors.auth",
|
||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
|
@ -82,14 +90,16 @@ TEMPLATES = [
|
||||||
|
|
||||||
WSGI_APPLICATION = "newsreader.wsgi.application"
|
WSGI_APPLICATION = "newsreader.wsgi.application"
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.postgresql",
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
"HOST": get_env("POSTGRES_HOST", default=""),
|
"HOST": os.environ["POSTGRES_HOST"],
|
||||||
"PORT": get_env("POSTGRES_PORT", default=""),
|
"PORT": os.environ["POSTGRES_PORT"],
|
||||||
"NAME": get_env("POSTGRES_DB", default=""),
|
"NAME": os.environ["POSTGRES_DB"],
|
||||||
"USER": get_env("POSTGRES_USER", default=""),
|
"USER": os.environ["POSTGRES_USER"],
|
||||||
"PASSWORD": get_env("POSTGRES_PASSWORD", default=""),
|
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,15 +107,17 @@ DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||||
|
|
||||||
CACHES = {
|
CACHES = {
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||||
"LOCATION": "memcached:11211",
|
"LOCATION": "memcached:11211",
|
||||||
},
|
},
|
||||||
"axes": {
|
"axes": {
|
||||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||||
"LOCATION": "memcached:11211",
|
"LOCATION": "memcached:11211",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
# https://docs.djangoproject.com/en/2.2/topics/logging/#configuring-logging
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
|
|
@ -159,6 +171,8 @@ LOGGING = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
|
||||||
|
|
@ -173,6 +187,8 @@ AUTH_USER_MODEL = "accounts.User"
|
||||||
|
|
||||||
LOGIN_REDIRECT_URL = "/"
|
LOGIN_REDIRECT_URL = "/"
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/2.2/topics/i18n/
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
TIME_ZONE = "Europe/Amsterdam"
|
TIME_ZONE = "Europe/Amsterdam"
|
||||||
|
|
@ -180,31 +196,34 @@ USE_I18N = True
|
||||||
USE_L10N = True
|
USE_L10N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/2.2/howto/static-files/
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
STATIC_ROOT = BASE_DIR / "static"
|
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
||||||
STATICFILES_DIRS = (DJANGO_PROJECT_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 = [
|
STATICFILES_FINDERS = [
|
||||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||||
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||||
|
|
||||||
DEFAULT_FROM_EMAIL = get_env(
|
# Reddit integration
|
||||||
"EMAIL_DEFAULT_FROM", required=False, default="webmaster@localhost"
|
REDDIT_CLIENT_ID = "CLIENT_ID"
|
||||||
|
REDDIT_CLIENT_SECRET = "CLIENT_SECRET"
|
||||||
|
REDDIT_REDIRECT_URL = (
|
||||||
|
"http://127.0.0.1:8000/accounts/settings/integrations/reddit/callback/"
|
||||||
)
|
)
|
||||||
|
|
||||||
EMAIL_HOST = get_env("EMAIL_HOST", required=False, default="localhost")
|
# Twitter integration
|
||||||
EMAIL_PORT = get_env("EMAIL_PORT", cast=int, required=False, default=25)
|
TWITTER_CONSUMER_ID = "CONSUMER_ID"
|
||||||
|
TWITTER_CONSUMER_SECRET = "CONSUMER_SECRET"
|
||||||
EMAIL_HOST_USER = get_env("EMAIL_HOST_USER", required=False, default="")
|
TWITTER_REDIRECT_URL = (
|
||||||
EMAIL_HOST_PASSWORD = get_env("EMAIL_HOST_PASSWORD", required=False, default="")
|
"http://127.0.0.1:8000/accounts/settings/integrations/twitter/callback/"
|
||||||
|
)
|
||||||
EMAIL_USE_TLS = get_env("EMAIL_USE_TLS", required=False, default=False)
|
|
||||||
EMAIL_USE_SSL = get_env("EMAIL_USE_SSL", required=False, default=False)
|
|
||||||
|
|
||||||
|
|
||||||
# Third party settings
|
# Third party settings
|
||||||
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
|
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
|
||||||
|
|
@ -221,12 +240,7 @@ REST_FRAMEWORK = {
|
||||||
"rest_framework.permissions.IsAuthenticated",
|
"rest_framework.permissions.IsAuthenticated",
|
||||||
"newsreader.accounts.permissions.IsOwner",
|
"newsreader.accounts.permissions.IsOwner",
|
||||||
),
|
),
|
||||||
"DEFAULT_RENDERER_CLASSES": (
|
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
|
||||||
"djangorestframework_camel_case.render.CamelCaseJSONRenderer",
|
|
||||||
),
|
|
||||||
"DEFAULT_PARSER_CLASSES": (
|
|
||||||
"djangorestframework_camel_case.parser.CamelCaseJSONParser",
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SWAGGER_SETTINGS = {
|
SWAGGER_SETTINGS = {
|
||||||
|
|
@ -241,9 +255,13 @@ SWAGGER_SETTINGS = {
|
||||||
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
||||||
CELERY_BROKER_URL = "amqp://guest@rabbitmq:5672"
|
CELERY_BROKER_URL = "amqp://guest@rabbitmq:5672"
|
||||||
|
|
||||||
|
REGISTRATION_OPEN = True
|
||||||
|
REGISTRATION_AUTO_LOGIN = True
|
||||||
|
ACCOUNT_ACTIVATION_DAYS = 7
|
||||||
|
|
||||||
# Sentry
|
# Sentry
|
||||||
SENTRY_CONFIG = {
|
SENTRY_CONFIG = {
|
||||||
"dsn": get_env("SENTRY_DSN", default="", required=False),
|
"dsn": os.environ.get("SENTRY_DSN"),
|
||||||
"send_default_pii": False,
|
"send_default_pii": False,
|
||||||
"integrations": [DjangoIntegration(), CeleryIntegration()]
|
"integrations": [DjangoIntegration(), CeleryIntegration()]
|
||||||
if DjangoIntegration and CeleryIntegration
|
if DjangoIntegration and CeleryIntegration
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,14 @@
|
||||||
from .base import * # noqa: F403
|
from .base import * # isort:skip
|
||||||
from .utils import get_current_version
|
from .version import get_current_version
|
||||||
|
|
||||||
|
|
||||||
SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl"
|
SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl"
|
||||||
|
|
||||||
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405
|
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
|
||||||
|
|
||||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||||
|
|
||||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
INSTALLED_APPS += ["debug_toolbar", "django_extensions"]
|
||||||
|
|
||||||
|
|
||||||
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
|
|
||||||
"django.template.context_processors.debug",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Project settings
|
# Project settings
|
||||||
VERSION = get_current_version()
|
VERSION = get_current_version()
|
||||||
|
|
@ -28,8 +23,8 @@ try:
|
||||||
|
|
||||||
from .local import * # noqa
|
from .local import * # noqa
|
||||||
|
|
||||||
SENTRY_CONFIG.update({"release": VERSION}) # noqa: F405
|
SENTRY_CONFIG.update({"release": VERSION})
|
||||||
|
|
||||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
sentry_init(**SENTRY_CONFIG)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
from .base import * # noqa: F403
|
from .base import * # isort:skip
|
||||||
from .utils import get_current_version
|
from .version import get_current_version
|
||||||
|
|
||||||
|
|
||||||
DEBUG = True
|
ALLOWED_HOSTS = ["django", "127.0.0.1"]
|
||||||
|
|
||||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
INSTALLED_APPS += ["debug_toolbar", "django_extensions"]
|
||||||
|
|
||||||
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405
|
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
|
||||||
|
|
||||||
LOGGING["loggers"].update( # noqa: F405
|
LOGGING["loggers"].update(
|
||||||
{
|
{
|
||||||
"celery.task": {"handlers": ["console", "celery"], "level": "DEBUG"},
|
"celery.task": {"handlers": ["console", "celery"], "level": "DEBUG"},
|
||||||
}
|
}
|
||||||
|
|
@ -16,10 +16,7 @@ LOGGING["loggers"].update( # noqa: F405
|
||||||
|
|
||||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||||
|
|
||||||
|
DEBUG = True
|
||||||
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
|
|
||||||
"django.template.context_processors.debug",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Project settings
|
# Project settings
|
||||||
VERSION = get_current_version()
|
VERSION = get_current_version()
|
||||||
|
|
@ -36,8 +33,8 @@ try:
|
||||||
|
|
||||||
from .local import * # noqa
|
from .local import * # noqa
|
||||||
|
|
||||||
SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT}) # noqa: F405
|
SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT})
|
||||||
|
|
||||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
sentry_init(**SENTRY_CONFIG)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
from .base import * # noqa: F403
|
from .base import * # isort:skip
|
||||||
from .utils import get_current_version
|
from .version import get_current_version
|
||||||
|
|
||||||
|
|
||||||
DEBUG = True
|
del LOGGING["handlers"]["file"]
|
||||||
|
del LOGGING["handlers"]["celery"]
|
||||||
|
|
||||||
|
LOGGING["loggers"].update(
|
||||||
del LOGGING["handlers"]["file"] # noqa: F405
|
|
||||||
del LOGGING["handlers"]["celery"] # noqa: F405
|
|
||||||
|
|
||||||
LOGGING["loggers"].update( # noqa: F405
|
|
||||||
{
|
{
|
||||||
"celery.task": {"handlers": ["console"], "level": "DEBUG"},
|
"celery.task": {"handlers": ["console"], "level": "DEBUG"},
|
||||||
"newsreader": {"handlers": ["console"], "level": "INFO"},
|
"newsreader": {"handlers": ["console"], "level": "INFO"},
|
||||||
|
|
@ -22,25 +19,25 @@ AXES_ENABLED = False
|
||||||
|
|
||||||
CACHES = {
|
CACHES = {
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||||
"LOCATION": "memcached:11211",
|
"LOCATION": "memcached:11211",
|
||||||
},
|
},
|
||||||
"axes": {
|
"axes": {
|
||||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||||
"LOCATION": "memcached:11211",
|
"LOCATION": "memcached:11211",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Project settings
|
# Project settings
|
||||||
VERSION = get_current_version()
|
VERSION = get_current_version()
|
||||||
ENVIRONMENT = "ci"
|
ENVIRONMENT = "gitlab"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Optionally use sentry integration
|
# Optionally use sentry integration
|
||||||
from sentry_sdk import init as sentry_init
|
from sentry_sdk import init as sentry_init
|
||||||
|
|
||||||
SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT}) # noqa: F405
|
SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT})
|
||||||
|
|
||||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
sentry_init(**SENTRY_CONFIG)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
@ -1,32 +1,74 @@
|
||||||
from newsreader.conf.utils import get_env
|
import os
|
||||||
|
|
||||||
from .base import * # noqa: F403
|
from .version import get_current_version
|
||||||
from .utils import get_current_version
|
|
||||||
|
|
||||||
|
|
||||||
|
from .base import * # isort:skip
|
||||||
|
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
|
|
||||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
ALLOWED_HOSTS = ["127.0.0.1", "localhost", "rss.fudiggity.nl", "django"]
|
||||||
|
|
||||||
ADMINS = [
|
ADMINS = [
|
||||||
("", email) for email in get_env("ADMINS", split=",", required=False, default=[])
|
("", email)
|
||||||
|
for email in os.getenv("ADMINS", "").split(",")
|
||||||
|
if os.environ.get("ADMINS")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Email
|
||||||
|
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||||
|
DEFAULT_FROM_EMAIL = os.environ.get("EMAIL_DEFAULT_FROM", "webmaster@localhost")
|
||||||
|
|
||||||
|
EMAIL_HOST = os.environ.get("EMAIL_HOST", "localhost")
|
||||||
|
EMAIL_PORT = os.environ.get("EMAIL_PORT", 25)
|
||||||
|
|
||||||
|
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "")
|
||||||
|
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "")
|
||||||
|
|
||||||
|
EMAIL_USE_TLS = bool(os.environ.get("EMAIL_USE_TLS"))
|
||||||
|
EMAIL_USE_SSL = bool(os.environ.get("EMAIL_USE_SSL"))
|
||||||
|
|
||||||
# Project settings
|
# Project settings
|
||||||
VERSION = get_current_version(debug=False)
|
VERSION = get_current_version(debug=False)
|
||||||
ENVIRONMENT = "production"
|
ENVIRONMENT = "production"
|
||||||
|
|
||||||
|
# Reddit integration
|
||||||
|
REDDIT_CLIENT_ID = os.environ.get("REDDIT_CLIENT_ID", "")
|
||||||
|
REDDIT_CLIENT_SECRET = os.environ.get("REDDIT_CLIENT_SECRET", "")
|
||||||
|
REDDIT_REDIRECT_URL = os.environ.get("REDDIT_CALLBACK_URL", "")
|
||||||
|
|
||||||
|
# Twitter integration
|
||||||
|
TWITTER_CONSUMER_ID = os.environ.get("TWITTER_CONSUMER_ID", "")
|
||||||
|
TWITTER_CONSUMER_SECRET = os.environ.get("TWITTER_CONSUMER_SECRET", "")
|
||||||
|
TWITTER_REDIRECT_URL = os.environ.get("TWITTER_REDIRECT_URL", "")
|
||||||
|
|
||||||
# Third party settings
|
# Third party settings
|
||||||
AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler"
|
AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler"
|
||||||
|
|
||||||
|
REGISTRATION_OPEN = False
|
||||||
|
|
||||||
# Optionally use sentry integration
|
# Optionally use sentry integration
|
||||||
try:
|
try:
|
||||||
from sentry_sdk import init as sentry_init
|
from sentry_sdk import init as sentry_init
|
||||||
|
|
||||||
SENTRY_CONFIG.update( # noqa: F405
|
SENTRY_CONFIG.update(
|
||||||
{"release": VERSION, "environment": ENVIRONMENT, "debug": False}
|
{"release": VERSION, "environment": ENVIRONMENT, "debug": False}
|
||||||
)
|
)
|
||||||
|
|
||||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
sentry_init(**SENTRY_CONFIG)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
0
src/newsreader/conf/sentry.py
Normal file
0
src/newsreader/conf/sentry.py
Normal file
|
|
@ -1,85 +0,0 @@
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Iterable, Type
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def get_env(
|
|
||||||
name: str,
|
|
||||||
cast: Type = str,
|
|
||||||
required: bool = True,
|
|
||||||
default: Any = None,
|
|
||||||
split: str = "",
|
|
||||||
) -> Any:
|
|
||||||
if cast is not str and split:
|
|
||||||
raise TypeError(f"Split is not possible with {cast}")
|
|
||||||
|
|
||||||
value = os.getenv(name)
|
|
||||||
|
|
||||||
if not value:
|
|
||||||
if required:
|
|
||||||
logger.warning(f"Missing environment variable: {name}")
|
|
||||||
|
|
||||||
return default
|
|
||||||
|
|
||||||
bool_mapping = {"yes": True, "true": True, "false": False, "no": False}
|
|
||||||
|
|
||||||
if cast is bool:
|
|
||||||
_value = bool_mapping.get(value.lower())
|
|
||||||
|
|
||||||
if not value:
|
|
||||||
raise ValueError(f"Unknown boolean value: {_value}")
|
|
||||||
|
|
||||||
return _value
|
|
||||||
|
|
||||||
value = value if not cast else cast(value)
|
|
||||||
return value if not split else value.split(split)
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_version(debug: bool = True) -> str:
|
|
||||||
version = get_env("VERSION", required=False)
|
|
||||||
|
|
||||||
if version:
|
|
||||||
return version
|
|
||||||
|
|
||||||
if debug:
|
|
||||||
try:
|
|
||||||
output = subprocess.check_output(
|
|
||||||
["git", "show", "--no-patch", "--format=%H"], universal_newlines=True
|
|
||||||
)
|
|
||||||
return output.strip()
|
|
||||||
except (subprocess.CalledProcessError, OSError):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
try:
|
|
||||||
output = subprocess.check_output(
|
|
||||||
["git", "describe", "--tags"], universal_newlines=True
|
|
||||||
)
|
|
||||||
return output.strip()
|
|
||||||
except (subprocess.CalledProcessError, OSError):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
ROOT_MARKERS = ("pyproject.toml", "package.json", "README.md", "CHANGELOG.md")
|
|
||||||
|
|
||||||
|
|
||||||
def get_root_dir() -> Path:
|
|
||||||
file = Path(__file__)
|
|
||||||
return _traverse_dirs(file.parent, ROOT_MARKERS)
|
|
||||||
|
|
||||||
|
|
||||||
def _traverse_dirs(path: Path, root_markers: Iterable[str]) -> Path:
|
|
||||||
if path.parent == path:
|
|
||||||
raise OSError("Root directory detected")
|
|
||||||
|
|
||||||
files = (file.name for file in path.iterdir())
|
|
||||||
|
|
||||||
if not any((marker for marker in root_markers if marker in files)):
|
|
||||||
return _traverse_dirs(path.parent, root_markers)
|
|
||||||
|
|
||||||
return path
|
|
||||||
24
src/newsreader/conf/version.py
Normal file
24
src/newsreader/conf/version.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_version(debug=True):
|
||||||
|
if "VERSION" in os.environ:
|
||||||
|
return os.environ["VERSION"]
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(
|
||||||
|
["git", "show", "--no-patch", "--format=%H"], universal_newlines=True
|
||||||
|
)
|
||||||
|
return output.strip()
|
||||||
|
except (subprocess.CalledProcessError, OSError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(
|
||||||
|
["git", "describe", "--tags"], universal_newlines=True
|
||||||
|
)
|
||||||
|
return output.strip()
|
||||||
|
except (subprocess.CalledProcessError, OSError):
|
||||||
|
return ""
|
||||||
|
|
@ -994,6 +994,8 @@
|
||||||
"email": "sonnyba871@gmail.com",
|
"email": "sonnyba871@gmail.com",
|
||||||
"reddit_refresh_token": null,
|
"reddit_refresh_token": null,
|
||||||
"reddit_access_token": null,
|
"reddit_access_token": null,
|
||||||
|
"twitter_oauth_token": null,
|
||||||
|
"twitter_oauth_token_secret": null,
|
||||||
"auto_mark_read": true,
|
"auto_mark_read": true,
|
||||||
"groups": [],
|
"groups": [],
|
||||||
"user_permissions": []
|
"user_permissions": []
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,15 @@ import React from 'react';
|
||||||
class Messages extends React.Component {
|
class Messages extends React.Component {
|
||||||
state = { messages: this.props.messages };
|
state = { messages: this.props.messages };
|
||||||
|
|
||||||
close = index => {
|
close = ::this.close;
|
||||||
|
|
||||||
|
close(index) {
|
||||||
const newMessages = this.state.messages.filter((message, currentIndex) => {
|
const newMessages = this.state.messages.filter((message, currentIndex) => {
|
||||||
return currentIndex != index;
|
return currentIndex != index;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setState({ messages: newMessages });
|
this.setState({ messages: newMessages });
|
||||||
};
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const messages = this.state.messages.map((message, index) => {
|
const messages = this.state.messages.map((message, index) => {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
class NavList extends React.Component {
|
|
||||||
render() {
|
|
||||||
const entries = Object.entries(this.props.navLinks);
|
|
||||||
const links = entries.map(([name, link], index) => {
|
|
||||||
return (
|
|
||||||
<li key={index} className="nav-list__item">
|
|
||||||
<a href={link}>{name}</a>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const className = this.props.includeBorder
|
|
||||||
? 'nav-list nav-list--bordered'
|
|
||||||
: 'nav-list';
|
|
||||||
|
|
||||||
return <ol className={className}>{links}</ol>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NavList;
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
class Selector {
|
class Selector {
|
||||||
|
onClick = ::this.onClick;
|
||||||
|
|
||||||
inputs = [];
|
inputs = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -9,13 +11,13 @@ class Selector {
|
||||||
selectAllInput.onchange = this.onClick;
|
selectAllInput.onchange = this.onClick;
|
||||||
}
|
}
|
||||||
|
|
||||||
onClick = e => {
|
onClick(e) {
|
||||||
const targetValue = e.target.checked;
|
const targetValue = e.target.checked;
|
||||||
|
|
||||||
this.inputs.forEach(input => {
|
this.inputs.forEach(input => {
|
||||||
input.checked = targetValue;
|
input.checked = targetValue;
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Selector;
|
export default Selector;
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import NavList from './NavList.js';
|
|
||||||
|
|
||||||
// TODO: show empty category message
|
|
||||||
class Sidebar extends React.Component {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div className="sidebar">
|
|
||||||
<div className="sidebar__nav">
|
|
||||||
<NavList
|
|
||||||
navLinks={this.props.navLinks}
|
|
||||||
includeBorder={this.props.includeBorder}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{this.props.children}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label htmlFor="menu-input" className="sidebar__close" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Sidebar;
|
|
||||||
|
|
@ -2,4 +2,3 @@ import './lib/index.js';
|
||||||
import './pages/homepage/index.js';
|
import './pages/homepage/index.js';
|
||||||
import './pages/categories/index.js';
|
import './pages/categories/index.js';
|
||||||
import './pages/rules/index.js';
|
import './pages/rules/index.js';
|
||||||
import './pages/default/index.js';
|
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,12 @@ import Card from '../../components/Card.js';
|
||||||
import CategoryCard from './components/CategoryCard.js';
|
import CategoryCard from './components/CategoryCard.js';
|
||||||
import CategoryModal from './components/CategoryModal.js';
|
import CategoryModal from './components/CategoryModal.js';
|
||||||
import Messages from '../../components/Messages.js';
|
import Messages from '../../components/Messages.js';
|
||||||
import Sidebar from '../../components/Sidebar.js';
|
|
||||||
|
|
||||||
class App extends React.Component {
|
class App extends React.Component {
|
||||||
|
selectCategory = ::this.selectCategory;
|
||||||
|
deselectCategory = ::this.deselectCategory;
|
||||||
|
deleteCategory = ::this.deleteCategory;
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
|
@ -20,15 +23,15 @@ class App extends React.Component {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
selectCategory = categoryId => {
|
selectCategory(categoryId) {
|
||||||
this.setState({ selectedCategoryId: categoryId });
|
this.setState({ selectedCategoryId: categoryId });
|
||||||
};
|
}
|
||||||
|
|
||||||
deselectCategory = () => {
|
deselectCategory() {
|
||||||
this.setState({ selectedCategoryId: null });
|
this.setState({ selectedCategoryId: null });
|
||||||
};
|
}
|
||||||
|
|
||||||
deleteCategory = categoryId => {
|
deleteCategory(categoryId) {
|
||||||
const url = `/api/categories/${categoryId}/`;
|
const url = `/api/categories/${categoryId}/`;
|
||||||
const options = {
|
const options = {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|
@ -56,7 +59,7 @@ class App extends React.Component {
|
||||||
text: 'Unable to remove category, try again later',
|
text: 'Unable to remove category, try again later',
|
||||||
};
|
};
|
||||||
return this.setState({ selectedCategoryId: null, message: message });
|
return this.setState({ selectedCategoryId: null, message: message });
|
||||||
};
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { categories } = this.state;
|
const { categories } = this.state;
|
||||||
|
|
@ -87,9 +90,6 @@ class App extends React.Component {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{this.state.message && <Messages messages={[this.state.message]} />}
|
{this.state.message && <Messages messages={[this.state.message]} />}
|
||||||
<Sidebar navLinks={this.props.navLinks} />
|
|
||||||
|
|
||||||
<div className="main__container">
|
|
||||||
<Card header={pageHeader} />
|
<Card header={pageHeader} />
|
||||||
{cards}
|
{cards}
|
||||||
{selectedCategory && (
|
{selectedCategory && (
|
||||||
|
|
@ -99,7 +99,6 @@ class App extends React.Component {
|
||||||
handleDelete={this.deleteCategory}
|
handleDelete={this.deleteCategory}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,11 @@ if (page) {
|
||||||
let createUrl = document.getElementById('createUrl').textContent;
|
let createUrl = document.getElementById('createUrl').textContent;
|
||||||
let updateUrl = document.getElementById('updateUrl').textContent;
|
let updateUrl = document.getElementById('updateUrl').textContent;
|
||||||
|
|
||||||
let linkScript = document.getElementById('Links');
|
|
||||||
let navLinks = JSON.parse(linkScript.textContent);
|
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<App
|
<App
|
||||||
categories={categories}
|
categories={categories}
|
||||||
createUrl={createUrl.substring(1, createUrl.length - 2)}
|
createUrl={createUrl.substring(1, createUrl.length - 2)}
|
||||||
updateUrl={updateUrl.substring(1, updateUrl.length - 4)}
|
updateUrl={updateUrl.substring(1, updateUrl.length - 4)}
|
||||||
navLinks={navLinks}
|
|
||||||
/>,
|
/>,
|
||||||
page
|
page
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
|
|
||||||
import Sidebar from '../../components/Sidebar';
|
|
||||||
|
|
||||||
const mainElements = [...document.getElementsByClassName('main')];
|
|
||||||
const mainElement = mainElements.find(element => element.dataset.renderSidebar);
|
|
||||||
|
|
||||||
if (mainElement) {
|
|
||||||
let linkScript = document.getElementById('Links');
|
|
||||||
let navLinks = JSON.parse(linkScript.textContent);
|
|
||||||
|
|
||||||
ReactDOM.render(
|
|
||||||
ReactDOM.createPortal(<Sidebar navLinks={navLinks} />, mainElement),
|
|
||||||
document.createElement('div')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -4,25 +4,14 @@ import { connect } from 'react-redux';
|
||||||
import { isEqual } from 'lodash';
|
import { isEqual } from 'lodash';
|
||||||
|
|
||||||
import { fetchCategories } from './actions/categories';
|
import { fetchCategories } from './actions/categories';
|
||||||
import { filterPosts } from './components/postlist/filters.js';
|
|
||||||
|
|
||||||
import ScrollTop from './components/ScrollTop.js';
|
import ScrollTop from './components/ScrollTop.js';
|
||||||
import HomepageSidebar from './components/sidebar/Sidebar.js';
|
import Sidebar from './components/sidebar/Sidebar.js';
|
||||||
import PostList from './components/postlist/PostList.js';
|
import PostList from './components/postlist/PostList.js';
|
||||||
import PostModal from './components/PostModal.js';
|
import PostModal from './components/PostModal.js';
|
||||||
import Messages from '../../components/Messages.js';
|
import Messages from '../../components/Messages.js';
|
||||||
|
|
||||||
class App extends React.Component {
|
class App extends React.Component {
|
||||||
state = { postListNode: null };
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.postListRef = node => {
|
|
||||||
this.setState({ postListNode: node });
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.props.fetchCategories();
|
this.props.fetchCategories();
|
||||||
}
|
}
|
||||||
|
|
@ -30,12 +19,12 @@ class App extends React.Component {
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HomepageSidebar navLinks={this.props.navLinks} />
|
<Sidebar />
|
||||||
<PostList
|
<PostList
|
||||||
feedUrl={this.props.feedUrl}
|
feedUrl={this.props.feedUrl}
|
||||||
|
subredditUrl={this.props.subredditUrl}
|
||||||
|
timelineUrl={this.props.timelineUrl}
|
||||||
timezone={this.props.timezone}
|
timezone={this.props.timezone}
|
||||||
forwardedRef={this.postListRef}
|
|
||||||
postsByType={this.props.postsByType}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isEqual(this.props.post, {}) && (
|
{!isEqual(this.props.post, {}) && (
|
||||||
|
|
@ -45,13 +34,15 @@ class App extends React.Component {
|
||||||
category={this.props.category}
|
category={this.props.category}
|
||||||
selectedType={this.props.selectedType}
|
selectedType={this.props.selectedType}
|
||||||
feedUrl={this.props.feedUrl}
|
feedUrl={this.props.feedUrl}
|
||||||
|
subredditUrl={this.props.subredditUrl}
|
||||||
|
timelineUrl={this.props.timelineUrl}
|
||||||
categoriesUrl={this.props.categoriesUrl}
|
categoriesUrl={this.props.categoriesUrl}
|
||||||
timezone={this.props.timezone}
|
timezone={this.props.timezone}
|
||||||
autoMarking={this.props.autoMarking}
|
autoMarking={this.props.autoMarking}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ScrollTop postListNode={this.state.postListNode} />
|
<ScrollTop />
|
||||||
|
|
||||||
{this.props.error && (
|
{this.props.error && (
|
||||||
<Messages messages={[{ type: 'error', text: this.props.error.message }]} />
|
<Messages messages={[{ type: 'error', text: this.props.error.message }]} />
|
||||||
|
|
@ -63,10 +54,9 @@ class App extends React.Component {
|
||||||
|
|
||||||
const mapStateToProps = state => {
|
const mapStateToProps = state => {
|
||||||
const { error } = state.error;
|
const { error } = state.error;
|
||||||
const postsByType = filterPosts(state);
|
|
||||||
|
|
||||||
if (!isEqual(state.selected.post, {})) {
|
if (!isEqual(state.selected.post, {})) {
|
||||||
const ruleId = state.selected.post.rule.id;
|
const ruleId = state.selected.post.rule;
|
||||||
|
|
||||||
const rule = state.rules.items[ruleId];
|
const rule = state.rules.items[ruleId];
|
||||||
const category = state.categories.items[rule.category];
|
const category = state.categories.items[rule.category];
|
||||||
|
|
@ -77,11 +67,10 @@ const mapStateToProps = state => {
|
||||||
rule,
|
rule,
|
||||||
post: state.selected.post,
|
post: state.selected.post,
|
||||||
selectedType: state.selected.item.type,
|
selectedType: state.selected.item.type,
|
||||||
postsByType: postsByType,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error, post: state.selected.post, postsByType: postsByType };
|
return { error, post: state.selected.post };
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
|
|
|
||||||
|
|
@ -124,10 +124,10 @@ export const fetchPostsBySection = (section, next = false) => {
|
||||||
|
|
||||||
switch (section.type) {
|
switch (section.type) {
|
||||||
case RULE_TYPE:
|
case RULE_TYPE:
|
||||||
url = next ? next : `/api/rules/${section.id}/posts/`;
|
url = next ? next : `/api/rules/${section.id}/posts/?read=false`;
|
||||||
break;
|
break;
|
||||||
case CATEGORY_TYPE:
|
case CATEGORY_TYPE:
|
||||||
url = next ? next : `/api/categories/${section.id}/posts/`;
|
url = next ? next : `/api/categories/${section.id}/posts/?read=false`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,18 @@ import { connect } from 'react-redux';
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
|
|
||||||
import { unSelectPost, markPostRead, toggleSaved } from '../actions/posts.js';
|
import { unSelectPost, markPostRead, toggleSaved } from '../actions/posts.js';
|
||||||
import { SAVED_TYPE } from '../constants.js';
|
import {
|
||||||
|
CATEGORY_TYPE,
|
||||||
|
RULE_TYPE,
|
||||||
|
SAVED_TYPE,
|
||||||
|
FEED,
|
||||||
|
SUBREDDIT,
|
||||||
|
TWITTER_TIMELINE,
|
||||||
|
} from '../constants.js';
|
||||||
import { formatDatetime } from '../../../utils.js';
|
import { formatDatetime } from '../../../utils.js';
|
||||||
|
|
||||||
class PostModal extends React.Component {
|
class PostModal extends React.Component {
|
||||||
|
modalListener = ::this.modalListener;
|
||||||
readTimer = null;
|
readTimer = null;
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
|
@ -31,13 +39,13 @@ class PostModal extends React.Component {
|
||||||
window.removeEventListener('click', this.modalListener);
|
window.removeEventListener('click', this.modalListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
modalListener = e => {
|
modalListener(e) {
|
||||||
const targetClassName = e.target.className;
|
const targetClassName = e.target.className;
|
||||||
|
|
||||||
if (this.props.post && targetClassName == 'modal post-modal') {
|
if (this.props.post && targetClassName == 'modal post-modal') {
|
||||||
this.props.unSelectPost();
|
this.props.unSelectPost();
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const post = this.props.post;
|
const post = this.props.post;
|
||||||
|
|
@ -46,13 +54,17 @@ class PostModal extends React.Component {
|
||||||
const titleClassName = post.read ? 'post__title post__title--read' : 'post__title';
|
const titleClassName = post.read ? 'post__title post__title--read' : 'post__title';
|
||||||
const readButtonDisabled =
|
const readButtonDisabled =
|
||||||
post.read || this.props.isUpdating || this.props.selectedType === SAVED_TYPE;
|
post.read || this.props.isUpdating || this.props.selectedType === SAVED_TYPE;
|
||||||
const savedIconClass = post.saved
|
const savedIconClass = post.saved ? 'saved-icon saved-icon--saved' : 'saved-icon';
|
||||||
? 'post__save post__save--saved saved-icon saved-icon--saved'
|
|
||||||
: 'post__save saved-icon';
|
|
||||||
|
|
||||||
let ruleUrl = '';
|
let ruleUrl = '';
|
||||||
|
|
||||||
switch (this.props.rule.type) {
|
switch (this.props.rule.type) {
|
||||||
|
case SUBREDDIT:
|
||||||
|
ruleUrl = `${this.props.subredditUrl}/${this.props.rule.id}/`;
|
||||||
|
break;
|
||||||
|
case TWITTER_TIMELINE:
|
||||||
|
ruleUrl = `${this.props.timelineUrl}/${this.props.rule.id}/`;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
ruleUrl = `${this.props.feedUrl}/${this.props.rule.id}/`;
|
ruleUrl = `${this.props.feedUrl}/${this.props.rule.id}/`;
|
||||||
break;
|
break;
|
||||||
|
|
@ -61,7 +73,6 @@ class PostModal extends React.Component {
|
||||||
return (
|
return (
|
||||||
<div className="modal post-modal">
|
<div className="modal post-modal">
|
||||||
<div className="post">
|
<div className="post">
|
||||||
<div className="post__container">
|
|
||||||
<div className="post__header">
|
<div className="post__header">
|
||||||
<div className="post__actions">
|
<div className="post__actions">
|
||||||
<button
|
<button
|
||||||
|
|
@ -82,18 +93,13 @@ class PostModal extends React.Component {
|
||||||
</div>
|
</div>
|
||||||
<div className="post__heading">
|
<div className="post__heading">
|
||||||
<h2 className={titleClassName}>{`${post.title} `}</h2>
|
<h2 className={titleClassName}>{`${post.title} `}</h2>
|
||||||
<div className="post__meta">
|
<div className="post__meta-info">
|
||||||
<div className="post__text">
|
<span className="post__date">
|
||||||
<span className="post__date">{publicationDate}</span>
|
{publicationDate} {this.props.timezone}
|
||||||
|
</span>
|
||||||
{post.author && <span className="post__author">{post.author}</span>}
|
{post.author && <span className="post__author">{post.author}</span>}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="post__buttons">
|
|
||||||
{this.props.category && (
|
{this.props.category && (
|
||||||
<span
|
<span className="badge post__category" title={this.props.category.name}>
|
||||||
className="badge post__category"
|
|
||||||
title={this.props.category.name}
|
|
||||||
>
|
|
||||||
<a
|
<a
|
||||||
href={`${this.props.categoriesUrl}/${this.props.category.id}/`}
|
href={`${this.props.categoriesUrl}/${this.props.category.id}/`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
@ -123,8 +129,6 @@ class PostModal extends React.Component {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* HTML is sanitized by the collectors */}
|
{/* HTML is sanitized by the collectors */}
|
||||||
<div className="post__body" dangerouslySetInnerHTML={{ __html: post.body }} />
|
<div className="post__body" dangerouslySetInnerHTML={{ __html: post.body }} />
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,40 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export default class ScrollTop extends React.Component {
|
export default class ScrollTop extends React.Component {
|
||||||
state = {
|
scrollListener = ::this.scrollListener;
|
||||||
listenerAttached: false,
|
|
||||||
showTop: false,
|
|
||||||
showBottom: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidUpdate() {
|
state = { showTop: false, showBottom: false };
|
||||||
if (this.props.postListNode && !this.state.listenerAttached) {
|
|
||||||
this.props.postListNode.addEventListener('scroll', this.scrollListener);
|
|
||||||
|
|
||||||
this.setState({ listenerAttached: true });
|
componentDidMount() {
|
||||||
}
|
window.addEventListener('scroll', this.scrollListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollListener = () => {
|
scrollListener() {
|
||||||
const postList = this.props.postListNode;
|
const showBottom = window.innerHeight + window.scrollY < document.body.offsetHeight;
|
||||||
const elementEnd =
|
|
||||||
postList.scrollTop + postList.offsetHeight >= postList.scrollHeight;
|
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
showTop: postList.scrollTop > window.innerHeight,
|
showTop: window.pageYOffset > 0 ? true : false,
|
||||||
showBottom: !elementEnd,
|
showBottom: showBottom,
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const postList = this.props.postListNode;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
postList && (
|
|
||||||
<div className="scroll-to-top">
|
<div className="scroll-to-top">
|
||||||
{this.state.showTop && (
|
{this.state.showTop && (
|
||||||
<i
|
<i
|
||||||
className="scroll-to-top__icon scroll-to-top__icon--top"
|
className="scroll-to-top__icon scroll-to-top__icon--top"
|
||||||
onClick={() => postList.scroll({ top: 0 })}
|
onClick={() => window.scrollTo(0, 0)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{this.state.showBottom && (
|
{this.state.showBottom && (
|
||||||
<i
|
<i
|
||||||
className="scroll-to-top__icon scroll-to-top__icon--bottom"
|
className="scroll-to-top__icon scroll-to-top__icon--bottom"
|
||||||
onClick={() => postList.scroll({ top: postList.scrollHeight })}
|
onClick={() => window.scrollTo(0, document.body.scrollHeight)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,21 @@ import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
|
|
||||||
import { CATEGORY_TYPE, SAVED_TYPE } from '../../constants.js';
|
import {
|
||||||
|
CATEGORY_TYPE,
|
||||||
|
RULE_TYPE,
|
||||||
|
SAVED_TYPE,
|
||||||
|
FEED,
|
||||||
|
SUBREDDIT,
|
||||||
|
TWITTER_TIMELINE,
|
||||||
|
} from '../../constants.js';
|
||||||
import { selectPost, toggleSaved } from '../../actions/posts.js';
|
import { selectPost, toggleSaved } from '../../actions/posts.js';
|
||||||
import { formatDatetime } from '../../../../utils.js';
|
import { formatDatetime } from '../../../../utils.js';
|
||||||
|
|
||||||
class PostItem extends React.Component {
|
class PostItem extends React.Component {
|
||||||
render() {
|
render() {
|
||||||
const rule = { ...this.props.post.rule };
|
const rule = { ...this.props.post.rule };
|
||||||
const post = { ...this.props.post };
|
const post = { ...this.props.post, rule: rule.id };
|
||||||
const token = Cookies.get('csrftoken');
|
const token = Cookies.get('csrftoken');
|
||||||
const publicationDate = formatDatetime(post.publicationDate);
|
const publicationDate = formatDatetime(post.publicationDate);
|
||||||
|
|
||||||
|
|
@ -18,10 +25,17 @@ class PostItem extends React.Component {
|
||||||
: 'posts__header';
|
: 'posts__header';
|
||||||
const savedIconClass = post.saved ? 'saved-icon saved-icon--saved' : 'saved-icon';
|
const savedIconClass = post.saved ? 'saved-icon saved-icon--saved' : 'saved-icon';
|
||||||
|
|
||||||
const ruleUrl = `${this.props.feedUrl}/${rule.id}/`;
|
let ruleUrl = '';
|
||||||
|
if (rule.type === SUBREDDIT) {
|
||||||
|
ruleUrl = `${this.props.subredditUrl}/${rule.id}/`;
|
||||||
|
} else if (rule.type === TWITTER_TIMELINE) {
|
||||||
|
ruleUrl = `${this.props.timelineUrl}/${rule.id}/`;
|
||||||
|
} else {
|
||||||
|
ruleUrl = `${this.props.feedUrl}/${rule.id}/`;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="posts__item" ref={this.props.forwardedRef}>
|
<li className="posts__item">
|
||||||
<h5
|
<h5
|
||||||
className={titleClassName}
|
className={titleClassName}
|
||||||
title={post.title}
|
title={post.title}
|
||||||
|
|
@ -32,7 +46,7 @@ class PostItem extends React.Component {
|
||||||
|
|
||||||
<div className="posts-info">
|
<div className="posts-info">
|
||||||
<span className="posts-info__date" title={publicationDate}>
|
<span className="posts-info__date" title={publicationDate}>
|
||||||
{publicationDate} {post.author && `By ${post.author}`}
|
{publicationDate} {this.props.timezone} {post.author && `By ${post.author}`}
|
||||||
</span>
|
</span>
|
||||||
{[CATEGORY_TYPE, SAVED_TYPE].includes(this.props.selected.type) && (
|
{[CATEGORY_TYPE, SAVED_TYPE].includes(this.props.selected.type) && (
|
||||||
<span className="badge">
|
<span className="badge">
|
||||||
|
|
|
||||||
|
|
@ -4,46 +4,31 @@ import { isEqual } from 'lodash';
|
||||||
|
|
||||||
import { fetchPostsBySection, fetchSavedPosts } from '../../actions/posts.js';
|
import { fetchPostsBySection, fetchSavedPosts } from '../../actions/posts.js';
|
||||||
import { SAVED_TYPE } from '../../constants.js';
|
import { SAVED_TYPE } from '../../constants.js';
|
||||||
|
import { filterPosts } from './filters.js';
|
||||||
|
|
||||||
import LoadingIndicator from '../../../../components/LoadingIndicator.js';
|
import LoadingIndicator from '../../../../components/LoadingIndicator.js';
|
||||||
import PostItem from './PostItem.js';
|
import PostItem from './PostItem.js';
|
||||||
|
|
||||||
class PostList extends React.Component {
|
class PostList extends React.Component {
|
||||||
lastPostRef = null;
|
checkScrollHeight = ::this.checkScrollHeight;
|
||||||
observer = null;
|
|
||||||
|
|
||||||
constructor(props) {
|
componentDidMount() {
|
||||||
super(props);
|
window.addEventListener('scroll', this.checkScrollHeight);
|
||||||
|
|
||||||
this.lastPostRef = React.createRef();
|
|
||||||
|
|
||||||
const observerOptions = { root: null, rootMargin: '0px', threshold: 0 };
|
|
||||||
this.observer = new IntersectionObserver(this.handleIntersect, observerOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate() {
|
|
||||||
if (this.lastPostRef.current && !this.props.isFetching) {
|
|
||||||
this.observer.observe(this.lastPostRef.current);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this.observer.disconnect();
|
window.removeEventListener('scroll', this.checkScrollHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleIntersect = entries => {
|
checkScrollHeight(e) {
|
||||||
entries.every(entry => {
|
const postList = document.body.querySelector('.posts__list');
|
||||||
if (entry.isIntersecting) {
|
|
||||||
this.observer.unobserve(entry.target);
|
|
||||||
|
|
||||||
if (this.props.next && !this.props.lastReached) {
|
if (this.props.next && !this.props.lastReached) {
|
||||||
|
if (window.scrollY + window.innerHeight >= postList.offsetHeight) {
|
||||||
this.paginate();
|
this.paginate();
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
};
|
|
||||||
|
|
||||||
paginate() {
|
paginate() {
|
||||||
if (this.props.selected.type === SAVED_TYPE) {
|
if (this.props.selected.type === SAVED_TYPE) {
|
||||||
|
|
@ -54,21 +39,18 @@ class PostList extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const isLastItem = this.props.postsByType.toReversed().find(item => !item.read);
|
|
||||||
|
|
||||||
const postItems = this.props.postsByType.map((item, index) => {
|
const postItems = this.props.postsByType.map((item, index) => {
|
||||||
const defaultProps = {
|
return (
|
||||||
key: index,
|
<PostItem
|
||||||
post: item,
|
key={index}
|
||||||
selected: this.props.selected,
|
post={item}
|
||||||
feedUrl: this.props.feedUrl,
|
selected={this.props.selected}
|
||||||
};
|
feedUrl={this.props.feedUrl}
|
||||||
|
subredditUrl={this.props.subredditUrl}
|
||||||
if (isLastItem?.id === item.id) {
|
timelineUrl={this.props.timelineUrl}
|
||||||
return <PostItem {...defaultProps} forwardedRef={this.lastPostRef} />;
|
timezone={this.props.timezone}
|
||||||
} else {
|
/>
|
||||||
return <PostItem {...defaultProps} />;
|
);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isEqual(this.props.selected, {})) {
|
if (isEqual(this.props.selected, {})) {
|
||||||
|
|
@ -92,7 +74,7 @@ class PostList extends React.Component {
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div className="posts" ref={this.props.forwardedRef}>
|
<div className="posts">
|
||||||
<ul className="posts__list">{postItems}</ul>
|
<ul className="posts__list">{postItems}</ul>
|
||||||
{this.props.isFetching && <LoadingIndicator />}
|
{this.props.isFetching && <LoadingIndicator />}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -103,6 +85,7 @@ class PostList extends React.Component {
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
isFetching: state.posts.isFetching,
|
isFetching: state.posts.isFetching,
|
||||||
|
postsByType: filterPosts(state),
|
||||||
next: state.selected.next,
|
next: state.selected.next,
|
||||||
lastReached: state.selected.lastReached,
|
lastReached: state.selected.lastReached,
|
||||||
selected: state.selected.item,
|
selected: state.selected.item,
|
||||||
|
|
@ -113,6 +96,4 @@ const mapDispatchToProps = dispatch => ({
|
||||||
fetchSavedPosts: (next = false) => dispatch(fetchSavedPosts(next)),
|
fetchSavedPosts: (next = false) => dispatch(fetchSavedPosts(next)),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })(
|
export default connect(mapStateToProps, mapDispatchToProps)(PostList);
|
||||||
PostList
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,31 @@ const isEmpty = (object = {}) => {
|
||||||
return Object.keys(object).length === 0;
|
return Object.keys(object).length === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sortOrdering = (firstPost, secondPost) => {
|
||||||
|
const dateOrdering =
|
||||||
|
new Date(firstPost.publicationDate) < new Date(secondPost.publicationDate);
|
||||||
|
|
||||||
|
if (firstPost.read && !secondPost.read) {
|
||||||
|
return 1;
|
||||||
|
} else if (secondPost.read && !firstPost.read) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateOrdering;
|
||||||
|
};
|
||||||
|
|
||||||
|
const savedOrdering = (firstPost, secondPost) => {
|
||||||
|
return new Date(firstPost.publicationDate) < new Date(secondPost.publicationDate);
|
||||||
|
};
|
||||||
|
|
||||||
export const filterPostsByRule = (rule = {}, posts = []) => {
|
export const filterPostsByRule = (rule = {}, posts = []) => {
|
||||||
return posts.filter(post => {
|
const filteredPosts = posts.filter(post => {
|
||||||
return post.rule.id === rule.id;
|
return post.rule === rule.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const filteredData = filteredPosts.map(post => ({ ...post, rule: { ...rule } }));
|
||||||
|
|
||||||
|
return filteredData.sort(sortOrdering);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const filterPostsByCategory = (category = {}, rules = [], posts = []) => {
|
export const filterPostsByCategory = (category = {}, rules = [], posts = []) => {
|
||||||
|
|
@ -15,13 +36,24 @@ export const filterPostsByCategory = (category = {}, rules = [], posts = []) =>
|
||||||
return rule.category === category.id;
|
return rule.category === category.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
const ruleIds = filteredRules.map(rule => rule.id);
|
const filteredData = filteredRules.map(rule => {
|
||||||
|
const filteredPosts = posts.filter(post => {
|
||||||
|
return post.rule === rule.id;
|
||||||
|
});
|
||||||
|
|
||||||
return [...posts].filter(post => ruleIds.includes(post.rule.id));
|
return filteredPosts.map(post => ({ ...post, rule: { ...rule } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedPosts = [...filteredData.flat()].sort(sortOrdering);
|
||||||
|
|
||||||
|
return sortedPosts;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const filterPostsBySaved = (rules = [], posts = []) => {
|
export const filterPostsBySaved = (rules = [], posts = []) => {
|
||||||
return [...posts].filter(post => post.saved);
|
const filteredPosts = posts.filter(post => post.saved);
|
||||||
|
return filteredPosts
|
||||||
|
.map(post => ({ ...post, rule: { ...rules.find(rule => rule.id === post.rule) } }))
|
||||||
|
.sort(savedOrdering);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const filterPosts = state => {
|
export const filterPosts = state => {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
import { isEqual } from 'lodash';
|
||||||
|
|
||||||
import { CATEGORY_TYPE } from '../../constants.js';
|
import { CATEGORY_TYPE } from '../../constants.js';
|
||||||
import { selectCategory, fetchCategory } from '../../actions/categories.js';
|
import { selectCategory, fetchCategory } from '../../actions/categories.js';
|
||||||
import { fetchPostsBySection } from '../../actions/posts.js';
|
import { fetchPostsBySection } from '../../actions/posts.js';
|
||||||
|
|
||||||
import { isSelected } from './functions.js';
|
import { isSelected } from './functions.js';
|
||||||
import RuleItem from './RuleItem.js';
|
import RuleItem from './RuleItem.js';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,15 @@ import Cookies from 'js-cookie';
|
||||||
import { markRead } from '../../actions/selected.js';
|
import { markRead } from '../../actions/selected.js';
|
||||||
|
|
||||||
class ReadButton extends React.Component {
|
class ReadButton extends React.Component {
|
||||||
markSelectedRead = () => {
|
markSelectedRead = ::this.markSelectedRead;
|
||||||
|
|
||||||
|
markSelectedRead() {
|
||||||
const token = Cookies.get('csrftoken');
|
const token = Cookies.get('csrftoken');
|
||||||
|
|
||||||
if (this.props.selected.unread > 0) {
|
if (this.props.selected.unread > 0) {
|
||||||
this.props.markRead({ ...this.props.selected }, token);
|
this.props.markRead({ ...this.props.selected }, token);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
import { isEqual } from 'lodash';
|
||||||
|
|
||||||
import { RULE_TYPE } from '../../constants.js';
|
import { RULE_TYPE } from '../../constants.js';
|
||||||
import { selectRule, fetchRule } from '../../actions/rules.js';
|
import { selectRule, fetchRule } from '../../actions/rules.js';
|
||||||
import { fetchPostsBySection } from '../../actions/posts.js';
|
import { fetchPostsBySection } from '../../actions/posts.js';
|
||||||
|
|
||||||
import { isSelected } from './functions.js';
|
import { isSelected } from './functions.js';
|
||||||
|
|
||||||
class RuleItem extends React.Component {
|
class RuleItem extends React.Component {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
import { isEqual } from 'lodash';
|
||||||
|
|
||||||
|
import { filterCategories, filterRules } from './filters.js';
|
||||||
|
|
||||||
import Sidebar from '../../../../components/Sidebar.js';
|
|
||||||
import LoadingIndicator from '../../../../components/LoadingIndicator.js';
|
|
||||||
import { CATEGORY_TYPE, RULE_TYPE } from '../../constants.js';
|
import { CATEGORY_TYPE, RULE_TYPE } from '../../constants.js';
|
||||||
|
import LoadingIndicator from '../../../../components/LoadingIndicator.js';
|
||||||
import CategoryItem from './CategoryItem.js';
|
import CategoryItem from './CategoryItem.js';
|
||||||
import SavedItem from './SavedItem.js';
|
import SavedItem from './SavedItem.js';
|
||||||
import ReadButton from './ReadButton.js';
|
import ReadButton from './ReadButton.js';
|
||||||
|
|
||||||
import { filterCategories, filterRules } from './filters.js';
|
// TODO: show empty category message
|
||||||
|
class Sidebar extends React.Component {
|
||||||
class HomepageSidebar extends React.Component {
|
|
||||||
render() {
|
render() {
|
||||||
const categoryItems = this.props.categories.items.map(category => {
|
const categoryItems = this.props.categories.items.map(category => {
|
||||||
const rules = this.props.rules.items.filter(rule => {
|
const rules = this.props.rules.items.filter(rule => {
|
||||||
|
|
@ -33,18 +33,18 @@ class HomepageSidebar extends React.Component {
|
||||||
[CATEGORY_TYPE, RULE_TYPE].includes(this.props.selected.item.type);
|
[CATEGORY_TYPE, RULE_TYPE].includes(this.props.selected.item.type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar navLinks={this.props.navLinks} includeBorder={true}>
|
<div className="sidebar">
|
||||||
{(this.props.categories.isFetching || this.props.rules.isFetching) && (
|
{(this.props.categories.isFetching || this.props.rules.isFetching) && (
|
||||||
<LoadingIndicator />
|
<LoadingIndicator />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ul className="sidebar__list">
|
<ul className="sidebar__nav">
|
||||||
<SavedItem selected={this.props.selected.item} />
|
<SavedItem selected={this.props.selected.item} />
|
||||||
{categoryItems}
|
{categoryItems}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{showReadButton && <ReadButton />}
|
{showReadButton && <ReadButton />}
|
||||||
</Sidebar>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -55,4 +55,4 @@ const mapStateToProps = state => ({
|
||||||
selected: state.selected,
|
selected: state.selected,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps)(HomepageSidebar);
|
export default connect(mapStateToProps)(Sidebar);
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue