Compare commits

..

32 commits

Author SHA1 Message Date
3a0f152ead Update select styling 2020-05-23 13:08:27 +02:00
e5903eebb2 Add card component 2020-05-23 13:01:43 +02:00
abd757f571 Refactor password confirm form 2020-05-23 12:15:30 +02:00
3ea359ff8a Redirect authenticated users to index 2020-05-23 12:08:19 +02:00
2e857818da Add help-text/label components 2020-05-23 12:04:07 +02:00
0a3abab6bc Add form padding mixin & update intro text 2020-05-23 11:59:37 +02:00
ae56d6d76b Split confirm/cancel buttons into components 2020-05-23 11:54:56 +02:00
b2eaf0411f Refactor settings page 2020-05-23 11:46:53 +02:00
edaa89a5d7 Refactor password-change view 2020-05-23 11:40:54 +02:00
2a74f91ad5 Remove old form components 2020-05-23 11:37:09 +02:00
ed372bc114 Move form components to components dir 2020-05-22 22:55:14 +02:00
86c6149327 Rename password-reset templates 2020-05-22 22:47:23 +02:00
274959475e Move login form 2020-05-22 22:26:19 +02:00
2c04f25da3 Refactor registration form 2020-05-22 22:23:29 +02:00
1c602e618a Refactor activation resend form 2020-05-21 22:40:20 +02:00
58b112dc47 Update submit button text's 2020-05-21 22:32:28 +02:00
9500a40363 Refactor loging form 2020-05-21 22:06:51 +02:00
4bec9a9079 Render hidden form fields & fix tests 2020-05-21 21:34:16 +02:00
d446746794 Move back password reset & registration templates 2020-05-21 20:49:11 +02:00
ad74254593 Move widget to forms.py 2020-05-21 17:49:53 +02:00
af8403f7ce Include whole context to pass csrf_token to form.html & set category on widget 2020-05-21 17:36:56 +02:00
c732fdc96e Fix RuleWidget 2020-05-21 17:11:06 +02:00
2d638cd977 Move registration & password-reset dirs to accounts app 2020-05-21 14:14:53 +02:00
ec99df3d3b Some more path refactoring 2020-05-21 14:03:13 +02:00
3144d86254 Refactor news:collection template paths 2020-05-21 13:47:58 +02:00
f97e495e72 Refactor news:core template paths 2020-05-21 13:44:04 +02:00
9df0a86790 Initial rule widget 2020-05-21 12:58:29 +02:00
ba2b5d0547 Update rules list view 2020-05-15 20:32:35 +02:00
1ec81ff48c Fix templates 2020-05-15 19:38:35 +02:00
880ae577bd Use seperate form dir 2020-05-14 21:08:38 +02:00
b866f48e3b Refactor rule create/update views 2020-05-14 20:40:24 +02:00
9a46fa7ab0 Initial form refactor 2020-05-14 20:20:09 +02:00
381 changed files with 17063 additions and 18754 deletions

11
.babelrc Normal file
View file

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

16
.coveragerc Normal file
View file

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

View file

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

3
.gitignore vendored
View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

10
docker/django Normal file
View file

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

9
docker/webpack Normal file
View file

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

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

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

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

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

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
python /app/src/manage.py migrate
python /app/src/manage.py runserver 0.0.0.0:8000

View file

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

View file

