Compare commits

..

141 commits

Author SHA1 Message Date
e40d69d5ff Use correct settings module for development
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/tests Pipeline was successful
2025-05-11 09:44:55 +02:00
83707701e9 Fix template formatting issues
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/tests Pipeline was successful
2025-05-05 16:49:34 +02:00
116e2c1577 Fix cache permissions
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/tests Pipeline was successful
see https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#run---mounttypecache
2025-05-05 16:22:07 +02:00
cf96371b90 Fix formatting errors warnings 2025-05-05 15:42:12 +02:00
eadd7a5612 Add missing command invocation
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline was successful
2025-05-05 15:34:37 +02:00
62053a1048 Use uv image build with same python version
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline failed
2025-05-05 15:32:51 +02:00
b4340176da Use correct project name
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline was successful
2025-05-05 15:16:48 +02:00
433ff9413d Specify javascript build target 2025-05-05 15:14:54 +02:00
91949622b7 Update woodpecker configuration
Some checks failed
ci/woodpecker/push/build Pipeline failed
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline was successful
2025-05-05 15:11:55 +02:00
10affeb32f Docker compose refactor
Added shell interpolation for environment variables
2025-05-05 15:02:03 +02:00
e96c6f3528 Use psycopg-binary package
To prevent building the package from source
2025-05-05 14:40:40 +02:00
a534a3b691 Move jest configuration
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline failed
2025-05-04 19:52:24 +02:00
ebbbe99eaf Update package.json 2025-05-04 19:44:55 +02:00
c7f90e233e Move prettier configuration 2025-05-04 19:44:00 +02:00
9ba6824dd3 Remove unused isort configuration 2025-05-04 19:38:45 +02:00
4c5d3aec28 Move coverage configuration to pyproject.toml 2025-05-04 19:38:26 +02:00
dd9aaf467e Add editorconfig configuration 2025-05-04 19:34:25 +02:00
1417c52007 Apply prettier formatting
All checks were successful
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/tests Pipeline was successful
2025-03-28 21:55:35 +01:00
bfd081337b Run formatting / fix lint errors
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline was successful
2025-03-28 21:41:47 +01:00
b8559f0499 Remove reddit code
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline was successful
2025-03-27 22:02:12 +01:00
b465d0bb8d Remove leftover function binding usages
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline failed
2025-03-27 21:44:21 +01:00
1a54fdbcd1 Remove function binding usage
Some checks failed
ci/woodpecker/push/build Pipeline failed
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline failed
2025-03-24 09:17:30 +01:00
34afcc02b6 Remove requests oathlib
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/tests Pipeline was successful
2025-03-23 21:16:36 +01:00
1574661c57 Fix ruff errors
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/tests Pipeline was successful
2025-03-23 21:05:01 +01:00
3160becb72 Remove django-registration-redux
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline was successful
2025-03-23 21:01:23 +01:00
105371abaf Use long command options
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline was successful
2025-03-23 16:25:03 +01:00
ed37be0c60 Add celery healthcheck & update existing healthcheck 2025-03-23 16:24:33 +01:00
161234defd Bump rabbitmq version 2025-03-23 16:23:45 +01:00
f3ba0f1d09 Update ruff & uv usage
Some checks failed
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline was successful
2025-03-23 16:19:15 +01:00
aff565862c Add woodpecker CI configuration
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/tests Pipeline was successful
2024-12-26 20:20:21 +01:00
bf43603d65 Update versions 2024-10-13 12:52:06 +02:00
91a7f6325c Update changelog 2024-10-13 12:49:55 +02:00
e33497569a Apply query optimizations for posts 2024-10-13 10:16:57 +02:00
2d5801f226 Update changelog 2024-10-07 21:43:52 +02:00
89d4ebdc49 Add missing VERSION environment variable 2024-10-07 21:42:20 +02:00
174912a967 Update changelog 2024-10-07 21:02:03 +02:00
bb92f07f00 Use full screen height for mobile post layout 2024-10-07 21:00:03 +02:00
fa491120a0 Use line-through to indicate read status 2024-10-07 20:57:08 +02:00
ccde406193 Update CI after branch changes 2024-10-06 21:23:42 +02:00
a498417bad Update changelog 2024-10-06 20:56:09 +02:00
16ebf3bdb3 Apply ruff formatting 2024-10-06 20:47:57 +02:00
99c232fea2 Apply javascript formatting 2024-10-06 20:46:33 +02:00
fbb6405da9 Sidebar refactor 2024-10-06 20:39:05 +02:00
03b5847641 Apply formatting 2024-09-09 20:35:44 +02:00
dfb049ae14 Django 4.2 upgrade 2024-09-07 20:50:38 +02:00
b78f03d3b0 Remove twitter integration 2024-09-06 09:17:23 +02:00
e09b3d6e4c Use root user for development docker containers 2024-09-06 08:48:18 +02:00
cc5b4cc0bb Add missing migration 2024-09-06 08:47:56 +02:00
70a0d5a96d Remove drf-yasg 2024-09-06 08:41:10 +02:00
cc8aafa310 Remove deprecated ruff optiong 2024-09-05 07:08:35 +02:00
57375591b5 Use ruff for formatting/linting 2024-09-05 06:58:35 +02:00
bb74e875e0 Fix typo 2024-08-31 10:21:25 +02:00
bc8ec0257e Update unknown request tests 2024-08-31 10:08:22 +02:00
a041d5f7fa Use uv to manage requirements 2024-08-30 21:05:55 +02:00
e95c2a440e Remove pip-tools & rerun requirements 2024-08-28 09:06:58 +02:00
5fc0742688 Fix multiline linting job 2024-08-28 08:44:41 +02:00
f5f7f99f71 Fix javascript tests 2024-08-26 09:33:00 +02:00
284f64d202 Set default babel preset targets 2024-08-26 09:19:30 +02:00
b34bef899c Fix jest setup 2024-08-25 08:56:44 +02:00
aa0a29fefb Use commonjs module for testing 2024-08-24 21:19:26 +02:00
2a5372166e Add modules: false for test transforms 2024-08-24 21:03:35 +02:00
fd3bf4f542 Update babel plugins 2024-08-24 20:53:59 +02:00
c7fb545096 Update babel config 2024-08-24 20:34:24 +02:00
c7aa431e4a Move .babelrc to babel.config.json 2024-08-24 16:38:53 +02:00
3152c8f14e Update jest setup 2024-08-24 16:15:25 +02:00
9e6be5c807 Remove trailing 's' 2024-08-24 16:00:51 +02:00
106bd6cb4c Add ignore pattern & use correct transform patter 2024-08-24 15:56:14 +02:00
040193a3ed Update jest configuration 2024-08-24 15:49:09 +02:00
d8b04b3329 Remove unknown --system pip flag 2024-08-24 15:40:06 +02:00
b6805c1675 Update CI installation steps 2024-08-24 15:36:14 +02:00
07c685401f Update webpack 2024-08-24 15:19:01 +02:00
8b080a3cee Remove loose option 2024-08-24 15:15:02 +02:00
12c1ac9d17 Remove version from docker-compose 2024-08-24 15:14:42 +02:00
67d7b10632 Fix docker images 2024-08-24 15:14:04 +02:00
1b8b9dcd41 Add .nvmrc 2024-08-24 14:39:59 +02:00
35c9e78809 Update docker images 2024-08-24 14:39:10 +02:00
4935d7d186 Use node lts for CI 2024-08-13 09:22:40 +02:00
2b3e35078d Update webpack configuration 2024-08-13 09:11:54 +02:00
d05e29b5e0 Use uv for dependency management 2024-08-13 09:07:47 +02:00
e9e8fc351c Add volume notes 2024-08-10 14:26:09 +02:00
16168cc9d9 Remove proxy_redirect directive 2023-10-01 21:59:23 +02:00
9097caf438 Use production webpack configuration 2023-09-28 20:34:08 +02:00
0f89fc2447 Update version 2023-09-28 20:30:18 +02:00
b36bf4e0bc Merge branch 'master' into development 2023-09-28 20:03:07 +02:00
40749403b9 Sort posts before storing in redux 2023-09-28 20:02:27 +02:00
15884d3b4e Update CHANGELOG.md 2023-08-13 18:19:08 +02:00
40a0b72d87 Prevent observer from observing while loading posts 2023-08-13 17:45:11 +02:00
a4f5a7bdd7 Use React's ref feature 2023-08-13 17:45:11 +02:00
fedeed15c5 Use IntersectionObserver to paginate 2023-08-13 17:45:11 +02:00
ff6dfcaa05 Set DEBUG=True for gitlab configuration 2023-08-13 17:44:30 +02:00
2790e9c82e Merge branch 'master' into development 2023-07-02 13:08:12 +02:00
f0689ebfab 0.4.2 2023-07-02 12:55:19 +02:00
41f249ed5a 0.4.1 2023-07-02 11:00:47 +02:00
8e04436b68 Update changelog 2023-07-02 10:51:45 +02:00
5b59b189d6 Add missing env vars 2023-07-02 10:50:13 +02:00
8e728200ec Merge branch 'master' into development 2023-07-02 10:34:46 +02:00
8e7b059ad3 0.4.0 2023-07-02 10:23:16 +02:00
df848b1e43 Rerun black 2023-07-02 10:13:19 +02:00
e80579af4b Update changelog 2023-07-02 10:07:47 +02:00
d479b5e5f7 Update rabbitmq 2023-07-02 09:11:12 +02:00
b06af33a19 Update celery 2023-07-02 09:06:21 +02:00
858c2c6eb3 Use docker extensions for env variables 2023-07-02 08:24:11 +02:00
72f8426f72 Downgrade django-celery-beat
As this is the last version support django 2.2
2023-07-01 20:44:08 +02:00
1aea2df2ea Update cache settings 2023-06-29 10:04:45 +02:00
492b8d33ff Add missing build target for development celery 2023-06-29 09:43:43 +02:00
cbc6a73b76 Remove duplicate setting 2023-06-29 09:35:34 +02:00
4b04178a4f Set env files explicitly 2023-06-29 09:35:24 +02:00
ba4b17a8e2 Set correct default settings module 2023-06-28 19:56:06 +02:00
70a1ae306b Set correct celery broker URL 2023-06-28 19:55:19 +02:00
b91f5c8939 Add missing env_file setting & remove redundant database settings 2023-06-28 19:42:44 +02:00
5a73707d61 Split production dependecies & update production configuration 2023-06-28 19:38:44 +02:00
0f66c5eb9b Rebuild dependencies with python 3.9 for now 2023-06-28 09:57:44 +02:00
7f4a3a3e49 Update logging configuration for gitlab 2023-06-28 08:43:05 +02:00
9258d33f4e Update gitlab configuration 2023-06-28 08:37:28 +02:00
a9741d4063 Use django docker image for CI 2023-06-27 20:50:00 +02:00
61827b955d Use debug celery logging for development 2023-06-27 20:47:08 +02:00
b03f2fc902 Update fixture 2023-06-27 20:45:15 +02:00
89d88ccceb Update logging configuration 2023-06-27 20:37:47 +02:00
2a0c0072a4 Update gitlab configuration 2023-06-27 10:29:07 +02:00
6a46dc01e2 Remove variable defaults
These are not set whenever a merged compose file is used
2023-06-27 10:11:07 +02:00
60af3ba4f6 Set development env file 2023-06-27 09:35:33 +02:00
65dae40e9a Update npm packages 2023-06-27 09:35:24 +02:00
bfacd97c73 Update gitignore 2023-06-27 09:28:15 +02:00
3ebba6df47 Use more enviroment variables 2023-06-27 09:06:24 +02:00
b8a9d885f5 Use older python directories for now 2023-06-27 08:35:28 +02:00
fd5f910ac0 Remove redundant override 2023-06-27 08:35:07 +02:00
6ac4e5d5c2 Downgrade docker images for now 2023-06-26 21:25:41 +02:00
ef0c070755 Update docker django image 2023-06-26 20:27:58 +02:00
59f719d7c3 Update gitlab configuration 2023-06-26 20:25:36 +02:00
720f6fdb78 Use Makefile to generate requirements 2023-06-26 20:21:00 +02:00
82a7176629 Increase healthcheck interval 2023-03-05 15:32:15 +01:00
89f23fe668 Initial refactor 2023-03-05 15:21:04 +01:00
12b4aa0b91 Remove unused imports 2022-05-26 12:58:33 +02:00
c48de9c6e1 Rerun isort 2022-05-26 12:00:20 +02:00
e5220eb9a5 Remove deprecated option isort option 2022-05-26 11:59:31 +02:00
1f0a8a54da Use quiet option for CI jobs 2022-05-26 11:57:10 +02:00
bea7afb355 Use python 3.9 to build dependencies 2022-05-26 11:54:21 +02:00
bd48634509 Use coverage run command 2022-05-26 11:44:54 +02:00
d3f9a11f44 Replace node-sass with dart sass 2022-05-26 11:39:59 +02:00
9d05cac15c Update gitlab jobs 2022-05-26 11:15:46 +02:00
20309e70fa Use pip-tools to manage dependencies 2022-05-26 11:12:22 +02:00
312 changed files with 13224 additions and 36322 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

