Compare commits

..

No commits in common. "main" and "0.3.4" have entirely different histories.
main ... 0.3.4

349 changed files with 33608 additions and 15840 deletions

11
.babelrc Normal file
View file

@ -0,0 +1,11 @@
{
"presets": ["@babel/preset-env"],
"plugins": [
"@babel/plugin-transform-runtime",
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-transform-react-jsx",
"@babel/plugin-syntax-function-bind",
"@babel/plugin-proposal-function-bind",
["@babel/plugin-proposal-class-properties", {loose: true}],
]
}

16
.coveragerc Normal file
View file

@ -0,0 +1,16 @@
[run]
source = ./src/newsreader/
omit =
**/tests/**
**/migrations/**
**/conf/**
**/apps.py
**/admin.py
**/tests.py
**/urls.py
**/wsgi.py
**/celery.py
**/__init__.py
[html]
directory = coverage

View file

@ -1,25 +0,0 @@
# https://editorconfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
trim_trailing_whitespace = true
[*.py]
indent_style = space
indent_size = 4
[*.{yaml,yml,toml,md}]
indent_style = space
indent_size = 2
[Dockerfile*]
indent_style = space
indent_size = 4
[*.json]
indent_style = space
indent_size = 2

3
.gitignore vendored
View file

@ -35,7 +35,6 @@ eggs/
lib/
!src/newsreader/scss/lib
!src/newsreader/js/lib
lib64/
parts/
@ -115,7 +114,7 @@ celerybeat-schedule
*.sage.py
# Environments
*.env
.env
.venv
env/
venv/

30
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,30 @@
stages:
- build
- test
- lint
- release
- deploy
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
DJANGO_SETTINGS_MODULE: "newsreader.conf.gitlab"
POSTGRES_HOST: "$POSTGRES_HOST"
POSTGRES_DB: "$POSTGRES_NAME"
POSTGRES_NAME: "$POSTGRES_NAME"
POSTGRES_USER: "$POSTGRES_USER"
POSTGRES_PASSWORD: "$POSTGRES_PASSWORD"
cache:
key: "$CI_COMMIT_REF_SLUG"
paths:
- .venv/
- .cache/pip
- .cache/poetry
- node_modules/
include:
- local: '/gitlab-ci/build.yml'
- local: '/gitlab-ci/test.yml'
- local: '/gitlab-ci/lint.yml'
- local: '/gitlab-ci/release.yml'
- local: '/gitlab-ci/deploy.yml'

12
.isort.cfg Normal file
View file

@ -0,0 +1,12 @@
[settings]
include_trailing_comma = true
line_length = 88
multi_line_output = 3
skip = env/, venv/
default_section = THIRDPARTY
known_first_party = newsreader
known_django = django
sections = FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
lines_between_types=1
lines_after_imports=2
lines_between_types=1

1
.nvmrc
View file

