Compare commits
512 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e40d69d5ff | |||
| 83707701e9 | |||
| 116e2c1577 | |||
| cf96371b90 | |||
| eadd7a5612 | |||
| 62053a1048 | |||
| b4340176da | |||
| 433ff9413d | |||
| 91949622b7 | |||
| 10affeb32f | |||
| e96c6f3528 | |||
| a534a3b691 | |||
| ebbbe99eaf | |||
| c7f90e233e | |||
| 9ba6824dd3 | |||
| 4c5d3aec28 | |||
| dd9aaf467e | |||
| 1417c52007 | |||
| bfd081337b | |||
| b8559f0499 | |||
| b465d0bb8d | |||
| 1a54fdbcd1 | |||
| 34afcc02b6 | |||
| 1574661c57 | |||
| 3160becb72 | |||
| 105371abaf | |||
| ed37be0c60 | |||
| 161234defd | |||
| f3ba0f1d09 | |||
| aff565862c | |||
| bf43603d65 | |||
| 91a7f6325c | |||
| e33497569a | |||
| 2d5801f226 | |||
| 89d4ebdc49 | |||
| 174912a967 | |||
| bb92f07f00 | |||
| fa491120a0 | |||
| ccde406193 | |||
| a498417bad | |||
| 16ebf3bdb3 | |||
| 99c232fea2 | |||
| fbb6405da9 | |||
| 03b5847641 | |||
| dfb049ae14 | |||
| b78f03d3b0 | |||
| e09b3d6e4c | |||
| cc5b4cc0bb | |||
| 70a0d5a96d | |||
| cc8aafa310 | |||
| 57375591b5 | |||
| bb74e875e0 | |||
| bc8ec0257e | |||
| a041d5f7fa | |||
| e95c2a440e | |||
| 5fc0742688 | |||
| f5f7f99f71 | |||
| 284f64d202 | |||
| b34bef899c | |||
| aa0a29fefb | |||
| 2a5372166e | |||
| fd3bf4f542 | |||
| c7fb545096 | |||
| c7aa431e4a | |||
| 3152c8f14e | |||
| 9e6be5c807 | |||
| 106bd6cb4c | |||
| 040193a3ed | |||
| d8b04b3329 | |||
| b6805c1675 | |||
| 07c685401f | |||
| 8b080a3cee | |||
| 12c1ac9d17 | |||
| 67d7b10632 | |||
| 1b8b9dcd41 | |||
| 35c9e78809 | |||
| 4935d7d186 | |||
| 2b3e35078d | |||
| d05e29b5e0 | |||
| e9e8fc351c | |||
| 16168cc9d9 | |||
| 9097caf438 | |||
| 0f89fc2447 | |||
| b36bf4e0bc | |||
| 40749403b9 | |||
| 15884d3b4e | |||
| 40a0b72d87 | |||
| a4f5a7bdd7 | |||
| fedeed15c5 | |||
| ff6dfcaa05 | |||
| 2790e9c82e | |||
| f0689ebfab | |||
| 41f249ed5a | |||
| 8e04436b68 | |||
| 5b59b189d6 | |||
| 8e728200ec | |||
| 8e7b059ad3 | |||
| df848b1e43 | |||
| e80579af4b | |||
| d479b5e5f7 | |||
| b06af33a19 | |||
| 858c2c6eb3 | |||
| 72f8426f72 | |||
| 1aea2df2ea | |||
| 492b8d33ff | |||
| cbc6a73b76 | |||
| 4b04178a4f | |||
| ba4b17a8e2 | |||
| 70a1ae306b | |||
| b91f5c8939 | |||
| 5a73707d61 | |||
| 0f66c5eb9b | |||
| 7f4a3a3e49 | |||
| 9258d33f4e | |||
| a9741d4063 | |||
| 61827b955d | |||
| b03f2fc902 | |||
| 89d88ccceb | |||
| 2a0c0072a4 | |||
| 6a46dc01e2 | |||
| 60af3ba4f6 | |||
| 65dae40e9a | |||
| bfacd97c73 | |||
| 3ebba6df47 | |||
| b8a9d885f5 | |||
| fd5f910ac0 | |||
| 6ac4e5d5c2 | |||
| ef0c070755 | |||
| 59f719d7c3 | |||
| 720f6fdb78 | |||
| 82a7176629 | |||
| 89f23fe668 | |||
| 12b4aa0b91 | |||
| c48de9c6e1 | |||
| e5220eb9a5 | |||
| 1f0a8a54da | |||
| bea7afb355 | |||
| bd48634509 | |||
| d3f9a11f44 | |||
| 9d05cac15c | |||
| 20309e70fa | |||
| 53aa8da2dd | |||
| f5b708aafe | |||
| 85e152f6e8 | |||
| da05b3ac2e | |||
| e6cfef8d96 | |||
| 04d95386f5 | |||
| 879c6ebc90 | |||
| 6b2c4996d5 | |||
| 7d36763eff | |||
| ba5001fafa | |||
| 106a087291 | |||
| 426f857f05 | |||
| 510f7187a8 | |||
| ea541bfe64 | |||
| 83829b7d19 | |||
| b67724220a | |||
| 8498303006 | |||
| 8f37eec519 | |||
| 48d48885c4 | |||
| 9e572534aa | |||
| 392900956c | |||
| b106ebf827 | |||
| f326a4c923 | |||
| fee2a4f17b | |||
| db5780f9f1 | |||
| d89e1bc6d4 | |||
| 9c5378cf67 | |||
| 02cbaeb491 | |||
| 9b1408160d | |||
| 8af8dab6db | |||
| 782671542f | |||
| e42653f7fd | |||
| 223656f2d2 | |||
| e008d2f53c | |||
| 501022db3b | |||
| 3c4e659bc8 | |||
| 104a5575fa | |||
| 0241de95cd | |||
| d1badbef30 | |||
| ecb99425e0 | |||
| 2afeb3c102 | |||
| 1a1bfdfbab | |||
| 1e8a3aedb1 | |||
| 5603b23468 | |||
| 0ba632a2a6 | |||
| 813222073e | |||
| 712f601e9c | |||
| 2ed828a243 | |||
| 679dc2a0d6 | |||
| f02a7b6eb7 | |||
| 101058672b | |||
| 73bac1301b | |||
| 3aa3c29613 | |||
| 146401117b | |||
| 1909916b64 | |||
| 6d09629c8e | |||
| e99b6653d8 | |||
| 85e02a8147 | |||
| 6db7d6c3f5 | |||
| 4d0613df2e | |||
| 18dbf2d312 | |||
| 0e108c8110 | |||
| c53e9756dd | |||
| ab1e4c44ec | |||
| 8c69e4a27e | |||
| 66edc1e8dd | |||
| 0eefafe3db | |||
| 9c88cfde59 | |||
| a24d06b257 | |||
| fef4729e0b | |||
| 8b7850b17b | |||
| c61ce0bcb7 | |||
| 091bcdbef3 | |||
| 90cb3ad1d4 | |||
| dfa43fa8a2 | |||
| 91d1757bde | |||
| 00164bd3b5 | |||
| f0a1179d23 | |||
| ee5f59fd7c | |||
| 039e8b803d | |||
| 439a54c0ce | |||
| cf078ee42a | |||
| 9e25f14c73 | |||
| 282d64a923 | |||
| 9095f35545 | |||
| 4496972205 | |||
| d2a1fd7f3a | |||
| e251f633e1 | |||
| 0ac8842431 | |||
| 55eee6c6ed | |||
| 01f86399b2 | |||
| 32e85ec05e | |||
| 4cb3846e36 | |||
| 06e4ea33b5 | |||
| ceaee1165b | |||
| db25e240e3 | |||
| 73401b6ca3 | |||
| 517a89d2da | |||
| 04af0f9c5d | |||
| 57dcabd685 | |||
| f98220f8cc | |||
| 73e823bb05 | |||
| 06bc705c00 | |||
| 4cdb16b2c1 | |||
| 116b6d1308 | |||
| c1d11ae94e | |||
| 29f20cca24 | |||
| ffefc76acc | |||
| 9ad6a1a7b8 | |||
| 6ae3b5c508 | |||
| 0a1bf0d5e6 | |||
| aaef828837 | |||
| ee9b36d8ae | |||
| 00e0705d12 | |||
| ccc9726c8a | |||
| ec4f1c9300 | |||
| 9e5e05c056 | |||
| 51ffd82648 | |||
| ab7a4d9a8a | |||
| 003889d29e | |||
| 195597afa0 | |||
| 4b9de97d70 | |||
| 763d8ee093 | |||
| b0c6714002 | |||
| b6921a20e7 | |||
| 1c3a33c1d8 | |||
| 593b06006c | |||
| f12639987f | |||
| 48388a47f6 | |||
| 77103eb680 | |||
| ca5c2f6b55 | |||
| d228dc5f45 | |||
| d4a41a62da | |||
| 576ab9a917 | |||
| 40a027587b | |||
| a7b4271a7d | |||
| 6120b26a44 | |||
| 4074df3f09 | |||
| 805321f66d | |||
| 84802fd48b | |||
| 65e4f3bb80 | |||
| f0df342f61 | |||
| 47eaef40b3 | |||
| 0d9163d363 | |||
| 64a3d2aab5 | |||
| b035526848 | |||
| af7fbaf1e8 | |||
| 30bd140483 | |||
| 7ee5ad7879 | |||
| 1429e5a7ec | |||
| e0af3dcc20 | |||
| c94158a3a6 | |||
| 34c5318c42 | |||
| 52a71a3f4e | |||
| d14aff1baa | |||
| 03ac016dd3 | |||
| e58c5a4559 | |||
| 128284dca3 | |||
| ad51d17d2d | |||
| bd9573cebc | |||
| 285da805cb | |||
| aff108d7fc | |||
| 6fb848d90e | |||
| cad6a62a17 | |||
| a820155fc0 | |||
| 78bc696294 | |||
| 286971649a | |||
| 4bca6a432f | |||
| 7adb1cddb8 | |||
| cba167c98c | |||
| bea0257cae | |||
| 7af681887f | |||
| 6ff996b674 | |||
| 7dab98ef5a | |||
| 7d803bbfa0 | |||
| 5a7222d9a4 | |||
| fd432a87d1 | |||
| b25cdcf4db | |||
| a1c11baa3d | |||
| fd16478909 | |||
| 6a13c58712 | |||
| 629b35b69b | |||
| c04f23b3ee | |||
| 2b10612304 | |||
| c52c30fb08 | |||
| 632b3b14f1 | |||
| ac9e6a7224 | |||
| 5ec7239a1b | |||
| f513a494d9 | |||
| 9d6a79d55d | |||
| 2d640eac04 | |||
| 4df50b3a16 | |||
| 96422a1532 | |||
| 2a40311a87 | |||
| 2e56d3208b | |||
| 96f016260d | |||
| 1cae7e0b06 | |||
| a6827e604d | |||
| e73edd5083 | |||
| a3a812b949 | |||
| 9a212c6288 | |||
| 0f5e9e7fca | |||
| 296202b69f | |||
| 863e8671da | |||
| 21dff8eb15 | |||
| b5b174a213 | |||
| ec45a661e5 | |||
| e8947d1182 | |||
| ed8584603e | |||
| 24b1704da6 | |||
| 5ce5c5cfe1 | |||
| 046117cfb3 | |||
| fb03d6b86f | |||
| 7d28dd854f | |||
| 7cef924c37 | |||
| e57a4c656d | |||
| 854d086340 | |||
| 177755e302 | |||
| d4b58624d6 | |||
| ec72827bd8 | |||
| 6ce013d0d4 | |||
| 6f30571dd1 | |||
| 391796a0c0 | |||
| 03d673e6c0 | |||
| 78608b2b57 | |||
| 04043fbe98 | |||
| 2254f22023 | |||
| fcf49fa123 | |||
| 73ddb785e0 | |||
| 3c080e1300 | |||
| 1993338120 | |||
| 6661b69094 | |||
| eb09c4f729 | |||
| 2be35bce53 | |||
| 017dd9a582 | |||
| bcd051e992 | |||
| a46b6fade5 | |||
| 0bd47f1bb0 | |||
| 68bba5f835 | |||
| 00f6427c57 | |||
| 722afe8c12 | |||
| 083f728404 | |||
| c8771cb272 | |||
| d3ccc16f81 | |||
| f2200754e8 | |||
| 061a2e852d | |||
| 4dfa755881 | |||
| 18c0046e61 | |||
| 86ced1a62b | |||
| 6609b1306b | |||
| 3b2384a266 | |||
| 2d3eae6e39 | |||
| cbc6d646ce | |||
| 5e13de6c8b | |||
| 04ff905ae5 | |||
| ddfd208d1c | |||
| 27ed0259a1 | |||
| a22ef354be | |||
| 61ddef3b63 | |||
| e3840342b3 | |||
| a4b5373ed2 | |||
| 13d33749da | |||
| bb47e2af8d | |||
| f4adb9635a | |||
| aeb85bd2cf | |||
| 69eaedf89c | |||
| 3c613b59e2 | |||
| ed415f2b5c | |||
| d6d19fa9b9 | |||
| 7ee727e96e | |||
| 9285672273 | |||
| bec3488e63 | |||
| 7fc899937d | |||
| d61b3a9498 | |||
| 57e9073f6b | |||
| 62da6e0d8e | |||
| b811d1945b | |||
| 3152254f43 | |||
| e9f05868c1 | |||
| 708076b2ab | |||
| 992df528f6 | |||
| 6ff26c71a0 | |||
| 9b56cf53e3 | |||
| 6f00da37b9 | |||
| cac71d5475 | |||
| 7d86cea6ec | |||
| e495d7c188 | |||
| cda2654573 | |||
| ae942f5ef9 | |||
| 73ae0271e4 | |||
| 66408cc218 | |||
| b28d42b97b | |||
| 16230a11f3 | |||
| a3a2033e37 | |||
| 7c888c1461 | |||
| ab5d9ea46d | |||
| dc2fb681d0 | |||
| fdb90525a8 | |||
| 6bfb84dab9 | |||
| b5b59c5baf | |||
| 770ace2f5d | |||
| f6100416c3 | |||
| 99ba773b91 | |||
| fdec1efcf0 | |||
| 9ecca7a80b | |||
| e4e4e97cfd | |||
| 420481f18a | |||
| 4da301eb3e | |||
| 4345a006a6 | |||
| 533561ba1e | |||
| afc3c11775 | |||
| 8b47fd8216 | |||
| a87d4f387f | |||
| acd9bd30cb | |||
| c22fdfe4ce | |||
| 21b9e4f0fd | |||
| a5de001f35 | |||
| b3bff398d9 | |||
|
|
61bc7e9b04 | ||
| 961535dd60 | |||
| ab0b24b3d2 | |||
| 3045702a1e | |||
| 5976870d38 | |||
| ee22e0a0ae | |||
| 61e45ed0cc | |||
| 6c6ea6c481 | |||
| c13f968234 | |||
| 36076dbc40 | |||
| f9bce3507c | |||
| e1e6571bb0 | |||
| c3b087e004 | |||
| 62e763604e | |||
| 12693dac10 | |||
| 06ce82bf0c | |||
| 28620eab29 | |||
| 68534cd541 | |||
| 5a630ea4b3 | |||
| 8acf3bbc7f | |||
| 38d9d74db4 | |||
| d345bc2595 | |||
| 08e4d043b9 | |||
| a9edd520a7 | |||
| a4102130d2 | |||
| b2829716b0 | |||
| 94f4ed6327 | |||
| b952d70d92 | |||
| a350cc280d | |||
| bb0c2e792c | |||
| fb709fe1d0 | |||
| 858f84aaad | |||
| 61702e720a | |||
| 752ba62aee | |||
| 8c3bc408b9 | |||
| 0658d6404f | |||
| 679414a703 | |||
| b1c5be61f1 | |||
| a74ffae9a7 | |||
| 1b774a7208 | |||
| 8f314e0ca6 | |||
| a95db91726 | |||
| 88cf5f9bd4 | |||
| a798b9858c | |||
| 9c6be7357d | |||
| 982c5bb132 | |||
| ed658c4dfd | |||
| cfd064ec85 | |||
| 48a9b25545 | |||
| c75de1c469 | |||
| 69e4e7b269 | |||
| c508cca080 | |||
| abacb72b30 |
381 changed files with 18195 additions and 16759 deletions
11
.babelrc
11
.babelrc
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"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
16
.coveragerc
|
|
@ -1,16 +0,0 @@
|
|||
[run]
|
||||
source = ./src/newsreader/
|
||||
omit =
|
||||
**/tests/**
|
||||
**/migrations/**
|
||||
**/conf/**
|
||||
**/apps.py
|
||||
**/admin.py
|
||||
**/tests.py
|
||||
**/urls.py
|
||||
**/wsgi.py
|
||||
**/celery.py
|
||||
**/__init__.py
|
||||
|
||||
[html]
|
||||
directory = coverage
|
||||
25
.editorconfig
Normal file
25
.editorconfig
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# https://editorconfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# Unix-style newlines with a newline ending every file
|
||||
[*]
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.py]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{yaml,yml,toml,md}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[Dockerfile*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.json]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -35,6 +35,7 @@ eggs/
|
|||
|
||||
lib/
|
||||
!src/newsreader/scss/lib
|
||||
!src/newsreader/js/lib
|
||||
|
||||
lib64/
|
||||
parts/
|
||||
|
|
@ -114,7 +115,7 @@ celerybeat-schedule
|
|||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
*.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
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
12
.isort.cfg
|
|
@ -1,12 +0,0 @@
|
|||
[settings]
|
||||
include_trailing_comma = true
|
||||
line_length = 88
|
||||
multi_line_output = 3
|
||||
skip = env/, venv/
|
||||
default_section = THIRDPARTY
|
||||
known_first_party = newsreader
|
||||
known_django = django
|
||||
sections = FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
|
||||
lines_between_types=1
|
||||
lines_after_imports=2
|
||||
lines_between_types=1
|
||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
lts/*
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 90,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
10
.woodpecker/build.yaml
Normal file
10
.woodpecker/build.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
when:
|
||||
- event: push
|
||||
- event: pull_request
|
||||
- event: manual
|
||||
|
||||
steps:
|
||||
- image: node:lts-alpine
|
||||
commands:
|
||||
- npm install
|
||||
- npm run build:prod
|
||||
18
.woodpecker/lint.yaml
Normal file
18
.woodpecker/lint.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
when:
|
||||
- event: push
|
||||
- event: pull_request
|
||||
- event: manual
|
||||
|
||||
steps:
|
||||
- name: python linting
|
||||
image: ghcr.io/astral-sh/uv:python3.11-alpine
|
||||
commands:
|
||||
- uv sync --group ci
|
||||
- uv run --no-sync -- ruff check src/
|
||||
- uv run --no-sync -- ruff format --check src/
|
||||
|
||||
- name: javascript linting
|
||||
image: node:lts-alpine
|
||||
commands:
|
||||
- npm ci
|
||||
- npm run lint
|
||||
37
.woodpecker/tests.yaml
Normal file
37
.woodpecker/tests.yaml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
when:
|
||||
- event: push
|
||||
- event: pull_request
|
||||
- event: manual
|
||||
|
||||
services:
|
||||
- name: postgres
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_NAME: &db-name newsreader
|
||||
POSTGRES_USER: &db-user newsreader
|
||||
POSTGRES_PASSWORD: &db-password sekrit
|
||||
- name: memcached
|
||||
image: memcached:1.5.22
|
||||
|
||||
steps:
|
||||
- name: python tests
|
||||
image: ghcr.io/astral-sh/uv:python3.11-alpine
|
||||
environment:
|
||||
DJANGO_SETTINGS_MODULE: "newsreader.conf.ci"
|
||||
DJANGO_SECRET_KEY: sekrit
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: 5432
|
||||
POSTGRES_DB: *db-name
|
||||
POSTGRES_USER: *db-user
|
||||
POSTGRES_PASSWORD: *db-password
|
||||
commands:
|
||||
- pip install uv
|
||||
- uv sync --group ci
|
||||
- uv run --no-sync -- coverage run ./src/manage.py test newsreader
|
||||
- uv run --no-sync -- coverage report --show-missing
|
||||
|
||||
- name: javascript tests
|
||||
image: node:lts-alpine
|
||||
commands:
|
||||
- npm ci
|
||||
- npm test
|
||||
149
CHANGELOG.md
Normal file
149
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# Changelog
|
||||
|
||||
## 0.5.3
|
||||
|
||||
- Apply query optimizations for retrieving posts
|
||||
|
||||
## 0.5.2
|
||||
|
||||
- Add missing `VERSION` environment variable
|
||||
|
||||
## 0.5.1
|
||||
|
||||
- Use line-through styling for read posts
|
||||
- Use full height for post layout
|
||||
|
||||
## 0.5.0
|
||||
|
||||
- Upgrade python to 3.11
|
||||
- Upgrade django to 4.2
|
||||
- Migrate from pip-tools to uv
|
||||
- Migrate from black to ruff for formatting
|
||||
- Upgrade webpack to 5.9 (with various tooling)
|
||||
- Styling refactor
|
||||
- Mobile/tablet layout added
|
||||
|
||||
## 0.4.4
|
||||
|
||||
- Sort posts before storing in redux store
|
||||
|
||||
## 0.4.3
|
||||
|
||||
- Use `IntersectionObserver` to paginate
|
||||
|
||||
## 0.4.2
|
||||
|
||||
- Set `SECURE_PROXY_SSL_HEADER` setting for production
|
||||
|
||||
## 0.4.1
|
||||
|
||||
- Add missing env variables
|
||||
|
||||
## 0.4.0
|
||||
|
||||
- Add Makefile & use `pip-tools` to generate dependencies
|
||||
- Add `pyproject.toml`
|
||||
- Update dependencies
|
||||
- Update docker-compose setup
|
||||
- Default to `newsreader.conf.docker` settings module
|
||||
- Add scroll to top/bottom buttons
|
||||
|
||||
## 0.3.13.8
|
||||
|
||||
- Update dependencies
|
||||
- Fix csrf_token's not rendering
|
||||
|
||||
## 0.3.13.7
|
||||
|
||||
- Check for Twitter error codes in response
|
||||
|
||||
## 0.3.13.6
|
||||
|
||||
- Try to load sentry by default for all environments
|
||||
|
||||
## 0.3.13.5
|
||||
|
||||
- Set response keyword argument
|
||||
|
||||
## 0.3.13.4
|
||||
|
||||
- Fix import error
|
||||
|
||||
## 0.3.13.3
|
||||
|
||||
- Use sentry's set_extra to provide extra debug variables
|
||||
|
||||
## 0.3.13.2
|
||||
|
||||
- Update sentry-sdk
|
||||
|
||||
## 0.3.13.1
|
||||
|
||||
- Fix mutual exclusive exception for email settings
|
||||
- Temporarly set exception level for StreamDeniedException exceptions
|
||||
|
||||
## 0.3.13
|
||||
|
||||
- Update django to 3.2
|
||||
- Notify users of expired credentials
|
||||
|
||||
## 0.3.12.1
|
||||
|
||||
- Add missing background-color
|
||||
|
||||
## 0.3.12
|
||||
|
||||
- Update light theme
|
||||
- Sticky navbar
|
||||
- Sticky post modal header
|
||||
|
||||
## 0.3.11
|
||||
|
||||
- Add saved posts section
|
||||
- Bump django version
|
||||
|
||||
## 0.3.10
|
||||
|
||||
- Add custom color for confirm buttons
|
||||
- Update font sizes
|
||||
|
||||
## 0.3.9
|
||||
|
||||
- Cursor based pagination
|
||||
- Updated django version
|
||||
|
||||
## 0.3.8
|
||||
|
||||
- Update light / dark theme
|
||||
- Replace css.gg with fontawesome
|
||||
- Update deploy job
|
||||
|
||||
## 0.3.7
|
||||
|
||||
- Add a dark theme
|
||||
- Update object representations
|
||||
- Move sentry to optional dependency
|
||||
- Add CHANGELOG.md
|
||||
|
||||
## 0.3.6.3
|
||||
|
||||
- Update deploy job
|
||||
|
||||
## 0.3.6.2
|
||||
|
||||
- Use warning logging level for BuilderSkippedException's
|
||||
- Change working directory before running ansible
|
||||
|
||||
## 0.3.6.1
|
||||
|
||||
- Install ansible required roles
|
||||
|
||||
## 0.3.6
|
||||
|
||||
- Update deploy job
|
||||
- Add user manageable reddit filters
|
||||
|
||||
## 0.3.5
|
||||
|
||||
- Show timezone next to post datetimes
|
||||
- Take read status in consideration when sorting posts
|
||||
84
Dockerfile
Normal file
84
Dockerfile
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# stage 1
|
||||
FROM python:3.11-alpine AS backend
|
||||
|
||||
ARG USER_ID=1000
|
||||
ARG GROUP_ID=1000
|
||||
ARG UV_LINK_MODE=copy
|
||||
|
||||
RUN apk update \
|
||||
&& apk add --no-cache \
|
||||
vim \
|
||||
curl \
|
||||
gettext
|
||||
|
||||
RUN addgroup -g $USER_ID newsreader && adduser -Du $GROUP_ID -G newsreader newsreader
|
||||
|
||||
RUN mkdir --parents /app/src /app/logs /app/media /app/bin /app/static \
|
||||
&& chown -R newsreader:newsreader /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
USER newsreader
|
||||
|
||||
COPY --chown=newsreader:newsreader uv.lock pyproject.toml /app/
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:python3.11-alpine /usr/local/bin/uv /bin/uv
|
||||
|
||||
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
|
||||
uv sync --frozen --no-default-groups --no-install-project
|
||||
|
||||
COPY --chown=newsreader:newsreader ./bin/docker-entrypoint.sh /app/bin/docker-entrypoint.sh
|
||||
|
||||
VOLUME ["/app/logs", "/app/media", "/app/static"]
|
||||
|
||||
|
||||
|
||||
# stage 2
|
||||
FROM node:lts-alpine AS frontend-build
|
||||
|
||||
ARG BUILD_ARG=prod
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN chown node:node /app
|
||||
|
||||
USER node
|
||||
|
||||
COPY --chown=node:node ./package*.json ./webpack.*.js ./babel.config.js /app/
|
||||
|
||||
RUN --mount=type=cache,uid=1000,gid=1000,target=/home/node/.npm \
|
||||
npm ci
|
||||
|
||||
COPY --chown=node:node ./src /app/src
|
||||
|
||||
RUN npm run build:$BUILD_ARG
|
||||
|
||||
|
||||
|
||||
# stage 3
|
||||
FROM backend AS production
|
||||
|
||||
COPY --from=frontend-build --chown=newsreader:newsreader \
|
||||
/app/src/newsreader/static /app/src/newsreader/static
|
||||
|
||||
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
|
||||
uv sync --frozen --only-group production --extra sentry
|
||||
|
||||
COPY --chown=newsreader:newsreader ./src /app/src
|
||||
|
||||
ENV DJANGO_SETTINGS_MODULE=newsreader.conf.production
|
||||
|
||||
# Note that the static volume will have to be recreated to be pre-populated
|
||||
# correctly with the latest static files. See
|
||||
# https://docs.docker.com/storage/volumes/#populate-a-volume-using-a-container
|
||||
RUN uv run --no-sync -- src/manage.py collectstatic --noinput
|
||||
|
||||
|
||||
|
||||
# (optional) stage 4
|
||||
FROM backend AS development
|
||||
|
||||
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
|
||||
uv sync --frozen --group development
|
||||
|
||||
ENV DJANGO_SETTINGS_MODULE=newsreader.conf.docker
|
||||
21
babel.config.js
Normal file
21
babel.config.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
5
bin/docker-entrypoint.sh
Executable file
5
bin/docker-entrypoint.sh
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
#!/bin/bash
|
||||
|
||||
uv run --no-sync -- /app/src/manage.py migrate
|
||||
|
||||
exec "$@"
|
||||
21
config/nginx/conf.d/local.conf
Normal file
21
config/nginx/conf.d/local.conf
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
36
docker-compose.development.yml
Normal file
36
docker-compose.development.yml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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
|
||||
16
docker-compose.production.yml
Normal file
16
docker-compose.production.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
volumes:
|
||||
logs:
|
||||
static-files:
|
||||
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:1.23
|
||||
depends_on:
|
||||
django:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${NGINX_HTTP_PORT:-80}:80"
|
||||
volumes:
|
||||
- ./config/nginx/conf.d:/etc/nginx/conf.d
|
||||
- logs:/var/log/nginx
|
||||
- static-files:/app/static
|
||||
|
|
@ -1,56 +1,126 @@
|
|||
version: '3'
|
||||
volumes:
|
||||
logs:
|
||||
media:
|
||||
postgres-data:
|
||||
static-files:
|
||||
node-modules:
|
||||
|
||||
x-db-connection-env: &db-connection-env
|
||||
POSTGRES_HOST: ${POSTGRES_HOST:-db}
|
||||
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
|
||||
POSTGRES_DB: &pg-database ${POSTGRES_DB:-newsreader}
|
||||
POSTGRES_USER: &pg-user ${POSTGRES_USER:-newsreader}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-newsreader}
|
||||
|
||||
x-db-env: &db-env
|
||||
<<: *db-connection-env
|
||||
PGUSER: *pg-user
|
||||
PGDATABASE: *pg-database
|
||||
|
||||
x-django-env: &django-env
|
||||
<<: *db-connection-env
|
||||
|
||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,django}
|
||||
INTERNAL_IPS: ${INTERNAL_IPS:-localhost,127.0.0.1,django}
|
||||
|
||||
# see token_urlsafe from python's secret module to generate one
|
||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-Ojg68lYsP3kq2r5JgozUzKVSRFywm17BTMS5iwpLM44}
|
||||
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-newsreader.conf.production}
|
||||
|
||||
ADMINS: ${ADMINS:-""}
|
||||
|
||||
VERSION: ${VERSION:-""}
|
||||
|
||||
# Email
|
||||
EMAIL_HOST: ${EMAIL_HOST:-localhost}
|
||||
EMAIL_PORT: ${EMAIL_PORT:-25}
|
||||
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-""}
|
||||
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-""}
|
||||
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-no}
|
||||
EMAIL_USE_SSL: ${EMAIL_USE_SSL:-no}
|
||||
EMAIL_DEFAULT_FROM: ${EMAIL_DEFAULT_FROM:-webmaster@localhost}
|
||||
|
||||
# Sentry
|
||||
SENTRY_DSN: ${SENTRY_DSN:-""}
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
environment:
|
||||
POSTGRES_DB: "newsreader"
|
||||
POSTGRES_USER: "newsreader"
|
||||
POSTGRES_PASSWORD: "newsreader"
|
||||
<<: *db-env
|
||||
image: postgres:15
|
||||
healthcheck:
|
||||
test: /usr/bin/pg_isready
|
||||
start_period: 10s
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3.7
|
||||
image: rabbitmq:4
|
||||
|
||||
memcached:
|
||||
image: memcached:1.5.22
|
||||
ports:
|
||||
- "11211:11211"
|
||||
image: memcached:1.6
|
||||
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:
|
||||
context: .
|
||||
dockerfile: ./docker/django
|
||||
command: celery worker --app newsreader --loglevel INFO --beat --scheduler django --workdir /app/src/
|
||||
<<: *app-build
|
||||
environment:
|
||||
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
|
||||
<<: *django-env
|
||||
command: |
|
||||
uv run --no-sync --
|
||||
celery
|
||||
--app newsreader
|
||||
--workdir /app/src/
|
||||
worker --loglevel INFO
|
||||
--concurrency 2
|
||||
--beat
|
||||
--scheduler django
|
||||
-n worker1@%h
|
||||
-n worker2@%h
|
||||
healthcheck:
|
||||
test: uv run --no-sync -- celery --app newsreader status || exit 1
|
||||
start_period: 10s
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
depends_on:
|
||||
- rabbitmq
|
||||
django:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/django
|
||||
command: src/entrypoint.sh
|
||||
environment:
|
||||
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
|
||||
ports:
|
||||
- '8000:8000'
|
||||
depends_on:
|
||||
- db
|
||||
rabbitmq:
|
||||
condition: service_started
|
||||
django:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/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
|
||||
- logs:/app/logs
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
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/
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
FROM node:12
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json /app/
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . /app/
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
static:
|
||||
stage: build
|
||||
image: node:12
|
||||
before_script:
|
||||
- npm install
|
||||
script:
|
||||
- npm run build
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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
188
jest.config.js
|
|
@ -1,188 +0,0 @@
|
|||
// 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,
|
||||
};
|
||||
16850
package-lock.json
generated
16850
package-lock.json
generated
File diff suppressed because it is too large
Load diff
74
package.json
74
package.json
|
|
@ -1,61 +1,75 @@
|
|||
{
|
||||
"name": "newsreader",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.3",
|
||||
"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": "[git@git.fudiggity.nl:5000]:sonny/newsreader.git"
|
||||
"url": "forgejo.fudiggity.nl:sonny/newsreader"
|
||||
},
|
||||
"author": "Sonny",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"css.gg": "^1.0.6",
|
||||
"@fortawesome/fontawesome-free": "^5.15.2",
|
||||
"js-cookie": "^2.2.1",
|
||||
"lodash": "^4.17.15",
|
||||
"lodash": "^4.17.20",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-redux": "^7.1.3",
|
||||
"react-redux": "^7.2.2",
|
||||
"redux": "^4.0.5",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"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",
|
||||
"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",
|
||||
"prettier": "^1.19.1",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react": "^16.14.0",
|
||||
"react-dom": "^16.14.0",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
"sass": "^1.52.1",
|
||||
"sass-loader": "^8.0.2",
|
||||
"style-loader": "^1.1.3",
|
||||
"webpack": "^4.42.1",
|
||||
"webpack-cli": "^3.3.11",
|
||||
"style-loader": "^2.0.0",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^5.94.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"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
1159
poetry.lock
generated
File diff suppressed because it is too large
Load diff
113
pyproject.toml
113
pyproject.toml
|
|
@ -1,40 +1,81 @@
|
|||
[tool.poetry]
|
||||
[project]
|
||||
name = "newsreader"
|
||||
version = "0.2"
|
||||
description = "Webapplication for reading RSS feeds"
|
||||
authors = ["Sonny <sonnyba871@gmail.com>"]
|
||||
license = "GPL-3.0"
|
||||
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",
|
||||
]
|
||||
|
||||
[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"
|
||||
[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.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"
|
||||
[project.optional-dependencies]
|
||||
sentry = ["sentry-sdk~=2.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry>=0.12"]
|
||||
build-backend = "poetry.masonry.api"
|
||||
[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"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
#!/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.dev")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.docker")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
|
|
|
|||
|
|
@ -1,27 +1,36 @@
|
|||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import ugettext as _
|
||||
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 UserAdmin(admin.ModelAdmin):
|
||||
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", "first_name", "last_name", "is_active")},
|
||||
{"fields": ("email", "password", "first_name", "last_name", "is_active")},
|
||||
),
|
||||
(
|
||||
_("Permission settings"),
|
||||
{
|
||||
"classes": ("collapse",),
|
||||
"fields": ("is_staff", "is_superuser", "groups", "user_permissions"),
|
||||
},
|
||||
{"classes": ("collapse",), "fields": ("is_staff", "is_superuser")},
|
||||
),
|
||||
(_("Misc settings"), {"fields": ("date_joined", "last_login")}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
|||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
name = "accounts"
|
||||
name = "newsreader.accounts"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
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")
|
||||
fields = ("first_name", "last_name", "auto_mark_read")
|
||||
widgets = {"auto_mark_read": CheckboxInput}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0001_initial")]
|
||||
|
||||
operations = [migrations.RemoveField(model_name="user", name="username")]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import newsreader.accounts.models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0002_remove_user_username")]
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0003_auto_20190714_1417")]
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0004_auto_20190714_1501")]
|
||||
|
||||
operations = [migrations.RemoveField(model_name="user", name="task_interval")]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0005_remove_user_task_interval")]
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0006_auto_20191116_1253")]
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ def update_task_name(apps, schema_editor):
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0007_auto_20191116_1255")]
|
||||
|
||||
operations = [migrations.RunPython(update_task_name)]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("django_celery_beat", "0012_periodictask_expire_seconds"),
|
||||
("accounts", "0008_auto_20200422_2243"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# 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")]
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# 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",
|
||||
),
|
||||
)
|
||||
]
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# 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"),
|
||||
]
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# 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"
|
||||
),
|
||||
)
|
||||
]
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# 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",
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 4.2.16 on 2025-03-26 08:46
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0017_auto_20240906_0914"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="reddit_access_token",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="reddit_refresh_token",
|
||||
),
|
||||
]
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
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 IntervalSchedule, PeriodicTask
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
|
||||
|
||||
class UserManager(DjangoUserManager):
|
||||
|
|
@ -41,13 +39,13 @@ class UserManager(DjangoUserManager):
|
|||
class User(AbstractUser):
|
||||
email = models.EmailField(_("email address"), unique=True)
|
||||
|
||||
task = models.OneToOneField(
|
||||
PeriodicTask,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
editable=False,
|
||||
verbose_name="collection task",
|
||||
# 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"
|
||||
),
|
||||
)
|
||||
|
||||
username = None
|
||||
|
|
@ -57,24 +55,8 @@ class User(AbstractUser):
|
|||
USERNAME_FIELD = "email"
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
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()
|
||||
tasks = PeriodicTask.objects.filter(name__contains=self.email)
|
||||
tasks.delete()
|
||||
|
||||
return super().delete(*args, **kwargs)
|
||||
|
|
|
|||
|
|
@ -2,17 +2,23 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block actions %}
|
||||
<section class="section form__section--last">
|
||||
<fieldset class="fieldset form__fieldset">
|
||||
{% include "components/form/cancel-button.html" %}
|
||||
</fieldset>
|
||||
<section class="section form__section--last">
|
||||
<fieldset class="fieldset form__fieldset">
|
||||
{% include "components/form/confirm-button.html" %}
|
||||
|
||||
<fieldset class="fieldset form__fieldset">
|
||||
<a class="link button button--primary" href="{% url 'accounts:password-change' %}">
|
||||
{% trans "Change password" %}
|
||||
</a>
|
||||
<a class="link button button--primary" href="{% url 'accounts:password-change' %}">
|
||||
{% trans "Change password" %}
|
||||
</a>
|
||||
|
||||
{% include "components/form/confirm-button.html" %}
|
||||
</fieldset>
|
||||
</section>
|
||||
{% 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 %}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
{% extends "base.html" %}
|
||||
{% extends "sidebar.html" %}
|
||||
|
||||
{% block content %}
|
||||
<main id="login--page" class="main">
|
||||
{% include "accounts/components/login-form.html" with form=form title="Login" confirm_text="Login" %}
|
||||
<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 %}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
{% extends "base.html" %}
|
||||
{% extends "sidebar.html" %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<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 %}
|
||||
{% 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 %}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
{% extends "base.html" %}
|
||||
{% extends "sidebar.html" %}
|
||||
|
||||
{% block content %}
|
||||
<main id="settings--page" class="main">
|
||||
{% include "accounts/components/settings-form.html" with form=form title="User profile" confirm_text="Save" %}
|
||||
<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 %}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ from django.utils.crypto import get_random_string
|
|||
|
||||
import factory
|
||||
|
||||
from registration.models import RegistrationProfile
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
|
||||
|
||||
|
|
@ -29,11 +27,3 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from registration.models import RegistrationProfile
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
|
||||
|
||||
class ActivationTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.register_url = reverse("accounts:register")
|
||||
self.register_success_url = reverse("accounts:register-complete")
|
||||
self.success_url = reverse("accounts:activate-complete")
|
||||
|
||||
def test_activation(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
self.assertRedirects(response, self.register_success_url)
|
||||
|
||||
register_profile = RegistrationProfile.objects.get()
|
||||
|
||||
kwargs = {"activation_key": register_profile.activation_key}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
def test_expired_key(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
|
||||
register_profile = RegistrationProfile.objects.get()
|
||||
user = register_profile.user
|
||||
|
||||
user.date_joined -= datetime.timedelta(days=settings.ACCOUNT_ACTIVATION_DAYS)
|
||||
user.save()
|
||||
|
||||
kwargs = {"activation_key": register_profile.activation_key}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertContains(response, _("Account activation failed"))
|
||||
|
||||
user.refresh_from_db()
|
||||
self.assertFalse(user.is_active)
|
||||
|
||||
def test_invalid_key(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
self.assertRedirects(response, self.register_success_url)
|
||||
|
||||
kwargs = {"activation_key": "not-a-valid-key"}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertContains(response, _("Account activation failed"))
|
||||
|
||||
user = User.objects.get()
|
||||
|
||||
self.assertEquals(user.is_active, False)
|
||||
|
||||
def test_activated_key(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
self.assertRedirects(response, self.register_success_url)
|
||||
|
||||
register_profile = RegistrationProfile.objects.get()
|
||||
|
||||
kwargs = {"activation_key": register_profile.activation_key}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
# try this a second time
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertRedirects(response, self.success_url)
|
||||
37
src/newsreader/accounts/tests/test_favicon.py
Normal file
37
src/newsreader/accounts/tests/test_favicon.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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()
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
from django.core import mail
|
||||
from django.test import TransactionTestCase as TestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from registration.models import RegistrationProfile
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
from newsreader.accounts.tests.factories import UserFactory
|
||||
|
||||
|
||||
class RegistrationTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.url = reverse("accounts:register")
|
||||
self.success_url = reverse("accounts:register-complete")
|
||||
self.disallowed_url = reverse("accounts:register-closed")
|
||||
|
||||
def test_simple(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
def test_registration(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
self.assertEquals(User.objects.count(), 1)
|
||||
self.assertEquals(RegistrationProfile.objects.count(), 1)
|
||||
|
||||
user = User.objects.get()
|
||||
|
||||
self.assertEquals(user.is_active, False)
|
||||
self.assertEquals(len(mail.outbox), 1)
|
||||
|
||||
def test_existing_email(self):
|
||||
UserFactory(email="test@test.com")
|
||||
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
self.assertEquals(User.objects.count(), 1)
|
||||
self.assertContains(response, _("User with this Email address already exists"))
|
||||
|
||||
def test_pending_registration(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
self.assertEquals(User.objects.count(), 1)
|
||||
self.assertEquals(RegistrationProfile.objects.count(), 1)
|
||||
|
||||
user = User.objects.get()
|
||||
|
||||
self.assertEquals(user.is_active, False)
|
||||
self.assertEquals(len(mail.outbox), 1)
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertEquals(response.status_code, 200)
|
||||
self.assertContains(response, _("User with this Email address already exists"))
|
||||
|
||||
def test_disabled_account(self):
|
||||
UserFactory(email="test@test.com", is_active=False)
|
||||
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
self.assertEquals(User.objects.count(), 1)
|
||||
self.assertContains(response, _("User with this Email address already exists"))
|
||||
|
||||
@override_settings(REGISTRATION_OPEN=False)
|
||||
def test_registration_closed(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertRedirects(response, self.disallowed_url)
|
||||
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertRedirects(response, self.disallowed_url)
|
||||
|
||||
self.assertEquals(User.objects.count(), 0)
|
||||
self.assertEquals(RegistrationProfile.objects.count(), 0)
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
from django.core import mail
|
||||
from django.test import TransactionTestCase as TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from registration.models import RegistrationProfile
|
||||
|
||||
from newsreader.accounts.tests.factories import RegistrationProfileFactory, UserFactory
|
||||
|
||||
|
||||
class ResendActivationTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.url = reverse("accounts:activate-resend")
|
||||
self.success_url = reverse("accounts:activate-complete")
|
||||
self.register_url = reverse("accounts:register")
|
||||
|
||||
def test_simple(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
def test_resent_form(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
|
||||
register_profile = RegistrationProfile.objects.get()
|
||||
original_kwargs = {"activation_key": register_profile.activation_key}
|
||||
|
||||
response = self.client.post(self.url, {"email": "test@test.com"})
|
||||
|
||||
self.assertContains(response, _("We have sent an email to"))
|
||||
|
||||
self.assertEquals(len(mail.outbox), 2)
|
||||
|
||||
register_profile.refresh_from_db()
|
||||
|
||||
kwargs = {"activation_key": register_profile.activation_key}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
register_profile.refresh_from_db()
|
||||
user = register_profile.user
|
||||
|
||||
self.assertEquals(user.is_active, True)
|
||||
|
||||
# test the old activation code
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=original_kwargs))
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
self.assertContains(response, _("Account activation failed"))
|
||||
|
||||
def test_existing_account(self):
|
||||
user = UserFactory(is_active=True)
|
||||
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)
|
||||
|
|
@ -5,25 +5,27 @@ from newsreader.accounts.models import User
|
|||
from newsreader.accounts.tests.factories import UserFactory
|
||||
|
||||
|
||||
class UserSettingsViewTestCase(TestCase):
|
||||
class SettingsViewTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = UserFactory(password="test")
|
||||
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(reverse("accounts:settings"))
|
||||
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"),
|
||||
reverse("accounts:settings:home"),
|
||||
{"first_name": "First name", "last_name": "Last name"},
|
||||
)
|
||||
|
||||
user = User.objects.get()
|
||||
|
||||
self.assertRedirects(response, reverse("accounts:settings"))
|
||||
self.assertRedirects(response, reverse("accounts:settings:home"))
|
||||
|
||||
self.assertEquals(user.first_name, "First name")
|
||||
self.assertEquals(user.last_name, "Last name")
|
||||
|
|
@ -1,22 +1,21 @@
|
|||
from django.test import TestCase
|
||||
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
from newsreader.accounts.tests.factories import UserFactory
|
||||
|
||||
|
||||
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 = User.objects.create(email="durp@burp.nl", task=None)
|
||||
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.delete()
|
||||
|
||||
self.assertEquals(PeriodicTask.objects.count(), 0)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
from django.contrib.auth.decorators import login_required
|
||||
from django.urls import path
|
||||
from django.urls import include, path
|
||||
|
||||
from newsreader.accounts.views import (
|
||||
ActivationCompleteView,
|
||||
ActivationResendView,
|
||||
ActivationView,
|
||||
FaviconRedirectView,
|
||||
LoginView,
|
||||
LogoutView,
|
||||
PasswordChangeView,
|
||||
|
|
@ -12,33 +10,21 @@ 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"),
|
||||
path("register/", RegistrationView.as_view(), name="register"),
|
||||
path(
|
||||
"register/complete/",
|
||||
RegistrationCompleteView.as_view(),
|
||||
name="register-complete",
|
||||
),
|
||||
path("register/closed/", RegistrationClosedView.as_view(), name="register-closed"),
|
||||
path(
|
||||
"activate/complete/", ActivationCompleteView.as_view(), name="activate-complete"
|
||||
),
|
||||
path("activate/resend/", ActivationResendView.as_view(), name="activate-resend"),
|
||||
path(
|
||||
# This URL should be placed after all activate/ url's (see arg)
|
||||
"activate/<str:activation_key>/",
|
||||
ActivationView.as_view(),
|
||||
name="activate",
|
||||
),
|
||||
# Password
|
||||
path("password-reset/", PasswordResetView.as_view(), name="password-reset"),
|
||||
path(
|
||||
"password-reset/done/",
|
||||
|
|
@ -60,5 +46,6 @@ urlpatterns = [
|
|||
login_required(PasswordChangeView.as_view()),
|
||||
name="password-change",
|
||||
),
|
||||
path("settings/", login_required(SettingsView.as_view()), name="settings"),
|
||||
# Settings
|
||||
path("settings/", include((settings_patterns, "settings"))),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
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}
|
||||
23
src/newsreader/accounts/views/__init__.py
Normal file
23
src/newsreader/accounts/views/__init__.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
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",
|
||||
]
|
||||
13
src/newsreader/accounts/views/auth.py
Normal file
13
src/newsreader/accounts/views/auth.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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")
|
||||
26
src/newsreader/accounts/views/favicon.py
Normal file
26
src/newsreader/accounts/views/favicon.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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
|
||||
34
src/newsreader/accounts/views/password.py
Normal file
34
src/newsreader/accounts/views/password.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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")
|
||||
32
src/newsreader/accounts/views/settings.py
Normal file
32
src/newsreader/accounts/views/settings.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from django.core.cache import cache
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic.edit import FormView, ModelFormMixin
|
||||
|
||||
from newsreader.accounts.forms import UserSettingsForm
|
||||
from newsreader.accounts.models import User
|
||||
from newsreader.utils.views import NavListMixin
|
||||
|
||||
|
||||
class SettingsView(NavListMixin, ModelFormMixin, FormView):
|
||||
template_name = "accounts/views/settings.html"
|
||||
success_url = reverse_lazy("accounts:settings:home")
|
||||
form_class = UserSettingsForm
|
||||
model = User
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
return {
|
||||
**context,
|
||||
"favicon_task_allowed": not cache.get(f"{self.request.user}-favicon-task"),
|
||||
}
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
return self.request.user
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return {**super().get_form_kwargs(), "instance": self.request.user}
|
||||
Binary file not shown.
BIN
src/newsreader/assets/fonts/Inter-VariableFont_opsz,wght.ttf
Normal file
BIN
src/newsreader/assets/fonts/Inter-VariableFont_opsz,wght.ttf
Normal file
Binary file not shown.
|
|
@ -3,7 +3,7 @@ import os
|
|||
from celery import Celery
|
||||
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.docker")
|
||||
|
||||
app = Celery("newsreader")
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
|
|
|
|||
|
|
@ -1,18 +1,25 @@
|
|||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from pathlib import Path
|
||||
from newsreader.conf.utils import get_env, get_root_dir
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
|
||||
DJANGO_PROJECT_DIR = os.path.join(BASE_DIR, "src", "newsreader")
|
||||
load_dotenv()
|
||||
|
||||
# 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
|
||||
try:
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
except ImportError:
|
||||
CeleryIntegration = None
|
||||
DjangoIntegration = None
|
||||
|
||||
ALLOWED_HOSTS = ["127.0.0.1"]
|
||||
INTERNAL_IPS = ["127.0.0.1"]
|
||||
|
||||
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"])
|
||||
|
||||
# Application definition
|
||||
INSTALLED_APPS = [
|
||||
|
|
@ -22,20 +29,22 @@ 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",
|
||||
|
|
@ -54,14 +63,15 @@ MIDDLEWARE = [
|
|||
|
||||
ROOT_URLCONF = "newsreader.urls"
|
||||
|
||||
FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
|
||||
"DIRS": [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",
|
||||
|
|
@ -72,31 +82,30 @@ 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": os.environ.get("POSTGRES_HOST", ""),
|
||||
"NAME": os.environ.get("POSTGRES_NAME", "newsreader"),
|
||||
"USER": os.environ.get("POSTGRES_USER"),
|
||||
"PASSWORD": os.environ.get("POSTGRES_PASSWORD"),
|
||||
"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=""),
|
||||
}
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "localhost:11211",
|
||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
"axes": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "localhost:11211",
|
||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
}
|
||||
|
||||
# Logging
|
||||
# https://docs.djangoproject.com/en/2.2/topics/logging/#configuring-logging
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
|
|
@ -105,61 +114,51 @@ LOGGING = {
|
|||
"require_debug_true": {"()": "django.utils.log.RequireDebugTrue"},
|
||||
},
|
||||
"formatters": {
|
||||
"django.server": {
|
||||
"timestamped": {
|
||||
"()": "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",
|
||||
},
|
||||
"django.server": {
|
||||
"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.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",
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": BASE_DIR / "logs" / "celery.log",
|
||||
"backupCount": 5,
|
||||
"maxBytes": 50000000, # 50 mB
|
||||
"formatter": "timestamped",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"django": {
|
||||
"handlers": ["console", "mail_admins", "syslog_errors"],
|
||||
"level": "INFO",
|
||||
},
|
||||
"django": {"handlers": ["console"], "level": "INFO"},
|
||||
"django.server": {
|
||||
"handlers": ["django.server"],
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"celery": {"handlers": ["syslog", "console"], "level": "INFO"},
|
||||
"celery.task": {"handlers": ["syslog", "console"], "level": "INFO"},
|
||||
"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 = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
|
||||
|
|
@ -174,8 +173,6 @@ 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"
|
||||
|
|
@ -183,19 +180,31 @@ 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 = os.path.join(BASE_DIR, "static")
|
||||
STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static")]
|
||||
STATIC_ROOT = BASE_DIR / "static"
|
||||
STATICFILES_DIRS = (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",
|
||||
]
|
||||
|
||||
DEFAULT_FROM_EMAIL = "newsreader@rss.fudiggity.nl"
|
||||
# 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)
|
||||
|
||||
|
||||
# Third party settings
|
||||
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
|
||||
|
|
@ -212,7 +221,12 @@ REST_FRAMEWORK = {
|
|||
"rest_framework.permissions.IsAuthenticated",
|
||||
"newsreader.accounts.permissions.IsOwner",
|
||||
),
|
||||
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
|
||||
"DEFAULT_RENDERER_CLASSES": (
|
||||
"djangorestframework_camel_case.render.CamelCaseJSONRenderer",
|
||||
),
|
||||
"DEFAULT_PARSER_CLASSES": (
|
||||
"djangorestframework_camel_case.parser.CamelCaseJSONParser",
|
||||
),
|
||||
}
|
||||
|
||||
SWAGGER_SETTINGS = {
|
||||
|
|
@ -223,8 +237,15 @@ 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"
|
||||
|
||||
REGISTRATION_OPEN = True
|
||||
REGISTRATION_AUTO_LOGIN = True
|
||||
ACCOUNT_ACTIVATION_DAYS = 7
|
||||
# Sentry
|
||||
SENTRY_CONFIG = {
|
||||
"dsn": get_env("SENTRY_DSN", default="", required=False),
|
||||
"send_default_pii": False,
|
||||
"integrations": [DjangoIntegration(), CeleryIntegration()]
|
||||
if DjangoIntegration and CeleryIntegration
|
||||
else [],
|
||||
}
|
||||
|
|
|
|||
46
src/newsreader/conf/ci.py
Normal file
46
src/newsreader/conf/ci.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from .base import * # noqa: F403
|
||||
from .utils import get_current_version
|
||||
|
||||
|
||||
DEBUG = True
|
||||
|
||||
|
||||
del LOGGING["handlers"]["file"] # noqa: F405
|
||||
del LOGGING["handlers"]["celery"] # noqa: F405
|
||||
|
||||
LOGGING["loggers"].update( # noqa: F405
|
||||
{
|
||||
"celery.task": {"handlers": ["console"], "level": "DEBUG"},
|
||||
"newsreader": {"handlers": ["console"], "level": "INFO"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
AXES_ENABLED = False
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
"axes": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
}
|
||||
|
||||
# Project settings
|
||||
VERSION = get_current_version()
|
||||
ENVIRONMENT = "ci"
|
||||
|
||||
try:
|
||||
# Optionally use sentry integration
|
||||
from sentry_sdk import init as sentry_init
|
||||
|
||||
SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT}) # noqa: F405
|
||||
|
||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
||||
except ImportError:
|
||||
pass
|
||||
|
|
@ -1,35 +1,35 @@
|
|||
from .base import * # isort:skip
|
||||
from .base import * # noqa: F403
|
||||
from .utils import get_current_version
|
||||
|
||||
|
||||
SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl"
|
||||
|
||||
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
|
||||
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"]
|
||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
||||
|
||||
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",
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
|
||||
"django.template.context_processors.debug",
|
||||
)
|
||||
|
||||
# Project settings
|
||||
VERSION = get_current_version()
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -1,29 +1,43 @@
|
|||
from .dev import * # isort:skip
|
||||
from .base import * # noqa: F403
|
||||
from .utils import get_current_version
|
||||
|
||||
|
||||
SECRET_KEY = "=q(ztyo)b6noom#a164g&s9vcj1aawa^g#ing_ir99=_zl4g&$"
|
||||
DEBUG = True
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": "newsreader",
|
||||
"USER": "newsreader",
|
||||
"PASSWORD": "newsreader",
|
||||
"HOST": "db",
|
||||
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"},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
"axes": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
}
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
# Celery
|
||||
# https://docs.celeryproject.org/en/latest/userguide/configuration.html
|
||||
CELERY_BROKER_URL = "amqp://guest:guest@rabbitmq:5672//"
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
from .base import * # isort:skip
|
||||
|
||||
|
||||
SECRET_KEY = "29%lkw+&n%^w4k#@_db2mo%*tc&xzb)x7xuq*(0$eucii%4r0c"
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
AXES_ENABLED = False
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
"axes": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
}
|
||||
|
|
@ -1,51 +1,32 @@
|
|||
import os
|
||||
from newsreader.conf.utils import get_env
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from .base import * # noqa: F403
|
||||
from .utils import get_current_version
|
||||
|
||||
|
||||
from .base import * # isort:skip
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DEBUG = False
|
||||
|
||||
ALLOWED_HOSTS = ["rss.fudiggity.nl"]
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
|
||||
ADMINS = [
|
||||
("", email)
|
||||
for email in os.getenv("ADMINS", "").split(",")
|
||||
if os.environ.get("ADMINS")
|
||||
("", email) for email in get_env("ADMINS", split=",", required=False, default=[])
|
||||
]
|
||||
|
||||
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",
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
# Project settings
|
||||
VERSION = get_current_version(debug=False)
|
||||
ENVIRONMENT = "production"
|
||||
|
||||
# Third party settings
|
||||
AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler"
|
||||
|
||||
REGISTRATION_OPEN = False
|
||||
# Optionally use sentry integration
|
||||
try:
|
||||
from sentry_sdk import init as sentry_init
|
||||
|
||||
SENTRY_CONFIG.update( # noqa: F405
|
||||
{"release": VERSION, "environment": ENVIRONMENT, "debug": False}
|
||||
)
|
||||
|
||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
||||
except ImportError:
|
||||
pass
|
||||
|
|
|
|||
85
src/newsreader/conf/utils.py
Normal file
85
src/newsreader/conf/utils.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Type
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_env(
|
||||
name: str,
|
||||
cast: Type = str,
|
||||
required: bool = True,
|
||||
default: Any = None,
|
||||
split: str = "",
|
||||
) -> Any:
|
||||
if cast is not str and split:
|
||||
raise TypeError(f"Split is not possible with {cast}")
|
||||
|
||||
value = os.getenv(name)
|
||||
|
||||
if not value:
|
||||
if required:
|
||||
logger.warning(f"Missing environment variable: {name}")
|
||||
|
||||
return default
|
||||
|
||||
bool_mapping = {"yes": True, "true": True, "false": False, "no": False}
|
||||
|
||||
if cast is bool:
|
||||
_value = bool_mapping.get(value.lower())
|
||||
|
||||
if not value:
|
||||
raise ValueError(f"Unknown boolean value: {_value}")
|
||||
|
||||
return _value
|
||||
|
||||
value = value if not cast else cast(value)
|
||||
return value if not split else value.split(split)
|
||||
|
||||
|
||||
def get_current_version(debug: bool = True) -> str:
|
||||
version = get_env("VERSION", required=False)
|
||||
|
||||
if version:
|
||||
return version
|
||||
|
||||
if debug:
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["git", "show", "--no-patch", "--format=%H"], universal_newlines=True
|
||||
)
|
||||
return output.strip()
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return ""
|
||||
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["git", "describe", "--tags"], universal_newlines=True
|
||||
)
|
||||
return output.strip()
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return ""
|
||||
|
||||
|
||||
ROOT_MARKERS = ("pyproject.toml", "package.json", "README.md", "CHANGELOG.md")
|
||||
|
||||
|
||||
def get_root_dir() -> Path:
|
||||
file = Path(__file__)
|
||||
return _traverse_dirs(file.parent, ROOT_MARKERS)
|
||||
|
||||
|
||||
def _traverse_dirs(path: Path, root_markers: Iterable[str]) -> Path:
|
||||
if path.parent == path:
|
||||
raise OSError("Root directory detected")
|
||||
|
||||
files = (file.name for file in path.iterdir())
|
||||
|
||||
if not any((marker for marker in root_markers if marker in files)):
|
||||
return _traverse_dirs(path.parent, root_markers)
|
||||
|
||||
return path
|
||||
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
|||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
name = "core"
|
||||
name = "newsreader.core"
|
||||
|
|
|
|||
9
src/newsreader/core/forms.py
Normal file
9
src/newsreader/core/forms.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
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}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework import pagination
|
||||
|
||||
|
||||
class ResultSetPagination(PageNumberPagination):
|
||||
class ResultSetPagination(pagination.PageNumberPagination):
|
||||
page_size_query_param = "count"
|
||||
max_page_size = 50
|
||||
page_size = 30
|
||||
|
|
@ -10,3 +10,9 @@ class ResultSetPagination(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
1066
src/newsreader/fixtures/fixture.json
Normal file
1066
src/newsreader/fixtures/fixture.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -47,7 +47,7 @@
|
|||
"user" : 2,
|
||||
"succeeded" : true,
|
||||
"modified" : "2019-07-20T11:28:16.473Z",
|
||||
"last_suceeded" : "2019-07-20T11:28:16.316Z",
|
||||
"last_run" : "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_suceeded" : "2019-07-20T11:28:15.691Z",
|
||||
"last_run" : "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_suceeded" : "2019-07-20T11:28:15.986Z",
|
||||
"last_run" : "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_suceeded" : "2019-07-20T11:28:16.078Z",
|
||||
"last_run" : "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_suceeded" : "2019-07-20T11:28:15.860Z",
|
||||
"last_run" : "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_suceeded" : "2019-07-20T11:28:16.034Z",
|
||||
"last_run" : "2019-07-20T11:28:16.034Z",
|
||||
"succeeded" : true,
|
||||
"modified" : "2019-07-20T12:07:21.704Z",
|
||||
"name" : "The Verge"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
|
||||
const Card = props => {
|
||||
return (
|
||||
<div className="card">
|
||||
<div id={`${props.id}`} className="card">
|
||||
<div className="card__header">{props.header}</div>
|
||||
<div className="card__content">{props.content}</div>
|
||||
<div className="card__footer">{props.footer}</div>
|
||||
|
|
|
|||
|
|
@ -3,26 +3,24 @@ import React from 'react';
|
|||
class Messages extends React.Component {
|
||||
state = { messages: this.props.messages };
|
||||
|
||||
close = ::this.close;
|
||||
|
||||
close(index) {
|
||||
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="gg-close" onClick={() => this.close(index)} />
|
||||
{message.text} <i className="fas fa-times" onClick={() => this.close(index)} />
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
return <ul className="list messages">{messages}</ul>;
|
||||
return <ul className="list messages messages--fixed">{messages}</ul>;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
22
src/newsreader/js/components/NavList.js
Normal file
22
src/newsreader/js/components/NavList.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
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;
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
class Selector {
|
||||
onClick = ::this.onClick;
|
||||
|
||||
inputs = [];
|
||||
|
||||
constructor() {
|
||||
|
|
@ -11,13 +9,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;
|
||||
|
|
|
|||
25
src/newsreader/js/components/Sidebar.js
Normal file
25
src/newsreader/js/components/Sidebar.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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;
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import './lib/index.js';
|
||||
import './pages/homepage/index.js';
|
||||
import './pages/categories/index.js';
|
||||
import './pages/rules/index.js';
|
||||
import './pages/default/index.js';
|
||||
|
|
|
|||
1
src/newsreader/js/lib/index.js
Normal file
1
src/newsreader/js/lib/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import './theme.js';
|
||||
76
src/newsreader/js/lib/theme.js
Normal file
76
src/newsreader/js/lib/theme.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
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();
|
||||
|
|
@ -6,12 +6,9 @@ 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);
|
||||
|
||||
|
|
@ -23,15 +20,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',
|
||||
|
|
@ -59,7 +56,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;
|
||||
|
|
@ -69,6 +66,7 @@ class App extends React.Component {
|
|||
key={category.pk}
|
||||
category={category}
|
||||
showDialog={this.selectCategory}
|
||||
updateUrl={this.props.updateUrl}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -80,7 +78,7 @@ class App extends React.Component {
|
|||
const pageHeader = (
|
||||
<>
|
||||
<h1 className="h1">Categories</h1>
|
||||
<a className="link button button--confirm" href="/core/categories/create/">
|
||||
<a className="link button button--confirm" href={`${this.props.createUrl}/`}>
|
||||
Create category
|
||||
</a>
|
||||
</>
|
||||
|
|
@ -89,15 +87,19 @@ class App extends React.Component {
|
|||
return (
|
||||
<>
|
||||
{this.state.message && <Messages messages={[this.state.message]} />}
|
||||
<Card header={pageHeader} />
|
||||
{cards}
|
||||
{selectedCategory && (
|
||||
<CategoryModal
|
||||
category={selectedCategory}
|
||||
handleCancel={this.deselectCategory}
|
||||
handleDelete={this.deleteCategory}
|
||||
/>
|
||||
)}
|
||||
<Sidebar navLinks={this.props.navLinks} />
|
||||
|
||||
<div className="main__container">
|
||||
<Card header={pageHeader} />
|
||||
{cards}
|
||||
{selectedCategory && (
|
||||
<CategoryModal
|
||||
category={selectedCategory}
|
||||
handleCancel={this.deselectCategory}
|
||||
handleDelete={this.deleteCategory}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const CategoryCard = props => {
|
|||
if (rule.favicon) {
|
||||
favicon = <img className="favicon" src={rule.favicon} />;
|
||||
} else {
|
||||
favicon = <i className="gg-image" />;
|
||||
favicon = <i className="fas fa-image" />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -33,7 +33,7 @@ const CategoryCard = props => {
|
|||
<>
|
||||
<a
|
||||
className="link button button--primary"
|
||||
href={`/core/categories/${category.pk}/`}
|
||||
href={`${props.updateUrl}/${category.pk}/`}
|
||||
>
|
||||
Edit
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -9,5 +9,19 @@ if (page) {
|
|||
const dataScript = document.getElementById('categories-data');
|
||||
const categories = JSON.parse(dataScript.textContent);
|
||||
|
||||
ReactDOM.render(<App categories={categories} />, page);
|
||||
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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
17
src/newsreader/js/pages/default/index.js
Normal file
17
src/newsreader/js/pages/default/index.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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
Loading…
Add table
Add a link
Reference in a new issue