@ -1,36 +1,27 @@
from django import forms
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from django.contrib.auth.forms import UserChangeForm
from django.utils.translation import gettext as _
from django.utils.translation import ugettext as _
from newsreader.accounts.models import User
class UserAdminForm(UserChangeForm):
class Meta:
widgets = {
"email": forms.EmailInput(attrs={"size": "50"}),
}
class UserAdmin(DjangoUserAdmin):
class UserAdmin(admin.ModelAdmin):
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")},
{"fields": ("email", "first_name", "last_name", "is_active")},
),
(
_("Permission settings"),
{"classes": ("collapse",), "fields": ("is_staff", "is_superuser")},
{
"classes": ("collapse",),
"fields": ("is_staff", "is_superuser", "groups", "user_permissions"),
},
),
(_("Misc settings"), {"fields": ("date_joined", "last_login")}),
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 UserManager as DjangoUserManager
from django.db import models
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):
@ -39,13 +41,13 @@ class UserManager(DjangoUserManager):
class User(AbstractUser):
email = models.EmailField(_("email address"), unique=True)
# settings
auto_mark_read = models.BooleanField(
_("Auto read marking"),
default=True,
help_text=_(
"Wether posts should be marked as read after x amount of seconds of reading"
),
task = models.OneToOneField(
PeriodicTask,
on_delete=models.SET_NULL,
null=True,
blank=True,
editable=False,
verbose_name="collection task",
)
username = None
@ -55,8 +57,24 @@ class User(AbstractUser):
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []
def delete(self, *args, **kwargs):
tasks = PeriodicTask.objects.filter(name__contains=self.email)
tasks.delete()
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
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)

View file

@ -2,23 +2,17 @@
{% load i18n %}
{% block actions %}
<section class="section form__section--last">
<fieldset class="fieldset form__fieldset">
{% include "components/form/confirm-button.html" %}
<section class="section form__section--last">
<fieldset class="fieldset form__fieldset">
{% include "components/form/cancel-button.html" %}
</fieldset>
<a class="link button button--primary" href="{% url 'accounts:password-change' %}">
{% trans "Change password" %}
</a>
<fieldset class="fieldset form__fieldset">
<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>
{% include "components/form/confirm-button.html" %}
</fieldset>
</section>
{% endblock actions %}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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

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

View file

@ -1,21 +1,22 @@
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):
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):
user = UserFactory(email="durp@burp.nl")
interval = IntervalSchedule.objects.create(
every=1, period=IntervalSchedule.HOURS
)
PeriodicTask.objects.create(
name=f"{user.email}-feed", task="FeedTask", interval=interval
)
user = User.objects.create(email="durp@burp.nl", task=None)
user.delete()
self.assertEquals(PeriodicTask.objects.count(), 0)

View file

