Compare commits
33 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e40d69d5ff | |||
| 83707701e9 | |||
| 116e2c1577 | |||
| cf96371b90 | |||
| eadd7a5612 | |||
| 62053a1048 | |||
| b4340176da | |||
| 433ff9413d | |||
| 91949622b7 | |||
| 10affeb32f | |||
| e96c6f3528 | |||
| a534a3b691 | |||
| ebbbe99eaf | |||
| c7f90e233e | |||
| 9ba6824dd3 | |||
| 4c5d3aec28 | |||
| dd9aaf467e | |||
| 1417c52007 | |||
| bfd081337b | |||
| b8559f0499 | |||
| b465d0bb8d | |||
| 1a54fdbcd1 | |||
| 34afcc02b6 | |||
| 1574661c57 | |||
| 3160becb72 | |||
| 105371abaf | |||
| ed37be0c60 | |||
| 161234defd | |||
| f3ba0f1d09 | |||
| aff565862c | |||
| bf43603d65 | |||
| 91a7f6325c | |||
| e33497569a |
127 changed files with 1073 additions and 14726 deletions
16
.coveragerc
16
.coveragerc
|
|
@ -1,16 +0,0 @@
|
||||||
[run]
|
|
||||||
source = ./src/newsreader/
|
|
||||||
omit =
|
|
||||||
**/tests/**
|
|
||||||
**/migrations/**
|
|
||||||
**/conf/**
|
|
||||||
**/apps.py
|
|
||||||
**/admin.py
|
|
||||||
**/tests.py
|
|
||||||
**/urls.py
|
|
||||||
**/wsgi.py
|
|
||||||
**/celery.py
|
|
||||||
**/__init__.py
|
|
||||||
|
|
||||||
[html]
|
|
||||||
directory = coverage
|
|
||||||
25
.editorconfig
Normal file
25
.editorconfig
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# https://editorconfig.org
|
||||||
|
|
||||||
|
# top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# Unix-style newlines with a newline ending every file
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.py]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{yaml,yml,toml,md}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[Dockerfile*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.json]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
stages:
|
|
||||||
- build
|
|
||||||
- test
|
|
||||||
- lint
|
|
||||||
- release
|
|
||||||
|
|
||||||
variables:
|
|
||||||
UV_CACHE_DIR: "$CI_PROJECT_DIR/.cache/uv"
|
|
||||||
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:
|
|
||||||
- .cache/pip
|
|
||||||
- .cache/uv
|
|
||||||
- node_modules/
|
|
||||||
- .venv/
|
|
||||||
|
|
||||||
include:
|
|
||||||
- local: '/gitlab-ci/build.yml'
|
|
||||||
- local: '/gitlab-ci/test.yml'
|
|
||||||
- local: '/gitlab-ci/lint.yml'
|
|
||||||
- local: '/gitlab-ci/release.yml'
|
|
||||||
12
.isort.cfg
12
.isort.cfg
|
|
@ -1,12 +0,0 @@
|
||||||
[settings]
|
|
||||||
include_trailing_comma = true
|
|
||||||
line_length = 88
|
|
||||||
multi_line_output = 3
|
|
||||||
skip = env/, venv/
|
|
||||||
default_section = THIRDPARTY
|
|
||||||
known_first_party = newsreader
|
|
||||||
known_django = django
|
|
||||||
sections = FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
|
|
||||||
lines_between_types=1
|
|
||||||
lines_after_imports=2
|
|
||||||
lines_between_types=1
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"singleQuote": true,
|
|
||||||
"printWidth": 90,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"useTabs": false,
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"arrowParens": "avoid"
|
|
||||||
}
|
|
||||||
10
.woodpecker/build.yaml
Normal file
10
.woodpecker/build.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
- event: pull_request
|
||||||
|
- event: manual
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- image: node:lts-alpine
|
||||||
|
commands:
|
||||||
|
- npm install
|
||||||
|
- npm run build:prod
|
||||||
18
.woodpecker/lint.yaml
Normal file
18
.woodpecker/lint.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
- event: pull_request
|
||||||
|
- event: manual
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: python linting
|
||||||
|
image: ghcr.io/astral-sh/uv:python3.11-alpine
|
||||||
|
commands:
|
||||||
|
- uv sync --group ci
|
||||||
|
- uv run --no-sync -- ruff check src/
|
||||||
|
- uv run --no-sync -- ruff format --check src/
|
||||||
|
|
||||||
|
- name: javascript linting
|
||||||
|
image: node:lts-alpine
|
||||||
|
commands:
|
||||||
|
- npm ci
|
||||||
|
- npm run lint
|
||||||
37
.woodpecker/tests.yaml
Normal file
37
.woodpecker/tests.yaml
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
- event: pull_request
|
||||||
|
- event: manual
|
||||||
|
|
||||||
|
services:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:15
|
||||||
|
environment:
|
||||||
|
POSTGRES_NAME: &db-name newsreader
|
||||||
|
POSTGRES_USER: &db-user newsreader
|
||||||
|
POSTGRES_PASSWORD: &db-password sekrit
|
||||||
|
- name: memcached
|
||||||
|
image: memcached:1.5.22
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: python tests
|
||||||
|
image: ghcr.io/astral-sh/uv:python3.11-alpine
|
||||||
|
environment:
|
||||||
|
DJANGO_SETTINGS_MODULE: "newsreader.conf.ci"
|
||||||
|
DJANGO_SECRET_KEY: sekrit
|
||||||
|
POSTGRES_HOST: postgres
|
||||||
|
POSTGRES_PORT: 5432
|
||||||
|
POSTGRES_DB: *db-name
|
||||||
|
POSTGRES_USER: *db-user
|
||||||
|
POSTGRES_PASSWORD: *db-password
|
||||||
|
commands:
|
||||||
|
- pip install uv
|
||||||
|
- uv sync --group ci
|
||||||
|
- uv run --no-sync -- coverage run ./src/manage.py test newsreader
|
||||||
|
- uv run --no-sync -- coverage report --show-missing
|
||||||
|
|
||||||
|
- name: javascript tests
|
||||||
|
image: node:lts-alpine
|
||||||
|
commands:
|
||||||
|
- npm ci
|
||||||
|
- npm test
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.5.3
|
||||||
|
|
||||||
|
- Apply query optimizations for retrieving posts
|
||||||
|
|
||||||
## 0.5.2
|
## 0.5.2
|
||||||
|
|
||||||
- Add missing `VERSION` environment variable
|
- Add missing `VERSION` environment variable
|
||||||
|
|
|
||||||
84
Dockerfile
Normal file
84
Dockerfile
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# stage 1
|
||||||
|
FROM python:3.11-alpine AS backend
|
||||||
|
|
||||||
|
ARG USER_ID=1000
|
||||||
|
ARG GROUP_ID=1000
|
||||||
|
ARG UV_LINK_MODE=copy
|
||||||
|
|
||||||
|
RUN apk update \
|
||||||
|
&& apk add --no-cache \
|
||||||
|
vim \
|
||||||
|
curl \
|
||||||
|
gettext
|
||||||
|
|
||||||
|
RUN addgroup -g $USER_ID newsreader && adduser -Du $GROUP_ID -G newsreader newsreader
|
||||||
|
|
||||||
|
RUN mkdir --parents /app/src /app/logs /app/media /app/bin /app/static \
|
||||||
|
&& chown -R newsreader:newsreader /app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
USER newsreader
|
||||||
|
|
||||||
|
COPY --chown=newsreader:newsreader uv.lock pyproject.toml /app/
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:python3.11-alpine /usr/local/bin/uv /bin/uv
|
||||||
|
|
||||||
|
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
|
||||||
|
uv sync --frozen --no-default-groups --no-install-project
|
||||||
|
|
||||||
|
COPY --chown=newsreader:newsreader ./bin/docker-entrypoint.sh /app/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
VOLUME ["/app/logs", "/app/media", "/app/static"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# stage 2
|
||||||
|
FROM node:lts-alpine AS frontend-build
|
||||||
|
|
||||||
|
ARG BUILD_ARG=prod
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN chown node:node /app
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
COPY --chown=node:node ./package*.json ./webpack.*.js ./babel.config.js /app/
|
||||||
|
|
||||||
|
RUN --mount=type=cache,uid=1000,gid=1000,target=/home/node/.npm \
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
COPY --chown=node:node ./src /app/src
|
||||||
|
|
||||||
|
RUN npm run build:$BUILD_ARG
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# stage 3
|
||||||
|
FROM backend AS production
|
||||||
|
|
||||||
|
COPY --from=frontend-build --chown=newsreader:newsreader \
|
||||||
|
/app/src/newsreader/static /app/src/newsreader/static
|
||||||
|
|
||||||
|
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
|
||||||
|
uv sync --frozen --only-group production --extra sentry
|
||||||
|
|
||||||
|
COPY --chown=newsreader:newsreader ./src /app/src
|
||||||
|
|
||||||
|
ENV DJANGO_SETTINGS_MODULE=newsreader.conf.production
|
||||||
|
|
||||||
|
# Note that the static volume will have to be recreated to be pre-populated
|
||||||
|
# correctly with the latest static files. See
|
||||||
|
# https://docs.docker.com/storage/volumes/#populate-a-volume-using-a-container
|
||||||
|
RUN uv run --no-sync -- src/manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# (optional) stage 4
|
||||||
|
FROM backend AS development
|
||||||
|
|
||||||
|
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
|
||||||
|
uv sync --frozen --group development
|
||||||
|
|
||||||
|
ENV DJANGO_SETTINGS_MODULE=newsreader.conf.docker
|
||||||
|
|
@ -1,23 +1,21 @@
|
||||||
module.exports = api => {
|
module.exports = api => {
|
||||||
const isTest = api.env('test');
|
const isTest = api.env('test');
|
||||||
|
|
||||||
const preset = [
|
const preset = [
|
||||||
"@babel/preset-env" , { targets: 'defaults' }
|
"@babel/preset-env", { targets: 'defaults' }
|
||||||
|
];
|
||||||
|
const testPreset = [
|
||||||
|
"@babel/preset-env", { targets: { node: process.versions.node } }
|
||||||
];
|
];
|
||||||
const testPreset = [
|
|
||||||
"@babel/preset-env", { targets: { node: process.versions.node } }
|
|
||||||
];
|
|
||||||
|
|
||||||
const plugins = [
|
const plugins = [
|
||||||
"@babel/plugin-syntax-dynamic-import",
|
"@babel/plugin-syntax-dynamic-import",
|
||||||
"@babel/plugin-transform-react-jsx",
|
"@babel/plugin-transform-react-jsx",
|
||||||
"@babel/plugin-syntax-function-bind",
|
"@babel/plugin-proposal-class-properties"
|
||||||
"@babel/plugin-proposal-function-bind",
|
]
|
||||||
"@babel/plugin-proposal-class-properties"
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"presets": [isTest ? testPreset: preset],
|
"presets": [isTest ? testPreset : preset],
|
||||||
"plugins": plugins
|
"plugins": plugins
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
/app/.venv/bin/python /app/src/manage.py migrate
|
uv run --no-sync -- /app/src/manage.py migrate
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,13 @@
|
||||||
volumes:
|
volumes:
|
||||||
static-files:
|
static-files:
|
||||||
node-modules:
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
celery:
|
|
||||||
build:
|
|
||||||
target: development
|
|
||||||
volumes:
|
|
||||||
- ./src/:/app/src
|
|
||||||
|
|
||||||
django:
|
django:
|
||||||
build:
|
build: &app-development-build
|
||||||
target: development
|
target: development
|
||||||
command: /app/.venv/bin/python /app/src/manage.py runserver 0.0.0.0:8000
|
command: uv run --no-sync -- /app/src/manage.py runserver 0.0.0.0:8000
|
||||||
|
environment: &django-env
|
||||||
|
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-newsreader.conf.docker}
|
||||||
ports:
|
ports:
|
||||||
- "${DJANGO_PORT:-8000}:8000"
|
- "${DJANGO_PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -21,12 +16,21 @@ services:
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
|
|
||||||
|
celery:
|
||||||
|
build:
|
||||||
|
<<: *app-development-build
|
||||||
|
environment:
|
||||||
|
<<: *django-env
|
||||||
|
volumes:
|
||||||
|
- ./src/:/app/src
|
||||||
|
|
||||||
webpack:
|
webpack:
|
||||||
build:
|
build:
|
||||||
|
target: frontend-build
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./docker/webpack
|
args:
|
||||||
|
BUILD_ARG: "dev"
|
||||||
command: npm run build:watch
|
command: npm run build:watch
|
||||||
volumes:
|
volumes:
|
||||||
- ./src/:/app/src
|
- ./src/:/app/src
|
||||||
- static-files:/app/src/newsreader/static
|
- static-files:/app/src/newsreader/static
|
||||||
- node-modules:/app/node_modules
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ services:
|
||||||
django:
|
django:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
# Note that --env-file should be used to set these correctly
|
|
||||||
- "${NGINX_HTTP_PORT:-80}:80"
|
- "${NGINX_HTTP_PORT:-80}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/nginx/conf.d:/etc/nginx/conf.d
|
- ./config/nginx/conf.d:/etc/nginx/conf.d
|
||||||
|
|
|
||||||
|
|
@ -4,38 +4,43 @@ volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
static-files:
|
static-files:
|
||||||
|
|
||||||
x-db-env: &db-env
|
x-db-connection-env: &db-connection-env
|
||||||
POSTGRES_HOST:
|
POSTGRES_HOST: ${POSTGRES_HOST:-db}
|
||||||
POSTGRES_PORT:
|
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
|
||||||
POSTGRES_DB:
|
POSTGRES_DB: &pg-database ${POSTGRES_DB:-newsreader}
|
||||||
POSTGRES_USER:
|
POSTGRES_USER: &pg-user ${POSTGRES_USER:-newsreader}
|
||||||
POSTGRES_PASSWORD:
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-newsreader}
|
||||||
|
|
||||||
x-django-build-env: &django-build-env
|
x-db-env: &db-env
|
||||||
<<: *db-env
|
<<: *db-connection-env
|
||||||
DJANGO_SECRET_KEY:
|
PGUSER: *pg-user
|
||||||
DJANGO_SETTINGS_MODULE:
|
PGDATABASE: *pg-database
|
||||||
|
|
||||||
x-django-env: &django-env
|
x-django-env: &django-env
|
||||||
<<: *django-build-env
|
<<: *db-connection-env
|
||||||
VERSION:
|
|
||||||
|
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,django}
|
||||||
|
INTERNAL_IPS: ${INTERNAL_IPS:-localhost,127.0.0.1,django}
|
||||||
|
|
||||||
|
# see token_urlsafe from python's secret module to generate one
|
||||||
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-Ojg68lYsP3kq2r5JgozUzKVSRFywm17BTMS5iwpLM44}
|
||||||
|
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-newsreader.conf.production}
|
||||||
|
|
||||||
|
ADMINS: ${ADMINS:-""}
|
||||||
|
|
||||||
|
VERSION: ${VERSION:-""}
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
EMAIL_HOST:
|
EMAIL_HOST: ${EMAIL_HOST:-localhost}
|
||||||
EMAIL_PORT:
|
EMAIL_PORT: ${EMAIL_PORT:-25}
|
||||||
EMAIL_HOST_USER:
|
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-""}
|
||||||
EMAIL_HOST_PASSWORD:
|
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-""}
|
||||||
EMAIL_USE_TLS:
|
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-no}
|
||||||
EMAIL_USE_SSL:
|
EMAIL_USE_SSL: ${EMAIL_USE_SSL:-no}
|
||||||
EMAIL_DEFAULT_FROM:
|
EMAIL_DEFAULT_FROM: ${EMAIL_DEFAULT_FROM:-webmaster@localhost}
|
||||||
|
|
||||||
# Reddit
|
|
||||||
REDDIT_CLIENT_ID:
|
|
||||||
REDDIT_CLIENT_SECRET:
|
|
||||||
REDDIT_CALLBACK_URL:
|
|
||||||
|
|
||||||
# Sentry
|
# Sentry
|
||||||
SENTRY_DSN:
|
SENTRY_DSN: ${SENTRY_DSN:-""}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
|
|
@ -43,8 +48,8 @@ services:
|
||||||
<<: *db-env
|
<<: *db-env
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
healthcheck:
|
healthcheck:
|
||||||
# Note that --env-file should be used to set these correctly
|
test: /usr/bin/pg_isready
|
||||||
test: /usr/bin/pg_isready --username="${POSTGRES_USER}" --dbname="${POSTGRES_DB}"
|
start_period: 10s
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
@ -52,7 +57,7 @@ services:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: rabbitmq:3.12
|
image: rabbitmq:4
|
||||||
|
|
||||||
memcached:
|
memcached:
|
||||||
image: memcached:1.6
|
image: memcached:1.6
|
||||||
|
|
@ -60,56 +65,26 @@ services:
|
||||||
- memcached
|
- memcached
|
||||||
- -m 64
|
- -m 64
|
||||||
|
|
||||||
celery:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: ./docker/django
|
|
||||||
target: production
|
|
||||||
args:
|
|
||||||
<<: *django-build-env
|
|
||||||
environment:
|
|
||||||
<<: *django-env
|
|
||||||
command: |
|
|
||||||
/app/.venv/bin/celery --app newsreader
|
|
||||||
--workdir /app/src/
|
|
||||||
worker --loglevel INFO
|
|
||||||
--concurrency 2
|
|
||||||
--beat
|
|
||||||
--scheduler django
|
|
||||||
-n worker1@%h
|
|
||||||
-n worker2@%h
|
|
||||||
depends_on:
|
|
||||||
rabbitmq:
|
|
||||||
condition: service_started
|
|
||||||
memcached:
|
|
||||||
condition: service_started
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
django:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
|
||||||
- logs:/app/logs
|
|
||||||
|
|
||||||
django:
|
django:
|
||||||
build:
|
build: &app-build
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./docker/django
|
|
||||||
target: production
|
target: production
|
||||||
args:
|
|
||||||
<<: *django-build-env
|
|
||||||
environment:
|
environment:
|
||||||
<<: *django-env
|
<<: *django-env
|
||||||
entrypoint: /app/bin/docker-entrypoint.sh
|
entrypoint: ["/bin/sh", "/app/bin/docker-entrypoint.sh"]
|
||||||
command: |
|
command: |
|
||||||
/app/.venv/bin/gunicorn --bind 0.0.0.0:8000
|
uv run --no-sync --
|
||||||
|
gunicorn
|
||||||
|
--bind 0.0.0.0:8000
|
||||||
--workers 3
|
--workers 3
|
||||||
--chdir /app/src/
|
--chdir /app/src/
|
||||||
newsreader.wsgi:application
|
newsreader.wsgi:application
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: /usr/bin/curl --fail http://django:8000 || exit 1
|
test: /usr/bin/curl --fail http://django:8000 || exit 1
|
||||||
interval: 30s
|
start_period: 10s
|
||||||
|
interval: 10s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 10
|
retries: 5
|
||||||
depends_on:
|
depends_on:
|
||||||
memcached:
|
memcached:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|
@ -119,3 +94,33 @@ services:
|
||||||
- logs:/app/logs
|
- logs:/app/logs
|
||||||
- media:/app/media
|
- media:/app/media
|
||||||
- static-files:/app/static
|
- static-files:/app/static
|
||||||
|
|
||||||
|
celery:
|
||||||
|
build:
|
||||||
|
<<: *app-build
|
||||||
|
environment:
|
||||||
|
<<: *django-env
|
||||||
|
command: |
|
||||||
|
uv run --no-sync --
|
||||||
|
celery
|
||||||
|
--app newsreader
|
||||||
|
--workdir /app/src/
|
||||||
|
worker --loglevel INFO
|
||||||
|
--concurrency 2
|
||||||
|
--beat
|
||||||
|
--scheduler django
|
||||||
|
-n worker1@%h
|
||||||
|
-n worker2@%h
|
||||||
|
healthcheck:
|
||||||
|
test: uv run --no-sync -- celery --app newsreader status || exit 1
|
||||||
|
start_period: 10s
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
depends_on:
|
||||||
|
rabbitmq:
|
||||||
|
condition: service_started
|
||||||
|
django:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- logs:/app/logs
|
||||||
|
|
|
||||||
102
docker/django
102
docker/django
|
|
@ -1,102 +0,0 @@
|
||||||
# stage 1
|
|
||||||
FROM python:3.11-bookworm AS backend
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install --yes --no-install-recommends \
|
|
||||||
vim \
|
|
||||||
curl \
|
|
||||||
gettext \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
RUN mkdir /app/src
|
|
||||||
RUN mkdir /app/logs
|
|
||||||
RUN mkdir /app/media
|
|
||||||
|
|
||||||
COPY uv.lock pyproject.toml /app/
|
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
|
||||||
RUN uv sync --frozen --no-install-project
|
|
||||||
|
|
||||||
|
|
||||||
# stage 2
|
|
||||||
FROM node:lts AS frontend-build
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY ./*.json ./*.js ./babel.config.js /app/
|
|
||||||
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY ./src /app/src
|
|
||||||
|
|
||||||
RUN npm run build:prod
|
|
||||||
|
|
||||||
|
|
||||||
# stage 3
|
|
||||||
FROM python:3.11-bookworm AS production
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install --yes --no-install-recommends \
|
|
||||||
postgresql-client \
|
|
||||||
vim \
|
|
||||||
curl \
|
|
||||||
gettext \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN mkdir /app/logs
|
|
||||||
RUN mkdir /app/media
|
|
||||||
RUN mkdir /app/bin
|
|
||||||
|
|
||||||
COPY --from=backend /app/.venv /app/.venv
|
|
||||||
COPY --from=backend /app/uv.lock /app/pyproject.toml /app/
|
|
||||||
COPY --from=backend /bin/uv /bin/uv
|
|
||||||
|
|
||||||
COPY ./bin/docker-entrypoint.sh /app/bin/docker-entrypoint.sh
|
|
||||||
|
|
||||||
COPY --from=frontend-build /app/src/newsreader/static /app/src/newsreader/static
|
|
||||||
|
|
||||||
COPY ./src /app/src
|
|
||||||
|
|
||||||
RUN uv sync --frozen --extra production
|
|
||||||
|
|
||||||
RUN useradd -M -u 1000 newsreader
|
|
||||||
RUN chown -R newsreader:newsreader /app
|
|
||||||
|
|
||||||
USER newsreader
|
|
||||||
|
|
||||||
ARG POSTGRES_HOST
|
|
||||||
ARG POSTGRES_PORT
|
|
||||||
ARG POSTGRES_DB
|
|
||||||
ARG POSTGRES_USER
|
|
||||||
ARG POSTGRES_PASSWORD
|
|
||||||
ARG DJANGO_SECRET_KEY
|
|
||||||
ARG DJANGO_SETTINGS_MODULE
|
|
||||||
|
|
||||||
# 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 /app/.venv/bin/python src/manage.py collectstatic --noinput
|
|
||||||
|
|
||||||
|
|
||||||
# (optional) stage 4
|
|
||||||
FROM python:3.11-bookworm AS development
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install --yes --no-install-recommends \
|
|
||||||
vim \
|
|
||||||
curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN mkdir /app/logs
|
|
||||||
RUN mkdir /app/media
|
|
||||||
RUN mkdir /app/bin
|
|
||||||
|
|
||||||
COPY --from=backend /app/.venv /app/.venv
|
|
||||||
COPY --from=backend /app/uv.lock /app/pyproject.toml /app/
|
|
||||||
COPY ./bin/docker-entrypoint.sh /app/bin/docker-entrypoint.sh
|
|
||||||
COPY --from=backend /app/src/ /app/src/
|
|
||||||
|
|
||||||
COPY --from=backend /bin/uv /bin/uv
|
|
||||||
RUN uv sync --frozen --extra testing --extra development
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
FROM node:lts
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
RUN mkdir /app/src
|
|
||||||
|
|
||||||
COPY package*.json webpack.*.js babel.config.js /app/
|
|
||||||
|
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
COPY ./src /app/src
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
static:
|
|
||||||
stage: build
|
|
||||||
image: node:lts
|
|
||||||
before_script:
|
|
||||||
- npm install
|
|
||||||
script:
|
|
||||||
- npm run build
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
python-linting:
|
|
||||||
stage: lint
|
|
||||||
image: python:3.11
|
|
||||||
before_script:
|
|
||||||
- pip install uv
|
|
||||||
- uv sync --extra testing --extra ci
|
|
||||||
script:
|
|
||||||
- ./.venv/bin/ruff check src/
|
|
||||||
- ./.venv/bin/ruff format --check src/
|
|
||||||
only:
|
|
||||||
refs:
|
|
||||||
- main
|
|
||||||
- merge_requests
|
|
||||||
|
|
||||||
javascript-linting:
|
|
||||||
stage: lint
|
|
||||||
image: node:lts
|
|
||||||
before_script:
|
|
||||||
- npm install
|
|
||||||
script:
|
|
||||||
- npm run lint
|
|
||||||
only:
|
|
||||||
refs:
|
|
||||||
- main
|
|
||||||
- merge_requests
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
release:
|
|
||||||
stage: release
|
|
||||||
image: registry.gitlab.com/gitlab-org/release-cli:latest
|
|
||||||
rules:
|
|
||||||
- if: $CI_COMMIT_TAG
|
|
||||||
script:
|
|
||||||
- echo 'running release job'
|
|
||||||
release:
|
|
||||||
name: 'Release $CI_COMMIT_TAG'
|
|
||||||
description: './CHANGELOG.md'
|
|
||||||
tag_name: '$CI_COMMIT_TAG'
|
|
||||||
ref: '$CI_COMMIT_TAG'
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
python-tests:
|
|
||||||
stage: test
|
|
||||||
coverage: '/TOTAL\s+\d+\s+\d+\s+(\d+%)/'
|
|
||||||
services:
|
|
||||||
- postgres:15
|
|
||||||
- memcached:1.5.22
|
|
||||||
image: python:3.11
|
|
||||||
before_script:
|
|
||||||
- pip install uv
|
|
||||||
- uv sync --extra testing --extra ci
|
|
||||||
script:
|
|
||||||
- ./.venv/bin/coverage run ./src/manage.py test newsreader
|
|
||||||
|
|
||||||
javascript-tests:
|
|
||||||
stage: test
|
|
||||||
image: node:lts
|
|
||||||
before_script:
|
|
||||||
- npm install
|
|
||||||
script:
|
|
||||||
- npm test
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
roots: ['src/newsreader/js/tests/'],
|
|
||||||
|
|
||||||
clearMocks: true,
|
|
||||||
coverageDirectory: 'coverage',
|
|
||||||
};
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "newsreader",
|
"name": "newsreader",
|
||||||
"version": "0.4.4",
|
"version": "0.5.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "newsreader",
|
"name": "newsreader",
|
||||||
"version": "0.4.4",
|
"version": "0.5.3",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^5.15.2",
|
"@fortawesome/fontawesome-free": "^5.15.2",
|
||||||
|
|
|
||||||
24
package.json
24
package.json
|
|
@ -1,20 +1,19 @@
|
||||||
{
|
{
|
||||||
"name": "newsreader",
|
"name": "newsreader",
|
||||||
"version": "0.4.4",
|
"version": "0.5.3",
|
||||||
"description": "Application for viewing RSS feeds",
|
"description": "Application for viewing RSS feeds",
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "npx prettier \"src/newsreader/js/**/*.js\" --check",
|
"lint": "npx prettier \"src/newsreader/js/**/*.js\" --check",
|
||||||
"format": "npx prettier \"src/newsreader/js/**/*.js\" --write",
|
"format": "npx prettier \"src/newsreader/js/**/*.js\" --write",
|
||||||
"build": "npx webpack --config webpack.dev.babel.js",
|
|
||||||
"build:watch": "npx webpack --config webpack.dev.babel.js --watch",
|
"build:watch": "npx webpack --config webpack.dev.babel.js --watch",
|
||||||
|
"build:dev": "npx webpack --config webpack.dev.babel.js",
|
||||||
"build:prod": "npx webpack --config webpack.prod.babel.js",
|
"build:prod": "npx webpack --config webpack.prod.babel.js",
|
||||||
"test": "npx jest",
|
"test": "npx jest",
|
||||||
"test:watch": "npm test -- --watch"
|
"test:watch": "npm test -- --watch"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "[git@git.fudiggity.nl:5000]:sonny/newsreader.git"
|
"url": "forgejo.fudiggity.nl:sonny/newsreader"
|
||||||
},
|
},
|
||||||
"author": "Sonny",
|
"author": "Sonny",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
|
|
@ -55,5 +54,22 @@
|
||||||
"webpack": "^5.94.0",
|
"webpack": "^5.94.0",
|
||||||
"webpack-cli": "^5.1.4",
|
"webpack-cli": "^5.1.4",
|
||||||
"webpack-merge": "^4.2.2"
|
"webpack-merge": "^4.2.2"
|
||||||
|
},
|
||||||
|
"prettier": {
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 90,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "avoid"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"roots": [
|
||||||
|
"src/newsreader/js/tests/"
|
||||||
|
],
|
||||||
|
"clearMocks": true,
|
||||||
|
"coverageDirectory": "coverage"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
101
pyproject.toml
101
pyproject.toml
|
|
@ -1,58 +1,81 @@
|
||||||
[project]
|
[project]
|
||||||
name = 'newsreader'
|
name = "newsreader"
|
||||||
version = '0.4.4'
|
version = "0.5.3"
|
||||||
authors = [{name = 'Sonny', email= 'sonnyba871@gmail.com'}]
|
authors = [{ name = "Sonny" }]
|
||||||
license = {text = 'GPL-3.0'}
|
license = { text = "GPL-3.0" }
|
||||||
requires-python = '>=3.11'
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
'django~=4.2',
|
"django~=4.2",
|
||||||
'celery~=5.4',
|
"celery~=5.4",
|
||||||
'psycopg',
|
"psycopg[binary]",
|
||||||
'django-axes',
|
"django-axes",
|
||||||
'django-celery-beat~=2.7.0',
|
"django-celery-beat~=2.7.0",
|
||||||
'django-registration-redux~=2.7',
|
"django-rest-framework",
|
||||||
'django-rest-framework',
|
"djangorestframework-camel-case",
|
||||||
'djangorestframework-camel-case',
|
"pymemcache",
|
||||||
'pymemcache',
|
"python-dotenv~=1.0.1",
|
||||||
'python-dotenv~=1.0.1',
|
"ftfy~=6.2",
|
||||||
'ftfy~=6.2',
|
"requests",
|
||||||
'requests',
|
"feedparser",
|
||||||
'requests_oauthlib',
|
"bleach",
|
||||||
'feedparser',
|
"beautifulsoup4",
|
||||||
'bleach',
|
"lxml",
|
||||||
'beautifulsoup4',
|
|
||||||
'lxml',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[dependency-groups]
|
||||||
testing = [
|
test-tools = ["ruff", "factory_boy", "freezegun"]
|
||||||
'factory-boy',
|
|
||||||
'freezegun',
|
|
||||||
'tblib',
|
|
||||||
"ruff>=0.6.3",
|
|
||||||
]
|
|
||||||
development = [
|
development = [
|
||||||
'django-debug-toolbar',
|
"django-debug-toolbar",
|
||||||
'django-stubs',
|
"django-stubs",
|
||||||
'django-extensions',
|
"django-extensions",
|
||||||
]
|
]
|
||||||
ci = ['coverage~=7.6.1']
|
ci = ["coverage~=7.6.1"]
|
||||||
production = ['gunicorn~=23.0', 'sentry-sdk~=2.0']
|
production = ["gunicorn~=23.0"]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
sentry = ["sentry-sdk~=2.0"]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
environments = ["sys_platform == 'linux'"]
|
environments = ["sys_platform == 'linux'"]
|
||||||
|
default-groups = ["test-tools"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
include = ['pyproject.toml', 'src/**/*.py']
|
include = ["pyproject.toml", "src/**/*.py"]
|
||||||
|
|
||||||
line-length = 88
|
line-length = 88
|
||||||
|
|
||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint]
|
||||||
default-section = 'third-party'
|
select = ["E4", "E7", "E9", "F", "I"]
|
||||||
known-first-party = ['newsreader']
|
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
lines-between-types=1
|
lines-between-types=1
|
||||||
lines-after-imports=2
|
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]
|
[tool.ruff.lint.isort.sections]
|
||||||
django = ['django']
|
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"
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,6 @@ class UserAdminForm(UserChangeForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
widgets = {
|
widgets = {
|
||||||
"email": forms.EmailInput(attrs={"size": "50"}),
|
"email": forms.EmailInput(attrs={"size": "50"}),
|
||||||
"reddit_access_token": forms.PasswordInput(
|
|
||||||
attrs={"size": "90"}, render_value=True
|
|
||||||
),
|
|
||||||
"reddit_refresh_token": forms.PasswordInput(
|
|
||||||
attrs={"size": "90"}, render_value=True
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -34,10 +28,6 @@ class UserAdmin(DjangoUserAdmin):
|
||||||
_("User settings"),
|
_("User settings"),
|
||||||
{"fields": ("email", "password", "first_name", "last_name", "is_active")},
|
{"fields": ("email", "password", "first_name", "last_name", "is_active")},
|
||||||
),
|
),
|
||||||
(
|
|
||||||
_("Reddit settings"),
|
|
||||||
{"fields": ("reddit_access_token", "reddit_refresh_token")},
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
_("Permission settings"),
|
_("Permission settings"),
|
||||||
{"classes": ("collapse",), "fields": ("is_staff", "is_superuser")},
|
{"classes": ("collapse",), "fields": ("is_staff", "is_superuser")},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Generated by Django 4.2.16 on 2025-03-26 08:46
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0017_auto_20240906_0914"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="user",
|
||||||
|
name="reddit_access_token",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="user",
|
||||||
|
name="reddit_refresh_token",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -39,10 +39,6 @@ class UserManager(DjangoUserManager):
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
email = models.EmailField(_("email address"), unique=True)
|
email = models.EmailField(_("email address"), unique=True)
|
||||||
|
|
||||||
# reddit settings
|
|
||||||
reddit_refresh_token = models.CharField(max_length=255, blank=True, null=True)
|
|
||||||
reddit_access_token = models.CharField(max_length=255, blank=True, null=True)
|
|
||||||
|
|
||||||
# settings
|
# settings
|
||||||
auto_mark_read = models.BooleanField(
|
auto_mark_read = models.BooleanField(
|
||||||
_("Auto read marking"),
|
_("Auto read marking"),
|
||||||
|
|
|
||||||
|
|
@ -2,27 +2,23 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
<section class="section form__section--last">
|
<section class="section form__section--last">
|
||||||
<fieldset class="fieldset form__fieldset">
|
<fieldset class="fieldset form__fieldset">
|
||||||
{% include "components/form/confirm-button.html" %}
|
{% include "components/form/confirm-button.html" %}
|
||||||
|
|
||||||
<a class="link button button--primary" href="{% url 'accounts:password-change' %}">
|
<a class="link button button--primary" href="{% url 'accounts:password-change' %}">
|
||||||
{% trans "Change password" %}
|
{% trans "Change password" %}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% if favicon_task_allowed %}
|
{% if favicon_task_allowed %}
|
||||||
<a class="link button button--primary" href="{% url 'accounts:settings:favicon' %}">
|
<a class="link button button--primary" href="{% url 'accounts:settings:favicon' %}">
|
||||||
{% trans "Fetch favicons" %}
|
{% trans "Fetch favicons" %}
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<button class="button button--primary button--disabled" disabled>
|
<button class="button button--primary button--disabled" disabled>
|
||||||
{% trans "Fetch favicons" %}
|
{% trans "Fetch favicons" %}
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
<a class="link button button--primary" href="{% url 'accounts:settings:integrations' %}">
|
</section>
|
||||||
{% trans "Third party integrations" %}
|
|
||||||
</a>
|
|
||||||
</fieldset>
|
|
||||||
</section>
|
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
{% extends "sidebar.html" %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<main id="integrations--page" class="main" data-render-sidebar=true>
|
|
||||||
<div class="main__container">
|
|
||||||
<section class="section">
|
|
||||||
{% include "components/header/header.html" with title="Integrations" only %}
|
|
||||||
|
|
||||||
<div class="integrations">
|
|
||||||
<h3 class="integrations__title">Reddit</h3>
|
|
||||||
<div class="integrations__controls">
|
|
||||||
{% if reddit_authorization_url %}
|
|
||||||
<a class="link button button--reddit" href="{{ reddit_authorization_url }}">
|
|
||||||
{% trans "Authorize account" %}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<button class="button button--reddit button--disabled" disabled>
|
|
||||||
{% trans "Authorize account" %}
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if reddit_refresh_url %}
|
|
||||||
<a class="link button button--reddit" href="{{ reddit_refresh_url }}">
|
|
||||||
{% trans "Refresh token" %}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<button class="button button--reddit button--disabled" disabled>
|
|
||||||
{% trans "Refresh token" %}
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if reddit_revoke_url %}
|
|
||||||
<a class="link button button--reddit" href="{{ reddit_revoke_url }}">
|
|
||||||
{% trans "Deauthorize account" %}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<button class="button button--reddit button--disabled" disabled>
|
|
||||||
{% trans "Deauthorize account" %}
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
{% extends "sidebar.html" %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<main id="reddit--page" class="main" data-render-sidebar=true>
|
|
||||||
<div class="main__container">
|
|
||||||
<section class="section text-section">
|
|
||||||
{% if error %}
|
|
||||||
<h1 class="h1">{% trans "Reddit authorization failed" %}</h1>
|
|
||||||
<p>{{ error }}</p>
|
|
||||||
{% elif access_token and refresh_token %}
|
|
||||||
<h1 class="h1">{% trans "Reddit account is linked" %}</h1>
|
|
||||||
<p>{% trans "Your reddit account was successfully linked." %}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<a class="link" href="{% url 'accounts:settings:integrations' %}">{% trans "Return to integrations page" %}</a>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -5,8 +5,6 @@ from django.utils.crypto import get_random_string
|
||||||
|
|
||||||
import factory
|
import factory
|
||||||
|
|
||||||
from registration.models import RegistrationProfile
|
|
||||||
|
|
||||||
from newsreader.accounts.models import User
|
from newsreader.accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -29,11 +27,3 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
|
|
||||||
|
|
||||||
class RegistrationProfileFactory(factory.django.DjangoModelFactory):
|
|
||||||
user = factory.SubFactory(UserFactory)
|
|
||||||
activation_key = factory.LazyFunction(get_activation_key)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = RegistrationProfile
|
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
import datetime
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
|
|
||||||
from registration.models import RegistrationProfile
|
|
||||||
|
|
||||||
from newsreader.accounts.models import User
|
|
||||||
|
|
||||||
|
|
||||||
class ActivationTestCase(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.register_url = reverse("accounts:register")
|
|
||||||
self.register_success_url = reverse("accounts:register-complete")
|
|
||||||
self.success_url = reverse("accounts:activate-complete")
|
|
||||||
|
|
||||||
def test_activation(self):
|
|
||||||
data = {
|
|
||||||
"email": "test@test.com",
|
|
||||||
"password1": "test12456",
|
|
||||||
"password2": "test12456",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.post(self.register_url, data)
|
|
||||||
self.assertRedirects(response, self.register_success_url)
|
|
||||||
|
|
||||||
register_profile = RegistrationProfile.objects.get()
|
|
||||||
|
|
||||||
kwargs = {"activation_key": register_profile.activation_key}
|
|
||||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
|
||||||
|
|
||||||
self.assertRedirects(response, self.success_url)
|
|
||||||
|
|
||||||
def test_expired_key(self):
|
|
||||||
data = {
|
|
||||||
"email": "test@test.com",
|
|
||||||
"password1": "test12456",
|
|
||||||
"password2": "test12456",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.post(self.register_url, data)
|
|
||||||
|
|
||||||
register_profile = RegistrationProfile.objects.get()
|
|
||||||
user = register_profile.user
|
|
||||||
|
|
||||||
user.date_joined -= datetime.timedelta(days=settings.ACCOUNT_ACTIVATION_DAYS)
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
kwargs = {"activation_key": register_profile.activation_key}
|
|
||||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
|
||||||
|
|
||||||
self.assertEqual(200, response.status_code)
|
|
||||||
self.assertContains(response, _("Account activation failed"))
|
|
||||||
|
|
||||||
user.refresh_from_db()
|
|
||||||
self.assertFalse(user.is_active)
|
|
||||||
|
|
||||||
def test_invalid_key(self):
|
|
||||||
data = {
|
|
||||||
"email": "test@test.com",
|
|
||||||
"password1": "test12456",
|
|
||||||
"password2": "test12456",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.post(self.register_url, data)
|
|
||||||
self.assertRedirects(response, self.register_success_url)
|
|
||||||
|
|
||||||
kwargs = {"activation_key": "not-a-valid-key"}
|
|
||||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
|
||||||
|
|
||||||
self.assertContains(response, _("Account activation failed"))
|
|
||||||
|
|
||||||
user = User.objects.get()
|
|
||||||
|
|
||||||
self.assertEquals(user.is_active, False)
|
|
||||||
|
|
||||||
def test_activated_key(self):
|
|
||||||
data = {
|
|
||||||
"email": "test@test.com",
|
|
||||||
"password1": "test12456",
|
|
||||||
"password2": "test12456",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.post(self.register_url, data)
|
|
||||||
self.assertRedirects(response, self.register_success_url)
|
|
||||||
|
|
||||||
register_profile = RegistrationProfile.objects.get()
|
|
||||||
|
|
||||||
kwargs = {"activation_key": register_profile.activation_key}
|
|
||||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
|
||||||
|
|
||||||
self.assertRedirects(response, self.success_url)
|
|
||||||
|
|
||||||
# try this a second time
|
|
||||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
|
||||||
|
|
||||||
self.assertRedirects(response, self.success_url)
|
|
||||||
|
|
@ -1,275 +0,0 @@
|
||||||
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.urls import reverse
|
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
|
|
||||||
from newsreader.accounts.tests.factories import UserFactory
|
|
||||||
from newsreader.news.collection.exceptions import (
|
|
||||||
StreamException,
|
|
||||||
StreamTooManyException,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class IntegrationsViewTestCase(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = UserFactory(email="test@test.nl", password="test")
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
|
|
||||||
self.url = reverse("accounts:settings:integrations")
|
|
||||||
|
|
||||||
|
|
||||||
class RedditIntegrationsTestCase(IntegrationsViewTestCase):
|
|
||||||
def test_reddit_authorization(self):
|
|
||||||
self.user.reddit_refresh_token = None
|
|
||||||
self.user.save()
|
|
||||||
|
|
||||||
response = self.client.get(self.url)
|
|
||||||
|
|
||||||
soup = BeautifulSoup(response.content, features="lxml")
|
|
||||||
button = soup.find("a", class_="link button button--reddit")
|
|
||||||
|
|
||||||
self.assertEquals(button.text.strip(), "Authorize account")
|
|
||||||
|
|
||||||
def test_reddit_refresh_token(self):
|
|
||||||
self.user.reddit_refresh_token = "jadajadajada"
|
|
||||||
self.user.reddit_access_token = None
|
|
||||||
self.user.save()
|
|
||||||
|
|
||||||
response = self.client.get(self.url)
|
|
||||||
|
|
||||||
soup = BeautifulSoup(response.content, features="lxml")
|
|
||||||
button = soup.find("a", class_="link button button--reddit")
|
|
||||||
|
|
||||||
self.assertEquals(button.text.strip(), "Refresh token")
|
|
||||||
|
|
||||||
def test_reddit_revoke(self):
|
|
||||||
self.user.reddit_refresh_token = "jadajadajada"
|
|
||||||
self.user.reddit_access_token = None
|
|
||||||
self.user.save()
|
|
||||||
|
|
||||||
response = self.client.get(self.url)
|
|
||||||
|
|
||||||
soup = BeautifulSoup(response.content, features="lxml")
|
|
||||||
buttons = soup.find_all("a", class_="link button button--reddit")
|
|
||||||
|
|
||||||
self.assertIn(
|
|
||||||
"Deauthorize account", [button.text.strip() for button in buttons]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RedditTemplateViewTestCase(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = UserFactory(email="test@test.nl", password="test")
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
|
|
||||||
self.base_url = reverse("accounts:settings:reddit-template")
|
|
||||||
self.state = str(uuid4())
|
|
||||||
|
|
||||||
self.patch = patch("newsreader.news.collection.reddit.post")
|
|
||||||
self.mocked_post = self.patch.start()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
patch.stopall()
|
|
||||||
|
|
||||||
def test_simple(self):
|
|
||||||
response = self.client.get(self.base_url)
|
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 200)
|
|
||||||
self.assertContains(response, "Return to integrations page")
|
|
||||||
|
|
||||||
def test_successful_authorization(self):
|
|
||||||
self.mocked_post.return_value.json.return_value = {
|
|
||||||
"access_token": "1001010412",
|
|
||||||
"refresh_token": "134510143",
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.set(f"{self.user.email}-reddit-auth", self.state)
|
|
||||||
|
|
||||||
params = {"state": self.state, "code": "Valid code"}
|
|
||||||
url = f"{self.base_url}?{urlencode(params)}"
|
|
||||||
|
|
||||||
response = self.client.get(url)
|
|
||||||
|
|
||||||
self.mocked_post.assert_called_once()
|
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 200)
|
|
||||||
self.assertContains(response, "Your reddit account was successfully linked.")
|
|
||||||
|
|
||||||
self.user.refresh_from_db()
|
|
||||||
|
|
||||||
self.assertEquals(self.user.reddit_access_token, "1001010412")
|
|
||||||
self.assertEquals(self.user.reddit_refresh_token, "134510143")
|
|
||||||
|
|
||||||
self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), None)
|
|
||||||
|
|
||||||
def test_error(self):
|
|
||||||
params = {"error": "Denied authorization"}
|
|
||||||
|
|
||||||
url = f"{self.base_url}?{urlencode(params)}"
|
|
||||||
|
|
||||||
response = self.client.get(url)
|
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 200)
|
|
||||||
self.assertContains(response, "Denied authorization")
|
|
||||||
|
|
||||||
def test_invalid_state(self):
|
|
||||||
cache.set(f"{self.user.email}-reddit-auth", str(uuid4()))
|
|
||||||
|
|
||||||
params = {"code": "Valid code", "state": "Invalid state"}
|
|
||||||
|
|
||||||
url = f"{self.base_url}?{urlencode(params)}"
|
|
||||||
|
|
||||||
response = self.client.get(url)
|
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 200)
|
|
||||||
self.assertContains(
|
|
||||||
response, "The saved state for Reddit authorization did not match"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_stream_error(self):
|
|
||||||
self.mocked_post.side_effect = StreamTooManyException
|
|
||||||
|
|
||||||
cache.set(f"{self.user.email}-reddit-auth", self.state)
|
|
||||||
|
|
||||||
params = {"state": self.state, "code": "Valid code"}
|
|
||||||
url = f"{self.base_url}?{urlencode(params)}"
|
|
||||||
|
|
||||||
response = self.client.get(url)
|
|
||||||
|
|
||||||
self.mocked_post.assert_called_once()
|
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 200)
|
|
||||||
self.assertContains(response, "Too many requests")
|
|
||||||
|
|
||||||
self.user.refresh_from_db()
|
|
||||||
|
|
||||||
self.assertEquals(self.user.reddit_access_token, None)
|
|
||||||
self.assertEquals(self.user.reddit_refresh_token, None)
|
|
||||||
|
|
||||||
self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state)
|
|
||||||
|
|
||||||
def test_unexpected_json(self):
|
|
||||||
self.mocked_post.return_value.json.return_value = {"message": "Happy eastern"}
|
|
||||||
|
|
||||||
cache.set(f"{self.user.email}-reddit-auth", self.state)
|
|
||||||
|
|
||||||
params = {"state": self.state, "code": "Valid code"}
|
|
||||||
url = f"{self.base_url}?{urlencode(params)}"
|
|
||||||
|
|
||||||
response = self.client.get(url)
|
|
||||||
|
|
||||||
self.mocked_post.assert_called_once()
|
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 200)
|
|
||||||
self.assertContains(response, "Access and refresh token not found in response")
|
|
||||||
|
|
||||||
self.user.refresh_from_db()
|
|
||||||
|
|
||||||
self.assertEquals(self.user.reddit_access_token, None)
|
|
||||||
self.assertEquals(self.user.reddit_refresh_token, None)
|
|
||||||
|
|
||||||
self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state)
|
|
||||||
|
|
||||||
|
|
||||||
class RedditTokenRedirectViewTestCase(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = UserFactory(email="test@test.nl", password="test")
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
|
|
||||||
self.patch = patch("newsreader.accounts.views.integrations.RedditTokenTask")
|
|
||||||
self.mocked_task = self.patch.start()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
cache.clear()
|
|
||||||
|
|
||||||
def test_simple(self):
|
|
||||||
response = self.client.get(reverse("accounts:settings:reddit-refresh"))
|
|
||||||
|
|
||||||
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
|
||||||
|
|
||||||
self.mocked_task.delay.assert_called_once_with(self.user.pk)
|
|
||||||
|
|
||||||
self.assertEquals(1, cache.get(f"{self.user.email}-reddit-refresh"))
|
|
||||||
|
|
||||||
def test_not_active(self):
|
|
||||||
cache.set(f"{self.user.email}-reddit-refresh", 1)
|
|
||||||
|
|
||||||
response = self.client.get(reverse("accounts:settings:reddit-refresh"))
|
|
||||||
|
|
||||||
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
|
||||||
|
|
||||||
self.mocked_task.delay.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class RedditRevokeRedirectViewTestCase(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = UserFactory(email="test@test.nl", password="test")
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
|
|
||||||
self.patch = patch("newsreader.accounts.views.integrations.revoke_reddit_token")
|
|
||||||
self.mocked_revoke = self.patch.start()
|
|
||||||
|
|
||||||
def test_simple(self):
|
|
||||||
self.user.reddit_access_token = "jadajadajada"
|
|
||||||
self.user.reddit_refresh_token = "jadajadajada"
|
|
||||||
self.user.save()
|
|
||||||
|
|
||||||
self.mocked_revoke.return_value = True
|
|
||||||
|
|
||||||
response = self.client.get(reverse("accounts:settings:reddit-revoke"))
|
|
||||||
|
|
||||||
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
|
||||||
|
|
||||||
self.mocked_revoke.assert_called_once_with(self.user)
|
|
||||||
|
|
||||||
self.user.refresh_from_db()
|
|
||||||
|
|
||||||
self.assertEquals(self.user.reddit_access_token, None)
|
|
||||||
self.assertEquals(self.user.reddit_refresh_token, None)
|
|
||||||
|
|
||||||
def test_no_refresh_token(self):
|
|
||||||
self.user.reddit_refresh_token = None
|
|
||||||
self.user.save()
|
|
||||||
|
|
||||||
response = self.client.get(reverse("accounts:settings:reddit-revoke"))
|
|
||||||
|
|
||||||
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
|
||||||
|
|
||||||
self.mocked_revoke.assert_not_called()
|
|
||||||
|
|
||||||
def test_unsuccessful_response(self):
|
|
||||||
self.user.reddit_access_token = "jadajadajada"
|
|
||||||
self.user.reddit_refresh_token = "jadajadajada"
|
|
||||||
self.user.save()
|
|
||||||
|
|
||||||
self.mocked_revoke.return_value = False
|
|
||||||
|
|
||||||
response = self.client.get(reverse("accounts:settings:reddit-revoke"))
|
|
||||||
|
|
||||||
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
|
||||||
|
|
||||||
self.user.refresh_from_db()
|
|
||||||
|
|
||||||
self.assertEquals(self.user.reddit_access_token, "jadajadajada")
|
|
||||||
self.assertEquals(self.user.reddit_refresh_token, "jadajadajada")
|
|
||||||
|
|
||||||
def test_stream_exception(self):
|
|
||||||
self.user.reddit_access_token = "jadajadajada"
|
|
||||||
self.user.reddit_refresh_token = "jadajadajada"
|
|
||||||
self.user.save()
|
|
||||||
|
|
||||||
self.mocked_revoke.side_effect = StreamException
|
|
||||||
|
|
||||||
response = self.client.get(reverse("accounts:settings:reddit-revoke"))
|
|
||||||
|
|
||||||
self.assertRedirects(response, reverse("accounts:settings:integrations"))
|
|
||||||
|
|
||||||
self.user.refresh_from_db()
|
|
||||||
|
|
||||||
self.assertEquals(self.user.reddit_access_token, "jadajadajada")
|
|
||||||
self.assertEquals(self.user.reddit_refresh_token, "jadajadajada")
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
from django.core import mail
|
|
||||||
from django.test import TransactionTestCase as TestCase
|
|
||||||
from django.test.utils import override_settings
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
|
|
||||||
from registration.models import RegistrationProfile
|
|
||||||
|
|
||||||
from newsreader.accounts.models import User
|
|
||||||
from newsreader.accounts.tests.factories import UserFactory
|
|
||||||
|
|
||||||
|
|
||||||
class RegistrationTestCase(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.url = reverse("accounts:register")
|
|
||||||
self.success_url = reverse("accounts:register-complete")
|
|
||||||
self.disallowed_url = reverse("accounts:register-closed")
|
|
||||||
|
|
||||||
def test_simple(self):
|
|
||||||
response = self.client.get(self.url)
|
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_registration(self):
|
|
||||||
data = {
|
|
||||||
"email": "test@test.com",
|
|
||||||
"password1": "test12456",
|
|
||||||
"password2": "test12456",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.post(self.url, data)
|
|
||||||
self.assertRedirects(response, self.success_url)
|
|
||||||
|
|
||||||
self.assertEquals(User.objects.count(), 1)
|
|
||||||
self.assertEquals(RegistrationProfile.objects.count(), 1)
|
|
||||||
|
|
||||||
user = User.objects.get()
|
|
||||||
|
|
||||||
self.assertEquals(user.is_active, False)
|
|
||||||
self.assertEquals(len(mail.outbox), 1)
|
|
||||||
|
|
||||||
def test_existing_email(self):
|
|
||||||
UserFactory(email="test@test.com")
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"email": "test@test.com",
|
|
||||||
"password1": "test12456",
|
|
||||||
"password2": "test12456",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.post(self.url, data)
|
|
||||||
self.assertEquals(response.status_code, 200)
|
|
||||||
|
|
||||||
self.assertEquals(User.objects.count(), 1)
|
|
||||||
self.assertContains(response, _("User with this Email address already exists"))
|
|
||||||
|
|
||||||
def test_pending_registration(self):
|
|
||||||
data = {
|
|
||||||
"email": "test@test.com",
|
|
||||||
"password1": "test12456",
|
|
||||||
"password2": "test12456",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.post(self.url, data)
|
|
||||||
self.assertRedirects(response, self.success_url)
|
|
||||||
|
|
||||||
self.assertEquals(User.objects.count(), 1)
|
|
||||||
self.assertEquals(RegistrationProfile.objects.count(), 1)
|
|
||||||
|
|
||||||
user = User.objects.get()
|
|
||||||
|
|
||||||
self.assertEquals(user.is_active, False)
|
|
||||||
self.assertEquals(len(mail.outbox), 1)
|
|
||||||
|
|
||||||
response = self.client.post(self.url, data)
|
|
||||||
self.assertEquals(response.status_code, 200)
|
|
||||||
self.assertContains(response, _("User with this Email address already exists"))
|
|
||||||
|
|
||||||
def test_disabled_account(self):
|
|
||||||
UserFactory(email="test@test.com", is_active=False)
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"email": "test@test.com",
|
|
||||||
"password1": "test12456",
|
|
||||||
"password2": "test12456",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.post(self.url, data)
|
|
||||||
self.assertEquals(response.status_code, 200)
|
|
||||||
|
|
||||||
self.assertEquals(User.objects.count(), 1)
|
|
||||||
self.assertContains(response, _("User with this Email address already exists"))
|
|
||||||
|
|
||||||
@override_settings(REGISTRATION_OPEN=False)
|
|
||||||
def test_registration_closed(self):
|
|
||||||
response = self.client.get(self.url)
|
|
||||||
|
|
||||||
self.assertRedirects(response, self.disallowed_url)
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"email": "test@test.com",
|
|
||||||
"password1": "test12456",
|
|
||||||
"password2": "test12456",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.post(self.url, data)
|
|
||||||
self.assertRedirects(response, self.disallowed_url)
|
|
||||||
|
|
||||||
self.assertEquals(User.objects.count(), 0)
|
|
||||||
self.assertEquals(RegistrationProfile.objects.count(), 0)
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
from django.core import mail
|
|
||||||
from django.test import TransactionTestCase as TestCase
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
|
|
||||||
from registration.models import RegistrationProfile
|
|
||||||
|
|
||||||
from newsreader.accounts.tests.factories import RegistrationProfileFactory, UserFactory
|
|
||||||
|
|
||||||
|
|
||||||
class ResendActivationTestCase(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.url = reverse("accounts:activate-resend")
|
|
||||||
self.success_url = reverse("accounts:activate-complete")
|
|
||||||
self.register_url = reverse("accounts:register")
|
|
||||||
|
|
||||||
def test_simple(self):
|
|
||||||
response = self.client.get(self.url)
|
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_resent_form(self):
|
|
||||||
data = {
|
|
||||||
"email": "test@test.com",
|
|
||||||
"password1": "test12456",
|
|
||||||
"password2": "test12456",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.post(self.register_url, data)
|
|
||||||
|
|
||||||
register_profile = RegistrationProfile.objects.get()
|
|
||||||
original_kwargs = {"activation_key": register_profile.activation_key}
|
|
||||||
|
|
||||||
response = self.client.post(self.url, {"email": "test@test.com"})
|
|
||||||
|
|
||||||
self.assertContains(response, _("We have sent an email to"))
|
|
||||||
|
|
||||||
self.assertEquals(len(mail.outbox), 2)
|
|
||||||
|
|
||||||
register_profile.refresh_from_db()
|
|
||||||
|
|
||||||
kwargs = {"activation_key": register_profile.activation_key}
|
|
||||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
|
||||||
|
|
||||||
self.assertRedirects(response, self.success_url)
|
|
||||||
|
|
||||||
register_profile.refresh_from_db()
|
|
||||||
user = register_profile.user
|
|
||||||
|
|
||||||
self.assertEquals(user.is_active, True)
|
|
||||||
|
|
||||||
# test the old activation code
|
|
||||||
response = self.client.get(reverse("accounts:activate", kwargs=original_kwargs))
|
|
||||||
|
|
||||||
self.assertEquals(response.status_code, 200)
|
|
||||||
self.assertContains(response, _("Account activation failed"))
|
|
||||||
|
|
||||||
def test_existing_account(self):
|
|
||||||
user = UserFactory(is_active=True)
|
|
||||||
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)
|
|
||||||
|
|
@ -2,11 +2,7 @@ from django.contrib.auth.decorators import login_required
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
from newsreader.accounts.views import (
|
from newsreader.accounts.views import (
|
||||||
ActivationCompleteView,
|
|
||||||
ActivationResendView,
|
|
||||||
ActivationView,
|
|
||||||
FaviconRedirectView,
|
FaviconRedirectView,
|
||||||
IntegrationsView,
|
|
||||||
LoginView,
|
LoginView,
|
||||||
LogoutView,
|
LogoutView,
|
||||||
PasswordChangeView,
|
PasswordChangeView,
|
||||||
|
|
@ -14,36 +10,11 @@ from newsreader.accounts.views import (
|
||||||
PasswordResetConfirmView,
|
PasswordResetConfirmView,
|
||||||
PasswordResetDoneView,
|
PasswordResetDoneView,
|
||||||
PasswordResetView,
|
PasswordResetView,
|
||||||
RedditRevokeRedirectView,
|
|
||||||
RedditTemplateView,
|
|
||||||
RedditTokenRedirectView,
|
|
||||||
RegistrationClosedView,
|
|
||||||
RegistrationCompleteView,
|
|
||||||
RegistrationView,
|
|
||||||
SettingsView,
|
SettingsView,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
settings_patterns = [
|
settings_patterns = [
|
||||||
# Integrations
|
|
||||||
path(
|
|
||||||
"integrations/reddit/callback/",
|
|
||||||
login_required(RedditTemplateView.as_view()),
|
|
||||||
name="reddit-template",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"integrations/reddit/refresh/",
|
|
||||||
login_required(RedditTokenRedirectView.as_view()),
|
|
||||||
name="reddit-refresh",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"integrations/reddit/revoke/",
|
|
||||||
login_required(RedditRevokeRedirectView.as_view()),
|
|
||||||
name="reddit-revoke",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"integrations/", login_required(IntegrationsView.as_view()), name="integrations"
|
|
||||||
),
|
|
||||||
# Misc
|
# Misc
|
||||||
path("favicon/", login_required(FaviconRedirectView.as_view()), name="favicon"),
|
path("favicon/", login_required(FaviconRedirectView.as_view()), name="favicon"),
|
||||||
path("", login_required(SettingsView.as_view()), name="home"),
|
path("", login_required(SettingsView.as_view()), name="home"),
|
||||||
|
|
@ -53,24 +24,6 @@ urlpatterns = [
|
||||||
# Auth
|
# Auth
|
||||||
path("login/", LoginView.as_view(), name="login"),
|
path("login/", LoginView.as_view(), name="login"),
|
||||||
path("logout/", LogoutView.as_view(), name="logout"),
|
path("logout/", LogoutView.as_view(), name="logout"),
|
||||||
# Register
|
|
||||||
path("register/", RegistrationView.as_view(), name="register"),
|
|
||||||
path(
|
|
||||||
"register/complete/",
|
|
||||||
RegistrationCompleteView.as_view(),
|
|
||||||
name="register-complete",
|
|
||||||
),
|
|
||||||
path("register/closed/", RegistrationClosedView.as_view(), name="register-closed"),
|
|
||||||
path(
|
|
||||||
"activate/complete/", ActivationCompleteView.as_view(), name="activate-complete"
|
|
||||||
),
|
|
||||||
path("activate/resend/", ActivationResendView.as_view(), name="activate-resend"),
|
|
||||||
path(
|
|
||||||
# This URL should be placed after all activate/ url's (see arg)
|
|
||||||
"activate/<str:activation_key>/",
|
|
||||||
ActivationView.as_view(),
|
|
||||||
name="activate",
|
|
||||||
),
|
|
||||||
# Password
|
# Password
|
||||||
path("password-reset/", PasswordResetView.as_view(), name="password-reset"),
|
path("password-reset/", PasswordResetView.as_view(), name="password-reset"),
|
||||||
path(
|
path(
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
from newsreader.accounts.views.auth import LoginView, LogoutView
|
from newsreader.accounts.views.auth import LoginView, LogoutView
|
||||||
from newsreader.accounts.views.favicon import FaviconRedirectView
|
from newsreader.accounts.views.favicon import FaviconRedirectView
|
||||||
from newsreader.accounts.views.integrations import (
|
|
||||||
IntegrationsView,
|
|
||||||
RedditRevokeRedirectView,
|
|
||||||
RedditTemplateView,
|
|
||||||
RedditTokenRedirectView,
|
|
||||||
)
|
|
||||||
from newsreader.accounts.views.password import (
|
from newsreader.accounts.views.password import (
|
||||||
PasswordChangeView,
|
PasswordChangeView,
|
||||||
PasswordResetCompleteView,
|
PasswordResetCompleteView,
|
||||||
|
|
@ -13,34 +7,17 @@ from newsreader.accounts.views.password import (
|
||||||
PasswordResetDoneView,
|
PasswordResetDoneView,
|
||||||
PasswordResetView,
|
PasswordResetView,
|
||||||
)
|
)
|
||||||
from newsreader.accounts.views.registration import (
|
|
||||||
ActivationCompleteView,
|
|
||||||
ActivationResendView,
|
|
||||||
ActivationView,
|
|
||||||
RegistrationClosedView,
|
|
||||||
RegistrationCompleteView,
|
|
||||||
RegistrationView,
|
|
||||||
)
|
|
||||||
from newsreader.accounts.views.settings import SettingsView
|
from newsreader.accounts.views.settings import SettingsView
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"LoginView",
|
"LoginView",
|
||||||
"LogoutView",
|
"LogoutView",
|
||||||
"FaviconRedirectView",
|
"FaviconRedirectView",
|
||||||
"IntegrationsView",
|
|
||||||
"RedditRevokeRedirectView",
|
|
||||||
"RedditTemplateView",
|
|
||||||
"RedditTokenRedirectView",
|
|
||||||
"PasswordChangeView",
|
"PasswordChangeView",
|
||||||
"PasswordResetCompleteView",
|
"PasswordResetCompleteView",
|
||||||
"PasswordResetConfirmView",
|
"PasswordResetConfirmView",
|
||||||
"PasswordResetDoneView",
|
"PasswordResetDoneView",
|
||||||
"PasswordResetView",
|
"PasswordResetView",
|
||||||
"ActivationCompleteView",
|
|
||||||
"ActivationResendView",
|
|
||||||
"ActivationView",
|
|
||||||
"RegistrationClosedView",
|
|
||||||
"RegistrationCompleteView",
|
|
||||||
"RegistrationView",
|
|
||||||
"SettingsView",
|
"SettingsView",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
import logging
|
|
||||||
|
|
||||||
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, TemplateView
|
|
||||||
|
|
||||||
from newsreader.news.collection.exceptions import StreamException
|
|
||||||
from newsreader.news.collection.reddit import (
|
|
||||||
get_reddit_access_token,
|
|
||||||
get_reddit_authorization_url,
|
|
||||||
revoke_reddit_token,
|
|
||||||
)
|
|
||||||
from newsreader.news.collection.tasks import RedditTokenTask
|
|
||||||
from newsreader.utils.views import NavListMixin
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class IntegrationsView(NavListMixin, TemplateView):
|
|
||||||
template_name = "accounts/views/integrations.html"
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
return {
|
|
||||||
**super().get_context_data(**kwargs),
|
|
||||||
**self.get_reddit_context(**kwargs),
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_reddit_context(self, **kwargs):
|
|
||||||
user = self.request.user
|
|
||||||
reddit_authorization_url = None
|
|
||||||
reddit_refresh_url = None
|
|
||||||
|
|
||||||
reddit_task_active = cache.get(f"{user.email}-reddit-refresh")
|
|
||||||
|
|
||||||
if (
|
|
||||||
user.reddit_refresh_token
|
|
||||||
and not user.reddit_access_token
|
|
||||||
and not reddit_task_active
|
|
||||||
):
|
|
||||||
reddit_refresh_url = reverse_lazy("accounts:settings:reddit-refresh")
|
|
||||||
|
|
||||||
if not user.reddit_refresh_token:
|
|
||||||
reddit_authorization_url = get_reddit_authorization_url(user)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"reddit_authorization_url": reddit_authorization_url,
|
|
||||||
"reddit_refresh_url": reddit_refresh_url,
|
|
||||||
"reddit_revoke_url": (
|
|
||||||
reverse_lazy("accounts:settings:reddit-revoke")
|
|
||||||
if not reddit_authorization_url
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RedditTemplateView(NavListMixin, TemplateView):
|
|
||||||
template_name = "accounts/views/reddit.html"
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
context = self.get_context_data(**kwargs)
|
|
||||||
|
|
||||||
error = request.GET.get("error", None)
|
|
||||||
state = request.GET.get("state", None)
|
|
||||||
code = request.GET.get("code", None)
|
|
||||||
|
|
||||||
if error:
|
|
||||||
return self.render_to_response({**context, "error": error})
|
|
||||||
|
|
||||||
if not code or not state:
|
|
||||||
return self.render_to_response(context)
|
|
||||||
|
|
||||||
cached_state = cache.get(f"{request.user.email}-reddit-auth")
|
|
||||||
|
|
||||||
if state != cached_state:
|
|
||||||
return self.render_to_response(
|
|
||||||
{
|
|
||||||
**context,
|
|
||||||
"error": _(
|
|
||||||
"The saved state for Reddit authorization did not match"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
access_token, refresh_token = get_reddit_access_token(code, request.user)
|
|
||||||
|
|
||||||
return self.render_to_response(
|
|
||||||
{
|
|
||||||
**context,
|
|
||||||
"access_token": access_token,
|
|
||||||
"refresh_token": refresh_token,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except StreamException as e:
|
|
||||||
return self.render_to_response({**context, "error": str(e)})
|
|
||||||
except KeyError:
|
|
||||||
return self.render_to_response(
|
|
||||||
{
|
|
||||||
**context,
|
|
||||||
"error": _("Access and refresh token not found in response"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RedditTokenRedirectView(RedirectView):
|
|
||||||
url = reverse_lazy("accounts:settings:integrations")
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
response = super().get(request, *args, **kwargs)
|
|
||||||
|
|
||||||
user = request.user
|
|
||||||
task_active = cache.get(f"{user.email}-reddit-refresh")
|
|
||||||
|
|
||||||
if not task_active:
|
|
||||||
RedditTokenTask.delay(user.pk)
|
|
||||||
messages.success(request, _("Access token is being retrieved"))
|
|
||||||
cache.set(f"{user.email}-reddit-refresh", 1, 300)
|
|
||||||
return response
|
|
||||||
|
|
||||||
messages.error(request, _("Unable to retrieve token"))
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
class RedditRevokeRedirectView(RedirectView):
|
|
||||||
url = reverse_lazy("accounts:settings:integrations")
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
response = super().get(request, *args, **kwargs)
|
|
||||||
|
|
||||||
user = request.user
|
|
||||||
|
|
||||||
if not user.reddit_refresh_token:
|
|
||||||
messages.error(request, _("No reddit account is linked to this account"))
|
|
||||||
return response
|
|
||||||
|
|
||||||
try:
|
|
||||||
is_revoked = revoke_reddit_token(user)
|
|
||||||
except StreamException:
|
|
||||||
logger.exception(f"Unable to revoke reddit token for {user.pk}")
|
|
||||||
|
|
||||||
messages.error(request, _("Unable to revoke reddit token"))
|
|
||||||
return response
|
|
||||||
|
|
||||||
if not is_revoked:
|
|
||||||
messages.error(request, _("Unable to revoke reddit token"))
|
|
||||||
return response
|
|
||||||
|
|
||||||
user.reddit_access_token = None
|
|
||||||
user.reddit_refresh_token = None
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
messages.success(request, _("Reddit account deathorized"))
|
|
||||||
return response
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
from django.shortcuts import render
|
|
||||||
from django.urls import reverse_lazy
|
|
||||||
from django.views.generic import TemplateView
|
|
||||||
|
|
||||||
from registration.backends.default import views as registration_views
|
|
||||||
|
|
||||||
from newsreader.utils.views import NavListMixin
|
|
||||||
|
|
||||||
|
|
||||||
# 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(NavListMixin, 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(NavListMixin, TemplateView):
|
|
||||||
template_name = "registration/registration_complete.html"
|
|
||||||
|
|
||||||
|
|
||||||
class RegistrationClosedView(NavListMixin, TemplateView):
|
|
||||||
template_name = "registration/registration_closed.html"
|
|
||||||
|
|
||||||
|
|
||||||
# Redirects or renders failed activation template
|
|
||||||
class ActivationView(NavListMixin, registration_views.ActivationView):
|
|
||||||
template_name = "registration/activation_failure.html"
|
|
||||||
|
|
||||||
def get_success_url(self, user):
|
|
||||||
return ("accounts:activate-complete", (), {})
|
|
||||||
|
|
||||||
|
|
||||||
class ActivationCompleteView(NavListMixin, TemplateView):
|
|
||||||
template_name = "registration/activation_complete.html"
|
|
||||||
|
|
||||||
|
|
||||||
# Renders activation form resend or resend_activation_complete
|
|
||||||
class ActivationResendView(NavListMixin, registration_views.ResendActivationView):
|
|
||||||
template_name = "registration/activation_resend_form.html"
|
|
||||||
|
|
||||||
def render_form_submitted_template(self, form):
|
|
||||||
"""
|
|
||||||
Renders resend activation complete template with the submitted email.
|
|
||||||
|
|
||||||
"""
|
|
||||||
email = form.cleaned_data["email"]
|
|
||||||
context = {"email": email}
|
|
||||||
|
|
||||||
return render(
|
|
||||||
self.request, "registration/activation_resend_complete.html", context
|
|
||||||
)
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import os
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from newsreader.conf.utils import get_env, get_root_dir
|
||||||
|
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
@ -15,16 +13,13 @@ except ImportError:
|
||||||
DjangoIntegration = None
|
DjangoIntegration = None
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
|
BASE_DIR = get_root_dir()
|
||||||
DJANGO_PROJECT_DIR = os.path.join(BASE_DIR, "src", "newsreader")
|
DJANGO_PROJECT_DIR = BASE_DIR / "src" / "newsreader"
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
|
||||||
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
|
|
||||||
# SECURITY WARNING: don"t run with debug turned on in production!
|
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
|
|
||||||
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
|
ALLOWED_HOSTS = get_env("ALLOWED_HOSTS", split=",", default=["127.0.0.1", "localhost"])
|
||||||
INTERNAL_IPS = ["127.0.0.1", "localhost"]
|
INTERNAL_IPS = get_env("INTERNAL_IPS", split=",", default=["127.0.0.1", "localhost"])
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
|
|
@ -39,7 +34,6 @@ INSTALLED_APPS = [
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
"celery",
|
"celery",
|
||||||
"django_celery_beat",
|
"django_celery_beat",
|
||||||
"registration",
|
|
||||||
"axes",
|
"axes",
|
||||||
# app modules
|
# app modules
|
||||||
"newsreader.accounts",
|
"newsreader.accounts",
|
||||||
|
|
@ -49,7 +43,7 @@ INSTALLED_APPS = [
|
||||||
"newsreader.news.collection",
|
"newsreader.news.collection",
|
||||||
]
|
]
|
||||||
|
|
||||||
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
|
SECRET_KEY = get_env("DJANGO_SECRET_KEY", default="")
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
"axes.backends.AxesBackend",
|
"axes.backends.AxesBackend",
|
||||||
|
|
@ -74,11 +68,10 @@ FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
|
"DIRS": [DJANGO_PROJECT_DIR / "templates"],
|
||||||
"APP_DIRS": True,
|
"APP_DIRS": True,
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"context_processors": [
|
"context_processors": [
|
||||||
"django.template.context_processors.debug",
|
|
||||||
"django.template.context_processors.request",
|
"django.template.context_processors.request",
|
||||||
"django.contrib.auth.context_processors.auth",
|
"django.contrib.auth.context_processors.auth",
|
||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
|
@ -89,16 +82,14 @@ TEMPLATES = [
|
||||||
|
|
||||||
WSGI_APPLICATION = "newsreader.wsgi.application"
|
WSGI_APPLICATION = "newsreader.wsgi.application"
|
||||||
|
|
||||||
# Database
|
|
||||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.postgresql",
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
"HOST": os.environ["POSTGRES_HOST"],
|
"HOST": get_env("POSTGRES_HOST", default=""),
|
||||||
"PORT": os.environ["POSTGRES_PORT"],
|
"PORT": get_env("POSTGRES_PORT", default=""),
|
||||||
"NAME": os.environ["POSTGRES_DB"],
|
"NAME": get_env("POSTGRES_DB", default=""),
|
||||||
"USER": os.environ["POSTGRES_USER"],
|
"USER": get_env("POSTGRES_USER", default=""),
|
||||||
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
|
"PASSWORD": get_env("POSTGRES_PASSWORD", default=""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,8 +106,6 @@ CACHES = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# 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,
|
||||||
|
|
@ -170,8 +159,6 @@ LOGGING = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Password validation
|
|
||||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
|
||||||
|
|
@ -186,8 +173,6 @@ AUTH_USER_MODEL = "accounts.User"
|
||||||
|
|
||||||
LOGIN_REDIRECT_URL = "/"
|
LOGIN_REDIRECT_URL = "/"
|
||||||
|
|
||||||
# Internationalization
|
|
||||||
# https://docs.djangoproject.com/en/2.2/topics/i18n/
|
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
TIME_ZONE = "Europe/Amsterdam"
|
TIME_ZONE = "Europe/Amsterdam"
|
||||||
|
|
@ -195,30 +180,31 @@ USE_I18N = True
|
||||||
USE_L10N = True
|
USE_L10N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
|
||||||
# https://docs.djangoproject.com/en/2.2/howto/static-files/
|
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
STATIC_ROOT = BASE_DIR / "static"
|
||||||
STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static")]
|
STATICFILES_DIRS = (DJANGO_PROJECT_DIR / "static",)
|
||||||
|
|
||||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-STATICFILES_FINDERS
|
|
||||||
STATICFILES_FINDERS = [
|
STATICFILES_FINDERS = [
|
||||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||||
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||||
|
|
||||||
# Reddit integration
|
DEFAULT_FROM_EMAIL = get_env(
|
||||||
REDDIT_CLIENT_ID = "CLIENT_ID"
|
"EMAIL_DEFAULT_FROM", required=False, default="webmaster@localhost"
|
||||||
REDDIT_CLIENT_SECRET = "CLIENT_SECRET"
|
|
||||||
REDDIT_REDIRECT_URL = (
|
|
||||||
"http://127.0.0.1:8000/accounts/settings/integrations/reddit/callback/"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Twitter integration
|
EMAIL_HOST = get_env("EMAIL_HOST", required=False, default="localhost")
|
||||||
TWITTER_URL = "https://twitter.com"
|
EMAIL_PORT = get_env("EMAIL_PORT", cast=int, required=False, default=25)
|
||||||
|
|
||||||
|
EMAIL_HOST_USER = get_env("EMAIL_HOST_USER", required=False, default="")
|
||||||
|
EMAIL_HOST_PASSWORD = get_env("EMAIL_HOST_PASSWORD", required=False, default="")
|
||||||
|
|
||||||
|
EMAIL_USE_TLS = get_env("EMAIL_USE_TLS", required=False, default=False)
|
||||||
|
EMAIL_USE_SSL = get_env("EMAIL_USE_SSL", required=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
# Third party settings
|
# Third party settings
|
||||||
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
|
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
|
||||||
|
|
@ -227,7 +213,6 @@ AXES_FAILURE_LIMIT = 5
|
||||||
AXES_COOLOFF_TIME = 3 # in hours
|
AXES_COOLOFF_TIME = 3 # in hours
|
||||||
AXES_RESET_ON_SUCCESS = True
|
AXES_RESET_ON_SUCCESS = True
|
||||||
|
|
||||||
# TODO: verify parser works correctly
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
"rest_framework.authentication.SessionAuthentication",
|
"rest_framework.authentication.SessionAuthentication",
|
||||||
|
|
@ -256,13 +241,9 @@ SWAGGER_SETTINGS = {
|
||||||
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
||||||
CELERY_BROKER_URL = "amqp://guest@rabbitmq:5672"
|
CELERY_BROKER_URL = "amqp://guest@rabbitmq:5672"
|
||||||
|
|
||||||
REGISTRATION_OPEN = True
|
|
||||||
REGISTRATION_AUTO_LOGIN = True
|
|
||||||
ACCOUNT_ACTIVATION_DAYS = 7
|
|
||||||
|
|
||||||
# Sentry
|
# Sentry
|
||||||
SENTRY_CONFIG = {
|
SENTRY_CONFIG = {
|
||||||
"dsn": os.environ.get("SENTRY_DSN"),
|
"dsn": get_env("SENTRY_DSN", default="", required=False),
|
||||||
"send_default_pii": False,
|
"send_default_pii": False,
|
||||||
"integrations": [DjangoIntegration(), CeleryIntegration()]
|
"integrations": [DjangoIntegration(), CeleryIntegration()]
|
||||||
if DjangoIntegration and CeleryIntegration
|
if DjangoIntegration and CeleryIntegration
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from .base import * # noqa: F403
|
from .base import * # noqa: F403
|
||||||
from .version import get_current_version
|
from .utils import get_current_version
|
||||||
|
|
||||||
|
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|
@ -33,7 +33,7 @@ CACHES = {
|
||||||
|
|
||||||
# Project settings
|
# Project settings
|
||||||
VERSION = get_current_version()
|
VERSION = get_current_version()
|
||||||
ENVIRONMENT = "gitlab"
|
ENVIRONMENT = "ci"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Optionally use sentry integration
|
# Optionally use sentry integration
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from .base import * # noqa: F403
|
from .base import * # noqa: F403
|
||||||
from .version import get_current_version
|
from .utils import get_current_version
|
||||||
|
|
||||||
|
|
||||||
SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl"
|
SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl"
|
||||||
|
|
@ -10,6 +10,11 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||||
|
|
||||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
||||||
|
|
||||||
|
|
||||||
|
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
|
||||||
|
"django.template.context_processors.debug",
|
||||||
|
)
|
||||||
|
|
||||||
# Project settings
|
# Project settings
|
||||||
VERSION = get_current_version()
|
VERSION = get_current_version()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
from .base import * # noqa: F403
|
from .base import * # noqa: F403
|
||||||
from .version import get_current_version
|
from .utils import get_current_version
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_HOSTS = ["django", "127.0.0.1"]
|
DEBUG = True
|
||||||
|
|
||||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
||||||
|
|
||||||
|
|
@ -16,7 +16,10 @@ LOGGING["loggers"].update( # noqa: F405
|
||||||
|
|
||||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||||
|
|
||||||
DEBUG = True
|
|
||||||
|
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
|
||||||
|
"django.template.context_processors.debug",
|
||||||
|
)
|
||||||
|
|
||||||
# Project settings
|
# Project settings
|
||||||
VERSION = get_current_version()
|
VERSION = get_current_version()
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,24 @@
|
||||||
import os
|
from newsreader.conf.utils import get_env
|
||||||
|
|
||||||
from .version import get_current_version
|
|
||||||
|
|
||||||
|
|
||||||
from .base import * # noqa: F403
|
from .base import * # noqa: F403
|
||||||
|
from .utils import get_current_version
|
||||||
|
|
||||||
|
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
|
|
||||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
|
||||||
ALLOWED_HOSTS = ["127.0.0.1", "localhost", "rss.fudiggity.nl", "django"]
|
|
||||||
|
|
||||||
ADMINS = [
|
ADMINS = [
|
||||||
("", email)
|
("", email) for email in get_env("ADMINS", split=",", required=False, default=[])
|
||||||
for email in os.getenv("ADMINS", "").split(",")
|
|
||||||
if os.environ.get("ADMINS")
|
|
||||||
]
|
]
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
||||||
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")], # noqa: F405
|
|
||||||
"APP_DIRS": True,
|
|
||||||
"OPTIONS": {
|
|
||||||
"context_processors": [
|
|
||||||
"django.template.context_processors.request",
|
|
||||||
"django.contrib.auth.context_processors.auth",
|
|
||||||
"django.contrib.messages.context_processors.messages",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
# Email
|
|
||||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
|
||||||
DEFAULT_FROM_EMAIL = os.environ.get("EMAIL_DEFAULT_FROM", "webmaster@localhost")
|
|
||||||
|
|
||||||
EMAIL_HOST = os.environ.get("EMAIL_HOST", "localhost")
|
|
||||||
EMAIL_PORT = os.environ.get("EMAIL_PORT", 25)
|
|
||||||
|
|
||||||
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "")
|
|
||||||
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "")
|
|
||||||
|
|
||||||
EMAIL_USE_TLS = bool(os.environ.get("EMAIL_USE_TLS"))
|
|
||||||
EMAIL_USE_SSL = bool(os.environ.get("EMAIL_USE_SSL"))
|
|
||||||
|
|
||||||
# Project settings
|
# Project settings
|
||||||
VERSION = get_current_version(debug=False)
|
VERSION = get_current_version(debug=False)
|
||||||
ENVIRONMENT = "production"
|
ENVIRONMENT = "production"
|
||||||
|
|
||||||
# Reddit integration
|
|
||||||
REDDIT_CLIENT_ID = os.environ.get("REDDIT_CLIENT_ID", "")
|
|
||||||
REDDIT_CLIENT_SECRET = os.environ.get("REDDIT_CLIENT_SECRET", "")
|
|
||||||
REDDIT_REDIRECT_URL = os.environ.get("REDDIT_CALLBACK_URL", "")
|
|
||||||
|
|
||||||
# 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
|
||||||
|
|
|
||||||
85
src/newsreader/conf/utils.py
Normal file
85
src/newsreader/conf/utils.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable, Type
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_env(
|
||||||
|
name: str,
|
||||||
|
cast: Type = str,
|
||||||
|
required: bool = True,
|
||||||
|
default: Any = None,
|
||||||
|
split: str = "",
|
||||||
|
) -> Any:
|
||||||
|
if cast is not str and split:
|
||||||
|
raise TypeError(f"Split is not possible with {cast}")
|
||||||
|
|
||||||
|
value = os.getenv(name)
|
||||||
|
|
||||||
|
if not value:
|
||||||
|
if required:
|
||||||
|
logger.warning(f"Missing environment variable: {name}")
|
||||||
|
|
||||||
|
return default
|
||||||
|
|
||||||
|
bool_mapping = {"yes": True, "true": True, "false": False, "no": False}
|
||||||
|
|
||||||
|
if cast is bool:
|
||||||
|
_value = bool_mapping.get(value.lower())
|
||||||
|
|
||||||
|
if not value:
|
||||||
|
raise ValueError(f"Unknown boolean value: {_value}")
|
||||||
|
|
||||||
|
return _value
|
||||||
|
|
||||||
|
value = value if not cast else cast(value)
|
||||||
|
return value if not split else value.split(split)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_version(debug: bool = True) -> str:
|
||||||
|
version = get_env("VERSION", required=False)
|
||||||
|
|
||||||
|
if version:
|
||||||
|
return version
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(
|
||||||
|
["git", "show", "--no-patch", "--format=%H"], universal_newlines=True
|
||||||
|
)
|
||||||
|
return output.strip()
|
||||||
|
except (subprocess.CalledProcessError, OSError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(
|
||||||
|
["git", "describe", "--tags"], universal_newlines=True
|
||||||
|
)
|
||||||
|
return output.strip()
|
||||||
|
except (subprocess.CalledProcessError, OSError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_MARKERS = ("pyproject.toml", "package.json", "README.md", "CHANGELOG.md")
|
||||||
|
|
||||||
|
|
||||||
|
def get_root_dir() -> Path:
|
||||||
|
file = Path(__file__)
|
||||||
|
return _traverse_dirs(file.parent, ROOT_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _traverse_dirs(path: Path, root_markers: Iterable[str]) -> Path:
|
||||||
|
if path.parent == path:
|
||||||
|
raise OSError("Root directory detected")
|
||||||
|
|
||||||
|
files = (file.name for file in path.iterdir())
|
||||||
|
|
||||||
|
if not any((marker for marker in root_markers if marker in files)):
|
||||||
|
return _traverse_dirs(path.parent, root_markers)
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_version(debug=True):
|
|
||||||
version = os.environ.get("VERSION")
|
|
||||||
|
|
||||||
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 ""
|
|
||||||
|
|
@ -3,15 +3,13 @@ import React from 'react';
|
||||||
class Messages extends React.Component {
|
class Messages extends React.Component {
|
||||||
state = { messages: this.props.messages };
|
state = { messages: this.props.messages };
|
||||||
|
|
||||||
close = ::this.close;
|
close = index => {
|
||||||
|
|
||||||
close(index) {
|
|
||||||
const newMessages = this.state.messages.filter((message, currentIndex) => {
|
const newMessages = this.state.messages.filter((message, currentIndex) => {
|
||||||
return currentIndex != index;
|
return currentIndex != index;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setState({ messages: newMessages });
|
this.setState({ messages: newMessages });
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const messages = this.state.messages.map((message, index) => {
|
const messages = this.state.messages.map((message, index) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
class Selector {
|
class Selector {
|
||||||
onClick = ::this.onClick;
|
|
||||||
|
|
||||||
inputs = [];
|
inputs = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -11,13 +9,13 @@ class Selector {
|
||||||
selectAllInput.onchange = this.onClick;
|
selectAllInput.onchange = this.onClick;
|
||||||
}
|
}
|
||||||
|
|
||||||
onClick(e) {
|
onClick = e => {
|
||||||
const targetValue = e.target.checked;
|
const targetValue = e.target.checked;
|
||||||
|
|
||||||
this.inputs.forEach(input => {
|
this.inputs.forEach(input => {
|
||||||
input.checked = targetValue;
|
input.checked = targetValue;
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Selector;
|
export default Selector;
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,6 @@ import Messages from '../../components/Messages.js';
|
||||||
import Sidebar from '../../components/Sidebar.js';
|
import Sidebar from '../../components/Sidebar.js';
|
||||||
|
|
||||||
class App extends React.Component {
|
class App extends React.Component {
|
||||||
selectCategory = ::this.selectCategory;
|
|
||||||
deselectCategory = ::this.deselectCategory;
|
|
||||||
deleteCategory = ::this.deleteCategory;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
|
@ -24,15 +20,15 @@ class App extends React.Component {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
selectCategory(categoryId) {
|
selectCategory = categoryId => {
|
||||||
this.setState({ selectedCategoryId: categoryId });
|
this.setState({ selectedCategoryId: categoryId });
|
||||||
}
|
};
|
||||||
|
|
||||||
deselectCategory() {
|
deselectCategory = () => {
|
||||||
this.setState({ selectedCategoryId: null });
|
this.setState({ selectedCategoryId: null });
|
||||||
}
|
};
|
||||||
|
|
||||||
deleteCategory(categoryId) {
|
deleteCategory = categoryId => {
|
||||||
const url = `/api/categories/${categoryId}/`;
|
const url = `/api/categories/${categoryId}/`;
|
||||||
const options = {
|
const options = {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|
@ -60,7 +56,7 @@ class App extends React.Component {
|
||||||
text: 'Unable to remove category, try again later',
|
text: 'Unable to remove category, try again later',
|
||||||
};
|
};
|
||||||
return this.setState({ selectedCategoryId: null, message: message });
|
return this.setState({ selectedCategoryId: null, message: message });
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { categories } = this.state;
|
const { categories } = this.state;
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ class App extends React.Component {
|
||||||
<HomepageSidebar navLinks={this.props.navLinks} />
|
<HomepageSidebar navLinks={this.props.navLinks} />
|
||||||
<PostList
|
<PostList
|
||||||
feedUrl={this.props.feedUrl}
|
feedUrl={this.props.feedUrl}
|
||||||
subredditUrl={this.props.subredditUrl}
|
|
||||||
timezone={this.props.timezone}
|
timezone={this.props.timezone}
|
||||||
forwardedRef={this.postListRef}
|
forwardedRef={this.postListRef}
|
||||||
postsByType={this.props.postsByType}
|
postsByType={this.props.postsByType}
|
||||||
|
|
@ -46,7 +45,6 @@ class App extends React.Component {
|
||||||
category={this.props.category}
|
category={this.props.category}
|
||||||
selectedType={this.props.selectedType}
|
selectedType={this.props.selectedType}
|
||||||
feedUrl={this.props.feedUrl}
|
feedUrl={this.props.feedUrl}
|
||||||
subredditUrl={this.props.subredditUrl}
|
|
||||||
categoriesUrl={this.props.categoriesUrl}
|
categoriesUrl={this.props.categoriesUrl}
|
||||||
timezone={this.props.timezone}
|
timezone={this.props.timezone}
|
||||||
autoMarking={this.props.autoMarking}
|
autoMarking={this.props.autoMarking}
|
||||||
|
|
|
||||||
|
|
@ -124,10 +124,10 @@ export const fetchPostsBySection = (section, next = false) => {
|
||||||
|
|
||||||
switch (section.type) {
|
switch (section.type) {
|
||||||
case RULE_TYPE:
|
case RULE_TYPE:
|
||||||
url = next ? next : `/api/rules/${section.id}/posts/?read=false`;
|
url = next ? next : `/api/rules/${section.id}/posts/`;
|
||||||
break;
|
break;
|
||||||
case CATEGORY_TYPE:
|
case CATEGORY_TYPE:
|
||||||
url = next ? next : `/api/categories/${section.id}/posts/?read=false`;
|
url = next ? next : `/api/categories/${section.id}/posts/`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,10 @@ import { connect } from 'react-redux';
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
|
|
||||||
import { unSelectPost, markPostRead, toggleSaved } from '../actions/posts.js';
|
import { unSelectPost, markPostRead, toggleSaved } from '../actions/posts.js';
|
||||||
import { SAVED_TYPE, SUBREDDIT } from '../constants.js';
|
import { SAVED_TYPE } from '../constants.js';
|
||||||
import { formatDatetime } from '../../../utils.js';
|
import { formatDatetime } from '../../../utils.js';
|
||||||
|
|
||||||
class PostModal extends React.Component {
|
class PostModal extends React.Component {
|
||||||
modalListener = ::this.modalListener;
|
|
||||||
readTimer = null;
|
readTimer = null;
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
|
@ -32,13 +31,13 @@ class PostModal extends React.Component {
|
||||||
window.removeEventListener('click', this.modalListener);
|
window.removeEventListener('click', this.modalListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
modalListener(e) {
|
modalListener = e => {
|
||||||
const targetClassName = e.target.className;
|
const targetClassName = e.target.className;
|
||||||
|
|
||||||
if (this.props.post && targetClassName == 'modal post-modal') {
|
if (this.props.post && targetClassName == 'modal post-modal') {
|
||||||
this.props.unSelectPost();
|
this.props.unSelectPost();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const post = this.props.post;
|
const post = this.props.post;
|
||||||
|
|
@ -54,9 +53,6 @@ class PostModal extends React.Component {
|
||||||
let ruleUrl = '';
|
let ruleUrl = '';
|
||||||
|
|
||||||
switch (this.props.rule.type) {
|
switch (this.props.rule.type) {
|
||||||
case SUBREDDIT:
|
|
||||||
ruleUrl = `${this.props.subredditUrl}/${this.props.rule.id}/`;
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
ruleUrl = `${this.props.feedUrl}/${this.props.rule.id}/`;
|
ruleUrl = `${this.props.feedUrl}/${this.props.rule.id}/`;
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export default class ScrollTop extends React.Component {
|
export default class ScrollTop extends React.Component {
|
||||||
scrollListener = ::this.scrollListener;
|
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
listenerAttached: false,
|
listenerAttached: false,
|
||||||
showTop: false,
|
showTop: false,
|
||||||
|
|
@ -17,7 +15,7 @@ export default class ScrollTop extends React.Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollListener() {
|
scrollListener = () => {
|
||||||
const postList = this.props.postListNode;
|
const postList = this.props.postListNode;
|
||||||
const elementEnd =
|
const elementEnd =
|
||||||
postList.scrollTop + postList.offsetHeight >= postList.scrollHeight;
|
postList.scrollTop + postList.offsetHeight >= postList.scrollHeight;
|
||||||
|
|
@ -26,7 +24,7 @@ export default class ScrollTop extends React.Component {
|
||||||
showTop: postList.scrollTop > window.innerHeight,
|
showTop: postList.scrollTop > window.innerHeight,
|
||||||
showBottom: !elementEnd,
|
showBottom: !elementEnd,
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const postList = this.props.postListNode;
|
const postList = this.props.postListNode;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
|
|
||||||
import { CATEGORY_TYPE, SAVED_TYPE, SUBREDDIT } from '../../constants.js';
|
import { CATEGORY_TYPE, SAVED_TYPE } from '../../constants.js';
|
||||||
import { selectPost, toggleSaved } from '../../actions/posts.js';
|
import { selectPost, toggleSaved } from '../../actions/posts.js';
|
||||||
import { formatDatetime } from '../../../../utils.js';
|
import { formatDatetime } from '../../../../utils.js';
|
||||||
|
|
||||||
|
|
@ -18,12 +18,7 @@ class PostItem extends React.Component {
|
||||||
: 'posts__header';
|
: 'posts__header';
|
||||||
const savedIconClass = post.saved ? 'saved-icon saved-icon--saved' : 'saved-icon';
|
const savedIconClass = post.saved ? 'saved-icon saved-icon--saved' : 'saved-icon';
|
||||||
|
|
||||||
let ruleUrl = '';
|
const ruleUrl = `${this.props.feedUrl}/${rule.id}/`;
|
||||||
if (rule.type === SUBREDDIT) {
|
|
||||||
ruleUrl = `${this.props.subredditUrl}/${rule.id}/`;
|
|
||||||
} else {
|
|
||||||
ruleUrl = `${this.props.feedUrl}/${rule.id}/`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="posts__item" ref={this.props.forwardedRef}>
|
<li className="posts__item" ref={this.props.forwardedRef}>
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,11 @@ import { isEqual } from 'lodash';
|
||||||
|
|
||||||
import { fetchPostsBySection, fetchSavedPosts } from '../../actions/posts.js';
|
import { fetchPostsBySection, fetchSavedPosts } from '../../actions/posts.js';
|
||||||
import { SAVED_TYPE } from '../../constants.js';
|
import { SAVED_TYPE } from '../../constants.js';
|
||||||
import { filterPosts } from './filters.js';
|
|
||||||
|
|
||||||
import LoadingIndicator from '../../../../components/LoadingIndicator.js';
|
import LoadingIndicator from '../../../../components/LoadingIndicator.js';
|
||||||
import PostItem from './PostItem.js';
|
import PostItem from './PostItem.js';
|
||||||
|
|
||||||
class PostList extends React.Component {
|
class PostList extends React.Component {
|
||||||
handleIntersect = ::this.handleIntersect;
|
|
||||||
lastPostRef = null;
|
lastPostRef = null;
|
||||||
observer = null;
|
observer = null;
|
||||||
|
|
||||||
|
|
@ -33,7 +31,7 @@ class PostList extends React.Component {
|
||||||
this.observer.disconnect();
|
this.observer.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleIntersect(entries) {
|
handleIntersect = entries => {
|
||||||
entries.every(entry => {
|
entries.every(entry => {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
this.observer.unobserve(entry.target);
|
this.observer.unobserve(entry.target);
|
||||||
|
|
@ -45,7 +43,7 @@ class PostList extends React.Component {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
paginate() {
|
paginate() {
|
||||||
if (this.props.selected.type === SAVED_TYPE) {
|
if (this.props.selected.type === SAVED_TYPE) {
|
||||||
|
|
@ -64,7 +62,6 @@ class PostList extends React.Component {
|
||||||
post: item,
|
post: item,
|
||||||
selected: this.props.selected,
|
selected: this.props.selected,
|
||||||
feedUrl: this.props.feedUrl,
|
feedUrl: this.props.feedUrl,
|
||||||
subredditUrl: this.props.subredditUrl,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLastItem?.id === item.id) {
|
if (isLastItem?.id === item.id) {
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,13 @@ import Cookies from 'js-cookie';
|
||||||
import { markRead } from '../../actions/selected.js';
|
import { markRead } from '../../actions/selected.js';
|
||||||
|
|
||||||
class ReadButton extends React.Component {
|
class ReadButton extends React.Component {
|
||||||
markSelectedRead = ::this.markSelectedRead;
|
markSelectedRead = () => {
|
||||||
|
|
||||||
markSelectedRead() {
|
|
||||||
const token = Cookies.get('csrftoken');
|
const token = Cookies.get('csrftoken');
|
||||||
|
|
||||||
if (this.props.selected.unread > 0) {
|
if (this.props.selected.unread > 0) {
|
||||||
this.props.markRead({ ...this.props.selected }, token);
|
this.props.markRead({ ...this.props.selected }, token);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,4 @@ export const RULE_TYPE = 'RULE';
|
||||||
export const CATEGORY_TYPE = 'CATEGORY';
|
export const CATEGORY_TYPE = 'CATEGORY';
|
||||||
export const SAVED_TYPE = 'SAVED';
|
export const SAVED_TYPE = 'SAVED';
|
||||||
|
|
||||||
export const SUBREDDIT = 'subreddit';
|
|
||||||
export const FEED = 'feed';
|
export const FEED = 'feed';
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ if (page) {
|
||||||
const store = configureStore();
|
const store = configureStore();
|
||||||
|
|
||||||
const settings = JSON.parse(document.getElementById('homepageSettings').textContent);
|
const settings = JSON.parse(document.getElementById('homepageSettings').textContent);
|
||||||
const { feedUrl, subredditUrl, categoriesUrl } = settings;
|
const { feedUrl, categoriesUrl } = settings;
|
||||||
|
|
||||||
const navLinks = JSON.parse(document.getElementById('Links').textContent);
|
const navLinks = JSON.parse(document.getElementById('Links').textContent);
|
||||||
|
|
||||||
|
|
@ -20,7 +20,6 @@ if (page) {
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<App
|
<App
|
||||||
feedUrl={feedUrl.substring(1, feedUrl.length - 3)}
|
feedUrl={feedUrl.substring(1, feedUrl.length - 3)}
|
||||||
subredditUrl={subredditUrl.substring(1, subredditUrl.length - 3)}
|
|
||||||
categoriesUrl={categoriesUrl.substring(1, categoriesUrl.length - 3)}
|
categoriesUrl={categoriesUrl.substring(1, categoriesUrl.length - 3)}
|
||||||
timezone={settings.timezone}
|
timezone={settings.timezone}
|
||||||
autoMarking={settings.autoMarking}
|
autoMarking={settings.autoMarking}
|
||||||
|
|
|
||||||
|
|
@ -304,10 +304,10 @@ describe('post actions', () => {
|
||||||
type: constants.RULE_TYPE,
|
type: constants.RULE_TYPE,
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchMock.getOnce('/api/rules/4/posts/?read=false', {
|
fetchMock.getOnce('/api/rules/4/posts/', {
|
||||||
body: {
|
body: {
|
||||||
count: 2,
|
count: 2,
|
||||||
next: 'https://durp.com/api/rules/4/posts/?cursor=jabadabar&read=false',
|
next: 'https://durp.com/api/rules/4/posts/?cursor=jabadabar',
|
||||||
previous: null,
|
previous: null,
|
||||||
results: posts,
|
results: posts,
|
||||||
},
|
},
|
||||||
|
|
@ -325,7 +325,7 @@ describe('post actions', () => {
|
||||||
{ type: actions.REQUEST_POSTS },
|
{ type: actions.REQUEST_POSTS },
|
||||||
{
|
{
|
||||||
type: actions.RECEIVE_POSTS,
|
type: actions.RECEIVE_POSTS,
|
||||||
next: 'https://durp.com/api/rules/4/posts/?cursor=jabadabar&read=false',
|
next: 'https://durp.com/api/rules/4/posts/?cursor=jabadabar',
|
||||||
posts,
|
posts,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -373,10 +373,10 @@ describe('post actions', () => {
|
||||||
type: constants.CATEGORY_TYPE,
|
type: constants.CATEGORY_TYPE,
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchMock.getOnce('/api/categories/1/posts/?read=false', {
|
fetchMock.getOnce('/api/categories/1/posts/', {
|
||||||
body: {
|
body: {
|
||||||
count: 2,
|
count: 2,
|
||||||
next: 'https://durp.com/api/categories/4/posts/?cursor=jabadabar&read=false',
|
next: 'https://durp.com/api/categories/4/posts/?cursor=jabadabar',
|
||||||
previous: null,
|
previous: null,
|
||||||
results: posts,
|
results: posts,
|
||||||
},
|
},
|
||||||
|
|
@ -394,7 +394,7 @@ describe('post actions', () => {
|
||||||
{ type: actions.REQUEST_POSTS },
|
{ type: actions.REQUEST_POSTS },
|
||||||
{
|
{
|
||||||
type: actions.RECEIVE_POSTS,
|
type: actions.RECEIVE_POSTS,
|
||||||
next: 'https://durp.com/api/categories/4/posts/?cursor=jabadabar&read=false',
|
next: 'https://durp.com/api/categories/4/posts/?cursor=jabadabar',
|
||||||
posts,
|
posts,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -600,7 +600,7 @@ describe('post actions', () => {
|
||||||
|
|
||||||
const errorMessage = 'Page not found';
|
const errorMessage = 'Page not found';
|
||||||
|
|
||||||
fetchMock.getOnce(`/api/rules/${rule.id}/posts/?read=false`, () => {
|
fetchMock.getOnce(`/api/rules/${rule.id}/posts/`, () => {
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ describe('post actions', () => {
|
||||||
|
|
||||||
const action = {
|
const action = {
|
||||||
type: actions.RECEIVE_POSTS,
|
type: actions.RECEIVE_POSTS,
|
||||||
next: 'https://durp.com/api/rules/4/posts/?page=2&read=false',
|
next: 'https://durp.com/api/rules/4/posts/?page=2',
|
||||||
posts,
|
posts,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -254,13 +254,13 @@ describe('selected reducer', () => {
|
||||||
|
|
||||||
const action = {
|
const action = {
|
||||||
type: postActions.RECEIVE_POSTS,
|
type: postActions.RECEIVE_POSTS,
|
||||||
next: 'https://durp.com/api/rules/4/posts/?page=2&read=false',
|
next: 'https://durp.com/api/rules/4/posts/?page=2',
|
||||||
posts,
|
posts,
|
||||||
};
|
};
|
||||||
|
|
||||||
const expectedState = {
|
const expectedState = {
|
||||||
...defaultState,
|
...defaultState,
|
||||||
next: 'https://durp.com/api/rules/4/posts/?page=2&read=false',
|
next: 'https://durp.com/api/rules/4/posts/?page=2',
|
||||||
lastReached: false,
|
lastReached: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,3 @@ from django.utils.translation import gettext as _
|
||||||
|
|
||||||
class RuleTypeChoices(TextChoices):
|
class RuleTypeChoices(TextChoices):
|
||||||
feed = "feed", _("Feed")
|
feed = "feed", _("Feed")
|
||||||
subreddit = "subreddit", _("Subreddit")
|
|
||||||
twitter_timeline = "twitter_timeline", _("Twitter timeline")
|
|
||||||
|
|
||||||
|
|
||||||
class TwitterPostTypeChoices(TextChoices):
|
|
||||||
photo = "photo", _("Photo")
|
|
||||||
video = "video", _("Video")
|
|
||||||
animated_gif = "animated_gif", _("GIF")
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from django.db.models import Prefetch
|
||||||
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.generics import (
|
from rest_framework.generics import (
|
||||||
GenericAPIView,
|
GenericAPIView,
|
||||||
|
|
@ -10,7 +12,6 @@ from rest_framework.response import Response
|
||||||
from newsreader.core.pagination import CursorPagination
|
from newsreader.core.pagination import CursorPagination
|
||||||
from newsreader.news.collection.models import CollectionRule
|
from newsreader.news.collection.models import CollectionRule
|
||||||
from newsreader.news.collection.serializers import RuleSerializer
|
from newsreader.news.collection.serializers import RuleSerializer
|
||||||
from newsreader.news.core.filters import ReadFilter
|
|
||||||
from newsreader.news.core.models import Post
|
from newsreader.news.core.models import Post
|
||||||
from newsreader.news.core.serializers import PostSerializer
|
from newsreader.news.core.serializers import PostSerializer
|
||||||
|
|
||||||
|
|
@ -24,7 +25,6 @@ class NestedRuleView(ListAPIView):
|
||||||
queryset = CollectionRule.objects.prefetch_related("posts").all()
|
queryset = CollectionRule.objects.prefetch_related("posts").all()
|
||||||
serializer_class = PostSerializer
|
serializer_class = PostSerializer
|
||||||
pagination_class = CursorPagination
|
pagination_class = CursorPagination
|
||||||
filter_backends = [ReadFilter]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
|
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
|
||||||
|
|
@ -33,7 +33,9 @@ class NestedRuleView(ListAPIView):
|
||||||
# filtered on the user.
|
# filtered on the user.
|
||||||
filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
|
filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
|
||||||
|
|
||||||
rule = get_object_or_404(self.queryset, **filter_kwargs)
|
prefetch = Prefetch("posts", queryset=Post.objects.filter(read=False))
|
||||||
|
queryset = CollectionRule.objects.prefetch_related(prefetch)
|
||||||
|
rule = get_object_or_404(queryset, **filter_kwargs)
|
||||||
self.check_object_permissions(self.request, rule)
|
self.check_object_permissions(self.request, rule)
|
||||||
|
|
||||||
return rule.posts.order_by("-publication_date")
|
return rule.posts.order_by("-publication_date")
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
from newsreader.news.collection.forms.feed import FeedForm, OPMLImportForm
|
from newsreader.news.collection.forms.feed import FeedForm, OPMLImportForm
|
||||||
from newsreader.news.collection.forms.reddit import SubRedditForm
|
|
||||||
from newsreader.news.collection.forms.rules import CollectionRuleBulkForm
|
from newsreader.news.collection.forms.rules import CollectionRuleBulkForm
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"FeedForm",
|
"FeedForm",
|
||||||
"OPMLImportForm",
|
"OPMLImportForm",
|
||||||
"SubRedditForm",
|
|
||||||
"CollectionRuleBulkForm",
|
"CollectionRuleBulkForm",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
from django import forms
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from newsreader.news.collection.choices import RuleTypeChoices
|
|
||||||
from newsreader.news.collection.forms.base import CollectionRuleForm
|
|
||||||
from newsreader.news.collection.models import CollectionRule
|
|
||||||
from newsreader.news.collection.reddit import REDDIT_API_URL
|
|
||||||
|
|
||||||
|
|
||||||
def get_reddit_help_text():
|
|
||||||
return mark_safe(
|
|
||||||
"Only subreddits are supported"
|
|
||||||
" see the 'listings' section in <a className='link' target='_blank' rel='noopener noreferrer'"
|
|
||||||
" href='https://www.reddit.com/dev/api#section_listings'>the reddit API docs</a>."
|
|
||||||
" For example: <a className='link' target='_blank' rel='noopener noreferrer'"
|
|
||||||
" href='https://oauth.reddit.com/r/aww'>https://oauth.reddit.com/r/aww</a>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SubRedditForm(CollectionRuleForm):
|
|
||||||
url = forms.URLField(max_length=1024, help_text=get_reddit_help_text)
|
|
||||||
|
|
||||||
def clean_url(self):
|
|
||||||
url = self.cleaned_data["url"]
|
|
||||||
|
|
||||||
if not url.startswith(REDDIT_API_URL):
|
|
||||||
raise ValidationError(_("This does not look like an Reddit API URL"))
|
|
||||||
|
|
||||||
return url
|
|
||||||
|
|
||||||
def save(self, commit=True):
|
|
||||||
instance = super().save(commit=False)
|
|
||||||
|
|
||||||
instance.type = RuleTypeChoices.subreddit
|
|
||||||
|
|
||||||
if commit:
|
|
||||||
instance.save()
|
|
||||||
self.save_m2m()
|
|
||||||
|
|
||||||
return instance
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = CollectionRule
|
|
||||||
fields = (
|
|
||||||
"name",
|
|
||||||
"url",
|
|
||||||
"favicon",
|
|
||||||
"category",
|
|
||||||
"reddit_allow_nfsw",
|
|
||||||
"reddit_allow_spoiler",
|
|
||||||
"reddit_allow_viewed",
|
|
||||||
"reddit_upvotes_min",
|
|
||||||
"reddit_downvotes_max",
|
|
||||||
"reddit_comments_min",
|
|
||||||
)
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Generated by Django 4.2.16 on 2025-03-26 08:46
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("collection", "0017_remove_collectionrule_timezone"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="collectionrule",
|
||||||
|
name="reddit_allow_nfsw",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="collectionrule",
|
||||||
|
name="reddit_allow_spoiler",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="collectionrule",
|
||||||
|
name="reddit_allow_viewed",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="collectionrule",
|
||||||
|
name="reddit_comments_min",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="collectionrule",
|
||||||
|
name="reddit_downvotes_max",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="collectionrule",
|
||||||
|
name="reddit_upvotes_min",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="collectionrule",
|
||||||
|
name="screen_name",
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="collectionrule",
|
||||||
|
name="type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("feed", "Feed")], default="feed", max_length=20
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.conf import settings
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
|
@ -49,25 +48,6 @@ class CollectionRule(TimeStampedModel):
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reddit
|
|
||||||
reddit_allow_nfsw = models.BooleanField(_("Allow NSFW posts"), default=False)
|
|
||||||
reddit_allow_spoiler = models.BooleanField(_("Allow spoilers"), default=False)
|
|
||||||
reddit_allow_viewed = models.BooleanField(
|
|
||||||
_("Allow already seen posts"), default=True
|
|
||||||
)
|
|
||||||
reddit_upvotes_min = models.PositiveIntegerField(
|
|
||||||
_("Minimum amount of upvotes"), default=0
|
|
||||||
)
|
|
||||||
reddit_downvotes_max = models.PositiveIntegerField(
|
|
||||||
_("Maximum amount of downvotes"), blank=True, null=True
|
|
||||||
)
|
|
||||||
reddit_comments_min = models.PositiveIntegerField(
|
|
||||||
_("Minimum amount of comments"), default=0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Twitter (legacy)
|
|
||||||
screen_name = models.CharField(max_length=255, blank=True, null=True)
|
|
||||||
|
|
||||||
objects = CollectionRuleQuerySet.as_manager()
|
objects = CollectionRuleQuerySet.as_manager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|
@ -75,22 +55,10 @@ class CollectionRule(TimeStampedModel):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def update_url(self):
|
def update_url(self):
|
||||||
if self.type == RuleTypeChoices.subreddit:
|
|
||||||
return reverse("news:collection:subreddit-update", kwargs={"pk": self.pk})
|
|
||||||
elif self.type == RuleTypeChoices.twitter_timeline:
|
|
||||||
return "#not-supported"
|
|
||||||
|
|
||||||
return reverse("news:collection:feed-update", kwargs={"pk": self.pk})
|
return reverse("news:collection:feed-update", kwargs={"pk": self.pk})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def source_url(self):
|
def source_url(self):
|
||||||
if self.type == RuleTypeChoices.subreddit:
|
|
||||||
from newsreader.news.collection.reddit import REDDIT_API_URL, REDDIT_URL
|
|
||||||
|
|
||||||
return self.url.replace(REDDIT_API_URL, REDDIT_URL)
|
|
||||||
elif self.type == RuleTypeChoices.twitter_timeline:
|
|
||||||
return f"{settings.TWITTER_URL}/{self.screen_name}"
|
|
||||||
|
|
||||||
return self.url
|
return self.url
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
||||||
|
|
@ -1,419 +0,0 @@
|
||||||
import logging
|
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from html import unescape
|
|
||||||
from json.decoder import JSONDecodeError
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.cache import cache
|
|
||||||
from django.utils.html import format_html
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from newsreader.news.collection.base import (
|
|
||||||
PostBuilder,
|
|
||||||
PostClient,
|
|
||||||
PostCollector,
|
|
||||||
PostStream,
|
|
||||||
Scheduler,
|
|
||||||
)
|
|
||||||
from newsreader.news.collection.choices import RuleTypeChoices
|
|
||||||
from newsreader.news.collection.exceptions import (
|
|
||||||
BuilderDuplicateException,
|
|
||||||
BuilderException,
|
|
||||||
BuilderMissingDataException,
|
|
||||||
BuilderParseException,
|
|
||||||
BuilderSkippedException,
|
|
||||||
StreamDeniedException,
|
|
||||||
StreamException,
|
|
||||||
StreamParseException,
|
|
||||||
StreamTooManyException,
|
|
||||||
)
|
|
||||||
from newsreader.news.collection.models import CollectionRule
|
|
||||||
from newsreader.news.collection.tasks import RedditTokenTask
|
|
||||||
from newsreader.news.collection.utils import fetch, post, truncate_text
|
|
||||||
from newsreader.news.core.models import Post
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
REDDIT_URL = "https://www.reddit.com"
|
|
||||||
REDDIT_API_URL = "https://oauth.reddit.com"
|
|
||||||
|
|
||||||
RATE_LIMIT = 60
|
|
||||||
RATE_LIMIT_DURATION = timedelta(seconds=60)
|
|
||||||
|
|
||||||
REDDIT_IMAGE_EXTENSIONS = (".jpg", ".png", ".gif")
|
|
||||||
REDDIT_VIDEO_EXTENSIONS = (".mp4", ".gifv", ".webm")
|
|
||||||
|
|
||||||
# see type prefixes on https://www.reddit.com/dev/api/
|
|
||||||
REDDIT_POST = "t3"
|
|
||||||
|
|
||||||
|
|
||||||
def get_reddit_authorization_url(user):
|
|
||||||
state = str(uuid4())
|
|
||||||
cache.set(f"{user.email}-reddit-auth", state)
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"client_id": settings.REDDIT_CLIENT_ID,
|
|
||||||
"redirect_uri": settings.REDDIT_REDIRECT_URL,
|
|
||||||
"state": state,
|
|
||||||
"response_type": "code",
|
|
||||||
"duration": "permanent",
|
|
||||||
"scope": "identity,mysubreddits,save,read",
|
|
||||||
}
|
|
||||||
|
|
||||||
authorization_url = f"{REDDIT_URL}/api/v1/authorize"
|
|
||||||
return f"{authorization_url}?{urlencode(params)}"
|
|
||||||
|
|
||||||
|
|
||||||
def get_reddit_access_token(code, user):
|
|
||||||
client_auth = requests.auth.HTTPBasicAuth(
|
|
||||||
settings.REDDIT_CLIENT_ID, settings.REDDIT_CLIENT_SECRET
|
|
||||||
)
|
|
||||||
|
|
||||||
response = post(
|
|
||||||
f"{REDDIT_URL}/api/v1/access_token",
|
|
||||||
data={
|
|
||||||
"redirect_uri": settings.REDDIT_REDIRECT_URL,
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"code": code,
|
|
||||||
},
|
|
||||||
auth=client_auth,
|
|
||||||
)
|
|
||||||
|
|
||||||
response_data = response.json()
|
|
||||||
|
|
||||||
user.reddit_access_token = response_data["access_token"]
|
|
||||||
user.reddit_refresh_token = response_data["refresh_token"]
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
cache.delete(f"{user.email}-reddit-auth")
|
|
||||||
|
|
||||||
return response_data["access_token"], response_data["refresh_token"]
|
|
||||||
|
|
||||||
|
|
||||||
# Note that the API always returns 204's with correct basic auth headers
|
|
||||||
def revoke_reddit_token(user):
|
|
||||||
client_auth = requests.auth.HTTPBasicAuth(
|
|
||||||
settings.REDDIT_CLIENT_ID, settings.REDDIT_CLIENT_SECRET
|
|
||||||
)
|
|
||||||
|
|
||||||
response = post(
|
|
||||||
f"{REDDIT_URL}/api/v1/revoke_token",
|
|
||||||
data={"token": user.reddit_refresh_token, "token_type_hint": "refresh_token"},
|
|
||||||
auth=client_auth,
|
|
||||||
)
|
|
||||||
|
|
||||||
return response.status_code == 204
|
|
||||||
|
|
||||||
|
|
||||||
class RedditBuilder(PostBuilder):
|
|
||||||
rule_type = RuleTypeChoices.subreddit
|
|
||||||
|
|
||||||
def build(self):
|
|
||||||
results = {}
|
|
||||||
|
|
||||||
if "data" not in self.payload or "children" not in self.payload["data"]:
|
|
||||||
return
|
|
||||||
|
|
||||||
entries = self.payload["data"]["children"]
|
|
||||||
|
|
||||||
for entry in entries:
|
|
||||||
try:
|
|
||||||
post = self.build_post(entry)
|
|
||||||
except BuilderDuplicateException:
|
|
||||||
logger.warning("Skipping duplicate post")
|
|
||||||
continue
|
|
||||||
except BuilderSkippedException as e:
|
|
||||||
logger.warning(e.message)
|
|
||||||
continue
|
|
||||||
except BuilderException:
|
|
||||||
logger.exception("Failed building post")
|
|
||||||
continue
|
|
||||||
|
|
||||||
identifier = post.remote_identifier
|
|
||||||
results[identifier] = post
|
|
||||||
|
|
||||||
self.instances = results.values()
|
|
||||||
|
|
||||||
def build_post(self, entry):
|
|
||||||
rule = self.stream.rule
|
|
||||||
entry_data = entry.get("data", {})
|
|
||||||
remote_identifier = entry_data.get("id", "")
|
|
||||||
kind = entry.get("kind")
|
|
||||||
|
|
||||||
if remote_identifier in self.existing_posts:
|
|
||||||
raise BuilderDuplicateException(payload=entry)
|
|
||||||
elif kind != REDDIT_POST:
|
|
||||||
raise BuilderParseException(
|
|
||||||
message=f"Payload is not an reddit post, its of kind {kind}",
|
|
||||||
payload=entry,
|
|
||||||
)
|
|
||||||
elif not entry_data:
|
|
||||||
raise BuilderMissingDataException(
|
|
||||||
message=f"Post {remote_identifier} did not contain any data",
|
|
||||||
payload=entry,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
title = entry_data["title"]
|
|
||||||
author = entry_data["author"]
|
|
||||||
|
|
||||||
post_url_fragment = entry_data["permalink"]
|
|
||||||
direct_url = entry_data["url"]
|
|
||||||
|
|
||||||
is_text = entry_data["is_self"]
|
|
||||||
is_video = entry_data["is_video"]
|
|
||||||
|
|
||||||
is_nsfw = entry_data["over_18"]
|
|
||||||
is_spoiler = entry_data["spoiler"]
|
|
||||||
is_viewed = entry_data["clicked"]
|
|
||||||
upvotes = entry_data["ups"]
|
|
||||||
downvotes = entry_data["downs"]
|
|
||||||
comments = entry_data["num_comments"]
|
|
||||||
except KeyError as e:
|
|
||||||
raise BuilderMissingDataException(payload=entry) from e
|
|
||||||
|
|
||||||
if not rule.reddit_allow_nfsw and is_nsfw:
|
|
||||||
raise BuilderSkippedException("Rule does not allow NSFW posts")
|
|
||||||
elif not rule.reddit_allow_spoiler and is_spoiler:
|
|
||||||
raise BuilderSkippedException("Rule does not allow spoilers")
|
|
||||||
elif not rule.reddit_allow_viewed and is_viewed:
|
|
||||||
raise BuilderSkippedException("Post was already seen by user")
|
|
||||||
elif not upvotes >= rule.reddit_upvotes_min:
|
|
||||||
raise BuilderSkippedException(
|
|
||||||
"Post does not meet minimum amount of upvotes"
|
|
||||||
)
|
|
||||||
elif (
|
|
||||||
rule.reddit_downvotes_max is not None
|
|
||||||
and downvotes > rule.reddit_downvotes_max
|
|
||||||
):
|
|
||||||
raise BuilderSkippedException("Post has more downvotes than allowed")
|
|
||||||
elif not comments >= rule.reddit_comments_min:
|
|
||||||
raise BuilderSkippedException("Post does not have enough comments")
|
|
||||||
|
|
||||||
title = truncate_text(Post, "title", title)
|
|
||||||
author = truncate_text(Post, "author", author)
|
|
||||||
|
|
||||||
if is_text:
|
|
||||||
body = self.get_text_post(entry_data)
|
|
||||||
elif direct_url.endswith(REDDIT_IMAGE_EXTENSIONS):
|
|
||||||
body = self.get_image_post(title, direct_url)
|
|
||||||
elif is_video:
|
|
||||||
body = self.get_native_video_post(entry_data)
|
|
||||||
elif direct_url.endswith(REDDIT_VIDEO_EXTENSIONS):
|
|
||||||
body = self.get_video_post(direct_url)
|
|
||||||
else:
|
|
||||||
body = self.get_url_post(title, direct_url)
|
|
||||||
|
|
||||||
try:
|
|
||||||
_created_date = datetime.fromtimestamp(entry_data["created_utc"])
|
|
||||||
created_date = _created_date.replace(tzinfo=timezone.utc)
|
|
||||||
except (OverflowError, OSError) as e:
|
|
||||||
raise BuilderParseException(payload=entry) from e
|
|
||||||
except KeyError as e:
|
|
||||||
raise BuilderMissingDataException(payload=entry) from e
|
|
||||||
|
|
||||||
post_entry = {
|
|
||||||
"remote_identifier": remote_identifier,
|
|
||||||
"title": title,
|
|
||||||
"body": body,
|
|
||||||
"author": author,
|
|
||||||
"url": f"{REDDIT_URL}{post_url_fragment}",
|
|
||||||
"publication_date": created_date,
|
|
||||||
"rule": rule,
|
|
||||||
}
|
|
||||||
|
|
||||||
return Post(**post_entry)
|
|
||||||
|
|
||||||
def get_text_post(self, entry):
|
|
||||||
try:
|
|
||||||
uncleaned_body = entry["selftext_html"]
|
|
||||||
except KeyError as e:
|
|
||||||
raise BuilderMissingDataException(payload=entry) from e
|
|
||||||
|
|
||||||
unescaped_body = unescape(uncleaned_body) if uncleaned_body else ""
|
|
||||||
return self.sanitize_fragment(unescaped_body) if unescaped_body else ""
|
|
||||||
|
|
||||||
def get_image_post(self, title, url):
|
|
||||||
return format_html(
|
|
||||||
"<div><img alt='{title}' src='{url}' loading='lazy' /></div>",
|
|
||||||
url=url,
|
|
||||||
title=title,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_native_video_post(self, entry):
|
|
||||||
try:
|
|
||||||
video_info = entry["secure_media"]["reddit_video"]
|
|
||||||
except KeyError as e:
|
|
||||||
raise BuilderMissingDataException(payload=entry) from e
|
|
||||||
|
|
||||||
return format_html(
|
|
||||||
"<div><video controls muted><source src='{url}' type='video/mp4' /></video></div>",
|
|
||||||
url=video_info["fallback_url"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_video_post(self, url):
|
|
||||||
extension = next(
|
|
||||||
extension.replace(".", "")
|
|
||||||
for extension in REDDIT_VIDEO_EXTENSIONS
|
|
||||||
if url.endswith(extension)
|
|
||||||
)
|
|
||||||
|
|
||||||
if extension == "gifv":
|
|
||||||
return format_html(
|
|
||||||
"<div><video controls muted><source src='{url}' type='video/mp4' /></video></div>",
|
|
||||||
url=url.replace(extension, "mp4"),
|
|
||||||
)
|
|
||||||
|
|
||||||
return format_html(
|
|
||||||
"<div><video controls muted><source src='{url}' type='video/{extension}' /></video></div>",
|
|
||||||
url=url,
|
|
||||||
extension=extension,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_url_post(self, title, url):
|
|
||||||
return format_html(
|
|
||||||
"<div><a target='_blank' rel='noopener noreferrer' alt='{title}' href='{url}' class='link'>Direct url</a></div>",
|
|
||||||
url=url,
|
|
||||||
title=title,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.stream.rule.pk}: RedditBuilder"
|
|
||||||
|
|
||||||
|
|
||||||
class RedditStream(PostStream):
|
|
||||||
rule_type = RuleTypeChoices.subreddit
|
|
||||||
headers = {}
|
|
||||||
|
|
||||||
def __init__(self, rule):
|
|
||||||
super().__init__(rule)
|
|
||||||
|
|
||||||
self.headers = {"Authorization": f"bearer {self.rule.user.reddit_access_token}"}
|
|
||||||
|
|
||||||
def read(self):
|
|
||||||
response = fetch(self.rule.url, headers=self.headers)
|
|
||||||
|
|
||||||
return self.parse(response), self
|
|
||||||
|
|
||||||
def parse(self, response):
|
|
||||||
try:
|
|
||||||
return response.json()
|
|
||||||
except JSONDecodeError as e:
|
|
||||||
raise StreamParseException(
|
|
||||||
response=response, message="Failed parsing json"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.rule.pk}: RedditStream"
|
|
||||||
|
|
||||||
|
|
||||||
class RedditClient(PostClient):
|
|
||||||
stream = RedditStream
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
streams = [[self.stream(rule) for rule in batch] for batch in self.rules]
|
|
||||||
rate_limitted = False
|
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
||||||
for batch in streams:
|
|
||||||
futures = {executor.submit(stream.read): stream for stream in batch}
|
|
||||||
|
|
||||||
if rate_limitted:
|
|
||||||
logger.warning("Aborting requests, ratelimit hit")
|
|
||||||
break
|
|
||||||
|
|
||||||
for future in as_completed(futures):
|
|
||||||
stream = futures[future]
|
|
||||||
|
|
||||||
try:
|
|
||||||
response_data = future.result()
|
|
||||||
|
|
||||||
stream.rule.error = None
|
|
||||||
stream.rule.succeeded = True
|
|
||||||
|
|
||||||
yield response_data
|
|
||||||
except StreamDeniedException as e:
|
|
||||||
logger.warning(
|
|
||||||
f"Access token expired for user {stream.rule.user.pk}"
|
|
||||||
)
|
|
||||||
|
|
||||||
stream.rule.user.reddit_access_token = None
|
|
||||||
stream.rule.user.save()
|
|
||||||
|
|
||||||
self.set_rule_error(stream.rule, e)
|
|
||||||
|
|
||||||
RedditTokenTask.delay(stream.rule.user.pk)
|
|
||||||
|
|
||||||
break
|
|
||||||
except StreamTooManyException as e:
|
|
||||||
logger.exception("Ratelimit hit, aborting batched subreddits")
|
|
||||||
|
|
||||||
self.set_rule_error(stream.rule, e)
|
|
||||||
|
|
||||||
rate_limitted = True
|
|
||||||
break
|
|
||||||
except StreamException as e:
|
|
||||||
logger.exception(
|
|
||||||
f"Stream failed reading content from {stream.rule.url}"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.set_rule_error(stream.rule, e)
|
|
||||||
|
|
||||||
continue
|
|
||||||
finally:
|
|
||||||
stream.rule.last_run = datetime.now(tz=timezone.utc)
|
|
||||||
stream.rule.save()
|
|
||||||
|
|
||||||
|
|
||||||
class RedditCollector(PostCollector):
|
|
||||||
builder = RedditBuilder
|
|
||||||
client = RedditClient
|
|
||||||
|
|
||||||
|
|
||||||
class RedditScheduler(Scheduler):
|
|
||||||
max_amount = RATE_LIMIT
|
|
||||||
max_user_amount = RATE_LIMIT / 4
|
|
||||||
|
|
||||||
def __init__(self, subreddits=[]):
|
|
||||||
if not subreddits:
|
|
||||||
self.subreddits = CollectionRule.objects.filter(
|
|
||||||
type=RuleTypeChoices.subreddit,
|
|
||||||
user__reddit_access_token__isnull=False,
|
|
||||||
user__reddit_refresh_token__isnull=False,
|
|
||||||
enabled=True,
|
|
||||||
).order_by("last_run")[:200]
|
|
||||||
else:
|
|
||||||
self.subreddits = subreddits
|
|
||||||
|
|
||||||
def get_scheduled_rules(self):
|
|
||||||
rule_mapping = {}
|
|
||||||
current_amount = 0
|
|
||||||
|
|
||||||
for subreddit in self.subreddits:
|
|
||||||
user_pk = subreddit.user.pk
|
|
||||||
|
|
||||||
if current_amount == self.max_amount:
|
|
||||||
break
|
|
||||||
|
|
||||||
if user_pk in rule_mapping:
|
|
||||||
max_amount_reached = len(rule_mapping[user_pk]) == self.max_user_amount
|
|
||||||
|
|
||||||
if max_amount_reached:
|
|
||||||
continue
|
|
||||||
|
|
||||||
rule_mapping[user_pk].append(subreddit)
|
|
||||||
current_amount += 1
|
|
||||||
|
|
||||||
continue
|
|
||||||
|
|
||||||
rule_mapping[user_pk] = [subreddit]
|
|
||||||
current_amount += 1
|
|
||||||
|
|
||||||
return list(rule_mapping.values())
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
from django.conf import settings
|
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.core.mail import send_mail
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from celery.exceptions import Reject
|
from celery.exceptions import Reject
|
||||||
from celery.utils.log import get_task_logger
|
from celery.utils.log import get_task_logger
|
||||||
|
|
@ -11,9 +6,7 @@ from celery.utils.log import get_task_logger
|
||||||
from newsreader.accounts.models import User
|
from newsreader.accounts.models import User
|
||||||
from newsreader.celery import app
|
from newsreader.celery import app
|
||||||
from newsreader.news.collection.choices import RuleTypeChoices
|
from newsreader.news.collection.choices import RuleTypeChoices
|
||||||
from newsreader.news.collection.exceptions.stream import StreamException
|
|
||||||
from newsreader.news.collection.feed import FeedCollector
|
from newsreader.news.collection.feed import FeedCollector
|
||||||
from newsreader.news.collection.utils import post
|
|
||||||
from newsreader.utils.celery import MemCacheLock
|
from newsreader.utils.celery import MemCacheLock
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -49,84 +42,6 @@ class FeedTask(app.Task):
|
||||||
raise Reject(reason="Task already running", requeue=False)
|
raise Reject(reason="Task already running", requeue=False)
|
||||||
|
|
||||||
|
|
||||||
class RedditTask(app.Task):
|
|
||||||
name = "RedditTask"
|
|
||||||
ignore_result = True
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
from newsreader.news.collection.reddit import RedditCollector, RedditScheduler
|
|
||||||
|
|
||||||
with MemCacheLock("reddit-task", self.app.oid) as acquired:
|
|
||||||
if acquired:
|
|
||||||
logger.info("Running reddit task")
|
|
||||||
|
|
||||||
scheduler = RedditScheduler()
|
|
||||||
subreddits = scheduler.get_scheduled_rules()
|
|
||||||
|
|
||||||
collector = RedditCollector()
|
|
||||||
collector.collect(rules=subreddits)
|
|
||||||
else:
|
|
||||||
logger.warning("Cancelling task due to existing lock")
|
|
||||||
|
|
||||||
raise Reject(reason="Task already running", requeue=False)
|
|
||||||
|
|
||||||
|
|
||||||
class RedditTokenTask(app.Task):
|
|
||||||
name = "RedditTokenTask"
|
|
||||||
ignore_result = True
|
|
||||||
|
|
||||||
def run(self, user_pk):
|
|
||||||
from newsreader.news.collection.reddit import REDDIT_URL
|
|
||||||
|
|
||||||
try:
|
|
||||||
user = User.objects.get(pk=user_pk)
|
|
||||||
except ObjectDoesNotExist:
|
|
||||||
message = f"User {user_pk} does not exist"
|
|
||||||
logger.exception(message)
|
|
||||||
|
|
||||||
raise Reject(reason=message, requeue=False)
|
|
||||||
|
|
||||||
if not user.reddit_refresh_token:
|
|
||||||
raise Reject(reason=f"User {user_pk} has no refresh token", requeue=False)
|
|
||||||
|
|
||||||
client_auth = requests.auth.HTTPBasicAuth(
|
|
||||||
settings.REDDIT_CLIENT_ID, settings.REDDIT_CLIENT_SECRET
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = post(
|
|
||||||
f"{REDDIT_URL}/api/v1/access_token",
|
|
||||||
data={
|
|
||||||
"grant_type": "refresh_token",
|
|
||||||
"refresh_token": user.reddit_refresh_token,
|
|
||||||
},
|
|
||||||
auth=client_auth,
|
|
||||||
)
|
|
||||||
except StreamException:
|
|
||||||
logger.exception(
|
|
||||||
f"Failed refreshing reddit access token for user {user_pk}"
|
|
||||||
)
|
|
||||||
|
|
||||||
user.reddit_refresh_token = None
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
message = _(
|
|
||||||
"Your Reddit account credentials have expired. Re-authenticate in"
|
|
||||||
" the settings page to keep retrieving Reddit specific information"
|
|
||||||
" from your account."
|
|
||||||
)
|
|
||||||
|
|
||||||
send_mail(
|
|
||||||
"Reddit account needs re-authentication", message, None, [user.email]
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
response_data = response.json()
|
|
||||||
|
|
||||||
user.reddit_access_token = response_data["access_token"]
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
|
|
||||||
class FaviconTask(app.Task):
|
class FaviconTask(app.Task):
|
||||||
name = "FaviconTask"
|
name = "FaviconTask"
|
||||||
ignore_result = True
|
ignore_result = True
|
||||||
|
|
@ -150,15 +65,6 @@ class FaviconTask(app.Task):
|
||||||
|
|
||||||
collector = FaviconCollector()
|
collector = FaviconCollector()
|
||||||
collector.collect(rules=rules)
|
collector.collect(rules=rules)
|
||||||
|
|
||||||
third_party_rules = user.rules.enabled().exclude(
|
|
||||||
type=RuleTypeChoices.feed
|
|
||||||
)
|
|
||||||
|
|
||||||
for rule in third_party_rules:
|
|
||||||
if rule.type == RuleTypeChoices.subreddit:
|
|
||||||
rule.favicon = "https://www.reddit.com/favicon.ico"
|
|
||||||
rule.save()
|
|
||||||
else:
|
else:
|
||||||
logger.warning("Cancelling task due to existing lock")
|
logger.warning("Cancelling task due to existing lock")
|
||||||
|
|
||||||
|
|
@ -167,5 +73,3 @@ class FaviconTask(app.Task):
|
||||||
|
|
||||||
FeedTask = app.register_task(FeedTask())
|
FeedTask = app.register_task(FeedTask())
|
||||||
FaviconTask = app.register_task(FaviconTask())
|
FaviconTask = app.register_task(FaviconTask())
|
||||||
RedditTask = app.register_task(RedditTask())
|
|
||||||
RedditTokenTask = app.register_task(RedditTokenTask())
|
|
||||||
|
|
|
||||||
|
|
@ -2,139 +2,137 @@
|
||||||
{% load i18n static filters %}
|
{% load i18n static filters %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main id="rules--page" class="main" data-render-sidebar=true>
|
<main id="rules--page" class="main" data-render-sidebar=true>
|
||||||
<div class="main__container">
|
<div class="main__container">
|
||||||
<form class="form rules-form">
|
<form class="form rules-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<section class="section form__section form__section--actions">
|
<section class="section form__section form__section--actions">
|
||||||
<div class="form__actions">
|
<div class="form__actions">
|
||||||
<a class="link button button--confirm" href="{% url "news:collection:feed-create" %}">{% trans "Add a feed" %}</a>
|
<a class="link button button--confirm"
|
||||||
<a class="link button button--confirm" href="{% url "news:collection:import" %}">{% trans "Import feeds" %}</a>
|
href="{% url 'news:collection:feed-create' %}">{% trans "Add a feed" %}</a>
|
||||||
<a class="link button button--reddit" href="{% url "news:collection:subreddit-create" %}">{% trans "Add a subreddit" %}</a>
|
<a class="link button button--confirm"
|
||||||
</div>
|
href="{% url 'news:collection:import' %}">{% trans "Import feeds" %}</a>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section form__section form__section--actions">
|
|
||||||
<fieldset class="fieldset form__fieldset">
|
|
||||||
<input type="submit" class="button button--primary" formaction="{% url "news:collection:rules-enable" %}" formmethod="post" value="{% trans "Enable" %}" />
|
|
||||||
<input type="submit" class="button button--primary" formaction="{% url "news:collection:rules-disable" %}" formmethod="post" value="{% trans "Disable" %}" />
|
|
||||||
<input type="submit" class="button button--error" formaction="{% url "news:collection:rules-delete" %}" formmethod="post" value="{% trans "Delete" %}"/>
|
|
||||||
</fieldset>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section form__section">
|
|
||||||
<table class="table rules-table" border="0" cellspacing="0">
|
|
||||||
<thead class="table__header rules-table__header">
|
|
||||||
<tr class="table__row rules-table__row">
|
|
||||||
<th class="table__heading rules-table__heading--select">
|
|
||||||
{% include "components/form/checkbox.html" with id="select-all" data_input="rules" id_for_label="select-all" %}
|
|
||||||
</th>
|
|
||||||
<th class="table__heading rules-table__heading rules-table__heading--name">{% trans "Name" %}</th>
|
|
||||||
<th class="table__heading rules-table__heading rules-table__heading--category">{% trans "Category" %}</th>
|
|
||||||
<th class="table__heading rules-table__heading rules-table__heading--url">{% trans "URL" %}</th>
|
|
||||||
<th class="table__heading rules-table__heading rules-table__heading--succeeded">{% trans "Successfuly ran" %}</th>
|
|
||||||
<th class="table__heading rules-table__heading rules-table__heading--enabled">{% trans "Enabled" %}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="table__body rules-table__body">
|
|
||||||
{% for rule in rules %}
|
|
||||||
<tr class="table__row {% if rule.failed %}table__row--error{% endif %} rules-table__row">
|
|
||||||
<td class="table__item rules-table__item--select">
|
|
||||||
{% with rule|id_for_label:"rules" as id_for_label %}
|
|
||||||
{% include "components/form/checkbox.html" with name="rules" value=rule.pk id=id_for_label id_for_label=id_for_label %}
|
|
||||||
{% endwith %}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td
|
|
||||||
class="table__item rules-table__item rules-table__item--name"
|
|
||||||
title="{{ rule.name }}"
|
|
||||||
>
|
|
||||||
<a class="link" href="{{ rule.update_url }}">
|
|
||||||
{{ rule.name }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td
|
|
||||||
class="table__item rules-table__item rules-table__item--category"
|
|
||||||
title="{{ rule.category.name }}"
|
|
||||||
>
|
|
||||||
{% if rule.category %}
|
|
||||||
<a
|
|
||||||
class="link"
|
|
||||||
href="{% url 'news:core:category-update' pk=rule.category.pk %}"
|
|
||||||
>
|
|
||||||
{{ rule.category.name }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td
|
|
||||||
class="table__item rules-table__item rules-table__item--url"
|
|
||||||
title="{{ rule.source_url }}"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
class="link"
|
|
||||||
href="{{ rule.source_url }}"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{{ rule.source_url }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td class="table__item rules-table__item rules-table__item--failed">
|
|
||||||
{% if rule.failed %}
|
|
||||||
<i class="fas fa-exclamation-triangle"></i>
|
|
||||||
{% else %}
|
|
||||||
<i class="fas fa-check"></i>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td class="table__item rules-table__item rules-table__item--enabled">
|
|
||||||
{% if rule.enabled %}
|
|
||||||
<i class="fas fa-check"></i>
|
|
||||||
{% else %}
|
|
||||||
<i class="fas fa-pause"></i>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="table__footer">
|
|
||||||
<div class="pagination">
|
|
||||||
<span class="pagination__previous">
|
|
||||||
{% if page_obj.has_previous %}
|
|
||||||
<a class="link button" href="?page=1">{% trans "first" %}</a>
|
|
||||||
<a class="link button" href="?page={{ page_obj.previous_page_number }}">
|
|
||||||
{% trans "previous" %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="pagination__current">
|
|
||||||
{% blocktrans with current_number=page_obj.number total_count=page_obj.paginator.num_pages %}
|
|
||||||
Page {{ current_number }} of {{ total_count }}
|
|
||||||
{% endblocktrans %}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="pagination__next">
|
|
||||||
{% if page_obj.has_next %}
|
|
||||||
<a class="link button" href="?page={{ page_obj.next_page_number }}">
|
|
||||||
{% trans "next" %}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a class="link button" href="?page={{ page_obj.paginator.num_pages }}">
|
|
||||||
{% trans "last" %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section form__section form__section--actions">
|
||||||
|
<fieldset class="fieldset form__fieldset">
|
||||||
|
<input type="submit" class="button button--primary" formaction="{% url 'news:collection:rules-enable' %}"
|
||||||
|
formmethod="post" value="{% trans " Enable" %}" />
|
||||||
|
<input type="submit" class="button button--primary" formaction="{% url 'news:collection:rules-disable' %}"
|
||||||
|
formmethod="post" value="{% trans " Disable" %}" />
|
||||||
|
<input type="submit" class="button button--error" formaction="{% url 'news:collection:rules-delete' %}"
|
||||||
|
formmethod="post" value="{% trans " Delete" %}" />
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section form__section">
|
||||||
|
<table class="table rules-table" border="0" cellspacing="0">
|
||||||
|
<thead class="table__header rules-table__header">
|
||||||
|
<tr class="table__row rules-table__row">
|
||||||
|
<th class="table__heading rules-table__heading--select">
|
||||||
|
{% include "components/form/checkbox.html" with id="select-all" data_input="rules" id_for_label="select-all" %}
|
||||||
|
</th>
|
||||||
|
<th class="table__heading rules-table__heading rules-table__heading--name">
|
||||||
|
{% trans "Name" %}
|
||||||
|
</th>
|
||||||
|
<th class="table__heading rules-table__heading rules-table__heading--category">
|
||||||
|
{% trans "Category" %}
|
||||||
|
</th>
|
||||||
|
<th class="table__heading rules-table__heading rules-table__heading--url">
|
||||||
|
{% trans "URL" %}
|
||||||
|
</th>
|
||||||
|
<th class="table__heading rules-table__heading rules-table__heading--succeeded">
|
||||||
|
{% trans "Successfuly ran" %}
|
||||||
|
</th>
|
||||||
|
<th class="table__heading rules-table__heading rules-table__heading--enabled">
|
||||||
|
{% trans "Enabled" %}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="table__body rules-table__body">
|
||||||
|
{% for rule in rules %}
|
||||||
|
<tr class="table__row {% if rule.failed %}table__row--error{% endif %} rules-table__row">
|
||||||
|
<td class="table__item rules-table__item--select">
|
||||||
|
{% with rule|id_for_label:"rules" as id_for_label %}
|
||||||
|
{% include "components/form/checkbox.html" with name="rules" value=rule.pk id=id_for_label
|
||||||
|
id_for_label=id_for_label %}
|
||||||
|
{% endwith %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="table__item rules-table__item rules-table__item--name" title="{{ rule.name }}">
|
||||||
|
<a class="link" href="{{ rule.update_url }}">
|
||||||
|
{{ rule.name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="table__item rules-table__item rules-table__item--category" title="{{ rule.category.name }}">
|
||||||
|
{% if rule.category %}
|
||||||
|
<a class="link" href="{% url 'news:core:category-update' pk=rule.category.pk %}">
|
||||||
|
{{ rule.category.name }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="table__item rules-table__item rules-table__item--url" title="{{ rule.source_url }}">
|
||||||
|
<a class="link" href="{{ rule.source_url }}" target="_blank" rel="noopener noreferrer">
|
||||||
|
{{ rule.source_url }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="table__item rules-table__item rules-table__item--failed">
|
||||||
|
{% if rule.failed %}
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="table__item rules-table__item rules-table__item--enabled">
|
||||||
|
{% if rule.enabled %}
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="fas fa-pause"></i>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="table__footer">
|
||||||
|
<div class="pagination">
|
||||||
|
<span class="pagination__previous">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<a class="link button" href="?page=1">{% trans "first" %}</a>
|
||||||
|
<a class="link button" href="?page={{ page_obj.previous_page_number }}">
|
||||||
|
{% trans "previous" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="pagination__current">
|
||||||
|
{% blocktrans with current_number=page_obj.number total_count=page_obj.paginator.num_pages %}
|
||||||
|
Page {{ current_number }} of {{ total_count }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="pagination__next">
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<a class="link button" href="?page={{ page_obj.next_page_number }}">
|
||||||
|
{% trans "next" %}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="link button" href="?page={{ page_obj.paginator.num_pages }}">
|
||||||
|
{% trans "last" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{% extends "sidebar.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% url "news:collection:rules" as cancel_url %}
|
|
||||||
|
|
||||||
<main id="subreddit--page" class="main" data-render-sidebar=true>
|
|
||||||
<div class="main__container">
|
|
||||||
{% include "components/form/form.html" with form=form title="Add a subreddit" cancel_url=cancel_url confirm_text="Add subrredit" %}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
{% load static i18n %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<main id="subreddit--page" class="main">
|
|
||||||
{% if subreddit.error %}
|
|
||||||
{% trans "Failed to retrieve posts" as title %}
|
|
||||||
{% include "components/textbox/textbox.html" with title=title body=subreddit.error class="text-section--error" only %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% url "news:collection:rules" as cancel_url %}
|
|
||||||
{% include "components/form/form.html" with form=form title="Update subreddit" cancel_url=cancel_url confirm_text="Save subreddit" %}
|
|
||||||
</main>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -202,7 +202,7 @@ class NestedRuleListViewTestCase(TestCase):
|
||||||
with self.subTest(post=post):
|
with self.subTest(post=post):
|
||||||
self.assertEqual(post["rule"]["id"], rule.pk)
|
self.assertEqual(post["rule"]["id"], rule.pk)
|
||||||
|
|
||||||
def test_unread_posts(self):
|
def test_posts(self):
|
||||||
rule = FeedFactory.create(user=self.user)
|
rule = FeedFactory.create(user=self.user)
|
||||||
|
|
||||||
FeedPostFactory.create_batch(size=10, rule=rule, read=False)
|
FeedPostFactory.create_batch(size=10, rule=rule, read=False)
|
||||||
|
|
@ -210,7 +210,6 @@ class NestedRuleListViewTestCase(TestCase):
|
||||||
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}),
|
reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}),
|
||||||
{"read": "false"},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
@ -221,23 +220,3 @@ class NestedRuleListViewTestCase(TestCase):
|
||||||
for post in data["results"]:
|
for post in data["results"]:
|
||||||
with self.subTest(post=post):
|
with self.subTest(post=post):
|
||||||
self.assertEqual(post["read"], False)
|
self.assertEqual(post["read"], False)
|
||||||
|
|
||||||
def test_read_posts(self):
|
|
||||||
rule = FeedFactory.create(user=self.user)
|
|
||||||
|
|
||||||
FeedPostFactory.create_batch(size=20, rule=rule, read=False)
|
|
||||||
FeedPostFactory.create_batch(size=10, rule=rule, read=True)
|
|
||||||
|
|
||||||
response = self.client.get(
|
|
||||||
reverse("api:news:collection:rules-nested-posts", kwargs={"pk": rule.pk}),
|
|
||||||
{"read": "true"},
|
|
||||||
)
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertEqual(len(data["results"]), 10)
|
|
||||||
|
|
||||||
for post in data["results"]:
|
|
||||||
with self.subTest(post=post):
|
|
||||||
self.assertEqual(post["read"], True)
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import factory
|
||||||
from newsreader.accounts.tests.factories import UserFactory
|
from newsreader.accounts.tests.factories import UserFactory
|
||||||
from newsreader.news.collection.choices import RuleTypeChoices
|
from newsreader.news.collection.choices import RuleTypeChoices
|
||||||
from newsreader.news.collection.models import CollectionRule
|
from newsreader.news.collection.models import CollectionRule
|
||||||
from newsreader.news.collection.reddit import REDDIT_URL
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionRuleFactory(factory.django.DjangoModelFactory):
|
class CollectionRuleFactory(factory.django.DjangoModelFactory):
|
||||||
|
|
@ -23,8 +22,3 @@ class CollectionRuleFactory(factory.django.DjangoModelFactory):
|
||||||
|
|
||||||
class FeedFactory(CollectionRuleFactory):
|
class FeedFactory(CollectionRuleFactory):
|
||||||
type = RuleTypeChoices.feed
|
type = RuleTypeChoices.feed
|
||||||
|
|
||||||
|
|
||||||
class SubredditFactory(CollectionRuleFactory):
|
|
||||||
type = RuleTypeChoices.subreddit
|
|
||||||
website_url = REDDIT_URL
|
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@ from django.test import TestCase
|
||||||
from newsreader.news.collection.favicon import FaviconBuilder
|
from newsreader.news.collection.favicon import FaviconBuilder
|
||||||
from newsreader.news.collection.tests.factories import CollectionRuleFactory
|
from newsreader.news.collection.tests.factories import CollectionRuleFactory
|
||||||
from newsreader.news.collection.tests.favicon.builder.mocks import (
|
from newsreader.news.collection.tests.favicon.builder.mocks import (
|
||||||
simple_mock,
|
|
||||||
mock_without_url,
|
|
||||||
mock_without_header,
|
|
||||||
mock_with_weird_path,
|
|
||||||
mock_with_other_url,
|
|
||||||
mock_with_multiple_icons,
|
mock_with_multiple_icons,
|
||||||
|
mock_with_other_url,
|
||||||
|
mock_with_weird_path,
|
||||||
|
mock_without_header,
|
||||||
|
mock_without_url,
|
||||||
|
simple_mock,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ feed_mock = {
|
||||||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||||
"language": None,
|
"language": None,
|
||||||
"type": "text/plain",
|
"type": "text/plain",
|
||||||
"value": "Trump's genocidal taunts will not " "end Iran - Zarif",
|
"value": "Trump's genocidal taunts will not end Iran - Zarif",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -83,7 +83,7 @@ feed_mock = {
|
||||||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||||
"language": None,
|
"language": None,
|
||||||
"type": "text/plain",
|
"type": "text/plain",
|
||||||
"value": "Huawei's Android loss: How it " "affects you",
|
"value": "Huawei's Android loss: How it affects you",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -124,7 +124,7 @@ feed_mock = {
|
||||||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||||
"language": None,
|
"language": None,
|
||||||
"type": "text/plain",
|
"type": "text/plain",
|
||||||
"value": "Birmingham head teacher threatened " "over LGBT lessons",
|
"value": "Birmingham head teacher threatened over LGBT lessons",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -11,21 +11,21 @@ from newsreader.news.core.models import Post
|
||||||
from newsreader.news.core.tests.factories import FeedPostFactory
|
from newsreader.news.core.tests.factories import FeedPostFactory
|
||||||
|
|
||||||
from .mocks import (
|
from .mocks import (
|
||||||
multiple_mock,
|
mock_with_html,
|
||||||
|
mock_with_long_author,
|
||||||
|
mock_with_long_exotic_title,
|
||||||
|
mock_with_long_title,
|
||||||
|
mock_with_longer_content_detail,
|
||||||
|
mock_with_multiple_content_detail,
|
||||||
|
mock_with_shorter_content_detail,
|
||||||
|
mock_with_update_entries,
|
||||||
|
mock_without_author,
|
||||||
|
mock_without_body,
|
||||||
|
mock_without_entries,
|
||||||
mock_without_identifier,
|
mock_without_identifier,
|
||||||
mock_without_publish_date,
|
mock_without_publish_date,
|
||||||
mock_without_url,
|
mock_without_url,
|
||||||
mock_without_body,
|
multiple_mock,
|
||||||
mock_without_author,
|
|
||||||
mock_without_entries,
|
|
||||||
mock_with_update_entries,
|
|
||||||
mock_with_html,
|
|
||||||
mock_with_long_author,
|
|
||||||
mock_with_long_title,
|
|
||||||
mock_with_long_exotic_title,
|
|
||||||
mock_with_longer_content_detail,
|
|
||||||
mock_with_shorter_content_detail,
|
|
||||||
mock_with_multiple_content_detail,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ simple_mock = {
|
||||||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||||
"language": None,
|
"language": None,
|
||||||
"type": "text/plain",
|
"type": "text/plain",
|
||||||
"value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif",
|
"value": "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ multiple_mock = {
|
||||||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||||
"language": None,
|
"language": None,
|
||||||
"type": "text/plain",
|
"type": "text/plain",
|
||||||
"value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif",
|
"value": "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -81,7 +81,7 @@ multiple_mock = {
|
||||||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||||
"language": None,
|
"language": None,
|
||||||
"type": "text/plain",
|
"type": "text/plain",
|
||||||
"value": "Huawei's Android loss: How it " "affects you",
|
"value": "Huawei's Android loss: How it affects you",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -122,7 +122,7 @@ multiple_mock = {
|
||||||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||||
"language": None,
|
"language": None,
|
||||||
"type": "text/plain",
|
"type": "text/plain",
|
||||||
"value": "Birmingham head teacher threatened " "over LGBT lessons",
|
"value": "Birmingham head teacher threatened over LGBT lessons",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -212,7 +212,7 @@ duplicate_mock = {
|
||||||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||||
"language": None,
|
"language": None,
|
||||||
"type": "text/plain",
|
"type": "text/plain",
|
||||||
"value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif",
|
"value": "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -250,7 +250,7 @@ duplicate_mock = {
|
||||||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||||
"language": None,
|
"language": None,
|
||||||
"type": "text/plain",
|
"type": "text/plain",
|
||||||
"value": "Huawei's Android loss: How it " "affects you",
|
"value": "Huawei's Android loss: How it affects you",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -290,7 +290,7 @@ duplicate_mock = {
|
||||||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||||
"language": None,
|
"language": None,
|
||||||
"type": "text/plain",
|
"type": "text/plain",
|
||||||
"value": "Birmingham head teacher threatened " "over LGBT lessons",
|
"value": "Birmingham head teacher threatened over LGBT lessons",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -356,7 +356,7 @@ multiple_update_mock = {
|
||||||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||||
"language": None,
|
"language": None,
|
||||||
"type": "text/plain",
|
"type": "text/plain",
|
||||||
"value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif",
|
"value": "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -395,7 +395,7 @@ multiple_update_mock = {
|
||||||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||||
"language": None,
|
"language": None,
|
||||||
"type": "text/plain",
|
"type": "text/plain",
|
||||||
"value": "Huawei's Android loss: How it " "affects you",
|
"value": "Huawei's Android loss: How it affects you",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -436,7 +436,7 @@ multiple_update_mock = {
|
||||||
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
"base": "http://feeds.bbci.co.uk/news/rss.xml",
|
||||||
"language": None,
|
"language": None,
|
||||||
"type": "text/plain",
|
"type": "text/plain",
|
||||||
"value": "Birmingham head teacher threatened " "over LGBT lessons",
|
"value": "Birmingham head teacher threatened over LGBT lessons",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -73,12 +73,12 @@ simple_mock_parsed = {
|
||||||
"not think face coverings should be "
|
"not think face coverings should be "
|
||||||
"mandatory in shops in England.",
|
"mandatory in shops in England.",
|
||||||
},
|
},
|
||||||
"title": "Coronavirus: I trust people's sense on face masks - " "Gove",
|
"title": "Coronavirus: I trust people's sense on face masks - Gove",
|
||||||
"title_detail": {
|
"title_detail": {
|
||||||
"base": "",
|
"base": "",
|
||||||
"language": None,
|
"language": None,
|
||||||
"type": "text/plain",
|
"type": "text/plain",
|
||||||
"value": "Coronavirus: I trust people's sense " "on face masks - Gove",
|
"value": "Coronavirus: I trust people's sense on face masks - Gove",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -109,7 +109,7 @@ simple_mock_parsed = {
|
||||||
"base": "",
|
"base": "",
|
||||||
"language": None,
|
"language": None,
|
||||||
"type": "text/plain",
|
"type": "text/plain",
|
||||||
"value": "Farm outbreak leads 200 to self " "isolate",
|
"value": "Farm outbreak leads 200 to self isolate",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -137,12 +137,12 @@ simple_mock_parsed = {
|
||||||
"talks on tackling people "
|
"talks on tackling people "
|
||||||
"smuggling.",
|
"smuggling.",
|
||||||
},
|
},
|
||||||
"title": "English Channel search operation after migrant " "crossings",
|
"title": "English Channel search operation after migrant crossings",
|
||||||
"title_detail": {
|
"title_detail": {
|
||||||
"base": "",
|
"base": "",
|
||||||
"language": None,
|
"language": None,
|
||||||
"type": "text/plain",
|
"type": "text/plain",
|
||||||
"value": "English Channel search operation " "after migrant crossings",
|
"value": "English Channel search operation after migrant crossings",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,472 +0,0 @@
|
||||||
from datetime import datetime, timezone
|
|
||||||
from unittest.mock import Mock
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from newsreader.news.collection.reddit import RedditBuilder
|
|
||||||
from newsreader.news.collection.tests.factories import SubredditFactory
|
|
||||||
from newsreader.news.collection.tests.reddit.builder.mocks import (
|
|
||||||
simple_mock,
|
|
||||||
empty_mock,
|
|
||||||
unknown_mock,
|
|
||||||
unsanitized_mock,
|
|
||||||
author_mock,
|
|
||||||
title_mock,
|
|
||||||
duplicate_mock,
|
|
||||||
image_mock,
|
|
||||||
external_image_mock,
|
|
||||||
video_mock,
|
|
||||||
external_video_mock,
|
|
||||||
external_gifv_mock,
|
|
||||||
nsfw_mock,
|
|
||||||
spoiler_mock,
|
|
||||||
seen_mock,
|
|
||||||
upvote_mock,
|
|
||||||
comment_mock,
|
|
||||||
downvote_mock,
|
|
||||||
)
|
|
||||||
from newsreader.news.core.models import Post
|
|
||||||
from newsreader.news.core.tests.factories import RedditPostFactory
|
|
||||||
|
|
||||||
|
|
||||||
class RedditBuilderTestCase(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.maxDiff = None
|
|
||||||
|
|
||||||
def test_simple_mock(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(simple_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
|
||||||
|
|
||||||
self.assertCountEqual(
|
|
||||||
("hm0qct", "hna75r", "hngs71", "hngsj8", "hnd7cy"), posts.keys()
|
|
||||||
)
|
|
||||||
|
|
||||||
post = posts["hm0qct"]
|
|
||||||
|
|
||||||
self.assertEqual(post.rule, subreddit)
|
|
||||||
self.assertEqual(
|
|
||||||
post.title,
|
|
||||||
"Linux Experiences/Rants or Education/Certifications thread - July 06, 2020",
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
" This megathread is also to hear opinions from anyone just starting out"
|
|
||||||
" with Linux or those that have used Linux (GNU or otherwise) for a long",
|
|
||||||
post.body,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertIn(
|
|
||||||
"<p>For those looking for certifications please use this megathread to ask about how"
|
|
||||||
" to get certified whether it's for the business world or for your own satisfaction."
|
|
||||||
' Be sure to check out <a href="/r/linuxadmin">r/linuxadmin</a> for more discussion in the'
|
|
||||||
" SysAdmin world!</p>",
|
|
||||||
post.body,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(post.author, "AutoModerator")
|
|
||||||
self.assertEqual(
|
|
||||||
post.url,
|
|
||||||
"https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
post.publication_date, datetime(2020, 7, 6, 6, 11, 22, tzinfo=timezone.utc)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_empty_data(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(empty_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
self.assertEqual(Post.objects.count(), 0)
|
|
||||||
|
|
||||||
def test_unknown_mock(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(unknown_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
self.assertEqual(Post.objects.count(), 0)
|
|
||||||
|
|
||||||
def test_html_sanitizing(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(unsanitized_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
|
||||||
|
|
||||||
self.assertCountEqual(("hnd7cy",), posts.keys())
|
|
||||||
|
|
||||||
post = posts["hnd7cy"]
|
|
||||||
|
|
||||||
self.assertEqual(post.body, "<article></article>")
|
|
||||||
|
|
||||||
def test_long_author_text_is_truncated(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(author_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
|
||||||
|
|
||||||
self.assertCountEqual(("hnd7cy",), posts.keys())
|
|
||||||
|
|
||||||
post = posts["hnd7cy"]
|
|
||||||
|
|
||||||
self.assertEqual(post.author, "TheQuantumZeroTheQuantumZeroTheQuantumZ…")
|
|
||||||
|
|
||||||
def test_long_title_text_is_truncated(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(title_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
|
||||||
|
|
||||||
self.assertCountEqual(("hnd7cy",), posts.keys())
|
|
||||||
|
|
||||||
post = posts["hnd7cy"]
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
post.title,
|
|
||||||
'Board statement on the LibreOffice 7.0 RC "Personal EditionBoard statement on the LibreOffice 7.0 RC "Personal Edition" label" labelBoard statement on the LibreOffice 7.0 RC "PersBoard statement on t…',
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_duplicate_in_response(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(duplicate_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
|
||||||
|
|
||||||
self.assertEqual(Post.objects.count(), 2)
|
|
||||||
self.assertCountEqual(("hm0qct", "hna75r"), posts.keys())
|
|
||||||
|
|
||||||
def test_duplicate_in_database(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
RedditPostFactory(remote_identifier="hm0qct", rule=subreddit, title="foo")
|
|
||||||
|
|
||||||
with builder(simple_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
|
||||||
|
|
||||||
self.assertEqual(Post.objects.count(), 5)
|
|
||||||
self.assertCountEqual(
|
|
||||||
("hm0qct", "hna75r", "hngs71", "hngsj8", "hnd7cy"), posts.keys()
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_image_post(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(image_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
|
||||||
|
|
||||||
self.assertCountEqual(("hr64xh", "hr4bxo", "hr14y5", "hr2fv0"), posts.keys())
|
|
||||||
|
|
||||||
post = posts["hr64xh"]
|
|
||||||
|
|
||||||
title = (
|
|
||||||
"Ya’ll, I just can’t... this is my "
|
|
||||||
"son, Judah. My wife and I have no "
|
|
||||||
"idea how we created such a "
|
|
||||||
"beautiful child."
|
|
||||||
)
|
|
||||||
url = "https://i.redd.it/cm2qybia1va51.jpg"
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
"https://www.reddit.com/r/aww/comments/hr64xh/yall_i_just_cant_this_is_my_son_judah_my_wife_and/",
|
|
||||||
post.url,
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
f"<div><img alt='{title}' src='{url}' loading='lazy' /></div>", post.body
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_external_image_post(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(external_image_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
|
||||||
|
|
||||||
self.assertCountEqual(("hr41am", "huoldn"), posts.keys())
|
|
||||||
|
|
||||||
post = posts["hr41am"]
|
|
||||||
|
|
||||||
url = "http://gfycat.com/thatalivedogwoodclubgall"
|
|
||||||
title = "Excited cows have a new brush!"
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
f"<div><a target='_blank' rel='noopener noreferrer' alt='{title}' href='{url}' class='link'>Direct url</a></div>",
|
|
||||||
post.body,
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
"https://www.reddit.com/r/aww/comments/hr41am/excited_cows_have_a_new_brush/",
|
|
||||||
post.url,
|
|
||||||
)
|
|
||||||
|
|
||||||
post = posts["huoldn"]
|
|
||||||
|
|
||||||
url = "https://i.imgur.com/usfMVUJ.jpg"
|
|
||||||
title = "Novosibirsk Zoo welcomes 16 cobalt-eyed Pallas’s cat kittens"
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
f"<div><img alt='{title}' src='{url}' loading='lazy' /></div>", post.body
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
"https://www.reddit.com/r/aww/comments/huoldn/novosibirsk_zoo_welcomes_16_cobalteyed_pallass/",
|
|
||||||
post.url,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_video_post(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(video_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
|
||||||
|
|
||||||
self.assertCountEqual(("hr32jf", "hr1r00", "hqy0ny", "hr0uzh"), posts.keys())
|
|
||||||
|
|
||||||
post = posts["hr1r00"]
|
|
||||||
|
|
||||||
url = "https://v.redd.it/eyvbxaeqtta51/DASH_480.mp4?source=fallback"
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
post.url,
|
|
||||||
"https://www.reddit.com/r/aww/comments/hr1r00/cool_catt_and_his_clingy_girlfriend/",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
f"<div><video controls muted><source src='{url}' type='video/mp4' /></video></div>",
|
|
||||||
post.body,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_external_video_post(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(external_video_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
post = Post.objects.get()
|
|
||||||
|
|
||||||
self.assertEqual(post.remote_identifier, "hulh8k")
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
post.url,
|
|
||||||
"https://www.reddit.com/r/aww/comments/hulh8k/dog_splashing_in_water/",
|
|
||||||
)
|
|
||||||
|
|
||||||
title = "Dog splashing in water"
|
|
||||||
url = "https://gfycat.com/excellentinfantileamericanwigeon"
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
f"<div><a target='_blank' rel='noopener noreferrer' alt='{title}' href='{url}' class='link'>Direct url</a></div>",
|
|
||||||
post.body,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_external_gifv_video_post(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(external_gifv_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
post = Post.objects.get()
|
|
||||||
|
|
||||||
self.assertEqual(post.remote_identifier, "humdlf")
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
post.url, "https://www.reddit.com/r/aww/comments/humdlf/if_i_fits_i_sits/"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
"<div><video controls muted><source src='https://i.imgur.com/grVh2AG.mp4' type='video/mp4' /></video></div>",
|
|
||||||
post.body,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_link_only_post(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(simple_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
post = Post.objects.get(remote_identifier="hngsj8")
|
|
||||||
|
|
||||||
title = "KeePassXC 2.6.0 released"
|
|
||||||
url = "https://keepassxc.org/blog/2020-07-07-2.6.0-released/"
|
|
||||||
|
|
||||||
self.assertIn(
|
|
||||||
f"<div><a target='_blank' rel='noopener noreferrer' alt='{title}' href='{url}' class='link'>Direct url</a></div>",
|
|
||||||
post.body,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
post.url,
|
|
||||||
"https://www.reddit.com/r/linux/comments/hngsj8/keepassxc_260_released/",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_skip_not_known_post_type(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(unknown_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
self.assertEqual(Post.objects.count(), 0)
|
|
||||||
|
|
||||||
def test_nsfw_not_allowed(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory(reddit_allow_nfsw=False)
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(nsfw_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
|
||||||
|
|
||||||
self.assertEqual(Post.objects.count(), 1)
|
|
||||||
self.assertCountEqual(("hna75r",), posts.keys())
|
|
||||||
|
|
||||||
def test_spoiler_not_allowed(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory(reddit_allow_spoiler=False)
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(spoiler_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
|
||||||
|
|
||||||
self.assertEqual(Post.objects.count(), 1)
|
|
||||||
self.assertCountEqual(("hm0qct",), posts.keys())
|
|
||||||
|
|
||||||
def test_already_seen_not_allowed(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory(reddit_allow_viewed=False)
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(seen_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
|
||||||
|
|
||||||
self.assertEqual(Post.objects.count(), 1)
|
|
||||||
self.assertCountEqual(("hna75r",), posts.keys())
|
|
||||||
|
|
||||||
def test_upvote_minimum(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory(reddit_upvotes_min=100)
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(upvote_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
|
||||||
|
|
||||||
self.assertEqual(Post.objects.count(), 1)
|
|
||||||
self.assertCountEqual(("hna75r",), posts.keys())
|
|
||||||
|
|
||||||
def test_comments_minimum(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory(reddit_comments_min=100)
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(comment_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
|
||||||
|
|
||||||
self.assertEqual(Post.objects.count(), 1)
|
|
||||||
self.assertCountEqual(("hm0qct",), posts.keys())
|
|
||||||
|
|
||||||
def test_downvote_maximum(self):
|
|
||||||
builder = RedditBuilder
|
|
||||||
|
|
||||||
subreddit = SubredditFactory(reddit_downvotes_max=20)
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
with builder(downvote_mock, mock_stream) as builder:
|
|
||||||
builder.build()
|
|
||||||
builder.save()
|
|
||||||
|
|
||||||
posts = {post.remote_identifier: post for post in Post.objects.all()}
|
|
||||||
|
|
||||||
self.assertEqual(Post.objects.count(), 1)
|
|
||||||
self.assertCountEqual(("hm0qct",), posts.keys())
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
# Note that some response data is truncated
|
|
||||||
|
|
||||||
simple_mock = {
|
|
||||||
"data": {
|
|
||||||
"after": "t3_hjywyf",
|
|
||||||
"before": None,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"approved_at_utc": None,
|
|
||||||
"approved_by": None,
|
|
||||||
"archived": False,
|
|
||||||
"author": "AutoModerator",
|
|
||||||
"banned_at_utc": None,
|
|
||||||
"banned_by": None,
|
|
||||||
"category": None,
|
|
||||||
"content_categories": None,
|
|
||||||
"created": 1593605471.0,
|
|
||||||
"created_utc": 1593576671.0,
|
|
||||||
"discussion_type": None,
|
|
||||||
"distinguished": "moderator",
|
|
||||||
"domain": "self.linux",
|
|
||||||
"edited": False,
|
|
||||||
"hidden": False,
|
|
||||||
"id": "hj34ck",
|
|
||||||
"locked": False,
|
|
||||||
"name": "t3_hj34ck",
|
|
||||||
"permalink": "/r/linux/comments/hj34ck/weekly_questions_and_hardware_thread_july_01_2020/",
|
|
||||||
"pinned": False,
|
|
||||||
"selftext": "Welcome to r/linux! If you're "
|
|
||||||
"new to Linux or trying to get "
|
|
||||||
"started this thread is for you. "
|
|
||||||
"Get help here or as always, "
|
|
||||||
"check out r/linuxquestions or "
|
|
||||||
"r/linux4noobs\n"
|
|
||||||
"\n"
|
|
||||||
"This megathread is for all your "
|
|
||||||
"question needs. As we don't "
|
|
||||||
"allow questions on r/linux "
|
|
||||||
"outside of this megathread, "
|
|
||||||
"please consider using "
|
|
||||||
"r/linuxquestions or "
|
|
||||||
"r/linux4noobs for the best "
|
|
||||||
"solution to your problem.\n"
|
|
||||||
"\n"
|
|
||||||
"Ask your hardware requests here "
|
|
||||||
"too or try r/linuxhardware!",
|
|
||||||
"selftext_html": "<!-- SC_OFF "
|
|
||||||
"--><div "
|
|
||||||
'class="md"><p>Welcome '
|
|
||||||
"to <a "
|
|
||||||
'href="/r/linux">r/linux</a>! '
|
|
||||||
"If you&#39;re new to "
|
|
||||||
"Linux or trying to get "
|
|
||||||
"started this thread is for "
|
|
||||||
"you. Get help here or as "
|
|
||||||
"always, check out <a "
|
|
||||||
'href="/r/linuxquestions">r/linuxquestions</a> '
|
|
||||||
"or <a "
|
|
||||||
'href="/r/linux4noobs">r/linux4noobs</a></p>\n'
|
|
||||||
"\n"
|
|
||||||
"<p>This megathread is "
|
|
||||||
"for all your question "
|
|
||||||
"needs. As we don&#39;t "
|
|
||||||
"allow questions on <a "
|
|
||||||
'href="/r/linux">r/linux</a> '
|
|
||||||
"outside of this megathread, "
|
|
||||||
"please consider using <a "
|
|
||||||
'href="/r/linuxquestions">r/linuxquestions</a> '
|
|
||||||
"or <a "
|
|
||||||
'href="/r/linux4noobs">r/linux4noobs</a> '
|
|
||||||
"for the best solution to "
|
|
||||||
"your problem.</p>\n"
|
|
||||||
"\n"
|
|
||||||
"<p>Ask your hardware "
|
|
||||||
"requests here too or try "
|
|
||||||
"<a "
|
|
||||||
'href="/r/linuxhardware">r/linuxhardware</a>!</p>\n'
|
|
||||||
"</div><!-- SC_ON "
|
|
||||||
"-->",
|
|
||||||
"spoiler": False,
|
|
||||||
"stickied": True,
|
|
||||||
"subreddit": "linux",
|
|
||||||
"subreddit_id": "t5_2qh1a",
|
|
||||||
"subreddit_name_prefixed": "r/linux",
|
|
||||||
"title": "Weekly Questions and Hardware " "Thread - July 01, 2020",
|
|
||||||
"url": "https://www.reddit.com/r/linux/comments/hj34ck/weekly_questions_and_hardware_thread_july_01_2020/",
|
|
||||||
"visited": False,
|
|
||||||
},
|
|
||||||
"kind": "t3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"archived": False,
|
|
||||||
"author": "AutoModerator",
|
|
||||||
"banned_at_utc": None,
|
|
||||||
"banned_by": None,
|
|
||||||
"category": None,
|
|
||||||
"created": 1593824903.0,
|
|
||||||
"created_utc": 1593796103.0,
|
|
||||||
"discussion_type": None,
|
|
||||||
"domain": "self.linux",
|
|
||||||
"edited": False,
|
|
||||||
"hidden": False,
|
|
||||||
"id": "hkmu0t",
|
|
||||||
"name": "t3_hkmu0t",
|
|
||||||
"permalink": "/r/linux/comments/hkmu0t/weekend_fluff_linux_in_the_wild_thread_july_03/",
|
|
||||||
"pinned": False,
|
|
||||||
"saved": False,
|
|
||||||
"selftext": "Welcome to the weekend! This "
|
|
||||||
"stickied thread is for you to "
|
|
||||||
"post pictures of your ubuntu "
|
|
||||||
"2006 install disk, slackware "
|
|
||||||
"floppies, on-topic memes or "
|
|
||||||
"more.\n"
|
|
||||||
"\n"
|
|
||||||
"When it's not the weekend, be "
|
|
||||||
"sure to check out "
|
|
||||||
"r/WildLinuxAppears or "
|
|
||||||
"r/linuxmemes!",
|
|
||||||
"selftext_html": "<!-- SC_OFF "
|
|
||||||
"--><div "
|
|
||||||
'class="md"><p>Welcome '
|
|
||||||
"to the weekend! This "
|
|
||||||
"stickied thread is for you "
|
|
||||||
"to post pictures of your "
|
|
||||||
"ubuntu 2006 install disk, "
|
|
||||||
"slackware floppies, "
|
|
||||||
"on-topic memes or "
|
|
||||||
"more.</p>\n"
|
|
||||||
"\n"
|
|
||||||
"<p>When it&#39;s "
|
|
||||||
"not the weekend, be sure to "
|
|
||||||
"check out <a "
|
|
||||||
'href="/r/WildLinuxAppears">r/WildLinuxAppears</a> '
|
|
||||||
"or <a "
|
|
||||||
'href="/r/linuxmemes">r/linuxmemes</a>!</p>\n'
|
|
||||||
"</div><!-- SC_ON "
|
|
||||||
"-->",
|
|
||||||
"spoiler": False,
|
|
||||||
"stickied": True,
|
|
||||||
"subreddit": "linux",
|
|
||||||
"subreddit_id": "t5_2qh1a",
|
|
||||||
"subreddit_name_prefixed": "r/linux",
|
|
||||||
"subreddit_subscribers": 542073,
|
|
||||||
"subreddit_type": "public",
|
|
||||||
"thumbnail": "",
|
|
||||||
"title": "Weekend Fluff / Linux in the Wild "
|
|
||||||
"Thread - July 03, 2020",
|
|
||||||
"url": "https://www.reddit.com/r/linux/comments/hkmu0t/weekend_fluff_linux_in_the_wild_thread_july_03/",
|
|
||||||
"visited": False,
|
|
||||||
},
|
|
||||||
"kind": "t3",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"dist": 27,
|
|
||||||
"modhash": None,
|
|
||||||
},
|
|
||||||
"kind": "Listing",
|
|
||||||
}
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
from unittest.mock import Mock, patch
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.utils.lorem_ipsum import words
|
|
||||||
|
|
||||||
from newsreader.accounts.tests.factories import UserFactory
|
|
||||||
from newsreader.news.collection.exceptions import (
|
|
||||||
StreamDeniedException,
|
|
||||||
StreamException,
|
|
||||||
StreamNotFoundException,
|
|
||||||
StreamParseException,
|
|
||||||
StreamTimeOutException,
|
|
||||||
StreamTooManyException,
|
|
||||||
)
|
|
||||||
from newsreader.news.collection.reddit import RedditClient
|
|
||||||
from newsreader.news.collection.tests.factories import SubredditFactory
|
|
||||||
|
|
||||||
from .mocks import simple_mock
|
|
||||||
|
|
||||||
|
|
||||||
class RedditClientTestCase(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.maxDiff = None
|
|
||||||
|
|
||||||
self.patched_read = patch("newsreader.news.collection.reddit.RedditStream.read")
|
|
||||||
self.mocked_read = self.patched_read.start()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
patch.stopall()
|
|
||||||
|
|
||||||
def test_client_retrieves_single_rules(self):
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
mock_stream = Mock(rule=subreddit)
|
|
||||||
|
|
||||||
self.mocked_read.return_value = (simple_mock, mock_stream)
|
|
||||||
|
|
||||||
with RedditClient([[subreddit]]) as client:
|
|
||||||
for data, stream in client:
|
|
||||||
with self.subTest(data=data, stream=stream):
|
|
||||||
self.assertEquals(data, simple_mock)
|
|
||||||
self.assertEquals(stream, mock_stream)
|
|
||||||
|
|
||||||
self.mocked_read.assert_called_once_with()
|
|
||||||
|
|
||||||
def test_client_catches_stream_exception(self):
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
|
|
||||||
self.mocked_read.side_effect = StreamException(message="Stream exception")
|
|
||||||
|
|
||||||
with RedditClient([[subreddit]]) as client:
|
|
||||||
for data, stream in client:
|
|
||||||
with self.subTest(data=data, stream=stream):
|
|
||||||
self.assertEquals(data, None)
|
|
||||||
self.assertEquals(stream, None)
|
|
||||||
self.assertEquals(stream.rule.error, "Stream exception")
|
|
||||||
self.assertEquals(stream.rule.succeeded, False)
|
|
||||||
|
|
||||||
self.mocked_read.assert_called_once_with()
|
|
||||||
|
|
||||||
def test_client_catches_stream_not_found_exception(self):
|
|
||||||
subreddit = SubredditFactory.create()
|
|
||||||
|
|
||||||
self.mocked_read.side_effect = StreamNotFoundException(
|
|
||||||
message="Stream not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
with RedditClient([[subreddit]]) as client:
|
|
||||||
for data, stream in client:
|
|
||||||
with self.subTest(data=data, stream=stream):
|
|
||||||
self.assertEquals(data, None)
|
|
||||||
self.assertEquals(stream, None)
|
|
||||||
self.assertEquals(stream.rule.error, "Stream not found")
|
|
||||||
self.assertEquals(stream.rule.succeeded, False)
|
|
||||||
|
|
||||||
self.mocked_read.assert_called_once_with()
|
|
||||||
|
|
||||||
@patch("newsreader.news.collection.reddit.RedditTokenTask")
|
|
||||||
def test_client_catches_stream_denied_exception(self, mocked_task):
|
|
||||||
user = UserFactory(
|
|
||||||
reddit_access_token=str(uuid4()), reddit_refresh_token=str(uuid4())
|
|
||||||
)
|
|
||||||
subreddit = SubredditFactory(user=user)
|
|
||||||
|
|
||||||
self.mocked_read.side_effect = StreamDeniedException(message="Token expired")
|
|
||||||
|
|
||||||
with RedditClient([(subreddit,)]) as client:
|
|
||||||
results = [(data, stream) for data, stream in client]
|
|
||||||
|
|
||||||
self.mocked_read.assert_called_once_with()
|
|
||||||
mocked_task.delay.assert_called_once_with(user.pk)
|
|
||||||
|
|
||||||
self.assertEquals(len(results), 0)
|
|
||||||
|
|
||||||
user.refresh_from_db()
|
|
||||||
subreddit.refresh_from_db()
|
|
||||||
|
|
||||||
self.assertEquals(user.reddit_access_token, None)
|
|
||||||
self.assertEquals(subreddit.succeeded, False)
|
|
||||||
self.assertEquals(subreddit.error, "Token expired")
|
|
||||||
|
|
||||||
def test_client_catches_stream_timed_out_exception(self):
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
|
|
||||||
self.mocked_read.side_effect = StreamTimeOutException(
|
|
||||||
message="Stream timed out"
|
|
||||||
)
|
|
||||||
|
|
||||||
with RedditClient([[subreddit]]) as client:
|
|
||||||
for data, stream in client:
|
|
||||||
with self.subTest(data=data, stream=stream):
|
|
||||||
self.assertEquals(data, None)
|
|
||||||
self.assertEquals(stream, None)
|
|
||||||
self.assertEquals(stream.rule.error, "Stream timed out")
|
|
||||||
self.assertEquals(stream.rule.succeeded, False)
|
|
||||||
|
|
||||||
self.mocked_read.assert_called_once_with()
|
|
||||||
|
|
||||||
def test_client_catches_stream_too_many_exception(self):
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
|
|
||||||
self.mocked_read.side_effect = StreamTooManyException
|
|
||||||
|
|
||||||
with RedditClient([[subreddit]]) as client:
|
|
||||||
for data, stream in client:
|
|
||||||
with self.subTest(data=data, stream=stream):
|
|
||||||
self.assertEquals(data, None)
|
|
||||||
self.assertEquals(stream, None)
|
|
||||||
self.assertEquals(stream.rule.error, "Too many requests")
|
|
||||||
self.assertEquals(stream.rule.succeeded, False)
|
|
||||||
|
|
||||||
self.mocked_read.assert_called_once_with()
|
|
||||||
|
|
||||||
def test_client_catches_stream_parse_exception(self):
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
|
|
||||||
self.mocked_read.side_effect = StreamParseException(
|
|
||||||
message="Stream could not be parsed"
|
|
||||||
)
|
|
||||||
|
|
||||||
with RedditClient([[subreddit]]) as client:
|
|
||||||
for data, stream in client:
|
|
||||||
with self.subTest(data=data, stream=stream):
|
|
||||||
self.assertEquals(data, None)
|
|
||||||
self.assertEquals(stream, None)
|
|
||||||
self.assertEquals(stream.rule.error, "Stream could not be parsed")
|
|
||||||
self.assertEquals(stream.rule.succeeded, False)
|
|
||||||
|
|
||||||
self.mocked_read.assert_called_once_with()
|
|
||||||
|
|
||||||
def test_client_catches_long_exception_text(self):
|
|
||||||
subreddit = SubredditFactory()
|
|
||||||
|
|
||||||
self.mocked_read.side_effect = StreamParseException(message=words(1000))
|
|
||||||
|
|
||||||
with RedditClient([[subreddit]]) as client:
|
|
||||||
for data, stream in client:
|
|
||||||
self.assertEquals(data, None)
|
|
||||||
self.assertEquals(stream, None)
|
|
||||||
self.assertEquals(len(stream.rule.error), 1024)
|
|
||||||
self.assertEquals(stream.rule.succeeded, False)
|
|
||||||
|
|
||||||
self.mocked_read.assert_called_once_with()
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,201 +0,0 @@
|
||||||
from datetime import datetime, timezone
|
|
||||||
from unittest.mock import patch
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from newsreader.news.collection.choices import RuleTypeChoices
|
|
||||||
from newsreader.news.collection.exceptions import (
|
|
||||||
StreamDeniedException,
|
|
||||||
StreamForbiddenException,
|
|
||||||
StreamNotFoundException,
|
|
||||||
StreamTimeOutException,
|
|
||||||
)
|
|
||||||
from newsreader.news.collection.reddit import RedditCollector
|
|
||||||
from newsreader.news.collection.tests.factories import SubredditFactory
|
|
||||||
from newsreader.news.collection.tests.reddit.collector.mocks import (
|
|
||||||
empty_mock,
|
|
||||||
simple_mock_1,
|
|
||||||
simple_mock_2,
|
|
||||||
)
|
|
||||||
from newsreader.news.core.models import Post
|
|
||||||
|
|
||||||
|
|
||||||
class RedditCollectorTestCase(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.maxDiff = None
|
|
||||||
|
|
||||||
self.patched_get = patch("newsreader.news.collection.reddit.fetch")
|
|
||||||
self.mocked_fetch = self.patched_get.start()
|
|
||||||
|
|
||||||
self.patched_parse = patch(
|
|
||||||
"newsreader.news.collection.reddit.RedditStream.parse"
|
|
||||||
)
|
|
||||||
self.mocked_parse = self.patched_parse.start()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
patch.stopall()
|
|
||||||
|
|
||||||
def test_simple_batch(self):
|
|
||||||
self.mocked_parse.side_effect = (simple_mock_1, simple_mock_2)
|
|
||||||
|
|
||||||
rules = (
|
|
||||||
(subreddit,)
|
|
||||||
for subreddit in SubredditFactory.create_batch(
|
|
||||||
user__reddit_access_token=str(uuid4()),
|
|
||||||
user__reddit_refresh_token=str(uuid4()),
|
|
||||||
enabled=True,
|
|
||||||
size=2,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
collector = RedditCollector()
|
|
||||||
collector.collect(rules=rules)
|
|
||||||
|
|
||||||
self.assertCountEqual(
|
|
||||||
Post.objects.values_list("remote_identifier", flat=True),
|
|
||||||
(
|
|
||||||
"hm6byg",
|
|
||||||
"hpkhgj",
|
|
||||||
"hph00n",
|
|
||||||
"hp9mlw",
|
|
||||||
"hpjn8x",
|
|
||||||
"gdfaip",
|
|
||||||
"hmd2ez",
|
|
||||||
"hpr28u",
|
|
||||||
"hpps6f",
|
|
||||||
"hp7uqe",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
for subreddit in rules:
|
|
||||||
with self.subTest(subreddit=subreddit):
|
|
||||||
self.assertEqual(subreddit.succeeded, True)
|
|
||||||
self.assertEqual(subreddit.last_run, datetime.now(tz=timezone.utc))
|
|
||||||
self.assertEqual(subreddit.error, None)
|
|
||||||
|
|
||||||
post = Post.objects.get(
|
|
||||||
remote_identifier="hph00n", rule__type=RuleTypeChoices.subreddit
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
post.publication_date,
|
|
||||||
datetime(2020, 7, 11, 22, 23, 24, tzinfo=timezone.utc),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(post.author, "HannahB888")
|
|
||||||
self.assertEqual(post.title, "Drake Interplanetary Smartkey thing that I made!")
|
|
||||||
self.assertEqual(
|
|
||||||
post.url,
|
|
||||||
"https://www.reddit.com/r/starcitizen/comments/hph00n/drake_interplanetary_smartkey_thing_that_i_made/",
|
|
||||||
)
|
|
||||||
|
|
||||||
post = Post.objects.get(
|
|
||||||
remote_identifier="hpr28u", rule__type=RuleTypeChoices.subreddit
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
post.publication_date,
|
|
||||||
datetime(2020, 7, 12, 10, 29, 10, tzinfo=timezone.utc),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(post.author, "Sebaron")
|
|
||||||
self.assertEqual(
|
|
||||||
post.title,
|
|
||||||
"I am a medical student, and I recently programmed an open-source eye-tracker for brain research",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
post.url,
|
|
||||||
"https://www.reddit.com/r/Python/comments/hpr28u/i_am_a_medical_student_and_i_recently_programmed/",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_empty_batch(self):
|
|
||||||
self.mocked_parse.side_effect = (empty_mock, empty_mock)
|
|
||||||
|
|
||||||
rules = (
|
|
||||||
(subreddit,)
|
|
||||||
for subreddit in SubredditFactory.create_batch(
|
|
||||||
user__reddit_access_token=str(uuid4()),
|
|
||||||
user__reddit_refresh_token=str(uuid4()),
|
|
||||||
enabled=True,
|
|
||||||
size=2,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
collector = RedditCollector()
|
|
||||||
collector.collect(rules=rules)
|
|
||||||
|
|
||||||
self.assertEqual(Post.objects.count(), 0)
|
|
||||||
|
|
||||||
for subreddit in rules:
|
|
||||||
with self.subTest(subreddit=subreddit):
|
|
||||||
self.assertEqual(subreddit.succeeded, True)
|
|
||||||
self.assertEqual(subreddit.last_run, datetime.now(tz=timezone.utc))
|
|
||||||
self.assertEqual(subreddit.error, None)
|
|
||||||
|
|
||||||
def test_not_found(self):
|
|
||||||
self.mocked_fetch.side_effect = StreamNotFoundException
|
|
||||||
|
|
||||||
rule = SubredditFactory(
|
|
||||||
user__reddit_access_token=str(uuid4()),
|
|
||||||
user__reddit_refresh_token=str(uuid4()),
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
collector = RedditCollector()
|
|
||||||
collector.collect(rules=((rule,),))
|
|
||||||
|
|
||||||
self.assertEqual(Post.objects.count(), 0)
|
|
||||||
self.assertEqual(rule.succeeded, False)
|
|
||||||
self.assertEqual(rule.error, "Stream not found")
|
|
||||||
|
|
||||||
@patch("newsreader.news.collection.reddit.RedditTokenTask")
|
|
||||||
def test_denied(self, mocked_task):
|
|
||||||
self.mocked_fetch.side_effect = StreamDeniedException
|
|
||||||
|
|
||||||
rule = SubredditFactory(
|
|
||||||
user__reddit_access_token=str(uuid4()),
|
|
||||||
user__reddit_refresh_token=str(uuid4()),
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
collector = RedditCollector()
|
|
||||||
collector.collect(rules=((rule,),))
|
|
||||||
|
|
||||||
self.assertEqual(Post.objects.count(), 0)
|
|
||||||
self.assertEqual(rule.succeeded, False)
|
|
||||||
self.assertEqual(rule.error, "Stream does not have sufficient permissions")
|
|
||||||
|
|
||||||
mocked_task.delay.assert_called_once_with(rule.user.pk)
|
|
||||||
|
|
||||||
def test_forbidden(self):
|
|
||||||
self.mocked_fetch.side_effect = StreamForbiddenException
|
|
||||||
|
|
||||||
rule = SubredditFactory(
|
|
||||||
user__reddit_access_token=str(uuid4()),
|
|
||||||
user__reddit_refresh_token=str(uuid4()),
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
collector = RedditCollector()
|
|
||||||
collector.collect(rules=((rule,),))
|
|
||||||
|
|
||||||
self.assertEqual(Post.objects.count(), 0)
|
|
||||||
self.assertEqual(rule.succeeded, False)
|
|
||||||
self.assertEqual(rule.error, "Stream forbidden")
|
|
||||||
|
|
||||||
def test_timed_out(self):
|
|
||||||
self.mocked_fetch.side_effect = StreamTimeOutException
|
|
||||||
|
|
||||||
rule = SubredditFactory(
|
|
||||||
user__reddit_access_token=str(uuid4()),
|
|
||||||
user__reddit_refresh_token=str(uuid4()),
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
collector = RedditCollector()
|
|
||||||
collector.collect(rules=((rule,),))
|
|
||||||
|
|
||||||
self.assertEqual(Post.objects.count(), 0)
|
|
||||||
self.assertEqual(rule.succeeded, False)
|
|
||||||
self.assertEqual(rule.error, "Stream timed out")
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,144 +0,0 @@
|
||||||
from json.decoder import JSONDecodeError
|
|
||||||
from unittest.mock import patch
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from newsreader.accounts.tests.factories import UserFactory
|
|
||||||
from newsreader.news.collection.exceptions import (
|
|
||||||
StreamDeniedException,
|
|
||||||
StreamException,
|
|
||||||
StreamForbiddenException,
|
|
||||||
StreamNotFoundException,
|
|
||||||
StreamParseException,
|
|
||||||
StreamTimeOutException,
|
|
||||||
)
|
|
||||||
from newsreader.news.collection.reddit import RedditStream
|
|
||||||
from newsreader.news.collection.tests.factories import SubredditFactory
|
|
||||||
from newsreader.news.collection.tests.reddit.stream.mocks import simple_mock
|
|
||||||
|
|
||||||
|
|
||||||
class RedditStreamTestCase(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.maxDiff = None
|
|
||||||
|
|
||||||
self.patched_fetch = patch("newsreader.news.collection.reddit.fetch")
|
|
||||||
self.mocked_fetch = self.patched_fetch.start()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
patch.stopall()
|
|
||||||
|
|
||||||
def test_simple_stream(self):
|
|
||||||
self.mocked_fetch.return_value.json.return_value = simple_mock
|
|
||||||
|
|
||||||
access_token = str(uuid4())
|
|
||||||
user = UserFactory(reddit_access_token=access_token)
|
|
||||||
|
|
||||||
subreddit = SubredditFactory(user=user)
|
|
||||||
stream = RedditStream(subreddit)
|
|
||||||
|
|
||||||
data, stream = stream.read()
|
|
||||||
|
|
||||||
self.assertEquals(data, simple_mock)
|
|
||||||
self.assertEquals(stream, stream)
|
|
||||||
self.mocked_fetch.assert_called_once_with(
|
|
||||||
subreddit.url, headers={"Authorization": f"bearer {access_token}"}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_stream_raises_exception(self):
|
|
||||||
self.mocked_fetch.side_effect = StreamException
|
|
||||||
|
|
||||||
access_token = str(uuid4())
|
|
||||||
user = UserFactory(reddit_access_token=access_token)
|
|
||||||
|
|
||||||
subreddit = SubredditFactory(user=user)
|
|
||||||
stream = RedditStream(subreddit)
|
|
||||||
|
|
||||||
with self.assertRaises(StreamException):
|
|
||||||
stream.read()
|
|
||||||
|
|
||||||
self.mocked_fetch.assert_called_once_with(
|
|
||||||
subreddit.url, headers={"Authorization": f"bearer {access_token}"}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_stream_raises_denied_exception(self):
|
|
||||||
self.mocked_fetch.side_effect = StreamDeniedException
|
|
||||||
|
|
||||||
access_token = str(uuid4())
|
|
||||||
user = UserFactory(reddit_access_token=access_token)
|
|
||||||
|
|
||||||
subreddit = SubredditFactory(user=user)
|
|
||||||
stream = RedditStream(subreddit)
|
|
||||||
|
|
||||||
with self.assertRaises(StreamDeniedException):
|
|
||||||
stream.read()
|
|
||||||
|
|
||||||
self.mocked_fetch.assert_called_once_with(
|
|
||||||
subreddit.url, headers={"Authorization": f"bearer {access_token}"}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_stream_raises_not_found_exception(self):
|
|
||||||
self.mocked_fetch.side_effect = StreamNotFoundException
|
|
||||||
|
|
||||||
access_token = str(uuid4())
|
|
||||||
user = UserFactory(reddit_access_token=access_token)
|
|
||||||
|
|
||||||
subreddit = SubredditFactory(user=user)
|
|
||||||
stream = RedditStream(subreddit)
|
|
||||||
|
|
||||||
with self.assertRaises(StreamNotFoundException):
|
|
||||||
stream.read()
|
|
||||||
|
|
||||||
self.mocked_fetch.assert_called_once_with(
|
|
||||||
subreddit.url, headers={"Authorization": f"bearer {access_token}"}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_stream_raises_time_out_exception(self):
|
|
||||||
self.mocked_fetch.side_effect = StreamTimeOutException
|
|
||||||
|
|
||||||
access_token = str(uuid4())
|
|
||||||
user = UserFactory(reddit_access_token=access_token)
|
|
||||||
|
|
||||||
subreddit = SubredditFactory(user=user)
|
|
||||||
stream = RedditStream(subreddit)
|
|
||||||
|
|
||||||
with self.assertRaises(StreamTimeOutException):
|
|
||||||
stream.read()
|
|
||||||
|
|
||||||
self.mocked_fetch.assert_called_once_with(
|
|
||||||
subreddit.url, headers={"Authorization": f"bearer {access_token}"}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_stream_raises_forbidden_exception(self):
|
|
||||||
self.mocked_fetch.side_effect = StreamForbiddenException
|
|
||||||
|
|
||||||
access_token = str(uuid4())
|
|
||||||
user = UserFactory(reddit_access_token=access_token)
|
|
||||||
|
|
||||||
subreddit = SubredditFactory(user=user)
|
|
||||||
stream = RedditStream(subreddit)
|
|
||||||
|
|
||||||
with self.assertRaises(StreamForbiddenException):
|
|
||||||
stream.read()
|
|
||||||
|
|
||||||
self.mocked_fetch.assert_called_once_with(
|
|
||||||
subreddit.url, headers={"Authorization": f"bearer {access_token}"}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_stream_raises_parse_exception(self):
|
|
||||||
self.mocked_fetch.return_value.json.side_effect = JSONDecodeError(
|
|
||||||
"No json found", "{}", 5
|
|
||||||
)
|
|
||||||
|
|
||||||
access_token = str(uuid4())
|
|
||||||
user = UserFactory(reddit_access_token=access_token)
|
|
||||||
|
|
||||||
subreddit = SubredditFactory(user=user)
|
|
||||||
stream = RedditStream(subreddit)
|
|
||||||
|
|
||||||
with self.assertRaises(StreamParseException):
|
|
||||||
stream.read()
|
|
||||||
|
|
||||||
self.mocked_fetch.assert_called_once_with(
|
|
||||||
subreddit.url, headers={"Authorization": f"bearer {access_token}"}
|
|
||||||
)
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from freezegun import freeze_time
|
|
||||||
|
|
||||||
from newsreader.accounts.tests.factories import UserFactory
|
|
||||||
from newsreader.news.collection.choices import RuleTypeChoices
|
|
||||||
from newsreader.news.collection.reddit import RedditScheduler
|
|
||||||
from newsreader.news.collection.tests.factories import CollectionRuleFactory
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2019-10-30 12:30:00")
|
|
||||||
class RedditSchedulerTestCase(TestCase):
|
|
||||||
def test_simple(self):
|
|
||||||
user_1 = UserFactory(
|
|
||||||
reddit_access_token="1231414", reddit_refresh_token="5235262"
|
|
||||||
)
|
|
||||||
user_2 = UserFactory(
|
|
||||||
reddit_access_token="3414777", reddit_refresh_token="3423425"
|
|
||||||
)
|
|
||||||
|
|
||||||
user_1_rules = [
|
|
||||||
CollectionRuleFactory(
|
|
||||||
user=user_1,
|
|
||||||
type=RuleTypeChoices.subreddit,
|
|
||||||
last_run=timezone.now() - timedelta(days=4),
|
|
||||||
enabled=True,
|
|
||||||
),
|
|
||||||
CollectionRuleFactory(
|
|
||||||
user=user_1,
|
|
||||||
type=RuleTypeChoices.subreddit,
|
|
||||||
last_run=timezone.now() - timedelta(days=3),
|
|
||||||
enabled=True,
|
|
||||||
),
|
|
||||||
CollectionRuleFactory(
|
|
||||||
user=user_1,
|
|
||||||
type=RuleTypeChoices.subreddit,
|
|
||||||
last_run=timezone.now() - timedelta(days=2),
|
|
||||||
enabled=True,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
user_2_rules = [
|
|
||||||
CollectionRuleFactory(
|
|
||||||
user=user_2,
|
|
||||||
type=RuleTypeChoices.subreddit,
|
|
||||||
last_run=timezone.now() - timedelta(days=4),
|
|
||||||
enabled=True,
|
|
||||||
),
|
|
||||||
CollectionRuleFactory(
|
|
||||||
user=user_2,
|
|
||||||
type=RuleTypeChoices.subreddit,
|
|
||||||
last_run=timezone.now() - timedelta(days=3),
|
|
||||||
enabled=True,
|
|
||||||
),
|
|
||||||
CollectionRuleFactory(
|
|
||||||
user=user_2,
|
|
||||||
type=RuleTypeChoices.subreddit,
|
|
||||||
last_run=timezone.now() - timedelta(days=2),
|
|
||||||
enabled=True,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
scheduler = RedditScheduler()
|
|
||||||
scheduled_subreddits = scheduler.get_scheduled_rules()
|
|
||||||
|
|
||||||
user_1_batch = [subreddit.pk for subreddit in scheduled_subreddits[0]]
|
|
||||||
|
|
||||||
self.assertIn(user_1_rules[0].pk, user_1_batch)
|
|
||||||
self.assertIn(user_1_rules[1].pk, user_1_batch)
|
|
||||||
self.assertIn(user_1_rules[2].pk, user_1_batch)
|
|
||||||
|
|
||||||
user_2_batch = [subreddit.pk for subreddit in scheduled_subreddits[1]]
|
|
||||||
|
|
||||||
self.assertIn(user_2_rules[0].pk, user_2_batch)
|
|
||||||
self.assertIn(user_2_rules[1].pk, user_2_batch)
|
|
||||||
self.assertIn(user_2_rules[2].pk, user_2_batch)
|
|
||||||
|
|
||||||
def test_max_amount(self):
|
|
||||||
users = UserFactory.create_batch(
|
|
||||||
reddit_access_token="1231414", reddit_refresh_token="5235262", size=5
|
|
||||||
)
|
|
||||||
|
|
||||||
nested_rules = [
|
|
||||||
CollectionRuleFactory.create_batch(
|
|
||||||
name=f"rule-{index}",
|
|
||||||
type=RuleTypeChoices.subreddit,
|
|
||||||
last_run=timezone.now() - timedelta(seconds=index),
|
|
||||||
enabled=True,
|
|
||||||
user=user,
|
|
||||||
size=15,
|
|
||||||
)
|
|
||||||
for index, user in enumerate(users)
|
|
||||||
]
|
|
||||||
|
|
||||||
rules = [rule for rule_list in nested_rules for rule in rule_list]
|
|
||||||
|
|
||||||
scheduler = RedditScheduler()
|
|
||||||
scheduled_subreddits = [
|
|
||||||
subreddit.pk
|
|
||||||
for batch in scheduler.get_scheduled_rules()
|
|
||||||
for subreddit in batch
|
|
||||||
]
|
|
||||||
|
|
||||||
for rule in rules[16:76]:
|
|
||||||
with self.subTest(rule=rule):
|
|
||||||
self.assertIn(rule.pk, scheduled_subreddits)
|
|
||||||
|
|
||||||
for rule in rules[0:15]:
|
|
||||||
with self.subTest(rule=rule):
|
|
||||||
self.assertNotIn(rule.pk, scheduled_subreddits)
|
|
||||||
|
|
||||||
def test_max_user_amount(self):
|
|
||||||
user = UserFactory(
|
|
||||||
reddit_access_token="1231414", reddit_refresh_token="5235262"
|
|
||||||
)
|
|
||||||
|
|
||||||
rules = [
|
|
||||||
CollectionRuleFactory(
|
|
||||||
name=f"rule-{index}",
|
|
||||||
type=RuleTypeChoices.subreddit,
|
|
||||||
last_run=timezone.now() - timedelta(seconds=index),
|
|
||||||
enabled=True,
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
for index in range(1, 17)
|
|
||||||
]
|
|
||||||
|
|
||||||
scheduler = RedditScheduler()
|
|
||||||
scheduled_subreddits = [
|
|
||||||
subreddit.pk
|
|
||||||
for batch in scheduler.get_scheduled_rules()
|
|
||||||
for subreddit in batch
|
|
||||||
]
|
|
||||||
|
|
||||||
for rule in rules[1:16]:
|
|
||||||
with self.subTest(rule=rule):
|
|
||||||
self.assertIn(rule.pk, scheduled_subreddits)
|
|
||||||
|
|
||||||
self.assertNotIn(rules[0].pk, scheduled_subreddits)
|
|
||||||
|
|
@ -88,17 +88,3 @@ class FeedUpdateViewTestCase(CollectionRuleViewTestCase, TestCase):
|
||||||
self.rule.refresh_from_db()
|
self.rule.refresh_from_db()
|
||||||
|
|
||||||
self.assertEqual(self.rule.category, None)
|
self.assertEqual(self.rule.category, None)
|
||||||
|
|
||||||
def test_rules_only(self):
|
|
||||||
rule = FeedFactory(
|
|
||||||
name="Python",
|
|
||||||
url="https://reddit.com/r/python",
|
|
||||||
user=self.user,
|
|
||||||
category=self.category,
|
|
||||||
type=RuleTypeChoices.subreddit,
|
|
||||||
)
|
|
||||||
url = reverse("news:collection:feed-update", kwargs={"pk": rule.pk})
|
|
||||||
|
|
||||||
response = self.client.get(url)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 404)
|
|
||||||
|
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
from django.test import TestCase
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
|
|
||||||
from newsreader.news.collection.choices import RuleTypeChoices
|
|
||||||
from newsreader.news.collection.models import CollectionRule
|
|
||||||
from newsreader.news.collection.reddit import REDDIT_API_URL, REDDIT_URL
|
|
||||||
from newsreader.news.collection.tests.factories import SubredditFactory
|
|
||||||
from newsreader.news.collection.tests.views.base import CollectionRuleViewTestCase
|
|
||||||
from newsreader.news.core.tests.factories import CategoryFactory
|
|
||||||
|
|
||||||
|
|
||||||
class SubRedditCreateViewTestCase(CollectionRuleViewTestCase, TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
self.form_data = {
|
|
||||||
"name": "new rule",
|
|
||||||
"url": f"{REDDIT_API_URL}/r/aww",
|
|
||||||
"category": str(self.category.pk),
|
|
||||||
"reddit_allow_nfsw": False,
|
|
||||||
"reddit_allow_spoiler": False,
|
|
||||||
"reddit_allow_viewed": True,
|
|
||||||
"reddit_upvotes_min": 0,
|
|
||||||
"reddit_comments_min": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
self.url = reverse("news:collection:subreddit-create")
|
|
||||||
|
|
||||||
def test_creation(self):
|
|
||||||
response = self.client.post(self.url, self.form_data)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
|
|
||||||
rule = CollectionRule.objects.get(name="new rule")
|
|
||||||
|
|
||||||
self.assertEqual(rule.type, RuleTypeChoices.subreddit)
|
|
||||||
self.assertEqual(rule.url, f"{REDDIT_API_URL}/r/aww")
|
|
||||||
self.assertEqual(rule.favicon, None)
|
|
||||||
self.assertEqual(rule.category.pk, self.category.pk)
|
|
||||||
self.assertEqual(rule.user.pk, self.user.pk)
|
|
||||||
|
|
||||||
def test_regular_reddit_url(self):
|
|
||||||
self.form_data.update(url=f"{REDDIT_URL}/r/aww")
|
|
||||||
|
|
||||||
response = self.client.post(self.url, self.form_data)
|
|
||||||
|
|
||||||
self.assertContains(response, _("This does not look like an Reddit API URL"))
|
|
||||||
|
|
||||||
|
|
||||||
class SubRedditUpdateViewTestCase(CollectionRuleViewTestCase, TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
self.rule = SubredditFactory(
|
|
||||||
name="Python",
|
|
||||||
url=f"{REDDIT_API_URL}/r/python.json",
|
|
||||||
user=self.user,
|
|
||||||
category=self.category,
|
|
||||||
type=RuleTypeChoices.subreddit,
|
|
||||||
)
|
|
||||||
self.url = reverse(
|
|
||||||
"news:collection:subreddit-update", kwargs={"pk": self.rule.pk}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.form_data = {
|
|
||||||
"name": self.rule.name,
|
|
||||||
"url": self.rule.url,
|
|
||||||
"category": str(self.category.pk),
|
|
||||||
"reddit_allow_nfsw": False,
|
|
||||||
"reddit_allow_spoiler": False,
|
|
||||||
"reddit_allow_viewed": True,
|
|
||||||
"reddit_upvotes_min": 0,
|
|
||||||
"reddit_comments_min": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_name_change(self):
|
|
||||||
self.form_data.update(name="Python 2")
|
|
||||||
|
|
||||||
response = self.client.post(self.url, self.form_data)
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
|
|
||||||
self.rule.refresh_from_db()
|
|
||||||
|
|
||||||
self.assertEqual(self.rule.name, "Python 2")
|
|
||||||
|
|
||||||
def test_category_change(self):
|
|
||||||
new_category = CategoryFactory(user=self.user)
|
|
||||||
|
|
||||||
self.form_data.update(category=new_category.pk)
|
|
||||||
|
|
||||||
response = self.client.post(self.url, self.form_data)
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
|
|
||||||
self.rule.refresh_from_db()
|
|
||||||
|
|
||||||
self.assertEqual(self.rule.category.pk, new_category.pk)
|
|
||||||
|
|
||||||
def test_subreddit_rules_only(self):
|
|
||||||
rule = SubredditFactory(
|
|
||||||
name="Fake subreddit",
|
|
||||||
url="https://leddit.com/r/python",
|
|
||||||
user=self.user,
|
|
||||||
category=self.category,
|
|
||||||
type=RuleTypeChoices.feed,
|
|
||||||
)
|
|
||||||
url = reverse("news:collection:subreddit-update", kwargs={"pk": rule.pk})
|
|
||||||
|
|
||||||
response = self.client.get(url)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 404)
|
|
||||||
|
|
||||||
def test_url_change(self):
|
|
||||||
self.form_data.update(name="aww", url=f"{REDDIT_API_URL}/r/aww")
|
|
||||||
|
|
||||||
response = self.client.post(self.url, self.form_data)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
|
|
||||||
rule = CollectionRule.objects.get(name="aww")
|
|
||||||
|
|
||||||
self.assertEqual(rule.type, RuleTypeChoices.subreddit)
|
|
||||||
self.assertEqual(rule.url, f"{REDDIT_API_URL}/r/aww")
|
|
||||||
self.assertEqual(rule.favicon, None)
|
|
||||||
self.assertEqual(rule.category.pk, self.category.pk)
|
|
||||||
self.assertEqual(rule.user.pk, self.user.pk)
|
|
||||||
|
|
||||||
def test_regular_reddit_url(self):
|
|
||||||
self.form_data.update(url=f"{REDDIT_URL}/r/aww")
|
|
||||||
|
|
||||||
response = self.client.post(self.url, self.form_data)
|
|
||||||
|
|
||||||
self.assertContains(response, _("This does not look like an Reddit API URL"))
|
|
||||||
|
|
@ -14,8 +14,6 @@ from newsreader.news.collection.views import (
|
||||||
FeedCreateView,
|
FeedCreateView,
|
||||||
FeedUpdateView,
|
FeedUpdateView,
|
||||||
OPMLImportView,
|
OPMLImportView,
|
||||||
SubRedditCreateView,
|
|
||||||
SubRedditUpdateView,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -49,15 +47,4 @@ urlpatterns = [
|
||||||
name="rules-disable",
|
name="rules-disable",
|
||||||
),
|
),
|
||||||
path("rules/import/", login_required(OPMLImportView.as_view()), name="import"),
|
path("rules/import/", login_required(OPMLImportView.as_view()), name="import"),
|
||||||
# Reddit
|
|
||||||
path(
|
|
||||||
"subreddits/create/",
|
|
||||||
login_required(SubRedditCreateView.as_view()),
|
|
||||||
name="subreddit-create",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"subreddits/<int:pk>/",
|
|
||||||
login_required(SubRedditUpdateView.as_view()),
|
|
||||||
name="subreddit-update",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,6 @@ def truncate_text(cls, field_name, value):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
if len(value) > max_length:
|
if len(value) > max_length:
|
||||||
return f"{value[:max_length - 1]}…"
|
return f"{value[: max_length - 1]}…"
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,6 @@ from newsreader.news.collection.views.feed import (
|
||||||
FeedUpdateView,
|
FeedUpdateView,
|
||||||
OPMLImportView,
|
OPMLImportView,
|
||||||
)
|
)
|
||||||
from newsreader.news.collection.views.reddit import (
|
|
||||||
SubRedditCreateView,
|
|
||||||
SubRedditUpdateView,
|
|
||||||
)
|
|
||||||
from newsreader.news.collection.views.rules import (
|
from newsreader.news.collection.views.rules import (
|
||||||
CollectionRuleBulkDeleteView,
|
CollectionRuleBulkDeleteView,
|
||||||
CollectionRuleBulkDisableView,
|
CollectionRuleBulkDisableView,
|
||||||
|
|
@ -19,8 +15,6 @@ __all__ = [
|
||||||
"FeedCreateView",
|
"FeedCreateView",
|
||||||
"FeedUpdateView",
|
"FeedUpdateView",
|
||||||
"OPMLImportView",
|
"OPMLImportView",
|
||||||
"SubRedditCreateView",
|
|
||||||
"SubRedditUpdateView",
|
|
||||||
"CollectionRuleBulkDeleteView",
|
"CollectionRuleBulkDeleteView",
|
||||||
"CollectionRuleBulkDisableView",
|
"CollectionRuleBulkDisableView",
|
||||||
"CollectionRuleBulkEnableView",
|
"CollectionRuleBulkEnableView",
|
||||||
|
|
|
||||||
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