diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py index d363fbd5..bfb22fa3 100644 --- a/bookwyrm/activitypub/__init__.py +++ b/bookwyrm/activitypub/__init__.py @@ -27,5 +27,5 @@ activity_objects = {c[0]: c[1] for c in cls_members if hasattr(c[1], "to_model") def parse(activity_json): - """ figure out what activity this is and parse it """ + """figure out what activity this is and parse it""" return naive_parse(activity_objects, activity_json) diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index dd2795bb..5349e1dd 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -10,11 +10,11 @@ from bookwyrm.tasks import app class ActivitySerializerError(ValueError): - """ routine problems serializing activitypub json """ + """routine problems serializing activitypub json""" class ActivityEncoder(JSONEncoder): - """ used to convert an Activity object into json """ + """used to convert an Activity object into json""" def default(self, o): return o.__dict__ @@ -22,7 +22,7 @@ class ActivityEncoder(JSONEncoder): @dataclass class Link: - """ for tagging a book in a status """ + """for tagging a book in a status""" href: str name: str @@ -31,14 +31,14 @@ class Link: @dataclass class Mention(Link): - """ a subtype of Link for mentioning an actor """ + """a subtype of Link for mentioning an actor""" type: str = "Mention" @dataclass class Signature: - """ public key block """ + """public key block""" creator: str created: str @@ -47,7 +47,7 @@ class Signature: def naive_parse(activity_objects, activity_json, serializer=None): - """ this navigates circular import issues """ + """this navigates circular import issues""" if not serializer: if activity_json.get("publicKeyPem"): # ugh @@ -67,7 +67,7 @@ def naive_parse(activity_objects, activity_json, serializer=None): @dataclass(init=False) class ActivityObject: - """ actor activitypub json """ + """actor activitypub json""" id: str type: str @@ -106,7 +106,7 @@ class ActivityObject: setattr(self, field.name, value) def to_model(self, model=None, instance=None, allow_create=True, save=True): - """ convert from an activity to a model instance """ + """convert from an activity to a model instance""" model = model or get_model_from_type(self.type) # only reject statuses if we're potentially creating them @@ -181,7 +181,7 @@ class ActivityObject: return instance def serialize(self): - """ convert to dictionary with context attr """ + """convert to dictionary with context attr""" data = self.__dict__.copy() # recursively serialize for (k, v) in data.items(): @@ -200,7 +200,7 @@ class ActivityObject: def set_related_field( model_name, origin_model_name, related_field_name, related_remote_id, data ): - """ load reverse related fields (editions, attachments) without blocking """ + """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) @@ -236,7 +236,7 @@ def set_related_field( def get_model_from_type(activity_type): - """ given the activity, what type of model """ + """given the activity, what type of model""" models = apps.get_models() model = [ m @@ -255,7 +255,7 @@ def get_model_from_type(activity_type): def resolve_remote_id( remote_id, model=None, refresh=False, save=True, get_activity=False ): - """ take a remote_id and return an instance, creating if necessary """ + """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 result = model.find_existing_by_remote_id(remote_id) if result and not refresh: diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index 7615adcf..f6ebf913 100644 --- a/bookwyrm/activitypub/book.py +++ b/bookwyrm/activitypub/book.py @@ -8,9 +8,10 @@ from .image import Document @dataclass(init=False) class Book(ActivityObject): - """ serializes an edition or work, abstract """ + """serializes an edition or work, abstract""" title: str + lastEditedBy: str = None sortTitle: str = "" subtitle: str = "" description: str = "" @@ -34,7 +35,7 @@ class Book(ActivityObject): @dataclass(init=False) class Edition(Book): - """ Edition instance of a book object """ + """Edition instance of a book object""" work: str isbn10: str = "" @@ -51,7 +52,7 @@ class Edition(Book): @dataclass(init=False) class Work(Book): - """ work instance of a book object """ + """work instance of a book object""" lccn: str = "" defaultEdition: str = "" @@ -61,9 +62,10 @@ class Work(Book): @dataclass(init=False) class Author(ActivityObject): - """ author of a book """ + """author of a book""" name: str + lastEditedBy: str = None born: str = None died: str = None aliases: List[str] = field(default_factory=lambda: []) diff --git a/bookwyrm/activitypub/image.py b/bookwyrm/activitypub/image.py index a7120ce4..7950faaf 100644 --- a/bookwyrm/activitypub/image.py +++ b/bookwyrm/activitypub/image.py @@ -5,7 +5,7 @@ from .base_activity import ActivityObject @dataclass(init=False) class Document(ActivityObject): - """ a document """ + """a document""" url: str name: str = "" @@ -15,6 +15,6 @@ class Document(ActivityObject): @dataclass(init=False) class Image(Document): - """ an image """ + """an image""" type: str = "Image" diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py index e1a42958..b501c3d6 100644 --- a/bookwyrm/activitypub/note.py +++ b/bookwyrm/activitypub/note.py @@ -9,19 +9,19 @@ from .image import Document @dataclass(init=False) class Tombstone(ActivityObject): - """ the placeholder for a deleted status """ + """the placeholder for a deleted status""" type: str = "Tombstone" def to_model(self, *args, **kwargs): # pylint: disable=unused-argument - """ this should never really get serialized, just searched for """ + """this should never really get serialized, just searched for""" model = apps.get_model("bookwyrm.Status") return model.find_existing_by_remote_id(self.id) @dataclass(init=False) class Note(ActivityObject): - """ Note activity """ + """Note activity""" published: str attributedTo: str @@ -39,7 +39,7 @@ class Note(ActivityObject): @dataclass(init=False) class Article(Note): - """ what's an article except a note with more fields """ + """what's an article except a note with more fields""" name: str type: str = "Article" @@ -47,14 +47,14 @@ class Article(Note): @dataclass(init=False) class GeneratedNote(Note): - """ just a re-typed note """ + """just a re-typed note""" type: str = "GeneratedNote" @dataclass(init=False) class Comment(Note): - """ like a note but with a book """ + """like a note but with a book""" inReplyToBook: str type: str = "Comment" @@ -62,7 +62,7 @@ class Comment(Note): @dataclass(init=False) class Quotation(Comment): - """ a quote and commentary on a book """ + """a quote and commentary on a book""" quote: str type: str = "Quotation" @@ -70,7 +70,7 @@ class Quotation(Comment): @dataclass(init=False) class Review(Comment): - """ a full book review """ + """a full book review""" name: str = None rating: int = None @@ -79,7 +79,7 @@ class Review(Comment): @dataclass(init=False) class Rating(Comment): - """ just a star rating """ + """just a star rating""" rating: int content: str = None diff --git a/bookwyrm/activitypub/ordered_collection.py b/bookwyrm/activitypub/ordered_collection.py index 650f6a40..e3a83be8 100644 --- a/bookwyrm/activitypub/ordered_collection.py +++ b/bookwyrm/activitypub/ordered_collection.py @@ -7,7 +7,7 @@ from .base_activity import ActivityObject @dataclass(init=False) class OrderedCollection(ActivityObject): - """ structure of an ordered collection activity """ + """structure of an ordered collection activity""" totalItems: int first: str @@ -19,7 +19,7 @@ class OrderedCollection(ActivityObject): @dataclass(init=False) class OrderedCollectionPrivate(OrderedCollection): - """ an ordered collection with privacy settings """ + """an ordered collection with privacy settings""" to: List[str] = field(default_factory=lambda: []) cc: List[str] = field(default_factory=lambda: []) @@ -27,14 +27,14 @@ class OrderedCollectionPrivate(OrderedCollection): @dataclass(init=False) class Shelf(OrderedCollectionPrivate): - """ structure of an ordered collection activity """ + """structure of an ordered collection activity""" type: str = "Shelf" @dataclass(init=False) class BookList(OrderedCollectionPrivate): - """ structure of an ordered collection activity """ + """structure of an ordered collection activity""" summary: str = None curation: str = "closed" @@ -43,7 +43,7 @@ class BookList(OrderedCollectionPrivate): @dataclass(init=False) class OrderedCollectionPage(ActivityObject): - """ structure of an ordered collection activity """ + """structure of an ordered collection activity""" partOf: str orderedItems: List @@ -54,7 +54,7 @@ class OrderedCollectionPage(ActivityObject): @dataclass(init=False) class CollectionItem(ActivityObject): - """ an item in a collection """ + """an item in a collection""" actor: str type: str = "CollectionItem" @@ -62,7 +62,7 @@ class CollectionItem(ActivityObject): @dataclass(init=False) class ListItem(CollectionItem): - """ a book on a list """ + """a book on a list""" book: str notes: str = None @@ -73,7 +73,7 @@ class ListItem(CollectionItem): @dataclass(init=False) class ShelfItem(CollectionItem): - """ a book on a list """ + """a book on a list""" book: str type: str = "ShelfItem" diff --git a/bookwyrm/activitypub/person.py b/bookwyrm/activitypub/person.py index 9231bd95..d5f37946 100644 --- a/bookwyrm/activitypub/person.py +++ b/bookwyrm/activitypub/person.py @@ -8,7 +8,7 @@ from .image import Image @dataclass(init=False) class PublicKey(ActivityObject): - """ public key block """ + """public key block""" owner: str publicKeyPem: str @@ -17,7 +17,7 @@ class PublicKey(ActivityObject): @dataclass(init=False) class Person(ActivityObject): - """ actor activitypub json """ + """actor activitypub json""" preferredUsername: str inbox: str diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py index c2cbfea3..f26936d7 100644 --- a/bookwyrm/activitypub/verbs.py +++ b/bookwyrm/activitypub/verbs.py @@ -9,13 +9,13 @@ from .ordered_collection import CollectionItem @dataclass(init=False) class Verb(ActivityObject): - """generic fields for activities """ + """generic fields for activities""" actor: str object: ActivityObject def action(self): - """ usually we just want to update and save """ + """usually we just want to update and save""" # self.object may return None if the object is invalid in an expected way # ie, Question type if self.object: @@ -24,7 +24,7 @@ class Verb(ActivityObject): @dataclass(init=False) class Create(Verb): - """ Create activity """ + """Create activity""" to: List[str] cc: List[str] = field(default_factory=lambda: []) @@ -34,14 +34,14 @@ class Create(Verb): @dataclass(init=False) class Delete(Verb): - """ Create activity """ + """Create activity""" to: List[str] cc: List[str] = field(default_factory=lambda: []) type: str = "Delete" def action(self): - """ find and delete the activity object """ + """find and delete the activity object""" if not self.object: return @@ -59,25 +59,25 @@ class Delete(Verb): @dataclass(init=False) class Update(Verb): - """ Update activity """ + """Update activity""" to: List[str] type: str = "Update" def action(self): - """ update a model instance from the dataclass """ + """update a model instance from the dataclass""" if self.object: self.object.to_model(allow_create=False) @dataclass(init=False) class Undo(Verb): - """ Undo an activity """ + """Undo an activity""" type: str = "Undo" def action(self): - """ find and remove the activity object """ + """find and remove the activity object""" if isinstance(self.object, str): # it may be that sometihng should be done with these, but idk what # this seems just to be coming from pleroma @@ -103,64 +103,64 @@ class Undo(Verb): @dataclass(init=False) class Follow(Verb): - """ Follow activity """ + """Follow activity""" object: str type: str = "Follow" def action(self): - """ relationship save """ + """relationship save""" self.to_model() @dataclass(init=False) class Block(Verb): - """ Block activity """ + """Block activity""" object: str type: str = "Block" def action(self): - """ relationship save """ + """relationship save""" self.to_model() @dataclass(init=False) class Accept(Verb): - """ Accept activity """ + """Accept activity""" object: Follow type: str = "Accept" def action(self): - """ find and remove the activity object """ + """find and remove the activity object""" obj = self.object.to_model(save=False, allow_create=False) obj.accept() @dataclass(init=False) class Reject(Verb): - """ Reject activity """ + """Reject activity""" object: Follow type: str = "Reject" def action(self): - """ find and remove the activity object """ + """find and remove the activity object""" obj = self.object.to_model(save=False, allow_create=False) obj.reject() @dataclass(init=False) class Add(Verb): - """Add activity """ + """Add activity""" target: ActivityObject object: CollectionItem type: str = "Add" def action(self): - """ figure out the target to assign the item to a collection """ + """figure out the target to assign the item to a collection""" target = resolve_remote_id(self.target) item = self.object.to_model(save=False) setattr(item, item.collection_field, target) @@ -169,31 +169,32 @@ class Add(Verb): @dataclass(init=False) class Remove(Add): - """Remove activity """ + """Remove activity""" type: str = "Remove" def action(self): - """ find and remove the activity object """ + """find and remove the activity object""" obj = self.object.to_model(save=False, allow_create=False) - obj.delete() + if obj: + obj.delete() @dataclass(init=False) class Like(Verb): - """ a user faving an object """ + """a user faving an object""" object: str type: str = "Like" def action(self): - """ like """ + """like""" self.to_model() @dataclass(init=False) class Announce(Verb): - """ boosting a status """ + """boosting a status""" published: str to: List[str] = field(default_factory=lambda: []) @@ -202,5 +203,5 @@ class Announce(Verb): type: str = "Announce" def action(self): - """ boost """ + """boost""" self.to_model() diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py index 949ae9da..86321cd8 100644 --- a/bookwyrm/activitystreams.py +++ b/bookwyrm/activitystreams.py @@ -8,22 +8,22 @@ from bookwyrm.views.helpers import privacy_filter class ActivityStream(RedisStore): - """ a category of activity stream (like home, local, federated) """ + """a category of activity stream (like home, local, federated)""" def stream_id(self, user): - """ the redis key for this user's instance of this stream """ + """the redis key for this user's instance of this stream""" return "{}-{}".format(user.id, self.key) def unread_id(self, user): - """ the redis key for this user's unread count for this stream """ + """the redis key for this user's unread count for this stream""" return "{}-unread".format(self.stream_id(user)) def get_rank(self, obj): # pylint: disable=no-self-use - """ statuses are sorted by date published """ + """statuses are sorted by date published""" return obj.published_date.timestamp() def add_status(self, status): - """ add a status to users' feeds """ + """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) @@ -35,19 +35,19 @@ class ActivityStream(RedisStore): pipeline.execute() def add_user_statuses(self, viewer, user): - """ add a user's statuses to another user's feed """ + """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()) self.bulk_add_objects_to_store(statuses, self.stream_id(viewer)) def remove_user_statuses(self, viewer, user): - """ remove a user's status from another user's feed """ + """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)) def get_activity_stream(self, user): - """ load the statuses to be displayed """ + """load the statuses to be displayed""" # clear unreads for this feed r.set(self.unread_id(user), 0) @@ -59,15 +59,15 @@ class ActivityStream(RedisStore): ) def get_unread_count(self, user): - """ get the unread status count for this user's feed """ + """get the unread status count for this user's feed""" return int(r.get(self.unread_id(user)) or 0) def populate_streams(self, user): - """ go from zero to a timeline """ + """go from zero to a timeline""" self.populate_store(self.stream_id(user)) def get_audience(self, status): # pylint: disable=no-self-use - """ given a status, what users should see it """ + """given a status, what users should see it""" # direct messages don't appeard in feeds, direct comments/reviews/etc do if status.privacy == "direct" and status.status_type == "Note": return [] @@ -98,7 +98,7 @@ class ActivityStream(RedisStore): return [self.stream_id(u) for u in self.get_audience(obj)] def get_statuses_for_user(self, user): # pylint: disable=no-self-use - """ given a user, what statuses should they see on this stream """ + """given a user, what statuses should they see on this stream""" return privacy_filter( user, models.Status.objects.select_subclasses(), @@ -111,7 +111,7 @@ class ActivityStream(RedisStore): class HomeStream(ActivityStream): - """ users you follow """ + """users you follow""" key = "home" @@ -134,7 +134,7 @@ class HomeStream(ActivityStream): class LocalStream(ActivityStream): - """ users you follow """ + """users you follow""" key = "local" @@ -154,7 +154,7 @@ class LocalStream(ActivityStream): class FederatedStream(ActivityStream): - """ users you follow """ + """users you follow""" key = "federated" @@ -182,7 +182,7 @@ streams = { @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 """ + """add newly created statuses to activity feeds""" # we're only interested in new statuses if not issubclass(sender, models.Status): return @@ -203,7 +203,7 @@ def add_status_on_create(sender, instance, created, *args, **kwargs): @receiver(signals.post_delete, sender=models.Boost) # pylint: disable=unused-argument def remove_boost_on_delete(sender, instance, *args, **kwargs): - """ boosts are deleted """ + """boosts are deleted""" # we're only interested in new statuses for stream in streams.values(): stream.remove_object_from_related_stores(instance) @@ -212,7 +212,7 @@ def remove_boost_on_delete(sender, instance, *args, **kwargs): @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 """ + """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) @@ -221,7 +221,7 @@ def add_statuses_on_follow(sender, instance, created, *args, **kwargs): @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 """ + """remove statuses from a feed on unfollow""" if not instance.user_subject.local: return HomeStream().remove_user_statuses(instance.user_subject, instance.user_object) @@ -230,7 +230,7 @@ def remove_statuses_on_unfollow(sender, instance, *args, **kwargs): @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 """ + """remove statuses from all feeds on block""" # blocks apply ot all feeds if instance.user_subject.local: for stream in streams.values(): @@ -245,7 +245,7 @@ def remove_statuses_on_block(sender, instance, *args, **kwargs): @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 """ + """remove statuses from all feeds on block""" public_streams = [LocalStream(), FederatedStream()] # add statuses back to streams with statuses from anyone if instance.user_subject.local: @@ -261,7 +261,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 """ + """build a user's feeds when they join""" if not created or not instance.local: return diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index 2fe5d825..264b5a38 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) class AbstractMinimalConnector(ABC): - """ just the bare bones, for other bookwyrm instances """ + """just the bare bones, for other bookwyrm instances""" def __init__(self, identifier): # load connector settings @@ -39,7 +39,7 @@ class AbstractMinimalConnector(ABC): setattr(self, field, getattr(info, field)) def search(self, query, min_confidence=None): - """ free text search """ + """free text search""" params = {} if min_confidence: params["min_confidence"] = min_confidence @@ -55,7 +55,7 @@ class AbstractMinimalConnector(ABC): return results def isbn_search(self, query): - """ isbn search """ + """isbn search""" params = {} data = get_data( "%s%s" % (self.isbn_search_url, query), @@ -70,27 +70,27 @@ class AbstractMinimalConnector(ABC): @abstractmethod def get_or_create_book(self, remote_id): - """ pull up a book record by whatever means possible """ + """pull up a book record by whatever means possible""" @abstractmethod def parse_search_data(self, data): - """ turn the result json from a search into a list """ + """turn the result json from a search into a list""" @abstractmethod def format_search_result(self, search_result): - """ create a SearchResult obj from json """ + """create a SearchResult obj from json""" @abstractmethod def parse_isbn_search_data(self, data): - """ turn the result json from a search into a list """ + """turn the result json from a search into a list""" @abstractmethod def format_isbn_search_result(self, search_result): - """ create a SearchResult obj from json """ + """create a SearchResult obj from json""" class AbstractConnector(AbstractMinimalConnector): - """ generic book data connector """ + """generic book data connector""" def __init__(self, identifier): super().__init__(identifier) @@ -99,14 +99,14 @@ class AbstractConnector(AbstractMinimalConnector): self.book_mappings = [] def is_available(self): - """ check if you're allowed to use this connector """ + """check if you're allowed to use this connector""" if self.max_query_count is not None: if self.connector.query_count >= self.max_query_count: return False return True def get_or_create_book(self, remote_id): - """ translate arbitrary json into an Activitypub dataclass """ + """translate arbitrary json into an Activitypub dataclass""" # first, check if we have the origin_id saved existing = models.Edition.find_existing_by_remote_id( remote_id @@ -151,7 +151,7 @@ class AbstractConnector(AbstractMinimalConnector): return edition def create_edition_from_data(self, work, edition_data): - """ if we already have the work, we're ready """ + """if we already have the work, we're ready""" mapped_data = dict_from_mappings(edition_data, self.book_mappings) mapped_data["work"] = work.remote_id edition_activity = activitypub.Edition(**mapped_data) @@ -171,7 +171,7 @@ class AbstractConnector(AbstractMinimalConnector): return edition def get_or_create_author(self, remote_id): - """ load that author """ + """load that author""" existing = models.Author.find_existing_by_remote_id(remote_id) if existing: return existing @@ -189,23 +189,23 @@ class AbstractConnector(AbstractMinimalConnector): @abstractmethod def is_work_data(self, data): - """ differentiate works and editions """ + """differentiate works and editions""" @abstractmethod def get_edition_from_work_data(self, data): - """ every work needs at least one edition """ + """every work needs at least one edition""" @abstractmethod def get_work_from_edition_data(self, data): - """ every edition needs a work """ + """every edition needs a work""" @abstractmethod def get_authors_from_data(self, data): - """ load author data """ + """load author data""" @abstractmethod def expand_book_data(self, book): - """ get more info on a book """ + """get more info on a book""" def dict_from_mappings(data, mappings): @@ -218,7 +218,7 @@ def dict_from_mappings(data, mappings): def get_data(url, params=None): - """ wrapper for request.get """ + """wrapper for request.get""" # check if the url is blocked if models.FederatedServer.is_blocked(url): raise ConnectorException( @@ -250,7 +250,7 @@ def get_data(url, params=None): def get_image(url): - """ wrapper for requesting an image """ + """wrapper for requesting an image""" try: resp = requests.get( url, @@ -268,7 +268,7 @@ def get_image(url): @dataclass class SearchResult: - """ standardized search result object """ + """standardized search result object""" title: str key: str @@ -284,14 +284,14 @@ class SearchResult: ) def json(self): - """ serialize a connector for json response """ + """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 """ + """associate a local database field with a field in an external dataset""" def __init__(self, local_field, remote_field=None, formatter=None): noop = lambda x: x @@ -301,7 +301,7 @@ class Mapping: self.formatter = formatter or noop def get_value(self, data): - """ pull a field from incoming json and return the formatted version """ + """pull a field from incoming json and return the formatted version""" value = data.get(self.remote_field) if not value: return None diff --git a/bookwyrm/connectors/bookwyrm_connector.py b/bookwyrm/connectors/bookwyrm_connector.py index f7869d55..640a0bca 100644 --- a/bookwyrm/connectors/bookwyrm_connector.py +++ b/bookwyrm/connectors/bookwyrm_connector.py @@ -4,7 +4,7 @@ from .abstract_connector import AbstractMinimalConnector, SearchResult class Connector(AbstractMinimalConnector): - """ this is basically just for search """ + """this is basically just for search""" def get_or_create_book(self, remote_id): edition = activitypub.resolve_remote_id(remote_id, model=models.Edition) diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py index 53198c0a..20d273e0 100644 --- a/bookwyrm/connectors/connector_manager.py +++ b/bookwyrm/connectors/connector_manager.py @@ -16,11 +16,11 @@ logger = logging.getLogger(__name__) class ConnectorException(HTTPError): - """ when the connector can't do what was asked """ + """when the connector can't do what was asked""" def search(query, min_confidence=0.1): - """ find books based on arbitary keywords """ + """find books based on arbitary keywords""" if not query: return [] results = [] @@ -68,19 +68,19 @@ def search(query, min_confidence=0.1): def local_search(query, min_confidence=0.1, raw=False): - """ only look at local search results """ + """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) def isbn_local_search(query, raw=False): - """ only look at local search results """ + """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 """ + """search until you find a result that fits""" for connector in get_connectors(): result = connector.search(query, min_confidence=min_confidence) if result: @@ -89,13 +89,13 @@ def first_search_result(query, min_confidence=0.1): def get_connectors(): - """ load all connectors """ + """load all connectors""" for info in models.Connector.objects.order_by("priority").all(): yield load_connector(info) def get_or_create_connector(remote_id): - """ get the connector related to the object's server """ + """get the connector related to the object's server""" url = urlparse(remote_id) identifier = url.netloc if not identifier: @@ -119,7 +119,7 @@ def get_or_create_connector(remote_id): @app.task def load_more_data(connector_id, book_id): - """ background the work of getting all 10,000 editions of LoTR """ + """background the work of getting all 10,000 editions of LoTR""" connector_info = models.Connector.objects.get(id=connector_id) connector = load_connector(connector_info) book = models.Book.objects.select_subclasses().get(id=book_id) @@ -127,7 +127,7 @@ def load_more_data(connector_id, book_id): def load_connector(connector_info): - """ instantiate the connector class """ + """instantiate the connector class""" connector = importlib.import_module( "bookwyrm.connectors.%s" % connector_info.connector_file ) @@ -137,6 +137,6 @@ def load_connector(connector_info): @receiver(signals.post_save, sender="bookwyrm.FederatedServer") # pylint: disable=unused-argument def create_connector(sender, instance, created, *args, **kwargs): - """ create a connector to an external bookwyrm server """ + """create a connector to an external bookwyrm server""" if instance.application_type == "bookwyrm": get_or_create_connector("https://{:s}".format(instance.server_name)) diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py index 8ee738eb..a7c30b66 100644 --- a/bookwyrm/connectors/openlibrary.py +++ b/bookwyrm/connectors/openlibrary.py @@ -9,7 +9,7 @@ from .openlibrary_languages import languages class Connector(AbstractConnector): - """ instantiate a connector for OL """ + """instantiate a connector for OL""" def __init__(self, identifier): super().__init__(identifier) @@ -59,7 +59,7 @@ class Connector(AbstractConnector): ] def get_remote_id_from_data(self, data): - """ format a url from an openlibrary id field """ + """format a url from an openlibrary id field""" try: key = data["key"] except KeyError: @@ -87,7 +87,7 @@ class Connector(AbstractConnector): return get_data(url) def get_authors_from_data(self, data): - """ parse author json and load or create authors """ + """parse author json and load or create authors""" for author_blob in data.get("authors", []): author_blob = author_blob.get("author", author_blob) # this id is "/authors/OL1234567A" @@ -99,7 +99,7 @@ class Connector(AbstractConnector): yield author def get_cover_url(self, cover_blob, size="L"): - """ ask openlibrary for the cover """ + """ask openlibrary for the cover""" if not cover_blob: return None cover_id = cover_blob[0] @@ -141,7 +141,7 @@ class Connector(AbstractConnector): ) def load_edition_data(self, olkey): - """ query openlibrary for editions of a work """ + """query openlibrary for editions of a work""" url = "%s/works/%s/editions" % (self.books_url, olkey) return get_data(url) @@ -166,7 +166,7 @@ class Connector(AbstractConnector): def ignore_edition(edition_data): - """ don't load a million editions that have no metadata """ + """don't load a million editions that have no metadata""" # an isbn, we love to see it if edition_data.get("isbn_13") or edition_data.get("isbn_10"): return False @@ -185,19 +185,19 @@ def ignore_edition(edition_data): def get_description(description_blob): - """ descriptions can be a string or a dict """ + """descriptions can be a string or a dict""" if isinstance(description_blob, dict): return description_blob.get("value") return description_blob def get_openlibrary_key(key): - """ convert /books/OL27320736M into OL27320736M """ + """convert /books/OL27320736M into OL27320736M""" return key.split("/")[-1] def get_languages(language_blob): - """ /language/eng -> English """ + """/language/eng -> English""" langs = [] for lang in language_blob: langs.append(languages.get(lang.get("key", ""), None)) @@ -205,7 +205,7 @@ def get_languages(language_blob): def pick_default_edition(options): - """ favor physical copies with covers in english """ + """favor physical copies with covers in english""" if not options: return None if len(options) == 1: diff --git a/bookwyrm/connectors/self_connector.py b/bookwyrm/connectors/self_connector.py index 500ffd74..22835941 100644 --- a/bookwyrm/connectors/self_connector.py +++ b/bookwyrm/connectors/self_connector.py @@ -10,11 +10,11 @@ from .abstract_connector import AbstractConnector, SearchResult class Connector(AbstractConnector): - """ instantiate a connector """ + """instantiate a connector""" # pylint: disable=arguments-differ def search(self, query, min_confidence=0.1, raw=False): - """ search your local database """ + """search your local database""" if not query: return [] # first, try searching unqiue identifiers @@ -35,7 +35,7 @@ class Connector(AbstractConnector): return search_results def isbn_search(self, query, raw=False): - """ search your local database """ + """search your local database""" if not query: return [] @@ -87,11 +87,11 @@ class Connector(AbstractConnector): return None def parse_isbn_search_data(self, data): - """ it's already in the right format, don't even worry about it """ + """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 """ + """it's already in the right format, don't even worry about it""" return data def expand_book_data(self, book): @@ -99,7 +99,7 @@ class Connector(AbstractConnector): def search_identifiers(query): - """ tries remote_id, isbn; defined as dedupe fields on the model """ + """tries remote_id, isbn; defined as dedupe fields on the model""" filters = [ {f.name: query} for f in models.Edition._meta.get_fields() @@ -115,7 +115,7 @@ def search_identifiers(query): def search_title_author(query, min_confidence): - """ searches for title and author """ + """searches for title and author""" vector = ( SearchVector("title", weight="A") + SearchVector("subtitle", weight="B") diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py index 8f79a652..f5f25186 100644 --- a/bookwyrm/context_processors.py +++ b/bookwyrm/context_processors.py @@ -3,5 +3,5 @@ from bookwyrm import models def site_settings(request): # pylint: disable=unused-argument - """ include the custom info about the site """ + """include the custom info about the site""" return {"site": models.SiteSettings.objects.get()} diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py index 1804254b..657310b0 100644 --- a/bookwyrm/emailing.py +++ b/bookwyrm/emailing.py @@ -8,7 +8,7 @@ from bookwyrm.settings import DOMAIN def email_data(): - """ fields every email needs """ + """fields every email needs""" site = models.SiteSettings.objects.get() if site.logo_small: logo_path = "/images/{}".format(site.logo_small.url) @@ -24,14 +24,14 @@ def email_data(): def invite_email(invite_request): - """ send out an invite code """ + """send out an invite code""" data = email_data() data["invite_link"] = invite_request.invite.link send_email.delay(invite_request.email, *format_email("invite", data)) def password_reset_email(reset_code): - """ generate a password reset email """ + """generate a password reset email""" data = email_data() data["reset_link"] = reset_code.link data["user"] = reset_code.user.display_name @@ -39,7 +39,7 @@ def password_reset_email(reset_code): def format_email(email_name, data): - """ render the email templates """ + """render the email templates""" subject = ( get_template("email/{}/subject.html".format(email_name)).render(data).strip() ) @@ -58,7 +58,7 @@ def format_email(email_name, data): @app.task def send_email(recipient, subject, html_content, text_content): - """ use a task to send the email """ + """use a task to send the email""" email = EmailMultiAlternatives( subject, text_content, settings.DEFAULT_FROM_EMAIL, [recipient] ) diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py index 7c41323c..b6197f33 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -3,7 +3,7 @@ import datetime from collections import defaultdict from django import forms -from django.forms import ModelForm, PasswordInput, widgets +from django.forms import ModelForm, PasswordInput, widgets, ChoiceField from django.forms.widgets import Textarea from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -12,7 +12,7 @@ from bookwyrm import models class CustomForm(ModelForm): - """ add css classes to the forms """ + """add css classes to the forms""" def __init__(self, *args, **kwargs): css_classes = defaultdict(lambda: "") @@ -150,12 +150,10 @@ class LimitedEditUserForm(CustomForm): help_texts = {f: None for f in fields} -class TagForm(CustomForm): +class UserGroupForm(CustomForm): class Meta: - model = models.Tag - fields = ["name"] - help_texts = {f: None for f in fields} - labels = {"name": "Add a tag"} + model = models.User + fields = ["groups"] class CoverForm(CustomForm): @@ -200,7 +198,7 @@ class ImportForm(forms.Form): class ExpiryWidget(widgets.Select): def value_from_datadict(self, data, files, name): - """ human-readable exiration time buckets """ + """human-readable exiration time buckets""" selected_string = super().value_from_datadict(data, files, name) if selected_string == "day": @@ -219,7 +217,7 @@ class ExpiryWidget(widgets.Select): class InviteRequestForm(CustomForm): def clean(self): - """ make sure the email isn't in use by a registered user """ + """make sure the email isn't in use by a registered user""" cleaned_data = super().clean() email = cleaned_data.get("email") if email and models.User.objects.filter(email=email).exists(): @@ -287,3 +285,20 @@ class ServerForm(CustomForm): class Meta: model = models.FederatedServer exclude = ["remote_id"] + + +class SortListForm(forms.Form): + sort_by = ChoiceField( + choices=( + ("order", _("List Order")), + ("title", _("Book Title")), + ("rating", _("Rating")), + ), + label=_("Sort By"), + ) + direction = ChoiceField( + choices=( + ("ascending", _("Ascending")), + ("descending", _("Descending")), + ), + ) diff --git a/bookwyrm/importers/goodreads_import.py b/bookwyrm/importers/goodreads_import.py index 0b126c14..7b577ea8 100644 --- a/bookwyrm/importers/goodreads_import.py +++ b/bookwyrm/importers/goodreads_import.py @@ -9,7 +9,7 @@ class GoodreadsImporter(Importer): service = "GoodReads" def parse_fields(self, entry): - """ handle the specific fields in goodreads csvs """ + """handle the specific fields in goodreads csvs""" entry.update({"import_source": self.service}) # add missing 'Date Started' field entry.update({"Date Started": None}) diff --git a/bookwyrm/importers/importer.py b/bookwyrm/importers/importer.py index ddbfa304..c1e41897 100644 --- a/bookwyrm/importers/importer.py +++ b/bookwyrm/importers/importer.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) class Importer: - """ Generic class for csv data import from an outside service """ + """Generic class for csv data import from an outside service""" service = "Unknown" delimiter = "," @@ -18,7 +18,7 @@ class Importer: mandatory_fields = ["Title", "Author"] def create_job(self, user, csv_file, include_reviews, privacy): - """ check over a csv and creates a database entry for the job""" + """check over a csv and creates a database entry for the job""" job = ImportJob.objects.create( user=user, include_reviews=include_reviews, privacy=privacy ) @@ -32,16 +32,16 @@ class Importer: return job def save_item(self, job, index, data): # pylint: disable=no-self-use - """ creates and saves an import item """ + """creates and saves an import item""" ImportItem(job=job, index=index, data=data).save() def parse_fields(self, entry): - """ updates csv data with additional info """ + """updates csv data with additional info""" entry.update({"import_source": self.service}) return entry def create_retry_job(self, user, original_job, items): - """ retry items that didn't import """ + """retry items that didn't import""" job = ImportJob.objects.create( user=user, include_reviews=original_job.include_reviews, @@ -53,7 +53,7 @@ class Importer: return job def start_import(self, job): - """ initalizes a csv import job """ + """initalizes a csv import job""" result = import_data.delay(self.service, job.id) job.task_id = result.id job.save() @@ -61,7 +61,7 @@ class Importer: @app.task def import_data(source, job_id): - """ does the actual lookup work in a celery task """ + """does the actual lookup work in a celery task""" job = ImportJob.objects.get(id=job_id) try: for item in job.items.all(): @@ -89,7 +89,7 @@ def import_data(source, job_id): def handle_imported_book(source, user, item, include_reviews, privacy): - """ process a csv and then post about it """ + """process a csv and then post about it""" if isinstance(item.book, models.Work): item.book = item.book.default_edition if not item.book: diff --git a/bookwyrm/importers/librarything_import.py b/bookwyrm/importers/librarything_import.py index 3755cb1a..b3175a82 100644 --- a/bookwyrm/importers/librarything_import.py +++ b/bookwyrm/importers/librarything_import.py @@ -6,7 +6,7 @@ from . import Importer class LibrarythingImporter(Importer): - """ csv downloads from librarything """ + """csv downloads from librarything""" service = "LibraryThing" delimiter = "\t" @@ -15,7 +15,7 @@ class LibrarythingImporter(Importer): mandatory_fields = ["Title", "Primary Author"] def parse_fields(self, entry): - """ custom parsing for librarything """ + """custom parsing for librarything""" data = {} data["import_source"] = self.service data["Book Id"] = entry["Book Id"] diff --git a/bookwyrm/management/commands/deduplicate_book_data.py b/bookwyrm/management/commands/deduplicate_book_data.py index edd91a71..ed01a784 100644 --- a/bookwyrm/management/commands/deduplicate_book_data.py +++ b/bookwyrm/management/commands/deduplicate_book_data.py @@ -6,7 +6,7 @@ from bookwyrm import models def update_related(canonical, obj): - """ update all the models with fk to the object being removed """ + """update all the models with fk to the object being removed""" # move related models to canonical related_models = [ (r.remote_field.name, r.related_model) for r in canonical._meta.related_objects @@ -24,7 +24,7 @@ def update_related(canonical, obj): def copy_data(canonical, obj): - """ try to get the most data possible """ + """try to get the most data possible""" for data_field in obj._meta.get_fields(): if not hasattr(data_field, "activitypub_field"): continue @@ -38,7 +38,7 @@ def copy_data(canonical, obj): def dedupe_model(model): - """ combine duplicate editions and update related models """ + """combine duplicate editions and update related models""" fields = model._meta.get_fields() dedupe_fields = [ f for f in fields if hasattr(f, "deduplication_field") and f.deduplication_field @@ -68,12 +68,12 @@ def dedupe_model(model): class Command(BaseCommand): - """ dedplucate allllll the book data models """ + """dedplucate allllll the book data models""" help = "merges duplicate book data" # pylint: disable=no-self-use,unused-argument def handle(self, *args, **options): - """ run deudplications """ + """run deudplications""" dedupe_model(models.Edition) dedupe_model(models.Work) dedupe_model(models.Author) diff --git a/bookwyrm/management/commands/erase_streams.py b/bookwyrm/management/commands/erase_streams.py index 042e857f..1d34b1bb 100644 --- a/bookwyrm/management/commands/erase_streams.py +++ b/bookwyrm/management/commands/erase_streams.py @@ -10,15 +10,15 @@ r = redis.Redis( def erase_streams(): - """ throw the whole redis away """ + """throw the whole redis away""" r.flushall() class Command(BaseCommand): - """ delete activity streams for all users """ + """delete activity streams for all users""" help = "Delete all the user streams" # pylint: disable=no-self-use,unused-argument def handle(self, *args, **options): - """ flush all, baby """ + """flush all, baby""" erase_streams() diff --git a/bookwyrm/management/commands/initdb.py b/bookwyrm/management/commands/initdb.py index a86a1652..0c0cc61f 100644 --- a/bookwyrm/management/commands/initdb.py +++ b/bookwyrm/management/commands/initdb.py @@ -108,7 +108,7 @@ def init_connectors(): def init_federated_servers(): - """ big no to nazis """ + """big no to nazis""" built_in_blocks = ["gab.ai", "gab.com"] for server in built_in_blocks: FederatedServer.objects.create( diff --git a/bookwyrm/management/commands/populate_streams.py b/bookwyrm/management/commands/populate_streams.py index 4cd2036a..04f6bf6e 100644 --- a/bookwyrm/management/commands/populate_streams.py +++ b/bookwyrm/management/commands/populate_streams.py @@ -10,7 +10,7 @@ r = redis.Redis( def populate_streams(): - """ build all the streams for all the users """ + """build all the streams for all the users""" users = models.User.objects.filter( local=True, is_active=True, @@ -21,10 +21,10 @@ def populate_streams(): class Command(BaseCommand): - """ start all over with user streams """ + """start all over with user streams""" help = "Populate streams for all users" # pylint: disable=no-self-use,unused-argument def handle(self, *args, **options): - """ run feed builder """ + """run feed builder""" populate_streams() diff --git a/bookwyrm/management/commands/remove_editions.py b/bookwyrm/management/commands/remove_editions.py index 6829c6d1..9eb9b7da 100644 --- a/bookwyrm/management/commands/remove_editions.py +++ b/bookwyrm/management/commands/remove_editions.py @@ -5,7 +5,7 @@ from bookwyrm import models def remove_editions(): - """ combine duplicate editions and update related models """ + """combine duplicate editions and update related models""" # not in use filters = { "%s__isnull" % r.name: True for r in models.Edition._meta.related_objects @@ -33,10 +33,10 @@ def remove_editions(): class Command(BaseCommand): - """ dedplucate allllll the book data models """ + """dedplucate allllll the book data models""" help = "merges duplicate book data" # pylint: disable=no-self-use,unused-argument def handle(self, *args, **options): - """ run deudplications """ + """run deudplications""" remove_editions() diff --git a/bookwyrm/migrations/0046_reviewrating.py b/bookwyrm/migrations/0046_reviewrating.py index 8d149004..26f6f36a 100644 --- a/bookwyrm/migrations/0046_reviewrating.py +++ b/bookwyrm/migrations/0046_reviewrating.py @@ -8,7 +8,7 @@ from psycopg2.extras import execute_values def convert_review_rating(app_registry, schema_editor): - """ take rating type Reviews and convert them to ReviewRatings """ + """take rating type Reviews and convert them to ReviewRatings""" db_alias = schema_editor.connection.alias reviews = ( @@ -29,7 +29,7 @@ VALUES %s""", def unconvert_review_rating(app_registry, schema_editor): - """ undo the conversion from ratings back to reviews""" + """undo the conversion from ratings back to reviews""" # All we need to do to revert this is drop the table, which Django will do # on its own, as long as we have a valid reverse function. So, this is a # no-op function so Django will do its thing diff --git a/bookwyrm/migrations/0067_denullify_list_item_order.py b/bookwyrm/migrations/0067_denullify_list_item_order.py new file mode 100644 index 00000000..51e28371 --- /dev/null +++ b/bookwyrm/migrations/0067_denullify_list_item_order.py @@ -0,0 +1,30 @@ +from django.db import migrations + + +def forwards_func(apps, schema_editor): + # Set all values for ListItem.order + BookList = apps.get_model("bookwyrm", "List") + db_alias = schema_editor.connection.alias + for book_list in BookList.objects.using(db_alias).all(): + for i, item in enumerate(book_list.listitem_set.order_by("id"), 1): + item.order = i + item.save() + + +def reverse_func(apps, schema_editor): + # null all values for ListItem.order + BookList = apps.get_model("bookwyrm", "List") + db_alias = schema_editor.connection.alias + for book_list in BookList.objects.using(db_alias).all(): + for item in book_list.listitem_set.order_by("id"): + item.order = None + item.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0066_user_deactivation_reason"), + ] + + operations = [migrations.RunPython(forwards_func, reverse_func)] diff --git a/bookwyrm/migrations/0068_ordering_for_list_items.py b/bookwyrm/migrations/0068_ordering_for_list_items.py new file mode 100644 index 00000000..fa64f13c --- /dev/null +++ b/bookwyrm/migrations/0068_ordering_for_list_items.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.6 on 2021-04-08 16:15 + +import bookwyrm.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0067_denullify_list_item_order"), + ] + + operations = [ + migrations.AlterField( + model_name="listitem", + name="order", + field=bookwyrm.models.fields.IntegerField(), + ), + migrations.AlterUniqueTogether( + name="listitem", + unique_together={("order", "book_list"), ("book", "book_list")}, + ), + ] diff --git a/bookwyrm/migrations/0069_auto_20210422_1604.py b/bookwyrm/migrations/0069_auto_20210422_1604.py new file mode 100644 index 00000000..6591e7b9 --- /dev/null +++ b/bookwyrm/migrations/0069_auto_20210422_1604.py @@ -0,0 +1,34 @@ +# Generated by Django 3.1.8 on 2021-04-22 16:04 + +import bookwyrm.models.fields +from django.conf import settings +from django.db import migrations +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0068_ordering_for_list_items"), + ] + + operations = [ + migrations.AlterField( + model_name="author", + name="last_edited_by", + field=bookwyrm.models.fields.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="book", + name="last_edited_by", + field=bookwyrm.models.fields.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/bookwyrm/migrations/0070_auto_20210423_0121.py b/bookwyrm/migrations/0070_auto_20210423_0121.py new file mode 100644 index 00000000..0b04c3ca --- /dev/null +++ b/bookwyrm/migrations/0070_auto_20210423_0121.py @@ -0,0 +1,35 @@ +# Generated by Django 3.1.8 on 2021-04-23 01:21 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0069_auto_20210422_1604"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="usertag", + unique_together=None, + ), + migrations.RemoveField( + model_name="usertag", + name="book", + ), + migrations.RemoveField( + model_name="usertag", + name="tag", + ), + migrations.RemoveField( + model_name="usertag", + name="user", + ), + migrations.DeleteModel( + name="Tag", + ), + migrations.DeleteModel( + name="UserTag", + ), + ] diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py index 35e32c2c..2a25a525 100644 --- a/bookwyrm/models/__init__.py +++ b/bookwyrm/models/__init__.py @@ -17,8 +17,6 @@ from .favorite import Favorite from .notification import Notification from .readthrough import ReadThrough, ProgressUpdate, ProgressMode -from .tag import Tag, UserTag - from .user import User, KeyPair, AnnualGoal from .relationship import UserFollows, UserFollowRequest, UserBlocks from .report import Report, ReportComment diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py index f687e96c..83b4c0ab 100644 --- a/bookwyrm/models/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin.py @@ -31,18 +31,18 @@ PropertyField = namedtuple("PropertyField", ("set_activity_from_field")) def set_activity_from_property_field(activity, obj, field): - """ assign a model property value to the activity json """ + """assign a model property value to the activity json""" activity[field[1]] = getattr(obj, field[0]) class ActivitypubMixin: - """ add this mixin for models that are AP serializable """ + """add this mixin for models that are AP serializable""" activity_serializer = lambda: {} reverse_unfurl = False def __init__(self, *args, **kwargs): - """ collect some info on model fields """ + """collect some info on model fields""" self.image_fields = [] self.many_to_many_fields = [] self.simple_fields = [] # "simple" @@ -85,7 +85,7 @@ class ActivitypubMixin: @classmethod def find_existing_by_remote_id(cls, remote_id): - """ look up a remote id in the db """ + """look up a remote id in the db""" return cls.find_existing({"id": remote_id}) @classmethod @@ -126,7 +126,7 @@ class ActivitypubMixin: return match.first() def broadcast(self, activity, sender, software=None): - """ send out an activity """ + """send out an activity""" broadcast_task.delay( sender.id, json.dumps(activity, cls=activitypub.ActivityEncoder), @@ -134,7 +134,7 @@ class ActivitypubMixin: ) def get_recipients(self, software=None): - """ figure out which inbox urls to post to """ + """figure out which inbox urls to post to""" # first we have to figure out who should receive this activity privacy = self.privacy if hasattr(self, "privacy") else "public" # is this activity owned by a user (statuses, lists, shelves), or is it @@ -148,13 +148,17 @@ class ActivitypubMixin: mentions = self.recipients if hasattr(self, "recipients") else [] # we always send activities to explicitly mentioned users' inboxes - recipients = [u.inbox for u in mentions or []] + recipients = [u.inbox for u in mentions or [] if not u.local] # unless it's a dm, all the followers should receive the activity if privacy != "direct": # we will send this out to a subset of all remote users - queryset = user_model.viewer_aware_objects(user).filter( - local=False, + queryset = ( + user_model.viewer_aware_objects(user) + .filter( + local=False, + ) + .distinct() ) # filter users first by whether they're using the desired software # this lets us send book updates only to other bw servers @@ -175,23 +179,23 @@ class ActivitypubMixin: "inbox", flat=True ) recipients += list(shared_inboxes) + list(inboxes) - return recipients + return list(set(recipients)) def to_activity_dataclass(self): - """ convert from a model to an activity """ + """convert from a model to an activity""" activity = generate_activity(self) return self.activity_serializer(**activity) def to_activity(self, **kwargs): # pylint: disable=unused-argument - """ convert from a model to a json activity """ + """convert from a model to a json activity""" return self.to_activity_dataclass().serialize() class ObjectMixin(ActivitypubMixin): - """ add this mixin for object models that are AP serializable """ + """add this mixin for object models that are AP serializable""" def save(self, *args, created=None, **kwargs): - """ broadcast created/updated/deleted objects as appropriate """ + """broadcast created/updated/deleted objects as appropriate""" broadcast = kwargs.get("broadcast", True) # this bonus kwarg would cause an error in the base save method if "broadcast" in kwargs: @@ -200,7 +204,9 @@ class ObjectMixin(ActivitypubMixin): created = created or not bool(self.id) # first off, we want to save normally no matter what super().save(*args, **kwargs) - if not broadcast: + if not broadcast or ( + hasattr(self, "status_type") and self.status_type == "Announce" + ): return # this will work for objects owned by a user (lists, shelves) @@ -248,7 +254,7 @@ class ObjectMixin(ActivitypubMixin): self.broadcast(activity, user) def to_create_activity(self, user, **kwargs): - """ returns the object wrapped in a Create activity """ + """returns the object wrapped in a Create activity""" activity_object = self.to_activity_dataclass(**kwargs) signature = None @@ -274,7 +280,7 @@ class ObjectMixin(ActivitypubMixin): ).serialize() def to_delete_activity(self, user): - """ notice of deletion """ + """notice of deletion""" return activitypub.Delete( id=self.remote_id + "/activity", actor=user.remote_id, @@ -284,7 +290,7 @@ class ObjectMixin(ActivitypubMixin): ).serialize() def to_update_activity(self, user): - """ wrapper for Updates to an activity """ + """wrapper for Updates to an activity""" activity_id = "%s#update/%s" % (self.remote_id, uuid4()) return activitypub.Update( id=activity_id, @@ -300,13 +306,13 @@ class OrderedCollectionPageMixin(ObjectMixin): @property def collection_remote_id(self): - """ this can be overriden if there's a special remote id, ie outbox """ + """this can be overriden if there's a special remote id, ie outbox""" return self.remote_id def to_ordered_collection( self, queryset, remote_id=None, page=False, collection_only=False, **kwargs ): - """ an ordered collection of whatevers """ + """an ordered collection of whatevers""" if not queryset.ordered: raise RuntimeError("queryset must be ordered") @@ -335,11 +341,11 @@ class OrderedCollectionPageMixin(ObjectMixin): class OrderedCollectionMixin(OrderedCollectionPageMixin): - """ extends activitypub models to work as ordered collections """ + """extends activitypub models to work as ordered collections""" @property def collection_queryset(self): - """ usually an ordered collection model aggregates a different model """ + """usually an ordered collection model aggregates a different model""" raise NotImplementedError("Model must define collection_queryset") activity_serializer = activitypub.OrderedCollection @@ -348,24 +354,24 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin): return self.to_ordered_collection(self.collection_queryset, **kwargs) def to_activity(self, **kwargs): - """ an ordered collection of the specified model queryset """ + """an ordered collection of the specified model queryset""" return self.to_ordered_collection( self.collection_queryset, **kwargs ).serialize() class CollectionItemMixin(ActivitypubMixin): - """ for items that are part of an (Ordered)Collection """ + """for items that are part of an (Ordered)Collection""" activity_serializer = activitypub.CollectionItem def broadcast(self, activity, sender, software="bookwyrm"): - """ only send book collection updates to other bookwyrm instances """ + """only send book collection updates to other bookwyrm instances""" super().broadcast(activity, sender, software=software) @property def privacy(self): - """ inherit the privacy of the list, or direct if pending """ + """inherit the privacy of the list, or direct if pending""" collection_field = getattr(self, self.collection_field) if self.approved: return collection_field.privacy @@ -373,7 +379,7 @@ class CollectionItemMixin(ActivitypubMixin): @property def recipients(self): - """ the owner of the list is a direct recipient """ + """the owner of the list is a direct recipient""" collection_field = getattr(self, self.collection_field) if collection_field.user.local: # don't broadcast to yourself @@ -381,7 +387,7 @@ class CollectionItemMixin(ActivitypubMixin): return [collection_field.user] def save(self, *args, broadcast=True, **kwargs): - """ broadcast updated """ + """broadcast updated""" # first off, we want to save normally no matter what super().save(*args, **kwargs) @@ -394,14 +400,14 @@ class CollectionItemMixin(ActivitypubMixin): self.broadcast(activity, self.user) def delete(self, *args, broadcast=True, **kwargs): - """ broadcast a remove activity """ + """broadcast a remove activity""" activity = self.to_remove_activity(self.user) super().delete(*args, **kwargs) if self.user.local and broadcast: self.broadcast(activity, self.user) def to_add_activity(self, user): - """ AP for shelving a book""" + """AP for shelving a book""" collection_field = getattr(self, self.collection_field) return activitypub.Add( id="{:s}#add".format(collection_field.remote_id), @@ -411,7 +417,7 @@ class CollectionItemMixin(ActivitypubMixin): ).serialize() def to_remove_activity(self, user): - """ AP for un-shelving a book""" + """AP for un-shelving a book""" collection_field = getattr(self, self.collection_field) return activitypub.Remove( id="{:s}#remove".format(collection_field.remote_id), @@ -422,24 +428,24 @@ class CollectionItemMixin(ActivitypubMixin): class ActivityMixin(ActivitypubMixin): - """ add this mixin for models that are AP serializable """ + """add this mixin for models that are AP serializable""" def save(self, *args, broadcast=True, **kwargs): - """ broadcast activity """ + """broadcast activity""" super().save(*args, **kwargs) user = self.user if hasattr(self, "user") else self.user_subject if broadcast and user.local: self.broadcast(self.to_activity(), user) def delete(self, *args, broadcast=True, **kwargs): - """ nevermind, undo that activity """ + """nevermind, undo that activity""" user = self.user if hasattr(self, "user") else self.user_subject if broadcast and user.local: self.broadcast(self.to_undo_activity(), user) super().delete(*args, **kwargs) def to_undo_activity(self): - """ undo an action """ + """undo an action""" user = self.user if hasattr(self, "user") else self.user_subject return activitypub.Undo( id="%s#undo" % self.remote_id, @@ -449,7 +455,7 @@ class ActivityMixin(ActivitypubMixin): def generate_activity(obj): - """ go through the fields on an object """ + """go through the fields on an object""" activity = {} for field in obj.activity_fields: field.set_activity_from_field(activity, obj) @@ -472,7 +478,7 @@ def generate_activity(obj): def unfurl_related_field(related_field, sort_field=None): - """ load reverse lookups (like public key owner or Status attachment """ + """load reverse lookups (like public key owner or Status attachment""" if sort_field and hasattr(related_field, "all"): return [ unfurl_related_field(i) for i in related_field.order_by(sort_field).all() @@ -488,7 +494,7 @@ def unfurl_related_field(related_field, sort_field=None): @app.task def broadcast_task(sender_id, activity, recipients): - """ the celery task for broadcast """ + """the celery task for broadcast""" user_model = apps.get_model("bookwyrm.User", require_ready=True) sender = user_model.objects.get(id=sender_id) for recipient in recipients: @@ -499,7 +505,7 @@ def broadcast_task(sender_id, activity, recipients): def sign_and_send(sender, data, destination): - """ crpyto whatever and http junk """ + """crpyto whatever and http junk""" now = http_date() if not sender.key_pair.private_key: @@ -528,7 +534,7 @@ def sign_and_send(sender, data, destination): def to_ordered_collection_page( queryset, remote_id, id_only=False, page=1, pure=False, **kwargs ): - """ serialize and pagiante a queryset """ + """serialize and pagiante a queryset""" paginated = Paginator(queryset, PAGE_LENGTH) activity_page = paginated.get_page(page) diff --git a/bookwyrm/models/attachment.py b/bookwyrm/models/attachment.py index eaeca11e..c8b2e51c 100644 --- a/bookwyrm/models/attachment.py +++ b/bookwyrm/models/attachment.py @@ -8,7 +8,7 @@ from . import fields class Attachment(ActivitypubMixin, BookWyrmModel): - """ an image (or, in the future, video etc) associated with a status """ + """an image (or, in the future, video etc) associated with a status""" status = models.ForeignKey( "Status", on_delete=models.CASCADE, related_name="attachments", null=True @@ -16,13 +16,13 @@ class Attachment(ActivitypubMixin, BookWyrmModel): reverse_unfurl = True class Meta: - """ one day we'll have other types of attachments besides images """ + """one day we'll have other types of attachments besides images""" abstract = True class Image(Attachment): - """ an image attachment """ + """an image attachment""" image = fields.ImageField( upload_to="status/", diff --git a/bookwyrm/models/author.py b/bookwyrm/models/author.py index 4c5fe6c8..b9a4b146 100644 --- a/bookwyrm/models/author.py +++ b/bookwyrm/models/author.py @@ -9,7 +9,7 @@ from . import fields class Author(BookDataModel): - """ basic biographic info """ + """basic biographic info""" wikipedia_link = fields.CharField( max_length=255, blank=True, null=True, deduplication_field=True @@ -24,7 +24,7 @@ class Author(BookDataModel): bio = fields.HtmlField(null=True, blank=True) def get_remote_id(self): - """ editions and works both use "book" instead of model_name """ + """editions and works both use "book" instead of model_name""" return "https://%s/author/%s" % (DOMAIN, self.id) activity_serializer = activitypub.Author diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index 261c9686..e85ff733 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -7,14 +7,14 @@ from .fields import RemoteIdField class BookWyrmModel(models.Model): - """ shared fields """ + """shared fields""" created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) remote_id = RemoteIdField(null=True, activitypub_field="id") def get_remote_id(self): - """ generate a url that resolves to the local object """ + """generate a url that resolves to the local object""" base_path = "https://%s" % DOMAIN if hasattr(self, "user"): base_path = "%s%s" % (base_path, self.user.local_path) @@ -22,17 +22,17 @@ class BookWyrmModel(models.Model): return "%s/%s/%d" % (base_path, model_name, self.id) class Meta: - """ this is just here to provide default fields for other models """ + """this is just here to provide default fields for other models""" abstract = True @property def local_path(self): - """ how to link to this object in the local app """ + """how to link to this object in the local app""" return self.get_remote_id().replace("https://%s" % DOMAIN, "") def visible_to_user(self, viewer): - """ is a user authorized to view an object? """ + """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 @@ -65,7 +65,7 @@ class BookWyrmModel(models.Model): @receiver(models.signals.post_save) # pylint: disable=unused-argument def set_remote_id(sender, instance, created, *args, **kwargs): - """ set the remote_id after save (when the id is available) """ + """set the remote_id after save (when the id is available)""" if not created or not hasattr(instance, "get_remote_id"): return if not instance.remote_id: diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index a6824c0a..dd098e56 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -13,7 +13,7 @@ from . import fields class BookDataModel(ObjectMixin, BookWyrmModel): - """ fields shared between editable book data (books, works, authors) """ + """fields shared between editable book data (books, works, authors)""" origin_id = models.CharField(max_length=255, null=True, blank=True) openlibrary_key = fields.CharField( @@ -26,15 +26,19 @@ class BookDataModel(ObjectMixin, BookWyrmModel): max_length=255, blank=True, null=True, deduplication_field=True ) - last_edited_by = models.ForeignKey("User", on_delete=models.PROTECT, null=True) + last_edited_by = fields.ForeignKey( + "User", + on_delete=models.PROTECT, + null=True, + ) class Meta: - """ can't initialize this model, that wouldn't make sense """ + """can't initialize this model, that wouldn't make sense""" abstract = True def save(self, *args, **kwargs): - """ ensure that the remote_id is within this instance """ + """ensure that the remote_id is within this instance""" if self.id: self.remote_id = self.get_remote_id() else: @@ -43,12 +47,12 @@ class BookDataModel(ObjectMixin, BookWyrmModel): return super().save(*args, **kwargs) def broadcast(self, activity, sender, software="bookwyrm"): - """ only send book data updates to other bookwyrm instances """ + """only send book data updates to other bookwyrm instances""" super().broadcast(activity, sender, software=software) class Book(BookDataModel): - """ a generic book, which can mean either an edition or a work """ + """a generic book, which can mean either an edition or a work""" connector = models.ForeignKey("Connector", on_delete=models.PROTECT, null=True) @@ -79,17 +83,17 @@ class Book(BookDataModel): @property def author_text(self): - """ format a list of authors """ + """format a list of authors""" return ", ".join(a.name for a in self.authors.all()) @property def latest_readthrough(self): - """ most recent readthrough activity """ + """most recent readthrough activity""" return self.readthrough_set.order_by("-updated_date").first() @property def edition_info(self): - """ properties of this edition, as a string """ + """properties of this edition, as a string""" items = [ self.physical_format if hasattr(self, "physical_format") else None, self.languages[0] + " language" @@ -102,20 +106,20 @@ class Book(BookDataModel): @property def alt_text(self): - """ image alt test """ + """image alt test""" text = "%s" % self.title if self.edition_info: text += " (%s)" % self.edition_info return text def save(self, *args, **kwargs): - """ can't be abstract for query reasons, but you shouldn't USE it """ + """can't be abstract for query reasons, but you shouldn't USE it""" if not isinstance(self, Edition) and not isinstance(self, Work): raise ValueError("Books should be added as Editions or Works") return super().save(*args, **kwargs) def get_remote_id(self): - """ editions and works both use "book" instead of model_name """ + """editions and works both use "book" instead of model_name""" return "https://%s/book/%d" % (DOMAIN, self.id) def __repr__(self): @@ -127,7 +131,7 @@ class Book(BookDataModel): class Work(OrderedCollectionPageMixin, Book): - """ a work (an abstract concept of a book that manifests in an edition) """ + """a work (an abstract concept of a book that manifests in an edition)""" # library of congress catalog control number lccn = fields.CharField( @@ -139,19 +143,19 @@ class Work(OrderedCollectionPageMixin, Book): ) def save(self, *args, **kwargs): - """ set some fields on the edition object """ + """set some fields on the edition object""" # set rank for edition in self.editions.all(): edition.save() return super().save(*args, **kwargs) def get_default_edition(self): - """ in case the default edition is not set """ + """in case the default edition is not set""" return self.default_edition or self.editions.order_by("-edition_rank").first() @transaction.atomic() def reset_default_edition(self): - """ sets a new default edition based on computed rank """ + """sets a new default edition based on computed rank""" self.default_edition = None # editions are re-ranked implicitly self.save() @@ -159,11 +163,11 @@ class Work(OrderedCollectionPageMixin, Book): self.save() def to_edition_list(self, **kwargs): - """ an ordered collection of editions """ + """an ordered collection of editions""" return self.to_ordered_collection( self.editions.order_by("-edition_rank").all(), remote_id="%s/editions" % self.remote_id, - **kwargs + **kwargs, ) activity_serializer = activitypub.Work @@ -172,7 +176,7 @@ class Work(OrderedCollectionPageMixin, Book): class Edition(Book): - """ an edition of a book """ + """an edition of a book""" # these identifiers only apply to editions, not works isbn_10 = fields.CharField( @@ -211,7 +215,7 @@ class Edition(Book): name_field = "title" def get_rank(self, ignore_default=False): - """ calculate how complete the data is on this edition """ + """calculate how complete the data is on this edition""" if ( not ignore_default and self.parent_work @@ -231,7 +235,7 @@ class Edition(Book): return rank def save(self, *args, **kwargs): - """ set some fields on the edition object """ + """set some fields on the edition object""" # calculate isbn 10/13 if self.isbn_13 and self.isbn_13[:3] == "978" and not self.isbn_10: self.isbn_10 = isbn_13_to_10(self.isbn_13) @@ -245,7 +249,7 @@ class Edition(Book): def isbn_10_to_13(isbn_10): - """ convert an isbn 10 into an isbn 13 """ + """convert an isbn 10 into an isbn 13""" isbn_10 = re.sub(r"[^0-9X]", "", isbn_10) # drop the last character of the isbn 10 number (the original checkdigit) converted = isbn_10[:9] @@ -267,7 +271,7 @@ def isbn_10_to_13(isbn_10): def isbn_13_to_10(isbn_13): - """ convert isbn 13 to 10, if possible """ + """convert isbn 13 to 10, if possible""" if isbn_13[:3] != "978": return None diff --git a/bookwyrm/models/connector.py b/bookwyrm/models/connector.py index 11bdbee2..6043fc02 100644 --- a/bookwyrm/models/connector.py +++ b/bookwyrm/models/connector.py @@ -9,7 +9,7 @@ ConnectorFiles = models.TextChoices("ConnectorFiles", CONNECTORS) class Connector(BookWyrmModel): - """ book data source connectors """ + """book data source connectors""" identifier = models.CharField(max_length=255, unique=True) priority = models.IntegerField(default=2) @@ -32,7 +32,7 @@ class Connector(BookWyrmModel): query_count_expiry = models.DateTimeField(auto_now_add=True, blank=True) class Meta: - """ check that there's code to actually use this connector """ + """check that there's code to actually use this connector""" constraints = [ models.CheckConstraint( diff --git a/bookwyrm/models/favorite.py b/bookwyrm/models/favorite.py index 7b72d175..c4518119 100644 --- a/bookwyrm/models/favorite.py +++ b/bookwyrm/models/favorite.py @@ -11,7 +11,7 @@ from .status import Status class Favorite(ActivityMixin, BookWyrmModel): - """ fav'ing a post """ + """fav'ing a post""" user = fields.ForeignKey( "User", on_delete=models.PROTECT, activitypub_field="actor" @@ -24,11 +24,11 @@ class Favorite(ActivityMixin, BookWyrmModel): @classmethod def ignore_activity(cls, activity): - """ don't bother with incoming favs of unknown statuses """ + """don't bother with incoming favs of unknown statuses""" return not Status.objects.filter(remote_id=activity.object).exists() def save(self, *args, **kwargs): - """ update user active time """ + """update user active time""" self.user.last_active_date = timezone.now() self.user.save(broadcast=False) super().save(*args, **kwargs) @@ -45,7 +45,7 @@ class Favorite(ActivityMixin, BookWyrmModel): ) def delete(self, *args, **kwargs): - """ delete and delete notifications """ + """delete and delete notifications""" # check for notification if self.status.user.local: notification_model = apps.get_model( @@ -62,6 +62,6 @@ class Favorite(ActivityMixin, BookWyrmModel): super().delete(*args, **kwargs) class Meta: - """ can't fav things twice """ + """can't fav things twice""" unique_together = ("user", "status") diff --git a/bookwyrm/models/federated_server.py b/bookwyrm/models/federated_server.py index aa2b2f6a..7d446ca0 100644 --- a/bookwyrm/models/federated_server.py +++ b/bookwyrm/models/federated_server.py @@ -13,7 +13,7 @@ FederationStatus = models.TextChoices( class FederatedServer(BookWyrmModel): - """ store which servers we federate with """ + """store which servers we federate with""" server_name = models.CharField(max_length=255, unique=True) status = models.CharField( @@ -25,7 +25,7 @@ class FederatedServer(BookWyrmModel): notes = models.TextField(null=True, blank=True) def block(self): - """ block a server """ + """block a server""" self.status = "blocked" self.save() @@ -35,7 +35,7 @@ class FederatedServer(BookWyrmModel): ) def unblock(self): - """ unblock a server """ + """unblock a server""" self.status = "federated" self.save() @@ -45,7 +45,7 @@ class FederatedServer(BookWyrmModel): @classmethod def is_blocked(cls, url): - """ look up if a domain is blocked """ + """look up if a domain is blocked""" url = urlparse(url) domain = url.netloc return cls.objects.filter(server_name=domain, status="blocked").exists() diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index 2aefae51..123b3efa 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -18,7 +18,7 @@ from bookwyrm.settings import DOMAIN def validate_remote_id(value): - """ make sure the remote_id looks like a url """ + """make sure the remote_id looks like a url""" if not value or not re.match(r"^http.?:\/\/[^\s]+$", value): raise ValidationError( _("%(value)s is not a valid remote_id"), @@ -27,7 +27,7 @@ def validate_remote_id(value): def validate_localname(value): - """ make sure localnames look okay """ + """make sure localnames look okay""" if not re.match(r"^[A-Za-z\-_\.0-9]+$", value): raise ValidationError( _("%(value)s is not a valid username"), @@ -36,7 +36,7 @@ def validate_localname(value): def validate_username(value): - """ make sure usernames look okay """ + """make sure usernames look okay""" if not re.match(r"^[A-Za-z\-_\.0-9]+@[A-Za-z\-_\.0-9]+\.[a-z]{2,}$", value): raise ValidationError( _("%(value)s is not a valid username"), @@ -45,7 +45,7 @@ def validate_username(value): class ActivitypubFieldMixin: - """ make a database field serializable """ + """make a database field serializable""" def __init__( self, @@ -64,7 +64,7 @@ class ActivitypubFieldMixin: super().__init__(*args, **kwargs) def set_field_from_activity(self, instance, data): - """ helper function for assinging a value to the field """ + """helper function for assinging a value to the field""" try: value = getattr(data, self.get_activitypub_field()) except AttributeError: @@ -78,7 +78,7 @@ class ActivitypubFieldMixin: setattr(instance, self.name, formatted) def set_activity_from_field(self, activity, instance): - """ update the json object """ + """update the json object""" value = getattr(instance, self.name) formatted = self.field_to_activity(value) if formatted is None: @@ -94,19 +94,19 @@ class ActivitypubFieldMixin: activity[key] = formatted def field_to_activity(self, value): - """ formatter to convert a model value into activitypub """ + """formatter to convert a model value into activitypub""" if hasattr(self, "activitypub_wrapper"): return {self.activitypub_wrapper: value} return value def field_from_activity(self, value): - """ formatter to convert activitypub into a model value """ + """formatter to convert activitypub into a model value""" if value and hasattr(self, "activitypub_wrapper"): value = value.get(self.activitypub_wrapper) return value def get_activitypub_field(self): - """ model_field_name to activitypubFieldName """ + """model_field_name to activitypubFieldName""" if self.activitypub_field: return self.activitypub_field name = self.name.split(".")[-1] @@ -115,7 +115,7 @@ class ActivitypubFieldMixin: class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin): - """ default (de)serialization for foreign key and one to one """ + """default (de)serialization for foreign key and one to one""" def __init__(self, *args, load_remote=True, **kwargs): self.load_remote = load_remote @@ -146,7 +146,7 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin): class RemoteIdField(ActivitypubFieldMixin, models.CharField): - """ a url that serves as a unique identifier """ + """a url that serves as a unique identifier""" def __init__(self, *args, max_length=255, validators=None, **kwargs): validators = validators or [validate_remote_id] @@ -156,7 +156,7 @@ class RemoteIdField(ActivitypubFieldMixin, models.CharField): class UsernameField(ActivitypubFieldMixin, models.CharField): - """ activitypub-aware username field """ + """activitypub-aware username field""" def __init__(self, activitypub_field="preferredUsername", **kwargs): self.activitypub_field = activitypub_field @@ -172,7 +172,7 @@ class UsernameField(ActivitypubFieldMixin, models.CharField): ) def deconstruct(self): - """ implementation of models.Field deconstruct """ + """implementation of models.Field deconstruct""" name, path, args, kwargs = super().deconstruct() del kwargs["verbose_name"] del kwargs["max_length"] @@ -191,7 +191,7 @@ PrivacyLevels = models.TextChoices( class PrivacyField(ActivitypubFieldMixin, models.CharField): - """ this maps to two differente activitypub fields """ + """this maps to two differente activitypub fields""" public = "https://www.w3.org/ns/activitystreams#Public" @@ -236,7 +236,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField): class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey): - """ activitypub-aware foreign key field """ + """activitypub-aware foreign key field""" def field_to_activity(self, value): if not value: @@ -245,7 +245,7 @@ class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey): class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField): - """ activitypub-aware foreign key field """ + """activitypub-aware foreign key field""" def field_to_activity(self, value): if not value: @@ -254,14 +254,14 @@ class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField): class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField): - """ activitypub-aware many to many field """ + """activitypub-aware many to many field""" def __init__(self, *args, link_only=False, **kwargs): self.link_only = link_only super().__init__(*args, **kwargs) def set_field_from_activity(self, instance, data): - """ helper function for assinging a value to the field """ + """helper function for assinging a value to the field""" value = getattr(data, self.get_activitypub_field()) formatted = self.field_from_activity(value) if formatted is None or formatted is MISSING: @@ -275,9 +275,12 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField): return [i.remote_id for i in value.all()] def field_from_activity(self, value): - items = [] if value is None or value is MISSING: - return [] + return None + if not isinstance(value, list): + # If this is a link, we currently aren't doing anything with it + return None + items = [] for remote_id in value: try: validate_remote_id(remote_id) @@ -290,7 +293,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField): class TagField(ManyToManyField): - """ special case of many to many that uses Tags """ + """special case of many to many that uses Tags""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -330,7 +333,7 @@ class TagField(ManyToManyField): def image_serializer(value, alt): - """ helper for serializing images """ + """helper for serializing images""" if value and hasattr(value, "url"): url = value.url else: @@ -340,7 +343,7 @@ def image_serializer(value, alt): class ImageField(ActivitypubFieldMixin, models.ImageField): - """ activitypub-aware image field """ + """activitypub-aware image field""" def __init__(self, *args, alt_field=None, **kwargs): self.alt_field = alt_field @@ -348,7 +351,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField): # pylint: disable=arguments-differ def set_field_from_activity(self, instance, data, save=True): - """ helper function for assinging a value to the field """ + """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: @@ -394,7 +397,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField): class DateTimeField(ActivitypubFieldMixin, models.DateTimeField): - """ activitypub-aware datetime field """ + """activitypub-aware datetime field""" def field_to_activity(self, value): if not value: @@ -413,7 +416,7 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField): class HtmlField(ActivitypubFieldMixin, models.TextField): - """ a text field for storing html """ + """a text field for storing html""" def field_from_activity(self, value): if not value or value == MISSING: @@ -424,30 +427,30 @@ class HtmlField(ActivitypubFieldMixin, models.TextField): class ArrayField(ActivitypubFieldMixin, DjangoArrayField): - """ activitypub-aware array field """ + """activitypub-aware array field""" def field_to_activity(self, value): return [str(i) for i in value] class CharField(ActivitypubFieldMixin, models.CharField): - """ activitypub-aware char field """ + """activitypub-aware char field""" class TextField(ActivitypubFieldMixin, models.TextField): - """ activitypub-aware text field """ + """activitypub-aware text field""" class BooleanField(ActivitypubFieldMixin, models.BooleanField): - """ activitypub-aware boolean field """ + """activitypub-aware boolean field""" class IntegerField(ActivitypubFieldMixin, models.IntegerField): - """ activitypub-aware boolean field """ + """activitypub-aware boolean field""" class DecimalField(ActivitypubFieldMixin, models.DecimalField): - """ activitypub-aware boolean field """ + """activitypub-aware boolean field""" def field_to_activity(self, value): if not value: diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py index 026cf7cd..1b1152ab 100644 --- a/bookwyrm/models/import_job.py +++ b/bookwyrm/models/import_job.py @@ -20,7 +20,7 @@ GOODREADS_SHELVES = { def unquote_string(text): - """ resolve csv quote weirdness """ + """resolve csv quote weirdness""" match = re.match(r'="([^"]*)"', text) if match: return match.group(1) @@ -28,7 +28,7 @@ def unquote_string(text): def construct_search_term(title, author): - """ formulate a query for the data connector """ + """formulate a query for the data connector""" # Strip brackets (usually series title from search term) title = re.sub(r"\s*\([^)]*\)\s*", "", title) # Open library doesn't like including author initials in search term. @@ -38,7 +38,7 @@ def construct_search_term(title, author): class ImportJob(models.Model): - """ entry for a specific request for book data import """ + """entry for a specific request for book data import""" user = models.ForeignKey(User, on_delete=models.CASCADE) created_date = models.DateTimeField(default=timezone.now) @@ -51,7 +51,7 @@ class ImportJob(models.Model): retry = models.BooleanField(default=False) def save(self, *args, **kwargs): - """ save and notify """ + """save and notify""" super().save(*args, **kwargs) if self.complete: notification_model = apps.get_model( @@ -65,7 +65,7 @@ class ImportJob(models.Model): class ImportItem(models.Model): - """ a single line of a csv being imported """ + """a single line of a csv being imported""" job = models.ForeignKey(ImportJob, on_delete=models.CASCADE, related_name="items") index = models.IntegerField() @@ -74,11 +74,11 @@ class ImportItem(models.Model): fail_reason = models.TextField(null=True) def resolve(self): - """ try various ways to lookup a book """ + """try various ways to lookup a book""" self.book = self.get_book_from_isbn() or self.get_book_from_title_author() def get_book_from_isbn(self): - """ search by isbn """ + """search by isbn""" search_result = connector_manager.first_search_result( self.isbn, min_confidence=0.999 ) @@ -88,7 +88,7 @@ class ImportItem(models.Model): return None def get_book_from_title_author(self): - """ search by title and author """ + """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 @@ -100,60 +100,60 @@ class ImportItem(models.Model): @property def title(self): - """ get the book title """ + """get the book title""" return self.data["Title"] @property def author(self): - """ get the book title """ + """get the book title""" return self.data["Author"] @property def isbn(self): - """ pulls out the isbn13 field from the csv line data """ + """pulls out the isbn13 field from the csv line data""" return unquote_string(self.data["ISBN13"]) @property def shelf(self): - """ the goodreads shelf field """ + """the goodreads shelf field""" if self.data["Exclusive Shelf"]: return GOODREADS_SHELVES.get(self.data["Exclusive Shelf"]) return None @property def review(self): - """ a user-written review, to be imported with the book data """ + """a user-written review, to be imported with the book data""" return self.data["My Review"] @property def rating(self): - """ x/5 star rating for a book """ + """x/5 star rating for a book""" return int(self.data["My Rating"]) @property def date_added(self): - """ when the book was added to this dataset """ + """when the book was added to this dataset""" if self.data["Date Added"]: return timezone.make_aware(dateutil.parser.parse(self.data["Date Added"])) return None @property def date_started(self): - """ when the book was started """ + """when the book was started""" if "Date Started" in self.data and self.data["Date Started"]: return timezone.make_aware(dateutil.parser.parse(self.data["Date Started"])) return None @property def date_read(self): - """ the date a book was completed """ + """the date a book was completed""" if self.data["Date Read"]: return timezone.make_aware(dateutil.parser.parse(self.data["Date Read"])) return None @property def reads(self): - """ formats a read through dataset for the book in this line """ + """formats a read through dataset for the book in this line""" start_date = self.date_started # Goodreads special case (no 'date started' field) diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py index 4d6b53cd..2a5c3382 100644 --- a/bookwyrm/models/list.py +++ b/bookwyrm/models/list.py @@ -21,7 +21,7 @@ CurationType = models.TextChoices( class List(OrderedCollectionMixin, BookWyrmModel): - """ a list of books """ + """a list of books""" name = fields.CharField(max_length=100) user = fields.ForeignKey( @@ -41,22 +41,22 @@ class List(OrderedCollectionMixin, BookWyrmModel): activity_serializer = activitypub.BookList def get_remote_id(self): - """ don't want the user to be in there in this case """ + """don't want the user to be in there in this case""" return "https://%s/list/%d" % (DOMAIN, self.id) @property def collection_queryset(self): - """ list of books for this shelf, overrides OrderedCollectionMixin """ - return self.books.filter(listitem__approved=True).all().order_by("listitem") + """list of books for this shelf, overrides OrderedCollectionMixin""" + return self.books.filter(listitem__approved=True).order_by("listitem") class Meta: - """ default sorting """ + """default sorting""" ordering = ("-updated_date",) class ListItem(CollectionItemMixin, BookWyrmModel): - """ ok """ + """ok""" book = fields.ForeignKey( "Edition", on_delete=models.PROTECT, activitypub_field="book" @@ -67,14 +67,14 @@ class ListItem(CollectionItemMixin, BookWyrmModel): ) notes = fields.TextField(blank=True, null=True) approved = models.BooleanField(default=True) - order = fields.IntegerField(blank=True, null=True) + order = fields.IntegerField() endorsement = models.ManyToManyField("User", related_name="endorsers") activity_serializer = activitypub.ListItem collection_field = "book_list" def save(self, *args, **kwargs): - """ create a notification too """ + """create a notification too""" created = not bool(self.id) super().save(*args, **kwargs) # tick the updated date on the parent list @@ -93,7 +93,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel): ) class Meta: - """ an opinionated constraint! you can't put a book on a list twice """ - - unique_together = ("book", "book_list") + # A book may only be placed into a list once, and each order in the list may be used only + # once + unique_together = (("book", "book_list"), ("order", "book_list")) ordering = ("-created_date",) diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py index 233d635b..ff0b4e5a 100644 --- a/bookwyrm/models/notification.py +++ b/bookwyrm/models/notification.py @@ -10,7 +10,7 @@ NotificationType = models.TextChoices( class Notification(BookWyrmModel): - """ you've been tagged, liked, followed, etc """ + """you've been tagged, liked, followed, etc""" user = models.ForeignKey("User", on_delete=models.CASCADE) related_book = models.ForeignKey("Edition", on_delete=models.CASCADE, null=True) @@ -29,7 +29,7 @@ class Notification(BookWyrmModel): ) def save(self, *args, **kwargs): - """ save, but don't make dupes """ + """save, but don't make dupes""" # there's probably a better way to do this if self.__class__.objects.filter( user=self.user, @@ -45,7 +45,7 @@ class Notification(BookWyrmModel): super().save(*args, **kwargs) class Meta: - """ checks if notifcation is in enum list for valid types """ + """checks if notifcation is in enum list for valid types""" constraints = [ models.CheckConstraint( diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py index 1a5fcb0d..664daa13 100644 --- a/bookwyrm/models/readthrough.py +++ b/bookwyrm/models/readthrough.py @@ -7,14 +7,14 @@ from .base_model import BookWyrmModel class ProgressMode(models.TextChoices): - """ types of prgress available """ + """types of prgress available""" PAGE = "PG", "page" PERCENT = "PCT", "percent" class ReadThrough(BookWyrmModel): - """ Store a read through a book in the database. """ + """Store a read through a book in the database.""" user = models.ForeignKey("User", on_delete=models.PROTECT) book = models.ForeignKey("Edition", on_delete=models.PROTECT) @@ -28,13 +28,13 @@ class ReadThrough(BookWyrmModel): finish_date = models.DateTimeField(blank=True, null=True) def save(self, *args, **kwargs): - """ update user active time """ + """update user active time""" self.user.last_active_date = timezone.now() self.user.save(broadcast=False) super().save(*args, **kwargs) def create_update(self): - """ add update to the readthrough """ + """add update to the readthrough""" if self.progress: return self.progressupdate_set.create( user=self.user, progress=self.progress, mode=self.progress_mode @@ -43,7 +43,7 @@ class ReadThrough(BookWyrmModel): class ProgressUpdate(BookWyrmModel): - """ Store progress through a book in the database. """ + """Store progress through a book in the database.""" user = models.ForeignKey("User", on_delete=models.PROTECT) readthrough = models.ForeignKey("ReadThrough", on_delete=models.CASCADE) @@ -53,7 +53,7 @@ class ProgressUpdate(BookWyrmModel): ) def save(self, *args, **kwargs): - """ update user active time """ + """update user active time""" self.user.last_active_date = timezone.now() self.user.save(broadcast=False) super().save(*args, **kwargs) diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py index 998d7bed..12f4c51a 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -11,7 +11,7 @@ from . import fields class UserRelationship(BookWyrmModel): - """ many-to-many through table for followers """ + """many-to-many through table for followers""" user_subject = fields.ForeignKey( "User", @@ -28,16 +28,16 @@ class UserRelationship(BookWyrmModel): @property def privacy(self): - """ all relationships are handled directly with the participants """ + """all relationships are handled directly with the participants""" return "direct" @property def recipients(self): - """ the remote user needs to recieve direct broadcasts """ + """the remote user needs to recieve direct broadcasts""" return [u for u in [self.user_subject, self.user_object] if not u.local] class Meta: - """ relationships should be unique """ + """relationships should be unique""" abstract = True constraints = [ @@ -50,24 +50,23 @@ class UserRelationship(BookWyrmModel): ), ] - def get_remote_id(self, status=None): # pylint: disable=arguments-differ - """ use shelf identifier in remote_id """ - status = status or "follows" + def get_remote_id(self): + """use shelf identifier in remote_id""" base_path = self.user_subject.remote_id - return "%s#%s/%d" % (base_path, status, self.id) + return "%s#follows/%d" % (base_path, self.id) class UserFollows(ActivityMixin, UserRelationship): - """ Following a user """ + """Following a user""" status = "follows" def to_activity(self): # pylint: disable=arguments-differ - """ overrides default to manually set serializer """ + """overrides default to manually set serializer""" return activitypub.Follow(**generate_activity(self)) def save(self, *args, **kwargs): - """ really really don't let a user follow someone who blocked them """ + """really really don't let a user follow someone who blocked them""" # blocking in either direction is a no-go if UserBlocks.objects.filter( Q( @@ -86,7 +85,7 @@ class UserFollows(ActivityMixin, UserRelationship): @classmethod def from_request(cls, follow_request): - """ converts a follow request into a follow relationship """ + """converts a follow request into a follow relationship""" return cls.objects.create( user_subject=follow_request.user_subject, user_object=follow_request.user_object, @@ -95,19 +94,22 @@ class UserFollows(ActivityMixin, UserRelationship): class UserFollowRequest(ActivitypubMixin, UserRelationship): - """ following a user requires manual or automatic confirmation """ + """following a user requires manual or automatic confirmation""" status = "follow_request" activity_serializer = activitypub.Follow def save(self, *args, broadcast=True, **kwargs): - """ make sure the follow or block relationship doesn't already exist """ - # don't create a request if a follow already exists + """make sure the follow or block relationship doesn't already exist""" + # if there's a request for a follow that already exists, accept it + # without changing the local database state if UserFollows.objects.filter( user_subject=self.user_subject, user_object=self.user_object, ).exists(): - raise IntegrityError() + self.accept(broadcast_only=True) + return + # blocking in either direction is a no-go if UserBlocks.objects.filter( Q( @@ -138,25 +140,34 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship): notification_type=notification_type, ) - def accept(self): - """ turn this request into the real deal""" + def get_accept_reject_id(self, status): + """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) + + def accept(self, broadcast_only=False): + """turn this request into the real deal""" user = self.user_object if not self.user_subject.local: activity = activitypub.Accept( - id=self.get_remote_id(status="accepts"), + id=self.get_accept_reject_id(status="accepts"), actor=self.user_object.remote_id, object=self.to_activity(), ).serialize() self.broadcast(activity, user) + if broadcast_only: + return + with transaction.atomic(): UserFollows.from_request(self) self.delete() def reject(self): - """ generate a Reject for this follow request """ + """generate a Reject for this follow request""" if self.user_object.local: activity = activitypub.Reject( - id=self.get_remote_id(status="rejects"), + id=self.get_accept_reject_id(status="rejects"), actor=self.user_object.remote_id, object=self.to_activity(), ).serialize() @@ -166,13 +177,13 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship): class UserBlocks(ActivityMixin, UserRelationship): - """ prevent another user from following you and seeing your posts """ + """prevent another user from following you and seeing your posts""" status = "blocks" activity_serializer = activitypub.Block def save(self, *args, **kwargs): - """ remove follow or follow request rels after a block is created """ + """remove follow or follow request rels after a block is created""" super().save(*args, **kwargs) UserFollows.objects.filter( diff --git a/bookwyrm/models/report.py b/bookwyrm/models/report.py index f9e8905b..7ff4c909 100644 --- a/bookwyrm/models/report.py +++ b/bookwyrm/models/report.py @@ -6,7 +6,7 @@ from .base_model import BookWyrmModel class Report(BookWyrmModel): - """ reported status or user """ + """reported status or user""" reporter = models.ForeignKey( "User", related_name="reporter", on_delete=models.PROTECT @@ -17,7 +17,7 @@ class Report(BookWyrmModel): resolved = models.BooleanField(default=False) def save(self, *args, **kwargs): - """ notify admins when a report is created """ + """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 @@ -34,7 +34,7 @@ class Report(BookWyrmModel): ) class Meta: - """ don't let users report themselves """ + """don't let users report themselves""" constraints = [ models.CheckConstraint(check=~Q(reporter=F("user")), name="self_report") @@ -43,13 +43,13 @@ class Report(BookWyrmModel): class ReportComment(BookWyrmModel): - """ updates on a report """ + """updates on a report""" user = models.ForeignKey("User", on_delete=models.PROTECT) note = models.TextField() report = models.ForeignKey(Report, on_delete=models.PROTECT) class Meta: - """ sort comments """ + """sort comments""" ordering = ("-created_date",) diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py index 5bbb84b9..4110ae8d 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -9,7 +9,7 @@ from . import fields class Shelf(OrderedCollectionMixin, BookWyrmModel): - """ a list of books owned by a user """ + """a list of books owned by a user""" TO_READ = "to-read" READING = "reading" @@ -34,36 +34,36 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel): activity_serializer = activitypub.Shelf def save(self, *args, **kwargs): - """ set the identifier """ + """set the identifier""" super().save(*args, **kwargs) if not self.identifier: self.identifier = self.get_identifier() super().save(*args, **kwargs, broadcast=False) def get_identifier(self): - """ custom-shelf-123 for the url """ + """custom-shelf-123 for the url""" slug = re.sub(r"[^\w]", "", self.name).lower() return "{:s}-{:d}".format(slug, self.id) @property def collection_queryset(self): - """ list of books for this shelf, overrides OrderedCollectionMixin """ - return self.books.all().order_by("shelfbook") + """list of books for this shelf, overrides OrderedCollectionMixin""" + return self.books.order_by("shelfbook") def get_remote_id(self): - """ shelf identifier instead of id """ + """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) class Meta: - """ user/shelf unqiueness """ + """user/shelf unqiueness""" unique_together = ("user", "identifier") class ShelfBook(CollectionItemMixin, BookWyrmModel): - """ many to many join table for books and shelves """ + """many to many join table for books and shelves""" book = fields.ForeignKey( "Edition", on_delete=models.PROTECT, activitypub_field="book" diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py index 1eb31869..193cffb7 100644 --- a/bookwyrm/models/site.py +++ b/bookwyrm/models/site.py @@ -12,7 +12,7 @@ from .user import User class SiteSettings(models.Model): - """ customized settings for this instance """ + """customized settings for this instance""" name = models.CharField(default="BookWyrm", max_length=100) instance_tagline = models.CharField( @@ -35,7 +35,7 @@ class SiteSettings(models.Model): @classmethod def get(cls): - """ gets the site settings db entry or defaults """ + """gets the site settings db entry or defaults""" try: return cls.objects.get(id=1) except cls.DoesNotExist: @@ -45,12 +45,12 @@ class SiteSettings(models.Model): def new_access_code(): - """ the identifier for a user invite """ + """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 """ + """gives someone access to create an account on the instance""" created_date = models.DateTimeField(auto_now_add=True) code = models.CharField(max_length=32, default=new_access_code) @@ -61,19 +61,19 @@ class SiteInvite(models.Model): invitees = models.ManyToManyField(User, related_name="invitees") def valid(self): - """ make sure it hasn't expired or been used """ + """make sure it hasn't expired or been used""" return (self.expiry is None or self.expiry > timezone.now()) and ( self.use_limit is None or self.times_used < self.use_limit ) @property def link(self): - """ formats the invite link """ + """formats the invite link""" return "https://{}/invite/{}".format(DOMAIN, self.code) class InviteRequest(BookWyrmModel): - """ prospective users can request an invite """ + """prospective users can request an invite""" email = models.EmailField(max_length=255, unique=True) invite = models.ForeignKey( @@ -83,30 +83,30 @@ class InviteRequest(BookWyrmModel): ignored = models.BooleanField(default=False) def save(self, *args, **kwargs): - """ don't create a request for a registered email """ + """don't create a request for a registered email""" if not self.id and User.objects.filter(email=self.email).exists(): raise IntegrityError() super().save(*args, **kwargs) def get_passowrd_reset_expiry(): - """ give people a limited time to use the link """ + """give people a limited time to use the link""" now = timezone.now() return now + datetime.timedelta(days=1) class PasswordReset(models.Model): - """ gives someone access to create an account on the instance """ + """gives someone access to create an account on the instance""" code = models.CharField(max_length=32, default=new_access_code) expiry = models.DateTimeField(default=get_passowrd_reset_expiry) user = models.OneToOneField(User, on_delete=models.CASCADE) def valid(self): - """ make sure it hasn't expired or been used """ + """make sure it hasn't expired or been used""" return self.expiry > timezone.now() @property def link(self): - """ formats the invite link """ + """formats the invite link""" return "https://{}/password-reset/{}".format(DOMAIN, self.code) diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 360288e9..bd21ec56 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -19,7 +19,7 @@ from . import fields class Status(OrderedCollectionPageMixin, BookWyrmModel): - """ any post, like a reply to a review, etc """ + """any post, like a reply to a review, etc""" user = fields.ForeignKey( "User", on_delete=models.PROTECT, activitypub_field="attributedTo" @@ -59,12 +59,12 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): deserialize_reverse_fields = [("attachments", "attachment")] class Meta: - """ default sorting """ + """default sorting""" ordering = ("-published_date",) def save(self, *args, **kwargs): - """ save and notify """ + """save and notify""" super().save(*args, **kwargs) notification_model = apps.get_model("bookwyrm.Notification", require_ready=True) @@ -98,7 +98,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): ) def delete(self, *args, **kwargs): # pylint: disable=unused-argument - """ "delete" a status """ + """ "delete" a status""" if hasattr(self, "boosted_status"): # okay but if it's a boost really delete it super().delete(*args, **kwargs) @@ -109,7 +109,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): @property def recipients(self): - """ tagged users who definitely need to get this status in broadcast """ + """tagged users who definitely need to get this status in broadcast""" mentions = [u for u in self.mention_users.all() if not u.local] if ( hasattr(self, "reply_parent") @@ -121,7 +121,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): @classmethod def ignore_activity(cls, activity): # pylint: disable=too-many-return-statements - """ keep notes if they are replies to existing statuses """ + """keep notes if they are replies to existing statuses""" if activity.type == "Announce": try: boosted = activitypub.resolve_remote_id( @@ -163,16 +163,16 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): @property def status_type(self): - """ expose the type of status for the ui using activity type """ + """expose the type of status for the ui using activity type""" return self.activity_serializer.__name__ @property def boostable(self): - """ you can't boost dms """ + """you can't boost dms""" return self.privacy in ["unlisted", "public"] def to_replies(self, **kwargs): - """ helper function for loading AP serialized replies to a status """ + """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, @@ -181,7 +181,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): ).serialize() def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ - """ return tombstone if the status is deleted """ + """return tombstone if the status is deleted""" if self.deleted: return activitypub.Tombstone( id=self.remote_id, @@ -210,16 +210,16 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): return activity def to_activity(self, pure=False): # pylint: disable=arguments-differ - """ json serialized activitypub class """ + """json serialized activitypub class""" return self.to_activity_dataclass(pure=pure).serialize() class GeneratedNote(Status): - """ these are app-generated messages about user activity """ + """these are app-generated messages about user activity""" @property def pure_content(self): - """ indicate the book in question for mastodon (or w/e) users """ + """indicate the book in question for mastodon (or w/e) users""" message = self.content books = ", ".join( '"%s"' % (book.remote_id, book.title) @@ -232,7 +232,7 @@ class GeneratedNote(Status): class Comment(Status): - """ like a review but without a rating and transient """ + """like a review but without a rating and transient""" book = fields.ForeignKey( "Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook" @@ -253,7 +253,7 @@ class Comment(Status): @property def pure_content(self): - """ indicate the book in question for mastodon (or w/e) users """ + """indicate the book in question for mastodon (or w/e) users""" return '%s
(comment on "%s")
' % ( self.content, self.book.remote_id, @@ -265,7 +265,7 @@ class Comment(Status): class Quotation(Status): - """ like a review but without a rating and transient """ + """like a review but without a rating and transient""" quote = fields.HtmlField() book = fields.ForeignKey( @@ -274,7 +274,7 @@ class Quotation(Status): @property def pure_content(self): - """ indicate the book in question for mastodon (or w/e) users """ + """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' % ( @@ -289,7 +289,7 @@ class Quotation(Status): class Review(Status): - """ a book review """ + """a book review""" name = fields.CharField(max_length=255, null=True) book = fields.ForeignKey( @@ -306,7 +306,7 @@ class Review(Status): @property def pure_name(self): - """ clarify review names for mastodon serialization """ + """clarify review names for mastodon serialization""" template = get_template("snippets/generated_status/review_pure_name.html") return template.render( {"book": self.book, "rating": self.rating, "name": self.name} @@ -314,7 +314,7 @@ class Review(Status): @property def pure_content(self): - """ indicate the book in question for mastodon (or w/e) users """ + """indicate the book in question for mastodon (or w/e) users""" return self.content activity_serializer = activitypub.Review @@ -322,7 +322,7 @@ class Review(Status): class ReviewRating(Review): - """ a subtype of review that only contains a rating """ + """a subtype of review that only contains a rating""" def save(self, *args, **kwargs): if not self.rating: @@ -339,7 +339,7 @@ class ReviewRating(Review): class Boost(ActivityMixin, Status): - """ boost'ing a post """ + """boost'ing a post""" boosted_status = fields.ForeignKey( "Status", @@ -350,7 +350,17 @@ class Boost(ActivityMixin, Status): activity_serializer = activitypub.Announce def save(self, *args, **kwargs): - """ save and notify """ + """save and notify""" + # This constraint can't work as it would cross tables. + # class Meta: + # unique_together = ('user', 'boosted_status') + if ( + Boost.objects.filter(boosted_status=self.boosted_status, user=self.user) + .exclude(id=self.id) + .exists() + ): + return + super().save(*args, **kwargs) if not self.boosted_status.user.local or self.boosted_status.user == self.user: return @@ -364,7 +374,7 @@ class Boost(ActivityMixin, Status): ) def delete(self, *args, **kwargs): - """ delete and un-notify """ + """delete and un-notify""" notification_model = apps.get_model("bookwyrm.Notification", require_ready=True) notification_model.objects.filter( user=self.boosted_status.user, @@ -375,7 +385,7 @@ class Boost(ActivityMixin, Status): super().delete(*args, **kwargs) def __init__(self, *args, **kwargs): - """ the user field is "actor" here instead of "attributedTo" """ + """the user field is "actor" here instead of "attributedTo" """ super().__init__(*args, **kwargs) reserve_fields = ["user", "boosted_status", "published_date", "privacy"] diff --git a/bookwyrm/models/tag.py b/bookwyrm/models/tag.py deleted file mode 100644 index 2c45b8f9..00000000 --- a/bookwyrm/models/tag.py +++ /dev/null @@ -1,63 +0,0 @@ -""" models for storing different kinds of Activities """ -import urllib.parse - -from django.apps import apps -from django.db import models - -from bookwyrm import activitypub -from bookwyrm.settings import DOMAIN -from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin -from .base_model import BookWyrmModel -from . import fields - - -class Tag(OrderedCollectionMixin, BookWyrmModel): - """ freeform tags for books """ - - name = fields.CharField(max_length=100, unique=True) - identifier = models.CharField(max_length=100) - - @property - def books(self): - """ count of books associated with this tag """ - edition_model = apps.get_model("bookwyrm.Edition", require_ready=True) - return ( - edition_model.objects.filter(usertag__tag__identifier=self.identifier) - .order_by("-created_date") - .distinct() - ) - - collection_queryset = books - - def get_remote_id(self): - """ tag should use identifier not id in remote_id """ - base_path = "https://%s" % DOMAIN - return "%s/tag/%s" % (base_path, self.identifier) - - def save(self, *args, **kwargs): - """ create a url-safe lookup key for the tag """ - if not self.id: - # add identifiers to new tags - self.identifier = urllib.parse.quote_plus(self.name) - super().save(*args, **kwargs) - - -class UserTag(CollectionItemMixin, BookWyrmModel): - """ an instance of a tag on a book by a user """ - - user = fields.ForeignKey( - "User", on_delete=models.PROTECT, activitypub_field="actor" - ) - book = fields.ForeignKey( - "Edition", on_delete=models.PROTECT, activitypub_field="object" - ) - tag = fields.ForeignKey("Tag", on_delete=models.PROTECT, activitypub_field="target") - - activity_serializer = activitypub.Add - object_field = "book" - collection_field = "tag" - - class Meta: - """ unqiueness constraint """ - - unique_together = ("user", "book", "tag") diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 0f98c82d..3efbd6ac 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -35,7 +35,7 @@ DeactivationReason = models.TextChoices( class User(OrderedCollectionPageMixin, AbstractUser): - """ a user who wants to read books """ + """a user who wants to read books""" username = fields.UsernameField() email = models.EmailField(unique=True, null=True) @@ -130,38 +130,38 @@ class User(OrderedCollectionPageMixin, AbstractUser): @property def following_link(self): - """ just how to find out the following info """ + """just how to find out the following info""" return "{:s}/following".format(self.remote_id) @property def alt_text(self): - """ alt text with username """ + """alt text with username""" return "avatar for %s" % (self.localname or self.username) @property def display_name(self): - """ show the cleanest version of the user's name possible """ + """show the cleanest version of the user's name possible""" if self.name and self.name != "": return self.name return self.localname or self.username @property def deleted(self): - """ for consistent naming """ + """for consistent naming""" return not self.is_active activity_serializer = activitypub.Person @classmethod def viewer_aware_objects(cls, viewer): - """ the user queryset filtered for the context of the logged in user """ + """the user queryset filtered for the context of the logged in user""" queryset = cls.objects.filter(is_active=True) if viewer and viewer.is_authenticated: queryset = queryset.exclude(blocks=viewer) return queryset def to_outbox(self, filter_type=None, **kwargs): - """ an ordered collection of statuses """ + """an ordered collection of statuses""" if filter_type: filter_class = apps.get_model( "bookwyrm.%s" % filter_type, require_ready=True @@ -188,7 +188,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): ).serialize() def to_following_activity(self, **kwargs): - """ activitypub following list """ + """activitypub following list""" remote_id = "%s/following" % self.remote_id return self.to_ordered_collection( self.following.order_by("-updated_date").all(), @@ -198,7 +198,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): ) def to_followers_activity(self, **kwargs): - """ activitypub followers list """ + """activitypub followers list""" remote_id = "%s/followers" % self.remote_id return self.to_ordered_collection( self.followers.order_by("-updated_date").all(), @@ -227,7 +227,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): return activity_object def save(self, *args, **kwargs): - """ populate fields for new local users """ + """populate fields for new local users""" created = not bool(self.id) if not self.local and not re.match(regex.full_username, self.username): # generate a username that uses the domain (webfinger format) @@ -292,19 +292,19 @@ class User(OrderedCollectionPageMixin, AbstractUser): ).save(broadcast=False) def delete(self, *args, **kwargs): - """ deactivate rather than delete a user """ + """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 """ + """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 """ + """public and private keys for a user""" private_key = models.TextField(blank=True, null=True) public_key = fields.TextField( @@ -319,7 +319,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel): return "%s/#main-key" % self.owner.remote_id def save(self, *args, **kwargs): - """ create a key pair """ + """create a key pair""" # no broadcasting happening here if "broadcast" in kwargs: del kwargs["broadcast"] @@ -337,7 +337,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel): class AnnualGoal(BookWyrmModel): - """ set a goal for how many books you read in a year """ + """set a goal for how many books you read in a year""" user = models.ForeignKey("User", on_delete=models.PROTECT) goal = models.IntegerField(validators=[MinValueValidator(1)]) @@ -347,17 +347,17 @@ class AnnualGoal(BookWyrmModel): ) class Meta: - """ unqiueness constraint """ + """unqiueness constraint""" unique_together = ("user", "year") def get_remote_id(self): - """ put the year in the path """ + """put the year in the path""" return "%s/goal/%d" % (self.user.remote_id, self.year) @property def books(self): - """ the books you've read this year """ + """the books you've read this year""" return ( self.user.readthrough_set.filter(finish_date__year__gte=self.year) .order_by("-finish_date") @@ -366,7 +366,7 @@ class AnnualGoal(BookWyrmModel): @property def ratings(self): - """ ratings for books read this year """ + """ratings for books read this year""" book_ids = [r.book.id for r in self.books] reviews = Review.objects.filter( user=self.user, @@ -376,12 +376,12 @@ class AnnualGoal(BookWyrmModel): @property def progress_percent(self): - """ how close to your goal, in percent form """ + """how close to your goal, in percent form""" return int(float(self.book_count / self.goal) * 100) @property def book_count(self): - """ how many books you've read this year """ + """how many books you've read this year""" return self.user.readthrough_set.filter( finish_date__year__gte=self.year ).count() @@ -389,7 +389,7 @@ class AnnualGoal(BookWyrmModel): @app.task def set_remote_server(user_id): - """ figure out the user's remote server in the background """ + """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) @@ -399,7 +399,7 @@ def set_remote_server(user_id): def get_or_create_remote_server(domain): - """ get info on a remote server """ + """get info on a remote server""" try: return FederatedServer.objects.get(server_name=domain) except FederatedServer.DoesNotExist: @@ -428,7 +428,7 @@ def get_or_create_remote_server(domain): @app.task def get_remote_reviews(outbox): - """ ingest reviews by a new remote bookwyrm user """ + """ingest reviews by a new remote bookwyrm user""" outbox_page = outbox + "?page=true&type=Review" data = get_data(outbox_page) diff --git a/bookwyrm/redis_store.py b/bookwyrm/redis_store.py index 4236d6df..259bc4fd 100644 --- a/bookwyrm/redis_store.py +++ b/bookwyrm/redis_store.py @@ -10,16 +10,16 @@ r = redis.Redis( class RedisStore(ABC): - """ sets of ranked, related objects, like statuses for a user's feed """ + """sets of ranked, related objects, like statuses for a user's feed""" max_length = settings.MAX_STREAM_LENGTH def get_value(self, obj): - """ the object and rank """ + """the object and rank""" return {obj.id: self.get_rank(obj)} def add_object_to_related_stores(self, obj, execute=True): - """ add an object to all suitable stores """ + """add an object to all suitable stores""" value = self.get_value(obj) # we want to do this as a bulk operation, hence "pipeline" pipeline = r.pipeline() @@ -34,14 +34,14 @@ class RedisStore(ABC): return pipeline.execute() def remove_object_from_related_stores(self, obj): - """ remove an object from all stores """ + """remove an object from all stores""" pipeline = r.pipeline() for store in self.get_stores_for_object(obj): pipeline.zrem(store, -1, obj.id) pipeline.execute() def bulk_add_objects_to_store(self, objs, store): - """ add a list of objects to a given store """ + """add a list of objects to a given store""" pipeline = r.pipeline() for obj in objs[: self.max_length]: pipeline.zadd(store, self.get_value(obj)) @@ -50,18 +50,18 @@ class RedisStore(ABC): pipeline.execute() def bulk_remove_objects_from_store(self, objs, store): - """ remoev a list of objects from a given store """ + """remoev 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 - """ load the values in a store """ + """load the values in a store""" return r.zrevrange(store, 0, -1) def populate_store(self, store): - """ go from zero to a store """ + """go from zero to a store""" pipeline = r.pipeline() queryset = self.get_objects_for_store(store) @@ -75,12 +75,12 @@ class RedisStore(ABC): @abstractmethod def get_objects_for_store(self, store): - """ a queryset of what should go in a store, used for populating it """ + """a queryset of what should go in a store, used for populating it""" @abstractmethod def get_stores_for_object(self, obj): - """ the stores that an object belongs in """ + """the stores that an object belongs in""" @abstractmethod def get_rank(self, obj): - """ how to rank an object """ + """how to rank an object""" diff --git a/bookwyrm/sanitize_html.py b/bookwyrm/sanitize_html.py index 2a630f83..0be64c58 100644 --- a/bookwyrm/sanitize_html.py +++ b/bookwyrm/sanitize_html.py @@ -3,7 +3,7 @@ from html.parser import HTMLParser class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method - """ Removes any html that isn't allowed_tagsed from a block """ + """Removes any html that isn't allowed_tagsed from a block""" def __init__(self): HTMLParser.__init__(self) @@ -28,7 +28,7 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method self.allow_html = True def handle_starttag(self, tag, attrs): - """ check if the tag is valid """ + """check if the tag is valid""" if self.allow_html and tag in self.allowed_tags: self.output.append(("tag", self.get_starttag_text())) self.tag_stack.append(tag) @@ -36,7 +36,7 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method self.output.append(("data", "")) def handle_endtag(self, tag): - """ keep the close tag """ + """keep the close tag""" if not self.allow_html or tag not in self.allowed_tags: self.output.append(("data", "")) return @@ -51,11 +51,11 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method self.output.append(("tag", "%s>" % tag)) def handle_data(self, data): - """ extract the answer, if we're in an answer tag """ + """extract the answer, if we're in an answer tag""" self.output.append(("data", data)) def get_output(self): - """ convert the output from a list of tuples to a string """ + """convert the output from a list of tuples to a string""" if self.tag_stack: self.allow_html = False if not self.allow_html: diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 7ea8c595..fb5488e7 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -153,7 +153,7 @@ LANGUAGES = [ ("de-de", _("German")), ("es", _("Spanish")), ("fr-fr", _("French")), - ("zh-cn", _("Simplified Chinese")), + ("zh-hans", _("Simplified Chinese")), ] diff --git a/bookwyrm/signatures.py b/bookwyrm/signatures.py index 80cbfdc7..5488cf9b 100644 --- a/bookwyrm/signatures.py +++ b/bookwyrm/signatures.py @@ -13,7 +13,7 @@ MAX_SIGNATURE_AGE = 300 def create_key_pair(): - """ a new public/private key pair, used for creating new users """ + """a new public/private key pair, used for creating new users""" random_generator = Random.new().read key = RSA.generate(1024, random_generator) private_key = key.export_key().decode("utf8") @@ -23,7 +23,7 @@ def create_key_pair(): def make_signature(sender, destination, date, digest): - """ uses a private key to sign an outgoing message """ + """uses a private key to sign an outgoing message""" inbox_parts = urlparse(destination) signature_headers = [ "(request-target): post %s" % inbox_parts.path, @@ -44,14 +44,14 @@ def make_signature(sender, destination, date, digest): def make_digest(data): - """ creates a message digest for signing """ + """creates a message digest for signing""" return "SHA-256=" + b64encode(hashlib.sha256(data.encode("utf-8")).digest()).decode( "utf-8" ) def verify_digest(request): - """ checks if a digest is syntactically valid and matches the message """ + """checks if a digest is syntactically valid and matches the message""" algorithm, digest = request.headers["digest"].split("=", 1) if algorithm == "SHA-256": hash_function = hashlib.sha256 @@ -66,7 +66,7 @@ def verify_digest(request): class Signature: - """ read and validate incoming signatures """ + """read and validate incoming signatures""" def __init__(self, key_id, headers, signature): self.key_id = key_id @@ -75,7 +75,7 @@ class Signature: @classmethod def parse(cls, request): - """ extract and parse a signature from an http request """ + """extract and parse a signature from an http request""" signature_dict = {} for pair in request.headers["Signature"].split(","): k, v = pair.split("=", 1) @@ -92,7 +92,7 @@ class Signature: return cls(key_id, headers, signature) def verify(self, public_key, request): - """ verify rsa signature """ + """verify rsa signature""" if http_date_age(request.headers["date"]) > MAX_SIGNATURE_AGE: raise ValueError("Request too old: %s" % (request.headers["date"],)) public_key = RSA.import_key(public_key) @@ -118,7 +118,7 @@ class Signature: def http_date_age(datestr): - """ age of a signature in seconds """ + """age of a signature in seconds""" parsed = datetime.datetime.strptime(datestr, "%a, %d %b %Y %H:%M:%S GMT") delta = datetime.datetime.utcnow() - parsed return delta.total_seconds() diff --git a/bookwyrm/static/css/bookwyrm.css b/bookwyrm/static/css/bookwyrm.css index 67eb1eba..9e74d69f 100644 --- a/bookwyrm/static/css/bookwyrm.css +++ b/bookwyrm/static/css/bookwyrm.css @@ -1,6 +1,5 @@ html { scroll-behavior: smooth; - scroll-padding-top: 20%; } body { @@ -30,6 +29,40 @@ body { min-width: 75% !important; } +/** Utilities not covered by Bulma + ******************************************************************************/ + +@media only screen and (max-width: 768px) { + .is-sr-only-mobile { + border: none !important; + clip: rect(0, 0, 0, 0) !important; + height: 0.01em !important; + overflow: hidden !important; + padding: 0 !important; + position: absolute !important; + white-space: nowrap !important; + width: 0.01em !important; + } + + .m-0-mobile { + margin: 0 !important; + } +} + +.button.is-transparent { + background-color: transparent; +} + +.card.is-stretchable { + display: flex; + flex-direction: column; + height: 100%; +} + +.card.is-stretchable .card-content { + flex-grow: 1; +} + /** Shelving ******************************************************************************/ @@ -86,6 +119,13 @@ body { } } +/** Stars + ******************************************************************************/ + +.stars { + white-space: nowrap; +} + /** Stars in a review form * * Specificity makes hovering taking over checked inputs. @@ -256,3 +296,53 @@ body { opacity: 0.5; cursor: not-allowed; } + +/* Book preview table + ******************************************************************************/ + +.book-preview td { + vertical-align: middle; +} + +@media only screen and (max-width: 768px) { + table.is-mobile, + table.is-mobile tbody { + display: block; + } + + table.is-mobile tr { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + border-top: 1px solid #dbdbdb; + } + + table.is-mobile td { + display: block; + box-sizing: border-box; + flex: 1 0 100%; + order: 2; + border-bottom: 0; + } + + table.is-mobile td.book-preview-top-row { + order: 1; + flex-basis: auto; + } + + table.is-mobile td[data-title]:not(:empty)::before { + content: attr(data-title); + display: block; + font-size: 0.75em; + font-weight: bold; + } + + table.is-mobile td:empty { + padding: 0; + } + + table.is-mobile th, + table.is-mobile thead { + display: none; + } +} diff --git a/bookwyrm/status.py b/bookwyrm/status.py index 793bd742..09fbdc06 100644 --- a/bookwyrm/status.py +++ b/bookwyrm/status.py @@ -6,7 +6,7 @@ from bookwyrm.sanitize_html import InputHtmlParser def create_generated_note(user, content, mention_books=None, privacy="public"): - """ a note created by the app about user activity """ + """a note created by the app about user activity""" # sanitize input html parser = InputHtmlParser() parser.feed(content) diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index d263e0e0..97f105bf 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -67,31 +67,16 @@ {% endif %} -{{ error | escape }}
{% endfor %} -{{ form.series }}
++ + +
{% for error in form.series.errors %}{{ error | escape }}
{% endfor %} diff --git a/bookwyrm/templates/book/editions.html b/bookwyrm/templates/book/editions.html index 91259465..70f067f7 100644 --- a/bookwyrm/templates/book/editions.html +++ b/bookwyrm/templates/book/editions.html @@ -25,7 +25,18 @@ {{ book.title }} - {% include 'book/publisher_info.html' with book=book %} + + {% with book=book %} +{% with format=book.physical_format pages=book.pages %} @@ -39,7 +40,7 @@ {% endif %}
- {% with date=book.published_date|date:'M jS Y' publisher=book.publishers|join:', ' %} + {% with date=book.published_date|naturalday publisher=book.publishers|join:', ' %} {% if date or book.first_published_date %}