diff --git a/.editorconfig b/.editorconfig index d102bc5a..f2e8a178 100644 --- a/.editorconfig +++ b/.editorconfig @@ -32,7 +32,7 @@ indent_size = 2 max_line_length = off # Computer generated files -[{package.json,*.lock,*.mo}] +[{icons.css,package.json,*.lock,*.mo}] indent_size = unset indent_style = unset max_line_length = unset diff --git a/.env.dev.example b/.env.dev.example index f42aaaae..1e4fb981 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 @@ -43,6 +43,9 @@ EMAIL_HOST_PASSWORD=emailpassword123 EMAIL_USE_TLS=true EMAIL_USE_SSL=false +# Thumbnails Generation +ENABLE_THUMBNAIL_GENERATION=false + # S3 configuration USE_S3=false AWS_ACCESS_KEY_ID= @@ -58,6 +61,7 @@ AWS_SECRET_ACCESS_KEY= # AWS_S3_REGION_NAME=None # "fr-par" # AWS_S3_ENDPOINT_URL=None # "https://s3.fr-par.scw.cloud" + # Preview image generation can be computing and storage intensive # ENABLE_PREVIEW_IMAGES=True diff --git a/.env.prod.example b/.env.prod.example index 5115469c..49729d53 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 @@ -43,6 +43,9 @@ EMAIL_HOST_PASSWORD=emailpassword123 EMAIL_USE_TLS=true EMAIL_USE_SSL=false +# Thumbnails Generation +ENABLE_THUMBNAIL_GENERATION=false + # S3 configuration USE_S3=false AWS_ACCESS_KEY_ID= @@ -58,6 +61,7 @@ AWS_SECRET_ACCESS_KEY= # AWS_S3_REGION_NAME=None # "fr-par" # AWS_S3_ENDPOINT_URL=None # "https://s3.fr-par.scw.cloud" + # Preview image generation can be computing and storage intensive # ENABLE_PREVIEW_IMAGES=True diff --git a/.github/workflows/curlylint.yaml b/.github/workflows/curlylint.yaml new file mode 100644 index 00000000..593a4283 --- /dev/null +++ b/.github/workflows/curlylint.yaml @@ -0,0 +1,28 @@ +name: Templates validator + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Install curlylint + run: pip install curlylint + + - name: Run linter + run: > + curlylint --rule 'aria_role: true' \ + --rule 'django_forms_rendering: true' \ + --rule 'html_has_lang: true' \ + --rule 'image_alt: true' \ + --rule 'meta_viewport: true' \ + --rule 'no_autofocus: true' \ + --rule 'tabindex_no_positive: true' \ + --exclude '_modal.html|create_status/layout.html|reading_modals/layout.html' \ + bookwyrm/templates diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml index 03147744..03875193 100644 --- a/.github/workflows/django-tests.yml +++ b/.github/workflows/django-tests.yml @@ -36,6 +36,7 @@ jobs: env: SECRET_KEY: beepbeep DEBUG: false + USE_HTTPS: true DOMAIN: your.domain.here BOOKWYRM_DATABASE_BACKEND: postgres MEDIA_ROOT: images/ diff --git a/Dockerfile b/Dockerfile index 1892ae23..349dd82b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,4 +8,4 @@ WORKDIR /app COPY requirements.txt /app/ RUN pip install -r requirements.txt --no-cache-dir -RUN apt-get update && apt-get install -y gettext libgettextpo-dev && apt-get clean +RUN apt-get update && apt-get install -y gettext libgettextpo-dev tidy && apt-get clean diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 81762388..24d383ac 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -48,7 +48,7 @@ class Signature: def naive_parse(activity_objects, activity_json, serializer=None): - """this navigates circular import issues""" + """this navigates circular import issues by looking up models' serializers""" if not serializer: if activity_json.get("publicKeyPem"): # ugh @@ -101,12 +101,15 @@ 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) - def to_model(self, model=None, instance=None, allow_create=True, save=True): + # pylint: disable=too-many-locals,too-many-branches,too-many-arguments + def to_model( + self, model=None, instance=None, allow_create=True, save=True, overwrite=True + ): """convert from an activity to a model instance""" model = model or get_model_from_type(self.type) @@ -126,27 +129,41 @@ class ActivityObject: return None instance = instance or model() + # keep track of what we've changed + update_fields = [] + # sets field on the model using the activity value for field in instance.simple_fields: try: - field.set_field_from_activity(instance, self) + changed = field.set_field_from_activity( + instance, self, overwrite=overwrite + ) + if changed: + update_fields.append(field.name) except AttributeError as e: raise ActivitySerializerError(e) # image fields have to be set after other fields because they can save # too early and jank up users for field in instance.image_fields: - field.set_field_from_activity(instance, self, save=save) + changed = field.set_field_from_activity( + instance, self, save=save, overwrite=overwrite + ) + if changed: + update_fields.append(field.name) if not save: return instance with transaction.atomic(): + # can't force an update on fields unless the object already exists in the db + if not instance.id: + update_fields = None # we can't set many to many and reverse fields on an unsaved object try: try: - instance.save(broadcast=False) + instance.save(broadcast=False, update_fields=update_fields) except TypeError: - instance.save() + instance.save(update_fields=update_fields) except IntegrityError as e: raise ActivitySerializerError(e) @@ -196,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): @@ -217,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 @@ -248,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] @@ -258,6 +275,8 @@ def resolve_remote_id( ): """take a remote_id and return an instance, creating if necessary""" if model: # a bonus check we can do if we already know the model + if isinstance(model, str): + model = apps.get_model(f"bookwyrm.{model}", require_ready=True) result = model.find_existing_by_remote_id(remote_id) if result and not refresh: return result if not get_activity else result.to_activity_dataclass() @@ -267,7 +286,7 @@ def resolve_remote_id( data = get_data(remote_id) except ConnectorException: raise ActivitySerializerError( - "Could not connect to host for remote_id in: %s" % (remote_id) + f"Could not connect to host for remote_id: {remote_id}" ) # determine the model implicitly, if not provided # or if it's a model with subclasses like Status, check again diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index bd27c4e6..d8599c4b 100644 --- a/bookwyrm/activitypub/book.py +++ b/bookwyrm/activitypub/book.py @@ -54,6 +54,7 @@ class Edition(Book): asin: str = "" pages: int = None physicalFormat: str = "" + physicalFormatDetail: str = "" publishers: List[str] = field(default_factory=lambda: []) editionRank: int = 0 diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py index 916da2d0..eb18b8b8 100644 --- a/bookwyrm/activitypub/note.py +++ b/bookwyrm/activitypub/note.py @@ -30,11 +30,12 @@ class Note(ActivityObject): to: List[str] = field(default_factory=lambda: []) cc: List[str] = field(default_factory=lambda: []) replies: Dict = field(default_factory=lambda: {}) - inReplyTo: str = "" - summary: str = "" + inReplyTo: str = None + summary: str = None tag: List[Link] = field(default_factory=lambda: []) attachment: List[Document] = field(default_factory=lambda: []) sensitive: bool = False + updated: str = None type: str = "Note" @@ -59,6 +60,9 @@ class Comment(Note): """like a note but with a book""" inReplyToBook: str + readingStatus: str = None + progress: int = None + progressMode: str = None type: str = "Comment" @@ -67,6 +71,8 @@ class Quotation(Comment): """a quote and commentary on a book""" quote: str + position: int = None + positionMode: str = None type: str = "Quotation" diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py index 50a479b7..b32b0413 100644 --- a/bookwyrm/activitypub/verbs.py +++ b/bookwyrm/activitypub/verbs.py @@ -69,8 +69,9 @@ class Update(Verb): def action(self): """update a model instance from the dataclass""" - if self.object: - self.object.to_model(allow_create=False) + if not self.object: + return + self.object.to_model(allow_create=False) @dataclass(init=False) diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py index a49a7ce4..4896e07d 100644 --- a/bookwyrm/activitystreams.py +++ b/bookwyrm/activitystreams.py @@ -1,35 +1,40 @@ """ 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 -from bookwyrm.views.helpers import privacy_filter +from bookwyrm.tasks import app class ActivityStream(RedisStore): - """a category of activity stream (like home, local, federated)""" + """a category of activity stream (like home, local, books)""" 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""" return obj.published_date.timestamp() - def add_status(self, status): + def add_status(self, status, increment_unread=False): """add a status to users' feeds""" # the pipeline contains all the add-to-stream activities pipeline = self.add_object_to_related_stores(status, execute=False) - for user in self.get_audience(status): - # add to the unread status count - pipeline.incr(self.unread_id(user)) + if increment_unread: + for user in self.get_audience(status): + # add to the unread status count + pipeline.incr(self.unread_id(user)) # and go! pipeline.execute() @@ -37,7 +42,7 @@ class ActivityStream(RedisStore): def add_user_statuses(self, viewer, user): """add a user's statuses to another user's feed""" # only add the statuses that the viewer should be able to see (ie, not dms) - statuses = privacy_filter(viewer, user.status_set.all()) + statuses = models.Status.privacy_filter(viewer).filter(user=user) self.bulk_add_objects_to_store(statuses, self.stream_id(viewer)) def remove_user_statuses(self, viewer, user): @@ -55,7 +60,13 @@ class ActivityStream(RedisStore): return ( models.Status.objects.select_subclasses() .filter(id__in=statuses) - .select_related("user", "reply_parent") + .select_related( + "user", + "reply_parent", + "comment__book", + "review__book", + "quotation__book", + ) .prefetch_related("mention_books", "mention_users") .order_by("-published_date") ) @@ -101,9 +112,8 @@ class ActivityStream(RedisStore): def get_statuses_for_user(self, user): # pylint: disable=no-self-use """given a user, what statuses should they see on this stream""" - return privacy_filter( + return models.Status.privacy_filter( user, - models.Status.objects.select_subclasses(), privacy_levels=["public", "unlisted", "followers"], ) @@ -127,11 +137,15 @@ class HomeStream(ActivityStream): ).distinct() def get_statuses_for_user(self, user): - return privacy_filter( + return models.Status.privacy_filter( user, - models.Status.objects.select_subclasses(), privacy_levels=["public", "unlisted", "followers"], - following_only=True, + ).exclude( + ~Q( # remove everything except + Q(user__followers=user) # user following + | Q(user=user) # is self + | Q(mention_users=user) # mentions user + ), ) @@ -148,36 +162,98 @@ class LocalStream(ActivityStream): def get_statuses_for_user(self, user): # all public statuses by a local user - return privacy_filter( + return models.Status.privacy_filter( user, - models.Status.objects.select_subclasses().filter(user__local=True), privacy_levels=["public"], - ) + ).filter(user__local=True) -class FederatedStream(ActivityStream): - """users you follow""" +class BooksStream(ActivityStream): + """books on your shelves""" - key = "federated" + key = "books" def get_audience(self, status): - # this stream wants no part in non-public statuses - if status.privacy != "public": + """anyone with the mentioned book on their shelves""" + # only show public statuses on the books feed, + # and only statuses that mention books + if status.privacy != "public" or not ( + status.mention_books.exists() or hasattr(status, "book") + ): return [] - return super().get_audience(status) - def get_statuses_for_user(self, user): - return privacy_filter( - user, - models.Status.objects.select_subclasses(), - privacy_levels=["public"], + work = ( + status.book.parent_work + if hasattr(status, "book") + else status.mention_books.first().parent_work ) + audience = super().get_audience(status) + if not audience: + return [] + return audience.filter(shelfbook__book__parent_work=work).distinct() + def get_statuses_for_user(self, user): + """any public status that mentions the user's books""" + books = user.shelfbook_set.values_list( + "book__parent_work__id", flat=True + ).distinct() + return ( + models.Status.privacy_filter( + user, + privacy_levels=["public"], + ) + .filter( + Q(comment__book__parent_work__id__in=books) + | Q(quotation__book__parent_work__id__in=books) + | Q(review__book__parent_work__id__in=books) + | Q(mention_books__parent_work__id__in=books) + ) + .distinct() + ) + + def add_book_statuses(self, user, book): + """add statuses about a book to a user's feed""" + work = book.parent_work + statuses = ( + models.Status.privacy_filter( + user, + privacy_levels=["public"], + ) + .filter( + Q(comment__book__parent_work=work) + | Q(quotation__book__parent_work=work) + | Q(review__book__parent_work=work) + | Q(mention_books__parent_work=work) + ) + .distinct() + ) + self.bulk_add_objects_to_store(statuses, self.stream_id(user)) + + def remove_book_statuses(self, user, book): + """add statuses about a book to a user's feed""" + work = book.parent_work + statuses = ( + models.Status.privacy_filter( + user, + privacy_levels=["public"], + ) + .filter( + Q(comment__book__parent_work=work) + | Q(quotation__book__parent_work=work) + | Q(review__book__parent_work=work) + | Q(mention_books__parent_work=work) + ) + .distinct() + ) + self.bulk_remove_objects_from_store(statuses, self.stream_id(user)) + + +# determine which streams are enabled in settings.py streams = { "home": HomeStream(), "local": LocalStream(), - "federated": FederatedStream(), + "books": BooksStream(), } @@ -190,41 +266,31 @@ def add_status_on_create(sender, instance, created, *args, **kwargs): return if instance.deleted: - for stream in streams.values(): - stream.remove_object_from_related_stores(instance) + remove_status_task.delay(instance.id) return - if not created: - return - - # iterates through Home, Local, Federated - for stream in streams.values(): - stream.add_status(instance) - - if sender != models.Boost: - return - # remove the original post and other, earlier boosts - boosted = instance.boost.boosted_status - old_versions = models.Boost.objects.filter( - boosted_status__id=boosted.id, - created_date__lt=instance.created_date, + # when creating new things, gotta wait on the transaction + transaction.on_commit( + lambda: add_status_on_create_command(sender, instance, created) ) - for stream in streams.values(): - stream.remove_object_from_related_stores(boosted) - for status in old_versions: - stream.remove_object_from_related_stores(status) + + +def add_status_on_create_command(sender, instance, created): + """runs this code only after the database commit completes""" + add_status_task.delay(instance.id, increment_unread=created) + + if sender == models.Boost: + handle_boost_task.delay(instance.id) @receiver(signals.post_delete, sender=models.Boost) # pylint: disable=unused-argument def remove_boost_on_delete(sender, instance, *args, **kwargs): """boosts are deleted""" - # we're only interested in new statuses - for stream in streams.values(): - # remove the boost - stream.remove_object_from_related_stores(instance) - # re-add the original status - stream.add_status(instance.boosted_status) + # remove the boost + remove_status_task.delay(instance.id) + # re-add the original status + add_status_task.delay(instance.boosted_status.id) @receiver(signals.post_save, sender=models.UserFollows) @@ -233,7 +299,9 @@ def add_statuses_on_follow(sender, instance, created, *args, **kwargs): """add a newly followed user's statuses to feeds""" if not created or not instance.user_subject.local: return - HomeStream().add_user_statuses(instance.user_subject, instance.user_object) + add_user_statuses_task.delay( + instance.user_subject.id, instance.user_object.id, stream_list=["home"] + ) @receiver(signals.post_delete, sender=models.UserFollows) @@ -242,7 +310,9 @@ def remove_statuses_on_unfollow(sender, instance, *args, **kwargs): """remove statuses from a feed on unfollow""" if not instance.user_subject.local: return - HomeStream().remove_user_statuses(instance.user_subject, instance.user_object) + remove_user_statuses_task.delay( + instance.user_subject.id, instance.user_object.id, stream_list=["home"] + ) @receiver(signals.post_save, sender=models.UserBlocks) @@ -251,29 +321,45 @@ def remove_statuses_on_block(sender, instance, *args, **kwargs): """remove statuses from all feeds on block""" # blocks apply ot all feeds if instance.user_subject.local: - for stream in streams.values(): - stream.remove_user_statuses(instance.user_subject, instance.user_object) + remove_user_statuses_task.delay( + instance.user_subject.id, instance.user_object.id + ) # and in both directions if instance.user_object.local: - for stream in streams.values(): - stream.remove_user_statuses(instance.user_object, instance.user_subject) + remove_user_statuses_task.delay( + instance.user_object.id, instance.user_subject.id + ) @receiver(signals.post_delete, sender=models.UserBlocks) # pylint: disable=unused-argument def add_statuses_on_unblock(sender, instance, *args, **kwargs): - """remove statuses from all feeds on block""" - public_streams = [LocalStream(), FederatedStream()] + """add statuses back to all feeds on unblock""" + # make sure there isn't a block in the other direction + if models.UserBlocks.objects.filter( + user_subject=instance.user_object, + user_object=instance.user_subject, + ).exists(): + return + + public_streams = [k for (k, v) in streams.items() if k != "home"] + # add statuses back to streams with statuses from anyone if instance.user_subject.local: - for stream in public_streams: - stream.add_user_statuses(instance.user_subject, instance.user_object) + add_user_statuses_task.delay( + instance.user_subject.id, + instance.user_object.id, + stream_list=public_streams, + ) # add statuses back to streams with statuses from anyone if instance.user_object.local: - for stream in public_streams: - stream.add_user_statuses(instance.user_object, instance.user_subject) + add_user_statuses_task.delay( + instance.user_object.id, + instance.user_subject.id, + stream_list=public_streams, + ) @receiver(signals.post_save, sender=models.User) @@ -283,5 +369,130 @@ def populate_streams_on_account_create(sender, instance, created, *args, **kwarg if not created or not instance.local: return + for stream in streams: + populate_stream_task.delay(stream, instance.id) + + +@receiver(signals.pre_save, sender=models.ShelfBook) +# pylint: disable=unused-argument +def add_statuses_on_shelve(sender, instance, *args, **kwargs): + """update books stream when user shelves a book""" + if not instance.user.local: + return + book = instance.book + + # check if the book is already on the user's shelves + editions = book.parent_work.editions.all() + if models.ShelfBook.objects.filter(user=instance.user, book__in=editions).exists(): + return + + add_book_statuses_task.delay(instance.user.id, book.id) + + +@receiver(signals.post_delete, sender=models.ShelfBook) +# pylint: disable=unused-argument +def remove_statuses_on_unshelve(sender, instance, *args, **kwargs): + """update books stream when user unshelves a book""" + if not instance.user.local: + return + + book = instance.book + + # check if the book is actually unshelved, not just moved + editions = book.parent_work.editions.all() + if models.ShelfBook.objects.filter(user=instance.user, book__in=editions).exists(): + return + + remove_book_statuses_task.delay(instance.user.id, book.id) + + +# ---- TASKS + + +@app.task(queue="low_priority") +def add_book_statuses_task(user_id, book_id): + """add statuses related to a book on shelve""" + user = models.User.objects.get(id=user_id) + book = models.Edition.objects.get(id=book_id) + BooksStream().add_book_statuses(user, book) + + +@app.task(queue="low_priority") +def remove_book_statuses_task(user_id, book_id): + """remove statuses about a book from a user's books feed""" + user = models.User.objects.get(id=user_id) + book = models.Edition.objects.get(id=book_id) + BooksStream().remove_book_statuses(user, book) + + +@app.task(queue="medium_priority") +def populate_stream_task(stream, user_id): + """background task for populating an empty activitystream""" + user = models.User.objects.get(id=user_id) + stream = streams[stream] + stream.populate_streams(user) + + +@app.task(queue="medium_priority") +def remove_status_task(status_ids): + """remove a status from any stream it might be in""" + # this can take an id or a list of ids + if not isinstance(status_ids, list): + status_ids = [status_ids] + statuses = models.Status.objects.filter(id__in=status_ids) + for stream in streams.values(): - stream.populate_streams(instance) + for status in statuses: + stream.remove_object_from_related_stores(status) + + +@app.task(queue="high_priority") +def add_status_task(status_id, increment_unread=False): + """add a status to any stream it should be in""" + status = models.Status.objects.get(id=status_id) + # we don't want to tick the unread count for csv import statuses, idk how better + # to check than just to see if the states is more than a few days old + if status.created_date < timezone.now() - timedelta(days=2): + increment_unread = False + for stream in streams.values(): + stream.add_status(status, increment_unread=increment_unread) + + +@app.task(queue="medium_priority") +def remove_user_statuses_task(viewer_id, user_id, stream_list=None): + """remove all statuses by a user from a viewer's stream""" + stream_list = [streams[s] for s in stream_list] if stream_list else streams.values() + viewer = models.User.objects.get(id=viewer_id) + user = models.User.objects.get(id=user_id) + for stream in stream_list: + stream.remove_user_statuses(viewer, user) + + +@app.task(queue="medium_priority") +def add_user_statuses_task(viewer_id, user_id, stream_list=None): + """add all statuses by a user to a viewer's stream""" + stream_list = [streams[s] for s in stream_list] if stream_list else streams.values() + viewer = models.User.objects.get(id=viewer_id) + user = models.User.objects.get(id=user_id) + for stream in stream_list: + stream.add_user_statuses(viewer, user) + + +@app.task(queue="medium_priority") +def handle_boost_task(boost_id): + """remove the original post and other, earlier boosts""" + instance = models.Status.objects.get(id=boost_id) + boosted = instance.boost.boosted_status + + # previous boosts of this status + old_versions = models.Boost.objects.filter( + boosted_status__id=boosted.id, + created_date__lt=instance.created_date, + ) + + for stream in streams.values(): + # people who should see the boost (not people who see the original status) + audience = stream.get_stores_for_object(instance) + stream.remove_object_from_related_stores(boosted, stores=audience) + for status in old_versions: + stream.remove_object_from_related_stores(status, stores=audience) diff --git a/bookwyrm/book_search.py b/bookwyrm/book_search.py new file mode 100644 index 00000000..6c89b61f --- /dev/null +++ b/bookwyrm/book_search.py @@ -0,0 +1,156 @@ +""" using a bookwyrm instance as a source of book data """ +from dataclasses import asdict, dataclass +from functools import reduce +import operator + +from django.contrib.postgres.search import SearchRank, SearchQuery +from django.db.models import OuterRef, Subquery, F, Q + +from bookwyrm import models +from bookwyrm.settings import MEDIA_FULL_URL + + +# pylint: disable=arguments-differ +def search(query, min_confidence=0, filters=None, return_first=False): + """search your local database""" + filters = filters or [] + if not query: + return [] + # first, try searching unqiue identifiers + results = search_identifiers(query, *filters, return_first=return_first) + if not results: + # then try searching title/author + results = search_title_author( + query, min_confidence, *filters, return_first=return_first + ) + return results + + +def isbn_search(query): + """search your local database""" + if not query: + return [] + + filters = [{f: query} for f in ["isbn_10", "isbn_13"]] + results = models.Edition.objects.filter( + reduce(operator.or_, (Q(**f) for f in filters)) + ).distinct() + + # when there are multiple editions of the same work, pick the default. + # it would be odd for this to happen. + + default_editions = models.Edition.objects.filter( + parent_work=OuterRef("parent_work") + ).order_by("-edition_rank") + results = ( + results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter( + default_id=F("id") + ) + or results + ) + return results + + +def format_search_result(search_result): + """convert a book object into a search result object""" + cover = None + if search_result.cover: + cover = f"{MEDIA_FULL_URL}{search_result.cover}" + + return SearchResult( + title=search_result.title, + key=search_result.remote_id, + author=search_result.author_text, + year=search_result.published_date.year + if search_result.published_date + else None, + cover=cover, + confidence=search_result.rank if hasattr(search_result, "rank") else 1, + connector="", + ).json() + + +def search_identifiers(query, *filters, return_first=False): + """tries remote_id, isbn; defined as dedupe fields on the model""" + # pylint: disable=W0212 + or_filters = [ + {f.name: query} + for f in models.Edition._meta.get_fields() + if hasattr(f, "deduplication_field") and f.deduplication_field + ] + results = models.Edition.objects.filter( + *filters, reduce(operator.or_, (Q(**f) for f in or_filters)) + ).distinct() + if results.count() <= 1: + return results + + # when there are multiple editions of the same work, pick the default. + # it would be odd for this to happen. + default_editions = models.Edition.objects.filter( + parent_work=OuterRef("parent_work") + ).order_by("-edition_rank") + results = ( + results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter( + default_id=F("id") + ) + or results + ) + if return_first: + return results.first() + return results + + +def search_title_author(query, min_confidence, *filters, return_first=False): + """searches for title and author""" + query = SearchQuery(query, config="simple") | SearchQuery(query, config="english") + results = ( + models.Edition.objects.filter(*filters, search_vector=query) + .annotate(rank=SearchRank(F("search_vector"), query)) + .filter(rank__gt=min_confidence) + .order_by("-rank") + ) + + # when there are multiple editions of the same work, pick the closest + editions_of_work = results.values("parent_work__id").values_list("parent_work__id") + + # filter out multiple editions of the same work + list_results = [] + for work_id in set(editions_of_work): + editions = results.filter(parent_work=work_id) + default = editions.order_by("-edition_rank").first() + default_rank = default.rank if default else 0 + # if mutliple books have the top rank, pick the default edition + if default_rank == editions.first().rank: + result = default + else: + result = editions.first() + if return_first: + return result + list_results.append(result) + return list_results + + +@dataclass +class SearchResult: + """standardized search result object""" + + title: str + key: str + connector: object + view_link: str = None + author: str = None + year: str = None + cover: str = None + confidence: int = 1 + + def __repr__(self): + # pylint: disable=consider-using-f-string + return "".format( + self.key, self.title, self.author + ) + + def json(self): + """serialize a connector for json response""" + serialized = asdict(self) + del serialized["connector"] + return serialized diff --git a/bookwyrm/connectors/__init__.py b/bookwyrm/connectors/__init__.py index 689f2701..efbdb166 100644 --- a/bookwyrm/connectors/__init__.py +++ b/bookwyrm/connectors/__init__.py @@ -3,4 +3,4 @@ from .settings import CONNECTORS from .abstract_connector import ConnectorException from .abstract_connector import get_data, get_image -from .connector_manager import search, local_search, first_search_result +from .connector_manager import search, first_search_result diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index fb102ea4..c032986d 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -1,6 +1,5 @@ """ functionality outline for a book data connector """ from abc import ABC, abstractmethod -from dataclasses import asdict, dataclass import logging from django.db import transaction @@ -9,6 +8,7 @@ from requests.exceptions import RequestException from bookwyrm import activitypub, models, settings from .connector_manager import load_more_data, ConnectorException +from .format_mappings import format_mappings logger = logging.getLogger(__name__) @@ -31,7 +31,6 @@ class AbstractMinimalConnector(ABC): "isbn_search_url", "name", "identifier", - "local", ] for field in self_fields: setattr(self, field, getattr(info, field)) @@ -43,7 +42,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 +56,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 +130,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 @@ -139,7 +138,7 @@ class AbstractConnector(AbstractMinimalConnector): **dict_from_mappings(work_data, self.book_mappings) ) # this will dedupe automatically - work = work_activity.to_model(model=models.Work) + work = work_activity.to_model(model=models.Work, overwrite=False) for author in self.get_authors_from_data(work_data): work.authors.add(author) @@ -156,7 +155,7 @@ class AbstractConnector(AbstractMinimalConnector): mapped_data = dict_from_mappings(edition_data, self.book_mappings) mapped_data["work"] = work.remote_id edition_activity = activitypub.Edition(**mapped_data) - edition = edition_activity.to_model(model=models.Edition) + edition = edition_activity.to_model(model=models.Edition, overwrite=False) edition.connector = self.connector edition.save() @@ -182,7 +181,7 @@ class AbstractConnector(AbstractMinimalConnector): return None # this will dedupe - return activity.to_model(model=models.Author) + return activity.to_model(model=models.Author, overwrite=False) @abstractmethod def is_work_data(self, data): @@ -222,9 +221,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( @@ -269,31 +266,6 @@ def get_image(url, timeout=10): return resp -@dataclass -class SearchResult: - """standardized search result object""" - - title: str - key: str - connector: object - view_link: str = None - author: str = None - year: str = None - cover: str = None - confidence: int = 1 - - def __repr__(self): - return "".format( - self.key, self.title, self.author - ) - - def json(self): - """serialize a connector for json response""" - serialized = asdict(self) - del serialized["connector"] - return serialized - - class Mapping: """associate a local database field with a field in an external dataset""" @@ -313,3 +285,25 @@ class Mapping: return self.formatter(value) except: # pylint: disable=bare-except return None + + +def infer_physical_format(format_text): + """try to figure out what the standardized format is from the free value""" + format_text = format_text.lower() + if format_text in format_mappings: + # try a direct match + return format_mappings[format_text] + # failing that, try substring + matches = [v for k, v in format_mappings.items() if k in format_text] + if not matches: + return None + return matches[0] + + +def unique_physical_format(format_text): + """only store the format if it isn't diretly in the format mappings""" + format_text = format_text.lower() + if format_text in format_mappings: + # try a direct match, so saving this would be redundant + return None + return format_text diff --git a/bookwyrm/connectors/bookwyrm_connector.py b/bookwyrm/connectors/bookwyrm_connector.py index 10a633b2..6dcba7c3 100644 --- a/bookwyrm/connectors/bookwyrm_connector.py +++ b/bookwyrm/connectors/bookwyrm_connector.py @@ -1,6 +1,7 @@ """ using another bookwyrm instance as a source of book data """ from bookwyrm import activitypub, models -from .abstract_connector import AbstractMinimalConnector, SearchResult +from bookwyrm.book_search import SearchResult +from .abstract_connector import AbstractMinimalConnector class Connector(AbstractMinimalConnector): diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py index 1a615c9b..45530cd6 100644 --- a/bookwyrm/connectors/connector_manager.py +++ b/bookwyrm/connectors/connector_manager.py @@ -10,7 +10,7 @@ from django.db.models import signals from requests import HTTPError -from bookwyrm import models +from bookwyrm import book_search, models from bookwyrm.tasks import app logger = logging.getLogger(__name__) @@ -55,7 +55,7 @@ def search(query, min_confidence=0.1, return_first=False): # if we found anything, return it return result_set[0] - if result_set or connector.local: + if result_set: results.append( { "connector": connector, @@ -71,22 +71,13 @@ def search(query, min_confidence=0.1, return_first=False): return results -def local_search(query, min_confidence=0.1, raw=False, filters=None): - """only look at local search results""" - connector = load_connector(models.Connector.objects.get(local=True)) - return connector.search( - query, min_confidence=min_confidence, raw=raw, filters=filters - ) - - -def isbn_local_search(query, raw=False): - """only look at local search results""" - connector = load_connector(models.Connector.objects.get(local=True)) - return connector.isbn_search(query, raw=raw) - - def first_search_result(query, min_confidence=0.1): """search until you find a result that fits""" + # try local search first + result = book_search.search(query, min_confidence=min_confidence, return_first=True) + if result: + return result + # otherwise, try remote endpoints return search(query, min_confidence=min_confidence, return_first=True) or None @@ -109,17 +100,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 +122,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 +132,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/format_mappings.py b/bookwyrm/connectors/format_mappings.py new file mode 100644 index 00000000..61f61efa --- /dev/null +++ b/bookwyrm/connectors/format_mappings.py @@ -0,0 +1,43 @@ +""" comparing a free text format to the standardized one """ +format_mappings = { + "paperback": "Paperback", + "soft": "Paperback", + "pamphlet": "Paperback", + "peperback": "Paperback", + "tapa blanda": "Paperback", + "turtleback": "Paperback", + "pocket": "Paperback", + "spiral": "Paperback", + "ring": "Paperback", + "平装": "Paperback", + "简装": "Paperback", + "hardcover": "Hardcover", + "hardcocer": "Hardcover", + "hardover": "Hardcover", + "hardback": "Hardcover", + "library": "Hardcover", + "tapa dura": "Hardcover", + "leather": "Hardcover", + "clothbound": "Hardcover", + "精装": "Hardcover", + "ebook": "EBook", + "e-book": "EBook", + "digital": "EBook", + "computer file": "EBook", + "epub": "EBook", + "online": "EBook", + "pdf": "EBook", + "elektronische": "EBook", + "electronic": "EBook", + "audiobook": "AudiobookFormat", + "audio": "AudiobookFormat", + "cd": "AudiobookFormat", + "dvd": "AudiobookFormat", + "mp3": "AudiobookFormat", + "cassette": "AudiobookFormat", + "kindle": "AudiobookFormat", + "talking": "AudiobookFormat", + "sound": "AudiobookFormat", + "comic": "GraphicNovel", + "graphic": "GraphicNovel", +} diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py index 116aa5c1..faed5429 100644 --- a/bookwyrm/connectors/inventaire.py +++ b/bookwyrm/connectors/inventaire.py @@ -2,13 +2,14 @@ import re from bookwyrm import models -from .abstract_connector import AbstractConnector, SearchResult, Mapping +from bookwyrm.book_search import SearchResult +from .abstract_connector import AbstractConnector, Mapping from .abstract_connector import get_data from .connector_manager import ConnectorException class Connector(AbstractConnector): - """instantiate a connector for OL""" + """instantiate a connector for inventaire""" def __init__(self, identifier): super().__init__(identifier) @@ -59,7 +60,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) @@ -71,7 +72,7 @@ class Connector(AbstractConnector): # flatten the data so that images, uri, and claims are on the same level return { **data.get("claims", {}), - **{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks"]}, + **{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks", "type"]}, } def search(self, query, min_confidence=None): # pylint: disable=arguments-differ @@ -87,11 +88,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 +96,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 +118,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,18 +128,14 @@ 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): data = self.load_edition_data(data.get("uri")) try: - uri = data["uris"][0] - except KeyError: + uri = data.get("uris", [])[0] + except IndexError: raise ConnectorException("Invalid book data") return self.get_book_data(self.get_remote_id(uri)) @@ -195,7 +184,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 +202,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 e58749c1..b8afc7ca 100644 --- a/bookwyrm/connectors/openlibrary.py +++ b/bookwyrm/connectors/openlibrary.py @@ -2,8 +2,9 @@ import re from bookwyrm import models -from .abstract_connector import AbstractConnector, SearchResult, Mapping -from .abstract_connector import get_data +from bookwyrm.book_search import SearchResult +from .abstract_connector import AbstractConnector, Mapping +from .abstract_connector import get_data, infer_physical_format, unique_physical_format from .connector_manager import ConnectorException from .openlibrary_languages import languages @@ -43,7 +44,16 @@ class Connector(AbstractConnector): ), Mapping("publishedDate", remote_field="publish_date"), Mapping("pages", remote_field="number_of_pages"), - Mapping("physicalFormat", remote_field="physical_format"), + Mapping( + "physicalFormat", + remote_field="physical_format", + formatter=infer_physical_format, + ), + Mapping( + "physicalFormatDetail", + remote_field="physical_format", + formatter=unique_physical_format, + ), Mapping("publishers"), ] @@ -71,7 +81,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 +91,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 +103,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 +112,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 +123,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 +162,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 deleted file mode 100644 index 8d5a7614..00000000 --- a/bookwyrm/connectors/self_connector.py +++ /dev/null @@ -1,164 +0,0 @@ -""" using a bookwyrm instance as a source of book data """ -from functools import reduce -import operator - -from django.contrib.postgres.search import SearchRank, SearchQuery -from django.db.models import OuterRef, Subquery, F, Q - -from bookwyrm import models -from .abstract_connector import AbstractConnector, SearchResult - - -class Connector(AbstractConnector): - """instantiate a connector""" - - # pylint: disable=arguments-differ - def search(self, query, min_confidence=0, raw=False, filters=None): - """search your local database""" - filters = filters or [] - if not query: - return [] - # first, try searching unqiue identifiers - results = search_identifiers(query, *filters) - if not results: - # then try searching title/author - results = search_title_author(query, min_confidence, *filters) - search_results = [] - for result in results: - if raw: - search_results.append(result) - else: - search_results.append(self.format_search_result(result)) - if len(search_results) >= 10: - break - if not raw: - search_results.sort(key=lambda r: r.confidence, reverse=True) - return search_results - - def isbn_search(self, query, raw=False): - """search your local database""" - if not query: - return [] - - filters = [{f: query} for f in ["isbn_10", "isbn_13"]] - results = models.Edition.objects.filter( - reduce(operator.or_, (Q(**f) for f in filters)) - ).distinct() - - # when there are multiple editions of the same work, pick the default. - # it would be odd for this to happen. - - default_editions = models.Edition.objects.filter( - parent_work=OuterRef("parent_work") - ).order_by("-edition_rank") - results = ( - results.annotate( - default_id=Subquery(default_editions.values("id")[:1]) - ).filter(default_id=F("id")) - or results - ) - - search_results = [] - for result in results: - if raw: - search_results.append(result) - else: - search_results.append(self.format_search_result(result)) - if len(search_results) >= 10: - break - return search_results - - def format_search_result(self, search_result): - cover = None - if search_result.cover: - cover = "%s%s" % (self.covers_url, search_result.cover) - - return SearchResult( - title=search_result.title, - key=search_result.remote_id, - author=search_result.author_text, - year=search_result.published_date.year - if search_result.published_date - else None, - connector=self, - cover=cover, - confidence=search_result.rank if hasattr(search_result, "rank") else 1, - ) - - def format_isbn_search_result(self, search_result): - return self.format_search_result(search_result) - - def is_work_data(self, data): - pass - - def get_edition_from_work_data(self, data): - pass - - def get_work_from_edition_data(self, data): - pass - - def get_authors_from_data(self, data): - return None - - def parse_isbn_search_data(self, data): - """it's already in the right format, don't even worry about it""" - return data - - def parse_search_data(self, data): - """it's already in the right format, don't even worry about it""" - return data - - def expand_book_data(self, book): - pass - - -def search_identifiers(query, *filters): - """tries remote_id, isbn; defined as dedupe fields on the model""" - # pylint: disable=W0212 - or_filters = [ - {f.name: query} - for f in models.Edition._meta.get_fields() - if hasattr(f, "deduplication_field") and f.deduplication_field - ] - results = models.Edition.objects.filter( - *filters, reduce(operator.or_, (Q(**f) for f in or_filters)) - ).distinct() - if results.count() <= 1: - return results - - # when there are multiple editions of the same work, pick the default. - # it would be odd for this to happen. - default_editions = models.Edition.objects.filter( - parent_work=OuterRef("parent_work") - ).order_by("-edition_rank") - return ( - results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter( - default_id=F("id") - ) - or results - ) - - -def search_title_author(query, min_confidence, *filters): - """searches for title and author""" - query = SearchQuery(query, config="simple") | SearchQuery(query, config="english") - results = ( - models.Edition.objects.filter(*filters, search_vector=query) - .annotate(rank=SearchRank(F("search_vector"), query)) - .filter(rank__gt=min_confidence) - .order_by("-rank") - ) - - # when there are multiple editions of the same work, pick the closest - editions_of_work = results.values("parent_work__id").values_list("parent_work__id") - - # filter out multiple editions of the same work - for work_id in set(editions_of_work): - editions = results.filter(parent_work=work_id) - default = editions.order_by("-edition_rank").first() - default_rank = default.rank if default else 0 - # if mutliple books have the top rank, pick the default edition - if default_rank == editions.first().rank: - yield default - else: - yield editions.first() diff --git a/bookwyrm/connectors/settings.py b/bookwyrm/connectors/settings.py index 4cc98da7..927e39b2 100644 --- a/bookwyrm/connectors/settings.py +++ b/bookwyrm/connectors/settings.py @@ -1,3 +1,3 @@ """ settings book data connectors """ -CONNECTORS = ["openlibrary", "inventaire", "self_connector", "bookwyrm_connector"] +CONNECTORS = ["openlibrary", "inventaire", "bookwyrm_connector"] diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py index 1f0387fe..309e84ed 100644 --- a/bookwyrm/context_processors.py +++ b/bookwyrm/context_processors.py @@ -11,7 +11,9 @@ def site_settings(request): # pylint: disable=unused-argument return { "site": models.SiteSettings.objects.get(), "active_announcements": models.Announcement.active_announcements(), + "thumbnail_generation_enabled": settings.ENABLE_THUMBNAIL_GENERATION, "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 657310b0..c6a197f2 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" @@ -23,6 +23,14 @@ def email_data(): } +def email_confirmation_email(user): + """newly registered users confirm email address""" + data = email_data() + data["confirmation_code"] = user.confirmation_code + data["confirmation_link"] = user.confirmation_link + send_email.delay(user.email, *format_email("confirm", data)) + + def invite_email(invite_request): """send out an invite code""" data = email_data() @@ -40,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 57a94e3c..298f73da 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -29,8 +29,7 @@ class CustomForm(ModelForm): input_type = visible.field.widget.input_type if isinstance(visible.field.widget, Textarea): input_type = "textarea" - visible.field.widget.attrs["cols"] = None - visible.field.widget.attrs["rows"] = None + visible.field.widget.attrs["rows"] = 5 visible.field.widget.attrs["class"] = css_classes[input_type] @@ -86,6 +85,7 @@ class CommentForm(CustomForm): "privacy", "progress", "progress_mode", + "reading_status", ] @@ -100,6 +100,8 @@ class QuotationForm(CustomForm): "content_warning", "sensitive", "privacy", + "position", + "position_mode", ] @@ -122,6 +124,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 @@ -131,9 +139,12 @@ class EditUserForm(CustomForm): "email", "summary", "show_goal", + "show_suggested_users", "manually_approves_followers", + "default_post_privacy", "discoverable", "preferred_timezone", + "preferred_language", ] help_texts = {f: None for f in fields} @@ -217,7 +228,7 @@ class ExpiryWidget(widgets.Select): elif selected_string == "forever": return None else: - return selected_string # "This will raise + return selected_string # This will raise return timezone.now() + interval @@ -249,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"))] ), } @@ -261,7 +269,7 @@ class CreateInviteForm(CustomForm): class ShelfForm(CustomForm): class Meta: model = models.Shelf - fields = ["user", "name", "privacy"] + fields = ["user", "name", "privacy", "description"] class GoalForm(CustomForm): @@ -285,7 +293,13 @@ class AnnouncementForm(CustomForm): class ListForm(CustomForm): class Meta: model = models.List - fields = ["user", "name", "description", "curation", "privacy"] + fields = ["user", "name", "description", "curation", "privacy", "group"] + + +class GroupForm(CustomForm): + class Meta: + model = models.Group + fields = ["user", "privacy", "name", "description"] class ReportForm(CustomForm): @@ -294,6 +308,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/imagegenerators.py b/bookwyrm/imagegenerators.py new file mode 100644 index 00000000..1d065192 --- /dev/null +++ b/bookwyrm/imagegenerators.py @@ -0,0 +1,113 @@ +"""Generators for all the different thumbnail sizes""" +from imagekit import ImageSpec, register +from imagekit.processors import ResizeToFit + + +class BookXSmallWebp(ImageSpec): + """Handles XSmall size in Webp format""" + + processors = [ResizeToFit(80, 80)] + format = "WEBP" + options = {"quality": 95} + + +class BookXSmallJpg(ImageSpec): + """Handles XSmall size in Jpeg format""" + + processors = [ResizeToFit(80, 80)] + format = "JPEG" + options = {"quality": 95} + + +class BookSmallWebp(ImageSpec): + """Handles Small size in Webp format""" + + processors = [ResizeToFit(100, 100)] + format = "WEBP" + options = {"quality": 95} + + +class BookSmallJpg(ImageSpec): + """Handles Small size in Jpeg format""" + + processors = [ResizeToFit(100, 100)] + format = "JPEG" + options = {"quality": 95} + + +class BookMediumWebp(ImageSpec): + """Handles Medium size in Webp format""" + + processors = [ResizeToFit(150, 150)] + format = "WEBP" + options = {"quality": 95} + + +class BookMediumJpg(ImageSpec): + """Handles Medium size in Jpeg format""" + + processors = [ResizeToFit(150, 150)] + format = "JPEG" + options = {"quality": 95} + + +class BookLargeWebp(ImageSpec): + """Handles Large size in Webp format""" + + processors = [ResizeToFit(200, 200)] + format = "WEBP" + options = {"quality": 95} + + +class BookLargeJpg(ImageSpec): + """Handles Large size in Jpeg format""" + + processors = [ResizeToFit(200, 200)] + format = "JPEG" + options = {"quality": 95} + + +class BookXLargeWebp(ImageSpec): + """Handles XLarge size in Webp format""" + + processors = [ResizeToFit(250, 250)] + format = "WEBP" + options = {"quality": 95} + + +class BookXLargeJpg(ImageSpec): + """Handles XLarge size in Jpeg format""" + + processors = [ResizeToFit(250, 250)] + format = "JPEG" + options = {"quality": 95} + + +class BookXxLargeWebp(ImageSpec): + """Handles XxLarge size in Webp format""" + + processors = [ResizeToFit(500, 500)] + format = "WEBP" + options = {"quality": 95} + + +class BookXxLargeJpg(ImageSpec): + """Handles XxLarge size in Jpeg format""" + + processors = [ResizeToFit(500, 500)] + format = "JPEG" + options = {"quality": 95} + + +register.generator("bw:book:xsmall:webp", BookXSmallWebp) +register.generator("bw:book:xsmall:jpg", BookXSmallJpg) +register.generator("bw:book:small:webp", BookSmallWebp) +register.generator("bw:book:small:jpg", BookSmallJpg) +register.generator("bw:book:medium:webp", BookMediumWebp) +register.generator("bw:book:medium:jpg", BookMediumJpg) +register.generator("bw:book:large:webp", BookLargeWebp) +register.generator("bw:book:large:jpg", BookLargeJpg) +register.generator("bw:book:xlarge:webp", BookXLargeWebp) +register.generator("bw:book:xlarge:jpg", BookXLargeJpg) +register.generator("bw:book:xxlarge:webp", BookXxLargeWebp) +register.generator("bw:book:xxlarge:jpg", BookXxLargeJpg) diff --git a/bookwyrm/importers/goodreads_import.py b/bookwyrm/importers/goodreads_import.py index 7b577ea8..c62e6582 100644 --- a/bookwyrm/importers/goodreads_import.py +++ b/bookwyrm/importers/goodreads_import.py @@ -3,10 +3,10 @@ from . import Importer class GoodreadsImporter(Importer): - """GoodReads is the default importer, thus Importer follows its structure. + """Goodreads is the default importer, thus Importer follows its structure. For a more complete example of overriding see librarything_import.py""" - service = "GoodReads" + service = "Goodreads" def parse_fields(self, entry): """handle the specific fields in goodreads csvs""" diff --git a/bookwyrm/importers/importer.py b/bookwyrm/importers/importer.py index d5f1449c..6d898a2a 100644 --- a/bookwyrm/importers/importer.py +++ b/bookwyrm/importers/importer.py @@ -1,8 +1,9 @@ -""" handle reading a csv from an external service, defaults are from GoodReads """ +""" handle reading a csv from an external service, defaults are from Goodreads """ 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/management/commands/initdb.py b/bookwyrm/management/commands/initdb.py index 71ac511a..d0ab648e 100644 --- a/bookwyrm/management/commands/initdb.py +++ b/bookwyrm/management/commands/initdb.py @@ -4,7 +4,6 @@ from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType from bookwyrm.models import Connector, FederatedServer, SiteSettings, User -from bookwyrm.settings import DOMAIN def init_groups(): @@ -73,19 +72,6 @@ def init_permissions(): def init_connectors(): """access book data sources""" - Connector.objects.create( - identifier=DOMAIN, - name="Local", - local=True, - connector_file="self_connector", - base_url="https://%s" % DOMAIN, - books_url="https://%s/book" % DOMAIN, - covers_url="https://%s/images/" % DOMAIN, - search_url="https://%s/search?q=" % DOMAIN, - isbn_search_url="https://%s/isbn/" % DOMAIN, - priority=1, - ) - Connector.objects.create( identifier="bookwyrm.social", name="BookWyrm dot Social", diff --git a/bookwyrm/management/commands/populate_streams.py b/bookwyrm/management/commands/populate_streams.py index 04f6bf6e..a04d7f5a 100644 --- a/bookwyrm/management/commands/populate_streams.py +++ b/bookwyrm/management/commands/populate_streams.py @@ -1,30 +1,37 @@ """ Re-create user streams """ from django.core.management.base import BaseCommand -import redis - -from bookwyrm import activitystreams, models, settings - -r = redis.Redis( - host=settings.REDIS_ACTIVITY_HOST, port=settings.REDIS_ACTIVITY_PORT, db=0 -) +from bookwyrm import activitystreams, models -def populate_streams(): +def populate_streams(stream=None): """build all the streams for all the users""" + streams = [stream] if stream else activitystreams.streams.keys() + print("Populations streams", streams) users = models.User.objects.filter( local=True, is_active=True, - ) + ).order_by("-last_active_date") + print("This may take a long time! Please be patient.") for user in users: - for stream in activitystreams.streams.values(): - stream.populate_streams(user) + for stream_key in streams: + print(".", end="") + activitystreams.populate_stream_task.delay(stream_key, user.id) class Command(BaseCommand): """start all over with user streams""" help = "Populate streams for all users" + + def add_arguments(self, parser): + parser.add_argument( + "--stream", + default=None, + help="Specifies which time of stream to populate", + ) + # pylint: disable=no-self-use,unused-argument def handle(self, *args, **options): """run feed builder""" - populate_streams() + stream = options.get("stream") + populate_streams(stream=stream) diff --git a/bookwyrm/management/commands/populate_suggestions.py b/bookwyrm/management/commands/populate_suggestions.py new file mode 100644 index 00000000..32495497 --- /dev/null +++ b/bookwyrm/management/commands/populate_suggestions.py @@ -0,0 +1,25 @@ +""" Populate suggested users """ +from django.core.management.base import BaseCommand + +from bookwyrm import models +from bookwyrm.suggested_users import rerank_suggestions_task + + +def populate_suggestions(): + """build all the streams for all the users""" + users = models.User.objects.filter( + local=True, + is_active=True, + ).values_list("id", flat=True) + for user in users: + rerank_suggestions_task.delay(user) + + +class Command(BaseCommand): + """start all over with user suggestions""" + + help = "Populate suggested users for all users" + # pylint: disable=no-self-use,unused-argument + def handle(self, *args, **options): + """run builder""" + populate_suggestions() diff --git a/bookwyrm/middleware/__init__.py b/bookwyrm/middleware/__init__.py new file mode 100644 index 00000000..03843c5a --- /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 00000000..8063dd1f --- /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/0046_user_default_post_privacy.py b/bookwyrm/migrations/0046_user_default_post_privacy.py new file mode 100644 index 00000000..f1c8e7c3 --- /dev/null +++ b/bookwyrm/migrations/0046_user_default_post_privacy.py @@ -0,0 +1,27 @@ +# Generated by Django 3.0.7 on 2021-02-14 00:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0045_auto_20210210_2114"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="default_post_privacy", + field=models.CharField( + choices=[ + ("public", "Public"), + ("unlisted", "Unlisted"), + ("followers", "Followers"), + ("direct", "Direct"), + ], + default="public", + max_length=255, + ), + ), + ] diff --git a/bookwyrm/migrations/0079_merge_20210804_1746.py b/bookwyrm/migrations/0079_merge_20210804_1746.py new file mode 100644 index 00000000..ed5d50d0 --- /dev/null +++ b/bookwyrm/migrations/0079_merge_20210804_1746.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.4 on 2021-08-04 17:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0046_user_default_post_privacy"), + ("bookwyrm", "0078_add_shelved_date"), + ] + + operations = [] diff --git a/bookwyrm/migrations/0080_alter_shelfbook_options.py b/bookwyrm/migrations/0080_alter_shelfbook_options.py new file mode 100644 index 00000000..b5ee7e67 --- /dev/null +++ b/bookwyrm/migrations/0080_alter_shelfbook_options.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.4 on 2021-08-05 00:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0079_merge_20210804_1746"), + ] + + operations = [ + migrations.AlterModelOptions( + name="shelfbook", + options={"ordering": ("-shelved_date", "-created_date", "-updated_date")}, + ), + ] diff --git a/bookwyrm/migrations/0081_alter_user_last_active_date.py b/bookwyrm/migrations/0081_alter_user_last_active_date.py new file mode 100644 index 00000000..dc6b640f --- /dev/null +++ b/bookwyrm/migrations/0081_alter_user_last_active_date.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.4 on 2021-08-06 02:51 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0080_alter_shelfbook_options"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="last_active_date", + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/bookwyrm/migrations/0082_auto_20210806_2324.py b/bookwyrm/migrations/0082_auto_20210806_2324.py new file mode 100644 index 00000000..ab0aa158 --- /dev/null +++ b/bookwyrm/migrations/0082_auto_20210806_2324.py @@ -0,0 +1,56 @@ +# Generated by Django 3.2.4 on 2021-08-06 23:24 + +import bookwyrm.models.base_model +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0081_alter_user_last_active_date"), + ] + + operations = [ + migrations.AddField( + model_name="sitesettings", + name="require_confirm_email", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="user", + name="confirmation_code", + field=models.CharField( + default=bookwyrm.models.base_model.new_access_code, max_length=32 + ), + ), + migrations.AlterField( + model_name="connector", + name="deactivation_reason", + field=models.CharField( + blank=True, + choices=[ + ("pending", "Pending"), + ("self_deletion", "Self Deletion"), + ("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_deletion", "Moderator Deletion"), + ("domain_block", "Domain Block"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0083_auto_20210816_2022.py b/bookwyrm/migrations/0083_auto_20210816_2022.py new file mode 100644 index 00000000..ecf2778b --- /dev/null +++ b/bookwyrm/migrations/0083_auto_20210816_2022.py @@ -0,0 +1,56 @@ +# Generated by Django 3.2.4 on 2021-08-16 20:22 + +import bookwyrm.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0082_auto_20210806_2324"), + ] + + operations = [ + migrations.AddField( + model_name="comment", + name="reading_status", + field=bookwyrm.models.fields.CharField( + blank=True, + choices=[ + ("to-read", "Toread"), + ("reading", "Reading"), + ("read", "Read"), + ], + max_length=255, + null=True, + ), + ), + migrations.AddField( + model_name="quotation", + name="reading_status", + field=bookwyrm.models.fields.CharField( + blank=True, + choices=[ + ("to-read", "Toread"), + ("reading", "Reading"), + ("read", "Read"), + ], + max_length=255, + null=True, + ), + ), + migrations.AddField( + model_name="review", + name="reading_status", + field=bookwyrm.models.fields.CharField( + blank=True, + choices=[ + ("to-read", "Toread"), + ("reading", "Reading"), + ("read", "Read"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0084_auto_20210817_1916.py b/bookwyrm/migrations/0084_auto_20210817_1916.py new file mode 100644 index 00000000..6e826f99 --- /dev/null +++ b/bookwyrm/migrations/0084_auto_20210817_1916.py @@ -0,0 +1,56 @@ +# Generated by Django 3.2.4 on 2021-08-17 19:16 + +import bookwyrm.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0083_auto_20210816_2022"), + ] + + operations = [ + migrations.AlterField( + model_name="comment", + name="reading_status", + field=bookwyrm.models.fields.CharField( + blank=True, + choices=[ + ("to-read", "To-Read"), + ("reading", "Reading"), + ("read", "Read"), + ], + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="quotation", + name="reading_status", + field=bookwyrm.models.fields.CharField( + blank=True, + choices=[ + ("to-read", "To-Read"), + ("reading", "Reading"), + ("read", "Read"), + ], + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="review", + name="reading_status", + field=bookwyrm.models.fields.CharField( + blank=True, + choices=[ + ("to-read", "To-Read"), + ("reading", "Reading"), + ("read", "Read"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0085_user_saved_lists.py b/bookwyrm/migrations/0085_user_saved_lists.py new file mode 100644 index 00000000..d4d9278c --- /dev/null +++ b/bookwyrm/migrations/0085_user_saved_lists.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.4 on 2021-08-23 18:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0084_auto_20210817_1916"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="saved_lists", + field=models.ManyToManyField( + related_name="saved_lists", to="bookwyrm.List" + ), + ), + ] diff --git a/bookwyrm/migrations/0086_auto_20210827_1727.py b/bookwyrm/migrations/0086_auto_20210827_1727.py new file mode 100644 index 00000000..ef6af206 --- /dev/null +++ b/bookwyrm/migrations/0086_auto_20210827_1727.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2.4 on 2021-08-27 17:27 + +from django.db import migrations, models +import django.db.models.expressions + + +def normalize_readthrough_dates(app_registry, schema_editor): + """Find any invalid dates and reset them""" + db_alias = schema_editor.connection.alias + app_registry.get_model("bookwyrm", "ReadThrough").objects.using(db_alias).filter( + start_date__gt=models.F("finish_date") + ).update(start_date=models.F("finish_date")) + + +def reverse_func(apps, schema_editor): + """nothing to do here""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0085_user_saved_lists"), + ] + + operations = [ + migrations.RunPython(normalize_readthrough_dates, reverse_func), + migrations.AlterModelOptions( + name="readthrough", + options={"ordering": ("-start_date",)}, + ), + migrations.AddConstraint( + model_name="readthrough", + constraint=models.CheckConstraint( + check=models.Q( + ("finish_date__gte", django.db.models.expressions.F("start_date")) + ), + name="chronology", + ), + ), + ] diff --git a/bookwyrm/migrations/0086_auto_20210828_1724.py b/bookwyrm/migrations/0086_auto_20210828_1724.py new file mode 100644 index 00000000..21247711 --- /dev/null +++ b/bookwyrm/migrations/0086_auto_20210828_1724.py @@ -0,0 +1,49 @@ +# Generated by Django 3.2.4 on 2021-08-28 17:24 + +import bookwyrm.models.fields +from django.conf import settings +from django.db import migrations, models +from django.db.models import F, Value, CharField +from django.db.models.functions import Concat + + +def forwards_func(apps, schema_editor): + """generate followers url""" + db_alias = schema_editor.connection.alias + apps.get_model("bookwyrm", "User").objects.using(db_alias).annotate( + generated_url=Concat( + F("remote_id"), Value("/followers"), output_field=CharField() + ) + ).update(followers_url=models.F("generated_url")) + + +def reverse_func(apps, schema_editor): + """noop""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0085_user_saved_lists"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="followers_url", + field=bookwyrm.models.fields.CharField( + default="/followers", max_length=255 + ), + preserve_default=False, + ), + migrations.RunPython(forwards_func, reverse_func), + migrations.AlterField( + model_name="user", + name="followers", + field=models.ManyToManyField( + related_name="following", + through="bookwyrm.UserFollows", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/bookwyrm/migrations/0087_merge_0086_auto_20210827_1727_0086_auto_20210828_1724.py b/bookwyrm/migrations/0087_merge_0086_auto_20210827_1727_0086_auto_20210828_1724.py new file mode 100644 index 00000000..cd531161 --- /dev/null +++ b/bookwyrm/migrations/0087_merge_0086_auto_20210827_1727_0086_auto_20210828_1724.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.4 on 2021-08-29 18:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0086_auto_20210827_1727"), + ("bookwyrm", "0086_auto_20210828_1724"), + ] + + operations = [] diff --git a/bookwyrm/migrations/0088_auto_20210905_2233.py b/bookwyrm/migrations/0088_auto_20210905_2233.py new file mode 100644 index 00000000..028cf7bf --- /dev/null +++ b/bookwyrm/migrations/0088_auto_20210905_2233.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.4 on 2021-09-05 22:33 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0087_merge_0086_auto_20210827_1727_0086_auto_20210828_1724"), + ] + + operations = [ + migrations.AddField( + model_name="quotation", + name="position", + field=models.IntegerField( + blank=True, + null=True, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + migrations.AddField( + model_name="quotation", + name="position_mode", + field=models.CharField( + blank=True, + choices=[("PG", "page"), ("PCT", "percent")], + default="PG", + max_length=3, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0089_user_show_suggested_users.py b/bookwyrm/migrations/0089_user_show_suggested_users.py new file mode 100644 index 00000000..047bb974 --- /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 00000000..7c870857 --- /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 00000000..6934e51e --- /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 00000000..7aae2c7a --- /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 00000000..4c62dd7b --- /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 00000000..165199be --- /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 00000000..8c3be9f8 --- /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 00000000..be703cdd --- /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 00000000..06d15f5e --- /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 00000000..ea6b5a34 --- /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 00000000..0d3b69a2 --- /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 00000000..28cf94e2 --- /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 00000000..09fdba31 --- /dev/null +++ b/bookwyrm/migrations/0098_auto_20210918_2238.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.4 on 2021-09-18 22:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0097_auto_20210917_1858"), + ] + + operations = [ + migrations.AddField( + model_name="sitesettings", + name="invite_request_text", + field=models.TextField( + default="If your request is approved, you will receive an email with a registration link." + ), + ), + migrations.AlterField( + model_name="sitesettings", + name="registration_closed_text", + field=models.TextField( + default='We aren\'t taking new users at this time. You can find an open instance at joinbookwyrm.com/instances.' + ), + ), + ] diff --git a/bookwyrm/migrations/0099_readthrough_is_active.py b/bookwyrm/migrations/0099_readthrough_is_active.py new file mode 100644 index 00000000..e7b177ba --- /dev/null +++ b/bookwyrm/migrations/0099_readthrough_is_active.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.4 on 2021-09-22 16:53 + +from django.db import migrations, models + + +def set_active_readthrough(apps, schema_editor): + """best-guess for deactivation date""" + db_alias = schema_editor.connection.alias + apps.get_model("bookwyrm", "ReadThrough").objects.using(db_alias).filter( + start_date__isnull=False, + finish_date__isnull=True, + ).update(is_active=True) + + +def reverse_func(apps, schema_editor): + """noop""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0098_auto_20210918_2238"), + ] + + operations = [ + migrations.AddField( + model_name="readthrough", + name="is_active", + field=models.BooleanField(default=False), + ), + migrations.RunPython(set_active_readthrough, reverse_func), + migrations.AlterField( + model_name="readthrough", + name="is_active", + field=models.BooleanField(default=True), + ), + ] diff --git a/bookwyrm/migrations/0100_shelf_description.py b/bookwyrm/migrations/0100_shelf_description.py new file mode 100644 index 00000000..18185b17 --- /dev/null +++ b/bookwyrm/migrations/0100_shelf_description.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.5 on 2021-09-28 23:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0099_readthrough_is_active"), + ] + + operations = [ + migrations.AddField( + model_name="shelf", + name="description", + field=models.TextField(blank=True, max_length=500, null=True), + ), + ] diff --git a/bookwyrm/migrations/0101_auto_20210929_1847.py b/bookwyrm/migrations/0101_auto_20210929_1847.py new file mode 100644 index 00000000..3fca28ea --- /dev/null +++ b/bookwyrm/migrations/0101_auto_20210929_1847.py @@ -0,0 +1,56 @@ +# Generated by Django 3.2 on 2021-05-21 00:17 + +from django.db import migrations +import bookwyrm +from bookwyrm.connectors.abstract_connector import infer_physical_format + + +def infer_format(app_registry, schema_editor): + """set the new phsyical format field based on existing format data""" + db_alias = schema_editor.connection.alias + + editions = ( + app_registry.get_model("bookwyrm", "Edition") + .objects.using(db_alias) + .filter(physical_format_detail__isnull=False) + ) + for edition in editions: + free_format = edition.physical_format_detail.lower() + edition.physical_format = infer_physical_format(free_format) + edition.save() + + +def reverse(app_registry, schema_editor): + """doesn't need to do anything""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0100_shelf_description"), + ] + + operations = [ + migrations.RenameField( + model_name="edition", + old_name="physical_format", + new_name="physical_format_detail", + ), + migrations.AddField( + model_name="edition", + name="physical_format", + field=bookwyrm.models.fields.CharField( + blank=True, + choices=[ + ("AudiobookFormat", "Audiobook"), + ("EBook", "eBook"), + ("GraphicNovel", "Graphic novel"), + ("Hardcover", "Hardcover"), + ("Paperback", "Paperback"), + ], + max_length=255, + null=True, + ), + ), + migrations.RunPython(infer_format, reverse), + ] diff --git a/bookwyrm/migrations/0102_remove_connector_local.py b/bookwyrm/migrations/0102_remove_connector_local.py new file mode 100644 index 00000000..857f0f58 --- /dev/null +++ b/bookwyrm/migrations/0102_remove_connector_local.py @@ -0,0 +1,41 @@ +# Generated by Django 3.2.5 on 2021-09-30 17:46 + +from django.db import migrations +from bookwyrm.settings import DOMAIN + + +def remove_self_connector(app_registry, schema_editor): + """set the new phsyical format field based on existing format data""" + db_alias = schema_editor.connection.alias + app_registry.get_model("bookwyrm", "Connector").objects.using(db_alias).filter( + connector_file="self_connector" + ).delete() + + +def reverse(app_registry, schema_editor): + """doesn't need to do anything""" + db_alias = schema_editor.connection.alias + model = app_registry.get_model("bookwyrm", "Connector") + model.objects.using(db_alias).create( + identifier=DOMAIN, + name="Local", + local=True, + connector_file="self_connector", + base_url=f"https://{DOMAIN}", + books_url=f"https://{DOMAIN}/book", + covers_url=f"https://{DOMAIN}/images/", + search_url=f"https://{DOMAIN}/search?q=", + isbn_search_url=f"https://{DOMAIN}/isbn/", + priority=1, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0101_auto_20210929_1847"), + ] + + operations = [ + migrations.RunPython(remove_self_connector, reverse), + ] diff --git a/bookwyrm/migrations/0103_remove_connector_local.py b/bookwyrm/migrations/0103_remove_connector_local.py new file mode 100644 index 00000000..788ce5f8 --- /dev/null +++ b/bookwyrm/migrations/0103_remove_connector_local.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.5 on 2021-09-30 18:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0102_remove_connector_local"), + ] + + operations = [ + migrations.RemoveField( + model_name="connector", + name="local", + ), + ] diff --git a/bookwyrm/migrations/0104_auto_20211001_2012.py b/bookwyrm/migrations/0104_auto_20211001_2012.py new file mode 100644 index 00000000..8d429040 --- /dev/null +++ b/bookwyrm/migrations/0104_auto_20211001_2012.py @@ -0,0 +1,53 @@ +# Generated by Django 3.2.5 on 2021-10-01 20:12 + +from django.db import migrations, models + + +def set_thread_id(app_registry, schema_editor): + """set thread ids""" + db_alias = schema_editor.connection.alias + # set the thread id on parent nodes + model = app_registry.get_model("bookwyrm", "Status") + model.objects.using(db_alias).filter(reply_parent__isnull=True).update( + thread_id=models.F("id") + ) + + queryset = model.objects.using(db_alias).filter( + reply_parent__isnull=False, + reply_parent__thread_id__isnull=False, + thread_id__isnull=True, + ) + iters = 0 + while queryset.exists(): + queryset.update( + thread_id=models.Subquery( + model.objects.filter(id=models.OuterRef("reply_parent")).values_list( + "thread_id" + )[:1] + ) + ) + print(iters) + iters += 1 + if iters > 50: + print("exceeded query depth") + break + + +def reverse(*_): + """do nothing""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0103_remove_connector_local"), + ] + + operations = [ + migrations.AddField( + model_name="status", + name="thread_id", + field=models.IntegerField(blank=True, null=True), + ), + migrations.RunPython(set_thread_id, reverse), + ] diff --git a/bookwyrm/migrations/0105_alter_connector_connector_file.py b/bookwyrm/migrations/0105_alter_connector_connector_file.py new file mode 100644 index 00000000..a6f08f77 --- /dev/null +++ b/bookwyrm/migrations/0105_alter_connector_connector_file.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.5 on 2021-10-03 19:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0104_auto_20211001_2012"), + ] + + operations = [ + migrations.AlterField( + model_name="connector", + name="connector_file", + field=models.CharField( + choices=[ + ("openlibrary", "Openlibrary"), + ("inventaire", "Inventaire"), + ("bookwyrm_connector", "Bookwyrm Connector"), + ], + max_length=255, + ), + ), + ] diff --git a/bookwyrm/migrations/0106_user_preferred_language.py b/bookwyrm/migrations/0106_user_preferred_language.py new file mode 100644 index 00000000..a77030a0 --- /dev/null +++ b/bookwyrm/migrations/0106_user_preferred_language.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.5 on 2021-10-06 19:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0105_alter_connector_connector_file"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="preferred_language", + field=models.CharField( + blank=True, + choices=[ + ("en-us", "English"), + ("de-de", "German"), + ("es", "Spanish"), + ("fr-fr", "French"), + ("zh-hans", "Simplified Chinese"), + ("zh-hant", "Traditional Chinese"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0107_alter_user_preferred_language.py b/bookwyrm/migrations/0107_alter_user_preferred_language.py new file mode 100644 index 00000000..be0fc5ea --- /dev/null +++ b/bookwyrm/migrations/0107_alter_user_preferred_language.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.5 on 2021-10-11 16:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0106_user_preferred_language"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="preferred_language", + field=models.CharField( + blank=True, + choices=[ + ("en-us", "English"), + ("de-de", "Deutsch (German)"), + ("es", "Español (Spanish)"), + ("fr-fr", "Français (French)"), + ("pt-br", "Português - Brasil (Brazilian Portugues)"), + ("zh-hans", "简体中文 (Simplified Chinese)"), + ("zh-hant", "繁體中文 (Traditional Chinese)"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0107_auto_20211016_0639.py b/bookwyrm/migrations/0107_auto_20211016_0639.py new file mode 100644 index 00000000..61dffca3 --- /dev/null +++ b/bookwyrm/migrations/0107_auto_20211016_0639.py @@ -0,0 +1,871 @@ +# Generated by Django 3.2.5 on 2021-10-16 06:39 + +import bookwyrm.models.fields +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0106_user_preferred_language"), + ] + + operations = [ + migrations.CreateModel( + name="Group", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_date", models.DateTimeField(auto_now_add=True)), + ("updated_date", models.DateTimeField(auto_now=True)), + ( + "remote_id", + bookwyrm.models.fields.RemoteIdField( + max_length=255, + null=True, + validators=[bookwyrm.models.fields.validate_remote_id], + ), + ), + ("name", bookwyrm.models.fields.CharField(max_length=100)), + ( + "description", + bookwyrm.models.fields.TextField(blank=True, null=True), + ), + ( + "privacy", + bookwyrm.models.fields.PrivacyField( + choices=[ + ("public", "Public"), + ("unlisted", "Unlisted"), + ("followers", "Followers"), + ("direct", "Direct"), + ], + default="public", + max_length=255, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="GroupMember", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_date", models.DateTimeField(auto_now_add=True)), + ("updated_date", models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name="GroupMemberInvitation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_date", models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.RemoveConstraint( + model_name="notification", + name="notification_type_valid", + ), + migrations.AlterField( + model_name="list", + name="curation", + field=bookwyrm.models.fields.CharField( + choices=[ + ("closed", "Closed"), + ("open", "Open"), + ("curated", "Curated"), + ("group", "Group"), + ], + default="closed", + max_length=255, + ), + ), + migrations.AlterField( + model_name="notification", + name="notification_type", + field=models.CharField( + choices=[ + ("FAVORITE", "Favorite"), + ("REPLY", "Reply"), + ("MENTION", "Mention"), + ("TAG", "Tag"), + ("FOLLOW", "Follow"), + ("FOLLOW_REQUEST", "Follow Request"), + ("BOOST", "Boost"), + ("IMPORT", "Import"), + ("ADD", "Add"), + ("REPORT", "Report"), + ("INVITE", "Invite"), + ("ACCEPT", "Accept"), + ("JOIN", "Join"), + ("LEAVE", "Leave"), + ("REMOVE", "Remove"), + ], + max_length=255, + ), + ), + migrations.AlterField( + model_name="user", + name="preferred_language", + field=models.CharField( + blank=True, + choices=[ + ("en-us", "English"), + ("de-de", "Deutsch (German)"), + ("es", "Español (Spanish)"), + ("fr-fr", "Français (French)"), + ("zh-hans", "简体中文 (Simplified Chinese)"), + ("zh-hant", "繁體中文 (Traditional Chinese)"), + ], + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="user", + name="preferred_timezone", + field=models.CharField( + choices=[ + ("Africa/Abidjan", "Africa/Abidjan"), + ("Africa/Accra", "Africa/Accra"), + ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), + ("Africa/Algiers", "Africa/Algiers"), + ("Africa/Asmara", "Africa/Asmara"), + ("Africa/Asmera", "Africa/Asmera"), + ("Africa/Bamako", "Africa/Bamako"), + ("Africa/Bangui", "Africa/Bangui"), + ("Africa/Banjul", "Africa/Banjul"), + ("Africa/Bissau", "Africa/Bissau"), + ("Africa/Blantyre", "Africa/Blantyre"), + ("Africa/Brazzaville", "Africa/Brazzaville"), + ("Africa/Bujumbura", "Africa/Bujumbura"), + ("Africa/Cairo", "Africa/Cairo"), + ("Africa/Casablanca", "Africa/Casablanca"), + ("Africa/Ceuta", "Africa/Ceuta"), + ("Africa/Conakry", "Africa/Conakry"), + ("Africa/Dakar", "Africa/Dakar"), + ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), + ("Africa/Djibouti", "Africa/Djibouti"), + ("Africa/Douala", "Africa/Douala"), + ("Africa/El_Aaiun", "Africa/El_Aaiun"), + ("Africa/Freetown", "Africa/Freetown"), + ("Africa/Gaborone", "Africa/Gaborone"), + ("Africa/Harare", "Africa/Harare"), + ("Africa/Johannesburg", "Africa/Johannesburg"), + ("Africa/Juba", "Africa/Juba"), + ("Africa/Kampala", "Africa/Kampala"), + ("Africa/Khartoum", "Africa/Khartoum"), + ("Africa/Kigali", "Africa/Kigali"), + ("Africa/Kinshasa", "Africa/Kinshasa"), + ("Africa/Lagos", "Africa/Lagos"), + ("Africa/Libreville", "Africa/Libreville"), + ("Africa/Lome", "Africa/Lome"), + ("Africa/Luanda", "Africa/Luanda"), + ("Africa/Lubumbashi", "Africa/Lubumbashi"), + ("Africa/Lusaka", "Africa/Lusaka"), + ("Africa/Malabo", "Africa/Malabo"), + ("Africa/Maputo", "Africa/Maputo"), + ("Africa/Maseru", "Africa/Maseru"), + ("Africa/Mbabane", "Africa/Mbabane"), + ("Africa/Mogadishu", "Africa/Mogadishu"), + ("Africa/Monrovia", "Africa/Monrovia"), + ("Africa/Nairobi", "Africa/Nairobi"), + ("Africa/Ndjamena", "Africa/Ndjamena"), + ("Africa/Niamey", "Africa/Niamey"), + ("Africa/Nouakchott", "Africa/Nouakchott"), + ("Africa/Ouagadougou", "Africa/Ouagadougou"), + ("Africa/Porto-Novo", "Africa/Porto-Novo"), + ("Africa/Sao_Tome", "Africa/Sao_Tome"), + ("Africa/Timbuktu", "Africa/Timbuktu"), + ("Africa/Tripoli", "Africa/Tripoli"), + ("Africa/Tunis", "Africa/Tunis"), + ("Africa/Windhoek", "Africa/Windhoek"), + ("America/Adak", "America/Adak"), + ("America/Anchorage", "America/Anchorage"), + ("America/Anguilla", "America/Anguilla"), + ("America/Antigua", "America/Antigua"), + ("America/Araguaina", "America/Araguaina"), + ( + "America/Argentina/Buenos_Aires", + "America/Argentina/Buenos_Aires", + ), + ("America/Argentina/Catamarca", "America/Argentina/Catamarca"), + ( + "America/Argentina/ComodRivadavia", + "America/Argentina/ComodRivadavia", + ), + ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), + ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), + ("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"), + ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), + ( + "America/Argentina/Rio_Gallegos", + "America/Argentina/Rio_Gallegos", + ), + ("America/Argentina/Salta", "America/Argentina/Salta"), + ("America/Argentina/San_Juan", "America/Argentina/San_Juan"), + ("America/Argentina/San_Luis", "America/Argentina/San_Luis"), + ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), + ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), + ("America/Aruba", "America/Aruba"), + ("America/Asuncion", "America/Asuncion"), + ("America/Atikokan", "America/Atikokan"), + ("America/Atka", "America/Atka"), + ("America/Bahia", "America/Bahia"), + ("America/Bahia_Banderas", "America/Bahia_Banderas"), + ("America/Barbados", "America/Barbados"), + ("America/Belem", "America/Belem"), + ("America/Belize", "America/Belize"), + ("America/Blanc-Sablon", "America/Blanc-Sablon"), + ("America/Boa_Vista", "America/Boa_Vista"), + ("America/Bogota", "America/Bogota"), + ("America/Boise", "America/Boise"), + ("America/Buenos_Aires", "America/Buenos_Aires"), + ("America/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Catamarca", "America/Catamarca"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Coral_Harbour", "America/Coral_Harbour"), + ("America/Cordoba", "America/Cordoba"), + ("America/Costa_Rica", "America/Costa_Rica"), + ("America/Creston", "America/Creston"), + ("America/Cuiaba", "America/Cuiaba"), + ("America/Curacao", "America/Curacao"), + ("America/Danmarkshavn", "America/Danmarkshavn"), + ("America/Dawson", "America/Dawson"), + ("America/Dawson_Creek", "America/Dawson_Creek"), + ("America/Denver", "America/Denver"), + ("America/Detroit", "America/Detroit"), + ("America/Dominica", "America/Dominica"), + ("America/Edmonton", "America/Edmonton"), + ("America/Eirunepe", "America/Eirunepe"), + ("America/El_Salvador", "America/El_Salvador"), + ("America/Ensenada", "America/Ensenada"), + ("America/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fort_Wayne", "America/Fort_Wayne"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("America/Godthab", "America/Godthab"), + ("America/Goose_Bay", "America/Goose_Bay"), + ("America/Grand_Turk", "America/Grand_Turk"), + ("America/Grenada", "America/Grenada"), + ("America/Guadeloupe", "America/Guadeloupe"), + ("America/Guatemala", "America/Guatemala"), + ("America/Guayaquil", "America/Guayaquil"), + ("America/Guyana", "America/Guyana"), + ("America/Halifax", "America/Halifax"), + ("America/Havana", "America/Havana"), + ("America/Hermosillo", "America/Hermosillo"), + ("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"), + ("America/Indiana/Knox", "America/Indiana/Knox"), + ("America/Indiana/Marengo", "America/Indiana/Marengo"), + ("America/Indiana/Petersburg", "America/Indiana/Petersburg"), + ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), + ("America/Indiana/Vevay", "America/Indiana/Vevay"), + ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), + ("America/Indiana/Winamac", "America/Indiana/Winamac"), + ("America/Indianapolis", "America/Indianapolis"), + ("America/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Jujuy", "America/Jujuy"), + ("America/Juneau", "America/Juneau"), + ("America/Kentucky/Louisville", "America/Kentucky/Louisville"), + ("America/Kentucky/Monticello", "America/Kentucky/Monticello"), + ("America/Knox_IN", "America/Knox_IN"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("America/Louisville", "America/Louisville"), + ("America/Lower_Princes", "America/Lower_Princes"), + ("America/Maceio", "America/Maceio"), + ("America/Managua", "America/Managua"), + ("America/Manaus", "America/Manaus"), + ("America/Marigot", "America/Marigot"), + ("America/Martinique", "America/Martinique"), + ("America/Matamoros", "America/Matamoros"), + ("America/Mazatlan", "America/Mazatlan"), + ("America/Mendoza", "America/Mendoza"), + ("America/Menominee", "America/Menominee"), + ("America/Merida", "America/Merida"), + ("America/Metlakatla", "America/Metlakatla"), + ("America/Mexico_City", "America/Mexico_City"), + ("America/Miquelon", "America/Miquelon"), + ("America/Moncton", "America/Moncton"), + ("America/Monterrey", "America/Monterrey"), + ("America/Montevideo", "America/Montevideo"), + ("America/Montreal", "America/Montreal"), + ("America/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("America/Nipigon", "America/Nipigon"), + ("America/Nome", "America/Nome"), + ("America/Noronha", "America/Noronha"), + ("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"), + ("America/North_Dakota/Center", "America/North_Dakota/Center"), + ( + "America/North_Dakota/New_Salem", + "America/North_Dakota/New_Salem", + ), + ("America/Nuuk", "America/Nuuk"), + ("America/Ojinaga", "America/Ojinaga"), + ("America/Panama", "America/Panama"), + ("America/Pangnirtung", "America/Pangnirtung"), + ("America/Paramaribo", "America/Paramaribo"), + ("America/Phoenix", "America/Phoenix"), + ("America/Port-au-Prince", "America/Port-au-Prince"), + ("America/Port_of_Spain", "America/Port_of_Spain"), + ("America/Porto_Acre", "America/Porto_Acre"), + ("America/Porto_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("America/Rainy_River", "America/Rainy_River"), + ("America/Rankin_Inlet", "America/Rankin_Inlet"), + ("America/Recife", "America/Recife"), + ("America/Regina", "America/Regina"), + ("America/Resolute", "America/Resolute"), + ("America/Rio_Branco", "America/Rio_Branco"), + ("America/Rosario", "America/Rosario"), + ("America/Santa_Isabel", "America/Santa_Isabel"), + ("America/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("America/Shiprock", "America/Shiprock"), + ("America/Sitka", "America/Sitka"), + ("America/St_Barthelemy", "America/St_Barthelemy"), + ("America/St_Johns", "America/St_Johns"), + ("America/St_Kitts", "America/St_Kitts"), + ("America/St_Lucia", "America/St_Lucia"), + ("America/St_Thomas", "America/St_Thomas"), + ("America/St_Vincent", "America/St_Vincent"), + ("America/Swift_Current", "America/Swift_Current"), + ("America/Tegucigalpa", "America/Tegucigalpa"), + ("America/Thule", "America/Thule"), + ("America/Thunder_Bay", "America/Thunder_Bay"), + ("America/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Virgin", "America/Virgin"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("America/Yellowknife", "America/Yellowknife"), + ("Antarctica/Casey", "Antarctica/Casey"), + ("Antarctica/Davis", "Antarctica/Davis"), + ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), + ("Antarctica/Macquarie", "Antarctica/Macquarie"), + ("Antarctica/Mawson", "Antarctica/Mawson"), + ("Antarctica/McMurdo", "Antarctica/McMurdo"), + ("Antarctica/Palmer", "Antarctica/Palmer"), + ("Antarctica/Rothera", "Antarctica/Rothera"), + ("Antarctica/South_Pole", "Antarctica/South_Pole"), + ("Antarctica/Syowa", "Antarctica/Syowa"), + ("Antarctica/Troll", "Antarctica/Troll"), + ("Antarctica/Vostok", "Antarctica/Vostok"), + ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), + ("Asia/Aden", "Asia/Aden"), + ("Asia/Almaty", "Asia/Almaty"), + ("Asia/Amman", "Asia/Amman"), + ("Asia/Anadyr", "Asia/Anadyr"), + ("Asia/Aqtau", "Asia/Aqtau"), + ("Asia/Aqtobe", "Asia/Aqtobe"), + ("Asia/Ashgabat", "Asia/Ashgabat"), + ("Asia/Ashkhabad", "Asia/Ashkhabad"), + ("Asia/Atyrau", "Asia/Atyrau"), + ("Asia/Baghdad", "Asia/Baghdad"), + ("Asia/Bahrain", "Asia/Bahrain"), + ("Asia/Baku", "Asia/Baku"), + ("Asia/Bangkok", "Asia/Bangkok"), + ("Asia/Barnaul", "Asia/Barnaul"), + ("Asia/Beirut", "Asia/Beirut"), + ("Asia/Bishkek", "Asia/Bishkek"), + ("Asia/Brunei", "Asia/Brunei"), + ("Asia/Calcutta", "Asia/Calcutta"), + ("Asia/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Chongqing", "Asia/Chongqing"), + ("Asia/Chungking", "Asia/Chungking"), + ("Asia/Colombo", "Asia/Colombo"), + ("Asia/Dacca", "Asia/Dacca"), + ("Asia/Damascus", "Asia/Damascus"), + ("Asia/Dhaka", "Asia/Dhaka"), + ("Asia/Dili", "Asia/Dili"), + ("Asia/Dubai", "Asia/Dubai"), + ("Asia/Dushanbe", "Asia/Dushanbe"), + ("Asia/Famagusta", "Asia/Famagusta"), + ("Asia/Gaza", "Asia/Gaza"), + ("Asia/Harbin", "Asia/Harbin"), + ("Asia/Hebron", "Asia/Hebron"), + ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), + ("Asia/Hong_Kong", "Asia/Hong_Kong"), + ("Asia/Hovd", "Asia/Hovd"), + ("Asia/Irkutsk", "Asia/Irkutsk"), + ("Asia/Istanbul", "Asia/Istanbul"), + ("Asia/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kashgar", "Asia/Kashgar"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("Asia/Katmandu", "Asia/Katmandu"), + ("Asia/Khandyga", "Asia/Khandyga"), + ("Asia/Kolkata", "Asia/Kolkata"), + ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), + ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), + ("Asia/Kuching", "Asia/Kuching"), + ("Asia/Kuwait", "Asia/Kuwait"), + ("Asia/Macao", "Asia/Macao"), + ("Asia/Macau", "Asia/Macau"), + ("Asia/Magadan", "Asia/Magadan"), + ("Asia/Makassar", "Asia/Makassar"), + ("Asia/Manila", "Asia/Manila"), + ("Asia/Muscat", "Asia/Muscat"), + ("Asia/Nicosia", "Asia/Nicosia"), + ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), + ("Asia/Novosibirsk", "Asia/Novosibirsk"), + ("Asia/Omsk", "Asia/Omsk"), + ("Asia/Oral", "Asia/Oral"), + ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), + ("Asia/Pontianak", "Asia/Pontianak"), + ("Asia/Pyongyang", "Asia/Pyongyang"), + ("Asia/Qatar", "Asia/Qatar"), + ("Asia/Qostanay", "Asia/Qostanay"), + ("Asia/Qyzylorda", "Asia/Qyzylorda"), + ("Asia/Rangoon", "Asia/Rangoon"), + ("Asia/Riyadh", "Asia/Riyadh"), + ("Asia/Saigon", "Asia/Saigon"), + ("Asia/Sakhalin", "Asia/Sakhalin"), + ("Asia/Samarkand", "Asia/Samarkand"), + ("Asia/Seoul", "Asia/Seoul"), + ("Asia/Shanghai", "Asia/Shanghai"), + ("Asia/Singapore", "Asia/Singapore"), + ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), + ("Asia/Taipei", "Asia/Taipei"), + ("Asia/Tashkent", "Asia/Tashkent"), + ("Asia/Tbilisi", "Asia/Tbilisi"), + ("Asia/Tehran", "Asia/Tehran"), + ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), + ("Asia/Thimbu", "Asia/Thimbu"), + ("Asia/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), + ("Asia/Urumqi", "Asia/Urumqi"), + ("Asia/Ust-Nera", "Asia/Ust-Nera"), + ("Asia/Vientiane", "Asia/Vientiane"), + ("Asia/Vladivostok", "Asia/Vladivostok"), + ("Asia/Yakutsk", "Asia/Yakutsk"), + ("Asia/Yangon", "Asia/Yangon"), + ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), + ("Asia/Yerevan", "Asia/Yerevan"), + ("Atlantic/Azores", "Atlantic/Azores"), + ("Atlantic/Bermuda", "Atlantic/Bermuda"), + ("Atlantic/Canary", "Atlantic/Canary"), + ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), + ("Atlantic/Faeroe", "Atlantic/Faeroe"), + ("Atlantic/Faroe", "Atlantic/Faroe"), + ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), + ("Atlantic/Madeira", "Atlantic/Madeira"), + ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), + ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), + ("Atlantic/St_Helena", "Atlantic/St_Helena"), + ("Atlantic/Stanley", "Atlantic/Stanley"), + ("Australia/ACT", "Australia/ACT"), + ("Australia/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Canberra", "Australia/Canberra"), + ("Australia/Currie", "Australia/Currie"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/LHI", "Australia/LHI"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/NSW", "Australia/NSW"), + ("Australia/North", "Australia/North"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Queensland", "Australia/Queensland"), + ("Australia/South", "Australia/South"), + ("Australia/Sydney", "Australia/Sydney"), + ("Australia/Tasmania", "Australia/Tasmania"), + ("Australia/Victoria", "Australia/Victoria"), + ("Australia/West", "Australia/West"), + ("Australia/Yancowinna", "Australia/Yancowinna"), + ("Brazil/Acre", "Brazil/Acre"), + ("Brazil/DeNoronha", "Brazil/DeNoronha"), + ("Brazil/East", "Brazil/East"), + ("Brazil/West", "Brazil/West"), + ("CET", "CET"), + ("CST6CDT", "CST6CDT"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Canada/Saskatchewan", "Canada/Saskatchewan"), + ("Canada/Yukon", "Canada/Yukon"), + ("Chile/Continental", "Chile/Continental"), + ("Chile/EasterIsland", "Chile/EasterIsland"), + ("Cuba", "Cuba"), + ("EET", "EET"), + ("EST", "EST"), + ("EST5EDT", "EST5EDT"), + ("Egypt", "Egypt"), + ("Eire", "Eire"), + ("Etc/GMT", "Etc/GMT"), + ("Etc/GMT+0", "Etc/GMT+0"), + ("Etc/GMT+1", "Etc/GMT+1"), + ("Etc/GMT+10", "Etc/GMT+10"), + ("Etc/GMT+11", "Etc/GMT+11"), + ("Etc/GMT+12", "Etc/GMT+12"), + ("Etc/GMT+2", "Etc/GMT+2"), + ("Etc/GMT+3", "Etc/GMT+3"), + ("Etc/GMT+4", "Etc/GMT+4"), + ("Etc/GMT+5", "Etc/GMT+5"), + ("Etc/GMT+6", "Etc/GMT+6"), + ("Etc/GMT+7", "Etc/GMT+7"), + ("Etc/GMT+8", "Etc/GMT+8"), + ("Etc/GMT+9", "Etc/GMT+9"), + ("Etc/GMT-0", "Etc/GMT-0"), + ("Etc/GMT-1", "Etc/GMT-1"), + ("Etc/GMT-10", "Etc/GMT-10"), + ("Etc/GMT-11", "Etc/GMT-11"), + ("Etc/GMT-12", "Etc/GMT-12"), + ("Etc/GMT-13", "Etc/GMT-13"), + ("Etc/GMT-14", "Etc/GMT-14"), + ("Etc/GMT-2", "Etc/GMT-2"), + ("Etc/GMT-3", "Etc/GMT-3"), + ("Etc/GMT-4", "Etc/GMT-4"), + ("Etc/GMT-5", "Etc/GMT-5"), + ("Etc/GMT-6", "Etc/GMT-6"), + ("Etc/GMT-7", "Etc/GMT-7"), + ("Etc/GMT-8", "Etc/GMT-8"), + ("Etc/GMT-9", "Etc/GMT-9"), + ("Etc/GMT0", "Etc/GMT0"), + ("Etc/Greenwich", "Etc/Greenwich"), + ("Etc/UCT", "Etc/UCT"), + ("Etc/UTC", "Etc/UTC"), + ("Etc/Universal", "Etc/Universal"), + ("Etc/Zulu", "Etc/Zulu"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("Europe/Belfast", "Europe/Belfast"), + ("Europe/Belgrade", "Europe/Belgrade"), + ("Europe/Berlin", "Europe/Berlin"), + ("Europe/Bratislava", "Europe/Bratislava"), + ("Europe/Brussels", "Europe/Brussels"), + ("Europe/Bucharest", "Europe/Bucharest"), + ("Europe/Budapest", "Europe/Budapest"), + ("Europe/Busingen", "Europe/Busingen"), + ("Europe/Chisinau", "Europe/Chisinau"), + ("Europe/Copenhagen", "Europe/Copenhagen"), + ("Europe/Dublin", "Europe/Dublin"), + ("Europe/Gibraltar", "Europe/Gibraltar"), + ("Europe/Guernsey", "Europe/Guernsey"), + ("Europe/Helsinki", "Europe/Helsinki"), + ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), + ("Europe/Istanbul", "Europe/Istanbul"), + ("Europe/Jersey", "Europe/Jersey"), + ("Europe/Kaliningrad", "Europe/Kaliningrad"), + ("Europe/Kiev", "Europe/Kiev"), + ("Europe/Kirov", "Europe/Kirov"), + ("Europe/Lisbon", "Europe/Lisbon"), + ("Europe/Ljubljana", "Europe/Ljubljana"), + ("Europe/London", "Europe/London"), + ("Europe/Luxembourg", "Europe/Luxembourg"), + ("Europe/Madrid", "Europe/Madrid"), + ("Europe/Malta", "Europe/Malta"), + ("Europe/Mariehamn", "Europe/Mariehamn"), + ("Europe/Minsk", "Europe/Minsk"), + ("Europe/Monaco", "Europe/Monaco"), + ("Europe/Moscow", "Europe/Moscow"), + ("Europe/Nicosia", "Europe/Nicosia"), + ("Europe/Oslo", "Europe/Oslo"), + ("Europe/Paris", "Europe/Paris"), + ("Europe/Podgorica", "Europe/Podgorica"), + ("Europe/Prague", "Europe/Prague"), + ("Europe/Riga", "Europe/Riga"), + ("Europe/Rome", "Europe/Rome"), + ("Europe/Samara", "Europe/Samara"), + ("Europe/San_Marino", "Europe/San_Marino"), + ("Europe/Sarajevo", "Europe/Sarajevo"), + ("Europe/Saratov", "Europe/Saratov"), + ("Europe/Simferopol", "Europe/Simferopol"), + ("Europe/Skopje", "Europe/Skopje"), + ("Europe/Sofia", "Europe/Sofia"), + ("Europe/Stockholm", "Europe/Stockholm"), + ("Europe/Tallinn", "Europe/Tallinn"), + ("Europe/Tirane", "Europe/Tirane"), + ("Europe/Tiraspol", "Europe/Tiraspol"), + ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), + ("Europe/Uzhgorod", "Europe/Uzhgorod"), + ("Europe/Vaduz", "Europe/Vaduz"), + ("Europe/Vatican", "Europe/Vatican"), + ("Europe/Vienna", "Europe/Vienna"), + ("Europe/Vilnius", "Europe/Vilnius"), + ("Europe/Volgograd", "Europe/Volgograd"), + ("Europe/Warsaw", "Europe/Warsaw"), + ("Europe/Zagreb", "Europe/Zagreb"), + ("Europe/Zaporozhye", "Europe/Zaporozhye"), + ("Europe/Zurich", "Europe/Zurich"), + ("GB", "GB"), + ("GB-Eire", "GB-Eire"), + ("GMT", "GMT"), + ("GMT+0", "GMT+0"), + ("GMT-0", "GMT-0"), + ("GMT0", "GMT0"), + ("Greenwich", "Greenwich"), + ("HST", "HST"), + ("Hongkong", "Hongkong"), + ("Iceland", "Iceland"), + ("Indian/Antananarivo", "Indian/Antananarivo"), + ("Indian/Chagos", "Indian/Chagos"), + ("Indian/Christmas", "Indian/Christmas"), + ("Indian/Cocos", "Indian/Cocos"), + ("Indian/Comoro", "Indian/Comoro"), + ("Indian/Kerguelen", "Indian/Kerguelen"), + ("Indian/Mahe", "Indian/Mahe"), + ("Indian/Maldives", "Indian/Maldives"), + ("Indian/Mauritius", "Indian/Mauritius"), + ("Indian/Mayotte", "Indian/Mayotte"), + ("Indian/Reunion", "Indian/Reunion"), + ("Iran", "Iran"), + ("Israel", "Israel"), + ("Jamaica", "Jamaica"), + ("Japan", "Japan"), + ("Kwajalein", "Kwajalein"), + ("Libya", "Libya"), + ("MET", "MET"), + ("MST", "MST"), + ("MST7MDT", "MST7MDT"), + ("Mexico/BajaNorte", "Mexico/BajaNorte"), + ("Mexico/BajaSur", "Mexico/BajaSur"), + ("Mexico/General", "Mexico/General"), + ("NZ", "NZ"), + ("NZ-CHAT", "NZ-CHAT"), + ("Navajo", "Navajo"), + ("PRC", "PRC"), + ("PST8PDT", "PST8PDT"), + ("Pacific/Apia", "Pacific/Apia"), + ("Pacific/Auckland", "Pacific/Auckland"), + ("Pacific/Bougainville", "Pacific/Bougainville"), + ("Pacific/Chatham", "Pacific/Chatham"), + ("Pacific/Chuuk", "Pacific/Chuuk"), + ("Pacific/Easter", "Pacific/Easter"), + ("Pacific/Efate", "Pacific/Efate"), + ("Pacific/Enderbury", "Pacific/Enderbury"), + ("Pacific/Fakaofo", "Pacific/Fakaofo"), + ("Pacific/Fiji", "Pacific/Fiji"), + ("Pacific/Funafuti", "Pacific/Funafuti"), + ("Pacific/Galapagos", "Pacific/Galapagos"), + ("Pacific/Gambier", "Pacific/Gambier"), + ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), + ("Pacific/Guam", "Pacific/Guam"), + ("Pacific/Honolulu", "Pacific/Honolulu"), + ("Pacific/Johnston", "Pacific/Johnston"), + ("Pacific/Kanton", "Pacific/Kanton"), + ("Pacific/Kiritimati", "Pacific/Kiritimati"), + ("Pacific/Kosrae", "Pacific/Kosrae"), + ("Pacific/Kwajalein", "Pacific/Kwajalein"), + ("Pacific/Majuro", "Pacific/Majuro"), + ("Pacific/Marquesas", "Pacific/Marquesas"), + ("Pacific/Midway", "Pacific/Midway"), + ("Pacific/Nauru", "Pacific/Nauru"), + ("Pacific/Niue", "Pacific/Niue"), + ("Pacific/Norfolk", "Pacific/Norfolk"), + ("Pacific/Noumea", "Pacific/Noumea"), + ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), + ("Pacific/Palau", "Pacific/Palau"), + ("Pacific/Pitcairn", "Pacific/Pitcairn"), + ("Pacific/Pohnpei", "Pacific/Pohnpei"), + ("Pacific/Ponape", "Pacific/Ponape"), + ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Samoa", "Pacific/Samoa"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Truk", "Pacific/Truk"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("Pacific/Yap", "Pacific/Yap"), + ("Poland", "Poland"), + ("Portugal", "Portugal"), + ("ROC", "ROC"), + ("ROK", "ROK"), + ("Singapore", "Singapore"), + ("Turkey", "Turkey"), + ("UCT", "UCT"), + ("US/Alaska", "US/Alaska"), + ("US/Aleutian", "US/Aleutian"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/East-Indiana", "US/East-Indiana"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Indiana-Starke", "US/Indiana-Starke"), + ("US/Michigan", "US/Michigan"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("US/Samoa", "US/Samoa"), + ("UTC", "UTC"), + ("Universal", "Universal"), + ("W-SU", "W-SU"), + ("WET", "WET"), + ("Zulu", "Zulu"), + ], + default="UTC", + max_length=255, + ), + ), + migrations.AddConstraint( + model_name="notification", + constraint=models.CheckConstraint( + check=models.Q( + ( + "notification_type__in", + [ + "FAVORITE", + "REPLY", + "MENTION", + "TAG", + "FOLLOW", + "FOLLOW_REQUEST", + "BOOST", + "IMPORT", + "ADD", + "REPORT", + "INVITE", + "ACCEPT", + "JOIN", + "LEAVE", + "REMOVE", + ], + ) + ), + name="notification_type_valid", + ), + ), + migrations.AddField( + model_name="groupmemberinvitation", + name="group", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_invitations", + to="bookwyrm.group", + ), + ), + migrations.AddField( + model_name="groupmemberinvitation", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="group_invitations", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="groupmember", + name="group", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="memberships", + to="bookwyrm.group", + ), + ), + migrations.AddField( + model_name="groupmember", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="memberships", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="group", + name="user", + field=bookwyrm.models.fields.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + migrations.AddField( + model_name="list", + name="group", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="bookwyrm.group", + ), + ), + migrations.AddField( + model_name="notification", + name="related_group", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="bookwyrm.group", + ), + ), + migrations.AddConstraint( + model_name="groupmemberinvitation", + constraint=models.UniqueConstraint( + fields=("group", "user"), name="unique_invitation" + ), + ), + migrations.AddConstraint( + model_name="groupmember", + constraint=models.UniqueConstraint( + fields=("group", "user"), name="unique_membership" + ), + ), + ] diff --git a/bookwyrm/migrations/0108_alter_user_preferred_language.py b/bookwyrm/migrations/0108_alter_user_preferred_language.py new file mode 100644 index 00000000..3614ae1c --- /dev/null +++ b/bookwyrm/migrations/0108_alter_user_preferred_language.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.5 on 2021-10-11 17:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0107_alter_user_preferred_language"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="preferred_language", + field=models.CharField( + blank=True, + choices=[ + ("en-us", "English"), + ("de-de", "Deutsch (German)"), + ("es-es", "Español (Spanish)"), + ("fr-fr", "Français (French)"), + ("pt-br", "Português - Brasil (Brazilian Portuguese)"), + ("zh-hans", "简体中文 (Simplified Chinese)"), + ("zh-hant", "繁體中文 (Traditional Chinese)"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0109_status_edited_date.py b/bookwyrm/migrations/0109_status_edited_date.py new file mode 100644 index 00000000..3d4d733b --- /dev/null +++ b/bookwyrm/migrations/0109_status_edited_date.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.5 on 2021-10-15 15:54 + +import bookwyrm.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0108_alter_user_preferred_language"), + ] + + operations = [ + migrations.AddField( + model_name="status", + name="edited_date", + field=bookwyrm.models.fields.DateTimeField(blank=True, null=True), + ), + ] diff --git a/bookwyrm/migrations/0110_auto_20211015_1734.py b/bookwyrm/migrations/0110_auto_20211015_1734.py new file mode 100644 index 00000000..ed7dd43c --- /dev/null +++ b/bookwyrm/migrations/0110_auto_20211015_1734.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.5 on 2021-10-15 17:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0109_status_edited_date"), + ] + + operations = [ + migrations.AddField( + model_name="quotation", + name="raw_quote", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="status", + name="raw_content", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/bookwyrm/migrations/0111_merge_0107_auto_20211016_0639_0110_auto_20211015_1734.py b/bookwyrm/migrations/0111_merge_0107_auto_20211016_0639_0110_auto_20211015_1734.py new file mode 100644 index 00000000..f5f316c1 --- /dev/null +++ b/bookwyrm/migrations/0111_merge_0107_auto_20211016_0639_0110_auto_20211015_1734.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.5 on 2021-10-16 19:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0107_auto_20211016_0639"), + ("bookwyrm", "0110_auto_20211015_1734"), + ] + + operations = [] diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py index 6f378e83..c5ea44e0 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 @@ -22,10 +21,16 @@ from .relationship import UserFollows, UserFollowRequest, UserBlocks from .report import Report, ReportComment from .federated_server import FederatedServer +from .group import Group, GroupMember, GroupMemberInvitation + 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 729d9cba..3a88c524 100644 --- a/bookwyrm/models/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin.py @@ -7,7 +7,7 @@ import operator import logging from uuid import uuid4 import requests -from requests.exceptions import HTTPError, SSLError +from requests.exceptions import RequestException from Crypto.PublicKey import RSA from Crypto.Signature import pkcs1_15 @@ -43,7 +43,7 @@ class ActivitypubMixin: reverse_unfurl = False def __init__(self, *args, **kwargs): - """collect some info on model fields""" + """collect some info on model fields for later use""" self.image_fields = [] self.many_to_many_fields = [] self.simple_fields = [] # "simple" @@ -266,7 +266,7 @@ class ObjectMixin(ActivitypubMixin): signed_message = signer.sign(SHA256.new(content.encode("utf8"))) signature = activitypub.Signature( - creator="%s#main-key" % user.remote_id, + creator=f"{user.remote_id}#main-key", created=activity_object.published, signatureValue=b64encode(signed_message).decode("utf8"), ) @@ -285,16 +285,16 @@ class ObjectMixin(ActivitypubMixin): return activitypub.Delete( id=self.remote_id + "/activity", actor=user.remote_id, - to=["%s/followers" % user.remote_id], + to=[f"{user.remote_id}/followers"], cc=["https://www.w3.org/ns/activitystreams#Public"], object=self, ).serialize() def to_update_activity(self, user): """wrapper for Updates to an activity""" - activity_id = "%s#update/%s" % (self.remote_id, uuid4()) + uuid = uuid4() return activitypub.Update( - id=activity_id, + id=f"{self.remote_id}#update/{uuid}", actor=user.remote_id, to=["https://www.w3.org/ns/activitystreams#Public"], object=self, @@ -337,8 +337,8 @@ class OrderedCollectionPageMixin(ObjectMixin): paginated = Paginator(queryset, PAGE_LENGTH) # add computed fields specific to orderd collections activity["totalItems"] = paginated.count - activity["first"] = "%s?page=1" % remote_id - activity["last"] = "%s?page=%d" % (remote_id, paginated.num_pages) + activity["first"] = f"{remote_id}?page=1" + activity["last"] = f"{remote_id}?page={paginated.num_pages}" return serializer(**activity) @@ -362,6 +362,13 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin): self.collection_queryset, **kwargs ).serialize() + def delete(self, *args, broadcast=True, **kwargs): + """Delete the object""" + activity = self.to_delete_activity(self.user) + super().delete(*args, **kwargs) + if self.user.local and broadcast: + self.broadcast(activity, self.user) + class CollectionItemMixin(ActivitypubMixin): """for items that are part of an (Ordered)Collection""" @@ -413,7 +420,7 @@ class CollectionItemMixin(ActivitypubMixin): """AP for shelving a book""" collection_field = getattr(self, self.collection_field) return activitypub.Add( - id="{:s}#add".format(collection_field.remote_id), + id=f"{collection_field.remote_id}#add", actor=user.remote_id, object=self.to_activity_dataclass(), target=collection_field.remote_id, @@ -423,7 +430,7 @@ class CollectionItemMixin(ActivitypubMixin): """AP for un-shelving a book""" collection_field = getattr(self, self.collection_field) return activitypub.Remove( - id="{:s}#remove".format(collection_field.remote_id), + id=f"{collection_field.remote_id}#remove", actor=user.remote_id, object=self.to_activity_dataclass(), target=collection_field.remote_id, @@ -451,7 +458,7 @@ class ActivityMixin(ActivitypubMixin): """undo an action""" user = self.user if hasattr(self, "user") else self.user_subject return activitypub.Undo( - id="%s#undo" % self.remote_id, + id=f"{self.remote_id}#undo", actor=user.remote_id, object=self, ).serialize() @@ -495,7 +502,7 @@ def unfurl_related_field(related_field, sort_field=None): return related_field.remote_id -@app.task +@app.task(queue="medium_priority") def broadcast_task(sender_id, activity, recipients): """the celery task for broadcast""" user_model = apps.get_model("bookwyrm.User", require_ready=True) @@ -503,7 +510,7 @@ def broadcast_task(sender_id, activity, recipients): for recipient in recipients: try: sign_and_send(sender, activity, recipient) - except (HTTPError, SSLError, requests.exceptions.ConnectionError): + except RequestException: pass @@ -548,11 +555,11 @@ def to_ordered_collection_page( prev_page = next_page = None if activity_page.has_next(): - next_page = "%s?page=%d" % (remote_id, activity_page.next_page_number()) + next_page = f"{remote_id}?page={activity_page.next_page_number()}" if activity_page.has_previous(): - prev_page = "%s?page=%d" % (remote_id, activity_page.previous_page_number()) + prev_page = f"{remote_id}?page=%d{activity_page.previous_page_number()}" return activitypub.OrderedCollectionPage( - id="%s?page=%s" % (remote_id, page), + id=f"{remote_id}?page={page}", partOf=remote_id, orderedItems=items, next=next_page, diff --git a/bookwyrm/models/antispam.py b/bookwyrm/models/antispam.py new file mode 100644 index 00000000..7a85bbcf --- /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 6da80b17..53cf94ff 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 2cb7c036..f62678f7 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -1,19 +1,30 @@ """ base model with default fields """ +import base64 +from Crypto import Random + +from django.core.exceptions import PermissionDenied from django.db import models +from django.db.models import Q from django.dispatch import receiver +from django.http import Http404 +from django.utils.translation import gettext_lazy as _ from bookwyrm.settings import DOMAIN from .fields import RemoteIdField -DeactivationReason = models.TextChoices( - "DeactivationReason", - [ - "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(): + """the identifier for a user invite""" + return base64.b32encode(Random.get_random_bytes(5)).decode("ascii") class BookWyrmModel(models.Model): @@ -25,11 +36,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""" @@ -39,37 +50,123 @@ class BookWyrmModel(models.Model): @property def local_path(self): """how to link to this object in the local app""" - return self.get_remote_id().replace("https://%s" % DOMAIN, "") + return self.get_remote_id().replace(f"https://{DOMAIN}", "") - def visible_to_user(self, viewer): + def raise_visible_to_user(self, viewer): """is a user authorized to view an object?""" # make sure this is an object with privacy owned by a user if not hasattr(self, "user") or not hasattr(self, "privacy"): - return None + return # viewer can't see it if the object's owner blocked them if viewer in self.user.blocks.all(): - return False + raise Http404() # you can see your own posts and any public or unlisted posts if viewer == self.user or self.privacy in ["public", "unlisted"]: - return True + return # you can see the followers only posts of people you follow - if ( - self.privacy == "followers" - and self.user.followers.filter(id=viewer.id).first() + if self.privacy == "followers" and ( + self.user.followers.filter(id=viewer.id).first() ): - return True + return # you can see dms you are tagged in if hasattr(self, "mention_users"): if ( - self.privacy == "direct" + self.privacy in ["direct", "followers"] and self.mention_users.filter(id=viewer.id).first() ): - return True - return False + + return + + # you can see groups of which you are a member + if ( + hasattr(self, "memberships") + and self.memberships.filter(user=viewer).exists() + ): + return + + # you can see objects which have a group of which you are a member + if hasattr(self, "group"): + if ( + hasattr(self.group, "memberships") + and self.group.memberships.filter(user=viewer).exists() + ): + return + + raise Http404() + + def raise_not_editable(self, viewer): + """does this user have permission to edit this object? liable to be overwritten + by models that inherit this base model class""" + if not hasattr(self, "user"): + return + + # generally moderators shouldn't be able to edit other people's stuff + if self.user == viewer: + return + + raise PermissionDenied() + + def raise_not_deletable(self, viewer): + """does this user have permission to delete this object? liable to be + overwritten by models that inherit this base model class""" + if not hasattr(self, "user"): + return + + # but generally moderators can delete other people's stuff + if self.user == viewer or viewer.has_perm("moderate_post"): + return + + raise PermissionDenied() + + @classmethod + def privacy_filter(cls, viewer, privacy_levels=None): + """filter objects that have "user" and "privacy" fields""" + queryset = cls.objects + if hasattr(queryset, "select_subclasses"): + queryset = queryset.select_subclasses() + + privacy_levels = privacy_levels or ["public", "unlisted", "followers", "direct"] + # you can't see followers only or direct messages if you're not logged in + if viewer.is_anonymous: + privacy_levels = [ + p for p in privacy_levels if not p in ["followers", "direct"] + ] + else: + # exclude blocks from both directions + queryset = queryset.exclude( + Q(user__blocked_by=viewer) | Q(user__blocks=viewer) + ) + + # filter to only provided privacy levels + queryset = queryset.filter(privacy__in=privacy_levels) + + if "followers" in privacy_levels: + queryset = cls.followers_filter(queryset, viewer) + + # exclude direct messages not intended for the user + if "direct" in privacy_levels: + queryset = cls.direct_filter(queryset, viewer) + + return queryset + + @classmethod + def followers_filter(cls, queryset, viewer): + """Override-able filter for "followers" privacy level""" + return queryset.exclude( + ~Q( # user isn't following and it isn't their own status + Q(user__followers=viewer) | Q(user=viewer) + ), + privacy="followers", # and the status is followers only + ) + + @classmethod + def direct_filter(cls, queryset, viewer): + """Override-able filter for "direct" privacy level""" + return queryset.exclude(~Q(user=viewer), privacy="direct") @receiver(models.signals.post_save) diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index a6aa5de2..8ae75baf 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -3,14 +3,22 @@ import re from django.contrib.postgres.search import SearchVectorField from django.contrib.postgres.indexes import GinIndex -from django.db import models +from django.db import models, transaction +from django.db.models import Prefetch from django.dispatch import receiver +from django.utils.translation import gettext_lazy as _ from model_utils import FieldTracker from model_utils.managers import InheritanceManager +from imagekit.models import ImageSpecField from bookwyrm import activitypub from bookwyrm.preview_images import generate_edition_preview_image_task -from bookwyrm.settings import DOMAIN, DEFAULT_LANGUAGE, ENABLE_PREVIEW_IMAGES +from bookwyrm.settings import ( + DOMAIN, + DEFAULT_LANGUAGE, + ENABLE_PREVIEW_IMAGES, + ENABLE_THUMBNAIL_GENERATION, +) from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin from .base_model import BookWyrmModel @@ -97,6 +105,40 @@ class Book(BookDataModel): objects = InheritanceManager() field_tracker = FieldTracker(fields=["authors", "title", "subtitle", "cover"]) + if ENABLE_THUMBNAIL_GENERATION: + cover_bw_book_xsmall_webp = ImageSpecField( + source="cover", id="bw:book:xsmall:webp" + ) + cover_bw_book_xsmall_jpg = ImageSpecField( + source="cover", id="bw:book:xsmall:jpg" + ) + cover_bw_book_small_webp = ImageSpecField( + source="cover", id="bw:book:small:webp" + ) + cover_bw_book_small_jpg = ImageSpecField(source="cover", id="bw:book:small:jpg") + cover_bw_book_medium_webp = ImageSpecField( + source="cover", id="bw:book:medium:webp" + ) + cover_bw_book_medium_jpg = ImageSpecField( + source="cover", id="bw:book:medium:jpg" + ) + cover_bw_book_large_webp = ImageSpecField( + source="cover", id="bw:book:large:webp" + ) + cover_bw_book_large_jpg = ImageSpecField(source="cover", id="bw:book:large:jpg") + cover_bw_book_xlarge_webp = ImageSpecField( + source="cover", id="bw:book:xlarge:webp" + ) + cover_bw_book_xlarge_jpg = ImageSpecField( + source="cover", id="bw:book:xlarge:jpg" + ) + cover_bw_book_xxlarge_webp = ImageSpecField( + source="cover", id="bw:book:xxlarge:webp" + ) + cover_bw_book_xxlarge_jpg = ImageSpecField( + source="cover", id="bw:book:xxlarge:jpg" + ) + @property def author_text(self): """format a list of authors""" @@ -123,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): @@ -136,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, @@ -175,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, ) @@ -184,6 +227,16 @@ class Work(OrderedCollectionPageMixin, Book): deserialize_reverse_fields = [("editions", "editions")] +# https://schema.org/BookFormatType +FormatChoices = [ + ("AudiobookFormat", _("Audiobook")), + ("EBook", _("eBook")), + ("GraphicNovel", _("Graphic novel")), + ("Hardcover", _("Hardcover")), + ("Paperback", _("Paperback")), +] + + class Edition(Book): """an edition of a book""" @@ -201,7 +254,10 @@ class Edition(Book): max_length=255, blank=True, null=True, deduplication_field=True ) pages = fields.IntegerField(blank=True, null=True) - physical_format = fields.CharField(max_length=255, blank=True, null=True) + physical_format = fields.CharField( + max_length=255, choices=FormatChoices, null=True, blank=True + ) + physical_format_detail = fields.CharField(max_length=255, blank=True, null=True) publishers = fields.ArrayField( models.CharField(max_length=255), blank=True, default=list ) @@ -265,6 +321,27 @@ class Edition(Book): return super().save(*args, **kwargs) + @classmethod + def viewer_aware_objects(cls, viewer): + """annotate a book query with metadata related to the user""" + queryset = cls.objects + if not viewer or not viewer.is_authenticated: + return queryset + + queryset = queryset.prefetch_related( + Prefetch( + "shelfbook_set", + queryset=viewer.shelfbook_set.all(), + to_attr="current_shelves", + ), + Prefetch( + "readthrough_set", + queryset=viewer.readthrough_set.filter(is_active=True).all(), + to_attr="active_readthroughs", + ), + ) + return queryset + def isbn_10_to_13(isbn_10): """convert an isbn 10 into an isbn 13""" @@ -321,4 +398,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 2d671790..99e73ab3 100644 --- a/bookwyrm/models/connector.py +++ b/bookwyrm/models/connector.py @@ -14,12 +14,11 @@ class Connector(BookWyrmModel): identifier = models.CharField(max_length=255, unique=True) priority = models.IntegerField(default=2) name = models.CharField(max_length=255, null=True, blank=True) - local = models.BooleanField(default=False) connector_file = models.CharField(max_length=255, choices=ConnectorFiles.choices) 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 +28,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 c4518119..4c367521 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) + 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 e297c46c..eb03d457 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 57b364c9..7d4981e3 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -58,7 +58,7 @@ class ActivitypubFieldMixin: activitypub_field=None, activitypub_wrapper=None, deduplication_field=False, - **kwargs + **kwargs, ): self.deduplication_field = deduplication_field if activitypub_wrapper: @@ -68,8 +68,8 @@ class ActivitypubFieldMixin: self.activitypub_field = activitypub_field super().__init__(*args, **kwargs) - def set_field_from_activity(self, instance, data): - """helper function for assinging a value to the field""" + def set_field_from_activity(self, instance, data, overwrite=True): + """helper function for assinging a value to the field. Returns if changed""" try: value = getattr(data, self.get_activitypub_field()) except AttributeError: @@ -79,8 +79,21 @@ class ActivitypubFieldMixin: value = getattr(data, "actor") formatted = self.field_from_activity(value) if formatted is None or formatted is MISSING or formatted == {}: - return + return False + + current_value = ( + getattr(instance, self.name) if hasattr(instance, self.name) else None + ) + # if we're not in overwrite mode, only continue updating the field if its unset + if current_value and not overwrite: + return False + + # the field is unchanged + if current_value == formatted: + return False + setattr(instance, self.name, formatted) + return True def set_activity_from_field(self, activity, instance): """update the json object""" @@ -206,17 +219,34 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField): ) # pylint: disable=invalid-name - def set_field_from_activity(self, instance, data): + def set_field_from_activity(self, instance, data, overwrite=True): + if not overwrite: + return False + + original = getattr(instance, self.name) to = data.to cc = data.cc + + # we need to figure out who this is to get their followers link + for field in ["attributedTo", "owner", "actor"]: + if hasattr(data, field): + user_field = field + break + if not user_field: + raise ValidationError("No user field found for privacy", data) + user = activitypub.resolve_remote_id(getattr(data, user_field), model="User") + if to == [self.public]: setattr(instance, self.name, "public") + elif to == [user.followers_url]: + setattr(instance, self.name, "followers") elif cc == []: setattr(instance, self.name, "direct") elif self.public in cc: setattr(instance, self.name, "unlisted") else: setattr(instance, self.name, "followers") + return original == getattr(instance, self.name) def set_activity_from_field(self, activity, instance): # explicitly to anyone mentioned (statuses only) @@ -225,9 +255,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField): mentions = [u.remote_id for u in instance.mention_users.all()] # this is a link to the followers list # pylint: disable=protected-access - followers = instance.user.__class__._meta.get_field( - "followers" - ).field_to_activity(instance.user.followers) + followers = instance.user.followers_url if instance.privacy == "public": activity["to"] = [self.public] activity["cc"] = [followers] + mentions @@ -267,18 +295,22 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField): self.link_only = link_only super().__init__(*args, **kwargs) - def set_field_from_activity(self, instance, data): + def set_field_from_activity(self, instance, data, overwrite=True): """helper function for assinging a value to the field""" + if not overwrite and getattr(instance, self.name).exists(): + return False + value = getattr(data, self.get_activitypub_field()) formatted = self.field_from_activity(value) if formatted is None or formatted is MISSING: - return + return False getattr(instance, self.name).set(formatted) instance.save(broadcast=False) + return True 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): @@ -368,13 +400,18 @@ class ImageField(ActivitypubFieldMixin, models.ImageField): super().__init__(*args, **kwargs) # pylint: disable=arguments-differ - def set_field_from_activity(self, instance, data, save=True): + def set_field_from_activity(self, instance, data, save=True, overwrite=True): """helper function for assinging a value to the field""" value = getattr(data, self.get_activitypub_field()) formatted = self.field_from_activity(value) if formatted is None or formatted is MISSING: - return + return False + + if not overwrite and hasattr(instance, self.name): + return False + getattr(instance, self.name).save(*formatted, save=save) + return True def set_activity_from_field(self, activity, instance): value = getattr(instance, self.name) @@ -412,7 +449,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/group.py b/bookwyrm/models/group.py new file mode 100644 index 00000000..552e2c28 --- /dev/null +++ b/bookwyrm/models/group.py @@ -0,0 +1,182 @@ +""" do book related things with other users """ +from django.apps import apps +from django.db import models, IntegrityError, transaction +from django.db.models import Q +from bookwyrm.settings import DOMAIN +from .base_model import BookWyrmModel +from . import fields +from .relationship import UserBlocks + + +class Group(BookWyrmModel): + """A group of users""" + + name = fields.CharField(max_length=100) + user = fields.ForeignKey("User", on_delete=models.CASCADE) + description = fields.TextField(blank=True, null=True) + privacy = fields.PrivacyField() + + def get_remote_id(self): + """don't want the user to be in there in this case""" + return f"https://{DOMAIN}/group/{self.id}" + + @classmethod + def followers_filter(cls, queryset, viewer): + """Override filter for "followers" privacy level to allow non-following + group members to see the existence of groups and group lists""" + + return queryset.exclude( + ~Q( # user is not a group member + Q(user__followers=viewer) | Q(user=viewer) | Q(memberships__user=viewer) + ), + privacy="followers", # and the status of the group is followers only + ) + + @classmethod + def direct_filter(cls, queryset, viewer): + """Override filter for "direct" privacy level to allow group members + to see the existence of groups and group lists""" + + return queryset.exclude(~Q(memberships__user=viewer), privacy="direct") + + +class GroupMember(models.Model): + """Users who are members of a group""" + + created_date = models.DateTimeField(auto_now_add=True) + updated_date = models.DateTimeField(auto_now=True) + group = models.ForeignKey( + "Group", on_delete=models.CASCADE, related_name="memberships" + ) + user = models.ForeignKey( + "User", on_delete=models.CASCADE, related_name="memberships" + ) + + class Meta: + """Users can only have one membership per group""" + + constraints = [ + models.UniqueConstraint(fields=["group", "user"], name="unique_membership") + ] + + def save(self, *args, **kwargs): + """don't let a user invite someone who blocked them""" + # blocking in either direction is a no-go + if UserBlocks.objects.filter( + Q( + user_subject=self.group.user, + user_object=self.user, + ) + | Q( + user_subject=self.user, + user_object=self.group.user, + ) + ).exists(): + raise IntegrityError() + # accepts and requests are handled by the GroupInvitation model + super().save(*args, **kwargs) + + @classmethod + def from_request(cls, join_request): + """converts a join request into a member relationship""" + + # remove the invite + join_request.delete() + + # make a group member + return cls.objects.create( + user=join_request.user, + group=join_request.group, + ) + + @classmethod + def remove(cls, owner, user): + """remove a user from a group""" + + memberships = cls.objects.filter(group__user=owner, user=user).all() + for member in memberships: + member.delete() + + +class GroupMemberInvitation(models.Model): + """adding a user to a group requires manual confirmation""" + + created_date = models.DateTimeField(auto_now_add=True) + group = models.ForeignKey( + "Group", on_delete=models.CASCADE, related_name="user_invitations" + ) + user = models.ForeignKey( + "User", on_delete=models.CASCADE, related_name="group_invitations" + ) + + class Meta: + """Users can only have one outstanding invitation per group""" + + constraints = [ + models.UniqueConstraint(fields=["group", "user"], name="unique_invitation") + ] + + def save(self, *args, **kwargs): + """make sure the membership doesn't already exist""" + # if there's an invitation for a membership that already exists, accept it + # without changing the local database state + if GroupMember.objects.filter(user=self.user, group=self.group).exists(): + self.accept() + return + + # blocking in either direction is a no-go + if UserBlocks.objects.filter( + Q( + user_subject=self.group.user, + user_object=self.user, + ) + | Q( + user_subject=self.user, + user_object=self.group.user, + ) + ).exists(): + raise IntegrityError() + + # make an invitation + super().save(*args, **kwargs) + + # now send the invite + model = apps.get_model("bookwyrm.Notification", require_ready=True) + notification_type = "INVITE" + model.objects.create( + user=self.user, + related_user=self.group.user, + related_group=self.group, + notification_type=notification_type, + ) + + def accept(self): + """turn this request into the real deal""" + + with transaction.atomic(): + GroupMember.from_request(self) + + model = apps.get_model("bookwyrm.Notification", require_ready=True) + # tell the group owner + model.objects.create( + user=self.group.user, + related_user=self.user, + related_group=self.group, + notification_type="ACCEPT", + ) + + # let the other members know about it + for membership in self.group.memberships.all(): + member = membership.user + if member not in (self.user, self.group.user): + model.objects.create( + user=member, + related_user=self.user, + related_group=self.group, + notification_type="JOIN", + ) + + def reject(self): + """generate a Reject for this membership request""" + + self.delete() diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py index f2993846..22253fef 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.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 bbad5ba9..978a7a9b 100644 --- a/bookwyrm/models/list.py +++ b/bookwyrm/models/list.py @@ -1,22 +1,20 @@ """ make a list of books!! """ from django.apps import apps from django.db import models +from django.db.models import Q from django.utils import timezone from bookwyrm import activitypub from bookwyrm.settings import DOMAIN + from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin from .base_model import BookWyrmModel +from .group import GroupMember from . import fields - CurationType = models.TextChoices( "Curation", - [ - "closed", - "open", - "curated", - ], + ["closed", "open", "curated", "group"], ) @@ -32,6 +30,13 @@ class List(OrderedCollectionMixin, BookWyrmModel): curation = fields.CharField( max_length=255, default="closed", choices=CurationType.choices ) + group = models.ForeignKey( + "Group", + on_delete=models.SET_NULL, + default=None, + blank=True, + null=True, + ) books = models.ManyToManyField( "Edition", symmetrical=False, @@ -42,7 +47,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): @@ -54,6 +59,52 @@ class List(OrderedCollectionMixin, BookWyrmModel): ordering = ("-updated_date",) + def raise_not_editable(self, viewer): + """the associated user OR the list owner can edit""" + if self.user == viewer: + return + # group members can edit items in group lists + is_group_member = GroupMember.objects.filter( + group=self.group, user=viewer + ).exists() + if is_group_member: + return + super().raise_not_editable(viewer) + + @classmethod + def followers_filter(cls, queryset, viewer): + """Override filter for "followers" privacy level to allow non-following + group members to see the existence of group lists""" + + return queryset.exclude( + ~Q( # user isn't following or group member + Q(user__followers=viewer) + | Q(user=viewer) + | Q(group__memberships__user=viewer) + ), + privacy="followers", # and the status (of the list) is followers only + ) + + @classmethod + def direct_filter(cls, queryset, viewer): + """Override filter for "direct" privacy level to allow + group members to see the existence of group lists""" + + return queryset.exclude( + ~Q( # user not self and not in the group if this is a group list + Q(user=viewer) | Q(group__memberships__user=viewer) + ), + privacy="direct", + ) + + @classmethod + def remove_from_group(cls, owner, user): + """remove a list from a group""" + + cls.objects.filter(group__user=owner, user=user).all().update( + group=None, curation="closed" + ) + class ListItem(CollectionItemMixin, BookWyrmModel): """ok""" @@ -82,9 +133,9 @@ class ListItem(CollectionItemMixin, BookWyrmModel): self.book_list.save(broadcast=False) list_owner = self.book_list.user + model = apps.get_model("bookwyrm.Notification", require_ready=True) # create a notification if somoene ELSE added to a local user's list if created and list_owner.local and list_owner != self.user: - model = apps.get_model("bookwyrm.Notification", require_ready=True) model.objects.create( user=list_owner, related_user=self.user, @@ -92,6 +143,28 @@ class ListItem(CollectionItemMixin, BookWyrmModel): notification_type="ADD", ) + if self.book_list.group: + for membership in self.book_list.group.memberships.all(): + if membership.user != self.user: + model.objects.create( + user=membership.user, + related_user=self.user, + related_list_item=self, + notification_type="ADD", + ) + + def raise_not_deletable(self, viewer): + """the associated user OR the list owner can delete""" + if self.book_list.user == viewer: + return + # group members can delete items in group lists + is_group_member = GroupMember.objects.filter( + group=self.book_list.group, user=viewer + ).exists() + if is_group_member: + return + super().raise_not_deletable(viewer) + class Meta: """A book may only be placed into a list once, and each order in the list may be used only once""" diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py index ff0b4e5a..b15b95c2 100644 --- a/bookwyrm/models/notification.py +++ b/bookwyrm/models/notification.py @@ -1,11 +1,13 @@ """ 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 - +# pylint: disable=line-too-long NotificationType = models.TextChoices( "NotificationType", - "FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT", + "FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT INVITE ACCEPT JOIN LEAVE REMOVE", ) @@ -17,6 +19,9 @@ class Notification(BookWyrmModel): related_user = models.ForeignKey( "User", on_delete=models.CASCADE, null=True, related_name="related_user" ) + related_group = models.ForeignKey( + "Group", on_delete=models.CASCADE, null=True, related_name="notifications" + ) related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True) related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True) related_list_item = models.ForeignKey( @@ -35,6 +40,7 @@ class Notification(BookWyrmModel): user=self.user, related_book=self.related_book, related_user=self.related_user, + related_group=self.related_group, related_status=self.related_status, related_import=self.related_import, related_list_item=self.related_list_item, @@ -53,3 +59,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 664daa13..f75918ac 100644 --- a/bookwyrm/models/readthrough.py +++ b/bookwyrm/models/readthrough.py @@ -1,7 +1,7 @@ """ progress in a book """ -from django.db import models -from django.utils import timezone from django.core import validators +from django.db import models +from django.db.models import F, Q from .base_model import BookWyrmModel @@ -26,11 +26,14 @@ class ReadThrough(BookWyrmModel): ) start_date = models.DateTimeField(blank=True, null=True) finish_date = models.DateTimeField(blank=True, null=True) + is_active = models.BooleanField(default=True) def save(self, *args, **kwargs): """update user active time""" - self.user.last_active_date = timezone.now() - self.user.save(broadcast=False) + self.user.update_active_date() + # an active readthrough must have an unset finish date + if self.finish_date: + self.is_active = False super().save(*args, **kwargs) def create_update(self): @@ -41,6 +44,16 @@ class ReadThrough(BookWyrmModel): ) return None + class Meta: + """Don't let readthroughs end before they start""" + + constraints = [ + models.CheckConstraint( + check=Q(finish_date__gte=F("start_date")), name="chronology" + ) + ] + ordering = ("-start_date",) + class ProgressUpdate(BookWyrmModel): """Store progress through a book in the database.""" @@ -54,6 +67,5 @@ class ProgressUpdate(BookWyrmModel): def save(self, *args, **kwargs): """update user active time""" - self.user.last_active_date = timezone.now() - self.user.save(broadcast=False) + self.user.update_active_date() super().save(*args, **kwargs) diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py index edb89d13..fc7a9df8 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 7ff4c909..636817cb 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 c4e907d2..c578f082 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -1,5 +1,6 @@ """ puttin' books on shelves """ import re +from django.core.exceptions import PermissionDenied from django.db import models from django.utils import timezone @@ -20,6 +21,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel): name = fields.CharField(max_length=100) identifier = models.CharField(max_length=100) + description = models.TextField(blank=True, null=True, max_length=500) user = fields.ForeignKey( "User", on_delete=models.PROTECT, activitypub_field="owner" ) @@ -44,18 +46,29 @@ 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): """list of books for this shelf, overrides OrderedCollectionMixin""" return self.books.order_by("shelfbook") + @property + def deletable(self): + """can the shelf be safely deleted?""" + return self.editable and not self.shelfbook_set.exists() + def get_remote_id(self): """shelf identifier instead of id""" base_path = self.user.remote_id identifier = self.identifier or self.get_identifier() - return "%s/books/%s" % (base_path, identifier) + return f"{base_path}/books/{identifier}" + + def raise_not_deletable(self, viewer): + """don't let anyone delete a default shelf""" + super().raise_not_deletable(viewer) + if not self.deletable: + raise PermissionDenied() class Meta: """user/shelf unqiueness""" diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py index 872f6b45..8338fff8 100644 --- a/bookwyrm/models/site.py +++ b/bookwyrm/models/site.py @@ -1,8 +1,6 @@ """ the particulars for this instance of BookWyrm """ -import base64 import datetime -from Crypto import Random from django.db import models, IntegrityError from django.dispatch import receiver from django.utils import timezone @@ -10,7 +8,7 @@ from model_utils import FieldTracker from bookwyrm.preview_images import generate_site_preview_image_task from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES -from .base_model import BookWyrmModel +from .base_model import BookWyrmModel, new_access_code from .user import User @@ -22,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.") @@ -33,6 +38,7 @@ class SiteSettings(models.Model): # registration allow_registration = models.BooleanField(default=True) allow_invite_requests = models.BooleanField(default=True) + require_confirm_email = models.BooleanField(default=True) # images logo = models.ImageField(upload_to="logos/", null=True, blank=True) @@ -61,11 +67,6 @@ class SiteSettings(models.Model): return default_settings -def new_access_code(): - """the identifier for a user invite""" - return base64.b32encode(Random.get_random_bytes(5)).decode("ascii") - - class SiteInvite(models.Model): """gives someone access to create an account on the instance""" @@ -86,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): @@ -126,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 3c25f1af..2b395ec8 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -3,8 +3,10 @@ from dataclasses import MISSING import re from django.apps import apps +from django.core.exceptions import PermissionDenied from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.db.models import Q from django.dispatch import receiver from django.template.loader import get_template from django.utils import timezone @@ -29,6 +31,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): "User", on_delete=models.PROTECT, activitypub_field="attributedTo" ) content = fields.HtmlField(blank=True, null=True) + raw_content = models.TextField(blank=True, null=True) mention_users = fields.TagField("User", related_name="mention_user") mention_books = fields.TagField("Edition", related_name="mention_book") local = models.BooleanField(default=True) @@ -41,6 +44,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): published_date = fields.DateTimeField( default=timezone.now, activitypub_field="published" ) + edited_date = fields.DateTimeField( + blank=True, null=True, activitypub_field="updated" + ) deleted = models.BooleanField(default=False) deleted_date = models.DateTimeField(blank=True, null=True) favorites = models.ManyToManyField( @@ -56,6 +62,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): on_delete=models.PROTECT, activitypub_field="inReplyTo", ) + thread_id = models.IntegerField(blank=True, null=True) objects = InheritanceManager() activity_serializer = activitypub.Note @@ -69,37 +76,14 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): def save(self, *args, **kwargs): """save and notify""" + if self.reply_parent: + self.thread_id = self.reply_parent.thread_id or self.reply_parent.id + 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, - ) + if not self.reply_parent: + self.thread_id = self.id + super().save(broadcast=False, update_fields=["thread_id"]) def delete(self, *args, **kwargs): # pylint: disable=unused-argument """ "delete" a status""" @@ -108,6 +92,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 +167,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): """helper function for loading AP serialized replies to a status""" return self.to_ordered_collection( self.replies(self), - remote_id="%s/replies" % self.remote_id, + remote_id=f"{self.remote_id}/replies", collection_only=True, - **kwargs + **kwargs, ).serialize() def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ @@ -217,6 +205,35 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): """json serialized activitypub class""" return self.to_activity_dataclass(pure=pure).serialize() + def raise_not_editable(self, viewer): + """certain types of status aren't editable""" + # first, the standard raise + super().raise_not_editable(viewer) + if isinstance(self, (GeneratedNote, ReviewRating)): + raise PermissionDenied() + + @classmethod + def privacy_filter(cls, viewer, privacy_levels=None): + queryset = super().privacy_filter(viewer, privacy_levels=privacy_levels) + return queryset.filter(deleted=False) + + @classmethod + def direct_filter(cls, queryset, viewer): + """Overridden filter for "direct" privacy level""" + return queryset.exclude( + ~Q(Q(user=viewer) | Q(mention_users=viewer)), privacy="direct" + ) + + @classmethod + def followers_filter(cls, queryset, viewer): + """Override-able filter for "followers" privacy level""" + return queryset.exclude( + ~Q( # not yourself, a follower, or someone who is tagged + Q(user__followers=viewer) | Q(user=viewer) | Q(mention_users=viewer) + ), + privacy="followers", # and the status is followers only + ) + class GeneratedNote(Status): """these are app-generated messages about user activity""" @@ -226,21 +243,40 @@ 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" -class Comment(Status): - """like a review but without a rating and transient""" +ReadingStatusChoices = models.TextChoices( + "ReadingStatusChoices", ["to-read", "reading", "read"] +) + + +class BookStatus(Status): + """Shared fields for comments, quotes, reviews""" book = fields.ForeignKey( "Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook" ) + pure_type = "Note" + + reading_status = fields.CharField( + max_length=255, choices=ReadingStatusChoices.choices, null=True, blank=True + ) + + class Meta: + """not a real model, sorry""" + + abstract = True + + +class Comment(BookStatus): + """like a review but without a rating and transient""" # this is it's own field instead of a foreign key to the progress update # so that the update can be deleted without impacting the status @@ -258,22 +294,28 @@ class Comment(Status): @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 - pure_type = "Note" -class Quotation(Status): +class Quotation(BookStatus): """like a review but without a rating and transient""" quote = fields.HtmlField() - book = fields.ForeignKey( - "Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook" + raw_quote = models.TextField(blank=True, null=True) + position = models.IntegerField( + validators=[MinValueValidator(0)], null=True, blank=True + ) + position_mode = models.CharField( + max_length=3, + choices=ProgressMode.choices, + default=ProgressMode.PAGE, + null=True, + blank=True, ) @property @@ -281,24 +323,18 @@ class Quotation(Status): """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 - pure_type = "Note" -class Review(Status): +class Review(BookStatus): """a book review""" name = fields.CharField(max_length=255, null=True) - book = fields.ForeignKey( - "Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook" - ) rating = fields.DecimalField( default=None, null=True, @@ -368,27 +404,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" """ @@ -401,10 +416,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 49458a2e..d7945843 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -7,7 +7,7 @@ from django.contrib.auth.models import AbstractUser, Group from django.contrib.postgres.fields import CICharField from django.core.validators import MinValueValidator from django.dispatch import receiver -from django.db import models +from django.db import models, transaction from django.utils import timezone from model_utils import FieldTracker import pytz @@ -17,16 +17,22 @@ from bookwyrm.connectors import get_data, ConnectorException from bookwyrm.models.shelf import Shelf from bookwyrm.models.status import Status, Review from bookwyrm.preview_images import generate_user_preview_image_task -from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES +from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES from bookwyrm.signatures import create_key_pair from bookwyrm.tasks import app from bookwyrm.utils import regex from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin -from .base_model import BookWyrmModel, DeactivationReason +from .base_model import BookWyrmModel, DeactivationReason, new_access_code from .federated_server import FederatedServer from . import fields, Review +def site_link(): + """helper for generating links to the site""" + protocol = "https" if USE_HTTPS else "http" + return f"{protocol}://{DOMAIN}" + + class User(OrderedCollectionPageMixin, AbstractUser): """a user who wants to read books""" @@ -76,9 +82,9 @@ class User(OrderedCollectionPageMixin, AbstractUser): preview_image = models.ImageField( upload_to="previews/avatars/", blank=True, null=True ) - followers = fields.ManyToManyField( + followers_url = fields.CharField(max_length=255, activitypub_field="followers") + followers = models.ManyToManyField( "self", - link_only=True, symmetrical=False, through="UserFollows", through_fields=("user_object", "user_subject"), @@ -98,6 +104,9 @@ class User(OrderedCollectionPageMixin, AbstractUser): through_fields=("user_subject", "user_object"), related_name="blocked_by", ) + saved_lists = models.ManyToManyField( + "List", symmetrical=False, related_name="saved_lists", blank=True + ) favorites = models.ManyToManyField( "Status", symmetrical=False, @@ -105,35 +114,57 @@ class User(OrderedCollectionPageMixin, AbstractUser): through_fields=("user", "status"), related_name="favorite_statuses", ) + default_post_privacy = models.CharField( + max_length=255, default="public", choices=fields.PrivacyLevels.choices + ) remote_id = fields.RemoteIdField(null=True, unique=True, activitypub_field="id") created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) - last_active_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 + preferred_language = models.CharField( + choices=LANGUAGES, + null=True, + blank=True, + max_length=255, ) + deactivation_reason = models.CharField( + 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" property_fields = [("following_link", "following")] field_tracker = FieldTracker(fields=["name", "avatar"]) + @property + def confirmation_link(self): + """helper for generating confirmation links""" + link = site_link() + return f"{link}/confirm-email/{self.confirmation_code}" + @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): @@ -170,12 +201,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" @@ -199,22 +233,22 @@ 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, id_only=True, - **kwargs + **kwargs, ) def to_followers_activity(self, **kwargs): """activitypub followers list""" - remote_id = "%s/followers" % self.remote_id + remote_id = self.followers_url return self.to_ordered_collection( self.followers.order_by("-updated_date").all(), remote_id=remote_id, id_only=True, - **kwargs + **kwargs, ) def to_activity(self, **kwargs): @@ -242,42 +276,65 @@ 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) - super().save(*args, **kwargs) + 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 # this is a new remote user, we need to set their remote server field if not self.local: super().save(*args, **kwargs) - set_remote_server.delay(self.id) + transaction.on_commit(lambda: set_remote_server.delay(self.id)) return - # populate fields for local users - self.remote_id = "https://%s/user/%s" % (DOMAIN, self.localname) - self.inbox = "%s/inbox" % self.remote_id - self.shared_inbox = "https://%s/inbox" % DOMAIN - self.outbox = "%s/outbox" % self.remote_id + with transaction.atomic(): + # populate fields for local users + link = site_link() + self.remote_id = f"{link}/user/{self.localname}" + self.followers_url = f"{self.remote_id}/followers" + self.inbox = f"{self.remote_id}/inbox" + self.shared_inbox = f"{link}/inbox" + self.outbox = f"{self.remote_id}/outbox" - # an id needs to be set before we can proceed with related models + # an id needs to be set before we can proceed with related models + super().save(*args, **kwargs) + + # make users editors by default + try: + self.groups.add(Group.objects.get(name="editor")) + except Group.DoesNotExist: + # this should only happen in tests + pass + + # create keys and shelves for new local users + self.key_pair = KeyPair.objects.create( + remote_id=f"{self.remote_id}/#main-key" + ) + self.save(broadcast=False, update_fields=["key_pair"]) + + self.create_shelves() + + def delete(self, *args, **kwargs): + """deactivate rather than delete a user""" + self.is_active = False + # skip the logic in this class's save() super().save(*args, **kwargs) - # make users editors by default - try: - self.groups.add(Group.objects.get(name="editor")) - except Group.DoesNotExist: - # this should only happen in tests - pass - - # create keys and shelves for new local users - self.key_pair = KeyPair.objects.create( - remote_id="%s/#main-key" % self.remote_id - ) - self.save(broadcast=False) + @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", @@ -301,17 +358,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""" @@ -326,7 +372,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""" @@ -363,7 +409,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): @@ -400,13 +446,13 @@ 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) actor_parts = urlparse(user.remote_id) user.federated_server = get_or_create_remote_server(actor_parts.netloc) - user.save(broadcast=False) + user.save(broadcast=False, update_fields=["federated_server"]) if user.bookwyrm_user and user.outbox: get_remote_reviews.delay(user.outbox) @@ -419,7 +465,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): @@ -439,7 +485,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 29c4961c..8224a278 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: @@ -338,9 +341,9 @@ def save_and_cleanup(image, instance=None): save_without_broadcast = isinstance(instance, (models.Book, models.User)) if save_without_broadcast: - instance.save(broadcast=False) + instance.save(broadcast=False, update_fields=["preview_image"]) else: - instance.save() + instance.save(update_fields=["preview_image"]) # Clean up old file after saving if old_path and default_storage.exists(old_path): @@ -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/redis_store.py b/bookwyrm/redis_store.py index 259bc4fd..78f373a2 100644 --- a/bookwyrm/redis_store.py +++ b/bookwyrm/redis_store.py @@ -33,10 +33,11 @@ class RedisStore(ABC): # and go! return pipeline.execute() - def remove_object_from_related_stores(self, obj): + def remove_object_from_related_stores(self, obj, stores=None): """remove an object from all stores""" + stores = self.get_stores_for_object(obj) if stores is None else stores pipeline = r.pipeline() - for store in self.get_stores_for_object(obj): + for store in stores: pipeline.zrem(store, -1, obj.id) pipeline.execute() @@ -50,15 +51,15 @@ class RedisStore(ABC): pipeline.execute() def bulk_remove_objects_from_store(self, objs, store): - """remoev a list of objects from a given store""" + """remove a list of objects from a given store""" pipeline = r.pipeline() for obj in objs[: self.max_length]: pipeline.zrem(store, -1, obj.id) pipeline.execute() - def get_store(self, store): # pylint: disable=no-self-use + def get_store(self, store, **kwargs): # pylint: disable=no-self-use """load the values in a store""" - return r.zrevrange(store, 0, -1) + return r.zrevrange(store, 0, -1, **kwargs) def populate_store(self, store): """go from zero to a store""" diff --git a/bookwyrm/sanitize_html.py b/bookwyrm/sanitize_html.py index 0be64c58..8b0e3c4c 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 17fcfabe..44d65cca 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -7,22 +7,14 @@ from django.utils.translation import gettext_lazy as _ env = Env() +env.read_env() DOMAIN = env("DOMAIN") -VERSION = "0.0.1" +VERSION = "0.1.0" 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 = "3eb4edb1" # email EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend") @@ -32,13 +24,14 @@ 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__))) LOCALE_PATHS = [ os.path.join(BASE_DIR, "locale"), ] +LANGUAGE_COOKIE_NAME = env.str("LANGUAGE_COOKIE_NAME", "django_language") DEFAULT_AUTO_FIELD = "django.db.models.AutoField" @@ -75,6 +68,7 @@ INSTALLED_APPS = [ "django_rename_app", "bookwyrm", "celery", + "imagekit", "storages", ] @@ -85,7 +79,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", ] @@ -118,7 +113,11 @@ REDIS_ACTIVITY_PORT = env("REDIS_ACTIVITY_PORT", 6379) REDIS_ACTIVITY_PASSWORD = env("REDIS_ACTIVITY_PASSWORD", None) MAX_STREAM_LENGTH = int(env("MAX_STREAM_LENGTH", 200)) -STREAMS = ["home", "local", "federated"] + +STREAMS = [ + {"key": "home", "name": _("Home Timeline"), "shortname": _("Home")}, + {"key": "books", "name": _("Books Timeline"), "shortname": _("Books")}, +] # Database # https://docs.djangoproject.com/en/3.2/ref/settings/#databases @@ -130,7 +129,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), }, } @@ -164,11 +163,12 @@ AUTH_PASSWORD_VALIDATORS = [ LANGUAGE_CODE = "en-us" LANGUAGES = [ ("en-us", _("English")), - ("de-de", _("German")), - ("es", _("Spanish")), - ("fr-fr", _("French")), - ("zh-hans", _("Simplified Chinese")), - ("zh-hant", _("Traditional Chinese")), + ("de-de", _("Deutsch (German)")), + ("es-es", _("Español (Spanish)")), + ("fr-fr", _("Français (French)")), + ("pt-br", _("Português - Brasil (Brazilian Portuguese)")), + ("zh-hans", _("简体中文 (Simplified Chinese)")), + ("zh-hant", _("繁體中文 (Traditional Chinese)")), ] @@ -181,12 +181,12 @@ 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) +IMAGEKIT_CACHEFILE_DIR = "thumbnails" # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.2/howto/static-files/ @@ -213,12 +213,13 @@ 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"{PROTOCOL}://{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"{PROTOCOL}://{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/" MEDIA_FULL_URL = MEDIA_URL + STATIC_FULL_URL = STATIC_URL DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage" # I don't know if it's used, but the site crashes without it STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static")) @@ -227,5 +228,6 @@ 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}" + STATIC_FULL_URL = f"{PROTOCOL}://{DOMAIN}{STATIC_URL}" MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images")) diff --git a/bookwyrm/signatures.py b/bookwyrm/signatures.py index c8c90028..61cafe71 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 d10fb9b7..0d280fd5 100644 --- a/bookwyrm/static/css/bookwyrm.css +++ b/bookwyrm/static/css/bookwyrm.css @@ -29,6 +29,15 @@ body { min-width: 75% !important; } +.modal-card-body { + max-height: 70vh; +} + +.clip-text { + max-height: 35em; + overflow: hidden; +} + /** Utilities not covered by Bulma ******************************************************************************/ @@ -80,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 ******************************************************************************/ @@ -87,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; } @@ -158,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 @@ -227,16 +277,21 @@ body { /* Cover caption * -------------------------------------------------------------------------- */ -.no-cover .cover_caption { +.no-cover .cover-caption { position: absolute; top: 0; right: 0; bottom: 0; left: 0; - padding: 0.25em; + padding: 0.5em; font-size: 0.75em; color: white; background-color: #002549; + display: flex; + align-items: center; + justify-content: center; + white-space: initial; + text-align: center; } /** Avatars @@ -278,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 */ @@ -395,6 +492,37 @@ ol.ordered-list li::before { } } +/* Threads + ******************************************************************************/ + +.thread .is-main .card { + box-shadow: 0 0.5em 1em -0.125em rgb(50 115 220 / 35%), 0 0 0 1px rgb(50 115 220 / 2%); +} + +.thread::after { + content: ""; + position: absolute; + z-index: -1; + top: 0; + bottom: 0; + left: 2.5em; + border-left: 2px solid #e0e0e0; +} + +/* Breadcrumbs + ******************************************************************************/ + +.breadcrumb li:first-child * { + padding-left: 0; +} + +.breadcrumb li > * { + align-items: center; + display: flex; + justify-content: center; + padding: 0 0.75em; +} + /* Dimensions * @todo These could be in rem. ******************************************************************************/ diff --git a/bookwyrm/static/css/fonts/icomoon.eot b/bookwyrm/static/css/fonts/icomoon.eot index 566fb13d..8eba8692 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 6be97327..82e41329 100644 --- a/bookwyrm/static/css/fonts/icomoon.svg +++ b/bookwyrm/static/css/fonts/icomoon.svg @@ -7,39 +7,39 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + diff --git a/bookwyrm/static/css/fonts/icomoon.ttf b/bookwyrm/static/css/fonts/icomoon.ttf index 55df6418..5bf90a0a 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 fa53e8cf..6ce6834d 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 c78af145..b43224e3 100644 --- a/bookwyrm/static/css/vendor/icons.css +++ b/bookwyrm/static/css/vendor/icons.css @@ -1,156 +1,144 @@ - -/** @todo Replace icons with SVG symbols. - @see https://www.youtube.com/watch?v=9xXBYcWgCHA */ @font-face { - font-family: 'icomoon'; - src: url('../fonts/icomoon.eot?n5x55'); - src: url('../fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'), - url('../fonts/icomoon.ttf?n5x55') format('truetype'), - url('../fonts/icomoon.woff?n5x55') format('woff'), - url('../fonts/icomoon.svg?n5x55#icomoon') format('svg'); - font-weight: normal; - font-style: normal; - font-display: block; + font-family: 'icomoon'; + 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; } [class^="icon-"], [class*=" icon-"] { - /* use !important to prevent issues with browser extensions that change fonts */ - font-family: 'icomoon' !important; - speak: never; - font-style: normal; - font-weight: normal; - font-variant: normal; - text-transform: none; - line-height: 1; + /* use !important to prevent issues with browser extensions that change fonts */ + font-family: 'icomoon' !important; + speak: never; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + line-height: 1; - /* Better Font Rendering =========== */ - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + /* Better Font Rendering =========== */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } -.icon-graphic-heart:before { - content: "\e91e"; -} -.icon-graphic-paperplane:before { - content: "\e91f"; -} -.icon-graphic-banknote:before { - content: "\e920"; -} -.icon-stars:before { - content: "\e91a"; -} -.icon-warning:before { - content: "\e91b"; -} .icon-book:before { - content: "\e900"; -} -.icon-bookmark:before { - content: "\e91c"; -} -.icon-rss:before { - content: "\e91d"; + content: "\e901"; } .icon-envelope:before { - content: "\e901"; + content: "\e902"; } .icon-arrow-right:before { - content: "\e902"; + content: "\e903"; } .icon-bell:before { - content: "\e903"; + content: "\e904"; } .icon-x:before { - content: "\e904"; + content: "\e905"; } .icon-quote-close:before { - content: "\e905"; + content: "\e906"; } .icon-quote-open:before { - content: "\e906"; + content: "\e907"; } .icon-image:before { - content: "\e907"; + content: "\e908"; } .icon-pencil:before { - content: "\e908"; + content: "\e909"; } .icon-list:before { - content: "\e909"; + content: "\e90a"; } .icon-unlock:before { - content: "\e90a"; -} -.icon-unlisted:before { - content: "\e90a"; + content: "\e90b"; } .icon-globe:before { - content: "\e90b"; -} -.icon-public:before { - content: "\e90b"; + content: "\e90c"; } .icon-lock:before { - content: "\e90c"; -} -.icon-followers:before { - content: "\e90c"; + content: "\e90d"; } .icon-chain-broken:before { - content: "\e90d"; + content: "\e90e"; } .icon-chain:before { - content: "\e90e"; + content: "\e90f"; } .icon-comments:before { - content: "\e90f"; + content: "\e910"; } .icon-comment:before { - content: "\e910"; + content: "\e911"; } .icon-boost:before { - content: "\e911"; + content: "\e912"; } .icon-arrow-left:before { - content: "\e912"; + content: "\e913"; } .icon-arrow-up:before { - content: "\e913"; + content: "\e914"; } .icon-arrow-down:before { - content: "\e914"; -} -.icon-home:before { - content: "\e915"; + content: "\e915"; } .icon-local:before { - content: "\e916"; + content: "\e917"; } .icon-dots-three:before { - content: "\e917"; + content: "\e918"; } .icon-check:before { - content: "\e918"; + content: "\e919"; } .icon-dots-three-vertical:before { - content: "\e919"; + content: "\e91a"; +} +.icon-bookmark:before { + content: "\e91b"; +} +.icon-warning:before { + content: "\e91c"; +} +.icon-rss:before { + content: "\e91d"; +} +.icon-graphic-heart:before { + content: "\e91e"; +} +.icon-graphic-paperplane:before { + content: "\e91f"; +} +.icon-graphic-banknote:before { + content: "\e920"; } .icon-search:before { - content: "\e986"; + content: "\e986"; } .icon-star-empty:before { - content: "\e9d7"; + content: "\e9d7"; } .icon-star-half:before { - content: "\e9d8"; + content: "\e9d8"; } .icon-star-full:before { - content: "\e9d9"; + content: "\e9d9"; } .icon-heart:before { - content: "\e9da"; + content: "\e9da"; } .icon-plus:before { - content: "\ea0a"; + content: "\ea0a"; +} +.icon-question-circle:before { + content: "\e900"; +} +.icon-spinner:before { + content: "\e97a"; } diff --git a/bookwyrm/static/js/block_href.js b/bookwyrm/static/js/block_href.js new file mode 100644 index 00000000..fc20a6ab --- /dev/null +++ b/bookwyrm/static/js/block_href.js @@ -0,0 +1,21 @@ +/* exported BlockHref */ + +let BlockHref = new class { + constructor() { + document.querySelectorAll('[data-href]') + .forEach(t => t.addEventListener('click', this.followLink.bind(this))); + } + + /** + * Follow a fake link + * + * @param {Event} event + * @return {undefined} + */ + followLink(event) { + const url = event.currentTarget.dataset.href; + + window.location.href = url; + } +}(); + diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js index e43ed134..2d5b88ad 100644 --- a/bookwyrm/static/js/bookwyrm.js +++ b/bookwyrm/static/js/bookwyrm.js @@ -28,6 +28,12 @@ let BookWyrm = new class { this.revealForm.bind(this)) ); + document.querySelectorAll('[data-hides]') + .forEach(button => button.addEventListener( + 'change', + this.hideForm.bind(this)) + ); + document.querySelectorAll('[data-back]') .forEach(button => button.addEventListener( 'click', @@ -119,8 +125,8 @@ let BookWyrm = new class { } /** - * Toggle form. - * + * Show form. + * * @param {Event} event * @return {undefined} */ @@ -128,7 +134,23 @@ let BookWyrm = new class { let trigger = event.currentTarget; let hidden = trigger.closest('.hidden-form').querySelectorAll('.is-hidden')[0]; - this.addRemoveClass(hidden, 'is-hidden', !hidden); + if (hidden) { + this.addRemoveClass(hidden, 'is-hidden', !hidden); + } + } + + /** + * Hide form. + * + * @param {Event} event + * @return {undefined} + */ + hideForm(event) { + let trigger = event.currentTarget; + let targetId = trigger.dataset.hides + let visible = document.getElementById(targetId) + + this.addRemoveClass(visible, 'is-hidden', true); } /** @@ -138,8 +160,11 @@ let BookWyrm = new class { * @return {undefined} */ toggleAction(event) { - event.preventDefault(); let trigger = event.currentTarget; + + if (!trigger.dataset.allowDefault || event.currentTarget == event.target) { + event.preventDefault(); + } let pressed = trigger.getAttribute('aria-pressed') === 'false'; let targetId = trigger.dataset.controls; @@ -164,7 +189,7 @@ let BookWyrm = new class { } // Show/hide container. - let container = document.getElementById('hide-' + targetId); + let container = document.getElementById('hide_' + targetId); if (container) { this.toggleContainer(container, pressed); @@ -177,6 +202,13 @@ let BookWyrm = new class { this.toggleCheckbox(checkbox, pressed); } + // Toggle form disabled, if appropriate + let disable = trigger.dataset.disables; + + if (disable) { + this.toggleDisabled(disable, !pressed); + } + // Set focus, if appropriate. let focus = trigger.dataset.focusTarget; @@ -217,9 +249,9 @@ let BookWyrm = new class { } /** - * Check or uncheck a checbox. + * Check or uncheck a checkbox. * - * @param {object} checkbox - DOM node + * @param {string} checkbox - id of the checkbox * @param {boolean} pressed - Is the trigger pressed? * @return {undefined} */ @@ -227,6 +259,17 @@ let BookWyrm = new class { document.getElementById(checkbox).checked = !!pressed; } + /** + * Enable or disable a form element or fieldset + * + * @param {string} form_element - id of the element + * @param {boolean} pressed - Is the trigger pressed? + * @return {undefined} + */ + toggleDisabled(form_element, pressed) { + document.getElementById(form_element).disabled = !!pressed; + } + /** * Give the focus to an element. * Only move the focus based on user interactions. @@ -280,7 +323,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 00000000..2a50bfcb --- /dev/null +++ b/bookwyrm/static/js/status_cache.js @@ -0,0 +1,238 @@ +/* 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 + if (form.reading_status) { + 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/storage_backends.py b/bookwyrm/storage_backends.py index e10dfb84..4fb0feff 100644 --- a/bookwyrm/storage_backends.py +++ b/bookwyrm/storage_backends.py @@ -1,4 +1,6 @@ """Handles backends for storages""" +import os +from tempfile import SpooledTemporaryFile from storages.backends.s3boto3 import S3Boto3Storage @@ -15,3 +17,33 @@ class ImagesStorage(S3Boto3Storage): # pylint: disable=abstract-method location = "images" default_acl = "public-read" file_overwrite = False + + """ + This is our custom version of S3Boto3Storage that fixes a bug in + boto3 where the passed in file is closed upon upload. + From: + https://github.com/matthewwithanm/django-imagekit/issues/391#issuecomment-275367006 + https://github.com/boto/boto3/issues/929 + https://github.com/matthewwithanm/django-imagekit/issues/391 + """ + + def _save(self, name, content): + """ + We create a clone of the content file as when this is passed to + boto3 it wrongly closes the file upon upload where as the storage + backend expects it to still be open + """ + # Seek our content back to the start + content.seek(0, os.SEEK_SET) + + # Create a temporary file that will write to disk after a specified + # size. This file will be automatically deleted when closed by + # boto3 or after exiting the `with` statement if the boto3 is fixed + with SpooledTemporaryFile() as content_autoclose: + + # Write our original content into our copy that will be closed by boto3 + content_autoclose.write(content.read()) + + # Upload the object which will auto close the + # content_autoclose instance + return super()._save(name, content_autoclose) diff --git a/bookwyrm/suggested_users.py b/bookwyrm/suggested_users.py new file mode 100644 index 00000000..86c181a2 --- /dev/null +++ b/bookwyrm/suggested_users.py @@ -0,0 +1,277 @@ +""" store recommended follows in redis """ +import math +import logging +from django.dispatch import receiver +from django.db.models import signals, Count, Q + +from bookwyrm import models +from bookwyrm.redis_store import RedisStore, r +from bookwyrm.tasks import app + + +logger = logging.getLogger(__name__) + + +class SuggestedUsers(RedisStore): + """suggested users for a user""" + + max_length = 30 + + def get_rank(self, obj): + """get computed rank""" + return obj.mutuals # + (1.0 - (1.0 / (obj.shared_books + 1))) + + def store_id(self, user): # pylint: disable=no-self-use + """the key used to store this user's recs""" + if isinstance(user, int): + 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""" + return { + "mutuals": math.floor(rank), + # "shared_books": int(1 / (-1 * (rank % 1 - 1))) - 1, + } + + def get_objects_for_store(self, store): + """a list of potential follows for a user""" + user = models.User.objects.get(id=store.split("-")[0]) + + return get_annotated_users( + user, + ~Q(id=user.id), + ~Q(followers=user), + ~Q(follower_requests=user), + bookwyrm_user=True, + ) + + def get_stores_for_object(self, obj): + return [self.store_id(u) for u in self.get_users_for_object(obj)] + + def get_users_for_object(self, obj): # pylint: disable=no-self-use + """given a user, who might want to follow them""" + return models.User.objects.filter(local=True,).exclude( + Q(id=obj.id) | Q(followers=obj) | Q(id__in=obj.blocks.all()) | Q(blocks=obj) + ) + + def rerank_obj(self, obj, update_only=True): + """update all the instances of this user with new ranks""" + pipeline = r.pipeline() + for store_user in self.get_users_for_object(obj): + annotated_user = get_annotated_users( + store_user, + id=obj.id, + ).first() + if not annotated_user: + continue + + pipeline.zadd( + self.store_id(store_user), + self.get_value(annotated_user), + xx=update_only, + ) + pipeline.execute() + + def rerank_user_suggestions(self, user): + """update the ranks of the follows suggested to a user""" + self.populate_store(self.store_id(user)) + + def remove_suggestion(self, user, suggested_user): + """take a user out of someone's suggestions""" + self.bulk_remove_objects_from_store([suggested_user], self.store_id(user)) + + def get_suggestions(self, user, local=False): + """get suggestions""" + 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: + counts = self.get_counts_from_rank(rank) + try: + 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) + continue + user.mutuals = counts["mutuals"] + if (local and user.local) or not local: + results.append(user) + if len(results) >= 5: + break + return results + + +def get_annotated_users(viewer, *args, **kwargs): + """Users, annotated with things they have in common""" + return ( + models.User.objects.filter(discoverable=True, is_active=True, *args, **kwargs) + .exclude(Q(id__in=viewer.blocks.all()) | Q(blocks=viewer)) + .annotate( + mutuals=Count( + "followers", + filter=Q( + ~Q(id=viewer.id), + ~Q(id__in=viewer.following.all()), + followers__in=viewer.following.all(), + ), + distinct=True, + ), + # shared_books=Count( + # "shelfbook", + # filter=Q( + # ~Q(id=viewer.id), + # shelfbook__book__parent_work__in=[ + # s.book.parent_work for s in viewer.shelfbook_set.all() + # ], + # ), + # distinct=True, + # ), + ) + ) + + +suggested_users = SuggestedUsers() + + +@receiver(signals.post_save, sender=models.UserFollows) +# pylint: disable=unused-argument +def update_suggestions_on_follow(sender, instance, created, *args, **kwargs): + """remove a follow from the recs and update the ranks""" + if not created or not instance.user_object.discoverable: + return + + if instance.user_subject.local: + remove_suggestion_task.delay(instance.user_subject.id, instance.user_object.id) + rerank_user_task.delay(instance.user_object.id, update_only=False) + + +@receiver(signals.post_save, sender=models.UserFollowRequest) +# pylint: disable=unused-argument +def update_suggestions_on_follow_request(sender, instance, created, *args, **kwargs): + """remove a follow from the recs and update the ranks""" + if not created or not instance.user_object.discoverable: + return + + if instance.user_subject.local: + remove_suggestion_task.delay(instance.user_subject.id, instance.user_object.id) + + +@receiver(signals.post_save, sender=models.UserBlocks) +# pylint: disable=unused-argument +def update_suggestions_on_block(sender, instance, *args, **kwargs): + """remove blocked users from recs""" + if instance.user_subject.local and instance.user_object.discoverable: + remove_suggestion_task.delay(instance.user_subject.id, instance.user_object.id) + if instance.user_object.local and instance.user_subject.discoverable: + remove_suggestion_task.delay(instance.user_object.id, instance.user_subject.id) + + +@receiver(signals.post_delete, sender=models.UserFollows) +# pylint: disable=unused-argument +def update_suggestions_on_unfollow(sender, instance, **kwargs): + """update rankings, but don't re-suggest because it was probably intentional""" + if instance.user_object.discoverable: + rerank_user_task.delay(instance.user_object.id, update_only=False) + + +# @receiver(signals.post_save, sender=models.ShelfBook) +# @receiver(signals.post_delete, sender=models.ShelfBook) +# # pylint: disable=unused-argument +# def update_rank_on_shelving(sender, instance, *args, **kwargs): +# """when a user shelves or unshelves a book, re-compute their rank""" +# # if it's a local user, re-calculate who is rec'ed to them +# if instance.user.local: +# rerank_suggestions_task.delay(instance.user.id) +# +# # if the user is discoverable, update their rankings +# if instance.user.discoverable: +# rerank_user_task.delay(instance.user.id) + + +@receiver(signals.post_save, sender=models.User) +# pylint: disable=unused-argument, too-many-arguments +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) + + # 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 + if instance.discoverable: + rerank_user_task.delay(instance.id, update_only=False) + elif not created: + remove_user_task.delay(instance.id) + + +@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(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(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(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 6d1992a7..b860e018 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/author/author.html b/bookwyrm/templates/author/author.html index 0bc42775..8a15cd0f 100644 --- a/bookwyrm/templates/author/author.html +++ b/bookwyrm/templates/author/author.html @@ -22,7 +22,7 @@ -
+
{% if author.aliases or author.born or author.died or author.wikipedia_link or author.openlibrary_key or author.inventaire_id %} @@ -57,7 +57,7 @@ {% if author.wikipedia_link %}

- + {% trans "Wikipedia" %}

@@ -70,7 +70,7 @@

{% endif %} - + {% if author.inventaire_id %}

@@ -86,7 +86,7 @@

{% endif %} - + {% if author.goodreads_key %}

@@ -109,7 +109,7 @@

diff --git a/bookwyrm/templates/author/edit_author.html b/bookwyrm/templates/author/edit_author.html index 103341bf..54d7f4f1 100644 --- a/bookwyrm/templates/author/edit_author.html +++ b/bookwyrm/templates/author/edit_author.html @@ -12,7 +12,9 @@ diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index c5dab109..36241ee2 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -4,7 +4,6 @@ {% load humanize %} {% load utilities %} {% load static %} -{% load layout %} {% block title %}{{ book|book_title }}{% endblock %} @@ -43,7 +42,7 @@

{% endif %} - {% if book.authors %} + {% if book.authors.exists %}
{% trans "by" %} {% include 'snippets/authors.html' with book=book %}
@@ -62,7 +61,7 @@
- {% include 'snippets/book_cover.html' with book=book cover_class='is-h-m-mobile' %} + {% include 'snippets/book_cover.html' with size='xxlarge' size_mobile='medium' book=book cover_class='is-h-m-mobile' %} {% include 'snippets/rate_action.html' with user=request.user book=book %}
@@ -72,8 +71,8 @@ {% if user_authenticated and not book.cover %}
{% trans "Add cover" as button_text %} - {% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="add-cover" controls_uid=book.id focus="modal-title-add-cover" class="is-small" %} - {% include 'book/cover_modal.html' with book=book controls_text="add-cover" controls_uid=book.id %} + {% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="add_cover" controls_uid=book.id focus="modal_title_add_cover" class="is-small" %} + {% include 'book/cover_modal.html' with book=book controls_text="add_cover" controls_uid=book.id %} {% if request.GET.cover_error %}