@ -1 +0,0 @@
lts/*

10
.prettierrc.json Normal file
View file

@ -0,0 +1,10 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 90,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "avoid"
}

View file

@ -1,10 +0,0 @@
when:
- event: push
- event: pull_request
- event: manual
steps:
- image: node:lts-alpine
commands:
- npm install
- npm run build:prod

View file

@ -1,18 +0,0 @@
when:
- event: push
- event: pull_request
- event: manual
steps:
- name: python linting
image: ghcr.io/astral-sh/uv:python3.11-alpine
commands:
- uv sync --group ci
- uv run --no-sync -- ruff check src/
- uv run --no-sync -- ruff format --check src/
- name: javascript linting
image: node:lts-alpine
commands:
- npm ci
- npm run lint

View file

@ -1,37 +0,0 @@
when:
- event: push
- event: pull_request
- event: manual
services:
- name: postgres
image: postgres:15
environment:
POSTGRES_NAME: &db-name newsreader
POSTGRES_USER: &db-user newsreader
POSTGRES_PASSWORD: &db-password sekrit
- name: memcached
image: memcached:1.5.22
steps:
- name: python tests
image: ghcr.io/astral-sh/uv:python3.11-alpine
environment:
DJANGO_SETTINGS_MODULE: "newsreader.conf.ci"
DJANGO_SECRET_KEY: sekrit
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: *db-name
POSTGRES_USER: *db-user
POSTGRES_PASSWORD: *db-password
commands:
- pip install uv
- uv sync --group ci
- uv run --no-sync -- coverage run ./src/manage.py test newsreader
- uv run --no-sync -- coverage report --show-missing
- name: javascript tests
image: node:lts-alpine
commands:
- npm ci
- npm test

View file

@ -1,149 +0,0 @@
# Changelog
## 0.5.3
- Apply query optimizations for retrieving posts
## 0.5.2
- Add missing `VERSION` environment variable
## 0.5.1
- Use line-through styling for read posts
- Use full height for post layout
## 0.5.0
- Upgrade python to 3.11
- Upgrade django to 4.2
- Migrate from pip-tools to uv
- Migrate from black to ruff for formatting
- Upgrade webpack to 5.9 (with various tooling)
- Styling refactor
- Mobile/tablet layout added
## 0.4.4
- Sort posts before storing in redux store
## 0.4.3
- Use `IntersectionObserver` to paginate
## 0.4.2
- Set `SECURE_PROXY_SSL_HEADER` setting for production
## 0.4.1
- Add missing env variables
## 0.4.0
- Add Makefile & use `pip-tools` to generate dependencies
- Add `pyproject.toml`
- Update dependencies
- Update docker-compose setup
- Default to `newsreader.conf.docker` settings module
- Add scroll to top/bottom buttons
## 0.3.13.8
- Update dependencies
- Fix csrf_token's not rendering
## 0.3.13.7
- Check for Twitter error codes in response
## 0.3.13.6
- Try to load sentry by default for all environments
## 0.3.13.5
- Set response keyword argument
## 0.3.13.4
- Fix import error
## 0.3.13.3
- Use sentry's set_extra to provide extra debug variables
## 0.3.13.2
- Update sentry-sdk
## 0.3.13.1
- Fix mutual exclusive exception for email settings
- Temporarly set exception level for StreamDeniedException exceptions
## 0.3.13
- Update django to 3.2
- Notify users of expired credentials
## 0.3.12.1
- Add missing background-color
## 0.3.12
- Update light theme
- Sticky navbar
- Sticky post modal header
## 0.3.11
- Add saved posts section
- Bump django version
## 0.3.10
- Add custom color for confirm buttons
- Update font sizes
## 0.3.9
- Cursor based pagination
- Updated django version
## 0.3.8
- Update light / dark theme
- Replace css.gg with fontawesome
- Update deploy job
## 0.3.7
- Add a dark theme
- Update object representations
- Move sentry to optional dependency
- Add CHANGELOG.md
## 0.3.6.3
- Update deploy job
## 0.3.6.2
- Use warning logging level for BuilderSkippedException's
- Change working directory before running ansible
## 0.3.6.1
- Install ansible required roles
## 0.3.6
- Update deploy job
- Add user manageable reddit filters
## 0.3.5
- Show timezone next to post datetimes
- Take read status in consideration when sorting posts

View file

@ -1,84 +0,0 @@
# stage 1
FROM python:3.11-alpine AS backend
ARG USER_ID=1000
ARG GROUP_ID=1000
ARG UV_LINK_MODE=copy
RUN apk update \
&& apk add --no-cache \
vim \
curl \
gettext
RUN addgroup -g $USER_ID newsreader && adduser -Du $GROUP_ID -G newsreader newsreader
RUN mkdir --parents /app/src /app/logs /app/media /app/bin /app/static \
&& chown -R newsreader:newsreader /app
WORKDIR /app
USER newsreader
COPY --chown=newsreader:newsreader uv.lock pyproject.toml /app/
COPY --from=ghcr.io/astral-sh/uv:python3.11-alpine /usr/local/bin/uv /bin/uv
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
uv sync --frozen --no-default-groups --no-install-project
COPY --chown=newsreader:newsreader ./bin/docker-entrypoint.sh /app/bin/docker-entrypoint.sh
VOLUME ["/app/logs", "/app/media", "/app/static"]
# stage 2
FROM node:lts-alpine AS frontend-build
ARG BUILD_ARG=prod
WORKDIR /app
RUN chown node:node /app
USER node
COPY --chown=node:node ./package*.json ./webpack.*.js ./babel.config.js /app/
RUN --mount=type=cache,uid=1000,gid=1000,target=/home/node/.npm \
npm ci
COPY --chown=node:node ./src /app/src
RUN npm run build:$BUILD_ARG
# stage 3
FROM backend AS production
COPY --from=frontend-build --chown=newsreader:newsreader \
/app/src/newsreader/static /app/src/newsreader/static
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
uv sync --frozen --only-group production --extra sentry
COPY --chown=newsreader:newsreader ./src /app/src
ENV DJANGO_SETTINGS_MODULE=newsreader.conf.production
# Note that the static volume will have to be recreated to be pre-populated
# correctly with the latest static files. See
# https://docs.docker.com/storage/volumes/#populate-a-volume-using-a-container
RUN uv run --no-sync -- src/manage.py collectstatic --noinput
# (optional) stage 4
FROM backend AS development
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
uv sync --frozen --group development
ENV DJANGO_SETTINGS_MODULE=newsreader.conf.docker

View file

@ -1,21 +0,0 @@
module.exports = api => {
const isTest = api.env('test');
const preset = [
"@babel/preset-env", { targets: 'defaults' }
];
const testPreset = [
"@babel/preset-env", { targets: { node: process.versions.node } }
];
const plugins = [
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-transform-react-jsx",
"@babel/plugin-proposal-class-properties"
]
return {
"presets": [isTest ? testPreset : preset],
"plugins": plugins
}
}

View file

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

View file

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

View file

@ -1,36 +0,0 @@
volumes:
static-files:
services:
django:
build: &app-development-build
target: development
command: uv run --no-sync -- /app/src/manage.py runserver 0.0.0.0:8000
environment: &django-env
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-newsreader.conf.docker}
ports:
- "${DJANGO_PORT:-8000}:8000"
volumes:
- ./src:/app/src
- static-files:/app/src/newsreader/static
stdin_open: true
tty: true
celery:
build:
<<: *app-development-build
environment:
<<: *django-env
volumes:
- ./src/:/app/src
webpack:
build:
target: frontend-build
context: .
args:
BUILD_ARG: "dev"
command: npm run build:watch
volumes:
- ./src/:/app/src
- static-files:/app/src/newsreader/static

View file

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

View file

@ -1,126 +1,62 @@
version: "3"
volumes:
logs:
media:
postgres-data:
static-files:
x-db-connection-env: &db-connection-env
POSTGRES_HOST: ${POSTGRES_HOST:-db}
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
POSTGRES_DB: &pg-database ${POSTGRES_DB:-newsreader}
POSTGRES_USER: &pg-user ${POSTGRES_USER:-newsreader}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-newsreader}
x-db-env: &db-env
<<: *db-connection-env
PGUSER: *pg-user
PGDATABASE: *pg-database
x-django-env: &django-env
<<: *db-connection-env
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,django}
INTERNAL_IPS: ${INTERNAL_IPS:-localhost,127.0.0.1,django}
# see token_urlsafe from python's secret module to generate one
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-Ojg68lYsP3kq2r5JgozUzKVSRFywm17BTMS5iwpLM44}
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-newsreader.conf.production}
ADMINS: ${ADMINS:-""}
VERSION: ${VERSION:-""}
# Email
EMAIL_HOST: ${EMAIL_HOST:-localhost}
EMAIL_PORT: ${EMAIL_PORT:-25}
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-""}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-""}
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-no}
EMAIL_USE_SSL: ${EMAIL_USE_SSL:-no}
EMAIL_DEFAULT_FROM: ${EMAIL_DEFAULT_FROM:-webmaster@localhost}
# Sentry
SENTRY_DSN: ${SENTRY_DSN:-""}
node-modules:
services:
db:
image: postgres
environment:
<<: *db-env
image: postgres:15
healthcheck:
test: /usr/bin/pg_isready
start_period: 10s
interval: 5s
timeout: 10s
retries: 10
POSTGRES_DB: "newsreader"
POSTGRES_USER: "newsreader"
POSTGRES_PASSWORD: "newsreader"
volumes:
- postgres-data:/var/lib/postgresql/data
rabbitmq:
image: rabbitmq:4
image: rabbitmq:3.7
memcached:
image: memcached:1.6
ports:
- "11211:11211"
entrypoint:
- memcached
- -m 64
django:
build: &app-build
context: .
target: production
environment:
<<: *django-env
entrypoint: ["/bin/sh", "/app/bin/docker-entrypoint.sh"]
command: |
uv run --no-sync --
gunicorn
--bind 0.0.0.0:8000
--workers 3
--chdir /app/src/
newsreader.wsgi:application
healthcheck:
test: /usr/bin/curl --fail http://django:8000 || exit 1
start_period: 10s
interval: 10s
timeout: 10s
retries: 5
depends_on:
memcached:
condition: service_started
db:
condition: service_healthy
volumes:
- logs:/app/logs
- media:/app/media
- static-files:/app/static
celery:
build:
<<: *app-build
context: .
dockerfile: ./docker/django
command: celery worker -n worker1@%h -n worker2@%h --app newsreader --loglevel INFO --concurrency 2 --workdir /app/src/ --beat --scheduler django
environment:
<<: *django-env
command: |
uv run --no-sync --
celery
--app newsreader
--workdir /app/src/
worker --loglevel INFO
--concurrency 2
--beat
--scheduler django
-n worker1@%h
-n worker2@%h
healthcheck:
test: uv run --no-sync -- celery --app newsreader status || exit 1
start_period: 10s
interval: 10s
timeout: 10s
retries: 5
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
depends_on:
rabbitmq:
condition: service_started
django:
condition: service_healthy
- rabbitmq
- memcached
volumes:
- logs:/app/logs
- .:/app
django:
build:
context: .
dockerfile: ./docker/django
command: python /app/src/manage.py runserver 0.0.0.0:8000
environment:
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
ports:
- "8000:8000"
depends_on:
- db
- memcached
volumes:
- .:/app
- static-files:/app/src/newsreader/static
stdin_open: true
tty: true
webpack:
build:
context: .
dockerfile: ./docker/webpack
command: npm run build:watch
volumes:
- .:/app
- static-files:/app/src/newsreader/static
- node-modules:/app/node_modules

10
docker/django Normal file
View file

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

9
docker/webpack Normal file
View file

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

7
gitlab-ci/build.yml Normal file
View file

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

22
gitlab-ci/deploy.yml Normal file
View file

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

28
gitlab-ci/lint.yml Normal file
View file

@ -0,0 +1,28 @@
python-linting:
stage: lint
image: python:3.7
before_script:
- pip install poetry --quiet
- poetry config cache-dir ~/.cache/poetry
- poetry config virtualenvs.in-project true
- poetry install --no-interaction --quiet
script:
- poetry run isort src/ --check-only --recursive
- poetry run black src/ --line-length 88 --check
- poetry run autoflake src/ --check --recursive --remove-all-unused-imports --ignore-init-module-imports
only:
refs:
- development
- merge_requests
javascript-linting:
stage: lint
image: node:12
before_script:
- npm install
script:
- npm run lint
only:
refs:
- development
- merge_requests

12
gitlab-ci/release.yml Normal file
View file

@ -0,0 +1,12 @@
release:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
rules:
- if: $CI_COMMIT_TAG
script:
- echo 'running release job'
release:
name: 'Release $CI_COMMIT_TAG'
description: 'Auto created release'
tag_name: '$CI_COMMIT_TAG'
ref: '$CI_COMMIT_TAG'

23
gitlab-ci/test.yml Normal file
View file

@ -0,0 +1,23 @@
python-tests:
stage: test
coverage: '/TOTAL\s+\d+\s+\d+\s+(\d+%)/'
services:
- postgres:11
- memcached:1.5.22
image: python:3.7
before_script:
- pip install poetry --quiet
- poetry config cache-dir .cache/poetry
- poetry config virtualenvs.in-project true
- poetry install --no-interaction --quiet
script:
- poetry run coverage run src/manage.py test newsreader
- poetry run coverage report
javascript-tests:
stage: test
image: node:12
before_script:
- npm install
script:
- npm test

188
jest.config.js Normal file
View file

@ -0,0 +1,188 @@
// For a detailed explanation regarding each configuration property, visit:
// https://jestjs.io/docs/en/configuration.html
module.exports = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// Respect "browser" field in package.json when resolving modules
// browser: false,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/tmp/jest_rs",
// Automatically clear mock calls and instances between every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: null,
// The directory where Jest should output its coverage files
coverageDirectory: 'coverage',
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: null,
// A path to a custom dependency extractor
// dependencyExtractor: null,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: null,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: null,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "json",
// "jsx",
// "ts",
// "tsx",
// "node"
// ],
// A map from regular expressions to module names that allow to stub out resources with a single module
// moduleNameMapper: {},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: null,
// Run tests from one or more projects
// projects: null,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state between every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: null,
// Automatically restore mock state between every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
rootDir: 'src/newsreader/js/tests/',
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
testEnvironment: 'node',
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: null,
// This option allows use of a custom test runner
// testRunner: "jasmine2",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",
// A map from regular expressions to paths to transformers
// transform: null,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: null,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
};

17390
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,75 +1,63 @@
{
"name": "newsreader",
"version": "0.5.3",
"version": "0.1.0",
"description": "Application for viewing RSS feeds",
"main": "index.js",
"scripts": {
"lint": "npx prettier \"src/newsreader/js/**/*.js\" --check",
"format": "npx prettier \"src/newsreader/js/**/*.js\" --write",
"build": "npx webpack --config webpack.dev.babel.js",
"build:watch": "npx webpack --config webpack.dev.babel.js --watch",
"build:dev": "npx webpack --config webpack.dev.babel.js",
"build:prod": "npx webpack --config webpack.prod.babel.js",
"test": "npx jest",
"test:watch": "npm test -- --watch"
},
"repository": {
"type": "git",
"url": "forgejo.fudiggity.nl:sonny/newsreader"
"url": "[git@git.fudiggity.nl:5000]:sonny/newsreader.git"
},
"author": "Sonny",
"license": "GPL-3.0-or-later",
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.2",
"css.gg": "^1.0.6",
"js-cookie": "^2.2.1",
"lodash": "^4.17.20",
"object-assign": "^4.1.1",
"react-redux": "^7.2.2",
"react-redux": "^7.1.3",
"redux": "^4.0.5",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0"
},
"devDependencies": {
"@babel/core": "^7.12.13",
"@babel/plugin-proposal-class-properties": "^7.12.13",
"@babel/plugin-proposal-function-bind": "^7.12.13",
"@babel/plugin-transform-react-jsx": "^7.12.13",
"@babel/preset-env": "^7.12.13",
"@babel/register": "^7.12.13",
"@babel/runtime": "^7.12.13",
"babel-jest": "^29.7.0",
"babel-loader": "^8.2.2",
"@babel/core": "^7.7.7",
"@babel/plugin-proposal-class-properties": "^7.7.4",
"@babel/plugin-proposal-function-bind": "^7.7.4",
"@babel/plugin-syntax-dynamic-import": "^7.7.4",
"@babel/plugin-syntax-function-bind": "^7.7.4",
"@babel/plugin-transform-react-jsx": "^7.7.7",
"@babel/plugin-transform-runtime": "^7.7.6",
"@babel/preset-env": "^7.7.7",
"@babel/register": "^7.7.7",
"@babel/runtime": "^7.7.7",
"babel-jest": "^24.9.0",
"babel-loader": "^8.1.0",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^7.1.2",
"fetch-mock": "^8.3.2",
"jest": "^29.7.0",
"mini-css-extract-plugin": "^2.9.1",
"node-fetch": "^2.6.1",
"css-loader": "^3.4.2",
"fetch-mock": "^8.3.1",
"file-loader": "^6.0.0",
"jest": "^24.9.0",
"mini-css-extract-plugin": "^0.9.0",
"node-fetch": "^2.6.0",
"node-sass": "^4.14.1",
"prettier": "^1.19.1",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"redux-mock-store": "^1.5.4",
"sass": "^1.52.1",
"sass-loader": "^8.0.2",
"style-loader": "^2.0.0",
"url-loader": "^4.1.1",
"webpack": "^5.94.0",
"webpack-cli": "^5.1.4",
"style-loader": "^1.1.3",
"url-loader": "^4.1.0",
"webpack": "^4.42.1",
"webpack-cli": "^3.3.11",
"webpack-merge": "^4.2.2"
},
"prettier": {
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 90,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "avoid"
},
"jest": {
"roots": [
"src/newsreader/js/tests/"
],
"clearMocks": true,
"coverageDirectory": "coverage"
}
}

