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..d4476fd2 100644
--- a/.env.dev.example
+++ b/.env.dev.example
@@ -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..99520916 100644
--- a/.env.prod.example
+++ b/.env.prod.example
@@ -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..e27d0b1b
--- /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' \
+ 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/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py
index da32fbaf..52b1b1f2 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
@@ -106,8 +106,10 @@ 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):
+ # 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)
@@ -129,9 +131,12 @@ class ActivityObject:
# 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:
- changed = 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:
@@ -140,7 +145,9 @@ class ActivityObject:
# 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:
- changed = 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)
@@ -268,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()
diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py
index 916da2d0..d61471fe 100644
--- a/bookwyrm/activitypub/note.py
+++ b/bookwyrm/activitypub/note.py
@@ -30,8 +30,8 @@ class Note(ActivityObject):
to: List[str] = field(default_factory=lambda: [])
cc: List[str] = field(default_factory=lambda: [])
replies: Dict = field(default_factory=lambda: {})
- inReplyTo: str = ""
- summary: str = ""
+ inReplyTo: str = None
+ summary: str = None
tag: List[Link] = field(default_factory=lambda: [])
attachment: List[Document] = field(default_factory=lambda: [])
sensitive: bool = False
@@ -59,6 +59,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 +70,8 @@ class Quotation(Comment):
"""a quote and commentary on a book"""
quote: str
+ position: int = None
+ positionMode: str = None
type: str = "Quotation"
diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py
index a49a7ce4..6dedd717 100644
--- a/bookwyrm/activitystreams.py
+++ b/bookwyrm/activitystreams.py
@@ -1,14 +1,16 @@
""" access the activity streams stored in redis """
from django.dispatch import receiver
+from django.db import transaction
from django.db.models import signals, Q
from bookwyrm import models
from bookwyrm.redis_store import RedisStore, r
+from bookwyrm.tasks import app
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"""
@@ -22,14 +24,15 @@ class ActivityStream(RedisStore):
"""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()
@@ -55,7 +58,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")
)
@@ -155,29 +164,89 @@ 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
streams = {
"home": HomeStream(),
"local": LocalStream(),
- "federated": FederatedStream(),
+ "books": BooksStream(),
}
@@ -190,41 +259,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 +292,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 +303,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 +314,38 @@ 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()]
+ public_streams = [v for (k, v) in streams.items() if k != "home"]
+
# add statuses back to streams with statuses from anyone
if instance.user_subject.local:
- for stream in public_streams:
- stream.add_user_statuses(instance.user_subject, instance.user_object)
+ add_user_statuses_task.delay(
+ instance.user_subject.id,
+ instance.user_object.id,
+ stream_list=public_streams,
+ )
# add statuses back to streams with statuses from anyone
if instance.user_object.local:
- for stream in public_streams:
- stream.add_user_statuses(instance.user_object, instance.user_subject)
+ add_user_statuses_task.delay(
+ instance.user_object.id,
+ instance.user_subject.id,
+ stream_list=public_streams,
+ )
@receiver(signals.post_save, sender=models.User)
@@ -284,4 +356,123 @@ def populate_streams_on_account_create(sender, instance, created, *args, **kwarg
return
for stream in streams.values():
- stream.populate_streams(instance)
+ 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
+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
+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
+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
+def remove_status_task(status_ids):
+ """remove a status from any stream it might be in"""
+ # this can take an id or a list of ids
+ if not isinstance(status_ids, list):
+ status_ids = [status_ids]
+ statuses = models.Status.objects.filter(id__in=status_ids)
+
+ for stream in streams.values():
+ for status in statuses:
+ stream.remove_object_from_related_stores(status)
+
+
+@app.task
+def add_status_task(status_id, increment_unread=False):
+ """remove a status from any stream it might be in"""
+ status = models.Status.objects.get(id=status_id)
+ for stream in streams.values():
+ stream.add_status(status, increment_unread=increment_unread)
+
+
+@app.task
+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
+def add_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.add_user_statuses(viewer, user)
+
+
+@app.task
+def handle_boost_task(boost_id):
+ """remove the original post and other, earlier boosts"""
+ instance = models.Status.objects.get(id=boost_id)
+ boosted = instance.boost.boosted_status
+
+ old_versions = models.Boost.objects.filter(
+ boosted_status__id=boosted.id,
+ created_date__lt=instance.created_date,
+ ).values_list("id", flat=True)
+
+ for stream in streams.values():
+ audience = stream.get_stores_for_object(instance)
+ stream.remove_object_from_related_stores(boosted, stores=audience)
+ for status in old_versions:
+ stream.remove_object_from_related_stores(status, stores=audience)
diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py
index fb102ea4..ffacffdf 100644
--- a/bookwyrm/connectors/abstract_connector.py
+++ b/bookwyrm/connectors/abstract_connector.py
@@ -139,7 +139,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 +156,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 +182,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):
diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py
index 116aa5c1..d2a7b9fa 100644
--- a/bookwyrm/connectors/inventaire.py
+++ b/bookwyrm/connectors/inventaire.py
@@ -71,7 +71,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
@@ -145,8 +145,8 @@ class Connector(AbstractConnector):
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))
diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py
index 1f0387fe..0610a8b9 100644
--- a/bookwyrm/context_processors.py
+++ b/bookwyrm/context_processors.py
@@ -11,6 +11,7 @@ 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,
diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py
index 657310b0..fff3985e 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 c9e795c3..69052605 100644
--- a/bookwyrm/forms.py
+++ b/bookwyrm/forms.py
@@ -86,6 +86,7 @@ class CommentForm(CustomForm):
"privacy",
"progress",
"progress_mode",
+ "reading_status",
]
@@ -100,6 +101,8 @@ class QuotationForm(CustomForm):
"content_warning",
"sensitive",
"privacy",
+ "position",
+ "position_mode",
]
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/management/commands/populate_streams.py b/bookwyrm/management/commands/populate_streams.py
index f8aa21a5..a04d7f5a 100644
--- a/bookwyrm/management/commands/populate_streams.py
+++ b/bookwyrm/management/commands/populate_streams.py
@@ -3,22 +3,35 @@ from django.core.management.base import BaseCommand
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/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/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py
index 729d9cba..f287b752 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"
@@ -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"""
@@ -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
diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py
index 2cb7c036..5b55ea50 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/book.py b/bookwyrm/models/book.py
index d9382807..e21d61e2 100644
--- a/bookwyrm/models/book.py
+++ b/bookwyrm/models/book.py
@@ -7,10 +7,16 @@ from django.db import models
from django.dispatch import receiver
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 +103,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"""
diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py
index b58f8174..cc5a7bb5 100644
--- a/bookwyrm/models/fields.py
+++ b/bookwyrm/models/fields.py
@@ -66,7 +66,7 @@ class ActivitypubFieldMixin:
self.activitypub_field = activitypub_field
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. Returns if changed"""
try:
value = getattr(data, self.get_activitypub_field())
@@ -79,8 +79,15 @@ class ActivitypubFieldMixin:
if formatted is None or formatted is MISSING or formatted == {}:
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 hasattr(instance, self.name) and getattr(instance, self.name) == formatted:
+ if current_value == formatted:
return False
setattr(instance, self.name, formatted)
@@ -210,12 +217,27 @@ 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:
@@ -231,9 +253,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
mentions = [u.remote_id for u in instance.mention_users.all()]
# this is a link to the followers list
# pylint: disable=protected-access
- followers = instance.user.__class__._meta.get_field(
- "followers"
- ).field_to_activity(instance.user.followers)
+ followers = instance.user.followers_url
if instance.privacy == "public":
activity["to"] = [self.public]
activity["cc"] = [followers] + mentions
@@ -273,8 +293,11 @@ 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:
@@ -377,13 +400,16 @@ 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 False
+ if not overwrite and hasattr(instance, self.name):
+ return False
+
getattr(instance, self.name).save(*formatted, save=save)
return True
diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py
index f2993846..05aada16 100644
--- a/bookwyrm/models/import_job.py
+++ b/bookwyrm/models/import_job.py
@@ -80,7 +80,7 @@ class ImportItem(models.Model):
else:
# don't fall back on title/author search is isbn is present.
# you're too likely to mismatch
- self.get_book_from_title_author()
+ self.book = self.get_book_from_title_author()
def get_book_from_isbn(self):
"""search by isbn"""
diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py
index df341c8b..343d3c11 100644
--- a/bookwyrm/models/readthrough.py
+++ b/bookwyrm/models/readthrough.py
@@ -1,7 +1,8 @@
""" 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 django.utils import timezone
from .base_model import BookWyrmModel
@@ -41,6 +42,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."""
diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py
index 872f6b45..ef3f7c3c 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/status.py b/bookwyrm/models/status.py
index 3c25f1af..d0f094d2 100644
--- a/bookwyrm/models/status.py
+++ b/bookwyrm/models/status.py
@@ -235,12 +235,31 @@ class GeneratedNote(Status):
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
@@ -265,15 +284,21 @@ class Comment(Status):
)
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"
+ 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
@@ -289,16 +314,12 @@ class Quotation(Status):
)
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,
diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py
index 21b6bbaa..0745dffa 100644
--- a/bookwyrm/models/user.py
+++ b/bookwyrm/models/user.py
@@ -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"""
@@ -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"
+ )
favorites = models.ManyToManyField(
"Status",
symmetrical=False,
@@ -111,7 +120,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
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)
@@ -123,11 +132,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"""
@@ -207,17 +223,17 @@ 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):
"""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):
@@ -259,10 +275,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
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
+ 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
super().save(*args, **kwargs)
diff --git a/bookwyrm/redis_store.py b/bookwyrm/redis_store.py
index fa5c73a5..521e73b2 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 = stores or self.get_stores_for_object(obj)
pipeline = r.pipeline()
- for store in self.get_stores_for_object(obj):
+ for store in stores:
pipeline.zrem(store, -1, obj.id)
pipeline.execute()
diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py
index 17fcfabe..c1f90079 100644
--- a/bookwyrm/settings.py
+++ b/bookwyrm/settings.py
@@ -75,6 +75,7 @@ INSTALLED_APPS = [
"django_rename_app",
"bookwyrm",
"celery",
+ "imagekit",
"storages",
]
@@ -118,7 +119,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
@@ -187,6 +192,9 @@ USER_AGENT = "%s (BookWyrm/%s; +https://%s/)" % (
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/
diff --git a/bookwyrm/static/css/bookwyrm.css b/bookwyrm/static/css/bookwyrm.css
index d10fb9b7..0724c7f1 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
******************************************************************************/
@@ -227,16 +236,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
diff --git a/bookwyrm/static/css/fonts/icomoon.eot b/bookwyrm/static/css/fonts/icomoon.eot
index 566fb13d..2c801b2b 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..6327b19e 100644
--- a/bookwyrm/static/css/fonts/icomoon.svg
+++ b/bookwyrm/static/css/fonts/icomoon.svg
@@ -33,13 +33,12 @@
{% 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" %} -