diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py index 1feb495b..4896e07d 100644 --- a/bookwyrm/activitystreams.py +++ b/bookwyrm/activitystreams.py @@ -8,7 +8,6 @@ from django.utils import timezone from bookwyrm import models from bookwyrm.redis_store import RedisStore, r from bookwyrm.tasks import app -from bookwyrm.views.helpers import privacy_filter class ActivityStream(RedisStore): @@ -43,7 +42,7 @@ class ActivityStream(RedisStore): def add_user_statuses(self, viewer, user): """add a user's statuses to another user's feed""" # only add the statuses that the viewer should be able to see (ie, not dms) - statuses = privacy_filter(viewer, user.status_set.all()) + statuses = models.Status.privacy_filter(viewer).filter(user=user) self.bulk_add_objects_to_store(statuses, self.stream_id(viewer)) def remove_user_statuses(self, viewer, user): @@ -113,9 +112,8 @@ class ActivityStream(RedisStore): def get_statuses_for_user(self, user): # pylint: disable=no-self-use """given a user, what statuses should they see on this stream""" - return privacy_filter( + return models.Status.privacy_filter( user, - models.Status.objects.select_subclasses(), privacy_levels=["public", "unlisted", "followers"], ) @@ -139,11 +137,15 @@ class HomeStream(ActivityStream): ).distinct() def get_statuses_for_user(self, user): - return privacy_filter( + return models.Status.privacy_filter( user, - models.Status.objects.select_subclasses(), privacy_levels=["public", "unlisted", "followers"], - following_only=True, + ).exclude( + ~Q( # remove everything except + Q(user__followers=user) # user following + | Q(user=user) # is self + | Q(mention_users=user) # mentions user + ), ) @@ -160,11 +162,10 @@ class LocalStream(ActivityStream): def get_statuses_for_user(self, user): # all public statuses by a local user - return privacy_filter( + return models.Status.privacy_filter( user, - models.Status.objects.select_subclasses().filter(user__local=True), privacy_levels=["public"], - ) + ).filter(user__local=True) class BooksStream(ActivityStream): @@ -197,50 +198,53 @@ class BooksStream(ActivityStream): books = user.shelfbook_set.values_list( "book__parent_work__id", flat=True ).distinct() - return privacy_filter( - user, - models.Status.objects.select_subclasses() + return ( + models.Status.privacy_filter( + user, + privacy_levels=["public"], + ) .filter( Q(comment__book__parent_work__id__in=books) | Q(quotation__book__parent_work__id__in=books) | Q(review__book__parent_work__id__in=books) | Q(mention_books__parent_work__id__in=books) ) - .distinct(), - privacy_levels=["public"], + .distinct() ) def add_book_statuses(self, user, book): """add statuses about a book to a user's feed""" work = book.parent_work - statuses = privacy_filter( - user, - models.Status.objects.select_subclasses() + statuses = ( + models.Status.privacy_filter( + user, + privacy_levels=["public"], + ) .filter( Q(comment__book__parent_work=work) | Q(quotation__book__parent_work=work) | Q(review__book__parent_work=work) | Q(mention_books__parent_work=work) ) - .distinct(), - privacy_levels=["public"], + .distinct() ) self.bulk_add_objects_to_store(statuses, self.stream_id(user)) def remove_book_statuses(self, user, book): """add statuses about a book to a user's feed""" work = book.parent_work - statuses = privacy_filter( - user, - models.Status.objects.select_subclasses() + statuses = ( + models.Status.privacy_filter( + user, + privacy_levels=["public"], + ) .filter( Q(comment__book__parent_work=work) | Q(quotation__book__parent_work=work) | Q(review__book__parent_work=work) | Q(mention_books__parent_work=work) ) - .distinct(), - privacy_levels=["public"], + .distinct() ) self.bulk_remove_objects_from_store(statuses, self.stream_id(user)) @@ -480,12 +484,14 @@ def handle_boost_task(boost_id): instance = models.Status.objects.get(id=boost_id) boosted = instance.boost.boosted_status + # previous boosts of this status old_versions = models.Boost.objects.filter( boosted_status__id=boosted.id, created_date__lt=instance.created_date, ) for stream in streams.values(): + # people who should see the boost (not people who see the original status) audience = stream.get_stores_for_object(instance) stream.remove_object_from_related_stores(boosted, stores=audience) for status in old_versions: diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index 058aa478..ec6ffd40 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -4,6 +4,7 @@ from Crypto import Random from django.core.exceptions import PermissionDenied from django.db import models +from django.db.models import Q from django.dispatch import receiver from django.http import Http404 from django.utils.translation import gettext_lazy as _ @@ -122,6 +123,52 @@ class BookWyrmModel(models.Model): raise PermissionDenied() + @classmethod + def privacy_filter(cls, viewer, privacy_levels=None): + """filter objects that have "user" and "privacy" fields""" + queryset = cls.objects + if hasattr(queryset, "select_subclasses"): + queryset = queryset.select_subclasses() + + privacy_levels = privacy_levels or ["public", "unlisted", "followers", "direct"] + # you can't see followers only or direct messages if you're not logged in + if viewer.is_anonymous: + privacy_levels = [ + p for p in privacy_levels if not p in ["followers", "direct"] + ] + else: + # exclude blocks from both directions + queryset = queryset.exclude( + Q(user__blocked_by=viewer) | Q(user__blocks=viewer) + ) + + # filter to only provided privacy levels + queryset = queryset.filter(privacy__in=privacy_levels) + + if "followers" in privacy_levels: + queryset = cls.followers_filter(queryset, viewer) + + # exclude direct messages not intended for the user + if "direct" in privacy_levels: + queryset = cls.direct_filter(queryset, viewer) + + return queryset + + @classmethod + def followers_filter(cls, queryset, viewer): + """Override-able filter for "followers" privacy level""" + return queryset.exclude( + ~Q( # user isn't following and it isn't their own status + Q(user__followers=viewer) | Q(user=viewer) + ), + privacy="followers", # and the status is followers only + ) + + @classmethod + def direct_filter(cls, queryset, viewer): + """Override-able filter for "direct" privacy level""" + return queryset.exclude(~Q(user=viewer), privacy="direct") + @receiver(models.signals.post_save) # pylint: disable=unused-argument diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 58488123..1325aa88 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -6,6 +6,7 @@ from django.apps import apps from django.core.exceptions import PermissionDenied from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.db.models import Q from django.dispatch import receiver from django.template.loader import get_template from django.utils import timezone @@ -207,6 +208,18 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): if isinstance(self, (GeneratedNote, ReviewRating)): raise PermissionDenied() + @classmethod + def privacy_filter(cls, viewer, privacy_levels=None): + queryset = super().privacy_filter(viewer, privacy_levels=privacy_levels) + return queryset.filter(deleted=False) + + @classmethod + def direct_filter(cls, queryset, viewer): + """Overridden filter for "direct" privacy level""" + return queryset.exclude( + ~Q(Q(user=viewer) | Q(mention_users=viewer)), privacy="direct" + ) + class GeneratedNote(Status): """these are app-generated messages about user activity""" diff --git a/bookwyrm/redis_store.py b/bookwyrm/redis_store.py index 521e73b2..78f373a2 100644 --- a/bookwyrm/redis_store.py +++ b/bookwyrm/redis_store.py @@ -35,7 +35,7 @@ class RedisStore(ABC): def remove_object_from_related_stores(self, obj, stores=None): """remove an object from all stores""" - stores = stores or self.get_stores_for_object(obj) + stores = self.get_stores_for_object(obj) if stores is None else stores pipeline = r.pipeline() for store in stores: pipeline.zrem(store, -1, obj.id) diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 39469a7a..b2f90487 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -210,12 +210,13 @@ if USE_S3: AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"} # S3 Static settings STATIC_LOCATION = "static" - STATIC_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/" + STATIC_URL = f"{PROTOCOL}://{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/" STATICFILES_STORAGE = "bookwyrm.storage_backends.StaticStorage" # S3 Media settings MEDIA_LOCATION = "images" - MEDIA_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/" + MEDIA_URL = f"{PROTOCOL}://{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/" MEDIA_FULL_URL = MEDIA_URL + STATIC_FULL_URL = STATIC_URL DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage" # I don't know if it's used, but the site crashes without it STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static")) @@ -225,4 +226,5 @@ else: STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static")) MEDIA_URL = "/images/" MEDIA_FULL_URL = f"{PROTOCOL}://{DOMAIN}{MEDIA_URL}" + STATIC_FULL_URL = f"{PROTOCOL}://{DOMAIN}{STATIC_URL}" MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images")) diff --git a/bookwyrm/suggested_users.py b/bookwyrm/suggested_users.py index 4b85f1f6..f5ede190 100644 --- a/bookwyrm/suggested_users.py +++ b/bookwyrm/suggested_users.py @@ -151,6 +151,17 @@ def update_suggestions_on_follow(sender, instance, created, *args, **kwargs): rerank_user_task.delay(instance.user_object.id, update_only=False) +@receiver(signals.post_save, sender=models.UserFollowRequest) +# pylint: disable=unused-argument +def update_suggestions_on_follow_request(sender, instance, created, *args, **kwargs): + """remove a follow from the recs and update the ranks""" + if not created or not instance.user_object.discoverable: + return + + if instance.user_subject.local: + remove_suggestion_task.delay(instance.user_subject.id, instance.user_object.id) + + @receiver(signals.post_save, sender=models.UserBlocks) # pylint: disable=unused-argument def update_suggestions_on_block(sender, instance, *args, **kwargs): diff --git a/bookwyrm/templates/settings/dashboard/dashboard.html b/bookwyrm/templates/settings/dashboard/dashboard.html index fbf3ff72..f320028b 100644 --- a/bookwyrm/templates/settings/dashboard/dashboard.html +++ b/bookwyrm/templates/settings/dashboard/dashboard.html @@ -9,26 +9,26 @@ {% block panel %} -
-
+
+

