Merge remote-tracking branch 'origin/bookwyrm-groups' into bookwyrm-groups

Merge changes from main project into local branch
This commit is contained in:
Hugh Rundle 2021-10-08 18:53:59 +11:00
commit 39e002ee13
74 changed files with 96262 additions and 4267 deletions

View file

@ -8,7 +8,6 @@ from django.utils import timezone
from bookwyrm import models from bookwyrm import models
from bookwyrm.redis_store import RedisStore, r from bookwyrm.redis_store import RedisStore, r
from bookwyrm.tasks import app from bookwyrm.tasks import app
from bookwyrm.views.helpers import privacy_filter
class ActivityStream(RedisStore): class ActivityStream(RedisStore):
@ -43,7 +42,7 @@ class ActivityStream(RedisStore):
def add_user_statuses(self, viewer, user): def add_user_statuses(self, viewer, user):
"""add a user's statuses to another user's feed""" """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) # 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)) self.bulk_add_objects_to_store(statuses, self.stream_id(viewer))
def remove_user_statuses(self, viewer, user): 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 def get_statuses_for_user(self, user): # pylint: disable=no-self-use
"""given a user, what statuses should they see on this stream""" """given a user, what statuses should they see on this stream"""
return privacy_filter( return models.Status.privacy_filter(
user, user,
models.Status.objects.select_subclasses(),
privacy_levels=["public", "unlisted", "followers"], privacy_levels=["public", "unlisted", "followers"],
) )
@ -139,11 +137,15 @@ class HomeStream(ActivityStream):
).distinct() ).distinct()
def get_statuses_for_user(self, user): def get_statuses_for_user(self, user):
return privacy_filter( return models.Status.privacy_filter(
user, user,
models.Status.objects.select_subclasses(),
privacy_levels=["public", "unlisted", "followers"], 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): def get_statuses_for_user(self, user):
# all public statuses by a local user # all public statuses by a local user
return privacy_filter( return models.Status.privacy_filter(
user, user,
models.Status.objects.select_subclasses().filter(user__local=True),
privacy_levels=["public"], privacy_levels=["public"],
) ).filter(user__local=True)
class BooksStream(ActivityStream): class BooksStream(ActivityStream):
@ -197,50 +198,53 @@ class BooksStream(ActivityStream):
books = user.shelfbook_set.values_list( books = user.shelfbook_set.values_list(
"book__parent_work__id", flat=True "book__parent_work__id", flat=True
).distinct() ).distinct()
return privacy_filter( return (
models.Status.privacy_filter(
user, user,
models.Status.objects.select_subclasses() privacy_levels=["public"],
)
.filter( .filter(
Q(comment__book__parent_work__id__in=books) Q(comment__book__parent_work__id__in=books)
| Q(quotation__book__parent_work__id__in=books) | Q(quotation__book__parent_work__id__in=books)
| Q(review__book__parent_work__id__in=books) | Q(review__book__parent_work__id__in=books)
| Q(mention_books__parent_work__id__in=books) | Q(mention_books__parent_work__id__in=books)
) )
.distinct(), .distinct()
privacy_levels=["public"],
) )
def add_book_statuses(self, user, book): def add_book_statuses(self, user, book):
"""add statuses about a book to a user's feed""" """add statuses about a book to a user's feed"""
work = book.parent_work work = book.parent_work
statuses = privacy_filter( statuses = (
models.Status.privacy_filter(
user, user,
models.Status.objects.select_subclasses() privacy_levels=["public"],
)
.filter( .filter(
Q(comment__book__parent_work=work) Q(comment__book__parent_work=work)
| Q(quotation__book__parent_work=work) | Q(quotation__book__parent_work=work)
| Q(review__book__parent_work=work) | Q(review__book__parent_work=work)
| Q(mention_books__parent_work=work) | Q(mention_books__parent_work=work)
) )
.distinct(), .distinct()
privacy_levels=["public"],
) )
self.bulk_add_objects_to_store(statuses, self.stream_id(user)) self.bulk_add_objects_to_store(statuses, self.stream_id(user))
def remove_book_statuses(self, user, book): def remove_book_statuses(self, user, book):
"""add statuses about a book to a user's feed""" """add statuses about a book to a user's feed"""
work = book.parent_work work = book.parent_work
statuses = privacy_filter( statuses = (
models.Status.privacy_filter(
user, user,
models.Status.objects.select_subclasses() privacy_levels=["public"],
)
.filter( .filter(
Q(comment__book__parent_work=work) Q(comment__book__parent_work=work)
| Q(quotation__book__parent_work=work) | Q(quotation__book__parent_work=work)
| Q(review__book__parent_work=work) | Q(review__book__parent_work=work)
| Q(mention_books__parent_work=work) | Q(mention_books__parent_work=work)
) )
.distinct(), .distinct()
privacy_levels=["public"],
) )
self.bulk_remove_objects_from_store(statuses, self.stream_id(user)) 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) instance = models.Status.objects.get(id=boost_id)
boosted = instance.boost.boosted_status boosted = instance.boost.boosted_status
# previous boosts of this status
old_versions = models.Boost.objects.filter( old_versions = models.Boost.objects.filter(
boosted_status__id=boosted.id, boosted_status__id=boosted.id,
created_date__lt=instance.created_date, created_date__lt=instance.created_date,
) )
for stream in streams.values(): 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) audience = stream.get_stores_for_object(instance)
stream.remove_object_from_related_stores(boosted, stores=audience) stream.remove_object_from_related_stores(boosted, stores=audience)
for status in old_versions: for status in old_versions:

View file

@ -144,6 +144,7 @@ class EditUserForm(CustomForm):
"default_post_privacy", "default_post_privacy",
"discoverable", "discoverable",
"preferred_timezone", "preferred_timezone",
"preferred_language",
] ]
help_texts = {f: None for f in fields} help_texts = {f: None for f in fields}

View file

@ -0,0 +1,30 @@
# Generated by Django 3.2.5 on 2021-10-06 19:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0105_alter_connector_connector_file"),
]
operations = [
migrations.AddField(
model_name="user",
name="preferred_language",
field=models.CharField(
blank=True,
choices=[
("en-us", "English"),
("de-de", "German"),
("es", "Spanish"),
("fr-fr", "French"),
("zh-hans", "Simplified Chinese"),
("zh-hant", "Traditional Chinese"),
],
max_length=255,
null=True,
),
),
]

View file

@ -4,6 +4,7 @@ from Crypto import Random
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import models from django.db import models
from django.db.models import Q
from django.dispatch import receiver from django.dispatch import receiver
from django.http import Http404 from django.http import Http404
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -122,6 +123,52 @@ class BookWyrmModel(models.Model):
raise PermissionDenied() 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) @receiver(models.signals.post_save)
# pylint: disable=unused-argument # pylint: disable=unused-argument

View file

@ -6,6 +6,7 @@ from django.apps import apps
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import Q
from django.dispatch import receiver from django.dispatch import receiver
from django.template.loader import get_template from django.template.loader import get_template
from django.utils import timezone from django.utils import timezone
@ -207,6 +208,18 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
if isinstance(self, (GeneratedNote, ReviewRating)): if isinstance(self, (GeneratedNote, ReviewRating)):
raise PermissionDenied() 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): class GeneratedNote(Status):
"""these are app-generated messages about user activity""" """these are app-generated messages about user activity"""

View file

@ -17,7 +17,7 @@ from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.models.shelf import Shelf from bookwyrm.models.shelf import Shelf
from bookwyrm.models.status import Status, Review from bookwyrm.models.status import Status, Review
from bookwyrm.preview_images import generate_user_preview_image_task from bookwyrm.preview_images import generate_user_preview_image_task
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES
from bookwyrm.signatures import create_key_pair from bookwyrm.signatures import create_key_pair
from bookwyrm.tasks import app from bookwyrm.tasks import app
from bookwyrm.utils import regex from bookwyrm.utils import regex
@ -133,6 +133,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
default=str(pytz.utc), default=str(pytz.utc),
max_length=255, max_length=255,
) )
preferred_language = models.CharField(
choices=LANGUAGES,
null=True,
blank=True,
max_length=255,
)
deactivation_reason = models.CharField( deactivation_reason = models.CharField(
max_length=255, choices=DeactivationReason, null=True, blank=True max_length=255, choices=DeactivationReason, null=True, blank=True
) )

View file

@ -35,7 +35,7 @@ class RedisStore(ABC):
def remove_object_from_related_stores(self, obj, stores=None): def remove_object_from_related_stores(self, obj, stores=None):
"""remove an object from all stores""" """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() pipeline = r.pipeline()
for store in stores: for store in stores:
pipeline.zrem(store, -1, obj.id) pipeline.zrem(store, -1, obj.id)

