diff --git a/blog/admin.py b/blog/admin.py index 5d93a5c1..2cf79fcd 100644 --- a/blog/admin.py +++ b/blog/admin.py @@ -12,11 +12,13 @@ class PostAdmin(ViewOnSiteMixin, admin.ModelAdmin): '__str__', 'film', 'author', + 'is_subscribers_only', 'is_published', 'date_published', 'view_link', ] list_filter = [ + 'is_subscribers_only', 'is_published', 'film', ] @@ -46,6 +48,7 @@ class PostAdmin(ViewOnSiteMixin, admin.ModelAdmin): 'attachments', 'header', 'thumbnail', + 'is_subscribers_only', 'is_published', ], }, diff --git a/blog/migrations/0012_post_is_subscribers_only.py b/blog/migrations/0012_post_is_subscribers_only.py new file mode 100644 index 00000000..9f8b2bee --- /dev/null +++ b/blog/migrations/0012_post_is_subscribers_only.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.13 on 2024-07-02 09:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0011_post_contributors'), + ] + + operations = [ + migrations.AddField( + model_name='post', + name='is_subscribers_only', + field=models.BooleanField(default=False), + ), + ] diff --git a/blog/models.py b/blog/models.py index ccd2a326..5a11d036 100644 --- a/blog/models.py +++ b/blog/models.py @@ -35,6 +35,7 @@ class Post(mixins.CreatedUpdatedMixin, mixins.StaticThumbnailURLMixin, models.Mo date_published = models.DateTimeField(blank=True, null=True) legacy_id = models.CharField(max_length=256, blank=True) is_published = models.BooleanField(default=False) + is_subscribers_only = models.BooleanField(default=False) title = models.CharField(max_length=512) category = models.CharField(max_length=128, blank=True) diff --git a/blog/templates/blog/blog_toolbar.html b/blog/templates/blog/blog_toolbar.html index 6f731854..95642573 100644 --- a/blog/templates/blog/blog_toolbar.html +++ b/blog/templates/blog/blog_toolbar.html @@ -65,7 +65,7 @@
- - + + {% if not show_subscribe_instead %} + + + {% endif %}
diff --git a/blog/templates/blog/post_detail.html b/blog/templates/blog/post_detail.html index 8248dd06..0fee3254 100644 --- a/blog/templates/blog/post_detail.html +++ b/blog/templates/blog/post_detail.html @@ -4,7 +4,7 @@ {% load common_extras %} {% block head_style_extras %} -{% stylesheet 'vendor_highlight' %} + {% stylesheet 'vendor_highlight' %} {% endblock %} {% block title_prepend %}{{ post.title }} - Blog - {% endblock title_prepend %} @@ -31,11 +31,11 @@
{{ post.date_published|date:"jS M Y" }} {% if post.category %} - | {{ post.category }} + | {{ post.category }} {% endif %} {% if post.film %} - | {{ post.film }} + | {{ post.film }} {% endif %}

{{ post.title|linebreaksbr }}

