Compare commits

...

81 commits
0.4.4 ... 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
266 changed files with 11188 additions and 42220 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,28 @@
# 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

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
python /app/src/manage.py migrate
uv run --no-sync -- /app/src/manage.py migrate
exec "$@"

View file

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

View file

@ -1,20 +1,13 @@
version: "3.6"
volumes:
static-files:
node-modules:
services:
celery:
build:
target: development
volumes:
- ./src/:/app/src
django:
build:
build: &app-development-build
target: development
command: python /app/src/manage.py runserver 0.0.0.0:8000
command: uv run --no-sync -- /app/src/manage.py runserver 0.0.0.0:8000
environment: &django-env
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-newsreader.conf.docker}
ports:
- "${DJANGO_PORT:-8000}:8000"
volumes:
@ -23,12 +16,21 @@ services:
stdin_open: true
tty: true
celery:
build:
<<: *app-development-build
environment:
<<: *django-env
volumes:
- ./src/:/app/src
webpack:
build:
target: frontend-build
context: .
dockerfile: ./docker/webpack
args:
BUILD_ARG: "dev"
command: npm run build:watch
volumes:
- ./src/:/app/src
- static-files:/app/src/newsreader/static
- node-modules:/app/node_modules

View file

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

View file

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

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

View file

@ -1,50 +1,81 @@
[project]
name = 'newsreader'
version = '0.4.4'
authors = [{name = 'Sonny', email= 'sonnyba871@gmail.com'}]
license = {text = 'GPL-3.0'}
requires-python = '>=3.11'
name = "newsreader"
version = "0.5.3"
authors = [{ name = "Sonny" }]
license = { text = "GPL-3.0" }
requires-python = ">=3.11"
dependencies = [
'django~=3.2',
'celery~=5.0',
'psycopg2',
'django-axes',
'django-celery-beat~=2.5.0',
'django-registration-redux~=2.7',
'django-rest-framework',
'drf-yasg',
'python-memcached',
'python-dotenv~=0.12',
'ftfy~=5.8',
'requests',
'requests_oauthlib',
'feedparser',
'bleach',
'beautifulsoup4',
'lxml'
"django~=4.2",
"celery~=5.4",
"psycopg[binary]",
"django-axes",
"django-celery-beat~=2.7.0",
"django-rest-framework",
"djangorestframework-camel-case",
"pymemcache",
"python-dotenv~=1.0.1",
"ftfy~=6.2",
"requests",
"feedparser",
"bleach",
"beautifulsoup4",
"lxml",
]
[dependency-groups]
test-tools = ["ruff", "factory_boy", "freezegun"]
development = [
"django-debug-toolbar",
"django-stubs",
"django-extensions",
]
ci = ["coverage~=7.6.1"]
production = ["gunicorn~=23.0"]
[project.optional-dependencies]
testing = [
'factory-boy',
'freezegun',
'black',
'isort',
'autoflake',
'tblib',
sentry = ["sentry-sdk~=2.0"]
[tool.uv]
environments = ["sys_platform == 'linux'"]
default-groups = ["test-tools"]
[tool.ruff]
include = ["pyproject.toml", "src/**/*.py"]
line-length = 88
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I"]
[tool.ruff.lint.isort]
lines-between-types=1
lines-after-imports=2
default-section = "third-party"
known-first-party = ["newsreader"]
section-order = [
"future",
"standard-library",
"django",
"third-party",
"first-party",
"local-folder",
]
development = [
'pip-tools>=6.13.0',
'django-debug-toolbar',
'django-extensions',
[tool.ruff.lint.isort.sections]
django = ["django"]
[tool.coverage.run]
source = ["./src/newsreader/"]
omit = [
"**/tests/**",
"**/migrations/**",
"**/conf/**",
"**/apps.py",
"**/admin.py",
"**/tests.py",
"**/urls.py",
"**/wsgi.py",
"**/celery.py",
"**/__init__.py"
]
ci = ['coverage>=5.3.1']
production = ['gunicorn~=20.0', 'sentry-sdk~=1.0']

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
"""Django's command-line utility for administrative tasks."""
import os
import sys

View file

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

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

View file

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

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 %}
<main id="login--page" class="main">
{% include "accounts/components/login-form.html" with form=form title="Login" confirm_text="Login" %}
<main id="login--page" class="main" data-render-sidebar=true>
<div class="main__container">
{% include "accounts/components/login-form.html" with form=form title="Login" confirm_text="Login" %}
</div>
</main>
{% endblock %}

View file

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

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 %}
<main id="settings--page" class="main">
{% include "accounts/components/settings-form.html" with form=form title="User profile" confirm_text="Save" %}
<main id="settings--page" class="main" data-render-sidebar=true>
<div class="main__container">
{% include "accounts/components/settings-form.html" with form=form title="User profile" confirm_text="Save" %}
</div>
</main>
{% endblock %}

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
from registration.models import RegistrationProfile
from newsreader.accounts.models import User
@ -29,11 +27,3 @@ class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
class RegistrationProfileFactory(factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory)
activation_key = factory.LazyFunction(get_activation_key)
class Meta:
model = RegistrationProfile

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(
name=f"{user.email}-feed", task="FeedTask", interval=interval
)
PeriodicTask.objects.create(
name=f"{user.email}-timeline", task="TwitterTimelineTask", interval=interval
)
user.delete()

