This commit is contained in:
Gabriel Augendre 2021-12-28 22:31:53 +01:00
parent 8b32e2e9a4
commit 40a4a0a308
19 changed files with 336 additions and 83 deletions

View file

@ -1,7 +1,7 @@
exclude: (\.min\.(js|css)(\.map)?$|/vendor/) exclude: (\.min\.(js|css)(\.map)?$|/vendor/)
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1 rev: v4.1.0
hooks: hooks:
- id: check-ast - id: check-ast
- id: check-json - id: check-json
@ -29,7 +29,7 @@ repos:
hooks: hooks:
- id: black - id: black
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v2.29.1 rev: v2.30.0
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: args:
@ -40,7 +40,7 @@ repos:
- id: django-upgrade - id: django-upgrade
args: [--target-version, "4.0"] args: [--target-version, "4.0"]
- repo: https://github.com/rtts/djhtml - repo: https://github.com/rtts/djhtml
rev: v1.4.10 rev: v1.4.11
hooks: hooks:
- id: djhtml - id: djhtml
- repo: https://github.com/pycqa/flake8 - repo: https://github.com/pycqa/flake8
@ -60,7 +60,7 @@ repos:
- id: prettier - id: prettier
types_or: [javascript, css] types_or: [javascript, css]
- repo: https://github.com/pre-commit/mirrors-eslint - repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.4.1 rev: v8.5.0
hooks: hooks:
- id: eslint - id: eslint
args: [--fix] args: [--fix]

198
poetry.lock generated
View file

