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 52b1b1f27..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] @@ -286,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/activitystreams.py b/bookwyrm/activitystreams.py index 6dedd7176..5e0969e56 100644 --- a/bookwyrm/activitystreams.py +++ b/bookwyrm/activitystreams.py @@ -1,7 +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 @@ -14,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""" @@ -355,7 +358,7 @@ def populate_streams_on_account_create(sender, instance, created, *args, **kwarg if not created or not instance.local: return - for stream in streams.values(): + for stream in streams: populate_stream_task.delay(stream, instance.id) @@ -395,7 +398,7 @@ def remove_statuses_on_unshelve(sender, instance, *args, **kwargs): # ---- TASKS -@app.task +@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) @@ -403,7 +406,7 @@ def add_book_statuses_task(user_id, book_id): BooksStream().add_book_statuses(user, book) -@app.task +@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) @@ -411,7 +414,7 @@ def remove_book_statuses_task(user_id, book_id): BooksStream().remove_book_statuses(user, book) -@app.task +@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) @@ -419,7 +422,7 @@ def populate_stream_task(stream, user_id): stream.populate_streams(user) -@app.task +@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 @@ -432,15 +435,19 @@ def remove_status_task(status_ids): stream.remove_object_from_related_stores(status) -@app.task +@app.task(queue="high_priority") def add_status_task(status_id, increment_unread=False): - """remove a status from any stream it might be in""" + """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 +@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() @@ -450,9 +457,9 @@ def remove_user_statuses_task(viewer_id, user_id, stream_list=None): stream.remove_user_statuses(viewer, user) -@app.task +@app.task(queue="medium_priority") def add_user_statuses_task(viewer_id, user_id, stream_list=None): - """remove all statuses by a user from a viewer's stream""" + """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) @@ -460,7 +467,7 @@ def add_user_statuses_task(viewer_id, user_id, stream_list=None): stream.add_user_statuses(viewer, user) -@app.task +@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) @@ -469,7 +476,7 @@ def handle_boost_task(boost_id): old_versions = models.Boost.objects.filter( boosted_status__id=boosted.id, created_date__lt=instance.created_date, - ).values_list("id", flat=True) + ) for stream in streams.values(): audience = stream.get_stores_for_object(instance) 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 690526050..23063ff7c 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -125,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 @@ -134,6 +140,7 @@ class EditUserForm(CustomForm): "email", "summary", "show_goal", + "show_suggested_users", "manually_approves_followers", "default_post_privacy", "discoverable", @@ -253,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"))] ), } @@ -298,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/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/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 f287b752f..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) @@ -420,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, @@ -430,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, @@ -458,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() @@ -502,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) @@ -555,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..aa174a143 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -3,20 +3,19 @@ import base64 from Crypto import Random from django.db import models from django.dispatch import receiver +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 +32,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,7 +46,7 @@ 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): """is a user authorized to view an object?""" diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index 5888ea07a..70df3bd47 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -4,6 +4,7 @@ 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 transaction from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ from model_utils import FieldTracker @@ -164,9 +165,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): @@ -177,9 +178,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, @@ -216,7 +218,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, ) @@ -375,4 +377,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 cc5a7bb55..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: @@ -308,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): @@ -388,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) @@ -448,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..49fb53757 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): 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 343d3c115..e1090f415 100644 --- a/bookwyrm/models/readthrough.py +++ b/bookwyrm/models/readthrough.py @@ -2,7 +2,6 @@ from django.core import validators from django.db import models from django.db.models import F, Q -from django.utils import timezone from .base_model import BookWyrmModel @@ -30,8 +29,7 @@ class ReadThrough(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) def create_update(self): @@ -65,6 +63,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..6f1344022 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -44,7 +44,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 +55,7 @@ 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}" 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 d0f094d22..3a0fad5e5 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -67,40 +67,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 +74,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 +149,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 @@ -226,10 +196,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 +247,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 @@ -306,11 +275,9 @@ class Quotation(BookStatus): """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 @@ -389,27 +356,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" """ @@ -422,10 +368,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 0745dffa2..637baa6ee 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -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, @@ -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,30 +288,47 @@ class User(OrderedCollectionPageMixin, AbstractUser): transaction.on_commit(lambda: set_remote_server.delay(self.id)) return - # 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" + 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", @@ -321,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""" @@ -346,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""" @@ -383,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): @@ -420,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) @@ -439,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): @@ -459,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 6fa2e441a..fc94c7de8 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -325,5 +325,5 @@ {% endblock %} {% block scripts %} - + {% endblock %} 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 43ca81c74..250b5bf7b 100644 --- a/bookwyrm/templates/layout.html +++ b/bookwyrm/templates/layout.html @@ -4,7 +4,7 @@ - {% block title %}BookWyrm{% endblock %} | {{ site.name }} + {% block title %}BookWyrm{% endblock %} - {{ site.name }} @@ -17,8 +17,8 @@ {% else %} {% endif %} - - + + @@ -37,7 +37,12 @@
+ {% if list.id %}
{% trans "Delete list" as button_text %} {% include 'snippets/toggle/toggle_button.html' with class="is-danger" text=button_text icon_with_text="x" controls_text="delete_list" controls_uid=list.id focus="modal_title_delete_list" %}
+ {% endif %}
diff --git a/bookwyrm/templates/login.html b/bookwyrm/templates/login.html index 19c103eac..31e9cebb4 100644 --- a/bookwyrm/templates/login.html +++ b/bookwyrm/templates/login.html @@ -4,69 +4,65 @@ {% block title %}{% trans "Login" %}{% endblock %} {% block content %} -
-
-
-