1254
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,81 +1,44 @@
[project]
[tool.poetry]
name = "newsreader"
version = "0.5.3"
authors = [{ name = "Sonny" }]
license = { text = "GPL-3.0" }
requires-python = ">=3.11"
dependencies = [
"django~=4.2",
"celery~=5.4",
"psycopg[binary]",
"django-axes",
"django-celery-beat~=2.7.0",
"django-rest-framework",
"djangorestframework-camel-case",
"pymemcache",
"python-dotenv~=1.0.1",
"ftfy~=6.2",
"requests",
"feedparser",
"bleach",
"beautifulsoup4",
"lxml",
]
version = "0.2"
description = "Webapplication for reading RSS feeds"
authors = ["Sonny <sonnyba871@gmail.com>"]
license = "GPL-3.0"
[dependency-groups]
test-tools = ["ruff", "factory_boy", "freezegun"]
development = [
"django-debug-toolbar",
"django-stubs",
"django-extensions",
]
ci = ["coverage~=7.6.1"]
production = ["gunicorn~=23.0"]
[tool.poetry.dependencies]
python = "^3.7"
bleach = "^3.1.4"
Django = "^3.0.5"
celery = "^4.4.2"
beautifulsoup4 = "^4.9.0"
django-axes = "^5.3.1"
django-celery-beat = "^2.0.0"
djangorestframework = "^3.11.0"
drf-yasg = "^1.17.1"
django-registration-redux = "^2.7"
lxml = "^4.5.0"
feedparser = "^5.2.1"
python-memcached = "^1.59"
requests = "^2.23.0"
psycopg2-binary = "^2.8.5"
gunicorn = "^20.0.4"
python-dotenv = "^0.12.0"
django = ">=3.0.7"
sentry-sdk = "^0.15.1"
ftfy = "^5.8"
requests_oauthlib = "^1.3.0"
[project.optional-dependencies]
sentry = ["sentry-sdk~=2.0"]
[tool.poetry.dev-dependencies]
factory-boy = "^2.12.0"
freezegun = "^0.3.15"
django-debug-toolbar = "^2.2"
django-extensions = "^2.2.9"
black = "19.3b0"
isort = "4.3.21"
autoflake = "1.3.1"
tblib = "1.6.0"
coverage = "^5.1"
[tool.uv]
environments = ["sys_platform == 'linux'"]
default-groups = ["test-tools"]
[tool.ruff]
include = ["pyproject.toml", "src/**/*.py"]
line-length = 88
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I"]
[tool.ruff.lint.isort]
lines-between-types=1
lines-after-imports=2
default-section = "third-party"
known-first-party = ["newsreader"]
section-order = [
"future",
"standard-library",
"django",
"third-party",
"first-party",
"local-folder",
]
[tool.ruff.lint.isort.sections]
django = ["django"]
[tool.coverage.run]
source = ["./src/newsreader/"]
omit = [
"**/tests/**",
"**/migrations/**",
"**/conf/**",
"**/apps.py",
"**/admin.py",
"**/tests.py",
"**/urls.py",
"**/wsgi.py",
"**/celery.py",
"**/__init__.py"
]
[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"

View file

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

View file

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

View file

@ -2,4 +2,4 @@ from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = "newsreader.accounts"
name = "accounts"

View file

@ -1,11 +1,9 @@
from django import forms
from newsreader.accounts.models import User
from newsreader.core.forms import CheckboxInput
class UserSettingsForm(forms.ModelForm):
class Meta:
model = User
fields = ("first_name", "last_name", "auto_mark_read")
widgets = {"auto_mark_read": CheckboxInput}
fields = ("first_name", "last_name")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,19 +0,0 @@
# Generated by Django 3.0.7 on 2020-10-27 21:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("accounts", "0012_remove_user_task")]
operations = [
migrations.AddField(
model_name="user",
name="auto_mark_read",
field=models.BooleanField(
default=True,
help_text="Wether posts should be marked as read after x amount of seconds of reading",
verbose_name="Auto read marking",
),
)
]

View file

@ -1,48 +0,0 @@
# Generated by Django 3.0.7 on 2020-12-18 21:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("accounts", "0013_user_auto_mark_read")]
operations = [
migrations.AddField(
model_name="user",
name="reddit_allow_nfsw",
field=models.BooleanField(default=False, verbose_name="Allow NSFW posts"),
),
migrations.AddField(
model_name="user",
name="reddit_allow_spoiler",
field=models.BooleanField(default=False, verbose_name="Allow spoilers"),
),
migrations.AddField(
model_name="user",
name="reddit_allow_viewed",
field=models.BooleanField(
default=True, verbose_name="Allow already seen posts"
),
),
migrations.AddField(
model_name="user",
name="reddit_comments_min",
field=models.PositiveIntegerField(
default=0, verbose_name="Minimum amount of comments"
),
),
migrations.AddField(
model_name="user",
name="reddit_downvotes_max",
field=models.PositiveIntegerField(
default=0, verbose_name="Maximum amount of downvotes"
),
),
migrations.AddField(
model_name="user",
name="reddit_upvotes_min",
field=models.PositiveIntegerField(
default=0, verbose_name="Minimum amount of upvotes"
),
),
]

View file

@ -1,16 +0,0 @@
# Generated by Django 3.0.7 on 2020-12-19 12:30
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("accounts", "0014_auto_20201218_2216")]
operations = [
migrations.RemoveField(model_name="user", name="reddit_allow_nfsw"),
migrations.RemoveField(model_name="user", name="reddit_allow_spoiler"),
migrations.RemoveField(model_name="user", name="reddit_allow_viewed"),
migrations.RemoveField(model_name="user", name="reddit_comments_min"),
migrations.RemoveField(model_name="user", name="reddit_downvotes_max"),
migrations.RemoveField(model_name="user", name="reddit_upvotes_min"),
]