2
.gitignore vendored
View file

@ -115,7 +115,7 @@ celerybeat-schedule
*.sage.py *.sage.py
# Environments # Environments
.env *.env
.venv .venv
env/ env/
venv/ venv/

View file

@ -1,30 +0,0 @@
stages:
- build
- test
- lint
- release
- deploy
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
DJANGO_SETTINGS_MODULE: "newsreader.conf.gitlab"
POSTGRES_HOST: "$POSTGRES_HOST"
POSTGRES_DB: "$POSTGRES_NAME"
POSTGRES_NAME: "$POSTGRES_NAME"
POSTGRES_USER: "$POSTGRES_USER"
POSTGRES_PASSWORD: "$POSTGRES_PASSWORD"
cache:
key: "$CI_COMMIT_REF_SLUG"
paths:
- .venv/
- .cache/pip
- .cache/poetry
- node_modules/
include:
- local: '/gitlab-ci/build.yml'
- local: '/gitlab-ci/test.yml'
- local: '/gitlab-ci/lint.yml'
- local: '/gitlab-ci/release.yml'
- local: '/gitlab-ci/deploy.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,53 @@
# Changelog # Changelog
## 0.5.3
- Apply query optimizations for retrieving posts
## 0.5.2
- Add missing `VERSION` environment variable
## 0.5.1
- Use line-through styling for read posts
- Use full height for post layout
## 0.5.0
- Upgrade python to 3.11
- Upgrade django to 4.2
- Migrate from pip-tools to uv
- Migrate from black to ruff for formatting
- Upgrade webpack to 5.9 (with various tooling)
- Styling refactor
- Mobile/tablet layout added
## 0.4.4
- Sort posts before storing in redux store
## 0.4.3
- Use `IntersectionObserver` to paginate
## 0.4.2
- Set `SECURE_PROXY_SSL_HEADER` setting for production
## 0.4.1
- Add missing env variables
## 0.4.0
- Add Makefile & use `pip-tools` to generate dependencies
- Add `pyproject.toml`
- Update dependencies
- Update docker-compose setup
- Default to `newsreader.conf.docker` settings module
- Add scroll to top/bottom buttons
## 0.3.13.8 ## 0.3.13.8
- Update dependencies - Update dependencies

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

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

5
bin/docker-entrypoint.sh Executable file
View file

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

View file

@ -0,0 +1,21 @@
upstream gunicorn {
server django:8000;
}
server {
listen 80;
server_name localhost;
access_log /var/log/nginx/access_log;
error_log /var/log/nginx/error_log;
location /static/ {
root /app;
}
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_pass http://gunicorn;
}
}

View file

@ -0,0 +1,36 @@
volumes:
static-files:
services:
django:
build: &app-development-build
target: development
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:
- ./src:/app/src
- static-files:/app/src/newsreader/static
stdin_open: true
tty: true
celery:
build:
<<: *app-development-build
environment:
<<: *django-env
volumes:
- ./src/:/app/src
webpack:
build:
target: frontend-build
context: .
args:
BUILD_ARG: "dev"
command: npm run build:watch
volumes:
- ./src/:/app/src
- static-files:/app/src/newsreader/static

View file

@ -0,0 +1,16 @@
volumes:
logs:
static-files:
services:
nginx:
image: nginx:1.23
depends_on:
django:
condition: service_healthy
ports:
- "${NGINX_HTTP_PORT:-80}:80"
volumes:
- ./config/nginx/conf.d:/etc/nginx/conf.d
- logs:/var/log/nginx
- static-files:/app/static

View file

@ -1,62 +1,126 @@
version: "3"
volumes: volumes:
logs:
media:
postgres-data: postgres-data:
static-files: static-files:
node-modules:
x-db-connection-env: &db-connection-env
POSTGRES_HOST: ${POSTGRES_HOST:-db}
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
POSTGRES_DB: &pg-database ${POSTGRES_DB:-newsreader}
POSTGRES_USER: &pg-user ${POSTGRES_USER:-newsreader}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-newsreader}
x-db-env: &db-env
<<: *db-connection-env
PGUSER: *pg-user
PGDATABASE: *pg-database
x-django-env: &django-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_HOST:-localhost}
EMAIL_PORT: ${EMAIL_PORT:-25}
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-""}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-""}
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-no}
EMAIL_USE_SSL: ${EMAIL_USE_SSL:-no}
EMAIL_DEFAULT_FROM: ${EMAIL_DEFAULT_FROM:-webmaster@localhost}
# Sentry
SENTRY_DSN: ${SENTRY_DSN:-""}
services: services:
db: db:
image: postgres
environment: environment:
POSTGRES_DB: "newsreader" <<: *db-env
POSTGRES_USER: "newsreader" image: postgres:15
POSTGRES_PASSWORD: "newsreader" healthcheck:
test: /usr/bin/pg_isready
start_period: 10s
interval: 5s
timeout: 10s
retries: 10
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
rabbitmq: rabbitmq:
image: rabbitmq:3.7 image: rabbitmq:4
memcached: memcached:
image: memcached:1.6 image: memcached:1.6
ports:
- "11211:11211"
entrypoint: entrypoint:
- memcached - memcached
- -m 64 - -m 64
django:
build: &app-build
context: .
target: production
environment:
<<: *django-env
entrypoint: ["/bin/sh", "/app/bin/docker-entrypoint.sh"]
command: |
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
start_period: 10s
interval: 10s
timeout: 10s
retries: 5
depends_on:
memcached:
condition: service_started
db:
condition: service_healthy
volumes:
- logs:/app/logs
- media:/app/media
- static-files:/app/static
celery: celery:
build: build:
context: . <<: *app-build
dockerfile: ./docker/django
command: celery worker -n worker1@%h -n worker2@%h --app newsreader --loglevel INFO --concurrency 2 --workdir /app/src/ --beat --scheduler django
environment: environment:
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker <<: *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: depends_on:
- rabbitmq rabbitmq:
- memcached condition: service_started
django:
condition: service_healthy
volumes: volumes:
- .:/app - logs:/app/logs
django:
build:
context: .
dockerfile: ./docker/django
command: python /app/src/manage.py runserver 0.0.0.0:8000
environment:
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
ports:
- "8000:8000"
depends_on:
- db
- memcached
volumes:
- .:/app
- static-files:/app/src/newsreader/static
stdin_open: true
tty: true
webpack:
build:
context: .
dockerfile: ./docker/webpack
command: npm run build:watch
volumes:
- .:/app
- static-files:/app/src/newsreader/static
- node-modules:/app/node_modules

View file

@ -1,10 +0,0 @@
FROM python:3.9-slim
RUN pip install poetry
WORKDIR /app
COPY poetry.lock pyproject.toml /app/
RUN poetry config virtualenvs.create false && poetry install --no-interaction --extras sentry
COPY . /app/

View file

@ -1,9 +0,0 @@
FROM node:12
WORKDIR /app
COPY package.json package-lock.json /app/
RUN npm install
COPY . /app/

View file

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

View file

@ -1,22 +0,0 @@
deploy:
stage: deploy
image: python:3.7
environment:
name: production
url: rss.fudiggity.nl
rules:
- if: $CI_COMMIT_TAG
before_script:
- pip install ansible --quiet
- git clone https://git.fudiggity.nl/ansible/newsreader.git deployment --branch master
- cd deployment
- ansible-galaxy install -r requirements.yml
- mkdir /root/.ssh && echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts
- echo "$DEPLOY_KEY" > deploy_key && chmod 0600 deploy_key
- echo "$VAULT_PASSWORD" > vault
script:
- >
ansible-playbook playbook.yml
--private-key deploy_key
--vault-password-file vault
--extra-vars "app_branch=$CI_COMMIT_TAG"

View file

@ -1,28 +0,0 @@
python-linting:
stage: lint
image: python:3.7
before_script:
- pip install poetry --quiet
- poetry config cache-dir ~/.cache/poetry
- poetry config virtualenvs.in-project true
- poetry install --no-interaction --quiet
script:
- poetry run isort src/ --check-only --recursive
- poetry run black src/ --line-length 88 --check
- poetry run autoflake src/ --check --recursive --remove-all-unused-imports --ignore-init-module-imports
only:
refs:
- development
- merge_requests
javascript-linting:
stage: lint
image: node:12
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,24 +0,0 @@
python-tests:
stage: test
coverage: '/TOTAL\s+\d+\s+\d+\s+(\d+%)/'
services:
- postgres:11
- memcached:1.5.22
image: python:3.7
before_script:
- pip install poetry --quiet
- poetry --version
- poetry config cache-dir .cache/poetry
- poetry config virtualenvs.in-project true
- poetry install --no-interaction --extras sentry
script:
- poetry run coverage run src/manage.py test newsreader
- poetry run coverage report
javascript-tests:
stage: test
image: node:12
before_script:
- npm install
script:
- npm test

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

18061
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

1429
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,47 +1,81 @@
[tool.poetry] [project]
name = "newsreader" name = "newsreader"
version = "0.3.13.8" version = "0.5.3"
description = "Webapplication for reading RSS feeds" authors = [{ name = "Sonny" }]
authors = ["Sonny <sonnyba871@gmail.com>"] license = { text = "GPL-3.0" }
license = "GPL-3.0" requires-python = ">=3.11"
dependencies = [
"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",
]
[tool.poetry.dependencies] [dependency-groups]
python = "^3.7" test-tools = ["ruff", "factory_boy", "freezegun"]
bleach = "^3.1.4" development = [
Django = "^3.2" "django-debug-toolbar",
celery = "^4.4.2" "django-stubs",
beautifulsoup4 = "^4.9.0" "django-extensions",
django-axes = "^5.3.1" ]
django-celery-beat = "^2.0.0" ci = ["coverage~=7.6.1"]
djangorestframework = "^3.11.0" production = ["gunicorn~=23.0"]
drf-yasg = "^1.17.1"
django-registration-redux = "^2.7"
lxml = "^4.5.0"
feedparser = "^6.0.8"
python-memcached = "^1.59"
requests = "^2.23.0"
psycopg2-binary = "^2.8.5"
gunicorn = "^20.0.4"
python-dotenv = "^0.12.0"
sentry-sdk = {version = "^1.0.0", optional = true}
ftfy = "^5.8"
requests_oauthlib = "^1.3.0"
django-two-factor-auth = {extras = ["phonenumberslite"], version = "^1.13.1"}
[tool.poetry.extras] [project.optional-dependencies]
sentry = ["sentry_sdk"] sentry = ["sentry-sdk~=2.0"]
[tool.poetry.dev-dependencies] [tool.uv]
factory-boy = "^2.12.0" environments = ["sys_platform == 'linux'"]
freezegun = "^0.3.15" default-groups = ["test-tools"]
django-debug-toolbar = "^2.2"
django-extensions = "^2.2.9"
black = "19.3b0"
isort = "4.3.21"
autoflake = "1.3.1"
tblib = "1.6.0"
coverage = "^5.1"
[build-system] [tool.ruff]
requires = ["poetry>=1.0.10"] include = ["pyproject.toml", "src/**/*.py"]
build-backend = "poetry.masonry.api"
line-length = 88
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I"]
[tool.ruff.lint.isort]
lines-between-types=1
lines-after-imports=2
default-section = "third-party"
known-first-party = ["newsreader"]
section-order = [
"future",
"standard-library",
"django",
"third-party",
"first-party",
"local-folder",
]
[tool.ruff.lint.isort.sections]
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"
]

View file

@ -1,11 +1,12 @@
#!/usr/bin/env python #!/usr/bin/env python
"""Django's command-line utility for administrative tasks.""" """Django's command-line utility for administrative tasks."""
import os import os
import sys import sys
def main(): def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.docker")
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError as exc: except ImportError as exc:

View file

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

View file

