Compare commits

..

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

416 changed files with 15341 additions and 19936 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/ lib/
!src/newsreader/scss/lib !src/newsreader/scss/lib
!src/newsreader/js/lib
lib64/ lib64/
parts/ parts/
@ -115,7 +114,7 @@ celerybeat-schedule
*.sage.py *.sage.py
# Environments # Environments
*.env .env
.venv .venv
env/ env/
venv/ venv/

28
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,28 @@
stages:
- build
- test
- lint
- 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/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 +1,11 @@
# stage 1 FROM python:3.7-buster
FROM python:3.11-alpine AS backend
ARG USER_ID=1000 RUN pip install poetry
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 WORKDIR /app
COPY poetry.lock pyproject.toml /app/
USER newsreader RUN poetry config virtualenvs.create false
RUN poetry install --no-interaction
COPY --chown=newsreader:newsreader uv.lock pyproject.toml /app/ COPY . /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,52 @@
volumes: version: '3'
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:-""}
services: services:
db: db:
# See https://hub.docker.com/_/postgres
image: postgres
container_name: postgres
environment: environment:
<<: *db-env - POSTGRES_DB=$POSTGRES_NAME
image: postgres:15 - POSTGRES_USER=$POSTGRES_USER
healthcheck: - POSTGRES_PASSWORD=$POSTGRES_PASSWORD
test: /usr/bin/pg_isready
start_period: 10s
interval: 5s
timeout: 10s
retries: 10
volumes:
- postgres-data:/var/lib/postgresql/data
rabbitmq: rabbitmq:
image: rabbitmq:4 image: rabbitmq:3.7
container_name: rabbitmq
celery:
build: .
container_name: celery
command: poetry run celery -A newsreader worker -l INFO --beat --scheduler django --workdir=/app/src/
environment:
- POSTGRES_HOST=$POSTGRES_HOST
- POSTGRES_NAME=$POSTGRES_NAME
- POSTGRES_USER=$POSTGRES_USER
- POSTGRES_PASSWORD=$POSTGRES_PASSWORD
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
volumes:
- .:/app
depends_on:
- rabbitmq
memcached: memcached:
image: memcached:1.6 image: memcached:1.5.22
container_name: memcached
ports:
- "11211:11211"
entrypoint: entrypoint:
- memcached - memcached
- -m 64 - -m 64
web:
django: build: .
build: &app-build container_name: web
context: . command: src/entrypoint.sh
target: production
environment: environment:
<<: *django-env - POSTGRES_HOST=$POSTGRES_HOST
entrypoint: ["/bin/sh", "/app/bin/docker-entrypoint.sh"] - POSTGRES_NAME=$POSTGRES_NAME
command: | - POSTGRES_USER=$POSTGRES_USER
uv run --no-sync -- - POSTGRES_PASSWORD=$POSTGRES_PASSWORD
gunicorn - DJANGO_SETTINGS_MODULE=newsreader.conf.docker
--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: volumes:
- logs:/app/logs - .:/app
- media:/app/media ports:
- static-files:/app/static - '8000:8000'
celery:
build:
<<: *app-build
environment:
<<: *django-env
command: |
uv run --no-sync --
celery
--app newsreader
--workdir /app/src/
worker --loglevel INFO
--concurrency 2
--beat
--scheduler django
-n worker1@%h
-n worker2@%h
healthcheck:
test: uv run --no-sync -- celery --app newsreader status || exit 1
start_period: 10s
interval: 10s
timeout: 10s
retries: 5
depends_on: depends_on:
rabbitmq: - db
condition: service_started
django:
condition: service_healthy
volumes:
- logs:/app/logs

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

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

@ -0,0 +1,16 @@
deploy:
stage: deploy
image: debian:buster
environment:
name: production
url: rss.fudiggity.nl
before_script:
- apt-get update && apt-get install -y ansible git
- git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@git.fudiggity.nl/sonny/ansible-playbooks.git deployment
- mkdir /root/.ssh
- echo "192.168.178.63 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILbtcdgJBhVCKsO88cV19EYefDTopdYejEQCp1pYr1Ga" > /root/.ssh/known_hosts
- echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key
script:
- ansible-playbook deployment/playbook.yml --inventory deployment/apps.yml --limit newsreader --user ansible --private-key deployment/deploy_key
only:
- master

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

@ -0,0 +1,22 @@
python-linting:
stage: lint
allow_failure: true
image: python:3.7.4-slim-stretch
before_script:
- pip install poetry
- poetry config cache-dir ~/.cache/poetry
- poetry config virtualenvs.in-project true
- poetry install --no-interaction
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
javascript-linting:
stage: lint
allow_failure: true
image: node:12
before_script:
- npm install
script:
- npm run lint

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.4-slim-stretch
before_script:
- pip install poetry
- poetry config cache-dir .cache/poetry
- poetry config virtualenvs.in-project true
- poetry install --no-interaction
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,
};

17336
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

1159
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

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

5
src/entrypoint.sh Executable file
View file

@ -0,0 +1,5 @@
#!/bin/bash
# This file should only be used in conjuction with docker-compose
poetry run /app/src/manage.py migrate
poetry run /app/src/manage.py runserver 0.0.0.0:8000

View file

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

View file

@ -1,39 +1 @@
from django import forms # Register your models here.
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 newsreader.accounts.models import User
class UserAdminForm(UserChangeForm):
class Meta:
widgets = {
"email": forms.EmailInput(attrs={"size": "50"}),
}
class UserAdmin(DjangoUserAdmin):
list_display = ("email", "last_name", "date_joined", "is_active")
list_filter = ("is_active", "is_staff", "is_superuser")
ordering = ("email",)
search_fields = ["email", "last_name", "first_name"]
readonly_fields = ("last_login", "date_joined")
form = UserAdminForm
fieldsets = (
(
_("User settings"),
{"fields": ("email", "password", "first_name", "last_name", "is_active")},
),
(
_("Permission settings"),
{"classes": ("collapse",), "fields": ("is_staff", "is_superuser")},
),
(_("Misc settings"), {"fields": ("date_joined", "last_login")}),
)
admin.site.register(User, UserAdmin)

View file

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

View file

