Merge branch 'main' into wikidata

This commit is contained in:
Carlos Cámara 2024-02-04 20:34:51 +01:00 committed by GitHub
commit 71f527eb1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 659 additions and 207 deletions

View file

@ -137,3 +137,10 @@ TWO_FACTOR_LOGIN_MAX_SECONDS=60
# and AWS_S3_CUSTOM_DOMAIN (if used) are added by default.
# Value should be a comma-separated list of host names.
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

@ -1 +1 @@
0.7.1
0.7.2

View file

@ -1,54 +0,0 @@
""" Get your admin code to allow install """
from django.core.management.base import BaseCommand
from bookwyrm import models
from bookwyrm.settings import VERSION
# pylint: disable=no-self-use
class Command(BaseCommand):
"""command-line options"""
help = "What version is this?"
def add_arguments(self, parser):
"""specify which function to run"""
parser.add_argument(
"--current",
action="store_true",
help="Version stored in database",
)
parser.add_argument(
"--target",
action="store_true",
help="Version stored in settings",
)
parser.add_argument(
"--update",
action="store_true",
help="Update database version",
)
# pylint: disable=unused-argument
def handle(self, *args, **options):
"""execute init"""
site = models.SiteSettings.objects.get()
current = site.version or "0.0.1"
target = VERSION
if options.get("current"):
print(current)
return
if options.get("target"):
print(target)
return
if options.get("update"):
site.version = target
site.save()
return
if current != target:
print(f"{current}/{target}")
else:
print(current)

View file

@ -0,0 +1,23 @@
# Generated by Django 3.2.23 on 2024-01-04 23:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0191_merge_20240102_0326"),
]
operations = [
migrations.AlterField(
model_name="quotation",
name="endposition",
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name="quotation",
name="position",
field=models.TextField(blank=True, null=True),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.23 on 2024-01-02 19:36
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0191_merge_20240102_0326"),
]
operations = [
migrations.RenameField(
model_name="sitesettings",
old_name="version",
new_name="available_version",
),
]

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

@ -0,0 +1,13 @@
# Generated by Django 3.2.23 on 2024-02-03 15:39
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0192_make_page_positions_text"),
("bookwyrm", "0192_sitesettings_user_exports_enabled"),
]
operations = []

View file

@ -0,0 +1,13 @@
# Generated by Django 3.2.23 on 2024-02-03 16:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0192_rename_version_sitesettings_available_version"),
("bookwyrm", "0193_merge_20240203_1539"),
]
operations = []

View file