View file

@ -1,17 +0,0 @@
# Generated by Django 3.2 on 2021-04-23 20:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("accounts", "0015_auto_20201219_1330")]
operations = [
migrations.AlterField(
model_name="user",
name="first_name",
field=models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
)
]

View file

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

View file

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

View file

@ -39,14 +39,11 @@ class UserManager(DjangoUserManager):
class User(AbstractUser):
email = models.EmailField(_("email address"), unique=True)
# settings
auto_mark_read = models.BooleanField(
_("Auto read marking"),
default=True,
help_text=_(
"Wether posts should be marked as read after x amount of seconds of reading"
),
)
reddit_refresh_token = models.CharField(max_length=255, blank=True, null=True)
reddit_access_token = models.CharField(max_length=255, blank=True, null=True)
twitter_oauth_token = models.CharField(max_length=255, blank=True, null=True)
twitter_oauth_token_secret = models.CharField(max_length=255, blank=True, null=True)
username = None
@ -60,3 +57,7 @@ class User(AbstractUser):
tasks.delete()
return super().delete(*args, **kwargs)
@property
def has_twitter_auth(self):
return self.twitter_oauth_token and self.twitter_oauth_token_secret

View file

@ -2,7 +2,7 @@
{% load i18n %}
{% block actions %}
<section class="section form__section--last">
<section class="section form__section--last">
<fieldset class="fieldset form__fieldset">
{% include "components/form/confirm-button.html" %}
@ -19,6 +19,10 @@
{% trans "Fetch favicons" %}
</button>
{% endif %}
<a class="link button button--primary" href="{% url 'accounts:settings:integrations' %}">
{% trans "Third party integrations" %}
</a>
</fieldset>
</section>
</section>
{% endblock actions %}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,343 @@
import logging
from urllib.parse import parse_qs, urlencode
from django.conf import settings
from django.contrib import messages
from django.core.cache import cache
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import RedirectView, TemplateView
from requests_oauthlib import OAuth1 as OAuth
from newsreader.news.collection.exceptions import StreamException
from newsreader.news.collection.reddit import (
get_reddit_access_token,
get_reddit_authorization_url,
revoke_reddit_token,
)
from newsreader.news.collection.tasks import RedditTokenTask
from newsreader.news.collection.twitter import (
TWITTER_ACCESS_TOKEN_URL,
TWITTER_AUTH_URL,
TWITTER_REQUEST_TOKEN_URL,
TWITTER_REVOKE_URL,
)
from newsreader.news.collection.utils import post
logger = logging.getLogger(__name__)
class IntegrationsView(TemplateView):
template_name = "accounts/views/integrations.html"
def get_context_data(self, **kwargs):
return {
**super().get_context_data(**kwargs),
**self.get_reddit_context(**kwargs),
**self.get_twitter_context(**kwargs),
}
def get_reddit_context(self, **kwargs):
user = self.request.user
reddit_authorization_url = None
reddit_refresh_url = None
reddit_task_active = cache.get(f"{user.email}-reddit-refresh")
if (
user.reddit_refresh_token
and not user.reddit_access_token
and not reddit_task_active
):
reddit_refresh_url = reverse_lazy("accounts:settings:reddit-refresh")
if not user.reddit_refresh_token:
reddit_authorization_url = get_reddit_authorization_url(user)
return {
"reddit_authorization_url": reddit_authorization_url,
"reddit_refresh_url": reddit_refresh_url,
"reddit_revoke_url": (
reverse_lazy("accounts:settings:reddit-revoke")
if not reddit_authorization_url
else None
),
}
def get_twitter_context(self, **kwargs):
twitter_revoke_url = None
if self.request.user.has_twitter_auth:
twitter_revoke_url = reverse_lazy("accounts:settings:twitter-revoke")
return {
"twitter_auth_url": reverse_lazy("accounts:settings:twitter-auth"),
"twitter_revoke_url": twitter_revoke_url,
}
class RedditTemplateView(TemplateView):
template_name = "accounts/views/reddit.html"
def get(self, request, *args, **kwargs):
context = self.get_context_data(**kwargs)
error = request.GET.get("error", None)
state = request.GET.get("state", None)
code = request.GET.get("code", None)
if error:
return self.render_to_response({**context, "error": error})
if not code or not state:
return self.render_to_response(context)
cached_state = cache.get(f"{request.user.email}-reddit-auth")
if state != cached_state:
return self.render_to_response(
{
**context,
"error": _(
"The saved state for Reddit authorization did not match"
),
}
)
try:
access_token, refresh_token = get_reddit_access_token(code, request.user)
return self.render_to_response(
{
**context,
"access_token": access_token,
"refresh_token": refresh_token,
}
)
except StreamException as e:
return self.render_to_response({**context, "error": str(e)})
except KeyError:
return self.render_to_response(
{
**context,
"error": _("Access and refresh token not found in response"),
}
)
class RedditTokenRedirectView(RedirectView):
url = reverse_lazy("accounts:settings:integrations")
def get(self, request, *args, **kwargs):
response = super().get(request, *args, **kwargs)
user = request.user
task_active = cache.get(f"{user.email}-reddit-refresh")
if not task_active:
RedditTokenTask.delay(user.pk)
messages.success(request, _("Access token is being retrieved"))
cache.set(f"{user.email}-reddit-refresh", 1, 300)
return response
messages.error(request, _("Unable to retrieve token"))
return response
class RedditRevokeRedirectView(RedirectView):
url = reverse_lazy("accounts:settings:integrations")
def get(self, request, *args, **kwargs):
response = super().get(request, *args, **kwargs)
user = request.user
if not user.reddit_refresh_token:
messages.error(request, _("No reddit account is linked to this account"))
return response
try:
is_revoked = revoke_reddit_token(user)
except StreamException:
logger.exception(f"Unable to revoke reddit token for {user.pk}")
messages.error(request, _("Unable to revoke reddit token"))
return response
if not is_revoked:
messages.error(request, _("Unable to revoke reddit token"))
return response
user.reddit_access_token = None
user.reddit_refresh_token = None
user.save()
messages.success(request, _("Reddit account deathorized"))
return response
class TwitterRevokeRedirectView(RedirectView):
url = reverse_lazy("accounts:settings:integrations")
def get(self, request, *args, **kwargs):
if not request.user.has_twitter_auth:
messages.error(request, _("No twitter credentials found"))
return super().get(request, *args, **kwargs)
oauth = OAuth(
settings.TWITTER_CONSUMER_ID,
client_secret=settings.TWITTER_CONSUMER_SECRET,
resource_owner_key=request.user.twitter_oauth_token,
resource_owner_secret=request.user.twitter_oauth_token_secret,
)
try:
post(TWITTER_REVOKE_URL, auth=oauth)
except StreamException:
logger.exception("Failed revoking Twitter account")
messages.error(request, _("Unable revoke Twitter account"))
return super().get(request, *args, **kwargs)
request.user.twitter_oauth_token = None
request.user.twitter_oauth_token_secret = None
request.user.save()
messages.success(request, _("Twitter account revoked"))
return super().get(request, *args, **kwargs)
class TwitterAuthRedirectView(RedirectView):
url = reverse_lazy("accounts:settings:integrations")
def get(self, request, *args, **kwargs):
oauth = OAuth(
settings.TWITTER_CONSUMER_ID,
client_secret=settings.TWITTER_CONSUMER_SECRET,
callback_uri=settings.TWITTER_REDIRECT_URL,
)
try:
response = post(TWITTER_REQUEST_TOKEN_URL, auth=oauth)
except StreamException:
logger.exception("Failed requesting Twitter authentication token")
messages.error(request, _("Unable to retrieve initial Twitter token"))
return super().get(request, *args, **kwargs)
params = parse_qs(response.text)
try:
request_oauth_token = params["oauth_token"][0]
request_oauth_secret = params["oauth_token_secret"][0]
except KeyError:
logger.exception("No credentials found in response")
messages.error(request, _("Unable to retrieve initial Twitter token"))
return super().get(request, *args, **kwargs)
cache.set_many(
{
f"twitter-{request.user.email}-token": request_oauth_token,
f"twitter-{request.user.email}-secret": request_oauth_secret,
}
)
request_params = urlencode({"oauth_token": request_oauth_token})
return redirect(f"{TWITTER_AUTH_URL}/?{request_params}")
class TwitterTemplateView(TemplateView):
template_name = "accounts/views/twitter.html"
def get(self, request, *args, **kwargs):
context = self.get_context_data(**kwargs)
denied = request.GET.get("denied", False)
oauth_token = request.GET.get("oauth_token")
oauth_verifier = request.GET.get("oauth_verifier")
if denied:
return self.render_to_response(
{
**context,
"error": _("Twitter authorization failed"),
"authorized": False,
}
)
cached_token = cache.get(f"twitter-{request.user.email}-token")
if oauth_token != cached_token:
return self.render_to_response(
{
**context,
"error": _("OAuth tokens failed to match"),
"authorized": False,
}
)
cached_secret = cache.get(f"twitter-{request.user.email}-secret")
if not cached_token or not cached_secret:
return self.render_to_response(
{
**context,
"error": _("No matching tokens found for this user"),
"authorized": False,
}
)
oauth = OAuth(
settings.TWITTER_CONSUMER_ID,
client_secret=settings.TWITTER_CONSUMER_SECRET,
resource_owner_key=cached_token,
resource_owner_secret=cached_secret,
verifier=oauth_verifier,
)
try:
response = post(TWITTER_ACCESS_TOKEN_URL, auth=oauth)
except StreamException:
logger.exception("Failed requesting Twitter access token")
return self.render_to_response(
{
**context,
"error": _("Failed requesting access token"),
"authorized": False,
}
)
params = parse_qs(response.text)
try:
oauth_token = params["oauth_token"][0]
oauth_secret = params["oauth_token_secret"][0]
except KeyError:
logger.exception("No credentials in Twitter response")
return self.render_to_response(
{
**context,
"error": _("No credentials found in Twitter response"),
"authorized": False,
}
)
request.user.twitter_oauth_token = oauth_token
request.user.twitter_oauth_token_secret = oauth_secret
request.user.save()
cache.delete_many(
[
f"twitter-{request.user.email}-token",
f"twitter-{request.user.email}-secret",
]
)
return self.render_to_response({**context, "error": None, "authorized": True})