@ -1,11 +0,0 @@
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}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,27 +0,0 @@
# Generated by Django 3.0.5 on 2020-05-24 10:18
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("django_celery_beat", "0012_periodictask_expire_seconds"),
("accounts", "0008_auto_20200422_2243"),
]
operations = [
migrations.AlterField(
model_name="user",
name="task",
field=models.OneToOneField(
blank=True,
editable=False,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="django_celery_beat.PeriodicTask",
verbose_name="collection task",
),
)
]

View file

@ -1,20 +0,0 @@
# Generated by Django 3.0.5 on 2020-06-03 20:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("accounts", "0009_auto_20200524_1218")]
operations = [
migrations.AddField(
model_name="user",
name="reddit_access_token",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="user",
name="reddit_refresh_token",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View file

@ -1,20 +0,0 @@
# Generated by Django 3.0.7 on 2020-09-13 19:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("accounts", "0010_auto_20200603_2230")]
operations = [
migrations.AddField(
model_name="user",
name="twitter_oauth_token",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="user",
name="twitter_oauth_token_secret",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View file

@ -1,9 +0,0 @@
# Generated by Django 3.0.7 on 2020-09-26 15:34
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("accounts", "0011_auto_20200913_2101")]
operations = [migrations.RemoveField(model_name="user", name="task")]

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

@ -1,9 +1,11 @@
import json
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as DjangoUserManager from django.contrib.auth.models import UserManager as DjangoUserManager
from django.db import models from django.db import models
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django_celery_beat.models import PeriodicTask from django_celery_beat.models import IntervalSchedule, PeriodicTask
class UserManager(DjangoUserManager): class UserManager(DjangoUserManager):
@ -39,13 +41,13 @@ class UserManager(DjangoUserManager):
class User(AbstractUser): class User(AbstractUser):
email = models.EmailField(_("email address"), unique=True) email = models.EmailField(_("email address"), unique=True)
# settings task = models.OneToOneField(
auto_mark_read = models.BooleanField( PeriodicTask,
_("Auto read marking"), on_delete=models.SET_NULL,
default=True, null=True,
help_text=_( blank=True,
"Wether posts should be marked as read after x amount of seconds of reading" editable=False,
), verbose_name="collection task",
) )
username = None username = None
@ -55,8 +57,24 @@ class User(AbstractUser):
USERNAME_FIELD = "email" USERNAME_FIELD = "email"
REQUIRED_FIELDS = [] REQUIRED_FIELDS = []
def delete(self, *args, **kwargs): def save(self, *args, **kwargs):
tasks = PeriodicTask.objects.filter(name__contains=self.email) super().save(*args, **kwargs)
tasks.delete()
if not self.task:
task_interval, _ = IntervalSchedule.objects.get_or_create(
every=1, period=IntervalSchedule.HOURS
)
self.task, _ = PeriodicTask.objects.get_or_create(
enabled=True,
interval=task_interval,
name=f"{self.email}-collection-task",
task="newsreader.news.collection.tasks.FeedTask",
args=json.dumps([self.pk]),
)
self.save()
def delete(self, *args, **kwargs):
self.task.delete()
return super().delete(*args, **kwargs) return super().delete(*args, **kwargs)

View file

@ -1,17 +0,0 @@
{% extends "components/form/form.html" %}
{% load i18n %}
{% block actions %}
<section class="section form__section--last">
<fieldset class="fieldset form__fieldset">
{% include "components/form/cancel-button.html" %}
{% include "components/form/confirm-button.html" %}
</fieldset>
<fieldset class="fieldset form__fieldset">
<a class="link" href="{% url 'accounts:password-reset' %}">
<small class="small">{% trans "I forgot my password" %}</small>
</a>
</fieldset>
</section>
{% endblock actions %}

View file

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

View file

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<main id="login--page" class="main">
<form class="form login-form" method="POST" action="{% url 'accounts:login' %}">
{% csrf_token %}
<div class="form__header">
<h1 class="form__title">Login</h1>
</div>
<fieldset class="login-form__fieldset">
{{ form }}
</fieldset>
<fieldset class="login-form__fieldset">
<button class="button button--confirm" type="submit">Login</button>
<a class="link" href="{% url 'accounts:password-reset' %}">
<small class="small">I forgot my password</small>
</a>
</fieldset>
</form>
</main>
{% endblock %}

View file

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

View file

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

View file

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

View file

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

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

@ -1,37 +0,0 @@
from unittest.mock import patch
from django.core.cache import cache
from django.test import TestCase
from django.urls import reverse
from newsreader.accounts.tests.factories import UserFactory
class FaviconRedirectViewTestCase(TestCase):
def setUp(self):
self.user = UserFactory(email="test@test.nl", password="test")
self.client.force_login(self.user)
self.patch = patch("newsreader.accounts.views.favicon.FaviconTask")
self.mocked_task = self.patch.start()
def tearDown(self):
cache.clear()
def test_simple(self):
response = self.client.get(reverse("accounts:settings:favicon"))
self.assertRedirects(response, reverse("accounts:settings:home"))
self.mocked_task.delay.assert_called_once_with(self.user.pk)
self.assertEqual(1, cache.get(f"{self.user.email}-favicon-task"))
def test_not_active(self):
cache.set(f"{self.user.email}-favicon-task", 1)
response = self.client.get(reverse("accounts:settings:favicon"))
self.assertRedirects(response, reverse("accounts:settings:home"))
self.mocked_task.delay.assert_not_called()

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

@ -1,31 +0,0 @@
from django.test import TestCase
from django.urls import reverse
from newsreader.accounts.models import User
from newsreader.accounts.tests.factories import UserFactory
class SettingsViewTestCase(TestCase):
def setUp(self):
self.user = UserFactory(email="test@test.nl", password="test")
self.client.force_login(self.user)
self.url = reverse("accounts:settings:home")
def test_simple(self):
response = self.client.get(self.url)
self.assertEquals(response.status_code, 200)
def test_user_credential_change(self):
response = self.client.post(
reverse("accounts:settings:home"),
{"first_name": "First name", "last_name": "Last name"},
)
user = User.objects.get()
self.assertRedirects(response, reverse("accounts:settings:home"))
self.assertEquals(user.first_name, "First name")
self.assertEquals(user.last_name, "Last name")

View file

@ -1,21 +1,22 @@
from django.test import TestCase from django.test import TestCase
from django_celery_beat.models import IntervalSchedule, PeriodicTask from django_celery_beat.models import PeriodicTask
from newsreader.accounts.tests.factories import UserFactory from newsreader.accounts.models import User
class UserTestCase(TestCase): class UserTestCase(TestCase):
def test_task_is_created(self):
user = User.objects.create(email="durp@burp.nl", task=None)
task = PeriodicTask.objects.get(name=f"{user.email}-collection-task")
user.refresh_from_db()
self.assertEquals(task, user.task)
self.assertEquals(PeriodicTask.objects.count(), 1)
def test_task_is_deleted(self): def test_task_is_deleted(self):
user = UserFactory(email="durp@burp.nl") user = User.objects.create(email="durp@burp.nl", task=None)
interval = IntervalSchedule.objects.create(
every=1, period=IntervalSchedule.HOURS
)
PeriodicTask.objects.create(
name=f"{user.email}-feed", task="FeedTask", interval=interval
)
user.delete() user.delete()
self.assertEquals(PeriodicTask.objects.count(), 0) self.assertEquals(PeriodicTask.objects.count(), 0)

View file

@ -1,30 +1,41 @@
from django.contrib.auth.decorators import login_required from django.urls import path
from django.urls import include, path
from newsreader.accounts.views import ( from newsreader.accounts.views import (
FaviconRedirectView, ActivationCompleteView,
ActivationResendView,
ActivationView,
LoginView, LoginView,
LogoutView, LogoutView,
PasswordChangeView,
PasswordResetCompleteView, PasswordResetCompleteView,
PasswordResetConfirmView, PasswordResetConfirmView,
PasswordResetDoneView, PasswordResetDoneView,
PasswordResetView, PasswordResetView,
SettingsView, RegistrationClosedView,
RegistrationCompleteView,
RegistrationView,
) )
settings_patterns = [
# Misc
path("favicon/", login_required(FaviconRedirectView.as_view()), name="favicon"),
path("", login_required(SettingsView.as_view()), name="home"),
]
urlpatterns = [ urlpatterns = [
# Auth
path("login/", LoginView.as_view(), name="login"), path("login/", LoginView.as_view(), name="login"),
path("logout/", LogoutView.as_view(), name="logout"), path("logout/", LogoutView.as_view(), name="logout"),
# Password path("register/", RegistrationView.as_view(), name="register"),
path(
"register/complete/",
RegistrationCompleteView.as_view(),
name="register-complete",
),
path("register/closed/", RegistrationClosedView.as_view(), name="register-closed"),
path(
"activate/complete/", ActivationCompleteView.as_view(), name="activate-complete"
),
path("activate/resend/", ActivationResendView.as_view(), name="activate-resend"),
path(
# This URL should be placed after all activate/ url's (see arg)
"activate/<str:activation_key>/",
ActivationView.as_view(),
name="activate",
),
path("password-reset/", PasswordResetView.as_view(), name="password-reset"), path("password-reset/", PasswordResetView.as_view(), name="password-reset"),
path( path(
"password-reset/done/", "password-reset/done/",
@ -41,11 +52,5 @@ urlpatterns = [
PasswordResetCompleteView.as_view(), PasswordResetCompleteView.as_view(),
name="password-reset-complete", name="password-reset-complete",
), ),
path( # TODO: create password change views
"password-change/",
login_required(PasswordChangeView.as_view()),
name="password-change",
),
# Settings
path("settings/", include((settings_patterns, "settings"))),
] ]

View file

@ -0,0 +1,91 @@
from django.contrib.auth import views as django_views
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
class LoginView(django_views.LoginView):
template_name = "accounts/login.html"
def get_success_url(self):
return reverse_lazy("index")
class LogoutView(django_views.LogoutView):
next_page = reverse_lazy("accounts:login")
# RegistrationView shows a registration form and sends the email
# RegistrationCompleteView shows after filling in the registration form
# ActivationView is send within the activation email and activates the account
# ActivationCompleteView shows the success screen when activation was succesful
# ActivationResendView can be used when activation links are expired
# RegistrationClosedView shows when registration is disabled
class RegistrationView(registration_views.RegistrationView):
disallowed_url = reverse_lazy("accounts:register-closed")
template_name = "registration/registration_form.html"
success_url = reverse_lazy("accounts:register-complete")
class RegistrationCompleteView(TemplateView):
template_name = "registration/registration_complete.html"
class RegistrationClosedView(TemplateView):
template_name = "registration/registration_closed.html"
# Redirects or renders failed activation template
class ActivationView(registration_views.ActivationView):
template_name = "registration/activation_failure.html"
def get_success_url(self, user):
return ("accounts:activate-complete", (), {})
class ActivationCompleteView(TemplateView):
template_name = "registration/activation_complete.html"
# Renders activation form resend or resend_activation_complete
class ActivationResendView(registration_views.ResendActivationView):
template_name = "registration/activation_resend_form.html"
def render_form_submitted_template(self, form):
"""
Renders resend activation complete template with the submitted email.
"""
email = form.cleaned_data["email"]
context = {"email": email}
return render(
self.request, "registration/activation_resend_complete.html", context
)
# PasswordResetView sends the mail
# PasswordResetDoneView shows a success message for the above
# PasswordResetConfirmView checks the link the user clicked and
# prompts for a new password
# PasswordResetCompleteView shows a success message for the above
class PasswordResetView(django_views.PasswordResetView):
template_name = "password-reset/password_reset_form.html"
subject_template_name = "password-reset/password_reset_subject.txt"
email_template_name = "password-reset/password_reset_email.html"
success_url = reverse_lazy("accounts:password-reset-done")
class PasswordResetDoneView(django_views.PasswordResetDoneView):
template_name = "password-reset/password_reset_done.html"
class PasswordResetConfirmView(django_views.PasswordResetConfirmView):
template_name = "password-reset/password_reset_confirm.html"
success_url = reverse_lazy("accounts:password-reset-complete")
class PasswordResetCompleteView(django_views.PasswordResetCompleteView):
template_name = "password-reset/password_reset_complete.html"

View file

@ -1,23 +0,0 @@
from newsreader.accounts.views.auth import LoginView, LogoutView
from newsreader.accounts.views.favicon import FaviconRedirectView
from newsreader.accounts.views.password import (
PasswordChangeView,
PasswordResetCompleteView,
PasswordResetConfirmView,
PasswordResetDoneView,
PasswordResetView,
)
from newsreader.accounts.views.settings import SettingsView
__all__ = [
"LoginView",
"LogoutView",
"FaviconRedirectView",
"PasswordChangeView",
"PasswordResetCompleteView",
"PasswordResetConfirmView",
"PasswordResetDoneView",
"PasswordResetView",
"SettingsView",
]

View file

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

View file

@ -1,26 +0,0 @@
from django.contrib import messages
from django.core.cache import cache
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import RedirectView
from newsreader.news.collection.tasks import FaviconTask
class FaviconRedirectView(RedirectView):
url = reverse_lazy("accounts:settings:home")
def get(self, request, *args, **kwargs):
response = super().get(request, *args, **kwargs)
user = request.user
task_active = cache.get(f"{user.email}-favicon-task")
if not task_active:
FaviconTask.delay(user.pk)
messages.success(request, _("Favicons are being fetched"))
cache.set(f"{user.email}-favicon-task", 1, 18000) # 5 hours
return response
messages.error(request, _("Limit reached, try again later"))
return response

View file

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

View file

@ -1,32 +0,0 @@
from django.core.cache import cache
from django.urls import reverse_lazy
from django.views.generic.edit import FormView, ModelFormMixin
from newsreader.accounts.forms import UserSettingsForm
from newsreader.accounts.models import User
from newsreader.utils.views import NavListMixin
class SettingsView(NavListMixin, ModelFormMixin, FormView):
template_name = "accounts/views/settings.html"
success_url = reverse_lazy("accounts:settings:home")
form_class = UserSettingsForm
model = User
def get(self, request, *args, **kwargs):
self.object = self.get_object()
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
return {
**context,
"favicon_task_allowed": not cache.get(f"{self.request.user}-favicon-task"),
}
def get_object(self, **kwargs):
return self.request.user
def get_form_kwargs(self):
return {**super().get_form_kwargs(), "instance": self.request.user}

View file

@ -3,8 +3,12 @@ import os
from celery import Celery from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.docker") # note: this should be consistent with the setting from manage.py
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev")
# note: use the --workdir flag when running from different directories
app = Celery("newsreader") app = Celery("newsreader")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.config_from_object("django.conf:settings")
app.autodiscover_tasks() app.autodiscover_tasks()

View file

@ -1,25 +1,18 @@
from dotenv import load_dotenv import os
from newsreader.conf.utils import get_env, get_root_dir from pathlib import Path
load_dotenv() BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
DJANGO_PROJECT_DIR = os.path.join(BASE_DIR, "src", "newsreader")
try: # Quick-start development settings - unsuitable for production
from sentry_sdk.integrations.celery import CeleryIntegration # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
from sentry_sdk.integrations.django import DjangoIntegration # SECURITY WARNING: don"t run with debug turned on in production!
except ImportError: DEBUG = True
CeleryIntegration = None
DjangoIntegration = None
ALLOWED_HOSTS = ["127.0.0.1"]
BASE_DIR = get_root_dir() INTERNAL_IPS = ["127.0.0.1"]
DJANGO_PROJECT_DIR = BASE_DIR / "src" / "newsreader"
DEBUG = False
ALLOWED_HOSTS = get_env("ALLOWED_HOSTS", split=",", default=["127.0.0.1", "localhost"])
INTERNAL_IPS = get_env("INTERNAL_IPS", split=",", default=["127.0.0.1", "localhost"])
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
@ -29,22 +22,19 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django.forms",
# third party apps # third party apps
"rest_framework", "rest_framework",
"drf_yasg",
"celery", "celery",
"django_celery_beat", "django_celery_beat",
"registration",
"axes", "axes",
# app modules # app modules
"newsreader.accounts", "newsreader.accounts",
"newsreader.utils",
"newsreader.news",
"newsreader.news.core", "newsreader.news.core",
"newsreader.news.collection", "newsreader.news.collection",
] ]
SECRET_KEY = get_env("DJANGO_SECRET_KEY", default="")
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"axes.backends.AxesBackend", "axes.backends.AxesBackend",
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
@ -63,15 +53,14 @@ MIDDLEWARE = [
ROOT_URLCONF = "newsreader.urls" ROOT_URLCONF = "newsreader.urls"
FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
TEMPLATES = [ TEMPLATES = [
{ {
"BACKEND": "django.template.backends.django.DjangoTemplates", "BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [DJANGO_PROJECT_DIR / "templates"], "DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
"APP_DIRS": True, "APP_DIRS": True,
"OPTIONS": { "OPTIONS": {
"context_processors": [ "context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request", "django.template.context_processors.request",
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
@ -82,83 +71,31 @@ TEMPLATES = [
WSGI_APPLICATION = "newsreader.wsgi.application" WSGI_APPLICATION = "newsreader.wsgi.application"
# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.postgresql", "ENGINE": "django.db.backends.postgresql",
"HOST": get_env("POSTGRES_HOST", default=""), "HOST": os.environ.get("POSTGRES_HOST", ""),
"PORT": get_env("POSTGRES_PORT", default=""), "NAME": os.environ.get("POSTGRES_NAME", "newsreader"),
"NAME": get_env("POSTGRES_DB", default=""), "USER": os.environ.get("POSTGRES_USER"),
"USER": get_env("POSTGRES_USER", default=""), "PASSWORD": os.environ.get("POSTGRES_PASSWORD"),
"PASSWORD": get_env("POSTGRES_PASSWORD", default=""),
} }
} }
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
CACHES = { CACHES = {
"default": { "default": {
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache", "BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": "memcached:11211", "LOCATION": "localhost:11211",
}, },
"axes": { "axes": {
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache", "BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": "memcached:11211", "LOCATION": "localhost:11211",
},
}
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"filters": {
"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"},
"require_debug_true": {"()": "django.utils.log.RequireDebugTrue"},
},
"formatters": {
"timestamped": {
"()": "django.utils.log.ServerFormatter",
"format": "[{server_time}] {message}",
"style": "{",
},
},
"handlers": {
"console": {
"level": "INFO",
"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",
},
},
"loggers": {
"django": {"handlers": ["console"], "level": "INFO"},
"django.server": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
"celery.task": {"handlers": ["console", "celery"], "level": "INFO"},
"newsreader": {
"handlers": ["console", "file"],
"level": "DEBUG",
"propagate": False,
},
}, },
} }
# Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
@ -171,8 +108,8 @@ AUTH_PASSWORD_VALIDATORS = [
# Authentication user model # Authentication user model
AUTH_USER_MODEL = "accounts.User" AUTH_USER_MODEL = "accounts.User"
LOGIN_REDIRECT_URL = "/" # Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/
LANGUAGE_CODE = "en-us" LANGUAGE_CODE = "en-us"
TIME_ZONE = "Europe/Amsterdam" TIME_ZONE = "Europe/Amsterdam"
@ -180,31 +117,19 @@ USE_I18N = True
USE_L10N = True USE_L10N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/
STATIC_URL = "/static/" STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "static" STATIC_ROOT = os.path.join(BASE_DIR, "static")
STATICFILES_DIRS = (DJANGO_PROJECT_DIR / "static",) STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static")]
# https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-STATICFILES_FINDERS
STATICFILES_FINDERS = [ STATICFILES_FINDERS = [
"django.contrib.staticfiles.finders.FileSystemFinder", "django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder", "django.contrib.staticfiles.finders.AppDirectoriesFinder",
] ]
# Email DEFAULT_FROM_EMAIL = "newsreader@rss.fudiggity.nl"
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
DEFAULT_FROM_EMAIL = get_env(
"EMAIL_DEFAULT_FROM", required=False, default="webmaster@localhost"
)
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)
# Third party settings # Third party settings
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler" AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
@ -221,12 +146,7 @@ REST_FRAMEWORK = {
"rest_framework.permissions.IsAuthenticated", "rest_framework.permissions.IsAuthenticated",
"newsreader.accounts.permissions.IsOwner", "newsreader.accounts.permissions.IsOwner",
), ),
"DEFAULT_RENDERER_CLASSES": ( "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
"djangorestframework_camel_case.render.CamelCaseJSONRenderer",
),
"DEFAULT_PARSER_CLASSES": (
"djangorestframework_camel_case.parser.CamelCaseJSONParser",
),
} }
SWAGGER_SETTINGS = { SWAGGER_SETTINGS = {
@ -235,17 +155,6 @@ SWAGGER_SETTINGS = {
"DOC_EXPANSION": "list", "DOC_EXPANSION": "list",
} }
# Celery REGISTRATION_OPEN = True
# https://docs.celeryproject.org/en/stable/userguide/configuration.html REGISTRATION_AUTO_LOGIN = True
# Note that celery settings are prefix with CELERY. See src/newsreader/celery.py. ACCOUNT_ACTIVATION_DAYS = 7
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 [],
}

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

View file

@ -1,43 +1,19 @@
from .base import * # noqa: F403 from .dev import * # isort:skip
from .utils import get_current_version
DEBUG = True SECRET_KEY = "=q(ztyo)b6noom#a164g&s9vcj1aawa^g#ing_ir99=_zl4g&$"
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405 # Celery
# https://docs.celeryproject.org/en/latest/userguide/configuration.html
BROKER_URL = "amqp://guest:guest@rabbitmq:5672//"
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405 CACHES = {
"default": {
LOGGING["loggers"].update( # noqa: F405 "BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
{ "LOCATION": "memcached:11211",
"celery.task": {"handlers": ["console", "celery"], "level": "DEBUG"}, },
} "axes": {
) "BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": "memcached:11211",
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" },
}
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

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,45 @@
from newsreader.conf.utils import get_env import os
from .base import * # noqa: F403 from dotenv import load_dotenv
from .utils import get_current_version
from .base import * # isort:skip
load_dotenv()
DEBUG = False DEBUG = False
ALLOWED_HOSTS = ["rss.fudiggity.nl"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
ADMINS = [ DATABASES = {
("", email) for email in get_env("ADMINS", split=",", required=False, default=[]) "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",
]
},
}
] ]
# Project settings
VERSION = get_current_version(debug=False)
ENVIRONMENT = "production"
# Third party settings # Third party settings
AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler" AXES_HANDLER = "axes.handlers.database.DatabaseHandler"
# Optionally use sentry integration REGISTRATION_OPEN = False
try:
from sentry_sdk import init as sentry_init
SENTRY_CONFIG.update( # noqa: F405
{"release": VERSION, "environment": ENVIRONMENT, "debug": False}
)
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

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

