mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-12-16 13:16:33 +00:00
Merge remote-tracking branch 'origin/bookwyrm-groups' into bookwyrm-groups
Merge changes from main project into local branch
This commit is contained in:
commit
39e002ee13
74 changed files with 96262 additions and 4267 deletions
|
@ -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:
|
||||
|
|
|
@ -144,6 +144,7 @@ class EditUserForm(CustomForm):
|
|||
"default_post_privacy",
|
||||
"discoverable",
|
||||
"preferred_timezone",
|
||||
"preferred_language",
|
||||
]
|
||||
help_texts = {f: None for f in fields}
|
||||
|
||||
|
|
30
bookwyrm/migrations/0106_user_preferred_language.py
Normal file
30
bookwyrm/migrations/0106_user_preferred_language.py
Normal 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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -17,7 +17,7 @@ from bookwyrm.connectors import get_data, ConnectorException
|
|||
from bookwyrm.models.shelf import Shelf
|
||||
from bookwyrm.models.status import Status, Review
|
||||
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.tasks import app
|
||||
from bookwyrm.utils import regex
|
||||
|
@ -133,6 +133,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
default=str(pytz.utc),
|
||||
max_length=255,
|
||||
)
|
||||
preferred_language = models.CharField(
|
||||
choices=LANGUAGES,
|
||||
null=True,
|
||||
blank=True,
|
||||
max_length=255,
|
||||
)
|
||||
deactivation_reason = models.CharField(
|
||||
max_length=255, choices=DeactivationReason, null=True, blank=True
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -30,6 +30,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|||
LOCALE_PATHS = [
|
||||
os.path.join(BASE_DIR, "locale"),
|
||||
]
|
||||
LANGUAGE_COOKIE_NAME = env.str("LANGUAGE_COOKIE_NAME", "django_language")
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
|
@ -161,11 +162,11 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||
LANGUAGE_CODE = "en-us"
|
||||
LANGUAGES = [
|
||||
("en-us", _("English")),
|
||||
("de-de", _("German")),
|
||||
("es", _("Spanish")),
|
||||
("fr-fr", _("French")),
|
||||
("zh-hans", _("Simplified Chinese")),
|
||||
("zh-hant", _("Traditional Chinese")),
|
||||
("de-de", _("Deutsch (German)")), # German
|
||||
("es", _("Español (Spanish)")), # Spanish
|
||||
("fr-fr", _("Français (French)")), # French
|
||||
("zh-hans", _("简体中文 (Simplified Chinese)")), # Simplified Chinese
|
||||
("zh-hant", _("繁體中文 (Traditional Chinese)")), # Traditional Chinese
|
||||
]
|
||||
|
||||
|
||||
|
@ -210,12 +211,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 +227,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"))
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -29,7 +29,7 @@
|
|||
</label>
|
||||
|
||||
<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>
|
||||
</label>
|
||||
|
||||
|
|
|
@ -91,6 +91,12 @@
|
|||
{{ form.preferred_timezone }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="id_preferred_language">{% trans "Language:" %}</label>
|
||||
<div class="select">
|
||||
{{ form.preferred_language }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
@ -9,26 +9,26 @@
|
|||
|
||||
{% block panel %}
|
||||
|
||||
<div class="columns block has-text-centered">
|
||||
<div class="column is-3">
|
||||
<div class="columns block has-text-centered is-mobile is-multiline">
|
||||
<div class="column is-3-desktop is-6-mobile">
|
||||
<div class="notification">
|
||||
<h3>{% trans "Total users" %}</h3>
|
||||
<p class="title is-5">{{ users|intcomma }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="column is-3-desktop is-6-mobile">
|
||||
<div class="notification">
|
||||
<h3>{% trans "Active this month" %}</h3>
|
||||
<p class="title is-5">{{ active_users|intcomma }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="column is-3-desktop is-6-mobile">
|
||||
<div class="notification">
|
||||
<h3>{% trans "Statuses" %}</h3>
|
||||
<p class="title is-5">{{ statuses|intcomma }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="column is-3-desktop is-6-mobile">
|
||||
<div class="notification">
|
||||
<h3>{% trans "Works" %}</h3>
|
||||
<p class="title is-5">{{ works|intcomma }}</p>
|
||||
|
@ -64,7 +64,7 @@
|
|||
<div class="block content">
|
||||
<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="ml-1 mr-1">
|
||||
<label class="label" for="id_start">
|
||||
|
@ -95,19 +95,31 @@
|
|||
</div>
|
||||
</form>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<h3>{% trans "User signup activity" %}</h3>
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-half">
|
||||
<h3>{% trans "Total users" %}</h3>
|
||||
<div class="box">
|
||||
<canvas id="user_stats"></canvas>
|
||||
</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>
|
||||
<div class="box">
|
||||
<canvas id="status_stats"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<h3>{% trans "Works created" %}</h3>
|
||||
<div class="box">
|
||||
<canvas id="works_stats"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -115,6 +127,8 @@
|
|||
|
||||
{% block scripts %}
|
||||
<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/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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
@ -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>
|
|
@ -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>
|
21
bookwyrm/templates/settings/dashboard/status_chart.html
Normal file
21
bookwyrm/templates/settings/dashboard/status_chart.html
Normal 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>
|
||||
|
25
bookwyrm/templates/settings/dashboard/user_chart.html
Normal file
25
bookwyrm/templates/settings/dashboard/user_chart.html
Normal 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>
|
21
bookwyrm/templates/settings/dashboard/works_chart.html
Normal file
21
bookwyrm/templates/settings/dashboard/works_chart.html
Normal 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>
|
||||
|
|
@ -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 <em><a href="{{ path }}">{{ title }}</a></em>: {{ display_rating }} star
|
||||
{% plural %}
|
||||
rated <em><a href="{{ path }}">{{ title }}</a></em>: {{ display_rating }} stars
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
|
@ -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"]
|
||||
|
||||
|
|
|
@ -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"])
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -6,11 +6,10 @@ 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 django.utils import translation
|
||||
|
||||
from bookwyrm import activitypub, models
|
||||
from bookwyrm import activitypub, models, settings
|
||||
from bookwyrm.connectors import ConnectorException, get_data
|
||||
from bookwyrm.status import create_generated_note
|
||||
from bookwyrm.utils import regex
|
||||
|
@ -50,56 +49,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
|
||||
|
@ -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)
|
||||
except ParserError:
|
||||
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
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -11,6 +11,7 @@ from django.views.decorators.debug import sensitive_variables, sensitive_post_pa
|
|||
|
||||
from bookwyrm import forms, models
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from bookwyrm.views.helpers import set_language
|
||||
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
|
@ -55,8 +56,8 @@ class Login(View):
|
|||
login(request, user)
|
||||
user.update_active_date()
|
||||
if request.POST.get("first_login"):
|
||||
return redirect("get-started-profile")
|
||||
return redirect(request.GET.get("next", "/"))
|
||||
return set_language(user, redirect("get-started-profile"))
|
||||
return set_language(user, redirect(request.GET.get("next", "/")))
|
||||
|
||||
# maybe the user is pending email confirmation
|
||||
if models.User.objects.filter(
|
||||
|
|
|
@ -38,7 +38,7 @@ class PasswordResetRequest(View):
|
|||
# create a new reset code
|
||||
code = models.PasswordReset.objects.create(user=user)
|
||||
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)
|
||||
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ from django.utils.decorators import method_decorator
|
|||
from django.views import View
|
||||
|
||||
from bookwyrm import forms
|
||||
from bookwyrm.views.helpers import set_language
|
||||
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
|
@ -33,9 +34,9 @@ class EditUser(View):
|
|||
data = {"form": form, "user": request.user}
|
||||
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):
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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}"
|
||||
|
|
6
bw-dev
6
bw-dev
|
@ -105,6 +105,9 @@ case "$CMD" in
|
|||
collectstatic)
|
||||
runweb python manage.py collectstatic --no-input
|
||||
;;
|
||||
add_locale)
|
||||
runweb django-admin makemessages --no-wrap --ignore=venv -l $@
|
||||
;;
|
||||
makemessages)
|
||||
runweb django-admin makemessages --no-wrap --ignore=venv --all $@
|
||||
;;
|
||||
|
@ -167,7 +170,8 @@ case "$CMD" in
|
|||
echo " test [path]"
|
||||
echo " pytest [path]"
|
||||
echo " collectstatic"
|
||||
echo " makemessages [locale]"
|
||||
echo " add_locale [locale]"
|
||||
echo " makemessages"
|
||||
echo " compilemessages [locale]"
|
||||
echo " build"
|
||||
echo " clean"
|
||||
|
|
3
crowdin.yml
Normal file
3
crowdin.yml
Normal file
|
@ -0,0 +1,3 @@
|
|||
files:
|
||||
- source: /locale/en_US/LC_MESSAGES/django.po
|
||||
translation: /locale/%locale_with_underscore%/LC_MESSAGES/django.po
|
3587
locale/af_ZA/LC_MESSAGES/django.po
Normal file
3587
locale/af_ZA/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3655
locale/ar_SA/LC_MESSAGES/django.po
Normal file
3655
locale/ar_SA/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3587
locale/ca_ES/LC_MESSAGES/django.po
Normal file
3587
locale/ca_ES/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3621
locale/cs_CZ/LC_MESSAGES/django.po
Normal file
3621
locale/cs_CZ/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3587
locale/da_DK/LC_MESSAGES/django.po
Normal file
3587
locale/da_DK/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
3587
locale/el_GR/LC_MESSAGES/django.po
Normal file
3587
locale/el_GR/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3587
locale/en_Oulipo/LC_MESSAGES/django.po
Normal file
3587
locale/en_Oulipo/LC_MESSAGES/django.po
Normal file
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
3587
locale/es_ES/LC_MESSAGES/django.po
Normal file
3587
locale/es_ES/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3587
locale/fi_FI/LC_MESSAGES/django.po
Normal file
3587
locale/fi_FI/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
3621
locale/he_IL/LC_MESSAGES/django.po
Normal file
3621
locale/he_IL/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3587
locale/hu_HU/LC_MESSAGES/django.po
Normal file
3587
locale/hu_HU/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3587
locale/it_IT/LC_MESSAGES/django.po
Normal file
3587
locale/it_IT/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3570
locale/ja_JP/LC_MESSAGES/django.po
Normal file
3570
locale/ja_JP/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3570
locale/ko_KR/LC_MESSAGES/django.po
Normal file
3570
locale/ko_KR/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3587
locale/nl_NL/LC_MESSAGES/django.po
Normal file
3587
locale/nl_NL/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3587
locale/no_NO/LC_MESSAGES/django.po
Normal file
3587
locale/no_NO/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3621
locale/pl_PL/LC_MESSAGES/django.po
Normal file
3621
locale/pl_PL/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3587
locale/pt_BR/LC_MESSAGES/django.po
Normal file
3587
locale/pt_BR/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3587
locale/pt_PT/LC_MESSAGES/django.po
Normal file
3587
locale/pt_PT/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3604
locale/ro_RO/LC_MESSAGES/django.po
Normal file
3604
locale/ro_RO/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3621
locale/ru_RU/LC_MESSAGES/django.po
Normal file
3621
locale/ru_RU/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3604
locale/sr_SP/LC_MESSAGES/django.po
Normal file
3604
locale/sr_SP/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3587
locale/sv_SE/LC_MESSAGES/django.po
Normal file
3587
locale/sv_SE/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3587
locale/tr_TR/LC_MESSAGES/django.po
Normal file
3587
locale/tr_TR/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3621
locale/uk_UA/LC_MESSAGES/django.po
Normal file
3621
locale/uk_UA/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
3570
locale/vi_VN/LC_MESSAGES/django.po
Normal file
3570
locale/vi_VN/LC_MESSAGES/django.po
Normal file
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
Loading…
Reference in a new issue