@ -1,8 +1,10 @@
from django.contrib.auth.decorators import login_required
from django.urls import include, path
from django.urls import path
from newsreader.accounts.views import (
FaviconRedirectView,
ActivationCompleteView,
ActivationResendView,
ActivationView,
LoginView,
LogoutView,
PasswordChangeView,
@ -10,21 +12,33 @@ from newsreader.accounts.views import (
PasswordResetConfirmView,
PasswordResetDoneView,
PasswordResetView,
RegistrationClosedView,
RegistrationCompleteView,
RegistrationView,
SettingsView,
)
settings_patterns = [
# Misc
path("favicon/", login_required(FaviconRedirectView.as_view()), name="favicon"),
path("", login_required(SettingsView.as_view()), name="home"),
]
urlpatterns = [
# Auth
path("login/", LoginView.as_view(), name="login"),
path("logout/", LogoutView.as_view(), name="logout"),
# 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/done/",
@ -46,6 +60,5 @@ urlpatterns = [
login_required(PasswordChangeView.as_view()),
name="password-change",
),
# Settings
path("settings/", include((settings_patterns, "settings"))),
path("settings/", login_required(SettingsView.as_view()), name="settings"),
]

View file

@ -0,0 +1,115 @@
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 django.views.generic.edit import FormView, ModelFormMixin
from registration.backends.default import views as registration_views
from newsreader.accounts.forms import UserSettingsForm
from newsreader.accounts.models import User
class LoginView(django_views.LoginView):
template_name = "accounts/views/login.html"
success_url = reverse_lazy("index")
class LogoutView(django_views.LogoutView):
next_page = reverse_lazy("accounts:login")
# RegistrationView shows a registration form and sends the email
# RegistrationCompleteView shows after filling in the registration form
# ActivationView is send within the activation email and activates the account
# ActivationCompleteView shows the success screen when activation was succesful
# ActivationResendView can be used when activation links are expired
# RegistrationClosedView shows when registration is disabled
class RegistrationView(registration_views.RegistrationView):
disallowed_url = reverse_lazy("accounts:register-closed")
template_name = "registration/registration_form.html"
success_url = reverse_lazy("accounts:register-complete")
class RegistrationCompleteView(TemplateView):
template_name = "registration/registration_complete.html"
class RegistrationClosedView(TemplateView):
template_name = "registration/registration_closed.html"
# Redirects or renders failed activation template
class ActivationView(registration_views.ActivationView):
template_name = "registration/activation_failure.html"
def get_success_url(self, user):
return ("accounts:activate-complete", (), {})
class ActivationCompleteView(TemplateView):
template_name = "registration/activation_complete.html"
# Renders activation form resend or resend_activation_complete
class ActivationResendView(registration_views.ResendActivationView):
template_name = "registration/activation_resend_form.html"
def render_form_submitted_template(self, form):
"""
Renders resend activation complete template with the submitted email.
"""
email = form.cleaned_data["email"]
context = {"email": email}
return render(
self.request, "registration/activation_resend_complete.html", context
)
# PasswordResetView sends the mail
# PasswordResetDoneView shows a success message for the above
# PasswordResetConfirmView checks the link the user clicked and
# prompts for a new password
# PasswordResetCompleteView shows a success message for the above
class PasswordResetView(django_views.PasswordResetView):
template_name = "password-reset/password-reset.html"
subject_template_name = "password-reset/password-reset-subject.txt"
email_template_name = "password-reset/password-reset-email.html"
success_url = reverse_lazy("accounts:password-reset-done")
class PasswordResetDoneView(django_views.PasswordResetDoneView):
template_name = "password-reset/password-reset-done.html"
class PasswordResetConfirmView(django_views.PasswordResetConfirmView):
template_name = "password-reset/password-reset-confirm.html"
success_url = reverse_lazy("accounts:password-reset-complete")
class PasswordResetCompleteView(django_views.PasswordResetCompleteView):
template_name = "password-reset/password-reset-complete.html"
class PasswordChangeView(django_views.PasswordChangeView):
template_name = "accounts/views/password-change.html"
success_url = reverse_lazy("accounts:settings")
class SettingsView(ModelFormMixin, FormView):
template_name = "accounts/views/settings.html"
success_url = reverse_lazy("accounts:settings")
form_class = UserSettingsForm
model = User
def get(self, request, *args, **kwargs):
self.object = self.get_object()
return super().get(request, *args, **kwargs)
def get_object(self, **kwargs):
return self.request.user
def get_form_kwargs(self):
return {**super().get_form_kwargs(), "instance": self.request.user}

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

View file

@ -1,25 +1,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:
from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.django import DjangoIntegration
except ImportError:
CeleryIntegration = None
DjangoIntegration = None
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
# SECURITY WARNING: don"t run with debug turned on in production!
DEBUG = True
BASE_DIR = get_root_dir()
DJANGO_PROJECT_DIR = BASE_DIR / "src" / "newsreader"
DEBUG = False
ALLOWED_HOSTS = get_env("ALLOWED_HOSTS", split=",", default=["127.0.0.1", "localhost"])
INTERNAL_IPS = get_env("INTERNAL_IPS", split=",", default=["127.0.0.1", "localhost"])
ALLOWED_HOSTS = ["127.0.0.1"]
INTERNAL_IPS = ["127.0.0.1"]
# Application definition
INSTALLED_APPS = [
@ -29,22 +22,20 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.forms",
# third party apps
"rest_framework",
"drf_yasg",
"celery",
"django_celery_beat",
"registration",
"axes",
# app modules
"newsreader.accounts",
"newsreader.utils",
"newsreader.news",
"newsreader.news.core",
"newsreader.news.collection",
]
SECRET_KEY = get_env("DJANGO_SECRET_KEY", default="")
AUTHENTICATION_BACKENDS = [
"axes.backends.AxesBackend",
"django.contrib.auth.backends.ModelBackend",
@ -63,15 +54,14 @@ MIDDLEWARE = [
ROOT_URLCONF = "newsreader.urls"
FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [DJANGO_PROJECT_DIR / "templates"],
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
@ -82,30 +72,31 @@ TEMPLATES = [
WSGI_APPLICATION = "newsreader.wsgi.application"
# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"HOST": get_env("POSTGRES_HOST", default=""),
"PORT": get_env("POSTGRES_PORT", default=""),
"NAME": get_env("POSTGRES_DB", default=""),
"USER": get_env("POSTGRES_USER", default=""),
"PASSWORD": get_env("POSTGRES_PASSWORD", default=""),
"HOST": os.environ.get("POSTGRES_HOST", ""),
"NAME": os.environ.get("POSTGRES_NAME", "newsreader"),
"USER": os.environ.get("POSTGRES_USER"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD"),
}
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": "memcached:11211",
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": "localhost:11211",
},
"axes": {
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": "memcached:11211",
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": "localhost:11211",
},
}
# Logging
# https://docs.djangoproject.com/en/2.2/topics/logging/#configuring-logging
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
@ -114,51 +105,61 @@ LOGGING = {
"require_debug_true": {"()": "django.utils.log.RequireDebugTrue"},
},
"formatters": {
"timestamped": {
"django.server": {
"()": "django.utils.log.ServerFormatter",
"format": "[{server_time}] {message}",
"style": "{",
},
"syslog": {"class": "logging.Formatter", "format": "{message}", "style": "{"},
},
"handlers": {
"console": {
"level": "INFO",
"filters": ["require_debug_true"],
"class": "logging.StreamHandler",
"formatter": "timestamped",
},
"file": {
"level": "DEBUG",
"class": "logging.handlers.RotatingFileHandler",
"filename": BASE_DIR / "logs" / "newsreader.log",
"backupCount": 5,
"maxBytes": 50000000, # 50 mB
"formatter": "timestamped",
},
"celery": {
"django.server": {
"level": "INFO",
"class": "logging.handlers.RotatingFileHandler",
"filename": BASE_DIR / "logs" / "celery.log",
"backupCount": 5,
"maxBytes": 50000000, # 50 mB
"formatter": "timestamped",
"class": "logging.StreamHandler",
"formatter": "django.server",
},
"mail_admins": {
"level": "ERROR",
"filters": ["require_debug_false"],
"class": "django.utils.log.AdminEmailHandler",
},
"syslog": {
"level": "INFO",
"filters": ["require_debug_false"],
"class": "logging.handlers.SysLogHandler",
"formatter": "syslog",
"address": "/dev/log",
},
"syslog_errors": {
"level": "ERROR",
"filters": ["require_debug_false"],
"class": "logging.handlers.SysLogHandler",
"formatter": "syslog",
"address": "/dev/log",
},
},
"loggers": {
"django": {"handlers": ["console"], "level": "INFO"},
"django": {
"handlers": ["console", "mail_admins", "syslog_errors"],
"level": "INFO",
},
"django.server": {
"handlers": ["console"],
"handlers": ["django.server"],
"level": "INFO",
"propagate": False,
},
"celery.task": {"handlers": ["console", "celery"], "level": "INFO"},
"newsreader": {
"handlers": ["console", "file"],
"level": "DEBUG",
"propagate": False,
},
"celery": {"handlers": ["syslog", "console"], "level": "INFO"},
"celery.task": {"handlers": ["syslog", "console"], "level": "INFO"},
},
}
# Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
@ -173,6 +174,8 @@ AUTH_USER_MODEL = "accounts.User"
LOGIN_REDIRECT_URL = "/"
# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "Europe/Amsterdam"
@ -180,31 +183,19 @@ USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "static"
STATICFILES_DIRS = (DJANGO_PROJECT_DIR / "static",)
STATIC_ROOT = os.path.join(BASE_DIR, "static")
STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static")]
# https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-STATICFILES_FINDERS
STATICFILES_FINDERS = [
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
]
# Email
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
DEFAULT_FROM_EMAIL = 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)
DEFAULT_FROM_EMAIL = "newsreader@rss.fudiggity.nl"
# Third party settings
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
@ -221,12 +212,7 @@ REST_FRAMEWORK = {
"rest_framework.permissions.IsAuthenticated",
"newsreader.accounts.permissions.IsOwner",
),
"DEFAULT_RENDERER_CLASSES": (
"djangorestframework_camel_case.render.CamelCaseJSONRenderer",
),
"DEFAULT_PARSER_CLASSES": (
"djangorestframework_camel_case.parser.CamelCaseJSONParser",
),
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
}
SWAGGER_SETTINGS = {
@ -237,15 +223,8 @@ SWAGGER_SETTINGS = {
# Celery
# https://docs.celeryproject.org/en/stable/userguide/configuration.html
# Note that celery settings are prefix with CELERY. See src/newsreader/celery.py.
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
CELERY_BROKER_URL = "amqp://guest@rabbitmq:5672"
# Sentry
SENTRY_CONFIG = {
"dsn": get_env("SENTRY_DSN", default="", required=False),
"send_default_pii": False,
"integrations": [DjangoIntegration(), CeleryIntegration()]
if DjangoIntegration and CeleryIntegration
else [],
}
REGISTRATION_OPEN = True
REGISTRATION_AUTO_LOGIN = True
ACCOUNT_ACTIVATION_DAYS = 7

View file

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

View file

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

View file

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

View file

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

View file

@ -1,32 +1,51 @@
from newsreader.conf.utils import get_env
import os
from .base import * # noqa: F403
from .utils import get_current_version
from dotenv import load_dotenv
from .base import * # isort:skip
load_dotenv()
DEBUG = False
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
ALLOWED_HOSTS = ["rss.fudiggity.nl"]
ADMINS = [
("", email) for email in get_env("ADMINS", split=",", required=False, default=[])
("", email)
for email in os.getenv("ADMINS", "").split(",")
if os.environ.get("ADMINS")
]
# Project settings
VERSION = get_current_version(debug=False)
ENVIRONMENT = "production"
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"HOST": os.environ["POSTGRES_HOST"],
"PORT": os.environ["POSTGRES_PORT"],
"NAME": os.environ["POSTGRES_NAME"],
"USER": os.environ["POSTGRES_USER"],
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
}
}
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]
},
}
]
# Third party settings
AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler"
# Optionally use sentry integration
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
REGISTRATION_OPEN = False

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -47,7 +47,7 @@
"user" : 2,
"succeeded" : true,
"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",
"website_url" : null,
"created" : "2019-07-14T13:08:10.374Z",
@ -65,7 +65,7 @@
"error" : null,
"user" : 2,
"succeeded" : true,
"last_run" : "2019-07-20T11:28:15.691Z",
"last_suceeded" : "2019-07-20T11:28:15.691Z",
"name" : "BBC",
"modified" : "2019-07-20T12:07:49.164Z",
"timezone" : "UTC",
@ -85,7 +85,7 @@
"website_url" : null,
"name" : "Ars Technica",
"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",
"user" : 2
},
@ -102,7 +102,7 @@
"user" : 2,
"name" : "The Guardian",
"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",
"created" : "2019-07-20T11:25:02.089Z",
"website_url" : null,
@ -119,7 +119,7 @@
"website_url" : null,
"created" : "2019-07-20T11:25:30.121Z",
"user" : 2,
"last_run" : "2019-07-20T11:28:15.860Z",
"last_suceeded" : "2019-07-20T11:28:15.860Z",
"succeeded" : true,
"modified" : "2019-07-20T12:07:28.473Z",
"name" : "Tweakers"
@ -139,7 +139,7 @@
"website_url" : null,
"timezone" : "UTC",
"user" : 2,
"last_run" : "2019-07-20T11:28:16.034Z",
"last_suceeded" : "2019-07-20T11:28:16.034Z",
"succeeded" : true,
"modified" : "2019-07-20T12:07:21.704Z",
"name" : "The Verge"