View file

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

View file

@ -0,0 +1,59 @@
from django.shortcuts import render
from django.urls import reverse_lazy
from django.views.generic import TemplateView
from registration.backends.default import views as registration_views
from newsreader.news.collection.reddit import (
get_reddit_access_token,
get_reddit_authorization_url,
)
# RegistrationView shows a registration form and sends the email
# RegistrationCompleteView shows after filling in the registration form
# ActivationView is send within the activation email and activates the account
# ActivationCompleteView shows the success screen when activation was succesful
# ActivationResendView can be used when activation links are expired
# RegistrationClosedView shows when registration is disabled
class RegistrationView(registration_views.RegistrationView):
disallowed_url = reverse_lazy("accounts:register-closed")
template_name = "registration/registration_form.html"
success_url = reverse_lazy("accounts:register-complete")
class RegistrationCompleteView(TemplateView):
template_name = "registration/registration_complete.html"
class RegistrationClosedView(TemplateView):
template_name = "registration/registration_closed.html"
# Redirects or renders failed activation template
class ActivationView(registration_views.ActivationView):
template_name = "registration/activation_failure.html"
def get_success_url(self, user):
return ("accounts:activate-complete", (), {})
class ActivationCompleteView(TemplateView):
template_name = "registration/activation_complete.html"
# Renders activation form resend or resend_activation_complete
class ActivationResendView(registration_views.ResendActivationView):
template_name = "registration/activation_resend_form.html"
def render_form_submitted_template(self, form):
"""
Renders resend activation complete template with the submitted email.
"""
email = form.cleaned_data["email"]
context = {"email": email}
return render(
self.request, "registration/activation_resend_complete.html", context
)

