Fix moar mypy
This commit is contained in:
parent
643dc7f4a1
commit
880985dfe0
30 changed files with 234 additions and 106 deletions
|
@ -78,4 +78,3 @@ repos:
|
||||||
entry: mypy
|
entry: mypy
|
||||||
language: system
|
language: system
|
||||||
types_or: [python, pyi]
|
types_or: [python, pyi]
|
||||||
exclude: tasks.py$
|
|
||||||
|
|
63
poetry.lock
generated
63
poetry.lock
generated
|
@ -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"
|
||||||
|
@ -807,6 +836,22 @@ category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
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]]
|
[[package]]
|
||||||
name = "types-requests"
|
name = "types-requests"
|
||||||
version = "2.26.3"
|
version = "2.26.3"
|
||||||
|
@ -930,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 = "17e06d5348b12d6c12a95b1a9ba60278cddae6d15f80d4eba7173a67e5b5b123"
|
content-hash = "35d359b39bfd7c907a1119797a4badf33b3f3c964a5ec90e85c781cf99fecf7e"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
asgiref = [
|
asgiref = [
|
||||||
|
@ -1096,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"},
|
||||||
|
@ -1525,6 +1578,14 @@ types-pillow = [
|
||||||
{file = "types-Pillow-8.3.11.tar.gz", hash = "sha256:aa96a739184f48f69e6f30218400623fc5a95f5fec199c447663a32538440405"},
|
{file = "types-Pillow-8.3.11.tar.gz", hash = "sha256:aa96a739184f48f69e6f30218400623fc5a95f5fec199c447663a32538440405"},
|
||||||
{file = "types_Pillow-8.3.11-py3-none-any.whl", hash = "sha256:998189334e616b1dd42c9634669efbf726184039e96e9a23ec95246e0ecff3fc"},
|
{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 = [
|
types-requests = [
|
||||||
{file = "types-requests-2.26.3.tar.gz", hash = "sha256:d63fa617846dcefff5aa2d59e47ab4ffd806e4bb0567115f7adbb5e438302fe4"},
|
{file = "types-requests-2.26.3.tar.gz", hash = "sha256:d63fa617846dcefff5aa2d59e47ab4ffd806e4bb0567115f7adbb5e438302fe4"},
|
||||||
{file = "types_requests-2.26.3-py3-none-any.whl", hash = "sha256:ad18284931c5ddbf050ccdd138f200d18fd56f88aa3567019d8da9b2d4fe0344"},
|
{file = "types_requests-2.26.3-py3-none-any.whl", hash = "sha256:ad18284931c5ddbf050ccdd138f200d18fd56f88aa3567019d8da9b2d4fe0344"},
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
FROM python:3.8.6-buster
|
|
||||||
RUN python3 -m pip install pre-commit==2.9.3
|
|
|
@ -43,6 +43,7 @@ types-setuptools = "^57.4.5"
|
||||||
types-toml = "^0.10.1"
|
types-toml = "^0.10.1"
|
||||||
types-beautifulsoup4 = "^4.10.7"
|
types-beautifulsoup4 = "^4.10.7"
|
||||||
types-Pillow = "^8.3.11"
|
types-Pillow = "^8.3.11"
|
||||||
|
django-stubs = "^1.9.0"
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
target-version = ['py38']
|
target-version = ['py38']
|
||||||
|
@ -63,6 +64,16 @@ env = [
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
mypy_path = "$MYPY_CONFIG_FILE_DIR/stubs:$MYPY_CONFIG_FILE_DIR/src"
|
mypy_path = "$MYPY_CONFIG_FILE_DIR/stubs:$MYPY_CONFIG_FILE_DIR/src"
|
||||||
|
plugins = ["mypy_django_plugin.main"]
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
strict_equality = true
|
||||||
|
disallow_untyped_calls = true
|
||||||
|
warn_unreachable = true
|
||||||
|
enable_error_code = ["redundant-expr", "truthy-bool"]
|
||||||
|
|
||||||
|
[tool.django-stubs]
|
||||||
|
django_settings_module = "blog.settings"
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = [
|
module = [
|
||||||
|
@ -71,7 +82,8 @@ module = [
|
||||||
"django_otp.plugins.otp_static.models",
|
"django_otp.plugins.otp_static.models",
|
||||||
"two_factor.models",
|
"two_factor.models",
|
||||||
"django_otp.plugins.otp_totp.models",
|
"django_otp.plugins.otp_totp.models",
|
||||||
"model_bakery"
|
"model_bakery",
|
||||||
|
"invoke",
|
||||||
]
|
]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from django.contrib import admin, messages
|
from django.contrib import admin, messages
|
||||||
from django.contrib.admin import register
|
from django.contrib.admin import register
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
|
|
||||||
from .models import Article, Tag, User
|
from .models import Article, Tag, User
|
||||||
|
@ -73,13 +77,13 @@ class ArticleAdmin(admin.ModelAdmin):
|
||||||
autocomplete_fields = ["tags"]
|
autocomplete_fields = ["tags"]
|
||||||
show_full_result_count = False
|
show_full_result_count = False
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request: HttpRequest) -> QuerySet:
|
||||||
queryset = super().get_queryset(request)
|
queryset = super().get_queryset(request)
|
||||||
queryset = queryset.prefetch_related("tags")
|
queryset = queryset.prefetch_related("tags")
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@admin.action(description="Publish selected articles")
|
@admin.action(description="Publish selected articles")
|
||||||
def publish(self, request, queryset):
|
def publish(self, request: HttpRequest, queryset: QuerySet) -> None:
|
||||||
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.")
|
||||||
return
|
return
|
||||||
|
@ -88,7 +92,7 @@ class ArticleAdmin(admin.ModelAdmin):
|
||||||
messages.success(request, f"{len(queryset)} articles published.")
|
messages.success(request, f"{len(queryset)} articles published.")
|
||||||
|
|
||||||
@admin.action(description="Unpublish selected articles")
|
@admin.action(description="Unpublish selected articles")
|
||||||
def unpublish(self, request, queryset):
|
def unpublish(self, request: HttpRequest, queryset: QuerySet) -> None:
|
||||||
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.")
|
||||||
return
|
return
|
||||||
|
@ -97,7 +101,7 @@ class ArticleAdmin(admin.ModelAdmin):
|
||||||
messages.success(request, f"{len(queryset)} articles unpublished.")
|
messages.success(request, f"{len(queryset)} articles unpublished.")
|
||||||
|
|
||||||
@admin.action(description="Refresh draft key of selected articles")
|
@admin.action(description="Refresh draft key of selected articles")
|
||||||
def refresh_draft_key(self, request, queryset):
|
def refresh_draft_key(self, request: HttpRequest, queryset: QuerySet) -> None:
|
||||||
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.")
|
||||||
return
|
return
|
||||||
|
@ -110,12 +114,14 @@ class ArticleAdmin(admin.ModelAdmin):
|
||||||
class Media:
|
class Media:
|
||||||
css = {"all": ("admin_articles.css",)}
|
css = {"all": ("admin_articles.css",)}
|
||||||
|
|
||||||
def response_post_save_add(self, request, obj: Article):
|
def response_post_save_add(
|
||||||
|
self, request: HttpRequest, obj: Article
|
||||||
|
) -> HttpResponseRedirect:
|
||||||
if "_preview" in request.POST:
|
if "_preview" in request.POST:
|
||||||
return redirect("article-detail", slug=obj.slug)
|
return cast(HttpResponseRedirect, redirect("article-detail", slug=obj.slug))
|
||||||
return super().response_post_save_add(request, obj)
|
return super().response_post_save_add(request, obj)
|
||||||
|
|
||||||
def response_change(self, request, obj: Article):
|
def response_change(self, request: HttpRequest, obj: Article) -> HttpResponse:
|
||||||
if "_preview" in request.POST:
|
if "_preview" in request.POST:
|
||||||
obj.save()
|
obj.save()
|
||||||
return redirect("article-detail", slug=obj.slug)
|
return redirect("article-detail", slug=obj.slug)
|
||||||
|
@ -129,11 +135,11 @@ class ArticleAdmin(admin.ModelAdmin):
|
||||||
return redirect(".")
|
return redirect(".")
|
||||||
return super().response_change(request, obj)
|
return super().response_change(request, obj)
|
||||||
|
|
||||||
def read_time(self, instance: Article):
|
def read_time(self, instance: Article) -> str:
|
||||||
return f"{instance.get_read_time()} min"
|
return f"{instance.get_read_time()} min"
|
||||||
|
|
||||||
@admin.display(boolean=True)
|
@admin.display(boolean=True)
|
||||||
def has_custom_css(self, instance: Article):
|
def has_custom_css(self, instance: Article) -> bool:
|
||||||
return bool(instance.custom_css)
|
return bool(instance.custom_css)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -9,15 +9,15 @@ from markdown.inlinepatterns import (
|
||||||
|
|
||||||
|
|
||||||
class LazyImageInlineProcessor(ImageInlineProcessor):
|
class LazyImageInlineProcessor(ImageInlineProcessor):
|
||||||
def handleMatch(self, m, data):
|
def handleMatch(self, m, data): # type: ignore
|
||||||
el, match_start, index = super().handleMatch(m, data)
|
el, match_start, index = super().handleMatch(m, data)
|
||||||
if el is not None:
|
if el is not None:
|
||||||
el.set("loading", "lazy")
|
el.set("loading", "lazy")
|
||||||
return el, match_start, index
|
return el, match_start, index # type: ignore
|
||||||
|
|
||||||
|
|
||||||
class LazyImageReferenceInlineProcessor(ImageReferenceInlineProcessor):
|
class LazyImageReferenceInlineProcessor(ImageReferenceInlineProcessor):
|
||||||
def makeTag(self, href, title, text):
|
def makeTag(self, href, title, text): # type: ignore
|
||||||
el = super().makeTag(href, title, text)
|
el = super().makeTag(href, title, text)
|
||||||
if el is not None:
|
if el is not None:
|
||||||
el.set("loading", "lazy")
|
el.set("loading", "lazy")
|
||||||
|
@ -25,7 +25,7 @@ class LazyImageReferenceInlineProcessor(ImageReferenceInlineProcessor):
|
||||||
|
|
||||||
|
|
||||||
class LazyLoadingImageExtension(Extension):
|
class LazyLoadingImageExtension(Extension):
|
||||||
def extendMarkdown(self, md: Markdown):
|
def extendMarkdown(self, md: Markdown) -> None:
|
||||||
md.inlinePatterns.register(
|
md.inlinePatterns.register(
|
||||||
LazyImageInlineProcessor(IMAGE_LINK_RE, md), "image_link", 150
|
LazyImageInlineProcessor(IMAGE_LINK_RE, md), "image_link", 150
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
# Generated by Django 3.1.3 on 2020-12-24 16:46
|
# Generated by Django 3.1.3 on 2020-12-24 16:46
|
||||||
|
from django.apps.registry import Apps
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||||
|
|
||||||
|
|
||||||
def forwards_func(apps, schema_editor):
|
def forwards_func(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
|
||||||
Article = apps.get_model("articles", "Article")
|
Article = apps.get_model("articles", "Article")
|
||||||
db_alias = schema_editor.connection.alias
|
db_alias = schema_editor.connection.alias
|
||||||
Article.objects.using(db_alias).filter(slug="about-me").update(is_home=True)
|
Article.objects.using(db_alias).filter(slug="about-me").update(is_home=True)
|
||||||
|
|
||||||
|
|
||||||
def reverse_func(apps, schema_editor):
|
def reverse_func(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,17 @@
|
||||||
# Generated by Django 3.1.5 on 2021-03-03 15:33
|
# Generated by Django 3.1.5 on 2021-03-03 15:33
|
||||||
|
from django.apps.registry import Apps
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||||
|
|
||||||
|
|
||||||
def forwards(apps, schema_editor):
|
def forwards(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
|
||||||
Tag = apps.get_model("articles", "Tag")
|
Tag = apps.get_model("articles", "Tag")
|
||||||
Article = apps.get_model("articles", "Article")
|
Article = apps.get_model("articles", "Article")
|
||||||
db_alias = schema_editor.connection.alias
|
db_alias = schema_editor.connection.alias
|
||||||
articles = Article.objects.using(db_alias).all()
|
articles = Article.objects.using(db_alias).all()
|
||||||
for article in articles:
|
for article in articles:
|
||||||
tags = []
|
tags = []
|
||||||
|
keyword: str
|
||||||
for keyword in list(
|
for keyword in list(
|
||||||
filter(None, map(lambda k: k.strip(), article.keywords.split(",")))
|
filter(None, map(lambda k: k.strip(), article.keywords.split(",")))
|
||||||
):
|
):
|
||||||
|
@ -22,7 +24,7 @@ def forwards(apps, schema_editor):
|
||||||
Article.objects.bulk_update(articles, ["keywords"])
|
Article.objects.bulk_update(articles, ["keywords"])
|
||||||
|
|
||||||
|
|
||||||
def backwards(apps, schema_editor):
|
def backwards(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
|
||||||
Article = apps.get_model("articles", "Article")
|
Article = apps.get_model("articles", "Article")
|
||||||
db_alias = schema_editor.connection.alias
|
db_alias = schema_editor.connection.alias
|
||||||
articles = Article.objects.using(db_alias).all()
|
articles = Article.objects.using(db_alias).all()
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
# Generated by Django 3.1.5 on 2021-03-04 17:17
|
# Generated by Django 3.1.5 on 2021-03-04 17:17
|
||||||
|
from django.apps.registry import Apps
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
|
||||||
def forwards(apps, schema_editor):
|
def forwards(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
|
||||||
Tag = apps.get_model("articles", "Tag")
|
Tag = apps.get_model("articles", "Tag")
|
||||||
db_alias = schema_editor.connection.alias
|
db_alias = schema_editor.connection.alias
|
||||||
tags = Tag.objects.using(db_alias).all()
|
tags = Tag.objects.using(db_alias).all()
|
||||||
|
@ -13,7 +14,7 @@ def forwards(apps, schema_editor):
|
||||||
Tag.objects.bulk_update(tags, ["slug"])
|
Tag.objects.bulk_update(tags, ["slug"])
|
||||||
|
|
||||||
|
|
||||||
def backwards(apps, schema_editor):
|
def backwards(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
|
||||||
Tag = apps.get_model("articles", "Tag")
|
Tag = apps.get_model("articles", "Tag")
|
||||||
db_alias = schema_editor.connection.alias
|
db_alias = schema_editor.connection.alias
|
||||||
Tag.objects.using(db_alias).update(slug="")
|
Tag.objects.using(db_alias).update(slug="")
|
||||||
|
|
|
@ -3,7 +3,7 @@ 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
|
from typing import Any, Sequence
|
||||||
|
|
||||||
import rcssmin
|
import rcssmin
|
||||||
import readtime
|
import readtime
|
||||||
|
@ -115,7 +115,7 @@ class Article(models.Model):
|
||||||
self.save()
|
self.save()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def save(self, *args, **kwargs) -> None:
|
def save(self, *args: Any, **kwargs: Any) -> 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)
|
||||||
|
|
|
@ -55,5 +55,5 @@ def unpublished_article(author: User) -> Article:
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True, scope="session")
|
@pytest.fixture(autouse=True, scope="session")
|
||||||
def _collect_static():
|
def _collect_static() -> None:
|
||||||
call_command("collectstatic", "--no-input", "--clear")
|
call_command("collectstatic", "--no-input", "--clear")
|
||||||
|
|
|
@ -8,7 +8,7 @@ from articles.models import User
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
# @pytest.mark.skip("Fails for no apparent reason")
|
# @pytest.mark.skip("Fails for no apparent reason")
|
||||||
@pytest.mark.flaky(reruns=5, reruns_delay=3)
|
@pytest.mark.flaky(reruns=5, reruns_delay=3)
|
||||||
def test_can_access_add_article(client: Client, author: User):
|
def test_can_access_add_article(client: Client, author: User) -> None:
|
||||||
client.force_login(author)
|
client.force_login(author)
|
||||||
url = reverse("admin:articles_article_add")
|
url = reverse("admin:articles_article_add")
|
||||||
res = client.get(url)
|
res = client.get(url)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.http import HttpResponse
|
||||||
from django.test import Client
|
from django.test import Client
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
@ -7,7 +8,9 @@ from articles.utils import format_article_content
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_unauthenticated_render_redirects(published_article: Article, client: Client):
|
def test_unauthenticated_render_redirects(
|
||||||
|
published_article: Article, client: Client
|
||||||
|
) -> None:
|
||||||
api_res = client.post(
|
api_res = client.post(
|
||||||
reverse("api-render-article", kwargs={"article_pk": published_article.pk}),
|
reverse("api-render-article", kwargs={"article_pk": published_article.pk}),
|
||||||
data={"content": published_article.content},
|
data={"content": published_article.content},
|
||||||
|
@ -16,7 +19,9 @@ def test_unauthenticated_render_redirects(published_article: Article, client: Cl
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_render_article_same_content(published_article: Article, client: Client):
|
def test_render_article_same_content(
|
||||||
|
published_article: Article, client: Client
|
||||||
|
) -> None:
|
||||||
client.force_login(published_article.author)
|
client.force_login(published_article.author)
|
||||||
api_res = post_article(client, published_article, published_article.content)
|
api_res = post_article(client, published_article, published_article.content)
|
||||||
standard_res = client.get(
|
standard_res = client.get(
|
||||||
|
@ -36,7 +41,9 @@ def test_render_article_same_content(published_article: Article, client: Client)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_render_article_change_content(published_article: Article, client: Client):
|
def test_render_article_change_content(
|
||||||
|
published_article: Article, client: Client
|
||||||
|
) -> None:
|
||||||
client.force_login(published_article.author)
|
client.force_login(published_article.author)
|
||||||
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)
|
||||||
|
@ -47,7 +54,7 @@ def test_render_article_change_content(published_article: Article, client: Clien
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_render_article_doesnt_save(published_article, client: Client):
|
def test_render_article_doesnt_save(published_article: Article, client: Client) -> None:
|
||||||
client.force_login(published_article.author)
|
client.force_login(published_article.author)
|
||||||
original_content = published_article.content
|
original_content = published_article.content
|
||||||
preview_content = "This is a different content **with strong emphasis**"
|
preview_content = "This is a different content **with strong emphasis**"
|
||||||
|
@ -58,7 +65,7 @@ def test_render_article_doesnt_save(published_article, client: Client):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_render_article_no_tags(published_article, client: Client):
|
def test_render_article_no_tags(published_article: Article, client: Client) -> None:
|
||||||
client.force_login(published_article.author)
|
client.force_login(published_article.author)
|
||||||
api_res = client.post(
|
api_res = client.post(
|
||||||
reverse("api-render-article", kwargs={"article_pk": published_article.pk}),
|
reverse("api-render-article", kwargs={"article_pk": published_article.pk}),
|
||||||
|
@ -67,7 +74,7 @@ def test_render_article_no_tags(published_article, client: Client):
|
||||||
assert api_res.status_code == 200
|
assert api_res.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def post_article(client: Client, article: Article, content: str):
|
def post_article(client: Client, article: Article, content: str) -> HttpResponse:
|
||||||
return client.post(
|
return client.post(
|
||||||
reverse("api-render-article", kwargs={"article_pk": article.pk}),
|
reverse("api-render-article", kwargs={"article_pk": article.pk}),
|
||||||
data={
|
data={
|
||||||
|
|
|
@ -8,7 +8,7 @@ from articles.views.feeds import CompleteFeed
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_can_access_feed(client: Client, published_article):
|
def test_can_access_feed(client: Client, published_article: Article) -> None:
|
||||||
res = client.get(reverse("complete-feed"))
|
res = client.get(reverse("complete-feed"))
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert "application/rss+xml" in res["content-type"]
|
assert "application/rss+xml" in res["content-type"]
|
||||||
|
@ -17,7 +17,7 @@ def test_can_access_feed(client: Client, published_article):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_feed_limits_number_of_articles(client: Client, author: User):
|
def test_feed_limits_number_of_articles(client: Client, author: User) -> None:
|
||||||
baker.make(Article, 100, status=Article.PUBLISHED, author=author)
|
baker.make(Article, 100, status=Article.PUBLISHED, author=author)
|
||||||
res = client.get(reverse("complete-feed"))
|
res = client.get(reverse("complete-feed"))
|
||||||
content = res.content.decode("utf-8")
|
content = res.content.decode("utf-8")
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.http import HttpResponse
|
||||||
from django.test import Client
|
from django.test import Client
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
from articles.models import Article, User
|
from articles.models import Article, User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_can_access_list(client: Client, published_article: Article):
|
def test_can_access_list(client: Client, published_article: Article) -> None:
|
||||||
res = client.get(reverse("articles-list"))
|
res = client.get(reverse("articles-list"))
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
content = res.content.decode("utf-8")
|
content = res.content.decode("utf-8")
|
||||||
|
@ -16,7 +18,7 @@ def test_can_access_list(client: Client, published_article: Article):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_only_title_shown_on_list(client: Client, author: User):
|
def test_only_title_shown_on_list(client: Client, author: User) -> None:
|
||||||
title = "This is a very long title mouahahaha"
|
title = "This is a very long title mouahahaha"
|
||||||
abstract = "Some abstract"
|
abstract = "Some abstract"
|
||||||
after = "Some content after abstract"
|
after = "Some content after abstract"
|
||||||
|
@ -35,16 +37,16 @@ def test_only_title_shown_on_list(client: Client, author: User):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_access_article_by_slug(client: Client, published_article: Article):
|
def test_access_article_by_slug(client: Client, published_article: Article) -> None:
|
||||||
_test_access_article_by_slug(client, published_article)
|
_test_access_article_by_slug(client, published_article)
|
||||||
|
|
||||||
|
|
||||||
def _test_access_article_by_slug(client: Client, item: Article):
|
def _test_access_article_by_slug(client: Client, item: Article) -> None:
|
||||||
res = client.get(reverse("article-detail", kwargs={"slug": item.slug}))
|
res = client.get(reverse("article-detail", kwargs={"slug": item.slug}))
|
||||||
_assert_article_is_rendered(item, res)
|
_assert_article_is_rendered(item, res)
|
||||||
|
|
||||||
|
|
||||||
def _assert_article_is_rendered(item: Article, res):
|
def _assert_article_is_rendered(item: Article, res: HttpResponse) -> None:
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
content = res.content.decode("utf-8")
|
content = res.content.decode("utf-8")
|
||||||
assert item.title in content
|
assert item.title in content
|
||||||
|
@ -55,7 +57,7 @@ def _assert_article_is_rendered(item: Article, res):
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_anonymous_cant_access_draft_detail(
|
def test_anonymous_cant_access_draft_detail(
|
||||||
client: Client, unpublished_article: Article
|
client: Client, unpublished_article: Article
|
||||||
):
|
) -> None:
|
||||||
res = client.get(
|
res = client.get(
|
||||||
reverse("article-detail", kwargs={"slug": unpublished_article.slug})
|
reverse("article-detail", kwargs={"slug": unpublished_article.slug})
|
||||||
)
|
)
|
||||||
|
@ -65,7 +67,7 @@ def test_anonymous_cant_access_draft_detail(
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_anonymous_can_access_draft_detail_with_key(
|
def test_anonymous_can_access_draft_detail_with_key(
|
||||||
client: Client, unpublished_article: Article
|
client: Client, unpublished_article: Article
|
||||||
):
|
) -> None:
|
||||||
res = client.get(
|
res = client.get(
|
||||||
reverse("article-detail", kwargs={"slug": unpublished_article.slug})
|
reverse("article-detail", kwargs={"slug": unpublished_article.slug})
|
||||||
+ f"?draft_key={unpublished_article.draft_key}"
|
+ f"?draft_key={unpublished_article.draft_key}"
|
||||||
|
@ -76,7 +78,7 @@ def test_anonymous_can_access_draft_detail_with_key(
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_user_can_access_draft_detail(
|
def test_user_can_access_draft_detail(
|
||||||
client: Client, author: User, unpublished_article: Article
|
client: Client, author: User, unpublished_article: Article
|
||||||
):
|
) -> None:
|
||||||
client.force_login(author)
|
client.force_login(author)
|
||||||
_test_access_article_by_slug(client, unpublished_article)
|
_test_access_article_by_slug(client, unpublished_article)
|
||||||
|
|
||||||
|
@ -84,7 +86,7 @@ def test_user_can_access_draft_detail(
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_anonymous_cant_access_drafts_list(
|
def test_anonymous_cant_access_drafts_list(
|
||||||
client: Client, unpublished_article: Article
|
client: Client, unpublished_article: Article
|
||||||
):
|
) -> None:
|
||||||
res = client.get(reverse("drafts-list"))
|
res = client.get(reverse("drafts-list"))
|
||||||
assert res.status_code == 302
|
assert res.status_code == 302
|
||||||
|
|
||||||
|
@ -92,7 +94,7 @@ def test_anonymous_cant_access_drafts_list(
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_user_can_access_drafts_list(
|
def test_user_can_access_drafts_list(
|
||||||
client: Client, author: User, unpublished_article: Article
|
client: Client, author: User, unpublished_article: Article
|
||||||
):
|
) -> None:
|
||||||
client.force_login(author)
|
client.force_login(author)
|
||||||
res = client.get(reverse("drafts-list"))
|
res = client.get(reverse("drafts-list"))
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
|
@ -101,7 +103,7 @@ def test_user_can_access_drafts_list(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_has_goatcounter_if_set(client: Client, settings):
|
def test_has_goatcounter_if_set(client: Client, settings: SettingsWrapper) -> None:
|
||||||
settings.GOATCOUNTER_DOMAIN = "gc.gabnotes.org"
|
settings.GOATCOUNTER_DOMAIN = "gc.gabnotes.org"
|
||||||
res = client.get(reverse("articles-list"))
|
res = client.get(reverse("articles-list"))
|
||||||
content = res.content.decode("utf-8")
|
content = res.content.decode("utf-8")
|
||||||
|
@ -110,7 +112,9 @@ def test_has_goatcounter_if_set(client: Client, settings):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_doesnt_have_goatcounter_if_unset(client: Client, settings):
|
def test_doesnt_have_goatcounter_if_unset(
|
||||||
|
client: Client, settings: SettingsWrapper
|
||||||
|
) -> None:
|
||||||
settings.GOATCOUNTER_DOMAIN = None
|
settings.GOATCOUNTER_DOMAIN = None
|
||||||
res = client.get(reverse("articles-list"))
|
res = client.get(reverse("articles-list"))
|
||||||
content = res.content.decode("utf-8")
|
content = res.content.decode("utf-8")
|
||||||
|
@ -119,7 +123,9 @@ def test_doesnt_have_goatcounter_if_unset(client: Client, settings):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_logged_in_user_doesnt_have_goatcounter(client: Client, author: User, settings):
|
def test_logged_in_user_doesnt_have_goatcounter(
|
||||||
|
client: Client, author: User, settings: SettingsWrapper
|
||||||
|
) -> None:
|
||||||
client.force_login(author)
|
client.force_login(author)
|
||||||
settings.GOATCOUNTER_DOMAIN = "gc.gabnotes.org"
|
settings.GOATCOUNTER_DOMAIN = "gc.gabnotes.org"
|
||||||
res = client.get(reverse("articles-list"))
|
res = client.get(reverse("articles-list"))
|
||||||
|
@ -129,7 +135,7 @@ def test_logged_in_user_doesnt_have_goatcounter(client: Client, author: User, se
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_image_is_lazy(client: Client, published_article: Article):
|
def test_image_is_lazy(client: Client, published_article: Article) -> None:
|
||||||
res = client.get(reverse("article-detail", kwargs={"slug": published_article.slug}))
|
res = client.get(reverse("article-detail", kwargs={"slug": published_article.slug}))
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
content = res.content.decode("utf-8")
|
content = res.content.decode("utf-8")
|
||||||
|
|
|
@ -3,5 +3,5 @@ from django.core.management import call_command
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_missing_migrations():
|
def test_missing_migrations() -> None:
|
||||||
call_command("makemigrations", "--check")
|
call_command("makemigrations", "--check")
|
||||||
|
|
|
@ -4,7 +4,7 @@ from articles.models import Article, User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_publish_article(unpublished_article: Article):
|
def test_publish_article(unpublished_article: Article) -> None:
|
||||||
assert unpublished_article.status == Article.DRAFT
|
assert unpublished_article.status == Article.DRAFT
|
||||||
assert unpublished_article.published_at is None
|
assert unpublished_article.published_at is None
|
||||||
published_article = unpublished_article.publish()
|
published_article = unpublished_article.publish()
|
||||||
|
@ -13,7 +13,7 @@ def test_publish_article(unpublished_article: Article):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_unpublish_article(published_article: Article):
|
def test_unpublish_article(published_article: Article) -> None:
|
||||||
assert published_article.status == Article.PUBLISHED
|
assert published_article.status == Article.PUBLISHED
|
||||||
assert published_article.published_at is not None
|
assert published_article.published_at is not None
|
||||||
unpublished_article = published_article.unpublish()
|
unpublished_article = published_article.unpublish()
|
||||||
|
@ -22,7 +22,7 @@ def test_unpublish_article(published_article: Article):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_save_article_adds_missing_slug(author: User):
|
def test_save_article_adds_missing_slug(author: User) -> None:
|
||||||
# Explicitly calling bulk_create with one article because it doesn't call save().
|
# Explicitly calling bulk_create with one article because it doesn't call save().
|
||||||
articles = Article.objects.bulk_create(
|
articles = Article.objects.bulk_create(
|
||||||
[Article(author=author, title="noice title", slug="", status=Article.DRAFT)]
|
[Article(author=author, title="noice title", slug="", status=Article.DRAFT)]
|
||||||
|
@ -34,7 +34,7 @@ def test_save_article_adds_missing_slug(author: User):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_save_article_doesnt_change_existing_slug(published_article: Article):
|
def test_save_article_doesnt_change_existing_slug(published_article: Article) -> None:
|
||||||
original_slug = published_article.slug
|
original_slug = published_article.slug
|
||||||
published_article.title = "This is a brand new title"
|
published_article.title = "This is a brand new title"
|
||||||
published_article.save()
|
published_article.save()
|
||||||
|
@ -42,19 +42,19 @@ def test_save_article_doesnt_change_existing_slug(published_article: Article):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_empty_custom_css_minified(published_article):
|
def test_empty_custom_css_minified(published_article: Article) -> None:
|
||||||
published_article.custom_css = ""
|
published_article.custom_css = ""
|
||||||
assert published_article.get_minified_custom_css == ""
|
assert published_article.get_minified_custom_css == ""
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_simple_custom_css_minified(published_article):
|
def test_simple_custom_css_minified(published_article: Article) -> None:
|
||||||
published_article.custom_css = ".cls {\n background-color: red;\n}"
|
published_article.custom_css = ".cls {\n background-color: red;\n}"
|
||||||
assert published_article.get_minified_custom_css == ".cls{background-color:red}"
|
assert published_article.get_minified_custom_css == ".cls{background-color:red}"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_larger_custom_css_minified(published_article):
|
def test_larger_custom_css_minified(published_article: Article) -> None:
|
||||||
published_article.custom_css = """\
|
published_article.custom_css = """\
|
||||||
.profile {
|
.profile {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -3,13 +3,14 @@ import re
|
||||||
import markdown
|
import markdown
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.http import HttpRequest
|
||||||
from markdown.extensions.codehilite import CodeHiliteExtension
|
from markdown.extensions.codehilite import CodeHiliteExtension
|
||||||
from markdown.extensions.toc import TocExtension
|
from markdown.extensions.toc import TocExtension
|
||||||
|
|
||||||
from articles.markdown import LazyLoadingImageExtension
|
from articles.markdown import LazyLoadingImageExtension
|
||||||
|
|
||||||
|
|
||||||
def build_full_absolute_url(request, url: str) -> str:
|
def build_full_absolute_url(request: HttpRequest | None, url: str) -> str:
|
||||||
if request:
|
if request:
|
||||||
return request.build_absolute_uri(url)
|
return request.build_absolute_uri(url)
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from typing import Any
|
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
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ from articles.models import Article, Tag
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def render_article(request, article_pk: int) -> HttpResponse:
|
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)
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
from django.contrib.syndication.views import Feed
|
from django.contrib.syndication.views import Feed
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
|
from django.http import HttpRequest
|
||||||
|
|
||||||
from articles.models import Article, Tag
|
from articles.models import Article, Tag
|
||||||
from blog import settings
|
from blog import settings
|
||||||
|
@ -33,7 +34,7 @@ class CompleteFeed(BaseFeed):
|
||||||
|
|
||||||
|
|
||||||
class TagFeed(BaseFeed):
|
class TagFeed(BaseFeed):
|
||||||
def get_object(self, request, *args, **kwargs) -> Tag:
|
def get_object(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Tag:
|
||||||
return Tag.objects.get(slug=kwargs.get("slug"))
|
return Tag.objects.get(slug=kwargs.get("slug"))
|
||||||
|
|
||||||
def title(self, tag: Tag) -> str:
|
def title(self, tag: Tag) -> str:
|
||||||
|
|
|
@ -5,7 +5,9 @@ from typing import Any
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.core.paginator import Page
|
from django.core.paginator import Page
|
||||||
from django.db.models import F, Q
|
from django.db.models import F, Q, QuerySet
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.http.response import HttpResponseBase
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.views import generic
|
from django.views import generic
|
||||||
from django.views.generic import DetailView
|
from django.views.generic import DetailView
|
||||||
|
@ -20,7 +22,7 @@ class BaseArticleListView(generic.ListView):
|
||||||
main_title = "Blog posts"
|
main_title = "Blog posts"
|
||||||
html_title = ""
|
html_title = ""
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["blog_title"] = settings.BLOG["title"]
|
context["blog_title"] = settings.BLOG["title"]
|
||||||
context["blog_description"] = settings.BLOG["description"]
|
context["blog_description"] = settings.BLOG["description"]
|
||||||
|
@ -51,7 +53,7 @@ class PublicArticleListView(BaseArticleListView):
|
||||||
|
|
||||||
|
|
||||||
class ArticlesListView(PublicArticleListView):
|
class ArticlesListView(PublicArticleListView):
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
home_article = Article.objects.filter(
|
home_article = Article.objects.filter(
|
||||||
status=Article.PUBLISHED, is_home=True
|
status=Article.PUBLISHED, is_home=True
|
||||||
|
@ -64,12 +66,12 @@ class SearchArticlesListView(PublicArticleListView):
|
||||||
template_name = "articles/article_search.html"
|
template_name = "articles/article_search.html"
|
||||||
html_title = "Search"
|
html_title = "Search"
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["search_expression"] = self.request.GET.get("s") or ""
|
context["search_expression"] = self.request.GET.get("s") or ""
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self) -> QuerySet:
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
search_expression = self.request.GET.get("s")
|
search_expression = self.request.GET.get("s")
|
||||||
if not search_expression:
|
if not search_expression:
|
||||||
|
@ -98,25 +100,27 @@ class TagArticlesListView(PublicArticleListView):
|
||||||
main_title = ""
|
main_title = ""
|
||||||
html_title = ""
|
html_title = ""
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(
|
||||||
|
self, request: HttpRequest, *args: Any, **kwargs: Any
|
||||||
|
) -> HttpResponseBase:
|
||||||
self.tag = get_object_or_404(Tag, slug=self.kwargs.get("slug"))
|
self.tag = get_object_or_404(Tag, slug=self.kwargs.get("slug"))
|
||||||
self.main_title = self.html_title = f"{self.tag.name} articles"
|
self.main_title = self.html_title = f"{self.tag.name} articles"
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["feed_title"] = self.tag.get_feed_title()
|
context["feed_title"] = self.tag.get_feed_title()
|
||||||
context["feed_url"] = self.tag.get_feed_url()
|
context["feed_url"] = self.tag.get_feed_url()
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self) -> QuerySet:
|
||||||
return super().get_queryset().filter(tags=self.tag)
|
return super().get_queryset().filter(tags=self.tag)
|
||||||
|
|
||||||
|
|
||||||
class DraftsListView(LoginRequiredMixin, BaseArticleListView):
|
class DraftsListView(LoginRequiredMixin, BaseArticleListView):
|
||||||
queryset = Article.objects.filter(status=Article.DRAFT)
|
queryset = Article.objects.filter(status=Article.DRAFT)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["title"] = "Drafts"
|
context["title"] = "Drafts"
|
||||||
context["title_header"] = context["title"]
|
context["title_header"] = context["title"]
|
||||||
|
@ -128,7 +132,7 @@ class ArticleDetailView(DetailView):
|
||||||
context_object_name = "article"
|
context_object_name = "article"
|
||||||
template_name = "articles/article_detail.html"
|
template_name = "articles/article_detail.html"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self) -> QuerySet:
|
||||||
key = self.request.GET.get("draft_key")
|
key = self.request.GET.get("draft_key")
|
||||||
if key:
|
if key:
|
||||||
return Article.objects.filter(draft_key=key).prefetch_related("tags")
|
return Article.objects.filter(draft_key=key).prefetch_related("tags")
|
||||||
|
@ -138,7 +142,7 @@ class ArticleDetailView(DetailView):
|
||||||
queryset = queryset.filter(status=Article.PUBLISHED)
|
queryset = queryset.filter(status=Article.PUBLISHED)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def get_object(self, queryset=None) -> Article:
|
def get_object(self, queryset: QuerySet | None = None) -> Article:
|
||||||
obj: Article = super().get_object(queryset)
|
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
|
||||||
|
@ -146,6 +150,6 @@ class ArticleDetailView(DetailView):
|
||||||
|
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
kwargs["tags"] = self.object.tags.all()
|
kwargs["tags"] = self.object.tags.all()
|
||||||
return super().get_context_data(**kwargs)
|
return super().get_context_data(**kwargs)
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
from django.contrib import admin, messages
|
from django.contrib import admin, messages
|
||||||
from django.contrib.admin import register
|
from django.contrib.admin import register
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.http import HttpRequest
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
|
|
||||||
from attachments.models import Attachment
|
from attachments.models import Attachment
|
||||||
|
@ -38,7 +40,7 @@ class AttachmentAdmin(admin.ModelAdmin):
|
||||||
class Media:
|
class Media:
|
||||||
js = ["attachments/js/copy_url.js"]
|
js = ["attachments/js/copy_url.js"]
|
||||||
|
|
||||||
def processed_file_url(self, instance):
|
def processed_file_url(self, instance: Attachment) -> str:
|
||||||
if instance.processed_file:
|
if instance.processed_file:
|
||||||
return format_html(
|
return format_html(
|
||||||
'{0} <a class="copy-button" data-to-copy="{1}" href="#">📋</a>',
|
'{0} <a class="copy-button" data-to-copy="{1}" href="#">📋</a>',
|
||||||
|
@ -47,7 +49,7 @@ class AttachmentAdmin(admin.ModelAdmin):
|
||||||
)
|
)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def original_file_url(self, instance):
|
def original_file_url(self, instance: Attachment) -> str:
|
||||||
if instance.original_file:
|
if instance.original_file:
|
||||||
return format_html(
|
return format_html(
|
||||||
'{0} <a class="copy-button" data-to-copy="{1}" href="#">📋</a>',
|
'{0} <a class="copy-button" data-to-copy="{1}" href="#">📋</a>',
|
||||||
|
@ -57,7 +59,7 @@ class AttachmentAdmin(admin.ModelAdmin):
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@admin.action(description="Set as open graph image")
|
@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: HttpRequest, queryset: QuerySet) -> None:
|
||||||
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")
|
||||||
return
|
return
|
||||||
|
@ -66,7 +68,9 @@ class AttachmentAdmin(admin.ModelAdmin):
|
||||||
messages.success(request, "Done")
|
messages.success(request, "Done")
|
||||||
|
|
||||||
@admin.action(description="Reprocess selected attachments")
|
@admin.action(description="Reprocess selected attachments")
|
||||||
def reprocess_selected_attachments(self, request, queryset):
|
def reprocess_selected_attachments(
|
||||||
|
self, request: HttpRequest, queryset: QuerySet
|
||||||
|
) -> None:
|
||||||
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")
|
||||||
return
|
return
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from attachments.models import Attachment
|
from attachments.models import Attachment
|
||||||
|
@ -6,7 +8,7 @@ from attachments.models import Attachment
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Reprocess all attachments"
|
help = "Reprocess all attachments"
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args: Any, **options: Any) -> None:
|
||||||
for attachment in Attachment.objects.all():
|
for attachment in Attachment.objects.all():
|
||||||
self.stdout.write(f"Processing {attachment}...")
|
self.stdout.write(f"Processing {attachment}...")
|
||||||
attachment.reprocess()
|
attachment.reprocess()
|
||||||
|
|
|
@ -1,19 +1,23 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models.fields.files import FieldFile
|
from django.db.models.fields.files import FieldFile
|
||||||
|
from django.http import HttpRequest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from articles.utils import build_full_absolute_url
|
from articles.utils import build_full_absolute_url
|
||||||
|
|
||||||
|
|
||||||
class AbsoluteUrlFieldFile(FieldFile):
|
class AbsoluteUrlFieldFile(FieldFile):
|
||||||
def get_full_absolute_url(self, request):
|
def get_full_absolute_url(self, request: HttpRequest) -> str:
|
||||||
return build_full_absolute_url(request, self.url)
|
return build_full_absolute_url(request, self.url)
|
||||||
|
|
||||||
|
|
||||||
|
@ -22,7 +26,7 @@ class AbsoluteUrlFileField(models.FileField):
|
||||||
|
|
||||||
|
|
||||||
class AttachmentManager(models.Manager):
|
class AttachmentManager(models.Manager):
|
||||||
def get_open_graph_image(self):
|
def get_open_graph_image(self) -> Attachment | None:
|
||||||
return self.filter(open_graph_image=True).first()
|
return self.filter(open_graph_image=True).first()
|
||||||
|
|
||||||
|
|
||||||
|
@ -37,14 +41,14 @@ class Attachment(models.Model):
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["description"]
|
ordering = ["description"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f"{self.description} ({self.original_file.name})"
|
return f"{self.description} ({self.original_file.name})"
|
||||||
|
|
||||||
def reprocess(self):
|
def reprocess(self) -> None:
|
||||||
self.processed_file = None
|
self.processed_file = None
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args: Any, **kwargs: Any) -> None:
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
if self.processed_file:
|
if self.processed_file:
|
||||||
|
|
|
@ -1,13 +1,19 @@
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
def replace_post_body(request):
|
class Request(Protocol):
|
||||||
|
body: Any
|
||||||
|
|
||||||
|
|
||||||
|
def replace_post_body(request: Request) -> Request:
|
||||||
request.body = None
|
request.body = None
|
||||||
return request
|
return request
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def vcr_config():
|
def vcr_config() -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"before_record_request": replace_post_body,
|
"before_record_request": replace_post_body,
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ from attachments.models import Attachment
|
||||||
@pytest.mark.block_network()
|
@pytest.mark.block_network()
|
||||||
@pytest.mark.vcr()
|
@pytest.mark.vcr()
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_attachment_is_processed_by_shortpixel():
|
def test_attachment_is_processed_by_shortpixel() -> None:
|
||||||
# This path manipulation is required to make the test run from this directory
|
# This path manipulation is required to make the test run from this directory
|
||||||
# or from upper in the hierarchy (e.g.: settings.BASE_DIR)
|
# or from upper in the hierarchy (e.g.: settings.BASE_DIR)
|
||||||
img_path = Path(__file__).parent / "resources" / "image.png"
|
img_path = Path(__file__).parent / "resources" / "image.png"
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
def test_robots_txt(client):
|
from django.test.client import Client
|
||||||
|
|
||||||
|
|
||||||
|
def test_robots_txt(client: Client) -> None:
|
||||||
res = client.get("/robots.txt")
|
res = client.get("/robots.txt")
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert res["Content-Type"] == "text/plain"
|
assert res["Content-Type"] == "text/plain"
|
||||||
|
|
|
@ -4,7 +4,7 @@ import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
"""Run administrative tasks.""" # noqa: DAR401
|
"""Run administrative tasks.""" # noqa: DAR401
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "blog.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "blog.settings")
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -4,7 +4,7 @@ class Result:
|
||||||
delta: timedelta | None
|
delta: timedelta | None
|
||||||
wpm: int | None
|
wpm: int | None
|
||||||
def __init__(self, seconds: int | None = ..., wpm: int | None = ...) -> None: ...
|
def __init__(self, seconds: int | None = ..., wpm: int | None = ...) -> None: ...
|
||||||
def __unicode__(self): ...
|
def __unicode__(self) -> str: ...
|
||||||
@property
|
@property
|
||||||
def seconds(self) -> int: ...
|
def seconds(self) -> int: ...
|
||||||
@property
|
@property
|
||||||
|
|
34
tasks.py
34
tasks.py
|
@ -1,21 +1,31 @@
|
||||||
|
"""
|
||||||
|
Invoke management tasks for the project.
|
||||||
|
|
||||||
|
The current implementation with type annotations is not compatible
|
||||||
|
with invoke 1.6.0 and requires manual patching.
|
||||||
|
|
||||||
|
See https://github.com/pyinvoke/invoke/pull/458/files
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from invoke import task
|
from invoke import Context, task
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.resolve(strict=True)
|
BASE_DIR = Path(__file__).parent.resolve(strict=True)
|
||||||
SRC_DIR = BASE_DIR / "src"
|
SRC_DIR = BASE_DIR / "src"
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def test(ctx):
|
def test(ctx: Context) -> None:
|
||||||
with ctx.cd(SRC_DIR):
|
with ctx.cd(SRC_DIR):
|
||||||
ctx.run("pytest", pty=True, echo=True)
|
ctx.run("pytest", pty=True, echo=True)
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def test_cov(ctx):
|
def test_cov(ctx: Context) -> None:
|
||||||
with ctx.cd(SRC_DIR):
|
with ctx.cd(SRC_DIR):
|
||||||
ctx.run(
|
ctx.run(
|
||||||
"pytest --cov=. --cov-report term-missing:skip-covered",
|
"pytest --cov=. --cov-report term-missing:skip-covered",
|
||||||
|
@ -25,41 +35,41 @@ def test_cov(ctx):
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def pre_commit(ctx):
|
def pre_commit(ctx: Context) -> None:
|
||||||
with ctx.cd(BASE_DIR):
|
with ctx.cd(BASE_DIR):
|
||||||
ctx.run("pre-commit run --all-files", pty=True)
|
ctx.run("pre-commit run --all-files", pty=True)
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def mypy(ctx):
|
def mypy(ctx: Context) -> None:
|
||||||
with ctx.cd(BASE_DIR):
|
with ctx.cd(BASE_DIR):
|
||||||
ctx.run("pre-commit run --all-files mypy", pty=True)
|
ctx.run("pre-commit run --all-files mypy", pty=True)
|
||||||
|
|
||||||
|
|
||||||
@task(pre=[pre_commit, test_cov])
|
@task(pre=[pre_commit, test_cov])
|
||||||
def check(ctx):
|
def check(ctx: Context) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def build(ctx):
|
def build(ctx: Context) -> None:
|
||||||
with ctx.cd(BASE_DIR):
|
with ctx.cd(BASE_DIR):
|
||||||
ctx.run("docker-compose build django", pty=True, echo=True)
|
ctx.run("docker-compose build django", pty=True, echo=True)
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def publish(ctx):
|
def publish(ctx: Context) -> None:
|
||||||
with ctx.cd(BASE_DIR):
|
with ctx.cd(BASE_DIR):
|
||||||
ctx.run("docker-compose push django", pty=True, echo=True)
|
ctx.run("docker-compose push django", pty=True, echo=True)
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def deploy(ctx):
|
def deploy(ctx: Context) -> None:
|
||||||
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
|
@task
|
||||||
def check_alive(ctx):
|
def check_alive(ctx: Context) -> None:
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
try:
|
try:
|
||||||
res = requests.get("https://gabnotes.org")
|
res = requests.get("https://gabnotes.org")
|
||||||
|
@ -70,12 +80,12 @@ def check_alive(ctx):
|
||||||
|
|
||||||
|
|
||||||
@task(pre=[check, build, publish, deploy], post=[check_alive])
|
@task(pre=[check, build, publish, deploy], post=[check_alive])
|
||||||
def beam(ctx):
|
def beam(ctx: Context) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def download_db(ctx):
|
def download_db(ctx: Context) -> None:
|
||||||
with ctx.cd(BASE_DIR):
|
with ctx.cd(BASE_DIR):
|
||||||
ctx.run("scp ubuntu:/home/gaugendre/blog/db/db.sqlite3 ./db/db.sqlite3")
|
ctx.run("scp ubuntu:/home/gaugendre/blog/db/db.sqlite3 ./db/db.sqlite3")
|
||||||
ctx.run("rm -rf src/media/")
|
ctx.run("rm -rf src/media/")
|
||||||
|
|
Reference in a new issue