{% trans "Total users" %}

{{ users|intcomma }}

-
+

{% trans "Active this month" %}

{{ active_users|intcomma }}

-
+

{% trans "Statuses" %}

{{ statuses|intcomma }}

-
+

{% trans "Works" %}

{{ works|intcomma }}

@@ -64,7 +64,7 @@

{% trans "Instance Activity" %}

-
+
-
-
-

{% trans "User signup activity" %}

+
+
+

{% trans "Total users" %}

-
+
+

{% trans "User signup activity" %}

+
+ +
+
+

{% trans "Status activity" %}

+
+

{% trans "Works created" %}

+
+ +
+
@@ -115,6 +127,8 @@ {% block scripts %} -{% include 'settings/dashboard/dashboard_user_chart.html' %} -{% include 'settings/dashboard/dashboard_status_chart.html' %} +{% include 'settings/dashboard/user_chart.html' %} +{% include 'settings/dashboard/status_chart.html' %} +{% include 'settings/dashboard/registration_chart.html' %} +{% include 'settings/dashboard/works_chart.html' %} {% endblock %} diff --git a/bookwyrm/templates/settings/dashboard/dashboard_status_chart.html b/bookwyrm/templates/settings/dashboard/dashboard_status_chart.html deleted file mode 100644 index bbacf3f4..00000000 --- a/bookwyrm/templates/settings/dashboard/dashboard_status_chart.html +++ /dev/null @@ -1,26 +0,0 @@ -{% load i18n %} - - diff --git a/bookwyrm/templates/settings/dashboard/dashboard_user_chart.html b/bookwyrm/templates/settings/dashboard/dashboard_user_chart.html deleted file mode 100644 index 33be28f7..00000000 --- a/bookwyrm/templates/settings/dashboard/dashboard_user_chart.html +++ /dev/null @@ -1,29 +0,0 @@ -{% load i18n %} - diff --git a/bookwyrm/templates/settings/dashboard/registration_chart.html b/bookwyrm/templates/settings/dashboard/registration_chart.html new file mode 100644 index 00000000..3b258fec --- /dev/null +++ b/bookwyrm/templates/settings/dashboard/registration_chart.html @@ -0,0 +1,19 @@ +{% load i18n %} + diff --git a/bookwyrm/templates/settings/dashboard/status_chart.html b/bookwyrm/templates/settings/dashboard/status_chart.html new file mode 100644 index 00000000..a59036a5 --- /dev/null +++ b/bookwyrm/templates/settings/dashboard/status_chart.html @@ -0,0 +1,21 @@ +{% load i18n %} + + diff --git a/bookwyrm/templates/settings/dashboard/user_chart.html b/bookwyrm/templates/settings/dashboard/user_chart.html new file mode 100644 index 00000000..a8d356bb --- /dev/null +++ b/bookwyrm/templates/settings/dashboard/user_chart.html @@ -0,0 +1,25 @@ +{% load i18n %} + diff --git a/bookwyrm/templates/settings/dashboard/works_chart.html b/bookwyrm/templates/settings/dashboard/works_chart.html new file mode 100644 index 00000000..c65014e9 --- /dev/null +++ b/bookwyrm/templates/settings/dashboard/works_chart.html @@ -0,0 +1,21 @@ +{% load i18n %} + + diff --git a/bookwyrm/templates/snippets/generated_status/rating.html b/bookwyrm/templates/snippets/generated_status/rating.html index 2e9fb601..4238be0e 100644 --- a/bookwyrm/templates/snippets/generated_status/rating.html +++ b/bookwyrm/templates/snippets/generated_status/rating.html @@ -1,6 +1,6 @@ {% load i18n %}{% load humanize %}{% load utilities %} -{% blocktrans trimmed with title=book|book_title path=book.remote_id display_rating=rating|floatformat:"0" count counter=rating|add:0 %} +{% blocktrans trimmed with title=book|book_title path=book.remote_id display_rating=rating|floatformat:"-1" count counter=rating|add:0 %} rated {{ title }}: {{ display_rating }} star {% plural %} rated {{ title }}: {{ display_rating }} stars diff --git a/bookwyrm/templates/snippets/generated_status/review_pure_name.html b/bookwyrm/templates/snippets/generated_status/review_pure_name.html index 25960191..e54a818e 100644 --- a/bookwyrm/templates/snippets/generated_status/review_pure_name.html +++ b/bookwyrm/templates/snippets/generated_status/review_pure_name.html @@ -1,7 +1,7 @@ {% load i18n %} {% if rating %} -{% blocktrans with book_title=book.title|safe display_rating=rating|floatformat:"0" review_title=name|safe count counter=rating %}Review of "{{ book_title }}" ({{ display_rating }} star): {{ review_title }}{% plural %}Review of "{{ book_title }}" ({{ display_rating }} stars): {{ review_title }}{% endblocktrans %} +{% blocktrans with book_title=book.title|safe display_rating=rating|floatformat:"-1" review_title=name|safe count counter=rating %}Review of "{{ book_title }}" ({{ display_rating }} star): {{ review_title }}{% plural %}Review of "{{ book_title }}" ({{ display_rating }} stars): {{ review_title }}{% endblocktrans %} {% else %} diff --git a/bookwyrm/templatetags/bookwyrm_tags.py b/bookwyrm/templatetags/bookwyrm_tags.py index 2e03c13b..bccd8c75 100644 --- a/bookwyrm/templatetags/bookwyrm_tags.py +++ b/bookwyrm/templatetags/bookwyrm_tags.py @@ -2,7 +2,7 @@ from django import template from django.db.models import Avg -from bookwyrm import models, views +from bookwyrm import models register = template.Library() @@ -11,8 +11,8 @@ register = template.Library() @register.filter(name="rating") def get_rating(book, user): """get the overall rating of a book""" - queryset = views.helpers.privacy_filter( - user, models.Review.objects.filter(book__parent_work__editions=book) + queryset = models.Review.privacy_filter(user).filter( + book__parent_work__editions=book ) return queryset.aggregate(Avg("rating"))["rating__avg"] diff --git a/bookwyrm/tests/activitystreams/test_tasks.py b/bookwyrm/tests/activitystreams/test_tasks.py index f4c85e1b..80b0b771 100644 --- a/bookwyrm/tests/activitystreams/test_tasks.py +++ b/bookwyrm/tests/activitystreams/test_tasks.py @@ -22,6 +22,16 @@ class Activitystreams(TestCase): local=True, localname="nutria", ) + with patch("bookwyrm.models.user.set_remote_server.delay"): + self.remote_user = models.User.objects.create_user( + "rat", + "rat@rat.com", + "ratword", + local=False, + remote_id="https://example.com/users/rat", + inbox="https://example.com/users/rat/inbox", + outbox="https://example.com/users/rat/outbox", + ) work = models.Work.objects.create(title="test work") self.book = models.Edition.objects.create(title="test book", parent_work=work) with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): @@ -125,7 +135,7 @@ class Activitystreams(TestCase): @patch("bookwyrm.activitystreams.BooksStream.remove_object_from_related_stores") @patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") def test_boost_to_another_timeline(self, *_): - """add a boost and deduplicate the boosted status on the timeline""" + """boost from a non-follower doesn't remove original status from feed""" status = models.Status.objects.create(user=self.local_user, content="hi") with patch("bookwyrm.activitystreams.handle_boost_task.delay"): boost = models.Boost.objects.create( @@ -138,11 +148,32 @@ class Activitystreams(TestCase): activitystreams.handle_boost_task(boost.id) self.assertTrue(mock.called) + self.assertEqual(mock.call_count, 1) call_args = mock.call_args self.assertEqual(call_args[0][0], status) - self.assertEqual( - call_args[1]["stores"], ["{:d}-home".format(self.another_user.id)] - ) + self.assertEqual(call_args[1]["stores"], [f"{self.another_user.id}-home"]) + + @patch("bookwyrm.activitystreams.LocalStream.remove_object_from_related_stores") + @patch("bookwyrm.activitystreams.BooksStream.remove_object_from_related_stores") + @patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") + def test_boost_to_another_timeline_remote(self, *_): + """boost from a remote non-follower doesn't remove original status from feed""" + status = models.Status.objects.create(user=self.local_user, content="hi") + with patch("bookwyrm.activitystreams.handle_boost_task.delay"): + boost = models.Boost.objects.create( + boosted_status=status, + user=self.remote_user, + ) + with patch( + "bookwyrm.activitystreams.HomeStream.remove_object_from_related_stores" + ) as mock: + activitystreams.handle_boost_task(boost.id) + + self.assertTrue(mock.called) + self.assertEqual(mock.call_count, 1) + call_args = mock.call_args + self.assertEqual(call_args[0][0], status) + self.assertEqual(call_args[1]["stores"], []) @patch("bookwyrm.activitystreams.LocalStream.remove_object_from_related_stores") @patch("bookwyrm.activitystreams.BooksStream.remove_object_from_related_stores") @@ -163,12 +194,8 @@ class Activitystreams(TestCase): self.assertTrue(mock.called) call_args = mock.call_args self.assertEqual(call_args[0][0], status) - self.assertTrue( - "{:d}-home".format(self.another_user.id) in call_args[1]["stores"] - ) - self.assertTrue( - "{:d}-home".format(self.local_user.id) in call_args[1]["stores"] - ) + self.assertTrue(f"{self.another_user.id}-home" in call_args[1]["stores"]) + self.assertTrue(f"{self.local_user.id}-home" in call_args[1]["stores"]) @patch("bookwyrm.activitystreams.LocalStream.remove_object_from_related_stores") @patch("bookwyrm.activitystreams.BooksStream.remove_object_from_related_stores") @@ -188,6 +215,4 @@ class Activitystreams(TestCase): self.assertTrue(mock.called) call_args = mock.call_args self.assertEqual(call_args[0][0], status) - self.assertEqual( - call_args[1]["stores"], ["{:d}-home".format(self.local_user.id)] - ) + self.assertEqual(call_args[1]["stores"], [f"{self.local_user.id}-home"]) diff --git a/bookwyrm/views/admin/dashboard.py b/bookwyrm/views/admin/dashboard.py index e02b9143..2766eeeb 100644 --- a/bookwyrm/views/admin/dashboard.py +++ b/bookwyrm/views/admin/dashboard.py @@ -25,13 +25,6 @@ class Dashboard(View): """list of users""" interval = int(request.GET.get("days", 1)) now = timezone.now() - - user_queryset = models.User.objects.filter(local=True) - user_stats = {"labels": [], "total": [], "active": []} - - status_queryset = models.Status.objects.filter(user__local=True, deleted=False) - status_stats = {"labels": [], "total": []} - start = request.GET.get("start") if start: start = timezone.make_aware(parse(start)) @@ -42,31 +35,55 @@ class Dashboard(View): end = timezone.make_aware(parse(end)) if end else now start = start.replace(hour=0, minute=0, second=0) - interval_start = start - interval_end = interval_start + timedelta(days=interval) - while interval_start <= end: - print(interval_start, interval_end) - interval_queryset = user_queryset.filter( - Q(is_active=True) | Q(deactivation_date__gt=interval_end), - created_date__lte=interval_end, - ) - user_stats["total"].append(interval_queryset.filter().count()) - user_stats["active"].append( - interval_queryset.filter( - last_active_date__gt=interval_end - timedelta(days=31), - ).count() - ) - user_stats["labels"].append(interval_start.strftime("%b %d")) + user_queryset = models.User.objects.filter(local=True) + user_chart = Chart( + queryset=user_queryset, + queries={ + "total": lambda q, s, e: q.filter( + Q(is_active=True) | Q(deactivation_date__gt=e), + created_date__lte=e, + ).count(), + "active": lambda q, s, e: q.filter( + Q(is_active=True) | Q(deactivation_date__gt=e), + created_date__lte=e, + ) + .filter( + last_active_date__gt=e - timedelta(days=31), + ) + .count(), + }, + ) - status_stats["total"].append( - status_queryset.filter( - created_date__gt=interval_start, - created_date__lte=interval_end, + status_queryset = models.Status.objects.filter(user__local=True, deleted=False) + status_chart = Chart( + queryset=status_queryset, + queries={ + "total": lambda q, s, e: q.filter( + created_date__gt=s, + created_date__lte=e, ).count() - ) - status_stats["labels"].append(interval_start.strftime("%b %d")) - interval_start = interval_end - interval_end += timedelta(days=interval) + }, + ) + + register_chart = Chart( + queryset=user_queryset, + queries={ + "total": lambda q, s, e: q.filter( + created_date__gt=s, + created_date__lte=e, + ).count() + }, + ) + + works_chart = Chart( + queryset=models.Work.objects, + queries={ + "total": lambda q, s, e: q.filter( + created_date__gt=s, + created_date__lte=e, + ).count() + }, + ) data = { "start": start.strftime("%Y-%m-%d"), @@ -82,7 +99,34 @@ class Dashboard(View): "invite_requests": models.InviteRequest.objects.filter( ignored=False, invite_sent=False ).count(), - "user_stats": user_stats, - "status_stats": status_stats, + "user_stats": user_chart.get_chart(start, end, interval), + "status_stats": status_chart.get_chart(start, end, interval), + "register_stats": register_chart.get_chart(start, end, interval), + "works_stats": works_chart.get_chart(start, end, interval), } return TemplateResponse(request, "settings/dashboard/dashboard.html", data) + + +class Chart: + """Data for a chart""" + + def __init__(self, queryset, queries: dict): + self.queryset = queryset + self.queries = queries + + def get_chart(self, start, end, interval): + """load the data for the chart given a time scale and interval""" + interval_start = start + interval_end = interval_start + timedelta(days=interval) + + chart = {k: [] for k in self.queries.keys()} + chart["labels"] = [] + while interval_start <= end: + for (name, query) in self.queries.items(): + chart[name].append(query(self.queryset, interval_start, interval_end)) + chart["labels"].append(interval_start.strftime("%b %d")) + + interval_start = interval_end + interval_end += timedelta(days=interval) + + return chart diff --git a/bookwyrm/views/books/books.py b/bookwyrm/views/books/books.py index 298ba5a3..e495da3e 100644 --- a/bookwyrm/views/books/books.py +++ b/bookwyrm/views/books/books.py @@ -16,7 +16,7 @@ from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.connectors import connector_manager from bookwyrm.connectors.abstract_connector import get_image from bookwyrm.settings import PAGE_LENGTH -from bookwyrm.views.helpers import is_api_request, privacy_filter +from bookwyrm.views.helpers import is_api_request # pylint: disable=no-self-use @@ -48,8 +48,8 @@ class Book(View): raise Http404() # all reviews for all editions of the book - reviews = privacy_filter( - request.user, models.Review.objects.filter(book__parent_work__editions=book) + reviews = models.Review.privacy_filter(request.user).filter( + book__parent_work__editions=book ) # the reviews to show @@ -66,12 +66,9 @@ class Book(View): queryset = queryset.select_related("user").order_by("-published_date") paginated = Paginator(queryset, PAGE_LENGTH) - lists = privacy_filter( - request.user, - models.List.objects.filter( - listitem__approved=True, - listitem__book__in=book.parent_work.editions.all(), - ), + lists = models.List.privacy_filter(request.user,).filter( + listitem__approved=True, + listitem__book__in=book.parent_work.editions.all(), ) data = { "book": book, diff --git a/bookwyrm/views/feed.py b/bookwyrm/views/feed.py index 7f1bc22c..8eff848c 100644 --- a/bookwyrm/views/feed.py +++ b/bookwyrm/views/feed.py @@ -13,7 +13,7 @@ from bookwyrm import activitystreams, forms, models from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.settings import PAGE_LENGTH, STREAMS from bookwyrm.suggested_users import suggested_users -from .helpers import get_user_from_username, privacy_filter +from .helpers import get_user_from_username from .helpers import is_api_request, is_bookwyrm_request @@ -56,11 +56,15 @@ class DirectMessage(View): def get(self, request, username=None): """like a feed but for dms only""" # remove fancy subclasses of status, keep just good ol' notes - queryset = models.Status.objects.filter( - review__isnull=True, - comment__isnull=True, - quotation__isnull=True, - generatednote__isnull=True, + activities = ( + models.Status.privacy_filter(request.user, privacy_levels=["direct"]) + .filter( + review__isnull=True, + comment__isnull=True, + quotation__isnull=True, + generatednote__isnull=True, + ) + .order_by("-published_date") ) user = None @@ -70,11 +74,7 @@ class DirectMessage(View): except Http404: pass if user: - queryset = queryset.filter(Q(user=user) | Q(mention_users=user)) - - activities = privacy_filter( - request.user, queryset, privacy_levels=["direct"] - ).order_by("-published_date") + activities = activities.filter(Q(user=user) | Q(mention_users=user)) paginated = Paginator(activities, PAGE_LENGTH) data = { @@ -109,9 +109,11 @@ class Status(View): status.to_activity(pure=not is_bookwyrm_request(request)) ) - visible_thread = privacy_filter( - request.user, models.Status.objects.filter(thread_id=status.thread_id) - ).values_list("id", flat=True) + visible_thread = ( + models.Status.privacy_filter(request.user) + .filter(thread_id=status.thread_id) + .values_list("id", flat=True) + ) visible_thread = list(visible_thread) ancestors = models.Status.objects.select_subclasses().raw( diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py index 7e469f7f..f31b41ff 100644 --- a/bookwyrm/views/helpers.py +++ b/bookwyrm/views/helpers.py @@ -6,8 +6,6 @@ import dateutil.tz from dateutil.parser import ParserError from requests import HTTPError -from django.core.exceptions import FieldError -from django.db.models import Q from django.http import Http404 from bookwyrm import activitypub, models @@ -50,56 +48,6 @@ def is_bookwyrm_request(request): return True -def privacy_filter(viewer, queryset, privacy_levels=None, following_only=False): - """filter objects that have "user" and "privacy" fields""" - privacy_levels = privacy_levels or ["public", "unlisted", "followers", "direct"] - # if there'd a deleted field, exclude deleted items - try: - queryset = queryset.filter(deleted=False) - except FieldError: - pass - - # exclude blocks from both directions - if not viewer.is_anonymous: - queryset = queryset.exclude(Q(user__blocked_by=viewer) | Q(user__blocks=viewer)) - - # you can't see followers only or direct messages if you're not logged in - if viewer.is_anonymous: - privacy_levels = [p for p in privacy_levels if not p in ["followers", "direct"]] - - # filter to only privided privacy levels - queryset = queryset.filter(privacy__in=privacy_levels) - - # only include statuses the user follows - if following_only: - queryset = queryset.exclude( - ~Q( # remove everythign except - Q(user__followers=viewer) - | Q(user=viewer) # user following - | Q(mention_users=viewer) # is self # mentions user - ), - ) - # exclude followers-only statuses the user doesn't follow - elif "followers" in privacy_levels: - queryset = queryset.exclude( - ~Q( # user isn't following and it isn't their own status - Q(user__followers=viewer) | Q(user=viewer) - ), - privacy="followers", # and the status is followers only - ) - - # exclude direct messages not intended for the user - if "direct" in privacy_levels: - try: - queryset = queryset.exclude( - ~Q(Q(user=viewer) | Q(mention_users=viewer)), privacy="direct" - ) - except FieldError: - queryset = queryset.exclude(~Q(user=viewer), privacy="direct") - - return queryset - - def handle_remote_webfinger(query): """webfingerin' other servers""" user = None diff --git a/bookwyrm/views/list.py b/bookwyrm/views/list.py index a312a755..21c5d4cd 100644 --- a/bookwyrm/views/list.py +++ b/bookwyrm/views/list.py @@ -18,7 +18,7 @@ from django.views.decorators.http import require_POST from bookwyrm import book_search, forms, models from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.settings import PAGE_LENGTH -from .helpers import is_api_request, privacy_filter +from .helpers import is_api_request from .helpers import get_user_from_username @@ -30,19 +30,16 @@ class Lists(View): """display a book list""" # hide lists with no approved books lists = ( - models.List.objects.annotate( - item_count=Count("listitem", filter=Q(listitem__approved=True)) + models.List.privacy_filter( + request.user, privacy_levels=["public", "followers"] ) + .annotate(item_count=Count("listitem", filter=Q(listitem__approved=True))) .filter(item_count__gt=0) .select_related("user") .prefetch_related("listitem_set") .order_by("-updated_date") .distinct() ) - - lists = privacy_filter( - request.user, lists, privacy_levels=["public", "followers"] - ) paginated = Paginator(lists, 12) data = { "lists": paginated.get_page(request.GET.get("page")), @@ -92,8 +89,7 @@ class UserLists(View): def get(self, request, username): """display a book list""" user = get_user_from_username(request.user, username) - lists = models.List.objects.filter(user=user) - lists = privacy_filter(request.user, lists) + lists = models.List.privacy_filter(request.user).filter(user=user) paginated = Paginator(lists, 12) data = { diff --git a/bookwyrm/views/rss_feed.py b/bookwyrm/views/rss_feed.py index 5faa1624..b924095c 100644 --- a/bookwyrm/views/rss_feed.py +++ b/bookwyrm/views/rss_feed.py @@ -4,7 +4,8 @@ from django.contrib.syndication.views import Feed from django.template.loader import get_template from django.utils.translation import gettext_lazy as _ -from .helpers import get_user_from_username, privacy_filter +from bookwyrm import models +from .helpers import get_user_from_username # pylint: disable=no-self-use, unused-argument class RssFeed(Feed): @@ -35,11 +36,10 @@ class RssFeed(Feed): def items(self, obj): """the user's activity feed""" - return privacy_filter( + return models.Status.privacy_filter( obj, - obj.status_set.select_subclasses(), privacy_levels=["public", "unlisted"], - ) + ).filter(user=obj) def item_link(self, item): """link to the status""" diff --git a/bookwyrm/views/search.py b/bookwyrm/views/search.py index d131b399..7a56ae72 100644 --- a/bookwyrm/views/search.py +++ b/bookwyrm/views/search.py @@ -13,7 +13,7 @@ from bookwyrm.connectors import connector_manager from bookwyrm.book_search import search, format_search_result from bookwyrm.settings import PAGE_LENGTH from bookwyrm.utils import regex -from .helpers import is_api_request, privacy_filter +from .helpers import is_api_request from .helpers import handle_remote_webfinger @@ -108,9 +108,8 @@ def user_search(query, viewer, *_): def list_search(query, viewer, *_): """any relevent lists?""" return ( - privacy_filter( + models.List.privacy_filter( viewer, - models.List.objects, privacy_levels=["public", "followers"], ) .annotate( diff --git a/bookwyrm/views/shelf.py b/bookwyrm/views/shelf.py index 35f660b5..37f320dc 100644 --- a/bookwyrm/views/shelf.py +++ b/bookwyrm/views/shelf.py @@ -17,7 +17,6 @@ from bookwyrm import forms, models from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.settings import PAGE_LENGTH from .helpers import is_api_request, get_user_from_username -from .helpers import privacy_filter # pylint: disable=no-self-use @@ -33,7 +32,7 @@ class Shelf(View): if is_self: shelves = user.shelf_set.all() else: - shelves = privacy_filter(request.user, user.shelf_set).all() + shelves = models.Shelf.privacy_filter(request.user).filter(user=user).all() # get the shelf and make sure the logged in user should be able to see it if shelf_identifier: @@ -58,16 +57,17 @@ class Shelf(View): if is_api_request(request): return ActivitypubResponse(shelf.to_activity(**request.GET)) - reviews = models.Review.objects.filter( + reviews = models.Review.objects + if not is_self: + reviews = models.Review.privacy_filter(request.user) + + reviews = reviews.filter( user=user, rating__isnull=False, book__id=OuterRef("id"), deleted=False, ).order_by("-published_date") - if not is_self: - reviews = privacy_filter(request.user, reviews) - books = books.annotate( rating=Subquery(reviews.values("rating")[:1]), shelved_date=F("shelfbook__shelved_date"), diff --git a/bookwyrm/views/user.py b/bookwyrm/views/user.py index b5a3f9e1..b7ab1d3c 100644 --- a/bookwyrm/views/user.py +++ b/bookwyrm/views/user.py @@ -12,7 +12,6 @@ from bookwyrm import models from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.settings import PAGE_LENGTH from .helpers import get_user_from_username, is_api_request -from .helpers import privacy_filter # pylint: disable=no-self-use @@ -56,10 +55,10 @@ class User(View): # user's posts activities = ( - privacy_filter( + models.Status.privacy_filter( request.user, - user.status_set.select_subclasses(), ) + .filter(user=user) .select_related( "user", "reply_parent", diff --git a/bookwyrm/views/wellknown.py b/bookwyrm/views/wellknown.py index 0f860441..de4f7e62 100644 --- a/bookwyrm/views/wellknown.py +++ b/bookwyrm/views/wellknown.py @@ -9,7 +9,7 @@ from django.utils import timezone from django.views.decorators.http import require_GET from bookwyrm import models -from bookwyrm.settings import DOMAIN, VERSION, MEDIA_FULL_URL +from bookwyrm.settings import DOMAIN, VERSION, MEDIA_FULL_URL, STATIC_FULL_URL @require_GET @@ -93,8 +93,7 @@ def instance_info(_): status_count = models.Status.objects.filter(user__local=True, deleted=False).count() site = models.SiteSettings.get() - logo_path = site.logo or "images/logo.png" - logo = f"{MEDIA_FULL_URL}{logo_path}" + logo = get_image_url(site.logo, "logo.png") return JsonResponse( { "uri": DOMAIN, @@ -134,8 +133,14 @@ def host_meta(request): def opensearch(request): """Open Search xml spec""" site = models.SiteSettings.get() - logo_path = site.favicon or "images/favicon.png" - logo = f"{MEDIA_FULL_URL}{logo_path}" + image = get_image_url(site.favicon, "favicon.png") return TemplateResponse( - request, "opensearch.xml", {"image": logo, "DOMAIN": DOMAIN} + request, "opensearch.xml", {"image": image, "DOMAIN": DOMAIN} ) + + +def get_image_url(obj, fallback): + """helper for loading the full path to an image""" + if obj: + return f"{MEDIA_FULL_URL}{obj}" + return f"{STATIC_FULL_URL}images/{fallback}"