Compare commits
No commits in common. "main" and "0.3.1" have entirely different histories.
355 changed files with 33507 additions and 16115 deletions
11
.babelrc
Normal file
11
.babelrc
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"presets": ["@babel/preset-env"],
|
||||
"plugins": [
|
||||
"@babel/plugin-transform-runtime",
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
"@babel/plugin-transform-react-jsx",
|
||||
"@babel/plugin-syntax-function-bind",
|
||||
"@babel/plugin-proposal-function-bind",
|
||||
["@babel/plugin-proposal-class-properties", {loose: true}],
|
||||
]
|
||||
}
|
||||
16
.coveragerc
Normal file
16
.coveragerc
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[run]
|
||||
source = ./src/newsreader/
|
||||
omit =
|
||||
**/tests/**
|
||||
**/migrations/**
|
||||
**/conf/**
|
||||
**/apps.py
|
||||
**/admin.py
|
||||
**/tests.py
|
||||
**/urls.py
|
||||
**/wsgi.py
|
||||
**/celery.py
|
||||
**/__init__.py
|
||||
|
||||
[html]
|
||||
directory = coverage
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
# https://editorconfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# Unix-style newlines with a newline ending every file
|
||||
[*]
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.py]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{yaml,yml,toml,md}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[Dockerfile*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.json]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -35,7 +35,6 @@ eggs/
|
|||
|
||||
lib/
|
||||
!src/newsreader/scss/lib
|
||||
!src/newsreader/js/lib
|
||||
|
||||
lib64/
|
||||
parts/
|
||||
|
|
@ -115,7 +114,7 @@ celerybeat-schedule
|
|||
*.sage.py
|
||||
|
||||
# Environments
|
||||
*.env
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
|
|
|
|||
30
.gitlab-ci.yml
Normal file
30
.gitlab-ci.yml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
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'
|
||||
12
.isort.cfg
Normal file
12
.isort.cfg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[settings]
|
||||
include_trailing_comma = true
|
||||
line_length = 88
|
||||
multi_line_output = 3
|
||||
skip = env/, venv/
|
||||
default_section = THIRDPARTY
|
||||
known_first_party = newsreader
|
||||
known_django = django
|
||||
sections = FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
|
||||
lines_between_types=1
|
||||
lines_after_imports=2
|
||||
lines_between_types=1
|
||||
1
.nvmrc
1
.nvmrc
|
|
@ -1 +0,0 @@
|
|||
lts/*
|
||||
10
.prettierrc.json
Normal file
10
.prettierrc.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 90,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
when:
|
||||
- event: push
|
||||
- event: pull_request
|
||||
- event: manual
|
||||
|
||||
steps:
|
||||
- image: node:lts-alpine
|
||||
commands:
|
||||
- npm install
|
||||
- npm run build:prod
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
when:
|
||||
- event: push
|
||||
- event: pull_request
|
||||
- event: manual
|
||||
|
||||
steps:
|
||||
- name: python linting
|
||||
image: ghcr.io/astral-sh/uv:python3.11-alpine
|
||||
commands:
|
||||
- uv sync --group ci
|
||||
- uv run --no-sync -- ruff check src/
|
||||
- uv run --no-sync -- ruff format --check src/
|
||||
|
||||
- name: javascript linting
|
||||
image: node:lts-alpine
|
||||
commands:
|
||||
- npm ci
|
||||
- npm run lint
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
when:
|
||||
- event: push
|
||||
- event: pull_request
|
||||
- event: manual
|
||||
|
||||
services:
|
||||
- name: postgres
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_NAME: &db-name newsreader
|
||||
POSTGRES_USER: &db-user newsreader
|
||||
POSTGRES_PASSWORD: &db-password sekrit
|
||||
- name: memcached
|
||||
image: memcached:1.5.22
|
||||
|
||||
steps:
|
||||
- name: python tests
|
||||
image: ghcr.io/astral-sh/uv:python3.11-alpine
|
||||
environment:
|
||||
DJANGO_SETTINGS_MODULE: "newsreader.conf.ci"
|
||||
DJANGO_SECRET_KEY: sekrit
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: 5432
|
||||
POSTGRES_DB: *db-name
|
||||
POSTGRES_USER: *db-user
|
||||
POSTGRES_PASSWORD: *db-password
|
||||
commands:
|
||||
- pip install uv
|
||||
- uv sync --group ci
|
||||
- uv run --no-sync -- coverage run ./src/manage.py test newsreader
|
||||
- uv run --no-sync -- coverage report --show-missing
|
||||
|
||||
- name: javascript tests
|
||||
image: node:lts-alpine
|
||||
commands:
|
||||
- npm ci
|
||||
- npm test
|
||||
149
CHANGELOG.md
149
CHANGELOG.md
|
|
@ -1,149 +0,0 @@
|
|||
# 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
|
||||
|
||||
- Update dependencies
|
||||
- Fix csrf_token's not rendering
|
||||
|
||||
## 0.3.13.7
|
||||
|
||||
- Check for Twitter error codes in response
|
||||
|
||||
## 0.3.13.6
|
||||
|
||||
- Try to load sentry by default for all environments
|
||||
|
||||
## 0.3.13.5
|
||||
|
||||
- Set response keyword argument
|
||||
|
||||
## 0.3.13.4
|
||||
|
||||
- Fix import error
|
||||
|
||||
## 0.3.13.3
|
||||
|
||||
- Use sentry's set_extra to provide extra debug variables
|
||||
|
||||
## 0.3.13.2
|
||||
|
||||
- Update sentry-sdk
|
||||
|
||||
## 0.3.13.1
|
||||
|
||||
- Fix mutual exclusive exception for email settings
|
||||
- Temporarly set exception level for StreamDeniedException exceptions
|
||||
|
||||
## 0.3.13
|
||||
|
||||
- Update django to 3.2
|
||||
- Notify users of expired credentials
|
||||
|
||||
## 0.3.12.1
|
||||
|
||||
- Add missing background-color
|
||||
|
||||
## 0.3.12
|
||||
|
||||
- Update light theme
|
||||
- Sticky navbar
|
||||
- Sticky post modal header
|
||||
|
||||
## 0.3.11
|
||||
|
||||
- Add saved posts section
|
||||
- Bump django version
|
||||
|
||||
## 0.3.10
|
||||
|
||||
- Add custom color for confirm buttons
|
||||
- Update font sizes
|
||||
|
||||
## 0.3.9
|
||||
|
||||
- Cursor based pagination
|
||||
- Updated django version
|
||||
|
||||
## 0.3.8
|
||||
|
||||
- Update light / dark theme
|
||||
- Replace css.gg with fontawesome
|
||||
- Update deploy job
|
||||
|
||||
## 0.3.7
|
||||
|
||||
- Add a dark theme
|
||||
- Update object representations
|
||||
- Move sentry to optional dependency
|
||||
- Add CHANGELOG.md
|
||||
|
||||
## 0.3.6.3
|
||||
|
||||
- Update deploy job
|
||||
|
||||
## 0.3.6.2
|
||||
|
||||
- Use warning logging level for BuilderSkippedException's
|
||||
- Change working directory before running ansible
|
||||
|
||||
## 0.3.6.1
|
||||
|
||||
- Install ansible required roles
|
||||
|
||||
## 0.3.6
|
||||
|
||||
- Update deploy job
|
||||
- Add user manageable reddit filters
|
||||
|
||||
## 0.3.5
|
||||
|
||||
- Show timezone next to post datetimes
|
||||
- Take read status in consideration when sorting posts
|
||||
84
Dockerfile
84
Dockerfile
|
|
@ -1,84 +0,0 @@
|
|||
# stage 1
|
||||
FROM python:3.11-alpine AS backend
|
||||
|
||||
ARG USER_ID=1000
|
||||
ARG GROUP_ID=1000
|
||||
ARG UV_LINK_MODE=copy
|
||||
|
||||
RUN apk update \
|
||||
&& apk add --no-cache \
|
||||
vim \
|
||||
curl \
|
||||
gettext
|
||||
|
||||
RUN addgroup -g $USER_ID newsreader && adduser -Du $GROUP_ID -G newsreader newsreader
|
||||
|
||||
RUN mkdir --parents /app/src /app/logs /app/media /app/bin /app/static \
|
||||
&& chown -R newsreader:newsreader /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
USER newsreader
|
||||
|
||||
COPY --chown=newsreader:newsreader uv.lock pyproject.toml /app/
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:python3.11-alpine /usr/local/bin/uv /bin/uv
|
||||
|
||||
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
|
||||
uv sync --frozen --no-default-groups --no-install-project
|
||||
|
||||
COPY --chown=newsreader:newsreader ./bin/docker-entrypoint.sh /app/bin/docker-entrypoint.sh
|
||||
|
||||
VOLUME ["/app/logs", "/app/media", "/app/static"]
|
||||
|
||||
|
||||
|
||||
# stage 2
|
||||
FROM node:lts-alpine AS frontend-build
|
||||
|
||||
ARG BUILD_ARG=prod
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN chown node:node /app
|
||||
|
||||
USER node
|
||||
|
||||
COPY --chown=node:node ./package*.json ./webpack.*.js ./babel.config.js /app/
|
||||
|
||||
RUN --mount=type=cache,uid=1000,gid=1000,target=/home/node/.npm \
|
||||
npm ci
|
||||
|
||||
COPY --chown=node:node ./src /app/src
|
||||
|
||||
RUN npm run build:$BUILD_ARG
|
||||
|
||||
|
||||
|
||||
# stage 3
|
||||
FROM backend AS production
|
||||
|
||||
COPY --from=frontend-build --chown=newsreader:newsreader \
|
||||
/app/src/newsreader/static /app/src/newsreader/static
|
||||
|
||||
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
|
||||
uv sync --frozen --only-group production --extra sentry
|
||||
|
||||
COPY --chown=newsreader:newsreader ./src /app/src
|
||||
|
||||
ENV DJANGO_SETTINGS_MODULE=newsreader.conf.production
|
||||
|
||||
# Note that the static volume will have to be recreated to be pre-populated
|
||||
# correctly with the latest static files. See
|
||||
# https://docs.docker.com/storage/volumes/#populate-a-volume-using-a-container
|
||||
RUN uv run --no-sync -- src/manage.py collectstatic --noinput
|
||||
|
||||
|
||||
|
||||
# (optional) stage 4
|
||||
FROM backend AS development
|
||||
|
||||
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
|
||||
uv sync --frozen --group development
|
||||
|
||||
ENV DJANGO_SETTINGS_MODULE=newsreader.conf.docker
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
module.exports = api => {
|
||||
const isTest = api.env('test');
|
||||
|
||||
const preset = [
|
||||
"@babel/preset-env", { targets: 'defaults' }
|
||||
];
|
||||
const testPreset = [
|
||||
"@babel/preset-env", { targets: { node: process.versions.node } }
|
||||
];
|
||||
|
||||
const plugins = [
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
"@babel/plugin-transform-react-jsx",
|
||||
"@babel/plugin-proposal-class-properties"
|
||||
]
|
||||
|
||||
return {
|
||||
"presets": [isTest ? testPreset : preset],
|
||||
"plugins": plugins
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
uv run --no-sync -- /app/src/manage.py migrate
|
||||
|
||||
exec "$@"
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,126 +1,62 @@
|
|||
version: "3"
|
||||
volumes:
|
||||
logs:
|
||||
media:
|
||||
postgres-data:
|
||||
static-files:
|
||||
|
||||
x-db-connection-env: &db-connection-env
|
||||
POSTGRES_HOST: ${POSTGRES_HOST:-db}
|
||||
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
|
||||
POSTGRES_DB: &pg-database ${POSTGRES_DB:-newsreader}
|
||||
POSTGRES_USER: &pg-user ${POSTGRES_USER:-newsreader}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-newsreader}
|
||||
|
||||
x-db-env: &db-env
|
||||
<<: *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:-""}
|
||||
node-modules:
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
environment:
|
||||
<<: *db-env
|
||||
image: postgres:15
|
||||
healthcheck:
|
||||
test: /usr/bin/pg_isready
|
||||
start_period: 10s
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
POSTGRES_DB: "newsreader"
|
||||
POSTGRES_USER: "newsreader"
|
||||
POSTGRES_PASSWORD: "newsreader"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:4
|
||||
|
||||
image: rabbitmq:3.7
|
||||
memcached:
|
||||
image: memcached:1.6
|
||||
ports:
|
||||
- "11211:11211"
|
||||
entrypoint:
|
||||
- memcached
|
||||
- -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:
|
||||
build:
|
||||
<<: *app-build
|
||||
context: .
|
||||
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:
|
||||
<<: *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
|
||||
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
|
||||
depends_on:
|
||||
rabbitmq:
|
||||
condition: service_started
|
||||
django:
|
||||
condition: service_healthy
|
||||
- rabbitmq
|
||||
- memcached
|
||||
volumes:
|
||||
- logs:/app/logs
|
||||
- .:/app
|
||||
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
|
||||
|
|
|
|||
10
docker/django
Normal file
10
docker/django
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
FROM python:3.7-buster
|
||||
|
||||
RUN pip install poetry
|
||||
|
||||
WORKDIR /app
|
||||
COPY poetry.lock pyproject.toml /app/
|
||||
|
||||
RUN poetry config virtualenvs.create false && poetry install --no-interaction
|
||||
|
||||
COPY . /app/
|
||||
9
docker/webpack
Normal file
9
docker/webpack
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
FROM node:12
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json /app/
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . /app/
|
||||
7
gitlab-ci/build.yml
Normal file
7
gitlab-ci/build.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
static:
|
||||
stage: build
|
||||
image: node:12
|
||||
before_script:
|
||||
- npm install
|
||||
script:
|
||||
- npm run build
|
||||
22
gitlab-ci/deploy.yml
Normal file
22
gitlab-ci/deploy.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
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/sonny/ansible-playbooks.git deployment --branch master
|
||||
- mkdir /root/.ssh && echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts
|
||||
- echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key
|
||||
- echo "$VAULT_PASSWORD" > deployment/vault
|
||||
script:
|
||||
- >
|
||||
ansible-playbook deployment/playbook.yml
|
||||
--inventory deployment/apps.yml
|
||||
--limit newsreader
|
||||
--user ansible
|
||||
--private-key deployment/deploy_key
|
||||
--vault-password-file deployment/vault
|
||||
28
gitlab-ci/lint.yml
Normal file
28
gitlab-ci/lint.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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
|
||||
12
gitlab-ci/release.yml
Normal file
12
gitlab-ci/release.yml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
release:
|
||||
stage: release
|
||||
image: registry.gitlab.com/gitlab-org/release-cli:latest
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
script:
|
||||
- echo 'running release job'
|
||||
release:
|
||||
name: 'Release $CI_COMMIT_TAG'
|
||||
description: 'Auto created release'
|
||||
tag_name: '$CI_COMMIT_TAG'
|
||||
ref: '$CI_COMMIT_TAG'
|
||||
23
gitlab-ci/test.yml
Normal file
23
gitlab-ci/test.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
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 config cache-dir .cache/poetry
|
||||
- poetry config virtualenvs.in-project true
|
||||
- poetry install --no-interaction --quiet
|
||||
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
|
||||
188
jest.config.js
Normal file
188
jest.config.js
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
// For a detailed explanation regarding each configuration property, visit:
|
||||
// https://jestjs.io/docs/en/configuration.html
|
||||
|
||||
module.exports = {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// Respect "browser" field in package.json when resolving modules
|
||||
// browser: false,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "/tmp/jest_rs",
|
||||
|
||||
// Automatically clear mock calls and instances between every test
|
||||
clearMocks: true,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
// collectCoverage: false,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
// collectCoverageFrom: null,
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
coverageDirectory: 'coverage',
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// coveragePathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: null,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: null,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: null,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: null,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
// globals: {},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "json",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "node"
|
||||
// ],
|
||||
|
||||
// A map from regular expressions to module names that allow to stub out resources with a single module
|
||||
// moduleNameMapper: {},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
// preset: null,
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: null,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state between every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// resolver: null,
|
||||
|
||||
// Automatically restore mock state between every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
rootDir: 'src/newsreader/js/tests/',
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
// roots: [
|
||||
// "<rootDir>"
|
||||
// ],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: 'node',
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
// testMatch: [
|
||||
// "**/__tests__/**/*.[jt]s?(x)",
|
||||
// "**/?(*.)+(spec|test).[tj]s?(x)"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
// testPathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: null,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jasmine2",
|
||||
|
||||
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
|
||||
// testURL: "http://localhost",
|
||||
|
||||
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
|
||||
// timers: "real",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
// transform: null,
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// transformIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: null,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
};
|
||||
17390
package-lock.json
generated
17390
package-lock.json
generated
File diff suppressed because it is too large
Load diff
74
package.json
74
package.json
|
|
@ -1,75 +1,63 @@
|
|||
{
|
||||
"name": "newsreader",
|
||||
"version": "0.5.3",
|
||||
"version": "0.1.0",
|
||||
"description": "Application for viewing RSS feeds",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"lint": "npx prettier \"src/newsreader/js/**/*.js\" --check",
|
||||
"format": "npx prettier \"src/newsreader/js/**/*.js\" --write",
|
||||
"build": "npx webpack --config webpack.dev.babel.js",
|
||||
"build:watch": "npx webpack --config webpack.dev.babel.js --watch",
|
||||
"build:dev": "npx webpack --config webpack.dev.babel.js",
|
||||
"build:prod": "npx webpack --config webpack.prod.babel.js",
|
||||
"test": "npx jest",
|
||||
"test:watch": "npm test -- --watch"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "forgejo.fudiggity.nl:sonny/newsreader"
|
||||
"url": "[git@git.fudiggity.nl:5000]:sonny/newsreader.git"
|
||||
},
|
||||
"author": "Sonny",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.15.2",
|
||||
"css.gg": "^1.0.6",
|
||||
"js-cookie": "^2.2.1",
|
||||
"lodash": "^4.17.20",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-redux": "^7.2.2",
|
||||
"react-redux": "^7.1.3",
|
||||
"redux": "^4.0.5",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.13",
|
||||
"@babel/plugin-proposal-class-properties": "^7.12.13",
|
||||
"@babel/plugin-proposal-function-bind": "^7.12.13",
|
||||
"@babel/plugin-transform-react-jsx": "^7.12.13",
|
||||
"@babel/preset-env": "^7.12.13",
|
||||
"@babel/register": "^7.12.13",
|
||||
"@babel/runtime": "^7.12.13",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-loader": "^8.2.2",
|
||||
"@babel/core": "^7.7.7",
|
||||
"@babel/plugin-proposal-class-properties": "^7.7.4",
|
||||
"@babel/plugin-proposal-function-bind": "^7.7.4",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.7.4",
|
||||
"@babel/plugin-syntax-function-bind": "^7.7.4",
|
||||
"@babel/plugin-transform-react-jsx": "^7.7.7",
|
||||
"@babel/plugin-transform-runtime": "^7.7.6",
|
||||
"@babel/preset-env": "^7.7.7",
|
||||
"@babel/register": "^7.7.7",
|
||||
"@babel/runtime": "^7.7.7",
|
||||
"babel-jest": "^24.9.0",
|
||||
"babel-loader": "^8.1.0",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"css-loader": "^7.1.2",
|
||||
"fetch-mock": "^8.3.2",
|
||||
"jest": "^29.7.0",
|
||||
"mini-css-extract-plugin": "^2.9.1",
|
||||
"node-fetch": "^2.6.1",
|
||||
"css-loader": "^3.4.2",
|
||||
"fetch-mock": "^8.3.1",
|
||||
"file-loader": "^6.0.0",
|
||||
"jest": "^24.9.0",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"node-fetch": "^2.6.0",
|
||||
"node-sass": "^4.14.1",
|
||||
"prettier": "^1.19.1",
|
||||
"react": "^16.14.0",
|
||||
"react-dom": "^16.14.0",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
"sass": "^1.52.1",
|
||||
"sass-loader": "^8.0.2",
|
||||
"style-loader": "^2.0.0",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^5.94.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"style-loader": "^1.1.3",
|
||||
"url-loader": "^4.1.0",
|
||||
"webpack": "^4.42.1",
|
||||
"webpack-cli": "^3.3.11",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1254
poetry.lock
generated
Normal file
1254
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
117
pyproject.toml
117
pyproject.toml
|
|
@ -1,81 +1,44 @@
|
|||
[project]
|
||||
[tool.poetry]
|
||||
name = "newsreader"
|
||||
version = "0.5.3"
|
||||
authors = [{ name = "Sonny" }]
|
||||
license = { text = "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",
|
||||
]
|
||||
version = "0.2"
|
||||
description = "Webapplication for reading RSS feeds"
|
||||
authors = ["Sonny <sonnyba871@gmail.com>"]
|
||||
license = "GPL-3.0"
|
||||
|
||||
[dependency-groups]
|
||||
test-tools = ["ruff", "factory_boy", "freezegun"]
|
||||
development = [
|
||||
"django-debug-toolbar",
|
||||
"django-stubs",
|
||||
"django-extensions",
|
||||
]
|
||||
ci = ["coverage~=7.6.1"]
|
||||
production = ["gunicorn~=23.0"]
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.7"
|
||||
bleach = "^3.1.4"
|
||||
Django = "^3.0.5"
|
||||
celery = "^4.4.2"
|
||||
beautifulsoup4 = "^4.9.0"
|
||||
django-axes = "^5.3.1"
|
||||
django-celery-beat = "^2.0.0"
|
||||
djangorestframework = "^3.11.0"
|
||||
drf-yasg = "^1.17.1"
|
||||
django-registration-redux = "^2.7"
|
||||
lxml = "^4.5.0"
|
||||
feedparser = "^5.2.1"
|
||||
python-memcached = "^1.59"
|
||||
requests = "^2.23.0"
|
||||
psycopg2-binary = "^2.8.5"
|
||||
gunicorn = "^20.0.4"
|
||||
python-dotenv = "^0.12.0"
|
||||
django = ">=3.0.7"
|
||||
sentry-sdk = "^0.15.1"
|
||||
ftfy = "^5.8"
|
||||
requests_oauthlib = "^1.3.0"
|
||||
|
||||
[project.optional-dependencies]
|
||||
sentry = ["sentry-sdk~=2.0"]
|
||||
[tool.poetry.dev-dependencies]
|
||||
factory-boy = "^2.12.0"
|
||||
freezegun = "^0.3.15"
|
||||
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"
|
||||
|
||||
[tool.uv]
|
||||
environments = ["sys_platform == 'linux'"]
|
||||
default-groups = ["test-tools"]
|
||||
|
||||
[tool.ruff]
|
||||
include = ["pyproject.toml", "src/**/*.py"]
|
||||
|
||||
line-length = 88
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E4", "E7", "E9", "F", "I"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
lines-between-types=1
|
||||
lines-after-imports=2
|
||||
|
||||
default-section = "third-party"
|
||||
known-first-party = ["newsreader"]
|
||||
section-order = [
|
||||
"future",
|
||||
"standard-library",
|
||||
"django",
|
||||
"third-party",
|
||||
"first-party",
|
||||
"local-folder",
|
||||
]
|
||||
|
||||
[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"
|
||||
]
|
||||
[build-system]
|
||||
requires = ["poetry>=0.12"]
|
||||
build-backend = "poetry.masonry.api"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.docker")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from django import forms
|
|||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
||||
from django.contrib.auth.forms import UserChangeForm
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
|
||||
|
|
@ -11,6 +11,18 @@ class UserAdminForm(UserChangeForm):
|
|||
class Meta:
|
||||
widgets = {
|
||||
"email": forms.EmailInput(attrs={"size": "50"}),
|
||||
"reddit_access_token": forms.PasswordInput(
|
||||
attrs={"size": "90"}, render_value=True
|
||||
),
|
||||
"reddit_refresh_token": forms.PasswordInput(
|
||||
attrs={"size": "90"}, render_value=True
|
||||
),
|
||||
"twitter_oauth_token": forms.PasswordInput(
|
||||
attrs={"size": "90"}, render_value=True
|
||||
),
|
||||
"twitter_oauth_token_secret": forms.PasswordInput(
|
||||
attrs={"size": "90"}, render_value=True
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -28,6 +40,14 @@ class UserAdmin(DjangoUserAdmin):
|
|||
_("User settings"),
|
||||
{"fields": ("email", "password", "first_name", "last_name", "is_active")},
|
||||
),
|
||||
(
|
||||
_("Reddit settings"),
|
||||
{"fields": ("reddit_access_token", "reddit_refresh_token")},
|
||||
),
|
||||
(
|
||||
_("Twitter settings"),
|
||||
{"fields": ("twitter_oauth_token", "twitter_oauth_token_secret")},
|
||||
),
|
||||
(
|
||||
_("Permission settings"),
|
||||
{"classes": ("collapse",), "fields": ("is_staff", "is_superuser")},
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
|||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
name = "newsreader.accounts"
|
||||
name = "accounts"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
from django import forms
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
from newsreader.core.forms import CheckboxInput
|
||||
|
||||
|
||||
class UserSettingsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ("first_name", "last_name", "auto_mark_read")
|
||||
widgets = {"auto_mark_read": CheckboxInput}
|
||||
fields = ("first_name", "last_name")
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0001_initial")]
|
||||
|
||||
operations = [migrations.RemoveField(model_name="user", name="username")]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import newsreader.accounts.models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0002_remove_user_username")]
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0003_auto_20190714_1417")]
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0004_auto_20190714_1501")]
|
||||
|
||||
operations = [migrations.RemoveField(model_name="user", name="task_interval")]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0005_remove_user_task_interval")]
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0006_auto_20191116_1253")]
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ def update_task_name(apps, schema_editor):
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0007_auto_20191116_1255")]
|
||||
|
||||
operations = [migrations.RunPython(update_task_name)]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("django_celery_beat", "0012_periodictask_expire_seconds"),
|
||||
("accounts", "0008_auto_20200422_2243"),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0009_auto_20200524_1218")]
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0010_auto_20200603_2230")]
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0011_auto_20200913_2101")]
|
||||
|
||||
operations = [migrations.RemoveField(model_name="user", name="task")]
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
# Generated by Django 3.0.7 on 2020-10-27 21:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounts", "0012_remove_user_task")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="auto_mark_read",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text="Wether posts should be marked as read after x amount of seconds of reading",
|
||||
verbose_name="Auto read marking",
|
||||
),
|
||||
)
|
||||
]
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
# Generated by Django 3.0.7 on 2020-12-18 21:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounts", "0013_user_auto_mark_read")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="reddit_allow_nfsw",
|
||||
field=models.BooleanField(default=False, verbose_name="Allow NSFW posts"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="reddit_allow_spoiler",
|
||||
field=models.BooleanField(default=False, verbose_name="Allow spoilers"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="reddit_allow_viewed",
|
||||
field=models.BooleanField(
|
||||
default=True, verbose_name="Allow already seen posts"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="reddit_comments_min",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, verbose_name="Minimum amount of comments"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="reddit_downvotes_max",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, verbose_name="Maximum amount of downvotes"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="reddit_upvotes_min",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, verbose_name="Minimum amount of upvotes"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
# Generated by Django 3.0.7 on 2020-12-19 12:30
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounts", "0014_auto_20201218_2216")]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name="user", name="reddit_allow_nfsw"),
|
||||
migrations.RemoveField(model_name="user", name="reddit_allow_spoiler"),
|
||||
migrations.RemoveField(model_name="user", name="reddit_allow_viewed"),
|
||||
migrations.RemoveField(model_name="user", name="reddit_comments_min"),
|
||||
migrations.RemoveField(model_name="user", name="reddit_downvotes_max"),
|
||||
migrations.RemoveField(model_name="user", name="reddit_upvotes_min"),
|
||||
]
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
# Generated by Django 3.2 on 2021-04-23 20:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounts", "0015_auto_20201219_1330")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="first_name",
|
||||
field=models.CharField(
|
||||
blank=True, max_length=150, verbose_name="first name"
|
||||
),
|
||||
)
|
||||
]
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
# Generated by Django 3.2.25 on 2024-09-06 07:14
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0016_alter_user_first_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="twitter_oauth_token",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="twitter_oauth_token_secret",
|
||||
),
|
||||
]
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
# Generated by Django 4.2.16 on 2025-03-26 08:46
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0017_auto_20240906_0914"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="reddit_access_token",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="reddit_refresh_token",
|
||||
),
|
||||
]
|
||||
|
|
@ -39,14 +39,11 @@ class UserManager(DjangoUserManager):
|
|||
class User(AbstractUser):
|
||||
email = models.EmailField(_("email address"), unique=True)
|
||||
|
||||
# settings
|
||||
auto_mark_read = models.BooleanField(
|
||||
_("Auto read marking"),
|
||||
default=True,
|
||||
help_text=_(
|
||||
"Wether posts should be marked as read after x amount of seconds of reading"
|
||||
),
|
||||
)
|
||||
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_oauth_token = models.CharField(max_length=255, blank=True, null=True)
|
||||
twitter_oauth_token_secret = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
username = None
|
||||
|
||||
|
|
@ -60,3 +57,7 @@ class User(AbstractUser):
|
|||
tasks.delete()
|
||||
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def has_twitter_auth(self):
|
||||
return self.twitter_oauth_token and self.twitter_oauth_token_secret
|
||||
|
|
|
|||
|
|
@ -2,23 +2,16 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block actions %}
|
||||
<section class="section form__section--last">
|
||||
<section class="section form__section--last">
|
||||
<fieldset class="fieldset form__fieldset">
|
||||
{% include "components/form/confirm-button.html" %}
|
||||
|
||||
<a class="link button button--primary" href="{% url 'accounts:password-change' %}">
|
||||
{% trans "Change password" %}
|
||||
</a>
|
||||
|
||||
{% if favicon_task_allowed %}
|
||||
<a class="link button button--primary" href="{% url 'accounts:settings:favicon' %}">
|
||||
{% trans "Fetch favicons" %}
|
||||
<a class="link button button--primary" href="{% url 'accounts:integrations' %}">
|
||||
{% trans "Third party integrations" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="button button--primary button--disabled" disabled>
|
||||
{% trans "Fetch favicons" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% include "components/form/confirm-button.html" %}
|
||||
</fieldset>
|
||||
</section>
|
||||
</section>
|
||||
{% endblock actions %}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<main id="integrations--page" class="main">
|
||||
<section class="section">
|
||||
{% include "components/header/header.html" with title="Integrations" only %}
|
||||
|
||||
<div class="integrations">
|
||||
<h3 class="integrations__title">Reddit</h3>
|
||||
<div class="integrations__controls">
|
||||
{% if reddit_authorization_url %}
|
||||
<a class="link button button--reddit" href="{{ reddit_authorization_url }}">
|
||||
{% trans "Authorize account" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="button button--reddit button--disabled" disabled>
|
||||
{% trans "Authorize account" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if reddit_refresh_url %}
|
||||
<a class="link button button--reddit" href="{{ reddit_refresh_url }}">
|
||||
{% trans "Refresh token" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="button button--reddit button--disabled" disabled>
|
||||
{% trans "Refresh token" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if reddit_revoke_url %}
|
||||
<a class="link button button--reddit" href="{{ reddit_revoke_url }}">
|
||||
{% trans "Deauthorize account" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="button button--reddit button--disabled" disabled>
|
||||
{% trans "Deauthorize account" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="integrations">
|
||||
<h3 class="integrations__title">Twitter</h3>
|
||||
<div class="integrations__controls">
|
||||
{% if twitter_auth_url %}
|
||||
<a class="link button button--twitter" href="{{ twitter_auth_url }}">
|
||||
{% trans "Authorize account" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="button button--twitter button--disabled" disabled>
|
||||
{% trans "Authorize account" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if twitter_revoke_url %}
|
||||
<a class="link button button--twitter" href="{{ twitter_revoke_url }}">
|
||||
{% trans "Deauthorize account" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="button button--twitter button--disabled" disabled>
|
||||
{% trans "Deauthorize account" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
{% extends "sidebar.html" %}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<main id="login--page" class="main" data-render-sidebar=true>
|
||||
<div class="main__container">
|
||||
<main id="login--page" class="main">
|
||||
{% include "accounts/components/login-form.html" with form=form title="Login" confirm_text="Login" %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
{% extends "sidebar.html" %}
|
||||
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% url 'accounts:settings:home' as cancel_url %}
|
||||
|
||||
<main id="password-change--page" class="main" data-render-sidebar=true>
|
||||
<div class="main__container">
|
||||
<main id="password-change--page" class="main">
|
||||
{% url 'accounts:settings' as cancel_url %}
|
||||
{% include "components/form/form.html" with form=form title="Change password" confirm_text="Change password" cancel_url=cancel_url %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
|
|||
20
src/newsreader/accounts/templates/accounts/views/reddit.html
Normal file
20
src/newsreader/accounts/templates/accounts/views/reddit.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<main id="reddit--page" class="main">
|
||||
<section class="section text-section">
|
||||
{% if error %}
|
||||
<h1 class="h1">{% trans "Reddit authorization failed" %}</h1>
|
||||
<p>{{ error }}</p>
|
||||
{% elif access_token and refresh_token %}
|
||||
<h1 class="h1">{% trans "Reddit account is linked" %}</h1>
|
||||
<p>{% trans "Your reddit account was successfully linked." %}</p>
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
<a class="link" href="{% url 'accounts:integrations' %}">{% trans "Return to integrations page" %}</a>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
{% extends "sidebar.html" %}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<main id="settings--page" class="main" data-render-sidebar=true>
|
||||
<div class="main__container">
|
||||
<main id="settings--page" class="main">
|
||||
{% include "accounts/components/settings-form.html" with form=form title="User profile" confirm_text="Save" %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<main id="twitter--page" class="main">
|
||||
<section class="section text-section">
|
||||
{% if error %}
|
||||
<h1 class="h1">{% trans "Twitter authorization failed" %}</h1>
|
||||
<p>{{ error }}</p>
|
||||
{% elif authorized %}
|
||||
<h1 class="h1">{% trans "Twitter account is linked" %}</h1>
|
||||
<p>{% trans "Your Twitter account was successfully linked." %}</p>
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
<a class="link" href="{% url 'accounts:integrations' %}">{% trans "Return to integrations page" %}</a>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
@ -5,6 +5,8 @@ from django.utils.crypto import get_random_string
|
|||
|
||||
import factory
|
||||
|
||||
from registration.models import RegistrationProfile
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
|
||||
|
||||
|
|
@ -27,3 +29,11 @@ class UserFactory(factory.django.DjangoModelFactory):
|
|||
|
||||
class Meta:
|
||||
model = User
|
||||
|
||||
|
||||
class RegistrationProfileFactory(factory.django.DjangoModelFactory):
|
||||
user = factory.SubFactory(UserFactory)
|
||||
activation_key = factory.LazyFunction(get_activation_key)
|
||||
|
||||
class Meta:
|
||||
model = RegistrationProfile
|
||||
|
|
|
|||
99
src/newsreader/accounts/tests/test_activation.py
Normal file
99
src/newsreader/accounts/tests/test_activation.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from registration.models import RegistrationProfile
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
|
||||
|
||||
class ActivationTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.register_url = reverse("accounts:register")
|
||||
self.register_success_url = reverse("accounts:register-complete")
|
||||
self.success_url = reverse("accounts:activate-complete")
|
||||
|
||||
def test_activation(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
self.assertRedirects(response, self.register_success_url)
|
||||
|
||||
register_profile = RegistrationProfile.objects.get()
|
||||
|
||||
kwargs = {"activation_key": register_profile.activation_key}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
def test_expired_key(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
|
||||
register_profile = RegistrationProfile.objects.get()
|
||||
user = register_profile.user
|
||||
|
||||
user.date_joined -= datetime.timedelta(days=settings.ACCOUNT_ACTIVATION_DAYS)
|
||||
user.save()
|
||||
|
||||
kwargs = {"activation_key": register_profile.activation_key}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertContains(response, _("Account activation failed"))
|
||||
|
||||
user.refresh_from_db()
|
||||
self.assertFalse(user.is_active)
|
||||
|
||||
def test_invalid_key(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
self.assertRedirects(response, self.register_success_url)
|
||||
|
||||
kwargs = {"activation_key": "not-a-valid-key"}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertContains(response, _("Account activation failed"))
|
||||
|
||||
user = User.objects.get()
|
||||
|
||||
self.assertEquals(user.is_active, False)
|
||||
|
||||
def test_activated_key(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
self.assertRedirects(response, self.register_success_url)
|
||||
|
||||
register_profile = RegistrationProfile.objects.get()
|
||||
|
||||
kwargs = {"activation_key": register_profile.activation_key}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
# try this a second time
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from newsreader.accounts.tests.factories import UserFactory
|
||||
|
||||
|
||||
class FaviconRedirectViewTestCase(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.favicon.FaviconTask")
|
||||
self.mocked_task = self.patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def test_simple(self):
|
||||
response = self.client.get(reverse("accounts:settings:favicon"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts:settings:home"))
|
||||
|
||||
self.mocked_task.delay.assert_called_once_with(self.user.pk)
|
||||
|
||||
self.assertEqual(1, cache.get(f"{self.user.email}-favicon-task"))
|
||||
|
||||
def test_not_active(self):
|
||||
cache.set(f"{self.user.email}-favicon-task", 1)
|
||||
|
||||
response = self.client.get(reverse("accounts:settings:favicon"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts:settings:home"))
|
||||
|
||||
self.mocked_task.delay.assert_not_called()
|
||||
537
src/newsreader/accounts/tests/test_integrations.py
Normal file
537
src/newsreader/accounts/tests/test_integrations.py
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
from unittest.mock import Mock, patch
|
||||
from urllib.parse import urlencode
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from newsreader.accounts.tests.factories import UserFactory
|
||||
from newsreader.news.collection.exceptions import (
|
||||
StreamException,
|
||||
StreamTooManyException,
|
||||
)
|
||||
from newsreader.news.collection.twitter import TWITTER_AUTH_URL
|
||||
|
||||
|
||||
class IntegrationsViewTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = UserFactory(email="test@test.nl", password="test")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
self.url = reverse("accounts: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: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:reddit-refresh"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts: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:reddit-refresh"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts: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:reddit-revoke"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts: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:reddit-revoke"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts: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:reddit-revoke"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts: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:reddit-revoke"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts: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:twitter-revoke"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts: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:twitter-revoke"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts: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:twitter-revoke"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts: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: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:twitter-auth"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts: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:twitter-auth"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts: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: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: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: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: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: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:twitter-template')}?{urlencode(params)}"
|
||||
)
|
||||
|
||||
self.assertContains(response, _("No credentials found in Twitter response"))
|
||||
|
||||
self.user.refresh_from_db()
|
||||
|
||||
self.assertIsNone(self.user.twitter_oauth_token)
|
||||
self.assertIsNone(self.user.twitter_oauth_token_secret)
|
||||
|
||||
self.assertIsNotNone(cache.get(f"twitter-{self.user.email}-token"))
|
||||
self.assertIsNotNone(cache.get(f"twitter-{self.user.email}-secret"))
|
||||
110
src/newsreader/accounts/tests/test_registration.py
Normal file
110
src/newsreader/accounts/tests/test_registration.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
from django.core import mail
|
||||
from django.test import TransactionTestCase as TestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from registration.models import RegistrationProfile
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
from newsreader.accounts.tests.factories import UserFactory
|
||||
|
||||
|
||||
class RegistrationTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.url = reverse("accounts:register")
|
||||
self.success_url = reverse("accounts:register-complete")
|
||||
self.disallowed_url = reverse("accounts:register-closed")
|
||||
|
||||
def test_simple(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
def test_registration(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
self.assertEquals(User.objects.count(), 1)
|
||||
self.assertEquals(RegistrationProfile.objects.count(), 1)
|
||||
|
||||
user = User.objects.get()
|
||||
|
||||
self.assertEquals(user.is_active, False)
|
||||
self.assertEquals(len(mail.outbox), 1)
|
||||
|
||||
def test_existing_email(self):
|
||||
UserFactory(email="test@test.com")
|
||||
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
self.assertEquals(User.objects.count(), 1)
|
||||
self.assertContains(response, _("User with this Email address already exists"))
|
||||
|
||||
def test_pending_registration(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
self.assertEquals(User.objects.count(), 1)
|
||||
self.assertEquals(RegistrationProfile.objects.count(), 1)
|
||||
|
||||
user = User.objects.get()
|
||||
|
||||
self.assertEquals(user.is_active, False)
|
||||
self.assertEquals(len(mail.outbox), 1)
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertEquals(response.status_code, 200)
|
||||
self.assertContains(response, _("User with this Email address already exists"))
|
||||
|
||||
def test_disabled_account(self):
|
||||
UserFactory(email="test@test.com", is_active=False)
|
||||
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
self.assertEquals(User.objects.count(), 1)
|
||||
self.assertContains(response, _("User with this Email address already exists"))
|
||||
|
||||
@override_settings(REGISTRATION_OPEN=False)
|
||||
def test_registration_closed(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertRedirects(response, self.disallowed_url)
|
||||
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertRedirects(response, self.disallowed_url)
|
||||
|
||||
self.assertEquals(User.objects.count(), 0)
|
||||
self.assertEquals(RegistrationProfile.objects.count(), 0)
|
||||
77
src/newsreader/accounts/tests/test_resend_activation.py
Normal file
77
src/newsreader/accounts/tests/test_resend_activation.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
from django.core import mail
|
||||
from django.test import TransactionTestCase as TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from registration.models import RegistrationProfile
|
||||
|
||||
from newsreader.accounts.tests.factories import RegistrationProfileFactory, UserFactory
|
||||
|
||||
|
||||
class ResendActivationTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.url = reverse("accounts:activate-resend")
|
||||
self.success_url = reverse("accounts:activate-complete")
|
||||
self.register_url = reverse("accounts:register")
|
||||
|
||||
def test_simple(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
def test_resent_form(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
|
||||
register_profile = RegistrationProfile.objects.get()
|
||||
original_kwargs = {"activation_key": register_profile.activation_key}
|
||||
|
||||
response = self.client.post(self.url, {"email": "test@test.com"})
|
||||
|
||||
self.assertContains(response, _("We have sent an email to"))
|
||||
|
||||
self.assertEquals(len(mail.outbox), 2)
|
||||
|
||||
register_profile.refresh_from_db()
|
||||
|
||||
kwargs = {"activation_key": register_profile.activation_key}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
register_profile.refresh_from_db()
|
||||
user = register_profile.user
|
||||
|
||||
self.assertEquals(user.is_active, True)
|
||||
|
||||
# test the old activation code
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=original_kwargs))
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
self.assertContains(response, _("Account activation failed"))
|
||||
|
||||
def test_existing_account(self):
|
||||
user = UserFactory(is_active=True)
|
||||
profile = RegistrationProfileFactory(user=user, activated=True)
|
||||
|
||||
response = self.client.post(self.url, {"email": user.email})
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
# default behaviour is to show success page but not send an email
|
||||
self.assertContains(response, _("We have sent an email to"))
|
||||
|
||||
self.assertEquals(len(mail.outbox), 0)
|
||||
|
||||
def test_no_account(self):
|
||||
response = self.client.post(self.url, {"email": "fake@mail.com"})
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
# default behaviour is to show success page but not send an email
|
||||
self.assertContains(response, _("We have sent an email to"))
|
||||
|
||||
self.assertEquals(len(mail.outbox), 0)
|
||||
|
|
@ -10,7 +10,7 @@ class SettingsViewTestCase(TestCase):
|
|||
self.user = UserFactory(email="test@test.nl", password="test")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
self.url = reverse("accounts:settings:home")
|
||||
self.url = reverse("accounts:settings")
|
||||
|
||||
def test_simple(self):
|
||||
response = self.client.get(self.url)
|
||||
|
|
@ -19,13 +19,13 @@ class SettingsViewTestCase(TestCase):
|
|||
|
||||
def test_user_credential_change(self):
|
||||
response = self.client.post(
|
||||
reverse("accounts:settings:home"),
|
||||
reverse("accounts:settings"),
|
||||
{"first_name": "First name", "last_name": "Last name"},
|
||||
)
|
||||
|
||||
user = User.objects.get()
|
||||
|
||||
self.assertRedirects(response, reverse("accounts:settings:home"))
|
||||
self.assertRedirects(response, reverse("accounts:settings"))
|
||||
|
||||
self.assertEquals(user.first_name, "First name")
|
||||
self.assertEquals(user.last_name, "Last name")
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ class UserTestCase(TestCase):
|
|||
PeriodicTask.objects.create(
|
||||
name=f"{user.email}-feed", task="FeedTask", interval=interval
|
||||
)
|
||||
PeriodicTask.objects.create(
|
||||
name=f"{user.email}-timeline", task="TwitterTimelineTask", interval=interval
|
||||
)
|
||||
|
||||
user.delete()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
from django.contrib.auth.decorators import login_required
|
||||
from django.urls import include, path
|
||||
from django.urls import path
|
||||
|
||||
from newsreader.accounts.views import (
|
||||
FaviconRedirectView,
|
||||
ActivationCompleteView,
|
||||
ActivationResendView,
|
||||
ActivationView,
|
||||
IntegrationsView,
|
||||
LoginView,
|
||||
LogoutView,
|
||||
PasswordChangeView,
|
||||
|
|
@ -10,20 +13,41 @@ from newsreader.accounts.views import (
|
|||
PasswordResetConfirmView,
|
||||
PasswordResetDoneView,
|
||||
PasswordResetView,
|
||||
RedditRevokeRedirectView,
|
||||
RedditTemplateView,
|
||||
RedditTokenRedirectView,
|
||||
RegistrationClosedView,
|
||||
RegistrationCompleteView,
|
||||
RegistrationView,
|
||||
SettingsView,
|
||||
TwitterAuthRedirectView,
|
||||
TwitterRevokeRedirectView,
|
||||
TwitterTemplateView,
|
||||
)
|
||||
|
||||
|
||||
settings_patterns = [
|
||||
# Misc
|
||||
path("favicon/", login_required(FaviconRedirectView.as_view()), name="favicon"),
|
||||
path("", login_required(SettingsView.as_view()), name="home"),
|
||||
]
|
||||
|
||||
urlpatterns = [
|
||||
# Auth
|
||||
path("login/", LoginView.as_view(), name="login"),
|
||||
path("logout/", LogoutView.as_view(), name="logout"),
|
||||
# Register
|
||||
path("register/", RegistrationView.as_view(), name="register"),
|
||||
path(
|
||||
"register/complete/",
|
||||
RegistrationCompleteView.as_view(),
|
||||
name="register-complete",
|
||||
),
|
||||
path("register/closed/", RegistrationClosedView.as_view(), name="register-closed"),
|
||||
path(
|
||||
"activate/complete/", ActivationCompleteView.as_view(), name="activate-complete"
|
||||
),
|
||||
path("activate/resend/", ActivationResendView.as_view(), name="activate-resend"),
|
||||
path(
|
||||
# This URL should be placed after all activate/ url's (see arg)
|
||||
"activate/<str:activation_key>/",
|
||||
ActivationView.as_view(),
|
||||
name="activate",
|
||||
),
|
||||
# Password
|
||||
path("password-reset/", PasswordResetView.as_view(), name="password-reset"),
|
||||
path(
|
||||
|
|
@ -46,6 +70,42 @@ urlpatterns = [
|
|||
login_required(PasswordChangeView.as_view()),
|
||||
name="password-change",
|
||||
),
|
||||
# Integrations
|
||||
path(
|
||||
"settings/integrations/reddit/callback/",
|
||||
login_required(RedditTemplateView.as_view()),
|
||||
name="reddit-template",
|
||||
),
|
||||
path(
|
||||
"settings/integrations/reddit/refresh/",
|
||||
login_required(RedditTokenRedirectView.as_view()),
|
||||
name="reddit-refresh",
|
||||
),
|
||||
path(
|
||||
"settings/integrations/reddit/revoke/",
|
||||
login_required(RedditRevokeRedirectView.as_view()),
|
||||
name="reddit-revoke",
|
||||
),
|
||||
path(
|
||||
"settings/integrations/twitter/auth/",
|
||||
login_required(TwitterAuthRedirectView.as_view()),
|
||||
name="twitter-auth",
|
||||
),
|
||||
path(
|
||||
"settings/integrations/twitter/callback/",
|
||||
login_required(TwitterTemplateView.as_view()),
|
||||
name="twitter-template",
|
||||
),
|
||||
path(
|
||||
"settings/integrations/twitter/revoke/",
|
||||
login_required(TwitterRevokeRedirectView.as_view()),
|
||||
name="twitter-revoke",
|
||||
),
|
||||
path(
|
||||
"settings/integrations",
|
||||
login_required(IntegrationsView.as_view()),
|
||||
name="integrations",
|
||||
),
|
||||
# Settings
|
||||
path("settings/", include((settings_patterns, "settings"))),
|
||||
path("settings/", login_required(SettingsView.as_view()), name="settings"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
from newsreader.accounts.views.auth import LoginView, LogoutView
|
||||
from newsreader.accounts.views.favicon import FaviconRedirectView
|
||||
from newsreader.accounts.views.integrations import (
|
||||
IntegrationsView,
|
||||
RedditRevokeRedirectView,
|
||||
RedditTemplateView,
|
||||
RedditTokenRedirectView,
|
||||
TwitterAuthRedirectView,
|
||||
TwitterRevokeRedirectView,
|
||||
TwitterTemplateView,
|
||||
)
|
||||
from newsreader.accounts.views.password import (
|
||||
PasswordChangeView,
|
||||
PasswordResetCompleteView,
|
||||
|
|
@ -7,17 +15,12 @@ from newsreader.accounts.views.password import (
|
|||
PasswordResetDoneView,
|
||||
PasswordResetView,
|
||||
)
|
||||
from newsreader.accounts.views.registration import (
|
||||
ActivationCompleteView,
|
||||
ActivationResendView,
|
||||
ActivationView,
|
||||
RegistrationClosedView,
|
||||
RegistrationCompleteView,
|
||||
RegistrationView,
|
||||
)
|
||||
from newsreader.accounts.views.settings import SettingsView
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LoginView",
|
||||
"LogoutView",
|
||||
"FaviconRedirectView",
|
||||
"PasswordChangeView",
|
||||
"PasswordResetCompleteView",
|
||||
"PasswordResetConfirmView",
|
||||
"PasswordResetDoneView",
|
||||
"PasswordResetView",
|
||||
"SettingsView",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
from django.contrib.auth import views as django_views
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
from newsreader.utils.views import NavListMixin
|
||||
|
||||
|
||||
class LoginView(NavListMixin, django_views.LoginView):
|
||||
class LoginView(django_views.LoginView):
|
||||
template_name = "accounts/views/login.html"
|
||||
success_url = reverse_lazy("index")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
from django.contrib import messages
|
||||
from django.core.cache import cache
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
from newsreader.news.collection.tasks import FaviconTask
|
||||
|
||||
|
||||
class FaviconRedirectView(RedirectView):
|
||||
url = reverse_lazy("accounts:settings:home")
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
response = super().get(request, *args, **kwargs)
|
||||
|
||||
user = request.user
|
||||
task_active = cache.get(f"{user.email}-favicon-task")
|
||||
|
||||
if not task_active:
|
||||
FaviconTask.delay(user.pk)
|
||||
messages.success(request, _("Favicons are being fetched"))
|
||||
cache.set(f"{user.email}-favicon-task", 1, 18000) # 5 hours
|
||||
return response
|
||||
|
||||
messages.error(request, _("Limit reached, try again later"))
|
||||
return response
|
||||
343
src/newsreader/accounts/views/integrations.py
Normal file
343
src/newsreader/accounts/views/integrations.py
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
import logging
|
||||
|
||||
from urllib.parse import parse_qs, urlencode
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.cache import cache
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import RedirectView, TemplateView
|
||||
|
||||
from requests_oauthlib import OAuth1 as OAuth
|
||||
|
||||
from newsreader.news.collection.exceptions import StreamException
|
||||
from newsreader.news.collection.reddit import (
|
||||
get_reddit_access_token,
|
||||
get_reddit_authorization_url,
|
||||
revoke_reddit_token,
|
||||
)
|
||||
from newsreader.news.collection.tasks import RedditTokenTask
|
||||
from newsreader.news.collection.twitter import (
|
||||
TWITTER_ACCESS_TOKEN_URL,
|
||||
TWITTER_AUTH_URL,
|
||||
TWITTER_REQUEST_TOKEN_URL,
|
||||
TWITTER_REVOKE_URL,
|
||||
)
|
||||
from newsreader.news.collection.utils import post
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IntegrationsView(TemplateView):
|
||||
template_name = "accounts/views/integrations.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return {
|
||||
**super().get_context_data(**kwargs),
|
||||
**self.get_reddit_context(**kwargs),
|
||||
**self.get_twitter_context(**kwargs),
|
||||
}
|
||||
|
||||
def get_reddit_context(self, **kwargs):
|
||||
user = self.request.user
|
||||
reddit_authorization_url = None
|
||||
reddit_refresh_url = None
|
||||
|
||||
reddit_task_active = cache.get(f"{user.email}-reddit-refresh")
|
||||
|
||||
if (
|
||||
user.reddit_refresh_token
|
||||
and not user.reddit_access_token
|
||||
and not reddit_task_active
|
||||
):
|
||||
reddit_refresh_url = reverse_lazy("accounts: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: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:twitter-revoke")
|
||||
|
||||
return {
|
||||
"twitter_auth_url": reverse_lazy("accounts: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: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: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: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:integrations")
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
oauth = OAuth(
|
||||
settings.TWITTER_CONSUMER_ID,
|
||||
client_secret=settings.TWITTER_CONSUMER_SECRET,
|
||||
callback_uri=settings.TWITTER_REDIRECT_URL,
|
||||
)
|
||||
|
||||
try:
|
||||
response = post(TWITTER_REQUEST_TOKEN_URL, auth=oauth)
|
||||
except StreamException:
|
||||
logger.exception("Failed requesting Twitter authentication token")
|
||||
|
||||
messages.error(request, _("Unable to retrieve initial Twitter token"))
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
params = parse_qs(response.text)
|
||||
|
||||
try:
|
||||
request_oauth_token = params["oauth_token"][0]
|
||||
request_oauth_secret = params["oauth_token_secret"][0]
|
||||
except KeyError:
|
||||
logger.exception("No credentials found in response")
|
||||
|
||||
messages.error(request, _("Unable to retrieve initial Twitter token"))
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
cache.set_many(
|
||||
{
|
||||
f"twitter-{request.user.email}-token": request_oauth_token,
|
||||
f"twitter-{request.user.email}-secret": request_oauth_secret,
|
||||
}
|
||||
)
|
||||
|
||||
request_params = urlencode({"oauth_token": request_oauth_token})
|
||||
return redirect(f"{TWITTER_AUTH_URL}/?{request_params}")
|
||||
|
||||
|
||||
class TwitterTemplateView(TemplateView):
|
||||
template_name = "accounts/views/twitter.html"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = self.get_context_data(**kwargs)
|
||||
|
||||
denied = request.GET.get("denied", False)
|
||||
oauth_token = request.GET.get("oauth_token")
|
||||
oauth_verifier = request.GET.get("oauth_verifier")
|
||||
|
||||
if denied:
|
||||
return self.render_to_response(
|
||||
{
|
||||
**context,
|
||||
"error": _("Twitter authorization failed"),
|
||||
"authorized": False,
|
||||
}
|
||||
)
|
||||
|
||||
cached_token = cache.get(f"twitter-{request.user.email}-token")
|
||||
|
||||
if oauth_token != cached_token:
|
||||
return self.render_to_response(
|
||||
{
|
||||
**context,
|
||||
"error": _("OAuth tokens failed to match"),
|
||||
"authorized": False,
|
||||
}
|
||||
)
|
||||
|
||||
cached_secret = cache.get(f"twitter-{request.user.email}-secret")
|
||||
|
||||
if not cached_token or not cached_secret:
|
||||
return self.render_to_response(
|
||||
{
|
||||
**context,
|
||||
"error": _("No matching tokens found for this user"),
|
||||
"authorized": False,
|
||||
}
|
||||
)
|
||||
|
||||
oauth = OAuth(
|
||||
settings.TWITTER_CONSUMER_ID,
|
||||
client_secret=settings.TWITTER_CONSUMER_SECRET,
|
||||
resource_owner_key=cached_token,
|
||||
resource_owner_secret=cached_secret,
|
||||
verifier=oauth_verifier,
|
||||
)
|
||||
|
||||
try:
|
||||
response = post(TWITTER_ACCESS_TOKEN_URL, auth=oauth)
|
||||
except StreamException:
|
||||
logger.exception("Failed requesting Twitter access token")
|
||||
|
||||
return self.render_to_response(
|
||||
{
|
||||
**context,
|
||||
"error": _("Failed requesting access token"),
|
||||
"authorized": False,
|
||||
}
|
||||
)
|
||||
|
||||
params = parse_qs(response.text)
|
||||
|
||||
try:
|
||||
oauth_token = params["oauth_token"][0]
|
||||
oauth_secret = params["oauth_token_secret"][0]
|
||||
except KeyError:
|
||||
logger.exception("No credentials in Twitter response")
|
||||
|
||||
return self.render_to_response(
|
||||
{
|
||||
**context,
|
||||
"error": _("No credentials found in Twitter response"),
|
||||
"authorized": False,
|
||||
}
|
||||
)
|
||||
|
||||
request.user.twitter_oauth_token = oauth_token
|
||||
request.user.twitter_oauth_token_secret = oauth_secret
|
||||
request.user.save()
|
||||
|
||||
cache.delete_many(
|
||||
[
|
||||
f"twitter-{request.user.email}-token",
|
||||
f"twitter-{request.user.email}-secret",
|
||||
]
|
||||
)
|
||||
|
||||
return self.render_to_response({**context, "error": None, "authorized": True})
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
from django.contrib.auth import views as django_views
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
from newsreader.utils.views import NavListMixin
|
||||
from newsreader.news.collection.reddit import (
|
||||
get_reddit_access_token,
|
||||
get_reddit_authorization_url,
|
||||
)
|
||||
|
||||
|
||||
# PasswordResetView sends the mail
|
||||
|
|
@ -9,26 +12,26 @@ from newsreader.utils.views import NavListMixin
|
|||
# PasswordResetConfirmView checks the link the user clicked and
|
||||
# prompts for a new password
|
||||
# PasswordResetCompleteView shows a success message for the above
|
||||
class PasswordResetView(NavListMixin, django_views.PasswordResetView):
|
||||
class PasswordResetView(django_views.PasswordResetView):
|
||||
template_name = "password-reset/password-reset.html"
|
||||
subject_template_name = "password-reset/password-reset-subject.txt"
|
||||
email_template_name = "password-reset/password-reset-email.html"
|
||||
success_url = reverse_lazy("accounts:password-reset-done")
|
||||
|
||||
|
||||
class PasswordResetDoneView(NavListMixin, django_views.PasswordResetDoneView):
|
||||
class PasswordResetDoneView(django_views.PasswordResetDoneView):
|
||||
template_name = "password-reset/password-reset-done.html"
|
||||
|
||||
|
||||
class PasswordResetConfirmView(NavListMixin, django_views.PasswordResetConfirmView):
|
||||
class PasswordResetConfirmView(django_views.PasswordResetConfirmView):
|
||||
template_name = "password-reset/password-reset-confirm.html"
|
||||
success_url = reverse_lazy("accounts:password-reset-complete")
|
||||
|
||||
|
||||
class PasswordResetCompleteView(NavListMixin, django_views.PasswordResetCompleteView):
|
||||
class PasswordResetCompleteView(django_views.PasswordResetCompleteView):
|
||||
template_name = "password-reset/password-reset-complete.html"
|
||||
|
||||
|
||||
class PasswordChangeView(NavListMixin, django_views.PasswordChangeView):
|
||||
class PasswordChangeView(django_views.PasswordChangeView):
|
||||
template_name = "accounts/views/password-change.html"
|
||||
success_url = reverse_lazy("accounts:settings")
|
||||
|
|
|
|||
59
src/newsreader/accounts/views/registration.py
Normal file
59
src/newsreader/accounts/views/registration.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
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
|
||||
)
|
||||
|
|
@ -1,15 +1,17 @@
|
|||
from django.core.cache import cache
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic.edit import FormView, ModelFormMixin
|
||||
|
||||
from newsreader.accounts.forms import UserSettingsForm
|
||||
from newsreader.accounts.models import User
|
||||
from newsreader.utils.views import NavListMixin
|
||||
from newsreader.news.collection.reddit import (
|
||||
get_reddit_access_token,
|
||||
get_reddit_authorization_url,
|
||||
)
|
||||
|
||||
|
||||
class SettingsView(NavListMixin, ModelFormMixin, FormView):
|
||||
class SettingsView(ModelFormMixin, FormView):
|
||||
template_name = "accounts/views/settings.html"
|
||||
success_url = reverse_lazy("accounts:settings:home")
|
||||
success_url = reverse_lazy("accounts:settings")
|
||||
form_class = UserSettingsForm
|
||||
model = User
|
||||
|
||||
|
|
@ -17,14 +19,6 @@ class SettingsView(NavListMixin, ModelFormMixin, FormView):
|
|||
self.object = self.get_object()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
return {
|
||||
**context,
|
||||
"favicon_task_allowed": not cache.get(f"{self.request.user}-favicon-task"),
|
||||
}
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
return self.request.user
|
||||
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
101
src/newsreader/assets/fonts/METADATA.pb
Executable file
101
src/newsreader/assets/fonts/METADATA.pb
Executable file
|
|
@ -0,0 +1,101 @@
|
|||
name: "Rubik"
|
||||
designer: "Hubert and Fischer, Meir Sadan, Cyreal"
|
||||
license: "OFL"
|
||||
category: "SANS_SERIF"
|
||||
date_added: "2015-07-22"
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "normal"
|
||||
weight: 300
|
||||
filename: "Rubik-Light.ttf"
|
||||
post_script_name: "Rubik-Light"
|
||||
full_name: "Rubik Light"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "italic"
|
||||
weight: 300
|
||||
filename: "Rubik-LightItalic.ttf"
|
||||
post_script_name: "Rubik-LightItalic"
|
||||
full_name: "Rubik Light Italic"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "normal"
|
||||
weight: 400
|
||||
filename: "Rubik-Regular.ttf"
|
||||
post_script_name: "Rubik-Regular"
|
||||
full_name: "Rubik Regular"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "italic"
|
||||
weight: 400
|
||||
filename: "Rubik-Italic.ttf"
|
||||
post_script_name: "Rubik-Italic"
|
||||
full_name: "Rubik Italic"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "normal"
|
||||
weight: 500
|
||||
filename: "Rubik-Medium.ttf"
|
||||
post_script_name: "Rubik-Medium"
|
||||
full_name: "Rubik Medium"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "italic"
|
||||
weight: 500
|
||||
filename: "Rubik-MediumItalic.ttf"
|
||||
post_script_name: "Rubik-MediumItalic"
|
||||
full_name: "Rubik Medium Italic"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "normal"
|
||||
weight: 700
|
||||
filename: "Rubik-Bold.ttf"
|
||||
post_script_name: "Rubik-Bold"
|
||||
full_name: "Rubik Bold"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "italic"
|
||||
weight: 700
|
||||
filename: "Rubik-BoldItalic.ttf"
|
||||
post_script_name: "Rubik-BoldItalic"
|
||||
full_name: "Rubik Bold Italic"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "normal"
|
||||
weight: 900
|
||||
filename: "Rubik-Black.ttf"
|
||||
post_script_name: "Rubik-Black"
|
||||
full_name: "Rubik Black"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "italic"
|
||||
weight: 900
|
||||
filename: "Rubik-BlackItalic.ttf"
|
||||
post_script_name: "Rubik-BlackItalic"
|
||||
full_name: "Rubik Black Italic"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
subsets: "cyrillic"
|
||||
subsets: "cyrillic-ext"
|
||||
subsets: "hebrew"
|
||||
subsets: "latin"
|
||||
subsets: "latin-ext"
|
||||
subsets: "menu"
|
||||
BIN
src/newsreader/assets/fonts/Rubik-Black.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-Black.ttf
Executable file
Binary file not shown.
BIN
src/newsreader/assets/fonts/Rubik-BlackItalic.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-BlackItalic.ttf
Executable file
Binary file not shown.
BIN
src/newsreader/assets/fonts/Rubik-Bold.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-Bold.ttf
Executable file
Binary file not shown.
BIN
src/newsreader/assets/fonts/Rubik-BoldItalic.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-BoldItalic.ttf
Executable file
Binary file not shown.
BIN
src/newsreader/assets/fonts/Rubik-Italic.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-Italic.ttf
Executable file
Binary file not shown.
BIN
src/newsreader/assets/fonts/Rubik-Light.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-Light.ttf
Executable file
Binary file not shown.
BIN
src/newsreader/assets/fonts/Rubik-LightItalic.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-LightItalic.ttf
Executable file
Binary file not shown.
BIN
src/newsreader/assets/fonts/Rubik-Medium.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-Medium.ttf
Executable file
Binary file not shown.
BIN
src/newsreader/assets/fonts/Rubik-MediumItalic.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-MediumItalic.ttf
Executable file
Binary file not shown.
BIN
src/newsreader/assets/fonts/Rubik-Regular.ttf
Executable file
BIN
src/newsreader/assets/fonts/Rubik-Regular.ttf
Executable file
Binary file not shown.
|
|
@ -3,7 +3,7 @@ import os
|
|||
from celery import Celery
|
||||
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.docker")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev")
|
||||
|
||||
app = Celery("newsreader")
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
|
|
|
|||
|
|
@ -1,25 +1,20 @@
|
|||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
from newsreader.conf.utils import get_env, get_root_dir
|
||||
from pathlib import Path
|
||||
|
||||
from .version import get_current_version
|
||||
|
||||
|
||||
load_dotenv()
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
|
||||
DJANGO_PROJECT_DIR = os.path.join(BASE_DIR, "src", "newsreader")
|
||||
|
||||
try:
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
except ImportError:
|
||||
CeleryIntegration = None
|
||||
DjangoIntegration = None
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
|
||||
# SECURITY WARNING: don"t run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
|
||||
BASE_DIR = get_root_dir()
|
||||
DJANGO_PROJECT_DIR = BASE_DIR / "src" / "newsreader"
|
||||
|
||||
DEBUG = False
|
||||
|
||||
ALLOWED_HOSTS = get_env("ALLOWED_HOSTS", split=",", default=["127.0.0.1", "localhost"])
|
||||
INTERNAL_IPS = get_env("INTERNAL_IPS", split=",", default=["127.0.0.1", "localhost"])
|
||||
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
|
||||
INTERNAL_IPS = ["127.0.0.1", "localhost"]
|
||||
|
||||
# Application definition
|
||||
INSTALLED_APPS = [
|
||||
|
|
@ -32,8 +27,10 @@ INSTALLED_APPS = [
|
|||
"django.forms",
|
||||
# third party apps
|
||||
"rest_framework",
|
||||
"drf_yasg",
|
||||
"celery",
|
||||
"django_celery_beat",
|
||||
"registration",
|
||||
"axes",
|
||||
# app modules
|
||||
"newsreader.accounts",
|
||||
|
|
@ -43,8 +40,6 @@ INSTALLED_APPS = [
|
|||
"newsreader.news.collection",
|
||||
]
|
||||
|
||||
SECRET_KEY = get_env("DJANGO_SECRET_KEY", default="")
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"axes.backends.AxesBackend",
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
|
|
@ -68,10 +63,11 @@ FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
|
|||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [DJANGO_PROJECT_DIR / "templates"],
|
||||
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
|
|
@ -82,30 +78,31 @@ TEMPLATES = [
|
|||
|
||||
WSGI_APPLICATION = "newsreader.wsgi.application"
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"HOST": get_env("POSTGRES_HOST", default=""),
|
||||
"PORT": get_env("POSTGRES_PORT", default=""),
|
||||
"NAME": get_env("POSTGRES_DB", default=""),
|
||||
"USER": get_env("POSTGRES_USER", default=""),
|
||||
"PASSWORD": get_env("POSTGRES_PASSWORD", default=""),
|
||||
"HOST": os.environ.get("POSTGRES_HOST", ""),
|
||||
"NAME": os.environ.get("POSTGRES_NAME", "newsreader"),
|
||||
"USER": os.environ.get("POSTGRES_USER"),
|
||||
"PASSWORD": os.environ.get("POSTGRES_PASSWORD"),
|
||||
}
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "localhost:11211",
|
||||
},
|
||||
"axes": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "localhost:11211",
|
||||
},
|
||||
}
|
||||
|
||||
# Logging
|
||||
# https://docs.djangoproject.com/en/2.2/topics/logging/#configuring-logging
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
|
|
@ -119,46 +116,48 @@ LOGGING = {
|
|||
"format": "[{server_time}] {message}",
|
||||
"style": "{",
|
||||
},
|
||||
"syslog": {
|
||||
"class": "logging.Formatter",
|
||||
"format": "[newsreader] {message}",
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"level": "INFO",
|
||||
"filters": ["require_debug_true"],
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "timestamped",
|
||||
},
|
||||
"file": {
|
||||
"level": "DEBUG",
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": BASE_DIR / "logs" / "newsreader.log",
|
||||
"backupCount": 5,
|
||||
"maxBytes": 50000000, # 50 mB
|
||||
"formatter": "timestamped",
|
||||
},
|
||||
"celery": {
|
||||
"level": "INFO",
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": BASE_DIR / "logs" / "celery.log",
|
||||
"backupCount": 5,
|
||||
"maxBytes": 50000000, # 50 mB
|
||||
"formatter": "timestamped",
|
||||
"filters": ["require_debug_false"],
|
||||
"class": "logging.handlers.SysLogHandler",
|
||||
"formatter": "syslog",
|
||||
"address": "/dev/log",
|
||||
},
|
||||
"syslog": {
|
||||
"level": "ERROR",
|
||||
"filters": ["require_debug_false"],
|
||||
"class": "logging.handlers.SysLogHandler",
|
||||
"formatter": "syslog",
|
||||
"address": "/dev/log",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"django": {"handlers": ["console"], "level": "INFO"},
|
||||
"django": {"handlers": ["console", "syslog"], "level": "INFO"},
|
||||
"django.server": {
|
||||
"handlers": ["console"],
|
||||
"handlers": ["console", "syslog"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"celery.task": {"handlers": ["console", "celery"], "level": "INFO"},
|
||||
"newsreader": {
|
||||
"handlers": ["console", "file"],
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
"celery": {"handlers": ["celery", "console"], "level": "INFO"},
|
||||
"newsreader": {"handlers": ["syslog", "console"], "level": "INFO"},
|
||||
},
|
||||
}
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
|
||||
|
|
@ -173,6 +172,8 @@ AUTH_USER_MODEL = "accounts.User"
|
|||
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/2.2/topics/i18n/
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "Europe/Amsterdam"
|
||||
|
|
@ -180,31 +181,36 @@ USE_I18N = True
|
|||
USE_L10N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/2.2/howto/static-files/
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = BASE_DIR / "static"
|
||||
STATICFILES_DIRS = (DJANGO_PROJECT_DIR / "static",)
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
||||
STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static")]
|
||||
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-STATICFILES_FINDERS
|
||||
STATICFILES_FINDERS = [
|
||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||
]
|
||||
|
||||
# Email
|
||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
DEFAULT_FROM_EMAIL = "newsreader@rss.fudiggity.nl"
|
||||
|
||||
DEFAULT_FROM_EMAIL = get_env(
|
||||
"EMAIL_DEFAULT_FROM", required=False, default="webmaster@localhost"
|
||||
# Project settings
|
||||
VERSION = get_current_version()
|
||||
|
||||
# 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/"
|
||||
)
|
||||
|
||||
EMAIL_HOST = get_env("EMAIL_HOST", required=False, default="localhost")
|
||||
EMAIL_PORT = get_env("EMAIL_PORT", cast=int, required=False, default=25)
|
||||
|
||||
EMAIL_HOST_USER = get_env("EMAIL_HOST_USER", required=False, default="")
|
||||
EMAIL_HOST_PASSWORD = get_env("EMAIL_HOST_PASSWORD", required=False, default="")
|
||||
|
||||
EMAIL_USE_TLS = get_env("EMAIL_USE_TLS", required=False, default=False)
|
||||
EMAIL_USE_SSL = get_env("EMAIL_USE_SSL", required=False, default=False)
|
||||
|
||||
# Twitter integration
|
||||
TWITTER_CONSUMER_ID = "CONSUMER_ID"
|
||||
TWITTER_CONSUMER_SECRET = "CONSUMER_SECRET"
|
||||
TWITTER_REDIRECT_URL = (
|
||||
"http://127.0.0.1:8000/accounts/settings/integrations/twitter/callback/"
|
||||
)
|
||||
|
||||
# Third party settings
|
||||
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
|
||||
|
|
@ -221,12 +227,7 @@ REST_FRAMEWORK = {
|
|||
"rest_framework.permissions.IsAuthenticated",
|
||||
"newsreader.accounts.permissions.IsOwner",
|
||||
),
|
||||
"DEFAULT_RENDERER_CLASSES": (
|
||||
"djangorestframework_camel_case.render.CamelCaseJSONRenderer",
|
||||
),
|
||||
"DEFAULT_PARSER_CLASSES": (
|
||||
"djangorestframework_camel_case.parser.CamelCaseJSONParser",
|
||||
),
|
||||
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
|
||||
}
|
||||
|
||||
SWAGGER_SETTINGS = {
|
||||
|
|
@ -237,15 +238,8 @@ SWAGGER_SETTINGS = {
|
|||
|
||||
# Celery
|
||||
# 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_BROKER_URL = "amqp://guest@rabbitmq:5672"
|
||||
|
||||
# Sentry
|
||||
SENTRY_CONFIG = {
|
||||
"dsn": get_env("SENTRY_DSN", default="", required=False),
|
||||
"send_default_pii": False,
|
||||
"integrations": [DjangoIntegration(), CeleryIntegration()]
|
||||
if DjangoIntegration and CeleryIntegration
|
||||
else [],
|
||||
}
|
||||
REGISTRATION_OPEN = True
|
||||
REGISTRATION_AUTO_LOGIN = True
|
||||
ACCOUNT_ACTIVATION_DAYS = 7
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,35 +1,19 @@
|
|||
from .base import * # noqa: F403
|
||||
from .utils import get_current_version
|
||||
from .base import * # isort:skip
|
||||
|
||||
|
||||
SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl"
|
||||
|
||||
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405
|
||||
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
||||
|
||||
|
||||
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
|
||||
"django.template.context_processors.debug",
|
||||
)
|
||||
|
||||
# Project settings
|
||||
VERSION = get_current_version()
|
||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"]
|
||||
|
||||
# Third party settings
|
||||
AXES_FAILURE_LIMIT = 50
|
||||
AXES_COOLOFF_TIME = None
|
||||
|
||||
try:
|
||||
# Optionally use sentry integration
|
||||
from sentry_sdk import init as sentry_init
|
||||
|
||||
from .local import * # noqa
|
||||
|
||||
SENTRY_CONFIG.update({"release": VERSION}) # noqa: F405
|
||||
|
||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
||||
except ImportError:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -1,43 +1,29 @@
|
|||
from .base import * # noqa: F403
|
||||
from .utils import get_current_version
|
||||
from .dev import * # isort:skip
|
||||
|
||||
|
||||
DEBUG = True
|
||||
SECRET_KEY = "=q(ztyo)b6noom#a164g&s9vcj1aawa^g#ing_ir99=_zl4g&$"
|
||||
|
||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
||||
|
||||
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405
|
||||
|
||||
LOGGING["loggers"].update( # noqa: F405
|
||||
{
|
||||
"celery.task": {"handlers": ["console", "celery"], "level": "DEBUG"},
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": "newsreader",
|
||||
"USER": "newsreader",
|
||||
"PASSWORD": "newsreader",
|
||||
"HOST": "db",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
"axes": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
|
||||
"django.template.context_processors.debug",
|
||||
)
|
||||
|
||||
# Project settings
|
||||
VERSION = get_current_version()
|
||||
ENVIRONMENT = "docker"
|
||||
|
||||
# Third party settings
|
||||
# Axes
|
||||
AXES_FAILURE_LIMIT = 50
|
||||
AXES_COOLOFF_TIME = None
|
||||
|
||||
try:
|
||||
# Optionally use sentry integration
|
||||
from sentry_sdk import init as sentry_init
|
||||
|
||||
from .local import * # noqa
|
||||
|
||||
SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT}) # noqa: F405
|
||||
|
||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
||||
except ImportError:
|
||||
pass
|
||||
# Celery
|
||||
# https://docs.celeryproject.org/en/latest/userguide/configuration.html
|
||||
CELERY_BROKER_URL = "amqp://guest:guest@rabbitmq:5672//"
|
||||
|
|
|
|||
19
src/newsreader/conf/gitlab.py
Normal file
19
src/newsreader/conf/gitlab.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from .base import * # isort:skip
|
||||
|
||||
|
||||
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",
|
||||
},
|
||||
}
|
||||
|
|
@ -1,32 +1,76 @@
|
|||
from newsreader.conf.utils import get_env
|
||||
import os
|
||||
|
||||
from .base import * # noqa: F403
|
||||
from .utils import get_current_version
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
from .base import * # isort:skip
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DEBUG = False
|
||||
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
|
||||
ALLOWED_HOSTS = ["rss.fudiggity.nl"]
|
||||
ADMINS = [
|
||||
("", email) for email in get_env("ADMINS", split=",", required=False, default=[])
|
||||
("", email)
|
||||
for email in os.getenv("ADMINS", "").split(",")
|
||||
if os.environ.get("ADMINS")
|
||||
]
|
||||
|
||||
# Project settings
|
||||
VERSION = get_current_version(debug=False)
|
||||
ENVIRONMENT = "production"
|
||||
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",
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Reddit integration
|
||||
REDDIT_CLIENT_ID = os.environ.get("REDDIT_CLIENT_ID", "")
|
||||
REDDIT_CLIENT_SECRET = os.environ.get("REDDIT_CLIENT_SECRET", "")
|
||||
REDDIT_REDIRECT_URL = os.environ.get("REDDIT_CALLBACK_URL", "")
|
||||
|
||||
# Twitter integration
|
||||
TWITTER_CONSUMER_ID = os.environ.get("TWITTER_CONSUMER_ID", "")
|
||||
TWITTER_CONSUMER_SECRET = os.environ.get("TWITTER_CONSUMER_SECRET", "")
|
||||
TWITTER_REDIRECT_URL = os.environ.get("TWITTER_REDIRECT_URL", "")
|
||||
|
||||
# Third party settings
|
||||
AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler"
|
||||
|
||||
REGISTRATION_OPEN = False
|
||||
|
||||
# Optionally use sentry integration
|
||||
try:
|
||||
from sentry_sdk import init as sentry_init
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
SENTRY_CONFIG.update( # noqa: F405
|
||||
{"release": VERSION, "environment": ENVIRONMENT, "debug": False}
|
||||
sentry_init(
|
||||
dsn=os.environ.get("SENTRY_DSN"),
|
||||
integrations=[DjangoIntegration(), CeleryIntegration()],
|
||||
send_default_pii=False,
|
||||
release=VERSION,
|
||||
)
|
||||
|
||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
||||
except ImportError:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Type
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_env(
|
||||
name: str,
|
||||
cast: Type = str,
|
||||
required: bool = True,
|
||||
default: Any = None,
|
||||
split: str = "",
|
||||
) -> Any:
|
||||
if cast is not str and split:
|
||||
raise TypeError(f"Split is not possible with {cast}")
|
||||
|
||||
value = os.getenv(name)
|
||||
|
||||
if not value:
|
||||
if required:
|
||||
logger.warning(f"Missing environment variable: {name}")
|
||||
|
||||
return default
|
||||
|
||||
bool_mapping = {"yes": True, "true": True, "false": False, "no": False}
|
||||
|
||||
if cast is bool:
|
||||
_value = bool_mapping.get(value.lower())
|
||||
|
||||
if not value:
|
||||
raise ValueError(f"Unknown boolean value: {_value}")
|
||||
|
||||
return _value
|
||||
|
||||
value = value if not cast else cast(value)
|
||||
return value if not split else value.split(split)
|
||||
|
||||
|
||||
def get_current_version(debug: bool = True) -> str:
|
||||
version = get_env("VERSION", required=False)
|
||||
|
||||
if version:
|
||||
return version
|
||||
|
||||
if debug:
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["git", "show", "--no-patch", "--format=%H"], universal_newlines=True
|
||||
)
|
||||
return output.strip()
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return ""
|
||||
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["git", "describe", "--tags"], universal_newlines=True
|
||||
)
|
||||
return output.strip()
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return ""
|
||||
|
||||
|
||||
ROOT_MARKERS = ("pyproject.toml", "package.json", "README.md", "CHANGELOG.md")
|
||||
|
||||
|
||||
def get_root_dir() -> Path:
|
||||
file = Path(__file__)
|
||||
return _traverse_dirs(file.parent, ROOT_MARKERS)
|
||||
|
||||
|
||||
def _traverse_dirs(path: Path, root_markers: Iterable[str]) -> Path:
|
||||
if path.parent == path:
|
||||
raise OSError("Root directory detected")
|
||||
|
||||
files = (file.name for file in path.iterdir())
|
||||
|
||||
if not any((marker for marker in root_markers if marker in files)):
|
||||
return _traverse_dirs(path.parent, root_markers)
|
||||
|
||||
return path
|
||||
15
src/newsreader/conf/version.py
Normal file
15
src/newsreader/conf/version.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import os
|
||||
import subprocess
|
||||
|
||||
|
||||
def get_current_version():
|
||||
if "VERSION" in os.environ:
|
||||
return os.environ["VERSION"]
|
||||
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["git", "describe", "--tags"], universal_newlines=True
|
||||
)
|
||||
return output.strip()
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return ""
|
||||
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
|||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
name = "newsreader.core"
|
||||
name = "core"
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue