Compare commits
85 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e40d69d5ff | |||
| 83707701e9 | |||
| 116e2c1577 | |||
| cf96371b90 | |||
| eadd7a5612 | |||
| 62053a1048 | |||
| b4340176da | |||
| 433ff9413d | |||
| 91949622b7 | |||
| 10affeb32f | |||
| e96c6f3528 | |||
| a534a3b691 | |||
| ebbbe99eaf | |||
| c7f90e233e | |||
| 9ba6824dd3 | |||
| 4c5d3aec28 | |||
| dd9aaf467e | |||
| 1417c52007 | |||
| bfd081337b | |||
| b8559f0499 | |||
| b465d0bb8d | |||
| 1a54fdbcd1 | |||
| 34afcc02b6 | |||
| 1574661c57 | |||
| 3160becb72 | |||
| 105371abaf | |||
| ed37be0c60 | |||
| 161234defd | |||
| f3ba0f1d09 | |||
| aff565862c | |||
| bf43603d65 | |||
| 91a7f6325c | |||
| e33497569a | |||
| 2d5801f226 | |||
| 89d4ebdc49 | |||
| 174912a967 | |||
| bb92f07f00 | |||
| fa491120a0 | |||
| ccde406193 | |||
| a498417bad | |||
| 16ebf3bdb3 | |||
| 99c232fea2 | |||
| fbb6405da9 | |||
| 03b5847641 | |||
| dfb049ae14 | |||
| b78f03d3b0 | |||
| e09b3d6e4c | |||
| cc5b4cc0bb | |||
| 70a0d5a96d | |||
| cc8aafa310 | |||
| 57375591b5 | |||
| bb74e875e0 | |||
| bc8ec0257e | |||
| a041d5f7fa | |||
| e95c2a440e | |||
| 5fc0742688 | |||
| f5f7f99f71 | |||
| 284f64d202 | |||
| b34bef899c | |||
| aa0a29fefb | |||
| 2a5372166e | |||
| fd3bf4f542 | |||
| c7fb545096 | |||
| c7aa431e4a | |||
| 3152c8f14e | |||
| 9e6be5c807 | |||
| 106bd6cb4c | |||
| 040193a3ed | |||
| d8b04b3329 | |||
| b6805c1675 | |||
| 07c685401f | |||
| 8b080a3cee | |||
| 12c1ac9d17 | |||
| 67d7b10632 | |||
| 1b8b9dcd41 | |||
| 35c9e78809 | |||
| 4935d7d186 | |||
| 2b3e35078d | |||
| d05e29b5e0 | |||
| e9e8fc351c | |||
| 16168cc9d9 | |||
| 9097caf438 | |||
| 0f89fc2447 | |||
| b36bf4e0bc | |||
| 40749403b9 |
269 changed files with 11338 additions and 42315 deletions
11
.babelrc
11
.babelrc
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"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
16
.coveragerc
|
|
@ -1,16 +0,0 @@
|
|||
[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
|
||||
25
.editorconfig
Normal file
25
.editorconfig
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# 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
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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
12
.isort.cfg
|
|
@ -1,12 +0,0 @@
|
|||
[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
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
lts/*
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 90,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
10
.woodpecker/build.yaml
Normal file
10
.woodpecker/build.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
when:
|
||||
- event: push
|
||||
- event: pull_request
|
||||
- event: manual
|
||||
|
||||
steps:
|
||||
- image: node:lts-alpine
|
||||
commands:
|
||||
- npm install
|
||||
- npm run build:prod
|
||||
18
.woodpecker/lint.yaml
Normal file
18
.woodpecker/lint.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
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
|
||||
37
.woodpecker/tests.yaml
Normal file
37
.woodpecker/tests.yaml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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
|
||||
27
CHANGELOG.md
27
CHANGELOG.md
|
|
@ -1,5 +1,32 @@
|
|||
# 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
|
||||
|
|
|
|||
84
Dockerfile
Normal file
84
Dockerfile
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# 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
45
Makefile
|
|
@ -1,45 +0,0 @@
|
|||
# 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
|
||||
21
babel.config.js
Normal file
21
babel.config.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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
|
||||
|
||||
python /app/src/manage.py migrate
|
||||
uv run --no-sync -- /app/src/manage.py migrate
|
||||
|
||||
exec "$@"
|
||||
|
|
|
|||
|
|
@ -14,9 +14,8 @@ server {
|
|||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://gunicorn;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
proxy_redirect off;
|
||||
proxy_pass http://gunicorn;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,13 @@
|
|||
version: "3.6"
|
||||
|
||||
volumes:
|
||||
static-files:
|
||||
node-modules:
|
||||
|
||||
services:
|
||||
celery:
|
||||
build:
|
||||
target: development
|
||||
volumes:
|
||||
- ./src/:/app/src
|
||||
|
||||
django:
|
||||
build:
|
||||
build: &app-development-build
|
||||
target: development
|
||||
command: python /app/src/manage.py runserver 0.0.0.0:8000
|
||||
command: uv run --no-sync -- /app/src/manage.py runserver 0.0.0.0:8000
|
||||
environment: &django-env
|
||||
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-newsreader.conf.docker}
|
||||
ports:
|
||||
- "${DJANGO_PORT:-8000}:8000"
|
||||
volumes:
|
||||
|
|
@ -23,12 +16,21 @@ services:
|
|||
stdin_open: true
|
||||
tty: true
|
||||
|
||||
celery:
|
||||
build:
|
||||
<<: *app-development-build
|
||||
environment:
|
||||
<<: *django-env
|
||||
volumes:
|
||||
- ./src/:/app/src
|
||||
|
||||
webpack:
|
||||
build:
|
||||
target: frontend-build
|
||||
context: .
|
||||
dockerfile: ./docker/webpack
|
||||
args:
|
||||
BUILD_ARG: "dev"
|
||||
command: npm run build:watch
|
||||
volumes:
|
||||
- ./src/:/app/src
|
||||
- static-files:/app/src/newsreader/static
|
||||
- node-modules:/app/node_modules
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
version: "3.6"
|
||||
|
||||
volumes:
|
||||
logs:
|
||||
static-files:
|
||||
|
|
@ -11,7 +9,6 @@ services:
|
|||
django:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
# Note that --env-file should be used to set these correctly
|
||||
- "${NGINX_HTTP_PORT:-80}:80"
|
||||
volumes:
|
||||
- ./config/nginx/conf.d:/etc/nginx/conf.d
|
||||
|
|
|
|||
|
|
@ -1,47 +1,46 @@
|
|||
version: "3.6"
|
||||
|
||||
volumes:
|
||||
logs:
|
||||
media:
|
||||
postgres-data:
|
||||
static-files:
|
||||
|
||||
x-db-env: &db-env
|
||||
POSTGRES_HOST:
|
||||
POSTGRES_PORT:
|
||||
POSTGRES_DB:
|
||||
POSTGRES_USER:
|
||||
POSTGRES_PASSWORD:
|
||||
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-django-build-env: &django-build-env
|
||||
<<: *db-env
|
||||
DJANGO_SECRET_KEY:
|
||||
DJANGO_SETTINGS_MODULE:
|
||||
x-db-env: &db-env
|
||||
<<: *db-connection-env
|
||||
PGUSER: *pg-user
|
||||
PGDATABASE: *pg-database
|
||||
|
||||
x-django-env: &django-env
|
||||
<<: *django-build-env
|
||||
<<: *db-connection-env
|
||||
|
||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,django}
|
||||
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_PORT:
|
||||
EMAIL_HOST_USER:
|
||||
EMAIL_HOST_PASSWORD:
|
||||
EMAIL_USE_TLS:
|
||||
EMAIL_USE_SSL:
|
||||
EMAIL_DEFAULT_FROM:
|
||||
|
||||
# Reddit
|
||||
REDDIT_CLIENT_ID:
|
||||
REDDIT_CLIENT_SECRET:
|
||||
REDDIT_CALLBACK_URL:
|
||||
|
||||
# Twitter
|
||||
TWITTER_CONSUMER_ID:
|
||||
TWITTER_CONSUMER_SECRET:
|
||||
TWITTER_REDIRECT_URL:
|
||||
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: ${SENTRY_DSN:-""}
|
||||
|
||||
services:
|
||||
db:
|
||||
|
|
@ -49,8 +48,8 @@ services:
|
|||
<<: *db-env
|
||||
image: postgres:15
|
||||
healthcheck:
|
||||
# Note that --env-file should be used to set these correctly
|
||||
test: /usr/bin/pg_isready --username="${POSTGRES_USER}" --dbname="${POSTGRES_DB}"
|
||||
test: /usr/bin/pg_isready
|
||||
start_period: 10s
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
|
|
@ -58,7 +57,7 @@ services:
|
|||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3.12
|
||||
image: rabbitmq:4
|
||||
|
||||
memcached:
|
||||
image: memcached:1.6
|
||||
|
|
@ -66,56 +65,26 @@ services:
|
|||
- memcached
|
||||
- -m 64
|
||||
|
||||
celery:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/django
|
||||
target: production
|
||||
args:
|
||||
<<: *django-build-env
|
||||
environment:
|
||||
<<: *django-env
|
||||
command: |
|
||||
celery --app newsreader
|
||||
--workdir /app/src/
|
||||
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:
|
||||
build: &app-build
|
||||
context: .
|
||||
dockerfile: ./docker/django
|
||||
target: production
|
||||
args:
|
||||
<<: *django-build-env
|
||||
environment:
|
||||
<<: *django-env
|
||||
entrypoint: /app/bin/docker-entrypoint.sh
|
||||
entrypoint: ["/bin/sh", "/app/bin/docker-entrypoint.sh"]
|
||||
command: |
|
||||
gunicorn --bind 0.0.0.0:8000
|
||||
uv run --no-sync --
|
||||
gunicorn
|
||||
--bind 0.0.0.0:8000
|
||||
--workers 3
|
||||
--chdir /app/src/
|
||||
newsreader.wsgi:application
|
||||
healthcheck:
|
||||
test: /usr/bin/curl --fail http://django:8000 || exit 1
|
||||
interval: 30s
|
||||
start_period: 10s
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
retries: 5
|
||||
depends_on:
|
||||
memcached:
|
||||
condition: service_started
|
||||
|
|
@ -125,3 +94,33 @@ services:
|
|||
- logs:/app/logs
|
||||
- media:/app/media
|
||||
- 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
108
docker/django
|
|
@ -1,108 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
FROM node:16-bullseye
|
||||
|
||||
WORKDIR /app
|
||||
RUN mkdir /app/src
|
||||
|
||||
COPY package*.json webpack.*.js .babelrc /app/
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY ./src /app/src
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
static:
|
||||
stage: build
|
||||
image: node:16-bullseye
|
||||
before_script:
|
||||
- npm install
|
||||
script:
|
||||
- npm run build
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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'
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
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
188
jest.config.js
|
|
@ -1,188 +0,0 @@
|
|||
// 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,
|
||||
};
|
||||
27837
package-lock.json
generated
27837
package-lock.json
generated
File diff suppressed because it is too large
Load diff
42
package.json
42
package.json
|
|
@ -1,20 +1,19 @@
|
|||
{
|
||||
"name": "newsreader",
|
||||
"version": "0.4.3",
|
||||
"version": "0.5.3",
|
||||
"description": "Application for viewing RSS feeds",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"lint": "npx prettier \"src/newsreader/js/**/*.js\" --check",
|
||||
"format": "npx prettier \"src/newsreader/js/**/*.js\" --write",
|
||||
"build": "npx webpack --config webpack.dev.babel.js",
|
||||
"build:watch": "npx webpack --config webpack.dev.babel.js --watch",
|
||||
"build:dev": "npx webpack --config webpack.dev.babel.js",
|
||||
"build:prod": "npx webpack --config webpack.prod.babel.js",
|
||||
"test": "npx jest",
|
||||
"test:watch": "npm test -- --watch"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "[git@git.fudiggity.nl:5000]:sonny/newsreader.git"
|
||||
"url": "forgejo.fudiggity.nl:sonny/newsreader"
|
||||
},
|
||||
"author": "Sonny",
|
||||
"license": "GPL-3.0-or-later",
|
||||
|
|
@ -32,21 +31,17 @@
|
|||
"@babel/core": "^7.12.13",
|
||||
"@babel/plugin-proposal-class-properties": "^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-runtime": "^7.12.15",
|
||||
"@babel/preset-env": "^7.12.13",
|
||||
"@babel/register": "^7.12.13",
|
||||
"@babel/runtime": "^7.12.13",
|
||||
"babel-jest": "^24.9.0",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-loader": "^8.2.2",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"css-loader": "^3.6.0",
|
||||
"css-loader": "^7.1.2",
|
||||
"fetch-mock": "^8.3.2",
|
||||
"file-loader": "^6.2.0",
|
||||
"jest": "^24.9.0",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"jest": "^29.7.0",
|
||||
"mini-css-extract-plugin": "^2.9.1",
|
||||
"node-fetch": "^2.6.1",
|
||||
"prettier": "^1.19.1",
|
||||
"react": "^16.14.0",
|
||||
|
|
@ -54,10 +49,27 @@
|
|||
"redux-mock-store": "^1.5.4",
|
||||
"sass": "^1.52.1",
|
||||
"sass-loader": "^8.0.2",
|
||||
"style-loader": "^1.3.0",
|
||||
"style-loader": "^2.0.0",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^4.46.0",
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack": "^5.94.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
115
pyproject.toml
115
pyproject.toml
|
|
@ -1,50 +1,81 @@
|
|||
[project]
|
||||
name = 'newsreader'
|
||||
version = '0.4.3'
|
||||
authors = [{name = 'Sonny', email= 'sonnyba871@gmail.com'}]
|
||||
license = {text = 'GPL-3.0'}
|
||||
requires-python = '>=3.11'
|
||||
name = "newsreader"
|
||||
version = "0.5.3"
|
||||
authors = [{ name = "Sonny" }]
|
||||
license = { text = "GPL-3.0" }
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
'django~=3.2',
|
||||
'celery~=5.0',
|
||||
'psycopg2',
|
||||
|
||||
'django-axes',
|
||||
'django-celery-beat~=2.5.0',
|
||||
'django-registration-redux~=2.7',
|
||||
'django-rest-framework',
|
||||
'drf-yasg',
|
||||
|
||||
'python-memcached',
|
||||
'python-dotenv~=0.12',
|
||||
|
||||
'ftfy~=5.8',
|
||||
|
||||
'requests',
|
||||
'requests_oauthlib',
|
||||
|
||||
'feedparser',
|
||||
'bleach',
|
||||
'beautifulsoup4',
|
||||
'lxml'
|
||||
"django~=4.2",
|
||||
"celery~=5.4",
|
||||
"psycopg[binary]",
|
||||
"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]
|
||||
test-tools = ["ruff", "factory_boy", "freezegun"]
|
||||
development = [
|
||||
"django-debug-toolbar",
|
||||
"django-stubs",
|
||||
"django-extensions",
|
||||
]
|
||||
ci = ["coverage~=7.6.1"]
|
||||
production = ["gunicorn~=23.0"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
testing = [
|
||||
'factory-boy',
|
||||
'freezegun',
|
||||
'black',
|
||||
'isort',
|
||||
'autoflake',
|
||||
'tblib',
|
||||
sentry = ["sentry-sdk~=2.0"]
|
||||
|
||||
[tool.uv]
|
||||
environments = ["sys_platform == 'linux'"]
|
||||
default-groups = ["test-tools"]
|
||||
|
||||
[tool.ruff]
|
||||
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",
|
||||
]
|
||||
|
||||
development = [
|
||||
'pip-tools>=6.13.0',
|
||||
'django-debug-toolbar',
|
||||
'django-extensions',
|
||||
[tool.ruff.lint.isort.sections]
|
||||
django = ["django"]
|
||||
|
||||
[tool.coverage.run]
|
||||
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']
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
#
|
||||
# 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
|
||||
|
|
@ -1,320 +0,0 @@
|
|||
#
|
||||
# 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
|
||||
|
|
@ -1,338 +0,0 @@
|
|||
#
|
||||
# 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
|
||||
|
|
@ -1,237 +0,0 @@
|
|||
#
|
||||
# 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
|
||||
|
|
@ -1,262 +0,0 @@
|
|||
#
|
||||
# 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,5 +1,6 @@
|
|||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from django import forms
|
|||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
||||
from django.contrib.auth.forms import UserChangeForm
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
|
||||
|
|
@ -11,18 +11,6 @@ class UserAdminForm(UserChangeForm):
|
|||
class Meta:
|
||||
widgets = {
|
||||
"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
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -40,14 +28,6 @@ class UserAdmin(DjangoUserAdmin):
|
|||
_("User settings"),
|
||||
{"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"),
|
||||
{"classes": ("collapse",), "fields": ("is_staff", "is_superuser")},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
# 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",
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# 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,14 +39,6 @@ class UserManager(DjangoUserManager):
|
|||
class User(AbstractUser):
|
||||
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
|
||||
auto_mark_read = models.BooleanField(
|
||||
_("Auto read marking"),
|
||||
|
|
@ -68,7 +60,3 @@ class User(AbstractUser):
|
|||
tasks.delete()
|
||||
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def has_twitter_auth(self):
|
||||
return self.twitter_oauth_token and self.twitter_oauth_token_secret
|
||||
|
|
|
|||
|
|
@ -2,27 +2,23 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block actions %}
|
||||
<section class="section form__section--last">
|
||||
<fieldset class="fieldset form__fieldset">
|
||||
{% include "components/form/confirm-button.html" %}
|
||||
<section class="section form__section--last">
|
||||
<fieldset class="fieldset form__fieldset">
|
||||
{% include "components/form/confirm-button.html" %}
|
||||
|
||||
<a class="link button button--primary" href="{% url 'accounts:password-change' %}">
|
||||
{% trans "Change password" %}
|
||||
</a>
|
||||
<a class="link button button--primary" href="{% url 'accounts:password-change' %}">
|
||||
{% trans "Change password" %}
|
||||
</a>
|
||||
|
||||
{% if favicon_task_allowed %}
|
||||
<a class="link button button--primary" href="{% url 'accounts:settings:favicon' %}">
|
||||
{% trans "Fetch favicons" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="button button--primary button--disabled" disabled>
|
||||
{% trans "Fetch favicons" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<a class="link button button--primary" href="{% url 'accounts:settings:integrations' %}">
|
||||
{% trans "Third party integrations" %}
|
||||
</a>
|
||||
</fieldset>
|
||||
</section>
|
||||
{% if favicon_task_allowed %}
|
||||
<a class="link button button--primary" href="{% url 'accounts:settings:favicon' %}">
|
||||
{% trans "Fetch favicons" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="button button--primary button--disabled" disabled>
|
||||
{% trans "Fetch favicons" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
</section>
|
||||
{% endblock actions %}
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
{% 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,7 +1,9 @@
|
|||
{% extends "base.html" %}
|
||||
{% extends "sidebar.html" %}
|
||||
|
||||
{% block content %}
|
||||
<main id="login--page" class="main">
|
||||
{% include "accounts/components/login-form.html" with form=form title="Login" confirm_text="Login" %}
|
||||
<main id="login--page" class="main" data-render-sidebar=true>
|
||||
<div class="main__container">
|
||||
{% include "accounts/components/login-form.html" with form=form title="Login" confirm_text="Login" %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
{% extends "base.html" %}
|
||||
{% extends "sidebar.html" %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<main id="password-change--page" class="main">
|
||||
{% url 'accounts:settings:home' as cancel_url %}
|
||||
{% include "components/form/form.html" with form=form title="Change password" confirm_text="Change password" cancel_url=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 %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
{% 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,7 +1,9 @@
|
|||
{% extends "base.html" %}
|
||||
{% extends "sidebar.html" %}
|
||||
|
||||
{% block content %}
|
||||
<main id="settings--page" class="main">
|
||||
{% include "accounts/components/settings-form.html" with form=form title="User profile" confirm_text="Save" %}
|
||||
<main id="settings--page" class="main" data-render-sidebar=true>
|
||||
<div class="main__container">
|
||||
{% include "accounts/components/settings-form.html" with form=form title="User profile" confirm_text="Save" %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
{% 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,8 +5,6 @@ from django.utils.crypto import get_random_string
|
|||
|
||||
import factory
|
||||
|
||||
from registration.models import RegistrationProfile
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
|
||||
|
||||
|
|
@ -29,11 +27,3 @@ class UserFactory(factory.django.DjangoModelFactory):
|
|||
|
||||
class Meta:
|
||||
model = User
|
||||
|
||||
|
||||
class RegistrationProfileFactory(factory.django.DjangoModelFactory):
|
||||
user = factory.SubFactory(UserFactory)
|
||||
activation_key = factory.LazyFunction(get_activation_key)
|
||||
|
||||
class Meta:
|
||||
model = RegistrationProfile
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,537 +0,0 @@
|
|||
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"))
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
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,9 +15,6 @@ class UserTestCase(TestCase):
|
|||
PeriodicTask.objects.create(
|
||||
name=f"{user.email}-feed", task="FeedTask", interval=interval
|
||||
)
|
||||
PeriodicTask.objects.create(
|
||||
name=f"{user.email}-timeline", task="TwitterTimelineTask", interval=interval
|
||||
)
|
||||
|
||||
user.delete()
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,7 @@ from django.contrib.auth.decorators import login_required
|
|||
from django.urls import include, path
|
||||
|
||||
from newsreader.accounts.views import (
|
||||
ActivationCompleteView,
|
||||
ActivationResendView,
|
||||
ActivationView,
|
||||
FaviconRedirectView,
|
||||
IntegrationsView,
|
||||
LoginView,
|
||||
LogoutView,
|
||||
PasswordChangeView,
|
||||
|
|
@ -14,54 +10,11 @@ from newsreader.accounts.views import (
|
|||
PasswordResetConfirmView,
|
||||
PasswordResetDoneView,
|
||||
PasswordResetView,
|
||||
RedditRevokeRedirectView,
|
||||
RedditTemplateView,
|
||||
RedditTokenRedirectView,
|
||||
RegistrationClosedView,
|
||||
RegistrationCompleteView,
|
||||
RegistrationView,
|
||||
SettingsView,
|
||||
TwitterAuthRedirectView,
|
||||
TwitterRevokeRedirectView,
|
||||
TwitterTemplateView,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
path("favicon/", login_required(FaviconRedirectView.as_view()), name="favicon"),
|
||||
path("", login_required(SettingsView.as_view()), name="home"),
|
||||
|
|
@ -71,24 +24,6 @@ urlpatterns = [
|
|||
# Auth
|
||||
path("login/", LoginView.as_view(), name="login"),
|
||||
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
|
||||
path("password-reset/", PasswordResetView.as_view(), name="password-reset"),
|
||||
path(
|
||||
|
|
|
|||
|
|
@ -1,14 +1,5 @@
|
|||
from newsreader.accounts.views.auth import LoginView, LogoutView
|
||||
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 (
|
||||
PasswordChangeView,
|
||||
PasswordResetCompleteView,
|
||||
|
|
@ -16,12 +7,17 @@ from newsreader.accounts.views.password import (
|
|||
PasswordResetDoneView,
|
||||
PasswordResetView,
|
||||
)
|
||||
from newsreader.accounts.views.registration import (
|
||||
ActivationCompleteView,
|
||||
ActivationResendView,
|
||||
ActivationView,
|
||||
RegistrationClosedView,
|
||||
RegistrationCompleteView,
|
||||
RegistrationView,
|
||||
)
|
||||
from newsreader.accounts.views.settings import SettingsView
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LoginView",
|
||||
"LogoutView",
|
||||
"FaviconRedirectView",
|
||||
"PasswordChangeView",
|
||||
"PasswordResetCompleteView",
|
||||
"PasswordResetConfirmView",
|
||||
"PasswordResetDoneView",
|
||||
"PasswordResetView",
|
||||
"SettingsView",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
from django.contrib.auth import views as django_views
|
||||
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"
|
||||
success_url = reverse_lazy("index")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,343 +0,0 @@
|
|||
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,32 +1,34 @@
|
|||
from django.contrib.auth import views as django_views
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
from newsreader.utils.views import NavListMixin
|
||||
|
||||
|
||||
# PasswordResetView sends the mail
|
||||
# PasswordResetDoneView shows a success message for the above
|
||||
# PasswordResetConfirmView checks the link the user clicked and
|
||||
# prompts for a new password
|
||||
# PasswordResetCompleteView shows a success message for the above
|
||||
class PasswordResetView(django_views.PasswordResetView):
|
||||
class PasswordResetView(NavListMixin, django_views.PasswordResetView):
|
||||
template_name = "password-reset/password-reset.html"
|
||||
subject_template_name = "password-reset/password-reset-subject.txt"
|
||||
email_template_name = "password-reset/password-reset-email.html"
|
||||
success_url = reverse_lazy("accounts:password-reset-done")
|
||||
|
||||
|
||||
class PasswordResetDoneView(django_views.PasswordResetDoneView):
|
||||
class PasswordResetDoneView(NavListMixin, django_views.PasswordResetDoneView):
|
||||
template_name = "password-reset/password-reset-done.html"
|
||||
|
||||
|
||||
class PasswordResetConfirmView(django_views.PasswordResetConfirmView):
|
||||
class PasswordResetConfirmView(NavListMixin, django_views.PasswordResetConfirmView):
|
||||
template_name = "password-reset/password-reset-confirm.html"
|
||||
success_url = reverse_lazy("accounts:password-reset-complete")
|
||||
|
||||
|
||||
class PasswordResetCompleteView(django_views.PasswordResetCompleteView):
|
||||
class PasswordResetCompleteView(NavListMixin, django_views.PasswordResetCompleteView):
|
||||
template_name = "password-reset/password-reset-complete.html"
|
||||
|
||||
|
||||
class PasswordChangeView(django_views.PasswordChangeView):
|
||||
class PasswordChangeView(NavListMixin, django_views.PasswordChangeView):
|
||||
template_name = "accounts/views/password-change.html"
|
||||
success_url = reverse_lazy("accounts:settings")
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
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,9 +4,10 @@ from django.views.generic.edit import FormView, ModelFormMixin
|
|||
|
||||
from newsreader.accounts.forms import UserSettingsForm
|
||||
from newsreader.accounts.models import User
|
||||
from newsreader.utils.views import NavListMixin
|
||||
|
||||
|
||||
class SettingsView(ModelFormMixin, FormView):
|
||||
class SettingsView(NavListMixin, ModelFormMixin, FormView):
|
||||
template_name = "accounts/views/settings.html"
|
||||
success_url = reverse_lazy("accounts:settings:home")
|
||||
form_class = UserSettingsForm
|
||||
|
|
|
|||
Binary file not shown.
BIN
src/newsreader/assets/fonts/Inter-VariableFont_opsz,wght.ttf
Normal file
BIN
src/newsreader/assets/fonts/Inter-VariableFont_opsz,wght.ttf
Normal file
Binary file not shown.
|
|
@ -1,101 +0,0 @@
|
|||
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"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,9 +1,7 @@
|
|||
import os
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from newsreader.conf.utils import get_env, get_root_dir
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
|
@ -15,16 +13,13 @@ except ImportError:
|
|||
DjangoIntegration = None
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
|
||||
DJANGO_PROJECT_DIR = os.path.join(BASE_DIR, "src", "newsreader")
|
||||
BASE_DIR = get_root_dir()
|
||||
DJANGO_PROJECT_DIR = 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
|
||||
|
||||
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
|
||||
INTERNAL_IPS = ["127.0.0.1", "localhost"]
|
||||
ALLOWED_HOSTS = get_env("ALLOWED_HOSTS", split=",", default=["127.0.0.1", "localhost"])
|
||||
INTERNAL_IPS = get_env("INTERNAL_IPS", split=",", default=["127.0.0.1", "localhost"])
|
||||
|
||||
# Application definition
|
||||
INSTALLED_APPS = [
|
||||
|
|
@ -37,10 +32,8 @@ INSTALLED_APPS = [
|
|||
"django.forms",
|
||||
# third party apps
|
||||
"rest_framework",
|
||||
"drf_yasg",
|
||||
"celery",
|
||||
"django_celery_beat",
|
||||
"registration",
|
||||
"axes",
|
||||
# app modules
|
||||
"newsreader.accounts",
|
||||
|
|
@ -50,7 +43,7 @@ INSTALLED_APPS = [
|
|||
"newsreader.news.collection",
|
||||
]
|
||||
|
||||
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
|
||||
SECRET_KEY = get_env("DJANGO_SECRET_KEY", default="")
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"axes.backends.AxesBackend",
|
||||
|
|
@ -75,11 +68,10 @@ FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
|
|||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
|
||||
"DIRS": [DJANGO_PROJECT_DIR / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
|
|
@ -90,16 +82,14 @@ TEMPLATES = [
|
|||
|
||||
WSGI_APPLICATION = "newsreader.wsgi.application"
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"HOST": os.environ["POSTGRES_HOST"],
|
||||
"PORT": os.environ["POSTGRES_PORT"],
|
||||
"NAME": os.environ["POSTGRES_DB"],
|
||||
"USER": os.environ["POSTGRES_USER"],
|
||||
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
|
||||
"HOST": get_env("POSTGRES_HOST", default=""),
|
||||
"PORT": get_env("POSTGRES_PORT", default=""),
|
||||
"NAME": get_env("POSTGRES_DB", default=""),
|
||||
"USER": get_env("POSTGRES_USER", default=""),
|
||||
"PASSWORD": get_env("POSTGRES_PASSWORD", default=""),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,17 +97,15 @@ DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
|||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
"axes": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
}
|
||||
|
||||
# Logging
|
||||
# https://docs.djangoproject.com/en/2.2/topics/logging/#configuring-logging
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
|
|
@ -171,8 +159,6 @@ LOGGING = {
|
|||
},
|
||||
}
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
|
||||
|
|
@ -187,8 +173,6 @@ AUTH_USER_MODEL = "accounts.User"
|
|||
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/2.2/topics/i18n/
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "Europe/Amsterdam"
|
||||
|
|
@ -196,34 +180,31 @@ USE_I18N = True
|
|||
USE_L10N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/2.2/howto/static-files/
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
||||
STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static")]
|
||||
STATIC_ROOT = BASE_DIR / "static"
|
||||
STATICFILES_DIRS = (DJANGO_PROJECT_DIR / "static",)
|
||||
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-STATICFILES_FINDERS
|
||||
STATICFILES_FINDERS = [
|
||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||
]
|
||||
|
||||
# Email
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
|
||||
# Reddit integration
|
||||
REDDIT_CLIENT_ID = "CLIENT_ID"
|
||||
REDDIT_CLIENT_SECRET = "CLIENT_SECRET"
|
||||
REDDIT_REDIRECT_URL = (
|
||||
"http://127.0.0.1:8000/accounts/settings/integrations/reddit/callback/"
|
||||
DEFAULT_FROM_EMAIL = get_env(
|
||||
"EMAIL_DEFAULT_FROM", required=False, default="webmaster@localhost"
|
||||
)
|
||||
|
||||
# Twitter integration
|
||||
TWITTER_CONSUMER_ID = "CONSUMER_ID"
|
||||
TWITTER_CONSUMER_SECRET = "CONSUMER_SECRET"
|
||||
TWITTER_REDIRECT_URL = (
|
||||
"http://127.0.0.1:8000/accounts/settings/integrations/twitter/callback/"
|
||||
)
|
||||
EMAIL_HOST = get_env("EMAIL_HOST", required=False, default="localhost")
|
||||
EMAIL_PORT = get_env("EMAIL_PORT", cast=int, required=False, default=25)
|
||||
|
||||
EMAIL_HOST_USER = get_env("EMAIL_HOST_USER", required=False, default="")
|
||||
EMAIL_HOST_PASSWORD = get_env("EMAIL_HOST_PASSWORD", required=False, default="")
|
||||
|
||||
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
|
||||
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
|
||||
|
|
@ -240,7 +221,12 @@ REST_FRAMEWORK = {
|
|||
"rest_framework.permissions.IsAuthenticated",
|
||||
"newsreader.accounts.permissions.IsOwner",
|
||||
),
|
||||
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
|
||||
"DEFAULT_RENDERER_CLASSES": (
|
||||
"djangorestframework_camel_case.render.CamelCaseJSONRenderer",
|
||||
),
|
||||
"DEFAULT_PARSER_CLASSES": (
|
||||
"djangorestframework_camel_case.parser.CamelCaseJSONParser",
|
||||
),
|
||||
}
|
||||
|
||||
SWAGGER_SETTINGS = {
|
||||
|
|
@ -255,13 +241,9 @@ SWAGGER_SETTINGS = {
|
|||
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
||||
CELERY_BROKER_URL = "amqp://guest@rabbitmq:5672"
|
||||
|
||||
REGISTRATION_OPEN = True
|
||||
REGISTRATION_AUTO_LOGIN = True
|
||||
ACCOUNT_ACTIVATION_DAYS = 7
|
||||
|
||||
# Sentry
|
||||
SENTRY_CONFIG = {
|
||||
"dsn": os.environ.get("SENTRY_DSN"),
|
||||
"dsn": get_env("SENTRY_DSN", default="", required=False),
|
||||
"send_default_pii": False,
|
||||
"integrations": [DjangoIntegration(), CeleryIntegration()]
|
||||
if DjangoIntegration and CeleryIntegration
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
from .base import * # isort:skip
|
||||
from .version import get_current_version
|
||||
from .base import * # noqa: F403
|
||||
from .utils import get_current_version
|
||||
|
||||
|
||||
DEBUG = True
|
||||
|
||||
|
||||
del LOGGING["handlers"]["file"]
|
||||
del LOGGING["handlers"]["celery"]
|
||||
del LOGGING["handlers"]["file"] # noqa: F405
|
||||
del LOGGING["handlers"]["celery"] # noqa: F405
|
||||
|
||||
LOGGING["loggers"].update(
|
||||
LOGGING["loggers"].update( # noqa: F405
|
||||
{
|
||||
"celery.task": {"handlers": ["console"], "level": "DEBUG"},
|
||||
"newsreader": {"handlers": ["console"], "level": "INFO"},
|
||||
|
|
@ -22,25 +22,25 @@ AXES_ENABLED = False
|
|||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
"axes": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
}
|
||||
|
||||
# Project settings
|
||||
VERSION = get_current_version()
|
||||
ENVIRONMENT = "gitlab"
|
||||
ENVIRONMENT = "ci"
|
||||
|
||||
try:
|
||||
# Optionally use sentry integration
|
||||
from sentry_sdk import init as sentry_init
|
||||
|
||||
SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT})
|
||||
SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT}) # noqa: F405
|
||||
|
||||
sentry_init(**SENTRY_CONFIG)
|
||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
||||
except ImportError:
|
||||
pass
|
||||
|
|
@ -1,14 +1,19 @@
|
|||
from .base import * # isort:skip
|
||||
from .version import get_current_version
|
||||
from .base import * # noqa: F403
|
||||
from .utils import get_current_version
|
||||
|
||||
|
||||
SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl"
|
||||
|
||||
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
|
||||
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"]
|
||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
||||
|
||||
|
||||
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
|
||||
"django.template.context_processors.debug",
|
||||
)
|
||||
|
||||
# Project settings
|
||||
VERSION = get_current_version()
|
||||
|
|
@ -23,8 +28,8 @@ try:
|
|||
|
||||
from .local import * # noqa
|
||||
|
||||
SENTRY_CONFIG.update({"release": VERSION})
|
||||
SENTRY_CONFIG.update({"release": VERSION}) # noqa: F405
|
||||
|
||||
sentry_init(**SENTRY_CONFIG)
|
||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
||||
except ImportError:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
from .base import * # isort:skip
|
||||
from .version import get_current_version
|
||||
from .base import * # noqa: F403
|
||||
from .utils import get_current_version
|
||||
|
||||
|
||||
ALLOWED_HOSTS = ["django", "127.0.0.1"]
|
||||
DEBUG = True
|
||||
|
||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"]
|
||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
||||
|
||||
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
|
||||
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405
|
||||
|
||||
LOGGING["loggers"].update(
|
||||
LOGGING["loggers"].update( # noqa: F405
|
||||
{
|
||||
"celery.task": {"handlers": ["console", "celery"], "level": "DEBUG"},
|
||||
}
|
||||
|
|
@ -16,7 +16,10 @@ LOGGING["loggers"].update(
|
|||
|
||||
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
|
||||
VERSION = get_current_version()
|
||||
|
|
@ -33,8 +36,8 @@ try:
|
|||
|
||||
from .local import * # noqa
|
||||
|
||||
SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT})
|
||||
SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT}) # noqa: F405
|
||||
|
||||
sentry_init(**SENTRY_CONFIG)
|
||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
||||
except ImportError:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -1,77 +1,32 @@
|
|||
import os
|
||||
from newsreader.conf.utils import get_env
|
||||
|
||||
from .version import get_current_version
|
||||
from .base import * # noqa: F403
|
||||
from .utils import get_current_version
|
||||
|
||||
|
||||
from .base import * # isort:skip
|
||||
|
||||
DEBUG = False
|
||||
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
|
||||
ALLOWED_HOSTS = ["127.0.0.1", "localhost", "rss.fudiggity.nl", "django"]
|
||||
|
||||
ADMINS = [
|
||||
("", email)
|
||||
for email in os.getenv("ADMINS", "").split(",")
|
||||
if os.environ.get("ADMINS")
|
||||
("", email) for email in get_env("ADMINS", split=",", required=False, default=[])
|
||||
]
|
||||
|
||||
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
|
||||
VERSION = get_current_version(debug=False)
|
||||
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
|
||||
AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler"
|
||||
|
||||
REGISTRATION_OPEN = False
|
||||
|
||||
# Optionally use sentry integration
|
||||
try:
|
||||
from sentry_sdk import init as sentry_init
|
||||
|
||||
SENTRY_CONFIG.update(
|
||||
SENTRY_CONFIG.update( # noqa: F405
|
||||
{"release": VERSION, "environment": ENVIRONMENT, "debug": False}
|
||||
)
|
||||
|
||||
sentry_init(**SENTRY_CONFIG)
|
||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
||||
except ImportError:
|
||||
pass
|
||||
|
|
|
|||
85
src/newsreader/conf/utils.py
Normal file
85
src/newsreader/conf/utils.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
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
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
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,8 +994,6 @@
|
|||
"email": "sonnyba871@gmail.com",
|
||||
"reddit_refresh_token": null,
|
||||
"reddit_access_token": null,
|
||||
"twitter_oauth_token": null,
|
||||
"twitter_oauth_token_secret": null,
|
||||
"auto_mark_read": true,
|
||||
"groups": [],
|
||||
"user_permissions": []
|
||||
|
|
|
|||
|
|
@ -3,15 +3,13 @@ import React from 'react';
|
|||
class Messages extends React.Component {
|
||||
state = { messages: this.props.messages };
|
||||
|
||||
close = ::this.close;
|
||||
|
||||
close(index) {
|
||||
close = index => {
|
||||
const newMessages = this.state.messages.filter((message, currentIndex) => {
|
||||
return currentIndex != index;
|
||||
});
|
||||
|
||||
this.setState({ messages: newMessages });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const messages = this.state.messages.map((message, index) => {
|
||||
|
|
|
|||
22
src/newsreader/js/components/NavList.js
Normal file
22
src/newsreader/js/components/NavList.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
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,6 +1,4 @@
|
|||
class Selector {
|
||||
onClick = ::this.onClick;
|
||||
|
||||
inputs = [];
|
||||
|
||||
constructor() {
|
||||
|
|
@ -11,13 +9,13 @@ class Selector {
|
|||
selectAllInput.onchange = this.onClick;
|
||||
}
|
||||
|
||||
onClick(e) {
|
||||
onClick = e => {
|
||||
const targetValue = e.target.checked;
|
||||
|
||||
this.inputs.forEach(input => {
|
||||
input.checked = targetValue;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default Selector;
|
||||
|
|
|
|||
25
src/newsreader/js/components/Sidebar.js
Normal file
25
src/newsreader/js/components/Sidebar.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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,3 +2,4 @@ import './lib/index.js';
|
|||
import './pages/homepage/index.js';
|
||||
import './pages/categories/index.js';
|
||||
import './pages/rules/index.js';
|
||||
import './pages/default/index.js';
|
||||
|
|
|
|||
|
|
@ -6,12 +6,9 @@ import Card from '../../components/Card.js';
|
|||
import CategoryCard from './components/CategoryCard.js';
|
||||
import CategoryModal from './components/CategoryModal.js';
|
||||
import Messages from '../../components/Messages.js';
|
||||
import Sidebar from '../../components/Sidebar.js';
|
||||
|
||||
class App extends React.Component {
|
||||
selectCategory = ::this.selectCategory;
|
||||
deselectCategory = ::this.deselectCategory;
|
||||
deleteCategory = ::this.deleteCategory;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
|
|
@ -23,15 +20,15 @@ class App extends React.Component {
|
|||
};
|
||||
}
|
||||
|
||||
selectCategory(categoryId) {
|
||||
selectCategory = categoryId => {
|
||||
this.setState({ selectedCategoryId: categoryId });
|
||||
}
|
||||
};
|
||||
|
||||
deselectCategory() {
|
||||
deselectCategory = () => {
|
||||
this.setState({ selectedCategoryId: null });
|
||||
}
|
||||
};
|
||||
|
||||
deleteCategory(categoryId) {
|
||||
deleteCategory = categoryId => {
|
||||
const url = `/api/categories/${categoryId}/`;
|
||||
const options = {
|
||||
method: 'DELETE',
|
||||
|
|
@ -59,7 +56,7 @@ class App extends React.Component {
|
|||
text: 'Unable to remove category, try again later',
|
||||
};
|
||||
return this.setState({ selectedCategoryId: null, message: message });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { categories } = this.state;
|
||||
|
|
@ -90,15 +87,19 @@ class App extends React.Component {
|
|||
return (
|
||||
<>
|
||||
{this.state.message && <Messages messages={[this.state.message]} />}
|
||||
<Card header={pageHeader} />
|
||||
{cards}
|
||||
{selectedCategory && (
|
||||
<CategoryModal
|
||||
category={selectedCategory}
|
||||
handleCancel={this.deselectCategory}
|
||||
handleDelete={this.deleteCategory}
|
||||
/>
|
||||
)}
|
||||
<Sidebar navLinks={this.props.navLinks} />
|
||||
|
||||
<div className="main__container">
|
||||
<Card header={pageHeader} />
|
||||
{cards}
|
||||
{selectedCategory && (
|
||||
<CategoryModal
|
||||
category={selectedCategory}
|
||||
handleCancel={this.deselectCategory}
|
||||
handleDelete={this.deleteCategory}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,15 @@ if (page) {
|
|||
let createUrl = document.getElementById('createUrl').textContent;
|
||||
let updateUrl = document.getElementById('updateUrl').textContent;
|
||||
|
||||
let linkScript = document.getElementById('Links');
|
||||
let navLinks = JSON.parse(linkScript.textContent);
|
||||
|
||||
ReactDOM.render(
|
||||
<App
|
||||
categories={categories}
|
||||
createUrl={createUrl.substring(1, createUrl.length - 2)}
|
||||
updateUrl={updateUrl.substring(1, updateUrl.length - 4)}
|
||||
navLinks={navLinks}
|
||||
/>,
|
||||
page
|
||||
);
|
||||
|
|
|
|||
17
src/newsreader/js/pages/default/index.js
Normal file
17
src/newsreader/js/pages/default/index.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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,14 +4,25 @@ import { connect } from 'react-redux';
|
|||
import { isEqual } from 'lodash';
|
||||
|
||||
import { fetchCategories } from './actions/categories';
|
||||
import { filterPosts } from './components/postlist/filters.js';
|
||||
|
||||
import ScrollTop from './components/ScrollTop.js';
|
||||
import Sidebar from './components/sidebar/Sidebar.js';
|
||||
import HomepageSidebar from './components/sidebar/Sidebar.js';
|
||||
import PostList from './components/postlist/PostList.js';
|
||||
import PostModal from './components/PostModal.js';
|
||||
import Messages from '../../components/Messages.js';
|
||||
|
||||
class App extends React.Component {
|
||||
state = { postListNode: null };
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.postListRef = node => {
|
||||
this.setState({ postListNode: node });
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchCategories();
|
||||
}
|
||||
|
|
@ -19,12 +30,12 @@ class App extends React.Component {
|
|||
render() {
|
||||
return (
|
||||
<>
|
||||
<Sidebar />
|
||||
<HomepageSidebar navLinks={this.props.navLinks} />
|
||||
<PostList
|
||||
feedUrl={this.props.feedUrl}
|
||||
subredditUrl={this.props.subredditUrl}
|
||||
timelineUrl={this.props.timelineUrl}
|
||||
timezone={this.props.timezone}
|
||||
forwardedRef={this.postListRef}
|
||||
postsByType={this.props.postsByType}
|
||||
/>
|
||||
|
||||
{!isEqual(this.props.post, {}) && (
|
||||
|
|
@ -34,15 +45,13 @@ class App extends React.Component {
|
|||
category={this.props.category}
|
||||
selectedType={this.props.selectedType}
|
||||
feedUrl={this.props.feedUrl}
|
||||
subredditUrl={this.props.subredditUrl}
|
||||
timelineUrl={this.props.timelineUrl}
|
||||
categoriesUrl={this.props.categoriesUrl}
|
||||
timezone={this.props.timezone}
|
||||
autoMarking={this.props.autoMarking}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ScrollTop />
|
||||
<ScrollTop postListNode={this.state.postListNode} />
|
||||
|
||||
{this.props.error && (
|
||||
<Messages messages={[{ type: 'error', text: this.props.error.message }]} />
|
||||
|
|
@ -54,9 +63,10 @@ class App extends React.Component {
|
|||
|
||||
const mapStateToProps = state => {
|
||||
const { error } = state.error;
|
||||
const postsByType = filterPosts(state);
|
||||
|
||||
if (!isEqual(state.selected.post, {})) {
|
||||
const ruleId = state.selected.post.rule;
|
||||
const ruleId = state.selected.post.rule.id;
|
||||
|
||||
const rule = state.rules.items[ruleId];
|
||||
const category = state.categories.items[rule.category];
|
||||
|
|
@ -67,10 +77,11 @@ const mapStateToProps = state => {
|
|||
rule,
|
||||
post: state.selected.post,
|
||||
selectedType: state.selected.item.type,
|
||||
postsByType: postsByType,
|
||||
};
|
||||
}
|
||||
|
||||
return { error, post: state.selected.post };
|
||||
return { error, post: state.selected.post, postsByType: postsByType };
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
|
|
|||
|
|
@ -124,10 +124,10 @@ export const fetchPostsBySection = (section, next = false) => {
|
|||
|
||||
switch (section.type) {
|
||||
case RULE_TYPE:
|
||||
url = next ? next : `/api/rules/${section.id}/posts/?read=false`;
|
||||
url = next ? next : `/api/rules/${section.id}/posts/`;
|
||||
break;
|
||||
case CATEGORY_TYPE:
|
||||
url = next ? next : `/api/categories/${section.id}/posts/?read=false`;
|
||||
url = next ? next : `/api/categories/${section.id}/posts/`;
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,18 +3,10 @@ import { connect } from 'react-redux';
|
|||
import Cookies from 'js-cookie';
|
||||
|
||||
import { unSelectPost, markPostRead, toggleSaved } from '../actions/posts.js';
|
||||
import {
|
||||
CATEGORY_TYPE,
|
||||
RULE_TYPE,
|
||||
SAVED_TYPE,
|
||||
FEED,
|
||||
SUBREDDIT,
|
||||
TWITTER_TIMELINE,
|
||||
} from '../constants.js';
|
||||
import { SAVED_TYPE } from '../constants.js';
|
||||
import { formatDatetime } from '../../../utils.js';
|
||||
|
||||
class PostModal extends React.Component {
|
||||
modalListener = ::this.modalListener;
|
||||
readTimer = null;
|
||||
|
||||
componentDidMount() {
|
||||
|
|
@ -39,13 +31,13 @@ class PostModal extends React.Component {
|
|||
window.removeEventListener('click', this.modalListener);
|
||||
}
|
||||
|
||||
modalListener(e) {
|
||||
modalListener = e => {
|
||||
const targetClassName = e.target.className;
|
||||
|
||||
if (this.props.post && targetClassName == 'modal post-modal') {
|
||||
this.props.unSelectPost();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const post = this.props.post;
|
||||
|
|
@ -54,17 +46,13 @@ class PostModal extends React.Component {
|
|||
const titleClassName = post.read ? 'post__title post__title--read' : 'post__title';
|
||||
const readButtonDisabled =
|
||||
post.read || this.props.isUpdating || this.props.selectedType === SAVED_TYPE;
|
||||
const savedIconClass = post.saved ? 'saved-icon saved-icon--saved' : 'saved-icon';
|
||||
const savedIconClass = post.saved
|
||||
? 'post__save post__save--saved saved-icon saved-icon--saved'
|
||||
: 'post__save saved-icon';
|
||||
|
||||
let ruleUrl = '';
|
||||
|
||||
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:
|
||||
ruleUrl = `${this.props.feedUrl}/${this.props.rule.id}/`;
|
||||
break;
|
||||
|
|
@ -73,59 +61,67 @@ class PostModal extends React.Component {
|
|||
return (
|
||||
<div className="modal post-modal">
|
||||
<div className="post">
|
||||
<div className="post__header">
|
||||
<div className="post__actions">
|
||||
<button
|
||||
className={`button read-button ${readButtonDisabled &&
|
||||
'button--disabled'}`}
|
||||
onClick={() =>
|
||||
!readButtonDisabled && this.props.markPostRead(post, token)
|
||||
}
|
||||
>
|
||||
<i className="fas fa-check" /> Mark as read
|
||||
</button>
|
||||
<button
|
||||
className="button post__close-button"
|
||||
onClick={() => this.props.unSelectPost()}
|
||||
>
|
||||
<i className="fas fa-times"></i> Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="post__heading">
|
||||
<h2 className={titleClassName}>{`${post.title} `}</h2>
|
||||
<div className="post__meta-info">
|
||||
<span className="post__date">
|
||||
{publicationDate} {this.props.timezone}
|
||||
</span>
|
||||
{post.author && <span className="post__author">{post.author}</span>}
|
||||
{this.props.category && (
|
||||
<span className="badge post__category" title={this.props.category.name}>
|
||||
<div className="post__container">
|
||||
<div className="post__header">
|
||||
<div className="post__actions">
|
||||
<button
|
||||
className={`button read-button ${readButtonDisabled &&
|
||||
'button--disabled'}`}
|
||||
onClick={() =>
|
||||
!readButtonDisabled && this.props.markPostRead(post, token)
|
||||
}
|
||||
>
|
||||
<i className="fas fa-check" /> Mark as read
|
||||
</button>
|
||||
<button
|
||||
className="button post__close-button"
|
||||
onClick={() => this.props.unSelectPost()}
|
||||
>
|
||||
<i className="fas fa-times"></i> Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="post__heading">
|
||||
<h2 className={titleClassName}>{`${post.title} `}</h2>
|
||||
<div className="post__meta">
|
||||
<div className="post__text">
|
||||
<span className="post__date">{publicationDate}</span>
|
||||
{post.author && <span className="post__author">{post.author}</span>}
|
||||
</div>
|
||||
|
||||
<div className="post__buttons">
|
||||
{this.props.category && (
|
||||
<span
|
||||
className="badge post__category"
|
||||
title={this.props.category.name}
|
||||
>
|
||||
<a
|
||||
href={`${this.props.categoriesUrl}/${this.props.category.id}/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{this.props.category.name}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
<span className="badge post__rule" title={this.props.rule.name}>
|
||||
<a href={ruleUrl} target="_blank" rel="noopener noreferrer">
|
||||
{this.props.rule.name}
|
||||
</a>
|
||||
</span>
|
||||
<a
|
||||
href={`${this.props.categoriesUrl}/${this.props.category.id}/`}
|
||||
className="post__link"
|
||||
href={post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{this.props.category.name}
|
||||
<i className="fas fa-external-link-alt" />
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
<span className="badge post__rule" title={this.props.rule.name}>
|
||||
<a href={ruleUrl} target="_blank" rel="noopener noreferrer">
|
||||
{this.props.rule.name}
|
||||
</a>
|
||||
</span>
|
||||
<a
|
||||
className="post__link"
|
||||
href={post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i className="fas fa-external-link-alt" />
|
||||
</a>
|
||||
<span
|
||||
className={savedIconClass}
|
||||
onClick={() => this.props.toggleSaved(post, token)}
|
||||
/>
|
||||
<span
|
||||
className={savedIconClass}
|
||||
onClick={() => this.props.toggleSaved(post, token)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,40 +1,52 @@
|
|||
import React from 'react';
|
||||
|
||||
export default class ScrollTop extends React.Component {
|
||||
scrollListener = ::this.scrollListener;
|
||||
state = {
|
||||
listenerAttached: false,
|
||||
showTop: false,
|
||||
showBottom: false,
|
||||
};
|
||||
|
||||
state = { showTop: false, showBottom: false };
|
||||
componentDidUpdate() {
|
||||
if (this.props.postListNode && !this.state.listenerAttached) {
|
||||
this.props.postListNode.addEventListener('scroll', this.scrollListener);
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('scroll', this.scrollListener);
|
||||
this.setState({ listenerAttached: true });
|
||||
}
|
||||
}
|
||||
|
||||
scrollListener() {
|
||||
const showBottom = window.innerHeight + window.scrollY < document.body.offsetHeight;
|
||||
scrollListener = () => {
|
||||
const postList = this.props.postListNode;
|
||||
const elementEnd =
|
||||
postList.scrollTop + postList.offsetHeight >= postList.scrollHeight;
|
||||
|
||||
this.setState({
|
||||
showTop: window.pageYOffset > 0 ? true : false,
|
||||
showBottom: showBottom,
|
||||
showTop: postList.scrollTop > window.innerHeight,
|
||||
showBottom: !elementEnd,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="scroll-to-top">
|
||||
{this.state.showTop && (
|
||||
<i
|
||||
className="scroll-to-top__icon scroll-to-top__icon--top"
|
||||
onClick={() => window.scrollTo(0, 0)}
|
||||
/>
|
||||
)}
|
||||
const postList = this.props.postListNode;
|
||||
|
||||
{this.state.showBottom && (
|
||||
<i
|
||||
className="scroll-to-top__icon scroll-to-top__icon--bottom"
|
||||
onClick={() => window.scrollTo(0, document.body.scrollHeight)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
postList && (
|
||||
<div className="scroll-to-top">
|
||||
{this.state.showTop && (
|
||||
<i
|
||||
className="scroll-to-top__icon scroll-to-top__icon--top"
|
||||
onClick={() => postList.scroll({ top: 0 })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{this.state.showBottom && (
|
||||
<i
|
||||
className="scroll-to-top__icon scroll-to-top__icon--bottom"
|
||||
onClick={() => postList.scroll({ top: postList.scrollHeight })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,21 +2,14 @@ import React from 'react';
|
|||
import { connect } from 'react-redux';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
import {
|
||||
CATEGORY_TYPE,
|
||||
RULE_TYPE,
|
||||
SAVED_TYPE,
|
||||
FEED,
|
||||
SUBREDDIT,
|
||||
TWITTER_TIMELINE,
|
||||
} from '../../constants.js';
|
||||
import { CATEGORY_TYPE, SAVED_TYPE } from '../../constants.js';
|
||||
import { selectPost, toggleSaved } from '../../actions/posts.js';
|
||||
import { formatDatetime } from '../../../../utils.js';
|
||||
|
||||
class PostItem extends React.Component {
|
||||
render() {
|
||||
const rule = { ...this.props.post.rule };
|
||||
const post = { ...this.props.post, rule: rule.id };
|
||||
const post = { ...this.props.post };
|
||||
const token = Cookies.get('csrftoken');
|
||||
const publicationDate = formatDatetime(post.publicationDate);
|
||||
|
||||
|
|
@ -25,14 +18,7 @@ class PostItem extends React.Component {
|
|||
: 'posts__header';
|
||||
const savedIconClass = post.saved ? 'saved-icon saved-icon--saved' : 'saved-icon';
|
||||
|
||||
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}/`;
|
||||
}
|
||||
const ruleUrl = `${this.props.feedUrl}/${rule.id}/`;
|
||||
|
||||
return (
|
||||
<li className="posts__item" ref={this.props.forwardedRef}>
|
||||
|
|
@ -46,7 +32,7 @@ class PostItem extends React.Component {
|
|||
|
||||
<div className="posts-info">
|
||||
<span className="posts-info__date" title={publicationDate}>
|
||||
{publicationDate} {this.props.timezone} {post.author && `By ${post.author}`}
|
||||
{publicationDate} {post.author && `By ${post.author}`}
|
||||
</span>
|
||||
{[CATEGORY_TYPE, SAVED_TYPE].includes(this.props.selected.type) && (
|
||||
<span className="badge">
|
||||
|
|
|
|||
|
|
@ -4,13 +4,11 @@ import { isEqual } from 'lodash';
|
|||
|
||||
import { fetchPostsBySection, fetchSavedPosts } from '../../actions/posts.js';
|
||||
import { SAVED_TYPE } from '../../constants.js';
|
||||
import { filterPosts } from './filters.js';
|
||||
|
||||
import LoadingIndicator from '../../../../components/LoadingIndicator.js';
|
||||
import PostItem from './PostItem.js';
|
||||
|
||||
class PostList extends React.Component {
|
||||
handleIntersect = ::this.handleIntersect;
|
||||
lastPostRef = null;
|
||||
observer = null;
|
||||
|
||||
|
|
@ -33,7 +31,7 @@ class PostList extends React.Component {
|
|||
this.observer.disconnect();
|
||||
}
|
||||
|
||||
handleIntersect(entries) {
|
||||
handleIntersect = entries => {
|
||||
entries.every(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this.observer.unobserve(entry.target);
|
||||
|
|
@ -45,7 +43,7 @@ class PostList extends React.Component {
|
|||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
paginate() {
|
||||
if (this.props.selected.type === SAVED_TYPE) {
|
||||
|
|
@ -56,19 +54,17 @@ class PostList extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const isLastItem = this.props.postsByType.toReversed().find(item => !item.read);
|
||||
|
||||
const postItems = this.props.postsByType.map((item, index) => {
|
||||
const isLastItem = this.props.postsByType.length - 1 == index;
|
||||
const defaultProps = {
|
||||
key: index,
|
||||
post: item,
|
||||
selected: this.props.selected,
|
||||
feedUrl: this.props.feedUrl,
|
||||
subredditUrl: this.props.subredditUrl,
|
||||
timelineUrl: this.props.timelineUrl,
|
||||
timezone: this.props.timezone,
|
||||
};
|
||||
|
||||
if (isLastItem && !item.read) {
|
||||
if (isLastItem?.id === item.id) {
|
||||
return <PostItem {...defaultProps} forwardedRef={this.lastPostRef} />;
|
||||
} else {
|
||||
return <PostItem {...defaultProps} />;
|
||||
|
|
@ -96,7 +92,7 @@ class PostList extends React.Component {
|
|||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="posts">
|
||||
<div className="posts" ref={this.props.forwardedRef}>
|
||||
<ul className="posts__list">{postItems}</ul>
|
||||
{this.props.isFetching && <LoadingIndicator />}
|
||||
</div>
|
||||
|
|
@ -107,7 +103,6 @@ class PostList extends React.Component {
|
|||
|
||||
const mapStateToProps = state => ({
|
||||
isFetching: state.posts.isFetching,
|
||||
postsByType: filterPosts(state),
|
||||
next: state.selected.next,
|
||||
lastReached: state.selected.lastReached,
|
||||
selected: state.selected.item,
|
||||
|
|
@ -118,4 +113,6 @@ const mapDispatchToProps = dispatch => ({
|
|||
fetchSavedPosts: (next = false) => dispatch(fetchSavedPosts(next)),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(PostList);
|
||||
export default connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })(
|
||||
PostList
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,31 +4,10 @@ const isEmpty = (object = {}) => {
|
|||
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 = []) => {
|
||||
const filteredPosts = posts.filter(post => {
|
||||
return post.rule === rule.id;
|
||||
return posts.filter(post => {
|
||||
return post.rule.id === rule.id;
|
||||
});
|
||||
|
||||
const filteredData = filteredPosts.map(post => ({ ...post, rule: { ...rule } }));
|
||||
|
||||
return filteredData.sort(sortOrdering);
|
||||
};
|
||||
|
||||
export const filterPostsByCategory = (category = {}, rules = [], posts = []) => {
|
||||
|
|
@ -36,24 +15,13 @@ export const filterPostsByCategory = (category = {}, rules = [], posts = []) =>
|
|||
return rule.category === category.id;
|
||||
});
|
||||
|
||||
const filteredData = filteredRules.map(rule => {
|
||||
const filteredPosts = posts.filter(post => {
|
||||
return post.rule === rule.id;
|
||||
});
|
||||
const ruleIds = filteredRules.map(rule => rule.id);
|
||||
|
||||
return filteredPosts.map(post => ({ ...post, rule: { ...rule } }));
|
||||
});
|
||||
|
||||
const sortedPosts = [...filteredData.flat()].sort(sortOrdering);
|
||||
|
||||
return sortedPosts;
|
||||
return [...posts].filter(post => ruleIds.includes(post.rule.id));
|
||||
};
|
||||
|
||||
export const filterPostsBySaved = (rules = [], posts = []) => {
|
||||
const filteredPosts = posts.filter(post => post.saved);
|
||||
return filteredPosts
|
||||
.map(post => ({ ...post, rule: { ...rules.find(rule => rule.id === post.rule) } }))
|
||||
.sort(savedOrdering);
|
||||
return [...posts].filter(post => post.saved);
|
||||
};
|
||||
|
||||
export const filterPosts = state => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import { CATEGORY_TYPE } from '../../constants.js';
|
||||
import { selectCategory, fetchCategory } from '../../actions/categories.js';
|
||||
import { fetchPostsBySection } from '../../actions/posts.js';
|
||||
|
||||
import { isSelected } from './functions.js';
|
||||
import RuleItem from './RuleItem.js';
|
||||
|
||||
|
|
|
|||
|
|
@ -5,15 +5,13 @@ import Cookies from 'js-cookie';
|
|||
import { markRead } from '../../actions/selected.js';
|
||||
|
||||
class ReadButton extends React.Component {
|
||||
markSelectedRead = ::this.markSelectedRead;
|
||||
|
||||
markSelectedRead() {
|
||||
markSelectedRead = () => {
|
||||
const token = Cookies.get('csrftoken');
|
||||
|
||||
if (this.props.selected.unread > 0) {
|
||||
this.props.markRead({ ...this.props.selected }, token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import { RULE_TYPE } from '../../constants.js';
|
||||
import { selectRule, fetchRule } from '../../actions/rules.js';
|
||||
import { fetchPostsBySection } from '../../actions/posts.js';
|
||||
|
||||
import { isSelected } from './functions.js';
|
||||
|
||||
class RuleItem extends React.Component {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import { filterCategories, filterRules } from './filters.js';
|
||||
|
||||
import { CATEGORY_TYPE, RULE_TYPE } from '../../constants.js';
|
||||
import Sidebar from '../../../../components/Sidebar.js';
|
||||
import LoadingIndicator from '../../../../components/LoadingIndicator.js';
|
||||
import { CATEGORY_TYPE, RULE_TYPE } from '../../constants.js';
|
||||
|
||||
import CategoryItem from './CategoryItem.js';
|
||||
import SavedItem from './SavedItem.js';
|
||||
import ReadButton from './ReadButton.js';
|
||||
|
||||
// TODO: show empty category message
|
||||
class Sidebar extends React.Component {
|
||||
import { filterCategories, filterRules } from './filters.js';
|
||||
|
||||
class HomepageSidebar extends React.Component {
|
||||
render() {
|
||||
const categoryItems = this.props.categories.items.map(category => {
|
||||
const rules = this.props.rules.items.filter(rule => {
|
||||
|
|
@ -33,18 +33,18 @@ class Sidebar extends React.Component {
|
|||
[CATEGORY_TYPE, RULE_TYPE].includes(this.props.selected.item.type);
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<Sidebar navLinks={this.props.navLinks} includeBorder={true}>
|
||||
{(this.props.categories.isFetching || this.props.rules.isFetching) && (
|
||||
<LoadingIndicator />
|
||||
)}
|
||||
|
||||
<ul className="sidebar__nav">
|
||||
<ul className="sidebar__list">
|
||||
<SavedItem selected={this.props.selected.item} />
|
||||
{categoryItems}
|
||||
</ul>
|
||||
|
||||
{showReadButton && <ReadButton />}
|
||||
</div>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -55,4 +55,4 @@ const mapStateToProps = state => ({
|
|||
selected: state.selected,
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(Sidebar);
|
||||
export default connect(mapStateToProps)(HomepageSidebar);
|
||||
|
|
|
|||
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