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 197 additions and 49 deletions

View File

@ -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',
],
},

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)
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)

View File

@ -65,7 +65,7 @@
</div>
<div class="col">
<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-filled checkbox-like-icon-checked"></i>
{% if post.likes.count != 0 %}<span class="js-likes-count">{{ post.likes.count }}</span>{% endif %}
@ -75,15 +75,18 @@
<i class="i-edit"></i><span>Edit</span>
</a>
{% endif %}
<button class="btn btn-link" data-bs-toggle="dropdown">
<i class="i-more-vertical"></i>
</button>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item" href="https://projects.blender.org/studio/blender-studio/issues/new" target="_blank">
<i class="i-flag"></i>
<span>Report Problem</span>
</a>
</div>
{% if not show_subscribe_instead %}
<button class="btn btn-link" data-bs-toggle="dropdown">
<i class="i-more-vertical"></i>
</button>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item" href="https://projects.blender.org/studio/blender-studio/issues/new" target="_blank">
<i class="i-flag"></i>
<span>Report Problem</span>
</a>
</div>
{% endif %}
</div>
</div>
</div>

View File

@ -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 @@
<div class="mb-4">
<span>{{ post.date_published|date:"jS M Y" }}
{% if post.category %}
| {{ post.category }}
| {{ post.category }}
{% endif %}
</span>
{% if post.film %}
<span> |</span> <a class="text-primary" href="{{ post.film.url }}">{{ post.film }}</a>
<span> |</span> <a class="text-primary" href="{{ post.film.url }}">{{ post.film }}</a>
{% endif %}
</div>
<h1>{{ post.title|linebreaksbr }}</h1>
@ -43,11 +43,19 @@
{% include "blog/blog_toolbar.html" %}
<div class="markdown-text mb-4">
{% 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 %}
</div>
<section class="mb-4">
{% 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 %}
</section>
</div>
</div>

View File

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

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 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)

View File

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

View File

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