Compare commits

...

85 commits
0.4.3 ... main

Author SHA1 Message Date
e40d69d5ff Use correct settings module for development
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/tests Pipeline was successful
2025-05-11 09:44:55 +02:00
83707701e9 Fix template formatting issues
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/tests Pipeline was successful
2025-05-05 16:49:34 +02:00
116e2c1577 Fix cache permissions
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/tests Pipeline was successful
see https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#run---mounttypecache
2025-05-05 16:22:07 +02:00
cf96371b90 Fix formatting errors warnings 2025-05-05 15:42:12 +02:00
eadd7a5612 Add missing command invocation
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline was successful
2025-05-05 15:34:37 +02:00
62053a1048 Use uv image build with same python version
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline failed
2025-05-05 15:32:51 +02:00
b4340176da Use correct project name
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline was successful
2025-05-05 15:16:48 +02:00
433ff9413d Specify javascript build target 2025-05-05 15:14:54 +02:00
91949622b7 Update woodpecker configuration
Some checks failed
ci/woodpecker/push/build Pipeline failed
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline was successful
2025-05-05 15:11:55 +02:00
10affeb32f Docker compose refactor
Added shell interpolation for environment variables
2025-05-05 15:02:03 +02:00
e96c6f3528 Use psycopg-binary package
To prevent building the package from source
2025-05-05 14:40:40 +02:00
a534a3b691 Move jest configuration
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline failed
2025-05-04 19:52:24 +02:00
ebbbe99eaf Update package.json 2025-05-04 19:44:55 +02:00
c7f90e233e Move prettier configuration 2025-05-04 19:44:00 +02:00
9ba6824dd3 Remove unused isort configuration 2025-05-04 19:38:45 +02:00
4c5d3aec28 Move coverage configuration to pyproject.toml 2025-05-04 19:38:26 +02:00
dd9aaf467e Add editorconfig configuration 2025-05-04 19:34:25 +02:00
1417c52007 Apply prettier formatting
All checks were successful
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/tests Pipeline was successful
2025-03-28 21:55:35 +01:00
bfd081337b Run formatting / fix lint errors
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline was successful
2025-03-28 21:41:47 +01:00
b8559f0499 Remove reddit code
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline was successful
2025-03-27 22:02:12 +01:00
b465d0bb8d Remove leftover function binding usages
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline failed
2025-03-27 21:44:21 +01:00
1a54fdbcd1 Remove function binding usage
Some checks failed
ci/woodpecker/push/build Pipeline failed
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline failed
2025-03-24 09:17:30 +01:00
34afcc02b6 Remove requests oathlib
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/tests Pipeline was successful
2025-03-23 21:16:36 +01:00
1574661c57 Fix ruff errors
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/tests Pipeline was successful
2025-03-23 21:05:01 +01:00
3160becb72 Remove django-registration-redux
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline was successful
2025-03-23 21:01:23 +01:00
105371abaf Use long command options
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline was successful
2025-03-23 16:25:03 +01:00
ed37be0c60 Add celery healthcheck & update existing healthcheck 2025-03-23 16:24:33 +01:00
161234defd Bump rabbitmq version 2025-03-23 16:23:45 +01:00
f3ba0f1d09 Update ruff & uv usage
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline was successful
2025-03-23 16:19:15 +01:00
aff565862c Add woodpecker CI configuration
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/tests Pipeline was successful
2024-12-26 20:20:21 +01:00
bf43603d65 Update versions 2024-10-13 12:52:06 +02:00
91a7f6325c Update changelog 2024-10-13 12:49:55 +02:00
e33497569a Apply query optimizations for posts 2024-10-13 10:16:57 +02:00
2d5801f226 Update changelog 2024-10-07 21:43:52 +02:00
89d4ebdc49 Add missing VERSION environment variable 2024-10-07 21:42:20 +02:00
174912a967 Update changelog 2024-10-07 21:02:03 +02:00
bb92f07f00 Use full screen height for mobile post layout 2024-10-07 21:00:03 +02:00
fa491120a0 Use line-through to indicate read status 2024-10-07 20:57:08 +02:00
ccde406193 Update CI after branch changes 2024-10-06 21:23:42 +02:00
a498417bad Update changelog 2024-10-06 20:56:09 +02:00
16ebf3bdb3 Apply ruff formatting 2024-10-06 20:47:57 +02:00
99c232fea2 Apply javascript formatting 2024-10-06 20:46:33 +02:00
fbb6405da9 Sidebar refactor 2024-10-06 20:39:05 +02:00
03b5847641 Apply formatting 2024-09-09 20:35:44 +02:00
dfb049ae14 Django 4.2 upgrade 2024-09-07 20:50:38 +02:00
b78f03d3b0 Remove twitter integration 2024-09-06 09:17:23 +02:00
e09b3d6e4c Use root user for development docker containers 2024-09-06 08:48:18 +02:00
cc5b4cc0bb Add missing migration 2024-09-06 08:47:56 +02:00
70a0d5a96d Remove drf-yasg 2024-09-06 08:41:10 +02:00
cc8aafa310 Remove deprecated ruff optiong 2024-09-05 07:08:35 +02:00
57375591b5 Use ruff for formatting/linting 2024-09-05 06:58:35 +02:00
bb74e875e0 Fix typo 2024-08-31 10:21:25 +02:00
bc8ec0257e Update unknown request tests 2024-08-31 10:08:22 +02:00
a041d5f7fa Use uv to manage requirements 2024-08-30 21:05:55 +02:00
e95c2a440e Remove pip-tools & rerun requirements 2024-08-28 09:06:58 +02:00
5fc0742688 Fix multiline linting job 2024-08-28 08:44:41 +02:00
f5f7f99f71 Fix javascript tests 2024-08-26 09:33:00 +02:00
284f64d202 Set default babel preset targets 2024-08-26 09:19:30 +02:00
b34bef899c Fix jest setup 2024-08-25 08:56:44 +02:00
aa0a29fefb Use commonjs module for testing 2024-08-24 21:19:26 +02:00
2a5372166e Add modules: false for test transforms 2024-08-24 21:03:35 +02:00
fd3bf4f542 Update babel plugins 2024-08-24 20:53:59 +02:00
c7fb545096 Update babel config 2024-08-24 20:34:24 +02:00
c7aa431e4a Move .babelrc to babel.config.json 2024-08-24 16:38:53 +02:00
3152c8f14e Update jest setup 2024-08-24 16:15:25 +02:00
9e6be5c807 Remove trailing 's' 2024-08-24 16:00:51 +02:00
106bd6cb4c Add ignore pattern & use correct transform patter 2024-08-24 15:56:14 +02:00
040193a3ed Update jest configuration 2024-08-24 15:49:09 +02:00
d8b04b3329 Remove unknown --system pip flag 2024-08-24 15:40:06 +02:00
b6805c1675 Update CI installation steps 2024-08-24 15:36:14 +02:00
07c685401f Update webpack 2024-08-24 15:19:01 +02:00
8b080a3cee Remove loose option 2024-08-24 15:15:02 +02:00
12c1ac9d17 Remove version from docker-compose 2024-08-24 15:14:42 +02:00
67d7b10632 Fix docker images 2024-08-24 15:14:04 +02:00
1b8b9dcd41 Add .nvmrc 2024-08-24 14:39:59 +02:00
35c9e78809 Update docker images 2024-08-24 14:39:10 +02:00
4935d7d186 Use node lts for CI 2024-08-13 09:22:40 +02:00
2b3e35078d Update webpack configuration 2024-08-13 09:11:54 +02:00
d05e29b5e0 Use uv for dependency management 2024-08-13 09:07:47 +02:00
e9e8fc351c Add volume notes 2024-08-10 14:26:09 +02:00
16168cc9d9 Remove proxy_redirect directive 2023-10-01 21:59:23 +02:00
9097caf438 Use production webpack configuration 2023-09-28 20:34:08 +02:00
0f89fc2447 Update version 2023-09-28 20:30:18 +02:00
b36bf4e0bc Merge branch 'master' into development 2023-09-28 20:03:07 +02:00
40749403b9 Sort posts before storing in redux 2023-09-28 20:02:27 +02:00
269 changed files with 11338 additions and 42315 deletions

View file

@ -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}],
]
}

View file

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

View file

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

View file

@ -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
View file

