Compare commits
517 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 | |||
| abd7ed24e6 | |||
| 3cbf50d0b0 | |||
| a22ef354be | |||
| 61ddef3b63 | |||
| a85fe2c02f | |||
| 6a4f33c182 | |||
| e3840342b3 | |||
| a4b5373ed2 | |||
| 13d33749da | |||
| bb47e2af8d | |||
| f4adb9635a | |||
| aeb85bd2cf | |||
| 69eaedf89c | |||
| 3c613b59e2 | |||
| ed415f2b5c | |||
| d6d19fa9b9 | |||
| 7ee727e96e | |||
| 9285672273 | |||
| bec3488e63 | |||
| 7fc899937d | |||
| d61b3a9498 | |||
| 428cd39d13 | |||
| 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 |
416 changed files with 19602 additions and 15007 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/
|
lib/
|
||||||
!src/newsreader/scss/lib
|
!src/newsreader/scss/lib
|
||||||
|
!src/newsreader/js/lib
|
||||||
|
|
||||||
lib64/
|
lib64/
|
||||||
parts/
|
parts/
|
||||||
|
|
@ -114,7 +115,7 @@ celerybeat-schedule
|
||||||
*.sage.py
|
*.sage.py
|
||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
*.env
|
||||||
.venv
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
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
|
||||||
85
Dockerfile
85
Dockerfile
|
|
@ -1,11 +1,84 @@
|
||||||
FROM python:3.7-buster
|
# stage 1
|
||||||
|
FROM python:3.11-alpine AS backend
|
||||||
|
|
||||||
RUN pip install poetry
|
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
|
WORKDIR /app
|
||||||
COPY poetry.lock pyproject.toml /app/
|
|
||||||
|
|
||||||
RUN poetry config virtualenvs.create false
|
USER newsreader
|
||||||
RUN poetry install --no-interaction
|
|
||||||
|
|
||||||
COPY . /app/
|
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,52 +1,126 @@
|
||||||
version: '3'
|
volumes:
|
||||||
|
logs:
|
||||||
|
media:
|
||||||
|
postgres-data:
|
||||||
|
static-files:
|
||||||
|
|
||||||
|
x-db-connection-env: &db-connection-env
|
||||||
|
POSTGRES_HOST: ${POSTGRES_HOST:-db}
|
||||||
|
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
|
||||||
|
POSTGRES_DB: &pg-database ${POSTGRES_DB:-newsreader}
|
||||||
|
POSTGRES_USER: &pg-user ${POSTGRES_USER:-newsreader}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-newsreader}
|
||||||
|
|
||||||
|
x-db-env: &db-env
|
||||||
|
<<: *db-connection-env
|
||||||
|
PGUSER: *pg-user
|
||||||
|
PGDATABASE: *pg-database
|
||||||
|
|
||||||
|
x-django-env: &django-env
|
||||||
|
<<: *db-connection-env
|
||||||
|
|
||||||
|
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,django}
|
||||||
|
INTERNAL_IPS: ${INTERNAL_IPS:-localhost,127.0.0.1,django}
|
||||||
|
|
||||||
|
# see token_urlsafe from python's secret module to generate one
|
||||||
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-Ojg68lYsP3kq2r5JgozUzKVSRFywm17BTMS5iwpLM44}
|
||||||
|
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-newsreader.conf.production}
|
||||||
|
|
||||||
|
ADMINS: ${ADMINS:-""}
|
||||||
|
|
||||||
|
VERSION: ${VERSION:-""}
|
||||||
|
|
||||||
|
# Email
|
||||||
|
EMAIL_HOST: ${EMAIL_HOST:-localhost}
|
||||||
|
EMAIL_PORT: ${EMAIL_PORT:-25}
|
||||||
|
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-""}
|
||||||
|
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-""}
|
||||||
|
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-no}
|
||||||
|
EMAIL_USE_SSL: ${EMAIL_USE_SSL:-no}
|
||||||
|
EMAIL_DEFAULT_FROM: ${EMAIL_DEFAULT_FROM:-webmaster@localhost}
|
||||||
|
|
||||||
|
# Sentry
|
||||||
|
SENTRY_DSN: ${SENTRY_DSN:-""}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
# See https://hub.docker.com/_/postgres
|
|
||||||
image: postgres
|
|
||||||
container_name: postgres
|
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=$POSTGRES_NAME
|
<<: *db-env
|
||||||
- POSTGRES_USER=$POSTGRES_USER
|
image: postgres:15
|
||||||
- POSTGRES_PASSWORD=$POSTGRES_PASSWORD
|
healthcheck:
|
||||||
rabbitmq:
|
test: /usr/bin/pg_isready
|
||||||
image: rabbitmq:3.7
|
start_period: 10s
|
||||||
container_name: rabbitmq
|
interval: 5s
|
||||||
celery:
|
timeout: 10s
|
||||||
build: .
|
retries: 10
|
||||||
container_name: celery
|
|
||||||
command: poetry run celery -A newsreader worker -l INFO --beat --scheduler django --workdir=/app/src/
|
|
||||||
environment:
|
|
||||||
- POSTGRES_HOST=$POSTGRES_HOST
|
|
||||||
- POSTGRES_NAME=$POSTGRES_NAME
|
|
||||||
- POSTGRES_USER=$POSTGRES_USER
|
|
||||||
- POSTGRES_PASSWORD=$POSTGRES_PASSWORD
|
|
||||||
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
|
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- postgres-data:/var/lib/postgresql/data
|
||||||
depends_on:
|
|
||||||
- rabbitmq
|
rabbitmq:
|
||||||
|
image: rabbitmq:4
|
||||||
|
|
||||||
memcached:
|
memcached:
|
||||||
image: memcached:1.5.22
|
image: memcached:1.6
|
||||||
container_name: memcached
|
|
||||||
ports:
|
|
||||||
- "11211:11211"
|
|
||||||
entrypoint:
|
entrypoint:
|
||||||
- memcached
|
- memcached
|
||||||
- -m 64
|
- -m 64
|
||||||
web:
|
|
||||||
build: .
|
django:
|
||||||
container_name: web
|
build: &app-build
|
||||||
command: src/entrypoint.sh
|
context: .
|
||||||
|
target: production
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_HOST=$POSTGRES_HOST
|
<<: *django-env
|
||||||
- POSTGRES_NAME=$POSTGRES_NAME
|
entrypoint: ["/bin/sh", "/app/bin/docker-entrypoint.sh"]
|
||||||
- POSTGRES_USER=$POSTGRES_USER
|
command: |
|
||||||
- POSTGRES_PASSWORD=$POSTGRES_PASSWORD
|
uv run --no-sync --
|
||||||
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
|
gunicorn
|
||||||
volumes:
|
--bind 0.0.0.0:8000
|
||||||
- .:/app
|
--workers 3
|
||||||
ports:
|
--chdir /app/src/
|
||||||
- '8000:8000'
|
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:
|
depends_on:
|
||||||
- db
|
memcached:
|
||||||
|
condition: service_started
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- logs:/app/logs
|
||||||
|
- media:/app/media
|
||||||
|
- static-files:/app/static
|
||||||
|
|
||||||
|
celery:
|
||||||
|
build:
|
||||||
|
<<: *app-build
|
||||||
|
environment:
|
||||||
|
<<: *django-env
|
||||||
|
command: |
|
||||||
|
uv run --no-sync --
|
||||||
|
celery
|
||||||
|
--app newsreader
|
||||||
|
--workdir /app/src/
|
||||||
|
worker --loglevel INFO
|
||||||
|
--concurrency 2
|
||||||
|
--beat
|
||||||
|
--scheduler django
|
||||||
|
-n worker1@%h
|
||||||
|
-n worker2@%h
|
||||||
|
healthcheck:
|
||||||
|
test: uv run --no-sync -- celery --app newsreader status || exit 1
|
||||||
|
start_period: 10s
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
depends_on:
|
||||||
|
rabbitmq:
|
||||||
|
condition: service_started
|
||||||
|
django:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- logs:/app/logs
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"name": "newsreader",
|
||||||
"version": "0.1.0",
|
"version": "0.5.3",
|
||||||
"description": "Application for viewing RSS feeds",
|
"description": "Application for viewing RSS feeds",
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "npx prettier \"src/newsreader/js/**/*.js\" --check",
|
"lint": "npx prettier \"src/newsreader/js/**/*.js\" --check",
|
||||||
"format": "npx prettier \"src/newsreader/js/**/*.js\" --write",
|
"format": "npx prettier \"src/newsreader/js/**/*.js\" --write",
|
||||||
"build": "npx webpack --config webpack.dev.babel.js",
|
|
||||||
"build:watch": "npx webpack --config webpack.dev.babel.js --watch",
|
"build:watch": "npx webpack --config webpack.dev.babel.js --watch",
|
||||||
|
"build:dev": "npx webpack --config webpack.dev.babel.js",
|
||||||
"build:prod": "npx webpack --config webpack.prod.babel.js",
|
"build:prod": "npx webpack --config webpack.prod.babel.js",
|
||||||
"test": "npx jest",
|
"test": "npx jest",
|
||||||
"test:watch": "npm test -- --watch"
|
"test:watch": "npm test -- --watch"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "[git@git.fudiggity.nl:5000]:sonny/newsreader.git"
|
"url": "forgejo.fudiggity.nl:sonny/newsreader"
|
||||||
},
|
},
|
||||||
"author": "Sonny",
|
"author": "Sonny",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"css.gg": "^1.0.6",
|
"@fortawesome/fontawesome-free": "^5.15.2",
|
||||||
"js-cookie": "^2.2.1",
|
"js-cookie": "^2.2.1",
|
||||||
"lodash": "^4.17.15",
|
"lodash": "^4.17.20",
|
||||||
"object-assign": "^4.1.1",
|
"object-assign": "^4.1.1",
|
||||||
"react-redux": "^7.1.3",
|
"react-redux": "^7.2.2",
|
||||||
"redux": "^4.0.5",
|
"redux": "^4.0.5",
|
||||||
"redux-logger": "^3.0.6",
|
"redux-logger": "^3.0.6",
|
||||||
"redux-thunk": "^2.3.0"
|
"redux-thunk": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.7.7",
|
"@babel/core": "^7.12.13",
|
||||||
"@babel/plugin-proposal-class-properties": "^7.7.4",
|
"@babel/plugin-proposal-class-properties": "^7.12.13",
|
||||||
"@babel/plugin-proposal-function-bind": "^7.7.4",
|
"@babel/plugin-proposal-function-bind": "^7.12.13",
|
||||||
"@babel/plugin-syntax-dynamic-import": "^7.7.4",
|
"@babel/plugin-transform-react-jsx": "^7.12.13",
|
||||||
"@babel/plugin-syntax-function-bind": "^7.7.4",
|
"@babel/preset-env": "^7.12.13",
|
||||||
"@babel/plugin-transform-react-jsx": "^7.7.7",
|
"@babel/register": "^7.12.13",
|
||||||
"@babel/plugin-transform-runtime": "^7.7.6",
|
"@babel/runtime": "^7.12.13",
|
||||||
"@babel/preset-env": "^7.7.7",
|
"babel-jest": "^29.7.0",
|
||||||
"@babel/register": "^7.7.7",
|
"babel-loader": "^8.2.2",
|
||||||
"@babel/runtime": "^7.7.7",
|
|
||||||
"babel-jest": "^24.9.0",
|
|
||||||
"babel-loader": "^8.1.0",
|
|
||||||
"clean-webpack-plugin": "^3.0.0",
|
"clean-webpack-plugin": "^3.0.0",
|
||||||
"css-loader": "^3.4.2",
|
"css-loader": "^7.1.2",
|
||||||
"fetch-mock": "^8.3.1",
|
"fetch-mock": "^8.3.2",
|
||||||
"jest": "^24.9.0",
|
"jest": "^29.7.0",
|
||||||
"mini-css-extract-plugin": "^0.9.0",
|
"mini-css-extract-plugin": "^2.9.1",
|
||||||
"node-fetch": "^2.6.0",
|
"node-fetch": "^2.6.1",
|
||||||
"node-sass": "^4.13.1",
|
|
||||||
"prettier": "^1.19.1",
|
"prettier": "^1.19.1",
|
||||||
"react": "^16.12.0",
|
"react": "^16.14.0",
|
||||||
"react-dom": "^16.12.0",
|
"react-dom": "^16.14.0",
|
||||||
"redux-mock-store": "^1.5.4",
|
"redux-mock-store": "^1.5.4",
|
||||||
|
"sass": "^1.52.1",
|
||||||
"sass-loader": "^8.0.2",
|
"sass-loader": "^8.0.2",
|
||||||
"style-loader": "^1.1.3",
|
"style-loader": "^2.0.0",
|
||||||
"webpack": "^4.42.1",
|
"url-loader": "^4.1.1",
|
||||||
"webpack-cli": "^3.3.11",
|
"webpack": "^5.94.0",
|
||||||
|
"webpack-cli": "^5.1.4",
|
||||||
"webpack-merge": "^4.2.2"
|
"webpack-merge": "^4.2.2"
|
||||||
|
},
|
||||||
|
"prettier": {
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 90,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "avoid"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"roots": [
|
||||||
|
"src/newsreader/js/tests/"
|
||||||
|
],
|
||||||
|
"clearMocks": true,
|
||||||
|
"coverageDirectory": "coverage"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1159
poetry.lock
generated
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"
|
name = "newsreader"
|
||||||
version = "0.2"
|
version = "0.5.3"
|
||||||
description = "Webapplication for reading RSS feeds"
|
authors = [{ name = "Sonny" }]
|
||||||
authors = ["Sonny <sonnyba871@gmail.com>"]
|
license = { text = "GPL-3.0" }
|
||||||
license = "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]
|
[dependency-groups]
|
||||||
python = "^3.7"
|
test-tools = ["ruff", "factory_boy", "freezegun"]
|
||||||
bleach = "^3.1.4"
|
development = [
|
||||||
Django = "^3.0.5"
|
"django-debug-toolbar",
|
||||||
celery = "^4.4.2"
|
"django-stubs",
|
||||||
beautifulsoup4 = "^4.9.0"
|
"django-extensions",
|
||||||
django-axes = "^5.3.1"
|
]
|
||||||
django-celery-beat = "^2.0.0"
|
ci = ["coverage~=7.6.1"]
|
||||||
djangorestframework = "^3.11.0"
|
production = ["gunicorn~=23.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"
|
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[project.optional-dependencies]
|
||||||
factory-boy = "^2.12.0"
|
sentry = ["sentry-sdk~=2.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"
|
|
||||||
|
|
||||||
[build-system]
|
[tool.uv]
|
||||||
requires = ["poetry>=0.12"]
|
environments = ["sys_platform == 'linux'"]
|
||||||
build-backend = "poetry.masonry.api"
|
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
|
|
||||||
|
|
||||||
poetry run /app/src/manage.py migrate
|
|
||||||
poetry run /app/src/manage.py runserver 0.0.0.0:8000
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
"""Django's command-line utility for administrative tasks."""
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.docker")
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
|
|
|
||||||
|
|
@ -1 +1,39 @@
|
||||||
# Register your models here.
|
from django import forms
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
||||||
|
from django.contrib.auth.forms import UserChangeForm
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from newsreader.accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserAdminForm(UserChangeForm):
|
||||||
|
class Meta:
|
||||||
|
widgets = {
|
||||||
|
"email": forms.EmailInput(attrs={"size": "50"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UserAdmin(DjangoUserAdmin):
|
||||||
|
list_display = ("email", "last_name", "date_joined", "is_active")
|
||||||
|
list_filter = ("is_active", "is_staff", "is_superuser")
|
||||||
|
ordering = ("email",)
|
||||||
|
|
||||||
|
search_fields = ["email", "last_name", "first_name"]
|
||||||
|
readonly_fields = ("last_login", "date_joined")
|
||||||
|
|
||||||
|
form = UserAdminForm
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
_("User settings"),
|
||||||
|
{"fields": ("email", "password", "first_name", "last_name", "is_active")},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_("Permission settings"),
|
||||||
|
{"classes": ("collapse",), "fields": ("is_staff", "is_superuser")},
|
||||||
|
),
|
||||||
|
(_("Misc settings"), {"fields": ("date_joined", "last_login")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(User, UserAdmin)
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class AccountsConfig(AppConfig):
|
class AccountsConfig(AppConfig):
|
||||||
name = "accounts"
|
name = "newsreader.accounts"
|
||||||
|
|
|
||||||
11
src/newsreader/accounts/forms.py
Normal file
11
src/newsreader/accounts/forms.py
Normal file
|
|
@ -0,0 +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", "auto_mark_read")
|
||||||
|
widgets = {"auto_mark_read": CheckboxInput}
|
||||||
|
|
@ -8,7 +8,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0001_initial")]
|
dependencies = [("accounts", "0001_initial")]
|
||||||
|
|
||||||
operations = [migrations.RemoveField(model_name="user", name="username")]
|
operations = [migrations.RemoveField(model_name="user", name="username")]
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import newsreader.accounts.models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0002_remove_user_username")]
|
dependencies = [("accounts", "0002_remove_user_username")]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0003_auto_20190714_1417")]
|
dependencies = [("accounts", "0003_auto_20190714_1417")]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0004_auto_20190714_1501")]
|
dependencies = [("accounts", "0004_auto_20190714_1501")]
|
||||||
|
|
||||||
operations = [migrations.RemoveField(model_name="user", name="task_interval")]
|
operations = [migrations.RemoveField(model_name="user", name="task_interval")]
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0005_remove_user_task_interval")]
|
dependencies = [("accounts", "0005_remove_user_task_interval")]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0006_auto_20191116_1253")]
|
dependencies = [("accounts", "0006_auto_20191116_1253")]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ def update_task_name(apps, schema_editor):
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [("accounts", "0007_auto_20191116_1255")]
|
dependencies = [("accounts", "0007_auto_20191116_1255")]
|
||||||
|
|
||||||
operations = [migrations.RunPython(update_task_name)]
|
operations = [migrations.RunPython(update_task_name)]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Generated by Django 3.0.5 on 2020-05-24 10:18
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("django_celery_beat", "0012_periodictask_expire_seconds"),
|
||||||
|
("accounts", "0008_auto_20200422_2243"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="task",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
blank=True,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="django_celery_beat.PeriodicTask",
|
||||||
|
verbose_name="collection task",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
@ -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 AbstractUser
|
||||||
from django.contrib.auth.models import UserManager as DjangoUserManager
|
from django.contrib.auth.models import UserManager as DjangoUserManager
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
from django_celery_beat.models import PeriodicTask
|
||||||
|
|
||||||
|
|
||||||
class UserManager(DjangoUserManager):
|
class UserManager(DjangoUserManager):
|
||||||
|
|
@ -41,13 +39,13 @@ class UserManager(DjangoUserManager):
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
email = models.EmailField(_("email address"), unique=True)
|
email = models.EmailField(_("email address"), unique=True)
|
||||||
|
|
||||||
task = models.OneToOneField(
|
# settings
|
||||||
PeriodicTask,
|
auto_mark_read = models.BooleanField(
|
||||||
on_delete=models.SET_NULL,
|
_("Auto read marking"),
|
||||||
null=True,
|
default=True,
|
||||||
blank=True,
|
help_text=_(
|
||||||
editable=False,
|
"Wether posts should be marked as read after x amount of seconds of reading"
|
||||||
verbose_name="collection task",
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
username = None
|
username = None
|
||||||
|
|
@ -57,24 +55,8 @@ class User(AbstractUser):
|
||||||
USERNAME_FIELD = "email"
|
USERNAME_FIELD = "email"
|
||||||
REQUIRED_FIELDS = []
|
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):
|
def delete(self, *args, **kwargs):
|
||||||
self.task.delete()
|
tasks = PeriodicTask.objects.filter(name__contains=self.email)
|
||||||
|
tasks.delete()
|
||||||
|
|
||||||
return super().delete(*args, **kwargs)
|
return super().delete(*args, **kwargs)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
{% extends "components/form/form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<section class="section form__section--last">
|
||||||
|
<fieldset class="fieldset form__fieldset">
|
||||||
|
{% include "components/form/cancel-button.html" %}
|
||||||
|
{% include "components/form/confirm-button.html" %}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="fieldset form__fieldset">
|
||||||
|
<a class="link" href="{% url 'accounts:password-reset' %}">
|
||||||
|
<small class="small">{% trans "I forgot my password" %}</small>
|
||||||
|
</a>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
{% endblock actions %}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
{% extends "components/form/form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<section class="section form__section--last">
|
||||||
|
<fieldset class="fieldset form__fieldset">
|
||||||
|
{% include "components/form/confirm-button.html" %}
|
||||||
|
|
||||||
|
<a class="link button button--primary" href="{% url 'accounts:password-change' %}">
|
||||||
|
{% trans "Change password" %}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if favicon_task_allowed %}
|
||||||
|
<a class="link button button--primary" href="{% url 'accounts:settings:favicon' %}">
|
||||||
|
{% trans "Fetch favicons" %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<button class="button button--primary button--disabled" disabled>
|
||||||
|
{% trans "Fetch favicons" %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
{% endblock actions %}
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<main id="login--page" class="main">
|
|
||||||
<form class="form login-form" method="POST" action="{% url 'accounts:login' %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="form__header">
|
|
||||||
<h1 class="form__title">Login</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<fieldset class="login-form__fieldset">
|
|
||||||
{{ form }}
|
|
||||||
</fieldset>
|
|
||||||
<fieldset class="login-form__fieldset">
|
|
||||||
<button class="button button--confirm" type="submit">Login</button>
|
|
||||||
<a class="link" href="{% url 'accounts:password-reset' %}">
|
|
||||||
<small class="small">I forgot my password</small>
|
|
||||||
</a>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{% extends "sidebar.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main id="login--page" class="main" data-render-sidebar=true>
|
||||||
|
<div class="main__container">
|
||||||
|
{% include "accounts/components/login-form.html" with form=form title="Login" confirm_text="Login" %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
{% extends "sidebar.html" %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% url 'accounts:settings:home' as cancel_url %}
|
||||||
|
|
||||||
|
<main id="password-change--page" class="main" data-render-sidebar=true>
|
||||||
|
<div class="main__container">
|
||||||
|
{% include "components/form/form.html" with form=form title="Change password" confirm_text="Change password" cancel_url=cancel_url %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{% extends "sidebar.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main id="settings--page" class="main" data-render-sidebar=true>
|
||||||
|
<div class="main__container">
|
||||||
|
{% include "accounts/components/settings-form.html" with form=form title="User profile" confirm_text="Save" %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -5,8 +5,6 @@ from django.utils.crypto import get_random_string
|
||||||
|
|
||||||
import factory
|
import factory
|
||||||
|
|
||||||
from registration.models import RegistrationProfile
|
|
||||||
|
|
||||||
from newsreader.accounts.models import User
|
from newsreader.accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -29,11 +27,3 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
|
|
||||||
|
|
||||||
class RegistrationProfileFactory(factory.django.DjangoModelFactory):
|
|
||||||
user = factory.SubFactory(UserFactory)
|
|
||||||
activation_key = factory.LazyFunction(get_activation_key)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = RegistrationProfile
|
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
import datetime
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
|
|
||||||
from registration.models import RegistrationProfile
|
|
||||||
|
|
||||||
from newsreader.accounts.models import User
|
|
||||||
|
|
||||||
|
|
||||||
class ActivationTestCase(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.register_url = reverse("accounts:register")
|
|
||||||
self.register_success_url = reverse("accounts:register-complete")
|
|
||||||
self.success_url = reverse("accounts:activate-complete")
|
|
||||||
|
|
||||||
def test_activation(self):
|
|
||||||
data = {
|
|
||||||
"email": "test@test.com",
|
|
||||||
"password1": "test12456",
|
|
||||||
"password2": "test12456",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.post(self.register_url, data)
|
|
||||||
self.assertRedirects(response, self.register_success_url)
|
|
||||||
|
|
||||||
register_profile = RegistrationProfile.objects.get()
|
|
||||||
|
|
||||||
kwargs = {"activation_key": register_profile.activation_key}
|
|
||||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
|
||||||
|
|
||||||
self.assertRedirects(response, self.success_url)
|
|
||||||
|
|
||||||
def test_expired_key(self):
|
|
||||||
data = {
|
|
||||||
"email": "test@test.com",
|
|
||||||
"password1": "test12456",
|
|
||||||
"password2": "test12456",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.post(self.register_url, data)
|
|
||||||
|
|
||||||
register_profile = RegistrationProfile.objects.get()
|
|
||||||
user = register_profile.user
|
|
||||||
|
|
||||||
user.date_joined -= datetime.timedelta(days=settings.ACCOUNT_ACTIVATION_DAYS)
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
kwargs = {"activation_key": register_profile.activation_key}
|
|
||||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
|
||||||
|
|
||||||
self.assertEqual(200, response.status_code)
|
|
||||||
self.assertContains(response, _("Account activation failed"))
|
|
||||||
|
|
||||||
user.refresh_from_db()
|
|
||||||
self.assertFalse(user.is_active)
|
|
||||||
|
|
||||||
def test_invalid_key(self):
|
|
||||||
data = {
|
|
||||||
"email": "test@test.com",
|
|
||||||
"password1": "test12456",
|
|
||||||
"password2": "test12456",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.post(self.register_url, data)
|
|
||||||
self.assertRedirects(response, self.register_success_url)
|
|
||||||
|
|
||||||
kwargs = {"activation_key": "not-a-valid-key"}
|
|
||||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
|
||||||
|
|
||||||
self.assertContains(response, _("Account activation failed"))
|
|
||||||
|
|
||||||
user = User.objects.get()
|
|
||||||
|
|
||||||
self.assertEquals(user.is_active, False)
|
|
||||||
|
|
||||||
def test_activated_key(self):
|
|
||||||
data = {
|
|
||||||
"email": "test@test.com",
|
|
||||||
"password1": "test12456",
|
|
||||||
"password2": "test12456",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.post(self.register_url, data)
|
|
||||||
self.assertRedirects(response, self.register_success_url)
|
|
||||||
|
|
||||||
register_profile = RegistrationProfile.objects.get()
|
|
||||||
|
|
||||||
kwargs = {"activation_key": register_profile.activation_key}
|
|
||||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
|
||||||
|
|
||||||
self.assertRedirects(response, self.success_url)
|
|
||||||
|
|
||||||
# try this a second time
|
|
||||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
|
||||||
|
|
||||||
self.assertRedirects(response, self.success_url)
|
|
||||||
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)
|
|
||||||
31
src/newsreader/accounts/tests/test_settings.py
Normal file
31
src/newsreader/accounts/tests/test_settings.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from newsreader.accounts.models import User
|
||||||
|
from newsreader.accounts.tests.factories import UserFactory
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsViewTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = UserFactory(email="test@test.nl", password="test")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
self.url = reverse("accounts:settings:home")
|
||||||
|
|
||||||
|
def test_simple(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_user_credential_change(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("accounts:settings:home"),
|
||||||
|
{"first_name": "First name", "last_name": "Last name"},
|
||||||
|
)
|
||||||
|
|
||||||
|
user = User.objects.get()
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("accounts:settings:home"))
|
||||||
|
|
||||||
|
self.assertEquals(user.first_name, "First name")
|
||||||
|
self.assertEquals(user.last_name, "Last name")
|
||||||
|
|
@ -1,22 +1,21 @@
|
||||||
from django.test import TestCase
|
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):
|
class UserTestCase(TestCase):
|
||||||
def test_task_is_created(self):
|
|
||||||
user = User.objects.create(email="durp@burp.nl", task=None)
|
|
||||||
task = PeriodicTask.objects.get(name=f"{user.email}-collection-task")
|
|
||||||
|
|
||||||
user.refresh_from_db()
|
|
||||||
|
|
||||||
self.assertEquals(task, user.task)
|
|
||||||
self.assertEquals(PeriodicTask.objects.count(), 1)
|
|
||||||
|
|
||||||
def test_task_is_deleted(self):
|
def test_task_is_deleted(self):
|
||||||
user = 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()
|
user.delete()
|
||||||
|
|
||||||
self.assertEquals(PeriodicTask.objects.count(), 0)
|
self.assertEquals(PeriodicTask.objects.count(), 0)
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,30 @@
|
||||||
from django.urls import path
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
from newsreader.accounts.views import (
|
from newsreader.accounts.views import (
|
||||||
ActivationCompleteView,
|
FaviconRedirectView,
|
||||||
ActivationResendView,
|
|
||||||
ActivationView,
|
|
||||||
LoginView,
|
LoginView,
|
||||||
LogoutView,
|
LogoutView,
|
||||||
|
PasswordChangeView,
|
||||||
PasswordResetCompleteView,
|
PasswordResetCompleteView,
|
||||||
PasswordResetConfirmView,
|
PasswordResetConfirmView,
|
||||||
PasswordResetDoneView,
|
PasswordResetDoneView,
|
||||||
PasswordResetView,
|
PasswordResetView,
|
||||||
RegistrationClosedView,
|
SettingsView,
|
||||||
RegistrationCompleteView,
|
|
||||||
RegistrationView,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
settings_patterns = [
|
||||||
|
# Misc
|
||||||
|
path("favicon/", login_required(FaviconRedirectView.as_view()), name="favicon"),
|
||||||
|
path("", login_required(SettingsView.as_view()), name="home"),
|
||||||
|
]
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
# Auth
|
||||||
path("login/", LoginView.as_view(), name="login"),
|
path("login/", LoginView.as_view(), name="login"),
|
||||||
path("logout/", LogoutView.as_view(), name="logout"),
|
path("logout/", LogoutView.as_view(), name="logout"),
|
||||||
path("register/", RegistrationView.as_view(), name="register"),
|
# Password
|
||||||
path(
|
|
||||||
"register/complete/",
|
|
||||||
RegistrationCompleteView.as_view(),
|
|
||||||
name="register-complete",
|
|
||||||
),
|
|
||||||
path("register/closed/", RegistrationClosedView.as_view(), name="register-closed"),
|
|
||||||
path(
|
|
||||||
"activate/complete/", ActivationCompleteView.as_view(), name="activate-complete"
|
|
||||||
),
|
|
||||||
path("activate/resend/", ActivationResendView.as_view(), name="activate-resend"),
|
|
||||||
path(
|
|
||||||
# This URL should be placed after all activate/ url's (see arg)
|
|
||||||
"activate/<str:activation_key>/",
|
|
||||||
ActivationView.as_view(),
|
|
||||||
name="activate",
|
|
||||||
),
|
|
||||||
path("password-reset/", PasswordResetView.as_view(), name="password-reset"),
|
path("password-reset/", PasswordResetView.as_view(), name="password-reset"),
|
||||||
path(
|
path(
|
||||||
"password-reset/done/",
|
"password-reset/done/",
|
||||||
|
|
@ -52,5 +41,11 @@ urlpatterns = [
|
||||||
PasswordResetCompleteView.as_view(),
|
PasswordResetCompleteView.as_view(),
|
||||||
name="password-reset-complete",
|
name="password-reset-complete",
|
||||||
),
|
),
|
||||||
# TODO: create password change views
|
path(
|
||||||
|
"password-change/",
|
||||||
|
login_required(PasswordChangeView.as_view()),
|
||||||
|
name="password-change",
|
||||||
|
),
|
||||||
|
# Settings
|
||||||
|
path("settings/", include((settings_patterns, "settings"))),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,91 +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 registration.backends.default import views as registration_views
|
|
||||||
|
|
||||||
|
|
||||||
class LoginView(django_views.LoginView):
|
|
||||||
template_name = "accounts/login.html"
|
|
||||||
|
|
||||||
def get_success_url(self):
|
|
||||||
return reverse_lazy("index")
|
|
||||||
|
|
||||||
|
|
||||||
class LogoutView(django_views.LogoutView):
|
|
||||||
next_page = reverse_lazy("accounts:login")
|
|
||||||
|
|
||||||
|
|
||||||
# RegistrationView shows a registration form and sends the email
|
|
||||||
# RegistrationCompleteView shows after filling in the registration form
|
|
||||||
# ActivationView is send within the activation email and activates the account
|
|
||||||
# ActivationCompleteView shows the success screen when activation was succesful
|
|
||||||
# ActivationResendView can be used when activation links are expired
|
|
||||||
# RegistrationClosedView shows when registration is disabled
|
|
||||||
class RegistrationView(registration_views.RegistrationView):
|
|
||||||
disallowed_url = reverse_lazy("accounts:register-closed")
|
|
||||||
template_name = "registration/registration_form.html"
|
|
||||||
success_url = reverse_lazy("accounts:register-complete")
|
|
||||||
|
|
||||||
|
|
||||||
class RegistrationCompleteView(TemplateView):
|
|
||||||
template_name = "registration/registration_complete.html"
|
|
||||||
|
|
||||||
|
|
||||||
class RegistrationClosedView(TemplateView):
|
|
||||||
template_name = "registration/registration_closed.html"
|
|
||||||
|
|
||||||
|
|
||||||
# Redirects or renders failed activation template
|
|
||||||
class ActivationView(registration_views.ActivationView):
|
|
||||||
template_name = "registration/activation_failure.html"
|
|
||||||
|
|
||||||
def get_success_url(self, user):
|
|
||||||
return ("accounts:activate-complete", (), {})
|
|
||||||
|
|
||||||
|
|
||||||
class ActivationCompleteView(TemplateView):
|
|
||||||
template_name = "registration/activation_complete.html"
|
|
||||||
|
|
||||||
|
|
||||||
# Renders activation form resend or resend_activation_complete
|
|
||||||
class ActivationResendView(registration_views.ResendActivationView):
|
|
||||||
template_name = "registration/activation_resend_form.html"
|
|
||||||
|
|
||||||
def render_form_submitted_template(self, form):
|
|
||||||
"""
|
|
||||||
Renders resend activation complete template with the submitted email.
|
|
||||||
|
|
||||||
"""
|
|
||||||
email = form.cleaned_data["email"]
|
|
||||||
context = {"email": email}
|
|
||||||
|
|
||||||
return render(
|
|
||||||
self.request, "registration/activation_resend_complete.html", context
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# PasswordResetView sends the mail
|
|
||||||
# PasswordResetDoneView shows a success message for the above
|
|
||||||
# PasswordResetConfirmView checks the link the user clicked and
|
|
||||||
# prompts for a new password
|
|
||||||
# PasswordResetCompleteView shows a success message for the above
|
|
||||||
class PasswordResetView(django_views.PasswordResetView):
|
|
||||||
template_name = "password-reset/password_reset_form.html"
|
|
||||||
subject_template_name = "password-reset/password_reset_subject.txt"
|
|
||||||
email_template_name = "password-reset/password_reset_email.html"
|
|
||||||
success_url = reverse_lazy("accounts:password-reset-done")
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetDoneView(django_views.PasswordResetDoneView):
|
|
||||||
template_name = "password-reset/password_reset_done.html"
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetConfirmView(django_views.PasswordResetConfirmView):
|
|
||||||
template_name = "password-reset/password_reset_confirm.html"
|
|
||||||
success_url = reverse_lazy("accounts:password-reset-complete")
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetCompleteView(django_views.PasswordResetCompleteView):
|
|
||||||
template_name = "password-reset/password_reset_complete.html"
|
|
||||||
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,12 +3,8 @@ import os
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
|
||||||
|
|
||||||
# note: this should be consistent with the setting from manage.py
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.docker")
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev")
|
|
||||||
|
|
||||||
# note: use the --workdir flag when running from different directories
|
|
||||||
app = Celery("newsreader")
|
app = Celery("newsreader")
|
||||||
|
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||||
app.config_from_object("django.conf:settings")
|
|
||||||
|
|
||||||
app.autodiscover_tasks()
|
app.autodiscover_tasks()
|
||||||
|
|
|
||||||
|
|
@ -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
|
load_dotenv()
|
||||||
DJANGO_PROJECT_DIR = os.path.join(BASE_DIR, "src", "newsreader")
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
try:
|
||||||
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
|
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||||
# SECURITY WARNING: don"t run with debug turned on in production!
|
from sentry_sdk.integrations.django import DjangoIntegration
|
||||||
DEBUG = True
|
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
|
# Application definition
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
|
|
@ -22,19 +29,22 @@ INSTALLED_APPS = [
|
||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
|
"django.forms",
|
||||||
# third party apps
|
# third party apps
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
"drf_yasg",
|
|
||||||
"celery",
|
"celery",
|
||||||
"django_celery_beat",
|
"django_celery_beat",
|
||||||
"registration",
|
|
||||||
"axes",
|
"axes",
|
||||||
# app modules
|
# app modules
|
||||||
"newsreader.accounts",
|
"newsreader.accounts",
|
||||||
|
"newsreader.utils",
|
||||||
|
"newsreader.news",
|
||||||
"newsreader.news.core",
|
"newsreader.news.core",
|
||||||
"newsreader.news.collection",
|
"newsreader.news.collection",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
SECRET_KEY = get_env("DJANGO_SECRET_KEY", default="")
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
"axes.backends.AxesBackend",
|
"axes.backends.AxesBackend",
|
||||||
"django.contrib.auth.backends.ModelBackend",
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
|
|
@ -53,14 +63,15 @@ MIDDLEWARE = [
|
||||||
|
|
||||||
ROOT_URLCONF = "newsreader.urls"
|
ROOT_URLCONF = "newsreader.urls"
|
||||||
|
|
||||||
|
FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
|
"DIRS": [DJANGO_PROJECT_DIR / "templates"],
|
||||||
"APP_DIRS": True,
|
"APP_DIRS": True,
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"context_processors": [
|
"context_processors": [
|
||||||
"django.template.context_processors.debug",
|
|
||||||
"django.template.context_processors.request",
|
"django.template.context_processors.request",
|
||||||
"django.contrib.auth.context_processors.auth",
|
"django.contrib.auth.context_processors.auth",
|
||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
|
@ -71,31 +82,83 @@ TEMPLATES = [
|
||||||
|
|
||||||
WSGI_APPLICATION = "newsreader.wsgi.application"
|
WSGI_APPLICATION = "newsreader.wsgi.application"
|
||||||
|
|
||||||
# Database
|
|
||||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.postgresql",
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
"HOST": os.environ.get("POSTGRES_HOST", ""),
|
"HOST": get_env("POSTGRES_HOST", default=""),
|
||||||
"NAME": os.environ.get("POSTGRES_NAME", "newsreader"),
|
"PORT": get_env("POSTGRES_PORT", default=""),
|
||||||
"USER": os.environ.get("POSTGRES_USER"),
|
"NAME": get_env("POSTGRES_DB", default=""),
|
||||||
"PASSWORD": os.environ.get("POSTGRES_PASSWORD"),
|
"USER": get_env("POSTGRES_USER", default=""),
|
||||||
|
"PASSWORD": get_env("POSTGRES_PASSWORD", default=""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||||
|
|
||||||
CACHES = {
|
CACHES = {
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||||
"LOCATION": "localhost:11211",
|
"LOCATION": "memcached:11211",
|
||||||
},
|
},
|
||||||
"axes": {
|
"axes": {
|
||||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||||
"LOCATION": "localhost:11211",
|
"LOCATION": "memcached:11211",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"filters": {
|
||||||
|
"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"},
|
||||||
|
"require_debug_true": {"()": "django.utils.log.RequireDebugTrue"},
|
||||||
|
},
|
||||||
|
"formatters": {
|
||||||
|
"timestamped": {
|
||||||
|
"()": "django.utils.log.ServerFormatter",
|
||||||
|
"format": "[{server_time}] {message}",
|
||||||
|
"style": "{",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"level": "INFO",
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "timestamped",
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"level": "DEBUG",
|
||||||
|
"class": "logging.handlers.RotatingFileHandler",
|
||||||
|
"filename": BASE_DIR / "logs" / "newsreader.log",
|
||||||
|
"backupCount": 5,
|
||||||
|
"maxBytes": 50000000, # 50 mB
|
||||||
|
"formatter": "timestamped",
|
||||||
|
},
|
||||||
|
"celery": {
|
||||||
|
"level": "INFO",
|
||||||
|
"class": "logging.handlers.RotatingFileHandler",
|
||||||
|
"filename": BASE_DIR / "logs" / "celery.log",
|
||||||
|
"backupCount": 5,
|
||||||
|
"maxBytes": 50000000, # 50 mB
|
||||||
|
"formatter": "timestamped",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"django": {"handlers": ["console"], "level": "INFO"},
|
||||||
|
"django.server": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
"celery.task": {"handlers": ["console", "celery"], "level": "INFO"},
|
||||||
|
"newsreader": {
|
||||||
|
"handlers": ["console", "file"],
|
||||||
|
"level": "DEBUG",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Password validation
|
|
||||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
|
||||||
|
|
@ -108,8 +171,8 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||||
# Authentication user model
|
# Authentication user model
|
||||||
AUTH_USER_MODEL = "accounts.User"
|
AUTH_USER_MODEL = "accounts.User"
|
||||||
|
|
||||||
# Internationalization
|
LOGIN_REDIRECT_URL = "/"
|
||||||
# https://docs.djangoproject.com/en/2.2/topics/i18n/
|
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
TIME_ZONE = "Europe/Amsterdam"
|
TIME_ZONE = "Europe/Amsterdam"
|
||||||
|
|
@ -117,19 +180,31 @@ USE_I18N = True
|
||||||
USE_L10N = True
|
USE_L10N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
|
||||||
# https://docs.djangoproject.com/en/2.2/howto/static-files/
|
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
STATIC_ROOT = BASE_DIR / "static"
|
||||||
STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static")]
|
STATICFILES_DIRS = (DJANGO_PROJECT_DIR / "static",)
|
||||||
|
|
||||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-STATICFILES_FINDERS
|
|
||||||
STATICFILES_FINDERS = [
|
STATICFILES_FINDERS = [
|
||||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||||
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||||
]
|
]
|
||||||
|
|
||||||
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
|
# Third party settings
|
||||||
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
|
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
|
||||||
|
|
@ -146,7 +221,12 @@ REST_FRAMEWORK = {
|
||||||
"rest_framework.permissions.IsAuthenticated",
|
"rest_framework.permissions.IsAuthenticated",
|
||||||
"newsreader.accounts.permissions.IsOwner",
|
"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 = {
|
SWAGGER_SETTINGS = {
|
||||||
|
|
@ -155,6 +235,17 @@ SWAGGER_SETTINGS = {
|
||||||
"DOC_EXPANSION": "list",
|
"DOC_EXPANSION": "list",
|
||||||
}
|
}
|
||||||
|
|
||||||
REGISTRATION_OPEN = True
|
# Celery
|
||||||
REGISTRATION_AUTO_LOGIN = True
|
# https://docs.celeryproject.org/en/stable/userguide/configuration.html
|
||||||
ACCOUNT_ACTIVATION_DAYS = 7
|
# Note that celery settings are prefix with CELERY. See src/newsreader/celery.py.
|
||||||
|
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
||||||
|
CELERY_BROKER_URL = "amqp://guest@rabbitmq:5672"
|
||||||
|
|
||||||
|
# Sentry
|
||||||
|
SENTRY_CONFIG = {
|
||||||
|
"dsn": get_env("SENTRY_DSN", default="", required=False),
|
||||||
|
"send_default_pii": False,
|
||||||
|
"integrations": [DjangoIntegration(), CeleryIntegration()]
|
||||||
|
if DjangoIntegration and CeleryIntegration
|
||||||
|
else [],
|
||||||
|
}
|
||||||
|
|
|
||||||
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"
|
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"
|
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||||
|
|
||||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"]
|
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
"django.template.context_processors.debug",
|
||||||
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
|
)
|
||||||
"APP_DIRS": True,
|
|
||||||
"OPTIONS": {
|
# Project settings
|
||||||
"context_processors": [
|
VERSION = get_current_version()
|
||||||
"django.template.context_processors.debug",
|
|
||||||
"django.template.context_processors.request",
|
|
||||||
"django.contrib.auth.context_processors.auth",
|
|
||||||
"django.contrib.messages.context_processors.messages",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
# Third party settings
|
# Third party settings
|
||||||
AXES_FAILURE_LIMIT = 50
|
AXES_FAILURE_LIMIT = 50
|
||||||
AXES_COOLOFF_TIME = None
|
AXES_COOLOFF_TIME = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Optionally use sentry integration
|
||||||
|
from sentry_sdk import init as sentry_init
|
||||||
|
|
||||||
from .local import * # noqa
|
from .local import * # noqa
|
||||||
|
|
||||||
|
SENTRY_CONFIG.update({"release": VERSION}) # noqa: F405
|
||||||
|
|
||||||
|
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -1,19 +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
|
||||||
|
|
||||||
# Celery
|
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
||||||
# https://docs.celeryproject.org/en/latest/userguide/configuration.html
|
|
||||||
BROKER_URL = "amqp://guest:guest@rabbitmq:5672//"
|
|
||||||
|
|
||||||
CACHES = {
|
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405
|
||||||
"default": {
|
|
||||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
LOGGING["loggers"].update( # noqa: F405
|
||||||
"LOCATION": "memcached:11211",
|
{
|
||||||
},
|
"celery.task": {"handlers": ["console", "celery"], "level": "DEBUG"},
|
||||||
"axes": {
|
}
|
||||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
)
|
||||||
"LOCATION": "memcached:11211",
|
|
||||||
},
|
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||||
}
|
|
||||||
|
|
||||||
|
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
|
||||||
|
"django.template.context_processors.debug",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Project settings
|
||||||
|
VERSION = get_current_version()
|
||||||
|
ENVIRONMENT = "docker"
|
||||||
|
|
||||||
|
# Third party settings
|
||||||
|
# Axes
|
||||||
|
AXES_FAILURE_LIMIT = 50
|
||||||
|
AXES_COOLOFF_TIME = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Optionally use sentry integration
|
||||||
|
from sentry_sdk import init as sentry_init
|
||||||
|
|
||||||
|
from .local import * # noqa
|
||||||
|
|
||||||
|
SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT}) # noqa: F405
|
||||||
|
|
||||||
|
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
|
||||||
|
|
@ -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,45 +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
|
DEBUG = False
|
||||||
ALLOWED_HOSTS = ["rss.fudiggity.nl"]
|
|
||||||
|
|
||||||
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
|
||||||
DATABASES = {
|
ADMINS = [
|
||||||
"default": {
|
("", email) for email in get_env("ADMINS", split=",", required=False, default=[])
|
||||||
"ENGINE": "django.db.backends.postgresql",
|
|
||||||
"HOST": os.environ["POSTGRES_HOST"],
|
|
||||||
"PORT": os.environ["POSTGRES_PORT"],
|
|
||||||
"NAME": os.environ["POSTGRES_NAME"],
|
|
||||||
"USER": os.environ["POSTGRES_USER"],
|
|
||||||
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
||||||
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
|
|
||||||
"APP_DIRS": True,
|
|
||||||
"OPTIONS": {
|
|
||||||
"context_processors": [
|
|
||||||
"django.template.context_processors.request",
|
|
||||||
"django.contrib.auth.context_processors.auth",
|
|
||||||
"django.contrib.messages.context_processors.messages",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Third party settings
|
# Project settings
|
||||||
AXES_HANDLER = "axes.handlers.database.DatabaseHandler"
|
VERSION = get_current_version(debug=False)
|
||||||
|
ENVIRONMENT = "production"
|
||||||
|
|
||||||
REGISTRATION_OPEN = False
|
# Third party settings
|
||||||
|
AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler"
|
||||||
|
|
||||||
|
# Optionally use sentry integration
|
||||||
|
try:
|
||||||
|
from sentry_sdk import init as sentry_init
|
||||||
|
|
||||||
|
SENTRY_CONFIG.update( # noqa: F405
|
||||||
|
{"release": VERSION, "environment": ENVIRONMENT, "debug": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
|
||||||
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):
|
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"
|
page_size_query_param = "count"
|
||||||
max_page_size = 50
|
max_page_size = 50
|
||||||
page_size = 30
|
page_size = 30
|
||||||
|
|
@ -10,3 +10,9 @@ class ResultSetPagination(PageNumberPagination):
|
||||||
class LargeResultSetPagination(ResultSetPagination):
|
class LargeResultSetPagination(ResultSetPagination):
|
||||||
max_page_size = 100
|
max_page_size = 100
|
||||||
page_size = 50
|
page_size = 50
|
||||||
|
|
||||||
|
|
||||||
|
class CursorPagination(pagination.CursorPagination):
|
||||||
|
page_size_query_param = "count"
|
||||||
|
ordering = "-publication_date"
|
||||||
|
page_size = 30
|
||||||
|
|
|
||||||
|
|
@ -1,298 +0,0 @@
|
||||||
[
|
|
||||||
{
|
|
||||||
"model": "django_celery_beat.periodictask",
|
|
||||||
"pk": 10,
|
|
||||||
"fields": {
|
|
||||||
"name": "sonny@bakker.nl-collection-task",
|
|
||||||
"task": "newsreader.news.collection.tasks.FeedTask",
|
|
||||||
"interval": 4,
|
|
||||||
"crontab": null,
|
|
||||||
"solar": null,
|
|
||||||
"clocked": null,
|
|
||||||
"args": "[2]",
|
|
||||||
"kwargs": "{}",
|
|
||||||
"queue": null,
|
|
||||||
"exchange": null,
|
|
||||||
"routing_key": null,
|
|
||||||
"headers": "{}",
|
|
||||||
"priority": null,
|
|
||||||
"expires": null,
|
|
||||||
"one_off": false,
|
|
||||||
"start_time": null,
|
|
||||||
"enabled": true,
|
|
||||||
"last_run_at": "2019-11-29T22:29:08.345Z",
|
|
||||||
"total_run_count": 290,
|
|
||||||
"date_changed": "2019-11-29T22:29:18.378Z",
|
|
||||||
"description": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "django_celery_beat.periodictask",
|
|
||||||
"pk": 26,
|
|
||||||
"fields": {
|
|
||||||
"name": "sonnyba871@gmail.com-collection-task",
|
|
||||||
"task": "newsreader.news.collection.tasks.FeedTask",
|
|
||||||
"interval": 4,
|
|
||||||
"crontab": null,
|
|
||||||
"solar": null,
|
|
||||||
"clocked": null,
|
|
||||||
"args": "[18]",
|
|
||||||
"kwargs": "{}",
|
|
||||||
"queue": null,
|
|
||||||
"exchange": null,
|
|
||||||
"routing_key": null,
|
|
||||||
"headers": "{}",
|
|
||||||
"priority": null,
|
|
||||||
"expires": null,
|
|
||||||
"one_off": false,
|
|
||||||
"start_time": null,
|
|
||||||
"enabled": true,
|
|
||||||
"last_run_at": "2019-11-29T22:35:19.134Z",
|
|
||||||
"total_run_count": 103,
|
|
||||||
"date_changed": "2019-11-29T22:38:19.464Z",
|
|
||||||
"description": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "django_celery_beat.crontabschedule",
|
|
||||||
"pk": 1,
|
|
||||||
"fields": {
|
|
||||||
"minute": "0",
|
|
||||||
"hour": "4",
|
|
||||||
"day_of_week": "*",
|
|
||||||
"day_of_month": "*",
|
|
||||||
"month_of_year": "*",
|
|
||||||
"timezone": "UTC"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "django_celery_beat.intervalschedule",
|
|
||||||
"pk": 1,
|
|
||||||
"fields": {
|
|
||||||
"every": 5,
|
|
||||||
"period": "minutes"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "django_celery_beat.intervalschedule",
|
|
||||||
"pk": 2,
|
|
||||||
"fields": {
|
|
||||||
"every": 15,
|
|
||||||
"period": "minutes"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "django_celery_beat.intervalschedule",
|
|
||||||
"pk": 3,
|
|
||||||
"fields": {
|
|
||||||
"every": 30,
|
|
||||||
"period": "minutes"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "django_celery_beat.intervalschedule",
|
|
||||||
"pk": 4,
|
|
||||||
"fields": {
|
|
||||||
"every": 1,
|
|
||||||
"period": "hours"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "accounts.user",
|
|
||||||
"fields": {
|
|
||||||
"password": "pbkdf2_sha256$150000$5lBD7JemxYfE$B+lM5wWUW2n/ZulPFaWHtzWjyQ/QZ6iwjAC2I0R/VzU=",
|
|
||||||
"last_login": "2019-11-27T18:57:36.686Z",
|
|
||||||
"is_superuser": true,
|
|
||||||
"first_name": "",
|
|
||||||
"last_name": "",
|
|
||||||
"is_staff": true,
|
|
||||||
"is_active": true,
|
|
||||||
"date_joined": "2019-07-18T18:52:36.080Z",
|
|
||||||
"email": "sonny@bakker.nl",
|
|
||||||
"task": 10,
|
|
||||||
"groups": [],
|
|
||||||
"user_permissions": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "accounts.user",
|
|
||||||
"fields": {
|
|
||||||
"password": "pbkdf2_sha256$150000$vUwxT8T25R8C$S+Eq2tMRbSDE31/X5KGJ/M+Nblh7kKfzuM/z7HraR/Q=",
|
|
||||||
"last_login": null,
|
|
||||||
"is_superuser": false,
|
|
||||||
"first_name": "",
|
|
||||||
"last_name": "",
|
|
||||||
"is_staff": false,
|
|
||||||
"is_active": false,
|
|
||||||
"date_joined": "2019-11-25T15:35:14.051Z",
|
|
||||||
"email": "sonnyba871@gmail.com",
|
|
||||||
"task": 26,
|
|
||||||
"groups": [],
|
|
||||||
"user_permissions": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "core.category",
|
|
||||||
"pk": 8,
|
|
||||||
"fields": {
|
|
||||||
"created": "2019-11-17T19:37:24.671Z",
|
|
||||||
"modified": "2019-11-18T19:59:55.010Z",
|
|
||||||
"name": "World news",
|
|
||||||
"user": [
|
|
||||||
"sonny@bakker.nl"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "core.category",
|
|
||||||
"pk": 9,
|
|
||||||
"fields": {
|
|
||||||
"created": "2019-11-17T19:37:26.161Z",
|
|
||||||
"modified": "2019-11-18T19:59:45.010Z",
|
|
||||||
"name": "Tech",
|
|
||||||
"user": [
|
|
||||||
"sonny@bakker.nl"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "collection.collectionrule",
|
|
||||||
"pk": 3,
|
|
||||||
"fields": {
|
|
||||||
"created": "2019-07-14T13:08:10.374Z",
|
|
||||||
"modified": "2019-11-29T22:35:20.346Z",
|
|
||||||
"name": "Hackers News",
|
|
||||||
"url": "https://news.ycombinator.com/rss",
|
|
||||||
"website_url": "https://news.ycombinator.com/",
|
|
||||||
"favicon": "https://news.ycombinator.com/favicon.ico",
|
|
||||||
"timezone": "UTC",
|
|
||||||
"category": 9,
|
|
||||||
"last_suceeded": "2019-11-29T22:35:20.235Z",
|
|
||||||
"succeeded": true,
|
|
||||||
"error": null,
|
|
||||||
"user": [
|
|
||||||
"sonny@bakker.nl"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "collection.collectionrule",
|
|
||||||
"pk": 4,
|
|
||||||
"fields": {
|
|
||||||
"created": "2019-07-20T11:24:32.745Z",
|
|
||||||
"modified": "2019-11-29T22:35:19.525Z",
|
|
||||||
"name": "BBC",
|
|
||||||
"url": "http://feeds.bbci.co.uk/news/world/rss.xml",
|
|
||||||
"website_url": "https://www.bbc.co.uk/news/",
|
|
||||||
"favicon": "https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png",
|
|
||||||
"timezone": "UTC",
|
|
||||||
"category": 8,
|
|
||||||
"last_suceeded": "2019-11-29T22:35:19.241Z",
|
|
||||||
"succeeded": true,
|
|
||||||
"error": null,
|
|
||||||
"user": [
|
|
||||||
"sonny@bakker.nl"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "collection.collectionrule",
|
|
||||||
"pk": 5,
|
|
||||||
"fields": {
|
|
||||||
"created": "2019-07-20T11:24:50.411Z",
|
|
||||||
"modified": "2019-11-29T22:35:20.010Z",
|
|
||||||
"name": "Ars Technica",
|
|
||||||
"url": "http://feeds.arstechnica.com/arstechnica/index?fmt=xml",
|
|
||||||
"website_url": "https://arstechnica.com",
|
|
||||||
"favicon": "https://cdn.arstechnica.net/favicon.ico",
|
|
||||||
"timezone": "UTC",
|
|
||||||
"category": 9,
|
|
||||||
"last_suceeded": "2019-11-29T22:35:19.808Z",
|
|
||||||
"succeeded": true,
|
|
||||||
"error": null,
|
|
||||||
"user": [
|
|
||||||
"sonny@bakker.nl"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "collection.collectionrule",
|
|
||||||
"pk": 6,
|
|
||||||
"fields": {
|
|
||||||
"created": "2019-07-20T11:25:02.089Z",
|
|
||||||
"modified": "2019-11-29T22:35:20.233Z",
|
|
||||||
"name": "The Guardian",
|
|
||||||
"url": "https://www.theguardian.com/world/rss",
|
|
||||||
"website_url": "https://www.theguardian.com/world",
|
|
||||||
"favicon": "https://assets.guim.co.uk/images/favicons/873381bf11d58e20f551905d51575117/72x72.png",
|
|
||||||
"timezone": "UTC",
|
|
||||||
"category": 8,
|
|
||||||
"last_suceeded": "2019-11-29T22:35:20.076Z",
|
|
||||||
"succeeded": true,
|
|
||||||
"error": null,
|
|
||||||
"user": [
|
|
||||||
"sonny@bakker.nl"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "collection.collectionrule",
|
|
||||||
"pk": 7,
|
|
||||||
"fields": {
|
|
||||||
"created": "2019-07-20T11:25:30.121Z",
|
|
||||||
"modified": "2019-11-29T22:35:19.695Z",
|
|
||||||
"name": "Tweakers",
|
|
||||||
"url": "http://feeds.feedburner.com/tweakers/mixed?fmt=xml",
|
|
||||||
"website_url": "https://tweakers.net/",
|
|
||||||
"favicon": null,
|
|
||||||
"timezone": "UTC",
|
|
||||||
"category": 9,
|
|
||||||
"last_suceeded": "2019-11-29T22:35:19.528Z",
|
|
||||||
"succeeded": true,
|
|
||||||
"error": null,
|
|
||||||
"user": [
|
|
||||||
"sonny@bakker.nl"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "collection.collectionrule",
|
|
||||||
"pk": 8,
|
|
||||||
"fields": {
|
|
||||||
"created": "2019-07-20T11:25:46.256Z",
|
|
||||||
"modified": "2019-11-29T22:35:20.074Z",
|
|
||||||
"name": "The Verge",
|
|
||||||
"url": "https://www.theverge.com/rss/index.xml",
|
|
||||||
"website_url": "https://www.theverge.com/",
|
|
||||||
"favicon": "https://cdn.vox-cdn.com/uploads/chorus_asset/file/7395367/favicon-16x16.0.png",
|
|
||||||
"timezone": "UTC",
|
|
||||||
"category": 9,
|
|
||||||
"last_suceeded": "2019-11-29T22:35:20.012Z",
|
|
||||||
"succeeded": true,
|
|
||||||
"error": null,
|
|
||||||
"user": [
|
|
||||||
"sonny@bakker.nl"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"model": "collection.collectionrule",
|
|
||||||
"pk": 9,
|
|
||||||
"fields": {
|
|
||||||
"created": "2019-11-24T15:28:41.399Z",
|
|
||||||
"modified": "2019-11-29T22:35:19.807Z",
|
|
||||||
"name": "NOS",
|
|
||||||
"url": "http://feeds.nos.nl/nosnieuwsalgemeen",
|
|
||||||
"website_url": null,
|
|
||||||
"favicon": null,
|
|
||||||
"timezone": "Europe/Amsterdam",
|
|
||||||
"category": 8,
|
|
||||||
"last_suceeded": "2019-11-29T22:35:19.697Z",
|
|
||||||
"succeeded": true,
|
|
||||||
"error": null,
|
|
||||||
"user": [
|
|
||||||
"sonny@bakker.nl"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
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,
|
"user" : 2,
|
||||||
"succeeded" : true,
|
"succeeded" : true,
|
||||||
"modified" : "2019-07-20T11:28:16.473Z",
|
"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",
|
"name" : "Hackers News",
|
||||||
"website_url" : null,
|
"website_url" : null,
|
||||||
"created" : "2019-07-14T13:08:10.374Z",
|
"created" : "2019-07-14T13:08:10.374Z",
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
"error" : null,
|
"error" : null,
|
||||||
"user" : 2,
|
"user" : 2,
|
||||||
"succeeded" : true,
|
"succeeded" : true,
|
||||||
"last_suceeded" : "2019-07-20T11:28:15.691Z",
|
"last_run" : "2019-07-20T11:28:15.691Z",
|
||||||
"name" : "BBC",
|
"name" : "BBC",
|
||||||
"modified" : "2019-07-20T12:07:49.164Z",
|
"modified" : "2019-07-20T12:07:49.164Z",
|
||||||
"timezone" : "UTC",
|
"timezone" : "UTC",
|
||||||
|
|
@ -85,7 +85,7 @@
|
||||||
"website_url" : null,
|
"website_url" : null,
|
||||||
"name" : "Ars Technica",
|
"name" : "Ars Technica",
|
||||||
"succeeded" : true,
|
"succeeded" : true,
|
||||||
"last_suceeded" : "2019-07-20T11:28:15.986Z",
|
"last_run" : "2019-07-20T11:28:15.986Z",
|
||||||
"modified" : "2019-07-20T11:28:16.033Z",
|
"modified" : "2019-07-20T11:28:16.033Z",
|
||||||
"user" : 2
|
"user" : 2
|
||||||
},
|
},
|
||||||
|
|
@ -102,7 +102,7 @@
|
||||||
"user" : 2,
|
"user" : 2,
|
||||||
"name" : "The Guardian",
|
"name" : "The Guardian",
|
||||||
"succeeded" : true,
|
"succeeded" : true,
|
||||||
"last_suceeded" : "2019-07-20T11:28:16.078Z",
|
"last_run" : "2019-07-20T11:28:16.078Z",
|
||||||
"modified" : "2019-07-20T12:07:44.292Z",
|
"modified" : "2019-07-20T12:07:44.292Z",
|
||||||
"created" : "2019-07-20T11:25:02.089Z",
|
"created" : "2019-07-20T11:25:02.089Z",
|
||||||
"website_url" : null,
|
"website_url" : null,
|
||||||
|
|
@ -119,7 +119,7 @@
|
||||||
"website_url" : null,
|
"website_url" : null,
|
||||||
"created" : "2019-07-20T11:25:30.121Z",
|
"created" : "2019-07-20T11:25:30.121Z",
|
||||||
"user" : 2,
|
"user" : 2,
|
||||||
"last_suceeded" : "2019-07-20T11:28:15.860Z",
|
"last_run" : "2019-07-20T11:28:15.860Z",
|
||||||
"succeeded" : true,
|
"succeeded" : true,
|
||||||
"modified" : "2019-07-20T12:07:28.473Z",
|
"modified" : "2019-07-20T12:07:28.473Z",
|
||||||
"name" : "Tweakers"
|
"name" : "Tweakers"
|
||||||
|
|
@ -139,7 +139,7 @@
|
||||||
"website_url" : null,
|
"website_url" : null,
|
||||||
"timezone" : "UTC",
|
"timezone" : "UTC",
|
||||||
"user" : 2,
|
"user" : 2,
|
||||||
"last_suceeded" : "2019-07-20T11:28:16.034Z",
|
"last_run" : "2019-07-20T11:28:16.034Z",
|
||||||
"succeeded" : true,
|
"succeeded" : true,
|
||||||
"modified" : "2019-07-20T12:07:21.704Z",
|
"modified" : "2019-07-20T12:07:21.704Z",
|
||||||
"name" : "The Verge"
|
"name" : "The Verge"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||||
|
|
||||||
const Card = props => {
|
const Card = props => {
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div id={`${props.id}`} className="card">
|
||||||
<div className="card__header">{props.header}</div>
|
<div className="card__header">{props.header}</div>
|
||||||
<div className="card__content">{props.content}</div>
|
<div className="card__content">{props.content}</div>
|
||||||
<div className="card__footer">{props.footer}</div>
|
<div className="card__footer">{props.footer}</div>
|
||||||
|
|
|
||||||
|
|
@ -3,26 +3,24 @@ import React from 'react';
|
||||||
class Messages extends React.Component {
|
class Messages extends React.Component {
|
||||||
state = { messages: this.props.messages };
|
state = { messages: this.props.messages };
|
||||||
|
|
||||||
close = ::this.close;
|
close = index => {
|
||||||
|
|
||||||
close(index) {
|
|
||||||
const newMessages = this.state.messages.filter((message, currentIndex) => {
|
const newMessages = this.state.messages.filter((message, currentIndex) => {
|
||||||
return currentIndex != index;
|
return currentIndex != index;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setState({ messages: newMessages });
|
this.setState({ messages: newMessages });
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const messages = this.state.messages.map((message, index) => {
|
const messages = this.state.messages.map((message, index) => {
|
||||||
return (
|
return (
|
||||||
<li key={index} className={`messages__item messages__item--${message.type}`}>
|
<li key={index} className={`messages__item messages__item--${message.type}`}>
|
||||||
{message.text} <i className="gg-close" onClick={() => this.close(index)} />
|
{message.text} <i className="fas fa-times" onClick={() => this.close(index)} />
|
||||||
</li>
|
</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;
|
||||||
21
src/newsreader/js/components/Selector.js
Normal file
21
src/newsreader/js/components/Selector.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
class Selector {
|
||||||
|
inputs = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const selectAllInput = document.querySelector('#select-all');
|
||||||
|
|
||||||
|
this.inputs = document.querySelectorAll(`[name=${selectAllInput.dataset.input}`);
|
||||||
|
|
||||||
|
selectAllInput.onchange = this.onClick;
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick = e => {
|
||||||
|
const targetValue = e.target.checked;
|
||||||
|
|
||||||
|
this.inputs.forEach(input => {
|
||||||
|
input.checked = targetValue;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Selector;
|
||||||
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/homepage/index.js';
|
||||||
import './pages/rules/index.js';
|
|
||||||
import './pages/categories/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 CategoryCard from './components/CategoryCard.js';
|
||||||
import CategoryModal from './components/CategoryModal.js';
|
import CategoryModal from './components/CategoryModal.js';
|
||||||
import Messages from '../../components/Messages.js';
|
import Messages from '../../components/Messages.js';
|
||||||
|
import Sidebar from '../../components/Sidebar.js';
|
||||||
|
|
||||||
class App extends React.Component {
|
class App extends React.Component {
|
||||||
selectCategory = ::this.selectCategory;
|
|
||||||
deselectCategory = ::this.deselectCategory;
|
|
||||||
deleteCategory = ::this.deleteCategory;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
|
@ -23,15 +20,15 @@ class App extends React.Component {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
selectCategory(categoryId) {
|
selectCategory = categoryId => {
|
||||||
this.setState({ selectedCategoryId: categoryId });
|
this.setState({ selectedCategoryId: categoryId });
|
||||||
}
|
};
|
||||||
|
|
||||||
deselectCategory() {
|
deselectCategory = () => {
|
||||||
this.setState({ selectedCategoryId: null });
|
this.setState({ selectedCategoryId: null });
|
||||||
}
|
};
|
||||||
|
|
||||||
deleteCategory(categoryId) {
|
deleteCategory = categoryId => {
|
||||||
const url = `/api/categories/${categoryId}/`;
|
const url = `/api/categories/${categoryId}/`;
|
||||||
const options = {
|
const options = {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|
@ -59,7 +56,7 @@ class App extends React.Component {
|
||||||
text: 'Unable to remove category, try again later',
|
text: 'Unable to remove category, try again later',
|
||||||
};
|
};
|
||||||
return this.setState({ selectedCategoryId: null, message: message });
|
return this.setState({ selectedCategoryId: null, message: message });
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { categories } = this.state;
|
const { categories } = this.state;
|
||||||
|
|
@ -69,6 +66,7 @@ class App extends React.Component {
|
||||||
key={category.pk}
|
key={category.pk}
|
||||||
category={category}
|
category={category}
|
||||||
showDialog={this.selectCategory}
|
showDialog={this.selectCategory}
|
||||||
|
updateUrl={this.props.updateUrl}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -80,7 +78,7 @@ class App extends React.Component {
|
||||||
const pageHeader = (
|
const pageHeader = (
|
||||||
<>
|
<>
|
||||||
<h1 className="h1">Categories</h1>
|
<h1 className="h1">Categories</h1>
|
||||||
<a className="link button button--confirm" href="/categories/create/">
|
<a className="link button button--confirm" href={`${this.props.createUrl}/`}>
|
||||||
Create category
|
Create category
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
|
|
@ -89,15 +87,19 @@ class App extends React.Component {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{this.state.message && <Messages messages={[this.state.message]} />}
|
{this.state.message && <Messages messages={[this.state.message]} />}
|
||||||
<Card header={pageHeader} />
|
<Sidebar navLinks={this.props.navLinks} />
|
||||||
{cards}
|
|
||||||
{selectedCategory && (
|
<div className="main__container">
|
||||||
<CategoryModal
|
<Card header={pageHeader} />
|
||||||
category={selectedCategory}
|
{cards}
|
||||||
handleCancel={this.deselectCategory}
|
{selectedCategory && (
|
||||||
handleDelete={this.deleteCategory}
|
<CategoryModal
|
||||||
/>
|
category={selectedCategory}
|
||||||
)}
|
handleCancel={this.deselectCategory}
|
||||||
|
handleDelete={this.deleteCategory}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ const CategoryCard = props => {
|
||||||
if (rule.favicon) {
|
if (rule.favicon) {
|
||||||
favicon = <img className="favicon" src={rule.favicon} />;
|
favicon = <img className="favicon" src={rule.favicon} />;
|
||||||
} else {
|
} else {
|
||||||
favicon = <i className="gg-image" />;
|
favicon = <i className="fas fa-image" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -31,7 +31,10 @@ const CategoryCard = props => {
|
||||||
const cardContent = <>{category.rules && <ul className="list">{categoryRules}</ul>}</>;
|
const cardContent = <>{category.rules && <ul className="list">{categoryRules}</ul>}</>;
|
||||||
const cardFooter = (
|
const cardFooter = (
|
||||||
<>
|
<>
|
||||||
<a className="link button button--primary" href={`/categories/${category.pk}/`}>
|
<a
|
||||||
|
className="link button button--primary"
|
||||||
|
href={`${props.updateUrl}/${category.pk}/`}
|
||||||
|
>
|
||||||
Edit
|
Edit
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,19 @@ if (page) {
|
||||||
const dataScript = document.getElementById('categories-data');
|
const dataScript = document.getElementById('categories-data');
|
||||||
const categories = JSON.parse(dataScript.textContent);
|
const categories = JSON.parse(dataScript.textContent);
|
||||||
|
|
||||||
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