View file

@ -30,6 +30,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LOCALE_PATHS = [ LOCALE_PATHS = [
os.path.join(BASE_DIR, "locale"), os.path.join(BASE_DIR, "locale"),
] ]
LANGUAGE_COOKIE_NAME = env.str("LANGUAGE_COOKIE_NAME", "django_language")
DEFAULT_AUTO_FIELD = "django.db.models.AutoField" DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
@ -161,11 +162,11 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = "en-us" LANGUAGE_CODE = "en-us"
LANGUAGES = [ LANGUAGES = [
("en-us", _("English")), ("en-us", _("English")),
("de-de", _("German")), ("de-de", _("Deutsch (German)")), # German
("es", _("Spanish")), ("es", _("Español (Spanish)")), # Spanish
("fr-fr", _("French")), ("fr-fr", _("Français (French)")), # French
("zh-hans", _("Simplified Chinese")), ("zh-hans", _("简体中文 (Simplified Chinese)")), # Simplified Chinese
("zh-hant", _("Traditional Chinese")), ("zh-hant", _("繁體中文 (Traditional Chinese)")), # Traditional Chinese
] ]
@ -210,12 +211,13 @@ if USE_S3:
AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"} AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"}
# S3 Static settings # S3 Static settings
STATIC_LOCATION = "static" 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" STATICFILES_STORAGE = "bookwyrm.storage_backends.StaticStorage"
# S3 Media settings # S3 Media settings
MEDIA_LOCATION = "images" 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 MEDIA_FULL_URL = MEDIA_URL
STATIC_FULL_URL = STATIC_URL
DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage" DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage"
# I don't know if it's used, but the site crashes without it # 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")) STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
@ -225,4 +227,5 @@ else:
STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static")) STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
MEDIA_URL = "/images/" MEDIA_URL = "/images/"
MEDIA_FULL_URL = f"{PROTOCOL}://{DOMAIN}{MEDIA_URL}" 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")) MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images"))