{% trans "Log in" %}

- {% if login_form.non_field_errors %} -

{{ login_form.non_field_errors }}

- {% endif %} +

{% trans "Log in" %}

+
+
+ {% if login_form.non_field_errors %} +

{{ login_form.non_field_errors }}

+ {% endif %} - {% if show_confirmed_email %} -

{% trans "Success! Email address confirmed." %}

- {% endif %} - - {% csrf_token %} - {% if show_confirmed_email %}{% endif %} -
- -
- {{ login_form.localname }} -
+ {% if show_confirmed_email %} +

{% trans "Success! Email address confirmed." %}

+ {% endif %} + + {% csrf_token %} + {% if show_confirmed_email %}{% endif %} +
+ +
+ {{ login_form.localname }}
-
- -
- {{ login_form.password }} -
- {% for error in login_form.password.errors %} -

{{ error | escape }}

- {% endfor %} +
+
+ +
+ {{ login_form.password }}
-
-
- -
- + {% for error in login_form.password.errors %} +

{{ error | escape }}

+ {% endfor %} +
+
+
+
- -
+ +
+
-
+ {% if site.allow_registration %} +
- {% if site.allow_registration %}

{% trans "Create an Account" %}

{% include 'snippets/register_form.html' %}
- {% else %} -

{% trans "This instance is closed" %}

-

{% trans "Contact an administrator to get an invite" %}

- {% endif %} +
+
+ {% endif %} + +
+
+ {% include 'snippets/about.html' %} + +

+ {% trans "More about this site" %} +

-
-
- {% include 'snippets/about.html' %} - -

- {% trans "More about this site" %} -

-
-
{% endblock %} diff --git a/bookwyrm/templates/moderation/report.html b/bookwyrm/templates/moderation/report.html index db03850f8..d0e4026a7 100644 --- a/bookwyrm/templates/moderation/report.html +++ b/bookwyrm/templates/moderation/report.html @@ -1,4 +1,4 @@ -{% extends 'settings/admin_layout.html' %} +{% extends 'settings/layout.html' %} {% load i18n %} {% load humanize %} diff --git a/bookwyrm/templates/moderation/reports.html b/bookwyrm/templates/moderation/reports.html index 95a276af4..c83f626fa 100644 --- a/bookwyrm/templates/moderation/reports.html +++ b/bookwyrm/templates/moderation/reports.html @@ -1,4 +1,4 @@ -{% extends 'settings/admin_layout.html' %} +{% extends 'settings/layout.html' %} {% load i18n %} {% block title %} diff --git a/bookwyrm/templates/preferences/edit_user.html b/bookwyrm/templates/preferences/edit_user.html index 6732b8ef6..4dc792f9b 100644 --- a/bookwyrm/templates/preferences/edit_user.html +++ b/bookwyrm/templates/preferences/edit_user.html @@ -43,9 +43,19 @@
+ + + {% url 'directory' as path %} +

