Subscribers only blog posts #104416

Merged
Anna Sirota merged 2 commits from post-is-subscribers-only into main 2024-07-02 15:49:12 +02:00
9 changed files with 196 additions and 48 deletions
Showing only changes of commit 7b1dd948cc - Show all commits

View File

@ -12,11 +12,13 @@ class PostAdmin(ViewOnSiteMixin, admin.ModelAdmin):
'__str__', '__str__',
'film', 'film',
'author', 'author',
'is_subscribers_only',
'is_published', 'is_published',
'date_published', 'date_published',
'view_link', 'view_link',
] ]
list_filter = [ list_filter = [
'is_subscribers_only',
'is_published', 'is_published',
'film', 'film',
] ]
@ -46,6 +48,7 @@ class PostAdmin(ViewOnSiteMixin, admin.ModelAdmin):
'attachments', 'attachments',
'header', 'header',
'thumbnail', 'thumbnail',
'is_subscribers_only',
'is_published', 'is_published',
], ],
}, },

View File

@ -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),
),
]

View File

@ -35,6 +35,7 @@ class Post(mixins.CreatedUpdatedMixin, mixins.StaticThumbnailURLMixin, models.Mo
date_published = models.DateTimeField(blank=True, null=True) date_published = models.DateTimeField(blank=True, null=True)
legacy_id = models.CharField(max_length=256, blank=True) legacy_id = models.CharField(max_length=256, blank=True)
is_published = models.BooleanField(default=False) is_published = models.BooleanField(default=False)
is_subscribers_only = models.BooleanField(default=False)
title = models.CharField(max_length=512) title = models.CharField(max_length=512)
category = models.CharField(max_length=128, blank=True) category = models.CharField(max_length=128, blank=True)

View File

@ -65,7 +65,7 @@
</div> </div>
<div class="col"> <div class="col">
<div class="btn-row justify-content-end"> <div class="btn-row justify-content-end">
<button class="btn btn-link checkbox-like {% if not user.is_authenticated %}disabled{% endif %}" {% if not user.is_authenticated %}disabled{% endif %} {% if post.liked %}data-checked="checked" {% endif %} data-like-url="{{ post.like_url }}"> <button class="btn btn-link checkbox-like {% if not user.is_authenticated or show_subscribe_instead %}disabled{% endif %}" {% if not user.is_authenticated or show_subscribe_instead %}disabled{% endif %} {% if post.liked %}data-checked="checked" {% endif %} data-like-url="{{ post.like_url }}">
<i class="i-heart checkbox-like-icon-unchecked"></i> <i class="i-heart checkbox-like-icon-unchecked"></i>
<i class="i-heart-filled checkbox-like-icon-checked"></i> <i class="i-heart-filled checkbox-like-icon-checked"></i>
{% if post.likes.count != 0 %}<span class="js-likes-count">{{ post.likes.count }}</span>{% endif %} {% if post.likes.count != 0 %}<span class="js-likes-count">{{ post.likes.count }}</span>{% endif %}
@ -75,6 +75,8 @@
<i class="i-edit"></i><span>Edit</span> <i class="i-edit"></i><span>Edit</span>
</a> </a>
{% endif %} {% endif %}
{% if not show_subscribe_instead %}
<button class="btn btn-link" data-bs-toggle="dropdown"> <button class="btn btn-link" data-bs-toggle="dropdown">
<i class="i-more-vertical"></i> <i class="i-more-vertical"></i>
</button> </button>
@ -84,6 +86,7 @@
<span>Report Problem</span> <span>Report Problem</span>
</a> </a>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -4,7 +4,7 @@
{% load common_extras %} {% load common_extras %}
{% block head_style_extras %} {% block head_style_extras %}
{% stylesheet 'vendor_highlight' %} {% stylesheet 'vendor_highlight' %}
{% endblock %} {% endblock %}
{% block title_prepend %}{{ post.title }} - Blog - {% endblock title_prepend %} {% block title_prepend %}{{ post.title }} - Blog - {% endblock title_prepend %}
@ -43,11 +43,19 @@
{% include "blog/blog_toolbar.html" %} {% include "blog/blog_toolbar.html" %}
<div class="markdown-text mb-4"> <div class="markdown-text mb-4">
{# 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 %} {% with_shortcodes post.content_html %}
{% endif %}
</div> </div>
<section class="mb-4"> <section class="mb-4">
{# Display comment section only when the content is visible #}
{% if not show_subscribe_instead %}
{% include 'comments/components/comment_section.html' %} {% include 'comments/components/comment_section.html' %}
{% endif %}
</section> </section>
</div> </div>
</div> </div>

View File

@ -7,6 +7,8 @@ from django.contrib.contenttypes.models import ContentType
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from looper.tests.factories import SubscriptionFactory
from blog.models import Post from blog.models import Post
from comments.models import Comment from comments.models import Comment
from common.tests.factories.blog import PostFactory from common.tests.factories.blog import PostFactory
@ -472,3 +474,79 @@ class TestPostComments(TestCase):
# Blog post's author should be notified about the comment on their post # 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.author)), [action])
self.assertEqual(list(Action.objects.notifications(self.post_comment.user)), []) 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)

View File

@ -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 common.tests.factories.blog import PostFactory
from django.urls import reverse from common.tests.factories.users import UserFactory
from common.tests.factories.films import FilmFactory
from common.tests.factories.helpers import create_test_image
User = get_user_model()
@override_settings(
DEFAULT_FILE_STORAGE='django.core.files.storage.InMemoryStorage',
)
class TestPostFeed(TestCase): class TestPostFeed(TestCase):
feed_url = reverse_lazy('post-feed')
@classmethod @classmethod
def setUpTestData(cls) -> None: def setUpTestData(cls) -> None:
cls.admin = User.objects.create_superuser(username='superuser') cls.free_posts = [
cls.film = FilmFactory() PostFactory(
cls.thumbnail = create_test_image() is_subscribers_only=False,
cls.post_add_url = reverse('admin:blog_post_add') 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): def test_feed_contains_free_post_but_not_subscriber_only_posts(self):
r = self.client.get(reverse('post-feed')) response = self.client.get(self.feed_url)
self.assertEqual(r.status_code, 200)
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)

View File

@ -1,15 +1,17 @@
"""Views that render blog pages are defined here."""
from typing import List 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.contrib.syndication.views import Feed
from django.db.models.query import QuerySet
from django.utils import feedgenerator from django.utils import feedgenerator
from django.views.generic import ListView, DetailView
from blog.models import Post, Like from blog.models import Post, Like
from blog.queries import get_posts from blog.queries import get_posts
from comments.models import Comment from comments.models import Comment
from comments.queries import get_annotated_comments from comments.queries import get_annotated_comments
from comments.views.common import comments_to_template_type from comments.views.common import comments_to_template_type
import common.queries
class PostList(ListView): class PostList(ListView):
@ -27,17 +29,25 @@ class PostDetail(DetailView):
model = Post model = Post
context_object_name = '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): def get_context_data(self, **kwargs):
context = super().get_context_data(**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 # Display edit buttons for editors/authors
context['user_can_edit_post'] = self.request.user.is_staff and self.request.user.has_perm( context['user_can_edit_post'] = self.request.user.is_staff and self.request.user.has_perm(
'blog.change_post' 'blog.change_post'
@ -46,7 +56,8 @@ class PostDetail(DetailView):
if post.film and post.author.film_crew.filter(film=post.film): 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 context['user_film_role'] = post.author.film_crew.filter(film=post.film)[0].role
# Comment threads # 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) comments: List[Comment] = get_annotated_comments(post, self.request.user.pk)
context['comments'] = comments_to_template_type( context['comments'] = comments_to_template_type(
comments, post.comment_url, self.request.user comments, post.comment_url, self.request.user
@ -70,7 +81,9 @@ class PostFeed(Feed):
] ]
def items(self): 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): def item_title(self, item):
return item.title return item.title

View File

@ -49,6 +49,9 @@
{{ post.comments.count }} {{ post.comments.count }}
</div> </div>
{% endif %} {% endif %}
{% if not post.is_subscribers_only %}
{% include "common/components/cards/pill.html" with label='Free' %}
{% endif %}
</div> </div>
</a> </a>
</div> </div>