@ -152,8 +152,9 @@ class ActivitypubMixin:
# find anyone who's tagged in a status, for example
mentions = self.recipients if hasattr(self, "recipients") else []
# we always send activities to explicitly mentioned users' inboxes
recipients = [u.inbox for u in mentions or [] if not u.local]
# we always send activities to explicitly mentioned users (using shared inboxes
# 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
if privacy != "direct":
@ -173,18 +174,18 @@ class ActivitypubMixin:
if user:
queryset = queryset.filter(following=user)
# ideally, we will send to shared inboxes for efficiency
shared_inboxes = (
queryset.filter(shared_inbox__isnull=False)
.values_list("shared_inbox", flat=True)
.distinct()
# as above, we prefer shared inboxes if available
recipients.update(
queryset.filter(shared_inbox__isnull=False).values_list(
"shared_inbox", flat=True
)
)
# but not everyone has a shared inbox
inboxes = queryset.filter(shared_inbox__isnull=True).values_list(
"inbox", flat=True
recipients.update(
queryset.filter(shared_inbox__isnull=True).values_list(
"inbox", flat=True
)
)
recipients += list(shared_inboxes) + list(inboxes)
return list(set(recipients))
return list(recipients)
def to_activity_dataclass(self):
"""convert from a model to an activity"""

View file

@ -10,8 +10,11 @@ from django.dispatch import receiver
from django.utils import timezone
from model_utils import FieldTracker
from bookwyrm.connectors.abstract_connector import get_data
from bookwyrm.preview_images import generate_site_preview_image_task
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, STATIC_FULL_URL
from bookwyrm.settings import RELEASE_API
from bookwyrm.tasks import app, MISC
from .base_model import BookWyrmModel, new_access_code
from .user import User
from .fields import get_absolute_url
@ -45,7 +48,7 @@ class SiteSettings(SiteModel):
default_theme = models.ForeignKey(
"Theme", null=True, blank=True, on_delete=models.SET_NULL
)
version = models.CharField(null=True, blank=True, max_length=10)
available_version = models.CharField(null=True, blank=True, max_length=10)
# admin setup options
install_mode = models.BooleanField(default=False)
@ -96,6 +99,7 @@ class SiteSettings(SiteModel):
imports_enabled = models.BooleanField(default=True)
import_size_limit = 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)
field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"])
@ -244,3 +248,14 @@ def preview_image(instance, *args, **kwargs):
if len(changed_fields) > 0:
generate_site_preview_image_task.delay()
@app.task(queue=MISC)
def check_for_updates_task():
"""See if git remote knows about a new version"""
site = SiteSettings.objects.get()
release = get_data(RELEASE_API, timeout=3)
available_version = release.get("tag_name", None)
if available_version:
site.available_version = available_version
site.save(update_fields=["available_version"])

View file

@ -12,6 +12,8 @@ from django.db.models import Q
from django.dispatch import receiver
from django.template.loader import get_template
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.managers import InheritanceManager
@ -107,14 +109,14 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
@property
def recipients(self):
"""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 (
hasattr(self, "reply_parent")
and self.reply_parent
and not self.reply_parent.user.local
):
mentions.append(self.reply_parent.user)
return list(set(mentions))
mentions.add(self.reply_parent.user)
return list(mentions)
@classmethod
def ignore_activity(
@ -178,6 +180,24 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
"""you can't boost dms"""
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):
"""helper function for loading AP serialized replies to a status"""
return self.to_ordered_collection(
@ -301,6 +321,10 @@ class BookStatus(Status):
abstract = True
@property
def page_image(self):
return self.book.preview_image or self.book.cover or super().page_image
class Comment(BookStatus):
"""like a review but without a rating and transient"""
@ -332,17 +356,26 @@ class Comment(BookStatus):
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):
"""like a review but without a rating and transient"""
quote = fields.HtmlField()
raw_quote = models.TextField(blank=True, null=True)
position = models.IntegerField(
validators=[MinValueValidator(0)], null=True, blank=True
position = models.TextField(
null=True,
blank=True,
)
endposition = models.IntegerField(
validators=[MinValueValidator(0)], null=True, blank=True
endposition = models.TextField(
null=True,
blank=True,
)
position_mode = models.CharField(
max_length=3,
@ -374,6 +407,13 @@ class Quotation(BookStatus):
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):
"""a book review"""
@ -403,6 +443,13 @@ class Review(BookStatus):
"""indicate the book in question for mastodon (or w/e) users"""
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
pure_type = "Article"
@ -426,6 +473,18 @@ class ReviewRating(Review):
template = get_template("snippets/generated_status/rating.html")
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
pure_type = "Note"

View file

@ -30,6 +30,9 @@ RELEASE_API = env(
PAGE_LENGTH = env.int("PAGE_LENGTH", 15)
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"
@ -347,8 +350,7 @@ USE_L10N = True
USE_TZ = True
agent = requests.utils.default_user_agent()
USER_AGENT = f"{agent} (BookWyrm/{VERSION}; +https://{DOMAIN}/)"
USER_AGENT = f"BookWyrm (BookWyrm/{VERSION}; +https://{DOMAIN}/)"
# Imagekit generated thumbnails
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
# user with the same username - in which case you should change it!
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: {
b: {
2: "FB2",
3: "FB3",
},
l: {
a: {
c: "FLAC",

View file

@ -31,10 +31,10 @@
</p>
</div>
<div class="columns">
<div class="columns is-multiline">
{% if superlatives.top_rated %}
{% 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-left">
<a href="{{ book.local_path }}">
@ -53,7 +53,7 @@
{% if superlatives.wanted %}
{% 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-left">
<a href="{{ book.local_path }}">
@ -72,7 +72,7 @@
{% if superlatives.controversial %}
{% 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-left">
<a href="{{ book.local_path }}">

View file

@ -9,7 +9,8 @@
{% block title %}{{ book|book_title }}{% endblock %}
{% 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 %}
{% block content %}

View file

@ -6,8 +6,8 @@
{% block content %}
<h1 class="title">{% trans "Confirm your email address" %}</h1>
<div class="columns">
<div class="column">
<div class="columns is-multiline">
<div class="column is-full is-half-desktop">
<div class="block content">
<section class="block">
<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>
{% 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 }}">
{% include 'feed/summary_card.html' with year=annual_summary_year %}
<hr>

View file

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

View file

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

View file

@ -6,7 +6,7 @@
{% block content %}
<h1 class="title">{% trans "Log in" %}</h1>
<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 %}
<p class="notification is-danger">{{ login_form.non_field_errors }}</p>
{% endif %}
@ -20,13 +20,15 @@
<div class="field">
<label class="label" for="id_localname_confirm">{% trans "Username:" %}</label>
<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 class="field">
<label class="label" for="id_password_confirm">{% trans "Password:" %}</label>
<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>
{% include 'snippets/form_errors.html' with errors_list=login_form.password.errors id="desc_password" %}
@ -58,10 +60,10 @@
{% include 'snippets/about.html' %}
<p class="block">
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
</p>
</div>
</div>
</div>
{% endblock %}
{% endblock %}

View file

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

View file

@ -6,7 +6,7 @@
{% block content %}
<h1 class="title">{% trans "Reactivate Account" %}</h1>
<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 %}
<p class="notification is-danger">{{ login_form.non_field_errors }}</p>
{% endif %}
@ -16,13 +16,15 @@
<div class="field">
<label class="label" for="id_localname_confirm">{% trans "Username:" %}</label>
<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 class="field">
<label class="label" for="id_password_confirm">{% trans "Password:" %}</label>
<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>
{% include 'snippets/form_errors.html' with errors_list=login_form.password.errors id="desc_password" %}
@ -51,10 +53,10 @@
{% include 'snippets/about.html' %}
<p class="block">
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
</p>
</div>
</div>
</div>
{% endblock %}
{% endblock %}

View file

@ -23,7 +23,7 @@
<div class="notification is-warning">
<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 %}
<strong>You have moved your account</strong> to <a href="{{ moved_to }}">{{ username }}</a>
{% endblocktrans %}

View file

@ -14,7 +14,7 @@
{% block description %}
{% 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 %}
{{ related_user }} has moved to <a href="{{ related_user_moved_to }}">{{ username }}</a>
{% 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." %}
{% endspaceless %}
</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">
{% blocktrans trimmed %}
You will be able to create a new export file at {{ next_available }}

View file

@ -45,6 +45,10 @@
{% include 'settings/dashboard/warnings/update_version.html' with warning_level="warning" fullwidth=True %}
{% endif %}
{% if schedule_form %}
{% include 'settings/dashboard/warnings/check_for_updates.html' with warning_level="success" fullwidth=True %}
{% endif %}
{% if missing_privacy or missing_conduct %}
<div class="column is-12 columns m-0 p-0">
{% if missing_privacy %}

View file

@ -0,0 +1,24 @@
{% extends 'settings/dashboard/warnings/layout.html' %}
{% load i18n %}
{% block warning_link %}#{% endblock %}
{% block warning_text %}
<form name="check-version" method="POST" action="{% url 'settings-dashboard' %}" class="is-flex is-align-items-center">
{% csrf_token %}
<p class="pr-2">
{% blocktrans trimmed with current=current_version available=available_version %}
Would you like to automatically check for new BookWyrm releases? (recommended)
{% endblocktrans %}
</p>
{{ schedule_form.every.as_hidden }}
{{ schedule_form.period.as_hidden }}
<button class="button is-small" type="submit">{% trans "Schedule checks" %}</button>
</form>
{% endblock %}

View file

@ -90,6 +90,33 @@
</div>
</form>
</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">
<summary>
<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." %}
</div>
<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 }}">
<label>{% trans "hours" %}</label>
{% csrf_token %}
@ -120,6 +147,28 @@
</div>
</form>
</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 class="block">
<h4 class="title is-4">{% trans "Book Imports" %}</h4>

View file

@ -85,6 +85,10 @@
{% url 'settings-celery' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Celery status" %}</a>
</li>
<li>
{% url 'settings-schedules' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Scheduled tasks" %}</a>
</li>
<li>
{% url 'settings-email-config' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Email Configuration" %}</a>

View file

@ -0,0 +1,127 @@
{% extends 'settings/layout.html' %}
{% load i18n %}
{% load humanize %}
{% load utilities %}
{% block title %}
{% trans "Scheduled tasks" %}
{% endblock %}
{% block header %}
{% trans "Scheduled tasks" %}
{% endblock %}
{% block panel %}
<div class="block content">
<h3>{% trans "Tasks" %}</h3>
<div class="table-container">
<table class="table is-striped is-fullwidth">
<tr>
<th>
{% trans "Name" %}
</th>
<th>
{% trans "Celery task" %}
</th>
<th>
{% trans "Date changed" %}
</th>
<th>
{% trans "Last run at" %}
</th>
<th>
{% trans "Schedule" %}
</th>
<th>
{% trans "Schedule ID" %}
</th>
<th>
{% trans "Enabled" %}
</th>
</tr>
{% for task in tasks %}
<tr>
<td>
{{ task.name }}
</td>
<td class="overflow-wrap-anywhere">
{{ task.task }}
</td>
<td>
{{ task.date_changed }}
</td>
<td>
{{ task.last_run_at }}
</td>
<td>
{% firstof task.interval task.crontab "None" %}
</td>
<td>
{{ task.interval.id }}
</td>
<td>
<span class="tag">
{% if task.enabled %}
<span class="icon icon-check" aria-hidden="true"></span>
{% endif %}
{{ task.enabled|yesno }}
</span>
{% if task.name != "celery.backend_cleanup" %}
<form name="unschedule-{{ task.id }}" method="POST" action="{% url 'settings-schedules' task.id %}">
{% csrf_token %}
<button type="submit" class="button is-danger is-small">{% trans "Un-schedule" %}</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="2">
{% trans "No scheduled tasks" %}
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="block content">
<h3>{% trans "Schedules" %}</h3>
<div class="table-container">
<table class="table is-striped is-fullwidth">
<tr>
<th>
{% trans "ID" %}
</th>
<th>
{% trans "Schedule" %}
</th>
<th>
{% trans "Tasks" %}
</th>
</tr>
{% for schedule in schedules %}
<tr>
<td>
{{ schedule.id }}
</td>
<td class="overflow-wrap-anywhere">
{{ schedule }}
</td>
<td>
{{ schedule.periodictask_set.count }}
</td>
</tr>
{% empty %}
<tr>
<td colspan="2">
{% trans "No schedules found" %}
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% endblock %}

View file

@ -56,8 +56,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
<input
aria-label="{% if draft.position_mode == 'PG' %}Page{% else %}Percent{% endif %}"
class="input"
type="number"
min="0"
type="text"
name="position"
size="3"
value="{% firstof draft.position '' %}"
@ -72,8 +71,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
<input
aria-label="{% if draft.position_mode == 'PG' %}Page{% else %}Percent{% endif %}"
class="input"
type="number"
min="0"
type="text"
name="endposition"
size="3"
value="{% firstof draft.endposition '' %}"

View file

@ -1,24 +1,25 @@
{% 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">
{% if image %}
<meta name="twitter:image" content="{{ media_full_url }}{{ image }}">
<meta name="og:image" content="{{ media_full_url }}{{ image }}">
{% else %}
<meta name="twitter:image" content="{{ media_full_url }}{{ site.preview_image }}">
<meta name="og:image" content="{{ media_full_url }}{{ site.preview_image }}">
{% endif %}
<meta name="twitter:image" content="{{ media_full_url }}{{ page_image }}">
<meta name="og:image" content="{{ media_full_url }}{{ page_image }}">
{% elif site.logo %}
<meta name="twitter:card" content="summary">
<meta name="twitter:image" content="{{ media_full_url }}{{ site.logo }}">
<meta name="twitter:image:alt" content="{{ site.name }} Logo">
<meta name="og:image" content="{{ media_full_url }}{{ site.logo }}">
{% else %}
<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="og: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="twitter:image:alt" content="BookWyrm Logo">
<meta name="og:image" content="{% static "images/logo.png" %}">
{% endif %}
<meta name="twitter:image:alt" content="BookWyrm Logo">
<meta name="twitter: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 %}">
<meta name="og:description" content="{% if description %}{{ description }}{% else %}{{ site.instance_tagline }}{% endif %}">
{% firstof description site.instance_tagline as description %}
<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]
value = f"{name}@{domain}"
return value
return value
return "a new user account"
@register.filter(name="get_file_size")

View file

@ -227,14 +227,18 @@ class ActivitypubMixins(TestCase):
shared_inbox="http://example.com/inbox",
outbox="https://example.com/users/nutria/outbox",
)
MockSelf = namedtuple("Self", ("privacy", "user"))
mock_self = MockSelf("public", self.local_user)
MockSelf = namedtuple("Self", ("privacy", "user", "recipients"))
self.local_user.followers.add(self.remote_user)
self.local_user.followers.add(another_remote_user)
mock_self = MockSelf("public", self.local_user, [])
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertEqual(len(recipients), 1)
self.assertEqual(recipients[0], "http://example.com/inbox")
self.assertCountEqual(recipients, ["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, *_):
"""should differentiate between bookwyrm and other remote users"""

View file

@ -272,8 +272,8 @@ class BookViews(TestCase):
book=self.book,
content="hi",
quote="wow",
position=12,
endposition=13,
position="12",
endposition="13",
)
request = self.factory.get("")
@ -286,7 +286,9 @@ class BookViews(TestCase):
validate_html(result.render())
print(result.render())
self.assertEqual(result.status_code, 200)
self.assertEqual(result.context_data["statuses"].object_list[0].endposition, 13)
self.assertEqual(
result.context_data["statuses"].object_list[0].endposition, "13"
)
def _setup_cover_url():

View file

@ -18,7 +18,9 @@ class ExportViews(TestCase):
"""viewing and creating statuses"""
@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"""
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay"
@ -40,6 +42,7 @@ class ExportViews(TestCase):
bnf_id="beep",
)
# pylint: disable=invalid-name
def setUp(self):
"""individual test setup"""
self.factory = RequestFactory()
@ -53,11 +56,12 @@ class ExportViews(TestCase):
def test_export_file(self, *_):
"""simple export"""
models.ShelfBook.objects.create(
shelfbook = models.ShelfBook.objects.create(
shelf=self.local_user.shelf_set.first(),
user=self.local_user,
book=self.book,
)
book_date = str.encode(f"{shelfbook.shelved_date.date()}")
request = self.factory.post("")
request.user = self.local_user
export = views.Export.as_view()(request)
@ -66,7 +70,7 @@ class ExportViews(TestCase):
# pylint: disable=line-too-long
self.assertEqual(
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,start_date,finish_date,stopped_date,rating,review_name,review_cw,review_content\r\nTest Book,,"
+ self.book.remote_id.encode("utf-8")
+ b",,,,,beep,,,,,,123456789X,9781234567890,,,,,,,,\r\n",
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"
+ b"Test Book,,%b,,,,,beep,,,,,,123456789X,9781234567890,,,,,,,,,,to-read,To Read,%b\r\n"
% (self.book.remote_id.encode("utf-8"), book_date),
)

View file

@ -338,6 +338,16 @@ urlpatterns = [
views.disable_imports,
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(
r"^settings/imports/enable/?$",
views.enable_imports,
@ -359,6 +369,11 @@ urlpatterns = [
re_path(
r"^settings/celery/ping/?$", views.celery_ping, name="settings-celery-ping"
),
re_path(
r"^settings/schedules/(?P<task_id>\d+)?$",
views.ScheduledTasks.as_view(),
name="settings-schedules",
),
re_path(
r"^settings/email-config/?$",
views.EmailConfig.as_view(),

View file

@ -5,6 +5,7 @@ from .admin.announcements import EditAnnouncement, delete_announcement
from .admin.automod import AutoMod, automod_delete, run_automod
from .admin.automod import schedule_automod_task, unschedule_automod_task
from .admin.celery_status import CeleryStatus, celery_ping
from .admin.schedule import ScheduledTasks
from .admin.dashboard import Dashboard
from .admin.federation import Federation, FederatedServer
from .admin.federation import AddFederatedServer, ImportServerBlocklist
@ -18,6 +19,8 @@ from .admin.imports import (
set_import_size_limit,
set_user_import_completed,
set_user_import_limit,
enable_user_exports,
disable_user_exports,
)
from .admin.ip_blocklist import IPBlocklist
from .admin.invite import ManageInvites, Invite, InviteRequest

View file

@ -6,7 +6,7 @@ from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.http import require_POST
from django_celery_beat.models import PeriodicTask
from django_celery_beat.models import PeriodicTask, IntervalSchedule
from bookwyrm import forms, models
@ -54,7 +54,7 @@ def schedule_automod_task(request):
return TemplateResponse(request, "settings/automod/rules.html", data)
with transaction.atomic():
schedule = form.save(request)
schedule, _ = IntervalSchedule.objects.get_or_create(**form.cleaned_data)
PeriodicTask.objects.get_or_create(
interval=schedule,
name="automod-task",

View file

@ -6,16 +6,18 @@ from dateutil.parser import parse
from packaging import version
from django.contrib.auth.decorators import login_required, permission_required
from django.db import transaction
from django.db.models import Q
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django_celery_beat.models import PeriodicTask, IntervalSchedule
from csp.decorators import csp_update
from bookwyrm import models, settings
from bookwyrm.connectors.abstract_connector import get_data
from bookwyrm import forms, models, settings
from bookwyrm.utils import regex
@ -59,21 +61,36 @@ class Dashboard(View):
== site._meta.get_field("privacy_policy").get_default()
)
# check version
if site.available_version and version.parse(
site.available_version
) > version.parse(settings.VERSION):
data["current_version"] = settings.VERSION
data["available_version"] = site.available_version
try:
release = get_data(settings.RELEASE_API, timeout=3)
available_version = release.get("tag_name", None)
if available_version and version.parse(available_version) > version.parse(
settings.VERSION
):
data["current_version"] = settings.VERSION
data["available_version"] = available_version
except: # pylint: disable= bare-except
pass
if not PeriodicTask.objects.filter(name="check-for-updates").exists():
data["schedule_form"] = forms.IntervalScheduleForm(
{"every": 1, "period": "days"}
)
return TemplateResponse(request, "settings/dashboard/dashboard.html", data)
def post(self, request):
"""Create a schedule task to check for updates"""
schedule_form = forms.IntervalScheduleForm(request.POST)
if not schedule_form.is_valid():
raise schedule_form.ValidationError(schedule_form.errors)
with transaction.atomic():
schedule, _ = IntervalSchedule.objects.get_or_create(
**schedule_form.cleaned_data
)
PeriodicTask.objects.get_or_create(
interval=schedule,
name="check-for-updates",
task="bookwyrm.models.site.check_for_updates_task",
)
return redirect("settings-dashboard")
def get_charts_and_stats(request):
"""Defines the dashboard charts"""

View file

@ -9,7 +9,7 @@ from django.views.decorators.http import require_POST
from bookwyrm import models
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
@ -59,6 +59,7 @@ class ImportList(View):
"import_size_limit": site_settings.import_size_limit,
"import_limit_reset": site_settings.import_limit_reset,
"user_import_time_limit": site_settings.user_import_time_limit,
"use_s3": USE_S3,
}
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.save(update_fields=["user_import_time_limit"])
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

@ -0,0 +1,31 @@
""" Scheduled celery tasks """
from django.contrib.auth.decorators import login_required, permission_required
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
from django_celery_beat.models import PeriodicTask, IntervalSchedule
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.edit_instance_settings", raise_exception=True),
name="dispatch",
)
# pylint: disable=no-self-use
class ScheduledTasks(View):
"""Manage automated flagging"""
def get(self, request):
"""view schedules"""
data = {}
data["tasks"] = PeriodicTask.objects.all()
data["schedules"] = IntervalSchedule.objects.all()
return TemplateResponse(request, "settings/schedules.html", data)
# pylint: disable=unused-argument
def post(self, request, task_id):
"""un-schedule a task"""
task = PeriodicTask.objects.get(id=task_id)
task.delete()
return redirect("settings-schedules")

View file

@ -1,4 +1,5 @@
""" non-interactive pages """
from datetime import date
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q
@ -52,6 +53,19 @@ class Feed(View):
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 = {
**feed_page_data(request.user),
**{
@ -66,6 +80,7 @@ class Feed(View):
"path": f"/{tab['key']}",
"annual_summary_year": get_annual_summary_year(),
"has_tour": True,
"has_summary_read_throughs": len(readthroughs),
},
}
return TemplateResponse(request, "feed/feed.html", data)
@ -185,19 +200,15 @@ class Status(View):
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 = {
**feed_page_data(request.user),
**{
"status": status,
"children": children,
"ancestors": ancestors,
"preview": preview,
"title": status.page_title,
"description": status.page_description,
"page_image": status.page_image,
},
}
return TemplateResponse(request, "feed/status.html", data)

View file

@ -17,6 +17,7 @@ from bookwyrm import models
from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob
from bookwyrm.settings import PAGE_LENGTH
# pylint: disable=no-self-use,too-many-locals
@method_decorator(login_required, name="dispatch")
class Export(View):
@ -54,8 +55,19 @@ class Export(View):
fields = (
["title", "author_text"]
+ deduplication_fields
+ ["start_date", "finish_date", "stopped_date"]
+ ["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)
@ -97,9 +109,27 @@ class Export(View):
.first()
)
if review:
book.review_published = (
review.published_date.date() if review.published_date else None
)
book.review_name = review.name
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])
return HttpResponse(

View file

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

View file

@ -64,13 +64,18 @@ server {
# directly serve images and static files from the
# bookwyrm filesystem using sendfile.
# 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;
try_files $uri =404;
add_header X-Cache-Status STATIC;
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
location /flower/ {
proxy_pass http://flower:8888;

View file

@ -96,12 +96,17 @@ server {
# # directly serve images and static files from the
# # bookwyrm filesystem using sendfile.
# # 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;
# try_files $uri =404;
# add_header X-Cache-Status STATIC;
# 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
# location /flower/ {

View file

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

View file

@ -1,4 +1,4 @@
aiohttp==3.9.0
aiohttp==3.9.2
bleach==5.0.1
celery==5.2.7
colorthief==0.2.1

View file

@ -1,37 +0,0 @@
#!/usr/bin/env bash
set -e
# determine inital and target versions
initial_version="`./bw-dev runweb python manage.py instance_version --current`"
target_version="`./bw-dev runweb python manage.py instance_version --target`"
initial_version="`echo $initial_version | tail -n 1 | xargs`"
target_version="`echo $target_version | tail -n 1 | xargs`"
if [[ "$initial_version" = "$target_version" ]]; then
echo "Already up to date; version $initial_version"
exit
fi
echo "---------------------------------------"
echo "Updating from version: $initial_version"
echo ".......... to version: $target_version"
echo "---------------------------------------"
function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; }
# execute scripts between initial and target
for version in `ls -A updates/ | sort -V `; do
if version_gt $initial_version $version; then
# too early
continue
fi
if version_gt $version $target_version; then
# too late
continue
fi
echo "Running tasks for version $version"
./updates/$version
done
./bw-dev runweb python manage.py instance_version --update
echo "✨ ----------- Done! --------------- ✨"