@ -253,6 +253,35 @@ Django = ">=2.2"
phonenumbers = ["phonenumbers (>=7.0.2)"] phonenumbers = ["phonenumbers (>=7.0.2)"]
phonenumberslite = ["phonenumberslite (>=7.0.2)"] phonenumberslite = ["phonenumberslite (>=7.0.2)"]
[[package]]
name = "django-stubs"
version = "1.9.0"
description = "Mypy stubs for Django"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
django = "*"
django-stubs-ext = ">=0.3.0"
mypy = ">=0.910"
toml = "*"
types-pytz = "*"
types-PyYAML = "*"
typing-extensions = "*"
[[package]]
name = "django-stubs-ext"
version = "0.3.1"
description = "Monkey-patching and extensions for django-stubs"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
django = "*"
typing-extensions = "*"
[[package]] [[package]]
name = "django-two-factor-auth" name = "django-two-factor-auth"
version = "1.13" version = "1.13"
@ -396,6 +425,31 @@ category = "dev"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
[[package]]
name = "mypy"
version = "0.930"
description = "Optional static typing for Python"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
mypy-extensions = ">=0.4.3"
tomli = ">=1.1.0"
typing-extensions = ">=3.10"
[package.extras]
dmypy = ["psutil (>=4.0)"]
python2 = ["typed-ast (>=1.4.0,<2)"]
[[package]]
name = "mypy-extensions"
version = "0.4.3"
description = "Experimental type system extensions for programs checked with the mypy typechecker."
category = "dev"
optional = false
python-versions = "*"
[[package]] [[package]]
name = "nodeenv" name = "nodeenv"
version = "1.6.0" version = "1.6.0"
@ -758,6 +812,78 @@ category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
[[package]]
name = "types-beautifulsoup4"
version = "4.10.7"
description = "Typing stubs for beautifulsoup4"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "types-markdown"
version = "3.3.10"
description = "Typing stubs for Markdown"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "types-pillow"
version = "8.3.11"
description = "Typing stubs for Pillow"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "types-pytz"
version = "2021.3.3"
description = "Typing stubs for pytz"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "types-pyyaml"
version = "6.0.1"
description = "Typing stubs for PyYAML"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "types-requests"
version = "2.26.3"
description = "Typing stubs for requests"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "types-setuptools"
version = "57.4.5"
description = "Typing stubs for setuptools"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "types-toml"
version = "0.10.1"
description = "Typing stubs for toml"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "typing-extensions"
version = "4.0.1"
description = "Backported and Experimental Type Hints for Python 3.6+"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]] [[package]]
name = "tzdata" name = "tzdata"
version = "2021.5" version = "2021.5"
@ -849,7 +975,7 @@ multidict = ">=4.0"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "177905663c5207bbaed9cc8a093d99b96e932297e9901d8f7b7985583583fe14" content-hash = "35d359b39bfd7c907a1119797a4badf33b3f3c964a5ec90e85c781cf99fecf7e"
[metadata.files] [metadata.files]
asgiref = [ asgiref = [
@ -1015,6 +1141,14 @@ django-phonenumber-field = [
{file = "django-phonenumber-field-5.2.0.tar.gz", hash = "sha256:52b2e5970133ec5ab701218b802f7ab237229854dc95fd239b7e9e77dc43731d"}, {file = "django-phonenumber-field-5.2.0.tar.gz", hash = "sha256:52b2e5970133ec5ab701218b802f7ab237229854dc95fd239b7e9e77dc43731d"},
{file = "django_phonenumber_field-5.2.0-py3-none-any.whl", hash = "sha256:5547fb2b2cc690a306ba77a5038419afc8fa8298a486fb7895008e9067cc7e75"}, {file = "django_phonenumber_field-5.2.0-py3-none-any.whl", hash = "sha256:5547fb2b2cc690a306ba77a5038419afc8fa8298a486fb7895008e9067cc7e75"},
] ]
django-stubs = [
{file = "django-stubs-1.9.0.tar.gz", hash = "sha256:664843091636a917faf5256d028476559dc360fdef9050b6df87ab61b21607bf"},
{file = "django_stubs-1.9.0-py3-none-any.whl", hash = "sha256:59c9f81af64d214b1954eaf90f037778c8d2b9c2de946a3cda177fefcf588fbd"},
]
django-stubs-ext = [
{file = "django-stubs-ext-0.3.1.tar.gz", hash = "sha256:783c198d7e39a41be0b90fd843fa2770243a642922af679be4b19e03b82c8c28"},
{file = "django_stubs_ext-0.3.1-py3-none-any.whl", hash = "sha256:a51a3e9e844d4e1cacaaedbb33bf3def78a3956eed5d9575a640bd97ccd99cec"},
]
django-two-factor-auth = [] django-two-factor-auth = []
filelock = [ filelock = [
{file = "filelock-3.4.2-py3-none-any.whl", hash = "sha256:cf0fc6a2f8d26bd900f19bf33915ca70ba4dd8c56903eeb14e1e7a2fd7590146"}, {file = "filelock-3.4.2-py3-none-any.whl", hash = "sha256:cf0fc6a2f8d26bd900f19bf33915ca70ba4dd8c56903eeb14e1e7a2fd7590146"},
@ -1189,6 +1323,32 @@ multidict = [
{file = "multidict-5.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:c9631c642e08b9fff1c6255487e62971d8b8e821808ddd013d8ac058087591ac"}, {file = "multidict-5.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:c9631c642e08b9fff1c6255487e62971d8b8e821808ddd013d8ac058087591ac"},
{file = "multidict-5.2.0.tar.gz", hash = "sha256:0dd1c93edb444b33ba2274b66f63def8a327d607c6c790772f448a53b6ea59ce"}, {file = "multidict-5.2.0.tar.gz", hash = "sha256:0dd1c93edb444b33ba2274b66f63def8a327d607c6c790772f448a53b6ea59ce"},
] ]
mypy = [
{file = "mypy-0.930-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:221cc94dc6a801ccc2be7c0c9fd791c5e08d1fa2c5e1c12dec4eab15b2469871"},
{file = "mypy-0.930-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:db3a87376a1380f396d465bed462e76ea89f838f4c5e967d68ff6ee34b785c31"},
{file = "mypy-0.930-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1d2296f35aae9802eeb1327058b550371ee382d71374b3e7d2804035ef0b830b"},
{file = "mypy-0.930-cp310-cp310-win_amd64.whl", hash = "sha256:959319b9a3cafc33a8185f440a433ba520239c72e733bf91f9efd67b0a8e9b30"},
{file = "mypy-0.930-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:45a4dc21c789cfd09b8ccafe114d6de66f0b341ad761338de717192f19397a8c"},
{file = "mypy-0.930-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1e689e92cdebd87607a041585f1dc7339aa2e8a9f9bad9ba7e6ece619431b20c"},
{file = "mypy-0.930-cp36-cp36m-win_amd64.whl", hash = "sha256:ed4e0ea066bb12f56b2812a15ff223c57c0a44eca817ceb96b214bb055c7051f"},
{file = "mypy-0.930-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a9d8dffefba634b27d650e0de2564379a1a367e2e08d6617d8f89261a3bf63b2"},
{file = "mypy-0.930-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b419e9721260161e70d054a15abbd50603c16f159860cfd0daeab647d828fc29"},
{file = "mypy-0.930-cp37-cp37m-win_amd64.whl", hash = "sha256:601f46593f627f8a9b944f74fd387c9b5f4266b39abad77471947069c2fc7651"},
{file = "mypy-0.930-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1ea7199780c1d7940b82dbc0a4e37722b4e3851264dbba81e01abecc9052d8a7"},
{file = "mypy-0.930-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:70b197dd8c78fc5d2daf84bd093e8466a2b2e007eedaa85e792e513a820adbf7"},
{file = "mypy-0.930-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5feb56f8bb280468fe5fc8e6f56f48f99aa0df9eed3c507a11505ee4657b5380"},
{file = "mypy-0.930-cp38-cp38-win_amd64.whl", hash = "sha256:2e9c5409e9cb81049bb03fa1009b573dea87976713e3898561567a86c4eaee01"},
{file = "mypy-0.930-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:554873e45c1ca20f31ddf873deb67fa5d2e87b76b97db50669f0468ccded8fae"},
{file = "mypy-0.930-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0feb82e9fa849affca7edd24713dbe809dce780ced9f3feca5ed3d80e40b777f"},
{file = "mypy-0.930-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bc1a0607ea03c30225347334af66b0af12eefba018a89a88c209e02b7065ea95"},
{file = "mypy-0.930-cp39-cp39-win_amd64.whl", hash = "sha256:f9f665d69034b1fcfdbcd4197480d26298bbfb5d2dfe206245b6498addb34999"},
{file = "mypy-0.930-py3-none-any.whl", hash = "sha256:bf4a44e03040206f7c058d1f5ba02ef2d1820720c88bc4285c7d9a4269f54173"},
{file = "mypy-0.930.tar.gz", hash = "sha256:51426262ae4714cc7dd5439814676e0992b55bcc0f6514eccb4cf8e0678962c2"},
]
mypy-extensions = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
]
nodeenv = [ nodeenv = [
{file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"},
{file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"},
@ -1406,6 +1566,42 @@ tomli = [
{file = "tomli-2.0.0-py3-none-any.whl", hash = "sha256:b5bde28da1fed24b9bd1d4d2b8cba62300bfb4ec9a6187a957e8ddb9434c5224"}, {file = "tomli-2.0.0-py3-none-any.whl", hash = "sha256:b5bde28da1fed24b9bd1d4d2b8cba62300bfb4ec9a6187a957e8ddb9434c5224"},
{file = "tomli-2.0.0.tar.gz", hash = "sha256:c292c34f58502a1eb2bbb9f5bbc9a5ebc37bee10ffb8c2d6bbdfa8eb13cc14e1"}, {file = "tomli-2.0.0.tar.gz", hash = "sha256:c292c34f58502a1eb2bbb9f5bbc9a5ebc37bee10ffb8c2d6bbdfa8eb13cc14e1"},
] ]
types-beautifulsoup4 = [
{file = "types-beautifulsoup4-4.10.7.tar.gz", hash = "sha256:8a267194e405b7163bde055ef1120988c0221e5d58e1c5bfe08d601a21e63daf"},
{file = "types_beautifulsoup4-4.10.7-py3-none-any.whl", hash = "sha256:b450b70935b901f500125662ca1856fe6a3ba73ccc88b4fcd577a2b927cc8c14"},
]
types-markdown = [
{file = "types-Markdown-3.3.10.tar.gz", hash = "sha256:d4004e36a33cd3417cd299324232fc28e9915171a7ce46b43bf5b0c579db459d"},
{file = "types_Markdown-3.3.10-py3-none-any.whl", hash = "sha256:0e3153dc4ad3454326465e4e4e21fd90e8fc74966225ec5aeef15895a2c5d94a"},
]
types-pillow = [
{file = "types-Pillow-8.3.11.tar.gz", hash = "sha256:aa96a739184f48f69e6f30218400623fc5a95f5fec199c447663a32538440405"},
{file = "types_Pillow-8.3.11-py3-none-any.whl", hash = "sha256:998189334e616b1dd42c9634669efbf726184039e96e9a23ec95246e0ecff3fc"},
]
types-pytz = [
{file = "types-pytz-2021.3.3.tar.gz", hash = "sha256:f6d21d6687935a1615db464b1e1df800d19502c36bc0486f43be7dfd2c404947"},
{file = "types_pytz-2021.3.3-py3-none-any.whl", hash = "sha256:75859c64c9a97d68259af6da208e8f5aaf4be4536e4d431a82a6e8b848fc183d"},
]
types-pyyaml = [
{file = "types-PyYAML-6.0.1.tar.gz", hash = "sha256:2e27b0118ca4248a646101c5c318dc02e4ca2866d6bc42e84045dbb851555a76"},
{file = "types_PyYAML-6.0.1-py3-none-any.whl", hash = "sha256:d5b318269652e809b5c30a5fe666c50159ab80bfd41cd6bafe655bf20b29fcba"},
]
types-requests = [
{file = "types-requests-2.26.3.tar.gz", hash = "sha256:d63fa617846dcefff5aa2d59e47ab4ffd806e4bb0567115f7adbb5e438302fe4"},
{file = "types_requests-2.26.3-py3-none-any.whl", hash = "sha256:ad18284931c5ddbf050ccdd138f200d18fd56f88aa3567019d8da9b2d4fe0344"},
]
types-setuptools = [
{file = "types-setuptools-57.4.5.tar.gz", hash = "sha256:a4600efdca68a33204ad9c083fd9966d63aee61a7d007e912b6afc6ff57d6e02"},
{file = "types_setuptools-57.4.5-py3-none-any.whl", hash = "sha256:920a7c1ee120025e939a1707f8fd09a1266edbf7848eae7b8de7c5909a824cc8"},
]
types-toml = [
{file = "types-toml-0.10.1.tar.gz", hash = "sha256:5c1f8f8d57692397c8f902bf6b4d913a0952235db7db17d2908cc110e70610cb"},
{file = "types_toml-0.10.1-py3-none-any.whl", hash = "sha256:8cdfd2b7c89bed703158b042dd5cf04255dae77096db66f4a12ca0a93ccb07a5"},
]
typing-extensions = [
{file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"},
{file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"},
]
tzdata = [ tzdata = [
{file = "tzdata-2021.5-py2.py3-none-any.whl", hash = "sha256:3eee491e22ebfe1e5cfcc97a4137cd70f092ce59144d81f8924a844de05ba8f5"}, {file = "tzdata-2021.5-py2.py3-none-any.whl", hash = "sha256:3eee491e22ebfe1e5cfcc97a4137cd70f092ce59144d81f8924a844de05ba8f5"},
{file = "tzdata-2021.5.tar.gz", hash = "sha256:68dbe41afd01b867894bbdfd54fa03f468cfa4f0086bfb4adcd8de8f24f3ee21"}, {file = "tzdata-2021.5.tar.gz", hash = "sha256:68dbe41afd01b867894bbdfd54fa03f468cfa4f0086bfb4adcd8de8f24f3ee21"},

View file

@ -36,6 +36,14 @@ pytest-rerunfailures = "^10.2"
pytest-env = "^0.6.2" pytest-env = "^0.6.2"
poetry-deps-scanner = "^1.0.1" poetry-deps-scanner = "^1.0.1"
invoke = "^1.6.0" invoke = "^1.6.0"
mypy = "^0.930"
django-stubs = "^1.9.0"
types-Markdown = "^3.3.10"
types-requests = "^2.26.3"
types-setuptools = "^57.4.5"
types-toml = "^0.10.1"
types-beautifulsoup4 = "^4.10.7"
types-Pillow = "^8.3.11"
[tool.black] [tool.black]
target-version = ['py38'] target-version = ['py38']
@ -54,6 +62,25 @@ env = [
"GOATCOUNTER_DOMAIN=gc.gabnotes.org" "GOATCOUNTER_DOMAIN=gc.gabnotes.org"
] ]
[tool.mypy]
plugins = ["mypy_django_plugin.main"]
mypy_path = "$MYPY_CONFIG_FILE_DIR/stubs:$MYPY_CONFIG_FILE_DIR/src"
exclude = "/migrations/"
[[tool.mypy.overrides]]
module = [
"environ",
"django_otp.plugins.otp_static.models",
"two_factor.models",
"django_otp.plugins.otp_totp.models",
"model_bakery"
]
ignore_missing_imports = true
[tool.django-stubs]
django_settings_module = "blog.settings"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"

View file

@ -78,6 +78,7 @@ class ArticleAdmin(admin.ModelAdmin):
queryset = queryset.prefetch_related("tags") queryset = queryset.prefetch_related("tags")
return queryset return queryset
@admin.action(description="Publish selected articles")
def publish(self, request, queryset): def publish(self, request, queryset):
if not request.user.has_perm("articles.change_article"): if not request.user.has_perm("articles.change_article"):
messages.warning(request, "You're not allowed to do this.") messages.warning(request, "You're not allowed to do this.")
@ -86,8 +87,7 @@ class ArticleAdmin(admin.ModelAdmin):
article.publish() article.publish()
messages.success(request, f"{len(queryset)} articles published.") messages.success(request, f"{len(queryset)} articles published.")
publish.short_description = "Publish selected articles" @admin.action(description="Unpublish selected articles")
def unpublish(self, request, queryset): def unpublish(self, request, queryset):
if not request.user.has_perm("articles.change_article"): if not request.user.has_perm("articles.change_article"):
messages.warning(request, "You're not allowed to do this.") messages.warning(request, "You're not allowed to do this.")
@ -96,8 +96,7 @@ class ArticleAdmin(admin.ModelAdmin):
article.unpublish() article.unpublish()
messages.success(request, f"{len(queryset)} articles unpublished.") messages.success(request, f"{len(queryset)} articles unpublished.")
unpublish.short_description = "Unpublish selected articles" @admin.action(description="Refresh draft key of selected articles")
def refresh_draft_key(self, request, queryset): def refresh_draft_key(self, request, queryset):
if not request.user.has_perm("articles.change_article"): if not request.user.has_perm("articles.change_article"):
messages.warning(request, "You're not allowed to do this.") messages.warning(request, "You're not allowed to do this.")
@ -106,7 +105,6 @@ class ArticleAdmin(admin.ModelAdmin):
article.refresh_draft_key() article.refresh_draft_key()
messages.success(request, f"{len(queryset)} draft keys refreshed.") messages.success(request, f"{len(queryset)} draft keys refreshed.")
refresh_draft_key.short_description = "Refresh draft key of selected articles"
actions = [publish, unpublish, refresh_draft_key] actions = [publish, unpublish, refresh_draft_key]
class Media: class Media:
@ -134,11 +132,10 @@ class ArticleAdmin(admin.ModelAdmin):
def read_time(self, instance: Article): def read_time(self, instance: Article):
return f"{instance.get_read_time()} min" return f"{instance.get_read_time()} min"
@admin.display(boolean=True)
def has_custom_css(self, instance: Article): def has_custom_css(self, instance: Article):
return bool(instance.custom_css) return bool(instance.custom_css)
has_custom_css.boolean = True
@register(Tag) @register(Tag)
class TagAdmin(admin.ModelAdmin): class TagAdmin(admin.ModelAdmin):

View file

@ -1,4 +1,7 @@
from typing import Any
from django.conf import settings from django.conf import settings
from django.http import HttpRequest
from articles.models import Article from articles.models import Article
from attachments.models import Attachment from attachments.models import Attachment
@ -8,7 +11,7 @@ IGNORED_PATHS = [
] ]
def drafts_count(request): def drafts_count(request: HttpRequest) -> dict[str, Any]:
if request.path in IGNORED_PATHS: if request.path in IGNORED_PATHS:
return {} return {}
if not request.user.is_authenticated: if not request.user.is_authenticated:
@ -16,13 +19,13 @@ def drafts_count(request):
return {"drafts_count": Article.objects.filter(status=Article.DRAFT).count()} return {"drafts_count": Article.objects.filter(status=Article.DRAFT).count()}
def date_format(request): def date_format(request: HttpRequest) -> dict[str, Any]:
if request.path in IGNORED_PATHS: if request.path in IGNORED_PATHS:
return {} return {}
return {"CUSTOM_ISO": r"Y-m-d\TH:i:sO", "ISO_DATE": "Y-m-d"} return {"CUSTOM_ISO": r"Y-m-d\TH:i:sO", "ISO_DATE": "Y-m-d"}
def git_version(request): def git_version(request: HttpRequest) -> dict[str, Any]:
if request.path in IGNORED_PATHS: if request.path in IGNORED_PATHS:
return {} return {}
try: try:
@ -36,13 +39,13 @@ def git_version(request):
return {"git_version": version, "git_version_url": url} return {"git_version": version, "git_version_url": url}
def analytics(request): def analytics(request: HttpRequest) -> dict[str, Any]:
return { return {
"goatcounter_domain": settings.GOATCOUNTER_DOMAIN, "goatcounter_domain": settings.GOATCOUNTER_DOMAIN,
} }
def open_graph_image_url(request): def open_graph_image_url(request: HttpRequest) -> dict[str, Any]:
if request.path in IGNORED_PATHS: if request.path in IGNORED_PATHS:
return {} return {}
open_graph_image = Attachment.objects.get_open_graph_image() open_graph_image = Attachment.objects.get_open_graph_image()
@ -52,11 +55,11 @@ def open_graph_image_url(request):
return {"open_graph_image_url": url} return {"open_graph_image_url": url}
def blog_metadata(request): def blog_metadata(request: HttpRequest) -> dict[str, Any]:
context = {} return {
context["blog_title"] = settings.BLOG["title"] "blog_title": settings.BLOG["title"],
context["blog_description"] = settings.BLOG["description"] "blog_description": settings.BLOG["description"],
context["blog_author"] = settings.BLOG["author"] "blog_author": settings.BLOG["author"],
context["blog_repo_homepage"] = settings.BLOG["repo"]["homepage"] "blog_repo_homepage": settings.BLOG["repo"]["homepage"],
context["blog_status_url"] = settings.BLOG["status_url"] "blog_status_url": settings.BLOG["status_url"],
return context }

View file

@ -1,6 +1,9 @@
from __future__ import annotations
import random import random
import uuid import uuid
from functools import cached_property from functools import cached_property
from typing import Sequence
import rcssmin import rcssmin
import readtime import readtime
@ -32,16 +35,16 @@ class Tag(models.Model):
class Meta: class Meta:
ordering = ["name"] ordering = ["name"]
def __str__(self): def __str__(self) -> str:
return self.name return self.name
def get_absolute_url(self): def get_absolute_url(self) -> str:
return reverse("tag", kwargs={"slug": self.slug}) return reverse("tag", kwargs={"slug": self.slug})
def get_feed_title(self): def get_feed_title(self) -> str:
return f"{self.name} - {settings.BLOG['title']}" return f"{self.name} - {settings.BLOG['title']}"
def get_feed_url(self): def get_feed_url(self) -> str:
return reverse("tag-feed", kwargs={"slug": self.slug}) return reverse("tag-feed", kwargs={"slug": self.slug})
@ -75,65 +78,65 @@ class Article(models.Model):
class Meta: class Meta:
ordering = ["-published_at"] ordering = ["-published_at"]
def __str__(self): def __str__(self) -> str:
return self.title return self.title
def get_absolute_url(self): def get_absolute_url(self) -> str:
return reverse("article-detail", kwargs={"slug": self.slug}) return reverse("article-detail", kwargs={"slug": self.slug})
def get_mailto_url(self): def get_mailto_url(self) -> str:
email = settings.BLOG["email"] email = settings.BLOG["email"]
return f"mailto:{email}?subject={self.title}" return f"mailto:{email}?subject={self.title}"
def get_abstract(self): def get_abstract(self) -> str:
html = self.get_formatted_content html = self.get_formatted_content
return html.split("<!--more-->")[0] return html.split("<!--more-->")[0]
@cached_property @cached_property
def get_description(self): def get_description(self) -> str:
html = self.get_formatted_content html = self.get_formatted_content
text = find_first_paragraph_with_text(html) text = find_first_paragraph_with_text(html)
return truncate_words_after_char_count(text, 160) return truncate_words_after_char_count(text, 160)
@cached_property @cached_property
def get_formatted_content(self): def get_formatted_content(self) -> str:
return format_article_content(self.content) return format_article_content(self.content)
def publish(self): def publish(self) -> Article:
if not self.published_at: if not self.published_at:
self.published_at = timezone.now() self.published_at = timezone.now()
self.status = self.PUBLISHED self.status = self.PUBLISHED
self.save() self.save()
return self return self
def unpublish(self): def unpublish(self) -> Article:
self.published_at = None self.published_at = None
self.status = self.DRAFT self.status = self.DRAFT
self.save() self.save()
return self return self
def save(self, *args, **kwargs): def save(self, *args, **kwargs) -> None:
if not self.slug: if not self.slug:
self.slug = slugify(self.title) self.slug = slugify(self.title)
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
@property @property
def draft_public_url(self): def draft_public_url(self) -> str:
url = self.get_absolute_url() + f"?draft_key={self.draft_key}" url = self.get_absolute_url() + f"?draft_key={self.draft_key}"
return build_full_absolute_url(request=None, url=url) return build_full_absolute_url(request=None, url=url)
def refresh_draft_key(self): def refresh_draft_key(self) -> None:
self.draft_key = uuid.uuid4() self.draft_key = uuid.uuid4()
self.save() self.save()
def get_read_time(self): def get_read_time(self) -> int:
content = self.get_formatted_content content = self.get_formatted_content
if content: if content:
return readtime.of_html(content).minutes return readtime.of_html(content).minutes
return 0 return 0
@cached_property @cached_property
def get_related_articles(self): def get_related_articles(self) -> Sequence[Article]:
related_articles = set() related_articles = set()
published_articles = Article.objects.filter(status=Article.PUBLISHED).exclude( published_articles = Article.objects.filter(status=Article.PUBLISHED).exclude(
pk=self.pk pk=self.pk
@ -141,19 +144,19 @@ class Article(models.Model):
for tag in self.tags.all().prefetch_related( for tag in self.tags.all().prefetch_related(
Prefetch("articles", published_articles, to_attr="published_articles") Prefetch("articles", published_articles, to_attr="published_articles")
): ):
related_articles.update(tag.published_articles) related_articles.update(tag.published_articles) # type: ignore
sample_size = min([len(related_articles), 3]) sample_size = min([len(related_articles), 3])
return random.sample(list(related_articles), sample_size) return random.sample(list(related_articles), sample_size)
@cached_property @cached_property
def keywords(self): def keywords(self) -> str:
return ", ".join(map(lambda tag: tag.name, self.tags.all())) return ", ".join(map(lambda tag: tag.name, self.tags.all()))
@cached_property @cached_property
def get_minified_custom_css(self): def get_minified_custom_css(self) -> str:
return rcssmin.cssmin(self.custom_css) return rcssmin.cssmin(self.custom_css)
def get_admin_url(self): def get_admin_url(self) -> str:
content_type = ContentType.objects.get_for_model(self.__class__) content_type = ContentType.objects.get_for_model(self.__class__)
return reverse( return reverse(
f"admin:{content_type.app_label}_{content_type.model}_change", f"admin:{content_type.app_label}_{content_type.model}_change",

View file

@ -25,11 +25,11 @@ def test_render_article_same_content(published_article: Article, client: Client)
assert api_res.status_code == 200 assert api_res.status_code == 200
assert standard_res.status_code == 200 assert standard_res.status_code == 200
# ignore an expected difference # ignore an expected difference
api_content = api_res.content.decode("utf-8") # type: str api_content: str = api_res.content.decode("utf-8")
standard_content = standard_res.content.decode("utf-8") standard_content: str = standard_res.content.decode("utf-8")
api_content = api_content.replace( api_content = api_content.replace(
"?next=/api/render/1/", "/api/render/1/",
"?next=/some-article-slug/", "/some-article-slug/",
) )
assert api_content == standard_content assert api_content == standard_content
@ -41,7 +41,7 @@ def test_render_article_change_content(published_article: Article, client: Clien
preview_content = "This is a different content **with strong emphasis**" preview_content = "This is a different content **with strong emphasis**"
api_res = post_article(client, published_article, preview_content) api_res = post_article(client, published_article, preview_content)
assert api_res.status_code == 200 assert api_res.status_code == 200
api_content = api_res.content.decode("utf-8") # type: str api_content: str = api_res.content.decode("utf-8")
html_preview_content = format_article_content(preview_content) html_preview_content = format_article_content(preview_content)
assert html_preview_content in api_content assert html_preview_content in api_content

View file

@ -26,7 +26,7 @@ def test_only_title_shown_on_list(client: Client, author: User):
status=Article.PUBLISHED, status=Article.PUBLISHED,
author=author, author=author,
content=f"{abstract}\n<!--more-->\n{after}", content=f"{abstract}\n<!--more-->\n{after}",
) # type: Article )
res = client.get(reverse("articles-list")) res = client.get(reverse("articles-list"))
content = res.content.decode("utf-8") content = res.content.decode("utf-8")
assert title in content assert title in content

View file

@ -9,14 +9,14 @@ from markdown.extensions.toc import TocExtension
from articles.markdown import LazyLoadingImageExtension from articles.markdown import LazyLoadingImageExtension
def build_full_absolute_url(request, url): def build_full_absolute_url(request, url: str) -> str:
if request: if request:
return request.build_absolute_uri(url) return request.build_absolute_uri(url)
else: else:
return (settings.BLOG["base_url"] + url)[::-1].replace("//", "/", 1)[::-1] return (settings.BLOG["base_url"] + url)[::-1].replace("//", "/", 1)[::-1]
def format_article_content(content): def format_article_content(content: str) -> str:
md = markdown.Markdown( md = markdown.Markdown(
extensions=[ extensions=[
"extra", "extra",
@ -30,7 +30,7 @@ def format_article_content(content):
return md.convert(content) return md.convert(content)
def truncate_words_after_char_count(text, char_count): def truncate_words_after_char_count(text: str, char_count: int) -> str:
total_length = 0 total_length = 0
text_result = [] text_result = []
for word in text.split(): for word in text.split():
@ -41,14 +41,10 @@ def truncate_words_after_char_count(text, char_count):
return " ".join(text_result) + "..." return " ".join(text_result) + "..."
def find_first_paragraph_with_text(html): def find_first_paragraph_with_text(html: str) -> str:
bs = BeautifulSoup(html, "html.parser") bs = BeautifulSoup(html, "html.parser")
paragraph = bs.find("p", recursive=False) paragraphs = bs.find_all("p", recursive=False)
text = paragraph.text.strip() for paragraph in paragraphs:
while not text: if paragraph.text.strip():
try: return paragraph.text
paragraph = paragraph.next_sibling return ""
text = paragraph.text.strip()
except Exception:
break
return text

View file

@ -1,5 +1,7 @@
from typing import Any
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
@ -8,7 +10,7 @@ from articles.models import Article, Tag
@login_required @login_required
@require_POST @require_POST
def render_article(request, article_pk): def render_article(request: HttpRequest, article_pk: int) -> HttpResponse:
template = "articles/article_detail.html" template = "articles/article_detail.html"
article = Article.objects.get(pk=article_pk) article = Article.objects.get(pk=article_pk)
article.content = request.POST.get("content", article.content) article.content = request.POST.get("content", article.content)
@ -17,10 +19,9 @@ def render_article(request, article_pk):
has_code = request.POST.get("has_code") has_code = request.POST.get("has_code")
if has_code is not None: if has_code is not None:
article.has_code = has_code == "true" article.has_code = has_code == "true"
context = {"article": article} context: dict[str, Any] = {"article": article}
tags = request.POST.get("tag_ids") tags = request.POST.get("tag_ids")
if tags: if tags:
tags = Tag.objects.filter(pk__in=map(int, tags.split(","))) context["tags"] = Tag.objects.filter(pk__in=map(int, tags.split(",")))
context["tags"] = tags
html = render(request, template, context=context) html = render(request, template, context=context)
return HttpResponse(html) return HttpResponse(html)

View file

@ -18,7 +18,7 @@ class CompleteFeed(Feed):
def items(self, obj): def items(self, obj):
return self.get_queryset(obj)[: self.FEED_LIMIT] return self.get_queryset(obj)[: self.FEED_LIMIT]
def item_description(self, item: Article): def item_description(self, item: Article): # type: ignore[override]
return item.get_formatted_content return item.get_formatted_content
def item_pubdate(self, item: Article): def item_pubdate(self, item: Article):

View file

@ -50,9 +50,9 @@ class PublicArticleListView(BaseArticleListView):
class ArticlesListView(PublicArticleListView): class ArticlesListView(PublicArticleListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
home_article = Article.objects.filter( home_article: Article = Article.objects.filter(
status=Article.PUBLISHED, is_home=True status=Article.PUBLISHED, is_home=True
).first() # type: Article ).first()
context["article"] = home_article context["article"] = home_article
return context return context
@ -92,8 +92,8 @@ class SearchArticlesListView(PublicArticleListView):
class TagArticlesListView(PublicArticleListView): class TagArticlesListView(PublicArticleListView):
tag = None tag = None
main_title = None main_title = ""
html_title = None html_title = ""
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.tag = get_object_or_404(Tag, slug=self.kwargs.get("slug")) self.tag = get_object_or_404(Tag, slug=self.kwargs.get("slug"))
@ -136,7 +136,7 @@ class ArticleDetailView(generic.DetailView):
return queryset return queryset
def get_object(self, queryset=None) -> Article: def get_object(self, queryset=None) -> Article:
obj = super().get_object(queryset) # type: Article obj: Article = super().get_object(queryset)
if not self.request.user.is_authenticated: if not self.request.user.is_authenticated:
obj.views_count = F("views_count") + 1 obj.views_count = F("views_count") + 1
obj.save(update_fields=["views_count"]) obj.save(update_fields=["views_count"])

View file

@ -56,6 +56,7 @@ class AttachmentAdmin(admin.ModelAdmin):
) )
return "" return ""
@admin.action(description="Set as open graph image")
def set_as_open_graph_image(self, request, queryset): def set_as_open_graph_image(self, request, queryset):
if len(queryset) != 1: if len(queryset) != 1:
messages.error(request, "You must select only one attachment") messages.error(request, "You must select only one attachment")
@ -64,8 +65,7 @@ class AttachmentAdmin(admin.ModelAdmin):
queryset.update(open_graph_image=True) queryset.update(open_graph_image=True)
messages.success(request, "Done") messages.success(request, "Done")
set_as_open_graph_image.short_description = "Set as open graph image" @admin.action(description="Reprocess selected attachments")
def reprocess_selected_attachments(self, request, queryset): def reprocess_selected_attachments(self, request, queryset):
if len(queryset) == 0: if len(queryset) == 0:
messages.error(request, "You must select at least one attachment") messages.error(request, "You must select at least one attachment")
@ -73,5 +73,3 @@ class AttachmentAdmin(admin.ModelAdmin):
for attachment in queryset: for attachment in queryset:
attachment.reprocess() attachment.reprocess()
messages.success(request, "Attachments were successfully reprocessed.") messages.success(request, "Attachments were successfully reprocessed.")
reprocess_selected_attachments.short_description = "Reprocess selected attachments"

View file

@ -13,12 +13,12 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
import debug_toolbar import debug_toolbar # type: ignore
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from django.views.generic import TemplateView from django.views.generic import TemplateView
from two_factor.urls import urlpatterns as tf_urls from two_factor.urls import urlpatterns as tf_urls # type: ignore
from blog import settings from blog import settings

3
stubs/rcssmin.pyi Normal file
View file

@ -0,0 +1,3 @@
from typing import Callable
cssmin: Callable[[str], str]

View file

@ -0,0 +1,3 @@
from .api import of_html as of_html
from .api import of_markdown as of_markdown
from .api import of_text as of_text

5
stubs/readtime/api.pyi Normal file
View file

@ -0,0 +1,5 @@
from .result import Result
def of_text(text, wpm: int | None = ...) -> Result: ...
def of_html(html, wpm: int | None = ...) -> Result: ...
def of_markdown(markdown, wpm: int | None = ...) -> Result: ...

14
stubs/readtime/result.pyi Normal file
View file

@ -0,0 +1,14 @@
from datetime import timedelta
class Result:
delta: timedelta | None
wpm: int | None
def __init__(self, seconds: int | None = ..., wpm: int | None = ...) -> None: ...
def __unicode__(self): ...
@property
def seconds(self) -> int: ...
@property
def minutes(self) -> int: ...
@property
def text(self) -> str: ...
def total_seconds(self, delta) -> int: ...

View file

@ -22,6 +22,13 @@ def test_cov(ctx):
) )
@task(post=[test_cov])
def check(ctx):
with ctx.cd(BASE_DIR):
ctx.run("pre-commit run --all-files", pty=True)
ctx.run("mypy src", pty=True)
@task @task
def build(ctx): def build(ctx):
with ctx.cd(BASE_DIR): with ctx.cd(BASE_DIR):
@ -39,7 +46,7 @@ def deploy(ctx):
ctx.run("ssh ubuntu /home/gaugendre/blog/update", pty=True, echo=True) ctx.run("ssh ubuntu /home/gaugendre/blog/update", pty=True, echo=True)
@task(pre=[build, publish, deploy]) @task(pre=[check, build, publish, deploy])
def beam(ctx): def beam(ctx):
pass pass