Merge branch 'main' into import-rating-parser

This commit is contained in:
Jascha Ezra Urbach 2023-04-15 17:21:27 +02:00 committed by GitHub
commit c918617a6a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
84 changed files with 446 additions and 235 deletions

View file

@ -8,7 +8,7 @@ USE_HTTPS=true
DOMAIN=your.domain.here
EMAIL=your@email.here
# Instance defualt language (see options at bookwyrm/settings.py "LANGUAGES"
# Instance default language (see options at bookwyrm/settings.py "LANGUAGES"
LANGUAGE_CODE="en-us"
# Used for deciding which editions to prefer
DEFAULT_LANGUAGE="English"

View file

@ -127,7 +127,7 @@ class ActivityObject:
if (
allow_create
and hasattr(model, "ignore_activity")
and model.ignore_activity(self)
and model.ignore_activity(self, allow_external_connections)
):
return None
@ -384,7 +384,8 @@ def get_activitypub_data(url):
resp = requests.get(
url,
headers={
"Accept": "application/json; charset=utf-8",
# pylint: disable=line-too-long
"Accept": 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
"Date": now,
"Signature": make_signature("get", sender, url, now),
},

View file

@ -38,11 +38,14 @@ class ActivityStream(RedisStore):
def add_status(self, status, increment_unread=False):
"""add a status to users' feeds"""
audience = self.get_audience(status)
# the pipeline contains all the add-to-stream activities
pipeline = self.add_object_to_related_stores(status, execute=False)
pipeline = self.add_object_to_stores(
status, self.get_stores_for_users(audience), execute=False
)
if increment_unread:
for user_id in self.get_audience(status):
for user_id in audience:
# add to the unread status count
pipeline.incr(self.unread_id(user_id))
# add to the unread status count for status type
@ -102,9 +105,16 @@ class ActivityStream(RedisStore):
"""go from zero to a timeline"""
self.populate_store(self.stream_id(user.id))
@tracer.start_as_current_span("ActivityStream._get_audience")
def _get_audience(self, status): # pylint: disable=no-self-use
"""given a status, what users should see it"""
# direct messages don't appeard in feeds, direct comments/reviews/etc do
"""given a status, what users should see it, excluding the author"""
trace.get_current_span().set_attribute("status_type", status.status_type)
trace.get_current_span().set_attribute("status_privacy", status.privacy)
trace.get_current_span().set_attribute(
"status_reply_parent_privacy",
status.reply_parent.privacy if status.reply_parent else None,
)
# direct messages don't appear in feeds, direct comments/reviews/etc do
if status.privacy == "direct" and status.status_type == "Note":
return []
@ -119,15 +129,13 @@ class ActivityStream(RedisStore):
# only visible to the poster and mentioned users
if status.privacy == "direct":
audience = audience.filter(
Q(id=status.user.id) # if the user is the post's author
| Q(id__in=status.mention_users.all()) # if the user is mentioned
Q(id__in=status.mention_users.all()) # if the user is mentioned
)
# don't show replies to statuses the user can't see
elif status.reply_parent and status.reply_parent.privacy == "followers":
audience = audience.filter(
Q(id=status.user.id) # if the user is the post's author
| Q(id=status.reply_parent.user.id) # if the user is the OG author
Q(id=status.reply_parent.user.id) # if the user is the OG author
| (
Q(following=status.user) & Q(following=status.reply_parent.user)
) # if the user is following both authors
@ -136,8 +144,7 @@ class ActivityStream(RedisStore):
# only visible to the poster's followers and tagged users
elif status.privacy == "followers":
audience = audience.filter(
Q(id=status.user.id) # if the user is the post's author
| Q(following=status.user) # if the user is following the author
Q(following=status.user) # if the user is following the author
)
return audience.distinct()
@ -145,10 +152,15 @@ class ActivityStream(RedisStore):
def get_audience(self, status):
"""given a status, what users should see it"""
trace.get_current_span().set_attribute("stream_id", self.key)
return [user.id for user in self._get_audience(status)]
audience = self._get_audience(status)
status_author = models.User.objects.filter(
is_active=True, local=True, id=status.user.id
)
return list({user.id for user in list(audience) + list(status_author)})
def get_stores_for_object(self, obj):
return [self.stream_id(user_id) for user_id in self.get_audience(obj)]
def get_stores_for_users(self, user_ids):
"""convert a list of user ids into redis store ids"""
return [self.stream_id(user_id) for user_id in user_ids]
def get_statuses_for_user(self, user): # pylint: disable=no-self-use
"""given a user, what statuses should they see on this stream"""
@ -173,11 +185,13 @@ class HomeStream(ActivityStream):
audience = super()._get_audience(status)
if not audience:
return []
# if the user is the post's author
ids_self = [user.id for user in audience.filter(Q(id=status.user.id))]
# if the user is following the author
ids_following = [user.id for user in audience.filter(Q(following=status.user))]
return ids_self + ids_following
audience = audience.filter(following=status.user)
# if the user is the post's author
status_author = models.User.objects.filter(
is_active=True, local=True, id=status.user.id
)
return list({user.id for user in list(audience) + list(status_author)})
def get_statuses_for_user(self, user):
return models.Status.privacy_filter(
@ -197,11 +211,11 @@ class LocalStream(ActivityStream):
key = "local"
def _get_audience(self, status):
def get_audience(self, status):
# this stream wants no part in non-public statuses
if status.privacy != "public" or not status.user.local:
return []
return super()._get_audience(status)
return super().get_audience(status)
def get_statuses_for_user(self, user):
# all public statuses by a local user
@ -218,13 +232,6 @@ class BooksStream(ActivityStream):
def _get_audience(self, status):
"""anyone with the mentioned book on their shelves"""
# only show public statuses on the books feed,
# and only statuses that mention books
if status.privacy != "public" or not (
status.mention_books.exists() or hasattr(status, "book")
):
return []
work = (
status.book.parent_work
if hasattr(status, "book")
@ -236,6 +243,16 @@ class BooksStream(ActivityStream):
return []
return audience.filter(shelfbook__book__parent_work=work).distinct()
def get_audience(self, status):
# only show public statuses on the books feed,
# and only statuses that mention books
if status.privacy != "public" or not (
status.mention_books.exists() or hasattr(status, "book")
):
return []
return super().get_audience(status)
def get_statuses_for_user(self, user):
"""any public status that mentions the user's books"""
books = user.shelfbook_set.values_list(
@ -514,7 +531,9 @@ def remove_status_task(status_ids):
for stream in streams.values():
for status in statuses:
stream.remove_object_from_related_stores(status)
stream.remove_object_from_stores(
status, stream.get_stores_for_users(stream.get_audience(status))
)
@app.task(queue=HIGH, ignore_result=True)
@ -563,10 +582,10 @@ def handle_boost_task(boost_id):
for stream in streams.values():
# people who should see the boost (not people who see the original status)
audience = stream.get_stores_for_object(instance)
stream.remove_object_from_related_stores(boosted, stores=audience)
audience = stream.get_stores_for_users(stream.get_audience(instance))
stream.remove_object_from_stores(boosted, audience)
for status in old_versions:
stream.remove_object_from_related_stores(status, stores=audience)
stream.remove_object_from_stores(status, audience)
def get_status_type(status):

View file

@ -40,6 +40,7 @@ class BookwyrmConfig(AppConfig):
from bookwyrm.telemetry import open_telemetry
open_telemetry.instrumentDjango()
open_telemetry.instrumentPostgres()
if settings.ENABLE_PREVIEW_IMAGES and settings.FONTS:
# Download any fonts that we don't have yet

View file

@ -52,7 +52,7 @@ class AbstractMinimalConnector(ABC):
return f"{self.search_url}{quote_plus(query)}"
def process_search_response(self, query, data, min_confidence):
"""Format the search results based on the formt of the query"""
"""Format the search results based on the format of the query"""
if maybe_isbn(query):
return list(self.parse_isbn_search_data(data))[:10]
return list(self.parse_search_data(data, min_confidence))[:10]
@ -321,7 +321,7 @@ def infer_physical_format(format_text):
def unique_physical_format(format_text):
"""only store the format if it isn't diretly in the format mappings"""
"""only store the format if it isn't directly in the format mappings"""
format_text = format_text.lower()
if format_text in format_mappings:
# try a direct match, so saving this would be redundant

View file

@ -73,7 +73,7 @@ async def async_connector_search(query, items, min_confidence):
def search(query, min_confidence=0.1, return_first=False):
"""find books based on arbitary keywords"""
"""find books based on arbitrary keywords"""
if not query:
return []
results = []

View file

@ -97,7 +97,7 @@ class Connector(AbstractConnector):
)
def parse_isbn_search_data(self, data):
"""got some daaaata"""
"""got some data"""
results = data.get("entities")
if not results:
return

View file

@ -15,7 +15,7 @@ from .custom_form import CustomForm, StyledForm
# pylint: disable=missing-class-docstring
class ExpiryWidget(widgets.Select):
def value_from_datadict(self, data, files, name):
"""human-readable exiration time buckets"""
"""human-readable expiration time buckets"""
selected_string = super().value_from_datadict(data, files, name)
if selected_string == "day":

View file

@ -24,8 +24,7 @@ class ListsStream(RedisStore):
def add_list(self, book_list):
"""add a list to users' feeds"""
# the pipeline contains all the add-to-stream activities
self.add_object_to_related_stores(book_list)
self.add_object_to_stores(book_list, self.get_stores_for_object(book_list))
def add_user_lists(self, viewer, user):
"""add a user's lists to another user's feed"""
@ -86,18 +85,19 @@ class ListsStream(RedisStore):
if group:
audience = audience.filter(
Q(id=book_list.user.id) # if the user is the list's owner
| Q(following=book_list.user) # if the user is following the pwmer
| Q(following=book_list.user) # if the user is following the owner
# if a user is in the group
| Q(memberships__group__id=book_list.group.id)
)
else:
audience = audience.filter(
Q(id=book_list.user.id) # if the user is the list's owner
| Q(following=book_list.user) # if the user is following the pwmer
| Q(following=book_list.user) # if the user is following the owner
)
return audience.distinct()
def get_stores_for_object(self, obj):
"""the stores that an object belongs in"""
return [self.stream_id(u) for u in self.get_audience(obj)]
def get_lists_for_user(self, user): # pylint: disable=no-self-use
@ -233,7 +233,7 @@ def remove_list_task(list_id, re_add=False):
# delete for every store
stores = [ListsStream().stream_id(idx) for idx in stores]
ListsStream().remove_object_from_related_stores(list_id, stores=stores)
ListsStream().remove_object_from_stores(list_id, stores)
if re_add:
add_list_task.delay(list_id)

View file

@ -68,12 +68,12 @@ def dedupe_model(model):
class Command(BaseCommand):
"""dedplucate allllll the book data models"""
"""deduplicate allllll the book data models"""
help = "merges duplicate book data"
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
"""run deudplications"""
"""run deduplications"""
dedupe_model(models.Edition)
dedupe_model(models.Work)
dedupe_model(models.Author)

View file

@ -33,10 +33,10 @@ def remove_editions():
class Command(BaseCommand):
"""dedplucate allllll the book data models"""
"""deduplicate allllll the book data models"""
help = "merges duplicate book data"
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
"""run deudplications"""
"""run deduplications"""
remove_editions()

View file

@ -9,7 +9,7 @@ class Command(BaseCommand):
# pylint: disable=unused-argument
def handle(self, *args, **options):
"""reveoke nonessential low priority tasks"""
"""revoke nonessential low priority tasks"""
types = [
"bookwyrm.preview_images.generate_edition_preview_image_task",
"bookwyrm.preview_images.generate_user_preview_image_task",

View file

@ -1467,7 +1467,7 @@ class Migration(migrations.Migration):
(
"expiry",
models.DateTimeField(
default=bookwyrm.models.site.get_passowrd_reset_expiry
default=bookwyrm.models.site.get_password_reset_expiry
),
),
(

View file

@ -6,7 +6,7 @@ from bookwyrm.connectors.abstract_connector import infer_physical_format
def infer_format(app_registry, schema_editor):
"""set the new phsyical format field based on existing format data"""
"""set the new physical format field based on existing format data"""
db_alias = schema_editor.connection.alias
editions = (

View file

@ -5,7 +5,7 @@ from bookwyrm.settings import DOMAIN
def remove_self_connector(app_registry, schema_editor):
"""set the new phsyical format field based on existing format data"""
"""set the new physical format field based on existing format data"""
db_alias = schema_editor.connection.alias
app_registry.get_model("bookwyrm", "Connector").objects.using(db_alias).filter(
connector_file="self_connector"

View file

@ -25,7 +25,7 @@ from bookwyrm.tasks import app, MEDIUM, BROADCAST
from bookwyrm.models.fields import ImageField, ManyToManyField
logger = logging.getLogger(__name__)
# I tried to separate these classes into mutliple files but I kept getting
# I tried to separate these classes into multiple files but I kept getting
# circular import errors so I gave up. I'm sure it could be done though!
PropertyField = namedtuple("PropertyField", ("set_activity_from_field"))
@ -91,7 +91,7 @@ class ActivitypubMixin:
@classmethod
def find_existing(cls, data):
"""compare data to fields that can be used for deduplation.
"""compare data to fields that can be used for deduplication.
This always includes remote_id, but can also be unique identifiers
like an isbn for an edition"""
filters = []
@ -234,8 +234,8 @@ class ObjectMixin(ActivitypubMixin):
activity = self.to_create_activity(user)
self.broadcast(activity, user, software=software, queue=priority)
except AttributeError:
# janky as heck, this catches the mutliple inheritence chain
# for boosts and ignores this auxilliary broadcast
# janky as heck, this catches the multiple inheritance chain
# for boosts and ignores this auxiliary broadcast
return
return
@ -311,7 +311,7 @@ class OrderedCollectionPageMixin(ObjectMixin):
@property
def collection_remote_id(self):
"""this can be overriden if there's a special remote id, ie outbox"""
"""this can be overridden if there's a special remote id, ie outbox"""
return self.remote_id
def to_ordered_collection(
@ -339,7 +339,7 @@ class OrderedCollectionPageMixin(ObjectMixin):
activity["id"] = remote_id
paginated = Paginator(queryset, PAGE_LENGTH)
# add computed fields specific to orderd collections
# add computed fields specific to ordered collections
activity["totalItems"] = paginated.count
activity["first"] = f"{remote_id}?page=1"
activity["last"] = f"{remote_id}?page={paginated.num_pages}"
@ -405,7 +405,7 @@ class CollectionItemMixin(ActivitypubMixin):
# first off, we want to save normally no matter what
super().save(*args, **kwargs)
# list items can be updateda, normally you would only broadcast on created
# list items can be updated, normally you would only broadcast on created
if not broadcast or not self.user.local:
return
@ -565,7 +565,7 @@ async def sign_and_send(
def to_ordered_collection_page(
queryset, remote_id, id_only=False, page=1, pure=False, **kwargs
):
"""serialize and pagiante a queryset"""
"""serialize and paginate a queryset"""
paginated = Paginator(queryset, PAGE_LENGTH)
activity_page = paginated.get_page(page)

View file

@ -24,7 +24,7 @@ class AnnualGoal(BookWyrmModel):
)
class Meta:
"""unqiueness constraint"""
"""uniqueness constraint"""
unique_together = ("user", "year")

View file

@ -321,7 +321,7 @@ class Edition(Book):
def get_rank(self):
"""calculate how complete the data is on this edition"""
rank = 0
# big ups for havinga cover
# big ups for having a cover
rank += int(bool(self.cover)) * 3
# is it in the instance's preferred language?
rank += int(bool(DEFAULT_LANGUAGE in self.languages))

View file

@ -20,8 +20,9 @@ class Favorite(ActivityMixin, BookWyrmModel):
activity_serializer = activitypub.Like
# pylint: disable=unused-argument
@classmethod
def ignore_activity(cls, activity):
def ignore_activity(cls, activity, allow_external_connections=True):
"""don't bother with incoming favs of unknown statuses"""
return not Status.objects.filter(remote_id=activity.object).exists()

View file

@ -71,11 +71,11 @@ class ActivitypubFieldMixin:
def set_field_from_activity(
self, instance, data, overwrite=True, allow_external_connections=True
):
"""helper function for assinging a value to the field. Returns if changed"""
"""helper function for assigning a value to the field. Returns if changed"""
try:
value = getattr(data, self.get_activitypub_field())
except AttributeError:
# masssively hack-y workaround for boosts
# massively hack-y workaround for boosts
if self.get_activitypub_field() != "attributedTo":
raise
value = getattr(data, "actor")
@ -221,7 +221,7 @@ PrivacyLevels = [
class PrivacyField(ActivitypubFieldMixin, models.CharField):
"""this maps to two differente activitypub fields"""
"""this maps to two different activitypub fields"""
public = "https://www.w3.org/ns/activitystreams#Public"
@ -431,7 +431,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
def set_field_from_activity(
self, instance, data, save=True, overwrite=True, allow_external_connections=True
):
"""helper function for assinging a value to the field"""
"""helper function for assigning a value to the field"""
value = getattr(data, self.get_activitypub_field())
formatted = self.field_from_activity(
value, allow_external_connections=allow_external_connections

View file

@ -31,7 +31,7 @@ class Link(ActivitypubMixin, BookWyrmModel):
@property
def name(self):
"""link name via the assocaited domain"""
"""link name via the associated domain"""
return self.domain.name
def save(self, *args, **kwargs):

View file

@ -284,7 +284,7 @@ def notify_user_on_list_item_add(sender, instance, created, *args, **kwargs):
return
list_owner = instance.book_list.user
# create a notification if somoene ELSE added to a local user's list
# create a notification if someone ELSE added to a local user's list
if list_owner.local and list_owner != instance.user:
# keep the related_user singular, group the items
Notification.notify_list_item(list_owner, instance)

View file

@ -8,7 +8,7 @@ from .base_model import BookWyrmModel
class ProgressMode(models.TextChoices):
"""types of prgress available"""
"""types of progress available"""
PAGE = "PG", "page"
PERCENT = "PCT", "percent"

View file

@ -34,7 +34,7 @@ class UserRelationship(BookWyrmModel):
@property
def recipients(self):
"""the remote user needs to recieve direct broadcasts"""
"""the remote user needs to receive direct broadcasts"""
return [u for u in [self.user_subject, self.user_object] if not u.local]
def save(self, *args, **kwargs):

View file

@ -80,7 +80,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
raise PermissionDenied()
class Meta:
"""user/shelf unqiueness"""
"""user/shelf uniqueness"""
unique_together = ("user", "identifier")

View file

@ -209,7 +209,7 @@ class InviteRequest(BookWyrmModel):
super().save(*args, **kwargs)
def get_passowrd_reset_expiry():
def get_password_reset_expiry():
"""give people a limited time to use the link"""
now = timezone.now()
return now + datetime.timedelta(days=1)
@ -219,7 +219,7 @@ class PasswordReset(models.Model):
"""gives someone access to create an account on the instance"""
code = models.CharField(max_length=32, default=new_access_code)
expiry = models.DateTimeField(default=get_passowrd_reset_expiry)
expiry = models.DateTimeField(default=get_password_reset_expiry)
user = models.OneToOneField(User, on_delete=models.CASCADE)
def valid(self):

View file

@ -116,10 +116,16 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
return list(set(mentions))
@classmethod
def ignore_activity(cls, activity): # pylint: disable=too-many-return-statements
def ignore_activity(
cls, activity, allow_external_connections=True
): # pylint: disable=too-many-return-statements
"""keep notes if they are replies to existing statuses"""
if activity.type == "Announce":
boosted = activitypub.resolve_remote_id(activity.object, get_activity=True)
boosted = activitypub.resolve_remote_id(
activity.object,
get_activity=True,
allow_external_connections=allow_external_connections,
)
if not boosted:
# if we can't load the status, definitely ignore it
return True

View file

@ -16,12 +16,12 @@ class RedisStore(ABC):
"""the object and rank"""
return {obj.id: self.get_rank(obj)}
def add_object_to_related_stores(self, obj, execute=True):
"""add an object to all suitable stores"""
def add_object_to_stores(self, obj, stores, execute=True):
"""add an object to a given set of stores"""
value = self.get_value(obj)
# we want to do this as a bulk operation, hence "pipeline"
pipeline = r.pipeline()
for store in self.get_stores_for_object(obj):
for store in stores:
# add the status to the feed
pipeline.zadd(store, value)
# trim the store
@ -32,14 +32,14 @@ class RedisStore(ABC):
# and go!
return pipeline.execute()
def remove_object_from_related_stores(self, obj, stores=None):
# pylint: disable=no-self-use
def remove_object_from_stores(self, obj, stores):
"""remove an object from all stores"""
# if the stoers are provided, the object can just be an id
# if the stores are provided, the object can just be an id
if stores and isinstance(obj, int):
obj_id = obj
else:
obj_id = obj.id
stores = self.get_stores_for_object(obj) if stores is None else stores
pipeline = r.pipeline()
for store in stores:
pipeline.zrem(store, -1, obj_id)
@ -82,10 +82,6 @@ class RedisStore(ABC):
def get_objects_for_store(self, store):
"""a queryset of what should go in a store, used for populating it"""
@abstractmethod
def get_stores_for_object(self, obj):
"""the stores that an object belongs in"""
@abstractmethod
def get_rank(self, obj):
"""how to rank an object"""

View file

@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
env = Env()
env.read_env()
DOMAIN = env("DOMAIN")
VERSION = "0.6.0"
VERSION = "0.6.1"
RELEASE_API = env(
"RELEASE_API",
@ -226,7 +226,7 @@ STREAMS = [
# total time in seconds that the instance will spend searching connectors
SEARCH_TIMEOUT = env.int("SEARCH_TIMEOUT", 8)
# timeout for a query to an individual connector
QUERY_TIMEOUT = env.int("QUERY_TIMEOUT", 5)
QUERY_TIMEOUT = env.int("INTERACTIVE_QUERY_TIMEOUT", env.int("QUERY_TIMEOUT", 5))
# Redis cache backend
if env.bool("USE_DUMMY_CACHE", False):

View file

@ -5,7 +5,7 @@
* - .book-cover is positioned and sized based on its container.
*
* To have the cover within specific dimensions, specify a width or height for
* standard bulmas named breapoints:
* standard bulmas named breakpoints:
*
* `is-(w|h)-(auto|xs|s|m|l|xl|xxl)[-(mobile|tablet|desktop)]`
*
@ -43,7 +43,7 @@
max-height: 100%;
/* Useful when stretching under-sized images. */
image-rendering: optimizequality;
image-rendering: optimizeQuality;
image-rendering: smooth;
}

View file

@ -44,12 +44,12 @@
.bw-tabs a:hover {
border-bottom-color: transparent;
color: $text;
color: $text
}
.bw-tabs a.is-active {
border-bottom-color: transparent;
color: $link;
color: $link
}
.bw-tabs.is-left {

View file

@ -98,6 +98,22 @@ $family-secondary: $family-sans-serif;
}
.tabs li:not(.is-active) a {
color: #2e7eb9 !important;
}
.tabs li:not(.is-active) a:hover {
border-bottom-color: #2e7eb9 !important;
}
.tabs li:not(.is-active) a {
color: #2e7eb9 !important;
}
.tabs li.is-active a {
color: #e6e6e6 !important;
border-bottom-color: #e6e6e6 !important ;
}
#qrcode svg {
background-color: #a6a6a6;
}

View file

@ -65,6 +65,22 @@ $family-secondary: $family-sans-serif;
color: $grey !important;
}
.tabs li:not(.is-active) a {
color: #3273dc !important;
}
.tabs li:not(.is-active) a:hover {
border-bottom-color: #3273dc !important;
}
.tabs li:not(.is-active) a {
color: #3273dc !important;
}
.tabs li.is-active a {
color: #4a4a4a !important;
border-bottom-color: #4a4a4a !important ;
}
@import "../bookwyrm.scss";
@import "../vendor/icons.css";
@import "../vendor/shepherd.scss";

View file

@ -5,7 +5,7 @@ let BookWyrm = new (class {
constructor() {
this.MAX_FILE_SIZE_BYTES = 10 * 1000000;
this.initOnDOMLoaded();
this.initReccuringTasks();
this.initRecurringTasks();
this.initEventListeners();
}
@ -77,7 +77,7 @@ let BookWyrm = new (class {
/**
* Execute recurring tasks.
*/
initReccuringTasks() {
initRecurringTasks() {
// Polling
document.querySelectorAll("[data-poll]").forEach((liveArea) => this.polling(liveArea));
}

View file

@ -2,7 +2,7 @@
"use strict";
/**
* Remoev input field
* Remove input field
*
* @param {event} the button click event
*/

View file

@ -4,13 +4,16 @@ import logging
from django.dispatch import receiver
from django.db import transaction
from django.db.models import signals, Count, Q, Case, When, IntegerField
from opentelemetry import trace
from bookwyrm import models
from bookwyrm.redis_store import RedisStore, r
from bookwyrm.tasks import app, LOW, MEDIUM
from bookwyrm.telemetry import open_telemetry
logger = logging.getLogger(__name__)
tracer = open_telemetry.tracer()
class SuggestedUsers(RedisStore):
@ -49,30 +52,34 @@ class SuggestedUsers(RedisStore):
)
def get_stores_for_object(self, obj):
"""the stores that an object belongs in"""
return [self.store_id(u) for u in self.get_users_for_object(obj)]
def get_users_for_object(self, obj): # pylint: disable=no-self-use
"""given a user, who might want to follow them"""
return models.User.objects.filter(local=True,).exclude(
return models.User.objects.filter(local=True, is_active=True).exclude(
Q(id=obj.id) | Q(followers=obj) | Q(id__in=obj.blocks.all()) | Q(blocks=obj)
)
@tracer.start_as_current_span("SuggestedUsers.rerank_obj")
def rerank_obj(self, obj, update_only=True):
"""update all the instances of this user with new ranks"""
trace.get_current_span().set_attribute("update_only", update_only)
pipeline = r.pipeline()
for store_user in self.get_users_for_object(obj):
annotated_user = get_annotated_users(
store_user,
id=obj.id,
).first()
if not annotated_user:
continue
with tracer.start_as_current_span("SuggestedUsers.rerank_obj/user") as _:
annotated_user = get_annotated_users(
store_user,
id=obj.id,
).first()
if not annotated_user:
continue
pipeline.zadd(
self.store_id(store_user),
self.get_value(annotated_user),
xx=update_only,
)
pipeline.zadd(
self.store_id(store_user),
self.get_value(annotated_user),
xx=update_only,
)
pipeline.execute()
def rerank_user_suggestions(self, user):
@ -254,7 +261,9 @@ def rerank_user_task(user_id, update_only=False):
def remove_user_task(user_id):
"""do the hard work in celery"""
user = models.User.objects.get(id=user_id)
suggested_users.remove_object_from_related_stores(user)
suggested_users.remove_object_from_stores(
user, suggested_users.get_stores_for_object(user)
)
@app.task(queue=MEDIUM, ignore_result=True)
@ -268,7 +277,9 @@ def remove_suggestion_task(user_id, suggested_user_id):
def bulk_remove_instance_task(instance_id):
"""remove a bunch of users from recs"""
for user in models.User.objects.filter(federated_server__id=instance_id):
suggested_users.remove_object_from_related_stores(user)
suggested_users.remove_object_from_stores(
user, suggested_users.get_stores_for_object(user)
)
@app.task(queue=LOW, ignore_result=True)

View file

@ -22,6 +22,12 @@ def instrumentDjango():
DjangoInstrumentor().instrument()
def instrumentPostgres():
from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor
Psycopg2Instrumentor().instrument()
def instrumentCelery():
from opentelemetry.instrumentation.celery import CeleryInstrumentor
from celery.signals import worker_process_init

View file

@ -4,6 +4,7 @@
{% load humanize %}
{% load utilities %}
{% load static %}
{% load shelf_tags %}
{% block title %}{{ book|book_title }}{% endblock %}
@ -46,7 +47,13 @@
<meta itemprop="isPartOf" content="{{ book.series | escape }}">
<meta itemprop="volumeNumber" content="{{ book.series_number }}">
(<a href="{% url 'book-series-by' book.authors.first.id %}?series_name={{ book.series }}">{{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %}</a>)
{% if book.authors.exists %}
<a href="{% url 'book-series-by' book.authors.first.id %}?series_name={{ book.series }}">
{% endif %}
{{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %}
{% if book.authors.exists %}
</a>
{% endif %}
{% endif %}
</p>
{% endif %}
@ -239,7 +246,7 @@
<ul>
{% for shelf in user_shelfbooks %}
<li class="box">
<a href="{{ shelf.shelf.local_path }}">{{ shelf.shelf.name }}</a>
<a href="{{ shelf.shelf.local_path }}">{{ shelf.shelf|translate_shelf_name }}</a>
<div class="is-pulled-right">
{% include 'snippets/shelf_selector.html' with shelf=shelf.shelf class="is-small" readthrough=readthrough %}
</div>

View file

@ -30,7 +30,7 @@
<fieldset name="books" class="columns is-mobile">
{% if book_results %}
<div class="column is-narrow">
<p class="help mb-0">Search results</p>
<p class="help mb-0">{% trans "Search results" %}</p>
<div class="columns is-mobile">
{% for book in book_results %}

View file

@ -5,7 +5,7 @@
<div class="block">
<h2 class="title is-4">{% trans "Who to follow" %}</h2>
<p class="subtitle is-6">You can follow users on other BookWyrm instances and federated services like Mastodon.</p>
<p class="subtitle is-6">{% trans "You can follow users on other BookWyrm instances and federated services like Mastodon." %}</p>
<form class="field has-addons" method="get" action="{% url 'get-started-users' %}">
<div class="control">
<input type="text" name="query" value="{{ request.GET.query }}" class="input" placeholder="{% trans 'Search for a user' %}" aria-label="{% trans 'Search for a user' %}">

View file

@ -3,7 +3,7 @@
{% if list.curation == 'group' %}
{% blocktrans with username=list.user.display_name userpath=list.user.local_path groupname=list.group.name grouppath=list.group.local_path %}Created by <a href="{{ userpath }}">{{ username }}</a> and managed by <a href="{{ grouppath }}">{{ groupname }}</a>{% endblocktrans %}
{% elif list.curation != 'open' %}
{% elif list.curation == 'curated' %}
{% blocktrans with username=list.user.display_name path=list.user.local_path %}Created and curated by <a href="{{ path }}">{{ username }}</a>{% endblocktrans %}
{% else %}
{% blocktrans with username=list.user.display_name path=list.user.local_path %}Created by <a href="{{ path }}">{{ username }}</a>{% endblocktrans %}

View file

@ -133,7 +133,7 @@
</div>
{% url 'user-shelves' request.user.localname as path %}
<p class="notification is-light">
{% blocktrans %}Looking for shelf privacy? You can set a sepearate visibility level for each of your shelves. Go to <a href="{{ path }}">Your Books</a>, pick a shelf from the tab bar, and click "Edit shelf."{% endblocktrans %}
{% blocktrans %}Looking for shelf privacy? You can set a separate visibility level for each of your shelves. Go to <a href="{{ path }}">Your Books</a>, pick a shelf from the tab bar, and click "Edit shelf."{% endblocktrans %}
</p>
</div>
</section>

View file

@ -29,7 +29,7 @@
</div>
<div class="control">
<button type="submit" class="button is-primary">
<span>Search</span>
<span>{% trans "Search" %}</span>
<span class="icon icon-search" aria-hidden="true"></span>
</button>
</div>

View file

@ -116,6 +116,35 @@
</div>
{% endif %}
<section class="block">
<div class="content"><h2>{% trans "Clear Queues" %}</h2></div>
<div class="content notification is-warning is-flex is-align-items-start">
<span class="icon icon-warning is-size-4 pr-3" aria-hidden="true"></span>
{% trans "Clearing queues can cause serious problems including data loss! Only play with this if you really know what you're doing. You must shut down the Celery worker before you do this." %}
</div>
<form action="{% url 'settings-celery' %}" method="post">
{% csrf_token %}
<div class="columns">
<div class="column is-3">
<div class="content"><h3>{{ form.queues.label_tag }}</h3></div>
{{ form.queues }}
</div>
<div class="column is-9">
<div class="content"><h3>{{ form.tasks.label_tag }}</h3></div>
{{ form.tasks }}
</div>
</div>
<div class="buttons is-right">
<button type="submit" class="button is-danger">{% trans "Clear Queues" %}</button>
</div>
</form>
</section>
{% if errors %}
<div class="block content">
<h2>{% trans "Errors" %}</h2>

View file

@ -16,7 +16,7 @@
<p class="title is-5">{{ users|intcomma }}</p>
</div>
</div>
<div class="column is-3-desktop is-6-mobil is-flexe">
<div class="column is-3-desktop is-6-mobile is-flex">
<div class="notification is-flex-grow-1">
<h3>{% trans "Active this month" %}</h3>
<p class="title is-5">{{ active_users|intcomma }}</p>

View file

@ -28,7 +28,7 @@
>
<div class="notification">
{% trans "This is only intended to be used when things have gone very wrong with imports and you need to pause the feature while addressing issues." %}
{% trans "While imports are disabled, users will not be allowed to start new imports, but existing imports will not be effected." %}
{% trans "While imports are disabled, users will not be allowed to start new imports, but existing imports will not be affected." %}
</div>
{% csrf_token %}
<div class="control">

View file

@ -18,7 +18,7 @@ def get_book_description(book):
if book.description:
return book.description
if book.parent_work:
# this shoud always be true
# this should always be true
return book.parent_work.description
return None

View file

@ -9,6 +9,15 @@ from bookwyrm.utils import cache
register = template.Library()
SHELF_NAMES = {
"all": _("All books"),
"to-read": _("To Read"),
"reading": _("Currently Reading"),
"read": _("Read"),
"stopped-reading": _("Stopped Reading"),
}
@register.filter(name="is_book_on_shelf")
def get_is_book_on_shelf(book, shelf):
"""is a book on a shelf"""
@ -37,20 +46,16 @@ def get_next_shelf(current_shelf):
@register.filter(name="translate_shelf_name")
def get_translated_shelf_name(shelf):
"""produced translated shelf nidentifierame"""
"""produce translated shelf identifiername"""
if not shelf:
return ""
# support obj or dict
identifier = shelf["identifier"] if isinstance(shelf, dict) else shelf.identifier
if identifier == "all":
return _("All books")
if identifier == "to-read":
return _("To Read")
if identifier == "reading":
return _("Currently Reading")
if identifier == "read":
return _("Read")
return shelf["name"] if isinstance(shelf, dict) else shelf.name
try:
return SHELF_NAMES[identifier]
except KeyError:
return shelf["name"] if isinstance(shelf, dict) else shelf.name
@register.simple_tag(takes_context=True)

View file

@ -19,7 +19,7 @@ def get_uuid(identifier):
@register.simple_tag(takes_context=False)
def join(*args):
"""concatenate an arbitary set of values"""
"""concatenate an arbitrary set of values"""
return "_".join(str(a) for a in args)

View file

@ -19,7 +19,7 @@ class Author(TestCase):
)
def test_serialize_model(self):
"""check presense of author fields"""
"""check presence of author fields"""
activity = self.author.to_activity()
self.assertEqual(activity["id"], self.author.remote_id)
self.assertIsInstance(activity["aliases"], list)

View file

@ -59,7 +59,7 @@ class BaseActivity(TestCase):
self.assertIsInstance(representative, models.User)
def test_init(self, *_):
"""simple successfuly init"""
"""simple successfully init"""
instance = ActivityObject(id="a", type="b")
self.assertTrue(hasattr(instance, "id"))
self.assertTrue(hasattr(instance, "type"))

View file

@ -1,4 +1,4 @@
""" quotation activty object serializer class """
""" quotation activity object serializer class """
import json
import pathlib
from unittest.mock import patch
@ -30,7 +30,7 @@ class Quotation(TestCase):
self.status_data = json.loads(datafile.read_bytes())
def test_quotation_activity(self):
"""create a Quoteation ap object from json"""
"""create a Quotation ap object from json"""
quotation = activitypub.Quotation(**self.status_data)
self.assertEqual(quotation.type, "Quotation")

View file

@ -50,7 +50,7 @@ class Activitystreams(TestCase):
self.assertEqual(args[1], self.book)
def test_remove_book_statuses_task(self):
"""remove stauses related to a book"""
"""remove statuses related to a book"""
with patch("bookwyrm.activitystreams.BooksStream.remove_book_statuses") as mock:
activitystreams.remove_book_statuses_task(self.local_user.id, self.book.id)
self.assertTrue(mock.called)
@ -75,7 +75,7 @@ class Activitystreams(TestCase):
def test_remove_status_task(self):
"""remove a status from all streams"""
with patch(
"bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores"
"bookwyrm.activitystreams.ActivityStream.remove_object_from_stores"
) as mock:
activitystreams.remove_status_task(self.status.id)
self.assertEqual(mock.call_count, 3)
@ -132,8 +132,8 @@ class Activitystreams(TestCase):
self.assertEqual(args[0], self.local_user)
self.assertEqual(args[1], self.another_user)
@patch("bookwyrm.activitystreams.LocalStream.remove_object_from_related_stores")
@patch("bookwyrm.activitystreams.BooksStream.remove_object_from_related_stores")
@patch("bookwyrm.activitystreams.LocalStream.remove_object_from_stores")
@patch("bookwyrm.activitystreams.BooksStream.remove_object_from_stores")
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
def test_boost_to_another_timeline(self, *_):
"""boost from a non-follower doesn't remove original status from feed"""
@ -144,7 +144,7 @@ class Activitystreams(TestCase):
user=self.another_user,
)
with patch(
"bookwyrm.activitystreams.HomeStream.remove_object_from_related_stores"
"bookwyrm.activitystreams.HomeStream.remove_object_from_stores"
) as mock:
activitystreams.handle_boost_task(boost.id)
@ -152,10 +152,10 @@ class Activitystreams(TestCase):
self.assertEqual(mock.call_count, 1)
call_args = mock.call_args
self.assertEqual(call_args[0][0], status)
self.assertEqual(call_args[1]["stores"], [f"{self.another_user.id}-home"])
self.assertEqual(call_args[0][1], [f"{self.another_user.id}-home"])
@patch("bookwyrm.activitystreams.LocalStream.remove_object_from_related_stores")
@patch("bookwyrm.activitystreams.BooksStream.remove_object_from_related_stores")
@patch("bookwyrm.activitystreams.LocalStream.remove_object_from_stores")
@patch("bookwyrm.activitystreams.BooksStream.remove_object_from_stores")
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
def test_boost_to_another_timeline_remote(self, *_):
"""boost from a remote non-follower doesn't remove original status from feed"""
@ -166,7 +166,7 @@ class Activitystreams(TestCase):
user=self.remote_user,
)
with patch(
"bookwyrm.activitystreams.HomeStream.remove_object_from_related_stores"
"bookwyrm.activitystreams.HomeStream.remove_object_from_stores"
) as mock:
activitystreams.handle_boost_task(boost.id)
@ -174,10 +174,10 @@ class Activitystreams(TestCase):
self.assertEqual(mock.call_count, 1)
call_args = mock.call_args
self.assertEqual(call_args[0][0], status)
self.assertEqual(call_args[1]["stores"], [])
self.assertEqual(call_args[0][1], [])
@patch("bookwyrm.activitystreams.LocalStream.remove_object_from_related_stores")
@patch("bookwyrm.activitystreams.BooksStream.remove_object_from_related_stores")
@patch("bookwyrm.activitystreams.LocalStream.remove_object_from_stores")
@patch("bookwyrm.activitystreams.BooksStream.remove_object_from_stores")
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
def test_boost_to_following_timeline(self, *_):
"""add a boost and deduplicate the boosted status on the timeline"""
@ -189,17 +189,17 @@ class Activitystreams(TestCase):
user=self.another_user,
)
with patch(
"bookwyrm.activitystreams.HomeStream.remove_object_from_related_stores"
"bookwyrm.activitystreams.HomeStream.remove_object_from_stores"
) as mock:
activitystreams.handle_boost_task(boost.id)
self.assertTrue(mock.called)
call_args = mock.call_args
self.assertEqual(call_args[0][0], status)
self.assertTrue(f"{self.another_user.id}-home" in call_args[1]["stores"])
self.assertTrue(f"{self.local_user.id}-home" in call_args[1]["stores"])
self.assertTrue(f"{self.another_user.id}-home" in call_args[0][1])
self.assertTrue(f"{self.local_user.id}-home" in call_args[0][1])
@patch("bookwyrm.activitystreams.LocalStream.remove_object_from_related_stores")
@patch("bookwyrm.activitystreams.BooksStream.remove_object_from_related_stores")
@patch("bookwyrm.activitystreams.LocalStream.remove_object_from_stores")
@patch("bookwyrm.activitystreams.BooksStream.remove_object_from_stores")
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
def test_boost_to_same_timeline(self, *_):
"""add a boost and deduplicate the boosted status on the timeline"""
@ -210,10 +210,10 @@ class Activitystreams(TestCase):
user=self.local_user,
)
with patch(
"bookwyrm.activitystreams.HomeStream.remove_object_from_related_stores"
"bookwyrm.activitystreams.HomeStream.remove_object_from_stores"
) as mock:
activitystreams.handle_boost_task(boost.id)
self.assertTrue(mock.called)
call_args = mock.call_args
self.assertEqual(call_args[0][0], status)
self.assertEqual(call_args[1]["stores"], [f"{self.local_user.id}-home"])
self.assertEqual(call_args[0][1], [f"{self.local_user.id}-home"])

View file

@ -46,7 +46,7 @@ class Openlibrary(TestCase):
data = {"key": "/work/OL1234W"}
result = self.connector.get_remote_id_from_data(data)
self.assertEqual(result, "https://openlibrary.org/work/OL1234W")
# error handlding
# error handling
with self.assertRaises(ConnectorException):
self.connector.get_remote_id_from_data({})

View file

@ -59,7 +59,7 @@ class Activitystreams(TestCase):
def test_remove_list_task(self, *_):
"""remove a list from all streams"""
with patch(
"bookwyrm.lists_stream.ListsStream.remove_object_from_related_stores"
"bookwyrm.lists_stream.ListsStream.remove_object_from_stores"
) as mock:
lists_stream.remove_list_task(self.list.id)
self.assertEqual(mock.call_count, 1)

View file

@ -245,7 +245,7 @@ class ActivitypubMixins(TestCase):
# ObjectMixin
def test_object_save_create(self, *_):
"""should save uneventufully when broadcast is disabled"""
"""should save uneventfully when broadcast is disabled"""
class Success(Exception):
"""this means we got to the right method"""
@ -276,7 +276,7 @@ class ActivitypubMixins(TestCase):
ObjectModel(user=None).save()
def test_object_save_update(self, *_):
"""should save uneventufully when broadcast is disabled"""
"""should save uneventfully when broadcast is disabled"""
class Success(Exception):
"""this means we got to the right method"""

View file

@ -51,7 +51,7 @@ class BaseModel(TestCase):
def test_set_remote_id(self):
"""this function sets remote ids after creation"""
# using Work because it BookWrymModel is abstract and this requires save
# using Work because it BookWyrmModel is abstract and this requires save
# Work is a relatively not-fancy model.
instance = models.Work.objects.create(title="work title")
instance.remote_id = None

View file

@ -29,7 +29,7 @@ from bookwyrm.settings import DOMAIN
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
@patch("bookwyrm.lists_stream.populate_lists_task.delay")
class ModelFields(TestCase):
"""overwrites standard model feilds to work with activitypub"""
"""overwrites standard model fields to work with activitypub"""
def test_validate_remote_id(self, *_):
"""should look like a url"""
@ -125,7 +125,7 @@ class ModelFields(TestCase):
instance.run_validators("@example.com")
instance.run_validators("mouse@examplecom")
instance.run_validators("one two@fish.aaaa")
instance.run_validators("a*&@exampke.com")
instance.run_validators("a*&@example.com")
instance.run_validators("trailingwhite@example.com ")
self.assertIsNone(instance.run_validators("mouse@example.com"))
self.assertIsNone(instance.run_validators("mo-2use@ex3ample.com"))
@ -292,7 +292,7 @@ class ModelFields(TestCase):
self.assertEqual(value.name, "MOUSE?? MOUSE!!")
def test_foreign_key_from_activity_dict(self, *_):
"""test recieving activity json"""
"""test receiving activity json"""
instance = fields.ForeignKey(User, on_delete=models.CASCADE)
datafile = pathlib.Path(__file__).parent.joinpath("../data/ap_user.json")
userdata = json.loads(datafile.read_bytes())

View file

@ -397,7 +397,7 @@ class Status(TestCase):
# pylint: disable=unused-argument
def test_create_broadcast(self, one, two, broadcast_mock, *_):
"""should send out two verions of a status on create"""
"""should send out two versions of a status on create"""
models.Comment.objects.create(
content="hi", user=self.local_user, book=self.book
)

View file

@ -7,7 +7,7 @@ class MarkdownTags(TestCase):
"""lotta different things here"""
def test_get_markdown(self):
"""mardown format data"""
"""markdown format data"""
result = markdown.get_markdown("_hi_")
self.assertEqual(result, "<p><em>hi</em></p>")

View file

@ -41,7 +41,7 @@ class RatingTags(TestCase):
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
def test_get_rating(self, *_):
"""privacy filtered rating. Commented versions are how it ought to work with
subjective ratings, which are currenly not used for performance reasons."""
subjective ratings, which are currently not used for performance reasons."""
# follows-only: not included
models.ReviewRating.objects.create(
user=self.remote_user,

View file

@ -30,7 +30,7 @@ class PostgresTriggers(TestCase):
title="The Long Goodbye",
subtitle="wow cool",
series="series name",
languages=["irrelevent"],
languages=["irrelevant"],
)
book.authors.add(author)
book.refresh_from_db()
@ -40,7 +40,7 @@ class PostgresTriggers(TestCase):
"'cool':5B 'goodby':3A 'long':2A 'name':9 'rays':7C 'seri':8 'the':6C 'wow':4B",
)
def test_seach_vector_on_author_update(self, _):
def test_search_vector_on_author_update(self, _):
"""update search when an author name changes"""
author = models.Author.objects.create(name="The Rays")
book = models.Edition.objects.create(
@ -53,7 +53,7 @@ class PostgresTriggers(TestCase):
self.assertEqual(book.search_vector, "'goodby':3A 'jeremy':4C 'long':2A")
def test_seach_vector_on_author_delete(self, _):
def test_search_vector_on_author_delete(self, _):
"""update search when an author name changes"""
author = models.Author.objects.create(name="Jeremy")
book = models.Edition.objects.create(

View file

@ -107,7 +107,7 @@ class Signature(TestCase):
@responses.activate
def test_remote_signer(self):
"""signtures for remote users"""
"""signatures for remote users"""
datafile = pathlib.Path(__file__).parent.joinpath("data/ap_user.json")
data = json.loads(datafile.read_bytes())
data["id"] = self.fake_remote.remote_id

View file

@ -58,7 +58,7 @@ class InboxActivities(TestCase):
with patch("bookwyrm.activitystreams.remove_status_task.delay") as redis_mock:
views.inbox.activity_task(activity)
self.assertTrue(redis_mock.called)
# deletion doens't remove the status, it turns it into a tombstone
# deletion doesn't remove the status, it turns it into a tombstone
status = models.Status.objects.get()
self.assertTrue(status.deleted)
self.assertIsInstance(status.deleted_date, datetime)
@ -87,7 +87,7 @@ class InboxActivities(TestCase):
with patch("bookwyrm.activitystreams.remove_status_task.delay") as redis_mock:
views.inbox.activity_task(activity)
self.assertTrue(redis_mock.called)
# deletion doens't remove the status, it turns it into a tombstone
# deletion doesn't remove the status, it turns it into a tombstone
status = models.Status.objects.get()
self.assertTrue(status.deleted)
self.assertIsInstance(status.deleted_date, datetime)

View file

@ -114,7 +114,7 @@ class LoginViews(TestCase):
view = views.Login.as_view()
form = forms.LoginForm()
form.data["localname"] = "mouse"
form.data["password"] = "passsword1"
form.data["password"] = "password1"
request = self.factory.post("", form.data)
request.user = self.anonymous_user

View file

@ -72,7 +72,7 @@ class PasswordViews(TestCase):
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_password_reset_nonexistant_code(self):
def test_password_reset_nonexistent_code(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.PasswordReset.as_view()
request = self.factory.get("")

View file

@ -234,7 +234,7 @@ class StatusViews(TestCase):
)
def test_create_status_reply_with_mentions(self, *_):
"""reply to a post with an @mention'ed user"""
"""reply to a post with an @mention'd user"""
view = views.CreateStatus.as_view()
user = models.User.objects.create_user(
"rat", "rat@rat.com", "password", local=True, localname="rat"
@ -356,12 +356,12 @@ class StatusViews(TestCase):
self.assertEqual(len(hashtags), 2)
self.assertEqual(list(status.mention_hashtags.all()), list(hashtags))
hashtag_exising = models.Hashtag.objects.filter(name="#existing").first()
hashtag_existing = models.Hashtag.objects.filter(name="#existing").first()
hashtag_new = models.Hashtag.objects.filter(name="#NewTag").first()
self.assertEqual(
status.content,
"<p>this is an "
+ f'<a href="{hashtag_exising.remote_id}" data-mention="hashtag">'
+ f'<a href="{hashtag_existing.remote_id}" data-mention="hashtag">'
+ "#EXISTING</a> hashtag but all uppercase, this one is "
+ f'<a href="{hashtag_new.remote_id}" data-mention="hashtag">'
+ "#NewTag</a>.</p>",
@ -456,6 +456,24 @@ http://www.fish.com/"""
views.status.format_links(url), f'<a href="{url}">{url[8:]}</a>'
)
def test_format_mentions_with_at_symbol_links(self, *_):
"""A link with an @username shouldn't treat the username as a mention"""
content = "a link to https://example.com/user/@mouse"
mentions = views.status.find_mentions(self.local_user, content)
self.assertEqual(
views.status.format_mentions(content, mentions),
"a link to https://example.com/user/@mouse",
)
def test_format_hashtag_with_pound_symbol_links(self, *_):
"""A link with an @username shouldn't treat the username as a mention"""
content = "a link to https://example.com/page#anchor"
hashtags = views.status.find_or_create_hashtags(content)
self.assertEqual(
views.status.format_hashtags(content, hashtags),
"a link to https://example.com/page#anchor",
)
def test_to_markdown(self, *_):
"""this is mostly handled in other places, but nonetheless"""
text = "_hi_ and http://fish.com is <marquee>rad</marquee>"

View file

@ -53,7 +53,7 @@ class WellknownViews(TestCase):
data = json.loads(result.getvalue())
self.assertEqual(data["subject"], "acct:mouse@local.com")
def test_webfinger_case_sensitivty(self):
def test_webfinger_case_sensitivity(self):
"""ensure that webfinger queries are not case sensitive"""
request = self.factory.get("", {"resource": "acct:MoUsE@local.com"})
request.user = self.anonymous_user

View file

@ -1,10 +1,13 @@
""" celery status """
import json
from django.contrib.auth.decorators import login_required, permission_required
from django.http import HttpResponse
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_GET
from django import forms
import redis
from celerywyrm import settings
@ -46,21 +49,68 @@ class CeleryStatus(View):
queues = None
errors.append(err)
form = ClearCeleryForm()
data = {
"stats": stats,
"active_tasks": active_tasks,
"queues": queues,
"form": form,
"errors": errors,
}
return TemplateResponse(request, "settings/celery.html", data)
def post(self, request):
"""Submit form to clear queues"""
form = ClearCeleryForm(request.POST)
if form.is_valid():
if len(celery.control.ping()) != 0:
return HttpResponse(
"Refusing to delete tasks while Celery worker is active"
)
pipeline = r.pipeline()
for queue in form.cleaned_data["queues"]:
for task in r.lrange(queue, 0, -1):
task_json = json.loads(task)
if task_json["headers"]["task"] in form.cleaned_data["tasks"]:
pipeline.lrem(queue, 0, task)
results = pipeline.execute()
return HttpResponse(f"Deleted {sum(results)} tasks")
class ClearCeleryForm(forms.Form):
"""Form to clear queues"""
queues = forms.MultipleChoiceField(
label="Queues",
choices=[
(LOW, "Low prioirty"),
(MEDIUM, "Medium priority"),
(HIGH, "High priority"),
(IMPORTS, "Imports"),
(BROADCAST, "Broadcasts"),
],
widget=forms.CheckboxSelectMultiple,
)
tasks = forms.MultipleChoiceField(
label="Tasks", choices=[], widget=forms.CheckboxSelectMultiple
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
celery.loader.import_default_modules()
self.fields["tasks"].choices = sorted(
[(k, k) for k in celery.tasks.keys() if not k.startswith("celery.")]
)
@require_GET
# pylint: disable=unused-argument
def celery_ping(request):
"""Just tells you if Celery is on or not"""
try:
ping = celery.control.inspect().ping()
ping = celery.control.inspect().ping(timeout=5)
if ping:
return HttpResponse()
# pylint: disable=broad-except

View file

@ -76,7 +76,7 @@ class Dashboard(View):
def get_charts_and_stats(request):
"""Defines the dashbaord charts"""
"""Defines the dashboard charts"""
interval = int(request.GET.get("days", 1))
now = timezone.now()
start = request.GET.get("start")

View file

@ -8,6 +8,7 @@ from django.views import View
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
@ -57,7 +58,7 @@ class ImportList(View):
"""Mark an import as complete"""
import_job = get_object_or_404(models.ImportJob, id=import_id)
import_job.stop_job()
return redirect("settings-imports")
return redirect_to_referer(request, "settings-imports")
@require_POST

View file

@ -8,6 +8,7 @@ from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm import forms, models
from bookwyrm.views.helpers import redirect_to_referer
from bookwyrm.settings import PAGE_LENGTH
@ -84,26 +85,26 @@ class ReportAdmin(View):
@login_required
@permission_required("bookwyrm.moderate_user")
def suspend_user(_, user_id):
def suspend_user(request, user_id):
"""mark an account as inactive"""
user = get_object_or_404(models.User, id=user_id)
user.is_active = False
user.deactivation_reason = "moderator_suspension"
# this isn't a full deletion, so we don't want to tell the world
user.save(broadcast=False)
return redirect("settings-user", user.id)
return redirect_to_referer(request, "settings-user", user.id)
@login_required
@permission_required("bookwyrm.moderate_user")
def unsuspend_user(_, user_id):
def unsuspend_user(request, user_id):
"""mark an account as inactive"""
user = get_object_or_404(models.User, id=user_id)
user.is_active = True
user.deactivation_reason = None
# this isn't a full deletion, so we don't want to tell the world
user.save(broadcast=False)
return redirect("settings-user", user.id)
return redirect_to_referer(request, "settings-user", user.id)
@login_required
@ -123,7 +124,7 @@ def moderator_delete_user(request, user_id):
if form.is_valid() and moderator.check_password(form.cleaned_data["password"]):
user.deactivation_reason = "moderator_deletion"
user.delete()
return redirect("settings-user", user.id)
return redirect_to_referer(request, "settings-user", user.id)
form.errors["password"] = ["Invalid password"]

View file

@ -154,7 +154,7 @@ def add_authors(request, data):
data["author_matches"] = []
data["isni_matches"] = []
# creting a book or adding an author to a book needs another step
# creating a book or adding an author to a book needs another step
data["confirm_mode"] = True
# this isn't preserved because it isn't part of the form obj
data["remove_authors"] = request.POST.getlist("remove_authors")

View file

@ -104,7 +104,7 @@ def raise_is_blocked_activity(activity_json):
def sometimes_async_activity_task(activity_json, queue=MEDIUM):
"""Sometimes we can effectively respond to a request without queuing a new task,
and whever that is possible, we should do it."""
and whenever that is possible, we should do it."""
activity = activitypub.parse(activity_json)
# try resolving this activity without making any http requests

View file

@ -14,7 +14,7 @@ from bookwyrm.views.list.list import normalize_book_list_ordering
# pylint: disable=no-self-use
@method_decorator(login_required, name="dispatch")
class Curate(View):
"""approve or discard list suggestsions"""
"""approve or discard list suggestions"""
def get(self, request, list_id):
"""display a pending list"""

View file

@ -14,7 +14,7 @@ from bookwyrm.settings import PAGE_LENGTH
# pylint: disable=no-self-use
class EmbedList(View):
"""embeded book list page"""
"""embedded book list page"""
def get(self, request, list_id, list_key):
"""display a book list"""

View file

@ -8,7 +8,7 @@ from django.db import transaction
from django.db.models import Avg, DecimalField, Q, Max
from django.db.models.functions import Coalesce
from django.http import HttpResponseBadRequest, HttpResponse
from django.shortcuts import get_object_or_404
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
@ -183,7 +183,7 @@ def delete_list(request, list_id):
book_list.raise_not_deletable(request.user)
book_list.delete()
return redirect_to_referer(request, "lists")
return redirect("/list")
@require_POST

View file

@ -186,7 +186,7 @@ def update_readthrough_on_shelve(
active_readthrough = models.ReadThrough.objects.create(
user=user, book=annotated_book
)
# santiize and set dates
# sanitize and set dates
active_readthrough.start_date = load_date_in_user_tz_as_utc(start_date, user)
# if the stop or finish date is set, the readthrough will be set as inactive
active_readthrough.finish_date = load_date_in_user_tz_as_utc(finish_date, user)

View file

@ -96,34 +96,22 @@ class CreateStatus(View):
# inspect the text for user tags
content = status.content
for (mention_text, mention_user) in find_mentions(
request.user, content
).items():
mentions = find_mentions(request.user, content)
for (_, mention_user) in mentions.items():
# add them to status mentions fk
status.mention_users.add(mention_user)
content = format_mentions(content, mentions)
# turn the mention into a link
content = re.sub(
rf"{mention_text}\b(?!@)",
rf'<a href="{mention_user.remote_id}">{mention_text}</a>',
content,
)
# add reply parent to mentions
if status.reply_parent:
status.mention_users.add(status.reply_parent.user)
# inspect the text for hashtags
for (mention_text, mention_hashtag) in find_or_create_hashtags(content).items():
hashtags = find_or_create_hashtags(content)
for (_, mention_hashtag) in hashtags.items():
# add them to status mentions fk
status.mention_hashtags.add(mention_hashtag)
# turn the mention into a link
content = re.sub(
rf"{mention_text}\b(?!@)",
rf'<a href="{mention_hashtag.remote_id}" data-mention="hashtag">'
+ rf"{mention_text}</a>",
content,
)
content = format_hashtags(content, hashtags)
# deduplicate mentions
status.mention_users.set(set(status.mention_users.all()))
@ -150,6 +138,31 @@ class CreateStatus(View):
return redirect_to_referer(request)
def format_mentions(content, mentions):
"""Detect @mentions and make them links"""
for (mention_text, mention_user) in mentions.items():
# turn the mention into a link
content = re.sub(
rf"(?<!/)\B{mention_text}\b(?!@)",
rf'<a href="{mention_user.remote_id}">{mention_text}</a>',
content,
)
return content
def format_hashtags(content, hashtags):
"""Detect #hashtags and make them links"""
for (mention_text, mention_hashtag) in hashtags.items():
# turn the mention into a link
content = re.sub(
rf"(?<!/)\B{mention_text}\b(?!@)",
rf'<a href="{mention_hashtag.remote_id}" data-mention="hashtag">'
+ rf"{mention_text}</a>",
content,
)
return content
@method_decorator(login_required, name="dispatch")
class DeleteStatus(View):
"""tombstone that bad boy"""
@ -219,7 +232,7 @@ def find_mentions(user, content):
if not content:
return {}
# The regex has nested match groups, so the 0th entry has the full (outer) match
# And beacuse the strict username starts with @, the username is 1st char onward
# And because the strict username starts with @, the username is 1st char onward
usernames = [m[0][1:] for m in re.findall(regex.STRICT_USERNAME, content)]
known_users = (

56
bw-dev
View file

@ -23,21 +23,27 @@ trap showerr EXIT
source .env
trap - EXIT
if docker compose &> /dev/null ; then
DOCKER_COMPOSE="docker compose"
else
DOCKER_COMPOSE="docker-compose"
fi
function clean {
docker-compose stop
docker-compose rm -f
$DOCKER_COMPOSE stop
$DOCKER_COMPOSE rm -f
}
function runweb {
docker-compose run --rm web "$@"
$DOCKER_COMPOSE run --rm web "$@"
}
function execdb {
docker-compose exec db $@
$DOCKER_COMPOSE exec db $@
}
function execweb {
docker-compose exec web "$@"
$DOCKER_COMPOSE exec web "$@"
}
function initdb {
@ -75,20 +81,23 @@ set -x
case "$CMD" in
up)
docker-compose up --build "$@"
$DOCKER_COMPOSE up --build "$@"
;;
down)
$DOCKER_COMPOSE down
;;
service_ports_web)
prod_error
docker-compose run --rm --service-ports web
$DOCKER_COMPOSE run --rm --service-ports web
;;
initdb)
initdb "@"
;;
resetdb)
prod_error
docker-compose rm -svf
$DOCKER_COMPOSE rm -svf
docker volume rm -f bookwyrm_media_volume bookwyrm_pgdata bookwyrm_redis_activity_data bookwyrm_redis_broker_data bookwyrm_static_volume
docker-compose build
$DOCKER_COMPOSE build
migrate
migrate django_celery_beat
initdb
@ -113,7 +122,7 @@ case "$CMD" in
execdb psql -U ${POSTGRES_USER} ${POSTGRES_DB}
;;
restart_celery)
docker-compose restart celery_worker
$DOCKER_COMPOSE restart celery_worker
;;
pytest)
prod_error
@ -161,7 +170,7 @@ case "$CMD" in
runweb django-admin compilemessages --ignore venv
;;
build)
docker-compose build
$DOCKER_COMPOSE build
;;
clean)
prod_error
@ -169,7 +178,7 @@ case "$CMD" in
;;
black)
prod_error
docker-compose run --rm dev-tools black celerywyrm bookwyrm
$DOCKER_COMPOSE run --rm dev-tools black celerywyrm bookwyrm
;;
pylint)
prod_error
@ -178,25 +187,25 @@ case "$CMD" in
;;
prettier)
prod_error
docker-compose run --rm dev-tools npx prettier --write bookwyrm/static/js/*.js
$DOCKER_COMPOSE run --rm dev-tools npx prettier --write bookwyrm/static/js/*.js
;;
eslint)
prod_error
docker-compose run --rm dev-tools npx eslint bookwyrm/static --ext .js
$DOCKER_COMPOSE run --rm dev-tools npx eslint bookwyrm/static --ext .js
;;
stylelint)
prod_error
docker-compose run --rm dev-tools npx stylelint \
$DOCKER_COMPOSE run --rm dev-tools npx stylelint \
bookwyrm/static/css/bookwyrm.scss bookwyrm/static/css/bookwyrm/**/*.scss --fix \
--config dev-tools/.stylelintrc.js
;;
formatters)
prod_error
runweb pylint bookwyrm/
docker-compose run --rm dev-tools black celerywyrm bookwyrm
docker-compose run --rm dev-tools npx prettier --write bookwyrm/static/js/*.js
docker-compose run --rm dev-tools npx eslint bookwyrm/static --ext .js
docker-compose run --rm dev-tools npx stylelint \
$DOCKER_COMPOSE run --rm dev-tools black celerywyrm bookwyrm
$DOCKER_COMPOSE run --rm dev-tools npx prettier --write bookwyrm/static/js/*.js
$DOCKER_COMPOSE run --rm dev-tools npx eslint bookwyrm/static --ext .js
$DOCKER_COMPOSE run --rm dev-tools npx stylelint \
bookwyrm/static/css/bookwyrm.scss bookwyrm/static/css/bookwyrm/**/*.scss --fix \
--config dev-tools/.stylelintrc.js
;;
@ -206,14 +215,14 @@ case "$CMD" in
;;
update)
git pull
docker-compose build
$DOCKER_COMPOSE build
# ./update.sh
runweb python manage.py migrate
runweb python manage.py compile_themes
runweb python manage.py collectstatic --no-input
docker-compose up -d
docker-compose restart web
docker-compose restart celery_worker
$DOCKER_COMPOSE up -d
$DOCKER_COMPOSE restart web
$DOCKER_COMPOSE restart celery_worker
;;
populate_streams)
runweb python manage.py populate_streams "$@"
@ -284,6 +293,7 @@ case "$CMD" in
echo "Unrecognised command. Try:"
echo " setup"
echo " up [container]"
echo " down"
echo " service_ports_web"
echo " initdb"
echo " resetdb"

View file

@ -11,3 +11,4 @@ class CelerywyrmConfig(AppConfig):
from bookwyrm.telemetry import open_telemetry
open_telemetry.instrumentCelery()
open_telemetry.instrumentPostgres()

View file

@ -3,6 +3,8 @@
# pylint: disable=unused-wildcard-import
from bookwyrm.settings import *
QUERY_TIMEOUT = env.int("CELERY_QUERY_TIMEOUT", env.int("QUERY_TIMEOUT", 30))
# pylint: disable=line-too-long
REDIS_BROKER_PASSWORD = requests.utils.quote(env("REDIS_BROKER_PASSWORD", ""))
REDIS_BROKER_HOST = env("REDIS_BROKER_HOST", "redis_broker")

View file

@ -1,4 +1,4 @@
# Stub English-language trnaslation file
# Stub English-language translation file
# Copyright (C) 2021 Mouse Reeve
# This file is distributed under the same license as the BookWyrm package.
# Mouse Reeve <mousereeve@riseup.net>, 2021
@ -4045,7 +4045,7 @@ msgstr ""
#: bookwyrm/templates/preferences/edit_user.html:136
#, python-format
msgid "Looking for shelf privacy? You can set a sepearate visibility level for each of your shelves. Go to <a href=\"%(path)s\">Your Books</a>, pick a shelf from the tab bar, and click \"Edit shelf.\""
msgid "Looking for shelf privacy? You can set a separate visibility level for each of your shelves. Go to <a href=\"%(path)s\">Your Books</a>, pick a shelf from the tab bar, and click \"Edit shelf.\""
msgstr ""
#: bookwyrm/templates/preferences/export.html:4

View file

@ -17,7 +17,7 @@ Pillow==9.4.0
psycopg2==2.9.5
pycryptodome==3.16.0
python-dateutil==2.8.2
redis==4.5.3
redis==4.5.4
requests==2.28.2
responses==0.22.0
pytz>=2022.7
@ -29,6 +29,7 @@ opentelemetry-api==1.16.0
opentelemetry-exporter-otlp-proto-grpc==1.16.0
opentelemetry-instrumentation-celery==0.37b0
opentelemetry-instrumentation-django==0.37b0
opentelemetry-instrumentation-psycopg2==0.37b0
opentelemetry-sdk==1.16.0
protobuf==3.20.*
pyotp==2.8.0