{% blocktrans %}Your account will show up in the directory, and may be recommended to other BookWyrm users.{% endblocktrans %}

-
- - {% url 'directory' as path %} -

{% blocktrans %}Your account will show up in the directory, and may be recommended to other BookWyrm users.{% endblocktrans %}

-
diff --git a/bookwyrm/templates/settings/announcement.html b/bookwyrm/templates/settings/announcement.html index c0e5dec88..b3375bb50 100644 --- a/bookwyrm/templates/settings/announcement.html +++ b/bookwyrm/templates/settings/announcement.html @@ -1,4 +1,4 @@ -{% extends 'settings/admin_layout.html' %} +{% extends 'settings/layout.html' %} {% load i18n %}{% load humanize %} {% block title %}{% trans "Announcement" %} - {{ announcement.preview }}{% endblock %} diff --git a/bookwyrm/templates/settings/announcement_form.html b/bookwyrm/templates/settings/announcement_form.html index 8eb6acd52..ffdbfc2fd 100644 --- a/bookwyrm/templates/settings/announcement_form.html +++ b/bookwyrm/templates/settings/announcement_form.html @@ -13,21 +13,21 @@ {% csrf_token %}

- + {{ form.preview }} {% for error in form.preview.errors %}

{{ error | escape }}

{% endfor %}

- + {{ form.content }} {% for error in form.content.errors %}

{{ error | escape }}

{% endfor %}

- + {% for error in form.event_date.errors %}

{{ error | escape }}

@@ -37,7 +37,7 @@

- + {% for error in form.start_date.errors %}

{{ error | escape }}

@@ -46,7 +46,7 @@

- + {% for error in form.end_date.errors %}

{{ error | escape }}

@@ -55,7 +55,7 @@

- + {{ form.active }} {% for error in form.active.errors %}

{{ error | escape }}

diff --git a/bookwyrm/templates/settings/announcements.html b/bookwyrm/templates/settings/announcements.html index 18e765317..2c57ae787 100644 --- a/bookwyrm/templates/settings/announcements.html +++ b/bookwyrm/templates/settings/announcements.html @@ -1,4 +1,4 @@ -{% extends 'settings/admin_layout.html' %} +{% extends 'settings/layout.html' %} {% load i18n %}{% load humanize %} {% block title %}{% trans "Announcements" %}{% endblock %} @@ -14,40 +14,46 @@ {% include 'settings/announcement_form.html' with controls_text="create_announcement" %} - - - - - - - - - {% for announcement in announcements %} - - - - - - - - {% endfor %} -
- {% url 'settings-announcements' as url %} - {% trans "Date added" as text %} - {% include 'snippets/table-sort-header.html' with field="created_date" sort=sort text=text %} - - {% trans "Preview" as text %} - {% include 'snippets/table-sort-header.html' with field="preview" sort=sort text=text %} - - {% trans "Start date" as text %} - {% include 'snippets/table-sort-header.html' with field="start_date" sort=sort text=text %} - - {% trans "End date" as text %} - {% include 'snippets/table-sort-header.html' with field="end_date" sort=sort text=text %} - - {% trans "Status" as text %} - {% include 'snippets/table-sort-header.html' with field="active" sort=sort text=text %} -
{{ announcement.created_date|naturalday }}{{ announcement.preview }}{{ announcement.start_date|naturaltime|default:'' }}{{ announcement.end_date|naturaltime|default:'' }}{% if announcement.active %}{% trans "active" %}{% else %}{% trans "inactive" %}{% endif %}
+
+ + + + + + + + + {% for announcement in announcements %} + + + + + + + + {% endfor %} +
+ {% url 'settings-announcements' as url %} + {% trans "Date added" as text %} + {% include 'snippets/table-sort-header.html' with field="created_date" sort=sort text=text %} + + {% trans "Preview" as text %} + {% include 'snippets/table-sort-header.html' with field="preview" sort=sort text=text %} + + {% trans "Start date" as text %} + {% include 'snippets/table-sort-header.html' with field="start_date" sort=sort text=text %} + + {% trans "End date" as text %} + {% include 'snippets/table-sort-header.html' with field="end_date" sort=sort text=text %} + + {% trans "Status" as text %} + {% include 'snippets/table-sort-header.html' with field="active" sort=sort text=text %} +
{{ announcement.created_date|naturalday }}{{ announcement.preview }}{{ announcement.start_date|naturaltime|default:'' }}{{ announcement.end_date|naturaltime|default:'' }}{% if announcement.active %}{% trans "active" %}{% else %}{% trans "inactive" %}{% endif %}
+ + {% if not announcements %} +