View file

@ -1,9 +0,0 @@
from django import forms
class CheckboxInput(forms.CheckboxInput):
template_name = "components/form/checkbox.html"
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
return {**context, **attrs}

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" page_size_query_param = "count"
max_page_size = 50 max_page_size = 50
page_size = 30 page_size = 30
@ -10,9 +10,3 @@ class ResultSetPagination(pagination.PageNumberPagination):
class LargeResultSetPagination(ResultSetPagination): class LargeResultSetPagination(ResultSetPagination):
max_page_size = 100 max_page_size = 100
page_size = 50 page_size = 50
class CursorPagination(pagination.CursorPagination):
page_size_query_param = "count"
ordering = "-publication_date"
page_size = 30

View file

@ -0,0 +1,298 @@
[
{
"model": "django_celery_beat.periodictask",
"pk": 10,
"fields": {
"name": "sonny@bakker.nl-collection-task",
"task": "newsreader.news.collection.tasks.FeedTask",
"interval": 4,
"crontab": null,
"solar": null,
"clocked": null,
"args": "[2]",
"kwargs": "{}",
"queue": null,
"exchange": null,
"routing_key": null,
"headers": "{}",
"priority": null,
"expires": null,
"one_off": false,
"start_time": null,
"enabled": true,
"last_run_at": "2019-11-29T22:29:08.345Z",
"total_run_count": 290,
"date_changed": "2019-11-29T22:29:18.378Z",
"description": ""
}
},
{
"model": "django_celery_beat.periodictask",
"pk": 26,
"fields": {
"name": "sonnyba871@gmail.com-collection-task",
"task": "newsreader.news.collection.tasks.FeedTask",
"interval": 4,
"crontab": null,
"solar": null,
"clocked": null,
"args": "[18]",
"kwargs": "{}",
"queue": null,
"exchange": null,
"routing_key": null,
"headers": "{}",
"priority": null,
"expires": null,
"one_off": false,
"start_time": null,
"enabled": true,
"last_run_at": "2019-11-29T22:35:19.134Z",
"total_run_count": 103,
"date_changed": "2019-11-29T22:38:19.464Z",
"description": ""
}
},
{
"model": "django_celery_beat.crontabschedule",
"pk": 1,
"fields": {
"minute": "0",
"hour": "4",
"day_of_week": "*",
"day_of_month": "*",
"month_of_year": "*",
"timezone": "UTC"
}
},
{
"model": "django_celery_beat.intervalschedule",
"pk": 1,
"fields": {
"every": 5,
"period": "minutes"
}
},
{
"model": "django_celery_beat.intervalschedule",
"pk": 2,
"fields": {
"every": 15,
"period": "minutes"
}
},
{
"model": "django_celery_beat.intervalschedule",
"pk": 3,
"fields": {
"every": 30,
"period": "minutes"
}
},
{
"model": "django_celery_beat.intervalschedule",
"pk": 4,
"fields": {
"every": 1,
"period": "hours"
}
},
{
"model": "accounts.user",
"fields": {
"password": "pbkdf2_sha256$150000$5lBD7JemxYfE$B+lM5wWUW2n/ZulPFaWHtzWjyQ/QZ6iwjAC2I0R/VzU=",
"last_login": "2019-11-27T18:57:36.686Z",
"is_superuser": true,
"first_name": "",
"last_name": "",
"is_staff": true,
"is_active": true,
"date_joined": "2019-07-18T18:52:36.080Z",
"email": "sonny@bakker.nl",
"task": 10,
"groups": [],
"user_permissions": []
}
},
{
"model": "accounts.user",
"fields": {
"password": "pbkdf2_sha256$150000$vUwxT8T25R8C$S+Eq2tMRbSDE31/X5KGJ/M+Nblh7kKfzuM/z7HraR/Q=",
"last_login": null,
"is_superuser": false,
"first_name": "",
"last_name": "",
"is_staff": false,
"is_active": false,
"date_joined": "2019-11-25T15:35:14.051Z",
"email": "sonnyba871@gmail.com",
"task": 26,
"groups": [],
"user_permissions": []
}
},
{
"model": "core.category",
"pk": 8,
"fields": {
"created": "2019-11-17T19:37:24.671Z",
"modified": "2019-11-18T19:59:55.010Z",
"name": "World news",
"user": [
"sonny@bakker.nl"
]
}
},
{
"model": "core.category",
"pk": 9,
"fields": {
"created": "2019-11-17T19:37:26.161Z",
"modified": "2019-11-18T19:59:45.010Z",
"name": "Tech",
"user": [
"sonny@bakker.nl"
]
}
},
{
"model": "collection.collectionrule",
"pk": 3,
"fields": {
"created": "2019-07-14T13:08:10.374Z",
"modified": "2019-11-29T22:35:20.346Z",
"name": "Hackers News",
"url": "https://news.ycombinator.com/rss",
"website_url": "https://news.ycombinator.com/",
"favicon": "https://news.ycombinator.com/favicon.ico",
"timezone": "UTC",
"category": 9,
"last_suceeded": "2019-11-29T22:35:20.235Z",
"succeeded": true,
"error": null,
"user": [
"sonny@bakker.nl"
]
}
},
{
"model": "collection.collectionrule",
"pk": 4,
"fields": {
"created": "2019-07-20T11:24:32.745Z",
"modified": "2019-11-29T22:35:19.525Z",
"name": "BBC",
"url": "http://feeds.bbci.co.uk/news/world/rss.xml",
"website_url": "https://www.bbc.co.uk/news/",
"favicon": "https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png",
"timezone": "UTC",
"category": 8,
"last_suceeded": "2019-11-29T22:35:19.241Z",
"succeeded": true,
"error": null,
"user": [
"sonny@bakker.nl"
]
}
},
{
"model": "collection.collectionrule",
"pk": 5,
"fields": {
"created": "2019-07-20T11:24:50.411Z",
"modified": "2019-11-29T22:35:20.010Z",
"name": "Ars Technica",
"url": "http://feeds.arstechnica.com/arstechnica/index?fmt=xml",
"website_url": "https://arstechnica.com",
"favicon": "https://cdn.arstechnica.net/favicon.ico",
"timezone": "UTC",
"category": 9,
"last_suceeded": "2019-11-29T22:35:19.808Z",
"succeeded": true,
"error": null,
"user": [
"sonny@bakker.nl"
]
}
},
{
"model": "collection.collectionrule",
"pk": 6,
"fields": {
"created": "2019-07-20T11:25:02.089Z",
"modified": "2019-11-29T22:35:20.233Z",
"name": "The Guardian",
"url": "https://www.theguardian.com/world/rss",
"website_url": "https://www.theguardian.com/world",
"favicon": "https://assets.guim.co.uk/images/favicons/873381bf11d58e20f551905d51575117/72x72.png",
"timezone": "UTC",
"category": 8,
"last_suceeded": "2019-11-29T22:35:20.076Z",
"succeeded": true,
"error": null,
"user": [
"sonny@bakker.nl"
]
}
},
{
"model": "collection.collectionrule",
"pk": 7,
"fields": {
"created": "2019-07-20T11:25:30.121Z",
"modified": "2019-11-29T22:35:19.695Z",
"name": "Tweakers",
"url": "http://feeds.feedburner.com/tweakers/mixed?fmt=xml",
"website_url": "https://tweakers.net/",
"favicon": null,
"timezone": "UTC",
"category": 9,
"last_suceeded": "2019-11-29T22:35:19.528Z",
"succeeded": true,
"error": null,
"user": [
"sonny@bakker.nl"
]
}
},
{
"model": "collection.collectionrule",
"pk": 8,
"fields": {
"created": "2019-07-20T11:25:46.256Z",
"modified": "2019-11-29T22:35:20.074Z",
"name": "The Verge",
"url": "https://www.theverge.com/rss/index.xml",
"website_url": "https://www.theverge.com/",
"favicon": "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395367/favicon-16x16.0.png",
"timezone": "UTC",
"category": 9,
"last_suceeded": "2019-11-29T22:35:20.012Z",
"succeeded": true,
"error": null,
"user": [
"sonny@bakker.nl"
]
}
},
{
"model": "collection.collectionrule",
"pk": 9,
"fields": {
"created": "2019-11-24T15:28:41.399Z",
"modified": "2019-11-29T22:35:19.807Z",
"name": "NOS",
"url": "http://feeds.nos.nl/nosnieuwsalgemeen",
"website_url": null,
"favicon": null,
"timezone": "Europe/Amsterdam",
"category": 8,
"last_suceeded": "2019-11-29T22:35:19.697Z",
"succeeded": true,
"error": null,
"user": [
"sonny@bakker.nl"
]
}
}
]