View file

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

View file

@ -3,24 +3,26 @@ import React from 'react';
class Messages extends React.Component {
state = { messages: this.props.messages };
close = index => {
close = ::this.close;
close(index) {
const newMessages = this.state.messages.filter((message, currentIndex) => {
return currentIndex != index;
});
this.setState({ messages: newMessages });
};
}
render() {
const messages = this.state.messages.map((message, index) => {
return (
<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>
);
});
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,4 +1,6 @@
class Selector {
onClick = ::this.onClick;
inputs = [];
constructor() {
@ -9,13 +11,13 @@ class Selector {
selectAllInput.onchange = this.onClick;
}
onClick = e => {
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/categories/index.js';
import './pages/rules/index.js';
import './pages/default/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 CategoryModal from './components/CategoryModal.js';
import Messages from '../../components/Messages.js';
import Sidebar from '../../components/Sidebar.js';
class App extends React.Component {
selectCategory = ::this.selectCategory;
deselectCategory = ::this.deselectCategory;
deleteCategory = ::this.deleteCategory;
constructor(props) {
super(props);
@ -20,15 +23,15 @@ class App extends React.Component {
};
}
selectCategory = categoryId => {
selectCategory(categoryId) {
this.setState({ selectedCategoryId: categoryId });
};
}
deselectCategory = () => {
deselectCategory() {
this.setState({ selectedCategoryId: null });
};
}
deleteCategory = categoryId => {
deleteCategory(categoryId) {
const url = `/api/categories/${categoryId}/`;
const options = {
method: 'DELETE',
@ -56,7 +59,7 @@ class App extends React.Component {
text: 'Unable to remove category, try again later',
};
return this.setState({ selectedCategoryId: null, message: message });
};
}
render() {
const { categories } = this.state;
@ -66,7 +69,6 @@ class App extends React.Component {
key={category.pk}
category={category}
showDialog={this.selectCategory}
updateUrl={this.props.updateUrl}
/>
);
});
@ -78,7 +80,7 @@ class App extends React.Component {
const pageHeader = (
<>
<h1 className="h1">Categories</h1>
<a className="link button button--confirm" href={`${this.props.createUrl}/`}>
<a className="link button button--confirm" href="/core/categories/create/">
Create category
</a>
</>
@ -87,19 +89,15 @@ class App extends React.Component {
return (
<>
{this.state.message && <Messages messages={[this.state.message]} />}
<Sidebar navLinks={this.props.navLinks} />
<div className="main__container">
<Card header={pageHeader} />
{cards}
{selectedCategory && (
<CategoryModal
category={selectedCategory}
handleCancel={this.deselectCategory}
handleDelete={this.deleteCategory}
/>
)}
</div>
<Card header={pageHeader} />
{cards}
{selectedCategory && (
<CategoryModal
category={selectedCategory}
handleCancel={this.deselectCategory}
handleDelete={this.deleteCategory}
/>
)}
</>
);
}

View file

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

View file

@ -9,19 +9,5 @@ if (page) {
const dataScript = document.getElementById('categories-data');
const categories = JSON.parse(dataScript.textContent);
let createUrl = document.getElementById('createUrl').textContent;
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
);
ReactDOM.render(<App categories={categories} />, 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