@ -8,7 +8,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [

View file

@ -4,7 +4,6 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [("accounts", "0001_initial")] dependencies = [("accounts", "0001_initial")]
operations = [migrations.RemoveField(model_name="user", name="username")] operations = [migrations.RemoveField(model_name="user", name="username")]

View file

@ -6,7 +6,6 @@ import newsreader.accounts.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [("accounts", "0002_remove_user_username")] dependencies = [("accounts", "0002_remove_user_username")]
operations = [ operations = [

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [("accounts", "0003_auto_20190714_1417")] dependencies = [("accounts", "0003_auto_20190714_1417")]
operations = [ operations = [

View file

@ -4,7 +4,6 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [("accounts", "0004_auto_20190714_1501")] dependencies = [("accounts", "0004_auto_20190714_1501")]
operations = [migrations.RemoveField(model_name="user", name="task_interval")] operations = [migrations.RemoveField(model_name="user", name="task_interval")]

View file

@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [("accounts", "0005_remove_user_task_interval")] dependencies = [("accounts", "0005_remove_user_task_interval")]
operations = [ operations = [

View file

@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [("accounts", "0006_auto_20191116_1253")] dependencies = [("accounts", "0006_auto_20191116_1253")]
operations = [ operations = [

View file

@ -15,7 +15,6 @@ def update_task_name(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [("accounts", "0007_auto_20191116_1255")] dependencies = [("accounts", "0007_auto_20191116_1255")]
operations = [migrations.RunPython(update_task_name)] operations = [migrations.RunPython(update_task_name)]

View file

@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("django_celery_beat", "0012_periodictask_expire_seconds"), ("django_celery_beat", "0012_periodictask_expire_seconds"),
("accounts", "0008_auto_20200422_2243"), ("accounts", "0008_auto_20200422_2243"),

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [("accounts", "0009_auto_20200524_1218")] dependencies = [("accounts", "0009_auto_20200524_1218")]
operations = [ operations = [

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [("accounts", "0010_auto_20200603_2230")] dependencies = [("accounts", "0010_auto_20200603_2230")]
operations = [ operations = [

View file

@ -4,7 +4,6 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [("accounts", "0011_auto_20200913_2101")] dependencies = [("accounts", "0011_auto_20200913_2101")]
operations = [migrations.RemoveField(model_name="user", name="task")] operations = [migrations.RemoveField(model_name="user", name="task")]

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [("accounts", "0012_remove_user_task")] dependencies = [("accounts", "0012_remove_user_task")]
operations = [ operations = [

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [("accounts", "0013_user_auto_mark_read")] dependencies = [("accounts", "0013_user_auto_mark_read")]
operations = [ operations = [

View file

@ -4,7 +4,6 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [("accounts", "0014_auto_20201218_2216")] dependencies = [("accounts", "0014_auto_20201218_2216")]
operations = [ operations = [

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [("accounts", "0015_auto_20201219_1330")] dependencies = [("accounts", "0015_auto_20201219_1330")]
operations = [ operations = [

View file

@ -0,0 +1,20 @@
# Generated by Django 3.2.25 on 2024-09-06 07:14
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0016_alter_user_first_name"),
]
operations = [
migrations.RemoveField(
model_name="user",
name="twitter_oauth_token",
),
migrations.RemoveField(
model_name="user",
name="twitter_oauth_token_secret",
),
]

View file

@ -0,0 +1,20 @@
# Generated by Django 4.2.16 on 2025-03-26 08:46
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0017_auto_20240906_0914"),
]
operations = [
migrations.RemoveField(
model_name="user",
name="reddit_access_token",
),
migrations.RemoveField(
model_name="user",
name="reddit_refresh_token",
),
]

View file

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

View file

@ -1,4 +1,4 @@
from rest_framework.permissions import BasePermission, IsAuthenticated from rest_framework.permissions import BasePermission
class IsOwner(BasePermission): class IsOwner(BasePermission):
@ -21,9 +21,3 @@ class IsPostOwner(BasePermission):
return bool(is_category_user and is_rule_user) return bool(is_category_user and is_rule_user)
return is_rule_user return is_rule_user
class TwoFactorAuthenticated(IsAuthenticated):
def has_permission(self, request, view):
is_authenticated = super().has_permission(request, view)
return is_authenticated and request.user.is_verified()

View file

@ -1,76 +1,17 @@
{% extends "components/form/form.html" %} {% extends "components/form/form.html" %}
{% load i18n %} {% load i18n %}
{# TODO incorporate formtools wizard #}
{# TODO add support for other devices and backup tokens #}
{# see two_factor/templates/two_factor/core/login.html #}
{% block intro %}
<div class="form__intro">
{% if wizard.steps.current == 'token' %}
{% if device.method == 'call' %}
<p>
{% blocktrans trimmed %}
We are calling your phone right now, please enter the digits you hear.
{% endblocktrans %}
</p>
{% elif device.method == 'sms' %}
<p>
{% blocktrans trimmed %}
We sent you a text message, please enter the tokens we sent.
{% endblocktrans %}
</p>
{% else %}
<p>
{% blocktrans trimmed %}
Please enter the tokens generated by your token generator.
{% endblocktrans %}
</p>
{% endif %}
{% elif wizard.steps.current == 'backup' %}
<p>
{% blocktrans trimmed %}
Use this form for entering backup tokens for logging in.
These tokens have been generated for you to print and keep safe. Please
enter one of these backup tokens to login to your account.
{% endblocktrans %}
</p>
{% endif %}
</div>
{% endblock intro %}
{# TODO test this #}
{% block fields %}
{{ wizard.management_form }}
{{ block.super }}
{% endblock fields %}
{% block actions %} {% block actions %}
<section class="section form__section--last"> <section class="section form__section--last">
<fieldset class="fieldset form__fieldset"> <fieldset class="fieldset form__fieldset">
{% if cancel_url %} {% include "components/form/cancel-button.html" %}
{% include "components/form/cancel-button.html" %}
{% endif %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}" class="link button">
{% trans "Back" %}
</button>
{% else %}
<button disabled name="" type="button" class="link button">
{% trans "Back" %}
</button>
{% endif %}
{% include "components/form/confirm-button.html" %} {% include "components/form/confirm-button.html" %}
</fieldset> </fieldset>
{% if wizard.steps.index == wizard.steps.first %} <fieldset class="fieldset form__fieldset">
<fieldset class="fieldset form__fieldset"> <a class="link" href="{% url 'accounts:password-reset' %}">
<a class="link" href="{% url 'accounts:password-reset' %}"> <small class="small">{% trans "I forgot my password" %}</small>
<small class="small">{% trans "I forgot my password" %}</small> </a>
</a> </fieldset>
</fieldset>
{% endif %}
</section> </section>
{% endblock actions %} {% endblock actions %}

View file

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

View file

@ -1,70 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<main id="integrations--page" class="main">
<section class="section">
{% include "components/header/header.html" with title="Integrations" only %}
<div class="integrations">
<h3 class="integrations__title">Reddit</h3>
<div class="integrations__controls">
{% if reddit_authorization_url %}
<a class="link button button--reddit" href="{{ reddit_authorization_url }}">
{% trans "Authorize account" %}
</a>
{% else %}
<button class="button button--reddit button--disabled" disabled>
{% trans "Authorize account" %}
</button>
{% endif %}
{% if reddit_refresh_url %}
<a class="link button button--reddit" href="{{ reddit_refresh_url }}">
{% trans "Refresh token" %}
</a>
{% else %}
<button class="button button--reddit button--disabled" disabled>
{% trans "Refresh token" %}
</button>
{% endif %}
{% if reddit_revoke_url %}
<a class="link button button--reddit" href="{{ reddit_revoke_url }}">
{% trans "Deauthorize account" %}
</a>
{% else %}
<button class="button button--reddit button--disabled" disabled>
{% trans "Deauthorize account" %}
</button>
{% endif %}
</div>
</div>
<div class="integrations">
<h3 class="integrations__title">Twitter</h3>
<div class="integrations__controls">
{% if twitter_auth_url %}
<a class="link button button--twitter" href="{{ twitter_auth_url }}">
{% trans "Authorize account" %}
</a>
{% else %}
<button class="button button--twitter button--disabled" disabled>
{% trans "Authorize account" %}
</button>
{% endif %}
{% if twitter_revoke_url %}
<a class="link button button--twitter" href="{{ twitter_revoke_url }}">
{% trans "Deauthorize account" %}
</a>
{% else %}
<button class="button button--twitter button--disabled" disabled>
{% trans "Deauthorize account" %}
</button>
{% endif %}
</div>
</div>
</section>
</main>
{% endblock %}

View file

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

View file

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

View file

@ -1,20 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<main id="reddit--page" class="main">
<section class="section text-section">
{% if error %}
<h1 class="h1">{% trans "Reddit authorization failed" %}</h1>
<p>{{ error }}</p>
{% elif access_token and refresh_token %}
<h1 class="h1">{% trans "Reddit account is linked" %}</h1>
<p>{% trans "Your reddit account was successfully linked." %}</p>
{% endif %}
<p>
<a class="link" href="{% url 'accounts:settings:integrations' %}">{% trans "Return to integrations page" %}</a>
</p>
</section>
</main>
{% endblock %}

View file

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

View file

@ -1,20 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<main id="twitter--page" class="main">
<section class="section text-section">
{% if error %}
<h1 class="h1">{% trans "Twitter authorization failed" %}</h1>
<p>{{ error }}</p>
{% elif authorized %}
<h1 class="h1">{% trans "Twitter account is linked" %}</h1>
<p>{% trans "Your Twitter account was successfully linked." %}</p>
{% endif %}
<p>
<a class="link" href="{% url 'accounts:settings:integrations' %}">{% trans "Return to integrations page" %}</a>
</p>
</section>
</main>
{% endblock %}

View file

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

View file

@ -1,99 +0,0 @@
import datetime
from django.conf import settings
from django.test import TestCase
from django.urls import reverse
from django.utils.translation import gettext as _
from registration.models import RegistrationProfile
from newsreader.accounts.models import User
class ActivationTestCase(TestCase):
def setUp(self):
self.register_url = reverse("accounts:register")
self.register_success_url = reverse("accounts:register-complete")
self.success_url = reverse("accounts:activate-complete")
def test_activation(self):
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.register_url, data)
self.assertRedirects(response, self.register_success_url)
register_profile = RegistrationProfile.objects.get()
kwargs = {"activation_key": register_profile.activation_key}
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
self.assertRedirects(response, self.success_url)
def test_expired_key(self):
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.register_url, data)
register_profile = RegistrationProfile.objects.get()
user = register_profile.user
user.date_joined -= datetime.timedelta(days=settings.ACCOUNT_ACTIVATION_DAYS)
user.save()
kwargs = {"activation_key": register_profile.activation_key}
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
self.assertEqual(200, response.status_code)
self.assertContains(response, _("Account activation failed"))
user.refresh_from_db()
self.assertFalse(user.is_active)
def test_invalid_key(self):
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.register_url, data)
self.assertRedirects(response, self.register_success_url)
kwargs = {"activation_key": "not-a-valid-key"}
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
self.assertContains(response, _("Account activation failed"))
user = User.objects.get()
self.assertEquals(user.is_active, False)
def test_activated_key(self):
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.register_url, data)
self.assertRedirects(response, self.register_success_url)
register_profile = RegistrationProfile.objects.get()
kwargs = {"activation_key": register_profile.activation_key}
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
self.assertRedirects(response, self.success_url)
# try this a second time
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
self.assertRedirects(response, self.success_url)

View file

@ -1,537 +0,0 @@
from unittest.mock import Mock, patch
from urllib.parse import urlencode
from uuid import uuid4
from django.core.cache import cache
from django.test import TestCase
from django.urls import reverse
from django.utils.translation import gettext as _
from bs4 import BeautifulSoup
from newsreader.accounts.tests.factories import UserFactory
from newsreader.news.collection.exceptions import (
StreamException,
StreamTooManyException,
)
from newsreader.news.collection.twitter import TWITTER_AUTH_URL
class IntegrationsViewTestCase(TestCase):
def setUp(self):
self.user = UserFactory(email="test@test.nl", password="test")
self.client.force_login(self.user)
self.url = reverse("accounts:settings:integrations")
class RedditIntegrationsTestCase(IntegrationsViewTestCase):
def test_reddit_authorization(self):
self.user.reddit_refresh_token = None
self.user.save()
response = self.client.get(self.url)
soup = BeautifulSoup(response.content, features="lxml")
button = soup.find("a", class_="link button button--reddit")
self.assertEquals(button.text.strip(), "Authorize account")
def test_reddit_refresh_token(self):
self.user.reddit_refresh_token = "jadajadajada"
self.user.reddit_access_token = None
self.user.save()
response = self.client.get(self.url)
soup = BeautifulSoup(response.content, features="lxml")
button = soup.find("a", class_="link button button--reddit")
self.assertEquals(button.text.strip(), "Refresh token")
def test_reddit_revoke(self):
self.user.reddit_refresh_token = "jadajadajada"
self.user.reddit_access_token = None
self.user.save()
response = self.client.get(self.url)
soup = BeautifulSoup(response.content, features="lxml")
buttons = soup.find_all("a", class_="link button button--reddit")
self.assertIn(
"Deauthorize account", [button.text.strip() for button in buttons]
)
class RedditTemplateViewTestCase(TestCase):
def setUp(self):
self.user = UserFactory(email="test@test.nl", password="test")
self.client.force_login(self.user)
self.base_url = reverse("accounts:settings:reddit-template")
self.state = str(uuid4())
self.patch = patch("newsreader.news.collection.reddit.post")
self.mocked_post = self.patch.start()
def tearDown(self):
patch.stopall()
def test_simple(self):
response = self.client.get(self.base_url)
self.assertEquals(response.status_code, 200)
self.assertContains(response, "Return to integrations page")
def test_successful_authorization(self):
self.mocked_post.return_value.json.return_value = {
"access_token": "1001010412",
"refresh_token": "134510143",
}
cache.set(f"{self.user.email}-reddit-auth", self.state)
params = {"state": self.state, "code": "Valid code"}
url = f"{self.base_url}?{urlencode(params)}"
response = self.client.get(url)
self.mocked_post.assert_called_once()
self.assertEquals(response.status_code, 200)
self.assertContains(response, "Your reddit account was successfully linked.")
self.user.refresh_from_db()
self.assertEquals(self.user.reddit_access_token, "1001010412")
self.assertEquals(self.user.reddit_refresh_token, "134510143")
self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), None)
def test_error(self):
params = {"error": "Denied authorization"}
url = f"{self.base_url}?{urlencode(params)}"
response = self.client.get(url)
self.assertEquals(response.status_code, 200)
self.assertContains(response, "Denied authorization")
def test_invalid_state(self):
cache.set(f"{self.user.email}-reddit-auth", str(uuid4()))
params = {"code": "Valid code", "state": "Invalid state"}
url = f"{self.base_url}?{urlencode(params)}"
response = self.client.get(url)
self.assertEquals(response.status_code, 200)
self.assertContains(
response, "The saved state for Reddit authorization did not match"
)
def test_stream_error(self):
self.mocked_post.side_effect = StreamTooManyException
cache.set(f"{self.user.email}-reddit-auth", self.state)
params = {"state": self.state, "code": "Valid code"}
url = f"{self.base_url}?{urlencode(params)}"
response = self.client.get(url)
self.mocked_post.assert_called_once()
self.assertEquals(response.status_code, 200)
self.assertContains(response, "Too many requests")
self.user.refresh_from_db()
self.assertEquals(self.user.reddit_access_token, None)
self.assertEquals(self.user.reddit_refresh_token, None)
self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state)
def test_unexpected_json(self):
self.mocked_post.return_value.json.return_value = {"message": "Happy eastern"}
cache.set(f"{self.user.email}-reddit-auth", self.state)
params = {"state": self.state, "code": "Valid code"}
url = f"{self.base_url}?{urlencode(params)}"
response = self.client.get(url)
self.mocked_post.assert_called_once()
self.assertEquals(response.status_code, 200)
self.assertContains(response, "Access and refresh token not found in response")
self.user.refresh_from_db()
self.assertEquals(self.user.reddit_access_token, None)
self.assertEquals(self.user.reddit_refresh_token, None)
self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state)
class RedditTokenRedirectViewTestCase(TestCase):
def setUp(self):
self.user = UserFactory(email="test@test.nl", password="test")
self.client.force_login(self.user)
self.patch = patch("newsreader.accounts.views.integrations.RedditTokenTask")
self.mocked_task = self.patch.start()
def tearDown(self):
cache.clear()
def test_simple(self):
response = self.client.get(reverse("accounts:settings:reddit-refresh"))
self.assertRedirects(response, reverse("accounts:settings:integrations"))
self.mocked_task.delay.assert_called_once_with(self.user.pk)
self.assertEquals(1, cache.get(f"{self.user.email}-reddit-refresh"))
def test_not_active(self):
cache.set(f"{self.user.email}-reddit-refresh", 1)
response = self.client.get(reverse("accounts:settings:reddit-refresh"))
self.assertRedirects(response, reverse("accounts:settings:integrations"))
self.mocked_task.delay.assert_not_called()
class RedditRevokeRedirectViewTestCase(TestCase):
def setUp(self):
self.user = UserFactory(email="test@test.nl", password="test")
self.client.force_login(self.user)
self.patch = patch("newsreader.accounts.views.integrations.revoke_reddit_token")
self.mocked_revoke = self.patch.start()
def test_simple(self):
self.user.reddit_access_token = "jadajadajada"
self.user.reddit_refresh_token = "jadajadajada"
self.user.save()
self.mocked_revoke.return_value = True
response = self.client.get(reverse("accounts:settings:reddit-revoke"))
self.assertRedirects(response, reverse("accounts:settings:integrations"))
self.mocked_revoke.assert_called_once_with(self.user)
self.user.refresh_from_db()
self.assertEquals(self.user.reddit_access_token, None)
self.assertEquals(self.user.reddit_refresh_token, None)
def test_no_refresh_token(self):
self.user.reddit_refresh_token = None
self.user.save()
response = self.client.get(reverse("accounts:settings:reddit-revoke"))
self.assertRedirects(response, reverse("accounts:settings:integrations"))
self.mocked_revoke.assert_not_called()
def test_unsuccessful_response(self):
self.user.reddit_access_token = "jadajadajada"
self.user.reddit_refresh_token = "jadajadajada"
self.user.save()
self.mocked_revoke.return_value = False
response = self.client.get(reverse("accounts:settings:reddit-revoke"))
self.assertRedirects(response, reverse("accounts:settings:integrations"))
self.user.refresh_from_db()
self.assertEquals(self.user.reddit_access_token, "jadajadajada")
self.assertEquals(self.user.reddit_refresh_token, "jadajadajada")
def test_stream_exception(self):
self.user.reddit_access_token = "jadajadajada"
self.user.reddit_refresh_token = "jadajadajada"
self.user.save()
self.mocked_revoke.side_effect = StreamException
response = self.client.get(reverse("accounts:settings:reddit-revoke"))
self.assertRedirects(response, reverse("accounts:settings:integrations"))
self.user.refresh_from_db()
self.assertEquals(self.user.reddit_access_token, "jadajadajada")
self.assertEquals(self.user.reddit_refresh_token, "jadajadajada")
class TwitterRevokeRedirectView(TestCase):
def setUp(self):
self.user = UserFactory(email="test@test.nl", password="test")
self.client.force_login(self.user)
self.patch = patch("newsreader.accounts.views.integrations.post")
self.mocked_post = self.patch.start()
def tearDown(self):
patch.stopall()
def test_simple(self):
self.user.twitter_oauth_token = "jadajadajada"
self.user.twitter_oauth_token_secret = "jadajadajada"
self.user.save()
response = self.client.get(reverse("accounts:settings:twitter-revoke"))
self.assertRedirects(response, reverse("accounts:settings:integrations"))
self.user.refresh_from_db()
self.assertIsNone(self.user.twitter_oauth_token)
self.assertIsNone(self.user.twitter_oauth_token_secret)
def test_no_authorized_account(self):
self.user.twitter_oauth_token = None
self.user.twitter_oauth_token_secret = None
self.user.save()
response = self.client.get(reverse("accounts:settings:twitter-revoke"))
self.assertRedirects(response, reverse("accounts:settings:integrations"))
self.mocked_post.assert_not_called()
def test_stream_exception(self):
self.user.twitter_oauth_token = "jadajadajada"
self.user.twitter_oauth_token_secret = "jadajadajada"
self.user.save()
self.mocked_post.side_effect = StreamException
response = self.client.get(reverse("accounts:settings:twitter-revoke"))
self.assertRedirects(response, reverse("accounts:settings:integrations"))
self.user.refresh_from_db()
self.assertEquals(self.user.twitter_oauth_token, "jadajadajada")
self.assertEquals(self.user.twitter_oauth_token_secret, "jadajadajada")
class TwitterAuthRedirectViewTestCase(TestCase):
def setUp(self):
self.user = UserFactory(email="test@test.nl", password="test")
self.client.force_login(self.user)
self.patch = patch("newsreader.accounts.views.integrations.post")
self.mocked_post = self.patch.start()
def tearDown(self):
cache.clear()
def test_simple(self):
self.mocked_post.return_value = Mock(
text="oauth_token=foo&oauth_token_secret=bar"
)
response = self.client.get(reverse("accounts:settings:twitter-auth"))
self.assertRedirects(
response,
f"{TWITTER_AUTH_URL}/?oauth_token=foo",
fetch_redirect_response=False,
)
cached_token = cache.get(f"twitter-{self.user.email}-token")
cached_secret = cache.get(f"twitter-{self.user.email}-secret")
self.assertEquals(cached_token, "foo")
self.assertEquals(cached_secret, "bar")
def test_stream_exception(self):
self.mocked_post.side_effect = StreamException
response = self.client.get(reverse("accounts:settings:twitter-auth"))
self.assertRedirects(response, reverse("accounts:settings:integrations"))
cached_token = cache.get(f"twitter-{self.user.email}-token")
cached_secret = cache.get(f"twitter-{self.user.email}-secret")
self.assertIsNone(cached_token)
self.assertIsNone(cached_secret)
def test_unexpected_contents(self):
self.mocked_post.return_value = Mock(text="foo=bar&oauth_token_secret=bar")
response = self.client.get(reverse("accounts:settings:twitter-auth"))
self.assertRedirects(response, reverse("accounts:settings:integrations"))
cached_token = cache.get(f"twitter-{self.user.email}-token")
cached_secret = cache.get(f"twitter-{self.user.email}-secret")
self.assertIsNone(cached_token)
self.assertIsNone(cached_secret)
class TwitterTemplateViewTestCase(TestCase):
def setUp(self):
self.user = UserFactory(email="test@test.nl", password="test")
self.client.force_login(self.user)
self.patch = patch("newsreader.accounts.views.integrations.post")
self.mocked_post = self.patch.start()
def tearDown(self):
cache.clear()
def test_simple(self):
cache.set_many(
{
f"twitter-{self.user.email}-token": "foo",
f"twitter-{self.user.email}-secret": "bar",
}
)
params = {"denied": "", "oauth_token": "foo", "oauth_verifier": "barfoo"}
self.mocked_post.return_value = Mock(
text="oauth_token=realtoken&oauth_token_secret=realsecret"
)
response = self.client.get(
f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}"
)
self.assertContains(response, _("Twitter account is linked"))
self.user.refresh_from_db()
self.assertEquals(self.user.twitter_oauth_token, "realtoken")
self.assertEquals(self.user.twitter_oauth_token_secret, "realsecret")
self.assertIsNone(cache.get(f"twitter-{self.user.email}-token"))
self.assertIsNone(cache.get(f"twitter-{self.user.email}-secret"))
def test_denied(self):
params = {"denied": "true", "oauth_token": "foo", "oauth_verifier": "barfoo"}
response = self.client.get(
f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}"
)
self.assertContains(response, _("Twitter authorization failed"))
self.user.refresh_from_db()
self.assertIsNone(self.user.twitter_oauth_token)
self.assertIsNone(self.user.twitter_oauth_token_secret)
self.mocked_post.assert_not_called()
def test_mismatched_token(self):
cache.set_many(
{
f"twitter-{self.user.email}-token": "foo",
f"twitter-{self.user.email}-secret": "bar",
}
)
params = {"denied": "", "oauth_token": "boo", "oauth_verifier": "barfoo"}
response = self.client.get(
f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}"
)
self.assertContains(response, _("OAuth tokens failed to match"))
self.user.refresh_from_db()
self.assertIsNone(self.user.twitter_oauth_token)
self.assertIsNone(self.user.twitter_oauth_token_secret)
self.mocked_post.assert_not_called()
def test_missing_secret(self):
cache.set_many({f"twitter-{self.user.email}-token": "foo"})
params = {"denied": "", "oauth_token": "foo", "oauth_verifier": "barfoo"}
response = self.client.get(
f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}"
)
self.assertContains(response, _("No matching tokens found for this user"))
self.user.refresh_from_db()
self.assertIsNone(self.user.twitter_oauth_token_secret)
self.mocked_post.assert_not_called()
def test_stream_exception(self):
cache.set_many(
{
f"twitter-{self.user.email}-token": "foo",
f"twitter-{self.user.email}-secret": "bar",
}
)
params = {"denied": "", "oauth_token": "foo", "oauth_verifier": "barfoo"}
self.mocked_post.side_effect = StreamException
response = self.client.get(
f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}"
)
self.assertContains(response, _("Failed requesting access token"))
self.user.refresh_from_db()
self.assertIsNone(self.user.twitter_oauth_token)
self.assertIsNone(self.user.twitter_oauth_token_secret)
self.assertIsNotNone(cache.get(f"twitter-{self.user.email}-token"))
self.assertIsNotNone(cache.get(f"twitter-{self.user.email}-secret"))
def test_unexpected_contents(self):
cache.set_many(
{
f"twitter-{self.user.email}-token": "foo",
f"twitter-{self.user.email}-secret": "bar",
}
)
params = {"denied": "", "oauth_token": "foo", "oauth_verifier": "barfoo"}
self.mocked_post.return_value = Mock(
text="foobar=boo&oauth_token_secret=realsecret"
)
response = self.client.get(
f"{reverse('accounts:settings:twitter-template')}?{urlencode(params)}"
)
self.assertContains(response, _("No credentials found in Twitter response"))
self.user.refresh_from_db()
self.assertIsNone(self.user.twitter_oauth_token)
self.assertIsNone(self.user.twitter_oauth_token_secret)
self.assertIsNotNone(cache.get(f"twitter-{self.user.email}-token"))
self.assertIsNotNone(cache.get(f"twitter-{self.user.email}-secret"))

View file

@ -1,110 +0,0 @@
from django.core import mail
from django.test import TransactionTestCase as TestCase
from django.test.utils import override_settings
from django.urls import reverse
from django.utils.translation import gettext as _
from registration.models import RegistrationProfile
from newsreader.accounts.models import User
from newsreader.accounts.tests.factories import UserFactory
class RegistrationTestCase(TestCase):
def setUp(self):
self.url = reverse("accounts:register")
self.success_url = reverse("accounts:register-complete")
self.disallowed_url = reverse("accounts:register-closed")
def test_simple(self):
response = self.client.get(self.url)
self.assertEquals(response.status_code, 200)
def test_registration(self):
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.url, data)
self.assertRedirects(response, self.success_url)
self.assertEquals(User.objects.count(), 1)
self.assertEquals(RegistrationProfile.objects.count(), 1)
user = User.objects.get()
self.assertEquals(user.is_active, False)
self.assertEquals(len(mail.outbox), 1)
def test_existing_email(self):
UserFactory(email="test@test.com")
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.url, data)
self.assertEquals(response.status_code, 200)
self.assertEquals(User.objects.count(), 1)
self.assertContains(response, _("User with this Email address already exists"))
def test_pending_registration(self):
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.url, data)
self.assertRedirects(response, self.success_url)
self.assertEquals(User.objects.count(), 1)
self.assertEquals(RegistrationProfile.objects.count(), 1)
user = User.objects.get()
self.assertEquals(user.is_active, False)
self.assertEquals(len(mail.outbox), 1)
response = self.client.post(self.url, data)
self.assertEquals(response.status_code, 200)
self.assertContains(response, _("User with this Email address already exists"))
def test_disabled_account(self):
UserFactory(email="test@test.com", is_active=False)
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.url, data)
self.assertEquals(response.status_code, 200)
self.assertEquals(User.objects.count(), 1)
self.assertContains(response, _("User with this Email address already exists"))
@override_settings(REGISTRATION_OPEN=False)
def test_registration_closed(self):
response = self.client.get(self.url)
self.assertRedirects(response, self.disallowed_url)
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.url, data)
self.assertRedirects(response, self.disallowed_url)
self.assertEquals(User.objects.count(), 0)
self.assertEquals(RegistrationProfile.objects.count(), 0)

View file

@ -1,77 +0,0 @@
from django.core import mail
from django.test import TransactionTestCase as TestCase
from django.urls import reverse
from django.utils.translation import gettext as _
from registration.models import RegistrationProfile
from newsreader.accounts.tests.factories import RegistrationProfileFactory, UserFactory
class ResendActivationTestCase(TestCase):
def setUp(self):
self.url = reverse("accounts:activate-resend")
self.success_url = reverse("accounts:activate-complete")
self.register_url = reverse("accounts:register")
def test_simple(self):
response = self.client.get(self.url)
self.assertEquals(response.status_code, 200)
def test_resent_form(self):
data = {
"email": "test@test.com",
"password1": "test12456",
"password2": "test12456",
}
response = self.client.post(self.register_url, data)
register_profile = RegistrationProfile.objects.get()
original_kwargs = {"activation_key": register_profile.activation_key}
response = self.client.post(self.url, {"email": "test@test.com"})
self.assertContains(response, _("We have sent an email to"))
self.assertEquals(len(mail.outbox), 2)
register_profile.refresh_from_db()
kwargs = {"activation_key": register_profile.activation_key}
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
self.assertRedirects(response, self.success_url)
register_profile.refresh_from_db()
user = register_profile.user
self.assertEquals(user.is_active, True)
# test the old activation code
response = self.client.get(reverse("accounts:activate", kwargs=original_kwargs))
self.assertEquals(response.status_code, 200)
self.assertContains(response, _("Account activation failed"))
def test_existing_account(self):
user = UserFactory(is_active=True)
profile = RegistrationProfileFactory(user=user, activated=True)
response = self.client.post(self.url, {"email": user.email})
self.assertEquals(response.status_code, 200)
# default behaviour is to show success page but not send an email
self.assertContains(response, _("We have sent an email to"))
self.assertEquals(len(mail.outbox), 0)
def test_no_account(self):
response = self.client.post(self.url, {"email": "fake@mail.com"})
self.assertEquals(response.status_code, 200)
# default behaviour is to show success page but not send an email
self.assertContains(response, _("We have sent an email to"))
self.assertEquals(len(mail.outbox), 0)

View file

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

View file

@ -1,23 +1,8 @@
from django.contrib.auth.decorators import login_required
from django.urls import include, path from django.urls import include, path
from django_otp.decorators import otp_required
from two_factor.views import (
BackupTokensView,
DisableView,
LoginView,
PhoneDeleteView,
PhoneSetupView,
ProfileView,
QRGeneratorView,
SetupCompleteView,
)
from newsreader.accounts.views import ( from newsreader.accounts.views import (
ActivationCompleteView,
ActivationResendView,
ActivationView,
FaviconRedirectView, FaviconRedirectView,
IntegrationsView,
LoginView, LoginView,
LogoutView, LogoutView,
PasswordChangeView, PasswordChangeView,
@ -25,103 +10,20 @@ from newsreader.accounts.views import (
PasswordResetConfirmView, PasswordResetConfirmView,
PasswordResetDoneView, PasswordResetDoneView,
PasswordResetView, PasswordResetView,
RedditRevokeRedirectView,
RedditTemplateView,
RedditTokenRedirectView,
RegistrationClosedView,
RegistrationCompleteView,
RegistrationView,
SettingsView, SettingsView,
SetupView,
TwitterAuthRedirectView,
TwitterRevokeRedirectView,
TwitterTemplateView,
) )
settings_patterns = [ settings_patterns = [
# Integrations
path(
"integrations/reddit/callback/",
otp_required(RedditTemplateView.as_view()),
name="reddit-template",
),
path(
"integrations/reddit/refresh/",
otp_required(RedditTokenRedirectView.as_view()),
name="reddit-refresh",
),
path(
"integrations/reddit/revoke/",
otp_required(RedditRevokeRedirectView.as_view()),
name="reddit-revoke",
),
path(
"integrations/twitter/auth/",
otp_required(TwitterAuthRedirectView.as_view()),
name="twitter-auth",
),
path(
"integrations/twitter/callback/",
otp_required(TwitterTemplateView.as_view()),
name="twitter-template",
),
path(
"integrations/twitter/revoke/",
otp_required(TwitterRevokeRedirectView.as_view()),
name="twitter-revoke",
),
path(
"integrations/", otp_required(IntegrationsView.as_view()), name="integrations"
),
# Misc # Misc
path("favicon/", otp_required(FaviconRedirectView.as_view()), name="favicon"), path("favicon/", login_required(FaviconRedirectView.as_view()), name="favicon"),
path("", otp_required(SettingsView.as_view()), name="home"), path("", login_required(SettingsView.as_view()), name="home"),
]
# permissions are handled through the views itself
two_factor = [
path("accounts/setup/", SetupView.as_view(), name="setup"),
path("accounts/qrcode/", QRGeneratorView.as_view(), name="qr"),
path(
"accounts/setup/complete/", SetupCompleteView.as_view(), name="setup_complete"
),
path("accounts/backup/tokens/", BackupTokensView.as_view(), name="backup_tokens"),
path(
"accounts/backup/phone/register/", PhoneSetupView.as_view(), name="phone_create"
),
path(
"accounts/backup/phone/unregister/<int:pk>/",
PhoneDeleteView.as_view(),
name="phone_delete",
),
path("accounts/profile/", ProfileView.as_view(), name="profile"),
path("accounts/disable/", DisableView.as_view(), name="disable"),
] ]
urlpatterns = [ urlpatterns = [
# Auth # Auth
path("", include((two_factor, "two_factor"))),
path("login/", LoginView.as_view(), name="login"), path("login/", LoginView.as_view(), name="login"),
path("logout/", LogoutView.as_view(), name="logout"), path("logout/", LogoutView.as_view(), name="logout"),
# Register
path("register/", RegistrationView.as_view(), name="register"),
path(
"register/complete/",
RegistrationCompleteView.as_view(),
name="register-complete",
),
path("register/closed/", RegistrationClosedView.as_view(), name="register-closed"),
path(
"activate/complete/", ActivationCompleteView.as_view(), name="activate-complete"
),
path("activate/resend/", ActivationResendView.as_view(), name="activate-resend"),
path(
# This URL should be placed after all activate/ url's (see arg)
"activate/<str:activation_key>/",
ActivationView.as_view(),
name="activate",
),
# Password # Password
path("password-reset/", PasswordResetView.as_view(), name="password-reset"), path("password-reset/", PasswordResetView.as_view(), name="password-reset"),
path( path(
@ -141,7 +43,7 @@ urlpatterns = [
), ),
path( path(
"password-change/", "password-change/",
otp_required(PasswordChangeView.as_view()), login_required(PasswordChangeView.as_view()),
name="password-change", name="password-change",
), ),
# Settings # Settings

View file

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

View file

@ -1,30 +1,13 @@
from django.contrib.auth import views as django_views from django.contrib.auth import views as django_views
from django.shortcuts import redirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from two_factor.views.core import LoginView as TwoFactorLoginView from newsreader.utils.views import NavListMixin
from two_factor.views.core import SetupView as TwoFactorSetupView
class LoginView(TwoFactorLoginView): class LoginView(NavListMixin, django_views.LoginView):
redirect_authenticated_user = True
template_name = "accounts/views/login.html" template_name = "accounts/views/login.html"
success_url = reverse_lazy("index")
def done(self, form_list, **kwargs):
response = super().done(form_list, **kwargs)
user = self.get_user()
if not user.phonedevice_set.exists():
return redirect("accounts:two_factor:setup")
return response
class LogoutView(django_views.LogoutView): class LogoutView(django_views.LogoutView):
next_page = reverse_lazy("accounts:login") next_page = reverse_lazy("accounts:login")
class SetupView(TwoFactorSetupView):
success_url = "accounts:two_factor:setup_complete"
qrcode_url = "accounts:two_factor:qr"

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

View file

@ -1,59 +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
from newsreader.news.collection.reddit import (
get_reddit_access_token,
get_reddit_authorization_url,
)
# 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,13 +4,10 @@ from django.views.generic.edit import FormView, ModelFormMixin
from newsreader.accounts.forms import UserSettingsForm from newsreader.accounts.forms import UserSettingsForm
from newsreader.accounts.models import User from newsreader.accounts.models import User
from newsreader.news.collection.reddit import ( from newsreader.utils.views import NavListMixin
get_reddit_access_token,
get_reddit_authorization_url,
)
class SettingsView(ModelFormMixin, FormView): class SettingsView(NavListMixin, ModelFormMixin, FormView):
template_name = "accounts/views/settings.html" template_name = "accounts/views/settings.html"
success_url = reverse_lazy("accounts:settings:home") success_url = reverse_lazy("accounts:settings:home")
form_class = UserSettingsForm form_class = UserSettingsForm

View file

@ -1,101 +0,0 @@
name: "Rubik"
designer: "Hubert and Fischer, Meir Sadan, Cyreal"
license: "OFL"
category: "SANS_SERIF"
date_added: "2015-07-22"
fonts {
name: "Rubik"
style: "normal"
weight: 300
filename: "Rubik-Light.ttf"
post_script_name: "Rubik-Light"
full_name: "Rubik Light"
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
}
fonts {
name: "Rubik"
style: "italic"
weight: 300
filename: "Rubik-LightItalic.ttf"
post_script_name: "Rubik-LightItalic"
full_name: "Rubik Light Italic"
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
}
fonts {
name: "Rubik"
style: "normal"
weight: 400
filename: "Rubik-Regular.ttf"
post_script_name: "Rubik-Regular"
full_name: "Rubik Regular"
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
}
fonts {
name: "Rubik"
style: "italic"
weight: 400
filename: "Rubik-Italic.ttf"
post_script_name: "Rubik-Italic"
full_name: "Rubik Italic"
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
}
fonts {
name: "Rubik"
style: "normal"
weight: 500
filename: "Rubik-Medium.ttf"
post_script_name: "Rubik-Medium"
full_name: "Rubik Medium"
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
}
fonts {
name: "Rubik"
style: "italic"
weight: 500
filename: "Rubik-MediumItalic.ttf"
post_script_name: "Rubik-MediumItalic"
full_name: "Rubik Medium Italic"
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
}
fonts {
name: "Rubik"
style: "normal"
weight: 700
filename: "Rubik-Bold.ttf"
post_script_name: "Rubik-Bold"
full_name: "Rubik Bold"
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
}
fonts {
name: "Rubik"
style: "italic"
weight: 700
filename: "Rubik-BoldItalic.ttf"
post_script_name: "Rubik-BoldItalic"
full_name: "Rubik Bold Italic"
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
}
fonts {
name: "Rubik"
style: "normal"
weight: 900
filename: "Rubik-Black.ttf"
post_script_name: "Rubik-Black"
full_name: "Rubik Black"
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
}
fonts {
name: "Rubik"
style: "italic"
weight: 900
filename: "Rubik-BlackItalic.ttf"
post_script_name: "Rubik-BlackItalic"
full_name: "Rubik Black Italic"
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
}
subsets: "cyrillic"
subsets: "cyrillic-ext"
subsets: "hebrew"
subsets: "latin"
subsets: "latin-ext"
subsets: "menu"

View file

@ -3,7 +3,7 @@ import os
from celery import Celery from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.docker")
app = Celery("newsreader") app = Celery("newsreader")
app.config_from_object("django.conf:settings", namespace="CELERY") app.config_from_object("django.conf:settings", namespace="CELERY")

View file

@ -1,9 +1,7 @@
import os
from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from newsreader.conf.utils import get_env, get_root_dir
load_dotenv() load_dotenv()
@ -15,16 +13,13 @@ except ImportError:
DjangoIntegration = None DjangoIntegration = None
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent BASE_DIR = get_root_dir()
DJANGO_PROJECT_DIR = os.path.join(BASE_DIR, "src", "newsreader") DJANGO_PROJECT_DIR = BASE_DIR / "src" / "newsreader"
# Quick-start development settings - unsuitable for production DEBUG = False
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
# SECURITY WARNING: don"t run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ["127.0.0.1", "localhost"] ALLOWED_HOSTS = get_env("ALLOWED_HOSTS", split=",", default=["127.0.0.1", "localhost"])
INTERNAL_IPS = ["127.0.0.1", "localhost"] INTERNAL_IPS = get_env("INTERNAL_IPS", split=",", default=["127.0.0.1", "localhost"])
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
@ -37,15 +32,9 @@ INSTALLED_APPS = [
"django.forms", "django.forms",
# third party apps # third party apps
"rest_framework", "rest_framework",
"drf_yasg",
"celery", "celery",
"django_celery_beat", "django_celery_beat",
"registration",
"axes", "axes",
"django_otp",
"django_otp.plugins.otp_static",
"django_otp.plugins.otp_totp",
"two_factor",
# app modules # app modules
"newsreader.accounts", "newsreader.accounts",
"newsreader.utils", "newsreader.utils",
@ -54,6 +43,8 @@ INSTALLED_APPS = [
"newsreader.news.collection", "newsreader.news.collection",
] ]
SECRET_KEY = get_env("DJANGO_SECRET_KEY", default="")
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"axes.backends.AxesBackend", "axes.backends.AxesBackend",
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
@ -65,7 +56,6 @@ MIDDLEWARE = [
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django_otp.middleware.OTPMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"axes.middleware.AxesMiddleware", "axes.middleware.AxesMiddleware",
@ -78,11 +68,10 @@ FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
TEMPLATES = [ TEMPLATES = [
{ {
"BACKEND": "django.template.backends.django.DjangoTemplates", "BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")], "DIRS": [DJANGO_PROJECT_DIR / "templates"],
"APP_DIRS": True, "APP_DIRS": True,
"OPTIONS": { "OPTIONS": {
"context_processors": [ "context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request", "django.template.context_processors.request",
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
@ -93,15 +82,14 @@ TEMPLATES = [
WSGI_APPLICATION = "newsreader.wsgi.application" WSGI_APPLICATION = "newsreader.wsgi.application"
# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.postgresql", "ENGINE": "django.db.backends.postgresql",
"HOST": os.environ.get("POSTGRES_HOST", ""), "HOST": get_env("POSTGRES_HOST", default=""),
"NAME": os.environ.get("POSTGRES_NAME", "newsreader"), "PORT": get_env("POSTGRES_PORT", default=""),
"USER": os.environ.get("POSTGRES_USER"), "NAME": get_env("POSTGRES_DB", default=""),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD"), "USER": get_env("POSTGRES_USER", default=""),
"PASSWORD": get_env("POSTGRES_PASSWORD", default=""),
} }
} }
@ -109,17 +97,15 @@ DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
CACHES = { CACHES = {
"default": { "default": {
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache", "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": "localhost:11211", "LOCATION": "memcached:11211",
}, },
"axes": { "axes": {
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache", "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": "localhost:11211", "LOCATION": "memcached:11211",
}, },
} }
# Logging
# https://docs.djangoproject.com/en/2.2/topics/logging/#configuring-logging
LOGGING = { LOGGING = {
"version": 1, "version": 1,
"disable_existing_loggers": False, "disable_existing_loggers": False,
@ -133,48 +119,46 @@ LOGGING = {
"format": "[{server_time}] {message}", "format": "[{server_time}] {message}",
"style": "{", "style": "{",
}, },
"syslog": {
"class": "logging.Formatter",
"format": "[newsreader] {message}",
"style": "{",
},
}, },
"handlers": { "handlers": {
"console": { "console": {
"level": "INFO", "level": "INFO",
"filters": ["require_debug_true"],
"class": "logging.StreamHandler", "class": "logging.StreamHandler",
"formatter": "timestamped", "formatter": "timestamped",
}, },
"file": {
"level": "DEBUG",
"class": "logging.handlers.RotatingFileHandler",
"filename": BASE_DIR / "logs" / "newsreader.log",
"backupCount": 5,
"maxBytes": 50000000, # 50 mB
"formatter": "timestamped",
},
"celery": { "celery": {
"level": "INFO", "level": "INFO",
"filters": ["require_debug_false"], "class": "logging.handlers.RotatingFileHandler",
"class": "logging.handlers.SysLogHandler", "filename": BASE_DIR / "logs" / "celery.log",
"formatter": "syslog", "backupCount": 5,
"address": "/dev/log", "maxBytes": 50000000, # 50 mB
}, "formatter": "timestamped",
"syslog": {
"level": "ERROR",
"filters": ["require_debug_false"],
"class": "logging.handlers.SysLogHandler",
"formatter": "syslog",
"address": "/dev/log",
}, },
}, },
"loggers": { "loggers": {
"django": {"handlers": ["console", "syslog"], "level": "INFO"}, "django": {"handlers": ["console"], "level": "INFO"},
"django.server": { "django.server": {
"handlers": ["console", "syslog"], "handlers": ["console"],
"level": "INFO", "level": "INFO",
"propagate": False, "propagate": False,
}, },
"celery": {"handlers": ["celery", "console"], "level": "INFO"}, "celery.task": {"handlers": ["console", "celery"], "level": "INFO"},
"newsreader": {"handlers": ["syslog", "console"], "level": "INFO"}, "newsreader": {
"handlers": ["console", "file"],
"level": "DEBUG",
"propagate": False,
},
}, },
} }
# Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
@ -187,11 +171,8 @@ AUTH_PASSWORD_VALIDATORS = [
# Authentication user model # Authentication user model
AUTH_USER_MODEL = "accounts.User" AUTH_USER_MODEL = "accounts.User"
LOGIN_URL = "accounts:login"
LOGIN_REDIRECT_URL = "/" LOGIN_REDIRECT_URL = "/"
# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/
LANGUAGE_CODE = "en-us" LANGUAGE_CODE = "en-us"
TIME_ZONE = "Europe/Amsterdam" TIME_ZONE = "Europe/Amsterdam"
@ -199,37 +180,31 @@ USE_I18N = True
USE_L10N = True USE_L10N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/
STATIC_URL = "/static/" STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, "static") STATIC_ROOT = BASE_DIR / "static"
STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static")] STATICFILES_DIRS = (DJANGO_PROJECT_DIR / "static",)
# https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-STATICFILES_FINDERS
STATICFILES_FINDERS = [ STATICFILES_FINDERS = [
"django.contrib.staticfiles.finders.FileSystemFinder", "django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder", "django.contrib.staticfiles.finders.AppDirectoriesFinder",
] ]
# Email # Email
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
# Project settings DEFAULT_FROM_EMAIL = get_env(
ENVIRONMENT = "development" "EMAIL_DEFAULT_FROM", required=False, default="webmaster@localhost"
# 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/"
) )
# Twitter integration EMAIL_HOST = get_env("EMAIL_HOST", required=False, default="localhost")
TWITTER_CONSUMER_ID = "CONSUMER_ID" EMAIL_PORT = get_env("EMAIL_PORT", cast=int, required=False, default=25)
TWITTER_CONSUMER_SECRET = "CONSUMER_SECRET"
TWITTER_REDIRECT_URL = ( EMAIL_HOST_USER = get_env("EMAIL_HOST_USER", required=False, default="")
"http://127.0.0.1:8000/accounts/settings/integrations/twitter/callback/" EMAIL_HOST_PASSWORD = get_env("EMAIL_HOST_PASSWORD", required=False, default="")
)
EMAIL_USE_TLS = get_env("EMAIL_USE_TLS", required=False, default=False)
EMAIL_USE_SSL = get_env("EMAIL_USE_SSL", required=False, default=False)
# Third party settings # Third party settings
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler" AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
@ -243,10 +218,15 @@ REST_FRAMEWORK = {
"rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.SessionAuthentication",
), ),
"DEFAULT_PERMISSION_CLASSES": ( "DEFAULT_PERMISSION_CLASSES": (
"newsreader.accounts.permissions.TwoFactorAuthenticated", "rest_framework.permissions.IsAuthenticated",
"newsreader.accounts.permissions.IsOwner", "newsreader.accounts.permissions.IsOwner",
), ),
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), "DEFAULT_RENDERER_CLASSES": (
"djangorestframework_camel_case.render.CamelCaseJSONRenderer",
),
"DEFAULT_PARSER_CLASSES": (
"djangorestframework_camel_case.parser.CamelCaseJSONParser",
),
} }
SWAGGER_SETTINGS = { SWAGGER_SETTINGS = {
@ -257,18 +237,14 @@ SWAGGER_SETTINGS = {
# Celery # Celery
# https://docs.celeryproject.org/en/stable/userguide/configuration.html # https://docs.celeryproject.org/en/stable/userguide/configuration.html
# Note that celery settings are prefix with CELERY. See src/newsreader/celery.py.
CELERY_WORKER_HIJACK_ROOT_LOGGER = False CELERY_WORKER_HIJACK_ROOT_LOGGER = False
CELERY_BROKER_URL = "amqp://guest@rabbitmq:5672"
# Registration
REGISTRATION_OPEN = True
REGISTRATION_AUTO_LOGIN = True
ACCOUNT_ACTIVATION_DAYS = 7
# Sentry # Sentry
SENTRY_CONFIG = { SENTRY_CONFIG = {
"dsn": os.environ.get("SENTRY_DSN"), "dsn": get_env("SENTRY_DSN", default="", required=False),
"send_default_pii": False, "send_default_pii": False,
"environment": ENVIRONMENT,
"integrations": [DjangoIntegration(), CeleryIntegration()] "integrations": [DjangoIntegration(), CeleryIntegration()]
if DjangoIntegration and CeleryIntegration if DjangoIntegration and CeleryIntegration
else [], else [],

46
src/newsreader/conf/ci.py Normal file
View file

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

View file

@ -1,16 +1,19 @@
from .base import * # isort:skip from .base import * # noqa: F403
from .version import get_current_version from .utils import get_current_version
LOGGING.update({"loggers": {"two_factor": {"handlers": ["console"], "level": "INFO"}}})
SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl" SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl"
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
"django.template.context_processors.debug",
)
# Project settings # Project settings
VERSION = get_current_version() VERSION = get_current_version()
@ -20,13 +23,13 @@ AXES_FAILURE_LIMIT = 50
AXES_COOLOFF_TIME = None AXES_COOLOFF_TIME = None
try: try:
from .local import * # noqa
# Optionally use sentry integration # Optionally use sentry integration
from sentry_sdk import init as sentry_init from sentry_sdk import init as sentry_init
SENTRY_CONFIG.update({"release": VERSION}) from .local import * # noqa
sentry_init(**SENTRY_CONFIG) SENTRY_CONFIG.update({"release": VERSION}) # noqa: F405
sentry_init(**SENTRY_CONFIG) # noqa: F405
except ImportError: except ImportError:
pass pass

View file

@ -1,37 +1,25 @@
from .base import * # isort:skip from .base import * # noqa: F403
from .version import get_current_version from .utils import get_current_version
LOGGING.update({"loggers": {"two_factor": {"handlers": ["console"], "level": "INFO"}}}) DEBUG = True
SECRET_KEY = "=q(ztyo)b6noom#a164g&s9vcj1aawa^g#ing_ir99=_zl4g&$" INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] LOGGING["loggers"].update( # noqa: F405
{
"celery.task": {"handlers": ["console", "celery"], "level": "DEBUG"},
}
)
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "newsreader",
"USER": "newsreader",
"PASSWORD": "newsreader",
"HOST": "db",
}
}
CACHES = { TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
"default": { "django.template.context_processors.debug",
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache", )
"LOCATION": "memcached:11211",
},
"axes": {
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": "memcached:11211",
},
}
# Project settings # Project settings
VERSION = get_current_version() VERSION = get_current_version()
@ -42,18 +30,14 @@ ENVIRONMENT = "docker"
AXES_FAILURE_LIMIT = 50 AXES_FAILURE_LIMIT = 50
AXES_COOLOFF_TIME = None AXES_COOLOFF_TIME = None
# Celery
# https://docs.celeryproject.org/en/latest/userguide/configuration.html
CELERY_BROKER_URL = "amqp://guest:guest@rabbitmq:5672//"
try: try:
from .local import * # noqa
# Optionally use sentry integration # Optionally use sentry integration
from sentry_sdk import init as sentry_init from sentry_sdk import init as sentry_init
SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT}) from .local import * # noqa
sentry_init(**SENTRY_CONFIG) SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT}) # noqa: F405
sentry_init(**SENTRY_CONFIG) # noqa: F405
except ImportError: except ImportError:
pass pass

View file

@ -1,34 +0,0 @@
from .base import * # isort:skip
from .version import get_current_version
SECRET_KEY = "29%lkw+&n%^w4k#@_db2mo%*tc&xzb)x7xuq*(0$eucii%4r0c"
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
AXES_ENABLED = False
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": "memcached:11211",
},
"axes": {
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": "memcached:11211",
},
}
# Project settings
VERSION = get_current_version()
ENVIRONMENT = "gitlab"
try:
# Optionally use sentry integration
from sentry_sdk import init as sentry_init
SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT})
sentry_init(**SENTRY_CONFIG)
except ImportError:
pass

View file

@ -1,87 +1,32 @@
import os from newsreader.conf.utils import get_env
from .version import get_current_version from .base import * # noqa: F403
from .utils import get_current_version
from .base import * # isort:skip
DEBUG = False DEBUG = False
ALLOWED_HOSTS = ["rss.fudiggity.nl"] SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
ADMINS = [ ADMINS = [
("", email) ("", email) for email in get_env("ADMINS", split=",", required=False, default=[])
for email in os.getenv("ADMINS", "").split(",")
if os.environ.get("ADMINS")
] ]
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"HOST": os.environ["POSTGRES_HOST"],
"PORT": os.environ["POSTGRES_PORT"],
"NAME": os.environ["POSTGRES_NAME"],
"USER": os.environ["POSTGRES_USER"],
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
}
}
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]
},
}
]
# Email
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
DEFAULT_FROM_EMAIL = os.environ.get("EMAIL_DEFAULT_FROM", "webmaster@localhost")
EMAIL_HOST = os.environ.get("EMAIL_HOST", "localhost")
EMAIL_PORT = os.environ.get("EMAIL_PORT", 25)
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "")
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "")
EMAIL_USE_TLS = bool(os.environ.get("EMAIL_USE_TLS"))
EMAIL_USE_SSL = bool(os.environ.get("EMAIL_USE_SSL"))
# Project settings # Project settings
VERSION = get_current_version(debug=False) VERSION = get_current_version(debug=False)
ENVIRONMENT = "production" ENVIRONMENT = "production"
# Reddit integration
REDDIT_CLIENT_ID = os.environ.get("REDDIT_CLIENT_ID", "")
REDDIT_CLIENT_SECRET = os.environ.get("REDDIT_CLIENT_SECRET", "")
REDDIT_REDIRECT_URL = os.environ.get("REDDIT_CALLBACK_URL", "")
# Twitter integration
TWITTER_CONSUMER_ID = os.environ.get("TWITTER_CONSUMER_ID", "")
TWITTER_CONSUMER_SECRET = os.environ.get("TWITTER_CONSUMER_SECRET", "")
TWITTER_REDIRECT_URL = os.environ.get("TWITTER_REDIRECT_URL", "")
# Third party settings # Third party settings
AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler" AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler"
REGISTRATION_OPEN = False
# Optionally use sentry integration # Optionally use sentry integration
try: try:
from sentry_sdk import init as sentry_init from sentry_sdk import init as sentry_init
SENTRY_CONFIG.update( SENTRY_CONFIG.update( # noqa: F405
{"release": VERSION, "environment": ENVIRONMENT, "debug": False} {"release": VERSION, "environment": ENVIRONMENT, "debug": False}
) )
sentry_init(**SENTRY_CONFIG) sentry_init(**SENTRY_CONFIG) # noqa: F405
except ImportError: except ImportError:
pass pass