@ -0,0 +1 @@
lts/*

View file

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

View file

@ -1,5 +1,32 @@
# Changelog # Changelog
## 0.5.3
- Apply query optimizations for retrieving posts
## 0.5.2
- Add missing `VERSION` environment variable
## 0.5.1
- Use line-through styling for read posts
- Use full height for post layout
## 0.5.0
- Upgrade python to 3.11
- Upgrade django to 4.2
- Migrate from pip-tools to uv
- Migrate from black to ruff for formatting
- Upgrade webpack to 5.9 (with various tooling)
- Styling refactor
- Mobile/tablet layout added
## 0.4.4
- Sort posts before storing in redux store
## 0.4.3 ## 0.4.3
- Use `IntersectionObserver` to paginate - Use `IntersectionObserver` to paginate

84
Dockerfile Normal file
View 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

View file

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

View file

@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
python /app/src/manage.py migrate uv run --no-sync -- /app/src/manage.py migrate
exec "$@" exec "$@"

View file

@ -14,9 +14,8 @@ server {
} }
location / { location / {
proxy_pass http://gunicorn;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_redirect off; proxy_pass http://gunicorn;
} }
} }

View file

@ -1,20 +1,13 @@
version: "3.6"
volumes: volumes:
static-files: static-files:
node-modules:
services: services:
celery:
build:
target: development
volumes:
- ./src/:/app/src
django: django:
build: build: &app-development-build
target: development 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: ports:
- "${DJANGO_PORT:-8000}:8000" - "${DJANGO_PORT:-8000}:8000"
volumes: volumes:
@ -23,12 +16,21 @@ services:
stdin_open: true stdin_open: true
tty: true tty: true
celery:
build:
<<: *app-development-build
environment:
<<: *django-env
volumes:
- ./src/:/app/src
webpack: webpack:
build: build:
target: frontend-build
context: . context: .
dockerfile: ./docker/webpack args:
BUILD_ARG: "dev"
command: npm run build:watch command: npm run build:watch
volumes: volumes:
- ./src/:/app/src - ./src/:/app/src
- static-files:/app/src/newsreader/static - static-files:/app/src/newsreader/static
- node-modules:/app/node_modules

View file

@ -1,5 +1,3 @@
version: "3.6"
volumes: volumes:
logs: logs:
static-files: static-files:
@ -11,7 +9,6 @@ services:
django: django:
condition: service_healthy condition: service_healthy
ports: ports:
# Note that --env-file should be used to set these correctly
- "${NGINX_HTTP_PORT:-80}:80" - "${NGINX_HTTP_PORT:-80}:80"
volumes: volumes:
- ./config/nginx/conf.d:/etc/nginx/conf.d - ./config/nginx/conf.d:/etc/nginx/conf.d

View file

@ -1,47 +1,46 @@
version: "3.6"
volumes: volumes:
logs: logs:
media: media:
postgres-data: postgres-data:
static-files: static-files:
x-db-env: &db-env x-db-connection-env: &db-connection-env
POSTGRES_HOST: POSTGRES_HOST: ${POSTGRES_HOST:-db}
POSTGRES_PORT: POSTGRES_PORT: ${POSTGRES_PORT:-5432}
POSTGRES_DB: POSTGRES_DB: &pg-database ${POSTGRES_DB:-newsreader}
POSTGRES_USER: POSTGRES_USER: &pg-user ${POSTGRES_USER:-newsreader}
POSTGRES_PASSWORD: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-newsreader}
x-django-build-env: &django-build-env x-db-env: &db-env
<<: *db-env <<: *db-connection-env
DJANGO_SECRET_KEY: PGUSER: *pg-user
DJANGO_SETTINGS_MODULE: PGDATABASE: *pg-database
x-django-env: &django-env 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
EMAIL_HOST: EMAIL_HOST: ${EMAIL_HOST:-localhost}
EMAIL_PORT: EMAIL_PORT: ${EMAIL_PORT:-25}
EMAIL_HOST_USER: EMAIL_HOST_USER: ${EMAIL_HOST_USER:-""}
EMAIL_HOST_PASSWORD: EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-""}
EMAIL_USE_TLS: EMAIL_USE_TLS: ${EMAIL_USE_TLS:-no}
EMAIL_USE_SSL: EMAIL_USE_SSL: ${EMAIL_USE_SSL:-no}
EMAIL_DEFAULT_FROM: EMAIL_DEFAULT_FROM: ${EMAIL_DEFAULT_FROM:-webmaster@localhost}
# Reddit
REDDIT_CLIENT_ID:
REDDIT_CLIENT_SECRET:
REDDIT_CALLBACK_URL:
# Twitter
TWITTER_CONSUMER_ID:
TWITTER_CONSUMER_SECRET:
TWITTER_REDIRECT_URL:
# Sentry # Sentry
SENTRY_DSN: SENTRY_DSN: ${SENTRY_DSN:-""}
services: services:
db: db:
@ -49,8 +48,8 @@ services:
<<: *db-env <<: *db-env
image: postgres:15 image: postgres:15
healthcheck: healthcheck:
# Note that --env-file should be used to set these correctly test: /usr/bin/pg_isready
test: /usr/bin/pg_isready --username="${POSTGRES_USER}" --dbname="${POSTGRES_DB}" start_period: 10s
interval: 5s interval: 5s
timeout: 10s timeout: 10s
retries: 10 retries: 10
@ -58,7 +57,7 @@ services:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
rabbitmq: rabbitmq:
image: rabbitmq:3.12 image: rabbitmq:4
memcached: memcached:
image: memcached:1.6 image: memcached:1.6
@ -66,56 +65,26 @@ services:
- memcached - memcached
- -m 64 - -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: django:
build: build: &app-build
context: . context: .
dockerfile: ./docker/django
target: production target: production
args:
<<: *django-build-env
environment: environment:
<<: *django-env <<: *django-env
entrypoint: /app/bin/docker-entrypoint.sh entrypoint: ["/bin/sh", "/app/bin/docker-entrypoint.sh"]
command: | command: |
gunicorn --bind 0.0.0.0:8000 uv run --no-sync --
gunicorn
--bind 0.0.0.0:8000
--workers 3 --workers 3
--chdir /app/src/ --chdir /app/src/
newsreader.wsgi:application newsreader.wsgi:application
healthcheck: healthcheck:
test: /usr/bin/curl --fail http://django:8000 || exit 1 test: /usr/bin/curl --fail http://django:8000 || exit 1
interval: 30s start_period: 10s
interval: 10s
timeout: 10s timeout: 10s
retries: 10 retries: 5
depends_on: depends_on:
memcached: memcached:
condition: service_started condition: service_started
@ -125,3 +94,33 @@ services:
- logs:/app/logs - logs:/app/logs
- media:/app/media - media:/app/media
- static-files:/app/static - static-files:/app/static
celery:
build:
<<: *app-build
environment:
<<: *django-env
command: |
uv run --no-sync --
celery
--app newsreader
--workdir /app/src/
worker --loglevel INFO
--concurrency 2
--beat
--scheduler django
-n worker1@%h
-n worker2@%h
healthcheck:
test: uv run --no-sync -- celery --app newsreader status || exit 1
start_period: 10s
interval: 10s
timeout: 10s
retries: 5
depends_on:
rabbitmq:
condition: service_started
django:
condition: service_healthy
volumes:
- logs:/app/logs

View file

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

View file

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

View file

@ -1,7 +0,0 @@
static:
stage: build
image: node:16-bullseye
before_script:
- npm install
script:
- npm run build

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,20 +1,19 @@
{ {
"name": "newsreader", "name": "newsreader",
"version": "0.4.3", "version": "0.5.3",
"description": "Application for viewing RSS feeds", "description": "Application for viewing RSS feeds",
"main": "index.js",
"scripts": { "scripts": {
"lint": "npx prettier \"src/newsreader/js/**/*.js\" --check", "lint": "npx prettier \"src/newsreader/js/**/*.js\" --check",
"format": "npx prettier \"src/newsreader/js/**/*.js\" --write", "format": "npx prettier \"src/newsreader/js/**/*.js\" --write",
"build": "npx webpack --config webpack.dev.babel.js",
"build:watch": "npx webpack --config webpack.dev.babel.js --watch", "build:watch": "npx webpack --config webpack.dev.babel.js --watch",
"build:dev": "npx webpack --config webpack.dev.babel.js",
"build:prod": "npx webpack --config webpack.prod.babel.js", "build:prod": "npx webpack --config webpack.prod.babel.js",
"test": "npx jest", "test": "npx jest",
"test:watch": "npm test -- --watch" "test:watch": "npm test -- --watch"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "[git@git.fudiggity.nl:5000]:sonny/newsreader.git" "url": "forgejo.fudiggity.nl:sonny/newsreader"
}, },
"author": "Sonny", "author": "Sonny",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
@ -32,21 +31,17 @@
"@babel/core": "^7.12.13", "@babel/core": "^7.12.13",
"@babel/plugin-proposal-class-properties": "^7.12.13", "@babel/plugin-proposal-class-properties": "^7.12.13",
"@babel/plugin-proposal-function-bind": "^7.12.13", "@babel/plugin-proposal-function-bind": "^7.12.13",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-syntax-function-bind": "^7.12.13",
"@babel/plugin-transform-react-jsx": "^7.12.13", "@babel/plugin-transform-react-jsx": "^7.12.13",
"@babel/plugin-transform-runtime": "^7.12.15",
"@babel/preset-env": "^7.12.13", "@babel/preset-env": "^7.12.13",
"@babel/register": "^7.12.13", "@babel/register": "^7.12.13",
"@babel/runtime": "^7.12.13", "@babel/runtime": "^7.12.13",
"babel-jest": "^24.9.0", "babel-jest": "^29.7.0",
"babel-loader": "^8.2.2", "babel-loader": "^8.2.2",
"clean-webpack-plugin": "^3.0.0", "clean-webpack-plugin": "^3.0.0",
"css-loader": "^3.6.0", "css-loader": "^7.1.2",
"fetch-mock": "^8.3.2", "fetch-mock": "^8.3.2",
"file-loader": "^6.2.0", "jest": "^29.7.0",
"jest": "^24.9.0", "mini-css-extract-plugin": "^2.9.1",
"mini-css-extract-plugin": "^0.9.0",
"node-fetch": "^2.6.1", "node-fetch": "^2.6.1",
"prettier": "^1.19.1", "prettier": "^1.19.1",
"react": "^16.14.0", "react": "^16.14.0",
@ -54,10 +49,27 @@
"redux-mock-store": "^1.5.4", "redux-mock-store": "^1.5.4",
"sass": "^1.52.1", "sass": "^1.52.1",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"style-loader": "^1.3.0", "style-loader": "^2.0.0",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"webpack": "^4.46.0", "webpack": "^5.94.0",
"webpack-cli": "^3.3.12", "webpack-cli": "^5.1.4",
"webpack-merge": "^4.2.2" "webpack-merge": "^4.2.2"
},
"prettier": {
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 90,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "avoid"
},
"jest": {
"roots": [
"src/newsreader/js/tests/"
],
"clearMocks": true,
"coverageDirectory": "coverage"
} }
} }

