diff --git a/.env.dev.example b/.env.dev.example index 3cba06ab4..f42aaaaec 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -2,7 +2,8 @@ SECRET_KEY="7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr" # SECURITY WARNING: don't run with debug turned on in production! -DEBUG=false +DEBUG=true +USE_HTTPS=false DOMAIN=your.domain.here #EMAIL=your@email.here @@ -42,6 +43,21 @@ EMAIL_HOST_PASSWORD=emailpassword123 EMAIL_USE_TLS=true EMAIL_USE_SSL=false +# S3 configuration +USE_S3=false +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= + +# Commented are example values if you use a non-AWS, S3-compatible service +# AWS S3 should work with only AWS_STORAGE_BUCKET_NAME and AWS_S3_REGION_NAME +# non-AWS S3-compatible services will need AWS_STORAGE_BUCKET_NAME, +# along with both AWS_S3_CUSTOM_DOMAIN and AWS_S3_ENDPOINT_URL + +# AWS_STORAGE_BUCKET_NAME= # "example-bucket-name" +# AWS_S3_CUSTOM_DOMAIN=None # "example-bucket-name.s3.fr-par.scw.cloud" +# 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 d61c46af5..5115469ca 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -3,6 +3,7 @@ SECRET_KEY="7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr" # SECURITY WARNING: don't run with debug turned on in production! DEBUG=false +USE_HTTPS=true DOMAIN=your.domain.here EMAIL=your@email.here @@ -42,6 +43,21 @@ EMAIL_HOST_PASSWORD=emailpassword123 EMAIL_USE_TLS=true EMAIL_USE_SSL=false +# S3 configuration +USE_S3=false +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= + +# Commented are example values if you use a non-AWS, S3-compatible service +# AWS S3 should work with only AWS_STORAGE_BUCKET_NAME and AWS_S3_REGION_NAME +# non-AWS S3-compatible services will need AWS_STORAGE_BUCKET_NAME, +# along with both AWS_S3_CUSTOM_DOMAIN and AWS_S3_ENDPOINT_URL + +# AWS_STORAGE_BUCKET_NAME= # "example-bucket-name" +# AWS_S3_CUSTOM_DOMAIN=None # "example-bucket-name.s3.fr-par.scw.cloud" +# 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/black.yml b/.github/workflows/black.yml index fb681dcd5..7258b6087 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -1,6 +1,10 @@ name: Python Formatting (run ./bw-dev black to fix) -on: [push, pull_request] +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] jobs: lint: diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml index c11b7c408..038751935 100644 --- a/.github/workflows/django-tests.yml +++ b/.github/workflows/django-tests.yml @@ -9,18 +9,9 @@ jobs: build: runs-on: ubuntu-20.04 - strategy: - max-parallel: 4 - matrix: - db: [postgres] - python-version: [3.9] - include: - - db: postgres - db_port: 5432 - services: postgres: - image: postgres:12 + image: postgres:13 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: hunter2 @@ -33,22 +24,19 @@ jobs: - 5432:5432 steps: - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python uses: actions/setup-python@v2 with: - python-version: ${{ matrix.python-version }} + python-version: 3.9 - name: Install Dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Run Tests env: - DB: ${{ matrix.db }} - DB_HOST: 127.0.0.1 - DB_PORT: ${{ matrix.db_port }} - DB_PASSWORD: hunter2 SECRET_KEY: beepbeep - DEBUG: true + DEBUG: false + USE_HTTPS: true DOMAIN: your.domain.here BOOKWYRM_DATABASE_BACKEND: postgres MEDIA_ROOT: images/ @@ -64,6 +52,6 @@ jobs: EMAIL_HOST_USER: "" EMAIL_HOST_PASSWORD: "" EMAIL_USE_TLS: true - ENABLE_PREVIEW_IMAGES: true + ENABLE_PREVIEW_IMAGES: false run: | - pytest + pytest -n 3 diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 1a32940f9..1b14149f2 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -1,6 +1,10 @@ name: Pylint -on: [push, pull_request] +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] jobs: build: diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 81762388f..da32fbaf1 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -106,6 +106,7 @@ class ActivityObject: value = field.default setattr(self, field.name, value) + # pylint: disable=too-many-locals,too-many-branches def to_model(self, model=None, instance=None, allow_create=True, save=True): """convert from an activity to a model instance""" model = model or get_model_from_type(self.type) @@ -126,27 +127,36 @@ class ActivityObject: return None instance = instance or model() + # keep track of what we've changed + update_fields = [] for field in instance.simple_fields: try: - field.set_field_from_activity(instance, self) + changed = field.set_field_from_activity(instance, self) + 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) + 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) diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py index a49a7ce4d..bad7c59f8 100644 --- a/bookwyrm/activitystreams.py +++ b/bookwyrm/activitystreams.py @@ -4,11 +4,12 @@ from django.db.models import signals, Q from bookwyrm import models from bookwyrm.redis_store import RedisStore, r +from bookwyrm.settings import STREAMS from bookwyrm.views.helpers import privacy_filter 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""" @@ -155,29 +156,94 @@ class LocalStream(ActivityStream): ) -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) + + 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 privacy_filter( user, - models.Status.objects.select_subclasses(), + models.Status.objects.select_subclasses() + .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(), privacy_levels=["public"], ) + def add_book_statuses(self, user, book): + """add statuses about a book to a user's feed""" + work = book.parent_work + statuses = privacy_filter( + user, + models.Status.objects.select_subclasses() + .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(), + privacy_levels=["public"], + ) + 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 = privacy_filter( + user, + models.Status.objects.select_subclasses() + .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(), + privacy_levels=["public"], + ) + self.bulk_remove_objects_from_store(statuses, self.stream_id(user)) + + +# determine which streams are enabled in settings.py +available_streams = [s["key"] for s in STREAMS] streams = { - "home": HomeStream(), - "local": LocalStream(), - "federated": FederatedStream(), + k: v + for (k, v) in { + "home": HomeStream(), + "local": LocalStream(), + "books": BooksStream(), + }.items() + if k in available_streams } @@ -197,7 +263,6 @@ def add_status_on_create(sender, instance, created, *args, **kwargs): if not created: return - # iterates through Home, Local, Federated for stream in streams.values(): stream.add_status(instance) @@ -264,7 +329,7 @@ def remove_statuses_on_block(sender, instance, *args, **kwargs): # pylint: disable=unused-argument def add_statuses_on_unblock(sender, instance, *args, **kwargs): """remove statuses from all feeds on block""" - public_streams = [LocalStream(), FederatedStream()] + public_streams = [v for (k, v) in streams.items() if k != "home"] # add statuses back to streams with statuses from anyone if instance.user_subject.local: for stream in public_streams: @@ -285,3 +350,33 @@ def populate_streams_on_account_create(sender, instance, created, *args, **kwarg for stream in streams.values(): stream.populate_streams(instance) + + +@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 + # check if the book is already on the user's shelves + if models.ShelfBook.objects.filter( + user=instance.user, book__in=instance.book.parent_work.editions.all() + ).exists(): + return + + BooksStream().add_book_statuses(instance.user, instance.book) + + +@receiver(signals.post_delete, sender=models.ShelfBook) +# pylint: disable=unused-argument +def remove_statuses_on_shelve(sender, instance, *args, **kwargs): + """update books stream when user unshelves a book""" + if not instance.user.local: + return + # check if the book is actually unshelved, not just moved + if models.ShelfBook.objects.filter( + user=instance.user, book__in=instance.book.parent_work.editions.all() + ).exists(): + return + + BooksStream().remove_book_statuses(instance.user, instance.book) diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py index f2661a193..1f0387fe7 100644 --- a/bookwyrm/context_processors.py +++ b/bookwyrm/context_processors.py @@ -11,10 +11,7 @@ def site_settings(request): # pylint: disable=unused-argument return { "site": models.SiteSettings.objects.get(), "active_announcements": models.Announcement.active_announcements(), - "static_url": settings.STATIC_URL, - "media_url": settings.MEDIA_URL, - "static_path": settings.STATIC_PATH, - "media_path": settings.MEDIA_PATH, + "media_full_url": settings.MEDIA_FULL_URL, "preview_images_enabled": settings.ENABLE_PREVIEW_IMAGES, "request_protocol": request_protocol, } diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py index 657310b05..fff3985ef 100644 --- a/bookwyrm/emailing.py +++ b/bookwyrm/emailing.py @@ -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() diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py index 57a94e3cd..c9e795c3e 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -132,6 +132,7 @@ class EditUserForm(CustomForm): "summary", "show_goal", "manually_approves_followers", + "default_post_privacy", "discoverable", "preferred_timezone", ] diff --git a/bookwyrm/importers/importer.py b/bookwyrm/importers/importer.py index 203db0343..d5f1449ca 100644 --- a/bookwyrm/importers/importer.py +++ b/bookwyrm/importers/importer.py @@ -2,6 +2,8 @@ import csv import logging +from django.utils import timezone + from bookwyrm import models from bookwyrm.models import ImportJob, ImportItem from bookwyrm.tasks import app @@ -100,7 +102,10 @@ def handle_imported_book(source, user, item, include_reviews, privacy): # shelve the book if it hasn't been shelved already if item.shelf and not existing_shelf: desired_shelf = models.Shelf.objects.get(identifier=item.shelf, user=user) - models.ShelfBook.objects.create(book=item.book, shelf=desired_shelf, user=user) + shelved_date = item.date_added or timezone.now() + models.ShelfBook.objects.create( + book=item.book, shelf=desired_shelf, user=user, shelved_date=shelved_date + ) for read in item.reads: # check for an existing readthrough with the same dates diff --git a/bookwyrm/management/commands/populate_streams.py b/bookwyrm/management/commands/populate_streams.py index 85b71b87c..f8aa21a52 100644 --- a/bookwyrm/management/commands/populate_streams.py +++ b/bookwyrm/management/commands/populate_streams.py @@ -1,16 +1,6 @@ """ 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, - password=settings.REDIS_ACTIVITY_PASSWORD, - unix_socket_path=settings.REDIS_ACTIVITY_SOCKET, - db=0, -) +from bookwyrm import activitystreams, models def populate_streams(): diff --git a/bookwyrm/management/commands/populate_suggestions.py b/bookwyrm/management/commands/populate_suggestions.py new file mode 100644 index 000000000..32495497e --- /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/migrations/0046_user_default_post_privacy.py b/bookwyrm/migrations/0046_user_default_post_privacy.py new file mode 100644 index 000000000..f1c8e7c31 --- /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/0078_add_shelved_date.py b/bookwyrm/migrations/0078_add_shelved_date.py new file mode 100644 index 000000000..b8a95ab17 --- /dev/null +++ b/bookwyrm/migrations/0078_add_shelved_date.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.4 on 2021-07-03 08:25 + +from django.db import migrations, models +import django.utils.timezone + + +def copy_created_date(app_registry, schema_editor): + db_alias = schema_editor.connection.alias + ShelfBook = app_registry.get_model("bookwyrm", "ShelfBook") + ShelfBook.objects.all().update(shelved_date=models.F("created_date")) + + +def do_nothing(app_registry, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0077_auto_20210623_2155"), + ] + + operations = [ + migrations.AlterModelOptions( + name="shelfbook", + options={"ordering": ("-shelved_date",)}, + ), + migrations.AddField( + model_name="shelfbook", + name="shelved_date", + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.RunPython(copy_created_date, reverse_code=do_nothing), + ] diff --git a/bookwyrm/migrations/0079_merge_20210804_1746.py b/bookwyrm/migrations/0079_merge_20210804_1746.py new file mode 100644 index 000000000..ed5d50d0b --- /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 000000000..b5ee7e673 --- /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 000000000..dc6b640f6 --- /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 000000000..ab0aa158b --- /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/models/base_model.py b/bookwyrm/models/base_model.py index 2cb7c0365..5b55ea50f 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -1,4 +1,6 @@ """ base model with default fields """ +import base64 +from Crypto import Random from django.db import models from django.dispatch import receiver @@ -9,6 +11,7 @@ from .fields import RemoteIdField DeactivationReason = models.TextChoices( "DeactivationReason", [ + "pending", "self_deletion", "moderator_deletion", "domain_block", @@ -16,6 +19,11 @@ DeactivationReason = models.TextChoices( ) +def new_access_code(): + """the identifier for a user invite""" + return base64.b32encode(Random.get_random_bytes(5)).decode("ascii") + + class BookWyrmModel(models.Model): """shared fields""" diff --git a/bookwyrm/models/favorite.py b/bookwyrm/models/favorite.py index c45181196..9ab300b3c 100644 --- a/bookwyrm/models/favorite.py +++ b/bookwyrm/models/favorite.py @@ -30,7 +30,7 @@ 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.save(broadcast=False, update_fields=["last_active_date"]) super().save(*args, **kwargs) if self.status.user.local and self.status.user != self.user: diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index b59b61072..b58f81747 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -13,6 +13,7 @@ from django.db import models from django.forms import ClearableFileInput, ImageField as DjangoImageField from django.utils import timezone from django.utils.translation import gettext_lazy as _ + from bookwyrm import activitypub from bookwyrm.connectors import get_image from bookwyrm.sanitize_html import InputHtmlParser @@ -66,7 +67,7 @@ class ActivitypubFieldMixin: super().__init__(*args, **kwargs) def set_field_from_activity(self, instance, data): - """helper function for assinging a value to the field""" + """helper function for assinging a value to the field. Returns if changed""" try: value = getattr(data, self.get_activitypub_field()) except AttributeError: @@ -76,8 +77,14 @@ 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 + + # the field is unchanged + if hasattr(instance, self.name) and getattr(instance, self.name) == formatted: + return False + setattr(instance, self.name, formatted) + return True def set_activity_from_field(self, activity, instance): """update the json object""" @@ -204,6 +211,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField): # pylint: disable=invalid-name def set_field_from_activity(self, instance, data): + original = getattr(instance, self.name) to = data.to cc = data.cc if to == [self.public]: @@ -214,6 +222,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField): 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) @@ -269,9 +278,10 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField): 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: @@ -354,7 +364,8 @@ def image_serializer(value, alt): url = value.url else: return None - url = "https://%s%s" % (DOMAIN, url) + if not url[:4] == "http": + url = "https://{:s}{:s}".format(DOMAIN, url) return activitypub.Document(url=url, name=alt) @@ -371,8 +382,10 @@ class ImageField(ActivitypubFieldMixin, models.ImageField): 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).save(*formatted, save=save) + return True def set_activity_from_field(self, activity, instance): value = getattr(instance, self.name) diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py index 664daa13d..df341c8b2 100644 --- a/bookwyrm/models/readthrough.py +++ b/bookwyrm/models/readthrough.py @@ -30,7 +30,7 @@ class ReadThrough(BookWyrmModel): def save(self, *args, **kwargs): """update user active time""" self.user.last_active_date = timezone.now() - self.user.save(broadcast=False) + self.user.save(broadcast=False, update_fields=["last_active_date"]) super().save(*args, **kwargs) def create_update(self): @@ -55,5 +55,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.save(broadcast=False, update_fields=["last_active_date"]) super().save(*args, **kwargs) diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py index 4110ae8dc..c4e907d27 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -1,6 +1,7 @@ """ puttin' books on shelves """ import re from django.db import models +from django.utils import timezone from bookwyrm import activitypub from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin @@ -69,6 +70,7 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel): "Edition", on_delete=models.PROTECT, activitypub_field="book" ) shelf = models.ForeignKey("Shelf", on_delete=models.PROTECT) + shelved_date = models.DateTimeField(default=timezone.now) user = fields.ForeignKey( "User", on_delete=models.PROTECT, activitypub_field="actor" ) @@ -86,4 +88,4 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel): you can't put a book on shelf twice""" unique_together = ("book", "shelf") - ordering = ("-created_date",) + ordering = ("-shelved_date", "-created_date", "-updated_date") diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py index 872f6b454..ef3f7c3ca 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 @@ -33,6 +31,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 +60,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""" diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 49458a2e0..e10bcd293 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 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""" @@ -105,10 +111,13 @@ 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) show_goal = models.BooleanField(default=True) discoverable = fields.BooleanField(default=False) @@ -120,11 +129,18 @@ class User(OrderedCollectionPageMixin, AbstractUser): deactivation_reason = models.CharField( max_length=255, choices=DeactivationReason.choices, 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""" @@ -204,7 +220,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): self.following.order_by("-updated_date").all(), remote_id=remote_id, id_only=True, - **kwargs + **kwargs, ) def to_followers_activity(self, **kwargs): @@ -214,7 +230,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): self.followers.order_by("-updated_date").all(), remote_id=remote_id, id_only=True, - **kwargs + **kwargs, ) def to_activity(self, **kwargs): @@ -243,7 +259,6 @@ class User(OrderedCollectionPageMixin, AbstractUser): # 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) # this user already exists, no need to populate fields if not created: @@ -253,13 +268,13 @@ class User(OrderedCollectionPageMixin, AbstractUser): # 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.remote_id = "%s/user/%s" % (site_link(), self.localname) self.inbox = "%s/inbox" % self.remote_id - self.shared_inbox = "https://%s/inbox" % DOMAIN + self.shared_inbox = "%s/inbox" % site_link() self.outbox = "%s/outbox" % self.remote_id # an id needs to be set before we can proceed with related models @@ -276,7 +291,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): self.key_pair = KeyPair.objects.create( remote_id="%s/#main-key" % self.remote_id ) - self.save(broadcast=False) + self.save(broadcast=False, update_fields=["key_pair"]) shelves = [ { @@ -406,7 +421,7 @@ def set_remote_server(user_id): 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) diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index 510625e39..4f85bb56e 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -11,6 +11,7 @@ from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageColor from django.core.files.base import ContentFile from django.core.files.uploadedfile import InMemoryUploadedFile +from django.core.files.storage import default_storage from django.db.models import Avg from bookwyrm import models, settings @@ -319,9 +320,9 @@ def save_and_cleanup(image, instance=None): try: try: - old_path = instance.preview_image.path + old_path = instance.preview_image.name except ValueError: - old_path = "" + old_path = None # Save image.save(image_buffer, format="jpeg", quality=75) @@ -337,13 +338,13 @@ 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 os.path.exists(old_path): - os.remove(old_path) + if old_path and default_storage.exists(old_path): + default_storage.delete(old_path) finally: image_buffer.close() diff --git a/bookwyrm/redis_store.py b/bookwyrm/redis_store.py index 322b0611c..f908e163c 100644 --- a/bookwyrm/redis_store.py +++ b/bookwyrm/redis_store.py @@ -54,15 +54,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/settings.py b/bookwyrm/settings.py index 2d1ea84d7..4da064de0 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -54,6 +54,7 @@ SECRET_KEY = env("SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env.bool("DEBUG", True) +USE_HTTPS = env.bool("USE_HTTPS", False) ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", ["*"]) @@ -70,6 +71,7 @@ INSTALLED_APPS = [ "django_rename_app", "bookwyrm", "celery", + "storages", ] MIDDLEWARE = [ @@ -113,7 +115,11 @@ REDIS_ACTIVITY_PASSWORD = env("REDIS_ACTIVITY_PASSWORD", None) REDIS_ACTIVITY_SOCKET = env("REDIS_ACTIVITY_SOCKET", 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 @@ -176,19 +182,51 @@ USE_L10N = True USE_TZ = True -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/3.2/howto/static-files/ - -PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) -STATIC_URL = "/static/" -STATIC_PATH = "%s/%s" % (DOMAIN, env("STATIC_ROOT", "static")) -STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static")) -MEDIA_URL = "/images/" -MEDIA_PATH = "%s/%s" % (DOMAIN, env("MEDIA_ROOT", "images")) -MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images")) - USER_AGENT = "%s (BookWyrm/%s; +https://%s/)" % ( requests.utils.default_user_agent(), VERSION, DOMAIN, ) + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.2/howto/static-files/ + +PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) + +# Storage + +PROTOCOL = "http" +if USE_HTTPS: + PROTOCOL = "https" + +USE_S3 = env.bool("USE_S3", False) + +if USE_S3: + # AWS settings + AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID") + AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY") + AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME") + AWS_S3_CUSTOM_DOMAIN = env("AWS_S3_CUSTOM_DOMAIN") + AWS_S3_REGION_NAME = env("AWS_S3_REGION_NAME", "") + AWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL") + AWS_DEFAULT_ACL = "public-read" + 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) + STATICFILES_STORAGE = "bookwyrm.storage_backends.StaticStorage" + # S3 Media settings + MEDIA_LOCATION = "images" + MEDIA_URL = "https://%s/%s/" % (AWS_S3_CUSTOM_DOMAIN, MEDIA_LOCATION) + MEDIA_FULL_URL = MEDIA_URL + DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage" + # I don't know if it's used, but the site crashes without it + STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static")) + MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images")) +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_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images")) diff --git a/bookwyrm/static/css/bookwyrm.css b/bookwyrm/static/css/bookwyrm.css index 3db25d1fe..d10fb9b7e 100644 --- a/bookwyrm/static/css/bookwyrm.css +++ b/bookwyrm/static/css/bookwyrm.css @@ -72,6 +72,14 @@ body { flex-grow: 1; } +.preserve-whitespace p { + white-space: pre-wrap !important; +} + +.display-inline p { + display: inline !important; +} + /** Shelving ******************************************************************************/ diff --git a/bookwyrm/storage_backends.py b/bookwyrm/storage_backends.py new file mode 100644 index 000000000..e10dfb841 --- /dev/null +++ b/bookwyrm/storage_backends.py @@ -0,0 +1,17 @@ +"""Handles backends for storages""" +from storages.backends.s3boto3 import S3Boto3Storage + + +class StaticStorage(S3Boto3Storage): # pylint: disable=abstract-method + """Storage class for Static contents""" + + location = "static" + default_acl = "public-read" + + +class ImagesStorage(S3Boto3Storage): # pylint: disable=abstract-method + """Storage class for Image files""" + + location = "images" + default_acl = "public-read" + file_overwrite = False diff --git a/bookwyrm/suggested_users.py b/bookwyrm/suggested_users.py new file mode 100644 index 000000000..9c42d79d8 --- /dev/null +++ b/bookwyrm/suggested_users.py @@ -0,0 +1,221 @@ +""" 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 "{:d}-suggestions".format(user) + return "{:d}-suggestions".format(user.id) + + 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): + """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[:5]: + counts = self.get_counts_from_rank(rank) + try: + user = models.User.objects.get(id=user_id) + except models.User.DoesNotExist as err: + # if this happens, the suggestions are janked way up + logger.exception(err) + continue + user.mutuals = counts["mutuals"] + # user.shared_books = counts["shared_books"] + results.append(user) + 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.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 add_new_user(sender, instance, created, update_fields=None, **kwargs): + """a new user, wow how cool""" + # a new user is found, create suggestions for them + if created and instance.local: + rerank_suggestions_task.delay(instance.id) + + if update_fields and not "discoverable" in update_fields: + 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) + + +@app.task +def rerank_suggestions_task(user_id): + """do the hard work in celery""" + suggested_users.rerank_user_suggestions(user_id) + + +@app.task +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 +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 +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) diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index 9a75cbe94..c5dab1097 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -1,5 +1,10 @@ {% extends 'layout.html' %} -{% load i18n %}{% load bookwyrm_tags %}{% load humanize %}{% load utilities %}{% load layout %} +{% load i18n %} +{% load bookwyrm_tags %} +{% load humanize %} +{% load utilities %} +{% load static %} +{% load layout %} {% block title %}{{ book|book_title }}{% endblock %} @@ -321,5 +326,5 @@ {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/bookwyrm/templates/confirm_email/confirm_email.html b/bookwyrm/templates/confirm_email/confirm_email.html new file mode 100644 index 000000000..6c9eda5ee --- /dev/null +++ b/bookwyrm/templates/confirm_email/confirm_email.html @@ -0,0 +1,44 @@ +{% extends "layout.html" %} +{% load i18n %} + +{% block title %}{% trans "Confirm email" %}{% endblock %} + +{% block content %} +
{% trans "A confirmation code has been sent to the email address you used to register your account." %}
+ {% if not valid %} +{% trans "Sorry! We couldn't find that code." %}
+ {% endif %} + ++{% blocktrans trimmed %} +One last step before you join {{ site_name }}! Please confirm your email address by clicking the link below: +{% endblocktrans %} +
+ +{% trans "Confirm Email" as text %} +{% include 'email/snippets/action.html' with path=confirmation_link text=text %} + +
+{% blocktrans trimmed %}
+Or enter the code "{{ confirmation_code }}
" at login.
+{% endblocktrans %}
+
{% trans "There aren't any activities right now! Try following a user to get started" %}
+{% trans "There aren't any activities right now! Try following a user to get started" %}
+ + {% if suggested_users %} + {# suggested users for when things are very lonely #} + {% include 'feed/suggested_users.html' with suggested_users=suggested_users %} +{% trans "There are no books here right now! Try searching for a book to get started" %}
- {% else %} - {% with active_book=request.GET.book %} -- {% if shelf.identifier == 'to-read' %}{% trans "To Read" %} - {% elif shelf.identifier == 'reading' %}{% trans "Currently Reading" %} - {% elif shelf.identifier == 'read' %}{% trans "Read" %} - {% else %}{{ shelf.name }}{% endif %} -
-{% include 'snippets/book_titleby.html' with book=book %}
- {% include 'snippets/shelve_button/shelve_button.html' with book=book %} -{% trans "There are no books here right now! Try searching for a book to get started" %}
+ {% else %} + {% with active_book=request.GET.book %} ++ {% if shelf.identifier == 'to-read' %}{% trans "To Read" %} + {% elif shelf.identifier == 'reading' %}{% trans "Currently Reading" %} + {% elif shelf.identifier == 'read' %}{% trans "Read" %} + {% else %}{{ shelf.name }}{% endif %} +
+{% include 'snippets/book_titleby.html' with book=book %}
+ {% include 'snippets/shelve_button/shelve_button.html' with book=book %} +- {% blocktrans with mutuals=user.mutuals|intcomma count counter=user.mutuals %}{{ mutuals }} follower you follow{% plural %}{{ mutuals }} followers you follow{% endblocktrans %} -
- {% elif user.shared_books %} -{% blocktrans with shared_books=user.shared_books|intcomma count counter=user.shared_books %}{{ shared_books }} book on your shelves{% plural %}{{ shared_books }} books on your shelves{% endblocktrans %}
- {% endif %} -