Compare commits
No commits in common. "main" and "0.2.5.5" have entirely different histories.
346 changed files with 28723 additions and 16930 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/
|
lib/
|
||||||
!src/newsreader/scss/lib
|
!src/newsreader/scss/lib
|
||||||
!src/newsreader/js/lib
|
|
||||||
|
|
||||||
lib64/
|
lib64/
|
||||||
parts/
|
parts/
|
||||||
|
|
@ -115,7 +114,7 @@ celerybeat-schedule
|
||||||
*.sage.py
|
*.sage.py
|
||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
*.env
|
.env
|
||||||
.venv
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
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,60 @@
|
||||||
|
version: '3'
|
||||||
volumes:
|
volumes:
|
||||||
logs:
|
|
||||||
media:
|
|
||||||
postgres-data:
|
postgres-data:
|
||||||
static-files:
|
static-files:
|
||||||
|
node-modules:
|
||||||
x-db-connection-env: &db-connection-env
|
|
||||||
POSTGRES_HOST: ${POSTGRES_HOST:-db}
|
|
||||||
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
|
|
||||||
POSTGRES_DB: &pg-database ${POSTGRES_DB:-newsreader}
|
|
||||||
POSTGRES_USER: &pg-user ${POSTGRES_USER:-newsreader}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-newsreader}
|
|
||||||
|
|
||||||
x-db-env: &db-env
|
|
||||||
<<: *db-connection-env
|
|
||||||
PGUSER: *pg-user
|
|
||||||
PGDATABASE: *pg-database
|
|
||||||
|
|
||||||
x-django-env: &django-env
|
|
||||||
<<: *db-connection-env
|
|
||||||
|
|
||||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,django}
|
|
||||||
INTERNAL_IPS: ${INTERNAL_IPS:-localhost,127.0.0.1,django}
|
|
||||||
|
|
||||||
# see token_urlsafe from python's secret module to generate one
|
|
||||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-Ojg68lYsP3kq2r5JgozUzKVSRFywm17BTMS5iwpLM44}
|
|
||||||
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-newsreader.conf.production}
|
|
||||||
|
|
||||||
ADMINS: ${ADMINS:-""}
|
|
||||||
|
|
||||||
VERSION: ${VERSION:-""}
|
|
||||||
|
|
||||||
# Email
|
|
||||||
EMAIL_HOST: ${EMAIL_HOST:-localhost}
|
|
||||||
EMAIL_PORT: ${EMAIL_PORT:-25}
|
|
||||||
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-""}
|
|
||||||
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-""}
|
|
||||||
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-no}
|
|
||||||
EMAIL_USE_SSL: ${EMAIL_USE_SSL:-no}
|
|
||||||
EMAIL_DEFAULT_FROM: ${EMAIL_DEFAULT_FROM:-webmaster@localhost}
|
|
||||||
|
|
||||||
# Sentry
|
|
||||||
SENTRY_DSN: ${SENTRY_DSN:-""}
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
|
image: postgres
|
||||||
environment:
|
environment:
|
||||||
<<: *db-env
|
POSTGRES_DB: "newsreader"
|
||||||
image: postgres:15
|
POSTGRES_USER: "newsreader"
|
||||||
healthcheck:
|
POSTGRES_PASSWORD: "newsreader"
|
||||||
test: /usr/bin/pg_isready
|
|
||||||
start_period: 10s
|
|
||||||
interval: 5s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 10
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: rabbitmq:4
|
image: rabbitmq:3.7
|
||||||
|
|
||||||
memcached:
|
memcached:
|
||||||
image: memcached:1.6
|
image: memcached:1.5.22
|
||||||
|
ports:
|
||||||
|
- "11211:11211"
|
||||||
entrypoint:
|
entrypoint:
|
||||||
- memcached
|
- memcached
|
||||||
- -m 64
|
- -m 64
|
||||||
|
|
||||||
django:
|
|
||||||
build: &app-build
|
|
||||||
context: .
|
|
||||||
target: production
|
|
||||||
environment:
|
|
||||||
<<: *django-env
|
|
||||||
entrypoint: ["/bin/sh", "/app/bin/docker-entrypoint.sh"]
|
|
||||||
command: |
|
|
||||||
uv run --no-sync --
|
|
||||||
gunicorn
|
|
||||||
--bind 0.0.0.0:8000
|
|
||||||
--workers 3
|
|
||||||
--chdir /app/src/
|
|
||||||
newsreader.wsgi:application
|
|
||||||
healthcheck:
|
|
||||||
test: /usr/bin/curl --fail http://django:8000 || exit 1
|
|
||||||
start_period: 10s
|
|
||||||
interval: 10s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
depends_on:
|
|
||||||
memcached:
|
|
||||||
condition: service_started
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
|
||||||
- logs:/app/logs
|
|
||||||
- media:/app/media
|
|
||||||
- static-files:/app/static
|
|
||||||
|
|
||||||
celery:
|
celery:
|
||||||
build:
|
build:
|
||||||
<<: *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:
|
environment:
|
||||||
<<: *django-env
|
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
|
||||||
command: |
|
|
||||||
uv run --no-sync --
|
|
||||||
celery
|
|
||||||
--app newsreader
|
|
||||||
--workdir /app/src/
|
|
||||||
worker --loglevel INFO
|
|
||||||
--concurrency 2
|
|
||||||
--beat
|
|
||||||
--scheduler django
|
|
||||||
-n worker1@%h
|
|
||||||
-n worker2@%h
|
|
||||||
healthcheck:
|
|
||||||
test: uv run --no-sync -- celery --app newsreader status || exit 1
|
|
||||||
start_period: 10s
|
|
||||||
interval: 10s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
depends_on:
|
depends_on:
|
||||||
rabbitmq:
|
- rabbitmq
|
||||||
condition: service_started
|
|
||||||
django:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
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
|
||||||
|
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
|
||||||
24
gitlab-ci/deploy.yml
Normal file
24
gitlab-ci/deploy.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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
|
||||||
|
- mkdir /root/.ssh
|
||||||
|
- echo "$DEPLOY_HOST_KEY" > /root/.ssh/known_hosts
|
||||||
|
- echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key
|
||||||
|
- mkdir /root/.vaults
|
||||||
|
- echo "$VAULT_PASSWORD" > /root/.vaults/newsreader && chmod 0600 /root/.vaults/newsreader
|
||||||
|
script:
|
||||||
|
- >
|
||||||
|
ansible-playbook deployment/playbook.yml
|
||||||
|
--inventory deployment/apps.yml
|
||||||
|
--limit newsreader
|
||||||
|
--user ansible
|
||||||
|
--private-key deployment/deploy_key
|
||||||
|
--vault-password-file /root/.vaults/newsreader
|
||||||
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,
|
||||||
|
};
|
||||||
17455
package-lock.json
generated
17455
package-lock.json
generated
File diff suppressed because it is too large
Load diff
76
package.json
76
package.json
|
|
@ -1,75 +1,63 @@
|
||||||
{
|
{
|
||||||
"name": "newsreader",
|
"name": "newsreader",
|
||||||
"version": "0.5.3",
|
"version": "0.1.0",
|
||||||
"description": "Application for viewing RSS feeds",
|
"description": "Application for viewing RSS feeds",
|
||||||
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "npx prettier \"src/newsreader/js/**/*.js\" --check",
|
"lint": "npx prettier \"src/newsreader/js/**/*.js\" --check",
|
||||||
"format": "npx prettier \"src/newsreader/js/**/*.js\" --write",
|
"format": "npx prettier \"src/newsreader/js/**/*.js\" --write",
|
||||||
|
"build": "npx webpack --config webpack.dev.babel.js",
|
||||||
"build:watch": "npx webpack --config webpack.dev.babel.js --watch",
|
"build:watch": "npx webpack --config webpack.dev.babel.js --watch",
|
||||||
"build:dev": "npx webpack --config webpack.dev.babel.js",
|
|
||||||
"build:prod": "npx webpack --config webpack.prod.babel.js",
|
"build:prod": "npx webpack --config webpack.prod.babel.js",
|
||||||
"test": "npx jest",
|
"test": "npx jest",
|
||||||
"test:watch": "npm test -- --watch"
|
"test:watch": "npm test -- --watch"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "forgejo.fudiggity.nl:sonny/newsreader"
|
"url": "[git@git.fudiggity.nl:5000]:sonny/newsreader.git"
|
||||||
},
|
},
|
||||||
"author": "Sonny",
|
"author": "Sonny",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^5.15.2",
|
"css.gg": "^1.0.6",
|
||||||
"js-cookie": "^2.2.1",
|
"js-cookie": "^2.2.1",
|
||||||
"lodash": "^4.17.20",
|
"lodash": "^4.17.15",
|
||||||
"object-assign": "^4.1.1",
|
"object-assign": "^4.1.1",
|
||||||
"react-redux": "^7.2.2",
|
"react-redux": "^7.1.3",
|
||||||
"redux": "^4.0.5",
|
"redux": "^4.0.5",
|
||||||
"redux-logger": "^3.0.6",
|
"redux-logger": "^3.0.6",
|
||||||
"redux-thunk": "^2.3.0"
|
"redux-thunk": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.12.13",
|
"@babel/core": "^7.7.7",
|
||||||
"@babel/plugin-proposal-class-properties": "^7.12.13",
|
"@babel/plugin-proposal-class-properties": "^7.7.4",
|
||||||
"@babel/plugin-proposal-function-bind": "^7.12.13",
|
"@babel/plugin-proposal-function-bind": "^7.7.4",
|
||||||
"@babel/plugin-transform-react-jsx": "^7.12.13",
|
"@babel/plugin-syntax-dynamic-import": "^7.7.4",
|
||||||
"@babel/preset-env": "^7.12.13",
|
"@babel/plugin-syntax-function-bind": "^7.7.4",
|
||||||
"@babel/register": "^7.12.13",
|
"@babel/plugin-transform-react-jsx": "^7.7.7",
|
||||||
"@babel/runtime": "^7.12.13",
|
"@babel/plugin-transform-runtime": "^7.7.6",
|
||||||
"babel-jest": "^29.7.0",
|
"@babel/preset-env": "^7.7.7",
|
||||||
"babel-loader": "^8.2.2",
|
"@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",
|
"clean-webpack-plugin": "^3.0.0",
|
||||||
"css-loader": "^7.1.2",
|
"css-loader": "^3.4.2",
|
||||||
"fetch-mock": "^8.3.2",
|
"fetch-mock": "^8.3.1",
|
||||||
"jest": "^29.7.0",
|
"file-loader": "^6.0.0",
|
||||||
"mini-css-extract-plugin": "^2.9.1",
|
"jest": "^24.9.0",
|
||||||
"node-fetch": "^2.6.1",
|
"mini-css-extract-plugin": "^0.9.0",
|
||||||
|
"node-fetch": "^2.6.0",
|
||||||
|
"node-sass": "^4.14.1",
|
||||||
"prettier": "^1.19.1",
|
"prettier": "^1.19.1",
|
||||||
"react": "^16.14.0",
|
"react": "^16.12.0",
|
||||||
"react-dom": "^16.14.0",
|
"react-dom": "^16.12.0",
|
||||||
"redux-mock-store": "^1.5.4",
|
"redux-mock-store": "^1.5.4",
|
||||||
"sass": "^1.52.1",
|
|
||||||
"sass-loader": "^8.0.2",
|
"sass-loader": "^8.0.2",
|
||||||
"style-loader": "^2.0.0",
|
"style-loader": "^1.1.3",
|
||||||
"url-loader": "^4.1.1",
|
"url-loader": "^4.1.0",
|
||||||
"webpack": "^5.94.0",
|
"webpack": "^4.42.1",
|
||||||
"webpack-cli": "^5.1.4",
|
"webpack-cli": "^3.3.11",
|
||||||
"webpack-merge": "^4.2.2"
|
"webpack-merge": "^4.2.2"
|
||||||
},
|
|
||||||
"prettier": {
|
|
||||||
"semi": true,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"singleQuote": true,
|
|
||||||
"printWidth": 90,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"useTabs": false,
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"arrowParens": "avoid"
|
|
||||||
},
|
|
||||||
"jest": {
|
|
||||||
"roots": [
|
|
||||||
"src/newsreader/js/tests/"
|
|
||||||
],
|
|
||||||
"clearMocks": true,
|
|
||||||
"coverageDirectory": "coverage"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1189
poetry.lock
generated
Normal file
1189
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
115
pyproject.toml
115
pyproject.toml
|
|
@ -1,81 +1,42 @@
|
||||||
[project]
|
[tool.poetry]
|
||||||
name = "newsreader"
|
name = "newsreader"
|
||||||
version = "0.5.3"
|
version = "0.2"
|
||||||
authors = [{ name = "Sonny" }]
|
description = "Webapplication for reading RSS feeds"
|
||||||
license = { text = "GPL-3.0" }
|
authors = ["Sonny <sonnyba871@gmail.com>"]
|
||||||
requires-python = ">=3.11"
|
license = "GPL-3.0"
|
||||||
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",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependency-groups]
|
[tool.poetry.dependencies]
|
||||||
test-tools = ["ruff", "factory_boy", "freezegun"]
|
python = "^3.7"
|
||||||
development = [
|
bleach = "^3.1.4"
|
||||||
"django-debug-toolbar",
|
Django = "^3.0.5"
|
||||||
"django-stubs",
|
celery = "^4.4.2"
|
||||||
"django-extensions",
|
beautifulsoup4 = "^4.9.0"
|
||||||
]
|
django-axes = "^5.3.1"
|
||||||
ci = ["coverage~=7.6.1"]
|
django-celery-beat = "^2.0.0"
|
||||||
production = ["gunicorn~=23.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"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
sentry = ["sentry-sdk~=2.0"]
|
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]
|
[build-system]
|
||||||
environments = ["sys_platform == 'linux'"]
|
requires = ["poetry>=0.12"]
|
||||||
default-groups = ["test-tools"]
|
build-backend = "poetry.masonry.api"
|
||||||
|
|
||||||
[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"
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
"""Django's command-line utility for administrative tasks."""
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.docker")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev")
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from django import forms
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
||||||
from django.contrib.auth.forms import UserChangeForm
|
from django.contrib.auth.forms import UserChangeForm
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from newsreader.accounts.models import User
|
from newsreader.accounts.models import User
|
||||||
|
|
||||||
|
|
@ -11,6 +11,8 @@ class UserAdminForm(UserChangeForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
widgets = {
|
widgets = {
|
||||||
"email": forms.EmailInput(attrs={"size": "50"}),
|
"email": forms.EmailInput(attrs={"size": "50"}),
|
||||||
|
"reddit_access_token": forms.TextInput(attrs={"size": "90"}),
|
||||||
|
"reddit_refresh_token": forms.TextInput(attrs={"size": "90"}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -28,6 +30,10 @@ class UserAdmin(DjangoUserAdmin):
|
||||||
_("User settings"),
|
_("User settings"),
|
||||||
{"fields": ("email", "password", "first_name", "last_name", "is_active")},
|
{"fields": ("email", "password", "first_name", "last_name", "is_active")},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
_("Reddit settings"),
|
||||||
|
{"fields": ("reddit_access_token", "reddit_refresh_token")},
|
||||||
|
),
|
||||||
(
|
(
|
||||||
_("Permission settings"),
|
_("Permission settings"),
|
||||||
{"classes": ("collapse",), "fields": ("is_staff", "is_superuser")},
|
{"classes": ("collapse",), "fields": ("is_staff", "is_superuser")},
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class AccountsConfig(AppConfig):
|
class AccountsConfig(AppConfig):
|
||||||
name = "newsreader.accounts"
|
name = "accounts"
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from newsreader.accounts.models import User
|
from newsreader.accounts.models import User
|
||||||
from newsreader.core.forms import CheckboxInput
|
|
||||||
|
|
||||||
|
|
||||||
class UserSettingsForm(forms.ModelForm):
|
class UserSettingsForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ("first_name", "last_name", "auto_mark_read")
|
fields = ("first_name", "last_name")
|
||||||
widgets = {"auto_mark_read": CheckboxInput}
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0001_initial")]
|
dependencies = [("accounts", "0001_initial")]
|
||||||
|
|
||||||
operations = [migrations.RemoveField(model_name="user", name="username")]
|
operations = [migrations.RemoveField(model_name="user", name="username")]
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import newsreader.accounts.models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0002_remove_user_username")]
|
dependencies = [("accounts", "0002_remove_user_username")]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0003_auto_20190714_1417")]
|
dependencies = [("accounts", "0003_auto_20190714_1417")]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0004_auto_20190714_1501")]
|
dependencies = [("accounts", "0004_auto_20190714_1501")]
|
||||||
|
|
||||||
operations = [migrations.RemoveField(model_name="user", name="task_interval")]
|
operations = [migrations.RemoveField(model_name="user", name="task_interval")]
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0005_remove_user_task_interval")]
|
dependencies = [("accounts", "0005_remove_user_task_interval")]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0006_auto_20191116_1253")]
|
dependencies = [("accounts", "0006_auto_20191116_1253")]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ def update_task_name(apps, schema_editor):
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0007_auto_20191116_1255")]
|
dependencies = [("accounts", "0007_auto_20191116_1255")]
|
||||||
|
|
||||||
operations = [migrations.RunPython(update_task_name)]
|
operations = [migrations.RunPython(update_task_name)]
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("django_celery_beat", "0012_periodictask_expire_seconds"),
|
("django_celery_beat", "0012_periodictask_expire_seconds"),
|
||||||
("accounts", "0008_auto_20200422_2243"),
|
("accounts", "0008_auto_20200422_2243"),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0009_auto_20200524_1218")]
|
dependencies = [("accounts", "0009_auto_20200524_1218")]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
# Generated by Django 3.0.7 on 2020-09-13 19:01
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [("accounts", "0010_auto_20200603_2230")]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="user",
|
|
||||||
name="twitter_oauth_token",
|
|
||||||
field=models.CharField(blank=True, max_length=255, null=True),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="user",
|
|
||||||
name="twitter_oauth_token_secret",
|
|
||||||
field=models.CharField(blank=True, max_length=255, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
# Generated by Django 3.0.7 on 2020-09-26 15:34
|
|
||||||
|
|
||||||
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",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
|
import json
|
||||||
|
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.contrib.auth.models import UserManager as DjangoUserManager
|
from django.contrib.auth.models import UserManager as DjangoUserManager
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from django_celery_beat.models import PeriodicTask
|
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
||||||
|
|
||||||
|
|
||||||
class UserManager(DjangoUserManager):
|
class UserManager(DjangoUserManager):
|
||||||
|
|
@ -39,15 +41,18 @@ class UserManager(DjangoUserManager):
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
email = models.EmailField(_("email address"), unique=True)
|
email = models.EmailField(_("email address"), unique=True)
|
||||||
|
|
||||||
# settings
|
task = models.OneToOneField(
|
||||||
auto_mark_read = models.BooleanField(
|
PeriodicTask,
|
||||||
_("Auto read marking"),
|
on_delete=models.CASCADE,
|
||||||
default=True,
|
null=True,
|
||||||
help_text=_(
|
blank=True,
|
||||||
"Wether posts should be marked as read after x amount of seconds of reading"
|
editable=False,
|
||||||
),
|
verbose_name="collection task",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
reddit_refresh_token = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
reddit_access_token = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
|
||||||
username = None
|
username = None
|
||||||
|
|
||||||
objects = UserManager()
|
objects = UserManager()
|
||||||
|
|
@ -55,8 +60,24 @@ class User(AbstractUser):
|
||||||
USERNAME_FIELD = "email"
|
USERNAME_FIELD = "email"
|
||||||
REQUIRED_FIELDS = []
|
REQUIRED_FIELDS = []
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
tasks = PeriodicTask.objects.filter(name__contains=self.email)
|
super().save(*args, **kwargs)
|
||||||
tasks.delete()
|
|
||||||
|
|
||||||
|
if not self.task:
|
||||||
|
task_interval, _ = IntervalSchedule.objects.get_or_create(
|
||||||
|
every=1, period=IntervalSchedule.HOURS
|
||||||
|
)
|
||||||
|
|
||||||
|
self.task, _ = PeriodicTask.objects.get_or_create(
|
||||||
|
enabled=True,
|
||||||
|
interval=task_interval,
|
||||||
|
name=f"{self.email}-collection-task",
|
||||||
|
task="FeedTask",
|
||||||
|
args=json.dumps([self.pk]),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
self.task.delete()
|
||||||
return super().delete(*args, **kwargs)
|
return super().delete(*args, **kwargs)
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,29 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
<section class="section form__section--last">
|
<section class="section form__section--last">
|
||||||
<fieldset class="fieldset form__fieldset">
|
<fieldset class="fieldset form__fieldset">
|
||||||
{% include "components/form/confirm-button.html" %}
|
{% include "components/form/cancel-button.html" %}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<a class="link button button--primary" href="{% url 'accounts:password-change' %}">
|
<fieldset class="fieldset form__fieldset">
|
||||||
{% trans "Change password" %}
|
<a class="link button button--primary" href="{% url 'accounts:password-change' %}">
|
||||||
</a>
|
{% trans "Change password" %}
|
||||||
|
</a>
|
||||||
|
|
||||||
{% if favicon_task_allowed %}
|
{% include "components/form/confirm-button.html" %}
|
||||||
<a class="link button button--primary" href="{% url 'accounts:settings:favicon' %}">
|
|
||||||
{% trans "Fetch favicons" %}
|
{% if reddit_authorization_url %}
|
||||||
</a>
|
<a class="link button button--reddit" href="{{ reddit_authorization_url }}">
|
||||||
{% else %}
|
{% trans "Authorize Reddit account" %}
|
||||||
<button class="button button--primary button--disabled" disabled>
|
</a>
|
||||||
{% trans "Fetch favicons" %}
|
{% endif %}
|
||||||
</button>
|
|
||||||
{% endif %}
|
{% if reddit_refresh_url %}
|
||||||
</fieldset>
|
<a class="link button button--reddit" href="{{ reddit_refresh_url }}">
|
||||||
</section>
|
{% trans "Refresh Reddit access token" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
{% extends "sidebar.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main id="login--page" class="main" data-render-sidebar=true>
|
<main id="login--page" class="main">
|
||||||
<div class="main__container">
|
{% include "accounts/components/login-form.html" with form=form title="Login" confirm_text="Login" %}
|
||||||
{% include "accounts/components/login-form.html" with form=form title="Login" confirm_text="Login" %}
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
{% extends "sidebar.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% url 'accounts:settings:home' as cancel_url %}
|
<main id="password-change--page" class="main">
|
||||||
|
{% url 'accounts:settings' as cancel_url %}
|
||||||
<main id="password-change--page" class="main" data-render-sidebar=true>
|
{% include "components/form/form.html" with form=form title="Change password" confirm_text="Change password" cancel_url=cancel_url %}
|
||||||
<div class="main__container">
|
|
||||||
{% include "components/form/form.html" with form=form title="Change password" confirm_text="Change password" cancel_url=cancel_url %}
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
17
src/newsreader/accounts/templates/accounts/views/reddit.html
Normal file
17
src/newsreader/accounts/templates/accounts/views/reddit.html
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main id="settings--page" class="main">
|
||||||
|
<section class="section text-section">
|
||||||
|
{% if error %}
|
||||||
|
<h1 class="h1">Reddit authorization failed</h1>
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
{% elif access_token and refresh_token %}
|
||||||
|
<h1 class="h1">Reddit account is linked</h1>
|
||||||
|
<p>Your reddit account was successfully linked.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p><a href="{% url 'accounts:settings' %}">Return to settings page</a></p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
{% extends "sidebar.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main id="settings--page" class="main" data-render-sidebar=true>
|
<main id="settings--page" class="main">
|
||||||
<div class="main__container">
|
{% include "accounts/components/settings-form.html" with form=form title="User profile" confirm_text="Save" %}
|
||||||
{% include "accounts/components/settings-form.html" with form=form title="User profile" confirm_text="Save" %}
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ from django.utils.crypto import get_random_string
|
||||||
|
|
||||||
import factory
|
import factory
|
||||||
|
|
||||||
|
from registration.models import RegistrationProfile
|
||||||
|
|
||||||
from newsreader.accounts.models import User
|
from newsreader.accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -27,3 +29,11 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationProfileFactory(factory.django.DjangoModelFactory):
|
||||||
|
user = factory.SubFactory(UserFactory)
|
||||||
|
activation_key = factory.LazyFunction(get_activation_key)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = RegistrationProfile
|
||||||
|
|
|
||||||
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()
|
|
||||||
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)
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from newsreader.accounts.models import User
|
from newsreader.accounts.models import User
|
||||||
from newsreader.accounts.tests.factories import UserFactory
|
from newsreader.accounts.tests.factories import UserFactory
|
||||||
|
from newsreader.news.collection.exceptions import StreamTooManyException
|
||||||
|
|
||||||
|
|
||||||
class SettingsViewTestCase(TestCase):
|
class SettingsViewTestCase(TestCase):
|
||||||
|
|
@ -10,22 +16,146 @@ class SettingsViewTestCase(TestCase):
|
||||||
self.user = UserFactory(email="test@test.nl", password="test")
|
self.user = UserFactory(email="test@test.nl", password="test")
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
self.url = reverse("accounts:settings:home")
|
self.url = reverse("accounts:settings")
|
||||||
|
|
||||||
def test_simple(self):
|
def test_simple(self):
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 200)
|
self.assertEquals(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Authorize Reddit account")
|
||||||
|
|
||||||
def test_user_credential_change(self):
|
def test_user_credential_change(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("accounts:settings:home"),
|
reverse("accounts:settings"),
|
||||||
{"first_name": "First name", "last_name": "Last name"},
|
{"first_name": "First name", "last_name": "Last name"},
|
||||||
)
|
)
|
||||||
|
|
||||||
user = User.objects.get()
|
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.first_name, "First name")
|
||||||
self.assertEquals(user.last_name, "Last name")
|
self.assertEquals(user.last_name, "Last name")
|
||||||
|
|
||||||
|
def test_linked_reddit_account(self):
|
||||||
|
self.user.reddit_refresh_token = "test"
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
self.assertNotContains(response, "Authorize Reddit account")
|
||||||
|
|
||||||
|
|
||||||
|
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 settings 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)
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,22 @@
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
from django_celery_beat.models import PeriodicTask
|
||||||
|
|
||||||
from newsreader.accounts.tests.factories import UserFactory
|
from newsreader.accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
class UserTestCase(TestCase):
|
class UserTestCase(TestCase):
|
||||||
|
def test_task_is_created(self):
|
||||||
|
user = User.objects.create(email="durp@burp.nl", task=None)
|
||||||
|
task = PeriodicTask.objects.get(name=f"{user.email}-collection-task")
|
||||||
|
|
||||||
|
user.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEquals(task, user.task)
|
||||||
|
self.assertEquals(PeriodicTask.objects.count(), 1)
|
||||||
|
|
||||||
def test_task_is_deleted(self):
|
def test_task_is_deleted(self):
|
||||||
user = UserFactory(email="durp@burp.nl")
|
user = User.objects.create(email="durp@burp.nl", task=None)
|
||||||
|
|
||||||
interval = IntervalSchedule.objects.create(
|
|
||||||
every=1, period=IntervalSchedule.HOURS
|
|
||||||
)
|
|
||||||
PeriodicTask.objects.create(
|
|
||||||
name=f"{user.email}-feed", task="FeedTask", interval=interval
|
|
||||||
)
|
|
||||||
|
|
||||||
user.delete()
|
user.delete()
|
||||||
|
|
||||||
self.assertEquals(PeriodicTask.objects.count(), 0)
|
self.assertEquals(PeriodicTask.objects.count(), 0)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.urls import include, path
|
from django.urls import path
|
||||||
|
|
||||||
from newsreader.accounts.views import (
|
from newsreader.accounts.views import (
|
||||||
FaviconRedirectView,
|
ActivationCompleteView,
|
||||||
|
ActivationResendView,
|
||||||
|
ActivationView,
|
||||||
LoginView,
|
LoginView,
|
||||||
LogoutView,
|
LogoutView,
|
||||||
PasswordChangeView,
|
PasswordChangeView,
|
||||||
|
|
@ -10,21 +12,35 @@ from newsreader.accounts.views import (
|
||||||
PasswordResetConfirmView,
|
PasswordResetConfirmView,
|
||||||
PasswordResetDoneView,
|
PasswordResetDoneView,
|
||||||
PasswordResetView,
|
PasswordResetView,
|
||||||
|
RedditTemplateView,
|
||||||
|
RedditTokenRedirectView,
|
||||||
|
RegistrationClosedView,
|
||||||
|
RegistrationCompleteView,
|
||||||
|
RegistrationView,
|
||||||
SettingsView,
|
SettingsView,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
settings_patterns = [
|
|
||||||
# Misc
|
|
||||||
path("favicon/", login_required(FaviconRedirectView.as_view()), name="favicon"),
|
|
||||||
path("", login_required(SettingsView.as_view()), name="home"),
|
|
||||||
]
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Auth
|
|
||||||
path("login/", LoginView.as_view(), name="login"),
|
path("login/", LoginView.as_view(), name="login"),
|
||||||
path("logout/", LogoutView.as_view(), name="logout"),
|
path("logout/", LogoutView.as_view(), name="logout"),
|
||||||
# Password
|
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",
|
||||||
|
),
|
||||||
path("password-reset/", PasswordResetView.as_view(), name="password-reset"),
|
path("password-reset/", PasswordResetView.as_view(), name="password-reset"),
|
||||||
path(
|
path(
|
||||||
"password-reset/done/",
|
"password-reset/done/",
|
||||||
|
|
@ -46,6 +62,15 @@ urlpatterns = [
|
||||||
login_required(PasswordChangeView.as_view()),
|
login_required(PasswordChangeView.as_view()),
|
||||||
name="password-change",
|
name="password-change",
|
||||||
),
|
),
|
||||||
# Settings
|
path("settings/", login_required(SettingsView.as_view()), name="settings"),
|
||||||
path("settings/", include((settings_patterns, "settings"))),
|
path(
|
||||||
|
"settings/reddit/callback/",
|
||||||
|
login_required(RedditTemplateView.as_view()),
|
||||||
|
name="reddit-template",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"settings/reddit/refresh/",
|
||||||
|
login_required(RedditTokenRedirectView.as_view()),
|
||||||
|
name="reddit-refresh",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
210
src/newsreader/accounts/views.py
Normal file
210
src/newsreader/accounts/views.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth import views as django_views
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
from django.views.generic import RedirectView, TemplateView
|
||||||
|
from django.views.generic.edit import FormView, ModelFormMixin
|
||||||
|
|
||||||
|
from registration.backends.default import views as registration_views
|
||||||
|
|
||||||
|
from newsreader.accounts.forms import UserSettingsForm
|
||||||
|
from newsreader.accounts.models import User
|
||||||
|
from newsreader.news.collection.exceptions import StreamException
|
||||||
|
from newsreader.news.collection.reddit import (
|
||||||
|
get_reddit_access_token,
|
||||||
|
get_reddit_authorization_url,
|
||||||
|
)
|
||||||
|
from newsreader.news.collection.tasks import RedditTokenTask
|
||||||
|
|
||||||
|
|
||||||
|
class LoginView(django_views.LoginView):
|
||||||
|
template_name = "accounts/views/login.html"
|
||||||
|
success_url = reverse_lazy("index")
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutView(django_views.LogoutView):
|
||||||
|
next_page = reverse_lazy("accounts:login")
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# PasswordResetView sends the mail
|
||||||
|
# PasswordResetDoneView shows a success message for the above
|
||||||
|
# PasswordResetConfirmView checks the link the user clicked and
|
||||||
|
# prompts for a new password
|
||||||
|
# PasswordResetCompleteView shows a success message for the above
|
||||||
|
class PasswordResetView(django_views.PasswordResetView):
|
||||||
|
template_name = "password-reset/password-reset.html"
|
||||||
|
subject_template_name = "password-reset/password-reset-subject.txt"
|
||||||
|
email_template_name = "password-reset/password-reset-email.html"
|
||||||
|
success_url = reverse_lazy("accounts:password-reset-done")
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetDoneView(django_views.PasswordResetDoneView):
|
||||||
|
template_name = "password-reset/password-reset-done.html"
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetConfirmView(django_views.PasswordResetConfirmView):
|
||||||
|
template_name = "password-reset/password-reset-confirm.html"
|
||||||
|
success_url = reverse_lazy("accounts:password-reset-complete")
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetCompleteView(django_views.PasswordResetCompleteView):
|
||||||
|
template_name = "password-reset/password-reset-complete.html"
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChangeView(django_views.PasswordChangeView):
|
||||||
|
template_name = "accounts/views/password-change.html"
|
||||||
|
success_url = reverse_lazy("accounts:settings")
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsView(ModelFormMixin, FormView):
|
||||||
|
template_name = "accounts/views/settings.html"
|
||||||
|
success_url = reverse_lazy("accounts:settings")
|
||||||
|
form_class = UserSettingsForm
|
||||||
|
model = User
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
self.object = self.get_object()
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_object(self, **kwargs):
|
||||||
|
return self.request.user
|
||||||
|
|
||||||
|
def get_context_data(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 {
|
||||||
|
**super().get_context_data(**kwargs),
|
||||||
|
"reddit_authorization_url": reddit_authorization_url,
|
||||||
|
"reddit_refresh_url": reddit_refresh_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
return {**super().get_form_kwargs(), "instance": self.request.user}
|
||||||
|
|
||||||
|
|
||||||
|
class RedditTemplateView(TemplateView):
|
||||||
|
template_name = "accounts/views/reddit.html"
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
context = self.get_context_data(**kwargs)
|
||||||
|
|
||||||
|
error = request.GET.get("error", None)
|
||||||
|
state = request.GET.get("state", None)
|
||||||
|
code = request.GET.get("code", None)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
return self.render_to_response({**context, "error": error})
|
||||||
|
|
||||||
|
if not code or not state:
|
||||||
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
cached_state = cache.get(f"{request.user.email}-reddit-auth")
|
||||||
|
|
||||||
|
if state != cached_state:
|
||||||
|
return self.render_to_response(
|
||||||
|
{
|
||||||
|
**context,
|
||||||
|
"error": "The saved state for Reddit authorization did not match",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
access_token, refresh_token = get_reddit_access_token(code, request.user)
|
||||||
|
|
||||||
|
return self.render_to_response(
|
||||||
|
{
|
||||||
|
**context,
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except StreamException as e:
|
||||||
|
return self.render_to_response({**context, "error": str(e)})
|
||||||
|
except KeyError:
|
||||||
|
return self.render_to_response(
|
||||||
|
{**context, "error": "Access and refresh token not found in response"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RedditTokenRedirectView(RedirectView):
|
||||||
|
url = reverse_lazy("accounts:settings")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
from newsreader.accounts.views.auth import LoginView, LogoutView
|
|
||||||
from newsreader.accounts.views.favicon import FaviconRedirectView
|
|
||||||
from newsreader.accounts.views.password import (
|
|
||||||
PasswordChangeView,
|
|
||||||
PasswordResetCompleteView,
|
|
||||||
PasswordResetConfirmView,
|
|
||||||
PasswordResetDoneView,
|
|
||||||
PasswordResetView,
|
|
||||||
)
|
|
||||||
from newsreader.accounts.views.settings import SettingsView
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"LoginView",
|
|
||||||
"LogoutView",
|
|
||||||
"FaviconRedirectView",
|
|
||||||
"PasswordChangeView",
|
|
||||||
"PasswordResetCompleteView",
|
|
||||||
"PasswordResetConfirmView",
|
|
||||||
"PasswordResetDoneView",
|
|
||||||
"PasswordResetView",
|
|
||||||
"SettingsView",
|
|
||||||
]
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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):
|
|
||||||
template_name = "accounts/views/login.html"
|
|
||||||
success_url = reverse_lazy("index")
|
|
||||||
|
|
||||||
|
|
||||||
class LogoutView(django_views.LogoutView):
|
|
||||||
next_page = reverse_lazy("accounts:login")
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
from django.contrib.auth import views as django_views
|
|
||||||
from django.urls import reverse_lazy
|
|
||||||
|
|
||||||
from newsreader.utils.views import NavListMixin
|
|
||||||
|
|
||||||
|
|
||||||
# PasswordResetView sends the mail
|
|
||||||
# PasswordResetDoneView shows a success message for the above
|
|
||||||
# PasswordResetConfirmView checks the link the user clicked and
|
|
||||||
# prompts for a new password
|
|
||||||
# PasswordResetCompleteView shows a success message for the above
|
|
||||||
class PasswordResetView(NavListMixin, django_views.PasswordResetView):
|
|
||||||
template_name = "password-reset/password-reset.html"
|
|
||||||
subject_template_name = "password-reset/password-reset-subject.txt"
|
|
||||||
email_template_name = "password-reset/password-reset-email.html"
|
|
||||||
success_url = reverse_lazy("accounts:password-reset-done")
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetDoneView(NavListMixin, django_views.PasswordResetDoneView):
|
|
||||||
template_name = "password-reset/password-reset-done.html"
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetConfirmView(NavListMixin, 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):
|
|
||||||
template_name = "password-reset/password-reset-complete.html"
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordChangeView(NavListMixin, django_views.PasswordChangeView):
|
|
||||||
template_name = "accounts/views/password-change.html"
|
|
||||||
success_url = reverse_lazy("accounts:settings")
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class SettingsView(NavListMixin, ModelFormMixin, FormView):
|
|
||||||
template_name = "accounts/views/settings.html"
|
|
||||||
success_url = reverse_lazy("accounts:settings:home")
|
|
||||||
form_class = UserSettingsForm
|
|
||||||
model = User
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
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
|
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
|
||||||
return {**super().get_form_kwargs(), "instance": 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
|
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 = Celery("newsreader")
|
||||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
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:
|
# Quick-start development settings - unsuitable for production
|
||||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
|
||||||
from sentry_sdk.integrations.django import DjangoIntegration
|
# SECURITY WARNING: don"t run with debug turned on in production!
|
||||||
except ImportError:
|
DEBUG = True
|
||||||
CeleryIntegration = None
|
|
||||||
DjangoIntegration = None
|
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
|
||||||
BASE_DIR = get_root_dir()
|
INTERNAL_IPS = ["127.0.0.1", "localhost"]
|
||||||
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"])
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
|
|
@ -32,8 +27,10 @@ INSTALLED_APPS = [
|
||||||
"django.forms",
|
"django.forms",
|
||||||
# third party apps
|
# third party apps
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
|
"drf_yasg",
|
||||||
"celery",
|
"celery",
|
||||||
"django_celery_beat",
|
"django_celery_beat",
|
||||||
|
"registration",
|
||||||
"axes",
|
"axes",
|
||||||
# app modules
|
# app modules
|
||||||
"newsreader.accounts",
|
"newsreader.accounts",
|
||||||
|
|
@ -43,8 +40,6 @@ INSTALLED_APPS = [
|
||||||
"newsreader.news.collection",
|
"newsreader.news.collection",
|
||||||
]
|
]
|
||||||
|
|
||||||
SECRET_KEY = get_env("DJANGO_SECRET_KEY", default="")
|
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
"axes.backends.AxesBackend",
|
"axes.backends.AxesBackend",
|
||||||
"django.contrib.auth.backends.ModelBackend",
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
|
|
@ -68,10 +63,11 @@ FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
"DIRS": [DJANGO_PROJECT_DIR / "templates"],
|
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
|
||||||
"APP_DIRS": True,
|
"APP_DIRS": True,
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"context_processors": [
|
"context_processors": [
|
||||||
|
"django.template.context_processors.debug",
|
||||||
"django.template.context_processors.request",
|
"django.template.context_processors.request",
|
||||||
"django.contrib.auth.context_processors.auth",
|
"django.contrib.auth.context_processors.auth",
|
||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
|
@ -82,30 +78,31 @@ TEMPLATES = [
|
||||||
|
|
||||||
WSGI_APPLICATION = "newsreader.wsgi.application"
|
WSGI_APPLICATION = "newsreader.wsgi.application"
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.postgresql",
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
"HOST": get_env("POSTGRES_HOST", default=""),
|
"HOST": os.environ.get("POSTGRES_HOST", ""),
|
||||||
"PORT": get_env("POSTGRES_PORT", default=""),
|
"NAME": os.environ.get("POSTGRES_NAME", "newsreader"),
|
||||||
"NAME": get_env("POSTGRES_DB", default=""),
|
"USER": os.environ.get("POSTGRES_USER"),
|
||||||
"USER": get_env("POSTGRES_USER", default=""),
|
"PASSWORD": os.environ.get("POSTGRES_PASSWORD"),
|
||||||
"PASSWORD": get_env("POSTGRES_PASSWORD", default=""),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
|
||||||
|
|
||||||
CACHES = {
|
CACHES = {
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||||
"LOCATION": "memcached:11211",
|
"LOCATION": "localhost:11211",
|
||||||
},
|
},
|
||||||
"axes": {
|
"axes": {
|
||||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||||
"LOCATION": "memcached:11211",
|
"LOCATION": "localhost:11211",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
# https://docs.djangoproject.com/en/2.2/topics/logging/#configuring-logging
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
|
|
@ -119,46 +116,66 @@ LOGGING = {
|
||||||
"format": "[{server_time}] {message}",
|
"format": "[{server_time}] {message}",
|
||||||
"style": "{",
|
"style": "{",
|
||||||
},
|
},
|
||||||
|
"syslog": {
|
||||||
|
"class": "logging.Formatter",
|
||||||
|
"format": "[newsreader] {message}",
|
||||||
|
"style": "{",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"handlers": {
|
"handlers": {
|
||||||
"console": {
|
"console": {
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
|
"filters": ["require_debug_true"],
|
||||||
"class": "logging.StreamHandler",
|
"class": "logging.StreamHandler",
|
||||||
"formatter": "timestamped",
|
"formatter": "timestamped",
|
||||||
},
|
},
|
||||||
"file": {
|
"mail_admins": {
|
||||||
"level": "DEBUG",
|
"level": "ERROR",
|
||||||
"class": "logging.handlers.RotatingFileHandler",
|
"filters": ["require_debug_false"],
|
||||||
"filename": BASE_DIR / "logs" / "newsreader.log",
|
"class": "django.utils.log.AdminEmailHandler",
|
||||||
"backupCount": 5,
|
|
||||||
"maxBytes": 50000000, # 50 mB
|
|
||||||
"formatter": "timestamped",
|
|
||||||
},
|
},
|
||||||
"celery": {
|
"syslog": {
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"class": "logging.handlers.RotatingFileHandler",
|
"filters": ["require_debug_false"],
|
||||||
"filename": BASE_DIR / "logs" / "celery.log",
|
"class": "logging.handlers.SysLogHandler",
|
||||||
"backupCount": 5,
|
"formatter": "syslog",
|
||||||
"maxBytes": 50000000, # 50 mB
|
"address": "/dev/log",
|
||||||
"formatter": "timestamped",
|
},
|
||||||
|
"syslog_errors": {
|
||||||
|
"level": "ERROR",
|
||||||
|
"filters": ["require_debug_false"],
|
||||||
|
"class": "logging.handlers.SysLogHandler",
|
||||||
|
"formatter": "syslog",
|
||||||
|
"address": "/dev/log",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"loggers": {
|
"loggers": {
|
||||||
"django": {"handlers": ["console"], "level": "INFO"},
|
"django": {
|
||||||
|
"handlers": ["console", "mail_admins", "syslog_errors"],
|
||||||
|
"level": "WARNING",
|
||||||
|
},
|
||||||
"django.server": {
|
"django.server": {
|
||||||
"handlers": ["console"],
|
"handlers": ["console", "syslog_errors"],
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
"celery.task": {"handlers": ["console", "celery"], "level": "INFO"},
|
"django.request": {
|
||||||
"newsreader": {
|
"handlers": ["console", "syslog_errors"],
|
||||||
"handlers": ["console", "file"],
|
"level": "INFO",
|
||||||
"level": "DEBUG",
|
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
|
"celery": {"handlers": ["syslog", "console"], "level": "INFO"},
|
||||||
|
"celery.task": {
|
||||||
|
"handlers": ["syslog", "console"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
"newsreader": {"handlers": ["syslog", "console"], "level": "INFO"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
|
||||||
|
|
@ -173,6 +190,8 @@ AUTH_USER_MODEL = "accounts.User"
|
||||||
|
|
||||||
LOGIN_REDIRECT_URL = "/"
|
LOGIN_REDIRECT_URL = "/"
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/2.2/topics/i18n/
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
TIME_ZONE = "Europe/Amsterdam"
|
TIME_ZONE = "Europe/Amsterdam"
|
||||||
|
|
@ -180,31 +199,27 @@ USE_I18N = True
|
||||||
USE_L10N = True
|
USE_L10N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/2.2/howto/static-files/
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
STATIC_ROOT = BASE_DIR / "static"
|
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
||||||
STATICFILES_DIRS = (DJANGO_PROJECT_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 = [
|
STATICFILES_FINDERS = [
|
||||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||||
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Email
|
DEFAULT_FROM_EMAIL = "newsreader@rss.fudiggity.nl"
|
||||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
|
||||||
|
|
||||||
DEFAULT_FROM_EMAIL = get_env(
|
# Project settings
|
||||||
"EMAIL_DEFAULT_FROM", required=False, default="webmaster@localhost"
|
VERSION = get_current_version()
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
# Reddit integration
|
||||||
|
REDDIT_CLIENT_ID = "CLIENT_ID"
|
||||||
|
REDDIT_CLIENT_SECRET = "CLIENT_SECRET"
|
||||||
|
REDDIT_REDIRECT_URL = "http://127.0.0.1:8000/accounts/settings/reddit/callback/"
|
||||||
|
|
||||||
# Third party settings
|
# Third party settings
|
||||||
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
|
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
|
||||||
|
|
@ -221,12 +236,7 @@ REST_FRAMEWORK = {
|
||||||
"rest_framework.permissions.IsAuthenticated",
|
"rest_framework.permissions.IsAuthenticated",
|
||||||
"newsreader.accounts.permissions.IsOwner",
|
"newsreader.accounts.permissions.IsOwner",
|
||||||
),
|
),
|
||||||
"DEFAULT_RENDERER_CLASSES": (
|
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
|
||||||
"djangorestframework_camel_case.render.CamelCaseJSONRenderer",
|
|
||||||
),
|
|
||||||
"DEFAULT_PARSER_CLASSES": (
|
|
||||||
"djangorestframework_camel_case.parser.CamelCaseJSONParser",
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SWAGGER_SETTINGS = {
|
SWAGGER_SETTINGS = {
|
||||||
|
|
@ -237,15 +247,8 @@ SWAGGER_SETTINGS = {
|
||||||
|
|
||||||
# Celery
|
# Celery
|
||||||
# https://docs.celeryproject.org/en/stable/userguide/configuration.html
|
# https://docs.celeryproject.org/en/stable/userguide/configuration.html
|
||||||
# Note that celery settings are prefix with CELERY. See src/newsreader/celery.py.
|
|
||||||
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
||||||
CELERY_BROKER_URL = "amqp://guest@rabbitmq:5672"
|
|
||||||
|
|
||||||
# Sentry
|
REGISTRATION_OPEN = True
|
||||||
SENTRY_CONFIG = {
|
REGISTRATION_AUTO_LOGIN = True
|
||||||
"dsn": get_env("SENTRY_DSN", default="", required=False),
|
ACCOUNT_ACTIVATION_DAYS = 7
|
||||||
"send_default_pii": False,
|
|
||||||
"integrations": [DjangoIntegration(), CeleryIntegration()]
|
|
||||||
if DjangoIntegration and CeleryIntegration
|
|
||||||
else [],
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 .base import * # isort:skip
|
||||||
from .utils import get_current_version
|
|
||||||
|
|
||||||
|
|
||||||
SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl"
|
SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl"
|
||||||
|
|
||||||
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405
|
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
|
||||||
|
|
||||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||||
|
|
||||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
INSTALLED_APPS += ["debug_toolbar", "django_extensions"]
|
||||||
|
|
||||||
|
|
||||||
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
|
|
||||||
"django.template.context_processors.debug",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Project settings
|
|
||||||
VERSION = get_current_version()
|
|
||||||
|
|
||||||
# Third party settings
|
# Third party settings
|
||||||
AXES_FAILURE_LIMIT = 50
|
AXES_FAILURE_LIMIT = 50
|
||||||
AXES_COOLOFF_TIME = None
|
AXES_COOLOFF_TIME = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Optionally use sentry integration
|
|
||||||
from sentry_sdk import init as sentry_init
|
|
||||||
|
|
||||||
from .local import * # noqa
|
from .local import * # noqa
|
||||||
|
|
||||||
SENTRY_CONFIG.update({"release": VERSION}) # noqa: F405
|
|
||||||
|
|
||||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,29 @@
|
||||||
from .base import * # noqa: F403
|
from .dev import * # isort:skip
|
||||||
from .utils import get_current_version
|
|
||||||
|
|
||||||
|
|
||||||
DEBUG = True
|
SECRET_KEY = "=q(ztyo)b6noom#a164g&s9vcj1aawa^g#ing_ir99=_zl4g&$"
|
||||||
|
|
||||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
|
"NAME": "newsreader",
|
||||||
LOGGING["loggers"].update( # noqa: F405
|
"USER": "newsreader",
|
||||||
{
|
"PASSWORD": "newsreader",
|
||||||
"celery.task": {"handlers": ["console", "celery"], "level": "DEBUG"},
|
"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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Celery
|
||||||
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
|
# https://docs.celeryproject.org/en/latest/userguide/configuration.html
|
||||||
"django.template.context_processors.debug",
|
CELERY_BROKER_URL = "amqp://guest:guest@rabbitmq:5672//"
|
||||||
)
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
|
||||||
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,71 @@
|
||||||
from newsreader.conf.utils import get_env
|
import os
|
||||||
|
|
||||||
from .base import * # noqa: F403
|
from dotenv import load_dotenv
|
||||||
from .utils import get_current_version
|
|
||||||
|
|
||||||
|
|
||||||
|
from .base import * # isort:skip
|
||||||
|
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
|
|
||||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
ALLOWED_HOSTS = ["rss.fudiggity.nl"]
|
||||||
|
|
||||||
ADMINS = [
|
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
|
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
|
||||||
VERSION = get_current_version(debug=False)
|
|
||||||
ENVIRONMENT = "production"
|
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["REDDIT_CLIENT_ID"]
|
||||||
|
REDDIT_CLIENT_SECRET = os.environ["REDDIT_CLIENT_SECRET"]
|
||||||
|
REDDIT_REDIRECT_URL = os.environ["REDDIT_CALLBACK_URL"]
|
||||||
|
|
||||||
# Third party settings
|
# Third party settings
|
||||||
AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler"
|
AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler"
|
||||||
|
|
||||||
|
REGISTRATION_OPEN = False
|
||||||
|
|
||||||
# Optionally use sentry integration
|
# Optionally use sentry integration
|
||||||
try:
|
try:
|
||||||
from sentry_sdk import init as sentry_init
|
from sentry_sdk import init as sentry_init
|
||||||
|
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||||
|
from sentry_sdk.integrations.django import DjangoIntegration
|
||||||
|
|
||||||
SENTRY_CONFIG.update( # noqa: F405
|
sentry_init(
|
||||||
{"release": VERSION, "environment": ENVIRONMENT, "debug": False}
|
dsn=os.environ.get("SENTRY_DSN"),
|
||||||
|
integrations=[DjangoIntegration(), CeleryIntegration()],
|
||||||
|
send_default_pii=False,
|
||||||
|
release=VERSION,
|
||||||
)
|
)
|
||||||
|
|
||||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
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):
|
class CoreConfig(AppConfig):
|
||||||
name = "newsreader.core"
|
name = "core"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from rest_framework import pagination
|
from rest_framework.pagination import PageNumberPagination
|
||||||
|
|
||||||
|
|
||||||
class ResultSetPagination(pagination.PageNumberPagination):
|
class ResultSetPagination(PageNumberPagination):
|
||||||
page_size_query_param = "count"
|
page_size_query_param = "count"
|
||||||
max_page_size = 50
|
max_page_size = 50
|
||||||
page_size = 30
|
page_size = 30
|
||||||
|
|
@ -10,9 +10,3 @@ class ResultSetPagination(pagination.PageNumberPagination):
|
||||||
class LargeResultSetPagination(ResultSetPagination):
|
class LargeResultSetPagination(ResultSetPagination):
|
||||||
max_page_size = 100
|
max_page_size = 100
|
||||||
page_size = 50
|
page_size = 50
|
||||||
|
|
||||||
|
|
||||||
class CursorPagination(pagination.CursorPagination):
|
|
||||||
page_size_query_param = "count"
|
|
||||||
ordering = "-publication_date"
|
|
||||||
page_size = 30
|
|
||||||
|
|
|
||||||
4023
src/newsreader/fixtures/default-fixture.json
Normal file
4023
src/newsreader/fixtures/default-fixture.json
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -47,7 +47,7 @@
|
||||||
"user" : 2,
|
"user" : 2,
|
||||||
"succeeded" : true,
|
"succeeded" : true,
|
||||||
"modified" : "2019-07-20T11:28:16.473Z",
|
"modified" : "2019-07-20T11:28:16.473Z",
|
||||||
"last_run" : "2019-07-20T11:28:16.316Z",
|
"last_suceeded" : "2019-07-20T11:28:16.316Z",
|
||||||
"name" : "Hackers News",
|
"name" : "Hackers News",
|
||||||
"website_url" : null,
|
"website_url" : null,
|
||||||
"created" : "2019-07-14T13:08:10.374Z",
|
"created" : "2019-07-14T13:08:10.374Z",
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
"error" : null,
|
"error" : null,
|
||||||
"user" : 2,
|
"user" : 2,
|
||||||
"succeeded" : true,
|
"succeeded" : true,
|
||||||
"last_run" : "2019-07-20T11:28:15.691Z",
|
"last_suceeded" : "2019-07-20T11:28:15.691Z",
|
||||||
"name" : "BBC",
|
"name" : "BBC",
|
||||||
"modified" : "2019-07-20T12:07:49.164Z",
|
"modified" : "2019-07-20T12:07:49.164Z",
|
||||||
"timezone" : "UTC",
|
"timezone" : "UTC",
|
||||||
|
|
@ -85,7 +85,7 @@
|
||||||
"website_url" : null,
|
"website_url" : null,
|
||||||
"name" : "Ars Technica",
|
"name" : "Ars Technica",
|
||||||
"succeeded" : true,
|
"succeeded" : true,
|
||||||
"last_run" : "2019-07-20T11:28:15.986Z",
|
"last_suceeded" : "2019-07-20T11:28:15.986Z",
|
||||||
"modified" : "2019-07-20T11:28:16.033Z",
|
"modified" : "2019-07-20T11:28:16.033Z",
|
||||||
"user" : 2
|
"user" : 2
|
||||||
},
|
},
|
||||||
|
|
@ -102,7 +102,7 @@
|
||||||
"user" : 2,
|
"user" : 2,
|
||||||
"name" : "The Guardian",
|
"name" : "The Guardian",
|
||||||
"succeeded" : true,
|
"succeeded" : true,
|
||||||
"last_run" : "2019-07-20T11:28:16.078Z",
|
"last_suceeded" : "2019-07-20T11:28:16.078Z",
|
||||||
"modified" : "2019-07-20T12:07:44.292Z",
|
"modified" : "2019-07-20T12:07:44.292Z",
|
||||||
"created" : "2019-07-20T11:25:02.089Z",
|
"created" : "2019-07-20T11:25:02.089Z",
|
||||||
"website_url" : null,
|
"website_url" : null,
|
||||||
|
|
@ -119,7 +119,7 @@
|
||||||
"website_url" : null,
|
"website_url" : null,
|
||||||
"created" : "2019-07-20T11:25:30.121Z",
|
"created" : "2019-07-20T11:25:30.121Z",
|
||||||
"user" : 2,
|
"user" : 2,
|
||||||
"last_run" : "2019-07-20T11:28:15.860Z",
|
"last_suceeded" : "2019-07-20T11:28:15.860Z",
|
||||||
"succeeded" : true,
|
"succeeded" : true,
|
||||||
"modified" : "2019-07-20T12:07:28.473Z",
|
"modified" : "2019-07-20T12:07:28.473Z",
|
||||||
"name" : "Tweakers"
|
"name" : "Tweakers"
|
||||||
|
|
@ -139,7 +139,7 @@
|
||||||
"website_url" : null,
|
"website_url" : null,
|
||||||
"timezone" : "UTC",
|
"timezone" : "UTC",
|
||||||
"user" : 2,
|
"user" : 2,
|
||||||
"last_run" : "2019-07-20T11:28:16.034Z",
|
"last_suceeded" : "2019-07-20T11:28:16.034Z",
|
||||||
"succeeded" : true,
|
"succeeded" : true,
|
||||||
"modified" : "2019-07-20T12:07:21.704Z",
|
"modified" : "2019-07-20T12:07:21.704Z",
|
||||||
"name" : "The Verge"
|
"name" : "The Verge"
|
||||||
|
|
|
||||||
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