View file

@ -0,0 +1,85 @@
import logging
import os
import subprocess
from pathlib import Path
from typing import Any, Iterable, Type
logger = logging.getLogger(__name__)
def get_env(
name: str,
cast: Type = str,
required: bool = True,
default: Any = None,
split: str = "",
) -> Any:
if cast is not str and split:
raise TypeError(f"Split is not possible with {cast}")
value = os.getenv(name)
if not value:
if required:
logger.warning(f"Missing environment variable: {name}")
return default
bool_mapping = {"yes": True, "true": True, "false": False, "no": False}
if cast is bool:
_value = bool_mapping.get(value.lower())
if not value:
raise ValueError(f"Unknown boolean value: {_value}")
return _value
value = value if not cast else cast(value)
return value if not split else value.split(split)
def get_current_version(debug: bool = True) -> str:
version = get_env("VERSION", required=False)
if version:
return version
if debug:
try:
output = subprocess.check_output(
["git", "show", "--no-patch", "--format=%H"], universal_newlines=True
)
return output.strip()
except (subprocess.CalledProcessError, OSError):
return ""
try:
output = subprocess.check_output(
["git", "describe", "--tags"], universal_newlines=True
)
return output.strip()
except (subprocess.CalledProcessError, OSError):
return ""
ROOT_MARKERS = ("pyproject.toml", "package.json", "README.md", "CHANGELOG.md")
def get_root_dir() -> Path:
file = Path(__file__)
return _traverse_dirs(file.parent, ROOT_MARKERS)
def _traverse_dirs(path: Path, root_markers: Iterable[str]) -> Path:
if path.parent == path:
raise OSError("Root directory detected")
files = (file.name for file in path.iterdir())
if not any((marker for marker in root_markers if marker in files)):
return _traverse_dirs(path.parent, root_markers)
return path

View file

@ -1,24 +0,0 @@
import os
import subprocess
def get_current_version(debug=True):
if "VERSION" in os.environ:
return os.environ["VERSION"]
if debug:
try:
output = subprocess.check_output(
["git", "show", "--no-patch", "--format=%H"], universal_newlines=True
)
return output.strip()
except (subprocess.CalledProcessError, OSError):
return ""
try:
output = subprocess.check_output(
["git", "describe", "--tags"], universal_newlines=True
)
return output.strip()
except (subprocess.CalledProcessError, OSError):
return ""

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,22 @@
import React from 'react';
class NavList extends React.Component {
render() {
const entries = Object.entries(this.props.navLinks);
const links = entries.map(([name, link], index) => {
return (
<li key={index} className="nav-list__item">
<a href={link}>{name}</a>
</li>
);
});
const className = this.props.includeBorder
? 'nav-list nav-list--bordered'
: 'nav-list';
return <ol className={className}>{links}</ol>;
}
}
export default NavList;

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