From d1eced4f1f6c1aafaa8ad530e8df527d45851216 Mon Sep 17 00:00:00 2001 From: Gabriel Augendre Date: Sun, 27 Dec 2020 20:00:41 +0100 Subject: [PATCH] Allow sharing drafts publicly with a draft key --- articles/admin.py | 14 ++++++++++++- articles/migrations/0026_article_draft_key.py | 20 +++++++++++++++++++ articles/models.py | 12 +++++++++++ articles/tests/conftest.py | 3 +++ articles/tests/test_html_views.py | 15 ++++++++++++++ articles/views/html.py | 10 +++++++--- 6 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 articles/migrations/0026_article_draft_key.py diff --git a/articles/admin.py b/articles/admin.py index 1d23ea5..de3b5e7 100644 --- a/articles/admin.py +++ b/articles/admin.py @@ -36,6 +36,7 @@ class ArticleAdmin(admin.ModelAdmin): ("created_at", "updated_at"), "views_count", "has_code", + "draft_public_url", ] }, ), @@ -57,6 +58,7 @@ class ArticleAdmin(admin.ModelAdmin): "views_count", "status", "published_at", + "draft_public_url", ] prepopulated_fields = {"slug": ("title",)} change_form_template = "articles/article_change_form.html" @@ -80,7 +82,17 @@ class ArticleAdmin(admin.ModelAdmin): messages.success(request, f"{len(queryset)} articles unpublished.") unpublish.short_description = "Unpublish selected articles" - actions = [publish, unpublish] + + def refresh_draft_key(self, request, queryset): + if not request.user.has_perm("articles.change_article"): + messages.warning(request, "You're not allowed to do this.") + return + for article in queryset: + article.refresh_draft_key() + 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] class Media: css = {"all": ("admin_articles.css",)} diff --git a/articles/migrations/0026_article_draft_key.py b/articles/migrations/0026_article_draft_key.py new file mode 100644 index 0000000..f3360d8 --- /dev/null +++ b/articles/migrations/0026_article_draft_key.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1.4 on 2020-12-27 18:43 + +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("articles", "0025_article_custom_css"), + ] + + operations = [ + migrations.AddField( + model_name="article", + name="draft_key", + field=models.UUIDField(default=uuid.uuid4), + ), + ] diff --git a/articles/models.py b/articles/models.py index c5daa92..8083246 100644 --- a/articles/models.py +++ b/articles/models.py @@ -1,4 +1,5 @@ import re +import uuid from functools import cached_property import html2text @@ -12,6 +13,7 @@ from django.utils import timezone from markdown.extensions.codehilite import CodeHiliteExtension from articles.markdown import LazyLoadingImageExtension +from articles.utils import build_full_absolute_url class User(AbstractUser): @@ -47,6 +49,7 @@ class Article(AdminUrlMixin, models.Model): has_code = models.BooleanField(default=False, blank=True) is_home = models.BooleanField(default=False, blank=True) custom_css = models.TextField(blank=True) + draft_key = models.UUIDField(default=uuid.uuid4) class Meta: ordering = ["-published_at"] @@ -108,3 +111,12 @@ class Article(AdminUrlMixin, models.Model): if not self.slug: self.slug = slugify(self.title) return super().save(*args, **kwargs) + + @property + def draft_public_url(self): + url = self.get_absolute_url() + f"?draft_key={self.draft_key}" + return build_full_absolute_url(request=None, url=url) + + def refresh_draft_key(self): + self.draft_key = uuid.uuid4() + self.save() diff --git a/articles/tests/conftest.py b/articles/tests/conftest.py index be3b7c6..dae3822 100644 --- a/articles/tests/conftest.py +++ b/articles/tests/conftest.py @@ -1,3 +1,5 @@ +import uuid + import pytest from django.core.management import call_command from django.utils import timezone @@ -40,6 +42,7 @@ def unpublished_article(author: User) -> Article: published_at=None, slug="some-draft-article-slug", content="## some draft article markdown\n\n[a draft article link](https://article.com)", + draft_key=uuid.uuid4(), ) diff --git a/articles/tests/test_html_views.py b/articles/tests/test_html_views.py index 2439f55..5aa3574 100644 --- a/articles/tests/test_html_views.py +++ b/articles/tests/test_html_views.py @@ -41,6 +41,10 @@ def test_access_article_by_slug(client: Client, published_article: Article): def _test_access_article_by_slug(client: Client, item: Article): res = client.get(reverse("article-detail", kwargs={"slug": item.slug})) + _assert_article_is_rendered(item, res) + + +def _assert_article_is_rendered(item: Article, res): assert res.status_code == 200 content = res.content.decode("utf-8") assert item.title in content @@ -57,6 +61,17 @@ def test_anonymous_cant_access_draft_detail( assert res.status_code == 404 +@pytest.mark.django_db +def test_anonymous_can_access_draft_detail_with_key( + client: Client, unpublished_article: Article +): + res = client.get( + reverse("article-detail", kwargs={"slug": unpublished_article.slug}) + + f"?draft_key={unpublished_article.draft_key}" + ) + _assert_article_is_rendered(unpublished_article, res) + + @pytest.mark.django_db def test_user_can_access_draft_detail( client: Client, author: User, unpublished_article: Article diff --git a/articles/views/html.py b/articles/views/html.py index 8379242..2dfbb48 100644 --- a/articles/views/html.py +++ b/articles/views/html.py @@ -48,10 +48,14 @@ class ArticleDetailView(generic.DetailView): template_name = "articles/article_detail.html" def get_queryset(self): + key = self.request.GET.get("draft_key") + if key: + return Article.objects.filter(draft_key=key) + queryset = super().get_queryset() - if self.request.user.is_authenticated: - return queryset - return queryset.filter(status=Article.PUBLISHED) + if not self.request.user.is_authenticated: + queryset = queryset.filter(status=Article.PUBLISHED) + return queryset def get_object(self, queryset=None) -> Article: obj = super().get_object(queryset) # type: Article