Compare commits
510 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 | |||
| ddfd208d1c | |||
| 27ed0259a1 | |||
| a22ef354be | |||
| 61ddef3b63 | |||
| e3840342b3 | |||
| a4b5373ed2 | |||
| 13d33749da | |||
| bb47e2af8d | |||
| f4adb9635a | |||
| aeb85bd2cf | |||
| 69eaedf89c | |||
| 3c613b59e2 | |||
| ed415f2b5c | |||
| d6d19fa9b9 | |||
| 7ee727e96e | |||
| 9285672273 | |||
| bec3488e63 | |||
| 7fc899937d | |||
| d61b3a9498 | |||
| 57e9073f6b | |||
| 62da6e0d8e | |||
| b811d1945b | |||
| 3152254f43 | |||
| e9f05868c1 | |||
| 708076b2ab | |||
| 992df528f6 | |||
| 6ff26c71a0 | |||
| 9b56cf53e3 | |||
| 6f00da37b9 | |||
| cac71d5475 | |||
| 7d86cea6ec | |||
| e495d7c188 | |||
| cda2654573 | |||
| ae942f5ef9 | |||
| 73ae0271e4 | |||
| 66408cc218 | |||
| b28d42b97b | |||
| 16230a11f3 | |||
| a3a2033e37 | |||
| 7c888c1461 | |||
| ab5d9ea46d | |||
| dc2fb681d0 | |||
| fdb90525a8 | |||
| 6bfb84dab9 | |||
| b5b59c5baf | |||
| 770ace2f5d | |||
| f6100416c3 | |||
| 99ba773b91 | |||
| fdec1efcf0 | |||
| 9ecca7a80b | |||
| e4e4e97cfd | |||
| 420481f18a | |||
| 4da301eb3e | |||
| 4345a006a6 | |||
| 533561ba1e | |||
| afc3c11775 | |||
| 8b47fd8216 | |||
| a87d4f387f | |||
| acd9bd30cb | |||
| c22fdfe4ce | |||
| 21b9e4f0fd | |||
| a5de001f35 | |||
| b3bff398d9 | |||
|
|
61bc7e9b04 | ||
| 961535dd60 | |||
| ab0b24b3d2 | |||
| 3045702a1e | |||
| 5976870d38 | |||
| ee22e0a0ae | |||
| 61e45ed0cc | |||
| 6c6ea6c481 | |||
| c13f968234 | |||
| 36076dbc40 | |||
| f9bce3507c | |||
| e1e6571bb0 | |||
| c3b087e004 | |||
| 62e763604e | |||
| 12693dac10 | |||
| 06ce82bf0c | |||
| 28620eab29 | |||
| 68534cd541 | |||
| 5a630ea4b3 | |||
| 8acf3bbc7f | |||
| 38d9d74db4 | |||
| d345bc2595 | |||
| 08e4d043b9 | |||
| a9edd520a7 | |||
| a4102130d2 | |||
| b2829716b0 | |||
| 94f4ed6327 | |||
| b952d70d92 | |||
| a350cc280d | |||
| bb0c2e792c | |||
| fb709fe1d0 | |||
| 858f84aaad | |||
| 61702e720a | |||
| 752ba62aee | |||
| 8c3bc408b9 | |||
| 0658d6404f | |||
| 679414a703 | |||
| b1c5be61f1 | |||
| a74ffae9a7 | |||
| 1b774a7208 | |||
| 8f314e0ca6 | |||
| a95db91726 | |||
| 88cf5f9bd4 | |||
| a798b9858c | |||
| 9c6be7357d | |||
| 982c5bb132 | |||
| ed658c4dfd | |||
| cfd064ec85 | |||
| 48a9b25545 | |||
| c75de1c469 | |||
| 69e4e7b269 | |||
| c508cca080 | |||
| abacb72b30 |
346 changed files with 17915 additions and 16917 deletions
11
.babelrc
11
.babelrc
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"presets": ["@babel/preset-env"],
|
||||
"plugins": [
|
||||
"@babel/plugin-transform-runtime",
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
"@babel/plugin-transform-react-jsx",
|
||||
"@babel/plugin-syntax-function-bind",
|
||||
"@babel/plugin-proposal-function-bind",
|
||||
["@babel/plugin-proposal-class-properties", {loose: true}],
|
||||
]
|
||||
}
|
||||
16
.coveragerc
16
.coveragerc
|
|
@ -1,16 +0,0 @@
|
|||
[run]
|
||||
source = ./src/newsreader/
|
||||
omit =
|
||||
**/tests/**
|
||||
**/migrations/**
|
||||
**/conf/**
|
||||
**/apps.py
|
||||
**/admin.py
|
||||
**/tests.py
|
||||
**/urls.py
|
||||
**/wsgi.py
|
||||
**/celery.py
|
||||
**/__init__.py
|
||||
|
||||
[html]
|
||||
directory = coverage
|
||||
25
.editorconfig
Normal file
25
.editorconfig
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# https://editorconfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# Unix-style newlines with a newline ending every file
|
||||
[*]
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.py]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{yaml,yml,toml,md}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[Dockerfile*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.json]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -35,6 +35,7 @@ eggs/
|
|||
|
||||
lib/
|
||||
!src/newsreader/scss/lib
|
||||
!src/newsreader/js/lib
|
||||
|
||||
lib64/
|
||||
parts/
|
||||
|
|
@ -114,7 +115,7 @@ celerybeat-schedule
|
|||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
*.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
stages:
|
||||
- build
|
||||
- test
|
||||
- lint
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
||||
DJANGO_SETTINGS_MODULE: "newsreader.conf.gitlab"
|
||||
POSTGRES_HOST: "$POSTGRES_HOST"
|
||||
POSTGRES_DB: "$POSTGRES_NAME"
|
||||
POSTGRES_NAME: "$POSTGRES_NAME"
|
||||
POSTGRES_USER: "$POSTGRES_USER"
|
||||
POSTGRES_PASSWORD: "$POSTGRES_PASSWORD"
|
||||
|
||||
cache:
|
||||
key: "$CI_COMMIT_REF_SLUG"
|
||||
paths:
|
||||
- .venv/
|
||||
- .cache/pip
|
||||
- .cache/poetry
|
||||
- node_modules/
|
||||
|
||||
include:
|
||||
- local: '/gitlab-ci/build.yml'
|
||||
- local: '/gitlab-ci/test.yml'
|
||||
- local: '/gitlab-ci/lint.yml'
|
||||
- local: '/gitlab-ci/deploy.yml'
|
||||
12
.isort.cfg
12
.isort.cfg
|
|
@ -1,12 +0,0 @@
|
|||
[settings]
|
||||
include_trailing_comma = true
|
||||
line_length = 88
|
||||
multi_line_output = 3
|
||||
skip = env/, venv/
|
||||
default_section = THIRDPARTY
|
||||
known_first_party = newsreader
|
||||
known_django = django
|
||||
sections = FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
|
||||
lines_between_types=1
|
||||
lines_after_imports=2
|
||||
lines_between_types=1
|
||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
lts/*
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 90,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
10
.woodpecker/build.yaml
Normal file
10
.woodpecker/build.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
when:
|
||||
- event: push
|
||||
- event: pull_request
|
||||
- event: manual
|
||||
|
||||
steps:
|
||||
- image: node:lts-alpine
|
||||
commands:
|
||||
- npm install
|
||||
- npm run build:prod
|
||||
18
.woodpecker/lint.yaml
Normal file
18
.woodpecker/lint.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
when:
|
||||
- event: push
|
||||
- event: pull_request
|
||||
- event: manual
|
||||
|
||||
steps:
|
||||
- name: python linting
|
||||
image: ghcr.io/astral-sh/uv:python3.11-alpine
|
||||
commands:
|
||||
- uv sync --group ci
|
||||
- uv run --no-sync -- ruff check src/
|
||||
- uv run --no-sync -- ruff format --check src/
|
||||
|
||||
- name: javascript linting
|
||||
image: node:lts-alpine
|
||||
commands:
|
||||
- npm ci
|
||||
- npm run lint
|
||||
37
.woodpecker/tests.yaml
Normal file
37
.woodpecker/tests.yaml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
when:
|
||||
- event: push
|
||||
- event: pull_request
|
||||
- event: manual
|
||||
|
||||
services:
|
||||
- name: postgres
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_NAME: &db-name newsreader
|
||||
POSTGRES_USER: &db-user newsreader
|
||||
POSTGRES_PASSWORD: &db-password sekrit
|
||||
- name: memcached
|
||||
image: memcached:1.5.22
|
||||
|
||||
steps:
|
||||
- name: python tests
|
||||
image: ghcr.io/astral-sh/uv:python3.11-alpine
|
||||
environment:
|
||||
DJANGO_SETTINGS_MODULE: "newsreader.conf.ci"
|
||||
DJANGO_SECRET_KEY: sekrit
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: 5432
|
||||
POSTGRES_DB: *db-name
|
||||
POSTGRES_USER: *db-user
|
||||
POSTGRES_PASSWORD: *db-password
|
||||
commands:
|
||||
- pip install uv
|
||||
- uv sync --group ci
|
||||
- uv run --no-sync -- coverage run ./src/manage.py test newsreader
|
||||
- uv run --no-sync -- coverage report --show-missing
|
||||
|
||||
- name: javascript tests
|
||||
image: node:lts-alpine
|
||||
commands:
|
||||
- npm ci
|
||||
- npm test
|
||||
149
CHANGELOG.md
Normal file
149
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# Changelog
|
||||
|
||||
## 0.5.3
|
||||
|
||||
- Apply query optimizations for retrieving posts
|
||||
|
||||
## 0.5.2
|
||||
|
||||
- Add missing `VERSION` environment variable
|
||||
|
||||
## 0.5.1
|
||||
|
||||
- Use line-through styling for read posts
|
||||
- Use full height for post layout
|
||||
|
||||
## 0.5.0
|
||||
|
||||
- Upgrade python to 3.11
|
||||
- Upgrade django to 4.2
|
||||
- Migrate from pip-tools to uv
|
||||
- Migrate from black to ruff for formatting
|
||||
- Upgrade webpack to 5.9 (with various tooling)
|
||||
- Styling refactor
|
||||
- Mobile/tablet layout added
|
||||
|
||||
## 0.4.4
|
||||
|
||||
- Sort posts before storing in redux store
|
||||
|
||||
## 0.4.3
|
||||
|
||||
- Use `IntersectionObserver` to paginate
|
||||
|
||||
## 0.4.2
|
||||
|
||||
- Set `SECURE_PROXY_SSL_HEADER` setting for production
|
||||
|
||||
## 0.4.1
|
||||
|
||||
- Add missing env variables
|
||||
|
||||
## 0.4.0
|
||||
|
||||
- Add Makefile & use `pip-tools` to generate dependencies
|
||||
- Add `pyproject.toml`
|
||||
- Update dependencies
|
||||
- Update docker-compose setup
|
||||
- Default to `newsreader.conf.docker` settings module
|
||||
- Add scroll to top/bottom buttons
|
||||
|
||||
## 0.3.13.8
|
||||
|
||||
- Update dependencies
|
||||
- Fix csrf_token's not rendering
|
||||
|
||||
## 0.3.13.7
|
||||
|
||||
- Check for Twitter error codes in response
|
||||
|
||||
## 0.3.13.6
|
||||
|
||||
- Try to load sentry by default for all environments
|
||||
|
||||
## 0.3.13.5
|
||||
|
||||
- Set response keyword argument
|
||||
|
||||
## 0.3.13.4
|
||||
|
||||
- Fix import error
|
||||
|
||||
## 0.3.13.3
|
||||
|
||||
- Use sentry's set_extra to provide extra debug variables
|
||||
|
||||
## 0.3.13.2
|
||||
|
||||
- Update sentry-sdk
|
||||
|
||||
## 0.3.13.1
|
||||
|
||||
- Fix mutual exclusive exception for email settings
|
||||
- Temporarly set exception level for StreamDeniedException exceptions
|
||||
|
||||
## 0.3.13
|
||||
|
||||
- Update django to 3.2
|
||||
- Notify users of expired credentials
|
||||
|
||||
## 0.3.12.1
|
||||
|
||||
- Add missing background-color
|
||||
|
||||
## 0.3.12
|
||||
|
||||
- Update light theme
|
||||
- Sticky navbar
|
||||
- Sticky post modal header
|
||||
|
||||
## 0.3.11
|
||||
|
||||
- Add saved posts section
|
||||
- Bump django version
|
||||
|
||||
## 0.3.10
|
||||
|
||||
- Add custom color for confirm buttons
|
||||
- Update font sizes
|
||||
|
||||
## 0.3.9
|
||||
|
||||
- Cursor based pagination
|
||||
- Updated django version
|
||||
|
||||
## 0.3.8
|
||||
|
||||
- Update light / dark theme
|
||||
- Replace css.gg with fontawesome
|
||||
- Update deploy job
|
||||
|
||||
## 0.3.7
|
||||
|
||||
- Add a dark theme
|
||||
- Update object representations
|
||||
- Move sentry to optional dependency
|
||||
- Add CHANGELOG.md
|
||||
|
||||
## 0.3.6.3
|
||||
|
||||
- Update deploy job
|
||||
|
||||
## 0.3.6.2
|
||||
|
||||
- Use warning logging level for BuilderSkippedException's
|
||||
- Change working directory before running ansible
|
||||
|
||||
## 0.3.6.1
|
||||
|
||||
- Install ansible required roles
|
||||
|
||||
## 0.3.6
|
||||
|
||||
- Update deploy job
|
||||
- Add user manageable reddit filters
|
||||
|
||||
## 0.3.5
|
||||
|
||||
- Show timezone next to post datetimes
|
||||
- Take read status in consideration when sorting posts
|
||||
84
Dockerfile
Normal file
84
Dockerfile
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# stage 1
|
||||
FROM python:3.11-alpine AS backend
|
||||
|
||||
ARG USER_ID=1000
|
||||
ARG GROUP_ID=1000
|
||||
ARG UV_LINK_MODE=copy
|
||||
|
||||
RUN apk update \
|
||||
&& apk add --no-cache \
|
||||
vim \
|
||||
curl \
|
||||
gettext
|
||||
|
||||
RUN addgroup -g $USER_ID newsreader && adduser -Du $GROUP_ID -G newsreader newsreader
|
||||
|
||||
RUN mkdir --parents /app/src /app/logs /app/media /app/bin /app/static \
|
||||
&& chown -R newsreader:newsreader /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
USER newsreader
|
||||
|
||||
COPY --chown=newsreader:newsreader uv.lock pyproject.toml /app/
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:python3.11-alpine /usr/local/bin/uv /bin/uv
|
||||
|
||||
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
|
||||
uv sync --frozen --no-default-groups --no-install-project
|
||||
|
||||
COPY --chown=newsreader:newsreader ./bin/docker-entrypoint.sh /app/bin/docker-entrypoint.sh
|
||||
|
||||
VOLUME ["/app/logs", "/app/media", "/app/static"]
|
||||
|
||||
|
||||
|
||||
# stage 2
|
||||
FROM node:lts-alpine AS frontend-build
|
||||
|
||||
ARG BUILD_ARG=prod
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN chown node:node /app
|
||||
|
||||
USER node
|
||||
|
||||
COPY --chown=node:node ./package*.json ./webpack.*.js ./babel.config.js /app/
|
||||
|
||||
RUN --mount=type=cache,uid=1000,gid=1000,target=/home/node/.npm \
|
||||
npm ci
|
||||
|
||||
COPY --chown=node:node ./src /app/src
|
||||
|
||||
RUN npm run build:$BUILD_ARG
|
||||
|
||||
|
||||
|
||||
# stage 3
|
||||
FROM backend AS production
|
||||
|
||||
COPY --from=frontend-build --chown=newsreader:newsreader \
|
||||
/app/src/newsreader/static /app/src/newsreader/static
|
||||
|
||||
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
|
||||
uv sync --frozen --only-group production --extra sentry
|
||||
|
||||
COPY --chown=newsreader:newsreader ./src /app/src
|
||||
|
||||
ENV DJANGO_SETTINGS_MODULE=newsreader.conf.production
|
||||
|
||||
# Note that the static volume will have to be recreated to be pre-populated
|
||||
# correctly with the latest static files. See
|
||||
# https://docs.docker.com/storage/volumes/#populate-a-volume-using-a-container
|
||||
RUN uv run --no-sync -- src/manage.py collectstatic --noinput
|
||||
|
||||
|
||||
|
||||
# (optional) stage 4
|
||||
FROM backend AS development
|
||||
|
||||
RUN --mount=type=cache,uid=$USER_ID,gid=$GROUP_ID,target=/home/newsreader/.cache/uv \
|
||||
uv sync --frozen --group development
|
||||
|
||||
ENV DJANGO_SETTINGS_MODULE=newsreader.conf.docker
|
||||
21
babel.config.js
Normal file
21
babel.config.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
module.exports = api => {
|
||||
const isTest = api.env('test');
|
||||
|
||||
const preset = [
|
||||
"@babel/preset-env", { targets: 'defaults' }
|
||||
];
|
||||
const testPreset = [
|
||||
"@babel/preset-env", { targets: { node: process.versions.node } }
|
||||
];
|
||||
|
||||
const plugins = [
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
"@babel/plugin-transform-react-jsx",
|
||||
"@babel/plugin-proposal-class-properties"
|
||||
]
|
||||
|
||||
return {
|
||||
"presets": [isTest ? testPreset : preset],
|
||||
"plugins": plugins
|
||||
}
|
||||
}
|
||||
5
bin/docker-entrypoint.sh
Executable file
5
bin/docker-entrypoint.sh
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
#!/bin/bash
|
||||
|
||||
uv run --no-sync -- /app/src/manage.py migrate
|
||||
|
||||
exec "$@"
|
||||
21
config/nginx/conf.d/local.conf
Normal file
21
config/nginx/conf.d/local.conf
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
upstream gunicorn {
|
||||
server django:8000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
access_log /var/log/nginx/access_log;
|
||||
error_log /var/log/nginx/error_log;
|
||||
|
||||
location /static/ {
|
||||
root /app;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
proxy_pass http://gunicorn;
|
||||
}
|
||||
}
|
||||
36
docker-compose.development.yml
Normal file
36
docker-compose.development.yml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
volumes:
|
||||
static-files:
|
||||
|
||||
services:
|
||||
django:
|
||||
build: &app-development-build
|
||||
target: development
|
||||
command: uv run --no-sync -- /app/src/manage.py runserver 0.0.0.0:8000
|
||||
environment: &django-env
|
||||
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-newsreader.conf.docker}
|
||||
ports:
|
||||
- "${DJANGO_PORT:-8000}:8000"
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
- static-files:/app/src/newsreader/static
|
||||
stdin_open: true
|
||||
tty: true
|
||||
|
||||
celery:
|
||||
build:
|
||||
<<: *app-development-build
|
||||
environment:
|
||||
<<: *django-env
|
||||
volumes:
|
||||
- ./src/:/app/src
|
||||
|
||||
webpack:
|
||||
build:
|
||||
target: frontend-build
|
||||
context: .
|
||||
args:
|
||||
BUILD_ARG: "dev"
|
||||
command: npm run build:watch
|
||||
volumes:
|
||||
- ./src/:/app/src
|
||||
- static-files:/app/src/newsreader/static
|
||||
16
docker-compose.production.yml
Normal file
16
docker-compose.production.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
volumes:
|
||||
logs:
|
||||
static-files:
|
||||
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:1.23
|
||||
depends_on:
|
||||
django:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${NGINX_HTTP_PORT:-80}:80"
|
||||
volumes:
|
||||
- ./config/nginx/conf.d:/etc/nginx/conf.d
|
||||
- logs:/var/log/nginx
|
||||
- static-files:/app/static
|
||||
|
|
@ -1,56 +1,126 @@
|
|||
version: '3'
|
||||
volumes:
|
||||
logs:
|
||||
media:
|
||||
postgres-data:
|
||||
static-files:
|
||||
node-modules:
|
||||
|
||||
x-db-connection-env: &db-connection-env
|
||||
POSTGRES_HOST: ${POSTGRES_HOST:-db}
|
||||
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
|
||||
POSTGRES_DB: &pg-database ${POSTGRES_DB:-newsreader}
|
||||
POSTGRES_USER: &pg-user ${POSTGRES_USER:-newsreader}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-newsreader}
|
||||
|
||||
x-db-env: &db-env
|
||||
<<: *db-connection-env
|
||||
PGUSER: *pg-user
|
||||
PGDATABASE: *pg-database
|
||||
|
||||
x-django-env: &django-env
|
||||
<<: *db-connection-env
|
||||
|
||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,django}
|
||||
INTERNAL_IPS: ${INTERNAL_IPS:-localhost,127.0.0.1,django}
|
||||
|
||||
# see token_urlsafe from python's secret module to generate one
|
||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-Ojg68lYsP3kq2r5JgozUzKVSRFywm17BTMS5iwpLM44}
|
||||
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-newsreader.conf.production}
|
||||
|
||||
ADMINS: ${ADMINS:-""}
|
||||
|
||||
VERSION: ${VERSION:-""}
|
||||
|
||||
# Email
|
||||
EMAIL_HOST: ${EMAIL_HOST:-localhost}
|
||||
EMAIL_PORT: ${EMAIL_PORT:-25}
|
||||
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-""}
|
||||
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-""}
|
||||
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-no}
|
||||
EMAIL_USE_SSL: ${EMAIL_USE_SSL:-no}
|
||||
EMAIL_DEFAULT_FROM: ${EMAIL_DEFAULT_FROM:-webmaster@localhost}
|
||||
|
||||
# Sentry
|
||||
SENTRY_DSN: ${SENTRY_DSN:-""}
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
environment:
|
||||
POSTGRES_DB: "newsreader"
|
||||
POSTGRES_USER: "newsreader"
|
||||
POSTGRES_PASSWORD: "newsreader"
|
||||
<<: *db-env
|
||||
image: postgres:15
|
||||
healthcheck:
|
||||
test: /usr/bin/pg_isready
|
||||
start_period: 10s
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3.7
|
||||
image: rabbitmq:4
|
||||
|
||||
memcached:
|
||||
image: memcached:1.5.22
|
||||
ports:
|
||||
- "11211:11211"
|
||||
image: memcached:1.6
|
||||
entrypoint:
|
||||
- memcached
|
||||
- -m 64
|
||||
|
||||
django:
|
||||
build: &app-build
|
||||
context: .
|
||||
target: production
|
||||
environment:
|
||||
<<: *django-env
|
||||
entrypoint: ["/bin/sh", "/app/bin/docker-entrypoint.sh"]
|
||||
command: |
|
||||
uv run --no-sync --
|
||||
gunicorn
|
||||
--bind 0.0.0.0:8000
|
||||
--workers 3
|
||||
--chdir /app/src/
|
||||
newsreader.wsgi:application
|
||||
healthcheck:
|
||||
test: /usr/bin/curl --fail http://django:8000 || exit 1
|
||||
start_period: 10s
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
depends_on:
|
||||
memcached:
|
||||
condition: service_started
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- logs:/app/logs
|
||||
- media:/app/media
|
||||
- static-files:/app/static
|
||||
|
||||
celery:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/django
|
||||
command: celery worker --app newsreader --loglevel INFO --beat --scheduler django --workdir /app/src/
|
||||
<<: *app-build
|
||||
environment:
|
||||
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
|
||||
<<: *django-env
|
||||
command: |
|
||||
uv run --no-sync --
|
||||
celery
|
||||
--app newsreader
|
||||
--workdir /app/src/
|
||||
worker --loglevel INFO
|
||||
--concurrency 2
|
||||
--beat
|
||||
--scheduler django
|
||||
-n worker1@%h
|
||||
-n worker2@%h
|
||||
healthcheck:
|
||||
test: uv run --no-sync -- celery --app newsreader status || exit 1
|
||||
start_period: 10s
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
depends_on:
|
||||
- rabbitmq
|
||||
django:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/django
|
||||
command: src/entrypoint.sh
|
||||
environment:
|
||||
- DJANGO_SETTINGS_MODULE=newsreader.conf.docker
|
||||
ports:
|
||||
- '8000:8000'
|
||||
depends_on:
|
||||
- db
|
||||
rabbitmq:
|
||||
condition: service_started
|
||||
django:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app
|
||||
- static-files:/app/src/newsreader/static
|
||||
webpack:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/webpack
|
||||
command: npm run build:watch
|
||||
volumes:
|
||||
- .:/app
|
||||
- static-files:/app/src/newsreader/static
|
||||
- node-modules:/app/node_modules
|
||||
- logs:/app/logs
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
FROM python:3.7-buster
|
||||
|
||||
RUN pip install poetry
|
||||
|
||||
WORKDIR /app
|
||||
COPY poetry.lock pyproject.toml /app/
|
||||
|
||||
RUN poetry config virtualenvs.create false && poetry install --no-interaction
|
||||
|
||||
COPY . /app/
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
FROM node:12
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json /app/
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . /app/
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
static:
|
||||
stage: build
|
||||
image: node:12
|
||||
before_script:
|
||||
- npm install
|
||||
script:
|
||||
- npm run build
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
deploy:
|
||||
stage: deploy
|
||||
image: debian:buster
|
||||
environment:
|
||||
name: production
|
||||
url: rss.fudiggity.nl
|
||||
before_script:
|
||||
- apt-get update && apt-get install -y ansible git
|
||||
- git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@git.fudiggity.nl/sonny/ansible-playbooks.git deployment
|
||||
- mkdir /root/.ssh
|
||||
- echo "192.168.178.63 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILbtcdgJBhVCKsO88cV19EYefDTopdYejEQCp1pYr1Ga" > /root/.ssh/known_hosts
|
||||
- echo "$DEPLOY_KEY" > deployment/deploy_key && chmod 0600 deployment/deploy_key
|
||||
script:
|
||||
- ansible-playbook deployment/playbook.yml --inventory deployment/apps.yml --limit newsreader --user ansible --private-key deployment/deploy_key
|
||||
only:
|
||||
- master
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
python-linting:
|
||||
stage: lint
|
||||
allow_failure: true
|
||||
image: python:3.7.4-slim-stretch
|
||||
before_script:
|
||||
- pip install poetry
|
||||
- poetry config cache-dir ~/.cache/poetry
|
||||
- poetry config virtualenvs.in-project true
|
||||
- poetry install --no-interaction
|
||||
script:
|
||||
- poetry run isort src/ --check-only --recursive
|
||||
- poetry run black src/ --line-length 88 --check
|
||||
- poetry run autoflake src/ --check --recursive --remove-all-unused-imports --ignore-init-module-imports
|
||||
|
||||
javascript-linting:
|
||||
stage: lint
|
||||
allow_failure: true
|
||||
image: node:12
|
||||
before_script:
|
||||
- npm install
|
||||
script:
|
||||
- npm run lint
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
python-tests:
|
||||
stage: test
|
||||
coverage: '/TOTAL\s+\d+\s+\d+\s+(\d+%)/'
|
||||
services:
|
||||
- postgres:11
|
||||
- memcached:1.5.22
|
||||
image: python:3.7.4-slim-stretch
|
||||
before_script:
|
||||
- pip install poetry
|
||||
- poetry config cache-dir .cache/poetry
|
||||
- poetry config virtualenvs.in-project true
|
||||
- poetry install --no-interaction
|
||||
script:
|
||||
- poetry run coverage run src/manage.py test newsreader
|
||||
- poetry run coverage report
|
||||
|
||||
javascript-tests:
|
||||
stage: test
|
||||
image: node:12
|
||||
before_script:
|
||||
- npm install
|
||||
script:
|
||||
- npm test
|
||||
188
jest.config.js
188
jest.config.js
|
|
@ -1,188 +0,0 @@
|
|||
// For a detailed explanation regarding each configuration property, visit:
|
||||
// https://jestjs.io/docs/en/configuration.html
|
||||
|
||||
module.exports = {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// Respect "browser" field in package.json when resolving modules
|
||||
// browser: false,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "/tmp/jest_rs",
|
||||
|
||||
// Automatically clear mock calls and instances between every test
|
||||
clearMocks: true,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
// collectCoverage: false,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
// collectCoverageFrom: null,
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
coverageDirectory: 'coverage',
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// coveragePathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: null,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: null,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: null,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: null,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
// globals: {},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "json",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "node"
|
||||
// ],
|
||||
|
||||
// A map from regular expressions to module names that allow to stub out resources with a single module
|
||||
// moduleNameMapper: {},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
// preset: null,
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: null,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state between every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// resolver: null,
|
||||
|
||||
// Automatically restore mock state between every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
rootDir: 'src/newsreader/js/tests/',
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
// roots: [
|
||||
// "<rootDir>"
|
||||
// ],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: 'node',
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
// testMatch: [
|
||||
// "**/__tests__/**/*.[jt]s?(x)",
|
||||
// "**/?(*.)+(spec|test).[tj]s?(x)"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
// testPathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: null,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jasmine2",
|
||||
|
||||
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
|
||||
// testURL: "http://localhost",
|
||||
|
||||
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
|
||||
// timers: "real",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
// transform: null,
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// transformIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: null,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
};
|
||||
16913
package-lock.json
generated
16913
package-lock.json
generated
File diff suppressed because it is too large
Load diff
76
package.json
76
package.json
|
|
@ -1,63 +1,75 @@
|
|||
{
|
||||
"name": "newsreader",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.3",
|
||||
"description": "Application for viewing RSS feeds",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"lint": "npx prettier \"src/newsreader/js/**/*.js\" --check",
|
||||
"format": "npx prettier \"src/newsreader/js/**/*.js\" --write",
|
||||
"build": "npx webpack --config webpack.dev.babel.js",
|
||||
"build:watch": "npx webpack --config webpack.dev.babel.js --watch",
|
||||
"build:dev": "npx webpack --config webpack.dev.babel.js",
|
||||
"build:prod": "npx webpack --config webpack.prod.babel.js",
|
||||
"test": "npx jest",
|
||||
"test:watch": "npm test -- --watch"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "[git@git.fudiggity.nl:5000]:sonny/newsreader.git"
|
||||
"url": "forgejo.fudiggity.nl:sonny/newsreader"
|
||||
},
|
||||
"author": "Sonny",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"css.gg": "^1.0.6",
|
||||
"@fortawesome/fontawesome-free": "^5.15.2",
|
||||
"js-cookie": "^2.2.1",
|
||||
"lodash": "^4.17.15",
|
||||
"lodash": "^4.17.20",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-redux": "^7.1.3",
|
||||
"react-redux": "^7.2.2",
|
||||
"redux": "^4.0.5",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.7.7",
|
||||
"@babel/plugin-proposal-class-properties": "^7.7.4",
|
||||
"@babel/plugin-proposal-function-bind": "^7.7.4",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.7.4",
|
||||
"@babel/plugin-syntax-function-bind": "^7.7.4",
|
||||
"@babel/plugin-transform-react-jsx": "^7.7.7",
|
||||
"@babel/plugin-transform-runtime": "^7.7.6",
|
||||
"@babel/preset-env": "^7.7.7",
|
||||
"@babel/register": "^7.7.7",
|
||||
"@babel/runtime": "^7.7.7",
|
||||
"babel-jest": "^24.9.0",
|
||||
"babel-loader": "^8.1.0",
|
||||
"@babel/core": "^7.12.13",
|
||||
"@babel/plugin-proposal-class-properties": "^7.12.13",
|
||||
"@babel/plugin-proposal-function-bind": "^7.12.13",
|
||||
"@babel/plugin-transform-react-jsx": "^7.12.13",
|
||||
"@babel/preset-env": "^7.12.13",
|
||||
"@babel/register": "^7.12.13",
|
||||
"@babel/runtime": "^7.12.13",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-loader": "^8.2.2",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"css-loader": "^3.4.2",
|
||||
"fetch-mock": "^8.3.1",
|
||||
"file-loader": "^6.0.0",
|
||||
"jest": "^24.9.0",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"node-fetch": "^2.6.0",
|
||||
"node-sass": "^4.14.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"fetch-mock": "^8.3.2",
|
||||
"jest": "^29.7.0",
|
||||
"mini-css-extract-plugin": "^2.9.1",
|
||||
"node-fetch": "^2.6.1",
|
||||
"prettier": "^1.19.1",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react": "^16.14.0",
|
||||
"react-dom": "^16.14.0",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
"sass": "^1.52.1",
|
||||
"sass-loader": "^8.0.2",
|
||||
"style-loader": "^1.1.3",
|
||||
"url-loader": "^4.1.0",
|
||||
"webpack": "^4.42.1",
|
||||
"webpack-cli": "^3.3.11",
|
||||
"style-loader": "^2.0.0",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^5.94.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-merge": "^4.2.2"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 90,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid"
|
||||
},
|
||||
"jest": {
|
||||
"roots": [
|
||||
"src/newsreader/js/tests/"
|
||||
],
|
||||
"clearMocks": true,
|
||||
"coverageDirectory": "coverage"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1159
poetry.lock
generated
1159
poetry.lock
generated
File diff suppressed because it is too large
Load diff
113
pyproject.toml
113
pyproject.toml
|
|
@ -1,40 +1,81 @@
|
|||
[tool.poetry]
|
||||
[project]
|
||||
name = "newsreader"
|
||||
version = "0.2"
|
||||
description = "Webapplication for reading RSS feeds"
|
||||
authors = ["Sonny <sonnyba871@gmail.com>"]
|
||||
license = "GPL-3.0"
|
||||
version = "0.5.3"
|
||||
authors = [{ name = "Sonny" }]
|
||||
license = { text = "GPL-3.0" }
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"django~=4.2",
|
||||
"celery~=5.4",
|
||||
"psycopg[binary]",
|
||||
"django-axes",
|
||||
"django-celery-beat~=2.7.0",
|
||||
"django-rest-framework",
|
||||
"djangorestframework-camel-case",
|
||||
"pymemcache",
|
||||
"python-dotenv~=1.0.1",
|
||||
"ftfy~=6.2",
|
||||
"requests",
|
||||
"feedparser",
|
||||
"bleach",
|
||||
"beautifulsoup4",
|
||||
"lxml",
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.7"
|
||||
bleach = "^3.1.4"
|
||||
Django = "^3.0.5"
|
||||
celery = "^4.4.2"
|
||||
beautifulsoup4 = "^4.9.0"
|
||||
django-axes = "^5.3.1"
|
||||
django-celery-beat = "^2.0.0"
|
||||
djangorestframework = "^3.11.0"
|
||||
drf-yasg = "^1.17.1"
|
||||
django-registration-redux = "^2.7"
|
||||
lxml = "^4.5.0"
|
||||
feedparser = "^5.2.1"
|
||||
python-memcached = "^1.59"
|
||||
requests = "^2.23.0"
|
||||
psycopg2-binary = "^2.8.5"
|
||||
gunicorn = "^20.0.4"
|
||||
python-dotenv = "^0.12.0"
|
||||
[dependency-groups]
|
||||
test-tools = ["ruff", "factory_boy", "freezegun"]
|
||||
development = [
|
||||
"django-debug-toolbar",
|
||||
"django-stubs",
|
||||
"django-extensions",
|
||||
]
|
||||
ci = ["coverage~=7.6.1"]
|
||||
production = ["gunicorn~=23.0"]
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
factory-boy = "^2.12.0"
|
||||
freezegun = "^0.3.15"
|
||||
django-debug-toolbar = "^2.2"
|
||||
django-extensions = "^2.2.9"
|
||||
black = "19.3b0"
|
||||
isort = "4.3.21"
|
||||
autoflake = "1.3.1"
|
||||
tblib = "1.6.0"
|
||||
coverage = "^5.1"
|
||||
[project.optional-dependencies]
|
||||
sentry = ["sentry-sdk~=2.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry>=0.12"]
|
||||
build-backend = "poetry.masonry.api"
|
||||
[tool.uv]
|
||||
environments = ["sys_platform == 'linux'"]
|
||||
default-groups = ["test-tools"]
|
||||
|
||||
[tool.ruff]
|
||||
include = ["pyproject.toml", "src/**/*.py"]
|
||||
|
||||
line-length = 88
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E4", "E7", "E9", "F", "I"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
lines-between-types=1
|
||||
lines-after-imports=2
|
||||
|
||||
default-section = "third-party"
|
||||
known-first-party = ["newsreader"]
|
||||
section-order = [
|
||||
"future",
|
||||
"standard-library",
|
||||
"django",
|
||||
"third-party",
|
||||
"first-party",
|
||||
"local-folder",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort.sections]
|
||||
django = ["django"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["./src/newsreader/"]
|
||||
omit = [
|
||||
"**/tests/**",
|
||||
"**/migrations/**",
|
||||
"**/conf/**",
|
||||
"**/apps.py",
|
||||
"**/admin.py",
|
||||
"**/tests.py",
|
||||
"**/urls.py",
|
||||
"**/wsgi.py",
|
||||
"**/celery.py",
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
#!/bin/bash
|
||||
# This file should only be used in conjuction with docker-compose
|
||||
|
||||
python /app/src/manage.py migrate
|
||||
python /app/src/manage.py runserver 0.0.0.0:8000
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.docker")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
|
|
|
|||
|
|
@ -1,27 +1,36 @@
|
|||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
||||
from django.contrib.auth.forms import UserChangeForm
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
|
||||
|
||||
class UserAdmin(admin.ModelAdmin):
|
||||
class UserAdminForm(UserChangeForm):
|
||||
class Meta:
|
||||
widgets = {
|
||||
"email": forms.EmailInput(attrs={"size": "50"}),
|
||||
}
|
||||
|
||||
|
||||
class UserAdmin(DjangoUserAdmin):
|
||||
list_display = ("email", "last_name", "date_joined", "is_active")
|
||||
list_filter = ("is_active", "is_staff", "is_superuser")
|
||||
ordering = ("email",)
|
||||
|
||||
search_fields = ["email", "last_name", "first_name"]
|
||||
readonly_fields = ("last_login", "date_joined")
|
||||
|
||||
form = UserAdminForm
|
||||
fieldsets = (
|
||||
(
|
||||
_("User settings"),
|
||||
{"fields": ("email", "first_name", "last_name", "is_active")},
|
||||
{"fields": ("email", "password", "first_name", "last_name", "is_active")},
|
||||
),
|
||||
(
|
||||
_("Permission settings"),
|
||||
{
|
||||
"classes": ("collapse",),
|
||||
"fields": ("is_staff", "is_superuser", "groups", "user_permissions"),
|
||||
},
|
||||
{"classes": ("collapse",), "fields": ("is_staff", "is_superuser")},
|
||||
),
|
||||
(_("Misc settings"), {"fields": ("date_joined", "last_login")}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
|||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
name = "accounts"
|
||||
name = "newsreader.accounts"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
from django import forms
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
from newsreader.core.forms import CheckboxInput
|
||||
|
||||
|
||||
class UserSettingsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ("first_name", "last_name")
|
||||
fields = ("first_name", "last_name", "auto_mark_read")
|
||||
widgets = {"auto_mark_read": CheckboxInput}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0001_initial")]
|
||||
|
||||
operations = [migrations.RemoveField(model_name="user", name="username")]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import newsreader.accounts.models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0002_remove_user_username")]
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0003_auto_20190714_1417")]
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0004_auto_20190714_1501")]
|
||||
|
||||
operations = [migrations.RemoveField(model_name="user", name="task_interval")]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0005_remove_user_task_interval")]
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0006_auto_20191116_1253")]
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ def update_task_name(apps, schema_editor):
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0007_auto_20191116_1255")]
|
||||
|
||||
operations = [migrations.RunPython(update_task_name)]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("django_celery_beat", "0012_periodictask_expire_seconds"),
|
||||
("accounts", "0008_auto_20200422_2243"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 3.0.5 on 2020-06-03 20:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounts", "0009_auto_20200524_1218")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="reddit_access_token",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="reddit_refresh_token",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 3.0.7 on 2020-09-13 19:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounts", "0010_auto_20200603_2230")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="twitter_oauth_token",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="twitter_oauth_token_secret",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# Generated by Django 3.0.7 on 2020-09-26 15:34
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounts", "0011_auto_20200913_2101")]
|
||||
|
||||
operations = [migrations.RemoveField(model_name="user", name="task")]
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 3.0.7 on 2020-10-27 21:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounts", "0012_remove_user_task")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="auto_mark_read",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text="Wether posts should be marked as read after x amount of seconds of reading",
|
||||
verbose_name="Auto read marking",
|
||||
),
|
||||
)
|
||||
]
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# Generated by Django 3.0.7 on 2020-12-18 21:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounts", "0013_user_auto_mark_read")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="reddit_allow_nfsw",
|
||||
field=models.BooleanField(default=False, verbose_name="Allow NSFW posts"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="reddit_allow_spoiler",
|
||||
field=models.BooleanField(default=False, verbose_name="Allow spoilers"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="reddit_allow_viewed",
|
||||
field=models.BooleanField(
|
||||
default=True, verbose_name="Allow already seen posts"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="reddit_comments_min",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, verbose_name="Minimum amount of comments"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="reddit_downvotes_max",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, verbose_name="Maximum amount of downvotes"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="reddit_upvotes_min",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0, verbose_name="Minimum amount of upvotes"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# Generated by Django 3.0.7 on 2020-12-19 12:30
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounts", "0014_auto_20201218_2216")]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name="user", name="reddit_allow_nfsw"),
|
||||
migrations.RemoveField(model_name="user", name="reddit_allow_spoiler"),
|
||||
migrations.RemoveField(model_name="user", name="reddit_allow_viewed"),
|
||||
migrations.RemoveField(model_name="user", name="reddit_comments_min"),
|
||||
migrations.RemoveField(model_name="user", name="reddit_downvotes_max"),
|
||||
migrations.RemoveField(model_name="user", name="reddit_upvotes_min"),
|
||||
]
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 3.2 on 2021-04-23 20:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounts", "0015_auto_20201219_1330")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="first_name",
|
||||
field=models.CharField(
|
||||
blank=True, max_length=150, verbose_name="first name"
|
||||
),
|
||||
)
|
||||
]
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 3.2.25 on 2024-09-06 07:14
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0016_alter_user_first_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="twitter_oauth_token",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="twitter_oauth_token_secret",
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 4.2.16 on 2025-03-26 08:46
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0017_auto_20240906_0914"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="reddit_access_token",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="reddit_refresh_token",
|
||||
),
|
||||
]
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
import json
|
||||
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.auth.models import UserManager as DjangoUserManager
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
|
||||
|
||||
class UserManager(DjangoUserManager):
|
||||
|
|
@ -41,13 +39,13 @@ class UserManager(DjangoUserManager):
|
|||
class User(AbstractUser):
|
||||
email = models.EmailField(_("email address"), unique=True)
|
||||
|
||||
task = models.OneToOneField(
|
||||
PeriodicTask,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
editable=False,
|
||||
verbose_name="collection task",
|
||||
# settings
|
||||
auto_mark_read = models.BooleanField(
|
||||
_("Auto read marking"),
|
||||
default=True,
|
||||
help_text=_(
|
||||
"Wether posts should be marked as read after x amount of seconds of reading"
|
||||
),
|
||||
)
|
||||
|
||||
username = None
|
||||
|
|
@ -57,24 +55,8 @@ class User(AbstractUser):
|
|||
USERNAME_FIELD = "email"
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if not self.task:
|
||||
task_interval, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=1, period=IntervalSchedule.HOURS
|
||||
)
|
||||
|
||||
self.task, _ = PeriodicTask.objects.get_or_create(
|
||||
enabled=True,
|
||||
interval=task_interval,
|
||||
name=f"{self.email}-collection-task",
|
||||
task="newsreader.news.collection.tasks.FeedTask",
|
||||
args=json.dumps([self.pk]),
|
||||
)
|
||||
|
||||
self.save()
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
self.task.delete()
|
||||
tasks = PeriodicTask.objects.filter(name__contains=self.email)
|
||||
tasks.delete()
|
||||
|
||||
return super().delete(*args, **kwargs)
|
||||
|
|
|
|||
|
|
@ -2,17 +2,23 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block actions %}
|
||||
<section class="section form__section--last">
|
||||
<fieldset class="fieldset form__fieldset">
|
||||
{% include "components/form/cancel-button.html" %}
|
||||
</fieldset>
|
||||
<section class="section form__section--last">
|
||||
<fieldset class="fieldset form__fieldset">
|
||||
{% include "components/form/confirm-button.html" %}
|
||||
|
||||
<fieldset class="fieldset form__fieldset">
|
||||
<a class="link button button--primary" href="{% url 'accounts:password-change' %}">
|
||||
{% trans "Change password" %}
|
||||
</a>
|
||||
<a class="link button button--primary" href="{% url 'accounts:password-change' %}">
|
||||
{% trans "Change password" %}
|
||||
</a>
|
||||
|
||||
{% include "components/form/confirm-button.html" %}
|
||||
</fieldset>
|
||||
</section>
|
||||
{% if favicon_task_allowed %}
|
||||
<a class="link button button--primary" href="{% url 'accounts:settings:favicon' %}">
|
||||
{% trans "Fetch favicons" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="button button--primary button--disabled" disabled>
|
||||
{% trans "Fetch favicons" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
</section>
|
||||
{% endblock actions %}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
{% extends "base.html" %}
|
||||
{% extends "sidebar.html" %}
|
||||
|
||||
{% block content %}
|
||||
<main id="login--page" class="main">
|
||||
{% include "accounts/components/login-form.html" with form=form title="Login" confirm_text="Login" %}
|
||||
<main id="login--page" class="main" data-render-sidebar=true>
|
||||
<div class="main__container">
|
||||
{% include "accounts/components/login-form.html" with form=form title="Login" confirm_text="Login" %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
{% extends "base.html" %}
|
||||
{% extends "sidebar.html" %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<main id="password-change--page" class="main">
|
||||
{% url 'accounts:settings' as cancel_url %}
|
||||
{% include "components/form/form.html" with form=form title="Change password" confirm_text="Change password" cancel_url=cancel_url %}
|
||||
{% url 'accounts:settings:home' as cancel_url %}
|
||||
|
||||
<main id="password-change--page" class="main" data-render-sidebar=true>
|
||||
<div class="main__container">
|
||||
{% include "components/form/form.html" with form=form title="Change password" confirm_text="Change password" cancel_url=cancel_url %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
{% extends "base.html" %}
|
||||
{% extends "sidebar.html" %}
|
||||
|
||||
{% block content %}
|
||||
<main id="settings--page" class="main">
|
||||
{% include "accounts/components/settings-form.html" with form=form title="User profile" confirm_text="Save" %}
|
||||
<main id="settings--page" class="main" data-render-sidebar=true>
|
||||
<div class="main__container">
|
||||
{% include "accounts/components/settings-form.html" with form=form title="User profile" confirm_text="Save" %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ from django.utils.crypto import get_random_string
|
|||
|
||||
import factory
|
||||
|
||||
from registration.models import RegistrationProfile
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
|
||||
|
||||
|
|
@ -29,11 +27,3 @@ class UserFactory(factory.django.DjangoModelFactory):
|
|||
|
||||
class Meta:
|
||||
model = User
|
||||
|
||||
|
||||
class RegistrationProfileFactory(factory.django.DjangoModelFactory):
|
||||
user = factory.SubFactory(UserFactory)
|
||||
activation_key = factory.LazyFunction(get_activation_key)
|
||||
|
||||
class Meta:
|
||||
model = RegistrationProfile
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from registration.models import RegistrationProfile
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
|
||||
|
||||
class ActivationTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.register_url = reverse("accounts:register")
|
||||
self.register_success_url = reverse("accounts:register-complete")
|
||||
self.success_url = reverse("accounts:activate-complete")
|
||||
|
||||
def test_activation(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
self.assertRedirects(response, self.register_success_url)
|
||||
|
||||
register_profile = RegistrationProfile.objects.get()
|
||||
|
||||
kwargs = {"activation_key": register_profile.activation_key}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
def test_expired_key(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
|
||||
register_profile = RegistrationProfile.objects.get()
|
||||
user = register_profile.user
|
||||
|
||||
user.date_joined -= datetime.timedelta(days=settings.ACCOUNT_ACTIVATION_DAYS)
|
||||
user.save()
|
||||
|
||||
kwargs = {"activation_key": register_profile.activation_key}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertContains(response, _("Account activation failed"))
|
||||
|
||||
user.refresh_from_db()
|
||||
self.assertFalse(user.is_active)
|
||||
|
||||
def test_invalid_key(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
self.assertRedirects(response, self.register_success_url)
|
||||
|
||||
kwargs = {"activation_key": "not-a-valid-key"}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertContains(response, _("Account activation failed"))
|
||||
|
||||
user = User.objects.get()
|
||||
|
||||
self.assertEquals(user.is_active, False)
|
||||
|
||||
def test_activated_key(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
self.assertRedirects(response, self.register_success_url)
|
||||
|
||||
register_profile = RegistrationProfile.objects.get()
|
||||
|
||||
kwargs = {"activation_key": register_profile.activation_key}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
# try this a second time
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertRedirects(response, self.success_url)
|
||||
37
src/newsreader/accounts/tests/test_favicon.py
Normal file
37
src/newsreader/accounts/tests/test_favicon.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from newsreader.accounts.tests.factories import UserFactory
|
||||
|
||||
|
||||
class FaviconRedirectViewTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = UserFactory(email="test@test.nl", password="test")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
self.patch = patch("newsreader.accounts.views.favicon.FaviconTask")
|
||||
self.mocked_task = self.patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def test_simple(self):
|
||||
response = self.client.get(reverse("accounts:settings:favicon"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts:settings:home"))
|
||||
|
||||
self.mocked_task.delay.assert_called_once_with(self.user.pk)
|
||||
|
||||
self.assertEqual(1, cache.get(f"{self.user.email}-favicon-task"))
|
||||
|
||||
def test_not_active(self):
|
||||
cache.set(f"{self.user.email}-favicon-task", 1)
|
||||
|
||||
response = self.client.get(reverse("accounts:settings:favicon"))
|
||||
|
||||
self.assertRedirects(response, reverse("accounts:settings:home"))
|
||||
|
||||
self.mocked_task.delay.assert_not_called()
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
from django.core import mail
|
||||
from django.test import TransactionTestCase as TestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from registration.models import RegistrationProfile
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
from newsreader.accounts.tests.factories import UserFactory
|
||||
|
||||
|
||||
class RegistrationTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.url = reverse("accounts:register")
|
||||
self.success_url = reverse("accounts:register-complete")
|
||||
self.disallowed_url = reverse("accounts:register-closed")
|
||||
|
||||
def test_simple(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
def test_registration(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
self.assertEquals(User.objects.count(), 1)
|
||||
self.assertEquals(RegistrationProfile.objects.count(), 1)
|
||||
|
||||
user = User.objects.get()
|
||||
|
||||
self.assertEquals(user.is_active, False)
|
||||
self.assertEquals(len(mail.outbox), 1)
|
||||
|
||||
def test_existing_email(self):
|
||||
UserFactory(email="test@test.com")
|
||||
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
self.assertEquals(User.objects.count(), 1)
|
||||
self.assertContains(response, _("User with this Email address already exists"))
|
||||
|
||||
def test_pending_registration(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
self.assertEquals(User.objects.count(), 1)
|
||||
self.assertEquals(RegistrationProfile.objects.count(), 1)
|
||||
|
||||
user = User.objects.get()
|
||||
|
||||
self.assertEquals(user.is_active, False)
|
||||
self.assertEquals(len(mail.outbox), 1)
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertEquals(response.status_code, 200)
|
||||
self.assertContains(response, _("User with this Email address already exists"))
|
||||
|
||||
def test_disabled_account(self):
|
||||
UserFactory(email="test@test.com", is_active=False)
|
||||
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
self.assertEquals(User.objects.count(), 1)
|
||||
self.assertContains(response, _("User with this Email address already exists"))
|
||||
|
||||
@override_settings(REGISTRATION_OPEN=False)
|
||||
def test_registration_closed(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertRedirects(response, self.disallowed_url)
|
||||
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
self.assertRedirects(response, self.disallowed_url)
|
||||
|
||||
self.assertEquals(User.objects.count(), 0)
|
||||
self.assertEquals(RegistrationProfile.objects.count(), 0)
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
from django.core import mail
|
||||
from django.test import TransactionTestCase as TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from registration.models import RegistrationProfile
|
||||
|
||||
from newsreader.accounts.tests.factories import RegistrationProfileFactory, UserFactory
|
||||
|
||||
|
||||
class ResendActivationTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.url = reverse("accounts:activate-resend")
|
||||
self.success_url = reverse("accounts:activate-complete")
|
||||
self.register_url = reverse("accounts:register")
|
||||
|
||||
def test_simple(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
def test_resent_form(self):
|
||||
data = {
|
||||
"email": "test@test.com",
|
||||
"password1": "test12456",
|
||||
"password2": "test12456",
|
||||
}
|
||||
|
||||
response = self.client.post(self.register_url, data)
|
||||
|
||||
register_profile = RegistrationProfile.objects.get()
|
||||
original_kwargs = {"activation_key": register_profile.activation_key}
|
||||
|
||||
response = self.client.post(self.url, {"email": "test@test.com"})
|
||||
|
||||
self.assertContains(response, _("We have sent an email to"))
|
||||
|
||||
self.assertEquals(len(mail.outbox), 2)
|
||||
|
||||
register_profile.refresh_from_db()
|
||||
|
||||
kwargs = {"activation_key": register_profile.activation_key}
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=kwargs))
|
||||
|
||||
self.assertRedirects(response, self.success_url)
|
||||
|
||||
register_profile.refresh_from_db()
|
||||
user = register_profile.user
|
||||
|
||||
self.assertEquals(user.is_active, True)
|
||||
|
||||
# test the old activation code
|
||||
response = self.client.get(reverse("accounts:activate", kwargs=original_kwargs))
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
self.assertContains(response, _("Account activation failed"))
|
||||
|
||||
def test_existing_account(self):
|
||||
user = UserFactory(is_active=True)
|
||||
profile = RegistrationProfileFactory(user=user, activated=True)
|
||||
|
||||
response = self.client.post(self.url, {"email": user.email})
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
# default behaviour is to show success page but not send an email
|
||||
self.assertContains(response, _("We have sent an email to"))
|
||||
|
||||
self.assertEquals(len(mail.outbox), 0)
|
||||
|
||||
def test_no_account(self):
|
||||
response = self.client.post(self.url, {"email": "fake@mail.com"})
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
# default behaviour is to show success page but not send an email
|
||||
self.assertContains(response, _("We have sent an email to"))
|
||||
|
||||
self.assertEquals(len(mail.outbox), 0)
|
||||
|
|
@ -5,25 +5,27 @@ from newsreader.accounts.models import User
|
|||
from newsreader.accounts.tests.factories import UserFactory
|
||||
|
||||
|
||||
class UserSettingsViewTestCase(TestCase):
|
||||
class SettingsViewTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = UserFactory(password="test")
|
||||
self.user = UserFactory(email="test@test.nl", password="test")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
self.url = reverse("accounts:settings:home")
|
||||
|
||||
def test_simple(self):
|
||||
response = self.client.get(reverse("accounts:settings"))
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
def test_user_credential_change(self):
|
||||
response = self.client.post(
|
||||
reverse("accounts:settings"),
|
||||
reverse("accounts:settings:home"),
|
||||
{"first_name": "First name", "last_name": "Last name"},
|
||||
)
|
||||
|
||||
user = User.objects.get()
|
||||
|
||||
self.assertRedirects(response, reverse("accounts:settings"))
|
||||
self.assertRedirects(response, reverse("accounts:settings:home"))
|
||||
|
||||
self.assertEquals(user.first_name, "First name")
|
||||
self.assertEquals(user.last_name, "Last name")
|
||||
|
|
@ -1,22 +1,21 @@
|
|||
from django.test import TestCase
|
||||
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
||||
|
||||
from newsreader.accounts.models import User
|
||||
from newsreader.accounts.tests.factories import UserFactory
|
||||
|
||||
|
||||
class UserTestCase(TestCase):
|
||||
def test_task_is_created(self):
|
||||
user = User.objects.create(email="durp@burp.nl", task=None)
|
||||
task = PeriodicTask.objects.get(name=f"{user.email}-collection-task")
|
||||
|
||||
user.refresh_from_db()
|
||||
|
||||
self.assertEquals(task, user.task)
|
||||
self.assertEquals(PeriodicTask.objects.count(), 1)
|
||||
|
||||
def test_task_is_deleted(self):
|
||||
user = User.objects.create(email="durp@burp.nl", task=None)
|
||||
user = UserFactory(email="durp@burp.nl")
|
||||
|
||||
interval = IntervalSchedule.objects.create(
|
||||
every=1, period=IntervalSchedule.HOURS
|
||||
)
|
||||
PeriodicTask.objects.create(
|
||||
name=f"{user.email}-feed", task="FeedTask", interval=interval
|
||||
)
|
||||
|
||||
user.delete()
|
||||
|
||||
self.assertEquals(PeriodicTask.objects.count(), 0)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
from django.contrib.auth.decorators import login_required
|
||||
from django.urls import path
|
||||
from django.urls import include, path
|
||||
|
||||
from newsreader.accounts.views import (
|
||||
ActivationCompleteView,
|
||||
ActivationResendView,
|
||||
ActivationView,
|
||||
FaviconRedirectView,
|
||||
LoginView,
|
||||
LogoutView,
|
||||
PasswordChangeView,
|
||||
|
|
@ -12,33 +10,21 @@ from newsreader.accounts.views import (
|
|||
PasswordResetConfirmView,
|
||||
PasswordResetDoneView,
|
||||
PasswordResetView,
|
||||
RegistrationClosedView,
|
||||
RegistrationCompleteView,
|
||||
RegistrationView,
|
||||
SettingsView,
|
||||
)
|
||||
|
||||
|
||||
settings_patterns = [
|
||||
# Misc
|
||||
path("favicon/", login_required(FaviconRedirectView.as_view()), name="favicon"),
|
||||
path("", login_required(SettingsView.as_view()), name="home"),
|
||||
]
|
||||
|
||||
urlpatterns = [
|
||||
# Auth
|
||||
path("login/", LoginView.as_view(), name="login"),
|
||||
path("logout/", LogoutView.as_view(), name="logout"),
|
||||
path("register/", RegistrationView.as_view(), name="register"),
|
||||
path(
|
||||
"register/complete/",
|
||||
RegistrationCompleteView.as_view(),
|
||||
name="register-complete",
|
||||
),
|
||||
path("register/closed/", RegistrationClosedView.as_view(), name="register-closed"),
|
||||
path(
|
||||
"activate/complete/", ActivationCompleteView.as_view(), name="activate-complete"
|
||||
),
|
||||
path("activate/resend/", ActivationResendView.as_view(), name="activate-resend"),
|
||||
path(
|
||||
# This URL should be placed after all activate/ url's (see arg)
|
||||
"activate/<str:activation_key>/",
|
||||
ActivationView.as_view(),
|
||||
name="activate",
|
||||
),
|
||||
# Password
|
||||
path("password-reset/", PasswordResetView.as_view(), name="password-reset"),
|
||||
path(
|
||||
"password-reset/done/",
|
||||
|
|
@ -60,5 +46,6 @@ urlpatterns = [
|
|||
login_required(PasswordChangeView.as_view()),
|
||||
name="password-change",
|
||||
),
|
||||
path("settings/", login_required(SettingsView.as_view()), name="settings"),
|
||||
# Settings
|
||||
path("settings/", include((settings_patterns, "settings"))),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
from django.contrib.auth import views as django_views
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic import TemplateView
|
||||
from django.views.generic.edit import FormView, ModelFormMixin
|
||||
|
||||
from registration.backends.default import views as registration_views
|
||||
|
||||
from newsreader.accounts.forms import UserSettingsForm
|
||||
from newsreader.accounts.models import User
|
||||
|
||||
|
||||
class LoginView(django_views.LoginView):
|
||||
template_name = "accounts/views/login.html"
|
||||
success_url = reverse_lazy("index")
|
||||
|
||||
|
||||
class LogoutView(django_views.LogoutView):
|
||||
next_page = reverse_lazy("accounts:login")
|
||||
|
||||
|
||||
# RegistrationView shows a registration form and sends the email
|
||||
# RegistrationCompleteView shows after filling in the registration form
|
||||
# ActivationView is send within the activation email and activates the account
|
||||
# ActivationCompleteView shows the success screen when activation was succesful
|
||||
# ActivationResendView can be used when activation links are expired
|
||||
# RegistrationClosedView shows when registration is disabled
|
||||
class RegistrationView(registration_views.RegistrationView):
|
||||
disallowed_url = reverse_lazy("accounts:register-closed")
|
||||
template_name = "registration/registration_form.html"
|
||||
success_url = reverse_lazy("accounts:register-complete")
|
||||
|
||||
|
||||
class RegistrationCompleteView(TemplateView):
|
||||
template_name = "registration/registration_complete.html"
|
||||
|
||||
|
||||
class RegistrationClosedView(TemplateView):
|
||||
template_name = "registration/registration_closed.html"
|
||||
|
||||
|
||||
# Redirects or renders failed activation template
|
||||
class ActivationView(registration_views.ActivationView):
|
||||
template_name = "registration/activation_failure.html"
|
||||
|
||||
def get_success_url(self, user):
|
||||
return ("accounts:activate-complete", (), {})
|
||||
|
||||
|
||||
class ActivationCompleteView(TemplateView):
|
||||
template_name = "registration/activation_complete.html"
|
||||
|
||||
|
||||
# Renders activation form resend or resend_activation_complete
|
||||
class ActivationResendView(registration_views.ResendActivationView):
|
||||
template_name = "registration/activation_resend_form.html"
|
||||
|
||||
def render_form_submitted_template(self, form):
|
||||
"""
|
||||
Renders resend activation complete template with the submitted email.
|
||||
|
||||
"""
|
||||
email = form.cleaned_data["email"]
|
||||
context = {"email": email}
|
||||
|
||||
return render(
|
||||
self.request, "registration/activation_resend_complete.html", context
|
||||
)
|
||||
|
||||
|
||||
# PasswordResetView sends the mail
|
||||
# PasswordResetDoneView shows a success message for the above
|
||||
# PasswordResetConfirmView checks the link the user clicked and
|
||||
# prompts for a new password
|
||||
# PasswordResetCompleteView shows a success message for the above
|
||||
class PasswordResetView(django_views.PasswordResetView):
|
||||
template_name = "password-reset/password-reset.html"
|
||||
subject_template_name = "password-reset/password-reset-subject.txt"
|
||||
email_template_name = "password-reset/password-reset-email.html"
|
||||
success_url = reverse_lazy("accounts:password-reset-done")
|
||||
|
||||
|
||||
class PasswordResetDoneView(django_views.PasswordResetDoneView):
|
||||
template_name = "password-reset/password-reset-done.html"
|
||||
|
||||
|
||||
class PasswordResetConfirmView(django_views.PasswordResetConfirmView):
|
||||
template_name = "password-reset/password-reset-confirm.html"
|
||||
success_url = reverse_lazy("accounts:password-reset-complete")
|
||||
|
||||
|
||||
class PasswordResetCompleteView(django_views.PasswordResetCompleteView):
|
||||
template_name = "password-reset/password-reset-complete.html"
|
||||
|
||||
|
||||
class PasswordChangeView(django_views.PasswordChangeView):
|
||||
template_name = "accounts/views/password-change.html"
|
||||
success_url = reverse_lazy("accounts:settings")
|
||||
|
||||
|
||||
class SettingsView(ModelFormMixin, FormView):
|
||||
template_name = "accounts/views/settings.html"
|
||||
success_url = reverse_lazy("accounts:settings")
|
||||
form_class = UserSettingsForm
|
||||
model = User
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
return self.request.user
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return {**super().get_form_kwargs(), "instance": self.request.user}
|
||||
23
src/newsreader/accounts/views/__init__.py
Normal file
23
src/newsreader/accounts/views/__init__.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from newsreader.accounts.views.auth import LoginView, LogoutView
|
||||
from newsreader.accounts.views.favicon import FaviconRedirectView
|
||||
from newsreader.accounts.views.password import (
|
||||
PasswordChangeView,
|
||||
PasswordResetCompleteView,
|
||||
PasswordResetConfirmView,
|
||||
PasswordResetDoneView,
|
||||
PasswordResetView,
|
||||
)
|
||||
from newsreader.accounts.views.settings import SettingsView
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LoginView",
|
||||
"LogoutView",
|
||||
"FaviconRedirectView",
|
||||
"PasswordChangeView",
|
||||
"PasswordResetCompleteView",
|
||||
"PasswordResetConfirmView",
|
||||
"PasswordResetDoneView",
|
||||
"PasswordResetView",
|
||||
"SettingsView",
|
||||
]
|
||||
13
src/newsreader/accounts/views/auth.py
Normal file
13
src/newsreader/accounts/views/auth.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from django.contrib.auth import views as django_views
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
from newsreader.utils.views import NavListMixin
|
||||
|
||||
|
||||
class LoginView(NavListMixin, django_views.LoginView):
|
||||
template_name = "accounts/views/login.html"
|
||||
success_url = reverse_lazy("index")
|
||||
|
||||
|
||||
class LogoutView(django_views.LogoutView):
|
||||
next_page = reverse_lazy("accounts:login")
|
||||
26
src/newsreader/accounts/views/favicon.py
Normal file
26
src/newsreader/accounts/views/favicon.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from django.contrib import messages
|
||||
from django.core.cache import cache
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
from newsreader.news.collection.tasks import FaviconTask
|
||||
|
||||
|
||||
class FaviconRedirectView(RedirectView):
|
||||
url = reverse_lazy("accounts:settings:home")
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
response = super().get(request, *args, **kwargs)
|
||||
|
||||
user = request.user
|
||||
task_active = cache.get(f"{user.email}-favicon-task")
|
||||
|
||||
if not task_active:
|
||||
FaviconTask.delay(user.pk)
|
||||
messages.success(request, _("Favicons are being fetched"))
|
||||
cache.set(f"{user.email}-favicon-task", 1, 18000) # 5 hours
|
||||
return response
|
||||
|
||||
messages.error(request, _("Limit reached, try again later"))
|
||||
return response
|
||||
34
src/newsreader/accounts/views/password.py
Normal file
34
src/newsreader/accounts/views/password.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from django.contrib.auth import views as django_views
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
from newsreader.utils.views import NavListMixin
|
||||
|
||||
|
||||
# PasswordResetView sends the mail
|
||||
# PasswordResetDoneView shows a success message for the above
|
||||
# PasswordResetConfirmView checks the link the user clicked and
|
||||
# prompts for a new password
|
||||
# PasswordResetCompleteView shows a success message for the above
|
||||
class PasswordResetView(NavListMixin, django_views.PasswordResetView):
|
||||
template_name = "password-reset/password-reset.html"
|
||||
subject_template_name = "password-reset/password-reset-subject.txt"
|
||||
email_template_name = "password-reset/password-reset-email.html"
|
||||
success_url = reverse_lazy("accounts:password-reset-done")
|
||||
|
||||
|
||||
class PasswordResetDoneView(NavListMixin, django_views.PasswordResetDoneView):
|
||||
template_name = "password-reset/password-reset-done.html"
|
||||
|
||||
|
||||
class PasswordResetConfirmView(NavListMixin, django_views.PasswordResetConfirmView):
|
||||
template_name = "password-reset/password-reset-confirm.html"
|
||||
success_url = reverse_lazy("accounts:password-reset-complete")
|
||||
|
||||
|
||||
class PasswordResetCompleteView(NavListMixin, django_views.PasswordResetCompleteView):
|
||||
template_name = "password-reset/password-reset-complete.html"
|
||||
|
||||
|
||||
class PasswordChangeView(NavListMixin, django_views.PasswordChangeView):
|
||||
template_name = "accounts/views/password-change.html"
|
||||
success_url = reverse_lazy("accounts:settings")
|
||||
32
src/newsreader/accounts/views/settings.py
Normal file
32
src/newsreader/accounts/views/settings.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from django.core.cache import cache
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic.edit import FormView, ModelFormMixin
|
||||
|
||||
from newsreader.accounts.forms import UserSettingsForm
|
||||
from newsreader.accounts.models import User
|
||||
from newsreader.utils.views import NavListMixin
|
||||
|
||||
|
||||
class SettingsView(NavListMixin, ModelFormMixin, FormView):
|
||||
template_name = "accounts/views/settings.html"
|
||||
success_url = reverse_lazy("accounts:settings:home")
|
||||
form_class = UserSettingsForm
|
||||
model = User
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
return {
|
||||
**context,
|
||||
"favicon_task_allowed": not cache.get(f"{self.request.user}-favicon-task"),
|
||||
}
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
return self.request.user
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return {**super().get_form_kwargs(), "instance": self.request.user}
|
||||
Binary file not shown.
BIN
src/newsreader/assets/fonts/Inter-VariableFont_opsz,wght.ttf
Normal file
BIN
src/newsreader/assets/fonts/Inter-VariableFont_opsz,wght.ttf
Normal file
Binary file not shown.
|
|
@ -1,101 +0,0 @@
|
|||
name: "Rubik"
|
||||
designer: "Hubert and Fischer, Meir Sadan, Cyreal"
|
||||
license: "OFL"
|
||||
category: "SANS_SERIF"
|
||||
date_added: "2015-07-22"
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "normal"
|
||||
weight: 300
|
||||
filename: "Rubik-Light.ttf"
|
||||
post_script_name: "Rubik-Light"
|
||||
full_name: "Rubik Light"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "italic"
|
||||
weight: 300
|
||||
filename: "Rubik-LightItalic.ttf"
|
||||
post_script_name: "Rubik-LightItalic"
|
||||
full_name: "Rubik Light Italic"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "normal"
|
||||
weight: 400
|
||||
filename: "Rubik-Regular.ttf"
|
||||
post_script_name: "Rubik-Regular"
|
||||
full_name: "Rubik Regular"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "italic"
|
||||
weight: 400
|
||||
filename: "Rubik-Italic.ttf"
|
||||
post_script_name: "Rubik-Italic"
|
||||
full_name: "Rubik Italic"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "normal"
|
||||
weight: 500
|
||||
filename: "Rubik-Medium.ttf"
|
||||
post_script_name: "Rubik-Medium"
|
||||
full_name: "Rubik Medium"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "italic"
|
||||
weight: 500
|
||||
filename: "Rubik-MediumItalic.ttf"
|
||||
post_script_name: "Rubik-MediumItalic"
|
||||
full_name: "Rubik Medium Italic"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "normal"
|
||||
weight: 700
|
||||
filename: "Rubik-Bold.ttf"
|
||||
post_script_name: "Rubik-Bold"
|
||||
full_name: "Rubik Bold"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "italic"
|
||||
weight: 700
|
||||
filename: "Rubik-BoldItalic.ttf"
|
||||
post_script_name: "Rubik-BoldItalic"
|
||||
full_name: "Rubik Bold Italic"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "normal"
|
||||
weight: 900
|
||||
filename: "Rubik-Black.ttf"
|
||||
post_script_name: "Rubik-Black"
|
||||
full_name: "Rubik Black"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
fonts {
|
||||
name: "Rubik"
|
||||
style: "italic"
|
||||
weight: 900
|
||||
filename: "Rubik-BlackItalic.ttf"
|
||||
post_script_name: "Rubik-BlackItalic"
|
||||
full_name: "Rubik Black Italic"
|
||||
copyright: "Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)"
|
||||
}
|
||||
subsets: "cyrillic"
|
||||
subsets: "cyrillic-ext"
|
||||
subsets: "hebrew"
|
||||
subsets: "latin"
|
||||
subsets: "latin-ext"
|
||||
subsets: "menu"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -3,7 +3,7 @@ import os
|
|||
from celery import Celery
|
||||
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.dev")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "newsreader.conf.docker")
|
||||
|
||||
app = Celery("newsreader")
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
|
|
|
|||
|
|
@ -1,18 +1,25 @@
|
|||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from pathlib import Path
|
||||
from newsreader.conf.utils import get_env, get_root_dir
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent
|
||||
DJANGO_PROJECT_DIR = os.path.join(BASE_DIR, "src", "newsreader")
|
||||
load_dotenv()
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
|
||||
# SECURITY WARNING: don"t run with debug turned on in production!
|
||||
DEBUG = True
|
||||
try:
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
except ImportError:
|
||||
CeleryIntegration = None
|
||||
DjangoIntegration = None
|
||||
|
||||
ALLOWED_HOSTS = ["127.0.0.1"]
|
||||
INTERNAL_IPS = ["127.0.0.1"]
|
||||
|
||||
BASE_DIR = get_root_dir()
|
||||
DJANGO_PROJECT_DIR = BASE_DIR / "src" / "newsreader"
|
||||
|
||||
DEBUG = False
|
||||
|
||||
ALLOWED_HOSTS = get_env("ALLOWED_HOSTS", split=",", default=["127.0.0.1", "localhost"])
|
||||
INTERNAL_IPS = get_env("INTERNAL_IPS", split=",", default=["127.0.0.1", "localhost"])
|
||||
|
||||
# Application definition
|
||||
INSTALLED_APPS = [
|
||||
|
|
@ -22,12 +29,11 @@ INSTALLED_APPS = [
|
|||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django.forms",
|
||||
# third party apps
|
||||
"rest_framework",
|
||||
"drf_yasg",
|
||||
"celery",
|
||||
"django_celery_beat",
|
||||
"registration",
|
||||
"axes",
|
||||
# app modules
|
||||
"newsreader.accounts",
|
||||
|
|
@ -37,6 +43,8 @@ INSTALLED_APPS = [
|
|||
"newsreader.news.collection",
|
||||
]
|
||||
|
||||
SECRET_KEY = get_env("DJANGO_SECRET_KEY", default="")
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"axes.backends.AxesBackend",
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
|
|
@ -55,14 +63,15 @@ MIDDLEWARE = [
|
|||
|
||||
ROOT_URLCONF = "newsreader.urls"
|
||||
|
||||
FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
|
||||
"DIRS": [DJANGO_PROJECT_DIR / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
|
|
@ -71,35 +80,32 @@ TEMPLATES = [
|
|||
}
|
||||
]
|
||||
|
||||
FORM_RENDERER = "newsreader.utils.form.FormRenderer"
|
||||
|
||||
WSGI_APPLICATION = "newsreader.wsgi.application"
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"HOST": os.environ.get("POSTGRES_HOST", ""),
|
||||
"NAME": os.environ.get("POSTGRES_NAME", "newsreader"),
|
||||
"USER": os.environ.get("POSTGRES_USER"),
|
||||
"PASSWORD": os.environ.get("POSTGRES_PASSWORD"),
|
||||
"HOST": get_env("POSTGRES_HOST", default=""),
|
||||
"PORT": get_env("POSTGRES_PORT", default=""),
|
||||
"NAME": get_env("POSTGRES_DB", default=""),
|
||||
"USER": get_env("POSTGRES_USER", default=""),
|
||||
"PASSWORD": get_env("POSTGRES_PASSWORD", default=""),
|
||||
}
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "localhost:11211",
|
||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
"axes": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "localhost:11211",
|
||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
}
|
||||
|
||||
# Logging
|
||||
# https://docs.djangoproject.com/en/2.2/topics/logging/#configuring-logging
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
|
|
@ -108,61 +114,51 @@ LOGGING = {
|
|||
"require_debug_true": {"()": "django.utils.log.RequireDebugTrue"},
|
||||
},
|
||||
"formatters": {
|
||||
"django.server": {
|
||||
"timestamped": {
|
||||
"()": "django.utils.log.ServerFormatter",
|
||||
"format": "[{server_time}] {message}",
|
||||
"style": "{",
|
||||
},
|
||||
"syslog": {"class": "logging.Formatter", "format": "{message}", "style": "{"},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"level": "INFO",
|
||||
"filters": ["require_debug_true"],
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "timestamped",
|
||||
},
|
||||
"django.server": {
|
||||
"file": {
|
||||
"level": "DEBUG",
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": BASE_DIR / "logs" / "newsreader.log",
|
||||
"backupCount": 5,
|
||||
"maxBytes": 50000000, # 50 mB
|
||||
"formatter": "timestamped",
|
||||
},
|
||||
"celery": {
|
||||
"level": "INFO",
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "django.server",
|
||||
},
|
||||
"mail_admins": {
|
||||
"level": "ERROR",
|
||||
"filters": ["require_debug_false"],
|
||||
"class": "django.utils.log.AdminEmailHandler",
|
||||
},
|
||||
"syslog": {
|
||||
"level": "INFO",
|
||||
"filters": ["require_debug_false"],
|
||||
"class": "logging.handlers.SysLogHandler",
|
||||
"formatter": "syslog",
|
||||
"address": "/dev/log",
|
||||
},
|
||||
"syslog_errors": {
|
||||
"level": "ERROR",
|
||||
"filters": ["require_debug_false"],
|
||||
"class": "logging.handlers.SysLogHandler",
|
||||
"formatter": "syslog",
|
||||
"address": "/dev/log",
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": BASE_DIR / "logs" / "celery.log",
|
||||
"backupCount": 5,
|
||||
"maxBytes": 50000000, # 50 mB
|
||||
"formatter": "timestamped",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"django": {
|
||||
"handlers": ["console", "mail_admins", "syslog_errors"],
|
||||
"level": "INFO",
|
||||
},
|
||||
"django": {"handlers": ["console"], "level": "INFO"},
|
||||
"django.server": {
|
||||
"handlers": ["django.server"],
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"celery": {"handlers": ["syslog", "console"], "level": "INFO"},
|
||||
"celery.task": {"handlers": ["syslog", "console"], "level": "INFO"},
|
||||
"celery.task": {"handlers": ["console", "celery"], "level": "INFO"},
|
||||
"newsreader": {
|
||||
"handlers": ["console", "file"],
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
|
||||
|
|
@ -177,8 +173,6 @@ AUTH_USER_MODEL = "accounts.User"
|
|||
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/2.2/topics/i18n/
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "Europe/Amsterdam"
|
||||
|
|
@ -186,19 +180,31 @@ USE_I18N = True
|
|||
USE_L10N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/2.2/howto/static-files/
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
||||
STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static")]
|
||||
STATIC_ROOT = BASE_DIR / "static"
|
||||
STATICFILES_DIRS = (DJANGO_PROJECT_DIR / "static",)
|
||||
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-STATICFILES_FINDERS
|
||||
STATICFILES_FINDERS = [
|
||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||
]
|
||||
|
||||
DEFAULT_FROM_EMAIL = "newsreader@rss.fudiggity.nl"
|
||||
# Email
|
||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
|
||||
DEFAULT_FROM_EMAIL = get_env(
|
||||
"EMAIL_DEFAULT_FROM", required=False, default="webmaster@localhost"
|
||||
)
|
||||
|
||||
EMAIL_HOST = get_env("EMAIL_HOST", required=False, default="localhost")
|
||||
EMAIL_PORT = get_env("EMAIL_PORT", cast=int, required=False, default=25)
|
||||
|
||||
EMAIL_HOST_USER = get_env("EMAIL_HOST_USER", required=False, default="")
|
||||
EMAIL_HOST_PASSWORD = get_env("EMAIL_HOST_PASSWORD", required=False, default="")
|
||||
|
||||
EMAIL_USE_TLS = get_env("EMAIL_USE_TLS", required=False, default=False)
|
||||
EMAIL_USE_SSL = get_env("EMAIL_USE_SSL", required=False, default=False)
|
||||
|
||||
|
||||
# Third party settings
|
||||
AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler"
|
||||
|
|
@ -215,7 +221,12 @@ REST_FRAMEWORK = {
|
|||
"rest_framework.permissions.IsAuthenticated",
|
||||
"newsreader.accounts.permissions.IsOwner",
|
||||
),
|
||||
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
|
||||
"DEFAULT_RENDERER_CLASSES": (
|
||||
"djangorestframework_camel_case.render.CamelCaseJSONRenderer",
|
||||
),
|
||||
"DEFAULT_PARSER_CLASSES": (
|
||||
"djangorestframework_camel_case.parser.CamelCaseJSONParser",
|
||||
),
|
||||
}
|
||||
|
||||
SWAGGER_SETTINGS = {
|
||||
|
|
@ -226,8 +237,15 @@ SWAGGER_SETTINGS = {
|
|||
|
||||
# Celery
|
||||
# https://docs.celeryproject.org/en/stable/userguide/configuration.html
|
||||
# Note that celery settings are prefix with CELERY. See src/newsreader/celery.py.
|
||||
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
||||
CELERY_BROKER_URL = "amqp://guest@rabbitmq:5672"
|
||||
|
||||
REGISTRATION_OPEN = True
|
||||
REGISTRATION_AUTO_LOGIN = True
|
||||
ACCOUNT_ACTIVATION_DAYS = 7
|
||||
# Sentry
|
||||
SENTRY_CONFIG = {
|
||||
"dsn": get_env("SENTRY_DSN", default="", required=False),
|
||||
"send_default_pii": False,
|
||||
"integrations": [DjangoIntegration(), CeleryIntegration()]
|
||||
if DjangoIntegration and CeleryIntegration
|
||||
else [],
|
||||
}
|
||||
|
|
|
|||
46
src/newsreader/conf/ci.py
Normal file
46
src/newsreader/conf/ci.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from .base import * # noqa: F403
|
||||
from .utils import get_current_version
|
||||
|
||||
|
||||
DEBUG = True
|
||||
|
||||
|
||||
del LOGGING["handlers"]["file"] # noqa: F405
|
||||
del LOGGING["handlers"]["celery"] # noqa: F405
|
||||
|
||||
LOGGING["loggers"].update( # noqa: F405
|
||||
{
|
||||
"celery.task": {"handlers": ["console"], "level": "DEBUG"},
|
||||
"newsreader": {"handlers": ["console"], "level": "INFO"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
AXES_ENABLED = False
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
"axes": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
}
|
||||
|
||||
# Project settings
|
||||
VERSION = get_current_version()
|
||||
ENVIRONMENT = "ci"
|
||||
|
||||
try:
|
||||
# Optionally use sentry integration
|
||||
from sentry_sdk import init as sentry_init
|
||||
|
||||
SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT}) # noqa: F405
|
||||
|
||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
||||
except ImportError:
|
||||
pass
|
||||
|
|
@ -1,35 +1,35 @@
|
|||
from .base import * # isort:skip
|
||||
from .base import * # noqa: F403
|
||||
from .utils import get_current_version
|
||||
|
||||
|
||||
SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl"
|
||||
|
||||
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
|
||||
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"]
|
||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
|
||||
"django.template.context_processors.debug",
|
||||
)
|
||||
|
||||
# Project settings
|
||||
VERSION = get_current_version()
|
||||
|
||||
# Third party settings
|
||||
AXES_FAILURE_LIMIT = 50
|
||||
AXES_COOLOFF_TIME = None
|
||||
|
||||
try:
|
||||
# Optionally use sentry integration
|
||||
from sentry_sdk import init as sentry_init
|
||||
|
||||
from .local import * # noqa
|
||||
|
||||
SENTRY_CONFIG.update({"release": VERSION}) # noqa: F405
|
||||
|
||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
||||
except ImportError:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -1,29 +1,43 @@
|
|||
from .dev import * # isort:skip
|
||||
from .base import * # noqa: F403
|
||||
from .utils import get_current_version
|
||||
|
||||
|
||||
SECRET_KEY = "=q(ztyo)b6noom#a164g&s9vcj1aawa^g#ing_ir99=_zl4g&$"
|
||||
DEBUG = True
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": "newsreader",
|
||||
"USER": "newsreader",
|
||||
"PASSWORD": "newsreader",
|
||||
"HOST": "db",
|
||||
INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405
|
||||
|
||||
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405
|
||||
|
||||
LOGGING["loggers"].update( # noqa: F405
|
||||
{
|
||||
"celery.task": {"handlers": ["console", "celery"], "level": "DEBUG"},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
"axes": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
}
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
# Celery
|
||||
# https://docs.celeryproject.org/en/latest/userguide/configuration.html
|
||||
CELERY_BROKER_URL = "amqp://guest:guest@rabbitmq:5672//"
|
||||
|
||||
TEMPLATES[0]["OPTIONS"]["context_processors"].append( # noqa: F405
|
||||
"django.template.context_processors.debug",
|
||||
)
|
||||
|
||||
# Project settings
|
||||
VERSION = get_current_version()
|
||||
ENVIRONMENT = "docker"
|
||||
|
||||
# Third party settings
|
||||
# Axes
|
||||
AXES_FAILURE_LIMIT = 50
|
||||
AXES_COOLOFF_TIME = None
|
||||
|
||||
try:
|
||||
# Optionally use sentry integration
|
||||
from sentry_sdk import init as sentry_init
|
||||
|
||||
from .local import * # noqa
|
||||
|
||||
SENTRY_CONFIG.update({"release": VERSION, "environment": ENVIRONMENT}) # noqa: F405
|
||||
|
||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
||||
except ImportError:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
from .base import * # isort:skip
|
||||
|
||||
|
||||
SECRET_KEY = "29%lkw+&n%^w4k#@_db2mo%*tc&xzb)x7xuq*(0$eucii%4r0c"
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
AXES_ENABLED = False
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
"axes": {
|
||||
"BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
|
||||
"LOCATION": "memcached:11211",
|
||||
},
|
||||
}
|
||||
|
|
@ -1,51 +1,32 @@
|
|||
import os
|
||||
from newsreader.conf.utils import get_env
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from .base import * # noqa: F403
|
||||
from .utils import get_current_version
|
||||
|
||||
|
||||
from .base import * # isort:skip
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DEBUG = False
|
||||
|
||||
ALLOWED_HOSTS = ["rss.fudiggity.nl"]
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
|
||||
ADMINS = [
|
||||
("", email)
|
||||
for email in os.getenv("ADMINS", "").split(",")
|
||||
if os.environ.get("ADMINS")
|
||||
("", email) for email in get_env("ADMINS", split=",", required=False, default=[])
|
||||
]
|
||||
|
||||
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"HOST": os.environ["POSTGRES_HOST"],
|
||||
"PORT": os.environ["POSTGRES_PORT"],
|
||||
"NAME": os.environ["POSTGRES_NAME"],
|
||||
"USER": os.environ["POSTGRES_USER"],
|
||||
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
|
||||
}
|
||||
}
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
# Project settings
|
||||
VERSION = get_current_version(debug=False)
|
||||
ENVIRONMENT = "production"
|
||||
|
||||
# Third party settings
|
||||
AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler"
|
||||
|
||||
REGISTRATION_OPEN = False
|
||||
# Optionally use sentry integration
|
||||
try:
|
||||
from sentry_sdk import init as sentry_init
|
||||
|
||||
SENTRY_CONFIG.update( # noqa: F405
|
||||
{"release": VERSION, "environment": ENVIRONMENT, "debug": False}
|
||||
)
|
||||
|
||||
sentry_init(**SENTRY_CONFIG) # noqa: F405
|
||||
except ImportError:
|
||||
pass
|
||||
|
|
|
|||
85
src/newsreader/conf/utils.py
Normal file
85
src/newsreader/conf/utils.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Type
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_env(
|
||||
name: str,
|
||||
cast: Type = str,
|
||||
required: bool = True,
|
||||
default: Any = None,
|
||||
split: str = "",
|
||||
) -> Any:
|
||||
if cast is not str and split:
|
||||
raise TypeError(f"Split is not possible with {cast}")
|
||||
|
||||
value = os.getenv(name)
|
||||
|
||||
if not value:
|
||||
if required:
|
||||
logger.warning(f"Missing environment variable: {name}")
|
||||
|
||||
return default
|
||||
|
||||
bool_mapping = {"yes": True, "true": True, "false": False, "no": False}
|
||||
|
||||
if cast is bool:
|
||||
_value = bool_mapping.get(value.lower())
|
||||
|
||||
if not value:
|
||||
raise ValueError(f"Unknown boolean value: {_value}")
|
||||
|
||||
return _value
|
||||
|
||||
value = value if not cast else cast(value)
|
||||
return value if not split else value.split(split)
|
||||
|
||||
|
||||
def get_current_version(debug: bool = True) -> str:
|
||||
version = get_env("VERSION", required=False)
|
||||
|
||||
if version:
|
||||
return version
|
||||
|
||||
if debug:
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["git", "show", "--no-patch", "--format=%H"], universal_newlines=True
|
||||
)
|
||||
return output.strip()
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return ""
|
||||
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["git", "describe", "--tags"], universal_newlines=True
|
||||
)
|
||||
return output.strip()
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return ""
|
||||
|
||||
|
||||
ROOT_MARKERS = ("pyproject.toml", "package.json", "README.md", "CHANGELOG.md")
|
||||
|
||||
|
||||
def get_root_dir() -> Path:
|
||||
file = Path(__file__)
|
||||
return _traverse_dirs(file.parent, ROOT_MARKERS)
|
||||
|
||||
|
||||
def _traverse_dirs(path: Path, root_markers: Iterable[str]) -> Path:
|
||||
if path.parent == path:
|
||||
raise OSError("Root directory detected")
|
||||
|
||||
files = (file.name for file in path.iterdir())
|
||||
|
||||
if not any((marker for marker in root_markers if marker in files)):
|
||||
return _traverse_dirs(path.parent, root_markers)
|
||||
|
||||
return path
|
||||
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
|||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
name = "core"
|
||||
name = "newsreader.core"
|
||||
|
|
|
|||
9
src/newsreader/core/forms.py
Normal file
9
src/newsreader/core/forms.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from django import forms
|
||||
|
||||
|
||||
class CheckboxInput(forms.CheckboxInput):
|
||||
template_name = "components/form/checkbox.html"
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
context = super().get_context(name, value, attrs)
|
||||
return {**context, **attrs}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework import pagination
|
||||
|
||||
|
||||
class ResultSetPagination(PageNumberPagination):
|
||||
class ResultSetPagination(pagination.PageNumberPagination):
|
||||
page_size_query_param = "count"
|
||||
max_page_size = 50
|
||||
page_size = 30
|
||||
|
|
@ -10,3 +10,9 @@ class ResultSetPagination(PageNumberPagination):
|
|||
class LargeResultSetPagination(ResultSetPagination):
|
||||
max_page_size = 100
|
||||
page_size = 50
|
||||
|
||||
|
||||
class CursorPagination(pagination.CursorPagination):
|
||||
page_size_query_param = "count"
|
||||
ordering = "-publication_date"
|
||||
page_size = 30
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1066
src/newsreader/fixtures/fixture.json
Normal file
1066
src/newsreader/fixtures/fixture.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -47,7 +47,7 @@
|
|||
"user" : 2,
|
||||
"succeeded" : true,
|
||||
"modified" : "2019-07-20T11:28:16.473Z",
|
||||
"last_suceeded" : "2019-07-20T11:28:16.316Z",
|
||||
"last_run" : "2019-07-20T11:28:16.316Z",
|
||||
"name" : "Hackers News",
|
||||
"website_url" : null,
|
||||
"created" : "2019-07-14T13:08:10.374Z",
|
||||
|
|
@ -65,7 +65,7 @@
|
|||
"error" : null,
|
||||
"user" : 2,
|
||||
"succeeded" : true,
|
||||
"last_suceeded" : "2019-07-20T11:28:15.691Z",
|
||||
"last_run" : "2019-07-20T11:28:15.691Z",
|
||||
"name" : "BBC",
|
||||
"modified" : "2019-07-20T12:07:49.164Z",
|
||||
"timezone" : "UTC",
|
||||
|
|
@ -85,7 +85,7 @@
|
|||
"website_url" : null,
|
||||
"name" : "Ars Technica",
|
||||
"succeeded" : true,
|
||||
"last_suceeded" : "2019-07-20T11:28:15.986Z",
|
||||
"last_run" : "2019-07-20T11:28:15.986Z",
|
||||
"modified" : "2019-07-20T11:28:16.033Z",
|
||||
"user" : 2
|
||||
},
|
||||
|
|
@ -102,7 +102,7 @@
|
|||
"user" : 2,
|
||||
"name" : "The Guardian",
|
||||
"succeeded" : true,
|
||||
"last_suceeded" : "2019-07-20T11:28:16.078Z",
|
||||
"last_run" : "2019-07-20T11:28:16.078Z",
|
||||
"modified" : "2019-07-20T12:07:44.292Z",
|
||||
"created" : "2019-07-20T11:25:02.089Z",
|
||||
"website_url" : null,
|
||||
|
|
@ -119,7 +119,7 @@
|
|||
"website_url" : null,
|
||||
"created" : "2019-07-20T11:25:30.121Z",
|
||||
"user" : 2,
|
||||
"last_suceeded" : "2019-07-20T11:28:15.860Z",
|
||||
"last_run" : "2019-07-20T11:28:15.860Z",
|
||||
"succeeded" : true,
|
||||
"modified" : "2019-07-20T12:07:28.473Z",
|
||||
"name" : "Tweakers"
|
||||
|
|
@ -139,7 +139,7 @@
|
|||
"website_url" : null,
|
||||
"timezone" : "UTC",
|
||||
"user" : 2,
|
||||
"last_suceeded" : "2019-07-20T11:28:16.034Z",
|
||||
"last_run" : "2019-07-20T11:28:16.034Z",
|
||||
"succeeded" : true,
|
||||
"modified" : "2019-07-20T12:07:21.704Z",
|
||||
"name" : "The Verge"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
|
||||
const Card = props => {
|
||||
return (
|
||||
<div className="card">
|
||||
<div id={`${props.id}`} className="card">
|
||||
<div className="card__header">{props.header}</div>
|
||||
<div className="card__content">{props.content}</div>
|
||||
<div className="card__footer">{props.footer}</div>
|
||||
|
|
|
|||
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