File diff suppressed because it is too large Load diff

View file

@ -47,7 +47,7 @@
"user" : 2, "user" : 2,
"succeeded" : true, "succeeded" : true,
"modified" : "2019-07-20T11:28:16.473Z", "modified" : "2019-07-20T11:28:16.473Z",
"last_run" : "2019-07-20T11:28:16.316Z", "last_suceeded" : "2019-07-20T11:28:16.316Z",
"name" : "Hackers News", "name" : "Hackers News",
"website_url" : null, "website_url" : null,
"created" : "2019-07-14T13:08:10.374Z", "created" : "2019-07-14T13:08:10.374Z",
@ -65,7 +65,7 @@
"error" : null, "error" : null,
"user" : 2, "user" : 2,
"succeeded" : true, "succeeded" : true,
"last_run" : "2019-07-20T11:28:15.691Z", "last_suceeded" : "2019-07-20T11:28:15.691Z",
"name" : "BBC", "name" : "BBC",
"modified" : "2019-07-20T12:07:49.164Z", "modified" : "2019-07-20T12:07:49.164Z",
"timezone" : "UTC", "timezone" : "UTC",
@ -85,7 +85,7 @@
"website_url" : null, "website_url" : null,
"name" : "Ars Technica", "name" : "Ars Technica",
"succeeded" : true, "succeeded" : true,
"last_run" : "2019-07-20T11:28:15.986Z", "last_suceeded" : "2019-07-20T11:28:15.986Z",
"modified" : "2019-07-20T11:28:16.033Z", "modified" : "2019-07-20T11:28:16.033Z",
"user" : 2 "user" : 2
}, },
@ -102,7 +102,7 @@
"user" : 2, "user" : 2,
"name" : "The Guardian", "name" : "The Guardian",
"succeeded" : true, "succeeded" : true,
"last_run" : "2019-07-20T11:28:16.078Z", "last_suceeded" : "2019-07-20T11:28:16.078Z",
"modified" : "2019-07-20T12:07:44.292Z", "modified" : "2019-07-20T12:07:44.292Z",
"created" : "2019-07-20T11:25:02.089Z", "created" : "2019-07-20T11:25:02.089Z",
"website_url" : null, "website_url" : null,
@ -119,7 +119,7 @@
"website_url" : null, "website_url" : null,
"created" : "2019-07-20T11:25:30.121Z", "created" : "2019-07-20T11:25:30.121Z",
"user" : 2, "user" : 2,
"last_run" : "2019-07-20T11:28:15.860Z", "last_suceeded" : "2019-07-20T11:28:15.860Z",
"succeeded" : true, "succeeded" : true,
"modified" : "2019-07-20T12:07:28.473Z", "modified" : "2019-07-20T12:07:28.473Z",
"name" : "Tweakers" "name" : "Tweakers"
@ -139,7 +139,7 @@
"website_url" : null, "website_url" : null,
"timezone" : "UTC", "timezone" : "UTC",
"user" : 2, "user" : 2,
"last_run" : "2019-07-20T11:28:16.034Z", "last_suceeded" : "2019-07-20T11:28:16.034Z",
"succeeded" : true, "succeeded" : true,
"modified" : "2019-07-20T12:07:21.704Z", "modified" : "2019-07-20T12:07:21.704Z",
"name" : "The Verge" "name" : "The Verge"