{% trans "No announcements found." %}

+ {% endif %} +
{% include 'snippets/pagination.html' with page=announcements path=request.path %} {% endblock %} diff --git a/bookwyrm/templates/settings/dashboard.html b/bookwyrm/templates/settings/dashboard.html new file mode 100644 index 000000000..608d32c97 --- /dev/null +++ b/bookwyrm/templates/settings/dashboard.html @@ -0,0 +1,120 @@ +{% extends 'settings/layout.html' %} +{% load i18n %} +{% load humanize %} +{% load static %} + +{% block title %}{% trans "Dashboard" %}{% endblock %} + +{% block header %}{% trans "Dashboard" %}{% endblock %} + +{% block panel %} + +
+
+
+

{% trans "Total users" %}

+

{{ users|intcomma }}

+
+
+
+
+

{% trans "Active this month" %}

+

{{ active_users|intcomma }}

+
+
+
+
+

{% trans "Statuses" %}

+

{{ statuses|intcomma }}

+
+
+
+
+

{% trans "Works" %}

+

{{ works|intcomma }}

+
+
+
+ + + +
+

{% trans "Instance Activity" %}

+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+

{% trans "User signup activity" %}

+
+ +
+
+
+

{% trans "Status activity" %}

+
+ +
+
+
+
+ +{% endblock %} + +{% block scripts %} + +{% include 'settings/dashboard_user_chart.html' %} +{% include 'settings/dashboard_status_chart.html' %} +{% endblock %} diff --git a/bookwyrm/templates/settings/dashboard_status_chart.html b/bookwyrm/templates/settings/dashboard_status_chart.html new file mode 100644 index 000000000..bbacf3f46 --- /dev/null +++ b/bookwyrm/templates/settings/dashboard_status_chart.html @@ -0,0 +1,26 @@ +{% load i18n %} + + diff --git a/bookwyrm/templates/settings/dashboard_user_chart.html b/bookwyrm/templates/settings/dashboard_user_chart.html new file mode 100644 index 000000000..33be28f7e --- /dev/null +++ b/bookwyrm/templates/settings/dashboard_user_chart.html @@ -0,0 +1,29 @@ +{% load i18n %} + diff --git a/bookwyrm/templates/settings/domain_form.html b/bookwyrm/templates/settings/domain_form.html new file mode 100644 index 000000000..e93337495 --- /dev/null +++ b/bookwyrm/templates/settings/domain_form.html @@ -0,0 +1,30 @@ +{% extends 'components/inline_form.html' %} +{% load i18n %} + +{% block header %} +{% trans "Add domain" %} +{% endblock %} + +{% block form %} +
+ {% csrf_token %} + +
+
+
@
+
+
+ {{ form.domain }} +
+
+ {% for error in form.domain.errors %} +

{{ error | escape }}

+ {% endfor %} + +
+
+ +
+
+
+{% endblock %} diff --git a/bookwyrm/templates/settings/edit_server.html b/bookwyrm/templates/settings/edit_server.html index 55346a526..452316e43 100644 --- a/bookwyrm/templates/settings/edit_server.html +++ b/bookwyrm/templates/settings/edit_server.html @@ -1,4 +1,4 @@ -{% extends 'settings/admin_layout.html' %} +{% extends 'settings/layout.html' %} {% load i18n %} {% block title %}{% trans "Add instance" %}{% endblock %} diff --git a/bookwyrm/templates/settings/email_blocklist.html b/bookwyrm/templates/settings/email_blocklist.html new file mode 100644 index 000000000..ea63b954e --- /dev/null +++ b/bookwyrm/templates/settings/email_blocklist.html @@ -0,0 +1,61 @@ +{% extends 'settings/layout.html' %} +{% load i18n %} +{% load humanize %} + +{% block title %}{% trans "Email Blocklist" %}{% endblock %} + +{% block header %}{% trans "Email Blocklist" %}{% endblock %} + +{% block edit-button %} +{% trans "Add domain" as button_text %} +{% include 'snippets/toggle/open_button.html' with controls_text="add_domain" icon_with_text="plus" text=button_text focus="add_domain_header" %} +{% endblock %} + +{% block panel %} +{% include 'settings/domain_form.html' with controls_text="add_domain" class="block" %} + +