View file

@ -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) 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) @receiver(signals.post_save, sender=models.UserBlocks)
# pylint: disable=unused-argument # pylint: disable=unused-argument
def update_suggestions_on_block(sender, instance, *args, **kwargs): def update_suggestions_on_block(sender, instance, *args, **kwargs):

View file

@ -29,7 +29,7 @@
</label> </label>
<label class="field" data-hides="list_group_selector"> <label class="field" data-hides="list_group_selector">
<input type="radio" name="curation" value="open"{% if list.curation == 'open' %} checked{% endif %}> {% trans "Open" %} <input type="radio" name="curation" value="open"{% if list.curation == 'open' %} checked{% endif %}> {% trans "Open" context "curation type" %}
<p class="help mb-2">{% trans "Anyone can add books to this list" %}</p> <p class="help mb-2">{% trans "Anyone can add books to this list" %}</p>
</label> </label>

View file

@ -91,6 +91,12 @@
{{ form.preferred_timezone }} {{ form.preferred_timezone }}
</div> </div>
</div> </div>
<div class="field">
<label class="label" for="id_preferred_language">{% trans "Language:" %}</label>
<div class="select">
{{ form.preferred_language }}
</div>
</div>
</div> </div>
</section> </section>

View file

@ -9,26 +9,26 @@
{% block panel %} {% block panel %}
<div class="columns block has-text-centered"> <div class="columns block has-text-centered is-mobile is-multiline">
<div class="column is-3"> <div class="column is-3-desktop is-6-mobile">
<div class="notification"> <div class="notification">
<h3>{% trans "Total users" %}</h3> <h3>{% trans "Total users" %}</h3>
<p class="title is-5">{{ users|intcomma }}</p> <p class="title is-5">{{ users|intcomma }}</p>
</div> </div>
</div> </div>
<div class="column is-3"> <div class="column is-3-desktop is-6-mobile">
<div class="notification"> <div class="notification">
<h3>{% trans "Active this month" %}</h3> <h3>{% trans "Active this month" %}</h3>
<p class="title is-5">{{ active_users|intcomma }}</p> <p class="title is-5">{{ active_users|intcomma }}</p>
</div> </div>
</div> </div>
<div class="column is-3"> <div class="column is-3-desktop is-6-mobile">
<div class="notification"> <div class="notification">
<h3>{% trans "Statuses" %}</h3> <h3>{% trans "Statuses" %}</h3>
<p class="title is-5">{{ statuses|intcomma }}</p> <p class="title is-5">{{ statuses|intcomma }}</p>
</div> </div>
</div> </div>
<div class="column is-3"> <div class="column is-3-desktop is-6-mobile">
<div class="notification"> <div class="notification">
<h3>{% trans "Works" %}</h3> <h3>{% trans "Works" %}</h3>
<p class="title is-5">{{ works|intcomma }}</p> <p class="title is-5">{{ works|intcomma }}</p>
@ -64,7 +64,7 @@
<div class="block content"> <div class="block content">
<h2>{% trans "Instance Activity" %}</h2> <h2>{% trans "Instance Activity" %}</h2>
<form method="get" action="{% url 'settings-dashboard' %}" class="notification has-background-white-bis"> <form method="get" action="{% url 'settings-dashboard' %}" class="notification has-background-white-bis scroll-x">
<div class="is-flex is-align-items-flex-end"> <div class="is-flex is-align-items-flex-end">
<div class="ml-1 mr-1"> <div class="ml-1 mr-1">
<label class="label" for="id_start"> <label class="label" for="id_start">
@ -95,19 +95,31 @@
</div> </div>
</form> </form>
<div class="columns"> <div class="columns is-multiline">
<div class="column"> <div class="column is-half">
<h3>{% trans "User signup activity" %}</h3> <h3>{% trans "Total users" %}</h3>
<div class="box"> <div class="box">
<canvas id="user_stats"></canvas> <canvas id="user_stats"></canvas>
</div> </div>
</div> </div>
<div class="column"> <div class="column is-half">
<h3>{% trans "User signup activity" %}</h3>
<div class="box">
<canvas id="register_stats"></canvas>
</div>
</div>
<div class="column is-half">
<h3>{% trans "Status activity" %}</h3> <h3>{% trans "Status activity" %}</h3>
<div class="box"> <div class="box">
<canvas id="status_stats"></canvas> <canvas id="status_stats"></canvas>
</div> </div>
</div> </div>
<div class="column is-half">
<h3>{% trans "Works created" %}</h3>
<div class="box">
<canvas id="works_stats"></canvas>
</div>
</div>
</div> </div>
</div> </div>
@ -115,6 +127,8 @@
{% block scripts %} {% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.5.1/dist/chart.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@3.5.1/dist/chart.min.js"></script>
{% include 'settings/dashboard/dashboard_user_chart.html' %} {% include 'settings/dashboard/user_chart.html' %}
{% include 'settings/dashboard/dashboard_status_chart.html' %} {% include 'settings/dashboard/status_chart.html' %}
{% include 'settings/dashboard/registration_chart.html' %}
{% include 'settings/dashboard/works_chart.html' %}
{% endblock %} {% endblock %}

View file

@ -1,26 +0,0 @@
{% load i18n %}
<script>
const status_labels = [{% for label in status_stats.labels %}"{{ label }}",{% endfor %}];
const status_data = {
labels: status_labels,
datasets: [{
label: '{% trans "Statuses posted" %}',
backgroundColor: 'rgb(255, 99, 132)',
borderColor: 'rgb(255, 99, 132)',
data: {{ status_stats.total }},
}]
};
// === include 'setup' then 'config' above ===
const status_config = {
type: 'bar',
data: status_data,
options: {}
};
var statusStats = new Chart(
document.getElementById('status_stats'),
status_config
);
</script>

View file

@ -1,29 +0,0 @@
{% load i18n %}
<script>
const labels = [{% for label in user_stats.labels %}"{{ label }}",{% endfor %}];
const data = {
labels: labels,
datasets: [{
label: '{% trans "Total" %}',
backgroundColor: 'rgb(255, 99, 132)',
borderColor: 'rgb(255, 99, 132)',
data: {{ user_stats.total }},
}, {
label: '{% trans "Active this month" %}',
backgroundColor: 'rgb(75, 192, 192)',
borderColor: 'rgb(75, 192, 192)',
data: {{ user_stats.active }},
}]
};
const config = {
type: 'line',
data: data,
options: {}
};
var userStats = new Chart(
document.getElementById('user_stats'),
config
);
</script>

View file

@ -0,0 +1,19 @@
{% load i18n %}
<script>
var registerStats = new Chart(
document.getElementById('register_stats'),
{
type: 'bar',
data: {
labels: [{% for label in register_stats.labels %}"{{ label }}",{% endfor %}],
datasets: [{
label: '{% trans "Registrations" %}',
backgroundColor: 'hsl(171, 100%, 41%)',
borderColor: 'hsl(171, 100%, 41%)',
data: {{ register_stats.total }},
}]
},
options: {}
}
);
</script>

View file

@ -0,0 +1,21 @@
{% load i18n %}
<script>
var statusStats = new Chart(
document.getElementById('status_stats'),
{
type: 'bar',
data: {
labels: [{% for label in status_stats.labels %}"{{ label }}",{% endfor %}],
datasets: [{
label: '{% trans "Statuses posted" %}',
backgroundColor: 'hsl(141, 71%, 48%)',
borderColor: 'hsl(141, 71%, 48%)',
data: {{ status_stats.total }},
}]
},
options: {}
},
);
</script>

View file

@ -0,0 +1,25 @@
{% load i18n %}
<script>
var userStats = new Chart(
document.getElementById('user_stats'),
{
type: 'line',
data: {
labels: [{% for label in user_stats.labels %}"{{ label }}",{% endfor %}],
datasets: [{
label: '{% trans "Total" %}',
backgroundColor: 'hsl(217, 71%, 53%)',
borderColor: 'hsl(217, 71%, 53%)',
data: {{ user_stats.total }},
}, {
label: '{% trans "Active this month" %}',
backgroundColor: 'hsl(171, 100%, 41%)',
borderColor: 'hsl(171, 100%, 41%)',
data: {{ user_stats.active }},
}]
},
options: {}
}
);
</script>

View file

@ -0,0 +1,21 @@
{% load i18n %}
<script>
var worksStats = new Chart(
document.getElementById('works_stats'),
{
type: 'bar',
data: {
labels: [{% for label in works_stats.labels %}"{{ label }}",{% endfor %}],
datasets: [{
label: '{% trans "Works" %}',
backgroundColor: 'hsl(204, 86%, 53%)',
borderColor: 'hsl(204, 86%, 53%)',
data: {{ works_stats.total }},
}]
},
options: {}
}
);
</script>

View file

@ -1,6 +1,6 @@
{% load i18n %}{% load humanize %}{% load utilities %} {% 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 <em><a href="{{ path }}">{{ title }}</a></em>: {{ display_rating }} star rated <em><a href="{{ path }}">{{ title }}</a></em>: {{ display_rating }} star
{% plural %} {% plural %}
rated <em><a href="{{ path }}">{{ title }}</a></em>: {{ display_rating }} stars rated <em><a href="{{ path }}">{{ title }}</a></em>: {{ display_rating }} stars

View file

@ -1,7 +1,7 @@
{% load i18n %} {% load i18n %}
{% if rating %} {% 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 %} {% else %}

View file

@ -2,7 +2,7 @@
from django import template from django import template
from django.db.models import Avg from django.db.models import Avg
from bookwyrm import models, views from bookwyrm import models
register = template.Library() register = template.Library()
@ -11,8 +11,8 @@ register = template.Library()
@register.filter(name="rating") @register.filter(name="rating")
def get_rating(book, user): def get_rating(book, user):
"""get the overall rating of a book""" """get the overall rating of a book"""
queryset = views.helpers.privacy_filter( queryset = models.Review.privacy_filter(user).filter(
user, models.Review.objects.filter(book__parent_work__editions=book) book__parent_work__editions=book
) )
return queryset.aggregate(Avg("rating"))["rating__avg"] return queryset.aggregate(Avg("rating"))["rating__avg"]

View file

@ -22,6 +22,16 @@ class Activitystreams(TestCase):
local=True, local=True,
localname="nutria", 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") work = models.Work.objects.create(title="test work")
self.book = models.Edition.objects.create(title="test book", parent_work=work) self.book = models.Edition.objects.create(title="test book", parent_work=work)
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): 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.activitystreams.BooksStream.remove_object_from_related_stores")
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") @patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay")
def test_boost_to_another_timeline(self, *_): 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") status = models.Status.objects.create(user=self.local_user, content="hi")
with patch("bookwyrm.activitystreams.handle_boost_task.delay"): with patch("bookwyrm.activitystreams.handle_boost_task.delay"):
boost = models.Boost.objects.create( boost = models.Boost.objects.create(
@ -138,11 +148,32 @@ class Activitystreams(TestCase):
activitystreams.handle_boost_task(boost.id) activitystreams.handle_boost_task(boost.id)
self.assertTrue(mock.called) self.assertTrue(mock.called)
self.assertEqual(mock.call_count, 1)
call_args = mock.call_args call_args = mock.call_args
self.assertEqual(call_args[0][0], status) self.assertEqual(call_args[0][0], status)
self.assertEqual( self.assertEqual(call_args[1]["stores"], [f"{self.another_user.id}-home"])
call_args[1]["stores"], ["{:d}-home".format(self.another_user.id)]
@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.LocalStream.remove_object_from_related_stores")
@patch("bookwyrm.activitystreams.BooksStream.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) self.assertTrue(mock.called)
call_args = mock.call_args call_args = mock.call_args
self.assertEqual(call_args[0][0], status) self.assertEqual(call_args[0][0], status)
self.assertTrue( self.assertTrue(f"{self.another_user.id}-home" in call_args[1]["stores"])
"{:d}-home".format(self.another_user.id) in call_args[1]["stores"] self.assertTrue(f"{self.local_user.id}-home" in call_args[1]["stores"])
)
self.assertTrue(
"{:d}-home".format(self.local_user.id) in call_args[1]["stores"]
)
@patch("bookwyrm.activitystreams.LocalStream.remove_object_from_related_stores") @patch("bookwyrm.activitystreams.LocalStream.remove_object_from_related_stores")
@patch("bookwyrm.activitystreams.BooksStream.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) self.assertTrue(mock.called)
call_args = mock.call_args call_args = mock.call_args
self.assertEqual(call_args[0][0], status) self.assertEqual(call_args[0][0], status)
self.assertEqual( self.assertEqual(call_args[1]["stores"], [f"{self.local_user.id}-home"])
call_args[1]["stores"], ["{:d}-home".format(self.local_user.id)]
)

View file

@ -25,13 +25,6 @@ class Dashboard(View):
"""list of users""" """list of users"""
interval = int(request.GET.get("days", 1)) interval = int(request.GET.get("days", 1))
now = timezone.now() 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") start = request.GET.get("start")
if start: if start:
start = timezone.make_aware(parse(start)) start = timezone.make_aware(parse(start))
@ -42,31 +35,55 @@ class Dashboard(View):
end = timezone.make_aware(parse(end)) if end else now end = timezone.make_aware(parse(end)) if end else now
start = start.replace(hour=0, minute=0, second=0) start = start.replace(hour=0, minute=0, second=0)
interval_start = start user_queryset = models.User.objects.filter(local=True)
interval_end = interval_start + timedelta(days=interval) user_chart = Chart(
while interval_start <= end: queryset=user_queryset,
print(interval_start, interval_end) queries={
interval_queryset = user_queryset.filter( "total": lambda q, s, e: q.filter(
Q(is_active=True) | Q(deactivation_date__gt=interval_end), Q(is_active=True) | Q(deactivation_date__gt=e),
created_date__lte=interval_end, 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,
) )
user_stats["total"].append(interval_queryset.filter().count()) .filter(
user_stats["active"].append( last_active_date__gt=e - timedelta(days=31),
interval_queryset.filter( )
last_active_date__gt=interval_end - timedelta(days=31), .count(),
).count() },
) )
user_stats["labels"].append(interval_start.strftime("%b %d"))
status_stats["total"].append( status_queryset = models.Status.objects.filter(user__local=True, deleted=False)
status_queryset.filter( status_chart = Chart(
created_date__gt=interval_start, queryset=status_queryset,
created_date__lte=interval_end, queries={
"total": lambda q, s, e: q.filter(
created_date__gt=s,
created_date__lte=e,
).count() ).count()
},
)
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()
},
) )
status_stats["labels"].append(interval_start.strftime("%b %d"))
interval_start = interval_end
interval_end += timedelta(days=interval)
data = { data = {
"start": start.strftime("%Y-%m-%d"), "start": start.strftime("%Y-%m-%d"),
@ -82,7 +99,34 @@ class Dashboard(View):
"invite_requests": models.InviteRequest.objects.filter( "invite_requests": models.InviteRequest.objects.filter(
ignored=False, invite_sent=False ignored=False, invite_sent=False
).count(), ).count(),
"user_stats": user_stats, "user_stats": user_chart.get_chart(start, end, interval),
"status_stats": status_stats, "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) 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

View file

@ -16,7 +16,7 @@ from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.connectors import connector_manager from bookwyrm.connectors import connector_manager
from bookwyrm.connectors.abstract_connector import get_image from bookwyrm.connectors.abstract_connector import get_image
from bookwyrm.settings import PAGE_LENGTH 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 # pylint: disable=no-self-use
@ -48,8 +48,8 @@ class Book(View):
raise Http404() raise Http404()
# all reviews for all editions of the book # all reviews for all editions of the book
reviews = privacy_filter( reviews = models.Review.privacy_filter(request.user).filter(
request.user, models.Review.objects.filter(book__parent_work__editions=book) book__parent_work__editions=book
) )
# the reviews to show # the reviews to show
@ -66,12 +66,9 @@ class Book(View):
queryset = queryset.select_related("user").order_by("-published_date") queryset = queryset.select_related("user").order_by("-published_date")
paginated = Paginator(queryset, PAGE_LENGTH) paginated = Paginator(queryset, PAGE_LENGTH)
lists = privacy_filter( lists = models.List.privacy_filter(request.user,).filter(
request.user,
models.List.objects.filter(
listitem__approved=True, listitem__approved=True,
listitem__book__in=book.parent_work.editions.all(), listitem__book__in=book.parent_work.editions.all(),
),
) )
data = { data = {
"book": book, "book": book,

View file

@ -13,7 +13,7 @@ from bookwyrm import activitystreams, forms, models
from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH, STREAMS from bookwyrm.settings import PAGE_LENGTH, STREAMS
from bookwyrm.suggested_users import suggested_users 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 from .helpers import is_api_request, is_bookwyrm_request
@ -56,12 +56,16 @@ class DirectMessage(View):
def get(self, request, username=None): def get(self, request, username=None):
"""like a feed but for dms only""" """like a feed but for dms only"""
# remove fancy subclasses of status, keep just good ol' notes # remove fancy subclasses of status, keep just good ol' notes
queryset = models.Status.objects.filter( activities = (
models.Status.privacy_filter(request.user, privacy_levels=["direct"])
.filter(
review__isnull=True, review__isnull=True,
comment__isnull=True, comment__isnull=True,
quotation__isnull=True, quotation__isnull=True,
generatednote__isnull=True, generatednote__isnull=True,
) )
.order_by("-published_date")
)
user = None user = None
if username: if username:
@ -70,11 +74,7 @@ class DirectMessage(View):
except Http404: except Http404:
pass pass
if user: if user:
queryset = queryset.filter(Q(user=user) | Q(mention_users=user)) activities = activities.filter(Q(user=user) | Q(mention_users=user))
activities = privacy_filter(
request.user, queryset, privacy_levels=["direct"]
).order_by("-published_date")
paginated = Paginator(activities, PAGE_LENGTH) paginated = Paginator(activities, PAGE_LENGTH)
data = { data = {
@ -109,9 +109,11 @@ class Status(View):
status.to_activity(pure=not is_bookwyrm_request(request)) status.to_activity(pure=not is_bookwyrm_request(request))
) )
visible_thread = privacy_filter( visible_thread = (
request.user, models.Status.objects.filter(thread_id=status.thread_id) models.Status.privacy_filter(request.user)
).values_list("id", flat=True) .filter(thread_id=status.thread_id)
.values_list("id", flat=True)
)
visible_thread = list(visible_thread) visible_thread = list(visible_thread)
ancestors = models.Status.objects.select_subclasses().raw( ancestors = models.Status.objects.select_subclasses().raw(

View file

@ -6,11 +6,10 @@ import dateutil.tz
from dateutil.parser import ParserError from dateutil.parser import ParserError
from requests import HTTPError from requests import HTTPError
from django.core.exceptions import FieldError
from django.db.models import Q
from django.http import Http404 from django.http import Http404
from django.utils import translation
from bookwyrm import activitypub, models from bookwyrm import activitypub, models, settings
from bookwyrm.connectors import ConnectorException, get_data from bookwyrm.connectors import ConnectorException, get_data
from bookwyrm.status import create_generated_note from bookwyrm.status import create_generated_note
from bookwyrm.utils import regex from bookwyrm.utils import regex
@ -50,56 +49,6 @@ def is_bookwyrm_request(request):
return True 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): def handle_remote_webfinger(query):
"""webfingerin' other servers""" """webfingerin' other servers"""
user = None user = None
@ -196,3 +145,11 @@ def load_date_in_user_tz_as_utc(date_str: str, user: models.User) -> datetime:
return date.replace(tzinfo=user_tz).astimezone(dateutil.tz.UTC) return date.replace(tzinfo=user_tz).astimezone(dateutil.tz.UTC)
except ParserError: except ParserError:
return None return None
def set_language(user, response):
"""Updates a user's language"""
if user.preferred_language:
translation.activate(user.preferred_language)
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, user.preferred_language)
return response

View file

@ -18,7 +18,7 @@ from django.views.decorators.http import require_POST
from bookwyrm import book_search, forms, models from bookwyrm import book_search, forms, models
from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH 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 from .helpers import get_user_from_username
@ -30,19 +30,16 @@ class Lists(View):
"""display a book list""" """display a book list"""
# hide lists with no approved books # hide lists with no approved books
lists = ( lists = (
models.List.objects.annotate( models.List.privacy_filter(
item_count=Count("listitem", filter=Q(listitem__approved=True)) request.user, privacy_levels=["public", "followers"]
) )
.annotate(item_count=Count("listitem", filter=Q(listitem__approved=True)))
.filter(item_count__gt=0) .filter(item_count__gt=0)
.select_related("user") .select_related("user")
.prefetch_related("listitem_set") .prefetch_related("listitem_set")
.order_by("-updated_date") .order_by("-updated_date")
.distinct() .distinct()
) )
lists = privacy_filter(
request.user, lists, privacy_levels=["public", "followers"]
)
paginated = Paginator(lists, 12) paginated = Paginator(lists, 12)
data = { data = {
"lists": paginated.get_page(request.GET.get("page")), "lists": paginated.get_page(request.GET.get("page")),
@ -92,8 +89,7 @@ class UserLists(View):
def get(self, request, username): def get(self, request, username):
"""display a book list""" """display a book list"""
user = get_user_from_username(request.user, username) user = get_user_from_username(request.user, username)
lists = models.List.objects.filter(user=user) lists = models.List.privacy_filter(request.user).filter(user=user)
lists = privacy_filter(request.user, lists)
paginated = Paginator(lists, 12) paginated = Paginator(lists, 12)
data = { data = {

View file

@ -11,6 +11,7 @@ from django.views.decorators.debug import sensitive_variables, sensitive_post_pa
from bookwyrm import forms, models from bookwyrm import forms, models
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
from bookwyrm.views.helpers import set_language
# pylint: disable=no-self-use # pylint: disable=no-self-use
@ -55,8 +56,8 @@ class Login(View):
login(request, user) login(request, user)
user.update_active_date() user.update_active_date()
if request.POST.get("first_login"): if request.POST.get("first_login"):
return redirect("get-started-profile") return set_language(user, redirect("get-started-profile"))
return redirect(request.GET.get("next", "/")) return set_language(user, redirect(request.GET.get("next", "/")))
# maybe the user is pending email confirmation # maybe the user is pending email confirmation
if models.User.objects.filter( if models.User.objects.filter(

View file

@ -38,7 +38,7 @@ class PasswordResetRequest(View):
# create a new reset code # create a new reset code
code = models.PasswordReset.objects.create(user=user) code = models.PasswordReset.objects.create(user=user)
password_reset_email(code) password_reset_email(code)
data = {"message": _(f"A password reset link sent to {email}")} data = {"message": _(f"A password reset link was sent to {email}")}
return TemplateResponse(request, "password_reset_request.html", data) return TemplateResponse(request, "password_reset_request.html", data)

View file

@ -11,6 +11,7 @@ from django.utils.decorators import method_decorator
from django.views import View from django.views import View
from bookwyrm import forms from bookwyrm import forms
from bookwyrm.views.helpers import set_language
# pylint: disable=no-self-use # pylint: disable=no-self-use
@ -33,9 +34,9 @@ class EditUser(View):
data = {"form": form, "user": request.user} data = {"form": form, "user": request.user}
return TemplateResponse(request, "preferences/edit_user.html", data) return TemplateResponse(request, "preferences/edit_user.html", data)
save_user_form(form) user = save_user_form(form)
return redirect("user-feed", request.user.localname) return set_language(user, redirect("user-feed", request.user.localname))
def save_user_form(form): def save_user_form(form):

View file

@ -4,7 +4,8 @@ from django.contrib.syndication.views import Feed
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.translation import gettext_lazy as _ 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 # pylint: disable=no-self-use, unused-argument
class RssFeed(Feed): class RssFeed(Feed):
@ -35,11 +36,10 @@ class RssFeed(Feed):
def items(self, obj): def items(self, obj):
"""the user's activity feed""" """the user's activity feed"""
return privacy_filter( return models.Status.privacy_filter(
obj, obj,
obj.status_set.select_subclasses(),
privacy_levels=["public", "unlisted"], privacy_levels=["public", "unlisted"],
) ).filter(user=obj)
def item_link(self, item): def item_link(self, item):
"""link to the status""" """link to the status"""

View file

@ -13,7 +13,7 @@ from bookwyrm.connectors import connector_manager
from bookwyrm.book_search import search, format_search_result from bookwyrm.book_search import search, format_search_result
from bookwyrm.settings import PAGE_LENGTH from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.utils import regex 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 from .helpers import handle_remote_webfinger
@ -108,9 +108,8 @@ def user_search(query, viewer, *_):
def list_search(query, viewer, *_): def list_search(query, viewer, *_):
"""any relevent lists?""" """any relevent lists?"""
return ( return (
privacy_filter( models.List.privacy_filter(
viewer, viewer,
models.List.objects,
privacy_levels=["public", "followers"], privacy_levels=["public", "followers"],
) )
.annotate( .annotate(

View file

@ -17,7 +17,6 @@ from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH from bookwyrm.settings import PAGE_LENGTH
from .helpers import is_api_request, get_user_from_username from .helpers import is_api_request, get_user_from_username
from .helpers import privacy_filter
# pylint: disable=no-self-use # pylint: disable=no-self-use
@ -33,7 +32,7 @@ class Shelf(View):
if is_self: if is_self:
shelves = user.shelf_set.all() shelves = user.shelf_set.all()
else: 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 # get the shelf and make sure the logged in user should be able to see it
if shelf_identifier: if shelf_identifier:
@ -58,16 +57,17 @@ class Shelf(View):
if is_api_request(request): if is_api_request(request):
return ActivitypubResponse(shelf.to_activity(**request.GET)) 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, user=user,
rating__isnull=False, rating__isnull=False,
book__id=OuterRef("id"), book__id=OuterRef("id"),
deleted=False, deleted=False,
).order_by("-published_date") ).order_by("-published_date")
if not is_self:
reviews = privacy_filter(request.user, reviews)
books = books.annotate( books = books.annotate(
rating=Subquery(reviews.values("rating")[:1]), rating=Subquery(reviews.values("rating")[:1]),
shelved_date=F("shelfbook__shelved_date"), shelved_date=F("shelfbook__shelved_date"),

View file

@ -12,7 +12,6 @@ from bookwyrm import models
from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH from bookwyrm.settings import PAGE_LENGTH
from .helpers import get_user_from_username, is_api_request from .helpers import get_user_from_username, is_api_request
from .helpers import privacy_filter
# pylint: disable=no-self-use # pylint: disable=no-self-use
@ -56,10 +55,10 @@ class User(View):
# user's posts # user's posts
activities = ( activities = (
privacy_filter( models.Status.privacy_filter(
request.user, request.user,
user.status_set.select_subclasses(),
) )
.filter(user=user)
.select_related( .select_related(
"user", "user",
"reply_parent", "reply_parent",

View file

@ -9,7 +9,7 @@ from django.utils import timezone
from django.views.decorators.http import require_GET from django.views.decorators.http import require_GET
from bookwyrm import models 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 @require_GET
@ -93,8 +93,7 @@ def instance_info(_):
status_count = models.Status.objects.filter(user__local=True, deleted=False).count() status_count = models.Status.objects.filter(user__local=True, deleted=False).count()
site = models.SiteSettings.get() site = models.SiteSettings.get()
logo_path = site.logo or "images/logo.png" logo = get_image_url(site.logo, "logo.png")
logo = f"{MEDIA_FULL_URL}{logo_path}"
return JsonResponse( return JsonResponse(
{ {
"uri": DOMAIN, "uri": DOMAIN,
@ -134,8 +133,14 @@ def host_meta(request):
def opensearch(request): def opensearch(request):
"""Open Search xml spec""" """Open Search xml spec"""
site = models.SiteSettings.get() site = models.SiteSettings.get()
logo_path = site.favicon or "images/favicon.png" image = get_image_url(site.favicon, "favicon.png")
logo = f"{MEDIA_FULL_URL}{logo_path}"
return TemplateResponse( 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}"

6
bw-dev
View file

@ -105,6 +105,9 @@ case "$CMD" in
collectstatic) collectstatic)
runweb python manage.py collectstatic --no-input runweb python manage.py collectstatic --no-input
;; ;;
add_locale)
runweb django-admin makemessages --no-wrap --ignore=venv -l $@
;;
makemessages) makemessages)
runweb django-admin makemessages --no-wrap --ignore=venv --all $@ runweb django-admin makemessages --no-wrap --ignore=venv --all $@
;; ;;
@ -167,7 +170,8 @@ case "$CMD" in
echo " test [path]" echo " test [path]"
echo " pytest [path]" echo " pytest [path]"
echo " collectstatic" echo " collectstatic"
echo " makemessages [locale]" echo " add_locale [locale]"
echo " makemessages"
echo " compilemessages [locale]" echo " compilemessages [locale]"
echo " build" echo " build"
echo " clean" echo " clean"

3
crowdin.yml Normal file
View file

@ -0,0 +1,3 @@
files:
- source: /locale/en_US/LC_MESSAGES/django.po
translation: /locale/%locale_with_underscore%/LC_MESSAGES/django.po

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff