Merge branch 'main' into check-version-number

This commit is contained in:
Mouse Reeve 2024-02-03 08:02:15 -08:00
commit 48f8ee57a6
45 changed files with 481 additions and 119 deletions

View file

@ -137,3 +137,10 @@ TWO_FACTOR_LOGIN_MAX_SECONDS=60
# and AWS_S3_CUSTOM_DOMAIN (if used) are added by default. # and AWS_S3_CUSTOM_DOMAIN (if used) are added by default.
# Value should be a comma-separated list of host names. # Value should be a comma-separated list of host names.
CSP_ADDITIONAL_HOSTS= CSP_ADDITIONAL_HOSTS=
# The last number here means "megabytes"
# Increase if users are having trouble uploading BookWyrm export files.
DATA_UPLOAD_MAX_MEMORY_SIZE = (1024**2 * 100)
# Time before being logged out (in seconds)
# SESSION_COOKIE_AGE=2592000 # current default: 30 days

View file

@ -43,6 +43,7 @@ def search(
min_confidence: float = 0, min_confidence: float = 0,
filters: Optional[list[Any]] = None, filters: Optional[list[Any]] = None,
return_first: bool = False, return_first: bool = False,
books: Optional[QuerySet[models.Edition]] = None,
) -> Union[Optional[models.Edition], QuerySet[models.Edition]]: ) -> Union[Optional[models.Edition], QuerySet[models.Edition]]:
"""search your local database""" """search your local database"""
filters = filters or [] filters = filters or []
@ -54,13 +55,15 @@ def search(
# first, try searching unique identifiers # first, try searching unique identifiers
# unique identifiers never have spaces, title/author usually do # unique identifiers never have spaces, title/author usually do
if not " " in query: if not " " in query:
results = search_identifiers(query, *filters, return_first=return_first) results = search_identifiers(
query, *filters, return_first=return_first, books=books
)
# if there were no identifier results... # if there were no identifier results...
if not results: if not results:
# then try searching title/author # then try searching title/author
results = search_title_author( results = search_title_author(
query, min_confidence, *filters, return_first=return_first query, min_confidence, *filters, return_first=return_first, books=books
) )
return results return results
@ -98,9 +101,17 @@ def format_search_result(search_result):
def search_identifiers( def search_identifiers(
query, *filters, return_first=False query,
*filters,
return_first=False,
books=None,
) -> Union[Optional[models.Edition], QuerySet[models.Edition]]: ) -> Union[Optional[models.Edition], QuerySet[models.Edition]]:
"""tries remote_id, isbn; defined as dedupe fields on the model""" """search Editions by deduplication fields
Best for cases when we can assume someone is searching for an exact match on
commonly unique data identifiers like isbn or specific library ids.
"""
books = books or models.Edition.objects
if connectors.maybe_isbn(query): if connectors.maybe_isbn(query):
# Oh did you think the 'S' in ISBN stood for 'standard'? # Oh did you think the 'S' in ISBN stood for 'standard'?
normalized_isbn = query.strip().upper().rjust(10, "0") normalized_isbn = query.strip().upper().rjust(10, "0")
@ -111,7 +122,7 @@ def search_identifiers(
for f in models.Edition._meta.get_fields() for f in models.Edition._meta.get_fields()
if hasattr(f, "deduplication_field") and f.deduplication_field if hasattr(f, "deduplication_field") and f.deduplication_field
] ]
results = models.Edition.objects.filter( results = books.filter(
*filters, reduce(operator.or_, (Q(**f) for f in or_filters)) *filters, reduce(operator.or_, (Q(**f) for f in or_filters))
).distinct() ).distinct()
@ -121,12 +132,17 @@ def search_identifiers(
def search_title_author( def search_title_author(
query, min_confidence, *filters, return_first=False query,
min_confidence,
*filters,
return_first=False,
books=None,
) -> QuerySet[models.Edition]: ) -> QuerySet[models.Edition]:
"""searches for title and author""" """searches for title and author"""
books = books or models.Edition.objects
query = SearchQuery(query, config="simple") | SearchQuery(query, config="english") query = SearchQuery(query, config="simple") | SearchQuery(query, config="english")
results = ( results = (
models.Edition.objects.filter(*filters, search_vector=query) books.filter(*filters, search_vector=query)
.annotate(rank=SearchRank(F("search_vector"), query)) .annotate(rank=SearchRank(F("search_vector"), query))
.filter(rank__gt=min_confidence) .filter(rank__gt=min_confidence)
.order_by("-rank") .order_by("-rank")

View file

@ -0,0 +1,43 @@
""" Erase any data stored about deleted users """
import sys
from django.core.management.base import BaseCommand, CommandError
from bookwyrm import models
from bookwyrm.models.user import erase_user_data
# pylint: disable=missing-function-docstring
class Command(BaseCommand):
"""command-line options"""
help = "Remove Two Factor Authorisation from user"
def add_arguments(self, parser): # pylint: disable=no-self-use
parser.add_argument(
"--dryrun",
action="store_true",
help="Preview users to be cleared without altering the database",
)
def handle(self, *args, **options): # pylint: disable=unused-argument
# Check for anything fishy
bad_state = models.User.objects.filter(is_deleted=True, is_active=True)
if bad_state.exists():
raise CommandError(
f"{bad_state.count()} user(s) marked as both active and deleted"
)
deleted_users = models.User.objects.filter(is_deleted=True)
self.stdout.write(f"Found {deleted_users.count()} deleted users")
if options["dryrun"]:
self.stdout.write("\n".join(u.username for u in deleted_users[:5]))
if deleted_users.count() > 5:
self.stdout.write("... and more")
sys.exit()
self.stdout.write("Erasing user data:")
for user_id in deleted_users.values_list("id", flat=True):
erase_user_data.delay(user_id)
self.stdout.write(".", ending="")
self.stdout.write("")
self.stdout.write("Tasks created successfully")

View file

@ -22,17 +22,6 @@ def update_deleted_users(apps, schema_editor):
).update(is_deleted=True) ).update(is_deleted=True)
def erase_deleted_user_data(apps, schema_editor):
"""Retroactively clear user data"""
for user in User.objects.filter(is_deleted=True):
user.erase_user_data()
user.save(
broadcast=False,
update_fields=["email", "avatar", "preview_image", "summary", "name"],
)
user.erase_user_statuses(broadcast=False)
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -43,7 +32,4 @@ class Migration(migrations.Migration):
migrations.RunPython( migrations.RunPython(
update_deleted_users, reverse_code=migrations.RunPython.noop update_deleted_users, reverse_code=migrations.RunPython.noop
), ),
migrations.RunPython(
erase_deleted_user_data, reverse_code=migrations.RunPython.noop
),
] ]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.23 on 2024-01-16 10:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0191_merge_20240102_0326"),
]
operations = [
migrations.AddField(
model_name="sitesettings",
name="user_exports_enabled",
field=models.BooleanField(default=False),
),
]

View file

@ -152,8 +152,9 @@ class ActivitypubMixin:
# find anyone who's tagged in a status, for example # find anyone who's tagged in a status, for example
mentions = self.recipients if hasattr(self, "recipients") else [] mentions = self.recipients if hasattr(self, "recipients") else []
# we always send activities to explicitly mentioned users' inboxes # we always send activities to explicitly mentioned users (using shared inboxes
recipients = [u.inbox for u in mentions or [] if not u.local] # where available to avoid duplicate submissions to a given instance)
recipients = {u.shared_inbox or u.inbox for u in mentions if not u.local}
# unless it's a dm, all the followers should receive the activity # unless it's a dm, all the followers should receive the activity
if privacy != "direct": if privacy != "direct":
@ -173,18 +174,18 @@ class ActivitypubMixin:
if user: if user:
queryset = queryset.filter(following=user) queryset = queryset.filter(following=user)
# ideally, we will send to shared inboxes for efficiency # as above, we prefer shared inboxes if available
shared_inboxes = ( recipients.update(
queryset.filter(shared_inbox__isnull=False) queryset.filter(shared_inbox__isnull=False).values_list(
.values_list("shared_inbox", flat=True) "shared_inbox", flat=True
.distinct() )
) )
# but not everyone has a shared inbox recipients.update(
inboxes = queryset.filter(shared_inbox__isnull=True).values_list( queryset.filter(shared_inbox__isnull=True).values_list(
"inbox", flat=True "inbox", flat=True
)
) )
recipients += list(shared_inboxes) + list(inboxes) return list(recipients)
return list(set(recipients))
def to_activity_dataclass(self): def to_activity_dataclass(self):
"""convert from a model to an activity""" """convert from a model to an activity"""

View file

@ -99,6 +99,7 @@ class SiteSettings(SiteModel):
imports_enabled = models.BooleanField(default=True) imports_enabled = models.BooleanField(default=True)
import_size_limit = models.IntegerField(default=0) import_size_limit = models.IntegerField(default=0)
import_limit_reset = models.IntegerField(default=0) import_limit_reset = models.IntegerField(default=0)
user_exports_enabled = models.BooleanField(default=False)
user_import_time_limit = models.IntegerField(default=48) user_import_time_limit = models.IntegerField(default=48)
field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"]) field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"])

View file

@ -12,6 +12,8 @@ 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
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext_lazy
from model_utils import FieldTracker from model_utils import FieldTracker
from model_utils.managers import InheritanceManager from model_utils.managers import InheritanceManager
@ -107,14 +109,14 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
@property @property
def recipients(self): def recipients(self):
"""tagged users who definitely need to get this status in broadcast""" """tagged users who definitely need to get this status in broadcast"""
mentions = [u for u in self.mention_users.all() if not u.local] mentions = {u for u in self.mention_users.all() if not u.local}
if ( if (
hasattr(self, "reply_parent") hasattr(self, "reply_parent")
and self.reply_parent and self.reply_parent
and not self.reply_parent.user.local and not self.reply_parent.user.local
): ):
mentions.append(self.reply_parent.user) mentions.add(self.reply_parent.user)
return list(set(mentions)) return list(mentions)
@classmethod @classmethod
def ignore_activity( def ignore_activity(
@ -178,6 +180,24 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
"""you can't boost dms""" """you can't boost dms"""
return self.privacy in ["unlisted", "public"] return self.privacy in ["unlisted", "public"]
@property
def page_title(self):
"""title of the page when only this status is shown"""
return _("%(display_name)s's status") % {"display_name": self.user.display_name}
@property
def page_description(self):
"""description of the page in meta tags when only this status is shown"""
return None
@property
def page_image(self):
"""image to use as preview in meta tags when only this status is shown"""
if self.mention_books.exists():
book = self.mention_books.first()
return book.preview_image or book.cover
return self.user.preview_image
def to_replies(self, **kwargs): def to_replies(self, **kwargs):
"""helper function for loading AP serialized replies to a status""" """helper function for loading AP serialized replies to a status"""
return self.to_ordered_collection( return self.to_ordered_collection(
@ -301,6 +321,10 @@ class BookStatus(Status):
abstract = True abstract = True
@property
def page_image(self):
return self.book.preview_image or self.book.cover or super().page_image
class Comment(BookStatus): class Comment(BookStatus):
"""like a review but without a rating and transient""" """like a review but without a rating and transient"""
@ -332,6 +356,13 @@ class Comment(BookStatus):
activity_serializer = activitypub.Comment activity_serializer = activitypub.Comment
@property
def page_title(self):
return _("%(display_name)s's comment on %(book_title)s") % {
"display_name": self.user.display_name,
"book_title": self.book.title,
}
class Quotation(BookStatus): class Quotation(BookStatus):
"""like a review but without a rating and transient""" """like a review but without a rating and transient"""
@ -374,6 +405,13 @@ class Quotation(BookStatus):
activity_serializer = activitypub.Quotation activity_serializer = activitypub.Quotation
@property
def page_title(self):
return _("%(display_name)s's quote from %(book_title)s") % {
"display_name": self.user.display_name,
"book_title": self.book.title,
}
class Review(BookStatus): class Review(BookStatus):
"""a book review""" """a book review"""
@ -403,6 +441,13 @@ class Review(BookStatus):
"""indicate the book in question for mastodon (or w/e) users""" """indicate the book in question for mastodon (or w/e) users"""
return self.content return self.content
@property
def page_title(self):
return _("%(display_name)s's review of %(book_title)s") % {
"display_name": self.user.display_name,
"book_title": self.book.title,
}
activity_serializer = activitypub.Review activity_serializer = activitypub.Review
pure_type = "Article" pure_type = "Article"
@ -426,6 +471,18 @@ class ReviewRating(Review):
template = get_template("snippets/generated_status/rating.html") template = get_template("snippets/generated_status/rating.html")
return template.render({"book": self.book, "rating": self.rating}).strip() return template.render({"book": self.book, "rating": self.rating}).strip()
@property
def page_description(self):
return ngettext_lazy(
"%(display_name)s rated %(book_title)s: %(display_rating).1f star",
"%(display_name)s rated %(book_title)s: %(display_rating).1f stars",
"display_rating",
) % {
"display_name": self.user.display_name,
"book_title": self.book.title,
"display_rating": self.rating,
}
activity_serializer = activitypub.Rating activity_serializer = activitypub.Rating
pure_type = "Note" pure_type = "Note"

View file

@ -523,6 +523,20 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
@app.task(queue=MISC)
def erase_user_data(user_id):
"""Erase any custom data about this user asynchronously
This is for deleted historical user data that pre-dates data
being cleared automatically"""
user = User.objects.get(id=user_id)
user.erase_user_data()
user.save(
broadcast=False,
update_fields=["email", "avatar", "preview_image", "summary", "name"],
)
user.erase_user_statuses(broadcast=False)
@app.task(queue=MISC) @app.task(queue=MISC)
def set_remote_server(user_id, allow_external_connections=False): def set_remote_server(user_id, allow_external_connections=False):
"""figure out the user's remote server in the background""" """figure out the user's remote server in the background"""

View file

@ -30,6 +30,9 @@ RELEASE_API = env(
PAGE_LENGTH = env.int("PAGE_LENGTH", 15) PAGE_LENGTH = env.int("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English") DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
# TODO: extend maximum age to 1 year once termination of active sessions
# is implemented (see bookwyrm-social#2278, bookwyrm-social#3082).
SESSION_COOKIE_AGE = env.int("SESSION_COOKIE_AGE", 3600 * 24 * 30) # 1 month
JS_CACHE = "8a89cad7" JS_CACHE = "8a89cad7"
@ -347,8 +350,7 @@ USE_L10N = True
USE_TZ = True USE_TZ = True
agent = requests.utils.default_user_agent() USER_AGENT = f"BookWyrm (BookWyrm/{VERSION}; +https://{DOMAIN}/)"
USER_AGENT = f"{agent} (BookWyrm/{VERSION}; +https://{DOMAIN}/)"
# Imagekit generated thumbnails # Imagekit generated thumbnails
ENABLE_THUMBNAIL_GENERATION = env.bool("ENABLE_THUMBNAIL_GENERATION", False) ENABLE_THUMBNAIL_GENERATION = env.bool("ENABLE_THUMBNAIL_GENERATION", False)
@ -442,3 +444,5 @@ if HTTP_X_FORWARDED_PROTO:
# Do not change this setting unless you already have an existing # Do not change this setting unless you already have an existing
# user with the same username - in which case you should change it! # user with the same username - in which case you should change it!
INSTANCE_ACTOR_USERNAME = "bookwyrm.instance.actor" INSTANCE_ACTOR_USERNAME = "bookwyrm.instance.actor"
DATA_UPLOAD_MAX_MEMORY_SIZE = env.int("DATA_UPLOAD_MAX_MEMORY_SIZE", (1024**2 * 100))

View file

@ -111,6 +111,10 @@ const tries = {
}, },
}, },
f: { f: {
b: {
2: "FB2",
3: "FB3",
},
l: { l: {
a: { a: {
c: "FLAC", c: "FLAC",

View file

@ -31,10 +31,10 @@
</p> </p>
</div> </div>
<div class="columns"> <div class="columns is-multiline">
{% if superlatives.top_rated %} {% if superlatives.top_rated %}
{% with book=superlatives.top_rated.default_edition rating=superlatives.top_rated.rating %} {% with book=superlatives.top_rated.default_edition rating=superlatives.top_rated.rating %}
<div class="column is-one-third is-flex"> <div class="column is-half-tablet is-one-third-desktop is-flex-grow-1 is-flex">
<div class="media notification is-clipped"> <div class="media notification is-clipped">
<div class="media-left"> <div class="media-left">
<a href="{{ book.local_path }}"> <a href="{{ book.local_path }}">
@ -53,7 +53,7 @@
{% if superlatives.wanted %} {% if superlatives.wanted %}
{% with book=superlatives.wanted.default_edition %} {% with book=superlatives.wanted.default_edition %}
<div class="column is-one-third is-flex"> <div class="column is-half-tablet is-one-third-desktop is-flex-grow-1 is-flex">
<div class="media notification is-clipped"> <div class="media notification is-clipped">
<div class="media-left"> <div class="media-left">
<a href="{{ book.local_path }}"> <a href="{{ book.local_path }}">
@ -72,7 +72,7 @@
{% if superlatives.controversial %} {% if superlatives.controversial %}
{% with book=superlatives.controversial.default_edition %} {% with book=superlatives.controversial.default_edition %}
<div class="column is-one-third is-flex"> <div class="column is-half-tablet is-one-third-desktop is-flex-grow-1 is-flex">
<div class="media notification is-clipped"> <div class="media notification is-clipped">
<div class="media-left"> <div class="media-left">
<a href="{{ book.local_path }}"> <a href="{{ book.local_path }}">

View file

@ -9,7 +9,8 @@
{% block title %}{{ book|book_title }}{% endblock %} {% block title %}{{ book|book_title }}{% endblock %}
{% block opengraph %} {% block opengraph %}
{% include 'snippets/opengraph.html' with title=book.title description=book|book_description image=book.preview_image %} {% firstof book.preview_image book.cover as book_image %}
{% include 'snippets/opengraph.html' with title=book.title description=book|book_description image=book_image %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View file

@ -6,8 +6,8 @@
{% block content %} {% block content %}
<h1 class="title">{% trans "Confirm your email address" %}</h1> <h1 class="title">{% trans "Confirm your email address" %}</h1>
<div class="columns"> <div class="columns is-multiline">
<div class="column"> <div class="column is-full is-half-desktop">
<div class="block content"> <div class="block content">
<section class="block"> <section class="block">
<p>{% trans "A confirmation code has been sent to the email address you used to register your account." %}</p> <p>{% trans "A confirmation code has been sent to the email address you used to register your account." %}</p>

View file

@ -41,7 +41,7 @@
</section> </section>
{% endif %} {% endif %}
{% if annual_summary_year and tab.key == 'home' %} {% if annual_summary_year and tab.key == 'home' and has_summary_read_throughs %}
<section class="block is-hidden" data-hide="hide_annual_summary_{{ annual_summary_year }}"> <section class="block is-hidden" data-hide="hide_annual_summary_{{ annual_summary_year }}">
{% include 'feed/summary_card.html' with year=annual_summary_year %} {% include 'feed/summary_card.html' with year=annual_summary_year %}
<hr> <hr>

View file

@ -2,13 +2,11 @@
{% load feed_page_tags %} {% load feed_page_tags %}
{% load i18n %} {% load i18n %}
{% block title %}{{ title }}{% endblock %}
{% block opengraph %} {% block opengraph %}
{% firstof status.book status.mention_books.first as book %} {% include 'snippets/opengraph.html' with image=page_image %}
{% if book %}
{% include 'snippets/opengraph.html' with image=preview %}
{% else %}
{% include 'snippets/opengraph.html' %}
{% endif %}
{% endblock %} {% endblock %}

View file

@ -6,8 +6,8 @@
{% block content %} {% block content %}
<h1 class="title">{% trans "Create an Account" %}</h1> <h1 class="title">{% trans "Create an Account" %}</h1>
<div class="columns"> <div class="columns is-multiline">
<div class="column"> <div class="column is-full is-half-desktop">
<div class="block"> <div class="block">
{% if valid %} {% if valid %}
<div> <div>

View file

@ -6,7 +6,7 @@
{% block content %} {% block content %}
<h1 class="title">{% trans "Log in" %}</h1> <h1 class="title">{% trans "Log in" %}</h1>
<div class="columns is-multiline"> <div class="columns is-multiline">
<div class="column is-half"> <div class="column {% if site.allow_registration %}is-half{% else %}is-full is-half-desktop{% endif %}">
{% if login_form.non_field_errors %} {% if login_form.non_field_errors %}
<p class="notification is-danger">{{ login_form.non_field_errors }}</p> <p class="notification is-danger">{{ login_form.non_field_errors }}</p>
{% endif %} {% endif %}
@ -20,13 +20,15 @@
<div class="field"> <div class="field">
<label class="label" for="id_localname_confirm">{% trans "Username:" %}</label> <label class="label" for="id_localname_confirm">{% trans "Username:" %}</label>
<div class="control"> <div class="control">
<input type="text" name="localname" maxlength="255" class="input" required="" id="id_localname_confirm" value="{{ login_form.localname.value|default:'' }}"> <input type="text" name="localname" maxlength="255" class="input" required=""
id="id_localname_confirm" value="{{ login_form.localname.value|default:'' }}">
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="id_password_confirm">{% trans "Password:" %}</label> <label class="label" for="id_password_confirm">{% trans "Password:" %}</label>
<div class="control"> <div class="control">
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password_confirm" aria-describedby="desc_password"> <input type="password" name="password" maxlength="128" class="input" required=""
id="id_password_confirm" aria-describedby="desc_password">
</div> </div>
{% include 'snippets/form_errors.html' with errors_list=login_form.password.errors id="desc_password" %} {% include 'snippets/form_errors.html' with errors_list=login_form.password.errors id="desc_password" %}
@ -58,10 +60,10 @@
{% include 'snippets/about.html' %} {% include 'snippets/about.html' %}
<p class="block"> <p class="block">
<a href="{% url 'about' %}">{% trans "More about this site" %}</a> <a href="{% url 'about' %}">{% trans "More about this site" %}</a>
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -4,8 +4,8 @@
{% block title %}{% trans "Reset Password" %}{% endblock %} {% block title %}{% trans "Reset Password" %}{% endblock %}
{% block content %} {% block content %}
<div class="columns"> <div class="columns is-multiline">
<div class="column"> <div class="column is-full is-half-desktop">
<div class="block"> <div class="block">
<h1 class="title">{% trans "Reset Password" %}</h1> <h1 class="title">{% trans "Reset Password" %}</h1>

View file

@ -6,7 +6,7 @@
{% block content %} {% block content %}
<h1 class="title">{% trans "Reactivate Account" %}</h1> <h1 class="title">{% trans "Reactivate Account" %}</h1>
<div class="columns is-multiline"> <div class="columns is-multiline">
<div class="column is-half"> <div class="column {% if site.allow_registration %}is-half{% else %}is-full is-half-desktop{% endif %}">
{% if login_form.non_field_errors %} {% if login_form.non_field_errors %}
<p class="notification is-danger">{{ login_form.non_field_errors }}</p> <p class="notification is-danger">{{ login_form.non_field_errors }}</p>
{% endif %} {% endif %}
@ -16,13 +16,15 @@
<div class="field"> <div class="field">
<label class="label" for="id_localname_confirm">{% trans "Username:" %}</label> <label class="label" for="id_localname_confirm">{% trans "Username:" %}</label>
<div class="control"> <div class="control">
<input type="text" name="localname" maxlength="255" class="input" required="" id="id_localname_confirm" value="{{ login_form.localname.value|default:'' }}"> <input type="text" name="localname" maxlength="255" class="input" required=""
id="id_localname_confirm" value="{{ login_form.localname.value|default:'' }}">
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="id_password_confirm">{% trans "Password:" %}</label> <label class="label" for="id_password_confirm">{% trans "Password:" %}</label>
<div class="control"> <div class="control">
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password_confirm" aria-describedby="desc_password"> <input type="password" name="password" maxlength="128" class="input" required=""
id="id_password_confirm" aria-describedby="desc_password">
</div> </div>
{% include 'snippets/form_errors.html' with errors_list=login_form.password.errors id="desc_password" %} {% include 'snippets/form_errors.html' with errors_list=login_form.password.errors id="desc_password" %}
@ -51,10 +53,10 @@
{% include 'snippets/about.html' %} {% include 'snippets/about.html' %}
<p class="block"> <p class="block">
<a href="{% url 'about' %}">{% trans "More about this site" %}</a> <a href="{% url 'about' %}">{% trans "More about this site" %}</a>
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -23,7 +23,7 @@
<div class="notification is-warning"> <div class="notification is-warning">
<p> <p>
{% id_to_username request.user.moved_to as username %} {% id_to_username request.user.moved_to as username %}
{% blocktrans trimmed with moved_to=user.moved_to %} {% blocktrans trimmed with moved_to=user.moved_to %}
<strong>You have moved your account</strong> to <a href="{{ moved_to }}">{{ username }}</a> <strong>You have moved your account</strong> to <a href="{{ moved_to }}">{{ username }}</a>
{% endblocktrans %} {% endblocktrans %}

View file

@ -10,9 +10,7 @@
{% elif notification.notification_type == 'FOLLOW' %} {% elif notification.notification_type == 'FOLLOW' %}
{% include 'notifications/items/follow.html' %} {% include 'notifications/items/follow.html' %}
{% elif notification.notification_type == 'FOLLOW_REQUEST' %} {% elif notification.notification_type == 'FOLLOW_REQUEST' %}
{% if notification.related_users.0.is_active %} {% include 'notifications/items/follow_request.html' %}
{% include 'notifications/items/follow_request.html' %}
{% endif %}
{% elif notification.notification_type == 'IMPORT' %} {% elif notification.notification_type == 'IMPORT' %}
{% include 'notifications/items/import.html' %} {% include 'notifications/items/import.html' %}
{% elif notification.notification_type == 'USER_IMPORT' %} {% elif notification.notification_type == 'USER_IMPORT' %}

View file

@ -14,7 +14,7 @@
{% block description %} {% block description %}
{% if related_user_moved_to %} {% if related_user_moved_to %}
{% id_to_username request.user.moved_to as username %} {% id_to_username related_user_moved_to as username %}
{% blocktrans trimmed %} {% blocktrans trimmed %}
{{ related_user }} has moved to <a href="{{ related_user_moved_to }}">{{ username }}</a> {{ related_user }} has moved to <a href="{{ related_user_moved_to }}">{{ username }}</a>
{% endblocktrans %} {% endblocktrans %}

View file

@ -46,7 +46,11 @@
{% trans "If you wish to migrate any statuses (comments, reviews, or quotes) you must either set the account you are moving to as an <strong>alias</strong> of this one, or <strong>move</strong> this account to the new account, before you import your user data." %} {% trans "If you wish to migrate any statuses (comments, reviews, or quotes) you must either set the account you are moving to as an <strong>alias</strong> of this one, or <strong>move</strong> this account to the new account, before you import your user data." %}
{% endspaceless %} {% endspaceless %}
</p> </p>
{% if next_available %} {% if not site.user_exports_enabled %}
<p class="notification is-danger">
{% trans "New user exports are currently disabled." %}
</p>
{% elif next_available %}
<p class="notification is-warning"> <p class="notification is-warning">
{% blocktrans trimmed %} {% blocktrans trimmed %}
You will be able to create a new export file at {{ next_available }} You will be able to create a new export file at {{ next_available }}

View file

@ -90,6 +90,33 @@
</div> </div>
</form> </form>
</details> </details>
{% if site.user_exports_enabled %}
<details class="details-panel box">
<summary>
<span role="heading" aria-level="2" class="title is-6">
{% trans "Disable starting new user exports" %}
</span>
<span class="details-close icon icon-x" aria-hidden="true"></span>
</summary>
<form
name="disable-user-exports"
id="disable-user-exports"
method="POST"
action="{% url 'settings-user-exports-disable' %}"
>
<div class="notification">
{% trans "This is only intended to be used when things have gone very wrong with exports and you need to pause the feature while addressing issues." %}
{% trans "While exports are disabled, users will not be allowed to start new user exports, but existing exports will not be affected." %}
</div>
{% csrf_token %}
<div class="control">
<button type="submit" class="button is-danger">
{% trans "Disable user exports" %}
</button>
</div>
</form>
</details>
<details class="details-panel box"> <details class="details-panel box">
<summary> <summary>
<span role="heading" aria-level="2" class="title is-6"> <span role="heading" aria-level="2" class="title is-6">
@ -108,7 +135,7 @@
{% trans "Set the value to 0 to not enforce any limit." %} {% trans "Set the value to 0 to not enforce any limit." %}
</div> </div>
<div class="align.to-t"> <div class="align.to-t">
<label for="limit">{% trans "Restrict user imports and exports to once every " %}</label> <label for="limit">{% trans "Limit how often users can import and export user data" %}</label>
<input name="limit" class="input is-w-xs is-h-em" type="text" placeholder="0" value="{{ user_import_time_limit }}"> <input name="limit" class="input is-w-xs is-h-em" type="text" placeholder="0" value="{{ user_import_time_limit }}">
<label>{% trans "hours" %}</label> <label>{% trans "hours" %}</label>
{% csrf_token %} {% csrf_token %}
@ -120,6 +147,28 @@
</div> </div>
</form> </form>
</details> </details>
{% else %}
<form
name="enable-user-imports"
id="enable-user-imports"
method="POST"
action="{% url 'settings-user-exports-enable' %}"
class="box"
>
<div class="notification is-danger is-light">
<p class="my-2">{% trans "Users are currently unable to start new user exports. This is the default setting." %}</p>
{% if use_s3 %}
<p>{% trans "It is not currently possible to provide user exports when using s3 storage. The BookWyrm development team are working on a fix for this." %}</p>
{% endif %}
</div>
{% csrf_token %}
<div class="control">
<button type="submit" class="button is-success" {% if use_s3 %}disabled{% endif %}>
{% trans "Enable user exports" %}
</button>
</div>
</form>
{% endif %}
</div> </div>
<div class="block"> <div class="block">
<h4 class="title is-4">{% trans "Book Imports" %}</h4> <h4 class="title is-4">{% trans "Book Imports" %}</h4>

View file

@ -101,7 +101,6 @@
{% plural %} {% plural %}
{{ formatted_count }} books {{ formatted_count }} books
{% endblocktrans %} {% endblocktrans %}
{% if books.has_other_pages %} {% if books.has_other_pages %}
{% blocktrans trimmed with start=books.start_index end=books.end_index %} {% blocktrans trimmed with start=books.start_index end=books.end_index %}
(showing {{ start }}-{{ end }}) (showing {{ start }}-{{ end }})
@ -111,6 +110,8 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</h2> </h2>
{% include 'shelf/shelves_filters.html' with user=user query=query %}
</div> </div>
{% if is_self and shelf.id %} {% if is_self and shelf.id %}
<div class="column is-narrow"> <div class="column is-narrow">
@ -209,7 +210,17 @@
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
<p><em>{% trans "This shelf is empty." %}</em></p> <p>
<em>
{% if shelves_filter_query %}
{% blocktrans trimmed %}
We couldn't find any books that matched {{ shelves_filter_query }}
{% endblocktrans %}
{% else %}
{% trans "This shelf is empty." %}
{% endif %}
</em>
</p>
{% endif %} {% endif %}
</div> </div>

View file

@ -0,0 +1,9 @@
{% extends 'snippets/filters_panel/filter_field.html' %}
{% load i18n %}
{% block filter %}
<div class="control">
<label class="label" for="filter_query">{% trans 'Filter by keyword' %}</label>
<input aria-label="Filter by keyword" id="my-books-filter" class="input" type="text" name="filter" placeholder="{% trans 'Enter text here' %}" value="{{ shelves_filter_query|default:'' }}" spellcheck="false" />
</div>
{% endblock %}

View file

@ -0,0 +1,5 @@
{% extends 'snippets/filters_panel/filters_panel.html' %}
{% block filter_fields %}
{% include 'shelf/shelves_filter_field.html' %}
{% endblock %}

View file

@ -1,24 +1,25 @@
{% load static %} {% load static %}
{% if preview_images_enabled is True %} {% firstof image site.preview_image as page_image %}
{% if page_image %}
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
{% if image %} <meta name="twitter:image" content="{{ media_full_url }}{{ page_image }}">
<meta name="twitter:image" content="{{ media_full_url }}{{ image }}"> <meta name="og:image" content="{{ media_full_url }}{{ page_image }}">
<meta name="og:image" content="{{ media_full_url }}{{ image }}"> {% elif site.logo %}
{% else %} <meta name="twitter:card" content="summary">
<meta name="twitter:image" content="{{ media_full_url }}{{ site.preview_image }}"> <meta name="twitter:image" content="{{ media_full_url }}{{ site.logo }}">
<meta name="og:image" content="{{ media_full_url }}{{ site.preview_image }}"> <meta name="twitter:image:alt" content="{{ site.name }} Logo">
{% endif %} <meta name="og:image" content="{{ media_full_url }}{{ site.logo }}">
{% else %} {% else %}
<meta name="twitter:card" content="summary"> <meta name="twitter:card" content="summary">
<meta name="twitter:image" content="{% if site.logo %}{{ media_full_url }}{{ site.logo }}{% else %}{% static "images/logo.png" %}{% endif %}"> <meta name="twitter:image" content="{% static "images/logo.png" %}">
<meta name="og:image" content="{% if site.logo %}{{ media_full_url }}{{ site.logo }}{% else %}{% static "images/logo.png" %}{% endif %}"> <meta name="twitter:image:alt" content="BookWyrm Logo">
<meta name="og:image" content="{% static "images/logo.png" %}">
{% endif %} {% endif %}
<meta name="twitter:image:alt" content="BookWyrm Logo">
<meta name="twitter:title" content="{% if title %}{{ title }} - {% endif %}{{ site.name }}"> <meta name="twitter:title" content="{% if title %}{{ title }} - {% endif %}{{ site.name }}">
<meta name="og:title" content="{% if title %}{{ title }} - {% endif %}{{ site.name }}"> <meta name="og:title" content="{% if title %}{{ title }} - {% endif %}{{ site.name }}">
<meta name="twitter:description" content="{% if description %}{{ description }}{% else %}{{ site.instance_tagline }}{% endif %}"> {% firstof description site.instance_tagline as description %}
<meta name="og:description" content="{% if description %}{{ description }}{% else %}{{ site.instance_tagline }}{% endif %}"> <meta name="twitter:description" content="{{ description }}">
<meta name="og:description" content="{{ description }}">

View file

@ -125,7 +125,8 @@ def id_to_username(user_id):
name = parts[-1] name = parts[-1]
value = f"{name}@{domain}" value = f"{name}@{domain}"
return value return value
return "a new user account"
@register.filter(name="get_file_size") @register.filter(name="get_file_size")

View file

@ -227,14 +227,18 @@ class ActivitypubMixins(TestCase):
shared_inbox="http://example.com/inbox", shared_inbox="http://example.com/inbox",
outbox="https://example.com/users/nutria/outbox", outbox="https://example.com/users/nutria/outbox",
) )
MockSelf = namedtuple("Self", ("privacy", "user")) MockSelf = namedtuple("Self", ("privacy", "user", "recipients"))
mock_self = MockSelf("public", self.local_user)
self.local_user.followers.add(self.remote_user) self.local_user.followers.add(self.remote_user)
self.local_user.followers.add(another_remote_user) self.local_user.followers.add(another_remote_user)
mock_self = MockSelf("public", self.local_user, [])
recipients = ActivitypubMixin.get_recipients(mock_self) recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertEqual(len(recipients), 1) self.assertCountEqual(recipients, ["http://example.com/inbox"])
self.assertEqual(recipients[0], "http://example.com/inbox")
# should also work with recipient that is a follower
mock_self.recipients.append(another_remote_user)
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertCountEqual(recipients, ["http://example.com/inbox"])
def test_get_recipients_software(self, *_): def test_get_recipients_software(self, *_):
"""should differentiate between bookwyrm and other remote users""" """should differentiate between bookwyrm and other remote users"""

View file

@ -18,7 +18,9 @@ class ExportViews(TestCase):
"""viewing and creating statuses""" """viewing and creating statuses"""
@classmethod @classmethod
def setUpTestData(self): # pylint: disable=bad-classmethod-argument def setUpTestData(
self,
): # pylint: disable=bad-classmethod-argument, disable=invalid-name
"""we need basic test data and mocks""" """we need basic test data and mocks"""
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch( with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay" "bookwyrm.activitystreams.populate_stream_task.delay"
@ -40,6 +42,7 @@ class ExportViews(TestCase):
bnf_id="beep", bnf_id="beep",
) )
# pylint: disable=invalid-name
def setUp(self): def setUp(self):
"""individual test setup""" """individual test setup"""
self.factory = RequestFactory() self.factory = RequestFactory()
@ -53,11 +56,12 @@ class ExportViews(TestCase):
def test_export_file(self, *_): def test_export_file(self, *_):
"""simple export""" """simple export"""
models.ShelfBook.objects.create( shelfbook = models.ShelfBook.objects.create(
shelf=self.local_user.shelf_set.first(), shelf=self.local_user.shelf_set.first(),
user=self.local_user, user=self.local_user,
book=self.book, book=self.book,
) )
book_date = str.encode(f"{shelfbook.shelved_date.date()}")
request = self.factory.post("") request = self.factory.post("")
request.user = self.local_user request.user = self.local_user
export = views.Export.as_view()(request) export = views.Export.as_view()(request)
@ -66,7 +70,7 @@ class ExportViews(TestCase):
# pylint: disable=line-too-long # pylint: disable=line-too-long
self.assertEqual( self.assertEqual(
export.content, export.content,
b"title,author_text,remote_id,openlibrary_key,inventaire_id,librarything_key,goodreads_key,bnf_id,viaf,wikidata,asin,aasin,isfdb,isbn_10,isbn_13,oclc_number,rating,review_name,review_cw,review_content\r\nTest Book,," b"title,author_text,remote_id,openlibrary_key,inventaire_id,librarything_key,goodreads_key,bnf_id,viaf,wikidata,asin,aasin,isfdb,isbn_10,isbn_13,oclc_number,start_date,finish_date,stopped_date,rating,review_name,review_cw,review_content,review_published,shelf,shelf_name,shelf_date\r\n"
+ self.book.remote_id.encode("utf-8") + b"Test Book,,%b,,,,,beep,,,,,,123456789X,9781234567890,,,,,,,,,,to-read,To Read,%b\r\n"
+ b",,,,,beep,,,,,,123456789X,9781234567890,,,,,\r\n", % (self.book.remote_id.encode("utf-8"), book_date),
) )

View file

@ -338,6 +338,16 @@ urlpatterns = [
views.disable_imports, views.disable_imports,
name="settings-imports-disable", name="settings-imports-disable",
), ),
re_path(
r"^settings/user-exports/enable/?$",
views.enable_user_exports,
name="settings-user-exports-enable",
),
re_path(
r"^settings/user-exports/disable/?$",
views.disable_user_exports,
name="settings-user-exports-disable",
),
re_path( re_path(
r"^settings/imports/enable/?$", r"^settings/imports/enable/?$",
views.enable_imports, views.enable_imports,

View file

@ -19,6 +19,8 @@ from .admin.imports import (
set_import_size_limit, set_import_size_limit,
set_user_import_completed, set_user_import_completed,
set_user_import_limit, set_user_import_limit,
enable_user_exports,
disable_user_exports,
) )
from .admin.ip_blocklist import IPBlocklist from .admin.ip_blocklist import IPBlocklist
from .admin.invite import ManageInvites, Invite, InviteRequest from .admin.invite import ManageInvites, Invite, InviteRequest

View file

@ -9,7 +9,7 @@ from django.views.decorators.http import require_POST
from bookwyrm import models from bookwyrm import models
from bookwyrm.views.helpers import redirect_to_referer from bookwyrm.views.helpers import redirect_to_referer
from bookwyrm.settings import PAGE_LENGTH from bookwyrm.settings import PAGE_LENGTH, USE_S3
# pylint: disable=no-self-use # pylint: disable=no-self-use
@ -59,6 +59,7 @@ class ImportList(View):
"import_size_limit": site_settings.import_size_limit, "import_size_limit": site_settings.import_size_limit,
"import_limit_reset": site_settings.import_limit_reset, "import_limit_reset": site_settings.import_limit_reset,
"user_import_time_limit": site_settings.user_import_time_limit, "user_import_time_limit": site_settings.user_import_time_limit,
"use_s3": USE_S3,
} }
return TemplateResponse(request, "settings/imports/imports.html", data) return TemplateResponse(request, "settings/imports/imports.html", data)
@ -126,3 +127,25 @@ def set_user_import_limit(request):
site.user_import_time_limit = int(request.POST.get("limit")) site.user_import_time_limit = int(request.POST.get("limit"))
site.save(update_fields=["user_import_time_limit"]) site.save(update_fields=["user_import_time_limit"])
return redirect("settings-imports") return redirect("settings-imports")
@require_POST
@permission_required("bookwyrm.edit_instance_settings", raise_exception=True)
# pylint: disable=unused-argument
def enable_user_exports(request):
"""Allow users to export account data"""
site = models.SiteSettings.objects.get()
site.user_exports_enabled = True
site.save(update_fields=["user_exports_enabled"])
return redirect("settings-imports")
@require_POST
@permission_required("bookwyrm.edit_instance_settings", raise_exception=True)
# pylint: disable=unused-argument
def disable_user_exports(request):
"""Don't allow users to export account data"""
site = models.SiteSettings.objects.get()
site.user_exports_enabled = False
site.save(update_fields=["user_exports_enabled"])
return redirect("settings-imports")

View file

@ -1,4 +1,5 @@
""" non-interactive pages """ """ non-interactive pages """
from datetime import date
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import Q from django.db.models import Q
@ -52,6 +53,19 @@ class Feed(View):
suggestions = suggested_users.get_suggestions(request.user) suggestions = suggested_users.get_suggestions(request.user)
cutoff = (
date(get_annual_summary_year(), 12, 31)
if get_annual_summary_year()
else None
)
readthroughs = (
models.ReadThrough.objects.filter(
user=request.user, finish_date__lte=cutoff
)
if get_annual_summary_year()
else []
)
data = { data = {
**feed_page_data(request.user), **feed_page_data(request.user),
**{ **{
@ -66,6 +80,7 @@ class Feed(View):
"path": f"/{tab['key']}", "path": f"/{tab['key']}",
"annual_summary_year": get_annual_summary_year(), "annual_summary_year": get_annual_summary_year(),
"has_tour": True, "has_tour": True,
"has_summary_read_throughs": len(readthroughs),
}, },
} }
return TemplateResponse(request, "feed/feed.html", data) return TemplateResponse(request, "feed/feed.html", data)
@ -185,19 +200,15 @@ class Status(View):
params=[status.id, visible_thread, visible_thread], params=[status.id, visible_thread, visible_thread],
) )
preview = None
if hasattr(status, "book"):
preview = status.book.preview_image
elif status.mention_books.exists():
preview = status.mention_books.first().preview_image
data = { data = {
**feed_page_data(request.user), **feed_page_data(request.user),
**{ **{
"status": status, "status": status,
"children": children, "children": children,
"ancestors": ancestors, "ancestors": ancestors,
"preview": preview, "title": status.page_title,
"description": status.page_description,
"page_image": status.page_image,
}, },
} }
return TemplateResponse(request, "feed/status.html", data) return TemplateResponse(request, "feed/status.html", data)

View file

@ -17,7 +17,8 @@ from bookwyrm import models
from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob
from bookwyrm.settings import PAGE_LENGTH from bookwyrm.settings import PAGE_LENGTH
# pylint: disable=no-self-use
# pylint: disable=no-self-use,too-many-locals
@method_decorator(login_required, name="dispatch") @method_decorator(login_required, name="dispatch")
class Export(View): class Export(View):
"""Let users export data""" """Let users export data"""
@ -54,7 +55,19 @@ class Export(View):
fields = ( fields = (
["title", "author_text"] ["title", "author_text"]
+ deduplication_fields + deduplication_fields
+ ["rating", "review_name", "review_cw", "review_content"] + [
"start_date",
"finish_date",
"stopped_date",
"rating",
"review_name",
"review_cw",
"review_content",
"review_published",
"shelf",
"shelf_name",
"shelf_date",
]
) )
writer.writerow(fields) writer.writerow(fields)
@ -70,6 +83,24 @@ class Export(View):
book.rating = review_rating.rating if review_rating else None book.rating = review_rating.rating if review_rating else None
readthrough = (
models.ReadThrough.objects.filter(user=request.user, book=book)
.order_by("-start_date", "-finish_date")
.first()
)
if readthrough:
book.start_date = (
readthrough.start_date.date() if readthrough.start_date else None
)
book.finish_date = (
readthrough.finish_date.date() if readthrough.finish_date else None
)
book.stopped_date = (
readthrough.stopped_date.date()
if readthrough.stopped_date
else None
)
review = ( review = (
models.Review.objects.filter( models.Review.objects.filter(
user=request.user, book=book, content__isnull=False user=request.user, book=book, content__isnull=False
@ -78,9 +109,27 @@ class Export(View):
.first() .first()
) )
if review: if review:
book.review_published = (
review.published_date.date() if review.published_date else None
)
book.review_name = review.name book.review_name = review.name
book.review_cw = review.content_warning book.review_cw = review.content_warning
book.review_content = review.raw_content book.review_content = (
review.raw_content if review.raw_content else review.content
) # GoodReads imported reviews do not have raw_content, but content.
shelfbook = (
models.ShelfBook.objects.filter(user=request.user, book=book)
.order_by("-shelved_date", "-created_date", "-updated_date")
.last()
)
if shelfbook:
book.shelf = shelfbook.shelf.identifier
book.shelf_name = shelfbook.shelf.name
book.shelf_date = (
shelfbook.shelved_date.date() if shelfbook.shelved_date else None
)
writer.writerow([getattr(book, field, "") or "" for field in fields]) writer.writerow([getattr(book, field, "") or "" for field in fields])
return HttpResponse( return HttpResponse(

View file

@ -51,7 +51,7 @@ class Search(View):
def api_book_search(request): def api_book_search(request):
"""Return books via API response""" """Return books via API response"""
query = request.GET.get("q") query = request.GET.get("q")
query = isbn_check(query) query = isbn_check_and_format(query)
min_confidence = request.GET.get("min_confidence", 0) min_confidence = request.GET.get("min_confidence", 0)
# only return local book results via json so we don't cascade # only return local book results via json so we don't cascade
book_results = search(query, min_confidence=min_confidence) book_results = search(query, min_confidence=min_confidence)
@ -64,7 +64,7 @@ def book_search(request):
"""the real business is elsewhere""" """the real business is elsewhere"""
query = request.GET.get("q") query = request.GET.get("q")
# check if query is isbn # check if query is isbn
query = isbn_check(query) query = isbn_check_and_format(query)
min_confidence = request.GET.get("min_confidence", 0) min_confidence = request.GET.get("min_confidence", 0)
search_remote = request.GET.get("remote", False) and request.user.is_authenticated search_remote = request.GET.get("remote", False) and request.user.is_authenticated
@ -159,7 +159,7 @@ def list_search(request):
return TemplateResponse(request, "search/list.html", data) return TemplateResponse(request, "search/list.html", data)
def isbn_check(query): def isbn_check_and_format(query):
"""isbn10 or isbn13 check, if so remove separators""" """isbn10 or isbn13 check, if so remove separators"""
if query: if query:
su_num = re.sub(r"(?<=\d)\D(?=\d|[xX])", "", query) su_num = re.sub(r"(?<=\d)\D(?=\d|[xX])", "", query)

View file

@ -15,12 +15,14 @@ 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 bookwyrm.views.helpers import is_api_request, get_user_from_username from bookwyrm.views.helpers import is_api_request, get_user_from_username
from bookwyrm.book_search import search
# pylint: disable=no-self-use # pylint: disable=no-self-use
class Shelf(View): class Shelf(View):
"""shelf page""" """shelf page"""
# pylint: disable=R0914
def get(self, request, username, shelf_identifier=None): def get(self, request, username, shelf_identifier=None):
"""display a shelf""" """display a shelf"""
user = get_user_from_username(request.user, username) user = get_user_from_username(request.user, username)
@ -32,6 +34,8 @@ class Shelf(View):
else: else:
shelves = models.Shelf.privacy_filter(request.user).filter(user=user).all() shelves = models.Shelf.privacy_filter(request.user).filter(user=user).all()
shelves_filter_query = request.GET.get("filter")
# 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:
shelf = get_object_or_404(user.shelf_set, identifier=shelf_identifier) shelf = get_object_or_404(user.shelf_set, identifier=shelf_identifier)
@ -42,6 +46,7 @@ class Shelf(View):
FakeShelf = namedtuple( FakeShelf = namedtuple(
"Shelf", ("identifier", "name", "user", "books", "privacy") "Shelf", ("identifier", "name", "user", "books", "privacy")
) )
books = ( books = (
models.Edition.viewer_aware_objects(request.user) models.Edition.viewer_aware_objects(request.user)
.filter( .filter(
@ -50,6 +55,7 @@ class Shelf(View):
) )
.distinct() .distinct()
) )
shelf = FakeShelf("all", _("All books"), user, books, "public") shelf = FakeShelf("all", _("All books"), user, books, "public")
if is_api_request(request) and shelf_identifier: if is_api_request(request) and shelf_identifier:
@ -86,6 +92,9 @@ class Shelf(View):
books = sort_books(books, request.GET.get("sort")) books = sort_books(books, request.GET.get("sort"))
if shelves_filter_query:
books = search(shelves_filter_query, books=books)
paginated = Paginator( paginated = Paginator(
books, books,
PAGE_LENGTH, PAGE_LENGTH,
@ -103,6 +112,8 @@ class Shelf(View):
"page_range": paginated.get_elided_page_range( "page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1 page.number, on_each_side=2, on_ends=1
), ),
"shelves_filter_query": shelves_filter_query,
"size": "small",
} }
return TemplateResponse(request, "shelf/shelf.html", data) return TemplateResponse(request, "shelf/shelf.html", data)

5
bw-dev
View file

@ -246,6 +246,9 @@ case "$CMD" in
remove_remote_user_preview_images) remove_remote_user_preview_images)
runweb python manage.py remove_remote_user_preview_images runweb python manage.py remove_remote_user_preview_images
;; ;;
erase_deleted_user_data)
runweb python manage.py erase_deleted_user_data "$@"
;;
copy_media_to_s3) copy_media_to_s3)
awscommand "bookwyrm_media_volume:/images"\ awscommand "bookwyrm_media_volume:/images"\
"s3 cp /images s3://${AWS_STORAGE_BUCKET_NAME}/images\ "s3 cp /images s3://${AWS_STORAGE_BUCKET_NAME}/images\
@ -297,7 +300,7 @@ case "$CMD" in
echo "Unrecognised command. Try:" echo "Unrecognised command. Try:"
echo " setup" echo " setup"
echo " up [container]" echo " up [container]"
echo " down" echo " down"
echo " service_ports_web" echo " service_ports_web"
echo " initdb" echo " initdb"
echo " resetdb" echo " resetdb"

View file

@ -1,4 +1,4 @@
FROM python:3.9 FROM python:3.9-bookworm
WORKDIR /app/dev-tools WORKDIR /app/dev-tools
ENV PATH="/app/dev-tools/node_modules/.bin:$PATH" ENV PATH="/app/dev-tools/node_modules/.bin:$PATH"

View file

@ -64,13 +64,18 @@ server {
# directly serve images and static files from the # directly serve images and static files from the
# bookwyrm filesystem using sendfile. # bookwyrm filesystem using sendfile.
# make the logs quieter by not reporting these requests # make the logs quieter by not reporting these requests
location ~ ^/(images|static)/ { location ~ \.(bmp|ico|jpg|jpeg|png|svg|tif|tiff|ttf|webp|css|js)$ {
root /app; root /app;
try_files $uri =404; try_files $uri =404;
add_header X-Cache-Status STATIC; add_header X-Cache-Status STATIC;
access_log off; access_log off;
} }
# block access to any non-image files from images or static
location ~ ^/images/ {
return 403;
}
# monitor the celery queues with flower, no caching enabled # monitor the celery queues with flower, no caching enabled
location /flower/ { location /flower/ {
proxy_pass http://flower:8888; proxy_pass http://flower:8888;

View file

@ -96,12 +96,17 @@ server {
# # directly serve images and static files from the # # directly serve images and static files from the
# # bookwyrm filesystem using sendfile. # # bookwyrm filesystem using sendfile.
# # make the logs quieter by not reporting these requests # # make the logs quieter by not reporting these requests
# location ~ ^/(images|static)/ { # location ~ \.(bmp|ico|jpg|jpeg|png|svg|tif|tiff|ttf|webp|css|js)$ {
# root /app; # root /app;
# try_files $uri =404; # try_files $uri =404;
# add_header X-Cache-Status STATIC; # add_header X-Cache-Status STATIC;
# access_log off; # access_log off;
# } # }
# # block access to any non-image files from images or static
# location ~ ^/images/ {
# return 403;
# }
# #
# # monitor the celery queues with flower, no caching enabled # # monitor the celery queues with flower, no caching enabled
# location /flower/ { # location /flower/ {

View file

@ -2,6 +2,9 @@ bind 127.0.0.1 ::1
protected-mode yes protected-mode yes
port 6379 port 6379
auto-aof-rewrite-percentage 50
auto-aof-rewrite-min-size 128mb
rename-command FLUSHDB "" rename-command FLUSHDB ""
rename-command FLUSHALL "" rename-command FLUSHALL ""
rename-command DEBUG "" rename-command DEBUG ""

View file

@ -1,4 +1,4 @@
aiohttp==3.9.0 aiohttp==3.9.2
bleach==5.0.1 bleach==5.0.1
celery==5.2.7 celery==5.2.7
colorthief==0.2.1 colorthief==0.2.1
@ -16,7 +16,7 @@ libsass==0.22.0
Markdown==3.4.1 Markdown==3.4.1
Pillow==10.0.1 Pillow==10.0.1
psycopg2==2.9.5 psycopg2==2.9.5
pycryptodome==3.16.0 pycryptodome==3.19.1
python-dateutil==2.8.2 python-dateutil==2.8.2
redis==4.5.4 redis==4.5.4
requests==2.31.0 requests==2.31.0