From 1e0d547db5f9a610e29c27c156d0a30da36a1c51 Mon Sep 17 00:00:00 2001 From: Reinout Meliesie Date: Tue, 11 Mar 2025 10:25:28 +0100 Subject: [PATCH] Revert formatting changes --- bookwyrm/activitystreams.py | 594 +++++++++++++++++--------------- bookwyrm/apps.py | 59 ++-- bookwyrm/settings.py | 668 +++++++++++++++++++----------------- 3 files changed, 696 insertions(+), 625 deletions(-) diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py index 94fcd41e1..08c618b87 100644 --- a/bookwyrm/activitystreams.py +++ b/bookwyrm/activitystreams.py @@ -1,381 +1,406 @@ -""" Access the activity streams stored in Redis """ - - +""" 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 . tasks import app , STREAMS , IMPORT_TRIGGERED -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.redis_store import RedisStore, r +from bookwyrm.tasks import app, STREAMS, IMPORT_TRIGGERED -class ActivityStream (RedisStore) : - """A category of activity stream (like home, local, books)""" - def stream_id (self , user_id ) : - """The redis key for this user's instance of this stream""" +class ActivityStream(RedisStore): + """a category of activity stream (like home, local, books)""" + + def stream_id(self, user_id): + """the redis key for this user's instance of this stream""" return f"{user_id}-{self.key}" - def unread_id ( self , user_id ) : - """The redis key for this user's unread count for this stream""" - stream_id = self . stream_id (user_id) + def unread_id(self, user_id): + """the redis key for this user's unread count for this stream""" + stream_id = self.stream_id(user_id) return f"{stream_id}-unread" - def unread_by_status_type_id ( self , user_id ) : - """The redis key for this user's unread count for this stream""" - stream_id = self . stream_id (user_id) + def unread_by_status_type_id(self, user_id): + """the redis key for this user's unread count for this stream""" + stream_id = self.stream_id(user_id) return f"{stream_id}-unread-by-type" - def get_rank ( self , obj ) : - """Statuses are sorted by date published""" - return obj . published_date . timestamp () + def get_rank(self, obj): + """statuses are sorted by date published""" + return obj.published_date.timestamp() - def add_status ( self , status , increment_unread = False ) : - """Add a status to users' feeds""" - audience = self . get_audience (status) - # The pipeline contains all the add-to-stream activities - pipeline = self . add_object_to_stores ( - status , - self . get_stores_for_users (audience) , - execute = False , + def add_status(self, status, increment_unread=False): + """add a status to users' feeds""" + audience = self.get_audience(status) + # the pipeline contains all the add-to-stream activities + pipeline = self.add_object_to_stores( + status, self.get_stores_for_users(audience), execute=False ) - if increment_unread : - for user_id in audience : - # Add to the unread status count - pipeline . incr ( self . unread_id (user_id) ) - # Add to the unread status count for status type - pipeline . hincrby ( - self . unread_by_status_type_id (user_id) , - get_status_type (status) , - 1 , + if increment_unread: + for user_id in audience: + # add to the unread status count + pipeline.incr(self.unread_id(user_id)) + # add to the unread status count for status type + pipeline.hincrby( + self.unread_by_status_type_id(user_id), get_status_type(status), 1 ) - # And go! - pipeline . execute () + # and go! + pipeline.execute() - 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 = models . Status . privacy_filter (viewer) . filter ( user = user ) - self . bulk_add_objects_to_store ( statuses , self . stream_id ( viewer . id ) ) + 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 = models.Status.privacy_filter(viewer).filter(user=user) + self.bulk_add_objects_to_store(statuses, self.stream_id(viewer.id)) - def remove_user_statuses ( self , viewer , user ) : - """Remove a user's status from another user's feed""" - # Remove all so that followers only statuses are removed - statuses = user . status_set . all () - self . bulk_remove_objects_from_store ( statuses , self . stream_id ( viewer . id ) ) + def remove_user_statuses(self, viewer, user): + """remove a user's status from another user's feed""" + # remove all so that followers only statuses are removed + statuses = user.status_set.all() + self.bulk_remove_objects_from_store(statuses, self.stream_id(viewer.id)) - def get_activity_stream ( self , user ) : - """Load the statuses to be displayed""" - # Clear unreads for this feed - r . set ( self . unread_id ( user . id ) , 0 ) - r . delete ( self . unread_by_status_type_id ( user . id ) ) + def get_activity_stream(self, user): + """load the statuses to be displayed""" + # clear unreads for this feed + r.set(self.unread_id(user.id), 0) + r.delete(self.unread_by_status_type_id(user.id)) - statuses = self.get_store ( self . stream_id ( user . id ) ) - return models . Status . objects . select_subclasses () - . filter ( id__in = statuses ) - . select_related ( "user" , "reply_parent" , "comment__book" , "review__book" , "quotation__book" ) - . prefetch_related ( "mention_books" , "mention_users" ) - . order_by ("-published_date") - - def get_unread_count ( self , user ) : - """Get the unread status count for this user's feed""" - return int ( r . get ( self . unread_id ( user . id ) ) or 0 ) - - def get_unread_count_by_status_type ( self , user ) : - """Get the unread status count for this user's feed's status types""" - status_types = r . hgetall ( self . unread_by_status_type_id ( user . id ) ) - return { - str ( key . decode ("utf-8") ) : int (value) or 0 - for key , value in status_types . items () - } - - def populate_streams ( self , user) : - """Go from zero to a timeline""" - self . populate_store ( self . stream_id ( user . id ) ) - - def _get_audience ( self , status) : - """Given a status, what users should see it, excluding the author""" - # Direct messages don't appear in feeds, direct comments/reviews/etc do - if status . privacy == "direct" and status . status_type == "Note" : - return models . User . objects . none () - - # Everybody who could plausibly see this status - audience = models . User . objects - . filter ( is_active = True , local = True ) # We only create feeds for users of this instance - . exclude ( - Q ( id__in = status . user . blocks . all () ) | - Q ( blocks = status . user) # Not blocked + statuses = self.get_store(self.stream_id(user.id)) + return ( + models.Status.objects.select_subclasses() + .filter(id__in=statuses) + .select_related( + "user", + "reply_parent", + "comment__book", + "review__book", + "quotation__book", ) - - # Only visible to the poster and mentioned users - if status . privacy == "direct" : - audience = audience . filter ( Q ( id__in = status . mention_users . all () ) ) # If the user is mentioned - - # Don't show replies to statuses the user can't see - elif status . reply_parent and status . reply_parent . privacy == "followers" : - audience = audience . filter ( - Q ( id = status . reply_parent . user . id ) | # If the user is the OG author - ( Q ( following = status . user ) & Q ( following = status . reply_parent . user ) ) # If the user is following both authors - ) - - # Only visible to the poster's followers and tagged users - elif status . privacy == "followers" : - audience = audience . filter ( Q ( following = status . user ) ) # If the user is following the author - - return audience . distinct ("id") - - def get_audience ( self , status ) : - """Given a status, what users should see it""" - audience = self . _get_audience (status) . values_list ( "id" , flat = True ) - status_author = models . User . objects - . filter ( is_active = True , local = True , id = status . user . id ) - . values_list ( "id" , flat = True ) - return list ( set (audience) | set (status_author) ) - - def get_stores_for_users ( self , user_ids ) : - """Convert a list of user ids into Redis store ids""" - return [ self . stream_id (user_id) for user_id in user_ids ] - - def get_statuses_for_user ( self , user ) : - """Given a user, what statuses should they see on this stream""" - return models . Status . privacy_filter ( - user , - privacy_levels = [ "public" , "unlisted" , "followers" ] , + .prefetch_related("mention_books", "mention_users") + .order_by("-published_date") ) - def get_objects_for_store ( self , store ) : - user = models . User . objects . get ( id = store . split ("-") [0] ) - return self . get_statuses_for_user (user) + def get_unread_count(self, user): + """get the unread status count for this user's feed""" + return int(r.get(self.unread_id(user.id)) or 0) + + def get_unread_count_by_status_type(self, user): + """get the unread status count for this user's feed's status types""" + status_types = r.hgetall(self.unread_by_status_type_id(user.id)) + return { + str(key.decode("utf-8")): int(value) or 0 + for key, value in status_types.items() + } + + def populate_streams(self, user): + """go from zero to a timeline""" + self.populate_store(self.stream_id(user.id)) + + def _get_audience(self, status): # pylint: disable=no-self-use + """given a status, what users should see it, excluding the author""" + # direct messages don't appear in feeds, direct comments/reviews/etc do + if status.privacy == "direct" and status.status_type == "Note": + return models.User.objects.none() + + # everybody who could plausibly see this status + audience = models.User.objects.filter( + is_active=True, + local=True, # we only create feeds for users of this instance + ).exclude( + Q(id__in=status.user.blocks.all()) | Q(blocks=status.user) # not blocked + ) + + # only visible to the poster and mentioned users + if status.privacy == "direct": + audience = audience.filter( + Q(id__in=status.mention_users.all()) # if the user is mentioned + ) + + # don't show replies to statuses the user can't see + elif status.reply_parent and status.reply_parent.privacy == "followers": + audience = audience.filter( + Q(id=status.reply_parent.user.id) # if the user is the OG author + | ( + Q(following=status.user) & Q(following=status.reply_parent.user) + ) # if the user is following both authors + ) + + # only visible to the poster's followers and tagged users + elif status.privacy == "followers": + audience = audience.filter( + Q(following=status.user) # if the user is following the author + ) + return audience.distinct("id") + + def get_audience(self, status): + """given a status, what users should see it""" + audience = self._get_audience(status).values_list("id", flat=True) + status_author = models.User.objects.filter( + is_active=True, local=True, id=status.user.id + ).values_list("id", flat=True) + return list(set(audience) | set(status_author)) + + def get_stores_for_users(self, user_ids): + """convert a list of user ids into redis store ids""" + return [self.stream_id(user_id) for user_id in user_ids] + + def get_statuses_for_user(self, user): # pylint: disable=no-self-use + """given a user, what statuses should they see on this stream""" + return models.Status.privacy_filter( + user, + privacy_levels=["public", "unlisted", "followers"], + ) + + def get_objects_for_store(self, store): + user = models.User.objects.get(id=store.split("-")[0]) + return self.get_statuses_for_user(user) -class HomeStream (ActivityStream) : - """Users you follow""" +class HomeStream(ActivityStream): + """users you follow""" key = "home" - def get_audience ( self , status ) : - audience = super () . _get_audience (status) - # If the user is following the author - audience = audience . filter ( following = status . user ) . values_list ( "id" , flat = True ) - # If the user is the post's author - status_author = models . User . objects - . filter ( is_active = True , local = True , id = status . user . id ) - . values_list ( "id" , flat = True ) - return list ( set (audience) | set (status_author) ) + def get_audience(self, status): + audience = super()._get_audience(status) + # if the user is following the author + audience = audience.filter(following=status.user).values_list("id", flat=True) + # if the user is the post's author + status_author = models.User.objects.filter( + is_active=True, local=True, id=status.user.id + ).values_list("id", flat=True) + return list(set(audience) | set(status_author)) - def get_statuses_for_user ( self , user ) : - return models . Status . privacy_filter ( - user , - privacy_levels = [ "public" , "unlisted" , "followers" ] , - ) . exclude ( ~ Q ( # Remove everything except - Q ( user__followers = user ) | # User following - Q ( user = user ) | # Is self - Q ( mention_users = user ) # Mentions user - ) ) + def get_statuses_for_user(self, user): + return models.Status.privacy_filter( + user, + privacy_levels=["public", "unlisted", "followers"], + ).exclude( + ~Q( # remove everything except + Q(user__followers=user) # user following + | Q(user=user) # is self + | Q(mention_users=user) # mentions user + ), + ) -class LocalStream (ActivityStream) : - """Users you follow""" +class LocalStream(ActivityStream): + """users you follow""" key = "local" - def get_audience ( self , status ) : - # This stream wants no part in non-public statuses - if status . privacy != "public" or not status . user . local : + def get_audience(self, status): + # this stream wants no part in non-public statuses + if status.privacy != "public" or not status.user.local: return [] - return super () . get_audience (status) + return super().get_audience(status) - def get_statuses_for_user ( self , user ) : - # All public statuses by a local user - return models . Status - . privacy_filter ( user , privacy_levels = [ "public" ] ) - . filter ( user__local = True ) + def get_statuses_for_user(self, user): + # all public statuses by a local user + return models.Status.privacy_filter( + user, + privacy_levels=["public"], + ).filter(user__local=True) -class BooksStream (ActivityStream) : - """Books on your shelves""" +class BooksStream(ActivityStream): + """books on your shelves""" key = "books" - def _get_audience ( self , status ) : - """Anyone with the mentioned book on their shelves""" - work = status . book . parent_work if hasattr ( status , "book" ) - else status . mention_books . first () . parent_work + def _get_audience(self, status): + """anyone with the mentioned book on their shelves""" + work = ( + status.book.parent_work + if hasattr(status, "book") + else status.mention_books.first().parent_work + ) - audience = super () . _get_audience (status) - return audience . filter ( shelfbook__book__parent_work = work ) + audience = super()._get_audience(status) + return audience.filter(shelfbook__book__parent_work=work) - def get_audience ( self , status ) : - # 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" ) ) - ) : + def get_audience(self, status): + # 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) + return super().get_audience(status) - 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 ) + 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"], ) - . distinct () + .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" ] ) + 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"], + ) - book_comments = statuses . filter ( Q ( comment__book__parent_work = work ) ) - book_quotations = statuses . filter ( Q ( quotation__book__parent_work = work ) ) - book_reviews = statuses . filter ( Q ( review__book__parent_work = work ) ) - book_mentions = statuses . filter ( Q ( mention_books__parent_work = work ) ) + book_comments = statuses.filter(Q(comment__book__parent_work=work)) + book_quotations = statuses.filter(Q(quotation__book__parent_work=work)) + book_reviews = statuses.filter(Q(review__book__parent_work=work)) + book_mentions = statuses.filter(Q(mention_books__parent_work=work)) - self . bulk_add_objects_to_store ( book_comments , self . stream_id ( user . id ) ) - self . bulk_add_objects_to_store ( book_quotations , self . stream_id ( user . id ) ) - self . bulk_add_objects_to_store ( book_reviews , self . stream_id ( user . id ) ) - self . bulk_add_objects_to_store ( book_mentions , self . stream_id ( user . id ) ) + self.bulk_add_objects_to_store(book_comments, self.stream_id(user.id)) + self.bulk_add_objects_to_store(book_quotations, self.stream_id(user.id)) + self.bulk_add_objects_to_store(book_reviews, self.stream_id(user.id)) + self.bulk_add_objects_to_store(book_mentions, self.stream_id(user.id)) - 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" ] ) + 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"], + ) - book_comments = statuses . filter ( Q ( comment__book__parent_work = work ) ) - book_quotations = statuses . filter ( Q ( quotation__book__parent_work = work ) ) - book_reviews = statuses . filter ( Q ( review__book__parent_work = work ) ) - book_mentions = statuses . filter ( Q ( mention_books__parent_work = work ) ) + book_comments = statuses.filter(Q(comment__book__parent_work=work)) + book_quotations = statuses.filter(Q(quotation__book__parent_work=work)) + book_reviews = statuses.filter(Q(review__book__parent_work=work)) + book_mentions = statuses.filter(Q(mention_books__parent_work=work)) - self . bulk_remove_objects_from_store ( book_comments , self . stream_id ( user . id ) ) - self . bulk_remove_objects_from_store ( book_quotations , self . stream_id ( user . id ) ) - self . bulk_remove_objects_from_store ( book_reviews , self . stream_id ( user . id ) ) - self . bulk_remove_objects_from_store ( book_mentions , self . stream_id ( user . id ) ) + self.bulk_remove_objects_from_store(book_comments, self.stream_id(user.id)) + self.bulk_remove_objects_from_store(book_quotations, self.stream_id(user.id)) + self.bulk_remove_objects_from_store(book_reviews, self.stream_id(user.id)) + self.bulk_remove_objects_from_store(book_mentions, self.stream_id(user.id)) -# Determine which streams are enabled in settings.py +# determine which streams are enabled in settings.py streams = { - "home" : HomeStream () , - "local" : LocalStream () , - "books" : BooksStream () , + "home": HomeStream(), + "local": LocalStream(), + "books": BooksStream(), } -@ receiver ( signals . post_save ) -def add_status_on_create ( sender , instance , created , * args , ** kwargs ) : - """Add newly created statuses to activity feeds""" - # We're only interested in new statuses - if not issubclass ( sender , models . Status ) : +@receiver(signals.post_save) +# pylint: disable=unused-argument +def add_status_on_create(sender, instance, created, *args, **kwargs): + """add newly created statuses to activity feeds""" + # we're only interested in new statuses + if not issubclass(sender, models.Status): return - if instance . deleted : - remove_status_task . delay ( instance . id ) + if instance.deleted: + remove_status_task.delay(instance.id) return - # We don't want to create multiple add_status_tasks for each status, and because the transactions are atomic, - # on_commit won't run until the status is ready to add. - if not created : + # We don't want to create multiple add_status_tasks for each status, and because + # the transactions are atomic, on_commit won't run until the status is ready to add. + if not created: return - # When creating new things, gotta wait on the transaction - transaction . on_commit ( lambda : add_status_on_create_command ( sender , instance , created ) ) + # when creating new things, gotta wait on the transaction + transaction.on_commit( + lambda: add_status_on_create_command(sender, instance, created) + ) -def add_status_on_create_command ( sender , instance , created ) : - """Runs this code only after the database commit completes""" - # Boosts trigger 'saves" twice, so don't bother duplicating the task - if sender == models . Boost and not created : +def add_status_on_create_command(sender, instance, created): + """runs this code only after the database commit completes""" + # boosts trigger 'saves" twice, so don't bother duplicating the task + if sender == models.Boost and not created: return priority = STREAMS - # Check if this is an old status, de-prioritize if so + # check if this is an old status, de-prioritize if so # (this will happen if federation is very slow, or, more expectedly, on csv import) - if ( - instance . published_date < timezone . now () - timedelta ( days = 1 ) or - instance.created_date < instance.published_date - timedelta(days=1) - ) : - # A backdated status from a local user is an import, don't add it - if instance . user . local : + if instance.published_date < timezone.now() - timedelta( + days=1 + ) or instance.created_date < instance.published_date - timedelta(days=1): + # a backdated status from a local user is an import, don't add it + if instance.user.local: return - # An out of date remote status is a low priority but should be added + # an out of date remote status is a low priority but should be added priority = IMPORT_TRIGGERED - add_status_task . apply_async ( - args = ( instance . id , ) , - kwargs = { "increment_unread" : created } , - queue = priority , + add_status_task.apply_async( + args=(instance.id,), + kwargs={"increment_unread": created}, + queue=priority, ) - if sender == models . Boost : - handle_boost_task . delay ( instance . id ) + if sender == models.Boost: + handle_boost_task.delay(instance.id) -@ receiver ( signals . post_delete , sender = models . Boost ) -def remove_boost_on_delete ( sender , instance , * args , ** kwargs ) : - """Boosts are deleted""" - # 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_delete, sender=models.Boost) +# pylint: disable=unused-argument +def remove_boost_on_delete(sender, instance, *args, **kwargs): + """boosts are deleted""" + # 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 ) -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 : +@receiver(signals.post_save, sender=models.UserFollows) +# pylint: disable=unused-argument +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 - add_user_statuses_task . delay ( - instance . user_subject . id , - instance . user_object . id , - stream_list = [ "home" ] , + add_user_statuses_task.delay( + instance.user_subject.id, instance.user_object.id, stream_list=["home"] ) -@ receiver ( signals . post_delete , sender = models . UserFollows ) -def remove_statuses_on_unfollow ( sender , instance , * args , ** kwargs ) : - """Remove statuses from a feed on unfollow""" - if not instance . user_subject . local : +@receiver(signals.post_delete, sender=models.UserFollows) +# pylint: disable=unused-argument +def remove_statuses_on_unfollow(sender, instance, *args, **kwargs): + """remove statuses from a feed on unfollow""" + if not instance.user_subject.local: return - remove_user_statuses_task . delay ( - instance . user_subject . id , - instance . user_object . id , - stream_list = [ "home" ] , + remove_user_statuses_task.delay( + instance.user_subject.id, instance.user_object.id, stream_list=["home"] ) -@ receiver ( signals . post_save , sender = models . UserBlocks ) -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 : - remove_user_statuses_task . delay ( - instance . user_subject . id , - instance . user_object . id , +@receiver(signals.post_save, sender=models.UserBlocks) +# pylint: disable=unused-argument +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: + remove_user_statuses_task.delay( + instance.user_subject.id, instance.user_object.id ) - # And in both directions - if instance . user_object . local : - remove_user_statuses_task . delay ( - instance . user_object . id , - instance . user_subject . id , + # and in both directions + if instance.user_object.local: + 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): """add statuses back to all feeds on unblock""" # make sure there isn't a block in the other direction @@ -405,6 +430,7 @@ def add_statuses_on_unblock(sender, instance, *args, **kwargs): @receiver(signals.post_save, sender=models.User) +# pylint: disable=unused-argument def populate_streams_on_account_create(sender, instance, created, *args, **kwargs): """build a user's feeds when they join""" if not created or not instance.local: @@ -421,6 +447,7 @@ def populate_streams_on_account_create_command(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: @@ -436,6 +463,7 @@ def add_statuses_on_shelve(sender, instance, *args, **kwargs): @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: diff --git a/bookwyrm/apps.py b/bookwyrm/apps.py index 13d17b450..eeb102ee9 100644 --- a/bookwyrm/apps.py +++ b/bookwyrm/apps.py @@ -1,51 +1,48 @@ """Do further startup configuration and initialization""" - - -import logging import os import urllib +import logging + +from django.apps import AppConfig from bookwyrm import settings -from django . apps import AppConfig + +logger = logging.getLogger(__name__) - -logger = logging . getLogger (__name__) - - -def download_file ( url , destination) : +def download_file(url, destination): """Downloads a file to the given path""" - try : + try: # Ensure our destination directory exists - os . makedirs ( os . path . dirname (destination) , exist_ok = True ) - with urllib . request . urlopen (url) as stream : - with open ( destination , "b+w" ) as outfile : - outfile . write ( stream . read () ) - except ( urllib . error . HTTPError , urllib . error . URLError ) as err : - logger . error ( "Failed to download file %s: %s" , url , err ) - except OSError as err : - logger . error ( "Couldn't open font file %s for writing: %s" , destination , err ) - except Exception as err : - logger . error ( "Unknown error in file download: %s" , err ) + os.makedirs(os.path.dirname(destination), exist_ok=True) + with urllib.request.urlopen(url) as stream: + with open(destination, "b+w") as outfile: + outfile.write(stream.read()) + except (urllib.error.HTTPError, urllib.error.URLError) as err: + logger.error("Failed to download file %s: %s", url, err) + except OSError as err: + logger.error("Couldn't open font file %s for writing: %s", destination, err) + except Exception as err: # pylint:disable=broad-except + logger.error("Unknown error in file download: %s", err) -class BookwyrmConfig (AppConfig) : +class BookwyrmConfig(AppConfig): """Handles additional configuration""" name = "bookwyrm" verbose_name = "BookWyrm" - def ready (self) : - """Set up preview image files, if desired""" - if settings . ENABLE_PREVIEW_IMAGES and settings . FONTS : + def ready(self): + """set up OTLP and preview image files, if desired""" + if settings.ENABLE_PREVIEW_IMAGES and settings.FONTS: # Download any fonts that we don't have yet - logger . debug ("Downloading fonts..") - for name , config in settings . FONTS . items () : - font_path = os . path . join ( - settings . FONT_DIR , config [ "directory" ] , config [ "filename" ] + logger.debug("Downloading fonts..") + for name, config in settings.FONTS.items(): + font_path = os.path.join( + settings.FONT_DIR, config["directory"], config["filename"] ) - if "url" in config and not os . path . exists (font_path) : - logger . info ( "Just a sec, downloading %s" , name ) - download_file ( config [ "url" ] , font_path ) + if "url" in config and not os.path.exists(font_path): + logger.info("Just a sec, downloading %s", name) + download_file(config["url"], font_path) diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 91c19b9fc..234282ca0 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -1,249 +1,269 @@ -""" Bookwyrm settings and configuration """ - - - +""" bookwyrm settings and configuration """ import os -import requests - -from django . core . exceptions import ImproperlyConfigured -from django . utils . translation import gettext_lazy as _ -from environs import Env from typing import AnyStr +from environs import Env -env = Env () -env . read_env ( "/etc/bookwyrm/config.env" , recurse = False ) -DOMAIN = env ("DOMAIN") +import requests +from django.utils.translation import gettext_lazy as _ +from django.core.exceptions import ImproperlyConfigured -with open ( "VERSION" , encoding = "utf-8" ) as f : - version = f . read () - version = version . replace ( "\n" , "" ) + +# pylint: disable=line-too-long + +env = Env() +env.read_env("/etc/bookwyrm/config.env", recurse=False) +DOMAIN = env("DOMAIN") + +with open("VERSION", encoding="utf-8") as f: + version = f.read() + version = version.replace("\n", "") VERSION = version -RELEASE_API = env ( - "RELEASE_API" , - "https://api.github.com/repos/bookwyrm-social/bookwyrm/releases/latest" , +RELEASE_API = env( + "RELEASE_API", + "https://api.github.com/repos/bookwyrm-social/bookwyrm/releases/latest", ) -PAGE_LENGTH = env . int ( "PAGE_LENGTH" , 15 ) -DEFAULT_LANGUAGE = env ( "DEFAULT_LANGUAGE" , "English" ) -# TODO: Extend maximum age to 1 year once termination of active sessions is implemented (see bookwyrm-social#2278, bookwyrm-social#3082). -SESSION_COOKIE_AGE = env . int ( "SESSION_COOKIE_AGE" , 3600 * 24 * 30 ) # 1 month +PAGE_LENGTH = env.int("PAGE_LENGTH", 15) +DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English") +# TODO: extend maximum age to 1 year once termination of active sessions +# is implemented (see bookwyrm-social#2278, bookwyrm-social#3082). +SESSION_COOKIE_AGE = env.int("SESSION_COOKIE_AGE", 3600 * 24 * 30) # 1 month JS_CACHE = "8a89cad7" -# Email -EMAIL_BACKEND = env ( "EMAIL_BACKEND" , "django.core.mail.backends.smtp.EmailBackend" ) -EMAIL_HOST = env ("EMAIL_HOST") -EMAIL_PORT = env . int ( "EMAIL_PORT" , 587 ) -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 ) -EMAIL_SENDER_NAME = env ( "EMAIL_SENDER_NAME" , "admin" ) -EMAIL_SENDER_DOMAIN = env ( "EMAIL_SENDER_DOMAIN" , DOMAIN ) +# email +EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend") +EMAIL_HOST = env("EMAIL_HOST") +EMAIL_PORT = env.int("EMAIL_PORT", 587) +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) +EMAIL_SENDER_NAME = env("EMAIL_SENDER_NAME", "admin") +EMAIL_SENDER_DOMAIN = env("EMAIL_SENDER_DOMAIN", DOMAIN) EMAIL_SENDER = f"{EMAIL_SENDER_NAME}@{EMAIL_SENDER_DOMAIN}" # Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR : AnyStr = 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" ) +BASE_DIR: AnyStr = 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") -STATIC_ROOT = os . path . join ( BASE_DIR , env ( "STATIC_ROOT" , "static" ) ) -MEDIA_ROOT = os . path . join ( BASE_DIR , env ( "MEDIA_ROOT" , "images" ) ) +STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static")) +MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images")) DEFAULT_AUTO_FIELD = "django.db.models.AutoField" # Preview image -ENABLE_PREVIEW_IMAGES = env . bool ( "ENABLE_PREVIEW_IMAGES" , False ) -PREVIEW_BG_COLOR = env . str ( "PREVIEW_BG_COLOR" , "use_dominant_color_light" ) -PREVIEW_TEXT_COLOR = env . str ( "PREVIEW_TEXT_COLOR" , "#363636" ) -PREVIEW_IMG_WIDTH = env . int ( "PREVIEW_IMG_WIDTH" , 1200 ) -PREVIEW_IMG_HEIGHT = env . int ( "PREVIEW_IMG_HEIGHT" , 630 ) -PREVIEW_DEFAULT_COVER_COLOR = env . str ( "PREVIEW_DEFAULT_COVER_COLOR" , "#002549" ) -PREVIEW_DEFAULT_FONT = env . str ( "PREVIEW_DEFAULT_FONT" , "Source Han Sans" ) +ENABLE_PREVIEW_IMAGES = env.bool("ENABLE_PREVIEW_IMAGES", False) +PREVIEW_BG_COLOR = env.str("PREVIEW_BG_COLOR", "use_dominant_color_light") +PREVIEW_TEXT_COLOR = env.str("PREVIEW_TEXT_COLOR", "#363636") +PREVIEW_IMG_WIDTH = env.int("PREVIEW_IMG_WIDTH", 1200) +PREVIEW_IMG_HEIGHT = env.int("PREVIEW_IMG_HEIGHT", 630) +PREVIEW_DEFAULT_COVER_COLOR = env.str("PREVIEW_DEFAULT_COVER_COLOR", "#002549") +PREVIEW_DEFAULT_FONT = env.str("PREVIEW_DEFAULT_FONT", "Source Han Sans") FONTS = { - "Source Han Sans" : { - "directory" : "source_han_sans" , - "filename" : "SourceHanSans-VF.ttf.ttc" , - "url" : "https://github.com/adobe-fonts/source-han-sans/raw/release/Variable/OTC/SourceHanSans-VF.ttf.ttc" , + "Source Han Sans": { + "directory": "source_han_sans", + "filename": "SourceHanSans-VF.ttf.ttc", + "url": "https://github.com/adobe-fonts/source-han-sans/raw/release/Variable/OTC/SourceHanSans-VF.ttf.ttc", } } -FONT_DIR = os . path . join ( STATIC_ROOT , "fonts" ) +FONT_DIR = os.path.join(STATIC_ROOT, "fonts") # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ -# SECURITY WARNING: Don't run with debug turned on in production -DEBUG = env . bool ( "DEBUG" , True ) -USE_HTTPS = env . bool ( "USE_HTTPS" , not DEBUG ) +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = env.bool("DEBUG", True) +USE_HTTPS = env.bool("USE_HTTPS", not DEBUG) -# SECURITY WARNING: Keep the secret key used in production secret -SECRET_KEY = env ("SECRET_KEY") -if not DEBUG and SECRET_KEY == "7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr" : - raise ImproperlyConfigured ("You must change the SECRET_KEY env variable") +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = env("SECRET_KEY") +if not DEBUG and SECRET_KEY == "7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr": + raise ImproperlyConfigured("You must change the SECRET_KEY env variable") -ALLOWED_HOSTS = env . list ( "ALLOWED_HOSTS" , [ "*" ] ) +ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", ["*"]) # Application definition INSTALLED_APPS = [ - "django.contrib.admin" , - "django.contrib.auth" , - "django.contrib.contenttypes" , - "django.contrib.sessions" , - "django.contrib.messages" , - "django.contrib.staticfiles" , - "django.contrib.humanize" , - "oauth2_provider" , - "file_resubmit" , - "sass_processor" , - "bookwyrm" , - "celery" , - "django_celery_beat" , - "imagekit" , - "pgtrigger" , - "storages" , + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.humanize", + "oauth2_provider", + "file_resubmit", + "sass_processor", + "bookwyrm", + "celery", + "django_celery_beat", + "imagekit", + "pgtrigger", + "storages", ] MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware" , - "django.contrib.sessions.middleware.SessionMiddleware" , - "django.middleware.locale.LocaleMiddleware" , - "django.middleware.common.CommonMiddleware" , - "django.middleware.csrf.CsrfViewMiddleware" , - "csp.middleware.CSPMiddleware" , - "django.contrib.auth.middleware.AuthenticationMiddleware" , - "bookwyrm.middleware.TimezoneMiddleware" , - "bookwyrm.middleware.IPBlocklistMiddleware" , - "django.contrib.messages.middleware.MessageMiddleware" , - "django.middleware.clickjacking.XFrameOptionsMiddleware" , - "bookwyrm.middleware.FileTooBig" , + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "csp.middleware.CSPMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "bookwyrm.middleware.TimezoneMiddleware", + "bookwyrm.middleware.IPBlocklistMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "bookwyrm.middleware.FileTooBig", ] ROOT_URLCONF = "bookwyrm.urls" TEMPLATES = [ { - "BACKEND" : "django.template.backends.django.DjangoTemplates" , - "DIRS" : [ "templates" ] , - "APP_DIRS" : True , - "OPTIONS" : { - "context_processors" : [ - "django.template.context_processors.debug" , - "django.template.context_processors.request" , - "django.contrib.auth.context_processors.auth" , - "django.contrib.messages.context_processors.messages" , - "bookwyrm.context_processors.site_settings" , - ] , - } , - } , + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": ["templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "bookwyrm.context_processors.site_settings", + ], + }, + }, ] -LOG_LEVEL = env ( "LOG_LEVEL" , "INFO" ) . upper () +LOG_LEVEL = env("LOG_LEVEL", "INFO").upper() # Override aspects of the default handler to our taste -# See https://docs.djangoproject.com/en/3.2/topics/logging/#default-logging-configuration for a reference to the defaults we're overriding -# It seems that in order to override anything you have to include its entire dependency tree (handlers and filters) which makes this a bit verbose +# See https://docs.djangoproject.com/en/3.2/topics/logging/#default-logging-configuration +# for a reference to the defaults we're overriding +# +# It seems that in order to override anything you have to include its +# entire dependency tree (handlers and filters) which makes this a +# bit verbose LOGGING = { - "version" : 1 , - "disable_existing_loggers" : False , - "filters" : { - # These are copied from the default configuration, required for implementing mail_admins below - "require_debug_false" : { "()" : "django.utils.log.RequireDebugFalse" } , - "require_debug_true": { "()" : "django.utils.log.RequireDebugTrue" } , - "ignore_missing_variable" : { "()" : "bookwyrm.utils.log.IgnoreVariableDoesNotExist" } , - } , - "handlers" : { - # Overrides the default handler to make it log to console regardless of the DEBUG setting (default is to not log to console if DEBUG=False) - "console" : { - "level" : LOG_LEVEL , - "filters" : [ "ignore_missing_variable" ] , - "class" : "logging.StreamHandler" , + "version": 1, + "disable_existing_loggers": False, + "filters": { + # These are copied from the default configuration, required for + # implementing mail_admins below + "require_debug_false": { + "()": "django.utils.log.RequireDebugFalse", }, - # This is copied as-is from the default logger, and is required for the Django section below - "mail_admins" : { - "level" : "ERROR" , - "filters" : [ "require_debug_false" ] , - "class" : "django.utils.log.AdminEmailHandler" , - } , - } , - "loggers" : { - # Install our new console handler for Django's logger, and override the log level while we're at it - "django" : { - "handlers" : [ "console" , "mail_admins" ] , - "level" : LOG_LEVEL , - } , - "django.utils.autoreload" : { "level" : "INFO" } , - # Add a Bookwyrm-specific logger - "bookwyrm" : { - "handlers" : [ "console" ] , - "level" : LOG_LEVEL , - } , - } , + "require_debug_true": { + "()": "django.utils.log.RequireDebugTrue", + }, + "ignore_missing_variable": { + "()": "bookwyrm.utils.log.IgnoreVariableDoesNotExist", + }, + }, + "handlers": { + # Overrides the default handler to make it log to console + # regardless of the DEBUG setting (default is to not log to + # console if DEBUG=False) + "console": { + "level": LOG_LEVEL, + "filters": ["ignore_missing_variable"], + "class": "logging.StreamHandler", + }, + # This is copied as-is from the default logger, and is + # required for the django section below + "mail_admins": { + "level": "ERROR", + "filters": ["require_debug_false"], + "class": "django.utils.log.AdminEmailHandler", + }, + }, + "loggers": { + # Install our new console handler for Django's logger, and + # override the log level while we're at it + "django": { + "handlers": ["console", "mail_admins"], + "level": LOG_LEVEL, + }, + "django.utils.autoreload": { + "level": "INFO", + }, + # Add a bookwyrm-specific logger + "bookwyrm": { + "handlers": ["console"], + "level": LOG_LEVEL, + }, + }, } STATICFILES_FINDERS = [ - "django.contrib.staticfiles.finders.FileSystemFinder" , - "django.contrib.staticfiles.finders.AppDirectoriesFinder" , - "sass_processor.finders.CssFinder" , + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", + "sass_processor.finders.CssFinder", ] SASS_PROCESSOR_INCLUDE_FILE_PATTERN = r"^.+\.[s]{0,1}(?:a|c)ss$" -# When debug is disabled, make sure to compile themes once with `./bw-dev compile_themes` +# when debug is disabled, make sure to compile themes once with `./bw-dev compile_themes` SASS_PROCESSOR_ENABLED = DEBUG -# Minify CSS in production but not dev +# minify css is production but not dev if not DEBUG: SASS_OUTPUT_STYLE = "compressed" WSGI_APPLICATION = "bookwyrm.wsgi.application" -# Redis/activity streams settings -REDIS_ACTIVITY_HOST = env ( "REDIS_ACTIVITY_HOST" , "localhost" ) -REDIS_ACTIVITY_PORT = env . int ( "REDIS_ACTIVITY_PORT" , 6379 ) -REDIS_ACTIVITY_PASSWORD = requests . utils . quote ( env ( "REDIS_ACTIVITY_PASSWORD" , "" ) ) -REDIS_ACTIVITY_DB_INDEX = env . int ( "REDIS_ACTIVITY_DB_INDEX" , 0 ) -REDIS_ACTIVITY_URL = env ( - "REDIS_ACTIVITY_URL" , - f"redis://:{REDIS_ACTIVITY_PASSWORD}@{REDIS_ACTIVITY_HOST}:{REDIS_ACTIVITY_PORT}/{REDIS_ACTIVITY_DB_INDEX}" , +# redis/activity streams settings +REDIS_ACTIVITY_HOST = env("REDIS_ACTIVITY_HOST", "localhost") +REDIS_ACTIVITY_PORT = env.int("REDIS_ACTIVITY_PORT", 6379) +REDIS_ACTIVITY_PASSWORD = requests.utils.quote(env("REDIS_ACTIVITY_PASSWORD", "")) +REDIS_ACTIVITY_DB_INDEX = env.int("REDIS_ACTIVITY_DB_INDEX", 0) +REDIS_ACTIVITY_URL = env( + "REDIS_ACTIVITY_URL", + f"redis://:{REDIS_ACTIVITY_PASSWORD}@{REDIS_ACTIVITY_HOST}:{REDIS_ACTIVITY_PORT}/{REDIS_ACTIVITY_DB_INDEX}", ) -MAX_STREAM_LENGTH = env . int ( "MAX_STREAM_LENGTH" , 200 ) +MAX_STREAM_LENGTH = env.int("MAX_STREAM_LENGTH", 200) STREAMS = [ - { "key" : "home" , "name" : _("Home Timeline") , "shortname" : _("Home") } , - { "key" : "books" , "name" : _("Books Timeline") , "shortname" : _("Books") } , + {"key": "home", "name": _("Home Timeline"), "shortname": _("Home")}, + {"key": "books", "name": _("Books Timeline"), "shortname": _("Books")}, ] # Search configuration -# Total time in seconds that the instance will spend searching connectors -SEARCH_TIMEOUT = env . int ( "SEARCH_TIMEOUT" , 8 ) -# Timeout for a query to an individual connector -QUERY_TIMEOUT = env . int ( "INTERACTIVE_QUERY_TIMEOUT" , env . int ( "QUERY_TIMEOUT" , 5 ) ) +# total time in seconds that the instance will spend searching connectors +SEARCH_TIMEOUT = env.int("SEARCH_TIMEOUT", 8) +# timeout for a query to an individual connector +QUERY_TIMEOUT = env.int("INTERACTIVE_QUERY_TIMEOUT", env.int("QUERY_TIMEOUT", 5)) # Redis cache backend -if env . bool ( "USE_DUMMY_CACHE" , False ) : +if env.bool("USE_DUMMY_CACHE", False): CACHES = { - "default" : { - "BACKEND" : "django.core.cache.backends.dummy.DummyCache" , - } , - "file_resubmit" : { - "BACKEND" : "django.core.cache.backends.dummy.DummyCache" , - "LOCATION" : "/tmp/file_resubmit_tests/" , - } , + "default": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + }, + "file_resubmit": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + "LOCATION": "/tmp/file_resubmit_tests/", + }, } -else : +else: CACHES = { - "default" : { - "BACKEND" : "django.core.cache.backends.redis.RedisCache" , - "LOCATION" : REDIS_ACTIVITY_URL , - } , - "file_resubmit" : { - "BACKEND" : "django.core.cache.backends.filebased.FileBasedCache" , - "LOCATION" : "/tmp/file_resubmit/" , - } , + "default": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": REDIS_ACTIVITY_URL, + }, + "file_resubmit": { + "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", + "LOCATION": "/tmp/file_resubmit/", + }, } SESSION_ENGINE = "django.contrib.sessions.backends.cache" @@ -253,14 +273,14 @@ else : # https://docs.djangoproject.com/en/3.2/ref/settings/#databases DATABASES = { - "default" : { - "ENGINE" : "django.db.backends.postgresql_psycopg2" , - "NAME" : env ( "POSTGRES_DB" , "bookwyrm" ) , - "USER" : env ( "POSTGRES_USER" , "bookwyrm" ) , - "PASSWORD" : env ( "POSTGRES_PASSWORD" , "bookwyrm" ) , - "HOST" : env ( "POSTGRES_HOST" , "" ) , - "PORT" : env . int ( "PGPORT" , 5432 ) , - } , + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": env("POSTGRES_DB", "bookwyrm"), + "USER": env("POSTGRES_USER", "bookwyrm"), + "PASSWORD": env("POSTGRES_PASSWORD", "bookwyrm"), + "HOST": env("POSTGRES_HOST", ""), + "PORT": env.int("PGPORT", 5432), + }, } @@ -271,45 +291,53 @@ AUTH_USER_MODEL = "bookwyrm.User" # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ - { "NAME" : "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" } , - { "NAME" : "django.contrib.auth.password_validation.MinimumLengthValidator" } , - { "NAME" : "django.contrib.auth.password_validation.CommonPasswordValidator" } , - { "NAME" : "django.contrib.auth.password_validation.NumericPasswordValidator" } , + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, ] # Internationalization # https://docs.djangoproject.com/en/3.2/topics/i18n/ -LANGUAGE_CODE = env ( "LANGUAGE_CODE" , "en-us" ) +LANGUAGE_CODE = env("LANGUAGE_CODE", "en-us") LANGUAGES = [ - ( "en-us" , _("English") ) , - ( "ca-es" , _("Català (Catalan)") ) , - ( "de-de" , _("Deutsch (German)") ) , - ( "eo-uy" , _("Esperanto (Esperanto)") ) , - ( "es-es" , _("Español (Spanish)") ) , - ( "eu-es" , _("Euskara (Basque)") ) , - ( "gl-es" , _("Galego (Galician)") ) , - ( "it-it" , _("Italiano (Italian)") ) , - ( "ko-kr" , _("한국어 (Korean)") ) , - ( "fi-fi" , _("Suomi (Finnish)") ) , - ( "fr-fr" , _("Français (French)") ) , - ( "lt-lt" , _("Lietuvių (Lithuanian)") ) , - ( "nl-nl" , _("Nederlands (Dutch)") ) , - ( "no-no" , _("Norsk (Norwegian)") ) , - ( "pl-pl" , _("Polski (Polish)") ) , - ( "pt-br" , _("Português do Brasil (Brazilian Portuguese)") ) , - ( "pt-pt" , _("Português Europeu (European Portuguese)") ) , - ( "ro-ro" , _("Română (Romanian)") ) , - ( "sv-se" , _("Svenska (Swedish)") ) , - ( "uk-ua" , _("Українська (Ukrainian)") ) , - ( "zh-hans" , _("简体中文 (Simplified Chinese)") ) , - ( "zh-hant" , _("繁體中文 (Traditional Chinese)") ) , + ("en-us", _("English")), + ("ca-es", _("Català (Catalan)")), + ("de-de", _("Deutsch (German)")), + ("eo-uy", _("Esperanto (Esperanto)")), + ("es-es", _("Español (Spanish)")), + ("eu-es", _("Euskara (Basque)")), + ("gl-es", _("Galego (Galician)")), + ("it-it", _("Italiano (Italian)")), + ("ko-kr", _("한국어 (Korean)")), + ("fi-fi", _("Suomi (Finnish)")), + ("fr-fr", _("Français (French)")), + ("lt-lt", _("Lietuvių (Lithuanian)")), + ("nl-nl", _("Nederlands (Dutch)")), + ("no-no", _("Norsk (Norwegian)")), + ("pl-pl", _("Polski (Polish)")), + ("pt-br", _("Português do Brasil (Brazilian Portuguese)")), + ("pt-pt", _("Português Europeu (European Portuguese)")), + ("ro-ro", _("Română (Romanian)")), + ("sv-se", _("Svenska (Swedish)")), + ("uk-ua", _("Українська (Ukrainian)")), + ("zh-hans", _("简体中文 (Simplified Chinese)")), + ("zh-hant", _("繁體中文 (Traditional Chinese)")), ] LANGUAGE_ARTICLES = { - "English" : { "the" , "a" , "an" } , - "Español (Spanish)" : { "un" , "una" , "unos" , "unas" , "el" , "la" , "los" , "las" } , + "English": {"the", "a", "an"}, + "Español (Spanish)": {"un", "una", "unos", "unas", "el", "la", "los", "las"}, } TIME_ZONE = "UTC" @@ -319,78 +347,78 @@ USE_I18N = True USE_TZ = True # Imagekit generated thumbnails -ENABLE_THUMBNAIL_GENERATION = env . bool ( "ENABLE_THUMBNAIL_GENERATION" , False ) +ENABLE_THUMBNAIL_GENERATION = env.bool("ENABLE_THUMBNAIL_GENERATION", False) IMAGEKIT_CACHEFILE_DIR = "thumbnails" IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY = "bookwyrm.thumbnail_generation.Strategy" -PROJECT_DIR = os . path . dirname ( os . path . abspath (__file__) ) -CSP_ADDITIONAL_HOSTS = env . list ( "CSP_ADDITIONAL_HOSTS" , [] ) +PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) +CSP_ADDITIONAL_HOSTS = env.list("CSP_ADDITIONAL_HOSTS", []) PROTOCOL = "http" -if USE_HTTPS : +if USE_HTTPS: PROTOCOL = "https" SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True -PORT = env . int ( "PORT" , 443 if USE_HTTPS else 80 ) -if ( USE_HTTPS and PORT == 443 ) or ( not USE_HTTPS and PORT == 80 ) : +PORT = env.int("PORT", 443 if USE_HTTPS else 80) +if (USE_HTTPS and PORT == 443) or (not USE_HTTPS and PORT == 80): NETLOC = DOMAIN -else : +else: NETLOC = f"{DOMAIN}:{PORT}" BASE_URL = f"{PROTOCOL}://{NETLOC}" -CSRF_TRUSTED_ORIGINS = [ BASE_URL ] +CSRF_TRUSTED_ORIGINS = [BASE_URL] USER_AGENT = f"BookWyrm (BookWyrm/{VERSION}; +{BASE_URL})" # Storage -USE_S3 = env . bool ( "USE_S3" , False ) -USE_AZURE = env . bool ( "USE_AZURE" , False ) -S3_SIGNED_URL_EXPIRY = env . int ( "S3_SIGNED_URL_EXPIRY" , 900 ) +USE_S3 = env.bool("USE_S3", False) +USE_AZURE = env.bool("USE_AZURE", False) +S3_SIGNED_URL_EXPIRY = env.int("S3_SIGNED_URL_EXPIRY", 900) -if USE_S3 : +if USE_S3: # AWS settings - AWS_ACCESS_KEY_ID = env ("AWS_ACCESS_KEY_ID") - AWS_SECRET_ACCESS_KEY = env ("AWS_SECRET_ACCESS_KEY") - AWS_STORAGE_BUCKET_NAME = env ("AWS_STORAGE_BUCKET_NAME") - AWS_S3_CUSTOM_DOMAIN = env ( "AWS_S3_CUSTOM_DOMAIN" , None ) - AWS_S3_REGION_NAME = env ( "AWS_S3_REGION_NAME" , "" ) - AWS_S3_ENDPOINT_URL = env ( "AWS_S3_ENDPOINT_URL" , None ) + AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID") + AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY") + AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME") + AWS_S3_CUSTOM_DOMAIN = env("AWS_S3_CUSTOM_DOMAIN", None) + AWS_S3_REGION_NAME = env("AWS_S3_REGION_NAME", "") + AWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL", None) AWS_DEFAULT_ACL = "public-read" - AWS_S3_OBJECT_PARAMETERS = { "CacheControl" : "max-age=86400" } - AWS_S3_URL_PROTOCOL = env ( "AWS_S3_URL_PROTOCOL" , f"{PROTOCOL}:" ) + AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"} + AWS_S3_URL_PROTOCOL = env("AWS_S3_URL_PROTOCOL", f"{PROTOCOL}:") # Storages STORAGES = { - "default" : { - "BACKEND" : "storages.backends.s3.S3Storage" , - "OPTIONS" : { - "location" : "images" , - "default_acl" : "public-read" , - "file_overwrite" : False , - } , - } , - "staticfiles" : { - "BACKEND" : "storages.backends.s3.S3Storage" , - "OPTIONS" : { - "location" : "static" , - "default_acl" : "public-read" , - } , - } , - "sass_processor" : { - "BACKEND" : "storages.backends.s3.S3Storage" , - "OPTIONS" : { - "location" : "static" , - "default_acl" : "public-read" , - } , - } , - "exports" : { - "BACKEND" : "storages.backends.s3.S3Storage" , - "OPTIONS" : { - "location" : "images" , - "default_acl" : None , - "file_overwrite" : False , - } , - } , + "default": { + "BACKEND": "storages.backends.s3.S3Storage", + "OPTIONS": { + "location": "images", + "default_acl": "public-read", + "file_overwrite": False, + }, + }, + "staticfiles": { + "BACKEND": "storages.backends.s3.S3Storage", + "OPTIONS": { + "location": "static", + "default_acl": "public-read", + }, + }, + "sass_processor": { + "BACKEND": "storages.backends.s3.S3Storage", + "OPTIONS": { + "location": "static", + "default_acl": "public-read", + }, + }, + "exports": { + "BACKEND": "storages.backends.s3.S3Storage", + "OPTIONS": { + "location": "images", + "default_acl": None, + "file_overwrite": False, + }, + }, } # S3 Static settings STATIC_LOCATION = "static" @@ -402,62 +430,72 @@ if USE_S3 : MEDIA_FULL_URL = MEDIA_URL # Content Security Policy CSP_DEFAULT_SRC = [ - "'self'" , - f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}" if AWS_S3_CUSTOM_DOMAIN else None , + "'self'", + f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}" + if AWS_S3_CUSTOM_DOMAIN + else None, ] + CSP_ADDITIONAL_HOSTS CSP_SCRIPT_SRC = [ - "'self'" , - f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}" if AWS_S3_CUSTOM_DOMAIN else None , + "'self'", + f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}" + if AWS_S3_CUSTOM_DOMAIN + else None, ] + CSP_ADDITIONAL_HOSTS -elif USE_AZURE : +elif USE_AZURE: # Azure settings - AZURE_ACCOUNT_NAME = env ("AZURE_ACCOUNT_NAME") - AZURE_ACCOUNT_KEY = env ("AZURE_ACCOUNT_KEY") - AZURE_CONTAINER = env ("AZURE_CONTAINER") - AZURE_CUSTOM_DOMAIN = env ("AZURE_CUSTOM_DOMAIN") + AZURE_ACCOUNT_NAME = env("AZURE_ACCOUNT_NAME") + AZURE_ACCOUNT_KEY = env("AZURE_ACCOUNT_KEY") + AZURE_CONTAINER = env("AZURE_CONTAINER") + AZURE_CUSTOM_DOMAIN = env("AZURE_CUSTOM_DOMAIN") # Storages STORAGES = { - "default" : { - "BACKEND" : "storages.backends.azure_storage.AzureStorage" , - "OPTIONS" : { - "location" : "images" , - "overwrite_files" : False , - } , - } , - "staticfiles" : { - "BACKEND" : "storages.backends.azure_storage.AzureStorage" , - "OPTIONS" : { - "location" : "static" , - } , - } , - "exports" : { - "BACKEND" : None , # Not implemented yet - } , + "default": { + "BACKEND": "storages.backends.azure_storage.AzureStorage", + "OPTIONS": { + "location": "images", + "overwrite_files": False, + }, + }, + "staticfiles": { + "BACKEND": "storages.backends.azure_storage.AzureStorage", + "OPTIONS": { + "location": "static", + }, + }, + "exports": { + "BACKEND": None, # not implemented yet + }, } # Azure Static settings STATIC_LOCATION = "static" - STATIC_URL = ( f"{PROTOCOL}://{AZURE_CUSTOM_DOMAIN}/{AZURE_CONTAINER}/{STATIC_LOCATION}/" ) + STATIC_URL = ( + f"{PROTOCOL}://{AZURE_CUSTOM_DOMAIN}/{AZURE_CONTAINER}/{STATIC_LOCATION}/" + ) STATIC_FULL_URL = STATIC_URL # Azure Media settings MEDIA_LOCATION = "images" - MEDIA_URL = ( f"{PROTOCOL}://{AZURE_CUSTOM_DOMAIN}/{AZURE_CONTAINER}/{MEDIA_LOCATION}/" ) + MEDIA_URL = ( + f"{PROTOCOL}://{AZURE_CUSTOM_DOMAIN}/{AZURE_CONTAINER}/{MEDIA_LOCATION}/" + ) MEDIA_FULL_URL = MEDIA_URL # Content Security Policy - CSP_DEFAULT_SRC = [ "'self'" , AZURE_CUSTOM_DOMAIN ] + CSP_ADDITIONAL_HOSTS - CSP_SCRIPT_SRC = [ "'self'" , AZURE_CUSTOM_DOMAIN ] + CSP_ADDITIONAL_HOSTS -else : + CSP_DEFAULT_SRC = ["'self'", AZURE_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS + CSP_SCRIPT_SRC = ["'self'", AZURE_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS +else: # Storages STORAGES = { - "default" : { - "BACKEND" : "django.core.files.storage.FileSystemStorage" , - } , - "staticfiles" : { - "BACKEND" : "django.contrib.staticfiles.storage.StaticFilesStorage" , - } , - "exports" : { - "BACKEND" : "django.core.files.storage.FileSystemStorage" , - "OPTIONS" : { "location" : "exports" } , - } , + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + "exports": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + "OPTIONS": { + "location": "exports", + }, + }, } # Static settings STATIC_URL = "/static/" @@ -466,21 +504,29 @@ else : MEDIA_URL = "/images/" MEDIA_FULL_URL = BASE_URL + MEDIA_URL # Content Security Policy - CSP_DEFAULT_SRC = [ "'self'" ] + CSP_ADDITIONAL_HOSTS - CSP_SCRIPT_SRC = [ "'self'" ] + CSP_ADDITIONAL_HOSTS + CSP_DEFAULT_SRC = ["'self'"] + CSP_ADDITIONAL_HOSTS + CSP_SCRIPT_SRC = ["'self'"] + CSP_ADDITIONAL_HOSTS -CSP_INCLUDE_NONCE_IN = [ "script-src" ] +CSP_INCLUDE_NONCE_IN = ["script-src"] -TWO_FACTOR_LOGIN_MAX_SECONDS = env . int ( "TWO_FACTOR_LOGIN_MAX_SECONDS" , 60 ) -TWO_FACTOR_LOGIN_VALIDITY_WINDOW = env . int ( "TWO_FACTOR_LOGIN_VALIDITY_WINDOW" , 2 ) +OTEL_EXPORTER_OTLP_ENDPOINT = env("OTEL_EXPORTER_OTLP_ENDPOINT", None) +OTEL_EXPORTER_OTLP_HEADERS = env("OTEL_EXPORTER_OTLP_HEADERS", None) +OTEL_SERVICE_NAME = env("OTEL_SERVICE_NAME", None) +OTEL_EXPORTER_CONSOLE = env.bool("OTEL_EXPORTER_CONSOLE", False) -HTTP_X_FORWARDED_PROTO = env . bool ( "SECURE_PROXY_SSL_HEADER" , False ) -if HTTP_X_FORWARDED_PROTO : - SECURE_PROXY_SSL_HEADER = ( "HTTP_X_FORWARDED_PROTO" , "https" ) +TWO_FACTOR_LOGIN_MAX_SECONDS = env.int("TWO_FACTOR_LOGIN_MAX_SECONDS", 60) +TWO_FACTOR_LOGIN_VALIDITY_WINDOW = env.int("TWO_FACTOR_LOGIN_VALIDITY_WINDOW", 2) -# Instance Actor for signing GET requests to "secure mode" Mastodon servers. -# Do not change this setting unless you already have an existing user with the same username - in which case you should change it +HTTP_X_FORWARDED_PROTO = env.bool("SECURE_PROXY_SSL_HEADER", False) +if HTTP_X_FORWARDED_PROTO: + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +# Instance Actor for signing GET requests to "secure mode" +# Mastodon servers. +# Do not change this setting unless you already have an existing +# user with the same username - in which case you should change it! INSTANCE_ACTOR_USERNAME = "bookwyrm.instance.actor" -# We only allow specifying DATA_UPLOAD_MAX_MEMORY_SIZE in MiB from .env (note the difference in variable names). -DATA_UPLOAD_MAX_MEMORY_SIZE = env . int ( "DATA_UPLOAD_MAX_MEMORY_MiB" , 100 ) << 20 +# We only allow specifying DATA_UPLOAD_MAX_MEMORY_SIZE in MiB from .env +# (note the difference in variable names). +DATA_UPLOAD_MAX_MEMORY_SIZE = env.int("DATA_UPLOAD_MAX_MEMORY_MiB", 100) << 20