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 @@
-
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 %}