{% trans "Failed to load cover" %}

{% endif %} @@ -128,19 +127,19 @@ {% if user_authenticated and can_edit_book and not book|book_description %} {% trans 'Add Description' as button_text %} - {% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add-description" controls_uid=book.id focus="id_description" hide_active=True id="hide-description" %} + {% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add_description" controls_uid=book.id focus="id_description" hide_active=True id="hide_description" %} -
{% trans "Add read dates" as button_text %} - {% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="plus" class="is-small" controls_text="add-readthrough" focus="add-readthrough-focus" %} + {% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="plus" class="is-small" controls_text="add_readthrough" focus="add_readthrough_focus_" %}
-
@@ -204,7 +203,9 @@
+ {% with 0|uuid as controls_uid %} {% include 'snippets/create_status.html' with book=book hide_cover=True %} + {% endwith %}
{% endif %}
@@ -326,5 +327,5 @@ {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/bookwyrm/templates/book/book_identifiers.html b/bookwyrm/templates/book/book_identifiers.html index 6021d243..19ab619d 100644 --- a/bookwyrm/templates/book/book_identifiers.html +++ b/bookwyrm/templates/book/book_identifiers.html @@ -1,6 +1,7 @@ {% spaceless %} {% load i18n %} +{% if book.isbn_13 or book.oclc_number or book.asin %}
{% if book.isbn_13 %}
@@ -23,4 +24,5 @@
{% endif %}
+{% endif %} {% endspaceless %} diff --git a/bookwyrm/templates/book/edit/edit_book.html b/bookwyrm/templates/book/edit/edit_book.html new file mode 100644 index 00000000..fc11208f --- /dev/null +++ b/bookwyrm/templates/book/edit/edit_book.html @@ -0,0 +1,122 @@ +{% extends 'layout.html' %} +{% load i18n %} +{% load humanize %} + +{% block title %}{% if book %}{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}{% else %}{% trans "Add Book" %}{% endif %}{% endblock %} + +{% block content %} +
+

+ {% if book %} + {% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %} + {% else %} + {% trans "Add Book" %} + {% endif %} +

+ {% if book %} +
+
{% trans "Added:" %}
+
{{ book.created_date | naturaltime }}
+ +
{% trans "Updated:" %}
+
{{ book.updated_date | naturaltime }}
+ + {% if book.last_edited_by %} +
{% trans "Last edited by:" %}
+
{{ book.last_edited_by.display_name }}
+ {% endif %} + +
+ {% endif %} +
+ +
+ {% if confirm_mode %} +
+

{% trans "Confirm Book Info" %}

+
+ {% if author_matches %} + +
+ {% for author in author_matches %} +
+ + {% blocktrans with name=author.name %}Is "{{ name }}" an existing author?{% endblocktrans %} + + {% with forloop.counter0 as counter %} + {% for match in author.matches %} + +

+ {% blocktrans with book_title=match.book_set.first.title %}Author of {{ book_title }}{% endblocktrans %} +

+ {% endfor %} + + {% endwith %} +
+ {% endfor %} +
+ {% else %} +

{% blocktrans with name=add_author %}Creating a new author: {{ name }}{% endblocktrans %}

+ {% endif %} + + {% if not book %} +
+
+ + {% trans "Is this an edition of an existing work?" %} + + {% for match in book_matches %} + + {% endfor %} + +
+
+ {% endif %} +
+ + + + {% trans "Back" %} + +
+ +
+ {% endif %} + + {% include "book/edit/edit_book_form.html" %} + + {% if not confirm_mode %} +
+ + {% if book %} + {% trans "Cancel" %} + {% else %} + + {% trans "Cancel" %} + + {% endif %} +
+ {% endif %} +
+ +{% endblock %} diff --git a/bookwyrm/templates/book/edit_book.html b/bookwyrm/templates/book/edit/edit_book_form.html similarity index 54% rename from bookwyrm/templates/book/edit_book.html rename to bookwyrm/templates/book/edit/edit_book_form.html index 32018a25..982bb56d 100644 --- a/bookwyrm/templates/book/edit_book.html +++ b/bookwyrm/templates/book/edit/edit_book_form.html @@ -1,40 +1,4 @@ -{% extends 'layout.html' %} {% load i18n %} -{% load humanize %} - -{% block title %}{% if book %}{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}{% else %}{% trans "Add Book" %}{% endif %}{% endblock %} - -{% block content %} -
-

- {% if book %} - {% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %} - {% else %} - {% trans "Add Book" %} - {% endif %} -

- {% if book %} -
-
-
{% trans "Added:" %}
-
{{ book.created_date | naturaltime }}
-
- -
-
{% trans "Updated:" %}
-
{{ book.updated_date | naturaltime }}
-
- - {% if book.last_edited_by %} -
-
{% trans "Last edited by:" %}
-
{{ book.last_edited_by.display_name }}
-
- {% endif %} - -
- {% endif %} -
{% if form.non_field_errors %}
@@ -42,80 +6,14 @@
{% endif %} -{% if book %} -
-{% else %} - -{% endif %} - - {% csrf_token %} - {% if confirm_mode %} -
-

{% trans "Confirm Book Info" %}

-
- {% if author_matches %} - -
- {% for author in author_matches %} -
- - {% blocktrans with name=author.name %}Is "{{ name }}" an existing author?{% endblocktrans %} - - {% with forloop.counter0 as counter %} - {% for match in author.matches %} - -

- {% blocktrans with book_title=match.book_set.first.title %}Author of {{ book_title }}{% endblocktrans %} -

- {% endfor %} - - {% endwith %} -
- {% endfor %} -
- {% else %} -

{% blocktrans with name=add_author %}Creating a new author: {{ name }}{% endblocktrans %}

- {% endif %} - - {% if not book %} -
-
- - {% trans "Is this an edition of an existing work?" %} - - {% for match in book_matches %} - - {% endfor %} - -
-
- {% endif %} -
- - - - {% trans "Back" %} - -
- -
- {% endif %} - - -
-
-
-

{% trans "Metadata" %}

+{% csrf_token %} + +
+
+
+

{% trans "Metadata" %}

+
@@ -140,20 +38,25 @@ {% endfor %}
-
- - - {% for error in form.series.errors %} -

{{ error | escape }}

- {% endfor %} -
- -
- - {{ form.series_number }} - {% for error in form.series_number.errors %} -

{{ error | escape }}

- {% endfor %} +
+
+
+ + + {% for error in form.series.errors %} +

{{ error | escape }}

+ {% endfor %} +
+
+
+
+ + {{ form.series_number }} + {% for error in form.series_number.errors %} +

{{ error | escape }}

+ {% endfor %} +
+
@@ -164,7 +67,12 @@

{{ error | escape }}

{% endfor %}
+
+
+
+

{% trans "Publication" %}

+
{{ form.publishers }} @@ -189,10 +97,12 @@

{{ error | escape }}

{% endfor %}
-
+
+
-
-

{% trans "Authors" %}

+
+

{% trans "Authors" %}

+
{% if book.authors.exists %}
{% for author in book.authors.all %} @@ -213,45 +123,64 @@ {% trans "Separate multiple values with commas." %}
-
-
+
+ +
-
+
+

{% trans "Cover" %}

-
-
- {% include 'snippets/book_cover.html' with book=book cover_class='is-h-xl-mobile is-w-auto-tablet' %} -
+
+
+ {% if book.cover %} +
+ {% include 'snippets/book_cover.html' with book=book cover_class='is-h-xl-mobile is-w-auto-tablet' size_mobile='xlarge' size='large' %} +
+ {% endif %} -
-
+
{{ form.cover }}
- {% if book %}
- +
- {% endif %} {% for error in form.cover.errors %}

{{ error | escape }}

{% endfor %}
+
-
-

{% trans "Physical Properties" %}

-
- - {{ form.physical_format }} - {% for error in form.physical_format.errors %} -

{{ error | escape }}

- {% endfor %} +
+

{% trans "Physical Properties" %}

+
+
+
+
+ +
+ {{ form.physical_format }} +
+ {% for error in form.physical_format.errors %} +

{{ error | escape }}

+ {% endfor %} +
+
+
+
+ + {{ form.physical_format_detail }} + {% for error in form.physical_format_detail.errors %} +

{{ error | escape }}

+ {% endfor %} +
+
@@ -262,9 +191,11 @@ {% endfor %}
+
-
-

{% trans "Book Identifiers" %}

+
+

{% trans "Book Identifiers" %}

+
{{ form.isbn_13 }} @@ -313,15 +244,6 @@ {% endfor %}
-
+
- - {% if not confirm_mode %} -
- - {% trans "Cancel" %} -
- {% endif %} - - -{% endblock %} +
diff --git a/bookwyrm/templates/book/edition_filters.html b/bookwyrm/templates/book/edition_filters.html deleted file mode 100644 index a55b72af..00000000 --- a/bookwyrm/templates/book/edition_filters.html +++ /dev/null @@ -1,6 +0,0 @@ -{% extends 'snippets/filters_panel/filters_panel.html' %} - -{% block filter_fields %} -{% include 'book/language_filter.html' %} -{% include 'book/format_filter.html' %} -{% endblock %} diff --git a/bookwyrm/templates/book/editions/edition_filters.html b/bookwyrm/templates/book/editions/edition_filters.html new file mode 100644 index 00000000..c6702a5c --- /dev/null +++ b/bookwyrm/templates/book/editions/edition_filters.html @@ -0,0 +1,7 @@ +{% extends 'snippets/filters_panel/filters_panel.html' %} + +{% block filter_fields %} +{% include 'book/editions/search_filter.html' %} +{% include 'book/editions/language_filter.html' %} +{% include 'book/editions/format_filter.html' %} +{% endblock %} diff --git a/bookwyrm/templates/book/editions.html b/bookwyrm/templates/book/editions/editions.html similarity index 90% rename from bookwyrm/templates/book/editions.html rename to bookwyrm/templates/book/editions/editions.html index e2a0bdda..a3ff0802 100644 --- a/bookwyrm/templates/book/editions.html +++ b/bookwyrm/templates/book/editions/editions.html @@ -8,14 +8,14 @@

{% blocktrans with work_path=work.local_path work_title=work|book_title %}Editions of "{{ work_title }}"{% endblocktrans %}

-{% include 'book/edition_filters.html' %} +{% include 'book/editions/edition_filters.html' %}
{% for book in editions %}
diff --git a/bookwyrm/templates/book/format_filter.html b/bookwyrm/templates/book/editions/format_filter.html similarity index 100% rename from bookwyrm/templates/book/format_filter.html rename to bookwyrm/templates/book/editions/format_filter.html diff --git a/bookwyrm/templates/book/language_filter.html b/bookwyrm/templates/book/editions/language_filter.html similarity index 100% rename from bookwyrm/templates/book/language_filter.html rename to bookwyrm/templates/book/editions/language_filter.html diff --git a/bookwyrm/templates/book/editions/search_filter.html b/bookwyrm/templates/book/editions/search_filter.html new file mode 100644 index 00000000..f2345a68 --- /dev/null +++ b/bookwyrm/templates/book/editions/search_filter.html @@ -0,0 +1,8 @@ +{% extends 'snippets/filters_panel/filter_field.html' %} +{% load i18n %} + +{% block filter %} + + +{% endblock %} + diff --git a/bookwyrm/templates/book/publisher_info.html b/bookwyrm/templates/book/publisher_info.html index b7975a62..b39bcf5c 100644 --- a/bookwyrm/templates/book/publisher_info.html +++ b/bookwyrm/templates/book/publisher_info.html @@ -3,29 +3,30 @@ {% load i18n %} {% load humanize %} +{% firstof book.physical_format_detail book.get_physical_format_display as format %} +{% firstof book.physical_format book.physical_format_detail as format_property %} +{% with pages=book.pages %} +{% if format or pages %} + +{% if format_property %} + +{% endif %} + +{% if pages %} + +{% endif %} +

- {% with format=book.physical_format pages=book.pages %} - {% if format %} - {% comment %} - @todo The bookFormat property is limited to a list of values whereas the book edition is free text. - @see https://schema.org/bookFormat - {% endcomment %} - - {% endif %} - - {% if pages %} - - {% endif %} - - {% if format and not pages %} - {% blocktrans %}{{ format }}{% endblocktrans %} - {% elif format and pages %} - {% blocktrans %}{{ format }}, {{ pages }} pages{% endblocktrans %} - {% elif pages %} - {% blocktrans %}{{ pages }} pages{% endblocktrans %} - {% endif %} - {% endwith %} + {% if format and not pages %} + {{ format }} + {% elif format and pages %} + {% blocktrans %}{{ format }}, {{ pages }} pages{% endblocktrans %} + {% elif pages %} + {% blocktrans %}{{ pages }} pages{% endblocktrans %} + {% endif %}

+{% endif %} +{% endwith %} {% if book.languages %} {% for language in book.languages %} @@ -39,32 +40,34 @@

{% endif %} +{% with date=book.published_date|naturalday publisher=book.publishers|join:', ' %} +{% if date or book.first_published_date or book.publishers %} +{% if date or book.first_published_date %} + +{% endif %}

- {% with date=book.published_date|naturalday publisher=book.publishers|join:', ' %} - {% if date or book.first_published_date %} - - {% endif %} - {% comment %} - @todo The publisher property needs to be an Organization or a Person. We’ll be using Thing which is the more generic ancestor. - @see https://schema.org/Publisher - {% endcomment %} - {% if book.publishers %} - {% for publisher in book.publishers %} - - {% endfor %} - {% endif %} + {% comment %} + @todo The publisher property needs to be an Organization or a Person. We’ll be using Thing which is the more generic ancestor. + @see https://schema.org/Publisher + {% endcomment %} + {% if book.publishers %} + {% for publisher in book.publishers %} + + {% endfor %} + {% endif %} - {% if date and publisher %} - {% blocktrans %}Published {{ date }} by {{ publisher }}.{% endblocktrans %} - {% elif date %} - {% blocktrans %}Published {{ date }}{% endblocktrans %} - {% elif publisher %} - {% blocktrans %}Published by {{ publisher }}.{% endblocktrans %} - {% endif %} - {% endwith %} + {% if date and publisher %} + {% blocktrans %}Published {{ date }} by {{ publisher }}.{% endblocktrans %} + {% elif date %} + {% blocktrans %}Published {{ date }}{% endblocktrans %} + {% elif publisher %} + {% blocktrans %}Published by {{ publisher }}.{% endblocktrans %} + {% endif %}

+{% endif %} +{% endwith %} {% endspaceless %} diff --git a/bookwyrm/templates/book/readthrough.html b/bookwyrm/templates/book/readthrough.html index 75140746..12430f75 100644 --- a/bookwyrm/templates/book/readthrough.html +++ b/bookwyrm/templates/book/readthrough.html @@ -2,11 +2,10 @@ {% load humanize %} {% load tz %}
-
+
{% trans "Progress Updates:" %} -
    {% if readthrough.finish_date or readthrough.progress %}
  • @@ -24,7 +23,7 @@ {% if readthrough.progress %} {% trans "Show all updates" as button_text %} {% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="updates" controls_uid=readthrough.id class="is-small" %} -
@@ -69,15 +68,15 @@
-