View file

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

View file

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

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

View file

@ -1,25 +1,20 @@
from dotenv import load_dotenv
import os
from newsreader.conf.utils import get_env, get_root_dir
from pathlib import Path
from .version import get_current_version
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
DJANGO_PROJECT_DIR = os.path.join(BASE_DIR, "src", "newsreader")
try:
from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.django import DjangoIntegration
except ImportError:
CeleryIntegration = None
DjangoIntegration = None
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
# SECURITY WARNING: don"t run with debug turned on in production!
DEBUG = True
BASE_DIR = get_root_dir()
DJANGO_PROJECT_DIR = BASE_DIR / "src" / "newsreader"
DEBUG = False
ALLOWED_HOSTS = get_env("ALLOWED_HOSTS", split=",", default=["127.0.0.1", "localhost"])
INTERNAL_IPS = get_env("INTERNAL_IPS", split=",", default=["127.0.0.1", "localhost"])
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
INTERNAL_IPS = ["127.0.0.1", "localhost"]
# Application definition
INSTALLED_APPS = [
@ -32,8 +27,10 @@ INSTALLED_APPS = [
"django.forms",
# third party apps
"rest_framework",
"drf_yasg",
"celery",
"django_celery_beat",
"registration",
"axes",
# app modules
"newsreader.accounts",
@ -43,8 +40,6 @@ INSTALLED_APPS = [
"newsreader.news.collection",
]
SECRET_KEY = get_env("DJANGO_SECRET_KEY", default="")
AUTHENTICATION_BACKENDS = [
"axes.backends.AxesBackend",
"django.contrib.auth.backends.ModelBackend",
@ -68,10 +63,11 @@ FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [DJANGO_PROJECT_DIR / "templates"],
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
@ -82,30 +78,31 @@ TEMPLATES = [
WSGI_APPLICATION = "newsreader.wsgi.application"
# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"HOST": get_env("POSTGRES_HOST", default=""),
"PORT": get_env("POSTGRES_PORT", default=""),
"NAME": get_env("POSTGRES_DB", default=""),
"USER": get_env("POSTGRES_USER", default=""),
"PASSWORD": get_env("POSTGRES_PASSWORD", default=""),
"HOST": os.environ.get("POSTGRES_HOST", ""),
"NAME": os.environ.get("POSTGRES_NAME", "newsreader"),
"USER": os.environ.get("POSTGRES_USER"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD"),
}
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": "memcached:11211",
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": "localhost:11211",
},
"axes": {
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": "memcached:11211",
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": "localhost:11211",
},
}
# Logging
# https://docs.djangoproject.com/en/2.2/topics/logging/#configuring-logging
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
@ -119,46 +116,48 @@ LOGGING = {
"format": "[{server_time}] {message}",
"style": "{",
},
"syslog": {
"class": "logging.Formatter",
"format": "[newsreader] {message}",
"style": "{",
},
},
"handlers": {
"console": {
"level": "INFO",
"filters": ["require_debug_true"],
"class": "logging.StreamHandler",
"formatter": "timestamped",
},
"file": {
"level": "DEBUG",
"class": "logging.handlers.RotatingFileHandler",
"filename": BASE_DIR / "logs" / "newsreader.log",
"backupCount": 5,
"maxBytes": 50000000, # 50 mB
"formatter": "timestamped",
},
"celery": {
"level": "INFO",
"class": "logging.handlers.RotatingFileHandler",
"filename": BASE_DIR / "logs" / "celery.log",
"backupCount": 5,
"maxBytes": 50000000, # 50 mB
"formatter": "timestamped",
"filters": ["require_debug_false"],
"class": "logging.handlers.SysLogHandler",
"formatter": "syslog",
"address": "/dev/log",
},
"syslog": {
"level": "ERROR",
"filters": ["require_debug_false"],
"class": "logging.handlers.SysLogHandler",
"formatter": "syslog",
"address": "/dev/log",
},
},
"loggers": {
"django": {"handlers": ["console"], "level": "INFO"},
"django": {"handlers": ["console", "syslog"], "level": "INFO"},
"django.server": {
"handlers": ["console"],
"handlers": ["console", "syslog"],
"level": "INFO",
"propagate": False,
},
"celery.task": {"handlers": ["console", "celery"], "level": "INFO"},
"newsreader": {
"handlers": ["console", "file"],
"level": "DEBUG",
"propagate": False,
},
"celery": {"handlers": ["celery", "console"], "level": "INFO"},
"newsreader": {"handlers": ["syslog", "console"], "level": "INFO"},
},
}
# Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
@ -173,6 +172,8 @@ AUTH_USER_MODEL = "accounts.User"
LOGIN_REDIRECT_URL = "/"
# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "Europe/Amsterdam"
@ -180,31 +181,36 @@ USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "static"
STATICFILES_DIRS = (DJANGO_PROJECT_DIR / "static",)
STATIC_ROOT = os.path.join(BASE_DIR, "static")
STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static")]
# https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-STATICFILES_FINDERS
STATICFILES_FINDERS = [
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
]
# Email
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
DEFAULT_FROM_EMAIL = "newsreader@rss.fudiggity.nl"
DEFAULT_FROM_EMAIL = get_env(
"EMAIL_DEFAULT_FROM", required=False, default="webmaster@localhost"
# Project settings
VERSION = get_current_version()
# Reddit integration
REDDIT_CLIENT_ID = "CLIENT_ID"
REDDIT_CLIENT_SECRET = "CLIENT_SECRET"
REDDIT_REDIRECT_URL = (
"http://127.0.0.1:8000/accounts/settings/integrations/reddit/callback/"
)
EMAIL_HOST = get_env("EMAIL_HOST", required=False, default="localhost")
EMAIL_PORT = get_env("EMAIL_PORT", cast=int, required=False, default=25)
EMAIL_HOST_USER = get_env("EMAIL_HOST_USER", required=False, default="")
EMAIL_HOST_PASSWORD = get_env("EMAIL_HOST_PASSWORD", required=False, default="")
EMAIL_USE_TLS = get_env("EMAIL_USE_TLS", required=False, default=False)
EMAIL_USE_SSL = get_env("EMAIL_USE_SSL", required=False, default=False)
# Twitter integration
TWITTER_CONSUMER_ID = "CONSUMER_ID"
TWITTER_CONSUMER_SECRET = "CONSUMER_SECRET"
TWITTER_REDIRECT_URL = (
"http://127.0.0.1:8000/accounts/settings/integrations/twitter/callback/"
)
# Third party settings
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
@ -221,12 +227,7 @@ REST_FRAMEWORK = {
"rest_framework.permissions.IsAuthenticated",
"newsreader.accounts.permissions.IsOwner",
),
"DEFAULT_RENDERER_CLASSES": (
"djangorestframework_camel_case.render.CamelCaseJSONRenderer",
),
"DEFAULT_PARSER_CLASSES": (
"djangorestframework_camel_case.parser.CamelCaseJSONParser",
),
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
}
SWAGGER_SETTINGS = {
@ -237,15 +238,8 @@ SWAGGER_SETTINGS = {
# Celery
# https://docs.celeryproject.org/en/stable/userguide/configuration.html
# Note that celery settings are prefix with CELERY. See src/newsreader/celery.py.
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
CELERY_BROKER_URL = "amqp://guest@rabbitmq:5672"
# Sentry
SENTRY_CONFIG = {
"dsn": get_env("SENTRY_DSN", default="", required=False),
"send_default_pii": False,
"integrations": [DjangoIntegration(), CeleryIntegration()]
if DjangoIntegration and CeleryIntegration
else [],
}
REGISTRATION_OPEN = True
REGISTRATION_AUTO_LOGIN = True
ACCOUNT_ACTIVATION_DAYS = 7

View file

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

View file

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

View file

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

View file

@ -0,0 +1,19 @@
from .base import * # isort:skip
SECRET_KEY = "29%lkw+&n%^w4k#@_db2mo%*tc&xzb)x7xuq*(0$eucii%4r0c"
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
AXES_ENABLED = False
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": "memcached:11211",
},
"axes": {
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": "memcached:11211",
},
}

View file

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

View file

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

View file

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

View file

@ -2,4 +2,4 @@ from django.apps import AppConfig
class CoreConfig(AppConfig):
name = "newsreader.core"
name = "core"

View file

@ -1,7 +1,7 @@
from rest_framework import pagination
from rest_framework.pagination import PageNumberPagination
class ResultSetPagination(pagination.PageNumberPagination):
class ResultSetPagination(PageNumberPagination):
page_size_query_param = "count"
max_page_size = 50
page_size = 30
@ -10,9 +10,3 @@ class ResultSetPagination(pagination.PageNumberPagination):
class LargeResultSetPagination(ResultSetPagination):
max_page_size = 100
page_size = 50
class CursorPagination(pagination.CursorPagination):
page_size_query_param = "count"
ordering = "-publication_date"
page_size = 30

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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