+ {% trans "When someone tries to register with an email from this domain, no account will be created. The registration process will appear to have worked." %} +

+ + + + {% url 'settings-federation' as url %} + + + + + {% for domain in domains %} + + + + + + {% endfor %} +
+ {% trans "Domain" %} + {% trans "Users" %} + {% trans "Options" %} +
{{ domain.domain }} + + {% with user_count=domain.users.count %} + {% blocktrans trimmed count conter=user_count with display_count=user_count|intcomma %} + {{ display_count }} user + {% plural %} + {{ display_count }} users + {% endblocktrans %} + {% endwith %} + + +
+ {% csrf_token %} + {% trans "Delete" as button_text %} + +
+
+ +{% endblock %} + diff --git a/bookwyrm/templates/settings/federated_server.html b/bookwyrm/templates/settings/federated_server.html index c47dd285c..6a1a5488c 100644 --- a/bookwyrm/templates/settings/federated_server.html +++ b/bookwyrm/templates/settings/federated_server.html @@ -1,4 +1,4 @@ -{% extends 'settings/admin_layout.html' %} +{% extends 'settings/layout.html' %} {% load i18n %} {% load markdown %} @@ -29,7 +29,7 @@
{% trans "Status:" %}
-
{{ server.status }}
+
{{ server.get_status_display }}
diff --git a/bookwyrm/templates/settings/federation.html b/bookwyrm/templates/settings/federation.html index e0a9ba73a..ffbabca35 100644 --- a/bookwyrm/templates/settings/federation.html +++ b/bookwyrm/templates/settings/federation.html @@ -1,4 +1,4 @@ -{% extends 'settings/admin_layout.html' %} +{% extends 'settings/layout.html' %} {% load i18n %} {% block title %}{% trans "Federated Instances" %}{% endblock %} @@ -12,6 +12,19 @@ {% endblock %} {% block panel %} +
+ +
+ {% url 'settings-federation' as url %} @@ -20,21 +33,30 @@ {% include 'snippets/table-sort-header.html' with field="server_name" sort=sort text=text %} + {% for server in servers %} - - + + + {% endfor %}
- {% trans "Date federated" as text %} + {% trans "Date added" as text %} {% include 'snippets/table-sort-header.html' with field="created_date" sort=sort text=text %} {% trans "Software" as text %} {% include 'snippets/table-sort-header.html' with field="application_type" sort=sort text=text %} + {% trans "Users" %} + {% trans "Status" %}
{{ server.server_name }} {{ server.created_date }}{{ server.application_type }} ({{ server.application_version }}){{ server.status }} + {% if server.application_type %} + {{ server.application_type }} + {% if server.application_version %}({{ server.application_version }}){% endif %} + {% endif %} + {{ server.user_set.count }}{{ server.get_status_display }}
diff --git a/bookwyrm/templates/settings/ip_address_form.html b/bookwyrm/templates/settings/ip_address_form.html new file mode 100644 index 000000000..c8a4c3e74 --- /dev/null +++ b/bookwyrm/templates/settings/ip_address_form.html @@ -0,0 +1,36 @@ +{% extends 'components/inline_form.html' %} +{% load i18n %} + +{% block header %} +{% trans "Add IP address" %} +{% endblock %} + +{% block form %} +
+
+ {% trans "Use IP address blocks with caution, and consider using blocks only temporarily, as IP addresses are often shared or change hands. If you block your own IP, you will not be able to access this page." %} +
+ + {% csrf_token %} + +
+ +
+ +
+ +
+ + {% for error in form.address.errors %} +

{{ error | escape }}