@@ -43,11 +43,19 @@ {% include "blog/blog_toolbar.html" %}
- {% with_shortcodes post.content_html %} + {# Subscribe banner might have to be displayed instead of content #} + {% if show_subscribe_instead %} + {% include "common/components/content_locked.html" %} + {% else %} + {% with_shortcodes post.content_html %} + {% endif %}
- {% include 'comments/components/comment_section.html' %} + {# Display comment section only when the content is visible #} + {% if not show_subscribe_instead %} + {% include 'comments/components/comment_section.html' %} + {% endif %}
diff --git a/blog/tests/test_blog_posts.py b/blog/tests/test_blog_posts.py index e32fe17e..fc153472 100644 --- a/blog/tests/test_blog_posts.py +++ b/blog/tests/test_blog_posts.py @@ -7,6 +7,8 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase, override_settings from django.urls import reverse +from looper.tests.factories import SubscriptionFactory + from blog.models import Post from comments.models import Comment from common.tests.factories.blog import PostFactory @@ -20,7 +22,7 @@ User = get_user_model() @override_settings( - DEFAULT_FILE_STORAGE='django.core.files.storage.FileSystemStorage', + DEFAULT_FILE_STORAGE='django.core.files.storage.InMemoryStorage', ) class TestPostCreation(TestCase): @classmethod @@ -472,3 +474,79 @@ class TestPostComments(TestCase): # Blog post's author should be notified about the comment on their post self.assertEqual(list(Action.objects.notifications(self.post.author)), [action]) self.assertEqual(list(Action.objects.notifications(self.post_comment.user)), []) + + +@override_settings( + DEFAULT_FILE_STORAGE='django.core.files.storage.FileSystemStorage', +) +class TestViewPost(TestCase): + @classmethod + def setUpTestData(cls) -> None: + # PostFactory mutes signals, hence a separate UserFactory call to make sure Customer is created + cls.post = PostFactory(is_subscribers_only=False, author=UserFactory()) + cls.post_subscribers_only = PostFactory(is_subscribers_only=True, author=UserFactory()) + + def test_post_can_be_viewed_by_anyone(self): + subscriber = UserFactory() + SubscriptionFactory(customer=subscriber.customer, status='active') + + group, _ = Group.objects.get_or_create(name='demo') + demo = UserFactory() + demo.groups.add(group) + + url = self.post.get_absolute_url() + for account, role in ( + (None, 'anonymous'), + (subscriber, 'subscriber'), + (demo, 'demo'), + (self.post.author, 'author'), + ): + with self.subTest(account=account, role=role): + assert not account or account.customer + self.client.logout() + if account: + self.client.force_login(account) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.post.content_html) + + def test_subscribers_only_post_cannot_be_viewed_by_non_subscriber(self): + non_subscriber = UserFactory() + + url = self.post_subscribers_only.get_absolute_url() + for account, role in ( + (None, 'anonymous'), + (non_subscriber, 'non-subscriber'), + ): + with self.subTest(account=account, role=role): + assert not account or account.customer + self.client.logout() + if account: + self.client.force_login(account) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, self.post_subscribers_only.content_html) + self.assertContains(response, 'Join Blender Studio') + self.assertContains(response, '/join/') + + def test_subscribers_only_post_can_be_viewed_by_subscriber_or_demo(self): + subscriber = UserFactory() + SubscriptionFactory(customer=subscriber.customer, status='active') + + group, _ = Group.objects.get_or_create(name='demo') + demo = UserFactory() + demo.groups.add(group) + + url = self.post_subscribers_only.get_absolute_url() + for account, role in ( + (subscriber, 'subscriber'), + (demo, 'demo'), + ): + with self.subTest(account=account, role=role): + assert account.customer + self.client.logout() + if account: + self.client.force_login(account) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.post_subscribers_only.content_html) diff --git a/blog/tests/test_feed.py b/blog/tests/test_feed.py index 316e79ca..49531975 100644 --- a/blog/tests/test_feed.py +++ b/blog/tests/test_feed.py @@ -1,23 +1,44 @@ -from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings +from django.urls import reverse_lazy -from django.test import TestCase -from django.urls import reverse - -from common.tests.factories.films import FilmFactory -from common.tests.factories.helpers import create_test_image - - -User = get_user_model() +from common.tests.factories.blog import PostFactory +from common.tests.factories.users import UserFactory +@override_settings( + DEFAULT_FILE_STORAGE='django.core.files.storage.InMemoryStorage', +) class TestPostFeed(TestCase): + feed_url = reverse_lazy('post-feed') + @classmethod def setUpTestData(cls) -> None: - cls.admin = User.objects.create_superuser(username='superuser') - cls.film = FilmFactory() - cls.thumbnail = create_test_image() - cls.post_add_url = reverse('admin:blog_post_add') + cls.free_posts = [ + PostFactory( + is_subscribers_only=False, + author=UserFactory(), + is_published=True, + thumbnail=SimpleUploadedFile('test.png', b'foobar', content_type='image/png'), + ) + for _ in range(2) + ] + cls.post_subscribers_only = PostFactory( + is_subscribers_only=True, author=UserFactory(), is_published=True + ) - def test_feed_index(self): - r = self.client.get(reverse('post-feed')) - self.assertEqual(r.status_code, 200) + def test_feed_contains_free_post_but_not_subscriber_only_posts(self): + response = self.client.get(self.feed_url) + + self.assertEqual(response.status_code, 200) + + self.assertContains(response, self.free_posts[0].get_absolute_url()) + self.assertContains(response, self.free_posts[1].get_absolute_url()) + self.assertContains(response, self.free_posts[0].excerpt) + self.assertContains(response, self.free_posts[1].excerpt) + self.assertContains(response, self.free_posts[0].title) + self.assertContains(response, self.free_posts[1].title) + + self.assertNotContains(response, self.post_subscribers_only.get_absolute_url()) + self.assertNotContains(response, self.post_subscribers_only.excerpt) + self.assertNotContains(response, self.post_subscribers_only.title) diff --git a/blog/views/blog.py b/blog/views/blog.py index 4bc9ab29..0636f3b9 100644 --- a/blog/views/blog.py +++ b/blog/views/blog.py @@ -1,15 +1,17 @@ +"""Views that render blog pages are defined here.""" from typing import List -from django.views.generic import ListView, DetailView -from django.db.models.query import QuerySet from django.contrib.syndication.views import Feed +from django.db.models.query import QuerySet from django.utils import feedgenerator +from django.views.generic import ListView, DetailView from blog.models import Post, Like from blog.queries import get_posts from comments.models import Comment from comments.queries import get_annotated_comments from comments.views.common import comments_to_template_type +import common.queries class PostList(ListView): @@ -27,17 +29,25 @@ class PostDetail(DetailView): model = Post context_object_name = 'post' - def get_object(self) -> Post: - object_ = super().get_object() - if self.request.user.is_authenticated: - object_.liked = Like.objects.filter( - post_id=object_.pk, user_id=self.request.user.pk - ).exists() - return object_ - def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - post: Post = self.get_object() + post: Post = self.object + + show_subscribe_instead = post.is_subscribers_only and ( + self.request.user.is_anonymous + or ( + # This is neither the author of the post nor a subscriber + self.request.user.pk != post.author_id + and not common.queries.has_active_subscription(self.request.user) + ) + ) + + if self.request.user.is_authenticated: + post.liked = Like.objects.filter(post_id=post.pk, user_id=self.request.user.pk).exists() + + # Whether the subscribe banner should be displayed instead of content + context['show_subscribe_instead'] = show_subscribe_instead + # Display edit buttons for editors/authors context['user_can_edit_post'] = self.request.user.is_staff and self.request.user.has_perm( 'blog.change_post' @@ -46,11 +56,12 @@ class PostDetail(DetailView): if post.film and post.author.film_crew.filter(film=post.film): context['user_film_role'] = post.author.film_crew.filter(film=post.film)[0].role - # Comment threads - comments: List[Comment] = get_annotated_comments(post, self.request.user.pk) - context['comments'] = comments_to_template_type( - comments, post.comment_url, self.request.user - ) + # Comment threads are only shown when post is visible + if not show_subscribe_instead: + comments: List[Comment] = get_annotated_comments(post, self.request.user.pk) + context['comments'] = comments_to_template_type( + comments, post.comment_url, self.request.user + ) return context @@ -70,7 +81,9 @@ class PostFeed(Feed): ] def items(self): - return Post.objects.filter(is_published=True).order_by('-date_published')[:5] + return Post.objects.filter(is_published=True, is_subscribers_only=False).order_by( + '-date_published' + )[:5] def item_title(self, item): return item.title diff --git a/common/templates/common/components/cards/card_blog.html b/common/templates/common/components/cards/card_blog.html index e11034c3..939fc02a 100644 --- a/common/templates/common/components/cards/card_blog.html +++ b/common/templates/common/components/cards/card_blog.html @@ -49,6 +49,9 @@ {{ post.comments.count }} {% endif %} + {% if not post.is_subscribers_only %} + {% include "common/components/cards/pill.html" with label='Free' %} + {% endif %}