diff --git a/.editorconfig b/.editorconfig
index d102bc5a..f2e8a178 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -32,7 +32,7 @@ indent_size = 2
max_line_length = off
# Computer generated files
-[{package.json,*.lock,*.mo}]
+[{icons.css,package.json,*.lock,*.mo}]
indent_size = unset
indent_style = unset
max_line_length = unset
diff --git a/.env.dev.example b/.env.dev.example
index f42aaaae..1e4fb981 100644
--- a/.env.dev.example
+++ b/.env.dev.example
@@ -16,7 +16,7 @@ DEFAULT_LANGUAGE="English"
MEDIA_ROOT=images/
-POSTGRES_PORT=5432
+PGPORT=5432
POSTGRES_PASSWORD=securedbypassword123
POSTGRES_USER=fedireads
POSTGRES_DB=fedireads
@@ -43,6 +43,9 @@ EMAIL_HOST_PASSWORD=emailpassword123
EMAIL_USE_TLS=true
EMAIL_USE_SSL=false
+# Thumbnails Generation
+ENABLE_THUMBNAIL_GENERATION=false
+
# S3 configuration
USE_S3=false
AWS_ACCESS_KEY_ID=
@@ -58,6 +61,7 @@ AWS_SECRET_ACCESS_KEY=
# AWS_S3_REGION_NAME=None # "fr-par"
# AWS_S3_ENDPOINT_URL=None # "https://s3.fr-par.scw.cloud"
+
# Preview image generation can be computing and storage intensive
# ENABLE_PREVIEW_IMAGES=True
diff --git a/.env.prod.example b/.env.prod.example
index 5115469c..49729d53 100644
--- a/.env.prod.example
+++ b/.env.prod.example
@@ -16,7 +16,7 @@ DEFAULT_LANGUAGE="English"
MEDIA_ROOT=images/
-POSTGRES_PORT=5432
+PGPORT=5432
POSTGRES_PASSWORD=securedbypassword123
POSTGRES_USER=fedireads
POSTGRES_DB=fedireads
@@ -43,6 +43,9 @@ EMAIL_HOST_PASSWORD=emailpassword123
EMAIL_USE_TLS=true
EMAIL_USE_SSL=false
+# Thumbnails Generation
+ENABLE_THUMBNAIL_GENERATION=false
+
# S3 configuration
USE_S3=false
AWS_ACCESS_KEY_ID=
@@ -58,6 +61,7 @@ AWS_SECRET_ACCESS_KEY=
# AWS_S3_REGION_NAME=None # "fr-par"
# AWS_S3_ENDPOINT_URL=None # "https://s3.fr-par.scw.cloud"
+
# Preview image generation can be computing and storage intensive
# ENABLE_PREVIEW_IMAGES=True
diff --git a/.github/workflows/curlylint.yaml b/.github/workflows/curlylint.yaml
new file mode 100644
index 00000000..593a4283
--- /dev/null
+++ b/.github/workflows/curlylint.yaml
@@ -0,0 +1,28 @@
+name: Templates validator
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Install curlylint
+ run: pip install curlylint
+
+ - name: Run linter
+ run: >
+ curlylint --rule 'aria_role: true' \
+ --rule 'django_forms_rendering: true' \
+ --rule 'html_has_lang: true' \
+ --rule 'image_alt: true' \
+ --rule 'meta_viewport: true' \
+ --rule 'no_autofocus: true' \
+ --rule 'tabindex_no_positive: true' \
+ --exclude '_modal.html|create_status/layout.html|reading_modals/layout.html' \
+ bookwyrm/templates
diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml
index 03147744..03875193 100644
--- a/.github/workflows/django-tests.yml
+++ b/.github/workflows/django-tests.yml
@@ -36,6 +36,7 @@ jobs:
env:
SECRET_KEY: beepbeep
DEBUG: false
+ USE_HTTPS: true
DOMAIN: your.domain.here
BOOKWYRM_DATABASE_BACKEND: postgres
MEDIA_ROOT: images/
diff --git a/Dockerfile b/Dockerfile
index 1892ae23..349dd82b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,4 +8,4 @@ WORKDIR /app
COPY requirements.txt /app/
RUN pip install -r requirements.txt --no-cache-dir
-RUN apt-get update && apt-get install -y gettext libgettextpo-dev && apt-get clean
+RUN apt-get update && apt-get install -y gettext libgettextpo-dev tidy && apt-get clean
diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py
index 81762388..24d383ac 100644
--- a/bookwyrm/activitypub/base_activity.py
+++ b/bookwyrm/activitypub/base_activity.py
@@ -48,7 +48,7 @@ class Signature:
def naive_parse(activity_objects, activity_json, serializer=None):
- """this navigates circular import issues"""
+ """this navigates circular import issues by looking up models' serializers"""
if not serializer:
if activity_json.get("publicKeyPem"):
# ugh
@@ -101,12 +101,15 @@ class ActivityObject:
except KeyError:
if field.default == MISSING and field.default_factory == MISSING:
raise ActivitySerializerError(
- "Missing required field: %s" % field.name
+ f"Missing required field: {field.name}"
)
value = field.default
setattr(self, field.name, value)
- def to_model(self, model=None, instance=None, allow_create=True, save=True):
+ # pylint: disable=too-many-locals,too-many-branches,too-many-arguments
+ def to_model(
+ self, model=None, instance=None, allow_create=True, save=True, overwrite=True
+ ):
"""convert from an activity to a model instance"""
model = model or get_model_from_type(self.type)
@@ -126,27 +129,41 @@ class ActivityObject:
return None
instance = instance or model()
+ # keep track of what we've changed
+ update_fields = []
+ # sets field on the model using the activity value
for field in instance.simple_fields:
try:
- field.set_field_from_activity(instance, self)
+ changed = field.set_field_from_activity(
+ instance, self, overwrite=overwrite
+ )
+ if changed:
+ update_fields.append(field.name)
except AttributeError as e:
raise ActivitySerializerError(e)
# image fields have to be set after other fields because they can save
# too early and jank up users
for field in instance.image_fields:
- field.set_field_from_activity(instance, self, save=save)
+ changed = field.set_field_from_activity(
+ instance, self, save=save, overwrite=overwrite
+ )
+ if changed:
+ update_fields.append(field.name)
if not save:
return instance
with transaction.atomic():
+ # can't force an update on fields unless the object already exists in the db
+ if not instance.id:
+ update_fields = None
# we can't set many to many and reverse fields on an unsaved object
try:
try:
- instance.save(broadcast=False)
+ instance.save(broadcast=False, update_fields=update_fields)
except TypeError:
- instance.save()
+ instance.save(update_fields=update_fields)
except IntegrityError as e:
raise ActivitySerializerError(e)
@@ -196,14 +213,14 @@ class ActivityObject:
return data
-@app.task
+@app.task(queue="medium_priority")
@transaction.atomic
def set_related_field(
model_name, origin_model_name, related_field_name, related_remote_id, data
):
"""load reverse related fields (editions, attachments) without blocking"""
- model = apps.get_model("bookwyrm.%s" % model_name, require_ready=True)
- origin_model = apps.get_model("bookwyrm.%s" % origin_model_name, require_ready=True)
+ model = apps.get_model(f"bookwyrm.{model_name}", require_ready=True)
+ origin_model = apps.get_model(f"bookwyrm.{origin_model_name}", require_ready=True)
with transaction.atomic():
if isinstance(data, str):
@@ -217,7 +234,7 @@ def set_related_field(
# this must exist because it's the object that triggered this function
instance = origin_model.find_existing_by_remote_id(related_remote_id)
if not instance:
- raise ValueError("Invalid related remote id: %s" % related_remote_id)
+ raise ValueError(f"Invalid related remote id: {related_remote_id}")
# set the origin's remote id on the activity so it will be there when
# the model instance is created
@@ -248,7 +265,7 @@ def get_model_from_type(activity_type):
]
if not model:
raise ActivitySerializerError(
- 'No model found for activity type "%s"' % activity_type
+ f'No model found for activity type "{activity_type}"'
)
return model[0]
@@ -258,6 +275,8 @@ def resolve_remote_id(
):
"""take a remote_id and return an instance, creating if necessary"""
if model: # a bonus check we can do if we already know the model
+ if isinstance(model, str):
+ model = apps.get_model(f"bookwyrm.{model}", require_ready=True)
result = model.find_existing_by_remote_id(remote_id)
if result and not refresh:
return result if not get_activity else result.to_activity_dataclass()
@@ -267,7 +286,7 @@ def resolve_remote_id(
data = get_data(remote_id)
except ConnectorException:
raise ActivitySerializerError(
- "Could not connect to host for remote_id in: %s" % (remote_id)
+ f"Could not connect to host for remote_id: {remote_id}"
)
# determine the model implicitly, if not provided
# or if it's a model with subclasses like Status, check again
diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py
index bd27c4e6..d8599c4b 100644
--- a/bookwyrm/activitypub/book.py
+++ b/bookwyrm/activitypub/book.py
@@ -54,6 +54,7 @@ class Edition(Book):
asin: str = ""
pages: int = None
physicalFormat: str = ""
+ physicalFormatDetail: str = ""
publishers: List[str] = field(default_factory=lambda: [])
editionRank: int = 0
diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py
index 916da2d0..eb18b8b8 100644
--- a/bookwyrm/activitypub/note.py
+++ b/bookwyrm/activitypub/note.py
@@ -30,11 +30,12 @@ class Note(ActivityObject):
to: List[str] = field(default_factory=lambda: [])
cc: List[str] = field(default_factory=lambda: [])
replies: Dict = field(default_factory=lambda: {})
- inReplyTo: str = ""
- summary: str = ""
+ inReplyTo: str = None
+ summary: str = None
tag: List[Link] = field(default_factory=lambda: [])
attachment: List[Document] = field(default_factory=lambda: [])
sensitive: bool = False
+ updated: str = None
type: str = "Note"
@@ -59,6 +60,9 @@ class Comment(Note):
"""like a note but with a book"""
inReplyToBook: str
+ readingStatus: str = None
+ progress: int = None
+ progressMode: str = None
type: str = "Comment"
@@ -67,6 +71,8 @@ class Quotation(Comment):
"""a quote and commentary on a book"""
quote: str
+ position: int = None
+ positionMode: str = None
type: str = "Quotation"
diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py
index 50a479b7..b32b0413 100644
--- a/bookwyrm/activitypub/verbs.py
+++ b/bookwyrm/activitypub/verbs.py
@@ -69,8 +69,9 @@ class Update(Verb):
def action(self):
"""update a model instance from the dataclass"""
- if self.object:
- self.object.to_model(allow_create=False)
+ if not self.object:
+ return
+ self.object.to_model(allow_create=False)
@dataclass(init=False)
diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py
index a49a7ce4..4896e07d 100644
--- a/bookwyrm/activitystreams.py
+++ b/bookwyrm/activitystreams.py
@@ -1,35 +1,40 @@
""" access the activity streams stored in redis """
+from datetime import timedelta
from django.dispatch import receiver
+from django.db import transaction
from django.db.models import signals, Q
+from django.utils import timezone
from bookwyrm import models
from bookwyrm.redis_store import RedisStore, r
-from bookwyrm.views.helpers import privacy_filter
+from bookwyrm.tasks import app
class ActivityStream(RedisStore):
- """a category of activity stream (like home, local, federated)"""
+ """a category of activity stream (like home, local, books)"""
def stream_id(self, user):
"""the redis key for this user's instance of this stream"""
- return "{}-{}".format(user.id, self.key)
+ return f"{user.id}-{self.key}"
def unread_id(self, user):
"""the redis key for this user's unread count for this stream"""
- return "{}-unread".format(self.stream_id(user))
+ stream_id = self.stream_id(user)
+ return f"{stream_id}-unread"
def get_rank(self, obj): # pylint: disable=no-self-use
"""statuses are sorted by date published"""
return obj.published_date.timestamp()
- def add_status(self, status):
+ def add_status(self, status, increment_unread=False):
"""add a status to users' feeds"""
# the pipeline contains all the add-to-stream activities
pipeline = self.add_object_to_related_stores(status, execute=False)
- for user in self.get_audience(status):
- # add to the unread status count
- pipeline.incr(self.unread_id(user))
+ if increment_unread:
+ for user in self.get_audience(status):
+ # add to the unread status count
+ pipeline.incr(self.unread_id(user))
# and go!
pipeline.execute()
@@ -37,7 +42,7 @@ class ActivityStream(RedisStore):
def add_user_statuses(self, viewer, user):
"""add a user's statuses to another user's feed"""
# only add the statuses that the viewer should be able to see (ie, not dms)
- statuses = privacy_filter(viewer, user.status_set.all())
+ statuses = models.Status.privacy_filter(viewer).filter(user=user)
self.bulk_add_objects_to_store(statuses, self.stream_id(viewer))
def remove_user_statuses(self, viewer, user):
@@ -55,7 +60,13 @@ class ActivityStream(RedisStore):
return (
models.Status.objects.select_subclasses()
.filter(id__in=statuses)
- .select_related("user", "reply_parent")
+ .select_related(
+ "user",
+ "reply_parent",
+ "comment__book",
+ "review__book",
+ "quotation__book",
+ )
.prefetch_related("mention_books", "mention_users")
.order_by("-published_date")
)
@@ -101,9 +112,8 @@ class ActivityStream(RedisStore):
def get_statuses_for_user(self, user): # pylint: disable=no-self-use
"""given a user, what statuses should they see on this stream"""
- return privacy_filter(
+ return models.Status.privacy_filter(
user,
- models.Status.objects.select_subclasses(),
privacy_levels=["public", "unlisted", "followers"],
)
@@ -127,11 +137,15 @@ class HomeStream(ActivityStream):
).distinct()
def get_statuses_for_user(self, user):
- return privacy_filter(
+ return models.Status.privacy_filter(
user,
- models.Status.objects.select_subclasses(),
privacy_levels=["public", "unlisted", "followers"],
- following_only=True,
+ ).exclude(
+ ~Q( # remove everything except
+ Q(user__followers=user) # user following
+ | Q(user=user) # is self
+ | Q(mention_users=user) # mentions user
+ ),
)
@@ -148,36 +162,98 @@ class LocalStream(ActivityStream):
def get_statuses_for_user(self, user):
# all public statuses by a local user
- return privacy_filter(
+ return models.Status.privacy_filter(
user,
- models.Status.objects.select_subclasses().filter(user__local=True),
privacy_levels=["public"],
- )
+ ).filter(user__local=True)
-class FederatedStream(ActivityStream):
- """users you follow"""
+class BooksStream(ActivityStream):
+ """books on your shelves"""
- key = "federated"
+ key = "books"
def get_audience(self, status):
- # this stream wants no part in non-public statuses
- if status.privacy != "public":
+ """anyone with the mentioned book on their shelves"""
+ # only show public statuses on the books feed,
+ # and only statuses that mention books
+ if status.privacy != "public" or not (
+ status.mention_books.exists() or hasattr(status, "book")
+ ):
return []
- return super().get_audience(status)
- def get_statuses_for_user(self, user):
- return privacy_filter(
- user,
- models.Status.objects.select_subclasses(),
- privacy_levels=["public"],
+ work = (
+ status.book.parent_work
+ if hasattr(status, "book")
+ else status.mention_books.first().parent_work
)
+ audience = super().get_audience(status)
+ if not audience:
+ return []
+ return audience.filter(shelfbook__book__parent_work=work).distinct()
+ def get_statuses_for_user(self, user):
+ """any public status that mentions the user's books"""
+ books = user.shelfbook_set.values_list(
+ "book__parent_work__id", flat=True
+ ).distinct()
+ return (
+ models.Status.privacy_filter(
+ user,
+ privacy_levels=["public"],
+ )
+ .filter(
+ Q(comment__book__parent_work__id__in=books)
+ | Q(quotation__book__parent_work__id__in=books)
+ | Q(review__book__parent_work__id__in=books)
+ | Q(mention_books__parent_work__id__in=books)
+ )
+ .distinct()
+ )
+
+ def add_book_statuses(self, user, book):
+ """add statuses about a book to a user's feed"""
+ work = book.parent_work
+ statuses = (
+ models.Status.privacy_filter(
+ user,
+ privacy_levels=["public"],
+ )
+ .filter(
+ Q(comment__book__parent_work=work)
+ | Q(quotation__book__parent_work=work)
+ | Q(review__book__parent_work=work)
+ | Q(mention_books__parent_work=work)
+ )
+ .distinct()
+ )
+ self.bulk_add_objects_to_store(statuses, self.stream_id(user))
+
+ def remove_book_statuses(self, user, book):
+ """add statuses about a book to a user's feed"""
+ work = book.parent_work
+ statuses = (
+ models.Status.privacy_filter(
+ user,
+ privacy_levels=["public"],
+ )
+ .filter(
+ Q(comment__book__parent_work=work)
+ | Q(quotation__book__parent_work=work)
+ | Q(review__book__parent_work=work)
+ | Q(mention_books__parent_work=work)
+ )
+ .distinct()
+ )
+ self.bulk_remove_objects_from_store(statuses, self.stream_id(user))
+
+
+# determine which streams are enabled in settings.py
streams = {
"home": HomeStream(),
"local": LocalStream(),
- "federated": FederatedStream(),
+ "books": BooksStream(),
}
@@ -190,41 +266,31 @@ def add_status_on_create(sender, instance, created, *args, **kwargs):
return
if instance.deleted:
- for stream in streams.values():
- stream.remove_object_from_related_stores(instance)
+ remove_status_task.delay(instance.id)
return
- if not created:
- return
-
- # iterates through Home, Local, Federated
- for stream in streams.values():
- stream.add_status(instance)
-
- if sender != models.Boost:
- return
- # remove the original post and other, earlier boosts
- boosted = instance.boost.boosted_status
- old_versions = models.Boost.objects.filter(
- boosted_status__id=boosted.id,
- created_date__lt=instance.created_date,
+ # when creating new things, gotta wait on the transaction
+ transaction.on_commit(
+ lambda: add_status_on_create_command(sender, instance, created)
)
- for stream in streams.values():
- stream.remove_object_from_related_stores(boosted)
- for status in old_versions:
- stream.remove_object_from_related_stores(status)
+
+
+def add_status_on_create_command(sender, instance, created):
+ """runs this code only after the database commit completes"""
+ add_status_task.delay(instance.id, increment_unread=created)
+
+ if sender == models.Boost:
+ handle_boost_task.delay(instance.id)
@receiver(signals.post_delete, sender=models.Boost)
# pylint: disable=unused-argument
def remove_boost_on_delete(sender, instance, *args, **kwargs):
"""boosts are deleted"""
- # we're only interested in new statuses
- for stream in streams.values():
- # remove the boost
- stream.remove_object_from_related_stores(instance)
- # re-add the original status
- stream.add_status(instance.boosted_status)
+ # remove the boost
+ remove_status_task.delay(instance.id)
+ # re-add the original status
+ add_status_task.delay(instance.boosted_status.id)
@receiver(signals.post_save, sender=models.UserFollows)
@@ -233,7 +299,9 @@ def add_statuses_on_follow(sender, instance, created, *args, **kwargs):
"""add a newly followed user's statuses to feeds"""
if not created or not instance.user_subject.local:
return
- HomeStream().add_user_statuses(instance.user_subject, instance.user_object)
+ add_user_statuses_task.delay(
+ instance.user_subject.id, instance.user_object.id, stream_list=["home"]
+ )
@receiver(signals.post_delete, sender=models.UserFollows)
@@ -242,7 +310,9 @@ def remove_statuses_on_unfollow(sender, instance, *args, **kwargs):
"""remove statuses from a feed on unfollow"""
if not instance.user_subject.local:
return
- HomeStream().remove_user_statuses(instance.user_subject, instance.user_object)
+ remove_user_statuses_task.delay(
+ instance.user_subject.id, instance.user_object.id, stream_list=["home"]
+ )
@receiver(signals.post_save, sender=models.UserBlocks)
@@ -251,29 +321,45 @@ def remove_statuses_on_block(sender, instance, *args, **kwargs):
"""remove statuses from all feeds on block"""
# blocks apply ot all feeds
if instance.user_subject.local:
- for stream in streams.values():
- stream.remove_user_statuses(instance.user_subject, instance.user_object)
+ remove_user_statuses_task.delay(
+ instance.user_subject.id, instance.user_object.id
+ )
# and in both directions
if instance.user_object.local:
- for stream in streams.values():
- stream.remove_user_statuses(instance.user_object, instance.user_subject)
+ remove_user_statuses_task.delay(
+ instance.user_object.id, instance.user_subject.id
+ )
@receiver(signals.post_delete, sender=models.UserBlocks)
# pylint: disable=unused-argument
def add_statuses_on_unblock(sender, instance, *args, **kwargs):
- """remove statuses from all feeds on block"""
- public_streams = [LocalStream(), FederatedStream()]
+ """add statuses back to all feeds on unblock"""
+ # make sure there isn't a block in the other direction
+ if models.UserBlocks.objects.filter(
+ user_subject=instance.user_object,
+ user_object=instance.user_subject,
+ ).exists():
+ return
+
+ public_streams = [k for (k, v) in streams.items() if k != "home"]
+
# add statuses back to streams with statuses from anyone
if instance.user_subject.local:
- for stream in public_streams:
- stream.add_user_statuses(instance.user_subject, instance.user_object)
+ add_user_statuses_task.delay(
+ instance.user_subject.id,
+ instance.user_object.id,
+ stream_list=public_streams,
+ )
# add statuses back to streams with statuses from anyone
if instance.user_object.local:
- for stream in public_streams:
- stream.add_user_statuses(instance.user_object, instance.user_subject)
+ add_user_statuses_task.delay(
+ instance.user_object.id,
+ instance.user_subject.id,
+ stream_list=public_streams,
+ )
@receiver(signals.post_save, sender=models.User)
@@ -283,5 +369,130 @@ def populate_streams_on_account_create(sender, instance, created, *args, **kwarg
if not created or not instance.local:
return
+ for stream in streams:
+ populate_stream_task.delay(stream, instance.id)
+
+
+@receiver(signals.pre_save, sender=models.ShelfBook)
+# pylint: disable=unused-argument
+def add_statuses_on_shelve(sender, instance, *args, **kwargs):
+ """update books stream when user shelves a book"""
+ if not instance.user.local:
+ return
+ book = instance.book
+
+ # check if the book is already on the user's shelves
+ editions = book.parent_work.editions.all()
+ if models.ShelfBook.objects.filter(user=instance.user, book__in=editions).exists():
+ return
+
+ add_book_statuses_task.delay(instance.user.id, book.id)
+
+
+@receiver(signals.post_delete, sender=models.ShelfBook)
+# pylint: disable=unused-argument
+def remove_statuses_on_unshelve(sender, instance, *args, **kwargs):
+ """update books stream when user unshelves a book"""
+ if not instance.user.local:
+ return
+
+ book = instance.book
+
+ # check if the book is actually unshelved, not just moved
+ editions = book.parent_work.editions.all()
+ if models.ShelfBook.objects.filter(user=instance.user, book__in=editions).exists():
+ return
+
+ remove_book_statuses_task.delay(instance.user.id, book.id)
+
+
+# ---- TASKS
+
+
+@app.task(queue="low_priority")
+def add_book_statuses_task(user_id, book_id):
+ """add statuses related to a book on shelve"""
+ user = models.User.objects.get(id=user_id)
+ book = models.Edition.objects.get(id=book_id)
+ BooksStream().add_book_statuses(user, book)
+
+
+@app.task(queue="low_priority")
+def remove_book_statuses_task(user_id, book_id):
+ """remove statuses about a book from a user's books feed"""
+ user = models.User.objects.get(id=user_id)
+ book = models.Edition.objects.get(id=book_id)
+ BooksStream().remove_book_statuses(user, book)
+
+
+@app.task(queue="medium_priority")
+def populate_stream_task(stream, user_id):
+ """background task for populating an empty activitystream"""
+ user = models.User.objects.get(id=user_id)
+ stream = streams[stream]
+ stream.populate_streams(user)
+
+
+@app.task(queue="medium_priority")
+def remove_status_task(status_ids):
+ """remove a status from any stream it might be in"""
+ # this can take an id or a list of ids
+ if not isinstance(status_ids, list):
+ status_ids = [status_ids]
+ statuses = models.Status.objects.filter(id__in=status_ids)
+
for stream in streams.values():
- stream.populate_streams(instance)
+ for status in statuses:
+ stream.remove_object_from_related_stores(status)
+
+
+@app.task(queue="high_priority")
+def add_status_task(status_id, increment_unread=False):
+ """add a status to any stream it should be in"""
+ status = models.Status.objects.get(id=status_id)
+ # we don't want to tick the unread count for csv import statuses, idk how better
+ # to check than just to see if the states is more than a few days old
+ if status.created_date < timezone.now() - timedelta(days=2):
+ increment_unread = False
+ for stream in streams.values():
+ stream.add_status(status, increment_unread=increment_unread)
+
+
+@app.task(queue="medium_priority")
+def remove_user_statuses_task(viewer_id, user_id, stream_list=None):
+ """remove all statuses by a user from a viewer's stream"""
+ stream_list = [streams[s] for s in stream_list] if stream_list else streams.values()
+ viewer = models.User.objects.get(id=viewer_id)
+ user = models.User.objects.get(id=user_id)
+ for stream in stream_list:
+ stream.remove_user_statuses(viewer, user)
+
+
+@app.task(queue="medium_priority")
+def add_user_statuses_task(viewer_id, user_id, stream_list=None):
+ """add all statuses by a user to a viewer's stream"""
+ stream_list = [streams[s] for s in stream_list] if stream_list else streams.values()
+ viewer = models.User.objects.get(id=viewer_id)
+ user = models.User.objects.get(id=user_id)
+ for stream in stream_list:
+ stream.add_user_statuses(viewer, user)
+
+
+@app.task(queue="medium_priority")
+def handle_boost_task(boost_id):
+ """remove the original post and other, earlier boosts"""
+ instance = models.Status.objects.get(id=boost_id)
+ boosted = instance.boost.boosted_status
+
+ # previous boosts of this status
+ old_versions = models.Boost.objects.filter(
+ boosted_status__id=boosted.id,
+ created_date__lt=instance.created_date,
+ )
+
+ for stream in streams.values():
+ # people who should see the boost (not people who see the original status)
+ audience = stream.get_stores_for_object(instance)
+ stream.remove_object_from_related_stores(boosted, stores=audience)
+ for status in old_versions:
+ stream.remove_object_from_related_stores(status, stores=audience)
diff --git a/bookwyrm/book_search.py b/bookwyrm/book_search.py
new file mode 100644
index 00000000..6c89b61f
--- /dev/null
+++ b/bookwyrm/book_search.py
@@ -0,0 +1,156 @@
+""" using a bookwyrm instance as a source of book data """
+from dataclasses import asdict, dataclass
+from functools import reduce
+import operator
+
+from django.contrib.postgres.search import SearchRank, SearchQuery
+from django.db.models import OuterRef, Subquery, F, Q
+
+from bookwyrm import models
+from bookwyrm.settings import MEDIA_FULL_URL
+
+
+# pylint: disable=arguments-differ
+def search(query, min_confidence=0, filters=None, return_first=False):
+ """search your local database"""
+ filters = filters or []
+ if not query:
+ return []
+ # first, try searching unqiue identifiers
+ results = search_identifiers(query, *filters, return_first=return_first)
+ if not results:
+ # then try searching title/author
+ results = search_title_author(
+ query, min_confidence, *filters, return_first=return_first
+ )
+ return results
+
+
+def isbn_search(query):
+ """search your local database"""
+ if not query:
+ return []
+
+ filters = [{f: query} for f in ["isbn_10", "isbn_13"]]
+ results = models.Edition.objects.filter(
+ reduce(operator.or_, (Q(**f) for f in filters))
+ ).distinct()
+
+ # when there are multiple editions of the same work, pick the default.
+ # it would be odd for this to happen.
+
+ default_editions = models.Edition.objects.filter(
+ parent_work=OuterRef("parent_work")
+ ).order_by("-edition_rank")
+ results = (
+ results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter(
+ default_id=F("id")
+ )
+ or results
+ )
+ return results
+
+
+def format_search_result(search_result):
+ """convert a book object into a search result object"""
+ cover = None
+ if search_result.cover:
+ cover = f"{MEDIA_FULL_URL}{search_result.cover}"
+
+ return SearchResult(
+ title=search_result.title,
+ key=search_result.remote_id,
+ author=search_result.author_text,
+ year=search_result.published_date.year
+ if search_result.published_date
+ else None,
+ cover=cover,
+ confidence=search_result.rank if hasattr(search_result, "rank") else 1,
+ connector="",
+ ).json()
+
+
+def search_identifiers(query, *filters, return_first=False):
+ """tries remote_id, isbn; defined as dedupe fields on the model"""
+ # pylint: disable=W0212
+ or_filters = [
+ {f.name: query}
+ for f in models.Edition._meta.get_fields()
+ if hasattr(f, "deduplication_field") and f.deduplication_field
+ ]
+ results = models.Edition.objects.filter(
+ *filters, reduce(operator.or_, (Q(**f) for f in or_filters))
+ ).distinct()
+ if results.count() <= 1:
+ return results
+
+ # when there are multiple editions of the same work, pick the default.
+ # it would be odd for this to happen.
+ default_editions = models.Edition.objects.filter(
+ parent_work=OuterRef("parent_work")
+ ).order_by("-edition_rank")
+ results = (
+ results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter(
+ default_id=F("id")
+ )
+ or results
+ )
+ if return_first:
+ return results.first()
+ return results
+
+
+def search_title_author(query, min_confidence, *filters, return_first=False):
+ """searches for title and author"""
+ query = SearchQuery(query, config="simple") | SearchQuery(query, config="english")
+ results = (
+ models.Edition.objects.filter(*filters, search_vector=query)
+ .annotate(rank=SearchRank(F("search_vector"), query))
+ .filter(rank__gt=min_confidence)
+ .order_by("-rank")
+ )
+
+ # when there are multiple editions of the same work, pick the closest
+ editions_of_work = results.values("parent_work__id").values_list("parent_work__id")
+
+ # filter out multiple editions of the same work
+ list_results = []
+ for work_id in set(editions_of_work):
+ editions = results.filter(parent_work=work_id)
+ default = editions.order_by("-edition_rank").first()
+ default_rank = default.rank if default else 0
+ # if mutliple books have the top rank, pick the default edition
+ if default_rank == editions.first().rank:
+ result = default
+ else:
+ result = editions.first()
+ if return_first:
+ return result
+ list_results.append(result)
+ return list_results
+
+
+@dataclass
+class SearchResult:
+ """standardized search result object"""
+
+ title: str
+ key: str
+ connector: object
+ view_link: str = None
+ author: str = None
+ year: str = None
+ cover: str = None
+ confidence: int = 1
+
+ def __repr__(self):
+ # pylint: disable=consider-using-f-string
+ return "".format(
+ self.key, self.title, self.author
+ )
+
+ def json(self):
+ """serialize a connector for json response"""
+ serialized = asdict(self)
+ del serialized["connector"]
+ return serialized
diff --git a/bookwyrm/connectors/__init__.py b/bookwyrm/connectors/__init__.py
index 689f2701..efbdb166 100644
--- a/bookwyrm/connectors/__init__.py
+++ b/bookwyrm/connectors/__init__.py
@@ -3,4 +3,4 @@ from .settings import CONNECTORS
from .abstract_connector import ConnectorException
from .abstract_connector import get_data, get_image
-from .connector_manager import search, local_search, first_search_result
+from .connector_manager import search, first_search_result
diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py
index fb102ea4..c032986d 100644
--- a/bookwyrm/connectors/abstract_connector.py
+++ b/bookwyrm/connectors/abstract_connector.py
@@ -1,6 +1,5 @@
""" functionality outline for a book data connector """
from abc import ABC, abstractmethod
-from dataclasses import asdict, dataclass
import logging
from django.db import transaction
@@ -9,6 +8,7 @@ from requests.exceptions import RequestException
from bookwyrm import activitypub, models, settings
from .connector_manager import load_more_data, ConnectorException
+from .format_mappings import format_mappings
logger = logging.getLogger(__name__)
@@ -31,7 +31,6 @@ class AbstractMinimalConnector(ABC):
"isbn_search_url",
"name",
"identifier",
- "local",
]
for field in self_fields:
setattr(self, field, getattr(info, field))
@@ -43,7 +42,7 @@ class AbstractMinimalConnector(ABC):
params["min_confidence"] = min_confidence
data = self.get_search_data(
- "%s%s" % (self.search_url, query),
+ f"{self.search_url}{query}",
params=params,
timeout=timeout,
)
@@ -57,7 +56,7 @@ class AbstractMinimalConnector(ABC):
"""isbn search"""
params = {}
data = self.get_search_data(
- "%s%s" % (self.isbn_search_url, query),
+ f"{self.isbn_search_url}{query}",
params=params,
)
results = []
@@ -131,7 +130,7 @@ class AbstractConnector(AbstractMinimalConnector):
work_data = data
if not work_data or not edition_data:
- raise ConnectorException("Unable to load book data: %s" % remote_id)
+ raise ConnectorException(f"Unable to load book data: {remote_id}")
with transaction.atomic():
# create activitypub object
@@ -139,7 +138,7 @@ class AbstractConnector(AbstractMinimalConnector):
**dict_from_mappings(work_data, self.book_mappings)
)
# this will dedupe automatically
- work = work_activity.to_model(model=models.Work)
+ work = work_activity.to_model(model=models.Work, overwrite=False)
for author in self.get_authors_from_data(work_data):
work.authors.add(author)
@@ -156,7 +155,7 @@ class AbstractConnector(AbstractMinimalConnector):
mapped_data = dict_from_mappings(edition_data, self.book_mappings)
mapped_data["work"] = work.remote_id
edition_activity = activitypub.Edition(**mapped_data)
- edition = edition_activity.to_model(model=models.Edition)
+ edition = edition_activity.to_model(model=models.Edition, overwrite=False)
edition.connector = self.connector
edition.save()
@@ -182,7 +181,7 @@ class AbstractConnector(AbstractMinimalConnector):
return None
# this will dedupe
- return activity.to_model(model=models.Author)
+ return activity.to_model(model=models.Author, overwrite=False)
@abstractmethod
def is_work_data(self, data):
@@ -222,9 +221,7 @@ def get_data(url, params=None, timeout=10):
"""wrapper for request.get"""
# check if the url is blocked
if models.FederatedServer.is_blocked(url):
- raise ConnectorException(
- "Attempting to load data from blocked url: {:s}".format(url)
- )
+ raise ConnectorException(f"Attempting to load data from blocked url: {url}")
try:
resp = requests.get(
@@ -269,31 +266,6 @@ def get_image(url, timeout=10):
return resp
-@dataclass
-class SearchResult:
- """standardized search result object"""
-
- title: str
- key: str
- connector: object
- view_link: str = None
- author: str = None
- year: str = None
- cover: str = None
- confidence: int = 1
-
- def __repr__(self):
- return "".format(
- self.key, self.title, self.author
- )
-
- def json(self):
- """serialize a connector for json response"""
- serialized = asdict(self)
- del serialized["connector"]
- return serialized
-
-
class Mapping:
"""associate a local database field with a field in an external dataset"""
@@ -313,3 +285,25 @@ class Mapping:
return self.formatter(value)
except: # pylint: disable=bare-except
return None
+
+
+def infer_physical_format(format_text):
+ """try to figure out what the standardized format is from the free value"""
+ format_text = format_text.lower()
+ if format_text in format_mappings:
+ # try a direct match
+ return format_mappings[format_text]
+ # failing that, try substring
+ matches = [v for k, v in format_mappings.items() if k in format_text]
+ if not matches:
+ return None
+ return matches[0]
+
+
+def unique_physical_format(format_text):
+ """only store the format if it isn't diretly in the format mappings"""
+ format_text = format_text.lower()
+ if format_text in format_mappings:
+ # try a direct match, so saving this would be redundant
+ return None
+ return format_text
diff --git a/bookwyrm/connectors/bookwyrm_connector.py b/bookwyrm/connectors/bookwyrm_connector.py
index 10a633b2..6dcba7c3 100644
--- a/bookwyrm/connectors/bookwyrm_connector.py
+++ b/bookwyrm/connectors/bookwyrm_connector.py
@@ -1,6 +1,7 @@
""" using another bookwyrm instance as a source of book data """
from bookwyrm import activitypub, models
-from .abstract_connector import AbstractMinimalConnector, SearchResult
+from bookwyrm.book_search import SearchResult
+from .abstract_connector import AbstractMinimalConnector
class Connector(AbstractMinimalConnector):
diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py
index 1a615c9b..45530cd6 100644
--- a/bookwyrm/connectors/connector_manager.py
+++ b/bookwyrm/connectors/connector_manager.py
@@ -10,7 +10,7 @@ from django.db.models import signals
from requests import HTTPError
-from bookwyrm import models
+from bookwyrm import book_search, models
from bookwyrm.tasks import app
logger = logging.getLogger(__name__)
@@ -55,7 +55,7 @@ def search(query, min_confidence=0.1, return_first=False):
# if we found anything, return it
return result_set[0]
- if result_set or connector.local:
+ if result_set:
results.append(
{
"connector": connector,
@@ -71,22 +71,13 @@ def search(query, min_confidence=0.1, return_first=False):
return results
-def local_search(query, min_confidence=0.1, raw=False, filters=None):
- """only look at local search results"""
- connector = load_connector(models.Connector.objects.get(local=True))
- return connector.search(
- query, min_confidence=min_confidence, raw=raw, filters=filters
- )
-
-
-def isbn_local_search(query, raw=False):
- """only look at local search results"""
- connector = load_connector(models.Connector.objects.get(local=True))
- return connector.isbn_search(query, raw=raw)
-
-
def first_search_result(query, min_confidence=0.1):
"""search until you find a result that fits"""
+ # try local search first
+ result = book_search.search(query, min_confidence=min_confidence, return_first=True)
+ if result:
+ return result
+ # otherwise, try remote endpoints
return search(query, min_confidence=min_confidence, return_first=True) or None
@@ -109,17 +100,17 @@ def get_or_create_connector(remote_id):
connector_info = models.Connector.objects.create(
identifier=identifier,
connector_file="bookwyrm_connector",
- base_url="https://%s" % identifier,
- books_url="https://%s/book" % identifier,
- covers_url="https://%s/images/covers" % identifier,
- search_url="https://%s/search?q=" % identifier,
+ base_url=f"https://{identifier}",
+ books_url=f"https://{identifier}/book",
+ covers_url=f"https://{identifier}/images/covers",
+ search_url=f"https://{identifier}/search?q=",
priority=2,
)
return load_connector(connector_info)
-@app.task
+@app.task(queue="low_priority")
def load_more_data(connector_id, book_id):
"""background the work of getting all 10,000 editions of LoTR"""
connector_info = models.Connector.objects.get(id=connector_id)
@@ -131,7 +122,7 @@ def load_more_data(connector_id, book_id):
def load_connector(connector_info):
"""instantiate the connector class"""
connector = importlib.import_module(
- "bookwyrm.connectors.%s" % connector_info.connector_file
+ f"bookwyrm.connectors.{connector_info.connector_file}"
)
return connector.Connector(connector_info.identifier)
@@ -141,4 +132,4 @@ def load_connector(connector_info):
def create_connector(sender, instance, created, *args, **kwargs):
"""create a connector to an external bookwyrm server"""
if instance.application_type == "bookwyrm":
- get_or_create_connector("https://{:s}".format(instance.server_name))
+ get_or_create_connector(f"https://{instance.server_name}")
diff --git a/bookwyrm/connectors/format_mappings.py b/bookwyrm/connectors/format_mappings.py
new file mode 100644
index 00000000..61f61efa
--- /dev/null
+++ b/bookwyrm/connectors/format_mappings.py
@@ -0,0 +1,43 @@
+""" comparing a free text format to the standardized one """
+format_mappings = {
+ "paperback": "Paperback",
+ "soft": "Paperback",
+ "pamphlet": "Paperback",
+ "peperback": "Paperback",
+ "tapa blanda": "Paperback",
+ "turtleback": "Paperback",
+ "pocket": "Paperback",
+ "spiral": "Paperback",
+ "ring": "Paperback",
+ "平装": "Paperback",
+ "简装": "Paperback",
+ "hardcover": "Hardcover",
+ "hardcocer": "Hardcover",
+ "hardover": "Hardcover",
+ "hardback": "Hardcover",
+ "library": "Hardcover",
+ "tapa dura": "Hardcover",
+ "leather": "Hardcover",
+ "clothbound": "Hardcover",
+ "精装": "Hardcover",
+ "ebook": "EBook",
+ "e-book": "EBook",
+ "digital": "EBook",
+ "computer file": "EBook",
+ "epub": "EBook",
+ "online": "EBook",
+ "pdf": "EBook",
+ "elektronische": "EBook",
+ "electronic": "EBook",
+ "audiobook": "AudiobookFormat",
+ "audio": "AudiobookFormat",
+ "cd": "AudiobookFormat",
+ "dvd": "AudiobookFormat",
+ "mp3": "AudiobookFormat",
+ "cassette": "AudiobookFormat",
+ "kindle": "AudiobookFormat",
+ "talking": "AudiobookFormat",
+ "sound": "AudiobookFormat",
+ "comic": "GraphicNovel",
+ "graphic": "GraphicNovel",
+}
diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py
index 116aa5c1..faed5429 100644
--- a/bookwyrm/connectors/inventaire.py
+++ b/bookwyrm/connectors/inventaire.py
@@ -2,13 +2,14 @@
import re
from bookwyrm import models
-from .abstract_connector import AbstractConnector, SearchResult, Mapping
+from bookwyrm.book_search import SearchResult
+from .abstract_connector import AbstractConnector, Mapping
from .abstract_connector import get_data
from .connector_manager import ConnectorException
class Connector(AbstractConnector):
- """instantiate a connector for OL"""
+ """instantiate a connector for inventaire"""
def __init__(self, identifier):
super().__init__(identifier)
@@ -59,7 +60,7 @@ class Connector(AbstractConnector):
def get_remote_id(self, value):
"""convert an id/uri into a url"""
- return "{:s}?action=by-uris&uris={:s}".format(self.books_url, value)
+ return f"{self.books_url}?action=by-uris&uris={value}"
def get_book_data(self, remote_id):
data = get_data(remote_id)
@@ -71,7 +72,7 @@ class Connector(AbstractConnector):
# flatten the data so that images, uri, and claims are on the same level
return {
**data.get("claims", {}),
- **{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks"]},
+ **{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks", "type"]},
}
def search(self, query, min_confidence=None): # pylint: disable=arguments-differ
@@ -87,11 +88,7 @@ class Connector(AbstractConnector):
def format_search_result(self, search_result):
images = search_result.get("image")
- cover = (
- "{:s}/img/entities/{:s}".format(self.covers_url, images[0])
- if images
- else None
- )
+ cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None
# a deeply messy translation of inventaire's scores
confidence = float(search_result.get("_score", 0.1))
confidence = 0.1 if confidence < 150 else 0.999
@@ -99,9 +96,7 @@ class Connector(AbstractConnector):
title=search_result.get("label"),
key=self.get_remote_id(search_result.get("uri")),
author=search_result.get("description"),
- view_link="{:s}/entity/{:s}".format(
- self.base_url, search_result.get("uri")
- ),
+ view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
cover=cover,
confidence=confidence,
connector=self,
@@ -123,9 +118,7 @@ class Connector(AbstractConnector):
title=title[0],
key=self.get_remote_id(search_result.get("uri")),
author=search_result.get("description"),
- view_link="{:s}/entity/{:s}".format(
- self.base_url, search_result.get("uri")
- ),
+ view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
cover=self.get_cover_url(search_result.get("image")),
connector=self,
)
@@ -135,18 +128,14 @@ class Connector(AbstractConnector):
def load_edition_data(self, work_uri):
"""get a list of editions for a work"""
- url = (
- "{:s}?action=reverse-claims&property=wdt:P629&value={:s}&sort=true".format(
- self.books_url, work_uri
- )
- )
+ url = f"{self.books_url}?action=reverse-claims&property=wdt:P629&value={work_uri}&sort=true"
return get_data(url)
def get_edition_from_work_data(self, data):
data = self.load_edition_data(data.get("uri"))
try:
- uri = data["uris"][0]
- except KeyError:
+ uri = data.get("uris", [])[0]
+ except IndexError:
raise ConnectorException("Invalid book data")
return self.get_book_data(self.get_remote_id(uri))
@@ -195,7 +184,7 @@ class Connector(AbstractConnector):
# cover may or may not be an absolute url already
if re.match(r"^http", cover_id):
return cover_id
- return "%s%s" % (self.covers_url, cover_id)
+ return f"{self.covers_url}{cover_id}"
def resolve_keys(self, keys):
"""cool, it's "wd:Q3156592" now what the heck does that mean"""
@@ -213,9 +202,7 @@ class Connector(AbstractConnector):
link = links.get("enwiki")
if not link:
return ""
- url = "{:s}/api/data?action=wp-extract&lang=en&title={:s}".format(
- self.base_url, link
- )
+ url = f"{self.base_url}/api/data?action=wp-extract&lang=en&title={link}"
try:
data = get_data(url)
except ConnectorException:
diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py
index e58749c1..b8afc7ca 100644
--- a/bookwyrm/connectors/openlibrary.py
+++ b/bookwyrm/connectors/openlibrary.py
@@ -2,8 +2,9 @@
import re
from bookwyrm import models
-from .abstract_connector import AbstractConnector, SearchResult, Mapping
-from .abstract_connector import get_data
+from bookwyrm.book_search import SearchResult
+from .abstract_connector import AbstractConnector, Mapping
+from .abstract_connector import get_data, infer_physical_format, unique_physical_format
from .connector_manager import ConnectorException
from .openlibrary_languages import languages
@@ -43,7 +44,16 @@ class Connector(AbstractConnector):
),
Mapping("publishedDate", remote_field="publish_date"),
Mapping("pages", remote_field="number_of_pages"),
- Mapping("physicalFormat", remote_field="physical_format"),
+ Mapping(
+ "physicalFormat",
+ remote_field="physical_format",
+ formatter=infer_physical_format,
+ ),
+ Mapping(
+ "physicalFormatDetail",
+ remote_field="physical_format",
+ formatter=unique_physical_format,
+ ),
Mapping("publishers"),
]
@@ -71,7 +81,7 @@ class Connector(AbstractConnector):
key = data["key"]
except KeyError:
raise ConnectorException("Invalid book data")
- return "%s%s" % (self.books_url, key)
+ return f"{self.books_url}{key}"
def is_work_data(self, data):
return bool(re.match(r"^[\/\w]+OL\d+W$", data["key"]))
@@ -81,7 +91,7 @@ class Connector(AbstractConnector):
key = data["key"]
except KeyError:
raise ConnectorException("Invalid book data")
- url = "%s%s/editions" % (self.books_url, key)
+ url = f"{self.books_url}{key}/editions"
data = self.get_book_data(url)
edition = pick_default_edition(data["entries"])
if not edition:
@@ -93,7 +103,7 @@ class Connector(AbstractConnector):
key = data["works"][0]["key"]
except (IndexError, KeyError):
raise ConnectorException("No work found for edition")
- url = "%s%s" % (self.books_url, key)
+ url = f"{self.books_url}{key}"
return self.get_book_data(url)
def get_authors_from_data(self, data):
@@ -102,7 +112,7 @@ class Connector(AbstractConnector):
author_blob = author_blob.get("author", author_blob)
# this id is "/authors/OL1234567A"
author_id = author_blob["key"]
- url = "%s%s" % (self.base_url, author_id)
+ url = f"{self.base_url}{author_id}"
author = self.get_or_create_author(url)
if not author:
continue
@@ -113,8 +123,8 @@ class Connector(AbstractConnector):
if not cover_blob:
return None
cover_id = cover_blob[0]
- image_name = "%s-%s.jpg" % (cover_id, size)
- return "%s/b/id/%s" % (self.covers_url, image_name)
+ image_name = f"{cover_id}-{size}.jpg"
+ return f"{self.covers_url}/b/id/{image_name}"
def parse_search_data(self, data):
return data.get("docs")
@@ -152,7 +162,7 @@ class Connector(AbstractConnector):
def load_edition_data(self, olkey):
"""query openlibrary for editions of a work"""
- url = "%s/works/%s/editions" % (self.books_url, olkey)
+ url = f"{self.books_url}/works/{olkey}/editions"
return self.get_book_data(url)
def expand_book_data(self, book):
diff --git a/bookwyrm/connectors/self_connector.py b/bookwyrm/connectors/self_connector.py
deleted file mode 100644
index 8d5a7614..00000000
--- a/bookwyrm/connectors/self_connector.py
+++ /dev/null
@@ -1,164 +0,0 @@
-""" using a bookwyrm instance as a source of book data """
-from functools import reduce
-import operator
-
-from django.contrib.postgres.search import SearchRank, SearchQuery
-from django.db.models import OuterRef, Subquery, F, Q
-
-from bookwyrm import models
-from .abstract_connector import AbstractConnector, SearchResult
-
-
-class Connector(AbstractConnector):
- """instantiate a connector"""
-
- # pylint: disable=arguments-differ
- def search(self, query, min_confidence=0, raw=False, filters=None):
- """search your local database"""
- filters = filters or []
- if not query:
- return []
- # first, try searching unqiue identifiers
- results = search_identifiers(query, *filters)
- if not results:
- # then try searching title/author
- results = search_title_author(query, min_confidence, *filters)
- search_results = []
- for result in results:
- if raw:
- search_results.append(result)
- else:
- search_results.append(self.format_search_result(result))
- if len(search_results) >= 10:
- break
- if not raw:
- search_results.sort(key=lambda r: r.confidence, reverse=True)
- return search_results
-
- def isbn_search(self, query, raw=False):
- """search your local database"""
- if not query:
- return []
-
- filters = [{f: query} for f in ["isbn_10", "isbn_13"]]
- results = models.Edition.objects.filter(
- reduce(operator.or_, (Q(**f) for f in filters))
- ).distinct()
-
- # when there are multiple editions of the same work, pick the default.
- # it would be odd for this to happen.
-
- default_editions = models.Edition.objects.filter(
- parent_work=OuterRef("parent_work")
- ).order_by("-edition_rank")
- results = (
- results.annotate(
- default_id=Subquery(default_editions.values("id")[:1])
- ).filter(default_id=F("id"))
- or results
- )
-
- search_results = []
- for result in results:
- if raw:
- search_results.append(result)
- else:
- search_results.append(self.format_search_result(result))
- if len(search_results) >= 10:
- break
- return search_results
-
- def format_search_result(self, search_result):
- cover = None
- if search_result.cover:
- cover = "%s%s" % (self.covers_url, search_result.cover)
-
- return SearchResult(
- title=search_result.title,
- key=search_result.remote_id,
- author=search_result.author_text,
- year=search_result.published_date.year
- if search_result.published_date
- else None,
- connector=self,
- cover=cover,
- confidence=search_result.rank if hasattr(search_result, "rank") else 1,
- )
-
- def format_isbn_search_result(self, search_result):
- return self.format_search_result(search_result)
-
- def is_work_data(self, data):
- pass
-
- def get_edition_from_work_data(self, data):
- pass
-
- def get_work_from_edition_data(self, data):
- pass
-
- def get_authors_from_data(self, data):
- return None
-
- def parse_isbn_search_data(self, data):
- """it's already in the right format, don't even worry about it"""
- return data
-
- def parse_search_data(self, data):
- """it's already in the right format, don't even worry about it"""
- return data
-
- def expand_book_data(self, book):
- pass
-
-
-def search_identifiers(query, *filters):
- """tries remote_id, isbn; defined as dedupe fields on the model"""
- # pylint: disable=W0212
- or_filters = [
- {f.name: query}
- for f in models.Edition._meta.get_fields()
- if hasattr(f, "deduplication_field") and f.deduplication_field
- ]
- results = models.Edition.objects.filter(
- *filters, reduce(operator.or_, (Q(**f) for f in or_filters))
- ).distinct()
- if results.count() <= 1:
- return results
-
- # when there are multiple editions of the same work, pick the default.
- # it would be odd for this to happen.
- default_editions = models.Edition.objects.filter(
- parent_work=OuterRef("parent_work")
- ).order_by("-edition_rank")
- return (
- results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter(
- default_id=F("id")
- )
- or results
- )
-
-
-def search_title_author(query, min_confidence, *filters):
- """searches for title and author"""
- query = SearchQuery(query, config="simple") | SearchQuery(query, config="english")
- results = (
- models.Edition.objects.filter(*filters, search_vector=query)
- .annotate(rank=SearchRank(F("search_vector"), query))
- .filter(rank__gt=min_confidence)
- .order_by("-rank")
- )
-
- # when there are multiple editions of the same work, pick the closest
- editions_of_work = results.values("parent_work__id").values_list("parent_work__id")
-
- # filter out multiple editions of the same work
- for work_id in set(editions_of_work):
- editions = results.filter(parent_work=work_id)
- default = editions.order_by("-edition_rank").first()
- default_rank = default.rank if default else 0
- # if mutliple books have the top rank, pick the default edition
- if default_rank == editions.first().rank:
- yield default
- else:
- yield editions.first()
diff --git a/bookwyrm/connectors/settings.py b/bookwyrm/connectors/settings.py
index 4cc98da7..927e39b2 100644
--- a/bookwyrm/connectors/settings.py
+++ b/bookwyrm/connectors/settings.py
@@ -1,3 +1,3 @@
""" settings book data connectors """
-CONNECTORS = ["openlibrary", "inventaire", "self_connector", "bookwyrm_connector"]
+CONNECTORS = ["openlibrary", "inventaire", "bookwyrm_connector"]
diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py
index 1f0387fe..309e84ed 100644
--- a/bookwyrm/context_processors.py
+++ b/bookwyrm/context_processors.py
@@ -11,7 +11,9 @@ def site_settings(request): # pylint: disable=unused-argument
return {
"site": models.SiteSettings.objects.get(),
"active_announcements": models.Announcement.active_announcements(),
+ "thumbnail_generation_enabled": settings.ENABLE_THUMBNAIL_GENERATION,
"media_full_url": settings.MEDIA_FULL_URL,
"preview_images_enabled": settings.ENABLE_PREVIEW_IMAGES,
"request_protocol": request_protocol,
+ "js_cache": settings.JS_CACHE,
}
diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py
index 657310b0..c6a197f2 100644
--- a/bookwyrm/emailing.py
+++ b/bookwyrm/emailing.py
@@ -11,7 +11,7 @@ def email_data():
"""fields every email needs"""
site = models.SiteSettings.objects.get()
if site.logo_small:
- logo_path = "/images/{}".format(site.logo_small.url)
+ logo_path = f"/images/{site.logo_small.url}"
else:
logo_path = "/static/images/logo-small.png"
@@ -23,6 +23,14 @@ def email_data():
}
+def email_confirmation_email(user):
+ """newly registered users confirm email address"""
+ data = email_data()
+ data["confirmation_code"] = user.confirmation_code
+ data["confirmation_link"] = user.confirmation_link
+ send_email.delay(user.email, *format_email("confirm", data))
+
+
def invite_email(invite_request):
"""send out an invite code"""
data = email_data()
@@ -40,23 +48,17 @@ def password_reset_email(reset_code):
def format_email(email_name, data):
"""render the email templates"""
- subject = (
- get_template("email/{}/subject.html".format(email_name)).render(data).strip()
- )
+ subject = get_template(f"email/{email_name}/subject.html").render(data).strip()
html_content = (
- get_template("email/{}/html_content.html".format(email_name))
- .render(data)
- .strip()
+ get_template(f"email/{email_name}/html_content.html").render(data).strip()
)
text_content = (
- get_template("email/{}/text_content.html".format(email_name))
- .render(data)
- .strip()
+ get_template(f"email/{email_name}/text_content.html").render(data).strip()
)
return (subject, html_content, text_content)
-@app.task
+@app.task(queue="high_priority")
def send_email(recipient, subject, html_content, text_content):
"""use a task to send the email"""
email = EmailMultiAlternatives(
diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py
index 57a94e3c..298f73da 100644
--- a/bookwyrm/forms.py
+++ b/bookwyrm/forms.py
@@ -29,8 +29,7 @@ class CustomForm(ModelForm):
input_type = visible.field.widget.input_type
if isinstance(visible.field.widget, Textarea):
input_type = "textarea"
- visible.field.widget.attrs["cols"] = None
- visible.field.widget.attrs["rows"] = None
+ visible.field.widget.attrs["rows"] = 5
visible.field.widget.attrs["class"] = css_classes[input_type]
@@ -86,6 +85,7 @@ class CommentForm(CustomForm):
"privacy",
"progress",
"progress_mode",
+ "reading_status",
]
@@ -100,6 +100,8 @@ class QuotationForm(CustomForm):
"content_warning",
"sensitive",
"privacy",
+ "position",
+ "position_mode",
]
@@ -122,6 +124,12 @@ class StatusForm(CustomForm):
fields = ["user", "content", "content_warning", "sensitive", "privacy"]
+class DirectForm(CustomForm):
+ class Meta:
+ model = models.Status
+ fields = ["user", "content", "content_warning", "sensitive", "privacy"]
+
+
class EditUserForm(CustomForm):
class Meta:
model = models.User
@@ -131,9 +139,12 @@ class EditUserForm(CustomForm):
"email",
"summary",
"show_goal",
+ "show_suggested_users",
"manually_approves_followers",
+ "default_post_privacy",
"discoverable",
"preferred_timezone",
+ "preferred_language",
]
help_texts = {f: None for f in fields}
@@ -217,7 +228,7 @@ class ExpiryWidget(widgets.Select):
elif selected_string == "forever":
return None
else:
- return selected_string # "This will raise
+ return selected_string # This will raise
return timezone.now() + interval
@@ -249,10 +260,7 @@ class CreateInviteForm(CustomForm):
]
),
"use_limit": widgets.Select(
- choices=[
- (i, _("%(count)d uses" % {"count": i}))
- for i in [1, 5, 10, 25, 50, 100]
- ]
+ choices=[(i, _(f"{i} uses")) for i in [1, 5, 10, 25, 50, 100]]
+ [(None, _("Unlimited"))]
),
}
@@ -261,7 +269,7 @@ class CreateInviteForm(CustomForm):
class ShelfForm(CustomForm):
class Meta:
model = models.Shelf
- fields = ["user", "name", "privacy"]
+ fields = ["user", "name", "privacy", "description"]
class GoalForm(CustomForm):
@@ -285,7 +293,13 @@ class AnnouncementForm(CustomForm):
class ListForm(CustomForm):
class Meta:
model = models.List
- fields = ["user", "name", "description", "curation", "privacy"]
+ fields = ["user", "name", "description", "curation", "privacy", "group"]
+
+
+class GroupForm(CustomForm):
+ class Meta:
+ model = models.Group
+ fields = ["user", "privacy", "name", "description"]
class ReportForm(CustomForm):
@@ -294,6 +308,18 @@ class ReportForm(CustomForm):
fields = ["user", "reporter", "statuses", "note"]
+class EmailBlocklistForm(CustomForm):
+ class Meta:
+ model = models.EmailBlocklist
+ fields = ["domain"]
+
+
+class IPBlocklistForm(CustomForm):
+ class Meta:
+ model = models.IPBlocklist
+ fields = ["address"]
+
+
class ServerForm(CustomForm):
class Meta:
model = models.FederatedServer
diff --git a/bookwyrm/imagegenerators.py b/bookwyrm/imagegenerators.py
new file mode 100644
index 00000000..1d065192
--- /dev/null
+++ b/bookwyrm/imagegenerators.py
@@ -0,0 +1,113 @@
+"""Generators for all the different thumbnail sizes"""
+from imagekit import ImageSpec, register
+from imagekit.processors import ResizeToFit
+
+
+class BookXSmallWebp(ImageSpec):
+ """Handles XSmall size in Webp format"""
+
+ processors = [ResizeToFit(80, 80)]
+ format = "WEBP"
+ options = {"quality": 95}
+
+
+class BookXSmallJpg(ImageSpec):
+ """Handles XSmall size in Jpeg format"""
+
+ processors = [ResizeToFit(80, 80)]
+ format = "JPEG"
+ options = {"quality": 95}
+
+
+class BookSmallWebp(ImageSpec):
+ """Handles Small size in Webp format"""
+
+ processors = [ResizeToFit(100, 100)]
+ format = "WEBP"
+ options = {"quality": 95}
+
+
+class BookSmallJpg(ImageSpec):
+ """Handles Small size in Jpeg format"""
+
+ processors = [ResizeToFit(100, 100)]
+ format = "JPEG"
+ options = {"quality": 95}
+
+
+class BookMediumWebp(ImageSpec):
+ """Handles Medium size in Webp format"""
+
+ processors = [ResizeToFit(150, 150)]
+ format = "WEBP"
+ options = {"quality": 95}
+
+
+class BookMediumJpg(ImageSpec):
+ """Handles Medium size in Jpeg format"""
+
+ processors = [ResizeToFit(150, 150)]
+ format = "JPEG"
+ options = {"quality": 95}
+
+
+class BookLargeWebp(ImageSpec):
+ """Handles Large size in Webp format"""
+
+ processors = [ResizeToFit(200, 200)]
+ format = "WEBP"
+ options = {"quality": 95}
+
+
+class BookLargeJpg(ImageSpec):
+ """Handles Large size in Jpeg format"""
+
+ processors = [ResizeToFit(200, 200)]
+ format = "JPEG"
+ options = {"quality": 95}
+
+
+class BookXLargeWebp(ImageSpec):
+ """Handles XLarge size in Webp format"""
+
+ processors = [ResizeToFit(250, 250)]
+ format = "WEBP"
+ options = {"quality": 95}
+
+
+class BookXLargeJpg(ImageSpec):
+ """Handles XLarge size in Jpeg format"""
+
+ processors = [ResizeToFit(250, 250)]
+ format = "JPEG"
+ options = {"quality": 95}
+
+
+class BookXxLargeWebp(ImageSpec):
+ """Handles XxLarge size in Webp format"""
+
+ processors = [ResizeToFit(500, 500)]
+ format = "WEBP"
+ options = {"quality": 95}
+
+
+class BookXxLargeJpg(ImageSpec):
+ """Handles XxLarge size in Jpeg format"""
+
+ processors = [ResizeToFit(500, 500)]
+ format = "JPEG"
+ options = {"quality": 95}
+
+
+register.generator("bw:book:xsmall:webp", BookXSmallWebp)
+register.generator("bw:book:xsmall:jpg", BookXSmallJpg)
+register.generator("bw:book:small:webp", BookSmallWebp)
+register.generator("bw:book:small:jpg", BookSmallJpg)
+register.generator("bw:book:medium:webp", BookMediumWebp)
+register.generator("bw:book:medium:jpg", BookMediumJpg)
+register.generator("bw:book:large:webp", BookLargeWebp)
+register.generator("bw:book:large:jpg", BookLargeJpg)
+register.generator("bw:book:xlarge:webp", BookXLargeWebp)
+register.generator("bw:book:xlarge:jpg", BookXLargeJpg)
+register.generator("bw:book:xxlarge:webp", BookXxLargeWebp)
+register.generator("bw:book:xxlarge:jpg", BookXxLargeJpg)
diff --git a/bookwyrm/importers/goodreads_import.py b/bookwyrm/importers/goodreads_import.py
index 7b577ea8..c62e6582 100644
--- a/bookwyrm/importers/goodreads_import.py
+++ b/bookwyrm/importers/goodreads_import.py
@@ -3,10 +3,10 @@ from . import Importer
class GoodreadsImporter(Importer):
- """GoodReads is the default importer, thus Importer follows its structure.
+ """Goodreads is the default importer, thus Importer follows its structure.
For a more complete example of overriding see librarything_import.py"""
- service = "GoodReads"
+ service = "Goodreads"
def parse_fields(self, entry):
"""handle the specific fields in goodreads csvs"""
diff --git a/bookwyrm/importers/importer.py b/bookwyrm/importers/importer.py
index d5f1449c..6d898a2a 100644
--- a/bookwyrm/importers/importer.py
+++ b/bookwyrm/importers/importer.py
@@ -1,8 +1,9 @@
-""" handle reading a csv from an external service, defaults are from GoodReads """
+""" handle reading a csv from an external service, defaults are from Goodreads """
import csv
import logging
from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
from bookwyrm import models
from bookwyrm.models import ImportJob, ImportItem
@@ -61,7 +62,7 @@ class Importer:
job.save()
-@app.task
+@app.task(queue="low_priority")
def import_data(source, job_id):
"""does the actual lookup work in a celery task"""
job = ImportJob.objects.get(id=job_id)
@@ -71,19 +72,20 @@ def import_data(source, job_id):
item.resolve()
except Exception as err: # pylint: disable=broad-except
logger.exception(err)
- item.fail_reason = "Error loading book"
+ item.fail_reason = _("Error loading book")
item.save()
continue
- if item.book:
+ if item.book or item.book_guess:
item.save()
+ if item.book:
# shelves book and handles reviews
handle_imported_book(
source, job.user, item, job.include_reviews, job.privacy
)
else:
- item.fail_reason = "Could not find a match for book"
+ item.fail_reason = _("Could not find a match for book")
item.save()
finally:
job.complete = True
@@ -125,6 +127,7 @@ def handle_imported_book(source, user, item, include_reviews, privacy):
# but "now" is a bad guess
published_date_guess = item.date_read or item.date_added
if item.review:
+ # pylint: disable=consider-using-f-string
review_title = (
"Review of {!r} on {!r}".format(
item.book.title,
diff --git a/bookwyrm/management/commands/initdb.py b/bookwyrm/management/commands/initdb.py
index 71ac511a..d0ab648e 100644
--- a/bookwyrm/management/commands/initdb.py
+++ b/bookwyrm/management/commands/initdb.py
@@ -4,7 +4,6 @@ from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from bookwyrm.models import Connector, FederatedServer, SiteSettings, User
-from bookwyrm.settings import DOMAIN
def init_groups():
@@ -73,19 +72,6 @@ def init_permissions():
def init_connectors():
"""access book data sources"""
- Connector.objects.create(
- identifier=DOMAIN,
- name="Local",
- local=True,
- connector_file="self_connector",
- base_url="https://%s" % DOMAIN,
- books_url="https://%s/book" % DOMAIN,
- covers_url="https://%s/images/" % DOMAIN,
- search_url="https://%s/search?q=" % DOMAIN,
- isbn_search_url="https://%s/isbn/" % DOMAIN,
- priority=1,
- )
-
Connector.objects.create(
identifier="bookwyrm.social",
name="BookWyrm dot Social",
diff --git a/bookwyrm/management/commands/populate_streams.py b/bookwyrm/management/commands/populate_streams.py
index 04f6bf6e..a04d7f5a 100644
--- a/bookwyrm/management/commands/populate_streams.py
+++ b/bookwyrm/management/commands/populate_streams.py
@@ -1,30 +1,37 @@
""" Re-create user streams """
from django.core.management.base import BaseCommand
-import redis
-
-from bookwyrm import activitystreams, models, settings
-
-r = redis.Redis(
- host=settings.REDIS_ACTIVITY_HOST, port=settings.REDIS_ACTIVITY_PORT, db=0
-)
+from bookwyrm import activitystreams, models
-def populate_streams():
+def populate_streams(stream=None):
"""build all the streams for all the users"""
+ streams = [stream] if stream else activitystreams.streams.keys()
+ print("Populations streams", streams)
users = models.User.objects.filter(
local=True,
is_active=True,
- )
+ ).order_by("-last_active_date")
+ print("This may take a long time! Please be patient.")
for user in users:
- for stream in activitystreams.streams.values():
- stream.populate_streams(user)
+ for stream_key in streams:
+ print(".", end="")
+ activitystreams.populate_stream_task.delay(stream_key, user.id)
class Command(BaseCommand):
"""start all over with user streams"""
help = "Populate streams for all users"
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--stream",
+ default=None,
+ help="Specifies which time of stream to populate",
+ )
+
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
"""run feed builder"""
- populate_streams()
+ stream = options.get("stream")
+ populate_streams(stream=stream)
diff --git a/bookwyrm/management/commands/populate_suggestions.py b/bookwyrm/management/commands/populate_suggestions.py
new file mode 100644
index 00000000..32495497
--- /dev/null
+++ b/bookwyrm/management/commands/populate_suggestions.py
@@ -0,0 +1,25 @@
+""" Populate suggested users """
+from django.core.management.base import BaseCommand
+
+from bookwyrm import models
+from bookwyrm.suggested_users import rerank_suggestions_task
+
+
+def populate_suggestions():
+ """build all the streams for all the users"""
+ users = models.User.objects.filter(
+ local=True,
+ is_active=True,
+ ).values_list("id", flat=True)
+ for user in users:
+ rerank_suggestions_task.delay(user)
+
+
+class Command(BaseCommand):
+ """start all over with user suggestions"""
+
+ help = "Populate suggested users for all users"
+ # pylint: disable=no-self-use,unused-argument
+ def handle(self, *args, **options):
+ """run builder"""
+ populate_suggestions()
diff --git a/bookwyrm/middleware/__init__.py b/bookwyrm/middleware/__init__.py
new file mode 100644
index 00000000..03843c5a
--- /dev/null
+++ b/bookwyrm/middleware/__init__.py
@@ -0,0 +1,3 @@
+""" look at all this nice middleware! """
+from .timezone_middleware import TimezoneMiddleware
+from .ip_middleware import IPBlocklistMiddleware
diff --git a/bookwyrm/middleware/ip_middleware.py b/bookwyrm/middleware/ip_middleware.py
new file mode 100644
index 00000000..8063dd1f
--- /dev/null
+++ b/bookwyrm/middleware/ip_middleware.py
@@ -0,0 +1,16 @@
+""" Block IP addresses """
+from django.http import Http404
+from bookwyrm import models
+
+
+class IPBlocklistMiddleware:
+ """check incoming traffic against an IP block-list"""
+
+ def __init__(self, get_response):
+ self.get_response = get_response
+
+ def __call__(self, request):
+ address = request.META.get("REMOTE_ADDR")
+ if models.IPBlocklist.objects.filter(address=address).exists():
+ raise Http404()
+ return self.get_response(request)
diff --git a/bookwyrm/timezone_middleware.py b/bookwyrm/middleware/timezone_middleware.py
similarity index 100%
rename from bookwyrm/timezone_middleware.py
rename to bookwyrm/middleware/timezone_middleware.py
diff --git a/bookwyrm/migrations/0046_user_default_post_privacy.py b/bookwyrm/migrations/0046_user_default_post_privacy.py
new file mode 100644
index 00000000..f1c8e7c3
--- /dev/null
+++ b/bookwyrm/migrations/0046_user_default_post_privacy.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.0.7 on 2021-02-14 00:39
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0045_auto_20210210_2114"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="default_post_privacy",
+ field=models.CharField(
+ choices=[
+ ("public", "Public"),
+ ("unlisted", "Unlisted"),
+ ("followers", "Followers"),
+ ("direct", "Direct"),
+ ],
+ default="public",
+ max_length=255,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0079_merge_20210804_1746.py b/bookwyrm/migrations/0079_merge_20210804_1746.py
new file mode 100644
index 00000000..ed5d50d0
--- /dev/null
+++ b/bookwyrm/migrations/0079_merge_20210804_1746.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.4 on 2021-08-04 17:46
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0046_user_default_post_privacy"),
+ ("bookwyrm", "0078_add_shelved_date"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0080_alter_shelfbook_options.py b/bookwyrm/migrations/0080_alter_shelfbook_options.py
new file mode 100644
index 00000000..b5ee7e67
--- /dev/null
+++ b/bookwyrm/migrations/0080_alter_shelfbook_options.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.2.4 on 2021-08-05 00:00
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0079_merge_20210804_1746"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="shelfbook",
+ options={"ordering": ("-shelved_date", "-created_date", "-updated_date")},
+ ),
+ ]
diff --git a/bookwyrm/migrations/0081_alter_user_last_active_date.py b/bookwyrm/migrations/0081_alter_user_last_active_date.py
new file mode 100644
index 00000000..dc6b640f
--- /dev/null
+++ b/bookwyrm/migrations/0081_alter_user_last_active_date.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.4 on 2021-08-06 02:51
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0080_alter_shelfbook_options"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="user",
+ name="last_active_date",
+ field=models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0082_auto_20210806_2324.py b/bookwyrm/migrations/0082_auto_20210806_2324.py
new file mode 100644
index 00000000..ab0aa158
--- /dev/null
+++ b/bookwyrm/migrations/0082_auto_20210806_2324.py
@@ -0,0 +1,56 @@
+# Generated by Django 3.2.4 on 2021-08-06 23:24
+
+import bookwyrm.models.base_model
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0081_alter_user_last_active_date"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="sitesettings",
+ name="require_confirm_email",
+ field=models.BooleanField(default=True),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="confirmation_code",
+ field=models.CharField(
+ default=bookwyrm.models.base_model.new_access_code, max_length=32
+ ),
+ ),
+ migrations.AlterField(
+ model_name="connector",
+ name="deactivation_reason",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("pending", "Pending"),
+ ("self_deletion", "Self Deletion"),
+ ("moderator_deletion", "Moderator Deletion"),
+ ("domain_block", "Domain Block"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="deactivation_reason",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("pending", "Pending"),
+ ("self_deletion", "Self Deletion"),
+ ("moderator_deletion", "Moderator Deletion"),
+ ("domain_block", "Domain Block"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0083_auto_20210816_2022.py b/bookwyrm/migrations/0083_auto_20210816_2022.py
new file mode 100644
index 00000000..ecf2778b
--- /dev/null
+++ b/bookwyrm/migrations/0083_auto_20210816_2022.py
@@ -0,0 +1,56 @@
+# Generated by Django 3.2.4 on 2021-08-16 20:22
+
+import bookwyrm.models.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0082_auto_20210806_2324"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="comment",
+ name="reading_status",
+ field=bookwyrm.models.fields.CharField(
+ blank=True,
+ choices=[
+ ("to-read", "Toread"),
+ ("reading", "Reading"),
+ ("read", "Read"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ migrations.AddField(
+ model_name="quotation",
+ name="reading_status",
+ field=bookwyrm.models.fields.CharField(
+ blank=True,
+ choices=[
+ ("to-read", "Toread"),
+ ("reading", "Reading"),
+ ("read", "Read"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ migrations.AddField(
+ model_name="review",
+ name="reading_status",
+ field=bookwyrm.models.fields.CharField(
+ blank=True,
+ choices=[
+ ("to-read", "Toread"),
+ ("reading", "Reading"),
+ ("read", "Read"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0084_auto_20210817_1916.py b/bookwyrm/migrations/0084_auto_20210817_1916.py
new file mode 100644
index 00000000..6e826f99
--- /dev/null
+++ b/bookwyrm/migrations/0084_auto_20210817_1916.py
@@ -0,0 +1,56 @@
+# Generated by Django 3.2.4 on 2021-08-17 19:16
+
+import bookwyrm.models.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0083_auto_20210816_2022"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="comment",
+ name="reading_status",
+ field=bookwyrm.models.fields.CharField(
+ blank=True,
+ choices=[
+ ("to-read", "To-Read"),
+ ("reading", "Reading"),
+ ("read", "Read"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="quotation",
+ name="reading_status",
+ field=bookwyrm.models.fields.CharField(
+ blank=True,
+ choices=[
+ ("to-read", "To-Read"),
+ ("reading", "Reading"),
+ ("read", "Read"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="review",
+ name="reading_status",
+ field=bookwyrm.models.fields.CharField(
+ blank=True,
+ choices=[
+ ("to-read", "To-Read"),
+ ("reading", "Reading"),
+ ("read", "Read"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0085_user_saved_lists.py b/bookwyrm/migrations/0085_user_saved_lists.py
new file mode 100644
index 00000000..d4d9278c
--- /dev/null
+++ b/bookwyrm/migrations/0085_user_saved_lists.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.2.4 on 2021-08-23 18:05
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0084_auto_20210817_1916"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="saved_lists",
+ field=models.ManyToManyField(
+ related_name="saved_lists", to="bookwyrm.List"
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0086_auto_20210827_1727.py b/bookwyrm/migrations/0086_auto_20210827_1727.py
new file mode 100644
index 00000000..ef6af206
--- /dev/null
+++ b/bookwyrm/migrations/0086_auto_20210827_1727.py
@@ -0,0 +1,40 @@
+# Generated by Django 3.2.4 on 2021-08-27 17:27
+
+from django.db import migrations, models
+import django.db.models.expressions
+
+
+def normalize_readthrough_dates(app_registry, schema_editor):
+ """Find any invalid dates and reset them"""
+ db_alias = schema_editor.connection.alias
+ app_registry.get_model("bookwyrm", "ReadThrough").objects.using(db_alias).filter(
+ start_date__gt=models.F("finish_date")
+ ).update(start_date=models.F("finish_date"))
+
+
+def reverse_func(apps, schema_editor):
+ """nothing to do here"""
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0085_user_saved_lists"),
+ ]
+
+ operations = [
+ migrations.RunPython(normalize_readthrough_dates, reverse_func),
+ migrations.AlterModelOptions(
+ name="readthrough",
+ options={"ordering": ("-start_date",)},
+ ),
+ migrations.AddConstraint(
+ model_name="readthrough",
+ constraint=models.CheckConstraint(
+ check=models.Q(
+ ("finish_date__gte", django.db.models.expressions.F("start_date"))
+ ),
+ name="chronology",
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0086_auto_20210828_1724.py b/bookwyrm/migrations/0086_auto_20210828_1724.py
new file mode 100644
index 00000000..21247711
--- /dev/null
+++ b/bookwyrm/migrations/0086_auto_20210828_1724.py
@@ -0,0 +1,49 @@
+# Generated by Django 3.2.4 on 2021-08-28 17:24
+
+import bookwyrm.models.fields
+from django.conf import settings
+from django.db import migrations, models
+from django.db.models import F, Value, CharField
+from django.db.models.functions import Concat
+
+
+def forwards_func(apps, schema_editor):
+ """generate followers url"""
+ db_alias = schema_editor.connection.alias
+ apps.get_model("bookwyrm", "User").objects.using(db_alias).annotate(
+ generated_url=Concat(
+ F("remote_id"), Value("/followers"), output_field=CharField()
+ )
+ ).update(followers_url=models.F("generated_url"))
+
+
+def reverse_func(apps, schema_editor):
+ """noop"""
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0085_user_saved_lists"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="followers_url",
+ field=bookwyrm.models.fields.CharField(
+ default="/followers", max_length=255
+ ),
+ preserve_default=False,
+ ),
+ migrations.RunPython(forwards_func, reverse_func),
+ migrations.AlterField(
+ model_name="user",
+ name="followers",
+ field=models.ManyToManyField(
+ related_name="following",
+ through="bookwyrm.UserFollows",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0087_merge_0086_auto_20210827_1727_0086_auto_20210828_1724.py b/bookwyrm/migrations/0087_merge_0086_auto_20210827_1727_0086_auto_20210828_1724.py
new file mode 100644
index 00000000..cd531161
--- /dev/null
+++ b/bookwyrm/migrations/0087_merge_0086_auto_20210827_1727_0086_auto_20210828_1724.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.4 on 2021-08-29 18:19
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0086_auto_20210827_1727"),
+ ("bookwyrm", "0086_auto_20210828_1724"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0088_auto_20210905_2233.py b/bookwyrm/migrations/0088_auto_20210905_2233.py
new file mode 100644
index 00000000..028cf7bf
--- /dev/null
+++ b/bookwyrm/migrations/0088_auto_20210905_2233.py
@@ -0,0 +1,34 @@
+# Generated by Django 3.2.4 on 2021-09-05 22:33
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0087_merge_0086_auto_20210827_1727_0086_auto_20210828_1724"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="quotation",
+ name="position",
+ field=models.IntegerField(
+ blank=True,
+ null=True,
+ validators=[django.core.validators.MinValueValidator(0)],
+ ),
+ ),
+ migrations.AddField(
+ model_name="quotation",
+ name="position_mode",
+ field=models.CharField(
+ blank=True,
+ choices=[("PG", "page"), ("PCT", "percent")],
+ default="PG",
+ max_length=3,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0089_user_show_suggested_users.py b/bookwyrm/migrations/0089_user_show_suggested_users.py
new file mode 100644
index 00000000..047bb974
--- /dev/null
+++ b/bookwyrm/migrations/0089_user_show_suggested_users.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.4 on 2021-09-08 16:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0088_auto_20210905_2233"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="show_suggested_users",
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0090_auto_20210908_2346.py b/bookwyrm/migrations/0090_auto_20210908_2346.py
new file mode 100644
index 00000000..7c870857
--- /dev/null
+++ b/bookwyrm/migrations/0090_auto_20210908_2346.py
@@ -0,0 +1,45 @@
+# Generated by Django 3.2.4 on 2021-09-08 23:46
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0089_user_show_suggested_users"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="connector",
+ name="deactivation_reason",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("pending", "Pending"),
+ ("self_deletion", "Self Deletion"),
+ ("moderator_suspension", "Moderator Suspension"),
+ ("moderator_deletion", "Moderator Deletion"),
+ ("domain_block", "Domain Block"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="deactivation_reason",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("pending", "Pending"),
+ ("self_deletion", "Self Deletion"),
+ ("moderator_suspension", "Moderator Suspension"),
+ ("moderator_deletion", "Moderator Deletion"),
+ ("domain_block", "Domain Block"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0090_emailblocklist.py b/bookwyrm/migrations/0090_emailblocklist.py
new file mode 100644
index 00000000..6934e51e
--- /dev/null
+++ b/bookwyrm/migrations/0090_emailblocklist.py
@@ -0,0 +1,32 @@
+# Generated by Django 3.2.4 on 2021-09-08 22:21
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0089_user_show_suggested_users"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="EmailBlocklist",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("domain", models.CharField(max_length=255, unique=True)),
+ ],
+ options={
+ "ordering": ("-created_date",),
+ },
+ ),
+ ]
diff --git a/bookwyrm/migrations/0091_merge_0090_auto_20210908_2346_0090_emailblocklist.py b/bookwyrm/migrations/0091_merge_0090_auto_20210908_2346_0090_emailblocklist.py
new file mode 100644
index 00000000..7aae2c7a
--- /dev/null
+++ b/bookwyrm/migrations/0091_merge_0090_auto_20210908_2346_0090_emailblocklist.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.4 on 2021-09-09 00:43
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0090_auto_20210908_2346"),
+ ("bookwyrm", "0090_emailblocklist"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0092_sitesettings_instance_short_description.py b/bookwyrm/migrations/0092_sitesettings_instance_short_description.py
new file mode 100644
index 00000000..4c62dd7b
--- /dev/null
+++ b/bookwyrm/migrations/0092_sitesettings_instance_short_description.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.4 on 2021-09-10 18:39
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0091_merge_0090_auto_20210908_2346_0090_emailblocklist"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="sitesettings",
+ name="instance_short_description",
+ field=models.TextField(blank=True, null=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0093_alter_sitesettings_instance_short_description.py b/bookwyrm/migrations/0093_alter_sitesettings_instance_short_description.py
new file mode 100644
index 00000000..165199be
--- /dev/null
+++ b/bookwyrm/migrations/0093_alter_sitesettings_instance_short_description.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.4 on 2021-09-10 19:11
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0092_sitesettings_instance_short_description"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="sitesettings",
+ name="instance_short_description",
+ field=models.CharField(blank=True, max_length=255, null=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0094_auto_20210911_1550.py b/bookwyrm/migrations/0094_auto_20210911_1550.py
new file mode 100644
index 00000000..8c3be9f8
--- /dev/null
+++ b/bookwyrm/migrations/0094_auto_20210911_1550.py
@@ -0,0 +1,39 @@
+# Generated by Django 3.2.4 on 2021-09-11 15:50
+
+from django.db import migrations, models
+from django.db.models import F, Value, CharField
+
+
+def set_deactivate_date(apps, schema_editor):
+ """best-guess for deactivation date"""
+ db_alias = schema_editor.connection.alias
+ apps.get_model("bookwyrm", "User").objects.using(db_alias).filter(
+ is_active=False
+ ).update(deactivation_date=models.F("last_active_date"))
+
+
+def reverse_func(apps, schema_editor):
+ """noop"""
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0093_alter_sitesettings_instance_short_description"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="deactivation_date",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="saved_lists",
+ field=models.ManyToManyField(
+ blank=True, related_name="saved_lists", to="bookwyrm.List"
+ ),
+ ),
+ migrations.RunPython(set_deactivate_date, reverse_func),
+ ]
diff --git a/bookwyrm/migrations/0094_importitem_book_guess.py b/bookwyrm/migrations/0094_importitem_book_guess.py
new file mode 100644
index 00000000..be703cdd
--- /dev/null
+++ b/bookwyrm/migrations/0094_importitem_book_guess.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.2.4 on 2021-09-11 14:22
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0093_alter_sitesettings_instance_short_description"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="importitem",
+ name="book_guess",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="book_guess",
+ to="bookwyrm.book",
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0095_auto_20210911_2053.py b/bookwyrm/migrations/0095_auto_20210911_2053.py
new file mode 100644
index 00000000..06d15f5e
--- /dev/null
+++ b/bookwyrm/migrations/0095_auto_20210911_2053.py
@@ -0,0 +1,45 @@
+# Generated by Django 3.2.4 on 2021-09-11 20:53
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0094_auto_20210911_1550"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="connector",
+ name="deactivation_reason",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("pending", "Pending"),
+ ("self_deletion", "Self deletion"),
+ ("moderator_suspension", "Moderator suspension"),
+ ("moderator_deletion", "Moderator deletion"),
+ ("domain_block", "Domain block"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="deactivation_reason",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("pending", "Pending"),
+ ("self_deletion", "Self deletion"),
+ ("moderator_suspension", "Moderator suspension"),
+ ("moderator_deletion", "Moderator deletion"),
+ ("domain_block", "Domain block"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0095_merge_20210911_2143.py b/bookwyrm/migrations/0095_merge_20210911_2143.py
new file mode 100644
index 00000000..ea6b5a34
--- /dev/null
+++ b/bookwyrm/migrations/0095_merge_20210911_2143.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.4 on 2021-09-11 21:43
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0094_auto_20210911_1550"),
+ ("bookwyrm", "0094_importitem_book_guess"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0096_merge_20210912_0044.py b/bookwyrm/migrations/0096_merge_20210912_0044.py
new file mode 100644
index 00000000..0d3b69a2
--- /dev/null
+++ b/bookwyrm/migrations/0096_merge_20210912_0044.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.4 on 2021-09-12 00:44
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0095_auto_20210911_2053"),
+ ("bookwyrm", "0095_merge_20210911_2143"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0097_auto_20210917_1858.py b/bookwyrm/migrations/0097_auto_20210917_1858.py
new file mode 100644
index 00000000..28cf94e2
--- /dev/null
+++ b/bookwyrm/migrations/0097_auto_20210917_1858.py
@@ -0,0 +1,38 @@
+# Generated by Django 3.2.4 on 2021-09-17 18:58
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0096_merge_20210912_0044"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="IPBlocklist",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("address", models.CharField(max_length=255, unique=True)),
+ ("is_active", models.BooleanField(default=True)),
+ ],
+ options={
+ "ordering": ("-created_date",),
+ },
+ ),
+ migrations.AddField(
+ model_name="emailblocklist",
+ name="is_active",
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0098_auto_20210918_2238.py b/bookwyrm/migrations/0098_auto_20210918_2238.py
new file mode 100644
index 00000000..09fdba31
--- /dev/null
+++ b/bookwyrm/migrations/0098_auto_20210918_2238.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.4 on 2021-09-18 22:38
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0097_auto_20210917_1858"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="sitesettings",
+ name="invite_request_text",
+ field=models.TextField(
+ default="If your request is approved, you will receive an email with a registration link."
+ ),
+ ),
+ migrations.AlterField(
+ model_name="sitesettings",
+ name="registration_closed_text",
+ field=models.TextField(
+ default='We aren\'t taking new users at this time. You can find an open instance at joinbookwyrm.com/instances .'
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0099_readthrough_is_active.py b/bookwyrm/migrations/0099_readthrough_is_active.py
new file mode 100644
index 00000000..e7b177ba
--- /dev/null
+++ b/bookwyrm/migrations/0099_readthrough_is_active.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.2.4 on 2021-09-22 16:53
+
+from django.db import migrations, models
+
+
+def set_active_readthrough(apps, schema_editor):
+ """best-guess for deactivation date"""
+ db_alias = schema_editor.connection.alias
+ apps.get_model("bookwyrm", "ReadThrough").objects.using(db_alias).filter(
+ start_date__isnull=False,
+ finish_date__isnull=True,
+ ).update(is_active=True)
+
+
+def reverse_func(apps, schema_editor):
+ """noop"""
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0098_auto_20210918_2238"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="readthrough",
+ name="is_active",
+ field=models.BooleanField(default=False),
+ ),
+ migrations.RunPython(set_active_readthrough, reverse_func),
+ migrations.AlterField(
+ model_name="readthrough",
+ name="is_active",
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0100_shelf_description.py b/bookwyrm/migrations/0100_shelf_description.py
new file mode 100644
index 00000000..18185b17
--- /dev/null
+++ b/bookwyrm/migrations/0100_shelf_description.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.5 on 2021-09-28 23:20
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0099_readthrough_is_active"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="shelf",
+ name="description",
+ field=models.TextField(blank=True, max_length=500, null=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0101_auto_20210929_1847.py b/bookwyrm/migrations/0101_auto_20210929_1847.py
new file mode 100644
index 00000000..3fca28ea
--- /dev/null
+++ b/bookwyrm/migrations/0101_auto_20210929_1847.py
@@ -0,0 +1,56 @@
+# Generated by Django 3.2 on 2021-05-21 00:17
+
+from django.db import migrations
+import bookwyrm
+from bookwyrm.connectors.abstract_connector import infer_physical_format
+
+
+def infer_format(app_registry, schema_editor):
+ """set the new phsyical format field based on existing format data"""
+ db_alias = schema_editor.connection.alias
+
+ editions = (
+ app_registry.get_model("bookwyrm", "Edition")
+ .objects.using(db_alias)
+ .filter(physical_format_detail__isnull=False)
+ )
+ for edition in editions:
+ free_format = edition.physical_format_detail.lower()
+ edition.physical_format = infer_physical_format(free_format)
+ edition.save()
+
+
+def reverse(app_registry, schema_editor):
+ """doesn't need to do anything"""
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0100_shelf_description"),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name="edition",
+ old_name="physical_format",
+ new_name="physical_format_detail",
+ ),
+ migrations.AddField(
+ model_name="edition",
+ name="physical_format",
+ field=bookwyrm.models.fields.CharField(
+ blank=True,
+ choices=[
+ ("AudiobookFormat", "Audiobook"),
+ ("EBook", "eBook"),
+ ("GraphicNovel", "Graphic novel"),
+ ("Hardcover", "Hardcover"),
+ ("Paperback", "Paperback"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ migrations.RunPython(infer_format, reverse),
+ ]
diff --git a/bookwyrm/migrations/0102_remove_connector_local.py b/bookwyrm/migrations/0102_remove_connector_local.py
new file mode 100644
index 00000000..857f0f58
--- /dev/null
+++ b/bookwyrm/migrations/0102_remove_connector_local.py
@@ -0,0 +1,41 @@
+# Generated by Django 3.2.5 on 2021-09-30 17:46
+
+from django.db import migrations
+from bookwyrm.settings import DOMAIN
+
+
+def remove_self_connector(app_registry, schema_editor):
+ """set the new phsyical format field based on existing format data"""
+ db_alias = schema_editor.connection.alias
+ app_registry.get_model("bookwyrm", "Connector").objects.using(db_alias).filter(
+ connector_file="self_connector"
+ ).delete()
+
+
+def reverse(app_registry, schema_editor):
+ """doesn't need to do anything"""
+ db_alias = schema_editor.connection.alias
+ model = app_registry.get_model("bookwyrm", "Connector")
+ model.objects.using(db_alias).create(
+ identifier=DOMAIN,
+ name="Local",
+ local=True,
+ connector_file="self_connector",
+ base_url=f"https://{DOMAIN}",
+ books_url=f"https://{DOMAIN}/book",
+ covers_url=f"https://{DOMAIN}/images/",
+ search_url=f"https://{DOMAIN}/search?q=",
+ isbn_search_url=f"https://{DOMAIN}/isbn/",
+ priority=1,
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0101_auto_20210929_1847"),
+ ]
+
+ operations = [
+ migrations.RunPython(remove_self_connector, reverse),
+ ]
diff --git a/bookwyrm/migrations/0103_remove_connector_local.py b/bookwyrm/migrations/0103_remove_connector_local.py
new file mode 100644
index 00000000..788ce5f8
--- /dev/null
+++ b/bookwyrm/migrations/0103_remove_connector_local.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.2.5 on 2021-09-30 18:03
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0102_remove_connector_local"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="connector",
+ name="local",
+ ),
+ ]
diff --git a/bookwyrm/migrations/0104_auto_20211001_2012.py b/bookwyrm/migrations/0104_auto_20211001_2012.py
new file mode 100644
index 00000000..8d429040
--- /dev/null
+++ b/bookwyrm/migrations/0104_auto_20211001_2012.py
@@ -0,0 +1,53 @@
+# Generated by Django 3.2.5 on 2021-10-01 20:12
+
+from django.db import migrations, models
+
+
+def set_thread_id(app_registry, schema_editor):
+ """set thread ids"""
+ db_alias = schema_editor.connection.alias
+ # set the thread id on parent nodes
+ model = app_registry.get_model("bookwyrm", "Status")
+ model.objects.using(db_alias).filter(reply_parent__isnull=True).update(
+ thread_id=models.F("id")
+ )
+
+ queryset = model.objects.using(db_alias).filter(
+ reply_parent__isnull=False,
+ reply_parent__thread_id__isnull=False,
+ thread_id__isnull=True,
+ )
+ iters = 0
+ while queryset.exists():
+ queryset.update(
+ thread_id=models.Subquery(
+ model.objects.filter(id=models.OuterRef("reply_parent")).values_list(
+ "thread_id"
+ )[:1]
+ )
+ )
+ print(iters)
+ iters += 1
+ if iters > 50:
+ print("exceeded query depth")
+ break
+
+
+def reverse(*_):
+ """do nothing"""
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0103_remove_connector_local"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="status",
+ name="thread_id",
+ field=models.IntegerField(blank=True, null=True),
+ ),
+ migrations.RunPython(set_thread_id, reverse),
+ ]
diff --git a/bookwyrm/migrations/0105_alter_connector_connector_file.py b/bookwyrm/migrations/0105_alter_connector_connector_file.py
new file mode 100644
index 00000000..a6f08f77
--- /dev/null
+++ b/bookwyrm/migrations/0105_alter_connector_connector_file.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.2.5 on 2021-10-03 19:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0104_auto_20211001_2012"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="connector",
+ name="connector_file",
+ field=models.CharField(
+ choices=[
+ ("openlibrary", "Openlibrary"),
+ ("inventaire", "Inventaire"),
+ ("bookwyrm_connector", "Bookwyrm Connector"),
+ ],
+ max_length=255,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0106_user_preferred_language.py b/bookwyrm/migrations/0106_user_preferred_language.py
new file mode 100644
index 00000000..a77030a0
--- /dev/null
+++ b/bookwyrm/migrations/0106_user_preferred_language.py
@@ -0,0 +1,30 @@
+# Generated by Django 3.2.5 on 2021-10-06 19:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0105_alter_connector_connector_file"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="preferred_language",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("en-us", "English"),
+ ("de-de", "German"),
+ ("es", "Spanish"),
+ ("fr-fr", "French"),
+ ("zh-hans", "Simplified Chinese"),
+ ("zh-hant", "Traditional Chinese"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0107_alter_user_preferred_language.py b/bookwyrm/migrations/0107_alter_user_preferred_language.py
new file mode 100644
index 00000000..be0fc5ea
--- /dev/null
+++ b/bookwyrm/migrations/0107_alter_user_preferred_language.py
@@ -0,0 +1,31 @@
+# Generated by Django 3.2.5 on 2021-10-11 16:22
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0106_user_preferred_language"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="user",
+ name="preferred_language",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("en-us", "English"),
+ ("de-de", "Deutsch (German)"),
+ ("es", "Español (Spanish)"),
+ ("fr-fr", "Français (French)"),
+ ("pt-br", "Português - Brasil (Brazilian Portugues)"),
+ ("zh-hans", "简体中文 (Simplified Chinese)"),
+ ("zh-hant", "繁體中文 (Traditional Chinese)"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0107_auto_20211016_0639.py b/bookwyrm/migrations/0107_auto_20211016_0639.py
new file mode 100644
index 00000000..61dffca3
--- /dev/null
+++ b/bookwyrm/migrations/0107_auto_20211016_0639.py
@@ -0,0 +1,871 @@
+# Generated by Django 3.2.5 on 2021-10-16 06:39
+
+import bookwyrm.models.fields
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0106_user_preferred_language"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Group",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ (
+ "remote_id",
+ bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
+ ),
+ ("name", bookwyrm.models.fields.CharField(max_length=100)),
+ (
+ "description",
+ bookwyrm.models.fields.TextField(blank=True, null=True),
+ ),
+ (
+ "privacy",
+ bookwyrm.models.fields.PrivacyField(
+ choices=[
+ ("public", "Public"),
+ ("unlisted", "Unlisted"),
+ ("followers", "Followers"),
+ ("direct", "Direct"),
+ ],
+ default="public",
+ max_length=255,
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="GroupMember",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name="GroupMemberInvitation",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ],
+ ),
+ migrations.RemoveConstraint(
+ model_name="notification",
+ name="notification_type_valid",
+ ),
+ migrations.AlterField(
+ model_name="list",
+ name="curation",
+ field=bookwyrm.models.fields.CharField(
+ choices=[
+ ("closed", "Closed"),
+ ("open", "Open"),
+ ("curated", "Curated"),
+ ("group", "Group"),
+ ],
+ default="closed",
+ max_length=255,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="notification",
+ name="notification_type",
+ field=models.CharField(
+ choices=[
+ ("FAVORITE", "Favorite"),
+ ("REPLY", "Reply"),
+ ("MENTION", "Mention"),
+ ("TAG", "Tag"),
+ ("FOLLOW", "Follow"),
+ ("FOLLOW_REQUEST", "Follow Request"),
+ ("BOOST", "Boost"),
+ ("IMPORT", "Import"),
+ ("ADD", "Add"),
+ ("REPORT", "Report"),
+ ("INVITE", "Invite"),
+ ("ACCEPT", "Accept"),
+ ("JOIN", "Join"),
+ ("LEAVE", "Leave"),
+ ("REMOVE", "Remove"),
+ ],
+ max_length=255,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="preferred_language",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("en-us", "English"),
+ ("de-de", "Deutsch (German)"),
+ ("es", "Español (Spanish)"),
+ ("fr-fr", "Français (French)"),
+ ("zh-hans", "简体中文 (Simplified Chinese)"),
+ ("zh-hant", "繁體中文 (Traditional Chinese)"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="preferred_timezone",
+ field=models.CharField(
+ choices=[
+ ("Africa/Abidjan", "Africa/Abidjan"),
+ ("Africa/Accra", "Africa/Accra"),
+ ("Africa/Addis_Ababa", "Africa/Addis_Ababa"),
+ ("Africa/Algiers", "Africa/Algiers"),
+ ("Africa/Asmara", "Africa/Asmara"),
+ ("Africa/Asmera", "Africa/Asmera"),
+ ("Africa/Bamako", "Africa/Bamako"),
+ ("Africa/Bangui", "Africa/Bangui"),
+ ("Africa/Banjul", "Africa/Banjul"),
+ ("Africa/Bissau", "Africa/Bissau"),
+ ("Africa/Blantyre", "Africa/Blantyre"),
+ ("Africa/Brazzaville", "Africa/Brazzaville"),
+ ("Africa/Bujumbura", "Africa/Bujumbura"),
+ ("Africa/Cairo", "Africa/Cairo"),
+ ("Africa/Casablanca", "Africa/Casablanca"),
+ ("Africa/Ceuta", "Africa/Ceuta"),
+ ("Africa/Conakry", "Africa/Conakry"),
+ ("Africa/Dakar", "Africa/Dakar"),
+ ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"),
+ ("Africa/Djibouti", "Africa/Djibouti"),
+ ("Africa/Douala", "Africa/Douala"),
+ ("Africa/El_Aaiun", "Africa/El_Aaiun"),
+ ("Africa/Freetown", "Africa/Freetown"),
+ ("Africa/Gaborone", "Africa/Gaborone"),
+ ("Africa/Harare", "Africa/Harare"),
+ ("Africa/Johannesburg", "Africa/Johannesburg"),
+ ("Africa/Juba", "Africa/Juba"),
+ ("Africa/Kampala", "Africa/Kampala"),
+ ("Africa/Khartoum", "Africa/Khartoum"),
+ ("Africa/Kigali", "Africa/Kigali"),
+ ("Africa/Kinshasa", "Africa/Kinshasa"),
+ ("Africa/Lagos", "Africa/Lagos"),
+ ("Africa/Libreville", "Africa/Libreville"),
+ ("Africa/Lome", "Africa/Lome"),
+ ("Africa/Luanda", "Africa/Luanda"),
+ ("Africa/Lubumbashi", "Africa/Lubumbashi"),
+ ("Africa/Lusaka", "Africa/Lusaka"),
+ ("Africa/Malabo", "Africa/Malabo"),
+ ("Africa/Maputo", "Africa/Maputo"),
+ ("Africa/Maseru", "Africa/Maseru"),
+ ("Africa/Mbabane", "Africa/Mbabane"),
+ ("Africa/Mogadishu", "Africa/Mogadishu"),
+ ("Africa/Monrovia", "Africa/Monrovia"),
+ ("Africa/Nairobi", "Africa/Nairobi"),
+ ("Africa/Ndjamena", "Africa/Ndjamena"),
+ ("Africa/Niamey", "Africa/Niamey"),
+ ("Africa/Nouakchott", "Africa/Nouakchott"),
+ ("Africa/Ouagadougou", "Africa/Ouagadougou"),
+ ("Africa/Porto-Novo", "Africa/Porto-Novo"),
+ ("Africa/Sao_Tome", "Africa/Sao_Tome"),
+ ("Africa/Timbuktu", "Africa/Timbuktu"),
+ ("Africa/Tripoli", "Africa/Tripoli"),
+ ("Africa/Tunis", "Africa/Tunis"),
+ ("Africa/Windhoek", "Africa/Windhoek"),
+ ("America/Adak", "America/Adak"),
+ ("America/Anchorage", "America/Anchorage"),
+ ("America/Anguilla", "America/Anguilla"),
+ ("America/Antigua", "America/Antigua"),
+ ("America/Araguaina", "America/Araguaina"),
+ (
+ "America/Argentina/Buenos_Aires",
+ "America/Argentina/Buenos_Aires",
+ ),
+ ("America/Argentina/Catamarca", "America/Argentina/Catamarca"),
+ (
+ "America/Argentina/ComodRivadavia",
+ "America/Argentina/ComodRivadavia",
+ ),
+ ("America/Argentina/Cordoba", "America/Argentina/Cordoba"),
+ ("America/Argentina/Jujuy", "America/Argentina/Jujuy"),
+ ("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"),
+ ("America/Argentina/Mendoza", "America/Argentina/Mendoza"),
+ (
+ "America/Argentina/Rio_Gallegos",
+ "America/Argentina/Rio_Gallegos",
+ ),
+ ("America/Argentina/Salta", "America/Argentina/Salta"),
+ ("America/Argentina/San_Juan", "America/Argentina/San_Juan"),
+ ("America/Argentina/San_Luis", "America/Argentina/San_Luis"),
+ ("America/Argentina/Tucuman", "America/Argentina/Tucuman"),
+ ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"),
+ ("America/Aruba", "America/Aruba"),
+ ("America/Asuncion", "America/Asuncion"),
+ ("America/Atikokan", "America/Atikokan"),
+ ("America/Atka", "America/Atka"),
+ ("America/Bahia", "America/Bahia"),
+ ("America/Bahia_Banderas", "America/Bahia_Banderas"),
+ ("America/Barbados", "America/Barbados"),
+ ("America/Belem", "America/Belem"),
+ ("America/Belize", "America/Belize"),
+ ("America/Blanc-Sablon", "America/Blanc-Sablon"),
+ ("America/Boa_Vista", "America/Boa_Vista"),
+ ("America/Bogota", "America/Bogota"),
+ ("America/Boise", "America/Boise"),
+ ("America/Buenos_Aires", "America/Buenos_Aires"),
+ ("America/Cambridge_Bay", "America/Cambridge_Bay"),
+ ("America/Campo_Grande", "America/Campo_Grande"),
+ ("America/Cancun", "America/Cancun"),
+ ("America/Caracas", "America/Caracas"),
+ ("America/Catamarca", "America/Catamarca"),
+ ("America/Cayenne", "America/Cayenne"),
+ ("America/Cayman", "America/Cayman"),
+ ("America/Chicago", "America/Chicago"),
+ ("America/Chihuahua", "America/Chihuahua"),
+ ("America/Coral_Harbour", "America/Coral_Harbour"),
+ ("America/Cordoba", "America/Cordoba"),
+ ("America/Costa_Rica", "America/Costa_Rica"),
+ ("America/Creston", "America/Creston"),
+ ("America/Cuiaba", "America/Cuiaba"),
+ ("America/Curacao", "America/Curacao"),
+ ("America/Danmarkshavn", "America/Danmarkshavn"),
+ ("America/Dawson", "America/Dawson"),
+ ("America/Dawson_Creek", "America/Dawson_Creek"),
+ ("America/Denver", "America/Denver"),
+ ("America/Detroit", "America/Detroit"),
+ ("America/Dominica", "America/Dominica"),
+ ("America/Edmonton", "America/Edmonton"),
+ ("America/Eirunepe", "America/Eirunepe"),
+ ("America/El_Salvador", "America/El_Salvador"),
+ ("America/Ensenada", "America/Ensenada"),
+ ("America/Fort_Nelson", "America/Fort_Nelson"),
+ ("America/Fort_Wayne", "America/Fort_Wayne"),
+ ("America/Fortaleza", "America/Fortaleza"),
+ ("America/Glace_Bay", "America/Glace_Bay"),
+ ("America/Godthab", "America/Godthab"),
+ ("America/Goose_Bay", "America/Goose_Bay"),
+ ("America/Grand_Turk", "America/Grand_Turk"),
+ ("America/Grenada", "America/Grenada"),
+ ("America/Guadeloupe", "America/Guadeloupe"),
+ ("America/Guatemala", "America/Guatemala"),
+ ("America/Guayaquil", "America/Guayaquil"),
+ ("America/Guyana", "America/Guyana"),
+ ("America/Halifax", "America/Halifax"),
+ ("America/Havana", "America/Havana"),
+ ("America/Hermosillo", "America/Hermosillo"),
+ ("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"),
+ ("America/Indiana/Knox", "America/Indiana/Knox"),
+ ("America/Indiana/Marengo", "America/Indiana/Marengo"),
+ ("America/Indiana/Petersburg", "America/Indiana/Petersburg"),
+ ("America/Indiana/Tell_City", "America/Indiana/Tell_City"),
+ ("America/Indiana/Vevay", "America/Indiana/Vevay"),
+ ("America/Indiana/Vincennes", "America/Indiana/Vincennes"),
+ ("America/Indiana/Winamac", "America/Indiana/Winamac"),
+ ("America/Indianapolis", "America/Indianapolis"),
+ ("America/Inuvik", "America/Inuvik"),
+ ("America/Iqaluit", "America/Iqaluit"),
+ ("America/Jamaica", "America/Jamaica"),
+ ("America/Jujuy", "America/Jujuy"),
+ ("America/Juneau", "America/Juneau"),
+ ("America/Kentucky/Louisville", "America/Kentucky/Louisville"),
+ ("America/Kentucky/Monticello", "America/Kentucky/Monticello"),
+ ("America/Knox_IN", "America/Knox_IN"),
+ ("America/Kralendijk", "America/Kralendijk"),
+ ("America/La_Paz", "America/La_Paz"),
+ ("America/Lima", "America/Lima"),
+ ("America/Los_Angeles", "America/Los_Angeles"),
+ ("America/Louisville", "America/Louisville"),
+ ("America/Lower_Princes", "America/Lower_Princes"),
+ ("America/Maceio", "America/Maceio"),
+ ("America/Managua", "America/Managua"),
+ ("America/Manaus", "America/Manaus"),
+ ("America/Marigot", "America/Marigot"),
+ ("America/Martinique", "America/Martinique"),
+ ("America/Matamoros", "America/Matamoros"),
+ ("America/Mazatlan", "America/Mazatlan"),
+ ("America/Mendoza", "America/Mendoza"),
+ ("America/Menominee", "America/Menominee"),
+ ("America/Merida", "America/Merida"),
+ ("America/Metlakatla", "America/Metlakatla"),
+ ("America/Mexico_City", "America/Mexico_City"),
+ ("America/Miquelon", "America/Miquelon"),
+ ("America/Moncton", "America/Moncton"),
+ ("America/Monterrey", "America/Monterrey"),
+ ("America/Montevideo", "America/Montevideo"),
+ ("America/Montreal", "America/Montreal"),
+ ("America/Montserrat", "America/Montserrat"),
+ ("America/Nassau", "America/Nassau"),
+ ("America/New_York", "America/New_York"),
+ ("America/Nipigon", "America/Nipigon"),
+ ("America/Nome", "America/Nome"),
+ ("America/Noronha", "America/Noronha"),
+ ("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"),
+ ("America/North_Dakota/Center", "America/North_Dakota/Center"),
+ (
+ "America/North_Dakota/New_Salem",
+ "America/North_Dakota/New_Salem",
+ ),
+ ("America/Nuuk", "America/Nuuk"),
+ ("America/Ojinaga", "America/Ojinaga"),
+ ("America/Panama", "America/Panama"),
+ ("America/Pangnirtung", "America/Pangnirtung"),
+ ("America/Paramaribo", "America/Paramaribo"),
+ ("America/Phoenix", "America/Phoenix"),
+ ("America/Port-au-Prince", "America/Port-au-Prince"),
+ ("America/Port_of_Spain", "America/Port_of_Spain"),
+ ("America/Porto_Acre", "America/Porto_Acre"),
+ ("America/Porto_Velho", "America/Porto_Velho"),
+ ("America/Puerto_Rico", "America/Puerto_Rico"),
+ ("America/Punta_Arenas", "America/Punta_Arenas"),
+ ("America/Rainy_River", "America/Rainy_River"),
+ ("America/Rankin_Inlet", "America/Rankin_Inlet"),
+ ("America/Recife", "America/Recife"),
+ ("America/Regina", "America/Regina"),
+ ("America/Resolute", "America/Resolute"),
+ ("America/Rio_Branco", "America/Rio_Branco"),
+ ("America/Rosario", "America/Rosario"),
+ ("America/Santa_Isabel", "America/Santa_Isabel"),
+ ("America/Santarem", "America/Santarem"),
+ ("America/Santiago", "America/Santiago"),
+ ("America/Santo_Domingo", "America/Santo_Domingo"),
+ ("America/Sao_Paulo", "America/Sao_Paulo"),
+ ("America/Scoresbysund", "America/Scoresbysund"),
+ ("America/Shiprock", "America/Shiprock"),
+ ("America/Sitka", "America/Sitka"),
+ ("America/St_Barthelemy", "America/St_Barthelemy"),
+ ("America/St_Johns", "America/St_Johns"),
+ ("America/St_Kitts", "America/St_Kitts"),
+ ("America/St_Lucia", "America/St_Lucia"),
+ ("America/St_Thomas", "America/St_Thomas"),
+ ("America/St_Vincent", "America/St_Vincent"),
+ ("America/Swift_Current", "America/Swift_Current"),
+ ("America/Tegucigalpa", "America/Tegucigalpa"),
+ ("America/Thule", "America/Thule"),
+ ("America/Thunder_Bay", "America/Thunder_Bay"),
+ ("America/Tijuana", "America/Tijuana"),
+ ("America/Toronto", "America/Toronto"),
+ ("America/Tortola", "America/Tortola"),
+ ("America/Vancouver", "America/Vancouver"),
+ ("America/Virgin", "America/Virgin"),
+ ("America/Whitehorse", "America/Whitehorse"),
+ ("America/Winnipeg", "America/Winnipeg"),
+ ("America/Yakutat", "America/Yakutat"),
+ ("America/Yellowknife", "America/Yellowknife"),
+ ("Antarctica/Casey", "Antarctica/Casey"),
+ ("Antarctica/Davis", "Antarctica/Davis"),
+ ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"),
+ ("Antarctica/Macquarie", "Antarctica/Macquarie"),
+ ("Antarctica/Mawson", "Antarctica/Mawson"),
+ ("Antarctica/McMurdo", "Antarctica/McMurdo"),
+ ("Antarctica/Palmer", "Antarctica/Palmer"),
+ ("Antarctica/Rothera", "Antarctica/Rothera"),
+ ("Antarctica/South_Pole", "Antarctica/South_Pole"),
+ ("Antarctica/Syowa", "Antarctica/Syowa"),
+ ("Antarctica/Troll", "Antarctica/Troll"),
+ ("Antarctica/Vostok", "Antarctica/Vostok"),
+ ("Arctic/Longyearbyen", "Arctic/Longyearbyen"),
+ ("Asia/Aden", "Asia/Aden"),
+ ("Asia/Almaty", "Asia/Almaty"),
+ ("Asia/Amman", "Asia/Amman"),
+ ("Asia/Anadyr", "Asia/Anadyr"),
+ ("Asia/Aqtau", "Asia/Aqtau"),
+ ("Asia/Aqtobe", "Asia/Aqtobe"),
+ ("Asia/Ashgabat", "Asia/Ashgabat"),
+ ("Asia/Ashkhabad", "Asia/Ashkhabad"),
+ ("Asia/Atyrau", "Asia/Atyrau"),
+ ("Asia/Baghdad", "Asia/Baghdad"),
+ ("Asia/Bahrain", "Asia/Bahrain"),
+ ("Asia/Baku", "Asia/Baku"),
+ ("Asia/Bangkok", "Asia/Bangkok"),
+ ("Asia/Barnaul", "Asia/Barnaul"),
+ ("Asia/Beirut", "Asia/Beirut"),
+ ("Asia/Bishkek", "Asia/Bishkek"),
+ ("Asia/Brunei", "Asia/Brunei"),
+ ("Asia/Calcutta", "Asia/Calcutta"),
+ ("Asia/Chita", "Asia/Chita"),
+ ("Asia/Choibalsan", "Asia/Choibalsan"),
+ ("Asia/Chongqing", "Asia/Chongqing"),
+ ("Asia/Chungking", "Asia/Chungking"),
+ ("Asia/Colombo", "Asia/Colombo"),
+ ("Asia/Dacca", "Asia/Dacca"),
+ ("Asia/Damascus", "Asia/Damascus"),
+ ("Asia/Dhaka", "Asia/Dhaka"),
+ ("Asia/Dili", "Asia/Dili"),
+ ("Asia/Dubai", "Asia/Dubai"),
+ ("Asia/Dushanbe", "Asia/Dushanbe"),
+ ("Asia/Famagusta", "Asia/Famagusta"),
+ ("Asia/Gaza", "Asia/Gaza"),
+ ("Asia/Harbin", "Asia/Harbin"),
+ ("Asia/Hebron", "Asia/Hebron"),
+ ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"),
+ ("Asia/Hong_Kong", "Asia/Hong_Kong"),
+ ("Asia/Hovd", "Asia/Hovd"),
+ ("Asia/Irkutsk", "Asia/Irkutsk"),
+ ("Asia/Istanbul", "Asia/Istanbul"),
+ ("Asia/Jakarta", "Asia/Jakarta"),
+ ("Asia/Jayapura", "Asia/Jayapura"),
+ ("Asia/Jerusalem", "Asia/Jerusalem"),
+ ("Asia/Kabul", "Asia/Kabul"),
+ ("Asia/Kamchatka", "Asia/Kamchatka"),
+ ("Asia/Karachi", "Asia/Karachi"),
+ ("Asia/Kashgar", "Asia/Kashgar"),
+ ("Asia/Kathmandu", "Asia/Kathmandu"),
+ ("Asia/Katmandu", "Asia/Katmandu"),
+ ("Asia/Khandyga", "Asia/Khandyga"),
+ ("Asia/Kolkata", "Asia/Kolkata"),
+ ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"),
+ ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"),
+ ("Asia/Kuching", "Asia/Kuching"),
+ ("Asia/Kuwait", "Asia/Kuwait"),
+ ("Asia/Macao", "Asia/Macao"),
+ ("Asia/Macau", "Asia/Macau"),
+ ("Asia/Magadan", "Asia/Magadan"),
+ ("Asia/Makassar", "Asia/Makassar"),
+ ("Asia/Manila", "Asia/Manila"),
+ ("Asia/Muscat", "Asia/Muscat"),
+ ("Asia/Nicosia", "Asia/Nicosia"),
+ ("Asia/Novokuznetsk", "Asia/Novokuznetsk"),
+ ("Asia/Novosibirsk", "Asia/Novosibirsk"),
+ ("Asia/Omsk", "Asia/Omsk"),
+ ("Asia/Oral", "Asia/Oral"),
+ ("Asia/Phnom_Penh", "Asia/Phnom_Penh"),
+ ("Asia/Pontianak", "Asia/Pontianak"),
+ ("Asia/Pyongyang", "Asia/Pyongyang"),
+ ("Asia/Qatar", "Asia/Qatar"),
+ ("Asia/Qostanay", "Asia/Qostanay"),
+ ("Asia/Qyzylorda", "Asia/Qyzylorda"),
+ ("Asia/Rangoon", "Asia/Rangoon"),
+ ("Asia/Riyadh", "Asia/Riyadh"),
+ ("Asia/Saigon", "Asia/Saigon"),
+ ("Asia/Sakhalin", "Asia/Sakhalin"),
+ ("Asia/Samarkand", "Asia/Samarkand"),
+ ("Asia/Seoul", "Asia/Seoul"),
+ ("Asia/Shanghai", "Asia/Shanghai"),
+ ("Asia/Singapore", "Asia/Singapore"),
+ ("Asia/Srednekolymsk", "Asia/Srednekolymsk"),
+ ("Asia/Taipei", "Asia/Taipei"),
+ ("Asia/Tashkent", "Asia/Tashkent"),
+ ("Asia/Tbilisi", "Asia/Tbilisi"),
+ ("Asia/Tehran", "Asia/Tehran"),
+ ("Asia/Tel_Aviv", "Asia/Tel_Aviv"),
+ ("Asia/Thimbu", "Asia/Thimbu"),
+ ("Asia/Thimphu", "Asia/Thimphu"),
+ ("Asia/Tokyo", "Asia/Tokyo"),
+ ("Asia/Tomsk", "Asia/Tomsk"),
+ ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"),
+ ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"),
+ ("Asia/Ulan_Bator", "Asia/Ulan_Bator"),
+ ("Asia/Urumqi", "Asia/Urumqi"),
+ ("Asia/Ust-Nera", "Asia/Ust-Nera"),
+ ("Asia/Vientiane", "Asia/Vientiane"),
+ ("Asia/Vladivostok", "Asia/Vladivostok"),
+ ("Asia/Yakutsk", "Asia/Yakutsk"),
+ ("Asia/Yangon", "Asia/Yangon"),
+ ("Asia/Yekaterinburg", "Asia/Yekaterinburg"),
+ ("Asia/Yerevan", "Asia/Yerevan"),
+ ("Atlantic/Azores", "Atlantic/Azores"),
+ ("Atlantic/Bermuda", "Atlantic/Bermuda"),
+ ("Atlantic/Canary", "Atlantic/Canary"),
+ ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"),
+ ("Atlantic/Faeroe", "Atlantic/Faeroe"),
+ ("Atlantic/Faroe", "Atlantic/Faroe"),
+ ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"),
+ ("Atlantic/Madeira", "Atlantic/Madeira"),
+ ("Atlantic/Reykjavik", "Atlantic/Reykjavik"),
+ ("Atlantic/South_Georgia", "Atlantic/South_Georgia"),
+ ("Atlantic/St_Helena", "Atlantic/St_Helena"),
+ ("Atlantic/Stanley", "Atlantic/Stanley"),
+ ("Australia/ACT", "Australia/ACT"),
+ ("Australia/Adelaide", "Australia/Adelaide"),
+ ("Australia/Brisbane", "Australia/Brisbane"),
+ ("Australia/Broken_Hill", "Australia/Broken_Hill"),
+ ("Australia/Canberra", "Australia/Canberra"),
+ ("Australia/Currie", "Australia/Currie"),
+ ("Australia/Darwin", "Australia/Darwin"),
+ ("Australia/Eucla", "Australia/Eucla"),
+ ("Australia/Hobart", "Australia/Hobart"),
+ ("Australia/LHI", "Australia/LHI"),
+ ("Australia/Lindeman", "Australia/Lindeman"),
+ ("Australia/Lord_Howe", "Australia/Lord_Howe"),
+ ("Australia/Melbourne", "Australia/Melbourne"),
+ ("Australia/NSW", "Australia/NSW"),
+ ("Australia/North", "Australia/North"),
+ ("Australia/Perth", "Australia/Perth"),
+ ("Australia/Queensland", "Australia/Queensland"),
+ ("Australia/South", "Australia/South"),
+ ("Australia/Sydney", "Australia/Sydney"),
+ ("Australia/Tasmania", "Australia/Tasmania"),
+ ("Australia/Victoria", "Australia/Victoria"),
+ ("Australia/West", "Australia/West"),
+ ("Australia/Yancowinna", "Australia/Yancowinna"),
+ ("Brazil/Acre", "Brazil/Acre"),
+ ("Brazil/DeNoronha", "Brazil/DeNoronha"),
+ ("Brazil/East", "Brazil/East"),
+ ("Brazil/West", "Brazil/West"),
+ ("CET", "CET"),
+ ("CST6CDT", "CST6CDT"),
+ ("Canada/Atlantic", "Canada/Atlantic"),
+ ("Canada/Central", "Canada/Central"),
+ ("Canada/Eastern", "Canada/Eastern"),
+ ("Canada/Mountain", "Canada/Mountain"),
+ ("Canada/Newfoundland", "Canada/Newfoundland"),
+ ("Canada/Pacific", "Canada/Pacific"),
+ ("Canada/Saskatchewan", "Canada/Saskatchewan"),
+ ("Canada/Yukon", "Canada/Yukon"),
+ ("Chile/Continental", "Chile/Continental"),
+ ("Chile/EasterIsland", "Chile/EasterIsland"),
+ ("Cuba", "Cuba"),
+ ("EET", "EET"),
+ ("EST", "EST"),
+ ("EST5EDT", "EST5EDT"),
+ ("Egypt", "Egypt"),
+ ("Eire", "Eire"),
+ ("Etc/GMT", "Etc/GMT"),
+ ("Etc/GMT+0", "Etc/GMT+0"),
+ ("Etc/GMT+1", "Etc/GMT+1"),
+ ("Etc/GMT+10", "Etc/GMT+10"),
+ ("Etc/GMT+11", "Etc/GMT+11"),
+ ("Etc/GMT+12", "Etc/GMT+12"),
+ ("Etc/GMT+2", "Etc/GMT+2"),
+ ("Etc/GMT+3", "Etc/GMT+3"),
+ ("Etc/GMT+4", "Etc/GMT+4"),
+ ("Etc/GMT+5", "Etc/GMT+5"),
+ ("Etc/GMT+6", "Etc/GMT+6"),
+ ("Etc/GMT+7", "Etc/GMT+7"),
+ ("Etc/GMT+8", "Etc/GMT+8"),
+ ("Etc/GMT+9", "Etc/GMT+9"),
+ ("Etc/GMT-0", "Etc/GMT-0"),
+ ("Etc/GMT-1", "Etc/GMT-1"),
+ ("Etc/GMT-10", "Etc/GMT-10"),
+ ("Etc/GMT-11", "Etc/GMT-11"),
+ ("Etc/GMT-12", "Etc/GMT-12"),
+ ("Etc/GMT-13", "Etc/GMT-13"),
+ ("Etc/GMT-14", "Etc/GMT-14"),
+ ("Etc/GMT-2", "Etc/GMT-2"),
+ ("Etc/GMT-3", "Etc/GMT-3"),
+ ("Etc/GMT-4", "Etc/GMT-4"),
+ ("Etc/GMT-5", "Etc/GMT-5"),
+ ("Etc/GMT-6", "Etc/GMT-6"),
+ ("Etc/GMT-7", "Etc/GMT-7"),
+ ("Etc/GMT-8", "Etc/GMT-8"),
+ ("Etc/GMT-9", "Etc/GMT-9"),
+ ("Etc/GMT0", "Etc/GMT0"),
+ ("Etc/Greenwich", "Etc/Greenwich"),
+ ("Etc/UCT", "Etc/UCT"),
+ ("Etc/UTC", "Etc/UTC"),
+ ("Etc/Universal", "Etc/Universal"),
+ ("Etc/Zulu", "Etc/Zulu"),
+ ("Europe/Amsterdam", "Europe/Amsterdam"),
+ ("Europe/Andorra", "Europe/Andorra"),
+ ("Europe/Astrakhan", "Europe/Astrakhan"),
+ ("Europe/Athens", "Europe/Athens"),
+ ("Europe/Belfast", "Europe/Belfast"),
+ ("Europe/Belgrade", "Europe/Belgrade"),
+ ("Europe/Berlin", "Europe/Berlin"),
+ ("Europe/Bratislava", "Europe/Bratislava"),
+ ("Europe/Brussels", "Europe/Brussels"),
+ ("Europe/Bucharest", "Europe/Bucharest"),
+ ("Europe/Budapest", "Europe/Budapest"),
+ ("Europe/Busingen", "Europe/Busingen"),
+ ("Europe/Chisinau", "Europe/Chisinau"),
+ ("Europe/Copenhagen", "Europe/Copenhagen"),
+ ("Europe/Dublin", "Europe/Dublin"),
+ ("Europe/Gibraltar", "Europe/Gibraltar"),
+ ("Europe/Guernsey", "Europe/Guernsey"),
+ ("Europe/Helsinki", "Europe/Helsinki"),
+ ("Europe/Isle_of_Man", "Europe/Isle_of_Man"),
+ ("Europe/Istanbul", "Europe/Istanbul"),
+ ("Europe/Jersey", "Europe/Jersey"),
+ ("Europe/Kaliningrad", "Europe/Kaliningrad"),
+ ("Europe/Kiev", "Europe/Kiev"),
+ ("Europe/Kirov", "Europe/Kirov"),
+ ("Europe/Lisbon", "Europe/Lisbon"),
+ ("Europe/Ljubljana", "Europe/Ljubljana"),
+ ("Europe/London", "Europe/London"),
+ ("Europe/Luxembourg", "Europe/Luxembourg"),
+ ("Europe/Madrid", "Europe/Madrid"),
+ ("Europe/Malta", "Europe/Malta"),
+ ("Europe/Mariehamn", "Europe/Mariehamn"),
+ ("Europe/Minsk", "Europe/Minsk"),
+ ("Europe/Monaco", "Europe/Monaco"),
+ ("Europe/Moscow", "Europe/Moscow"),
+ ("Europe/Nicosia", "Europe/Nicosia"),
+ ("Europe/Oslo", "Europe/Oslo"),
+ ("Europe/Paris", "Europe/Paris"),
+ ("Europe/Podgorica", "Europe/Podgorica"),
+ ("Europe/Prague", "Europe/Prague"),
+ ("Europe/Riga", "Europe/Riga"),
+ ("Europe/Rome", "Europe/Rome"),
+ ("Europe/Samara", "Europe/Samara"),
+ ("Europe/San_Marino", "Europe/San_Marino"),
+ ("Europe/Sarajevo", "Europe/Sarajevo"),
+ ("Europe/Saratov", "Europe/Saratov"),
+ ("Europe/Simferopol", "Europe/Simferopol"),
+ ("Europe/Skopje", "Europe/Skopje"),
+ ("Europe/Sofia", "Europe/Sofia"),
+ ("Europe/Stockholm", "Europe/Stockholm"),
+ ("Europe/Tallinn", "Europe/Tallinn"),
+ ("Europe/Tirane", "Europe/Tirane"),
+ ("Europe/Tiraspol", "Europe/Tiraspol"),
+ ("Europe/Ulyanovsk", "Europe/Ulyanovsk"),
+ ("Europe/Uzhgorod", "Europe/Uzhgorod"),
+ ("Europe/Vaduz", "Europe/Vaduz"),
+ ("Europe/Vatican", "Europe/Vatican"),
+ ("Europe/Vienna", "Europe/Vienna"),
+ ("Europe/Vilnius", "Europe/Vilnius"),
+ ("Europe/Volgograd", "Europe/Volgograd"),
+ ("Europe/Warsaw", "Europe/Warsaw"),
+ ("Europe/Zagreb", "Europe/Zagreb"),
+ ("Europe/Zaporozhye", "Europe/Zaporozhye"),
+ ("Europe/Zurich", "Europe/Zurich"),
+ ("GB", "GB"),
+ ("GB-Eire", "GB-Eire"),
+ ("GMT", "GMT"),
+ ("GMT+0", "GMT+0"),
+ ("GMT-0", "GMT-0"),
+ ("GMT0", "GMT0"),
+ ("Greenwich", "Greenwich"),
+ ("HST", "HST"),
+ ("Hongkong", "Hongkong"),
+ ("Iceland", "Iceland"),
+ ("Indian/Antananarivo", "Indian/Antananarivo"),
+ ("Indian/Chagos", "Indian/Chagos"),
+ ("Indian/Christmas", "Indian/Christmas"),
+ ("Indian/Cocos", "Indian/Cocos"),
+ ("Indian/Comoro", "Indian/Comoro"),
+ ("Indian/Kerguelen", "Indian/Kerguelen"),
+ ("Indian/Mahe", "Indian/Mahe"),
+ ("Indian/Maldives", "Indian/Maldives"),
+ ("Indian/Mauritius", "Indian/Mauritius"),
+ ("Indian/Mayotte", "Indian/Mayotte"),
+ ("Indian/Reunion", "Indian/Reunion"),
+ ("Iran", "Iran"),
+ ("Israel", "Israel"),
+ ("Jamaica", "Jamaica"),
+ ("Japan", "Japan"),
+ ("Kwajalein", "Kwajalein"),
+ ("Libya", "Libya"),
+ ("MET", "MET"),
+ ("MST", "MST"),
+ ("MST7MDT", "MST7MDT"),
+ ("Mexico/BajaNorte", "Mexico/BajaNorte"),
+ ("Mexico/BajaSur", "Mexico/BajaSur"),
+ ("Mexico/General", "Mexico/General"),
+ ("NZ", "NZ"),
+ ("NZ-CHAT", "NZ-CHAT"),
+ ("Navajo", "Navajo"),
+ ("PRC", "PRC"),
+ ("PST8PDT", "PST8PDT"),
+ ("Pacific/Apia", "Pacific/Apia"),
+ ("Pacific/Auckland", "Pacific/Auckland"),
+ ("Pacific/Bougainville", "Pacific/Bougainville"),
+ ("Pacific/Chatham", "Pacific/Chatham"),
+ ("Pacific/Chuuk", "Pacific/Chuuk"),
+ ("Pacific/Easter", "Pacific/Easter"),
+ ("Pacific/Efate", "Pacific/Efate"),
+ ("Pacific/Enderbury", "Pacific/Enderbury"),
+ ("Pacific/Fakaofo", "Pacific/Fakaofo"),
+ ("Pacific/Fiji", "Pacific/Fiji"),
+ ("Pacific/Funafuti", "Pacific/Funafuti"),
+ ("Pacific/Galapagos", "Pacific/Galapagos"),
+ ("Pacific/Gambier", "Pacific/Gambier"),
+ ("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
+ ("Pacific/Guam", "Pacific/Guam"),
+ ("Pacific/Honolulu", "Pacific/Honolulu"),
+ ("Pacific/Johnston", "Pacific/Johnston"),
+ ("Pacific/Kanton", "Pacific/Kanton"),
+ ("Pacific/Kiritimati", "Pacific/Kiritimati"),
+ ("Pacific/Kosrae", "Pacific/Kosrae"),
+ ("Pacific/Kwajalein", "Pacific/Kwajalein"),
+ ("Pacific/Majuro", "Pacific/Majuro"),
+ ("Pacific/Marquesas", "Pacific/Marquesas"),
+ ("Pacific/Midway", "Pacific/Midway"),
+ ("Pacific/Nauru", "Pacific/Nauru"),
+ ("Pacific/Niue", "Pacific/Niue"),
+ ("Pacific/Norfolk", "Pacific/Norfolk"),
+ ("Pacific/Noumea", "Pacific/Noumea"),
+ ("Pacific/Pago_Pago", "Pacific/Pago_Pago"),
+ ("Pacific/Palau", "Pacific/Palau"),
+ ("Pacific/Pitcairn", "Pacific/Pitcairn"),
+ ("Pacific/Pohnpei", "Pacific/Pohnpei"),
+ ("Pacific/Ponape", "Pacific/Ponape"),
+ ("Pacific/Port_Moresby", "Pacific/Port_Moresby"),
+ ("Pacific/Rarotonga", "Pacific/Rarotonga"),
+ ("Pacific/Saipan", "Pacific/Saipan"),
+ ("Pacific/Samoa", "Pacific/Samoa"),
+ ("Pacific/Tahiti", "Pacific/Tahiti"),
+ ("Pacific/Tarawa", "Pacific/Tarawa"),
+ ("Pacific/Tongatapu", "Pacific/Tongatapu"),
+ ("Pacific/Truk", "Pacific/Truk"),
+ ("Pacific/Wake", "Pacific/Wake"),
+ ("Pacific/Wallis", "Pacific/Wallis"),
+ ("Pacific/Yap", "Pacific/Yap"),
+ ("Poland", "Poland"),
+ ("Portugal", "Portugal"),
+ ("ROC", "ROC"),
+ ("ROK", "ROK"),
+ ("Singapore", "Singapore"),
+ ("Turkey", "Turkey"),
+ ("UCT", "UCT"),
+ ("US/Alaska", "US/Alaska"),
+ ("US/Aleutian", "US/Aleutian"),
+ ("US/Arizona", "US/Arizona"),
+ ("US/Central", "US/Central"),
+ ("US/East-Indiana", "US/East-Indiana"),
+ ("US/Eastern", "US/Eastern"),
+ ("US/Hawaii", "US/Hawaii"),
+ ("US/Indiana-Starke", "US/Indiana-Starke"),
+ ("US/Michigan", "US/Michigan"),
+ ("US/Mountain", "US/Mountain"),
+ ("US/Pacific", "US/Pacific"),
+ ("US/Samoa", "US/Samoa"),
+ ("UTC", "UTC"),
+ ("Universal", "Universal"),
+ ("W-SU", "W-SU"),
+ ("WET", "WET"),
+ ("Zulu", "Zulu"),
+ ],
+ default="UTC",
+ max_length=255,
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="notification",
+ constraint=models.CheckConstraint(
+ check=models.Q(
+ (
+ "notification_type__in",
+ [
+ "FAVORITE",
+ "REPLY",
+ "MENTION",
+ "TAG",
+ "FOLLOW",
+ "FOLLOW_REQUEST",
+ "BOOST",
+ "IMPORT",
+ "ADD",
+ "REPORT",
+ "INVITE",
+ "ACCEPT",
+ "JOIN",
+ "LEAVE",
+ "REMOVE",
+ ],
+ )
+ ),
+ name="notification_type_valid",
+ ),
+ ),
+ migrations.AddField(
+ model_name="groupmemberinvitation",
+ name="group",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="user_invitations",
+ to="bookwyrm.group",
+ ),
+ ),
+ migrations.AddField(
+ model_name="groupmemberinvitation",
+ name="user",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="group_invitations",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AddField(
+ model_name="groupmember",
+ name="group",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="memberships",
+ to="bookwyrm.group",
+ ),
+ ),
+ migrations.AddField(
+ model_name="groupmember",
+ name="user",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="memberships",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AddField(
+ model_name="group",
+ name="user",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
+ ),
+ ),
+ migrations.AddField(
+ model_name="list",
+ name="group",
+ field=models.ForeignKey(
+ blank=True,
+ default=None,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="bookwyrm.group",
+ ),
+ ),
+ migrations.AddField(
+ model_name="notification",
+ name="related_group",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="notifications",
+ to="bookwyrm.group",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="groupmemberinvitation",
+ constraint=models.UniqueConstraint(
+ fields=("group", "user"), name="unique_invitation"
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="groupmember",
+ constraint=models.UniqueConstraint(
+ fields=("group", "user"), name="unique_membership"
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0108_alter_user_preferred_language.py b/bookwyrm/migrations/0108_alter_user_preferred_language.py
new file mode 100644
index 00000000..3614ae1c
--- /dev/null
+++ b/bookwyrm/migrations/0108_alter_user_preferred_language.py
@@ -0,0 +1,31 @@
+# Generated by Django 3.2.5 on 2021-10-11 17:38
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0107_alter_user_preferred_language"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="user",
+ name="preferred_language",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("en-us", "English"),
+ ("de-de", "Deutsch (German)"),
+ ("es-es", "Español (Spanish)"),
+ ("fr-fr", "Français (French)"),
+ ("pt-br", "Português - Brasil (Brazilian Portuguese)"),
+ ("zh-hans", "简体中文 (Simplified Chinese)"),
+ ("zh-hant", "繁體中文 (Traditional Chinese)"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0109_status_edited_date.py b/bookwyrm/migrations/0109_status_edited_date.py
new file mode 100644
index 00000000..3d4d733b
--- /dev/null
+++ b/bookwyrm/migrations/0109_status_edited_date.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.5 on 2021-10-15 15:54
+
+import bookwyrm.models.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0108_alter_user_preferred_language"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="status",
+ name="edited_date",
+ field=bookwyrm.models.fields.DateTimeField(blank=True, null=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0110_auto_20211015_1734.py b/bookwyrm/migrations/0110_auto_20211015_1734.py
new file mode 100644
index 00000000..ed7dd43c
--- /dev/null
+++ b/bookwyrm/migrations/0110_auto_20211015_1734.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.5 on 2021-10-15 17:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0109_status_edited_date"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="quotation",
+ name="raw_quote",
+ field=models.TextField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="status",
+ name="raw_content",
+ field=models.TextField(blank=True, null=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0111_merge_0107_auto_20211016_0639_0110_auto_20211015_1734.py b/bookwyrm/migrations/0111_merge_0107_auto_20211016_0639_0110_auto_20211015_1734.py
new file mode 100644
index 00000000..f5f316c1
--- /dev/null
+++ b/bookwyrm/migrations/0111_merge_0107_auto_20211016_0639_0110_auto_20211015_1734.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.5 on 2021-10-16 19:30
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0107_auto_20211016_0639"),
+ ("bookwyrm", "0110_auto_20211015_1734"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py
index 6f378e83..c5ea44e0 100644
--- a/bookwyrm/models/__init__.py
+++ b/bookwyrm/models/__init__.py
@@ -14,7 +14,6 @@ from .status import Review, ReviewRating
from .status import Boost
from .attachment import Image
from .favorite import Favorite
-from .notification import Notification
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
from .user import User, KeyPair, AnnualGoal
@@ -22,10 +21,16 @@ from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .report import Report, ReportComment
from .federated_server import FederatedServer
+from .group import Group, GroupMember, GroupMemberInvitation
+
from .import_job import ImportJob, ImportItem
-from .site import SiteSettings, SiteInvite, PasswordReset, InviteRequest
+from .site import SiteSettings, SiteInvite
+from .site import PasswordReset, InviteRequest
from .announcement import Announcement
+from .antispam import EmailBlocklist, IPBlocklist
+
+from .notification import Notification
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_models = {
diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py
index 729d9cba..3a88c524 100644
--- a/bookwyrm/models/activitypub_mixin.py
+++ b/bookwyrm/models/activitypub_mixin.py
@@ -7,7 +7,7 @@ import operator
import logging
from uuid import uuid4
import requests
-from requests.exceptions import HTTPError, SSLError
+from requests.exceptions import RequestException
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
@@ -43,7 +43,7 @@ class ActivitypubMixin:
reverse_unfurl = False
def __init__(self, *args, **kwargs):
- """collect some info on model fields"""
+ """collect some info on model fields for later use"""
self.image_fields = []
self.many_to_many_fields = []
self.simple_fields = [] # "simple"
@@ -266,7 +266,7 @@ class ObjectMixin(ActivitypubMixin):
signed_message = signer.sign(SHA256.new(content.encode("utf8")))
signature = activitypub.Signature(
- creator="%s#main-key" % user.remote_id,
+ creator=f"{user.remote_id}#main-key",
created=activity_object.published,
signatureValue=b64encode(signed_message).decode("utf8"),
)
@@ -285,16 +285,16 @@ class ObjectMixin(ActivitypubMixin):
return activitypub.Delete(
id=self.remote_id + "/activity",
actor=user.remote_id,
- to=["%s/followers" % user.remote_id],
+ to=[f"{user.remote_id}/followers"],
cc=["https://www.w3.org/ns/activitystreams#Public"],
object=self,
).serialize()
def to_update_activity(self, user):
"""wrapper for Updates to an activity"""
- activity_id = "%s#update/%s" % (self.remote_id, uuid4())
+ uuid = uuid4()
return activitypub.Update(
- id=activity_id,
+ id=f"{self.remote_id}#update/{uuid}",
actor=user.remote_id,
to=["https://www.w3.org/ns/activitystreams#Public"],
object=self,
@@ -337,8 +337,8 @@ class OrderedCollectionPageMixin(ObjectMixin):
paginated = Paginator(queryset, PAGE_LENGTH)
# add computed fields specific to orderd collections
activity["totalItems"] = paginated.count
- activity["first"] = "%s?page=1" % remote_id
- activity["last"] = "%s?page=%d" % (remote_id, paginated.num_pages)
+ activity["first"] = f"{remote_id}?page=1"
+ activity["last"] = f"{remote_id}?page={paginated.num_pages}"
return serializer(**activity)
@@ -362,6 +362,13 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin):
self.collection_queryset, **kwargs
).serialize()
+ def delete(self, *args, broadcast=True, **kwargs):
+ """Delete the object"""
+ activity = self.to_delete_activity(self.user)
+ super().delete(*args, **kwargs)
+ if self.user.local and broadcast:
+ self.broadcast(activity, self.user)
+
class CollectionItemMixin(ActivitypubMixin):
"""for items that are part of an (Ordered)Collection"""
@@ -413,7 +420,7 @@ class CollectionItemMixin(ActivitypubMixin):
"""AP for shelving a book"""
collection_field = getattr(self, self.collection_field)
return activitypub.Add(
- id="{:s}#add".format(collection_field.remote_id),
+ id=f"{collection_field.remote_id}#add",
actor=user.remote_id,
object=self.to_activity_dataclass(),
target=collection_field.remote_id,
@@ -423,7 +430,7 @@ class CollectionItemMixin(ActivitypubMixin):
"""AP for un-shelving a book"""
collection_field = getattr(self, self.collection_field)
return activitypub.Remove(
- id="{:s}#remove".format(collection_field.remote_id),
+ id=f"{collection_field.remote_id}#remove",
actor=user.remote_id,
object=self.to_activity_dataclass(),
target=collection_field.remote_id,
@@ -451,7 +458,7 @@ class ActivityMixin(ActivitypubMixin):
"""undo an action"""
user = self.user if hasattr(self, "user") else self.user_subject
return activitypub.Undo(
- id="%s#undo" % self.remote_id,
+ id=f"{self.remote_id}#undo",
actor=user.remote_id,
object=self,
).serialize()
@@ -495,7 +502,7 @@ def unfurl_related_field(related_field, sort_field=None):
return related_field.remote_id
-@app.task
+@app.task(queue="medium_priority")
def broadcast_task(sender_id, activity, recipients):
"""the celery task for broadcast"""
user_model = apps.get_model("bookwyrm.User", require_ready=True)
@@ -503,7 +510,7 @@ def broadcast_task(sender_id, activity, recipients):
for recipient in recipients:
try:
sign_and_send(sender, activity, recipient)
- except (HTTPError, SSLError, requests.exceptions.ConnectionError):
+ except RequestException:
pass
@@ -548,11 +555,11 @@ def to_ordered_collection_page(
prev_page = next_page = None
if activity_page.has_next():
- next_page = "%s?page=%d" % (remote_id, activity_page.next_page_number())
+ next_page = f"{remote_id}?page={activity_page.next_page_number()}"
if activity_page.has_previous():
- prev_page = "%s?page=%d" % (remote_id, activity_page.previous_page_number())
+ prev_page = f"{remote_id}?page=%d{activity_page.previous_page_number()}"
return activitypub.OrderedCollectionPage(
- id="%s?page=%s" % (remote_id, page),
+ id=f"{remote_id}?page={page}",
partOf=remote_id,
orderedItems=items,
next=next_page,
diff --git a/bookwyrm/models/antispam.py b/bookwyrm/models/antispam.py
new file mode 100644
index 00000000..7a85bbcf
--- /dev/null
+++ b/bookwyrm/models/antispam.py
@@ -0,0 +1,35 @@
+""" Lets try NOT to sell viagra """
+from django.db import models
+
+from .user import User
+
+
+class EmailBlocklist(models.Model):
+ """blocked email addresses"""
+
+ created_date = models.DateTimeField(auto_now_add=True)
+ domain = models.CharField(max_length=255, unique=True)
+ is_active = models.BooleanField(default=True)
+
+ class Meta:
+ """default sorting"""
+
+ ordering = ("-created_date",)
+
+ @property
+ def users(self):
+ """find the users associated with this address"""
+ return User.objects.filter(email__endswith=f"@{self.domain}")
+
+
+class IPBlocklist(models.Model):
+ """blocked ip addresses"""
+
+ created_date = models.DateTimeField(auto_now_add=True)
+ address = models.CharField(max_length=255, unique=True)
+ is_active = models.BooleanField(default=True)
+
+ class Meta:
+ """default sorting"""
+
+ ordering = ("-created_date",)
diff --git a/bookwyrm/models/author.py b/bookwyrm/models/author.py
index 6da80b17..53cf94ff 100644
--- a/bookwyrm/models/author.py
+++ b/bookwyrm/models/author.py
@@ -35,7 +35,7 @@ class Author(BookDataModel):
def get_remote_id(self):
"""editions and works both use "book" instead of model_name"""
- return "https://%s/author/%s" % (DOMAIN, self.id)
+ return f"https://{DOMAIN}/author/{self.id}"
activity_serializer = activitypub.Author
diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py
index 2cb7c036..f62678f7 100644
--- a/bookwyrm/models/base_model.py
+++ b/bookwyrm/models/base_model.py
@@ -1,19 +1,30 @@
""" base model with default fields """
+import base64
+from Crypto import Random
+
+from django.core.exceptions import PermissionDenied
from django.db import models
+from django.db.models import Q
from django.dispatch import receiver
+from django.http import Http404
+from django.utils.translation import gettext_lazy as _
from bookwyrm.settings import DOMAIN
from .fields import RemoteIdField
-DeactivationReason = models.TextChoices(
- "DeactivationReason",
- [
- "self_deletion",
- "moderator_deletion",
- "domain_block",
- ],
-)
+DeactivationReason = [
+ ("pending", _("Pending")),
+ ("self_deletion", _("Self deletion")),
+ ("moderator_suspension", _("Moderator suspension")),
+ ("moderator_deletion", _("Moderator deletion")),
+ ("domain_block", _("Domain block")),
+]
+
+
+def new_access_code():
+ """the identifier for a user invite"""
+ return base64.b32encode(Random.get_random_bytes(5)).decode("ascii")
class BookWyrmModel(models.Model):
@@ -25,11 +36,11 @@ class BookWyrmModel(models.Model):
def get_remote_id(self):
"""generate a url that resolves to the local object"""
- base_path = "https://%s" % DOMAIN
+ base_path = f"https://{DOMAIN}"
if hasattr(self, "user"):
- base_path = "%s%s" % (base_path, self.user.local_path)
+ base_path = f"{base_path}{self.user.local_path}"
model_name = type(self).__name__.lower()
- return "%s/%s/%d" % (base_path, model_name, self.id)
+ return f"{base_path}/{model_name}/{self.id}"
class Meta:
"""this is just here to provide default fields for other models"""
@@ -39,37 +50,123 @@ class BookWyrmModel(models.Model):
@property
def local_path(self):
"""how to link to this object in the local app"""
- return self.get_remote_id().replace("https://%s" % DOMAIN, "")
+ return self.get_remote_id().replace(f"https://{DOMAIN}", "")
- def visible_to_user(self, viewer):
+ def raise_visible_to_user(self, viewer):
"""is a user authorized to view an object?"""
# make sure this is an object with privacy owned by a user
if not hasattr(self, "user") or not hasattr(self, "privacy"):
- return None
+ return
# viewer can't see it if the object's owner blocked them
if viewer in self.user.blocks.all():
- return False
+ raise Http404()
# you can see your own posts and any public or unlisted posts
if viewer == self.user or self.privacy in ["public", "unlisted"]:
- return True
+ return
# you can see the followers only posts of people you follow
- if (
- self.privacy == "followers"
- and self.user.followers.filter(id=viewer.id).first()
+ if self.privacy == "followers" and (
+ self.user.followers.filter(id=viewer.id).first()
):
- return True
+ return
# you can see dms you are tagged in
if hasattr(self, "mention_users"):
if (
- self.privacy == "direct"
+ self.privacy in ["direct", "followers"]
and self.mention_users.filter(id=viewer.id).first()
):
- return True
- return False
+
+ return
+
+ # you can see groups of which you are a member
+ if (
+ hasattr(self, "memberships")
+ and self.memberships.filter(user=viewer).exists()
+ ):
+ return
+
+ # you can see objects which have a group of which you are a member
+ if hasattr(self, "group"):
+ if (
+ hasattr(self.group, "memberships")
+ and self.group.memberships.filter(user=viewer).exists()
+ ):
+ return
+
+ raise Http404()
+
+ def raise_not_editable(self, viewer):
+ """does this user have permission to edit this object? liable to be overwritten
+ by models that inherit this base model class"""
+ if not hasattr(self, "user"):
+ return
+
+ # generally moderators shouldn't be able to edit other people's stuff
+ if self.user == viewer:
+ return
+
+ raise PermissionDenied()
+
+ def raise_not_deletable(self, viewer):
+ """does this user have permission to delete this object? liable to be
+ overwritten by models that inherit this base model class"""
+ if not hasattr(self, "user"):
+ return
+
+ # but generally moderators can delete other people's stuff
+ if self.user == viewer or viewer.has_perm("moderate_post"):
+ return
+
+ raise PermissionDenied()
+
+ @classmethod
+ def privacy_filter(cls, viewer, privacy_levels=None):
+ """filter objects that have "user" and "privacy" fields"""
+ queryset = cls.objects
+ if hasattr(queryset, "select_subclasses"):
+ queryset = queryset.select_subclasses()
+
+ privacy_levels = privacy_levels or ["public", "unlisted", "followers", "direct"]
+ # you can't see followers only or direct messages if you're not logged in
+ if viewer.is_anonymous:
+ privacy_levels = [
+ p for p in privacy_levels if not p in ["followers", "direct"]
+ ]
+ else:
+ # exclude blocks from both directions
+ queryset = queryset.exclude(
+ Q(user__blocked_by=viewer) | Q(user__blocks=viewer)
+ )
+
+ # filter to only provided privacy levels
+ queryset = queryset.filter(privacy__in=privacy_levels)
+
+ if "followers" in privacy_levels:
+ queryset = cls.followers_filter(queryset, viewer)
+
+ # exclude direct messages not intended for the user
+ if "direct" in privacy_levels:
+ queryset = cls.direct_filter(queryset, viewer)
+
+ return queryset
+
+ @classmethod
+ def followers_filter(cls, queryset, viewer):
+ """Override-able filter for "followers" privacy level"""
+ return queryset.exclude(
+ ~Q( # user isn't following and it isn't their own status
+ Q(user__followers=viewer) | Q(user=viewer)
+ ),
+ privacy="followers", # and the status is followers only
+ )
+
+ @classmethod
+ def direct_filter(cls, queryset, viewer):
+ """Override-able filter for "direct" privacy level"""
+ return queryset.exclude(~Q(user=viewer), privacy="direct")
@receiver(models.signals.post_save)
diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py
index a6aa5de2..8ae75baf 100644
--- a/bookwyrm/models/book.py
+++ b/bookwyrm/models/book.py
@@ -3,14 +3,22 @@ import re
from django.contrib.postgres.search import SearchVectorField
from django.contrib.postgres.indexes import GinIndex
-from django.db import models
+from django.db import models, transaction
+from django.db.models import Prefetch
from django.dispatch import receiver
+from django.utils.translation import gettext_lazy as _
from model_utils import FieldTracker
from model_utils.managers import InheritanceManager
+from imagekit.models import ImageSpecField
from bookwyrm import activitypub
from bookwyrm.preview_images import generate_edition_preview_image_task
-from bookwyrm.settings import DOMAIN, DEFAULT_LANGUAGE, ENABLE_PREVIEW_IMAGES
+from bookwyrm.settings import (
+ DOMAIN,
+ DEFAULT_LANGUAGE,
+ ENABLE_PREVIEW_IMAGES,
+ ENABLE_THUMBNAIL_GENERATION,
+)
from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin
from .base_model import BookWyrmModel
@@ -97,6 +105,40 @@ class Book(BookDataModel):
objects = InheritanceManager()
field_tracker = FieldTracker(fields=["authors", "title", "subtitle", "cover"])
+ if ENABLE_THUMBNAIL_GENERATION:
+ cover_bw_book_xsmall_webp = ImageSpecField(
+ source="cover", id="bw:book:xsmall:webp"
+ )
+ cover_bw_book_xsmall_jpg = ImageSpecField(
+ source="cover", id="bw:book:xsmall:jpg"
+ )
+ cover_bw_book_small_webp = ImageSpecField(
+ source="cover", id="bw:book:small:webp"
+ )
+ cover_bw_book_small_jpg = ImageSpecField(source="cover", id="bw:book:small:jpg")
+ cover_bw_book_medium_webp = ImageSpecField(
+ source="cover", id="bw:book:medium:webp"
+ )
+ cover_bw_book_medium_jpg = ImageSpecField(
+ source="cover", id="bw:book:medium:jpg"
+ )
+ cover_bw_book_large_webp = ImageSpecField(
+ source="cover", id="bw:book:large:webp"
+ )
+ cover_bw_book_large_jpg = ImageSpecField(source="cover", id="bw:book:large:jpg")
+ cover_bw_book_xlarge_webp = ImageSpecField(
+ source="cover", id="bw:book:xlarge:webp"
+ )
+ cover_bw_book_xlarge_jpg = ImageSpecField(
+ source="cover", id="bw:book:xlarge:jpg"
+ )
+ cover_bw_book_xxlarge_webp = ImageSpecField(
+ source="cover", id="bw:book:xxlarge:webp"
+ )
+ cover_bw_book_xxlarge_jpg = ImageSpecField(
+ source="cover", id="bw:book:xxlarge:jpg"
+ )
+
@property
def author_text(self):
"""format a list of authors"""
@@ -123,9 +165,9 @@ class Book(BookDataModel):
@property
def alt_text(self):
"""image alt test"""
- text = "%s" % self.title
+ text = self.title
if self.edition_info:
- text += " (%s)" % self.edition_info
+ text += f" ({self.edition_info})"
return text
def save(self, *args, **kwargs):
@@ -136,9 +178,10 @@ class Book(BookDataModel):
def get_remote_id(self):
"""editions and works both use "book" instead of model_name"""
- return "https://%s/book/%d" % (DOMAIN, self.id)
+ return f"https://{DOMAIN}/book/{self.id}"
def __repr__(self):
+ # pylint: disable=consider-using-f-string
return "<{} key={!r} title={!r}>".format(
self.__class__,
self.openlibrary_key,
@@ -175,7 +218,7 @@ class Work(OrderedCollectionPageMixin, Book):
"""an ordered collection of editions"""
return self.to_ordered_collection(
self.editions.order_by("-edition_rank").all(),
- remote_id="%s/editions" % self.remote_id,
+ remote_id=f"{self.remote_id}/editions",
**kwargs,
)
@@ -184,6 +227,16 @@ class Work(OrderedCollectionPageMixin, Book):
deserialize_reverse_fields = [("editions", "editions")]
+# https://schema.org/BookFormatType
+FormatChoices = [
+ ("AudiobookFormat", _("Audiobook")),
+ ("EBook", _("eBook")),
+ ("GraphicNovel", _("Graphic novel")),
+ ("Hardcover", _("Hardcover")),
+ ("Paperback", _("Paperback")),
+]
+
+
class Edition(Book):
"""an edition of a book"""
@@ -201,7 +254,10 @@ class Edition(Book):
max_length=255, blank=True, null=True, deduplication_field=True
)
pages = fields.IntegerField(blank=True, null=True)
- physical_format = fields.CharField(max_length=255, blank=True, null=True)
+ physical_format = fields.CharField(
+ max_length=255, choices=FormatChoices, null=True, blank=True
+ )
+ physical_format_detail = fields.CharField(max_length=255, blank=True, null=True)
publishers = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
@@ -265,6 +321,27 @@ class Edition(Book):
return super().save(*args, **kwargs)
+ @classmethod
+ def viewer_aware_objects(cls, viewer):
+ """annotate a book query with metadata related to the user"""
+ queryset = cls.objects
+ if not viewer or not viewer.is_authenticated:
+ return queryset
+
+ queryset = queryset.prefetch_related(
+ Prefetch(
+ "shelfbook_set",
+ queryset=viewer.shelfbook_set.all(),
+ to_attr="current_shelves",
+ ),
+ Prefetch(
+ "readthrough_set",
+ queryset=viewer.readthrough_set.filter(is_active=True).all(),
+ to_attr="active_readthroughs",
+ ),
+ )
+ return queryset
+
def isbn_10_to_13(isbn_10):
"""convert an isbn 10 into an isbn 13"""
@@ -321,4 +398,6 @@ def preview_image(instance, *args, **kwargs):
changed_fields = instance.field_tracker.changed()
if len(changed_fields) > 0:
- generate_edition_preview_image_task.delay(instance.id)
+ transaction.on_commit(
+ lambda: generate_edition_preview_image_task.delay(instance.id)
+ )
diff --git a/bookwyrm/models/connector.py b/bookwyrm/models/connector.py
index 2d671790..99e73ab3 100644
--- a/bookwyrm/models/connector.py
+++ b/bookwyrm/models/connector.py
@@ -14,12 +14,11 @@ class Connector(BookWyrmModel):
identifier = models.CharField(max_length=255, unique=True)
priority = models.IntegerField(default=2)
name = models.CharField(max_length=255, null=True, blank=True)
- local = models.BooleanField(default=False)
connector_file = models.CharField(max_length=255, choices=ConnectorFiles.choices)
api_key = models.CharField(max_length=255, null=True, blank=True)
active = models.BooleanField(default=True)
deactivation_reason = models.CharField(
- max_length=255, choices=DeactivationReason.choices, null=True, blank=True
+ max_length=255, choices=DeactivationReason, null=True, blank=True
)
base_url = models.CharField(max_length=255)
@@ -29,7 +28,4 @@ class Connector(BookWyrmModel):
isbn_search_url = models.CharField(max_length=255, null=True, blank=True)
def __str__(self):
- return "{} ({})".format(
- self.identifier,
- self.id,
- )
+ return f"{self.identifier} ({self.id})"
diff --git a/bookwyrm/models/favorite.py b/bookwyrm/models/favorite.py
index c4518119..4c367521 100644
--- a/bookwyrm/models/favorite.py
+++ b/bookwyrm/models/favorite.py
@@ -1,7 +1,5 @@
""" like/fav/star a status """
-from django.apps import apps
from django.db import models
-from django.utils import timezone
from bookwyrm import activitypub
from .activitypub_mixin import ActivityMixin
@@ -29,38 +27,9 @@ class Favorite(ActivityMixin, BookWyrmModel):
def save(self, *args, **kwargs):
"""update user active time"""
- self.user.last_active_date = timezone.now()
- self.user.save(broadcast=False)
+ self.user.update_active_date()
super().save(*args, **kwargs)
- if self.status.user.local and self.status.user != self.user:
- notification_model = apps.get_model(
- "bookwyrm.Notification", require_ready=True
- )
- notification_model.objects.create(
- user=self.status.user,
- notification_type="FAVORITE",
- related_user=self.user,
- related_status=self.status,
- )
-
- def delete(self, *args, **kwargs):
- """delete and delete notifications"""
- # check for notification
- if self.status.user.local:
- notification_model = apps.get_model(
- "bookwyrm.Notification", require_ready=True
- )
- notification = notification_model.objects.filter(
- user=self.status.user,
- related_user=self.user,
- related_status=self.status,
- notification_type="FAVORITE",
- ).first()
- if notification:
- notification.delete()
- super().delete(*args, **kwargs)
-
class Meta:
"""can't fav things twice"""
diff --git a/bookwyrm/models/federated_server.py b/bookwyrm/models/federated_server.py
index e297c46c..eb03d457 100644
--- a/bookwyrm/models/federated_server.py
+++ b/bookwyrm/models/federated_server.py
@@ -1,16 +1,16 @@
""" connections to external ActivityPub servers """
from urllib.parse import urlparse
+
from django.apps import apps
from django.db import models
+from django.utils.translation import gettext_lazy as _
+
from .base_model import BookWyrmModel
-FederationStatus = models.TextChoices(
- "Status",
- [
- "federated",
- "blocked",
- ],
-)
+FederationStatus = [
+ ("federated", _("Federated")),
+ ("blocked", _("Blocked")),
+]
class FederatedServer(BookWyrmModel):
@@ -18,7 +18,7 @@ class FederatedServer(BookWyrmModel):
server_name = models.CharField(max_length=255, unique=True)
status = models.CharField(
- max_length=255, default="federated", choices=FederationStatus.choices
+ max_length=255, default="federated", choices=FederationStatus
)
# is it mastodon, bookwyrm, etc
application_type = models.CharField(max_length=255, null=True, blank=True)
@@ -28,7 +28,7 @@ class FederatedServer(BookWyrmModel):
def block(self):
"""block a server"""
self.status = "blocked"
- self.save()
+ self.save(update_fields=["status"])
# deactivate all associated users
self.user_set.filter(is_active=True).update(
@@ -45,7 +45,7 @@ class FederatedServer(BookWyrmModel):
def unblock(self):
"""unblock a server"""
self.status = "federated"
- self.save()
+ self.save(update_fields=["status"])
self.user_set.filter(deactivation_reason="domain_block").update(
is_active=True, deactivation_reason=None
diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py
index 57b364c9..7d4981e3 100644
--- a/bookwyrm/models/fields.py
+++ b/bookwyrm/models/fields.py
@@ -58,7 +58,7 @@ class ActivitypubFieldMixin:
activitypub_field=None,
activitypub_wrapper=None,
deduplication_field=False,
- **kwargs
+ **kwargs,
):
self.deduplication_field = deduplication_field
if activitypub_wrapper:
@@ -68,8 +68,8 @@ class ActivitypubFieldMixin:
self.activitypub_field = activitypub_field
super().__init__(*args, **kwargs)
- def set_field_from_activity(self, instance, data):
- """helper function for assinging a value to the field"""
+ def set_field_from_activity(self, instance, data, overwrite=True):
+ """helper function for assinging a value to the field. Returns if changed"""
try:
value = getattr(data, self.get_activitypub_field())
except AttributeError:
@@ -79,8 +79,21 @@ class ActivitypubFieldMixin:
value = getattr(data, "actor")
formatted = self.field_from_activity(value)
if formatted is None or formatted is MISSING or formatted == {}:
- return
+ return False
+
+ current_value = (
+ getattr(instance, self.name) if hasattr(instance, self.name) else None
+ )
+ # if we're not in overwrite mode, only continue updating the field if its unset
+ if current_value and not overwrite:
+ return False
+
+ # the field is unchanged
+ if current_value == formatted:
+ return False
+
setattr(instance, self.name, formatted)
+ return True
def set_activity_from_field(self, activity, instance):
"""update the json object"""
@@ -206,17 +219,34 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
)
# pylint: disable=invalid-name
- def set_field_from_activity(self, instance, data):
+ def set_field_from_activity(self, instance, data, overwrite=True):
+ if not overwrite:
+ return False
+
+ original = getattr(instance, self.name)
to = data.to
cc = data.cc
+
+ # we need to figure out who this is to get their followers link
+ for field in ["attributedTo", "owner", "actor"]:
+ if hasattr(data, field):
+ user_field = field
+ break
+ if not user_field:
+ raise ValidationError("No user field found for privacy", data)
+ user = activitypub.resolve_remote_id(getattr(data, user_field), model="User")
+
if to == [self.public]:
setattr(instance, self.name, "public")
+ elif to == [user.followers_url]:
+ setattr(instance, self.name, "followers")
elif cc == []:
setattr(instance, self.name, "direct")
elif self.public in cc:
setattr(instance, self.name, "unlisted")
else:
setattr(instance, self.name, "followers")
+ return original == getattr(instance, self.name)
def set_activity_from_field(self, activity, instance):
# explicitly to anyone mentioned (statuses only)
@@ -225,9 +255,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
mentions = [u.remote_id for u in instance.mention_users.all()]
# this is a link to the followers list
# pylint: disable=protected-access
- followers = instance.user.__class__._meta.get_field(
- "followers"
- ).field_to_activity(instance.user.followers)
+ followers = instance.user.followers_url
if instance.privacy == "public":
activity["to"] = [self.public]
activity["cc"] = [followers] + mentions
@@ -267,18 +295,22 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
self.link_only = link_only
super().__init__(*args, **kwargs)
- def set_field_from_activity(self, instance, data):
+ def set_field_from_activity(self, instance, data, overwrite=True):
"""helper function for assinging a value to the field"""
+ if not overwrite and getattr(instance, self.name).exists():
+ return False
+
value = getattr(data, self.get_activitypub_field())
formatted = self.field_from_activity(value)
if formatted is None or formatted is MISSING:
- return
+ return False
getattr(instance, self.name).set(formatted)
instance.save(broadcast=False)
+ return True
def field_to_activity(self, value):
if self.link_only:
- return "%s/%s" % (value.instance.remote_id, self.name)
+ return f"{value.instance.remote_id}/{self.name}"
return [i.remote_id for i in value.all()]
def field_from_activity(self, value):
@@ -368,13 +400,18 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
super().__init__(*args, **kwargs)
# pylint: disable=arguments-differ
- def set_field_from_activity(self, instance, data, save=True):
+ def set_field_from_activity(self, instance, data, save=True, overwrite=True):
"""helper function for assinging a value to the field"""
value = getattr(data, self.get_activitypub_field())
formatted = self.field_from_activity(value)
if formatted is None or formatted is MISSING:
- return
+ return False
+
+ if not overwrite and hasattr(instance, self.name):
+ return False
+
getattr(instance, self.name).save(*formatted, save=save)
+ return True
def set_activity_from_field(self, activity, instance):
value = getattr(instance, self.name)
@@ -412,7 +449,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
image_content = ContentFile(response.content)
extension = imghdr.what(None, image_content.read()) or ""
- image_name = "{:s}.{:s}".format(str(uuid4()), extension)
+ image_name = f"{uuid4()}.{extension}"
return [image_name, image_content]
def formfield(self, **kwargs):
diff --git a/bookwyrm/models/group.py b/bookwyrm/models/group.py
new file mode 100644
index 00000000..552e2c28
--- /dev/null
+++ b/bookwyrm/models/group.py
@@ -0,0 +1,182 @@
+""" do book related things with other users """
+from django.apps import apps
+from django.db import models, IntegrityError, transaction
+from django.db.models import Q
+from bookwyrm.settings import DOMAIN
+from .base_model import BookWyrmModel
+from . import fields
+from .relationship import UserBlocks
+
+
+class Group(BookWyrmModel):
+ """A group of users"""
+
+ name = fields.CharField(max_length=100)
+ user = fields.ForeignKey("User", on_delete=models.CASCADE)
+ description = fields.TextField(blank=True, null=True)
+ privacy = fields.PrivacyField()
+
+ def get_remote_id(self):
+ """don't want the user to be in there in this case"""
+ return f"https://{DOMAIN}/group/{self.id}"
+
+ @classmethod
+ def followers_filter(cls, queryset, viewer):
+ """Override filter for "followers" privacy level to allow non-following
+ group members to see the existence of groups and group lists"""
+
+ return queryset.exclude(
+ ~Q( # user is not a group member
+ Q(user__followers=viewer) | Q(user=viewer) | Q(memberships__user=viewer)
+ ),
+ privacy="followers", # and the status of the group is followers only
+ )
+
+ @classmethod
+ def direct_filter(cls, queryset, viewer):
+ """Override filter for "direct" privacy level to allow group members
+ to see the existence of groups and group lists"""
+
+ return queryset.exclude(~Q(memberships__user=viewer), privacy="direct")
+
+
+class GroupMember(models.Model):
+ """Users who are members of a group"""
+
+ created_date = models.DateTimeField(auto_now_add=True)
+ updated_date = models.DateTimeField(auto_now=True)
+ group = models.ForeignKey(
+ "Group", on_delete=models.CASCADE, related_name="memberships"
+ )
+ user = models.ForeignKey(
+ "User", on_delete=models.CASCADE, related_name="memberships"
+ )
+
+ class Meta:
+ """Users can only have one membership per group"""
+
+ constraints = [
+ models.UniqueConstraint(fields=["group", "user"], name="unique_membership")
+ ]
+
+ def save(self, *args, **kwargs):
+ """don't let a user invite someone who blocked them"""
+ # blocking in either direction is a no-go
+ if UserBlocks.objects.filter(
+ Q(
+ user_subject=self.group.user,
+ user_object=self.user,
+ )
+ | Q(
+ user_subject=self.user,
+ user_object=self.group.user,
+ )
+ ).exists():
+ raise IntegrityError()
+ # accepts and requests are handled by the GroupInvitation model
+ super().save(*args, **kwargs)
+
+ @classmethod
+ def from_request(cls, join_request):
+ """converts a join request into a member relationship"""
+
+ # remove the invite
+ join_request.delete()
+
+ # make a group member
+ return cls.objects.create(
+ user=join_request.user,
+ group=join_request.group,
+ )
+
+ @classmethod
+ def remove(cls, owner, user):
+ """remove a user from a group"""
+
+ memberships = cls.objects.filter(group__user=owner, user=user).all()
+ for member in memberships:
+ member.delete()
+
+
+class GroupMemberInvitation(models.Model):
+ """adding a user to a group requires manual confirmation"""
+
+ created_date = models.DateTimeField(auto_now_add=True)
+ group = models.ForeignKey(
+ "Group", on_delete=models.CASCADE, related_name="user_invitations"
+ )
+ user = models.ForeignKey(
+ "User", on_delete=models.CASCADE, related_name="group_invitations"
+ )
+
+ class Meta:
+ """Users can only have one outstanding invitation per group"""
+
+ constraints = [
+ models.UniqueConstraint(fields=["group", "user"], name="unique_invitation")
+ ]
+
+ def save(self, *args, **kwargs):
+ """make sure the membership doesn't already exist"""
+ # if there's an invitation for a membership that already exists, accept it
+ # without changing the local database state
+ if GroupMember.objects.filter(user=self.user, group=self.group).exists():
+ self.accept()
+ return
+
+ # blocking in either direction is a no-go
+ if UserBlocks.objects.filter(
+ Q(
+ user_subject=self.group.user,
+ user_object=self.user,
+ )
+ | Q(
+ user_subject=self.user,
+ user_object=self.group.user,
+ )
+ ).exists():
+ raise IntegrityError()
+
+ # make an invitation
+ super().save(*args, **kwargs)
+
+ # now send the invite
+ model = apps.get_model("bookwyrm.Notification", require_ready=True)
+ notification_type = "INVITE"
+ model.objects.create(
+ user=self.user,
+ related_user=self.group.user,
+ related_group=self.group,
+ notification_type=notification_type,
+ )
+
+ def accept(self):
+ """turn this request into the real deal"""
+
+ with transaction.atomic():
+ GroupMember.from_request(self)
+
+ model = apps.get_model("bookwyrm.Notification", require_ready=True)
+ # tell the group owner
+ model.objects.create(
+ user=self.group.user,
+ related_user=self.user,
+ related_group=self.group,
+ notification_type="ACCEPT",
+ )
+
+ # let the other members know about it
+ for membership in self.group.memberships.all():
+ member = membership.user
+ if member not in (self.user, self.group.user):
+ model.objects.create(
+ user=member,
+ related_user=self.user,
+ related_group=self.group,
+ notification_type="JOIN",
+ )
+
+ def reject(self):
+ """generate a Reject for this membership request"""
+
+ self.delete()
diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py
index f2993846..22253fef 100644
--- a/bookwyrm/models/import_job.py
+++ b/bookwyrm/models/import_job.py
@@ -2,7 +2,6 @@
import re
import dateutil.parser
-from django.apps import apps
from django.db import models
from django.utils import timezone
@@ -50,19 +49,6 @@ class ImportJob(models.Model):
)
retry = models.BooleanField(default=False)
- def save(self, *args, **kwargs):
- """save and notify"""
- super().save(*args, **kwargs)
- if self.complete:
- notification_model = apps.get_model(
- "bookwyrm.Notification", require_ready=True
- )
- notification_model.objects.create(
- user=self.user,
- notification_type="IMPORT",
- related_import=self,
- )
-
class ImportItem(models.Model):
"""a single line of a csv being imported"""
@@ -71,6 +57,13 @@ class ImportItem(models.Model):
index = models.IntegerField()
data = models.JSONField()
book = models.ForeignKey(Book, on_delete=models.SET_NULL, null=True, blank=True)
+ book_guess = models.ForeignKey(
+ Book,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="book_guess",
+ )
fail_reason = models.TextField(null=True)
def resolve(self):
@@ -78,9 +71,13 @@ class ImportItem(models.Model):
if self.isbn:
self.book = self.get_book_from_isbn()
else:
- # don't fall back on title/author search is isbn is present.
+ # don't fall back on title/author search if isbn is present.
# you're too likely to mismatch
- self.get_book_from_title_author()
+ book, confidence = self.get_book_from_title_author()
+ if confidence > 0.999:
+ self.book = book
+ else:
+ self.book_guess = book
def get_book_from_isbn(self):
"""search by isbn"""
@@ -96,12 +93,15 @@ class ImportItem(models.Model):
"""search by title and author"""
search_term = construct_search_term(self.title, self.author)
search_result = connector_manager.first_search_result(
- search_term, min_confidence=0.999
+ search_term, min_confidence=0.1
)
if search_result:
# raises ConnectorException
- return search_result.connector.get_or_create_book(search_result.key)
- return None
+ return (
+ search_result.connector.get_or_create_book(search_result.key),
+ search_result.confidence,
+ )
+ return None, 0
@property
def title(self):
@@ -174,6 +174,7 @@ class ImportItem(models.Model):
if start_date and start_date is not None and not self.date_read:
return [ReadThrough(start_date=start_date)]
if self.date_read:
+ start_date = start_date if start_date < self.date_read else None
return [
ReadThrough(
start_date=start_date,
@@ -183,7 +184,9 @@ class ImportItem(models.Model):
return []
def __repr__(self):
+ # pylint: disable=consider-using-f-string
return "<{!r}Item {!r}>".format(self.data["import_source"], self.data["Title"])
def __str__(self):
+ # pylint: disable=consider-using-f-string
return "{} by {}".format(self.data["Title"], self.data["Author"])
diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py
index bbad5ba9..978a7a9b 100644
--- a/bookwyrm/models/list.py
+++ b/bookwyrm/models/list.py
@@ -1,22 +1,20 @@
""" make a list of books!! """
from django.apps import apps
from django.db import models
+from django.db.models import Q
from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
+
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
+from .group import GroupMember
from . import fields
-
CurationType = models.TextChoices(
"Curation",
- [
- "closed",
- "open",
- "curated",
- ],
+ ["closed", "open", "curated", "group"],
)
@@ -32,6 +30,13 @@ class List(OrderedCollectionMixin, BookWyrmModel):
curation = fields.CharField(
max_length=255, default="closed", choices=CurationType.choices
)
+ group = models.ForeignKey(
+ "Group",
+ on_delete=models.SET_NULL,
+ default=None,
+ blank=True,
+ null=True,
+ )
books = models.ManyToManyField(
"Edition",
symmetrical=False,
@@ -42,7 +47,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
def get_remote_id(self):
"""don't want the user to be in there in this case"""
- return "https://%s/list/%d" % (DOMAIN, self.id)
+ return f"https://{DOMAIN}/list/{self.id}"
@property
def collection_queryset(self):
@@ -54,6 +59,52 @@ class List(OrderedCollectionMixin, BookWyrmModel):
ordering = ("-updated_date",)
+ def raise_not_editable(self, viewer):
+ """the associated user OR the list owner can edit"""
+ if self.user == viewer:
+ return
+ # group members can edit items in group lists
+ is_group_member = GroupMember.objects.filter(
+ group=self.group, user=viewer
+ ).exists()
+ if is_group_member:
+ return
+ super().raise_not_editable(viewer)
+
+ @classmethod
+ def followers_filter(cls, queryset, viewer):
+ """Override filter for "followers" privacy level to allow non-following
+ group members to see the existence of group lists"""
+
+ return queryset.exclude(
+ ~Q( # user isn't following or group member
+ Q(user__followers=viewer)
+ | Q(user=viewer)
+ | Q(group__memberships__user=viewer)
+ ),
+ privacy="followers", # and the status (of the list) is followers only
+ )
+
+ @classmethod
+ def direct_filter(cls, queryset, viewer):
+ """Override filter for "direct" privacy level to allow
+ group members to see the existence of group lists"""
+
+ return queryset.exclude(
+ ~Q( # user not self and not in the group if this is a group list
+ Q(user=viewer) | Q(group__memberships__user=viewer)
+ ),
+ privacy="direct",
+ )
+
+ @classmethod
+ def remove_from_group(cls, owner, user):
+ """remove a list from a group"""
+
+ cls.objects.filter(group__user=owner, user=user).all().update(
+ group=None, curation="closed"
+ )
+
class ListItem(CollectionItemMixin, BookWyrmModel):
"""ok"""
@@ -82,9 +133,9 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
self.book_list.save(broadcast=False)
list_owner = self.book_list.user
+ model = apps.get_model("bookwyrm.Notification", require_ready=True)
# create a notification if somoene ELSE added to a local user's list
if created and list_owner.local and list_owner != self.user:
- model = apps.get_model("bookwyrm.Notification", require_ready=True)
model.objects.create(
user=list_owner,
related_user=self.user,
@@ -92,6 +143,28 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
notification_type="ADD",
)
+ if self.book_list.group:
+ for membership in self.book_list.group.memberships.all():
+ if membership.user != self.user:
+ model.objects.create(
+ user=membership.user,
+ related_user=self.user,
+ related_list_item=self,
+ notification_type="ADD",
+ )
+
+ def raise_not_deletable(self, viewer):
+ """the associated user OR the list owner can delete"""
+ if self.book_list.user == viewer:
+ return
+ # group members can delete items in group lists
+ is_group_member = GroupMember.objects.filter(
+ group=self.book_list.group, user=viewer
+ ).exists()
+ if is_group_member:
+ return
+ super().raise_not_deletable(viewer)
+
class Meta:
"""A book may only be placed into a list once,
and each order in the list may be used only once"""
diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py
index ff0b4e5a..b15b95c2 100644
--- a/bookwyrm/models/notification.py
+++ b/bookwyrm/models/notification.py
@@ -1,11 +1,13 @@
""" alert a user to activity """
from django.db import models
+from django.dispatch import receiver
from .base_model import BookWyrmModel
+from . import Boost, Favorite, ImportJob, Report, Status, User
-
+# pylint: disable=line-too-long
NotificationType = models.TextChoices(
"NotificationType",
- "FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT",
+ "FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT INVITE ACCEPT JOIN LEAVE REMOVE",
)
@@ -17,6 +19,9 @@ class Notification(BookWyrmModel):
related_user = models.ForeignKey(
"User", on_delete=models.CASCADE, null=True, related_name="related_user"
)
+ related_group = models.ForeignKey(
+ "Group", on_delete=models.CASCADE, null=True, related_name="notifications"
+ )
related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True)
related_list_item = models.ForeignKey(
@@ -35,6 +40,7 @@ class Notification(BookWyrmModel):
user=self.user,
related_book=self.related_book,
related_user=self.related_user,
+ related_group=self.related_group,
related_status=self.related_status,
related_import=self.related_import,
related_list_item=self.related_list_item,
@@ -53,3 +59,127 @@ class Notification(BookWyrmModel):
name="notification_type_valid",
)
]
+
+
+@receiver(models.signals.post_save, sender=Favorite)
+# pylint: disable=unused-argument
+def notify_on_fav(sender, instance, *args, **kwargs):
+ """someone liked your content, you ARE loved"""
+ if not instance.status.user.local or instance.status.user == instance.user:
+ return
+ Notification.objects.create(
+ user=instance.status.user,
+ notification_type="FAVORITE",
+ related_user=instance.user,
+ related_status=instance.status,
+ )
+
+
+@receiver(models.signals.post_delete, sender=Favorite)
+# pylint: disable=unused-argument
+def notify_on_unfav(sender, instance, *args, **kwargs):
+ """oops, didn't like that after all"""
+ if not instance.status.user.local:
+ return
+ Notification.objects.filter(
+ user=instance.status.user,
+ related_user=instance.user,
+ related_status=instance.status,
+ notification_type="FAVORITE",
+ ).delete()
+
+
+@receiver(models.signals.post_save)
+# pylint: disable=unused-argument
+def notify_user_on_mention(sender, instance, *args, **kwargs):
+ """creating and deleting statuses with @ mentions and replies"""
+ if not issubclass(sender, Status):
+ return
+
+ if instance.deleted:
+ Notification.objects.filter(related_status=instance).delete()
+ return
+
+ if (
+ instance.reply_parent
+ and instance.reply_parent.user != instance.user
+ and instance.reply_parent.user.local
+ ):
+ Notification.objects.create(
+ user=instance.reply_parent.user,
+ notification_type="REPLY",
+ related_user=instance.user,
+ related_status=instance,
+ )
+ for mention_user in instance.mention_users.all():
+ # avoid double-notifying about this status
+ if not mention_user.local or (
+ instance.reply_parent and mention_user == instance.reply_parent.user
+ ):
+ continue
+ Notification.objects.create(
+ user=mention_user,
+ notification_type="MENTION",
+ related_user=instance.user,
+ related_status=instance,
+ )
+
+
+@receiver(models.signals.post_save, sender=Boost)
+# pylint: disable=unused-argument
+def notify_user_on_boost(sender, instance, *args, **kwargs):
+ """boosting a status"""
+ if (
+ not instance.boosted_status.user.local
+ or instance.boosted_status.user == instance.user
+ ):
+ return
+
+ Notification.objects.create(
+ user=instance.boosted_status.user,
+ related_status=instance.boosted_status,
+ related_user=instance.user,
+ notification_type="BOOST",
+ )
+
+
+@receiver(models.signals.post_delete, sender=Boost)
+# pylint: disable=unused-argument
+def notify_user_on_unboost(sender, instance, *args, **kwargs):
+ """unboosting a status"""
+ Notification.objects.filter(
+ user=instance.boosted_status.user,
+ related_status=instance.boosted_status,
+ related_user=instance.user,
+ notification_type="BOOST",
+ ).delete()
+
+
+@receiver(models.signals.post_save, sender=ImportJob)
+# pylint: disable=unused-argument
+def notify_user_on_import_complete(sender, instance, *args, **kwargs):
+ """we imported your books! aren't you proud of us"""
+ if not instance.complete:
+ return
+ Notification.objects.create(
+ user=instance.user,
+ notification_type="IMPORT",
+ related_import=instance,
+ )
+
+
+@receiver(models.signals.post_save, sender=Report)
+# pylint: disable=unused-argument
+def notify_admins_on_report(sender, instance, *args, **kwargs):
+ """something is up, make sure the admins know"""
+ # moderators and superusers should be notified
+ admins = User.objects.filter(
+ models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
+ | models.Q(is_superuser=True)
+ ).all()
+ for admin in admins:
+ Notification.objects.create(
+ user=admin,
+ related_report=instance,
+ notification_type="REPORT",
+ )
diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py
index 664daa13..f75918ac 100644
--- a/bookwyrm/models/readthrough.py
+++ b/bookwyrm/models/readthrough.py
@@ -1,7 +1,7 @@
""" progress in a book """
-from django.db import models
-from django.utils import timezone
from django.core import validators
+from django.db import models
+from django.db.models import F, Q
from .base_model import BookWyrmModel
@@ -26,11 +26,14 @@ class ReadThrough(BookWyrmModel):
)
start_date = models.DateTimeField(blank=True, null=True)
finish_date = models.DateTimeField(blank=True, null=True)
+ is_active = models.BooleanField(default=True)
def save(self, *args, **kwargs):
"""update user active time"""
- self.user.last_active_date = timezone.now()
- self.user.save(broadcast=False)
+ self.user.update_active_date()
+ # an active readthrough must have an unset finish date
+ if self.finish_date:
+ self.is_active = False
super().save(*args, **kwargs)
def create_update(self):
@@ -41,6 +44,16 @@ class ReadThrough(BookWyrmModel):
)
return None
+ class Meta:
+ """Don't let readthroughs end before they start"""
+
+ constraints = [
+ models.CheckConstraint(
+ check=Q(finish_date__gte=F("start_date")), name="chronology"
+ )
+ ]
+ ordering = ("-start_date",)
+
class ProgressUpdate(BookWyrmModel):
"""Store progress through a book in the database."""
@@ -54,6 +67,5 @@ class ProgressUpdate(BookWyrmModel):
def save(self, *args, **kwargs):
"""update user active time"""
- self.user.last_active_date = timezone.now()
- self.user.save(broadcast=False)
+ self.user.update_active_date()
super().save(*args, **kwargs)
diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py
index edb89d13..fc7a9df8 100644
--- a/bookwyrm/models/relationship.py
+++ b/bookwyrm/models/relationship.py
@@ -53,7 +53,7 @@ class UserRelationship(BookWyrmModel):
def get_remote_id(self):
"""use shelf identifier in remote_id"""
base_path = self.user_subject.remote_id
- return "%s#follows/%d" % (base_path, self.id)
+ return f"{base_path}#follows/{self.id}"
class UserFollows(ActivityMixin, UserRelationship):
@@ -144,7 +144,8 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
"""get id for sending an accept or reject of a local user"""
base_path = self.user_object.remote_id
- return "%s#%s/%d" % (base_path, status, self.id or 0)
+ status_id = self.id or 0
+ return f"{base_path}#{status}/{status_id}"
def accept(self, broadcast_only=False):
"""turn this request into the real deal"""
diff --git a/bookwyrm/models/report.py b/bookwyrm/models/report.py
index 7ff4c909..636817cb 100644
--- a/bookwyrm/models/report.py
+++ b/bookwyrm/models/report.py
@@ -1,5 +1,4 @@
""" flagged for moderation """
-from django.apps import apps
from django.db import models
from django.db.models import F, Q
from .base_model import BookWyrmModel
@@ -16,23 +15,6 @@ class Report(BookWyrmModel):
statuses = models.ManyToManyField("Status", blank=True)
resolved = models.BooleanField(default=False)
- def save(self, *args, **kwargs):
- """notify admins when a report is created"""
- super().save(*args, **kwargs)
- user_model = apps.get_model("bookwyrm.User", require_ready=True)
- # moderators and superusers should be notified
- admins = user_model.objects.filter(
- Q(user_permissions__name__in=["moderate_user", "moderate_post"])
- | Q(is_superuser=True)
- ).all()
- notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
- for admin in admins:
- notification_model.objects.create(
- user=admin,
- related_report=self,
- notification_type="REPORT",
- )
-
class Meta:
"""don't let users report themselves"""
diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py
index c4e907d2..c578f082 100644
--- a/bookwyrm/models/shelf.py
+++ b/bookwyrm/models/shelf.py
@@ -1,5 +1,6 @@
""" puttin' books on shelves """
import re
+from django.core.exceptions import PermissionDenied
from django.db import models
from django.utils import timezone
@@ -20,6 +21,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
name = fields.CharField(max_length=100)
identifier = models.CharField(max_length=100)
+ description = models.TextField(blank=True, null=True, max_length=500)
user = fields.ForeignKey(
"User", on_delete=models.PROTECT, activitypub_field="owner"
)
@@ -44,18 +46,29 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
def get_identifier(self):
"""custom-shelf-123 for the url"""
slug = re.sub(r"[^\w]", "", self.name).lower()
- return "{:s}-{:d}".format(slug, self.id)
+ return f"{slug}-{self.id}"
@property
def collection_queryset(self):
"""list of books for this shelf, overrides OrderedCollectionMixin"""
return self.books.order_by("shelfbook")
+ @property
+ def deletable(self):
+ """can the shelf be safely deleted?"""
+ return self.editable and not self.shelfbook_set.exists()
+
def get_remote_id(self):
"""shelf identifier instead of id"""
base_path = self.user.remote_id
identifier = self.identifier or self.get_identifier()
- return "%s/books/%s" % (base_path, identifier)
+ return f"{base_path}/books/{identifier}"
+
+ def raise_not_deletable(self, viewer):
+ """don't let anyone delete a default shelf"""
+ super().raise_not_deletable(viewer)
+ if not self.deletable:
+ raise PermissionDenied()
class Meta:
"""user/shelf unqiueness"""
diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py
index 872f6b45..8338fff8 100644
--- a/bookwyrm/models/site.py
+++ b/bookwyrm/models/site.py
@@ -1,8 +1,6 @@
""" the particulars for this instance of BookWyrm """
-import base64
import datetime
-from Crypto import Random
from django.db import models, IntegrityError
from django.dispatch import receiver
from django.utils import timezone
@@ -10,7 +8,7 @@ from model_utils import FieldTracker
from bookwyrm.preview_images import generate_site_preview_image_task
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES
-from .base_model import BookWyrmModel
+from .base_model import BookWyrmModel, new_access_code
from .user import User
@@ -22,10 +20,17 @@ class SiteSettings(models.Model):
max_length=150, default="Social Reading and Reviewing"
)
instance_description = models.TextField(default="This instance has no description.")
+ instance_short_description = models.CharField(max_length=255, blank=True, null=True)
# about page
registration_closed_text = models.TextField(
- default="Contact an administrator to get an invite"
+ default="We aren't taking new users at this time. You can find an open "
+ 'instance at '
+ "joinbookwyrm.com/instances ."
+ )
+ invite_request_text = models.TextField(
+ default="If your request is approved, you will receive an email with a "
+ "registration link."
)
code_of_conduct = models.TextField(default="Add a code of conduct here.")
privacy_policy = models.TextField(default="Add a privacy policy here.")
@@ -33,6 +38,7 @@ class SiteSettings(models.Model):
# registration
allow_registration = models.BooleanField(default=True)
allow_invite_requests = models.BooleanField(default=True)
+ require_confirm_email = models.BooleanField(default=True)
# images
logo = models.ImageField(upload_to="logos/", null=True, blank=True)
@@ -61,11 +67,6 @@ class SiteSettings(models.Model):
return default_settings
-def new_access_code():
- """the identifier for a user invite"""
- return base64.b32encode(Random.get_random_bytes(5)).decode("ascii")
-
-
class SiteInvite(models.Model):
"""gives someone access to create an account on the instance"""
@@ -86,7 +87,7 @@ class SiteInvite(models.Model):
@property
def link(self):
"""formats the invite link"""
- return "https://{}/invite/{}".format(DOMAIN, self.code)
+ return f"https://{DOMAIN}/invite/{self.code}"
class InviteRequest(BookWyrmModel):
@@ -126,7 +127,7 @@ class PasswordReset(models.Model):
@property
def link(self):
"""formats the invite link"""
- return "https://{}/password-reset/{}".format(DOMAIN, self.code)
+ return f"https://{DOMAIN}/password-reset/{self.code}"
# pylint: disable=unused-argument
diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py
index 3c25f1af..2b395ec8 100644
--- a/bookwyrm/models/status.py
+++ b/bookwyrm/models/status.py
@@ -3,8 +3,10 @@ from dataclasses import MISSING
import re
from django.apps import apps
+from django.core.exceptions import PermissionDenied
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
+from django.db.models import Q
from django.dispatch import receiver
from django.template.loader import get_template
from django.utils import timezone
@@ -29,6 +31,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
"User", on_delete=models.PROTECT, activitypub_field="attributedTo"
)
content = fields.HtmlField(blank=True, null=True)
+ raw_content = models.TextField(blank=True, null=True)
mention_users = fields.TagField("User", related_name="mention_user")
mention_books = fields.TagField("Edition", related_name="mention_book")
local = models.BooleanField(default=True)
@@ -41,6 +44,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
published_date = fields.DateTimeField(
default=timezone.now, activitypub_field="published"
)
+ edited_date = fields.DateTimeField(
+ blank=True, null=True, activitypub_field="updated"
+ )
deleted = models.BooleanField(default=False)
deleted_date = models.DateTimeField(blank=True, null=True)
favorites = models.ManyToManyField(
@@ -56,6 +62,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
on_delete=models.PROTECT,
activitypub_field="inReplyTo",
)
+ thread_id = models.IntegerField(blank=True, null=True)
objects = InheritanceManager()
activity_serializer = activitypub.Note
@@ -69,37 +76,14 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
def save(self, *args, **kwargs):
"""save and notify"""
+ if self.reply_parent:
+ self.thread_id = self.reply_parent.thread_id or self.reply_parent.id
+
super().save(*args, **kwargs)
- notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
-
- if self.deleted:
- notification_model.objects.filter(related_status=self).delete()
- return
-
- if (
- self.reply_parent
- and self.reply_parent.user != self.user
- and self.reply_parent.user.local
- ):
- notification_model.objects.create(
- user=self.reply_parent.user,
- notification_type="REPLY",
- related_user=self.user,
- related_status=self,
- )
- for mention_user in self.mention_users.all():
- # avoid double-notifying about this status
- if not mention_user.local or (
- self.reply_parent and mention_user == self.reply_parent.user
- ):
- continue
- notification_model.objects.create(
- user=mention_user,
- notification_type="MENTION",
- related_user=self.user,
- related_status=self,
- )
+ if not self.reply_parent:
+ self.thread_id = self.id
+ super().save(broadcast=False, update_fields=["thread_id"])
def delete(self, *args, **kwargs): # pylint: disable=unused-argument
""" "delete" a status"""
@@ -108,6 +92,10 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
super().delete(*args, **kwargs)
return
self.deleted = True
+ # clear user content
+ self.content = None
+ if hasattr(self, "quotation"):
+ self.quotation = None # pylint: disable=attribute-defined-outside-init
self.deleted_date = timezone.now()
self.save()
@@ -179,9 +167,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
"""helper function for loading AP serialized replies to a status"""
return self.to_ordered_collection(
self.replies(self),
- remote_id="%s/replies" % self.remote_id,
+ remote_id=f"{self.remote_id}/replies",
collection_only=True,
- **kwargs
+ **kwargs,
).serialize()
def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ
@@ -217,6 +205,35 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
"""json serialized activitypub class"""
return self.to_activity_dataclass(pure=pure).serialize()
+ def raise_not_editable(self, viewer):
+ """certain types of status aren't editable"""
+ # first, the standard raise
+ super().raise_not_editable(viewer)
+ if isinstance(self, (GeneratedNote, ReviewRating)):
+ raise PermissionDenied()
+
+ @classmethod
+ def privacy_filter(cls, viewer, privacy_levels=None):
+ queryset = super().privacy_filter(viewer, privacy_levels=privacy_levels)
+ return queryset.filter(deleted=False)
+
+ @classmethod
+ def direct_filter(cls, queryset, viewer):
+ """Overridden filter for "direct" privacy level"""
+ return queryset.exclude(
+ ~Q(Q(user=viewer) | Q(mention_users=viewer)), privacy="direct"
+ )
+
+ @classmethod
+ def followers_filter(cls, queryset, viewer):
+ """Override-able filter for "followers" privacy level"""
+ return queryset.exclude(
+ ~Q( # not yourself, a follower, or someone who is tagged
+ Q(user__followers=viewer) | Q(user=viewer) | Q(mention_users=viewer)
+ ),
+ privacy="followers", # and the status is followers only
+ )
+
class GeneratedNote(Status):
"""these are app-generated messages about user activity"""
@@ -226,21 +243,40 @@ class GeneratedNote(Status):
"""indicate the book in question for mastodon (or w/e) users"""
message = self.content
books = ", ".join(
- '"%s" ' % (book.remote_id, book.title)
+ f'"{book.title}" '
for book in self.mention_books.all()
)
- return "%s %s %s" % (self.user.display_name, message, books)
+ return f"{self.user.display_name} {message} {books}"
activity_serializer = activitypub.GeneratedNote
pure_type = "Note"
-class Comment(Status):
- """like a review but without a rating and transient"""
+ReadingStatusChoices = models.TextChoices(
+ "ReadingStatusChoices", ["to-read", "reading", "read"]
+)
+
+
+class BookStatus(Status):
+ """Shared fields for comments, quotes, reviews"""
book = fields.ForeignKey(
"Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
)
+ pure_type = "Note"
+
+ reading_status = fields.CharField(
+ max_length=255, choices=ReadingStatusChoices.choices, null=True, blank=True
+ )
+
+ class Meta:
+ """not a real model, sorry"""
+
+ abstract = True
+
+
+class Comment(BookStatus):
+ """like a review but without a rating and transient"""
# this is it's own field instead of a foreign key to the progress update
# so that the update can be deleted without impacting the status
@@ -258,22 +294,28 @@ class Comment(Status):
@property
def pure_content(self):
"""indicate the book in question for mastodon (or w/e) users"""
- return '%s(comment on "%s" )
' % (
- self.content,
- self.book.remote_id,
- self.book.title,
+ return (
+ f'{self.content}(comment on '
+ f'"{self.book.title}" )
'
)
activity_serializer = activitypub.Comment
- pure_type = "Note"
-class Quotation(Status):
+class Quotation(BookStatus):
"""like a review but without a rating and transient"""
quote = fields.HtmlField()
- book = fields.ForeignKey(
- "Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
+ raw_quote = models.TextField(blank=True, null=True)
+ position = models.IntegerField(
+ validators=[MinValueValidator(0)], null=True, blank=True
+ )
+ position_mode = models.CharField(
+ max_length=3,
+ choices=ProgressMode.choices,
+ default=ProgressMode.PAGE,
+ null=True,
+ blank=True,
)
@property
@@ -281,24 +323,18 @@ class Quotation(Status):
"""indicate the book in question for mastodon (or w/e) users"""
quote = re.sub(r"^", '
"', self.quote)
quote = re.sub(r"
$", '"
', quote)
- return '%s -- "%s"
%s' % (
- quote,
- self.book.remote_id,
- self.book.title,
- self.content,
+ return (
+ f'{quote} -- '
+ f'"{self.book.title}"
{self.content}'
)
activity_serializer = activitypub.Quotation
- pure_type = "Note"
-class Review(Status):
+class Review(BookStatus):
"""a book review"""
name = fields.CharField(max_length=255, null=True)
- book = fields.ForeignKey(
- "Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
- )
rating = fields.DecimalField(
default=None,
null=True,
@@ -368,27 +404,6 @@ class Boost(ActivityMixin, Status):
return
super().save(*args, **kwargs)
- if not self.boosted_status.user.local or self.boosted_status.user == self.user:
- return
-
- notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
- notification_model.objects.create(
- user=self.boosted_status.user,
- related_status=self.boosted_status,
- related_user=self.user,
- notification_type="BOOST",
- )
-
- def delete(self, *args, **kwargs):
- """delete and un-notify"""
- notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
- notification_model.objects.filter(
- user=self.boosted_status.user,
- related_status=self.boosted_status,
- related_user=self.user,
- notification_type="BOOST",
- ).delete()
- super().delete(*args, **kwargs)
def __init__(self, *args, **kwargs):
"""the user field is "actor" here instead of "attributedTo" """
@@ -401,10 +416,6 @@ class Boost(ActivityMixin, Status):
self.image_fields = []
self.deserialize_reverse_fields = []
- # This constraint can't work as it would cross tables.
- # class Meta:
- # unique_together = ('user', 'boosted_status')
-
# pylint: disable=unused-argument
@receiver(models.signals.post_save)
diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py
index 49458a2e..d7945843 100644
--- a/bookwyrm/models/user.py
+++ b/bookwyrm/models/user.py
@@ -7,7 +7,7 @@ from django.contrib.auth.models import AbstractUser, Group
from django.contrib.postgres.fields import CICharField
from django.core.validators import MinValueValidator
from django.dispatch import receiver
-from django.db import models
+from django.db import models, transaction
from django.utils import timezone
from model_utils import FieldTracker
import pytz
@@ -17,16 +17,22 @@ from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.models.shelf import Shelf
from bookwyrm.models.status import Status, Review
from bookwyrm.preview_images import generate_user_preview_image_task
-from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES
+from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES
from bookwyrm.signatures import create_key_pair
from bookwyrm.tasks import app
from bookwyrm.utils import regex
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
-from .base_model import BookWyrmModel, DeactivationReason
+from .base_model import BookWyrmModel, DeactivationReason, new_access_code
from .federated_server import FederatedServer
from . import fields, Review
+def site_link():
+ """helper for generating links to the site"""
+ protocol = "https" if USE_HTTPS else "http"
+ return f"{protocol}://{DOMAIN}"
+
+
class User(OrderedCollectionPageMixin, AbstractUser):
"""a user who wants to read books"""
@@ -76,9 +82,9 @@ class User(OrderedCollectionPageMixin, AbstractUser):
preview_image = models.ImageField(
upload_to="previews/avatars/", blank=True, null=True
)
- followers = fields.ManyToManyField(
+ followers_url = fields.CharField(max_length=255, activitypub_field="followers")
+ followers = models.ManyToManyField(
"self",
- link_only=True,
symmetrical=False,
through="UserFollows",
through_fields=("user_object", "user_subject"),
@@ -98,6 +104,9 @@ class User(OrderedCollectionPageMixin, AbstractUser):
through_fields=("user_subject", "user_object"),
related_name="blocked_by",
)
+ saved_lists = models.ManyToManyField(
+ "List", symmetrical=False, related_name="saved_lists", blank=True
+ )
favorites = models.ManyToManyField(
"Status",
symmetrical=False,
@@ -105,35 +114,57 @@ class User(OrderedCollectionPageMixin, AbstractUser):
through_fields=("user", "status"),
related_name="favorite_statuses",
)
+ default_post_privacy = models.CharField(
+ max_length=255, default="public", choices=fields.PrivacyLevels.choices
+ )
remote_id = fields.RemoteIdField(null=True, unique=True, activitypub_field="id")
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
- last_active_date = models.DateTimeField(auto_now=True)
+ last_active_date = models.DateTimeField(default=timezone.now)
manually_approves_followers = fields.BooleanField(default=False)
+
+ # options to turn features on and off
show_goal = models.BooleanField(default=True)
+ show_suggested_users = models.BooleanField(default=True)
discoverable = fields.BooleanField(default=False)
+
preferred_timezone = models.CharField(
choices=[(str(tz), str(tz)) for tz in pytz.all_timezones],
default=str(pytz.utc),
max_length=255,
)
- deactivation_reason = models.CharField(
- max_length=255, choices=DeactivationReason.choices, null=True, blank=True
+ preferred_language = models.CharField(
+ choices=LANGUAGES,
+ null=True,
+ blank=True,
+ max_length=255,
)
+ deactivation_reason = models.CharField(
+ max_length=255, choices=DeactivationReason, null=True, blank=True
+ )
+ deactivation_date = models.DateTimeField(null=True, blank=True)
+ confirmation_code = models.CharField(max_length=32, default=new_access_code)
name_field = "username"
property_fields = [("following_link", "following")]
field_tracker = FieldTracker(fields=["name", "avatar"])
+ @property
+ def confirmation_link(self):
+ """helper for generating confirmation links"""
+ link = site_link()
+ return f"{link}/confirm-email/{self.confirmation_code}"
+
@property
def following_link(self):
"""just how to find out the following info"""
- return "{:s}/following".format(self.remote_id)
+ return f"{self.remote_id}/following"
@property
def alt_text(self):
"""alt text with username"""
- return "avatar for %s" % (self.localname or self.username)
+ # pylint: disable=consider-using-f-string
+ return "avatar for {:s}".format(self.localname or self.username)
@property
def display_name(self):
@@ -170,12 +201,15 @@ class User(OrderedCollectionPageMixin, AbstractUser):
queryset = queryset.exclude(blocks=viewer)
return queryset
+ def update_active_date(self):
+ """this user is here! they are doing things!"""
+ self.last_active_date = timezone.now()
+ self.save(broadcast=False, update_fields=["last_active_date"])
+
def to_outbox(self, filter_type=None, **kwargs):
"""an ordered collection of statuses"""
if filter_type:
- filter_class = apps.get_model(
- "bookwyrm.%s" % filter_type, require_ready=True
- )
+ filter_class = apps.get_model(f"bookwyrm.{filter_type}", require_ready=True)
if not issubclass(filter_class, Status):
raise TypeError(
"filter_status_class must be a subclass of models.Status"
@@ -199,22 +233,22 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def to_following_activity(self, **kwargs):
"""activitypub following list"""
- remote_id = "%s/following" % self.remote_id
+ remote_id = f"{self.remote_id}/following"
return self.to_ordered_collection(
self.following.order_by("-updated_date").all(),
remote_id=remote_id,
id_only=True,
- **kwargs
+ **kwargs,
)
def to_followers_activity(self, **kwargs):
"""activitypub followers list"""
- remote_id = "%s/followers" % self.remote_id
+ remote_id = self.followers_url
return self.to_ordered_collection(
self.followers.order_by("-updated_date").all(),
remote_id=remote_id,
id_only=True,
- **kwargs
+ **kwargs,
)
def to_activity(self, **kwargs):
@@ -242,42 +276,65 @@ class User(OrderedCollectionPageMixin, AbstractUser):
if not self.local and not re.match(regex.FULL_USERNAME, self.username):
# generate a username that uses the domain (webfinger format)
actor_parts = urlparse(self.remote_id)
- self.username = "%s@%s" % (self.username, actor_parts.netloc)
- super().save(*args, **kwargs)
+ self.username = f"{self.username}@{actor_parts.netloc}"
# this user already exists, no need to populate fields
if not created:
+ if self.is_active:
+ self.deactivation_date = None
+ elif not self.deactivation_date:
+ self.deactivation_date = timezone.now()
+
super().save(*args, **kwargs)
return
# this is a new remote user, we need to set their remote server field
if not self.local:
super().save(*args, **kwargs)
- set_remote_server.delay(self.id)
+ transaction.on_commit(lambda: set_remote_server.delay(self.id))
return
- # populate fields for local users
- self.remote_id = "https://%s/user/%s" % (DOMAIN, self.localname)
- self.inbox = "%s/inbox" % self.remote_id
- self.shared_inbox = "https://%s/inbox" % DOMAIN
- self.outbox = "%s/outbox" % self.remote_id
+ with transaction.atomic():
+ # populate fields for local users
+ link = site_link()
+ self.remote_id = f"{link}/user/{self.localname}"
+ self.followers_url = f"{self.remote_id}/followers"
+ self.inbox = f"{self.remote_id}/inbox"
+ self.shared_inbox = f"{link}/inbox"
+ self.outbox = f"{self.remote_id}/outbox"
- # an id needs to be set before we can proceed with related models
+ # an id needs to be set before we can proceed with related models
+ super().save(*args, **kwargs)
+
+ # make users editors by default
+ try:
+ self.groups.add(Group.objects.get(name="editor"))
+ except Group.DoesNotExist:
+ # this should only happen in tests
+ pass
+
+ # create keys and shelves for new local users
+ self.key_pair = KeyPair.objects.create(
+ remote_id=f"{self.remote_id}/#main-key"
+ )
+ self.save(broadcast=False, update_fields=["key_pair"])
+
+ self.create_shelves()
+
+ def delete(self, *args, **kwargs):
+ """deactivate rather than delete a user"""
+ self.is_active = False
+ # skip the logic in this class's save()
super().save(*args, **kwargs)
- # make users editors by default
- try:
- self.groups.add(Group.objects.get(name="editor"))
- except Group.DoesNotExist:
- # this should only happen in tests
- pass
-
- # create keys and shelves for new local users
- self.key_pair = KeyPair.objects.create(
- remote_id="%s/#main-key" % self.remote_id
- )
- self.save(broadcast=False)
+ @property
+ def local_path(self):
+ """this model doesn't inherit bookwyrm model, so here we are"""
+ # pylint: disable=consider-using-f-string
+ return "/user/{:s}".format(self.localname or self.username)
+ def create_shelves(self):
+ """default shelves for a new user"""
shelves = [
{
"name": "To Read",
@@ -301,17 +358,6 @@ class User(OrderedCollectionPageMixin, AbstractUser):
editable=False,
).save(broadcast=False)
- def delete(self, *args, **kwargs):
- """deactivate rather than delete a user"""
- self.is_active = False
- # skip the logic in this class's save()
- super().save(*args, **kwargs)
-
- @property
- def local_path(self):
- """this model doesn't inherit bookwyrm model, so here we are"""
- return "/user/%s" % (self.localname or self.username)
-
class KeyPair(ActivitypubMixin, BookWyrmModel):
"""public and private keys for a user"""
@@ -326,7 +372,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
def get_remote_id(self):
# self.owner is set by the OneToOneField on User
- return "%s/#main-key" % self.owner.remote_id
+ return f"{self.owner.remote_id}/#main-key"
def save(self, *args, **kwargs):
"""create a key pair"""
@@ -363,7 +409,7 @@ class AnnualGoal(BookWyrmModel):
def get_remote_id(self):
"""put the year in the path"""
- return "{:s}/goal/{:d}".format(self.user.remote_id, self.year)
+ return f"{self.user.remote_id}/goal/{self.year}"
@property
def books(self):
@@ -400,13 +446,13 @@ class AnnualGoal(BookWyrmModel):
}
-@app.task
+@app.task(queue="low_priority")
def set_remote_server(user_id):
"""figure out the user's remote server in the background"""
user = User.objects.get(id=user_id)
actor_parts = urlparse(user.remote_id)
user.federated_server = get_or_create_remote_server(actor_parts.netloc)
- user.save(broadcast=False)
+ user.save(broadcast=False, update_fields=["federated_server"])
if user.bookwyrm_user and user.outbox:
get_remote_reviews.delay(user.outbox)
@@ -419,7 +465,7 @@ def get_or_create_remote_server(domain):
pass
try:
- data = get_data("https://%s/.well-known/nodeinfo" % domain)
+ data = get_data(f"https://{domain}/.well-known/nodeinfo")
try:
nodeinfo_url = data.get("links")[0].get("href")
except (TypeError, KeyError):
@@ -439,7 +485,7 @@ def get_or_create_remote_server(domain):
return server
-@app.task
+@app.task(queue="low_priority")
def get_remote_reviews(outbox):
"""ingest reviews by a new remote bookwyrm user"""
outbox_page = outbox + "?page=true&type=Review"
diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py
index 29c4961c..8224a278 100644
--- a/bookwyrm/preview_images.py
+++ b/bookwyrm/preview_images.py
@@ -220,6 +220,7 @@ def generate_default_inner_img():
# pylint: disable=too-many-locals
+# pylint: disable=too-many-statements
def generate_preview_image(
texts=None, picture=None, rating=None, show_instance_layer=True
):
@@ -237,7 +238,8 @@ def generate_preview_image(
# Color
if BG_COLOR in ["use_dominant_color_light", "use_dominant_color_dark"]:
- image_bg_color = "rgb(%s, %s, %s)" % dominant_color
+ red, green, blue = dominant_color
+ image_bg_color = f"rgb({red}, {green}, {blue})"
# Adjust color
image_bg_color_rgb = [x / 255.0 for x in ImageColor.getrgb(image_bg_color)]
@@ -315,7 +317,8 @@ def save_and_cleanup(image, instance=None):
"""Save and close the file"""
if not isinstance(instance, (models.Book, models.User, models.SiteSettings)):
return False
- file_name = "%s-%s.jpg" % (str(instance.id), str(uuid4()))
+ uuid = uuid4()
+ file_name = f"{instance.id}-{uuid}.jpg"
image_buffer = BytesIO()
try:
@@ -338,9 +341,9 @@ def save_and_cleanup(image, instance=None):
save_without_broadcast = isinstance(instance, (models.Book, models.User))
if save_without_broadcast:
- instance.save(broadcast=False)
+ instance.save(broadcast=False, update_fields=["preview_image"])
else:
- instance.save()
+ instance.save(update_fields=["preview_image"])
# Clean up old file after saving
if old_path and default_storage.exists(old_path):
@@ -352,7 +355,7 @@ def save_and_cleanup(image, instance=None):
# pylint: disable=invalid-name
-@app.task
+@app.task(queue="low_priority")
def generate_site_preview_image_task():
"""generate preview_image for the website"""
if not settings.ENABLE_PREVIEW_IMAGES:
@@ -377,7 +380,7 @@ def generate_site_preview_image_task():
# pylint: disable=invalid-name
-@app.task
+@app.task(queue="low_priority")
def generate_edition_preview_image_task(book_id):
"""generate preview_image for a book"""
if not settings.ENABLE_PREVIEW_IMAGES:
@@ -402,7 +405,7 @@ def generate_edition_preview_image_task(book_id):
save_and_cleanup(image, instance=book)
-@app.task
+@app.task(queue="low_priority")
def generate_user_preview_image_task(user_id):
"""generate preview_image for a book"""
if not settings.ENABLE_PREVIEW_IMAGES:
@@ -412,7 +415,7 @@ def generate_user_preview_image_task(user_id):
texts = {
"text_one": user.display_name,
- "text_three": "@{}@{}".format(user.localname, settings.DOMAIN),
+ "text_three": f"@{user.localname}@{settings.DOMAIN}",
}
if user.avatar:
diff --git a/bookwyrm/redis_store.py b/bookwyrm/redis_store.py
index 259bc4fd..78f373a2 100644
--- a/bookwyrm/redis_store.py
+++ b/bookwyrm/redis_store.py
@@ -33,10 +33,11 @@ class RedisStore(ABC):
# and go!
return pipeline.execute()
- def remove_object_from_related_stores(self, obj):
+ def remove_object_from_related_stores(self, obj, stores=None):
"""remove an object from all stores"""
+ stores = self.get_stores_for_object(obj) if stores is None else stores
pipeline = r.pipeline()
- for store in self.get_stores_for_object(obj):
+ for store in stores:
pipeline.zrem(store, -1, obj.id)
pipeline.execute()
@@ -50,15 +51,15 @@ class RedisStore(ABC):
pipeline.execute()
def bulk_remove_objects_from_store(self, objs, store):
- """remoev a list of objects from a given store"""
+ """remove a list of objects from a given store"""
pipeline = r.pipeline()
for obj in objs[: self.max_length]:
pipeline.zrem(store, -1, obj.id)
pipeline.execute()
- def get_store(self, store): # pylint: disable=no-self-use
+ def get_store(self, store, **kwargs): # pylint: disable=no-self-use
"""load the values in a store"""
- return r.zrevrange(store, 0, -1)
+ return r.zrevrange(store, 0, -1, **kwargs)
def populate_store(self, store):
"""go from zero to a store"""
diff --git a/bookwyrm/sanitize_html.py b/bookwyrm/sanitize_html.py
index 0be64c58..8b0e3c4c 100644
--- a/bookwyrm/sanitize_html.py
+++ b/bookwyrm/sanitize_html.py
@@ -48,7 +48,7 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
return
self.tag_stack = self.tag_stack[:-1]
- self.output.append(("tag", "%s>" % tag))
+ self.output.append(("tag", f"{tag}>"))
def handle_data(self, data):
"""extract the answer, if we're in an answer tag"""
diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py
index 17fcfabe..44d65cca 100644
--- a/bookwyrm/settings.py
+++ b/bookwyrm/settings.py
@@ -7,22 +7,14 @@ from django.utils.translation import gettext_lazy as _
env = Env()
+env.read_env()
DOMAIN = env("DOMAIN")
-VERSION = "0.0.1"
+VERSION = "0.1.0"
PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
-# celery
-CELERY_BROKER = "redis://:{}@redis_broker:{}/0".format(
- requests.utils.quote(env("REDIS_BROKER_PASSWORD", "")), env("REDIS_BROKER_PORT")
-)
-CELERY_RESULT_BACKEND = "redis://:{}@redis_broker:{}/0".format(
- requests.utils.quote(env("REDIS_BROKER_PASSWORD", "")), env("REDIS_BROKER_PORT")
-)
-CELERY_ACCEPT_CONTENT = ["application/json"]
-CELERY_TASK_SERIALIZER = "json"
-CELERY_RESULT_SERIALIZER = "json"
+JS_CACHE = "3eb4edb1"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
@@ -32,13 +24,14 @@ EMAIL_HOST_USER = env("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", True)
EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False)
-DEFAULT_FROM_EMAIL = "admin@{:s}".format(env("DOMAIN"))
+DEFAULT_FROM_EMAIL = f"admin@{DOMAIN}"
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LOCALE_PATHS = [
os.path.join(BASE_DIR, "locale"),
]
+LANGUAGE_COOKIE_NAME = env.str("LANGUAGE_COOKIE_NAME", "django_language")
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
@@ -75,6 +68,7 @@ INSTALLED_APPS = [
"django_rename_app",
"bookwyrm",
"celery",
+ "imagekit",
"storages",
]
@@ -85,7 +79,8 @@ MIDDLEWARE = [
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
- "bookwyrm.timezone_middleware.TimezoneMiddleware",
+ "bookwyrm.middleware.TimezoneMiddleware",
+ "bookwyrm.middleware.IPBlocklistMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
@@ -118,7 +113,11 @@ REDIS_ACTIVITY_PORT = env("REDIS_ACTIVITY_PORT", 6379)
REDIS_ACTIVITY_PASSWORD = env("REDIS_ACTIVITY_PASSWORD", None)
MAX_STREAM_LENGTH = int(env("MAX_STREAM_LENGTH", 200))
-STREAMS = ["home", "local", "federated"]
+
+STREAMS = [
+ {"key": "home", "name": _("Home Timeline"), "shortname": _("Home")},
+ {"key": "books", "name": _("Books Timeline"), "shortname": _("Books")},
+]
# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
@@ -130,7 +129,7 @@ DATABASES = {
"USER": env("POSTGRES_USER", "fedireads"),
"PASSWORD": env("POSTGRES_PASSWORD", "fedireads"),
"HOST": env("POSTGRES_HOST", ""),
- "PORT": env("POSTGRES_PORT", 5432),
+ "PORT": env("PGPORT", 5432),
},
}
@@ -164,11 +163,12 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = "en-us"
LANGUAGES = [
("en-us", _("English")),
- ("de-de", _("German")),
- ("es", _("Spanish")),
- ("fr-fr", _("French")),
- ("zh-hans", _("Simplified Chinese")),
- ("zh-hant", _("Traditional Chinese")),
+ ("de-de", _("Deutsch (German)")),
+ ("es-es", _("Español (Spanish)")),
+ ("fr-fr", _("Français (French)")),
+ ("pt-br", _("Português - Brasil (Brazilian Portuguese)")),
+ ("zh-hans", _("简体中文 (Simplified Chinese)")),
+ ("zh-hant", _("繁體中文 (Traditional Chinese)")),
]
@@ -181,12 +181,12 @@ USE_L10N = True
USE_TZ = True
-USER_AGENT = "%s (BookWyrm/%s; +https://%s/)" % (
- requests.utils.default_user_agent(),
- VERSION,
- DOMAIN,
-)
+agent = requests.utils.default_user_agent()
+USER_AGENT = f"{agent} (BookWyrm/{VERSION}; +https://{DOMAIN}/)"
+# Imagekit generated thumbnails
+ENABLE_THUMBNAIL_GENERATION = env.bool("ENABLE_THUMBNAIL_GENERATION", False)
+IMAGEKIT_CACHEFILE_DIR = "thumbnails"
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.2/howto/static-files/
@@ -213,12 +213,13 @@ if USE_S3:
AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"}
# S3 Static settings
STATIC_LOCATION = "static"
- STATIC_URL = "https://%s/%s/" % (AWS_S3_CUSTOM_DOMAIN, STATIC_LOCATION)
+ STATIC_URL = f"{PROTOCOL}://{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/"
STATICFILES_STORAGE = "bookwyrm.storage_backends.StaticStorage"
# S3 Media settings
MEDIA_LOCATION = "images"
- MEDIA_URL = "https://%s/%s/" % (AWS_S3_CUSTOM_DOMAIN, MEDIA_LOCATION)
+ MEDIA_URL = f"{PROTOCOL}://{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/"
MEDIA_FULL_URL = MEDIA_URL
+ STATIC_FULL_URL = STATIC_URL
DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage"
# I don't know if it's used, but the site crashes without it
STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
@@ -227,5 +228,6 @@ else:
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
MEDIA_URL = "/images/"
- MEDIA_FULL_URL = "%s://%s%s" % (PROTOCOL, DOMAIN, MEDIA_URL)
+ MEDIA_FULL_URL = f"{PROTOCOL}://{DOMAIN}{MEDIA_URL}"
+ STATIC_FULL_URL = f"{PROTOCOL}://{DOMAIN}{STATIC_URL}"
MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images"))
diff --git a/bookwyrm/signatures.py b/bookwyrm/signatures.py
index c8c90028..61cafe71 100644
--- a/bookwyrm/signatures.py
+++ b/bookwyrm/signatures.py
@@ -26,21 +26,21 @@ def make_signature(sender, destination, date, digest):
"""uses a private key to sign an outgoing message"""
inbox_parts = urlparse(destination)
signature_headers = [
- "(request-target): post %s" % inbox_parts.path,
- "host: %s" % inbox_parts.netloc,
- "date: %s" % date,
- "digest: %s" % digest,
+ f"(request-target): post {inbox_parts.path}",
+ f"host: {inbox_parts.netloc}",
+ f"date: {date}",
+ f"digest: {digest}",
]
message_to_sign = "\n".join(signature_headers)
signer = pkcs1_15.new(RSA.import_key(sender.key_pair.private_key))
signed_message = signer.sign(SHA256.new(message_to_sign.encode("utf8")))
signature = {
- "keyId": "%s#main-key" % sender.remote_id,
+ "keyId": f"{sender.remote_id}#main-key",
"algorithm": "rsa-sha256",
"headers": "(request-target) host date digest",
"signature": b64encode(signed_message).decode("utf8"),
}
- return ",".join('%s="%s"' % (k, v) for (k, v) in signature.items())
+ return ",".join(f'{k}="{v}"' for (k, v) in signature.items())
def make_digest(data):
@@ -58,7 +58,7 @@ def verify_digest(request):
elif algorithm == "SHA-512":
hash_function = hashlib.sha512
else:
- raise ValueError("Unsupported hash function: {}".format(algorithm))
+ raise ValueError(f"Unsupported hash function: {algorithm}")
expected = hash_function(request.body).digest()
if b64decode(digest) != expected:
@@ -95,18 +95,18 @@ class Signature:
def verify(self, public_key, request):
"""verify rsa signature"""
if http_date_age(request.headers["date"]) > MAX_SIGNATURE_AGE:
- raise ValueError("Request too old: %s" % (request.headers["date"],))
+ raise ValueError(f"Request too old: {request.headers['date']}")
public_key = RSA.import_key(public_key)
comparison_string = []
for signed_header_name in self.headers.split(" "):
if signed_header_name == "(request-target)":
- comparison_string.append("(request-target): post %s" % request.path)
+ comparison_string.append(f"(request-target): post {request.path}")
else:
if signed_header_name == "digest":
verify_digest(request)
comparison_string.append(
- "%s: %s" % (signed_header_name, request.headers[signed_header_name])
+ f"{signed_header_name}: {request.headers[signed_header_name]}"
)
comparison_string = "\n".join(comparison_string)
diff --git a/bookwyrm/static/css/bookwyrm.css b/bookwyrm/static/css/bookwyrm.css
index d10fb9b7..0d280fd5 100644
--- a/bookwyrm/static/css/bookwyrm.css
+++ b/bookwyrm/static/css/bookwyrm.css
@@ -29,6 +29,15 @@ body {
min-width: 75% !important;
}
+.modal-card-body {
+ max-height: 70vh;
+}
+
+.clip-text {
+ max-height: 35em;
+ overflow: hidden;
+}
+
/** Utilities not covered by Bulma
******************************************************************************/
@@ -80,6 +89,32 @@ body {
display: inline !important;
}
+input[type=file]::file-selector-button {
+ -moz-appearance: none;
+ -webkit-appearance: none;
+ background-color: #fff;
+ border-radius: 4px;
+ border: 1px solid #dbdbdb;
+ box-shadow: none;
+ color: #363636;
+ cursor: pointer;
+ font-size: 1rem;
+ height: 2.5em;
+ justify-content: center;
+ line-height: 1.5;
+ padding-bottom: calc(0.5em - 1px);
+ padding-left: 1em;
+ padding-right: 1em;
+ padding-top: calc(0.5em - 1px);
+ text-align: center;
+ white-space: nowrap;
+}
+
+input[type=file]::file-selector-button:hover {
+ border-color: #b5b5b5;
+ color: #363636;
+}
+
/** Shelving
******************************************************************************/
@@ -87,7 +122,7 @@ body {
@see https://www.youtube.com/watch?v=9xXBYcWgCHA */
.shelf-option:disabled > *::after {
font-family: "icomoon"; /* stylelint-disable font-family-no-missing-generic-family-keyword */
- content: "\e918";
+ content: "\e919"; /* icon-check */
margin-left: 0.5em;
}
@@ -158,21 +193,36 @@ body {
/* All stars are visually filled by default. */
.form-rate-stars .icon::before {
- content: '\e9d9';
+ content: '\e9d9'; /* icon-star-full */
+}
+
+/* Icons directly following half star inputs are marked as half */
+.form-rate-stars input.half:checked ~ .icon::before {
+ content: '\e9d8'; /* icon-star-half */
+}
+
+/* stylelint-disable no-descending-specificity */
+.form-rate-stars input.half:checked + input + .icon:hover::before {
+ content: '\e9d8' !important; /* icon-star-half */
+}
+
+/* Icons directly following half check inputs that follow the checked input are emptied. */
+.form-rate-stars input.half:checked + input + .icon ~ .icon::before {
+ content: '\e9d7'; /* icon-star-empty */
}
/* Icons directly following inputs that follow the checked input are emptied. */
.form-rate-stars input:checked ~ input + .icon::before {
- content: '\e9d7';
+ content: '\e9d7'; /* icon-star-empty */
}
/* When a label is hovered, repeat the fill-all-then-empty-following pattern. */
.form-rate-stars:hover .icon.icon::before {
- content: '\e9d9';
+ content: '\e9d9' !important; /* icon-star-full */
}
.form-rate-stars .icon:hover ~ .icon::before {
- content: '\e9d7';
+ content: '\e9d7' !important; /* icon-star-empty */
}
/** Book covers
@@ -227,16 +277,21 @@ body {
/* Cover caption
* -------------------------------------------------------------------------- */
-.no-cover .cover_caption {
+.no-cover .cover-caption {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
- padding: 0.25em;
+ padding: 0.5em;
font-size: 0.75em;
color: white;
background-color: #002549;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ white-space: initial;
+ text-align: center;
}
/** Avatars
@@ -278,17 +333,59 @@ body {
}
.quote > blockquote::before {
- content: "\e906";
+ content: "\e907"; /* icon-quote-open */
top: 0;
left: 0;
}
.quote > blockquote::after {
- content: "\e905";
+ content: "\e906"; /* icon-quote-close */
right: 0;
}
-/* States
+/** Animations and transitions
+ ******************************************************************************/
+
+@keyframes turning {
+ from { transform: rotateZ(0deg); }
+ to { transform: rotateZ(360deg); }
+}
+
+.is-processing .icon-spinner::before {
+ animation: turning 1.5s infinite linear;
+}
+
+.icon-spinner {
+ display: none;
+}
+
+.is-processing .icon-spinner {
+ display: flex;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .is-processing .icon::before {
+ transition-duration: 0.001ms !important;
+ }
+}
+
+/** Transient notification
+ ******************************************************************************/
+
+#live-messages {
+ position: fixed;
+ bottom: 1em;
+ right: 1em;
+}
+
+/** Tooltips
+ ******************************************************************************/
+
+.tooltip {
+ width: 100%;
+}
+
+/** States
******************************************************************************/
/* "disabled" for non-buttons */
@@ -395,6 +492,37 @@ ol.ordered-list li::before {
}
}
+/* Threads
+ ******************************************************************************/
+
+.thread .is-main .card {
+ box-shadow: 0 0.5em 1em -0.125em rgb(50 115 220 / 35%), 0 0 0 1px rgb(50 115 220 / 2%);
+}
+
+.thread::after {
+ content: "";
+ position: absolute;
+ z-index: -1;
+ top: 0;
+ bottom: 0;
+ left: 2.5em;
+ border-left: 2px solid #e0e0e0;
+}
+
+/* Breadcrumbs
+ ******************************************************************************/
+
+.breadcrumb li:first-child * {
+ padding-left: 0;
+}
+
+.breadcrumb li > * {
+ align-items: center;
+ display: flex;
+ justify-content: center;
+ padding: 0 0.75em;
+}
+
/* Dimensions
* @todo These could be in rem.
******************************************************************************/
diff --git a/bookwyrm/static/css/fonts/icomoon.eot b/bookwyrm/static/css/fonts/icomoon.eot
index 566fb13d..8eba8692 100644
Binary files a/bookwyrm/static/css/fonts/icomoon.eot and b/bookwyrm/static/css/fonts/icomoon.eot differ
diff --git a/bookwyrm/static/css/fonts/icomoon.svg b/bookwyrm/static/css/fonts/icomoon.svg
index 6be97327..82e41329 100644
--- a/bookwyrm/static/css/fonts/icomoon.svg
+++ b/bookwyrm/static/css/fonts/icomoon.svg
@@ -7,39 +7,39 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
diff --git a/bookwyrm/static/css/fonts/icomoon.ttf b/bookwyrm/static/css/fonts/icomoon.ttf
index 55df6418..5bf90a0a 100644
Binary files a/bookwyrm/static/css/fonts/icomoon.ttf and b/bookwyrm/static/css/fonts/icomoon.ttf differ
diff --git a/bookwyrm/static/css/fonts/icomoon.woff b/bookwyrm/static/css/fonts/icomoon.woff
index fa53e8cf..6ce6834d 100644
Binary files a/bookwyrm/static/css/fonts/icomoon.woff and b/bookwyrm/static/css/fonts/icomoon.woff differ
diff --git a/bookwyrm/static/css/vendor/icons.css b/bookwyrm/static/css/vendor/icons.css
index c78af145..b43224e3 100644
--- a/bookwyrm/static/css/vendor/icons.css
+++ b/bookwyrm/static/css/vendor/icons.css
@@ -1,156 +1,144 @@
-
-/** @todo Replace icons with SVG symbols.
- @see https://www.youtube.com/watch?v=9xXBYcWgCHA */
@font-face {
- font-family: 'icomoon';
- src: url('../fonts/icomoon.eot?n5x55');
- src: url('../fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'),
- url('../fonts/icomoon.ttf?n5x55') format('truetype'),
- url('../fonts/icomoon.woff?n5x55') format('woff'),
- url('../fonts/icomoon.svg?n5x55#icomoon') format('svg');
- font-weight: normal;
- font-style: normal;
- font-display: block;
+ font-family: 'icomoon';
+ src: url('../fonts/icomoon.eot?36x4a3');
+ src: url('../fonts/icomoon.eot?36x4a3#iefix') format('embedded-opentype'),
+ url('../fonts/icomoon.ttf?36x4a3') format('truetype'),
+ url('../fonts/icomoon.woff?36x4a3') format('woff'),
+ url('../fonts/icomoon.svg?36x4a3#icomoon') format('svg');
+ font-weight: normal;
+ font-style: normal;
+ font-display: block;
}
[class^="icon-"], [class*=" icon-"] {
- /* use !important to prevent issues with browser extensions that change fonts */
- font-family: 'icomoon' !important;
- speak: never;
- font-style: normal;
- font-weight: normal;
- font-variant: normal;
- text-transform: none;
- line-height: 1;
+ /* use !important to prevent issues with browser extensions that change fonts */
+ font-family: 'icomoon' !important;
+ speak: never;
+ font-style: normal;
+ font-weight: normal;
+ font-variant: normal;
+ text-transform: none;
+ line-height: 1;
- /* Better Font Rendering =========== */
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
+ /* Better Font Rendering =========== */
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
}
-.icon-graphic-heart:before {
- content: "\e91e";
-}
-.icon-graphic-paperplane:before {
- content: "\e91f";
-}
-.icon-graphic-banknote:before {
- content: "\e920";
-}
-.icon-stars:before {
- content: "\e91a";
-}
-.icon-warning:before {
- content: "\e91b";
-}
.icon-book:before {
- content: "\e900";
-}
-.icon-bookmark:before {
- content: "\e91c";
-}
-.icon-rss:before {
- content: "\e91d";
+ content: "\e901";
}
.icon-envelope:before {
- content: "\e901";
+ content: "\e902";
}
.icon-arrow-right:before {
- content: "\e902";
+ content: "\e903";
}
.icon-bell:before {
- content: "\e903";
+ content: "\e904";
}
.icon-x:before {
- content: "\e904";
+ content: "\e905";
}
.icon-quote-close:before {
- content: "\e905";
+ content: "\e906";
}
.icon-quote-open:before {
- content: "\e906";
+ content: "\e907";
}
.icon-image:before {
- content: "\e907";
+ content: "\e908";
}
.icon-pencil:before {
- content: "\e908";
+ content: "\e909";
}
.icon-list:before {
- content: "\e909";
+ content: "\e90a";
}
.icon-unlock:before {
- content: "\e90a";
-}
-.icon-unlisted:before {
- content: "\e90a";
+ content: "\e90b";
}
.icon-globe:before {
- content: "\e90b";
-}
-.icon-public:before {
- content: "\e90b";
+ content: "\e90c";
}
.icon-lock:before {
- content: "\e90c";
-}
-.icon-followers:before {
- content: "\e90c";
+ content: "\e90d";
}
.icon-chain-broken:before {
- content: "\e90d";
+ content: "\e90e";
}
.icon-chain:before {
- content: "\e90e";
+ content: "\e90f";
}
.icon-comments:before {
- content: "\e90f";
+ content: "\e910";
}
.icon-comment:before {
- content: "\e910";
+ content: "\e911";
}
.icon-boost:before {
- content: "\e911";
+ content: "\e912";
}
.icon-arrow-left:before {
- content: "\e912";
+ content: "\e913";
}
.icon-arrow-up:before {
- content: "\e913";
+ content: "\e914";
}
.icon-arrow-down:before {
- content: "\e914";
-}
-.icon-home:before {
- content: "\e915";
+ content: "\e915";
}
.icon-local:before {
- content: "\e916";
+ content: "\e917";
}
.icon-dots-three:before {
- content: "\e917";
+ content: "\e918";
}
.icon-check:before {
- content: "\e918";
+ content: "\e919";
}
.icon-dots-three-vertical:before {
- content: "\e919";
+ content: "\e91a";
+}
+.icon-bookmark:before {
+ content: "\e91b";
+}
+.icon-warning:before {
+ content: "\e91c";
+}
+.icon-rss:before {
+ content: "\e91d";
+}
+.icon-graphic-heart:before {
+ content: "\e91e";
+}
+.icon-graphic-paperplane:before {
+ content: "\e91f";
+}
+.icon-graphic-banknote:before {
+ content: "\e920";
}
.icon-search:before {
- content: "\e986";
+ content: "\e986";
}
.icon-star-empty:before {
- content: "\e9d7";
+ content: "\e9d7";
}
.icon-star-half:before {
- content: "\e9d8";
+ content: "\e9d8";
}
.icon-star-full:before {
- content: "\e9d9";
+ content: "\e9d9";
}
.icon-heart:before {
- content: "\e9da";
+ content: "\e9da";
}
.icon-plus:before {
- content: "\ea0a";
+ content: "\ea0a";
+}
+.icon-question-circle:before {
+ content: "\e900";
+}
+.icon-spinner:before {
+ content: "\e97a";
}
diff --git a/bookwyrm/static/js/block_href.js b/bookwyrm/static/js/block_href.js
new file mode 100644
index 00000000..fc20a6ab
--- /dev/null
+++ b/bookwyrm/static/js/block_href.js
@@ -0,0 +1,21 @@
+/* exported BlockHref */
+
+let BlockHref = new class {
+ constructor() {
+ document.querySelectorAll('[data-href]')
+ .forEach(t => t.addEventListener('click', this.followLink.bind(this)));
+ }
+
+ /**
+ * Follow a fake link
+ *
+ * @param {Event} event
+ * @return {undefined}
+ */
+ followLink(event) {
+ const url = event.currentTarget.dataset.href;
+
+ window.location.href = url;
+ }
+}();
+
diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js
index e43ed134..2d5b88ad 100644
--- a/bookwyrm/static/js/bookwyrm.js
+++ b/bookwyrm/static/js/bookwyrm.js
@@ -28,6 +28,12 @@ let BookWyrm = new class {
this.revealForm.bind(this))
);
+ document.querySelectorAll('[data-hides]')
+ .forEach(button => button.addEventListener(
+ 'change',
+ this.hideForm.bind(this))
+ );
+
document.querySelectorAll('[data-back]')
.forEach(button => button.addEventListener(
'click',
@@ -119,8 +125,8 @@ let BookWyrm = new class {
}
/**
- * Toggle form.
- *
+ * Show form.
+ *
* @param {Event} event
* @return {undefined}
*/
@@ -128,7 +134,23 @@ let BookWyrm = new class {
let trigger = event.currentTarget;
let hidden = trigger.closest('.hidden-form').querySelectorAll('.is-hidden')[0];
- this.addRemoveClass(hidden, 'is-hidden', !hidden);
+ if (hidden) {
+ this.addRemoveClass(hidden, 'is-hidden', !hidden);
+ }
+ }
+
+ /**
+ * Hide form.
+ *
+ * @param {Event} event
+ * @return {undefined}
+ */
+ hideForm(event) {
+ let trigger = event.currentTarget;
+ let targetId = trigger.dataset.hides
+ let visible = document.getElementById(targetId)
+
+ this.addRemoveClass(visible, 'is-hidden', true);
}
/**
@@ -138,8 +160,11 @@ let BookWyrm = new class {
* @return {undefined}
*/
toggleAction(event) {
- event.preventDefault();
let trigger = event.currentTarget;
+
+ if (!trigger.dataset.allowDefault || event.currentTarget == event.target) {
+ event.preventDefault();
+ }
let pressed = trigger.getAttribute('aria-pressed') === 'false';
let targetId = trigger.dataset.controls;
@@ -164,7 +189,7 @@ let BookWyrm = new class {
}
// Show/hide container.
- let container = document.getElementById('hide-' + targetId);
+ let container = document.getElementById('hide_' + targetId);
if (container) {
this.toggleContainer(container, pressed);
@@ -177,6 +202,13 @@ let BookWyrm = new class {
this.toggleCheckbox(checkbox, pressed);
}
+ // Toggle form disabled, if appropriate
+ let disable = trigger.dataset.disables;
+
+ if (disable) {
+ this.toggleDisabled(disable, !pressed);
+ }
+
// Set focus, if appropriate.
let focus = trigger.dataset.focusTarget;
@@ -217,9 +249,9 @@ let BookWyrm = new class {
}
/**
- * Check or uncheck a checbox.
+ * Check or uncheck a checkbox.
*
- * @param {object} checkbox - DOM node
+ * @param {string} checkbox - id of the checkbox
* @param {boolean} pressed - Is the trigger pressed?
* @return {undefined}
*/
@@ -227,6 +259,17 @@ let BookWyrm = new class {
document.getElementById(checkbox).checked = !!pressed;
}
+ /**
+ * Enable or disable a form element or fieldset
+ *
+ * @param {string} form_element - id of the element
+ * @param {boolean} pressed - Is the trigger pressed?
+ * @return {undefined}
+ */
+ toggleDisabled(form_element, pressed) {
+ document.getElementById(form_element).disabled = !!pressed;
+ }
+
/**
* Give the focus to an element.
* Only move the focus based on user interactions.
@@ -280,7 +323,10 @@ let BookWyrm = new class {
ajaxPost(form) {
return fetch(form.action, {
method : "POST",
- body: new FormData(form)
+ body: new FormData(form),
+ headers: {
+ 'Accept': 'application/json',
+ }
});
}
diff --git a/bookwyrm/static/js/status_cache.js b/bookwyrm/static/js/status_cache.js
new file mode 100644
index 00000000..2a50bfcb
--- /dev/null
+++ b/bookwyrm/static/js/status_cache.js
@@ -0,0 +1,238 @@
+/* exported StatusCache */
+/* globals BookWyrm */
+
+let StatusCache = new class {
+ constructor() {
+ document.querySelectorAll('[data-cache-draft]')
+ .forEach(t => t.addEventListener('change', this.updateDraft.bind(this)));
+
+ document.querySelectorAll('[data-cache-draft]')
+ .forEach(t => this.populateDraft(t));
+
+ document.querySelectorAll('.submit-status')
+ .forEach(button => button.addEventListener(
+ 'submit',
+ this.submitStatus.bind(this))
+ );
+
+ document.querySelectorAll('.form-rate-stars label.icon')
+ .forEach(button => button.addEventListener('click', this.toggleStar.bind(this)));
+ }
+
+ /**
+ * Update localStorage copy of drafted status
+ *
+ * @param {Event} event
+ * @return {undefined}
+ */
+ updateDraft(event) {
+ // Used in set reading goal
+ let key = event.target.dataset.cacheDraft;
+ let value = event.target.value;
+
+ if (!value) {
+ window.localStorage.removeItem(key);
+
+ return;
+ }
+
+ window.localStorage.setItem(key, value);
+ }
+
+ /**
+ * Toggle display of a DOM node based on its value in the localStorage.
+ *
+ * @param {object} node - DOM node to toggle.
+ * @return {undefined}
+ */
+ populateDraft(node) {
+ // Used in set reading goal
+ let key = node.dataset.cacheDraft;
+ let value = window.localStorage.getItem(key);
+
+ if (!value) {
+ return;
+ }
+
+ node.value = value;
+ }
+
+ /**
+ * Post a status with ajax
+ *
+ * @param {} event
+ * @return {undefined}
+ */
+ submitStatus(event) {
+ const form = event.currentTarget;
+ let trigger = event.submitter;
+
+ // Safari doesn't understand "submitter"
+ if (!trigger) {
+ trigger = event.currentTarget.querySelector("button[type=submit]");
+ }
+
+ // This allows the form to submit in the old fashioned way if there's a problem
+
+ if (!trigger || !form) {
+ return;
+ }
+
+ event.preventDefault();
+
+ BookWyrm.addRemoveClass(form, 'is-processing', true);
+ trigger.setAttribute('disabled', null);
+
+ BookWyrm.ajaxPost(form).finally(() => {
+ // Change icon to remove ongoing activity on the current UI.
+ // Enable back the element used to submit the form.
+ BookWyrm.addRemoveClass(form, 'is-processing', false);
+ trigger.removeAttribute('disabled');
+ })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error();
+ }
+ this.submitStatusSuccess(form);
+ })
+ .catch(error => {
+ console.warn(error);
+ this.announceMessage('status-error-message');
+ });
+ }
+
+ /**
+ * Show a message in the live region
+ *
+ * @param {String} the id of the message dom element
+ * @return {undefined}
+ */
+ announceMessage(message_id) {
+ const element = document.getElementById(message_id);
+ let copy = element.cloneNode(true);
+
+ copy.id = null;
+ element.insertAdjacentElement('beforebegin', copy);
+
+ BookWyrm.addRemoveClass(copy, 'is-hidden', false);
+ setTimeout(function() {
+ copy.remove();
+ }, 10000, copy);
+ }
+
+ /**
+ * Success state for a posted status
+ *
+ * @param {Object} the html form that was submitted
+ * @return {undefined}
+ */
+ submitStatusSuccess(form) {
+ // Clear form data
+ form.reset();
+
+ // Clear localstorage
+ form.querySelectorAll('[data-cache-draft]')
+ .forEach(node => window.localStorage.removeItem(node.dataset.cacheDraft));
+
+ // Close modals
+ let modal = form.closest(".modal.is-active");
+
+ if (modal) {
+ modal.getElementsByClassName("modal-close")[0].click();
+
+ // Update shelve buttons
+ if (form.reading_status) {
+ document.querySelectorAll("[data-shelve-button-book='" + form.book.value +"']")
+ .forEach(button => this.cycleShelveButtons(button, form.reading_status.value));
+ }
+
+ return;
+ }
+
+ // Close reply panel
+ let reply = form.closest(".reply-panel");
+
+ if (reply) {
+ document.querySelector("[data-controls=" + reply.id + "]").click();
+ }
+
+ this.announceMessage('status-success-message');
+ }
+
+ /**
+ * Change which buttons are available for a shelf
+ *
+ * @param {Object} html button dom element
+ * @param {String} the identifier of the selected shelf
+ * @return {undefined}
+ */
+ cycleShelveButtons(button, identifier) {
+ // Pressed button
+ let shelf = button.querySelector("[data-shelf-identifier='" + identifier + "']");
+ let next_identifier = shelf.dataset.shelfNext;
+
+ // Set all buttons to hidden
+ button.querySelectorAll("[data-shelf-identifier]")
+ .forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", true));
+
+ // Button that should be visible now
+ let next = button.querySelector("[data-shelf-identifier=" + next_identifier + "]");
+
+ // Show the desired button
+ BookWyrm.addRemoveClass(next, "is-hidden", false);
+
+ // ------ update the dropdown buttons
+ // Remove existing hidden class
+ button.querySelectorAll("[data-shelf-dropdown-identifier]")
+ .forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", false));
+
+ // Remove existing disabled states
+ button.querySelectorAll("[data-shelf-dropdown-identifier] button")
+ .forEach(item => item.disabled = false);
+
+ next_identifier = next_identifier == 'complete' ? 'read' : next_identifier;
+
+ // Disable the current state
+ button.querySelector(
+ "[data-shelf-dropdown-identifier=" + identifier + "] button"
+ ).disabled = true;
+
+ let main_button = button.querySelector(
+ "[data-shelf-dropdown-identifier=" + next_identifier + "]"
+ );
+
+ // Hide the option that's shown as the main button
+ BookWyrm.addRemoveClass(main_button, "is-hidden", true);
+
+ // Just hide the other two menu options, idk what to do with them
+ button.querySelectorAll("[data-extra-options]")
+ .forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", true));
+
+ // Close menu
+ let menu = button.querySelector(".dropdown-trigger[aria-expanded=true]");
+
+ if (menu) {
+ menu.click();
+ }
+ }
+
+ /**
+ * Reveal half-stars
+ *
+ * @param {Event} event
+ * @return {undefined}
+ */
+ toggleStar(event) {
+ const label = event.currentTarget;
+ let wholeStar = document.getElementById(label.getAttribute("for"));
+
+ if (wholeStar.checked) {
+ event.preventDefault();
+ let halfStar = document.getElementById(label.dataset.forHalf);
+
+ wholeStar.checked = null;
+ halfStar.checked = "checked";
+ }
+ }
+}();
+
diff --git a/bookwyrm/storage_backends.py b/bookwyrm/storage_backends.py
index e10dfb84..4fb0feff 100644
--- a/bookwyrm/storage_backends.py
+++ b/bookwyrm/storage_backends.py
@@ -1,4 +1,6 @@
"""Handles backends for storages"""
+import os
+from tempfile import SpooledTemporaryFile
from storages.backends.s3boto3 import S3Boto3Storage
@@ -15,3 +17,33 @@ class ImagesStorage(S3Boto3Storage): # pylint: disable=abstract-method
location = "images"
default_acl = "public-read"
file_overwrite = False
+
+ """
+ This is our custom version of S3Boto3Storage that fixes a bug in
+ boto3 where the passed in file is closed upon upload.
+ From:
+ https://github.com/matthewwithanm/django-imagekit/issues/391#issuecomment-275367006
+ https://github.com/boto/boto3/issues/929
+ https://github.com/matthewwithanm/django-imagekit/issues/391
+ """
+
+ def _save(self, name, content):
+ """
+ We create a clone of the content file as when this is passed to
+ boto3 it wrongly closes the file upon upload where as the storage
+ backend expects it to still be open
+ """
+ # Seek our content back to the start
+ content.seek(0, os.SEEK_SET)
+
+ # Create a temporary file that will write to disk after a specified
+ # size. This file will be automatically deleted when closed by
+ # boto3 or after exiting the `with` statement if the boto3 is fixed
+ with SpooledTemporaryFile() as content_autoclose:
+
+ # Write our original content into our copy that will be closed by boto3
+ content_autoclose.write(content.read())
+
+ # Upload the object which will auto close the
+ # content_autoclose instance
+ return super()._save(name, content_autoclose)
diff --git a/bookwyrm/suggested_users.py b/bookwyrm/suggested_users.py
new file mode 100644
index 00000000..86c181a2
--- /dev/null
+++ b/bookwyrm/suggested_users.py
@@ -0,0 +1,277 @@
+""" store recommended follows in redis """
+import math
+import logging
+from django.dispatch import receiver
+from django.db.models import signals, Count, Q
+
+from bookwyrm import models
+from bookwyrm.redis_store import RedisStore, r
+from bookwyrm.tasks import app
+
+
+logger = logging.getLogger(__name__)
+
+
+class SuggestedUsers(RedisStore):
+ """suggested users for a user"""
+
+ max_length = 30
+
+ def get_rank(self, obj):
+ """get computed rank"""
+ return obj.mutuals # + (1.0 - (1.0 / (obj.shared_books + 1)))
+
+ def store_id(self, user): # pylint: disable=no-self-use
+ """the key used to store this user's recs"""
+ if isinstance(user, int):
+ return f"{user}-suggestions"
+ return f"{user.id}-suggestions"
+
+ def get_counts_from_rank(self, rank): # pylint: disable=no-self-use
+ """calculate mutuals count and shared books count from rank"""
+ return {
+ "mutuals": math.floor(rank),
+ # "shared_books": int(1 / (-1 * (rank % 1 - 1))) - 1,
+ }
+
+ def get_objects_for_store(self, store):
+ """a list of potential follows for a user"""
+ user = models.User.objects.get(id=store.split("-")[0])
+
+ return get_annotated_users(
+ user,
+ ~Q(id=user.id),
+ ~Q(followers=user),
+ ~Q(follower_requests=user),
+ bookwyrm_user=True,
+ )
+
+ def get_stores_for_object(self, obj):
+ return [self.store_id(u) for u in self.get_users_for_object(obj)]
+
+ def get_users_for_object(self, obj): # pylint: disable=no-self-use
+ """given a user, who might want to follow them"""
+ return models.User.objects.filter(local=True,).exclude(
+ Q(id=obj.id) | Q(followers=obj) | Q(id__in=obj.blocks.all()) | Q(blocks=obj)
+ )
+
+ def rerank_obj(self, obj, update_only=True):
+ """update all the instances of this user with new ranks"""
+ pipeline = r.pipeline()
+ for store_user in self.get_users_for_object(obj):
+ annotated_user = get_annotated_users(
+ store_user,
+ id=obj.id,
+ ).first()
+ if not annotated_user:
+ continue
+
+ pipeline.zadd(
+ self.store_id(store_user),
+ self.get_value(annotated_user),
+ xx=update_only,
+ )
+ pipeline.execute()
+
+ def rerank_user_suggestions(self, user):
+ """update the ranks of the follows suggested to a user"""
+ self.populate_store(self.store_id(user))
+
+ def remove_suggestion(self, user, suggested_user):
+ """take a user out of someone's suggestions"""
+ self.bulk_remove_objects_from_store([suggested_user], self.store_id(user))
+
+ def get_suggestions(self, user, local=False):
+ """get suggestions"""
+ values = self.get_store(self.store_id(user), withscores=True)
+ results = []
+ # annotate users with mutuals and shared book counts
+ for user_id, rank in values:
+ counts = self.get_counts_from_rank(rank)
+ try:
+ user = models.User.objects.get(
+ id=user_id, is_active=True, bookwyrm_user=True
+ )
+ except models.User.DoesNotExist as err:
+ # if this happens, the suggestions are janked way up
+ logger.exception(err)
+ continue
+ user.mutuals = counts["mutuals"]
+ if (local and user.local) or not local:
+ results.append(user)
+ if len(results) >= 5:
+ break
+ return results
+
+
+def get_annotated_users(viewer, *args, **kwargs):
+ """Users, annotated with things they have in common"""
+ return (
+ models.User.objects.filter(discoverable=True, is_active=True, *args, **kwargs)
+ .exclude(Q(id__in=viewer.blocks.all()) | Q(blocks=viewer))
+ .annotate(
+ mutuals=Count(
+ "followers",
+ filter=Q(
+ ~Q(id=viewer.id),
+ ~Q(id__in=viewer.following.all()),
+ followers__in=viewer.following.all(),
+ ),
+ distinct=True,
+ ),
+ # shared_books=Count(
+ # "shelfbook",
+ # filter=Q(
+ # ~Q(id=viewer.id),
+ # shelfbook__book__parent_work__in=[
+ # s.book.parent_work for s in viewer.shelfbook_set.all()
+ # ],
+ # ),
+ # distinct=True,
+ # ),
+ )
+ )
+
+
+suggested_users = SuggestedUsers()
+
+
+@receiver(signals.post_save, sender=models.UserFollows)
+# pylint: disable=unused-argument
+def update_suggestions_on_follow(sender, instance, created, *args, **kwargs):
+ """remove a follow from the recs and update the ranks"""
+ if not created or not instance.user_object.discoverable:
+ return
+
+ if instance.user_subject.local:
+ remove_suggestion_task.delay(instance.user_subject.id, instance.user_object.id)
+ rerank_user_task.delay(instance.user_object.id, update_only=False)
+
+
+@receiver(signals.post_save, sender=models.UserFollowRequest)
+# pylint: disable=unused-argument
+def update_suggestions_on_follow_request(sender, instance, created, *args, **kwargs):
+ """remove a follow from the recs and update the ranks"""
+ if not created or not instance.user_object.discoverable:
+ return
+
+ if instance.user_subject.local:
+ remove_suggestion_task.delay(instance.user_subject.id, instance.user_object.id)
+
+
+@receiver(signals.post_save, sender=models.UserBlocks)
+# pylint: disable=unused-argument
+def update_suggestions_on_block(sender, instance, *args, **kwargs):
+ """remove blocked users from recs"""
+ if instance.user_subject.local and instance.user_object.discoverable:
+ remove_suggestion_task.delay(instance.user_subject.id, instance.user_object.id)
+ if instance.user_object.local and instance.user_subject.discoverable:
+ remove_suggestion_task.delay(instance.user_object.id, instance.user_subject.id)
+
+
+@receiver(signals.post_delete, sender=models.UserFollows)
+# pylint: disable=unused-argument
+def update_suggestions_on_unfollow(sender, instance, **kwargs):
+ """update rankings, but don't re-suggest because it was probably intentional"""
+ if instance.user_object.discoverable:
+ rerank_user_task.delay(instance.user_object.id, update_only=False)
+
+
+# @receiver(signals.post_save, sender=models.ShelfBook)
+# @receiver(signals.post_delete, sender=models.ShelfBook)
+# # pylint: disable=unused-argument
+# def update_rank_on_shelving(sender, instance, *args, **kwargs):
+# """when a user shelves or unshelves a book, re-compute their rank"""
+# # if it's a local user, re-calculate who is rec'ed to them
+# if instance.user.local:
+# rerank_suggestions_task.delay(instance.user.id)
+#
+# # if the user is discoverable, update their rankings
+# if instance.user.discoverable:
+# rerank_user_task.delay(instance.user.id)
+
+
+@receiver(signals.post_save, sender=models.User)
+# pylint: disable=unused-argument, too-many-arguments
+def update_user(sender, instance, created, update_fields=None, **kwargs):
+ """an updated user, neat"""
+ # a new user is found, create suggestions for them
+ if created and instance.local:
+ rerank_suggestions_task.delay(instance.id)
+
+ # we know what fields were updated and discoverability didn't change
+ if not instance.bookwyrm_user or (
+ update_fields and not "discoverable" in update_fields
+ ):
+ return
+
+ # deleted the user
+ if not created and not instance.is_active:
+ remove_user_task.delay(instance.id)
+ return
+
+ # this happens on every save, not just when discoverability changes, annoyingly
+ if instance.discoverable:
+ rerank_user_task.delay(instance.id, update_only=False)
+ elif not created:
+ remove_user_task.delay(instance.id)
+
+
+@receiver(signals.post_save, sender=models.FederatedServer)
+def domain_level_update(sender, instance, created, update_fields=None, **kwargs):
+ """remove users on a domain block"""
+ if (
+ not update_fields
+ or "status" not in update_fields
+ or instance.application_type != "bookwyrm"
+ ):
+ return
+
+ if instance.status == "blocked":
+ bulk_remove_instance_task.delay(instance.id)
+ return
+ bulk_add_instance_task.delay(instance.id)
+
+
+# ------------------- TASKS
+
+
+@app.task(queue="low_priority")
+def rerank_suggestions_task(user_id):
+ """do the hard work in celery"""
+ suggested_users.rerank_user_suggestions(user_id)
+
+
+@app.task(queue="low_priority")
+def rerank_user_task(user_id, update_only=False):
+ """do the hard work in celery"""
+ user = models.User.objects.get(id=user_id)
+ suggested_users.rerank_obj(user, update_only=update_only)
+
+
+@app.task(queue="low_priority")
+def remove_user_task(user_id):
+ """do the hard work in celery"""
+ user = models.User.objects.get(id=user_id)
+ suggested_users.remove_object_from_related_stores(user)
+
+
+@app.task(queue="medium_priority")
+def remove_suggestion_task(user_id, suggested_user_id):
+ """remove a specific user from a specific user's suggestions"""
+ suggested_user = models.User.objects.get(id=suggested_user_id)
+ suggested_users.remove_suggestion(user_id, suggested_user)
+
+
+@app.task(queue="low_priority")
+def bulk_remove_instance_task(instance_id):
+ """remove a bunch of users from recs"""
+ for user in models.User.objects.filter(federated_server__id=instance_id):
+ suggested_users.remove_object_from_related_stores(user)
+
+
+@app.task(queue="low_priority")
+def bulk_add_instance_task(instance_id):
+ """remove a bunch of users from recs"""
+ for user in models.User.objects.filter(federated_server__id=instance_id):
+ suggested_users.rerank_obj(user, update_only=False)
diff --git a/bookwyrm/tasks.py b/bookwyrm/tasks.py
index 6d1992a7..b860e018 100644
--- a/bookwyrm/tasks.py
+++ b/bookwyrm/tasks.py
@@ -2,10 +2,10 @@
import os
from celery import Celery
-from bookwyrm import settings
+from celerywyrm import settings
# set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "celerywyrm.settings")
app = Celery(
- "tasks", broker=settings.CELERY_BROKER, backend=settings.CELERY_RESULT_BACKEND
+ "tasks", broker=settings.CELERY_BROKER_URL, backend=settings.CELERY_RESULT_BACKEND
)
diff --git a/bookwyrm/templates/author/author.html b/bookwyrm/templates/author/author.html
index 0bc42775..8a15cd0f 100644
--- a/bookwyrm/templates/author/author.html
+++ b/bookwyrm/templates/author/author.html
@@ -22,7 +22,7 @@
-
+
{% if author.aliases or author.born or author.died or author.wikipedia_link or author.openlibrary_key or author.inventaire_id %}
@@ -57,7 +57,7 @@
{% if author.wikipedia_link %}
-
+
{% trans "Wikipedia" %}
@@ -70,7 +70,7 @@
{% endif %}
-
+
{% if author.inventaire_id %}
@@ -86,7 +86,7 @@
{% endif %}
-
+
{% if author.goodreads_key %}
@@ -109,7 +109,7 @@
{% for book in books %}
- {% include 'discover/small-book.html' with book=book %}
+ {% include 'landing/small-book.html' with book=book %}
{% endfor %}
diff --git a/bookwyrm/templates/author/edit_author.html b/bookwyrm/templates/author/edit_author.html
index 103341bf..54d7f4f1 100644
--- a/bookwyrm/templates/author/edit_author.html
+++ b/bookwyrm/templates/author/edit_author.html
@@ -12,7 +12,9 @@
{% trans "Added:" %} {{ author.created_date | naturaltime }}
{% trans "Updated:" %} {{ author.updated_date | naturaltime }}
+ {% if author.last_edited_by %}
{% trans "Last edited by:" %} {{ author.last_edited_by.display_name }}
+ {% endif %}
diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html
index c5dab109..36241ee2 100644
--- a/bookwyrm/templates/book/book.html
+++ b/bookwyrm/templates/book/book.html
@@ -4,7 +4,6 @@
{% load humanize %}
{% load utilities %}
{% load static %}
-{% load layout %}
{% block title %}{{ book|book_title }}{% endblock %}
@@ -43,7 +42,7 @@
{% endif %}
- {% if book.authors %}
+ {% if book.authors.exists %}
{% trans "by" %} {% include 'snippets/authors.html' with book=book %}
@@ -62,7 +61,7 @@
- {% include 'snippets/book_cover.html' with book=book cover_class='is-h-m-mobile' %}
+ {% include 'snippets/book_cover.html' with size='xxlarge' size_mobile='medium' book=book cover_class='is-h-m-mobile' %}
{% include 'snippets/rate_action.html' with user=request.user book=book %}
@@ -72,8 +71,8 @@
{% if user_authenticated and not book.cover %}
{% trans "Add cover" as button_text %}
- {% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="add-cover" controls_uid=book.id focus="modal-title-add-cover" class="is-small" %}
- {% include 'book/cover_modal.html' with book=book controls_text="add-cover" controls_uid=book.id %}
+ {% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="add_cover" controls_uid=book.id focus="modal_title_add_cover" class="is-small" %}
+ {% include 'book/cover_modal.html' with book=book controls_text="add_cover" controls_uid=book.id %}
{% if request.GET.cover_error %}
{% trans "Failed to load cover" %}
{% endif %}
@@ -128,19 +127,19 @@
{% if user_authenticated and can_edit_book and not book|book_description %}
{% trans 'Add Description' as button_text %}
- {% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add-description" controls_uid=book.id focus="id_description" hide_active=True id="hide-description" %}
+ {% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add_description" controls_uid=book.id focus="id_description" hide_active=True id="hide_description" %}
-
{% trans "Add read dates" as button_text %}
- {% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="plus" class="is-small" controls_text="add-readthrough" focus="add-readthrough-focus" %}
+ {% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="plus" class="is-small" controls_text="add_readthrough" focus="add_readthrough_focus_" %}
-
+
{% include 'snippets/readthrough_form.html' with readthrough=None %}
@@ -189,7 +188,7 @@
{% trans "Cancel" as button_text %}
- {% include 'snippets/toggle/close_button.html' with text=button_text controls_text="add-readthrough" %}
+ {% include 'snippets/toggle/close_button.html' with text=button_text controls_text="add_readthrough" %}
@@ -204,7 +203,9 @@
+ {% with 0|uuid as controls_uid %}
{% include 'snippets/create_status.html' with book=book hide_cover=True %}
+ {% endwith %}
{% endif %}
@@ -326,5 +327,5 @@
{% endblock %}
{% block scripts %}
-
+
{% endblock %}
diff --git a/bookwyrm/templates/book/book_identifiers.html b/bookwyrm/templates/book/book_identifiers.html
index 6021d243..19ab619d 100644
--- a/bookwyrm/templates/book/book_identifiers.html
+++ b/bookwyrm/templates/book/book_identifiers.html
@@ -1,6 +1,7 @@
{% spaceless %}
{% load i18n %}
+{% if book.isbn_13 or book.oclc_number or book.asin %}
{% if book.isbn_13 %}
@@ -23,4 +24,5 @@
{% endif %}
+{% endif %}
{% endspaceless %}
diff --git a/bookwyrm/templates/book/edit/edit_book.html b/bookwyrm/templates/book/edit/edit_book.html
new file mode 100644
index 00000000..fc11208f
--- /dev/null
+++ b/bookwyrm/templates/book/edit/edit_book.html
@@ -0,0 +1,122 @@
+{% extends 'layout.html' %}
+{% load i18n %}
+{% load humanize %}
+
+{% block title %}{% if book %}{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}{% else %}{% trans "Add Book" %}{% endif %}{% endblock %}
+
+{% block content %}
+
+
+ {% if book %}
+ {% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}
+ {% else %}
+ {% trans "Add Book" %}
+ {% endif %}
+
+ {% if book %}
+
+ {% trans "Added:" %}
+ {{ book.created_date | naturaltime }}
+
+ {% trans "Updated:" %}
+ {{ book.updated_date | naturaltime }}
+
+ {% if book.last_edited_by %}
+ {% trans "Last edited by:" %}
+ {{ book.last_edited_by.display_name }}
+ {% endif %}
+
+
+ {% endif %}
+
+
+
+ {% if confirm_mode %}
+
+
{% trans "Confirm Book Info" %}
+
+ {% if author_matches %}
+
+
+ {% else %}
+
{% blocktrans with name=add_author %}Creating a new author: {{ name }}{% endblocktrans %}
+ {% endif %}
+
+ {% if not book %}
+
+
+
+ {% trans "Is this an edition of an existing work?" %}
+
+ {% for match in book_matches %}
+
+ {{ match.parent_work.title }}
+
+ {% endfor %}
+
+ {% trans "This is a new work" %}
+
+
+
+ {% endif %}
+
+
+
{% trans "Confirm" %}
+
+ {% trans "Back" %}
+
+
+
+
+ {% endif %}
+
+ {% include "book/edit/edit_book_form.html" %}
+
+ {% if not confirm_mode %}
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/bookwyrm/templates/book/edit_book.html b/bookwyrm/templates/book/edit/edit_book_form.html
similarity index 54%
rename from bookwyrm/templates/book/edit_book.html
rename to bookwyrm/templates/book/edit/edit_book_form.html
index 32018a25..982bb56d 100644
--- a/bookwyrm/templates/book/edit_book.html
+++ b/bookwyrm/templates/book/edit/edit_book_form.html
@@ -1,40 +1,4 @@
-{% extends 'layout.html' %}
{% load i18n %}
-{% load humanize %}
-
-{% block title %}{% if book %}{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}{% else %}{% trans "Add Book" %}{% endif %}{% endblock %}
-
-{% block content %}
-
{% if form.non_field_errors %}
@@ -42,80 +6,14 @@
{% endif %}
-{% if book %}
-
-{% else %}
-
-{% endif %}
-
- {% csrf_token %}
- {% if confirm_mode %}
-
-
{% trans "Confirm Book Info" %}
-
- {% if author_matches %}
-
-
- {% else %}
-
{% blocktrans with name=add_author %}Creating a new author: {{ name }}{% endblocktrans %}
- {% endif %}
-
- {% if not book %}
-
-
-
- {% trans "Is this an edition of an existing work?" %}
-
- {% for match in book_matches %}
-
- {{ match.parent_work.title }}
-
- {% endfor %}
-
- {% trans "This is a new work" %}
-
-
-
- {% endif %}
-
-
-
{% trans "Confirm" %}
-
- {% trans "Back" %}
-
-
-
-
- {% endif %}
-
-
-
-
-
- {% trans "Metadata" %}
+{% csrf_token %}
+
+
+
+
+ {% trans "Metadata" %}
+
{% trans "Title:" %}
@@ -140,20 +38,25 @@
{% endfor %}
-
-
{% trans "Series:" %}
-
- {% for error in form.series.errors %}
-
{{ error | escape }}
- {% endfor %}
-
-
-
-
{% trans "Series number:" %}
- {{ form.series_number }}
- {% for error in form.series_number.errors %}
-
{{ error | escape }}
- {% endfor %}
+
+
+
+
{% trans "Series:" %}
+
+ {% for error in form.series.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
+
{% trans "Series number:" %}
+ {{ form.series_number }}
+ {% for error in form.series_number.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
@@ -164,7 +67,12 @@
{{ error | escape }}
{% endfor %}
+
+
+
+ {% trans "Publication" %}
+
{% trans "Publisher:" %}
{{ form.publishers }}
@@ -189,10 +97,12 @@
{{ error | escape }}
{% endfor %}
-
+
+
-
- {% trans "Authors" %}
+
-
+
+
+
-
+
+
{% trans "Cover" %}
-
-
- {% include 'snippets/book_cover.html' with book=book cover_class='is-h-xl-mobile is-w-auto-tablet' %}
-
+
+
+ {% if book.cover %}
+
+ {% include 'snippets/book_cover.html' with book=book cover_class='is-h-xl-mobile is-w-auto-tablet' size_mobile='xlarge' size='large' %}
+
+ {% endif %}
-
-
+
{% trans "Upload cover:" %}
{{ form.cover }}
- {% if book %}
{% trans "Load cover from url:" %}
-
+
- {% endif %}
{% for error in form.cover.errors %}
{{ error | escape }}
{% endfor %}
+
-
-
{% trans "Physical Properties" %}
-
-
{% trans "Format:" %}
- {{ form.physical_format }}
- {% for error in form.physical_format.errors %}
-
{{ error | escape }}
- {% endfor %}
+
+ {% trans "Physical Properties" %}
+
+
+
+
+
{% trans "Format:" %}
+
+ {{ form.physical_format }}
+
+ {% for error in form.physical_format.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
+
{% trans "Format details:" %}
+ {{ form.physical_format_detail }}
+ {% for error in form.physical_format_detail.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
@@ -262,9 +191,11 @@
{% endfor %}
+
-
-
{% trans "Book Identifiers" %}
+
+ {% trans "Book Identifiers" %}
+
{% trans "ISBN 13:" %}
{{ form.isbn_13 }}
@@ -313,15 +244,6 @@
{% endfor %}
-
+
-
- {% if not confirm_mode %}
-
- {% endif %}
-
-
-{% endblock %}
+
diff --git a/bookwyrm/templates/book/edition_filters.html b/bookwyrm/templates/book/edition_filters.html
deleted file mode 100644
index a55b72af..00000000
--- a/bookwyrm/templates/book/edition_filters.html
+++ /dev/null
@@ -1,6 +0,0 @@
-{% extends 'snippets/filters_panel/filters_panel.html' %}
-
-{% block filter_fields %}
-{% include 'book/language_filter.html' %}
-{% include 'book/format_filter.html' %}
-{% endblock %}
diff --git a/bookwyrm/templates/book/editions/edition_filters.html b/bookwyrm/templates/book/editions/edition_filters.html
new file mode 100644
index 00000000..c6702a5c
--- /dev/null
+++ b/bookwyrm/templates/book/editions/edition_filters.html
@@ -0,0 +1,7 @@
+{% extends 'snippets/filters_panel/filters_panel.html' %}
+
+{% block filter_fields %}
+{% include 'book/editions/search_filter.html' %}
+{% include 'book/editions/language_filter.html' %}
+{% include 'book/editions/format_filter.html' %}
+{% endblock %}
diff --git a/bookwyrm/templates/book/editions.html b/bookwyrm/templates/book/editions/editions.html
similarity index 90%
rename from bookwyrm/templates/book/editions.html
rename to bookwyrm/templates/book/editions/editions.html
index e2a0bdda..a3ff0802 100644
--- a/bookwyrm/templates/book/editions.html
+++ b/bookwyrm/templates/book/editions/editions.html
@@ -8,14 +8,14 @@
{% blocktrans with work_path=work.local_path work_title=work|book_title %}Editions of "{{ work_title }}" {% endblocktrans %}
-{% include 'book/edition_filters.html' %}
+{% include 'book/editions/edition_filters.html' %}
{% for book in editions %}
diff --git a/bookwyrm/templates/book/format_filter.html b/bookwyrm/templates/book/editions/format_filter.html
similarity index 100%
rename from bookwyrm/templates/book/format_filter.html
rename to bookwyrm/templates/book/editions/format_filter.html
diff --git a/bookwyrm/templates/book/language_filter.html b/bookwyrm/templates/book/editions/language_filter.html
similarity index 100%
rename from bookwyrm/templates/book/language_filter.html
rename to bookwyrm/templates/book/editions/language_filter.html
diff --git a/bookwyrm/templates/book/editions/search_filter.html b/bookwyrm/templates/book/editions/search_filter.html
new file mode 100644
index 00000000..f2345a68
--- /dev/null
+++ b/bookwyrm/templates/book/editions/search_filter.html
@@ -0,0 +1,8 @@
+{% extends 'snippets/filters_panel/filter_field.html' %}
+{% load i18n %}
+
+{% block filter %}
+
{% trans "Search editions" %}
+
+{% endblock %}
+
diff --git a/bookwyrm/templates/book/publisher_info.html b/bookwyrm/templates/book/publisher_info.html
index b7975a62..b39bcf5c 100644
--- a/bookwyrm/templates/book/publisher_info.html
+++ b/bookwyrm/templates/book/publisher_info.html
@@ -3,29 +3,30 @@
{% load i18n %}
{% load humanize %}
+{% firstof book.physical_format_detail book.get_physical_format_display as format %}
+{% firstof book.physical_format book.physical_format_detail as format_property %}
+{% with pages=book.pages %}
+{% if format or pages %}
+
+{% if format_property %}
+
+{% endif %}
+
+{% if pages %}
+
+{% endif %}
+
- {% with format=book.physical_format pages=book.pages %}
- {% if format %}
- {% comment %}
- @todo The bookFormat property is limited to a list of values whereas the book edition is free text.
- @see https://schema.org/bookFormat
- {% endcomment %}
-
- {% endif %}
-
- {% if pages %}
-
- {% endif %}
-
- {% if format and not pages %}
- {% blocktrans %}{{ format }}{% endblocktrans %}
- {% elif format and pages %}
- {% blocktrans %}{{ format }}, {{ pages }} pages{% endblocktrans %}
- {% elif pages %}
- {% blocktrans %}{{ pages }} pages{% endblocktrans %}
- {% endif %}
- {% endwith %}
+ {% if format and not pages %}
+ {{ format }}
+ {% elif format and pages %}
+ {% blocktrans %}{{ format }}, {{ pages }} pages{% endblocktrans %}
+ {% elif pages %}
+ {% blocktrans %}{{ pages }} pages{% endblocktrans %}
+ {% endif %}
+{% endif %}
+{% endwith %}
{% if book.languages %}
{% for language in book.languages %}
@@ -39,32 +40,34 @@
{% endif %}
+{% with date=book.published_date|naturalday publisher=book.publishers|join:', ' %}
+{% if date or book.first_published_date or book.publishers %}
+{% if date or book.first_published_date %}
+
+{% endif %}
- {% with date=book.published_date|naturalday publisher=book.publishers|join:', ' %}
- {% if date or book.first_published_date %}
-
- {% endif %}
- {% comment %}
- @todo The publisher property needs to be an Organization or a Person. We’ll be using Thing which is the more generic ancestor.
- @see https://schema.org/Publisher
- {% endcomment %}
- {% if book.publishers %}
- {% for publisher in book.publishers %}
-
- {% endfor %}
- {% endif %}
+ {% comment %}
+ @todo The publisher property needs to be an Organization or a Person. We’ll be using Thing which is the more generic ancestor.
+ @see https://schema.org/Publisher
+ {% endcomment %}
+ {% if book.publishers %}
+ {% for publisher in book.publishers %}
+
+ {% endfor %}
+ {% endif %}
- {% if date and publisher %}
- {% blocktrans %}Published {{ date }} by {{ publisher }}.{% endblocktrans %}
- {% elif date %}
- {% blocktrans %}Published {{ date }}{% endblocktrans %}
- {% elif publisher %}
- {% blocktrans %}Published by {{ publisher }}.{% endblocktrans %}
- {% endif %}
- {% endwith %}
+ {% if date and publisher %}
+ {% blocktrans %}Published {{ date }} by {{ publisher }}.{% endblocktrans %}
+ {% elif date %}
+ {% blocktrans %}Published {{ date }}{% endblocktrans %}
+ {% elif publisher %}
+ {% blocktrans %}Published by {{ publisher }}.{% endblocktrans %}
+ {% endif %}
+{% endif %}
+{% endwith %}
{% endspaceless %}
diff --git a/bookwyrm/templates/book/readthrough.html b/bookwyrm/templates/book/readthrough.html
index 75140746..12430f75 100644
--- a/bookwyrm/templates/book/readthrough.html
+++ b/bookwyrm/templates/book/readthrough.html
@@ -2,11 +2,10 @@
{% load humanize %}
{% load tz %}
-
+
{% trans "Progress Updates:" %}
-
{% if readthrough.finish_date or readthrough.progress %}
@@ -24,7 +23,7 @@
{% if readthrough.progress %}
{% trans "Show all updates" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="updates" controls_uid=readthrough.id class="is-small" %}
-
+
{% for progress_update in readthrough.progress_updates %}
@@ -36,7 +35,7 @@
{{ progress_update.progress }}%
{% endif %}
-
+
{% trans "Delete this progress update" %}
@@ -57,11 +56,11 @@
{% trans "Edit read dates" as button_text %}
- {% include 'snippets/toggle/toggle_button.html' with class="is-small" text=button_text icon="pencil" controls_text="edit-readthrough" controls_uid=readthrough.id focus="edit-readthrough" %}
+ {% include 'snippets/toggle/toggle_button.html' with class="is-small" text=button_text icon="pencil" controls_text="edit_readthrough" controls_uid=readthrough.id focus="edit_readthrough" %}
{% trans "Delete these read dates" as button_text %}
- {% include 'snippets/toggle/toggle_button.html' with class="is-small" text=button_text icon="x" controls_text="delete-readthrough" controls_uid=readthrough.id focus="modal-title-delete-readthrough" %}
+ {% include 'snippets/toggle/toggle_button.html' with class="is-small" text=button_text icon="x" controls_text="delete_readthrough" controls_uid=readthrough.id focus="modal_title_delete_readthrough" %}
@@ -69,15 +68,15 @@
-
+
{% trans "Edit read dates" %}
{% include 'snippets/readthrough_form.html' with readthrough=readthrough %}
{% trans "Save" %}
{% trans "Cancel" as button_text %}
- {% include 'snippets/toggle/close_button.html' with text=button_text controls_text="edit-readthrough" controls_uid=readthrough.id %}
+ {% include 'snippets/toggle/close_button.html' with text=button_text controls_text="edit_readthrough" controls_uid=readthrough.id %}
-{% include 'snippets/delete_readthrough_modal.html' with controls_text="delete-readthrough" controls_uid=readthrough.id no_body=True %}
+{% include 'snippets/delete_readthrough_modal.html' with controls_text="delete_readthrough" controls_uid=readthrough.id no_body=True %}
diff --git a/bookwyrm/templates/components/dropdown.html b/bookwyrm/templates/components/dropdown.html
index 35caa55b..98291bc9 100644
--- a/bookwyrm/templates/components/dropdown.html
+++ b/bookwyrm/templates/components/dropdown.html
@@ -3,7 +3,7 @@
{% with 0|uuid as uuid %}