+ {% endfor %} + +
+
+ +
+
+
+{% endblock %} diff --git a/bookwyrm/templates/settings/ip_blocklist.html b/bookwyrm/templates/settings/ip_blocklist.html new file mode 100644 index 000000000..c978c1265 --- /dev/null +++ b/bookwyrm/templates/settings/ip_blocklist.html @@ -0,0 +1,47 @@ +{% extends 'settings/layout.html' %} +{% load i18n %} +{% load humanize %} + +{% block title %}{% trans "IP Address Blocklist" %}{% endblock %} + +{% block header %}{% trans "IP Address Blocklist" %}{% endblock %} + +{% block edit-button %} +{% trans "Add IP address" as button_text %} +{% include 'snippets/toggle/open_button.html' with controls_text="add_address" icon_with_text="plus" text=button_text focus="add_address_header" %} +{% endblock %} + +{% block panel %} +{% include 'settings/ip_address_form.html' with controls_text="add_address" class="block" %} + +

+ {% trans "Any traffic from this IP address will get a 404 response when trying to access any part of the application." %} +

+ + + + + + + {% for address in addresses %} + + + + + {% endfor %} +
+ {% trans "Address" %} + + {% trans "Options" %} +
{{ address.address }} +
+ {% csrf_token %} + {% trans "Delete" as button_text %} + +
+
+ +{% endblock %} diff --git a/bookwyrm/templates/settings/ip_tooltip.html b/bookwyrm/templates/settings/ip_tooltip.html new file mode 100644 index 000000000..3a2bf543a --- /dev/null +++ b/bookwyrm/templates/settings/ip_tooltip.html @@ -0,0 +1,8 @@ +{% extends 'components/tooltip.html' %} +{% load i18n %} + +{% block tooltip_content %} + +{% trans "You can block IP ranges using CIDR syntax." %} + +{% endblock %} diff --git a/bookwyrm/templates/settings/admin_layout.html b/bookwyrm/templates/settings/layout.html similarity index 77% rename from bookwyrm/templates/settings/admin_layout.html rename to bookwyrm/templates/settings/layout.html index 6d6516476..dd315347d 100644 --- a/bookwyrm/templates/settings/admin_layout.html +++ b/bookwyrm/templates/settings/layout.html @@ -18,6 +18,13 @@
+
+ +

{% trans "Used when the instance is previewed on joinbookwyrm.com. Does not support html or markdown." %}

+ {{ site_form.instance_short_description }} +
{{ site_form.code_of_conduct }} @@ -102,6 +107,13 @@ {{ site_form.registration_closed_text }}
+
+ + {{ site_form.invite_request_text }} + {% for error in site_form.invite_request_text.errors %} +

{{ error|escape }}