View file

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

View file

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

View file

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

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

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.models import User
from newsreader.utils.views import NavListMixin
class SettingsView(ModelFormMixin, FormView):
class SettingsView(NavListMixin, ModelFormMixin, FormView):
template_name = "accounts/views/settings.html"
success_url = reverse_lazy("accounts:settings:home")
form_class = UserSettingsForm

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

View file

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

View file

@ -1,14 +1,19 @@
from .base import * # isort:skip
from .version import get_current_version
from .base import * # noqa: F403
from .utils import get_current_version
SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl"
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
INSTALLED_APPS += ["debug_toolbar", "django_extensions"]
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
"django.template.context_processors.debug",
)
# Project settings
VERSION = get_current_version()
@ -23,8 +28,8 @@ try:
from .local import * # noqa
SENTRY_CONFIG.update({"release": VERSION})
SENTRY_CONFIG.update({"release": VERSION}) # noqa: F405
sentry_init(**SENTRY_CONFIG)
sentry_init(**SENTRY_CONFIG) # noqa: F405
except ImportError:
pass

View file

@ -1,14 +1,14 @@
from .base import * # isort:skip
from .version import get_current_version
from .base import * # noqa: F403
from .utils import get_current_version
ALLOWED_HOSTS = ["django", "127.0.0.1"]
DEBUG = True
INSTALLED_APPS += ["debug_toolbar", "django_extensions"]
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405
LOGGING["loggers"].update(
LOGGING["loggers"].update( # noqa: F405
{
"celery.task": {"handlers": ["console", "celery"], "level": "DEBUG"},
}
@ -16,7 +16,10 @@ LOGGING["loggers"].update(
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
DEBUG = True
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
"django.template.context_processors.debug",
)
# Project settings
VERSION = get_current_version()
@ -33,8 +36,8 @@ try:
from .local import * # noqa
SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT})
SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT}) # noqa: F405
sentry_init(**SENTRY_CONFIG)
sentry_init(**SENTRY_CONFIG) # noqa: F405
except ImportError:
pass

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

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",
"reddit_refresh_token": null,
"reddit_access_token": null,
"twitter_oauth_token": null,
"twitter_oauth_token_secret": null,
"auto_mark_read": true,
"groups": [],
"user_permissions": []

View file

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

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 {
onClick = ::this.onClick;
inputs = [];
constructor() {
@ -11,13 +9,13 @@ class Selector {
selectAllInput.onchange = this.onClick;
}
onClick(e) {
onClick = e => {
const targetValue = e.target.checked;
this.inputs.forEach(input => {
input.checked = targetValue;
});
}
};
}
export default Selector;

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,13 +4,11 @@ import { isEqual } from 'lodash';
import { fetchPostsBySection, fetchSavedPosts } from '../../actions/posts.js';
import { SAVED_TYPE } from '../../constants.js';
import { filterPosts } from './filters.js';
import LoadingIndicator from '../../../../components/LoadingIndicator.js';
import PostItem from './PostItem.js';
class PostList extends React.Component {
handleIntersect = ::this.handleIntersect;
lastPostRef = null;
observer = null;
@ -33,7 +31,7 @@ class PostList extends React.Component {
this.observer.disconnect();
}
handleIntersect(entries) {
handleIntersect = entries => {
entries.every(entry => {
if (entry.isIntersecting) {
this.observer.unobserve(entry.target);
@ -45,7 +43,7 @@ class PostList extends React.Component {
return false;
}
});
}
};
paginate() {
if (this.props.selected.type === SAVED_TYPE) {
@ -64,9 +62,6 @@ class PostList extends React.Component {
post: item,
selected: this.props.selected,
feedUrl: this.props.feedUrl,
subredditUrl: this.props.subredditUrl,
timelineUrl: this.props.timelineUrl,
timezone: this.props.timezone,
};
if (isLastItem?.id === item.id) {
@ -97,7 +92,7 @@ class PostList extends React.Component {
);
} else {
return (
<div className="posts">
<div className="posts" ref={this.props.forwardedRef}>
<ul className="posts__list">{postItems}</ul>
{this.props.isFetching && <LoadingIndicator />}
</div>
@ -108,7 +103,6 @@ class PostList extends React.Component {
const mapStateToProps = state => ({
isFetching: state.posts.isFetching,
postsByType: filterPosts(state),
next: state.selected.next,
lastReached: state.selected.lastReached,
selected: state.selected.item,
@ -119,4 +113,6 @@ const mapDispatchToProps = dispatch => ({
fetchSavedPosts: (next = false) => dispatch(fetchSavedPosts(next)),
});
export default connect(mapStateToProps, mapDispatchToProps)(PostList);
export default connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })(
PostList
);

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,4 @@ export const RULE_TYPE = 'RULE';
export const CATEGORY_TYPE = 'CATEGORY';
export const SAVED_TYPE = 'SAVED';
export const SUBREDDIT = 'subreddit';
export const FEED = 'feed';
export const TWITTER_TIMELINE = 'twitter_timeline';

Some files were not shown because too many files have changed in this diff Show more