View file

@ -2,7 +2,7 @@ import React from 'react';
const Card = props => { const Card = props => {
return ( return (
<div id={`${props.id}`} className="card"> <div className="card">
<div className="card__header">{props.header}</div> <div className="card__header">{props.header}</div>
<div className="card__content">{props.content}</div> <div className="card__content">{props.content}</div>
<div className="card__footer">{props.footer}</div> <div className="card__footer">{props.footer}</div>

View file

@ -3,24 +3,26 @@ import React from 'react';
class Messages extends React.Component { class Messages extends React.Component {
state = { messages: this.props.messages }; state = { messages: this.props.messages };
close = index => { close = ::this.close;
close(index) {
const newMessages = this.state.messages.filter((message, currentIndex) => { const newMessages = this.state.messages.filter((message, currentIndex) => {
return currentIndex != index; return currentIndex != index;
}); });
this.setState({ messages: newMessages }); this.setState({ messages: newMessages });
}; }
render() { render() {
const messages = this.state.messages.map((message, index) => { const messages = this.state.messages.map((message, index) => {
return ( return (
<li key={index} className={`messages__item messages__item--${message.type}`}> <li key={index} className={`messages__item messages__item--${message.type}`}>
{message.text} <i className="fas fa-times" onClick={() => this.close(index)} /> {message.text} <i className="gg-close" onClick={() => this.close(index)} />
</li> </li>
); );
}); });
return <ul className="list messages messages--fixed">{messages}</ul>; return <ul className="list messages">{messages}</ul>;
} }
} }

View file

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

View file

@ -1,21 +0,0 @@
class Selector {
inputs = [];
constructor() {
const selectAllInput = document.querySelector('#select-all');
this.inputs = document.querySelectorAll(`[name=${selectAllInput.dataset.input}`);
selectAllInput.onchange = this.onClick;
}
onClick = e => {
const targetValue = e.target.checked;
this.inputs.forEach(input => {
input.checked = targetValue;
});
};
}
export default Selector;

View file

@ -1,25 +0,0 @@
import React from 'react';
import NavList from './NavList.js';
// TODO: show empty category message
class Sidebar extends React.Component {
render() {
return (
<div className="sidebar">
<div className="sidebar__nav">
<NavList
navLinks={this.props.navLinks}
includeBorder={this.props.includeBorder}
/>
{this.props.children}
</div>
<label htmlFor="menu-input" className="sidebar__close" />
</div>
);
}
}
export default Sidebar;

View file

@ -1,5 +1,3 @@
import './lib/index.js';
import './pages/homepage/index.js'; import './pages/homepage/index.js';
import './pages/categories/index.js';
import './pages/rules/index.js'; import './pages/rules/index.js';
import './pages/default/index.js'; import './pages/categories/index.js';

View file

@ -1 +0,0 @@
import './theme.js';

View file

@ -1,76 +0,0 @@
const isCSSVariablesSupported = () => {
return window.CSS && window.CSS.supports('color', 'var(--fake-color');
};
const changeTheme = event => {
const currentPref = sessionStorage.getItem('t-dark');
const isDark = currentPref && currentPref === 'true' ? true : false;
if (isDark) {
document.documentElement.classList.remove('dark-theme');
} else {
document.documentElement.classList.add('dark-theme');
}
try {
sessionStorage.setItem('t-dark', !isDark);
} catch (error) {
// do nothing.
}
};
const getThemePreference = () => {
try {
const currentPref = sessionStorage.getItem('t-dark');
if (currentPref && currentPref === 'true') {
return true;
} else if (
!currentPref &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
return true;
} else {
return false;
}
} catch (error) {
return false;
}
};
const toggleDarkTheme = isDark => {
if (isDark) {
document.documentElement.classList.add('dark-theme');
} else {
document.documentElement.classList.remove('dark-theme');
}
try {
sessionStorage.setItem('t-dark', isDark);
} catch (error) {
// do nothing.
}
};
const initThemeSelector = () => {
const themeButton = document.getElementsByClassName('theme-switcher')[0];
const prefersDarkTheme = window.matchMedia('(prefers-color-scheme: dark)');
if (getThemePreference()) {
toggleDarkTheme(true);
}
themeButton.addEventListener('click', changeTheme);
prefersDarkTheme.addListener(mediaQuery => {
toggleDarkTheme(mediaQuery.matches);
});
};
const init = () => {
if (isCSSVariablesSupported()) {
initThemeSelector();
}
};
init();

View file

@ -6,9 +6,12 @@ import Card from '../../components/Card.js';
import CategoryCard from './components/CategoryCard.js'; import CategoryCard from './components/CategoryCard.js';
import CategoryModal from './components/CategoryModal.js'; import CategoryModal from './components/CategoryModal.js';
import Messages from '../../components/Messages.js'; import Messages from '../../components/Messages.js';
import Sidebar from '../../components/Sidebar.js';
class App extends React.Component { class App extends React.Component {
selectCategory = ::this.selectCategory;
deselectCategory = ::this.deselectCategory;
deleteCategory = ::this.deleteCategory;
constructor(props) { constructor(props) {
super(props); super(props);
@ -20,15 +23,15 @@ class App extends React.Component {
}; };
} }
selectCategory = categoryId => { selectCategory(categoryId) {
this.setState({ selectedCategoryId: categoryId }); this.setState({ selectedCategoryId: categoryId });
}; }
deselectCategory = () => { deselectCategory() {
this.setState({ selectedCategoryId: null }); this.setState({ selectedCategoryId: null });
}; }
deleteCategory = categoryId => { deleteCategory(categoryId) {
const url = `/api/categories/${categoryId}/`; const url = `/api/categories/${categoryId}/`;
const options = { const options = {
method: 'DELETE', method: 'DELETE',
@ -56,7 +59,7 @@ class App extends React.Component {
text: 'Unable to remove category, try again later', text: 'Unable to remove category, try again later',
}; };
return this.setState({ selectedCategoryId: null, message: message }); return this.setState({ selectedCategoryId: null, message: message });
}; }
render() { render() {
const { categories } = this.state; const { categories } = this.state;
@ -66,7 +69,6 @@ class App extends React.Component {
key={category.pk} key={category.pk}
category={category} category={category}
showDialog={this.selectCategory} showDialog={this.selectCategory}
updateUrl={this.props.updateUrl}
/> />
); );
}); });
@ -78,7 +80,7 @@ class App extends React.Component {
const pageHeader = ( const pageHeader = (
<> <>
<h1 className="h1">Categories</h1> <h1 className="h1">Categories</h1>
<a className="link button button--confirm" href={`${this.props.createUrl}/`}> <a className="link button button--confirm" href="/categories/create/">
Create category Create category
</a> </a>
</> </>
@ -87,19 +89,15 @@ class App extends React.Component {
return ( return (
<> <>
{this.state.message && <Messages messages={[this.state.message]} />} {this.state.message && <Messages messages={[this.state.message]} />}
<Sidebar navLinks={this.props.navLinks} /> <Card header={pageHeader} />
{cards}
<div className="main__container"> {selectedCategory && (
<Card header={pageHeader} /> <CategoryModal
{cards} category={selectedCategory}
{selectedCategory && ( handleCancel={this.deselectCategory}
<CategoryModal handleDelete={this.deleteCategory}
category={selectedCategory} />
handleCancel={this.deselectCategory} )}
handleDelete={this.deleteCategory}
/>
)}
</div>
</> </>
); );
} }

View file

@ -11,7 +11,7 @@ const CategoryCard = props => {
if (rule.favicon) { if (rule.favicon) {
favicon = <img className="favicon" src={rule.favicon} />; favicon = <img className="favicon" src={rule.favicon} />;
} else { } else {
favicon = <i className="fas fa-image" />; favicon = <i className="gg-image" />;
} }
return ( return (
@ -31,10 +31,7 @@ const CategoryCard = props => {
const cardContent = <>{category.rules && <ul className="list">{categoryRules}</ul>}</>; const cardContent = <>{category.rules && <ul className="list">{categoryRules}</ul>}</>;
const cardFooter = ( const cardFooter = (
<> <>
<a <a className="link button button--primary" href={`/categories/${category.pk}/`}>
className="link button button--primary"
href={`${props.updateUrl}/${category.pk}/`}
>
Edit Edit
</a> </a>
<button <button

View file

@ -9,19 +9,5 @@ if (page) {
const dataScript = document.getElementById('categories-data'); const dataScript = document.getElementById('categories-data');
const categories = JSON.parse(dataScript.textContent); const categories = JSON.parse(dataScript.textContent);
let createUrl = document.getElementById('createUrl').textContent; ReactDOM.render(<App categories={categories} />, page);
let updateUrl = document.getElementById('updateUrl').textContent;
let linkScript = document.getElementById('Links');
let navLinks = JSON.parse(linkScript.textContent);
ReactDOM.render(
<App
categories={categories}
createUrl={createUrl.substring(1, createUrl.length - 2)}
updateUrl={updateUrl.substring(1, updateUrl.length - 4)}
navLinks={navLinks}
/>,
page
);
} }

View file

@ -1,17 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import Sidebar from '../../components/Sidebar';
const mainElements = [...document.getElementsByClassName('main')];
const mainElement = mainElements.find(element => element.dataset.renderSidebar);
if (mainElement) {
let linkScript = document.getElementById('Links');
let navLinks = JSON.parse(linkScript.textContent);
ReactDOM.render(
ReactDOM.createPortal(<Sidebar navLinks={navLinks} />, mainElement),
document.createElement('div')
);
}

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