diff --git a/.env.dev.example b/.env.dev.example index d4476fd24..1e4fb9812 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -16,7 +16,7 @@ DEFAULT_LANGUAGE="English" MEDIA_ROOT=images/ -POSTGRES_PORT=5432 +PGPORT=5432 POSTGRES_PASSWORD=securedbypassword123 POSTGRES_USER=fedireads POSTGRES_DB=fedireads diff --git a/.env.prod.example b/.env.prod.example index 99520916a..49729d533 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -16,7 +16,7 @@ DEFAULT_LANGUAGE="English" MEDIA_ROOT=images/ -POSTGRES_PORT=5432 +PGPORT=5432 POSTGRES_PASSWORD=securedbypassword123 POSTGRES_USER=fedireads POSTGRES_DB=fedireads diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index d20e7e944..24d383ac7 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -101,7 +101,7 @@ class ActivityObject: except KeyError: if field.default == MISSING and field.default_factory == MISSING: raise ActivitySerializerError( - "Missing required field: %s" % field.name + f"Missing required field: {field.name}" ) value = field.default setattr(self, field.name, value) @@ -213,14 +213,14 @@ class ActivityObject: return data -@app.task +@app.task(queue="medium_priority") @transaction.atomic def set_related_field( model_name, origin_model_name, related_field_name, related_remote_id, data ): """load reverse related fields (editions, attachments) without blocking""" - model = apps.get_model("bookwyrm.%s" % model_name, require_ready=True) - origin_model = apps.get_model("bookwyrm.%s" % origin_model_name, require_ready=True) + model = apps.get_model(f"bookwyrm.{model_name}", require_ready=True) + origin_model = apps.get_model(f"bookwyrm.{origin_model_name}", require_ready=True) with transaction.atomic(): if isinstance(data, str): @@ -234,7 +234,7 @@ def set_related_field( # this must exist because it's the object that triggered this function instance = origin_model.find_existing_by_remote_id(related_remote_id) if not instance: - raise ValueError("Invalid related remote id: %s" % related_remote_id) + raise ValueError(f"Invalid related remote id: {related_remote_id}") # set the origin's remote id on the activity so it will be there when # the model instance is created @@ -265,7 +265,7 @@ def get_model_from_type(activity_type): ] if not model: raise ActivitySerializerError( - 'No model found for activity type "%s"' % activity_type + f'No model found for activity type "{activity_type}"' ) return model[0] @@ -275,6 +275,8 @@ def resolve_remote_id( ): """take a remote_id and return an instance, creating if necessary""" if model: # a bonus check we can do if we already know the model + if isinstance(model, str): + model = apps.get_model(f"bookwyrm.{model}", require_ready=True) result = model.find_existing_by_remote_id(remote_id) if result and not refresh: return result if not get_activity else result.to_activity_dataclass() @@ -284,7 +286,7 @@ def resolve_remote_id( data = get_data(remote_id) except ConnectorException: raise ActivitySerializerError( - "Could not connect to host for remote_id in: %s" % (remote_id) + f"Could not connect to host for remote_id: {remote_id}" ) # determine the model implicitly, if not provided # or if it's a model with subclasses like Status, check again diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py index 556ef1853..d61471fe0 100644 --- a/bookwyrm/activitypub/note.py +++ b/bookwyrm/activitypub/note.py @@ -30,8 +30,8 @@ class Note(ActivityObject): to: List[str] = field(default_factory=lambda: []) cc: List[str] = field(default_factory=lambda: []) replies: Dict = field(default_factory=lambda: {}) - inReplyTo: str = "" - summary: str = "" + inReplyTo: str = None + summary: str = None tag: List[Link] = field(default_factory=lambda: []) attachment: List[Document] = field(default_factory=lambda: []) sensitive: bool = False @@ -70,6 +70,8 @@ class Quotation(Comment): """a quote and commentary on a book""" quote: str + position: int = None + positionMode: str = None type: str = "Quotation" diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py index c6ad57608..5e0969e56 100644 --- a/bookwyrm/activitystreams.py +++ b/bookwyrm/activitystreams.py @@ -1,6 +1,9 @@ """ access the activity streams stored in redis """ +from datetime import timedelta from django.dispatch import receiver +from django.db import transaction from django.db.models import signals, Q +from django.utils import timezone from bookwyrm import models from bookwyrm.redis_store import RedisStore, r @@ -13,11 +16,12 @@ class ActivityStream(RedisStore): def stream_id(self, user): """the redis key for this user's instance of this stream""" - return "{}-{}".format(user.id, self.key) + return f"{user.id}-{self.key}" def unread_id(self, user): """the redis key for this user's unread count for this stream""" - return "{}-unread".format(self.stream_id(user)) + stream_id = self.stream_id(user) + return f"{stream_id}-unread" def get_rank(self, obj): # pylint: disable=no-self-use """statuses are sorted by date published""" @@ -258,38 +262,31 @@ def add_status_on_create(sender, instance, created, *args, **kwargs): return if instance.deleted: - for stream in streams.values(): - stream.remove_object_from_related_stores(instance) + remove_status_task.delay(instance.id) return - for stream in streams.values(): - stream.add_status(instance, increment_unread=created) - - if sender != models.Boost: - return - # remove the original post and other, earlier boosts - boosted = instance.boost.boosted_status - old_versions = models.Boost.objects.filter( - boosted_status__id=boosted.id, - created_date__lt=instance.created_date, + # when creating new things, gotta wait on the transaction + transaction.on_commit( + lambda: add_status_on_create_command(sender, instance, created) ) - for stream in streams.values(): - audience = stream.get_stores_for_object(instance) - stream.remove_object_from_related_stores(boosted, stores=audience) - for status in old_versions: - stream.remove_object_from_related_stores(status, stores=audience) + + +def add_status_on_create_command(sender, instance, created): + """runs this code only after the database commit completes""" + add_status_task.delay(instance.id, increment_unread=created) + + if sender == models.Boost: + handle_boost_task.delay(instance.id) @receiver(signals.post_delete, sender=models.Boost) # pylint: disable=unused-argument def remove_boost_on_delete(sender, instance, *args, **kwargs): """boosts are deleted""" - # we're only interested in new statuses - for stream in streams.values(): - # remove the boost - stream.remove_object_from_related_stores(instance) - # re-add the original status - stream.add_status(instance.boosted_status) + # remove the boost + remove_status_task.delay(instance.id) + # re-add the original status + add_status_task.delay(instance.boosted_status.id) @receiver(signals.post_save, sender=models.UserFollows) @@ -298,7 +295,9 @@ def add_statuses_on_follow(sender, instance, created, *args, **kwargs): """add a newly followed user's statuses to feeds""" if not created or not instance.user_subject.local: return - HomeStream().add_user_statuses(instance.user_subject, instance.user_object) + add_user_statuses_task.delay( + instance.user_subject.id, instance.user_object.id, stream_list=["home"] + ) @receiver(signals.post_delete, sender=models.UserFollows) @@ -307,7 +306,9 @@ def remove_statuses_on_unfollow(sender, instance, *args, **kwargs): """remove statuses from a feed on unfollow""" if not instance.user_subject.local: return - HomeStream().remove_user_statuses(instance.user_subject, instance.user_object) + remove_user_statuses_task.delay( + instance.user_subject.id, instance.user_object.id, stream_list=["home"] + ) @receiver(signals.post_save, sender=models.UserBlocks) @@ -316,13 +317,15 @@ def remove_statuses_on_block(sender, instance, *args, **kwargs): """remove statuses from all feeds on block""" # blocks apply ot all feeds if instance.user_subject.local: - for stream in streams.values(): - stream.remove_user_statuses(instance.user_subject, instance.user_object) + remove_user_statuses_task.delay( + instance.user_subject.id, instance.user_object.id + ) # and in both directions if instance.user_object.local: - for stream in streams.values(): - stream.remove_user_statuses(instance.user_object, instance.user_subject) + remove_user_statuses_task.delay( + instance.user_object.id, instance.user_subject.id + ) @receiver(signals.post_delete, sender=models.UserBlocks) @@ -330,15 +333,22 @@ def remove_statuses_on_block(sender, instance, *args, **kwargs): def add_statuses_on_unblock(sender, instance, *args, **kwargs): """remove statuses from all feeds on block""" public_streams = [v for (k, v) in streams.items() if k != "home"] + # add statuses back to streams with statuses from anyone if instance.user_subject.local: - for stream in public_streams: - stream.add_user_statuses(instance.user_subject, instance.user_object) + add_user_statuses_task.delay( + instance.user_subject.id, + instance.user_object.id, + stream_list=public_streams, + ) # add statuses back to streams with statuses from anyone if instance.user_object.local: - for stream in public_streams: - stream.add_user_statuses(instance.user_object, instance.user_subject) + add_user_statuses_task.delay( + instance.user_object.id, + instance.user_subject.id, + stream_list=public_streams, + ) @receiver(signals.post_save, sender=models.User) @@ -348,8 +358,8 @@ def populate_streams_on_account_create(sender, instance, created, *args, **kwarg if not created or not instance.local: return - for stream in streams.values(): - stream.populate_streams(instance) + for stream in streams: + populate_stream_task.delay(stream, instance.id) @receiver(signals.pre_save, sender=models.ShelfBook) @@ -358,20 +368,14 @@ def add_statuses_on_shelve(sender, instance, *args, **kwargs): """update books stream when user shelves a book""" if not instance.user.local: return - book = None - if hasattr(instance, "book"): - book = instance.book - elif instance.mention_books.exists(): - book = instance.mention_books.first() - if not book: - return + book = instance.book # check if the book is already on the user's shelves editions = book.parent_work.editions.all() if models.ShelfBook.objects.filter(user=instance.user, book__in=editions).exists(): return - BooksStream().add_book_statuses(instance.user, book) + add_book_statuses_task.delay(instance.user.id, book.id) @receiver(signals.post_delete, sender=models.ShelfBook) @@ -381,24 +385,101 @@ def remove_statuses_on_unshelve(sender, instance, *args, **kwargs): if not instance.user.local: return - book = None - if hasattr(instance, "book"): - book = instance.book - elif instance.mention_books.exists(): - book = instance.mention_books.first() - if not book: - return + book = instance.book + # check if the book is actually unshelved, not just moved editions = book.parent_work.editions.all() if models.ShelfBook.objects.filter(user=instance.user, book__in=editions).exists(): return - BooksStream().remove_book_statuses(instance.user, instance.book) + remove_book_statuses_task.delay(instance.user.id, book.id) -@app.task +# ---- TASKS + + +@app.task(queue="low_priority") +def add_book_statuses_task(user_id, book_id): + """add statuses related to a book on shelve""" + user = models.User.objects.get(id=user_id) + book = models.Edition.objects.get(id=book_id) + BooksStream().add_book_statuses(user, book) + + +@app.task(queue="low_priority") +def remove_book_statuses_task(user_id, book_id): + """remove statuses about a book from a user's books feed""" + user = models.User.objects.get(id=user_id) + book = models.Edition.objects.get(id=book_id) + BooksStream().remove_book_statuses(user, book) + + +@app.task(queue="medium_priority") def populate_stream_task(stream, user_id): """background task for populating an empty activitystream""" user = models.User.objects.get(id=user_id) stream = streams[stream] stream.populate_streams(user) + + +@app.task(queue="medium_priority") +def remove_status_task(status_ids): + """remove a status from any stream it might be in""" + # this can take an id or a list of ids + if not isinstance(status_ids, list): + status_ids = [status_ids] + statuses = models.Status.objects.filter(id__in=status_ids) + + for stream in streams.values(): + for status in statuses: + stream.remove_object_from_related_stores(status) + + +@app.task(queue="high_priority") +def add_status_task(status_id, increment_unread=False): + """add a status to any stream it should be in""" + status = models.Status.objects.get(id=status_id) + # we don't want to tick the unread count for csv import statuses, idk how better + # to check than just to see if the states is more than a few days old + if status.created_date < timezone.now() - timedelta(days=2): + increment_unread = False + for stream in streams.values(): + stream.add_status(status, increment_unread=increment_unread) + + +@app.task(queue="medium_priority") +def remove_user_statuses_task(viewer_id, user_id, stream_list=None): + """remove all statuses by a user from a viewer's stream""" + stream_list = [streams[s] for s in stream_list] if stream_list else streams.values() + viewer = models.User.objects.get(id=viewer_id) + user = models.User.objects.get(id=user_id) + for stream in stream_list: + stream.remove_user_statuses(viewer, user) + + +@app.task(queue="medium_priority") +def add_user_statuses_task(viewer_id, user_id, stream_list=None): + """add all statuses by a user to a viewer's stream""" + stream_list = [streams[s] for s in stream_list] if stream_list else streams.values() + viewer = models.User.objects.get(id=viewer_id) + user = models.User.objects.get(id=user_id) + for stream in stream_list: + stream.add_user_statuses(viewer, user) + + +@app.task(queue="medium_priority") +def handle_boost_task(boost_id): + """remove the original post and other, earlier boosts""" + instance = models.Status.objects.get(id=boost_id) + boosted = instance.boost.boosted_status + + old_versions = models.Boost.objects.filter( + boosted_status__id=boosted.id, + created_date__lt=instance.created_date, + ) + + for stream in streams.values(): + audience = stream.get_stores_for_object(instance) + stream.remove_object_from_related_stores(boosted, stores=audience) + for status in old_versions: + stream.remove_object_from_related_stores(status, stores=audience) diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index ffacffdf0..455241cca 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -43,7 +43,7 @@ class AbstractMinimalConnector(ABC): params["min_confidence"] = min_confidence data = self.get_search_data( - "%s%s" % (self.search_url, query), + f"{self.search_url}{query}", params=params, timeout=timeout, ) @@ -57,7 +57,7 @@ class AbstractMinimalConnector(ABC): """isbn search""" params = {} data = self.get_search_data( - "%s%s" % (self.isbn_search_url, query), + f"{self.isbn_search_url}{query}", params=params, ) results = [] @@ -131,7 +131,7 @@ class AbstractConnector(AbstractMinimalConnector): work_data = data if not work_data or not edition_data: - raise ConnectorException("Unable to load book data: %s" % remote_id) + raise ConnectorException(f"Unable to load book data: {remote_id}") with transaction.atomic(): # create activitypub object @@ -222,9 +222,7 @@ def get_data(url, params=None, timeout=10): """wrapper for request.get""" # check if the url is blocked if models.FederatedServer.is_blocked(url): - raise ConnectorException( - "Attempting to load data from blocked url: {:s}".format(url) - ) + raise ConnectorException(f"Attempting to load data from blocked url: {url}") try: resp = requests.get( @@ -283,6 +281,7 @@ class SearchResult: confidence: int = 1 def __repr__(self): + # pylint: disable=consider-using-f-string return "".format( self.key, self.title, self.author ) diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py index 1a615c9b2..b676e9aa5 100644 --- a/bookwyrm/connectors/connector_manager.py +++ b/bookwyrm/connectors/connector_manager.py @@ -109,17 +109,17 @@ def get_or_create_connector(remote_id): connector_info = models.Connector.objects.create( identifier=identifier, connector_file="bookwyrm_connector", - base_url="https://%s" % identifier, - books_url="https://%s/book" % identifier, - covers_url="https://%s/images/covers" % identifier, - search_url="https://%s/search?q=" % identifier, + base_url=f"https://{identifier}", + books_url=f"https://{identifier}/book", + covers_url=f"https://{identifier}/images/covers", + search_url=f"https://{identifier}/search?q=", priority=2, ) return load_connector(connector_info) -@app.task +@app.task(queue="low_priority") def load_more_data(connector_id, book_id): """background the work of getting all 10,000 editions of LoTR""" connector_info = models.Connector.objects.get(id=connector_id) @@ -131,7 +131,7 @@ def load_more_data(connector_id, book_id): def load_connector(connector_info): """instantiate the connector class""" connector = importlib.import_module( - "bookwyrm.connectors.%s" % connector_info.connector_file + f"bookwyrm.connectors.{connector_info.connector_file}" ) return connector.Connector(connector_info.identifier) @@ -141,4 +141,4 @@ def load_connector(connector_info): def create_connector(sender, instance, created, *args, **kwargs): """create a connector to an external bookwyrm server""" if instance.application_type == "bookwyrm": - get_or_create_connector("https://{:s}".format(instance.server_name)) + get_or_create_connector(f"https://{instance.server_name}") diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py index d2a7b9faa..704554880 100644 --- a/bookwyrm/connectors/inventaire.py +++ b/bookwyrm/connectors/inventaire.py @@ -59,7 +59,7 @@ class Connector(AbstractConnector): def get_remote_id(self, value): """convert an id/uri into a url""" - return "{:s}?action=by-uris&uris={:s}".format(self.books_url, value) + return f"{self.books_url}?action=by-uris&uris={value}" def get_book_data(self, remote_id): data = get_data(remote_id) @@ -87,11 +87,7 @@ class Connector(AbstractConnector): def format_search_result(self, search_result): images = search_result.get("image") - cover = ( - "{:s}/img/entities/{:s}".format(self.covers_url, images[0]) - if images - else None - ) + cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None # a deeply messy translation of inventaire's scores confidence = float(search_result.get("_score", 0.1)) confidence = 0.1 if confidence < 150 else 0.999 @@ -99,9 +95,7 @@ class Connector(AbstractConnector): title=search_result.get("label"), key=self.get_remote_id(search_result.get("uri")), author=search_result.get("description"), - view_link="{:s}/entity/{:s}".format( - self.base_url, search_result.get("uri") - ), + view_link=f"{self.base_url}/entity/{search_result.get('uri')}", cover=cover, confidence=confidence, connector=self, @@ -123,9 +117,7 @@ class Connector(AbstractConnector): title=title[0], key=self.get_remote_id(search_result.get("uri")), author=search_result.get("description"), - view_link="{:s}/entity/{:s}".format( - self.base_url, search_result.get("uri") - ), + view_link=f"{self.base_url}/entity/{search_result.get('uri')}", cover=self.get_cover_url(search_result.get("image")), connector=self, ) @@ -135,11 +127,7 @@ class Connector(AbstractConnector): def load_edition_data(self, work_uri): """get a list of editions for a work""" - url = ( - "{:s}?action=reverse-claims&property=wdt:P629&value={:s}&sort=true".format( - self.books_url, work_uri - ) - ) + url = f"{self.books_url}?action=reverse-claims&property=wdt:P629&value={work_uri}&sort=true" return get_data(url) def get_edition_from_work_data(self, data): @@ -195,7 +183,7 @@ class Connector(AbstractConnector): # cover may or may not be an absolute url already if re.match(r"^http", cover_id): return cover_id - return "%s%s" % (self.covers_url, cover_id) + return f"{self.covers_url}{cover_id}" def resolve_keys(self, keys): """cool, it's "wd:Q3156592" now what the heck does that mean""" @@ -213,9 +201,7 @@ class Connector(AbstractConnector): link = links.get("enwiki") if not link: return "" - url = "{:s}/api/data?action=wp-extract&lang=en&title={:s}".format( - self.base_url, link - ) + url = f"{self.base_url}/api/data?action=wp-extract&lang=en&title={link}" try: data = get_data(url) except ConnectorException: diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py index e58749c13..fca5d0f71 100644 --- a/bookwyrm/connectors/openlibrary.py +++ b/bookwyrm/connectors/openlibrary.py @@ -71,7 +71,7 @@ class Connector(AbstractConnector): key = data["key"] except KeyError: raise ConnectorException("Invalid book data") - return "%s%s" % (self.books_url, key) + return f"{self.books_url}{key}" def is_work_data(self, data): return bool(re.match(r"^[\/\w]+OL\d+W$", data["key"])) @@ -81,7 +81,7 @@ class Connector(AbstractConnector): key = data["key"] except KeyError: raise ConnectorException("Invalid book data") - url = "%s%s/editions" % (self.books_url, key) + url = f"{self.books_url}{key}/editions" data = self.get_book_data(url) edition = pick_default_edition(data["entries"]) if not edition: @@ -93,7 +93,7 @@ class Connector(AbstractConnector): key = data["works"][0]["key"] except (IndexError, KeyError): raise ConnectorException("No work found for edition") - url = "%s%s" % (self.books_url, key) + url = f"{self.books_url}{key}" return self.get_book_data(url) def get_authors_from_data(self, data): @@ -102,7 +102,7 @@ class Connector(AbstractConnector): author_blob = author_blob.get("author", author_blob) # this id is "/authors/OL1234567A" author_id = author_blob["key"] - url = "%s%s" % (self.base_url, author_id) + url = f"{self.base_url}{author_id}" author = self.get_or_create_author(url) if not author: continue @@ -113,8 +113,8 @@ class Connector(AbstractConnector): if not cover_blob: return None cover_id = cover_blob[0] - image_name = "%s-%s.jpg" % (cover_id, size) - return "%s/b/id/%s" % (self.covers_url, image_name) + image_name = f"{cover_id}-{size}.jpg" + return f"{self.covers_url}/b/id/{image_name}" def parse_search_data(self, data): return data.get("docs") @@ -152,7 +152,7 @@ class Connector(AbstractConnector): def load_edition_data(self, olkey): """query openlibrary for editions of a work""" - url = "%s/works/%s/editions" % (self.books_url, olkey) + url = f"{self.books_url}/works/{olkey}/editions" return self.get_book_data(url) def expand_book_data(self, book): diff --git a/bookwyrm/connectors/self_connector.py b/bookwyrm/connectors/self_connector.py index 8d5a7614e..cdb586cb3 100644 --- a/bookwyrm/connectors/self_connector.py +++ b/bookwyrm/connectors/self_connector.py @@ -71,7 +71,7 @@ class Connector(AbstractConnector): def format_search_result(self, search_result): cover = None if search_result.cover: - cover = "%s%s" % (self.covers_url, search_result.cover) + cover = f"{self.covers_url}{search_result.cover}" return SearchResult( title=search_result.title, diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py index 0610a8b9a..309e84ed1 100644 --- a/bookwyrm/context_processors.py +++ b/bookwyrm/context_processors.py @@ -15,4 +15,5 @@ def site_settings(request): # pylint: disable=unused-argument "media_full_url": settings.MEDIA_FULL_URL, "preview_images_enabled": settings.ENABLE_PREVIEW_IMAGES, "request_protocol": request_protocol, + "js_cache": settings.JS_CACHE, } diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py index fff3985ef..c6a197f29 100644 --- a/bookwyrm/emailing.py +++ b/bookwyrm/emailing.py @@ -11,7 +11,7 @@ def email_data(): """fields every email needs""" site = models.SiteSettings.objects.get() if site.logo_small: - logo_path = "/images/{}".format(site.logo_small.url) + logo_path = f"/images/{site.logo_small.url}" else: logo_path = "/static/images/logo-small.png" @@ -48,23 +48,17 @@ def password_reset_email(reset_code): def format_email(email_name, data): """render the email templates""" - subject = ( - get_template("email/{}/subject.html".format(email_name)).render(data).strip() - ) + subject = get_template(f"email/{email_name}/subject.html").render(data).strip() html_content = ( - get_template("email/{}/html_content.html".format(email_name)) - .render(data) - .strip() + get_template(f"email/{email_name}/html_content.html").render(data).strip() ) text_content = ( - get_template("email/{}/text_content.html".format(email_name)) - .render(data) - .strip() + get_template(f"email/{email_name}/text_content.html").render(data).strip() ) return (subject, html_content, text_content) -@app.task +@app.task(queue="high_priority") def send_email(recipient, subject, html_content, text_content): """use a task to send the email""" email = EmailMultiAlternatives( diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py index e88124702..b9b93694c 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -101,6 +101,8 @@ class QuotationForm(CustomForm): "content_warning", "sensitive", "privacy", + "position", + "position_mode", ] @@ -123,6 +125,12 @@ class StatusForm(CustomForm): fields = ["user", "content", "content_warning", "sensitive", "privacy"] +class DirectForm(CustomForm): + class Meta: + model = models.Status + fields = ["user", "content", "content_warning", "sensitive", "privacy"] + + class EditUserForm(CustomForm): class Meta: model = models.User @@ -132,6 +140,7 @@ class EditUserForm(CustomForm): "email", "summary", "show_goal", + "show_suggested_users", "manually_approves_followers", "default_post_privacy", "discoverable", @@ -219,7 +228,7 @@ class ExpiryWidget(widgets.Select): elif selected_string == "forever": return None else: - return selected_string # "This will raise + return selected_string # This will raise return timezone.now() + interval @@ -251,10 +260,7 @@ class CreateInviteForm(CustomForm): ] ), "use_limit": widgets.Select( - choices=[ - (i, _("%(count)d uses" % {"count": i})) - for i in [1, 5, 10, 25, 50, 100] - ] + choices=[(i, _(f"{i} uses")) for i in [1, 5, 10, 25, 50, 100]] + [(None, _("Unlimited"))] ), } @@ -296,6 +302,18 @@ class ReportForm(CustomForm): fields = ["user", "reporter", "statuses", "note"] +class EmailBlocklistForm(CustomForm): + class Meta: + model = models.EmailBlocklist + fields = ["domain"] + + +class IPBlocklistForm(CustomForm): + class Meta: + model = models.IPBlocklist + fields = ["address"] + + class ServerForm(CustomForm): class Meta: model = models.FederatedServer diff --git a/bookwyrm/importers/importer.py b/bookwyrm/importers/importer.py index d5f1449ca..a10b4060c 100644 --- a/bookwyrm/importers/importer.py +++ b/bookwyrm/importers/importer.py @@ -3,6 +3,7 @@ import csv import logging from django.utils import timezone +from django.utils.translation import gettext_lazy as _ from bookwyrm import models from bookwyrm.models import ImportJob, ImportItem @@ -61,7 +62,7 @@ class Importer: job.save() -@app.task +@app.task(queue="low_priority") def import_data(source, job_id): """does the actual lookup work in a celery task""" job = ImportJob.objects.get(id=job_id) @@ -71,19 +72,20 @@ def import_data(source, job_id): item.resolve() except Exception as err: # pylint: disable=broad-except logger.exception(err) - item.fail_reason = "Error loading book" + item.fail_reason = _("Error loading book") item.save() continue - if item.book: + if item.book or item.book_guess: item.save() + if item.book: # shelves book and handles reviews handle_imported_book( source, job.user, item, job.include_reviews, job.privacy ) else: - item.fail_reason = "Could not find a match for book" + item.fail_reason = _("Could not find a match for book") item.save() finally: job.complete = True @@ -125,6 +127,7 @@ def handle_imported_book(source, user, item, include_reviews, privacy): # but "now" is a bad guess published_date_guess = item.date_read or item.date_added if item.review: + # pylint: disable=consider-using-f-string review_title = ( "Review of {!r} on {!r}".format( item.book.title, diff --git a/bookwyrm/middleware/__init__.py b/bookwyrm/middleware/__init__.py new file mode 100644 index 000000000..03843c5a3 --- /dev/null +++ b/bookwyrm/middleware/__init__.py @@ -0,0 +1,3 @@ +""" look at all this nice middleware! """ +from .timezone_middleware import TimezoneMiddleware +from .ip_middleware import IPBlocklistMiddleware diff --git a/bookwyrm/middleware/ip_middleware.py b/bookwyrm/middleware/ip_middleware.py new file mode 100644 index 000000000..8063dd1f6 --- /dev/null +++ b/bookwyrm/middleware/ip_middleware.py @@ -0,0 +1,16 @@ +""" Block IP addresses """ +from django.http import Http404 +from bookwyrm import models + + +class IPBlocklistMiddleware: + """check incoming traffic against an IP block-list""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + address = request.META.get("REMOTE_ADDR") + if models.IPBlocklist.objects.filter(address=address).exists(): + raise Http404() + return self.get_response(request) diff --git a/bookwyrm/timezone_middleware.py b/bookwyrm/middleware/timezone_middleware.py similarity index 100% rename from bookwyrm/timezone_middleware.py rename to bookwyrm/middleware/timezone_middleware.py diff --git a/bookwyrm/migrations/0086_auto_20210827_1727.py b/bookwyrm/migrations/0086_auto_20210827_1727.py new file mode 100644 index 000000000..ef6af206b --- /dev/null +++ b/bookwyrm/migrations/0086_auto_20210827_1727.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2.4 on 2021-08-27 17:27 + +from django.db import migrations, models +import django.db.models.expressions + + +def normalize_readthrough_dates(app_registry, schema_editor): + """Find any invalid dates and reset them""" + db_alias = schema_editor.connection.alias + app_registry.get_model("bookwyrm", "ReadThrough").objects.using(db_alias).filter( + start_date__gt=models.F("finish_date") + ).update(start_date=models.F("finish_date")) + + +def reverse_func(apps, schema_editor): + """nothing to do here""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0085_user_saved_lists"), + ] + + operations = [ + migrations.RunPython(normalize_readthrough_dates, reverse_func), + migrations.AlterModelOptions( + name="readthrough", + options={"ordering": ("-start_date",)}, + ), + migrations.AddConstraint( + model_name="readthrough", + constraint=models.CheckConstraint( + check=models.Q( + ("finish_date__gte", django.db.models.expressions.F("start_date")) + ), + name="chronology", + ), + ), + ] diff --git a/bookwyrm/migrations/0086_auto_20210828_1724.py b/bookwyrm/migrations/0086_auto_20210828_1724.py new file mode 100644 index 000000000..212477118 --- /dev/null +++ b/bookwyrm/migrations/0086_auto_20210828_1724.py @@ -0,0 +1,49 @@ +# Generated by Django 3.2.4 on 2021-08-28 17:24 + +import bookwyrm.models.fields +from django.conf import settings +from django.db import migrations, models +from django.db.models import F, Value, CharField +from django.db.models.functions import Concat + + +def forwards_func(apps, schema_editor): + """generate followers url""" + db_alias = schema_editor.connection.alias + apps.get_model("bookwyrm", "User").objects.using(db_alias).annotate( + generated_url=Concat( + F("remote_id"), Value("/followers"), output_field=CharField() + ) + ).update(followers_url=models.F("generated_url")) + + +def reverse_func(apps, schema_editor): + """noop""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0085_user_saved_lists"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="followers_url", + field=bookwyrm.models.fields.CharField( + default="/followers", max_length=255 + ), + preserve_default=False, + ), + migrations.RunPython(forwards_func, reverse_func), + migrations.AlterField( + model_name="user", + name="followers", + field=models.ManyToManyField( + related_name="following", + through="bookwyrm.UserFollows", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/bookwyrm/migrations/0087_merge_0086_auto_20210827_1727_0086_auto_20210828_1724.py b/bookwyrm/migrations/0087_merge_0086_auto_20210827_1727_0086_auto_20210828_1724.py new file mode 100644 index 000000000..cd5311619 --- /dev/null +++ b/bookwyrm/migrations/0087_merge_0086_auto_20210827_1727_0086_auto_20210828_1724.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.4 on 2021-08-29 18:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0086_auto_20210827_1727"), + ("bookwyrm", "0086_auto_20210828_1724"), + ] + + operations = [] diff --git a/bookwyrm/migrations/0088_auto_20210905_2233.py b/bookwyrm/migrations/0088_auto_20210905_2233.py new file mode 100644 index 000000000..028cf7bf7 --- /dev/null +++ b/bookwyrm/migrations/0088_auto_20210905_2233.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.4 on 2021-09-05 22:33 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0087_merge_0086_auto_20210827_1727_0086_auto_20210828_1724"), + ] + + operations = [ + migrations.AddField( + model_name="quotation", + name="position", + field=models.IntegerField( + blank=True, + null=True, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + migrations.AddField( + model_name="quotation", + name="position_mode", + field=models.CharField( + blank=True, + choices=[("PG", "page"), ("PCT", "percent")], + default="PG", + max_length=3, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0089_user_show_suggested_users.py b/bookwyrm/migrations/0089_user_show_suggested_users.py new file mode 100644 index 000000000..047bb974c --- /dev/null +++ b/bookwyrm/migrations/0089_user_show_suggested_users.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.4 on 2021-09-08 16:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0088_auto_20210905_2233"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="show_suggested_users", + field=models.BooleanField(default=True), + ), + ] diff --git a/bookwyrm/migrations/0090_auto_20210908_2346.py b/bookwyrm/migrations/0090_auto_20210908_2346.py new file mode 100644 index 000000000..7c8708573 --- /dev/null +++ b/bookwyrm/migrations/0090_auto_20210908_2346.py @@ -0,0 +1,45 @@ +# Generated by Django 3.2.4 on 2021-09-08 23:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0089_user_show_suggested_users"), + ] + + operations = [ + migrations.AlterField( + model_name="connector", + name="deactivation_reason", + field=models.CharField( + blank=True, + choices=[ + ("pending", "Pending"), + ("self_deletion", "Self Deletion"), + ("moderator_suspension", "Moderator Suspension"), + ("moderator_deletion", "Moderator Deletion"), + ("domain_block", "Domain Block"), + ], + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="user", + name="deactivation_reason", + field=models.CharField( + blank=True, + choices=[ + ("pending", "Pending"), + ("self_deletion", "Self Deletion"), + ("moderator_suspension", "Moderator Suspension"), + ("moderator_deletion", "Moderator Deletion"), + ("domain_block", "Domain Block"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0090_emailblocklist.py b/bookwyrm/migrations/0090_emailblocklist.py new file mode 100644 index 000000000..6934e51e9 --- /dev/null +++ b/bookwyrm/migrations/0090_emailblocklist.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.4 on 2021-09-08 22:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0089_user_show_suggested_users"), + ] + + operations = [ + migrations.CreateModel( + name="EmailBlocklist", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_date", models.DateTimeField(auto_now_add=True)), + ("domain", models.CharField(max_length=255, unique=True)), + ], + options={ + "ordering": ("-created_date",), + }, + ), + ] diff --git a/bookwyrm/migrations/0091_merge_0090_auto_20210908_2346_0090_emailblocklist.py b/bookwyrm/migrations/0091_merge_0090_auto_20210908_2346_0090_emailblocklist.py new file mode 100644 index 000000000..7aae2c7a6 --- /dev/null +++ b/bookwyrm/migrations/0091_merge_0090_auto_20210908_2346_0090_emailblocklist.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.4 on 2021-09-09 00:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0090_auto_20210908_2346"), + ("bookwyrm", "0090_emailblocklist"), + ] + + operations = [] diff --git a/bookwyrm/migrations/0092_sitesettings_instance_short_description.py b/bookwyrm/migrations/0092_sitesettings_instance_short_description.py new file mode 100644 index 000000000..4c62dd7be --- /dev/null +++ b/bookwyrm/migrations/0092_sitesettings_instance_short_description.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.4 on 2021-09-10 18:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0091_merge_0090_auto_20210908_2346_0090_emailblocklist"), + ] + + operations = [ + migrations.AddField( + model_name="sitesettings", + name="instance_short_description", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/bookwyrm/migrations/0093_alter_sitesettings_instance_short_description.py b/bookwyrm/migrations/0093_alter_sitesettings_instance_short_description.py new file mode 100644 index 000000000..165199be7 --- /dev/null +++ b/bookwyrm/migrations/0093_alter_sitesettings_instance_short_description.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.4 on 2021-09-10 19:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0092_sitesettings_instance_short_description"), + ] + + operations = [ + migrations.AlterField( + model_name="sitesettings", + name="instance_short_description", + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/bookwyrm/migrations/0094_auto_20210911_1550.py b/bookwyrm/migrations/0094_auto_20210911_1550.py new file mode 100644 index 000000000..8c3be9f89 --- /dev/null +++ b/bookwyrm/migrations/0094_auto_20210911_1550.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2.4 on 2021-09-11 15:50 + +from django.db import migrations, models +from django.db.models import F, Value, CharField + + +def set_deactivate_date(apps, schema_editor): + """best-guess for deactivation date""" + db_alias = schema_editor.connection.alias + apps.get_model("bookwyrm", "User").objects.using(db_alias).filter( + is_active=False + ).update(deactivation_date=models.F("last_active_date")) + + +def reverse_func(apps, schema_editor): + """noop""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0093_alter_sitesettings_instance_short_description"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="deactivation_date", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name="user", + name="saved_lists", + field=models.ManyToManyField( + blank=True, related_name="saved_lists", to="bookwyrm.List" + ), + ), + migrations.RunPython(set_deactivate_date, reverse_func), + ] diff --git a/bookwyrm/migrations/0094_importitem_book_guess.py b/bookwyrm/migrations/0094_importitem_book_guess.py new file mode 100644 index 000000000..be703cdde --- /dev/null +++ b/bookwyrm/migrations/0094_importitem_book_guess.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.4 on 2021-09-11 14:22 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0093_alter_sitesettings_instance_short_description"), + ] + + operations = [ + migrations.AddField( + model_name="importitem", + name="book_guess", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="book_guess", + to="bookwyrm.book", + ), + ), + ] diff --git a/bookwyrm/migrations/0095_auto_20210911_2053.py b/bookwyrm/migrations/0095_auto_20210911_2053.py new file mode 100644 index 000000000..06d15f5e6 --- /dev/null +++ b/bookwyrm/migrations/0095_auto_20210911_2053.py @@ -0,0 +1,45 @@ +# Generated by Django 3.2.4 on 2021-09-11 20:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0094_auto_20210911_1550"), + ] + + operations = [ + migrations.AlterField( + model_name="connector", + name="deactivation_reason", + field=models.CharField( + blank=True, + choices=[ + ("pending", "Pending"), + ("self_deletion", "Self deletion"), + ("moderator_suspension", "Moderator suspension"), + ("moderator_deletion", "Moderator deletion"), + ("domain_block", "Domain block"), + ], + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="user", + name="deactivation_reason", + field=models.CharField( + blank=True, + choices=[ + ("pending", "Pending"), + ("self_deletion", "Self deletion"), + ("moderator_suspension", "Moderator suspension"), + ("moderator_deletion", "Moderator deletion"), + ("domain_block", "Domain block"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0095_merge_20210911_2143.py b/bookwyrm/migrations/0095_merge_20210911_2143.py new file mode 100644 index 000000000..ea6b5a346 --- /dev/null +++ b/bookwyrm/migrations/0095_merge_20210911_2143.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.4 on 2021-09-11 21:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0094_auto_20210911_1550"), + ("bookwyrm", "0094_importitem_book_guess"), + ] + + operations = [] diff --git a/bookwyrm/migrations/0096_merge_20210912_0044.py b/bookwyrm/migrations/0096_merge_20210912_0044.py new file mode 100644 index 000000000..0d3b69a20 --- /dev/null +++ b/bookwyrm/migrations/0096_merge_20210912_0044.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.4 on 2021-09-12 00:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0095_auto_20210911_2053"), + ("bookwyrm", "0095_merge_20210911_2143"), + ] + + operations = [] diff --git a/bookwyrm/migrations/0097_auto_20210917_1858.py b/bookwyrm/migrations/0097_auto_20210917_1858.py new file mode 100644 index 000000000..28cf94e25 --- /dev/null +++ b/bookwyrm/migrations/0097_auto_20210917_1858.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.4 on 2021-09-17 18:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0096_merge_20210912_0044"), + ] + + operations = [ + migrations.CreateModel( + name="IPBlocklist", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_date", models.DateTimeField(auto_now_add=True)), + ("address", models.CharField(max_length=255, unique=True)), + ("is_active", models.BooleanField(default=True)), + ], + options={ + "ordering": ("-created_date",), + }, + ), + migrations.AddField( + model_name="emailblocklist", + name="is_active", + field=models.BooleanField(default=True), + ), + ] diff --git a/bookwyrm/migrations/0098_auto_20210918_2238.py b/bookwyrm/migrations/0098_auto_20210918_2238.py new file mode 100644 index 000000000..09fdba317 --- /dev/null +++ b/bookwyrm/migrations/0098_auto_20210918_2238.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.4 on 2021-09-18 22:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0097_auto_20210917_1858"), + ] + + operations = [ + migrations.AddField( + model_name="sitesettings", + name="invite_request_text", + field=models.TextField( + default="If your request is approved, you will receive an email with a registration link." + ), + ), + migrations.AlterField( + model_name="sitesettings", + name="registration_closed_text", + field=models.TextField( + default='We aren\'t taking new users at this time. You can find an open instance at joinbookwyrm.com/instances.' + ), + ), + ] diff --git a/bookwyrm/migrations/0099_readthrough_is_active.py b/bookwyrm/migrations/0099_readthrough_is_active.py new file mode 100644 index 000000000..e7b177bad --- /dev/null +++ b/bookwyrm/migrations/0099_readthrough_is_active.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.4 on 2021-09-22 16:53 + +from django.db import migrations, models + + +def set_active_readthrough(apps, schema_editor): + """best-guess for deactivation date""" + db_alias = schema_editor.connection.alias + apps.get_model("bookwyrm", "ReadThrough").objects.using(db_alias).filter( + start_date__isnull=False, + finish_date__isnull=True, + ).update(is_active=True) + + +def reverse_func(apps, schema_editor): + """noop""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0098_auto_20210918_2238"), + ] + + operations = [ + migrations.AddField( + model_name="readthrough", + name="is_active", + field=models.BooleanField(default=False), + ), + migrations.RunPython(set_active_readthrough, reverse_func), + migrations.AlterField( + model_name="readthrough", + name="is_active", + field=models.BooleanField(default=True), + ), + ] diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py index 6f378e83c..bffd62b45 100644 --- a/bookwyrm/models/__init__.py +++ b/bookwyrm/models/__init__.py @@ -14,7 +14,6 @@ from .status import Review, ReviewRating from .status import Boost from .attachment import Image from .favorite import Favorite -from .notification import Notification from .readthrough import ReadThrough, ProgressUpdate, ProgressMode from .user import User, KeyPair, AnnualGoal @@ -24,8 +23,12 @@ from .federated_server import FederatedServer from .import_job import ImportJob, ImportItem -from .site import SiteSettings, SiteInvite, PasswordReset, InviteRequest +from .site import SiteSettings, SiteInvite +from .site import PasswordReset, InviteRequest from .announcement import Announcement +from .antispam import EmailBlocklist, IPBlocklist + +from .notification import Notification cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass) activity_models = { diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py index 4e313723a..3a88c5249 100644 --- a/bookwyrm/models/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin.py @@ -266,7 +266,7 @@ class ObjectMixin(ActivitypubMixin): signed_message = signer.sign(SHA256.new(content.encode("utf8"))) signature = activitypub.Signature( - creator="%s#main-key" % user.remote_id, + creator=f"{user.remote_id}#main-key", created=activity_object.published, signatureValue=b64encode(signed_message).decode("utf8"), ) @@ -285,16 +285,16 @@ class ObjectMixin(ActivitypubMixin): return activitypub.Delete( id=self.remote_id + "/activity", actor=user.remote_id, - to=["%s/followers" % user.remote_id], + to=[f"{user.remote_id}/followers"], cc=["https://www.w3.org/ns/activitystreams#Public"], object=self, ).serialize() def to_update_activity(self, user): """wrapper for Updates to an activity""" - activity_id = "%s#update/%s" % (self.remote_id, uuid4()) + uuid = uuid4() return activitypub.Update( - id=activity_id, + id=f"{self.remote_id}#update/{uuid}", actor=user.remote_id, to=["https://www.w3.org/ns/activitystreams#Public"], object=self, @@ -337,8 +337,8 @@ class OrderedCollectionPageMixin(ObjectMixin): paginated = Paginator(queryset, PAGE_LENGTH) # add computed fields specific to orderd collections activity["totalItems"] = paginated.count - activity["first"] = "%s?page=1" % remote_id - activity["last"] = "%s?page=%d" % (remote_id, paginated.num_pages) + activity["first"] = f"{remote_id}?page=1" + activity["last"] = f"{remote_id}?page={paginated.num_pages}" return serializer(**activity) @@ -362,6 +362,13 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin): self.collection_queryset, **kwargs ).serialize() + def delete(self, *args, broadcast=True, **kwargs): + """Delete the object""" + activity = self.to_delete_activity(self.user) + super().delete(*args, **kwargs) + if self.user.local and broadcast: + self.broadcast(activity, self.user) + class CollectionItemMixin(ActivitypubMixin): """for items that are part of an (Ordered)Collection""" @@ -413,7 +420,7 @@ class CollectionItemMixin(ActivitypubMixin): """AP for shelving a book""" collection_field = getattr(self, self.collection_field) return activitypub.Add( - id="{:s}#add".format(collection_field.remote_id), + id=f"{collection_field.remote_id}#add", actor=user.remote_id, object=self.to_activity_dataclass(), target=collection_field.remote_id, @@ -423,7 +430,7 @@ class CollectionItemMixin(ActivitypubMixin): """AP for un-shelving a book""" collection_field = getattr(self, self.collection_field) return activitypub.Remove( - id="{:s}#remove".format(collection_field.remote_id), + id=f"{collection_field.remote_id}#remove", actor=user.remote_id, object=self.to_activity_dataclass(), target=collection_field.remote_id, @@ -451,7 +458,7 @@ class ActivityMixin(ActivitypubMixin): """undo an action""" user = self.user if hasattr(self, "user") else self.user_subject return activitypub.Undo( - id="%s#undo" % self.remote_id, + id=f"{self.remote_id}#undo", actor=user.remote_id, object=self, ).serialize() @@ -495,7 +502,7 @@ def unfurl_related_field(related_field, sort_field=None): return related_field.remote_id -@app.task +@app.task(queue="medium_priority") def broadcast_task(sender_id, activity, recipients): """the celery task for broadcast""" user_model = apps.get_model("bookwyrm.User", require_ready=True) @@ -548,11 +555,11 @@ def to_ordered_collection_page( prev_page = next_page = None if activity_page.has_next(): - next_page = "%s?page=%d" % (remote_id, activity_page.next_page_number()) + next_page = f"{remote_id}?page={activity_page.next_page_number()}" if activity_page.has_previous(): - prev_page = "%s?page=%d" % (remote_id, activity_page.previous_page_number()) + prev_page = f"{remote_id}?page=%d{activity_page.previous_page_number()}" return activitypub.OrderedCollectionPage( - id="%s?page=%s" % (remote_id, page), + id=f"{remote_id}?page={page}", partOf=remote_id, orderedItems=items, next=next_page, diff --git a/bookwyrm/models/antispam.py b/bookwyrm/models/antispam.py new file mode 100644 index 000000000..7a85bbcf0 --- /dev/null +++ b/bookwyrm/models/antispam.py @@ -0,0 +1,35 @@ +""" Lets try NOT to sell viagra """ +from django.db import models + +from .user import User + + +class EmailBlocklist(models.Model): + """blocked email addresses""" + + created_date = models.DateTimeField(auto_now_add=True) + domain = models.CharField(max_length=255, unique=True) + is_active = models.BooleanField(default=True) + + class Meta: + """default sorting""" + + ordering = ("-created_date",) + + @property + def users(self): + """find the users associated with this address""" + return User.objects.filter(email__endswith=f"@{self.domain}") + + +class IPBlocklist(models.Model): + """blocked ip addresses""" + + created_date = models.DateTimeField(auto_now_add=True) + address = models.CharField(max_length=255, unique=True) + is_active = models.BooleanField(default=True) + + class Meta: + """default sorting""" + + ordering = ("-created_date",) diff --git a/bookwyrm/models/author.py b/bookwyrm/models/author.py index 6da80b176..53cf94ff4 100644 --- a/bookwyrm/models/author.py +++ b/bookwyrm/models/author.py @@ -35,7 +35,7 @@ class Author(BookDataModel): def get_remote_id(self): """editions and works both use "book" instead of model_name""" - return "https://%s/author/%s" % (DOMAIN, self.id) + return f"https://{DOMAIN}/author/{self.id}" activity_serializer = activitypub.Author diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index 5b55ea50f..ca32521a3 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -1,22 +1,24 @@ """ base model with default fields """ import base64 from Crypto import Random + +from django.core.exceptions import PermissionDenied from django.db import models from django.dispatch import receiver +from django.http import Http404 +from django.utils.translation import gettext_lazy as _ from bookwyrm.settings import DOMAIN from .fields import RemoteIdField -DeactivationReason = models.TextChoices( - "DeactivationReason", - [ - "pending", - "self_deletion", - "moderator_deletion", - "domain_block", - ], -) +DeactivationReason = [ + ("pending", _("Pending")), + ("self_deletion", _("Self deletion")), + ("moderator_suspension", _("Moderator suspension")), + ("moderator_deletion", _("Moderator deletion")), + ("domain_block", _("Domain block")), +] def new_access_code(): @@ -33,11 +35,11 @@ class BookWyrmModel(models.Model): def get_remote_id(self): """generate a url that resolves to the local object""" - base_path = "https://%s" % DOMAIN + base_path = f"https://{DOMAIN}" if hasattr(self, "user"): - base_path = "%s%s" % (base_path, self.user.local_path) + base_path = f"{base_path}{self.user.local_path}" model_name = type(self).__name__.lower() - return "%s/%s/%d" % (base_path, model_name, self.id) + return f"{base_path}/{model_name}/{self.id}" class Meta: """this is just here to provide default fields for other models""" @@ -47,28 +49,28 @@ class BookWyrmModel(models.Model): @property def local_path(self): """how to link to this object in the local app""" - return self.get_remote_id().replace("https://%s" % DOMAIN, "") + return self.get_remote_id().replace(f"https://{DOMAIN}", "") - def visible_to_user(self, viewer): + def raise_visible_to_user(self, viewer): """is a user authorized to view an object?""" # make sure this is an object with privacy owned by a user if not hasattr(self, "user") or not hasattr(self, "privacy"): - return None + return # viewer can't see it if the object's owner blocked them if viewer in self.user.blocks.all(): - return False + raise Http404() # you can see your own posts and any public or unlisted posts if viewer == self.user or self.privacy in ["public", "unlisted"]: - return True + return # you can see the followers only posts of people you follow if ( self.privacy == "followers" and self.user.followers.filter(id=viewer.id).first() ): - return True + return # you can see dms you are tagged in if hasattr(self, "mention_users"): @@ -76,8 +78,32 @@ class BookWyrmModel(models.Model): self.privacy == "direct" and self.mention_users.filter(id=viewer.id).first() ): - return True - return False + return + raise Http404() + + def raise_not_editable(self, viewer): + """does this user have permission to edit this object? liable to be overwritten + by models that inherit this base model class""" + if not hasattr(self, "user"): + return + + # generally moderators shouldn't be able to edit other people's stuff + if self.user == viewer: + return + + raise PermissionDenied() + + def raise_not_deletable(self, viewer): + """does this user have permission to delete this object? liable to be + overwritten by models that inherit this base model class""" + if not hasattr(self, "user"): + return + + # but generally moderators can delete other people's stuff + if self.user == viewer or viewer.has_perm("moderate_post"): + return + + raise PermissionDenied() @receiver(models.signals.post_save) diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index 8bed69249..6f26ef7d1 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -3,7 +3,8 @@ import re from django.contrib.postgres.search import SearchVectorField from django.contrib.postgres.indexes import GinIndex -from django.db import models +from django.db import models, transaction +from django.db.models import Prefetch from django.dispatch import receiver from model_utils import FieldTracker from model_utils.managers import InheritanceManager @@ -163,9 +164,9 @@ class Book(BookDataModel): @property def alt_text(self): """image alt test""" - text = "%s" % self.title + text = self.title if self.edition_info: - text += " (%s)" % self.edition_info + text += f" ({self.edition_info})" return text def save(self, *args, **kwargs): @@ -176,9 +177,10 @@ class Book(BookDataModel): def get_remote_id(self): """editions and works both use "book" instead of model_name""" - return "https://%s/book/%d" % (DOMAIN, self.id) + return f"https://{DOMAIN}/book/{self.id}" def __repr__(self): + # pylint: disable=consider-using-f-string return "<{} key={!r} title={!r}>".format( self.__class__, self.openlibrary_key, @@ -215,7 +217,7 @@ class Work(OrderedCollectionPageMixin, Book): """an ordered collection of editions""" return self.to_ordered_collection( self.editions.order_by("-edition_rank").all(), - remote_id="%s/editions" % self.remote_id, + remote_id=f"{self.remote_id}/editions", **kwargs, ) @@ -305,6 +307,27 @@ class Edition(Book): return super().save(*args, **kwargs) + @classmethod + def viewer_aware_objects(cls, viewer): + """annotate a book query with metadata related to the user""" + queryset = cls.objects + if not viewer or not viewer.is_authenticated: + return queryset + + queryset = queryset.prefetch_related( + Prefetch( + "shelfbook_set", + queryset=viewer.shelfbook_set.all(), + to_attr="current_shelves", + ), + Prefetch( + "readthrough_set", + queryset=viewer.readthrough_set.filter(is_active=True).all(), + to_attr="active_readthroughs", + ), + ) + return queryset + def isbn_10_to_13(isbn_10): """convert an isbn 10 into an isbn 13""" @@ -361,4 +384,6 @@ def preview_image(instance, *args, **kwargs): changed_fields = instance.field_tracker.changed() if len(changed_fields) > 0: - generate_edition_preview_image_task.delay(instance.id) + transaction.on_commit( + lambda: generate_edition_preview_image_task.delay(instance.id) + ) diff --git a/bookwyrm/models/connector.py b/bookwyrm/models/connector.py index 2d6717908..9d2c6aeb3 100644 --- a/bookwyrm/models/connector.py +++ b/bookwyrm/models/connector.py @@ -19,7 +19,7 @@ class Connector(BookWyrmModel): api_key = models.CharField(max_length=255, null=True, blank=True) active = models.BooleanField(default=True) deactivation_reason = models.CharField( - max_length=255, choices=DeactivationReason.choices, null=True, blank=True + max_length=255, choices=DeactivationReason, null=True, blank=True ) base_url = models.CharField(max_length=255) @@ -29,7 +29,4 @@ class Connector(BookWyrmModel): isbn_search_url = models.CharField(max_length=255, null=True, blank=True) def __str__(self): - return "{} ({})".format( - self.identifier, - self.id, - ) + return f"{self.identifier} ({self.id})" diff --git a/bookwyrm/models/favorite.py b/bookwyrm/models/favorite.py index 9ab300b3c..4c3675219 100644 --- a/bookwyrm/models/favorite.py +++ b/bookwyrm/models/favorite.py @@ -1,7 +1,5 @@ """ like/fav/star a status """ -from django.apps import apps from django.db import models -from django.utils import timezone from bookwyrm import activitypub from .activitypub_mixin import ActivityMixin @@ -29,38 +27,9 @@ class Favorite(ActivityMixin, BookWyrmModel): def save(self, *args, **kwargs): """update user active time""" - self.user.last_active_date = timezone.now() - self.user.save(broadcast=False, update_fields=["last_active_date"]) + self.user.update_active_date() super().save(*args, **kwargs) - if self.status.user.local and self.status.user != self.user: - notification_model = apps.get_model( - "bookwyrm.Notification", require_ready=True - ) - notification_model.objects.create( - user=self.status.user, - notification_type="FAVORITE", - related_user=self.user, - related_status=self.status, - ) - - def delete(self, *args, **kwargs): - """delete and delete notifications""" - # check for notification - if self.status.user.local: - notification_model = apps.get_model( - "bookwyrm.Notification", require_ready=True - ) - notification = notification_model.objects.filter( - user=self.status.user, - related_user=self.user, - related_status=self.status, - notification_type="FAVORITE", - ).first() - if notification: - notification.delete() - super().delete(*args, **kwargs) - class Meta: """can't fav things twice""" diff --git a/bookwyrm/models/federated_server.py b/bookwyrm/models/federated_server.py index e297c46c4..eb03d457e 100644 --- a/bookwyrm/models/federated_server.py +++ b/bookwyrm/models/federated_server.py @@ -1,16 +1,16 @@ """ connections to external ActivityPub servers """ from urllib.parse import urlparse + from django.apps import apps from django.db import models +from django.utils.translation import gettext_lazy as _ + from .base_model import BookWyrmModel -FederationStatus = models.TextChoices( - "Status", - [ - "federated", - "blocked", - ], -) +FederationStatus = [ + ("federated", _("Federated")), + ("blocked", _("Blocked")), +] class FederatedServer(BookWyrmModel): @@ -18,7 +18,7 @@ class FederatedServer(BookWyrmModel): server_name = models.CharField(max_length=255, unique=True) status = models.CharField( - max_length=255, default="federated", choices=FederationStatus.choices + max_length=255, default="federated", choices=FederationStatus ) # is it mastodon, bookwyrm, etc application_type = models.CharField(max_length=255, null=True, blank=True) @@ -28,7 +28,7 @@ class FederatedServer(BookWyrmModel): def block(self): """block a server""" self.status = "blocked" - self.save() + self.save(update_fields=["status"]) # deactivate all associated users self.user_set.filter(is_active=True).update( @@ -45,7 +45,7 @@ class FederatedServer(BookWyrmModel): def unblock(self): """unblock a server""" self.status = "federated" - self.save() + self.save(update_fields=["status"]) self.user_set.filter(deactivation_reason="domain_block").update( is_active=True, deactivation_reason=None diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index 6ed5aa5e6..ccd669cbf 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -56,7 +56,7 @@ class ActivitypubFieldMixin: activitypub_field=None, activitypub_wrapper=None, deduplication_field=False, - **kwargs + **kwargs, ): self.deduplication_field = deduplication_field if activitypub_wrapper: @@ -224,8 +224,20 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField): original = getattr(instance, self.name) to = data.to cc = data.cc + + # we need to figure out who this is to get their followers link + for field in ["attributedTo", "owner", "actor"]: + if hasattr(data, field): + user_field = field + break + if not user_field: + raise ValidationError("No user field found for privacy", data) + user = activitypub.resolve_remote_id(getattr(data, user_field), model="User") + if to == [self.public]: setattr(instance, self.name, "public") + elif to == [user.followers_url]: + setattr(instance, self.name, "followers") elif cc == []: setattr(instance, self.name, "direct") elif self.public in cc: @@ -241,9 +253,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField): mentions = [u.remote_id for u in instance.mention_users.all()] # this is a link to the followers list # pylint: disable=protected-access - followers = instance.user.__class__._meta.get_field( - "followers" - ).field_to_activity(instance.user.followers) + followers = instance.user.followers_url if instance.privacy == "public": activity["to"] = [self.public] activity["cc"] = [followers] + mentions @@ -298,7 +308,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField): def field_to_activity(self, value): if self.link_only: - return "%s/%s" % (value.instance.remote_id, self.name) + return f"{value.instance.remote_id}/{self.name}" return [i.remote_id for i in value.all()] def field_from_activity(self, value): @@ -378,7 +388,7 @@ def image_serializer(value, alt): else: return None if not url[:4] == "http": - url = "https://{:s}{:s}".format(DOMAIN, url) + url = f"https://{DOMAIN}{url}" return activitypub.Document(url=url, name=alt) @@ -438,7 +448,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField): image_content = ContentFile(response.content) extension = imghdr.what(None, image_content.read()) or "" - image_name = "{:s}.{:s}".format(str(uuid4()), extension) + image_name = f"{uuid4()}.{extension}" return [image_name, image_content] def formfield(self, **kwargs): diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py index 05aada161..22253fef7 100644 --- a/bookwyrm/models/import_job.py +++ b/bookwyrm/models/import_job.py @@ -2,7 +2,6 @@ import re import dateutil.parser -from django.apps import apps from django.db import models from django.utils import timezone @@ -50,19 +49,6 @@ class ImportJob(models.Model): ) retry = models.BooleanField(default=False) - def save(self, *args, **kwargs): - """save and notify""" - super().save(*args, **kwargs) - if self.complete: - notification_model = apps.get_model( - "bookwyrm.Notification", require_ready=True - ) - notification_model.objects.create( - user=self.user, - notification_type="IMPORT", - related_import=self, - ) - class ImportItem(models.Model): """a single line of a csv being imported""" @@ -71,6 +57,13 @@ class ImportItem(models.Model): index = models.IntegerField() data = models.JSONField() book = models.ForeignKey(Book, on_delete=models.SET_NULL, null=True, blank=True) + book_guess = models.ForeignKey( + Book, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="book_guess", + ) fail_reason = models.TextField(null=True) def resolve(self): @@ -78,9 +71,13 @@ class ImportItem(models.Model): if self.isbn: self.book = self.get_book_from_isbn() else: - # don't fall back on title/author search is isbn is present. + # don't fall back on title/author search if isbn is present. # you're too likely to mismatch - self.book = self.get_book_from_title_author() + book, confidence = self.get_book_from_title_author() + if confidence > 0.999: + self.book = book + else: + self.book_guess = book def get_book_from_isbn(self): """search by isbn""" @@ -96,12 +93,15 @@ class ImportItem(models.Model): """search by title and author""" search_term = construct_search_term(self.title, self.author) search_result = connector_manager.first_search_result( - search_term, min_confidence=0.999 + search_term, min_confidence=0.1 ) if search_result: # raises ConnectorException - return search_result.connector.get_or_create_book(search_result.key) - return None + return ( + search_result.connector.get_or_create_book(search_result.key), + search_result.confidence, + ) + return None, 0 @property def title(self): @@ -174,6 +174,7 @@ class ImportItem(models.Model): if start_date and start_date is not None and not self.date_read: return [ReadThrough(start_date=start_date)] if self.date_read: + start_date = start_date if start_date < self.date_read else None return [ ReadThrough( start_date=start_date, @@ -183,7 +184,9 @@ class ImportItem(models.Model): return [] def __repr__(self): + # pylint: disable=consider-using-f-string return "<{!r}Item {!r}>".format(self.data["import_source"], self.data["Title"]) def __str__(self): + # pylint: disable=consider-using-f-string return "{} by {}".format(self.data["Title"], self.data["Author"]) diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py index bbad5ba9b..022a0d981 100644 --- a/bookwyrm/models/list.py +++ b/bookwyrm/models/list.py @@ -42,7 +42,7 @@ class List(OrderedCollectionMixin, BookWyrmModel): def get_remote_id(self): """don't want the user to be in there in this case""" - return "https://%s/list/%d" % (DOMAIN, self.id) + return f"https://{DOMAIN}/list/{self.id}" @property def collection_queryset(self): @@ -92,6 +92,12 @@ class ListItem(CollectionItemMixin, BookWyrmModel): notification_type="ADD", ) + def raise_not_deletable(self, viewer): + """the associated user OR the list owner can delete""" + if self.book_list.user == viewer: + return + super().raise_not_deletable(viewer) + class Meta: """A book may only be placed into a list once, and each order in the list may be used only once""" diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py index ff0b4e5a6..a4968f61f 100644 --- a/bookwyrm/models/notification.py +++ b/bookwyrm/models/notification.py @@ -1,6 +1,8 @@ """ alert a user to activity """ from django.db import models +from django.dispatch import receiver from .base_model import BookWyrmModel +from . import Boost, Favorite, ImportJob, Report, Status, User NotificationType = models.TextChoices( @@ -53,3 +55,127 @@ class Notification(BookWyrmModel): name="notification_type_valid", ) ] + + +@receiver(models.signals.post_save, sender=Favorite) +# pylint: disable=unused-argument +def notify_on_fav(sender, instance, *args, **kwargs): + """someone liked your content, you ARE loved""" + if not instance.status.user.local or instance.status.user == instance.user: + return + Notification.objects.create( + user=instance.status.user, + notification_type="FAVORITE", + related_user=instance.user, + related_status=instance.status, + ) + + +@receiver(models.signals.post_delete, sender=Favorite) +# pylint: disable=unused-argument +def notify_on_unfav(sender, instance, *args, **kwargs): + """oops, didn't like that after all""" + if not instance.status.user.local: + return + Notification.objects.filter( + user=instance.status.user, + related_user=instance.user, + related_status=instance.status, + notification_type="FAVORITE", + ).delete() + + +@receiver(models.signals.post_save) +# pylint: disable=unused-argument +def notify_user_on_mention(sender, instance, *args, **kwargs): + """creating and deleting statuses with @ mentions and replies""" + if not issubclass(sender, Status): + return + + if instance.deleted: + Notification.objects.filter(related_status=instance).delete() + return + + if ( + instance.reply_parent + and instance.reply_parent.user != instance.user + and instance.reply_parent.user.local + ): + Notification.objects.create( + user=instance.reply_parent.user, + notification_type="REPLY", + related_user=instance.user, + related_status=instance, + ) + for mention_user in instance.mention_users.all(): + # avoid double-notifying about this status + if not mention_user.local or ( + instance.reply_parent and mention_user == instance.reply_parent.user + ): + continue + Notification.objects.create( + user=mention_user, + notification_type="MENTION", + related_user=instance.user, + related_status=instance, + ) + + +@receiver(models.signals.post_save, sender=Boost) +# pylint: disable=unused-argument +def notify_user_on_boost(sender, instance, *args, **kwargs): + """boosting a status""" + if ( + not instance.boosted_status.user.local + or instance.boosted_status.user == instance.user + ): + return + + Notification.objects.create( + user=instance.boosted_status.user, + related_status=instance.boosted_status, + related_user=instance.user, + notification_type="BOOST", + ) + + +@receiver(models.signals.post_delete, sender=Boost) +# pylint: disable=unused-argument +def notify_user_on_unboost(sender, instance, *args, **kwargs): + """unboosting a status""" + Notification.objects.filter( + user=instance.boosted_status.user, + related_status=instance.boosted_status, + related_user=instance.user, + notification_type="BOOST", + ).delete() + + +@receiver(models.signals.post_save, sender=ImportJob) +# pylint: disable=unused-argument +def notify_user_on_import_complete(sender, instance, *args, **kwargs): + """we imported your books! aren't you proud of us""" + if not instance.complete: + return + Notification.objects.create( + user=instance.user, + notification_type="IMPORT", + related_import=instance, + ) + + +@receiver(models.signals.post_save, sender=Report) +# pylint: disable=unused-argument +def notify_admins_on_report(sender, instance, *args, **kwargs): + """something is up, make sure the admins know""" + # moderators and superusers should be notified + admins = User.objects.filter( + models.Q(user_permissions__name__in=["moderate_user", "moderate_post"]) + | models.Q(is_superuser=True) + ).all() + for admin in admins: + Notification.objects.create( + user=admin, + related_report=instance, + notification_type="REPORT", + ) diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py index df341c8b2..f75918ac1 100644 --- a/bookwyrm/models/readthrough.py +++ b/bookwyrm/models/readthrough.py @@ -1,7 +1,7 @@ """ progress in a book """ -from django.db import models -from django.utils import timezone from django.core import validators +from django.db import models +from django.db.models import F, Q from .base_model import BookWyrmModel @@ -26,11 +26,14 @@ class ReadThrough(BookWyrmModel): ) start_date = models.DateTimeField(blank=True, null=True) finish_date = models.DateTimeField(blank=True, null=True) + is_active = models.BooleanField(default=True) def save(self, *args, **kwargs): """update user active time""" - self.user.last_active_date = timezone.now() - self.user.save(broadcast=False, update_fields=["last_active_date"]) + self.user.update_active_date() + # an active readthrough must have an unset finish date + if self.finish_date: + self.is_active = False super().save(*args, **kwargs) def create_update(self): @@ -41,6 +44,16 @@ class ReadThrough(BookWyrmModel): ) return None + class Meta: + """Don't let readthroughs end before they start""" + + constraints = [ + models.CheckConstraint( + check=Q(finish_date__gte=F("start_date")), name="chronology" + ) + ] + ordering = ("-start_date",) + class ProgressUpdate(BookWyrmModel): """Store progress through a book in the database.""" @@ -54,6 +67,5 @@ class ProgressUpdate(BookWyrmModel): def save(self, *args, **kwargs): """update user active time""" - self.user.last_active_date = timezone.now() - self.user.save(broadcast=False, update_fields=["last_active_date"]) + self.user.update_active_date() super().save(*args, **kwargs) diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py index edb89d13c..fc7a9df83 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -53,7 +53,7 @@ class UserRelationship(BookWyrmModel): def get_remote_id(self): """use shelf identifier in remote_id""" base_path = self.user_subject.remote_id - return "%s#follows/%d" % (base_path, self.id) + return f"{base_path}#follows/{self.id}" class UserFollows(ActivityMixin, UserRelationship): @@ -144,7 +144,8 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship): """get id for sending an accept or reject of a local user""" base_path = self.user_object.remote_id - return "%s#%s/%d" % (base_path, status, self.id or 0) + status_id = self.id or 0 + return f"{base_path}#{status}/{status_id}" def accept(self, broadcast_only=False): """turn this request into the real deal""" diff --git a/bookwyrm/models/report.py b/bookwyrm/models/report.py index 7ff4c9091..636817cb2 100644 --- a/bookwyrm/models/report.py +++ b/bookwyrm/models/report.py @@ -1,5 +1,4 @@ """ flagged for moderation """ -from django.apps import apps from django.db import models from django.db.models import F, Q from .base_model import BookWyrmModel @@ -16,23 +15,6 @@ class Report(BookWyrmModel): statuses = models.ManyToManyField("Status", blank=True) resolved = models.BooleanField(default=False) - def save(self, *args, **kwargs): - """notify admins when a report is created""" - super().save(*args, **kwargs) - user_model = apps.get_model("bookwyrm.User", require_ready=True) - # moderators and superusers should be notified - admins = user_model.objects.filter( - Q(user_permissions__name__in=["moderate_user", "moderate_post"]) - | Q(is_superuser=True) - ).all() - notification_model = apps.get_model("bookwyrm.Notification", require_ready=True) - for admin in admins: - notification_model.objects.create( - user=admin, - related_report=self, - notification_type="REPORT", - ) - class Meta: """don't let users report themselves""" diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py index c4e907d27..89ea9471d 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -1,5 +1,6 @@ """ puttin' books on shelves """ import re +from django.core.exceptions import PermissionDenied from django.db import models from django.utils import timezone @@ -44,7 +45,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel): def get_identifier(self): """custom-shelf-123 for the url""" slug = re.sub(r"[^\w]", "", self.name).lower() - return "{:s}-{:d}".format(slug, self.id) + return f"{slug}-{self.id}" @property def collection_queryset(self): @@ -55,7 +56,13 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel): """shelf identifier instead of id""" base_path = self.user.remote_id identifier = self.identifier or self.get_identifier() - return "%s/books/%s" % (base_path, identifier) + return f"{base_path}/books/{identifier}" + + def raise_not_deletable(self, viewer): + """don't let anyone delete a default shelf""" + super().raise_not_deletable(viewer) + if not self.editable: + raise PermissionDenied() class Meta: """user/shelf unqiueness""" diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py index ef3f7c3ca..8338fff88 100644 --- a/bookwyrm/models/site.py +++ b/bookwyrm/models/site.py @@ -20,10 +20,17 @@ class SiteSettings(models.Model): max_length=150, default="Social Reading and Reviewing" ) instance_description = models.TextField(default="This instance has no description.") + instance_short_description = models.CharField(max_length=255, blank=True, null=True) # about page registration_closed_text = models.TextField( - default="Contact an administrator to get an invite" + default="We aren't taking new users at this time. You can find an open " + 'instance at ' + "joinbookwyrm.com/instances." + ) + invite_request_text = models.TextField( + default="If your request is approved, you will receive an email with a " + "registration link." ) code_of_conduct = models.TextField(default="Add a code of conduct here.") privacy_policy = models.TextField(default="Add a privacy policy here.") @@ -80,7 +87,7 @@ class SiteInvite(models.Model): @property def link(self): """formats the invite link""" - return "https://{}/invite/{}".format(DOMAIN, self.code) + return f"https://{DOMAIN}/invite/{self.code}" class InviteRequest(BookWyrmModel): @@ -120,7 +127,7 @@ class PasswordReset(models.Model): @property def link(self): """formats the invite link""" - return "https://{}/password-reset/{}".format(DOMAIN, self.code) + return f"https://{DOMAIN}/password-reset/{self.code}" # pylint: disable=unused-argument diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 9274a5813..b62036788 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -3,6 +3,7 @@ from dataclasses import MISSING import re from django.apps import apps +from django.core.exceptions import PermissionDenied from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.dispatch import receiver @@ -67,40 +68,6 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): ordering = ("-published_date",) - def save(self, *args, **kwargs): - """save and notify""" - super().save(*args, **kwargs) - - notification_model = apps.get_model("bookwyrm.Notification", require_ready=True) - - if self.deleted: - notification_model.objects.filter(related_status=self).delete() - return - - if ( - self.reply_parent - and self.reply_parent.user != self.user - and self.reply_parent.user.local - ): - notification_model.objects.create( - user=self.reply_parent.user, - notification_type="REPLY", - related_user=self.user, - related_status=self, - ) - for mention_user in self.mention_users.all(): - # avoid double-notifying about this status - if not mention_user.local or ( - self.reply_parent and mention_user == self.reply_parent.user - ): - continue - notification_model.objects.create( - user=mention_user, - notification_type="MENTION", - related_user=self.user, - related_status=self, - ) - def delete(self, *args, **kwargs): # pylint: disable=unused-argument """ "delete" a status""" if hasattr(self, "boosted_status"): @@ -108,6 +75,10 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): super().delete(*args, **kwargs) return self.deleted = True + # clear user content + self.content = None + if hasattr(self, "quotation"): + self.quotation = None # pylint: disable=attribute-defined-outside-init self.deleted_date = timezone.now() self.save() @@ -179,9 +150,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): """helper function for loading AP serialized replies to a status""" return self.to_ordered_collection( self.replies(self), - remote_id="%s/replies" % self.remote_id, + remote_id=f"{self.remote_id}/replies", collection_only=True, - **kwargs + **kwargs, ).serialize() def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ @@ -217,6 +188,13 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): """json serialized activitypub class""" return self.to_activity_dataclass(pure=pure).serialize() + def raise_not_editable(self, viewer): + """certain types of status aren't editable""" + # first, the standard raise + super().raise_not_editable(viewer) + if isinstance(self, (GeneratedNote, ReviewRating)): + raise PermissionDenied() + class GeneratedNote(Status): """these are app-generated messages about user activity""" @@ -226,10 +204,10 @@ class GeneratedNote(Status): """indicate the book in question for mastodon (or w/e) users""" message = self.content books = ", ".join( - '"%s"' % (book.remote_id, book.title) + f'"{book.title}"' for book in self.mention_books.all() ) - return "%s %s %s" % (self.user.display_name, message, books) + return f"{self.user.display_name} {message} {books}" activity_serializer = activitypub.GeneratedNote pure_type = "Note" @@ -277,10 +255,9 @@ class Comment(BookStatus): @property def pure_content(self): """indicate the book in question for mastodon (or w/e) users""" - return '%s

(comment on "%s")

' % ( - self.content, - self.book.remote_id, - self.book.title, + return ( + f'{self.content}

(comment on ' + f'"{self.book.title}")

' ) activity_serializer = activitypub.Comment @@ -290,17 +267,25 @@ class Quotation(BookStatus): """like a review but without a rating and transient""" quote = fields.HtmlField() + position = models.IntegerField( + validators=[MinValueValidator(0)], null=True, blank=True + ) + position_mode = models.CharField( + max_length=3, + choices=ProgressMode.choices, + default=ProgressMode.PAGE, + null=True, + blank=True, + ) @property def pure_content(self): """indicate the book in question for mastodon (or w/e) users""" quote = re.sub(r"^

", '

"', self.quote) quote = re.sub(r"

$", '"

', quote) - return '%s

-- "%s"

%s' % ( - quote, - self.book.remote_id, - self.book.title, - self.content, + return ( + f'{quote}

-- ' + f'"{self.book.title}"

{self.content}' ) activity_serializer = activitypub.Quotation @@ -379,27 +364,6 @@ class Boost(ActivityMixin, Status): return super().save(*args, **kwargs) - if not self.boosted_status.user.local or self.boosted_status.user == self.user: - return - - notification_model = apps.get_model("bookwyrm.Notification", require_ready=True) - notification_model.objects.create( - user=self.boosted_status.user, - related_status=self.boosted_status, - related_user=self.user, - notification_type="BOOST", - ) - - def delete(self, *args, **kwargs): - """delete and un-notify""" - notification_model = apps.get_model("bookwyrm.Notification", require_ready=True) - notification_model.objects.filter( - user=self.boosted_status.user, - related_status=self.boosted_status, - related_user=self.user, - notification_type="BOOST", - ).delete() - super().delete(*args, **kwargs) def __init__(self, *args, **kwargs): """the user field is "actor" here instead of "attributedTo" """ @@ -412,10 +376,6 @@ class Boost(ActivityMixin, Status): self.image_fields = [] self.deserialize_reverse_fields = [] - # This constraint can't work as it would cross tables. - # class Meta: - # unique_together = ('user', 'boosted_status') - # pylint: disable=unused-argument @receiver(models.signals.post_save) diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 0ef23d3f0..637baa6ee 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -82,9 +82,9 @@ class User(OrderedCollectionPageMixin, AbstractUser): preview_image = models.ImageField( upload_to="previews/avatars/", blank=True, null=True ) - followers = fields.ManyToManyField( + followers_url = fields.CharField(max_length=255, activitypub_field="followers") + followers = models.ManyToManyField( "self", - link_only=True, symmetrical=False, through="UserFollows", through_fields=("user_object", "user_subject"), @@ -105,7 +105,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): related_name="blocked_by", ) saved_lists = models.ManyToManyField( - "List", symmetrical=False, related_name="saved_lists" + "List", symmetrical=False, related_name="saved_lists", blank=True ) favorites = models.ManyToManyField( "Status", @@ -122,16 +122,21 @@ class User(OrderedCollectionPageMixin, AbstractUser): updated_date = models.DateTimeField(auto_now=True) last_active_date = models.DateTimeField(default=timezone.now) manually_approves_followers = fields.BooleanField(default=False) + + # options to turn features on and off show_goal = models.BooleanField(default=True) + show_suggested_users = models.BooleanField(default=True) discoverable = fields.BooleanField(default=False) + preferred_timezone = models.CharField( choices=[(str(tz), str(tz)) for tz in pytz.all_timezones], default=str(pytz.utc), max_length=255, ) deactivation_reason = models.CharField( - max_length=255, choices=DeactivationReason.choices, null=True, blank=True + max_length=255, choices=DeactivationReason, null=True, blank=True ) + deactivation_date = models.DateTimeField(null=True, blank=True) confirmation_code = models.CharField(max_length=32, default=new_access_code) name_field = "username" @@ -147,12 +152,13 @@ class User(OrderedCollectionPageMixin, AbstractUser): @property def following_link(self): """just how to find out the following info""" - return "{:s}/following".format(self.remote_id) + return f"{self.remote_id}/following" @property def alt_text(self): """alt text with username""" - return "avatar for %s" % (self.localname or self.username) + # pylint: disable=consider-using-f-string + return "avatar for {:s}".format(self.localname or self.username) @property def display_name(self): @@ -189,12 +195,15 @@ class User(OrderedCollectionPageMixin, AbstractUser): queryset = queryset.exclude(blocks=viewer) return queryset + def update_active_date(self): + """this user is here! they are doing things!""" + self.last_active_date = timezone.now() + self.save(broadcast=False, update_fields=["last_active_date"]) + def to_outbox(self, filter_type=None, **kwargs): """an ordered collection of statuses""" if filter_type: - filter_class = apps.get_model( - "bookwyrm.%s" % filter_type, require_ready=True - ) + filter_class = apps.get_model(f"bookwyrm.{filter_type}", require_ready=True) if not issubclass(filter_class, Status): raise TypeError( "filter_status_class must be a subclass of models.Status" @@ -218,7 +227,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): def to_following_activity(self, **kwargs): """activitypub following list""" - remote_id = "%s/following" % self.remote_id + remote_id = f"{self.remote_id}/following" return self.to_ordered_collection( self.following.order_by("-updated_date").all(), remote_id=remote_id, @@ -228,7 +237,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): def to_followers_activity(self, **kwargs): """activitypub followers list""" - remote_id = "%s/followers" % self.remote_id + remote_id = self.followers_url return self.to_ordered_collection( self.followers.order_by("-updated_date").all(), remote_id=remote_id, @@ -261,10 +270,15 @@ class User(OrderedCollectionPageMixin, AbstractUser): if not self.local and not re.match(regex.FULL_USERNAME, self.username): # generate a username that uses the domain (webfinger format) actor_parts = urlparse(self.remote_id) - self.username = "%s@%s" % (self.username, actor_parts.netloc) + self.username = f"{self.username}@{actor_parts.netloc}" # this user already exists, no need to populate fields if not created: + if self.is_active: + self.deactivation_date = None + elif not self.deactivation_date: + self.deactivation_date = timezone.now() + super().save(*args, **kwargs) return @@ -274,28 +288,47 @@ class User(OrderedCollectionPageMixin, AbstractUser): transaction.on_commit(lambda: set_remote_server.delay(self.id)) return - # populate fields for local users - self.remote_id = "%s/user/%s" % (site_link(), self.localname) - self.inbox = "%s/inbox" % self.remote_id - self.shared_inbox = "%s/inbox" % site_link() - self.outbox = "%s/outbox" % self.remote_id + with transaction.atomic(): + # populate fields for local users + link = site_link() + self.remote_id = f"{link}/user/{self.localname}" + self.followers_url = f"{self.remote_id}/followers" + self.inbox = f"{self.remote_id}/inbox" + self.shared_inbox = f"{link}/inbox" + self.outbox = f"{self.remote_id}/outbox" - # an id needs to be set before we can proceed with related models + # an id needs to be set before we can proceed with related models + super().save(*args, **kwargs) + + # make users editors by default + try: + self.groups.add(Group.objects.get(name="editor")) + except Group.DoesNotExist: + # this should only happen in tests + pass + + # create keys and shelves for new local users + self.key_pair = KeyPair.objects.create( + remote_id=f"{self.remote_id}/#main-key" + ) + self.save(broadcast=False, update_fields=["key_pair"]) + + self.create_shelves() + + def delete(self, *args, **kwargs): + """deactivate rather than delete a user""" + self.is_active = False + # skip the logic in this class's save() super().save(*args, **kwargs) - # make users editors by default - try: - self.groups.add(Group.objects.get(name="editor")) - except Group.DoesNotExist: - # this should only happen in tests - pass - - # create keys and shelves for new local users - self.key_pair = KeyPair.objects.create( - remote_id="%s/#main-key" % self.remote_id - ) - self.save(broadcast=False, update_fields=["key_pair"]) + @property + def local_path(self): + """this model doesn't inherit bookwyrm model, so here we are""" + # pylint: disable=consider-using-f-string + return "/user/{:s}".format(self.localname or self.username) + def create_shelves(self): + """default shelves for a new user""" shelves = [ { "name": "To Read", @@ -319,17 +352,6 @@ class User(OrderedCollectionPageMixin, AbstractUser): editable=False, ).save(broadcast=False) - def delete(self, *args, **kwargs): - """deactivate rather than delete a user""" - self.is_active = False - # skip the logic in this class's save() - super().save(*args, **kwargs) - - @property - def local_path(self): - """this model doesn't inherit bookwyrm model, so here we are""" - return "/user/%s" % (self.localname or self.username) - class KeyPair(ActivitypubMixin, BookWyrmModel): """public and private keys for a user""" @@ -344,7 +366,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel): def get_remote_id(self): # self.owner is set by the OneToOneField on User - return "%s/#main-key" % self.owner.remote_id + return f"{self.owner.remote_id}/#main-key" def save(self, *args, **kwargs): """create a key pair""" @@ -381,7 +403,7 @@ class AnnualGoal(BookWyrmModel): def get_remote_id(self): """put the year in the path""" - return "{:s}/goal/{:d}".format(self.user.remote_id, self.year) + return f"{self.user.remote_id}/goal/{self.year}" @property def books(self): @@ -418,7 +440,7 @@ class AnnualGoal(BookWyrmModel): } -@app.task +@app.task(queue="low_priority") def set_remote_server(user_id): """figure out the user's remote server in the background""" user = User.objects.get(id=user_id) @@ -437,7 +459,7 @@ def get_or_create_remote_server(domain): pass try: - data = get_data("https://%s/.well-known/nodeinfo" % domain) + data = get_data(f"https://{domain}/.well-known/nodeinfo") try: nodeinfo_url = data.get("links")[0].get("href") except (TypeError, KeyError): @@ -457,7 +479,7 @@ def get_or_create_remote_server(domain): return server -@app.task +@app.task(queue="low_priority") def get_remote_reviews(outbox): """ingest reviews by a new remote bookwyrm user""" outbox_page = outbox + "?page=true&type=Review" diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index 4f85bb56e..8224a2787 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -220,6 +220,7 @@ def generate_default_inner_img(): # pylint: disable=too-many-locals +# pylint: disable=too-many-statements def generate_preview_image( texts=None, picture=None, rating=None, show_instance_layer=True ): @@ -237,7 +238,8 @@ def generate_preview_image( # Color if BG_COLOR in ["use_dominant_color_light", "use_dominant_color_dark"]: - image_bg_color = "rgb(%s, %s, %s)" % dominant_color + red, green, blue = dominant_color + image_bg_color = f"rgb({red}, {green}, {blue})" # Adjust color image_bg_color_rgb = [x / 255.0 for x in ImageColor.getrgb(image_bg_color)] @@ -315,7 +317,8 @@ def save_and_cleanup(image, instance=None): """Save and close the file""" if not isinstance(instance, (models.Book, models.User, models.SiteSettings)): return False - file_name = "%s-%s.jpg" % (str(instance.id), str(uuid4())) + uuid = uuid4() + file_name = f"{instance.id}-{uuid}.jpg" image_buffer = BytesIO() try: @@ -352,7 +355,7 @@ def save_and_cleanup(image, instance=None): # pylint: disable=invalid-name -@app.task +@app.task(queue="low_priority") def generate_site_preview_image_task(): """generate preview_image for the website""" if not settings.ENABLE_PREVIEW_IMAGES: @@ -377,7 +380,7 @@ def generate_site_preview_image_task(): # pylint: disable=invalid-name -@app.task +@app.task(queue="low_priority") def generate_edition_preview_image_task(book_id): """generate preview_image for a book""" if not settings.ENABLE_PREVIEW_IMAGES: @@ -402,7 +405,7 @@ def generate_edition_preview_image_task(book_id): save_and_cleanup(image, instance=book) -@app.task +@app.task(queue="low_priority") def generate_user_preview_image_task(user_id): """generate preview_image for a book""" if not settings.ENABLE_PREVIEW_IMAGES: @@ -412,7 +415,7 @@ def generate_user_preview_image_task(user_id): texts = { "text_one": user.display_name, - "text_three": "@{}@{}".format(user.localname, settings.DOMAIN), + "text_three": f"@{user.localname}@{settings.DOMAIN}", } if user.avatar: diff --git a/bookwyrm/sanitize_html.py b/bookwyrm/sanitize_html.py index 0be64c58c..8b0e3c4cb 100644 --- a/bookwyrm/sanitize_html.py +++ b/bookwyrm/sanitize_html.py @@ -48,7 +48,7 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method return self.tag_stack = self.tag_stack[:-1] - self.output.append(("tag", "" % tag)) + self.output.append(("tag", f"")) def handle_data(self, data): """extract the answer, if we're in an answer tag""" diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index c1f900794..31c2edf80 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -13,16 +13,7 @@ VERSION = "0.0.1" PAGE_LENGTH = env("PAGE_LENGTH", 15) DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English") -# celery -CELERY_BROKER = "redis://:{}@redis_broker:{}/0".format( - requests.utils.quote(env("REDIS_BROKER_PASSWORD", "")), env("REDIS_BROKER_PORT") -) -CELERY_RESULT_BACKEND = "redis://:{}@redis_broker:{}/0".format( - requests.utils.quote(env("REDIS_BROKER_PASSWORD", "")), env("REDIS_BROKER_PORT") -) -CELERY_ACCEPT_CONTENT = ["application/json"] -CELERY_TASK_SERIALIZER = "json" -CELERY_RESULT_SERIALIZER = "json" +JS_CACHE = "7f2343cf" # email EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend") @@ -32,7 +23,7 @@ EMAIL_HOST_USER = env("EMAIL_HOST_USER") EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", True) EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False) -DEFAULT_FROM_EMAIL = "admin@{:s}".format(env("DOMAIN")) +DEFAULT_FROM_EMAIL = f"admin@{DOMAIN}" # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -86,7 +77,8 @@ MIDDLEWARE = [ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", - "bookwyrm.timezone_middleware.TimezoneMiddleware", + "bookwyrm.middleware.TimezoneMiddleware", + "bookwyrm.middleware.IPBlocklistMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] @@ -135,7 +127,7 @@ DATABASES = { "USER": env("POSTGRES_USER", "fedireads"), "PASSWORD": env("POSTGRES_PASSWORD", "fedireads"), "HOST": env("POSTGRES_HOST", ""), - "PORT": env("POSTGRES_PORT", 5432), + "PORT": env("PGPORT", 5432), }, } @@ -186,11 +178,8 @@ USE_L10N = True USE_TZ = True -USER_AGENT = "%s (BookWyrm/%s; +https://%s/)" % ( - requests.utils.default_user_agent(), - VERSION, - DOMAIN, -) +agent = requests.utils.default_user_agent() +USER_AGENT = f"{agent} (BookWyrm/{VERSION}; +https://{DOMAIN}/)" # Imagekit generated thumbnails ENABLE_THUMBNAIL_GENERATION = env.bool("ENABLE_THUMBNAIL_GENERATION", False) @@ -221,11 +210,11 @@ if USE_S3: AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"} # S3 Static settings STATIC_LOCATION = "static" - STATIC_URL = "https://%s/%s/" % (AWS_S3_CUSTOM_DOMAIN, STATIC_LOCATION) + STATIC_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/" STATICFILES_STORAGE = "bookwyrm.storage_backends.StaticStorage" # S3 Media settings MEDIA_LOCATION = "images" - MEDIA_URL = "https://%s/%s/" % (AWS_S3_CUSTOM_DOMAIN, MEDIA_LOCATION) + MEDIA_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/" MEDIA_FULL_URL = MEDIA_URL DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage" # I don't know if it's used, but the site crashes without it @@ -235,5 +224,5 @@ else: STATIC_URL = "/static/" STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static")) MEDIA_URL = "/images/" - MEDIA_FULL_URL = "%s://%s%s" % (PROTOCOL, DOMAIN, MEDIA_URL) + MEDIA_FULL_URL = f"{PROTOCOL}://{DOMAIN}{MEDIA_URL}" MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images")) diff --git a/bookwyrm/signatures.py b/bookwyrm/signatures.py index c8c900283..61cafe71f 100644 --- a/bookwyrm/signatures.py +++ b/bookwyrm/signatures.py @@ -26,21 +26,21 @@ def make_signature(sender, destination, date, digest): """uses a private key to sign an outgoing message""" inbox_parts = urlparse(destination) signature_headers = [ - "(request-target): post %s" % inbox_parts.path, - "host: %s" % inbox_parts.netloc, - "date: %s" % date, - "digest: %s" % digest, + f"(request-target): post {inbox_parts.path}", + f"host: {inbox_parts.netloc}", + f"date: {date}", + f"digest: {digest}", ] message_to_sign = "\n".join(signature_headers) signer = pkcs1_15.new(RSA.import_key(sender.key_pair.private_key)) signed_message = signer.sign(SHA256.new(message_to_sign.encode("utf8"))) signature = { - "keyId": "%s#main-key" % sender.remote_id, + "keyId": f"{sender.remote_id}#main-key", "algorithm": "rsa-sha256", "headers": "(request-target) host date digest", "signature": b64encode(signed_message).decode("utf8"), } - return ",".join('%s="%s"' % (k, v) for (k, v) in signature.items()) + return ",".join(f'{k}="{v}"' for (k, v) in signature.items()) def make_digest(data): @@ -58,7 +58,7 @@ def verify_digest(request): elif algorithm == "SHA-512": hash_function = hashlib.sha512 else: - raise ValueError("Unsupported hash function: {}".format(algorithm)) + raise ValueError(f"Unsupported hash function: {algorithm}") expected = hash_function(request.body).digest() if b64decode(digest) != expected: @@ -95,18 +95,18 @@ class Signature: def verify(self, public_key, request): """verify rsa signature""" if http_date_age(request.headers["date"]) > MAX_SIGNATURE_AGE: - raise ValueError("Request too old: %s" % (request.headers["date"],)) + raise ValueError(f"Request too old: {request.headers['date']}") public_key = RSA.import_key(public_key) comparison_string = [] for signed_header_name in self.headers.split(" "): if signed_header_name == "(request-target)": - comparison_string.append("(request-target): post %s" % request.path) + comparison_string.append(f"(request-target): post {request.path}") else: if signed_header_name == "digest": verify_digest(request) comparison_string.append( - "%s: %s" % (signed_header_name, request.headers[signed_header_name]) + f"{signed_header_name}: {request.headers[signed_header_name]}" ) comparison_string = "\n".join(comparison_string) diff --git a/bookwyrm/static/css/bookwyrm.css b/bookwyrm/static/css/bookwyrm.css index 0724c7f14..e1012c2fd 100644 --- a/bookwyrm/static/css/bookwyrm.css +++ b/bookwyrm/static/css/bookwyrm.css @@ -89,6 +89,32 @@ body { display: inline !important; } +input[type=file]::file-selector-button { + -moz-appearance: none; + -webkit-appearance: none; + background-color: #fff; + border-radius: 4px; + border: 1px solid #dbdbdb; + box-shadow: none; + color: #363636; + cursor: pointer; + font-size: 1rem; + height: 2.5em; + justify-content: center; + line-height: 1.5; + padding-bottom: calc(0.5em - 1px); + padding-left: 1em; + padding-right: 1em; + padding-top: calc(0.5em - 1px); + text-align: center; + white-space: nowrap; +} + +input[type=file]::file-selector-button:hover { + border-color: #b5b5b5; + color: #363636; +} + /** Shelving ******************************************************************************/ @@ -96,7 +122,7 @@ body { @see https://www.youtube.com/watch?v=9xXBYcWgCHA */ .shelf-option:disabled > *::after { font-family: "icomoon"; /* stylelint-disable font-family-no-missing-generic-family-keyword */ - content: "\e918"; + content: "\e919"; /* icon-check */ margin-left: 0.5em; } @@ -167,21 +193,36 @@ body { /* All stars are visually filled by default. */ .form-rate-stars .icon::before { - content: '\e9d9'; + content: '\e9d9'; /* icon-star-full */ +} + +/* Icons directly following half star inputs are marked as half */ +.form-rate-stars input.half:checked ~ .icon::before { + content: '\e9d8'; /* icon-star-half */ +} + +/* stylelint-disable no-descending-specificity */ +.form-rate-stars input.half:checked + input + .icon:hover::before { + content: '\e9d8' !important; /* icon-star-half */ +} + +/* Icons directly following half check inputs that follow the checked input are emptied. */ +.form-rate-stars input.half:checked + input + .icon ~ .icon::before { + content: '\e9d7'; /* icon-star-empty */ } /* Icons directly following inputs that follow the checked input are emptied. */ .form-rate-stars input:checked ~ input + .icon::before { - content: '\e9d7'; + content: '\e9d7'; /* icon-star-empty */ } /* When a label is hovered, repeat the fill-all-then-empty-following pattern. */ .form-rate-stars:hover .icon.icon::before { - content: '\e9d9'; + content: '\e9d9' !important; /* icon-star-full */ } .form-rate-stars .icon:hover ~ .icon::before { - content: '\e9d7'; + content: '\e9d7' !important; /* icon-star-empty */ } /** Book covers @@ -292,17 +333,59 @@ body { } .quote > blockquote::before { - content: "\e906"; + content: "\e907"; /* icon-quote-open */ top: 0; left: 0; } .quote > blockquote::after { - content: "\e905"; + content: "\e906"; /* icon-quote-close */ right: 0; } -/* States +/** Animations and transitions + ******************************************************************************/ + +@keyframes turning { + from { transform: rotateZ(0deg); } + to { transform: rotateZ(360deg); } +} + +.is-processing .icon-spinner::before { + animation: turning 1.5s infinite linear; +} + +.icon-spinner { + display: none; +} + +.is-processing .icon-spinner { + display: flex; +} + +@media (prefers-reduced-motion: reduce) { + .is-processing .icon::before { + transition-duration: 0.001ms !important; + } +} + +/** Transient notification + ******************************************************************************/ + +#live-messages { + position: fixed; + bottom: 1em; + right: 1em; +} + +/** Tooltips + ******************************************************************************/ + +.tooltip { + width: 100%; +} + +/** States ******************************************************************************/ /* "disabled" for non-buttons */ diff --git a/bookwyrm/static/css/fonts/icomoon.eot b/bookwyrm/static/css/fonts/icomoon.eot index 2c801b2b6..8eba8692f 100644 Binary files a/bookwyrm/static/css/fonts/icomoon.eot and b/bookwyrm/static/css/fonts/icomoon.eot differ diff --git a/bookwyrm/static/css/fonts/icomoon.svg b/bookwyrm/static/css/fonts/icomoon.svg index 6327b19e6..82e413294 100644 --- a/bookwyrm/static/css/fonts/icomoon.svg +++ b/bookwyrm/static/css/fonts/icomoon.svg @@ -7,38 +7,39 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bookwyrm/static/css/fonts/icomoon.ttf b/bookwyrm/static/css/fonts/icomoon.ttf index 242ca7392..5bf90a0a7 100644 Binary files a/bookwyrm/static/css/fonts/icomoon.ttf and b/bookwyrm/static/css/fonts/icomoon.ttf differ diff --git a/bookwyrm/static/css/fonts/icomoon.woff b/bookwyrm/static/css/fonts/icomoon.woff index 67b0f0a69..6ce6834d5 100644 Binary files a/bookwyrm/static/css/fonts/icomoon.woff and b/bookwyrm/static/css/fonts/icomoon.woff differ diff --git a/bookwyrm/static/css/vendor/icons.css b/bookwyrm/static/css/vendor/icons.css index db783c24f..b43224e34 100644 --- a/bookwyrm/static/css/vendor/icons.css +++ b/bookwyrm/static/css/vendor/icons.css @@ -1,10 +1,10 @@ @font-face { font-family: 'icomoon'; - src: url('../fonts/icomoon.eot?19nagi'); - src: url('../fonts/icomoon.eot?19nagi#iefix') format('embedded-opentype'), - url('../fonts/icomoon.ttf?19nagi') format('truetype'), - url('../fonts/icomoon.woff?19nagi') format('woff'), - url('../fonts/icomoon.svg?19nagi#icomoon') format('svg'); + src: url('../fonts/icomoon.eot?36x4a3'); + src: url('../fonts/icomoon.eot?36x4a3#iefix') format('embedded-opentype'), + url('../fonts/icomoon.ttf?36x4a3') format('truetype'), + url('../fonts/icomoon.woff?36x4a3') format('woff'), + url('../fonts/icomoon.svg?36x4a3#icomoon') format('svg'); font-weight: normal; font-style: normal; font-display: block; @@ -25,6 +25,90 @@ -moz-osx-font-smoothing: grayscale; } +.icon-book:before { + content: "\e901"; +} +.icon-envelope:before { + content: "\e902"; +} +.icon-arrow-right:before { + content: "\e903"; +} +.icon-bell:before { + content: "\e904"; +} +.icon-x:before { + content: "\e905"; +} +.icon-quote-close:before { + content: "\e906"; +} +.icon-quote-open:before { + content: "\e907"; +} +.icon-image:before { + content: "\e908"; +} +.icon-pencil:before { + content: "\e909"; +} +.icon-list:before { + content: "\e90a"; +} +.icon-unlock:before { + content: "\e90b"; +} +.icon-globe:before { + content: "\e90c"; +} +.icon-lock:before { + content: "\e90d"; +} +.icon-chain-broken:before { + content: "\e90e"; +} +.icon-chain:before { + content: "\e90f"; +} +.icon-comments:before { + content: "\e910"; +} +.icon-comment:before { + content: "\e911"; +} +.icon-boost:before { + content: "\e912"; +} +.icon-arrow-left:before { + content: "\e913"; +} +.icon-arrow-up:before { + content: "\e914"; +} +.icon-arrow-down:before { + content: "\e915"; +} +.icon-local:before { + content: "\e917"; +} +.icon-dots-three:before { + content: "\e918"; +} +.icon-check:before { + content: "\e919"; +} +.icon-dots-three-vertical:before { + content: "\e91a"; +} +.icon-bookmark:before { + content: "\e91b"; +} +.icon-warning:before { + content: "\e91c"; +} +.icon-rss:before { + content: "\e91d"; +} .icon-graphic-heart:before { content: "\e91e"; } @@ -34,102 +118,6 @@ .icon-graphic-banknote:before { content: "\e920"; } -.icon-warning:before { - content: "\e91b"; -} -.icon-book:before { - content: "\e900"; -} -.icon-bookmark:before { - content: "\e91a"; -} -.icon-rss:before { - content: "\e91d"; -} -.icon-envelope:before { - content: "\e901"; -} -.icon-arrow-right:before { - content: "\e902"; -} -.icon-bell:before { - content: "\e903"; -} -.icon-x:before { - content: "\e904"; -} -.icon-quote-close:before { - content: "\e905"; -} -.icon-quote-open:before { - content: "\e906"; -} -.icon-image:before { - content: "\e907"; -} -.icon-pencil:before { - content: "\e908"; -} -.icon-list:before { - content: "\e909"; -} -.icon-unlock:before { - content: "\e90a"; -} -.icon-unlisted:before { - content: "\e90a"; -} -.icon-globe:before { - content: "\e90b"; -} -.icon-public:before { - content: "\e90b"; -} -.icon-lock:before { - content: "\e90c"; -} -.icon-followers:before { - content: "\e90c"; -} -.icon-chain-broken:before { - content: "\e90d"; -} -.icon-chain:before { - content: "\e90e"; -} -.icon-comments:before { - content: "\e90f"; -} -.icon-comment:before { - content: "\e910"; -} -.icon-boost:before { - content: "\e911"; -} -.icon-arrow-left:before { - content: "\e912"; -} -.icon-arrow-up:before { - content: "\e913"; -} -.icon-arrow-down:before { - content: "\e914"; -} -.icon-home:before { - content: "\e915"; -} -.icon-local:before { - content: "\e916"; -} -.icon-dots-three:before { - content: "\e917"; -} -.icon-check:before { - content: "\e918"; -} -.icon-dots-three-vertical:before { - content: "\e919"; -} .icon-search:before { content: "\e986"; } @@ -148,3 +136,9 @@ .icon-plus:before { content: "\ea0a"; } +.icon-question-circle:before { + content: "\e900"; +} +.icon-spinner:before { + content: "\e97a"; +} diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js index 894b1fb69..f000fd082 100644 --- a/bookwyrm/static/js/bookwyrm.js +++ b/bookwyrm/static/js/bookwyrm.js @@ -301,7 +301,10 @@ let BookWyrm = new class { ajaxPost(form) { return fetch(form.action, { method : "POST", - body: new FormData(form) + body: new FormData(form), + headers: { + 'Accept': 'application/json', + } }); } diff --git a/bookwyrm/static/js/status_cache.js b/bookwyrm/static/js/status_cache.js new file mode 100644 index 000000000..b3e345b19 --- /dev/null +++ b/bookwyrm/static/js/status_cache.js @@ -0,0 +1,236 @@ +/* exported StatusCache */ +/* globals BookWyrm */ + +let StatusCache = new class { + constructor() { + document.querySelectorAll('[data-cache-draft]') + .forEach(t => t.addEventListener('change', this.updateDraft.bind(this))); + + document.querySelectorAll('[data-cache-draft]') + .forEach(t => this.populateDraft(t)); + + document.querySelectorAll('.submit-status') + .forEach(button => button.addEventListener( + 'submit', + this.submitStatus.bind(this)) + ); + + document.querySelectorAll('.form-rate-stars label.icon') + .forEach(button => button.addEventListener('click', this.toggleStar.bind(this))); + } + + /** + * Update localStorage copy of drafted status + * + * @param {Event} event + * @return {undefined} + */ + updateDraft(event) { + // Used in set reading goal + let key = event.target.dataset.cacheDraft; + let value = event.target.value; + + if (!value) { + window.localStorage.removeItem(key); + + return; + } + + window.localStorage.setItem(key, value); + } + + /** + * Toggle display of a DOM node based on its value in the localStorage. + * + * @param {object} node - DOM node to toggle. + * @return {undefined} + */ + populateDraft(node) { + // Used in set reading goal + let key = node.dataset.cacheDraft; + let value = window.localStorage.getItem(key); + + if (!value) { + return; + } + + node.value = value; + } + + /** + * Post a status with ajax + * + * @param {} event + * @return {undefined} + */ + submitStatus(event) { + const form = event.currentTarget; + let trigger = event.submitter; + + // Safari doesn't understand "submitter" + if (!trigger) { + trigger = event.currentTarget.querySelector("button[type=submit]"); + } + + // This allows the form to submit in the old fashioned way if there's a problem + + if (!trigger || !form) { + return; + } + + event.preventDefault(); + + BookWyrm.addRemoveClass(form, 'is-processing', true); + trigger.setAttribute('disabled', null); + + BookWyrm.ajaxPost(form).finally(() => { + // Change icon to remove ongoing activity on the current UI. + // Enable back the element used to submit the form. + BookWyrm.addRemoveClass(form, 'is-processing', false); + trigger.removeAttribute('disabled'); + }) + .then(response => { + if (!response.ok) { + throw new Error(); + } + this.submitStatusSuccess(form); + }) + .catch(error => { + console.warn(error); + this.announceMessage('status-error-message'); + }); + } + + /** + * Show a message in the live region + * + * @param {String} the id of the message dom element + * @return {undefined} + */ + announceMessage(message_id) { + const element = document.getElementById(message_id); + let copy = element.cloneNode(true); + + copy.id = null; + element.insertAdjacentElement('beforebegin', copy); + + BookWyrm.addRemoveClass(copy, 'is-hidden', false); + setTimeout(function() { + copy.remove(); + }, 10000, copy); + } + + /** + * Success state for a posted status + * + * @param {Object} the html form that was submitted + * @return {undefined} + */ + submitStatusSuccess(form) { + // Clear form data + form.reset(); + + // Clear localstorage + form.querySelectorAll('[data-cache-draft]') + .forEach(node => window.localStorage.removeItem(node.dataset.cacheDraft)); + + // Close modals + let modal = form.closest(".modal.is-active"); + + if (modal) { + modal.getElementsByClassName("modal-close")[0].click(); + + // Update shelve buttons + document.querySelectorAll("[data-shelve-button-book='" + form.book.value +"']") + .forEach(button => this.cycleShelveButtons(button, form.reading_status.value)); + + return; + } + + // Close reply panel + let reply = form.closest(".reply-panel"); + + if (reply) { + document.querySelector("[data-controls=" + reply.id + "]").click(); + } + + this.announceMessage('status-success-message'); + } + + /** + * Change which buttons are available for a shelf + * + * @param {Object} html button dom element + * @param {String} the identifier of the selected shelf + * @return {undefined} + */ + cycleShelveButtons(button, identifier) { + // Pressed button + let shelf = button.querySelector("[data-shelf-identifier='" + identifier + "']"); + let next_identifier = shelf.dataset.shelfNext; + + // Set all buttons to hidden + button.querySelectorAll("[data-shelf-identifier]") + .forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", true)); + + // Button that should be visible now + let next = button.querySelector("[data-shelf-identifier=" + next_identifier + "]"); + + // Show the desired button + BookWyrm.addRemoveClass(next, "is-hidden", false); + + // ------ update the dropdown buttons + // Remove existing hidden class + button.querySelectorAll("[data-shelf-dropdown-identifier]") + .forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", false)); + + // Remove existing disabled states + button.querySelectorAll("[data-shelf-dropdown-identifier] button") + .forEach(item => item.disabled = false); + + next_identifier = next_identifier == 'complete' ? 'read' : next_identifier; + + // Disable the current state + button.querySelector( + "[data-shelf-dropdown-identifier=" + identifier + "] button" + ).disabled = true; + + let main_button = button.querySelector( + "[data-shelf-dropdown-identifier=" + next_identifier + "]" + ); + + // Hide the option that's shown as the main button + BookWyrm.addRemoveClass(main_button, "is-hidden", true); + + // Just hide the other two menu options, idk what to do with them + button.querySelectorAll("[data-extra-options]") + .forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", true)); + + // Close menu + let menu = button.querySelector(".dropdown-trigger[aria-expanded=true]"); + + if (menu) { + menu.click(); + } + } + + /** + * Reveal half-stars + * + * @param {Event} event + * @return {undefined} + */ + toggleStar(event) { + const label = event.currentTarget; + let wholeStar = document.getElementById(label.getAttribute("for")); + + if (wholeStar.checked) { + event.preventDefault(); + let halfStar = document.getElementById(label.dataset.forHalf); + + wholeStar.checked = null; + halfStar.checked = "checked"; + } + } +}(); + diff --git a/bookwyrm/suggested_users.py b/bookwyrm/suggested_users.py index 9c42d79d8..e8f236324 100644 --- a/bookwyrm/suggested_users.py +++ b/bookwyrm/suggested_users.py @@ -24,8 +24,8 @@ class SuggestedUsers(RedisStore): def store_id(self, user): # pylint: disable=no-self-use """the key used to store this user's recs""" if isinstance(user, int): - return "{:d}-suggestions".format(user) - return "{:d}-suggestions".format(user.id) + return f"{user}-suggestions" + return f"{user.id}-suggestions" def get_counts_from_rank(self, rank): # pylint: disable=no-self-use """calculate mutuals count and shared books count from rank""" @@ -86,10 +86,12 @@ class SuggestedUsers(RedisStore): values = self.get_store(self.store_id(user), withscores=True) results = [] # annotate users with mutuals and shared book counts - for user_id, rank in values[:5]: + for user_id, rank in values: counts = self.get_counts_from_rank(rank) try: - user = models.User.objects.get(id=user_id) + user = models.User.objects.get( + id=user_id, is_active=True, bookwyrm_user=True + ) except models.User.DoesNotExist as err: # if this happens, the suggestions are janked way up logger.exception(err) @@ -97,6 +99,8 @@ class SuggestedUsers(RedisStore): user.mutuals = counts["mutuals"] # user.shared_books = counts["shared_books"] results.append(user) + if len(results) >= 5: + break return results @@ -178,13 +182,21 @@ def update_suggestions_on_unfollow(sender, instance, **kwargs): @receiver(signals.post_save, sender=models.User) # pylint: disable=unused-argument, too-many-arguments -def add_new_user(sender, instance, created, update_fields=None, **kwargs): - """a new user, wow how cool""" +def update_user(sender, instance, created, update_fields=None, **kwargs): + """an updated user, neat""" # a new user is found, create suggestions for them if created and instance.local: rerank_suggestions_task.delay(instance.id) - if update_fields and not "discoverable" in update_fields: + # we know what fields were updated and discoverability didn't change + if not instance.bookwyrm_user or ( + update_fields and not "discoverable" in update_fields + ): + return + + # deleted the user + if not created and not instance.is_active: + remove_user_task.delay(instance.id) return # this happens on every save, not just when discoverability changes, annoyingly @@ -194,28 +206,61 @@ def add_new_user(sender, instance, created, update_fields=None, **kwargs): remove_user_task.delay(instance.id) -@app.task +@receiver(signals.post_save, sender=models.FederatedServer) +def domain_level_update(sender, instance, created, update_fields=None, **kwargs): + """remove users on a domain block""" + if ( + not update_fields + or "status" not in update_fields + or instance.application_type != "bookwyrm" + ): + return + + if instance.status == "blocked": + bulk_remove_instance_task.delay(instance.id) + return + bulk_add_instance_task.delay(instance.id) + + +# ------------------- TASKS + + +@app.task(queue="low_priority") def rerank_suggestions_task(user_id): """do the hard work in celery""" suggested_users.rerank_user_suggestions(user_id) -@app.task +@app.task(queue="low_priority") def rerank_user_task(user_id, update_only=False): """do the hard work in celery""" user = models.User.objects.get(id=user_id) suggested_users.rerank_obj(user, update_only=update_only) -@app.task +@app.task(queue="low_priority") 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) -@app.task +@app.task(queue="medium_priority") def remove_suggestion_task(user_id, suggested_user_id): """remove a specific user from a specific user's suggestions""" suggested_user = models.User.objects.get(id=suggested_user_id) suggested_users.remove_suggestion(user_id, suggested_user) + + +@app.task(queue="low_priority") +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) + + +@app.task(queue="low_priority") +def bulk_add_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.rerank_obj(user, update_only=False) diff --git a/bookwyrm/tasks.py b/bookwyrm/tasks.py index 6d1992a77..b860e0184 100644 --- a/bookwyrm/tasks.py +++ b/bookwyrm/tasks.py @@ -2,10 +2,10 @@ import os from celery import Celery -from bookwyrm import settings +from celerywyrm import settings # set the default Django settings module for the 'celery' program. os.environ.setdefault("DJANGO_SETTINGS_MODULE", "celerywyrm.settings") app = Celery( - "tasks", broker=settings.CELERY_BROKER, backend=settings.CELERY_RESULT_BACKEND + "tasks", broker=settings.CELERY_BROKER_URL, backend=settings.CELERY_RESULT_BACKEND ) diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index e504041bb..fc94c7de8 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -4,7 +4,6 @@ {% load humanize %} {% load utilities %} {% load static %} -{% load layout %} {% block title %}{{ book|book_title }}{% endblock %} @@ -43,7 +42,7 @@

{% endif %} - {% if book.authors %} + {% if book.authors.exists %}
{% trans "by" %} {% include 'snippets/authors.html' with book=book %}
@@ -326,5 +325,5 @@ {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/bookwyrm/templates/components/modal.html b/bookwyrm/templates/components/modal.html index d8c1a4683..2eabd2e2b 100644 --- a/bookwyrm/templates/components/modal.html +++ b/bookwyrm/templates/components/modal.html @@ -14,7 +14,11 @@ - {% include 'snippets/toggle/toggle_button.html' with label=label class="delete" nonbutton=True %} + {% if static %} + {{ label }} + {% else %} + {% include 'snippets/toggle/toggle_button.html' with label=label class="delete" nonbutton=True %} + {% endif %} {% block modal-form-open %}{% endblock %} {% if not no_body %} @@ -27,6 +31,10 @@ {% block modal-form-close %}{% endblock %} - {% include 'snippets/toggle/toggle_button.html' with label=label class="modal-close is-large" nonbutton=True %} + {% if static %} + {{ label }} + {% else %} + {% include 'snippets/toggle/toggle_button.html' with label=label class="modal-close is-large" nonbutton=True %} + {% endif %} diff --git a/bookwyrm/templates/components/tooltip.html b/bookwyrm/templates/components/tooltip.html new file mode 100644 index 000000000..b1a8f56c1 --- /dev/null +++ b/bookwyrm/templates/components/tooltip.html @@ -0,0 +1,11 @@ +{% load i18n %} + +{% trans "Help" as button_text %} +{% include 'snippets/toggle/open_button.html' with text=button_text class="ml-3 is-rounded is-small is-white p-0 pb-1" icon="question-circle is-size-6" controls_text=controls_text controls_uid=controls_uid %} + + diff --git a/bookwyrm/templates/compose.html b/bookwyrm/templates/compose.html index e37ec170e..3a222cf6a 100644 --- a/bookwyrm/templates/compose.html +++ b/bookwyrm/templates/compose.html @@ -27,7 +27,7 @@ {% if not draft %} {% include 'snippets/create_status.html' %} {% else %} - {% include 'snippets/create_status/status.html' %} + {% include 'snippets/create_status/status.html' with no_script=True %} {% endif %} diff --git a/bookwyrm/templates/discover/discover.html b/bookwyrm/templates/discover/discover.html index 01ef21869..f55f81a09 100644 --- a/bookwyrm/templates/discover/discover.html +++ b/bookwyrm/templates/discover/discover.html @@ -15,14 +15,15 @@

+ {% with tile_classes="tile is-child box has-background-white-ter is-clipped" %}
-
+
{% include 'discover/large-book.html' with status=large_activities.0 %}
-
+
{% include 'discover/large-book.html' with status=large_activities.1 %}
@@ -31,18 +32,18 @@
-
+
{% include 'discover/large-book.html' with status=large_activities.2 %}
-
+
{% include 'discover/small-book.html' with status=small_activities.0 %}
-
+
{% include 'discover/small-book.html' with status=small_activities.1 %}
@@ -51,18 +52,18 @@
-
+
{% include 'discover/small-book.html' with status=small_activities.2 %}
-
+
{% include 'discover/small-book.html' with status=small_activities.3 %}
-
+
{% include 'discover/large-book.html' with status=large_activities.3 %}
@@ -71,16 +72,17 @@
-
+
{% include 'discover/large-book.html' with status=large_activities.4 %}
-
+
{% include 'discover/large-book.html' with status=large_activities.5 %}
+ {% endwith %}
diff --git a/bookwyrm/templates/feed/direct_messages.html b/bookwyrm/templates/feed/direct_messages.html index 115e1e6f4..77f9aac19 100644 --- a/bookwyrm/templates/feed/direct_messages.html +++ b/bookwyrm/templates/feed/direct_messages.html @@ -14,7 +14,7 @@
- {% include 'snippets/create_status/status.html' with type="direct" uuid=1 mention=partner %} + {% include 'snippets/create_status/status.html' with type="direct" uuid=1 mention=partner no_script=True %}
diff --git a/bookwyrm/templates/feed/feed.html b/bookwyrm/templates/feed/feed.html index 265a467a7..b8e351c9f 100644 --- a/bookwyrm/templates/feed/feed.html +++ b/bookwyrm/templates/feed/feed.html @@ -22,7 +22,7 @@ {% blocktrans with tab_key=tab.key %}load 0 unread status(es){% endblocktrans %} -{% if request.user.show_goal and not goal and tab.key == streams.first.key %} +{% if request.user.show_goal and not goal and tab.key == 'home' %} {% now 'Y' as year %}
{% include 'snippets/goal_card.html' with year=year %} @@ -37,7 +37,7 @@

{% trans "There aren't any activities right now! Try following a user to get started" %}

- {% if suggested_users %} + {% if request.user.show_suggested_users and suggested_users %} {# suggested users for when things are very lonely #} {% include 'feed/suggested_users.html' with suggested_users=suggested_users %} {% endif %} @@ -46,7 +46,7 @@ {% for activity in activities %} -{% if not activities.number > 1 and forloop.counter0 == 2 and suggested_users %} +{% if request.user.show_suggested_users and not activities.number > 1 and forloop.counter0 == 2 and suggested_users %} {# suggested users on the first page, two statuses down #} {% include 'feed/suggested_users.html' with suggested_users=suggested_users %} {% endif %} diff --git a/bookwyrm/templates/feed/layout.html b/bookwyrm/templates/feed/layout.html index 746f4fd09..8d79781b3 100644 --- a/bookwyrm/templates/feed/layout.html +++ b/bookwyrm/templates/feed/layout.html @@ -106,5 +106,5 @@ {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/bookwyrm/templates/feed/suggested_users.html b/bookwyrm/templates/feed/suggested_users.html index 1de1ae139..4e9f822b5 100644 --- a/bookwyrm/templates/feed/suggested_users.html +++ b/bookwyrm/templates/feed/suggested_users.html @@ -1,6 +1,15 @@ {% load i18n %}
-

{% trans "Who to follow" %}

+
+
+

{% trans "Who to follow" %}

+
+
+ {% csrf_token %} + {% trans "Don't show suggested users" as button_text %} + +
+
{% include 'snippets/suggested_users.html' with suggested_users=suggested_users %} {% trans "View directory" %}
diff --git a/bookwyrm/templates/import.html b/bookwyrm/templates/import/import.html similarity index 89% rename from bookwyrm/templates/import.html rename to bookwyrm/templates/import/import.html index d2e407486..cc296b75b 100644 --- a/bookwyrm/templates/import.html +++ b/bookwyrm/templates/import/import.html @@ -12,9 +12,14 @@
- + +
+ + {% include 'import/tooltip.html' with controls_text="goodreads-tooltip" %} +
+
@@ -25,7 +25,7 @@
-
+
{% include 'snippets/about.html' %}
diff --git a/bookwyrm/templates/landing/about.html b/bookwyrm/templates/landing/about.html index dd7036c4f..c3b1e84ef 100644 --- a/bookwyrm/templates/landing/about.html +++ b/bookwyrm/templates/landing/about.html @@ -1,4 +1,4 @@ -{% extends 'landing/landing_layout.html' %} +{% extends 'landing/layout.html' %} {% load i18n %} {% block panel %} diff --git a/bookwyrm/templates/landing/landing.html b/bookwyrm/templates/landing/landing.html index 7a30f1617..d13cd582a 100644 --- a/bookwyrm/templates/landing/landing.html +++ b/bookwyrm/templates/landing/landing.html @@ -1,4 +1,4 @@ -{% extends 'landing/landing_layout.html' %} +{% extends 'landing/layout.html' %} {% load i18n %} {% block panel %} diff --git a/bookwyrm/templates/landing/landing_layout.html b/bookwyrm/templates/landing/layout.html similarity index 53% rename from bookwyrm/templates/landing/landing_layout.html rename to bookwyrm/templates/landing/layout.html index 946482cbb..0d6f231c1 100644 --- a/bookwyrm/templates/landing/landing_layout.html +++ b/bookwyrm/templates/landing/layout.html @@ -40,38 +40,41 @@
{% if not request.user.is_authenticated %}
+

+ {% if site.allow_registration %} + {% blocktrans with name=site.name %}Join {{ name }}{% endblocktrans %} + {% elif site.allow_invite_requests %} + {% trans "Request an Invitation" %} + {% else %} + {% blocktrans with name=site.name%}{{ name}} registration is closed{% endblocktrans %} + {% endif %} +

+ {% if site.allow_registration %} -

{% blocktrans with name=site.name %}Join {{ name }}{% endblocktrans %}

-
- {% include 'snippets/register_form.html' %} -
- +
+ {% include 'snippets/register_form.html' %} +
+ {% elif site.allow_invite_requests %} + {% if request_received %} +

+ {% trans "Thank you! Your request has been received." %} +

+ {% else %} +

{{ site.invite_request_text }}

+
+ {% csrf_token %} +
+ + + {% for error in request_form.email.errors %} +

{{ error|escape }}

+ {% endfor %} +
+ +
+ {% endif %} {% else %} - -

{% trans "This instance is closed" %}

-

{{ site.registration_closed_text|safe}}

- - {% if site.allow_invite_requests %} - {% if request_received %} -

- {% trans "Thank you! Your request has been received." %} -

- {% else %} -

{% trans "Request an Invitation" %}

-
- {% csrf_token %} -
- - - {% for error in request_form.email.errors %} -

{{ error|escape }}

- {% endfor %} -
- -
- {% endif %} - {% endif %} - +

{{ site.registration_closed_text|safe}}

{% endif %}
{% else %} diff --git a/bookwyrm/templates/layout.html b/bookwyrm/templates/layout.html index 2b8364ec9..bf7a7f23e 100644 --- a/bookwyrm/templates/layout.html +++ b/bookwyrm/templates/layout.html @@ -4,12 +4,14 @@ - {% block title %}BookWyrm{% endblock %} | {{ site.name }} + {% block title %}BookWyrm{% endblock %} - {{ site.name }} + + {% if preview_images_enabled is True %} @@ -17,8 +19,8 @@ {% else %} {% endif %} - - + + @@ -34,10 +36,15 @@ -