View file

@ -1,50 +1,81 @@
[project] [project]
name = 'newsreader' name = "newsreader"
version = '0.4.3' version = "0.5.3"
authors = [{name = 'Sonny', email= 'sonnyba871@gmail.com'}] authors = [{ name = "Sonny" }]
license = {text = 'GPL-3.0'} license = { text = "GPL-3.0" }
requires-python = '>=3.11' requires-python = ">=3.11"
dependencies = [ dependencies = [
'django~=3.2', "django~=4.2",
'celery~=5.0', "celery~=5.4",
'psycopg2', "psycopg[binary]",
"django-axes",
'django-axes', "django-celery-beat~=2.7.0",
'django-celery-beat~=2.5.0', "django-rest-framework",
'django-registration-redux~=2.7', "djangorestframework-camel-case",
'django-rest-framework', "pymemcache",
'drf-yasg', "python-dotenv~=1.0.1",
"ftfy~=6.2",
'python-memcached', "requests",
'python-dotenv~=0.12', "feedparser",
"bleach",
'ftfy~=5.8', "beautifulsoup4",
"lxml",
'requests',
'requests_oauthlib',
'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] [project.optional-dependencies]
testing = [ sentry = ["sentry-sdk~=2.0"]
'factory-boy',
'freezegun', [tool.uv]
'black', environments = ["sys_platform == 'linux'"]
'isort', default-groups = ["test-tools"]
'autoflake',
'tblib', [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 = [ [tool.ruff.lint.isort.sections]
'pip-tools>=6.13.0', django = ["django"]
'django-debug-toolbar',
'django-extensions', [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']

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
#!/usr/bin/env python #!/usr/bin/env python
"""Django's command-line utility for administrative tasks.""" """Django's command-line utility for administrative tasks."""
import os import os
import sys import sys

View file

@ -2,7 +2,7 @@ from django import forms
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from django.contrib.auth.forms import UserChangeForm from django.contrib.auth.forms import UserChangeForm
from django.utils.translation import ugettext as _ from django.utils.translation import gettext as _
from newsreader.accounts.models import User from newsreader.accounts.models import User
@ -11,18 +11,6 @@ class UserAdminForm(UserChangeForm):
class Meta: class Meta:
widgets = { widgets = {
"email": forms.EmailInput(attrs={"size": "50"}), "email": forms.EmailInput(attrs={"size": "50"}),
"reddit_access_token": forms.PasswordInput(
attrs={"size": "90"}, render_value=True
),
"reddit_refresh_token": forms.PasswordInput(
attrs={"size": "90"}, render_value=True
),
"twitter_oauth_token": forms.PasswordInput(
attrs={"size": "90"}, render_value=True
),
"twitter_oauth_token_secret": forms.PasswordInput(
attrs={"size": "90"}, render_value=True
),
} }
@ -40,14 +28,6 @@ class UserAdmin(DjangoUserAdmin):
_("User settings"), _("User settings"),
{"fields": ("email", "password", "first_name", "last_name", "is_active")}, {"fields": ("email", "password", "first_name", "last_name", "is_active")},
), ),
(
_("Reddit settings"),
{"fields": ("reddit_access_token", "reddit_refresh_token")},
),
(
_("Twitter settings"),
{"fields": ("twitter_oauth_token", "twitter_oauth_token_secret")},
),
( (
_("Permission settings"), _("Permission settings"),
{"classes": ("collapse",), "fields": ("is_staff", "is_superuser")}, {"classes": ("collapse",), "fields": ("is_staff", "is_superuser")},

View file

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

View file

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

View file

@ -39,14 +39,6 @@ class UserManager(DjangoUserManager):
class User(AbstractUser): class User(AbstractUser):
email = models.EmailField(_("email address"), unique=True) email = models.EmailField(_("email address"), unique=True)
# reddit settings
reddit_refresh_token = models.CharField(max_length=255, blank=True, null=True)
reddit_access_token = models.CharField(max_length=255, blank=True, null=True)
# twitter settings
twitter_oauth_token = models.CharField(max_length=255, blank=True, null=True)
twitter_oauth_token_secret = models.CharField(max_length=255, blank=True, null=True)
# settings # settings
auto_mark_read = models.BooleanField( auto_mark_read = models.BooleanField(
_("Auto read marking"), _("Auto read marking"),
@ -68,7 +60,3 @@ class User(AbstractUser):
tasks.delete() tasks.delete()
return super().delete(*args, **kwargs) return super().delete(*args, **kwargs)
@property
def has_twitter_auth(self):
return self.twitter_oauth_token and self.twitter_oauth_token_secret

View file

@ -2,27 +2,23 @@
{% load i18n %} {% load i18n %}
{% block actions %} {% block actions %}
<section class="section form__section--last"> <section class="section form__section--last">
<fieldset class="fieldset form__fieldset"> <fieldset class="fieldset form__fieldset">
{% include "components/form/confirm-button.html" %} {% include "components/form/confirm-button.html" %}
<a class="link button button--primary" href="{% url 'accounts:password-change' %}"> <a class="link button button--primary" href="{% url 'accounts:password-change' %}">
{% trans "Change password" %} {% trans "Change password" %}
</a> </a>
{% if favicon_task_allowed %} {% if favicon_task_allowed %}
<a class="link button button--primary" href="{% url 'accounts:settings:favicon' %}"> <a class="link button button--primary" href="{% url 'accounts:settings:favicon' %}">
{% trans "Fetch favicons" %} {% trans "Fetch favicons" %}
</a> </a>
{% else %} {% else %}
<button class="button button--primary button--disabled" disabled> <button class="button button--primary button--disabled" disabled>
{% trans "Fetch favicons" %} {% trans "Fetch favicons" %}
</button> </button>
{% endif %} {% endif %}
</fieldset>
<a class="link button button--primary" href="{% url 'accounts:settings:integrations' %}"> </section>
{% trans "Third party integrations" %}
</a>
</fieldset>
</section>
{% endblock actions %} {% endblock actions %}

View file

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

View file

@ -1,7 +1,9 @@
{% extends "base.html" %} {% extends "sidebar.html" %}
{% block content %} {% block content %}
<main id="login--page" class="main"> <main id="login--page" class="main" data-render-sidebar=true>
{% include "accounts/components/login-form.html" with form=form title="Login" confirm_text="Login" %} <div class="main__container">
{% include "accounts/components/login-form.html" with form=form title="Login" confirm_text="Login" %}
</div>
</main> </main>
{% endblock %} {% endblock %}

View file

@ -1,8 +1,12 @@
{% extends "base.html" %} {% extends "sidebar.html" %}
{% block content %} {% block content %}
<main id="password-change--page" class="main"> {% url 'accounts:settings:home' as cancel_url %}
{% url 'accounts:settings:home' as cancel_url %}
{% include "components/form/form.html" with form=form title="Change password" confirm_text="Change password" cancel_url=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> </main>
{% endblock %} {% endblock %}

View file

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

View file

@ -1,7 +1,9 @@
{% extends "base.html" %} {% extends "sidebar.html" %}
{% block content %} {% block content %}
<main id="settings--page" class="main"> <main id="settings--page" class="main" data-render-sidebar=true>
{% include "accounts/components/settings-form.html" with form=form title="User profile" confirm_text="Save" %} <div class="main__container">
{% include "accounts/components/settings-form.html" with form=form title="User profile" confirm_text="Save" %}
</div>
</main> </main>
{% endblock %} {% endblock %}

View file

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

View file

@ -5,8 +5,6 @@ from django.utils.crypto import get_random_string
import factory import factory
from registration.models import RegistrationProfile
from newsreader.accounts.models import User from newsreader.accounts.models import User
@ -29,11 +27,3 @@ class UserFactory(factory.django.DjangoModelFactory):
class Meta: class Meta:
model = User model = User
class RegistrationProfileFactory(factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory)
activation_key = factory.LazyFunction(get_activation_key)
class Meta:
model = RegistrationProfile

View file

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

View file

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

View file

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

View file

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

View file

@ -15,9 +15,6 @@ class UserTestCase(TestCase):
PeriodicTask.objects.create( PeriodicTask.objects.create(
name=f"{user.email}-feed", task="FeedTask", interval=interval name=f"{user.email}-feed", task="FeedTask", interval=interval
) )
PeriodicTask.objects.create(
name=f"{user.email}-timeline", task="TwitterTimelineTask", interval=interval
)
user.delete() user.delete()

View file

@ -2,11 +2,7 @@ from django.contrib.auth.decorators import login_required
from django.urls import include, path from django.urls import include, path
from newsreader.accounts.views import ( from newsreader.accounts.views import (
ActivationCompleteView,
ActivationResendView,
ActivationView,
FaviconRedirectView, FaviconRedirectView,
IntegrationsView,
LoginView, LoginView,
LogoutView, LogoutView,
PasswordChangeView, PasswordChangeView,
@ -14,54 +10,11 @@ from newsreader.accounts.views import (
PasswordResetConfirmView, PasswordResetConfirmView,
PasswordResetDoneView, PasswordResetDoneView,
PasswordResetView, PasswordResetView,
RedditRevokeRedirectView,
RedditTemplateView,
RedditTokenRedirectView,
RegistrationClosedView,
RegistrationCompleteView,
RegistrationView,
SettingsView, SettingsView,
TwitterAuthRedirectView,
TwitterRevokeRedirectView,
TwitterTemplateView,
) )
settings_patterns = [ settings_patterns = [
# Integrations
path(
"integrations/reddit/callback/",
login_required(RedditTemplateView.as_view()),
name="reddit-template",
),
path(
"integrations/reddit/refresh/",
login_required(RedditTokenRedirectView.as_view()),
name="reddit-refresh",
),
path(
"integrations/reddit/revoke/",
login_required(RedditRevokeRedirectView.as_view()),
name="reddit-revoke",
),
path(
"integrations/twitter/auth/",
login_required(TwitterAuthRedirectView.as_view()),
name="twitter-auth",
),
path(
"integrations/twitter/callback/",
login_required(TwitterTemplateView.as_view()),
name="twitter-template",
),
path(
"integrations/twitter/revoke/",
login_required(TwitterRevokeRedirectView.as_view()),
name="twitter-revoke",
),
path(
"integrations/", login_required(IntegrationsView.as_view()), name="integrations"
),
# Misc # Misc
path("favicon/", login_required(FaviconRedirectView.as_view()), name="favicon"), path("favicon/", login_required(FaviconRedirectView.as_view()), name="favicon"),
path("", login_required(SettingsView.as_view()), name="home"), path("", login_required(SettingsView.as_view()), name="home"),
@ -71,24 +24,6 @@ urlpatterns = [
# Auth # Auth
path("login/", LoginView.as_view(), name="login"), path("login/", LoginView.as_view(), name="login"),
path("logout/", LogoutView.as_view(), name="logout"), path("logout/", LogoutView.as_view(), name="logout"),
# Register
path("register/", RegistrationView.as_view(), name="register"),
path(
"register/complete/",
RegistrationCompleteView.as_view(),
name="register-complete",
),
path("register/closed/", RegistrationClosedView.as_view(), name="register-closed"),
path(
"activate/complete/", ActivationCompleteView.as_view(), name="activate-complete"
),
path("activate/resend/", ActivationResendView.as_view(), name="activate-resend"),
path(
# This URL should be placed after all activate/ url's (see arg)
"activate/<str:activation_key>/",
ActivationView.as_view(),
name="activate",
),
# Password # Password
path("password-reset/", PasswordResetView.as_view(), name="password-reset"), path("password-reset/", PasswordResetView.as_view(), name="password-reset"),
path( path(

View file

@ -1,14 +1,5 @@
from newsreader.accounts.views.auth import LoginView, LogoutView from newsreader.accounts.views.auth import LoginView, LogoutView
from newsreader.accounts.views.favicon import FaviconRedirectView from newsreader.accounts.views.favicon import FaviconRedirectView
from newsreader.accounts.views.integrations import (
IntegrationsView,
RedditRevokeRedirectView,
RedditTemplateView,
RedditTokenRedirectView,
TwitterAuthRedirectView,
TwitterRevokeRedirectView,
TwitterTemplateView,
)
from newsreader.accounts.views.password import ( from newsreader.accounts.views.password import (
PasswordChangeView, PasswordChangeView,
PasswordResetCompleteView, PasswordResetCompleteView,
@ -16,12 +7,17 @@ from newsreader.accounts.views.password import (
PasswordResetDoneView, PasswordResetDoneView,
PasswordResetView, PasswordResetView,
) )
from newsreader.accounts.views.registration import (
ActivationCompleteView,
ActivationResendView,
ActivationView,
RegistrationClosedView,
RegistrationCompleteView,
RegistrationView,
)
from newsreader.accounts.views.settings import SettingsView from newsreader.accounts.views.settings import SettingsView
__all__ = [
"LoginView",
"LogoutView",
"FaviconRedirectView",
"PasswordChangeView",
"PasswordResetCompleteView",
"PasswordResetConfirmView",
"PasswordResetDoneView",
"PasswordResetView",
"SettingsView",
]

View file

@ -1,8 +1,10 @@
from django.contrib.auth import views as django_views from django.contrib.auth import views as django_views
from django.urls import reverse_lazy from django.urls import reverse_lazy
from newsreader.utils.views import NavListMixin
class LoginView(django_views.LoginView):
class LoginView(NavListMixin, django_views.LoginView):
template_name = "accounts/views/login.html" template_name = "accounts/views/login.html"
success_url = reverse_lazy("index") success_url = reverse_lazy("index")

View file

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

View file

@ -1,32 +1,34 @@
from django.contrib.auth import views as django_views from django.contrib.auth import views as django_views
from django.urls import reverse_lazy from django.urls import reverse_lazy
from newsreader.utils.views import NavListMixin
# PasswordResetView sends the mail # PasswordResetView sends the mail
# PasswordResetDoneView shows a success message for the above # PasswordResetDoneView shows a success message for the above
# PasswordResetConfirmView checks the link the user clicked and # PasswordResetConfirmView checks the link the user clicked and
# prompts for a new password # prompts for a new password
# PasswordResetCompleteView shows a success message for the above # PasswordResetCompleteView shows a success message for the above
class PasswordResetView(django_views.PasswordResetView): class PasswordResetView(NavListMixin, django_views.PasswordResetView):
template_name = "password-reset/password-reset.html" template_name = "password-reset/password-reset.html"
subject_template_name = "password-reset/password-reset-subject.txt" subject_template_name = "password-reset/password-reset-subject.txt"
email_template_name = "password-reset/password-reset-email.html" email_template_name = "password-reset/password-reset-email.html"
success_url = reverse_lazy("accounts:password-reset-done") success_url = reverse_lazy("accounts:password-reset-done")
class PasswordResetDoneView(django_views.PasswordResetDoneView): class PasswordResetDoneView(NavListMixin, django_views.PasswordResetDoneView):
template_name = "password-reset/password-reset-done.html" 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" template_name = "password-reset/password-reset-confirm.html"
success_url = reverse_lazy("accounts:password-reset-complete") success_url = reverse_lazy("accounts:password-reset-complete")
class PasswordResetCompleteView(django_views.PasswordResetCompleteView): class PasswordResetCompleteView(NavListMixin, django_views.PasswordResetCompleteView):
template_name = "password-reset/password-reset-complete.html" 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" template_name = "accounts/views/password-change.html"
success_url = reverse_lazy("accounts:settings") success_url = reverse_lazy("accounts:settings")

View file

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

View file

@ -4,9 +4,10 @@ from django.views.generic.edit import FormView, ModelFormMixin
from newsreader.accounts.forms import UserSettingsForm from newsreader.accounts.forms import UserSettingsForm
from newsreader.accounts.models import User from newsreader.accounts.models import User
from newsreader.utils.views import NavListMixin
class SettingsView(ModelFormMixin, FormView): class SettingsView(NavListMixin, ModelFormMixin, FormView):
template_name = "accounts/views/settings.html" template_name = "accounts/views/settings.html"
success_url = reverse_lazy("accounts:settings:home") success_url = reverse_lazy("accounts:settings:home")
form_class = UserSettingsForm form_class = UserSettingsForm

View file

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

View file

@ -1,9 +1,7 @@
import os
from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from newsreader.conf.utils import get_env, get_root_dir
load_dotenv() load_dotenv()
@ -15,16 +13,13 @@ except ImportError:
DjangoIntegration = None DjangoIntegration = None
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent BASE_DIR = get_root_dir()
DJANGO_PROJECT_DIR = os.path.join(BASE_DIR, "src", "newsreader") 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 DEBUG = False
ALLOWED_HOSTS = ["127.0.0.1", "localhost"] ALLOWED_HOSTS = get_env("ALLOWED_HOSTS", split=",", default=["127.0.0.1", "localhost"])
INTERNAL_IPS = ["127.0.0.1", "localhost"] INTERNAL_IPS = get_env("INTERNAL_IPS", split=",", default=["127.0.0.1", "localhost"])
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
@ -37,10 +32,8 @@ INSTALLED_APPS = [
"django.forms", "django.forms",
# third party apps # third party apps
"rest_framework", "rest_framework",
"drf_yasg",
"celery", "celery",
"django_celery_beat", "django_celery_beat",
"registration",
"axes", "axes",
# app modules # app modules
"newsreader.accounts", "newsreader.accounts",
@ -50,7 +43,7 @@ INSTALLED_APPS = [
"newsreader.news.collection", "newsreader.news.collection",
] ]
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] SECRET_KEY = get_env("DJANGO_SECRET_KEY", default="")
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"axes.backends.AxesBackend", "axes.backends.AxesBackend",
@ -75,11 +68,10 @@ FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
TEMPLATES = [ TEMPLATES = [
{ {
"BACKEND": "django.template.backends.django.DjangoTemplates", "BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")], "DIRS": [DJANGO_PROJECT_DIR / "templates"],
"APP_DIRS": True, "APP_DIRS": True,
"OPTIONS": { "OPTIONS": {
"context_processors": [ "context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request", "django.template.context_processors.request",
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
@ -90,16 +82,14 @@ TEMPLATES = [
WSGI_APPLICATION = "newsreader.wsgi.application" WSGI_APPLICATION = "newsreader.wsgi.application"
# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.postgresql", "ENGINE": "django.db.backends.postgresql",
"HOST": os.environ["POSTGRES_HOST"], "HOST": get_env("POSTGRES_HOST", default=""),
"PORT": os.environ["POSTGRES_PORT"], "PORT": get_env("POSTGRES_PORT", default=""),
"NAME": os.environ["POSTGRES_DB"], "NAME": get_env("POSTGRES_DB", default=""),
"USER": os.environ["POSTGRES_USER"], "USER": get_env("POSTGRES_USER", default=""),
"PASSWORD": os.environ["POSTGRES_PASSWORD"], "PASSWORD": get_env("POSTGRES_PASSWORD", default=""),
} }
} }
@ -107,17 +97,15 @@ DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
CACHES = { CACHES = {
"default": { "default": {
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache", "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": "memcached:11211", "LOCATION": "memcached:11211",
}, },
"axes": { "axes": {
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache", "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": "memcached:11211", "LOCATION": "memcached:11211",
}, },
} }
# Logging
# https://docs.djangoproject.com/en/2.2/topics/logging/#configuring-logging
LOGGING = { LOGGING = {
"version": 1, "version": 1,
"disable_existing_loggers": False, "disable_existing_loggers": False,
@ -171,8 +159,6 @@ LOGGING = {
}, },
} }
# Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
@ -187,8 +173,6 @@ AUTH_USER_MODEL = "accounts.User"
LOGIN_REDIRECT_URL = "/" LOGIN_REDIRECT_URL = "/"
# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/
LANGUAGE_CODE = "en-us" LANGUAGE_CODE = "en-us"
TIME_ZONE = "Europe/Amsterdam" TIME_ZONE = "Europe/Amsterdam"
@ -196,34 +180,31 @@ USE_I18N = True
USE_L10N = True USE_L10N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/
STATIC_URL = "/static/" STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, "static") STATIC_ROOT = BASE_DIR / "static"
STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static")] STATICFILES_DIRS = (DJANGO_PROJECT_DIR / "static",)
# https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-STATICFILES_FINDERS
STATICFILES_FINDERS = [ STATICFILES_FINDERS = [
"django.contrib.staticfiles.finders.FileSystemFinder", "django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder", "django.contrib.staticfiles.finders.AppDirectoriesFinder",
] ]
# Email # Email
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
# Reddit integration DEFAULT_FROM_EMAIL = get_env(
REDDIT_CLIENT_ID = "CLIENT_ID" "EMAIL_DEFAULT_FROM", required=False, default="webmaster@localhost"
REDDIT_CLIENT_SECRET = "CLIENT_SECRET"
REDDIT_REDIRECT_URL = (
"http://127.0.0.1:8000/accounts/settings/integrations/reddit/callback/"
) )
# Twitter integration EMAIL_HOST = get_env("EMAIL_HOST", required=False, default="localhost")
TWITTER_CONSUMER_ID = "CONSUMER_ID" EMAIL_PORT = get_env("EMAIL_PORT", cast=int, required=False, default=25)
TWITTER_CONSUMER_SECRET = "CONSUMER_SECRET"
TWITTER_REDIRECT_URL = ( EMAIL_HOST_USER = get_env("EMAIL_HOST_USER", required=False, default="")
"http://127.0.0.1:8000/accounts/settings/integrations/twitter/callback/" 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 # Third party settings
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler" AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
@ -240,7 +221,12 @@ REST_FRAMEWORK = {
"rest_framework.permissions.IsAuthenticated", "rest_framework.permissions.IsAuthenticated",
"newsreader.accounts.permissions.IsOwner", "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 = { SWAGGER_SETTINGS = {
@ -255,13 +241,9 @@ SWAGGER_SETTINGS = {
CELERY_WORKER_HIJACK_ROOT_LOGGER = False CELERY_WORKER_HIJACK_ROOT_LOGGER = False
CELERY_BROKER_URL = "amqp://guest@rabbitmq:5672" CELERY_BROKER_URL = "amqp://guest@rabbitmq:5672"
REGISTRATION_OPEN = True
REGISTRATION_AUTO_LOGIN = True
ACCOUNT_ACTIVATION_DAYS = 7
# Sentry # Sentry
SENTRY_CONFIG = { SENTRY_CONFIG = {
"dsn": os.environ.get("SENTRY_DSN"), "dsn": get_env("SENTRY_DSN", default="", required=False),
"send_default_pii": False, "send_default_pii": False,
"integrations": [DjangoIntegration(), CeleryIntegration()] "integrations": [DjangoIntegration(), CeleryIntegration()]
if DjangoIntegration and CeleryIntegration if DjangoIntegration and CeleryIntegration

View file

@ -1,14 +1,14 @@
from .base import * # isort:skip from .base import * # noqa: F403
from .version import get_current_version from .utils import get_current_version
DEBUG = True DEBUG = True
del LOGGING["handlers"]["file"] del LOGGING["handlers"]["file"] # noqa: F405
del LOGGING["handlers"]["celery"] del LOGGING["handlers"]["celery"] # noqa: F405
LOGGING["loggers"].update( LOGGING["loggers"].update( # noqa: F405
{ {
"celery.task": {"handlers": ["console"], "level": "DEBUG"}, "celery.task": {"handlers": ["console"], "level": "DEBUG"},
"newsreader": {"handlers": ["console"], "level": "INFO"}, "newsreader": {"handlers": ["console"], "level": "INFO"},
@ -22,25 +22,25 @@ AXES_ENABLED = False
CACHES = { CACHES = {
"default": { "default": {
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache", "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": "memcached:11211", "LOCATION": "memcached:11211",
}, },
"axes": { "axes": {
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache", "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": "memcached:11211", "LOCATION": "memcached:11211",
}, },
} }
# Project settings # Project settings
VERSION = get_current_version() VERSION = get_current_version()
ENVIRONMENT = "gitlab" ENVIRONMENT = "ci"
try: try:
# Optionally use sentry integration # Optionally use sentry integration
from sentry_sdk import init as sentry_init from sentry_sdk import init as sentry_init
SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT}) SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT}) # noqa: F405
sentry_init(**SENTRY_CONFIG) sentry_init(**SENTRY_CONFIG) # noqa: F405
except ImportError: except ImportError:
pass pass

View file

@ -1,14 +1,19 @@
from .base import * # isort:skip from .base import * # noqa: F403
from .version import get_current_version from .utils import get_current_version
SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl" SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl"
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" 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 # Project settings
VERSION = get_current_version() VERSION = get_current_version()
@ -23,8 +28,8 @@ try:
from .local import * # noqa 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: except ImportError:
pass pass

View file

@ -1,14 +1,14 @@
from .base import * # isort:skip from .base import * # noqa: F403
from .version import get_current_version 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"}, "celery.task": {"handlers": ["console", "celery"], "level": "DEBUG"},
} }
@ -16,7 +16,10 @@ LOGGING["loggers"].update(
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
DEBUG = True
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
"django.template.context_processors.debug",
)
# Project settings # Project settings
VERSION = get_current_version() VERSION = get_current_version()
@ -33,8 +36,8 @@ try:
from .local import * # noqa 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: except ImportError:
pass pass

View file

@ -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 DEBUG = False
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
ALLOWED_HOSTS = ["127.0.0.1", "localhost", "rss.fudiggity.nl", "django"]
ADMINS = [ ADMINS = [
("", email) ("", email) for email in get_env("ADMINS", split=",", required=False, default=[])
for email in os.getenv("ADMINS", "").split(",")
if os.environ.get("ADMINS")
] ]
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]
},
}
]
# Email
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
DEFAULT_FROM_EMAIL = os.environ.get("EMAIL_DEFAULT_FROM", "webmaster@localhost")
EMAIL_HOST = os.environ.get("EMAIL_HOST", "localhost")
EMAIL_PORT = os.environ.get("EMAIL_PORT", 25)
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "")
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "")
EMAIL_USE_TLS = bool(os.environ.get("EMAIL_USE_TLS"))
EMAIL_USE_SSL = bool(os.environ.get("EMAIL_USE_SSL"))
# Project settings # Project settings
VERSION = get_current_version(debug=False) VERSION = get_current_version(debug=False)
ENVIRONMENT = "production" ENVIRONMENT = "production"
# Reddit integration
REDDIT_CLIENT_ID = os.environ.get("REDDIT_CLIENT_ID", "")
REDDIT_CLIENT_SECRET = os.environ.get("REDDIT_CLIENT_SECRET", "")
REDDIT_REDIRECT_URL = os.environ.get("REDDIT_CALLBACK_URL", "")
# Twitter integration
TWITTER_CONSUMER_ID = os.environ.get("TWITTER_CONSUMER_ID", "")
TWITTER_CONSUMER_SECRET = os.environ.get("TWITTER_CONSUMER_SECRET", "")
TWITTER_REDIRECT_URL = os.environ.get("TWITTER_REDIRECT_URL", "")
# Third party settings # Third party settings
AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler" AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler"
REGISTRATION_OPEN = False
# Optionally use sentry integration # Optionally use sentry integration
try: try:
from sentry_sdk import init as sentry_init from sentry_sdk import init as sentry_init
SENTRY_CONFIG.update( SENTRY_CONFIG.update( # noqa: F405
{"release": VERSION, "environment": ENVIRONMENT, "debug": False} {"release": VERSION, "environment": ENVIRONMENT, "debug": False}
) )
sentry_init(**SENTRY_CONFIG) sentry_init(**SENTRY_CONFIG) # noqa: F405
except ImportError: except ImportError:
pass pass

View 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

View file

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

View file

@ -994,8 +994,6 @@
"email": "sonnyba871@gmail.com", "email": "sonnyba871@gmail.com",
"reddit_refresh_token": null, "reddit_refresh_token": null,
"reddit_access_token": null, "reddit_access_token": null,
"twitter_oauth_token": null,
"twitter_oauth_token_secret": null,
"auto_mark_read": true, "auto_mark_read": true,
"groups": [], "groups": [],
"user_permissions": [] "user_permissions": []

View file

@ -3,15 +3,13 @@ import React from 'react';
class Messages extends React.Component { class Messages extends React.Component {
state = { messages: this.props.messages }; state = { messages: this.props.messages };
close = ::this.close; close = index => {
close(index) {
const newMessages = this.state.messages.filter((message, currentIndex) => { const newMessages = this.state.messages.filter((message, currentIndex) => {
return currentIndex != index; return currentIndex != index;
}); });
this.setState({ messages: newMessages }); this.setState({ messages: newMessages });
} };
render() { render() {
const messages = this.state.messages.map((message, index) => { const messages = this.state.messages.map((message, index) => {

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

View file

@ -1,6 +1,4 @@
class Selector { class Selector {
onClick = ::this.onClick;
inputs = []; inputs = [];
constructor() { constructor() {
@ -11,13 +9,13 @@ class Selector {
selectAllInput.onchange = this.onClick; selectAllInput.onchange = this.onClick;
} }
onClick(e) { onClick = e => {
const targetValue = e.target.checked; const targetValue = e.target.checked;
this.inputs.forEach(input => { this.inputs.forEach(input => {
input.checked = targetValue; input.checked = targetValue;
}); });
} };
} }
export default Selector; export default Selector;

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

View file

@ -2,3 +2,4 @@ import './lib/index.js';
import './pages/homepage/index.js'; import './pages/homepage/index.js';
import './pages/categories/index.js'; import './pages/categories/index.js';
import './pages/rules/index.js'; import './pages/rules/index.js';
import './pages/default/index.js';

View file

@ -6,12 +6,9 @@ import Card from '../../components/Card.js';
import CategoryCard from './components/CategoryCard.js'; import CategoryCard from './components/CategoryCard.js';
import CategoryModal from './components/CategoryModal.js'; import CategoryModal from './components/CategoryModal.js';
import Messages from '../../components/Messages.js'; import Messages from '../../components/Messages.js';
import Sidebar from '../../components/Sidebar.js';
class App extends React.Component { class App extends React.Component {
selectCategory = ::this.selectCategory;
deselectCategory = ::this.deselectCategory;
deleteCategory = ::this.deleteCategory;
constructor(props) { constructor(props) {
super(props); super(props);
@ -23,15 +20,15 @@ class App extends React.Component {
}; };
} }
selectCategory(categoryId) { selectCategory = categoryId => {
this.setState({ selectedCategoryId: categoryId }); this.setState({ selectedCategoryId: categoryId });
} };
deselectCategory() { deselectCategory = () => {
this.setState({ selectedCategoryId: null }); this.setState({ selectedCategoryId: null });
} };
deleteCategory(categoryId) { deleteCategory = categoryId => {
const url = `/api/categories/${categoryId}/`; const url = `/api/categories/${categoryId}/`;
const options = { const options = {
method: 'DELETE', method: 'DELETE',
@ -59,7 +56,7 @@ class App extends React.Component {
text: 'Unable to remove category, try again later', text: 'Unable to remove category, try again later',
}; };
return this.setState({ selectedCategoryId: null, message: message }); return this.setState({ selectedCategoryId: null, message: message });
} };
render() { render() {
const { categories } = this.state; const { categories } = this.state;
@ -90,15 +87,19 @@ class App extends React.Component {
return ( return (
<> <>
{this.state.message && <Messages messages={[this.state.message]} />} {this.state.message && <Messages messages={[this.state.message]} />}
<Card header={pageHeader} /> <Sidebar navLinks={this.props.navLinks} />
{cards}
{selectedCategory && ( <div className="main__container">
<CategoryModal <Card header={pageHeader} />
category={selectedCategory} {cards}
handleCancel={this.deselectCategory} {selectedCategory && (
handleDelete={this.deleteCategory} <CategoryModal
/> category={selectedCategory}
)} handleCancel={this.deselectCategory}
handleDelete={this.deleteCategory}
/>
)}
</div>
</> </>
); );
} }

View file

@ -12,11 +12,15 @@ if (page) {
let createUrl = document.getElementById('createUrl').textContent; let createUrl = document.getElementById('createUrl').textContent;
let updateUrl = document.getElementById('updateUrl').textContent; let updateUrl = document.getElementById('updateUrl').textContent;
let linkScript = document.getElementById('Links');
let navLinks = JSON.parse(linkScript.textContent);
ReactDOM.render( ReactDOM.render(
<App <App
categories={categories} categories={categories}
createUrl={createUrl.substring(1, createUrl.length - 2)} createUrl={createUrl.substring(1, createUrl.length - 2)}
updateUrl={updateUrl.substring(1, updateUrl.length - 4)} updateUrl={updateUrl.substring(1, updateUrl.length - 4)}
navLinks={navLinks}
/>, />,
page page
); );

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

View file

@ -4,14 +4,25 @@ import { connect } from 'react-redux';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { fetchCategories } from './actions/categories'; import { fetchCategories } from './actions/categories';
import { filterPosts } from './components/postlist/filters.js';
import ScrollTop from './components/ScrollTop.js'; import ScrollTop from './components/ScrollTop.js';
import Sidebar from './components/sidebar/Sidebar.js'; import HomepageSidebar from './components/sidebar/Sidebar.js';
import PostList from './components/postlist/PostList.js'; import PostList from './components/postlist/PostList.js';
import PostModal from './components/PostModal.js'; import PostModal from './components/PostModal.js';
import Messages from '../../components/Messages.js'; import Messages from '../../components/Messages.js';
class App extends React.Component { class App extends React.Component {
state = { postListNode: null };
constructor(props) {
super(props);
this.postListRef = node => {
this.setState({ postListNode: node });
};
}
componentDidMount() { componentDidMount() {
this.props.fetchCategories(); this.props.fetchCategories();
} }
@ -19,12 +30,12 @@ class App extends React.Component {
render() { render() {
return ( return (
<> <>
<Sidebar /> <HomepageSidebar navLinks={this.props.navLinks} />
<PostList <PostList
feedUrl={this.props.feedUrl} feedUrl={this.props.feedUrl}
subredditUrl={this.props.subredditUrl}
timelineUrl={this.props.timelineUrl}
timezone={this.props.timezone} timezone={this.props.timezone}
forwardedRef={this.postListRef}
postsByType={this.props.postsByType}
/> />
{!isEqual(this.props.post, {}) && ( {!isEqual(this.props.post, {}) && (
@ -34,15 +45,13 @@ class App extends React.Component {
category={this.props.category} category={this.props.category}
selectedType={this.props.selectedType} selectedType={this.props.selectedType}
feedUrl={this.props.feedUrl} feedUrl={this.props.feedUrl}
subredditUrl={this.props.subredditUrl}
timelineUrl={this.props.timelineUrl}
categoriesUrl={this.props.categoriesUrl} categoriesUrl={this.props.categoriesUrl}
timezone={this.props.timezone} timezone={this.props.timezone}
autoMarking={this.props.autoMarking} autoMarking={this.props.autoMarking}
/> />
)} )}
<ScrollTop /> <ScrollTop postListNode={this.state.postListNode} />
{this.props.error && ( {this.props.error && (
<Messages messages={[{ type: 'error', text: this.props.error.message }]} /> <Messages messages={[{ type: 'error', text: this.props.error.message }]} />
@ -54,9 +63,10 @@ class App extends React.Component {
const mapStateToProps = state => { const mapStateToProps = state => {
const { error } = state.error; const { error } = state.error;
const postsByType = filterPosts(state);
if (!isEqual(state.selected.post, {})) { if (!isEqual(state.selected.post, {})) {
const ruleId = state.selected.post.rule; const ruleId = state.selected.post.rule.id;
const rule = state.rules.items[ruleId]; const rule = state.rules.items[ruleId];
const category = state.categories.items[rule.category]; const category = state.categories.items[rule.category];
@ -67,10 +77,11 @@ const mapStateToProps = state => {
rule, rule,
post: state.selected.post, post: state.selected.post,
selectedType: state.selected.item.type, selectedType: state.selected.item.type,
postsByType: postsByType,
}; };
} }
return { error, post: state.selected.post }; return { error, post: state.selected.post, postsByType: postsByType };
}; };
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({

View file

@ -124,10 +124,10 @@ export const fetchPostsBySection = (section, next = false) => {
switch (section.type) { switch (section.type) {
case RULE_TYPE: case RULE_TYPE:
url = next ? next : `/api/rules/${section.id}/posts/?read=false`; url = next ? next : `/api/rules/${section.id}/posts/`;
break; break;
case CATEGORY_TYPE: case CATEGORY_TYPE:
url = next ? next : `/api/categories/${section.id}/posts/?read=false`; url = next ? next : `/api/categories/${section.id}/posts/`;
break; break;
} }

View file

@ -3,18 +3,10 @@ import { connect } from 'react-redux';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { unSelectPost, markPostRead, toggleSaved } from '../actions/posts.js'; import { unSelectPost, markPostRead, toggleSaved } from '../actions/posts.js';
import { import { SAVED_TYPE } from '../constants.js';
CATEGORY_TYPE,
RULE_TYPE,
SAVED_TYPE,
FEED,
SUBREDDIT,
TWITTER_TIMELINE,
} from '../constants.js';
import { formatDatetime } from '../../../utils.js'; import { formatDatetime } from '../../../utils.js';
class PostModal extends React.Component { class PostModal extends React.Component {
modalListener = ::this.modalListener;
readTimer = null; readTimer = null;
componentDidMount() { componentDidMount() {
@ -39,13 +31,13 @@ class PostModal extends React.Component {
window.removeEventListener('click', this.modalListener); window.removeEventListener('click', this.modalListener);
} }
modalListener(e) { modalListener = e => {
const targetClassName = e.target.className; const targetClassName = e.target.className;
if (this.props.post && targetClassName == 'modal post-modal') { if (this.props.post && targetClassName == 'modal post-modal') {
this.props.unSelectPost(); this.props.unSelectPost();
} }
} };
render() { render() {
const post = this.props.post; const post = this.props.post;
@ -54,17 +46,13 @@ class PostModal extends React.Component {
const titleClassName = post.read ? 'post__title post__title--read' : 'post__title'; const titleClassName = post.read ? 'post__title post__title--read' : 'post__title';
const readButtonDisabled = const readButtonDisabled =
post.read || this.props.isUpdating || this.props.selectedType === SAVED_TYPE; post.read || this.props.isUpdating || this.props.selectedType === SAVED_TYPE;
const savedIconClass = post.saved ? '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 = ''; let ruleUrl = '';
switch (this.props.rule.type) { switch (this.props.rule.type) {
case SUBREDDIT:
ruleUrl = `${this.props.subredditUrl}/${this.props.rule.id}/`;
break;
case TWITTER_TIMELINE:
ruleUrl = `${this.props.timelineUrl}/${this.props.rule.id}/`;
break;
default: default:
ruleUrl = `${this.props.feedUrl}/${this.props.rule.id}/`; ruleUrl = `${this.props.feedUrl}/${this.props.rule.id}/`;
break; break;
@ -73,59 +61,67 @@ class PostModal extends React.Component {
return ( return (
<div className="modal post-modal"> <div className="modal post-modal">
<div className="post"> <div className="post">
<div className="post__header"> <div className="post__container">
<div className="post__actions"> <div className="post__header">
<button <div className="post__actions">
className={`button read-button ${readButtonDisabled && <button
'button--disabled'}`} className={`button read-button ${readButtonDisabled &&
onClick={() => 'button--disabled'}`}
!readButtonDisabled && this.props.markPostRead(post, token) onClick={() =>
} !readButtonDisabled && this.props.markPostRead(post, token)
> }
<i className="fas fa-check" /> Mark as read >
</button> <i className="fas fa-check" /> Mark as read
<button </button>
className="button post__close-button" <button
onClick={() => this.props.unSelectPost()} className="button post__close-button"
> onClick={() => this.props.unSelectPost()}
<i className="fas fa-times"></i> Close >
</button> <i className="fas fa-times"></i> Close
</div> </button>
<div className="post__heading"> </div>
<h2 className={titleClassName}>{`${post.title} `}</h2> <div className="post__heading">
<div className="post__meta-info"> <h2 className={titleClassName}>{`${post.title} `}</h2>
<span className="post__date"> <div className="post__meta">
{publicationDate} {this.props.timezone} <div className="post__text">
</span> <span className="post__date">{publicationDate}</span>
{post.author && <span className="post__author">{post.author}</span>} {post.author && <span className="post__author">{post.author}</span>}
{this.props.category && ( </div>
<span className="badge post__category" title={this.props.category.name}>
<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 <a
href={`${this.props.categoriesUrl}/${this.props.category.id}/`} className="post__link"
href={post.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{this.props.category.name} <i className="fas fa-external-link-alt" />
</a> </a>
</span> <span
)} className={savedIconClass}
<span className="badge post__rule" title={this.props.rule.name}> onClick={() => this.props.toggleSaved(post, token)}
<a href={ruleUrl} target="_blank" rel="noopener noreferrer"> />
{this.props.rule.name} </div>
</a> </div>
</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)}
/>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,40 +1,52 @@
import React from 'react'; import React from 'react';
export default class ScrollTop extends React.Component { 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() { this.setState({ listenerAttached: true });
window.addEventListener('scroll', this.scrollListener); }
} }
scrollListener() { scrollListener = () => {
const showBottom = window.innerHeight + window.scrollY < document.body.offsetHeight; const postList = this.props.postListNode;
const elementEnd =
postList.scrollTop + postList.offsetHeight >= postList.scrollHeight;
this.setState({ this.setState({
showTop: window.pageYOffset > 0 ? true : false, showTop: postList.scrollTop > window.innerHeight,
showBottom: showBottom, showBottom: !elementEnd,
}); });
} };
render() { render() {
return ( const postList = this.props.postListNode;
<div className="scroll-to-top">
{this.state.showTop && (
<i
className="scroll-to-top__icon scroll-to-top__icon--top"
onClick={() => window.scrollTo(0, 0)}
/>
)}
{this.state.showBottom && ( return (
<i postList && (
className="scroll-to-top__icon scroll-to-top__icon--bottom" <div className="scroll-to-top">
onClick={() => window.scrollTo(0, document.body.scrollHeight)} {this.state.showTop && (
/> <i
)} className="scroll-to-top__icon scroll-to-top__icon--top"
</div> 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>
)
); );
} }
} }

View file

@ -2,21 +2,14 @@ import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { import { CATEGORY_TYPE, SAVED_TYPE } from '../../constants.js';
CATEGORY_TYPE,
RULE_TYPE,
SAVED_TYPE,
FEED,
SUBREDDIT,
TWITTER_TIMELINE,
} from '../../constants.js';
import { selectPost, toggleSaved } from '../../actions/posts.js'; import { selectPost, toggleSaved } from '../../actions/posts.js';
import { formatDatetime } from '../../../../utils.js'; import { formatDatetime } from '../../../../utils.js';
class PostItem extends React.Component { class PostItem extends React.Component {
render() { render() {
const rule = { ...this.props.post.rule }; const rule = { ...this.props.post.rule };
const post = { ...this.props.post, rule: rule.id }; const post = { ...this.props.post };
const token = Cookies.get('csrftoken'); const token = Cookies.get('csrftoken');
const publicationDate = formatDatetime(post.publicationDate); const publicationDate = formatDatetime(post.publicationDate);
@ -25,14 +18,7 @@ class PostItem extends React.Component {
: 'posts__header'; : 'posts__header';
const savedIconClass = post.saved ? 'saved-icon saved-icon--saved' : 'saved-icon'; const savedIconClass = post.saved ? 'saved-icon saved-icon--saved' : 'saved-icon';
let ruleUrl = ''; const ruleUrl = `${this.props.feedUrl}/${rule.id}/`;
if (rule.type === SUBREDDIT) {
ruleUrl = `${this.props.subredditUrl}/${rule.id}/`;
} else if (rule.type === TWITTER_TIMELINE) {
ruleUrl = `${this.props.timelineUrl}/${rule.id}/`;
} else {
ruleUrl = `${this.props.feedUrl}/${rule.id}/`;
}
return ( return (
<li className="posts__item" ref={this.props.forwardedRef}> <li className="posts__item" ref={this.props.forwardedRef}>
@ -46,7 +32,7 @@ class PostItem extends React.Component {
<div className="posts-info"> <div className="posts-info">
<span className="posts-info__date" title={publicationDate}> <span className="posts-info__date" title={publicationDate}>
{publicationDate} {this.props.timezone} {post.author && `By ${post.author}`} {publicationDate} {post.author && `By ${post.author}`}
</span> </span>
{[CATEGORY_TYPE, SAVED_TYPE].includes(this.props.selected.type) && ( {[CATEGORY_TYPE, SAVED_TYPE].includes(this.props.selected.type) && (
<span className="badge"> <span className="badge">

View file

@ -4,13 +4,11 @@ import { isEqual } from 'lodash';
import { fetchPostsBySection, fetchSavedPosts } from '../../actions/posts.js'; import { fetchPostsBySection, fetchSavedPosts } from '../../actions/posts.js';
import { SAVED_TYPE } from '../../constants.js'; import { SAVED_TYPE } from '../../constants.js';
import { filterPosts } from './filters.js';
import LoadingIndicator from '../../../../components/LoadingIndicator.js'; import LoadingIndicator from '../../../../components/LoadingIndicator.js';
import PostItem from './PostItem.js'; import PostItem from './PostItem.js';
class PostList extends React.Component { class PostList extends React.Component {
handleIntersect = ::this.handleIntersect;
lastPostRef = null; lastPostRef = null;
observer = null; observer = null;
@ -33,7 +31,7 @@ class PostList extends React.Component {
this.observer.disconnect(); this.observer.disconnect();
} }
handleIntersect(entries) { handleIntersect = entries => {
entries.every(entry => { entries.every(entry => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
this.observer.unobserve(entry.target); this.observer.unobserve(entry.target);
@ -45,7 +43,7 @@ class PostList extends React.Component {
return false; return false;
} }
}); });
} };
paginate() { paginate() {
if (this.props.selected.type === SAVED_TYPE) { if (this.props.selected.type === SAVED_TYPE) {
@ -56,19 +54,17 @@ class PostList extends React.Component {
} }
render() { render() {
const isLastItem = this.props.postsByType.toReversed().find(item => !item.read);
const postItems = this.props.postsByType.map((item, index) => { const postItems = this.props.postsByType.map((item, index) => {
const isLastItem = this.props.postsByType.length - 1 == index;
const defaultProps = { const defaultProps = {
key: index, key: index,
post: item, post: item,
selected: this.props.selected, selected: this.props.selected,
feedUrl: this.props.feedUrl, 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} />; return <PostItem {...defaultProps} forwardedRef={this.lastPostRef} />;
} else { } else {
return <PostItem {...defaultProps} />; return <PostItem {...defaultProps} />;
@ -96,7 +92,7 @@ class PostList extends React.Component {
); );
} else { } else {
return ( return (
<div className="posts"> <div className="posts" ref={this.props.forwardedRef}>
<ul className="posts__list">{postItems}</ul> <ul className="posts__list">{postItems}</ul>
{this.props.isFetching && <LoadingIndicator />} {this.props.isFetching && <LoadingIndicator />}
</div> </div>
@ -107,7 +103,6 @@ class PostList extends React.Component {
const mapStateToProps = state => ({ const mapStateToProps = state => ({
isFetching: state.posts.isFetching, isFetching: state.posts.isFetching,
postsByType: filterPosts(state),
next: state.selected.next, next: state.selected.next,
lastReached: state.selected.lastReached, lastReached: state.selected.lastReached,
selected: state.selected.item, selected: state.selected.item,
@ -118,4 +113,6 @@ const mapDispatchToProps = dispatch => ({
fetchSavedPosts: (next = false) => dispatch(fetchSavedPosts(next)), fetchSavedPosts: (next = false) => dispatch(fetchSavedPosts(next)),
}); });
export default connect(mapStateToProps, mapDispatchToProps)(PostList); export default connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })(
PostList
);

View file

@ -4,31 +4,10 @@ const isEmpty = (object = {}) => {
return Object.keys(object).length === 0; return Object.keys(object).length === 0;
}; };
const sortOrdering = (firstPost, secondPost) => {
const dateOrdering =
new Date(firstPost.publicationDate) < new Date(secondPost.publicationDate);
if (firstPost.read && !secondPost.read) {
return 1;
} else if (secondPost.read && !firstPost.read) {
return -1;
}
return dateOrdering;
};
const savedOrdering = (firstPost, secondPost) => {
return new Date(firstPost.publicationDate) < new Date(secondPost.publicationDate);
};
export const filterPostsByRule = (rule = {}, posts = []) => { export const filterPostsByRule = (rule = {}, posts = []) => {
const filteredPosts = posts.filter(post => { return posts.filter(post => {
return post.rule === rule.id; return post.rule.id === rule.id;
}); });
const filteredData = filteredPosts.map(post => ({ ...post, rule: { ...rule } }));
return filteredData.sort(sortOrdering);
}; };
export const filterPostsByCategory = (category = {}, rules = [], posts = []) => { export const filterPostsByCategory = (category = {}, rules = [], posts = []) => {
@ -36,24 +15,13 @@ export const filterPostsByCategory = (category = {}, rules = [], posts = []) =>
return rule.category === category.id; return rule.category === category.id;
}); });
const filteredData = filteredRules.map(rule => { const ruleIds = filteredRules.map(rule => rule.id);
const filteredPosts = posts.filter(post => {
return post.rule === rule.id;
});
return filteredPosts.map(post => ({ ...post, rule: { ...rule } })); return [...posts].filter(post => ruleIds.includes(post.rule.id));
});
const sortedPosts = [...filteredData.flat()].sort(sortOrdering);
return sortedPosts;
}; };
export const filterPostsBySaved = (rules = [], posts = []) => { export const filterPostsBySaved = (rules = [], posts = []) => {
const filteredPosts = posts.filter(post => post.saved); return [...posts].filter(post => post.saved);
return filteredPosts
.map(post => ({ ...post, rule: { ...rules.find(rule => rule.id === post.rule) } }))
.sort(savedOrdering);
}; };
export const filterPosts = state => { export const filterPosts = state => {

View file

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { isEqual } from 'lodash';
import { CATEGORY_TYPE } from '../../constants.js'; import { CATEGORY_TYPE } from '../../constants.js';
import { selectCategory, fetchCategory } from '../../actions/categories.js'; import { selectCategory, fetchCategory } from '../../actions/categories.js';
import { fetchPostsBySection } from '../../actions/posts.js'; import { fetchPostsBySection } from '../../actions/posts.js';
import { isSelected } from './functions.js'; import { isSelected } from './functions.js';
import RuleItem from './RuleItem.js'; import RuleItem from './RuleItem.js';

View file

@ -5,15 +5,13 @@ import Cookies from 'js-cookie';
import { markRead } from '../../actions/selected.js'; import { markRead } from '../../actions/selected.js';
class ReadButton extends React.Component { class ReadButton extends React.Component {
markSelectedRead = ::this.markSelectedRead; markSelectedRead = () => {
markSelectedRead() {
const token = Cookies.get('csrftoken'); const token = Cookies.get('csrftoken');
if (this.props.selected.unread > 0) { if (this.props.selected.unread > 0) {
this.props.markRead({ ...this.props.selected }, token); this.props.markRead({ ...this.props.selected }, token);
} }
} };
render() { render() {
return ( return (

View file

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { isEqual } from 'lodash';
import { RULE_TYPE } from '../../constants.js'; import { RULE_TYPE } from '../../constants.js';
import { selectRule, fetchRule } from '../../actions/rules.js'; import { selectRule, fetchRule } from '../../actions/rules.js';
import { fetchPostsBySection } from '../../actions/posts.js'; import { fetchPostsBySection } from '../../actions/posts.js';
import { isSelected } from './functions.js'; import { isSelected } from './functions.js';
class RuleItem extends React.Component { class RuleItem extends React.Component {

View file

@ -1,17 +1,17 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { isEqual } from 'lodash';
import { filterCategories, filterRules } from './filters.js'; import Sidebar from '../../../../components/Sidebar.js';
import { CATEGORY_TYPE, RULE_TYPE } from '../../constants.js';
import LoadingIndicator from '../../../../components/LoadingIndicator.js'; import LoadingIndicator from '../../../../components/LoadingIndicator.js';
import { CATEGORY_TYPE, RULE_TYPE } from '../../constants.js';
import CategoryItem from './CategoryItem.js'; import CategoryItem from './CategoryItem.js';
import SavedItem from './SavedItem.js'; import SavedItem from './SavedItem.js';
import ReadButton from './ReadButton.js'; import ReadButton from './ReadButton.js';
// TODO: show empty category message import { filterCategories, filterRules } from './filters.js';
class Sidebar extends React.Component {
class HomepageSidebar extends React.Component {
render() { render() {
const categoryItems = this.props.categories.items.map(category => { const categoryItems = this.props.categories.items.map(category => {
const rules = this.props.rules.items.filter(rule => { const rules = this.props.rules.items.filter(rule => {
@ -33,18 +33,18 @@ class Sidebar extends React.Component {
[CATEGORY_TYPE, RULE_TYPE].includes(this.props.selected.item.type); [CATEGORY_TYPE, RULE_TYPE].includes(this.props.selected.item.type);
return ( return (
<div className="sidebar"> <Sidebar navLinks={this.props.navLinks} includeBorder={true}>
{(this.props.categories.isFetching || this.props.rules.isFetching) && ( {(this.props.categories.isFetching || this.props.rules.isFetching) && (
<LoadingIndicator /> <LoadingIndicator />
)} )}
<ul className="sidebar__nav"> <ul className="sidebar__list">
<SavedItem selected={this.props.selected.item} /> <SavedItem selected={this.props.selected.item} />
{categoryItems} {categoryItems}
</ul> </ul>
{showReadButton && <ReadButton />} {showReadButton && <ReadButton />}
</div> </Sidebar>
); );
} }
} }
@ -55,4 +55,4 @@ const mapStateToProps = state => ({
selected: state.selected, 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