+ {% endfor %} +
diff --git a/bookwyrm/templates/snippets/create_status/comment.html b/bookwyrm/templates/snippets/create_status/comment.html index 8f3f5c2b6..4bf1fc243 100644 --- a/bookwyrm/templates/snippets/create_status/comment.html +++ b/bookwyrm/templates/snippets/create_status/comment.html @@ -35,11 +35,16 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j size="3" value="{% firstof draft.progress readthrough.progress '' %}" id="progress_{{ uuid }}" + data-cache-draft="id_progress_comment_{{ book.id }}" >
-
- +
diff --git a/bookwyrm/templates/snippets/create_status/quotation.html b/bookwyrm/templates/snippets/create_status/quotation.html index c6f1f85fc..25f3199bf 100644 --- a/bookwyrm/templates/snippets/create_status/quotation.html +++ b/bookwyrm/templates/snippets/create_status/quotation.html @@ -24,6 +24,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j id="id_quote_{{ book.id }}_{{ type }}" placeholder="{% blocktrans with book_title=book.title %}An excerpt from '{{ book_title }}'{% endblocktrans %}" required + data-cache-draft="id_quote_{{ book.id }}_{{ type }}" >{{ draft.quote|default:'' }}
@@ -32,7 +33,11 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
-
diff --git a/bookwyrm/templates/snippets/create_status/review.html b/bookwyrm/templates/snippets/create_status/review.html index 714055a1c..67214f332 100644 --- a/bookwyrm/templates/snippets/create_status/review.html +++ b/bookwyrm/templates/snippets/create_status/review.html @@ -15,7 +15,17 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
- +
diff --git a/bookwyrm/templates/snippets/form_rate_stars.html b/bookwyrm/templates/snippets/form_rate_stars.html index fe2a2f03d..302f181ed 100644 --- a/bookwyrm/templates/snippets/form_rate_stars.html +++ b/bookwyrm/templates/snippets/form_rate_stars.html @@ -1,5 +1,6 @@ {% spaceless %} {% load i18n %} +{% load stars %}
+ {% blocktranslate trimmed count rating=forloop.counter0 with half_rating=forloop.counter0|half_star %} + {{ half_rating }} star + {% plural %} + {{ half_rating }} stars + {% endblocktranslate %} + + {% blocktranslate trimmed count rating=forloop.counter %} diff --git a/bookwyrm/templates/snippets/goal_form.html b/bookwyrm/templates/snippets/goal_form.html index 09f052a72..3afcf2ac9 100644 --- a/bookwyrm/templates/snippets/goal_form.html +++ b/bookwyrm/templates/snippets/goal_form.html @@ -1,5 +1,5 @@ {% load i18n %} - + {% csrf_token %} diff --git a/bookwyrm/templates/snippets/reading_modals/finish_reading_modal.html b/bookwyrm/templates/snippets/reading_modals/finish_reading_modal.html index 3a0346931..b041b70aa 100644 --- a/bookwyrm/templates/snippets/reading_modals/finish_reading_modal.html +++ b/bookwyrm/templates/snippets/reading_modals/finish_reading_modal.html @@ -9,7 +9,7 @@ Finish "{{ book_title }}" {% endblock %} {% block modal-form-open %} - + {% csrf_token %} {% endblock %} diff --git a/bookwyrm/templates/snippets/reading_modals/form.html b/bookwyrm/templates/snippets/reading_modals/form.html index d1ba916ff..382407ba7 100644 --- a/bookwyrm/templates/snippets/reading_modals/form.html +++ b/bookwyrm/templates/snippets/reading_modals/form.html @@ -12,4 +12,5 @@ + {% endblock %} diff --git a/bookwyrm/templates/snippets/reading_modals/progress_update_modal.html b/bookwyrm/templates/snippets/reading_modals/progress_update_modal.html index c766153af..b2aa27a6d 100644 --- a/bookwyrm/templates/snippets/reading_modals/progress_update_modal.html +++ b/bookwyrm/templates/snippets/reading_modals/progress_update_modal.html @@ -6,7 +6,7 @@ {% endblock %} {% block modal-form-open %} - + {% endblock %} {% block modal-body %} @@ -23,7 +23,8 @@ min="0" name="progress" size="3" - value="{{ readthrough.progress|default:'' }}"> + value="{{ readthrough.progress|default:'' }}" + >
{% csrf_token %} {% endblock %} diff --git a/bookwyrm/templates/snippets/reading_modals/want_to_read_modal.html b/bookwyrm/templates/snippets/reading_modals/want_to_read_modal.html index 1213b18e9..5dec637be 100644 --- a/bookwyrm/templates/snippets/reading_modals/want_to_read_modal.html +++ b/bookwyrm/templates/snippets/reading_modals/want_to_read_modal.html @@ -9,7 +9,7 @@ Want to Read "{{ book_title }}" {% endblock %} {% block modal-form-open %} - + {% csrf_token %} {% endblock %} diff --git a/bookwyrm/templates/snippets/shelve_button/shelve_button.html b/bookwyrm/templates/snippets/shelve_button/shelve_button.html index 189418122..0af16a935 100644 --- a/bookwyrm/templates/snippets/shelve_button/shelve_button.html +++ b/bookwyrm/templates/snippets/shelve_button/shelve_button.html @@ -6,7 +6,7 @@ {% with book.id|uuid as uuid %} {% active_shelf book as active_shelf %} {% latest_read_through book request.user as readthrough %} -
+
{% if switch_mode and active_shelf.book != book %}
{% include 'snippets/switch_edition_button.html' with edition=book size='is-small' %} diff --git a/bookwyrm/templates/snippets/shelve_button/shelve_button_dropdown.html b/bookwyrm/templates/snippets/shelve_button/shelve_button_dropdown.html index 353a37a1c..43e9591fb 100644 --- a/bookwyrm/templates/snippets/shelve_button/shelve_button_dropdown.html +++ b/bookwyrm/templates/snippets/shelve_button/shelve_button_dropdown.html @@ -7,5 +7,5 @@ {% endblock %} {% block dropdown-list %} -{% include 'snippets/shelve_button/shelve_button_options.html' with active_shelf=active_shelf shelves=user_shelves dropdown=True class="shelf-option is-fullwidth is-small is-radiusless is-white" %} +{% include 'snippets/shelve_button/shelve_button_dropdown_options.html' with active_shelf=active_shelf shelves=user_shelves dropdown=True class="shelf-option is-fullwidth is-small is-radiusless is-white" %} {% endblock %} diff --git a/bookwyrm/templates/snippets/shelve_button/shelve_button_dropdown_options.html b/bookwyrm/templates/snippets/shelve_button/shelve_button_dropdown_options.html new file mode 100644 index 000000000..32319f863 --- /dev/null +++ b/bookwyrm/templates/snippets/shelve_button/shelve_button_dropdown_options.html @@ -0,0 +1,69 @@ +{% load bookwyrm_tags %} +{% load utilities %} +{% load i18n %} + +{% with next_shelf_identifier=active_shelf.shelf.identifier|next_shelf %} + +{% for shelf in shelves %} +{% comparison_bool shelf.identifier active_shelf.shelf.identifier as is_current %} + +{% endfor %} + +{% if readthrough and active_shelf.shelf.identifier != 'read' %} + +{% endif %} + +{% if active_shelf.shelf %} + +{% endif %} + +{% endwith %} + diff --git a/bookwyrm/templates/snippets/shelve_button/shelve_button_options.html b/bookwyrm/templates/snippets/shelve_button/shelve_button_options.html index 5cce1477c..393340922 100644 --- a/bookwyrm/templates/snippets/shelve_button/shelve_button_options.html +++ b/bookwyrm/templates/snippets/shelve_button/shelve_button_options.html @@ -2,33 +2,43 @@ {% load utilities %} {% load i18n %} -{% for shelf in shelves %} -{% comparison_bool shelf.identifier active_shelf.shelf.identifier as is_current %} -{% if dropdown %}{% endif %} {% endfor %} -{% if dropdown %} -{% if readthrough and active_shelf.shelf.identifier != 'read' %} - -{% endif %} - -{% if active_shelf.shelf %} - -{% endif %} - -{% endif %} +{% endwith %} diff --git a/bookwyrm/templates/snippets/status/content_status.html b/bookwyrm/templates/snippets/status/content_status.html index 788ea3950..01734cc78 100644 --- a/bookwyrm/templates/snippets/status/content_status.html +++ b/bookwyrm/templates/snippets/status/content_status.html @@ -67,12 +67,18 @@ {% endif %} {% if status.content_warning %} -
-

{{ status.content_warning }}

+
+

+ {% trans "Content warning" as text %} + + {{ text }} + + {{ status.content_warning }} +

- {% trans "Show more" as button_text %} + {% trans "Show status" as button_text %} - {% with text=button_text class="is-small" controls_text="show_status_cw" controls_uid=status.id %} + {% with text=button_text class="is-small is-pulled-right" icon_with_text="arrow-down" controls_text="show_status_cw" controls_uid=status.id %} {% include 'snippets/toggle/open_button.html' %} {% endwith %}
@@ -84,17 +90,9 @@ class="is-hidden" {% endif %} > - {% if status.content_warning %} - {% trans "Show less" as button_text %} - - {% with text=button_text class="is-small" controls_text="show_status_cw" controls_uid=status.id %} - {% include 'snippets/toggle/close_button.html' %} - {% endwith %} - {% endif %} - {% if status.quote %}
-
{{ status.quote|safe }}
+
{{ status.quote|safe }}

— {% include 'snippets/book_titleby.html' with book=status.book %} @@ -141,6 +139,14 @@

{% endif %} + + {% if status.content_warning %} + {% trans "Hide status" as button_text %} + + {% with text=button_text class="is-small" controls_text="show_status_cw" controls_uid=status.id icon_with_text="arrow-up" %} + {% include 'snippets/toggle/close_button.html' %} + {% endwith %} + {% endif %}
diff --git a/bookwyrm/templates/snippets/status/layout.html b/bookwyrm/templates/snippets/status/layout.html index 6ffdd0f72..39445d9c1 100644 --- a/bookwyrm/templates/snippets/status/layout.html +++ b/bookwyrm/templates/snippets/status/layout.html @@ -66,7 +66,7 @@ {% block card-bonus %} {% if request.user.is_authenticated and not moderation_mode %} {% with status.id|uuid as uuid %} -