Merge branch 'main' into django-3-2

This commit is contained in:
Mouse Reeve 2021-04-26 11:21:36 -07:00
commit 0889c57b86
214 changed files with 4734 additions and 3265 deletions

View file

@ -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): 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) return naive_parse(activity_objects, activity_json)

View file

@ -10,11 +10,11 @@ from bookwyrm.tasks import app
class ActivitySerializerError(ValueError): class ActivitySerializerError(ValueError):
""" routine problems serializing activitypub json """ """routine problems serializing activitypub json"""
class ActivityEncoder(JSONEncoder): class ActivityEncoder(JSONEncoder):
""" used to convert an Activity object into json """ """used to convert an Activity object into json"""
def default(self, o): def default(self, o):
return o.__dict__ return o.__dict__
@ -22,7 +22,7 @@ class ActivityEncoder(JSONEncoder):
@dataclass @dataclass
class Link: class Link:
""" for tagging a book in a status """ """for tagging a book in a status"""
href: str href: str
name: str name: str
@ -31,14 +31,14 @@ class Link:
@dataclass @dataclass
class Mention(Link): class Mention(Link):
""" a subtype of Link for mentioning an actor """ """a subtype of Link for mentioning an actor"""
type: str = "Mention" type: str = "Mention"
@dataclass @dataclass
class Signature: class Signature:
""" public key block """ """public key block"""
creator: str creator: str
created: str created: str
@ -47,7 +47,7 @@ class Signature:
def naive_parse(activity_objects, activity_json, serializer=None): def naive_parse(activity_objects, activity_json, serializer=None):
""" this navigates circular import issues """ """this navigates circular import issues"""
if not serializer: if not serializer:
if activity_json.get("publicKeyPem"): if activity_json.get("publicKeyPem"):
# ugh # ugh
@ -67,7 +67,7 @@ def naive_parse(activity_objects, activity_json, serializer=None):
@dataclass(init=False) @dataclass(init=False)
class ActivityObject: class ActivityObject:
""" actor activitypub json """ """actor activitypub json"""
id: str id: str
type: str type: str
@ -106,7 +106,7 @@ class ActivityObject:
setattr(self, field.name, value) setattr(self, field.name, value)
def to_model(self, model=None, instance=None, allow_create=True, save=True): 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) model = model or get_model_from_type(self.type)
# only reject statuses if we're potentially creating them # only reject statuses if we're potentially creating them
@ -181,7 +181,7 @@ class ActivityObject:
return instance return instance
def serialize(self): def serialize(self):
""" convert to dictionary with context attr """ """convert to dictionary with context attr"""
data = self.__dict__.copy() data = self.__dict__.copy()
# recursively serialize # recursively serialize
for (k, v) in data.items(): for (k, v) in data.items():
@ -200,7 +200,7 @@ class ActivityObject:
def set_related_field( def set_related_field(
model_name, origin_model_name, related_field_name, related_remote_id, data 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) model = apps.get_model("bookwyrm.%s" % model_name, require_ready=True)
origin_model = apps.get_model("bookwyrm.%s" % origin_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): 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() models = apps.get_models()
model = [ model = [
m m
@ -255,7 +255,7 @@ def get_model_from_type(activity_type):
def resolve_remote_id( def resolve_remote_id(
remote_id, model=None, refresh=False, save=True, get_activity=False 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 if model: # a bonus check we can do if we already know the model
result = model.find_existing_by_remote_id(remote_id) result = model.find_existing_by_remote_id(remote_id)
if result and not refresh: if result and not refresh:

View file

@ -8,9 +8,10 @@ from .image import Document
@dataclass(init=False) @dataclass(init=False)
class Book(ActivityObject): class Book(ActivityObject):
""" serializes an edition or work, abstract """ """serializes an edition or work, abstract"""
title: str title: str
lastEditedBy: str = None
sortTitle: str = "" sortTitle: str = ""
subtitle: str = "" subtitle: str = ""
description: str = "" description: str = ""
@ -34,7 +35,7 @@ class Book(ActivityObject):
@dataclass(init=False) @dataclass(init=False)
class Edition(Book): class Edition(Book):
""" Edition instance of a book object """ """Edition instance of a book object"""
work: str work: str
isbn10: str = "" isbn10: str = ""
@ -51,7 +52,7 @@ class Edition(Book):
@dataclass(init=False) @dataclass(init=False)
class Work(Book): class Work(Book):
""" work instance of a book object """ """work instance of a book object"""
lccn: str = "" lccn: str = ""
defaultEdition: str = "" defaultEdition: str = ""
@ -61,9 +62,10 @@ class Work(Book):
@dataclass(init=False) @dataclass(init=False)
class Author(ActivityObject): class Author(ActivityObject):
""" author of a book """ """author of a book"""
name: str name: str
lastEditedBy: str = None
born: str = None born: str = None
died: str = None died: str = None
aliases: List[str] = field(default_factory=lambda: []) aliases: List[str] = field(default_factory=lambda: [])

View file

@ -5,7 +5,7 @@ from .base_activity import ActivityObject
@dataclass(init=False) @dataclass(init=False)
class Document(ActivityObject): class Document(ActivityObject):
""" a document """ """a document"""
url: str url: str
name: str = "" name: str = ""
@ -15,6 +15,6 @@ class Document(ActivityObject):
@dataclass(init=False) @dataclass(init=False)
class Image(Document): class Image(Document):
""" an image """ """an image"""
type: str = "Image" type: str = "Image"

View file

@ -9,19 +9,19 @@ from .image import Document
@dataclass(init=False) @dataclass(init=False)
class Tombstone(ActivityObject): class Tombstone(ActivityObject):
""" the placeholder for a deleted status """ """the placeholder for a deleted status"""
type: str = "Tombstone" type: str = "Tombstone"
def to_model(self, *args, **kwargs): # pylint: disable=unused-argument 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") model = apps.get_model("bookwyrm.Status")
return model.find_existing_by_remote_id(self.id) return model.find_existing_by_remote_id(self.id)
@dataclass(init=False) @dataclass(init=False)
class Note(ActivityObject): class Note(ActivityObject):
""" Note activity """ """Note activity"""
published: str published: str
attributedTo: str attributedTo: str
@ -39,7 +39,7 @@ class Note(ActivityObject):
@dataclass(init=False) @dataclass(init=False)
class Article(Note): 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 name: str
type: str = "Article" type: str = "Article"
@ -47,14 +47,14 @@ class Article(Note):
@dataclass(init=False) @dataclass(init=False)
class GeneratedNote(Note): class GeneratedNote(Note):
""" just a re-typed note """ """just a re-typed note"""
type: str = "GeneratedNote" type: str = "GeneratedNote"
@dataclass(init=False) @dataclass(init=False)
class Comment(Note): class Comment(Note):
""" like a note but with a book """ """like a note but with a book"""
inReplyToBook: str inReplyToBook: str
type: str = "Comment" type: str = "Comment"
@ -62,7 +62,7 @@ class Comment(Note):
@dataclass(init=False) @dataclass(init=False)
class Quotation(Comment): class Quotation(Comment):
""" a quote and commentary on a book """ """a quote and commentary on a book"""
quote: str quote: str
type: str = "Quotation" type: str = "Quotation"
@ -70,7 +70,7 @@ class Quotation(Comment):
@dataclass(init=False) @dataclass(init=False)
class Review(Comment): class Review(Comment):
""" a full book review """ """a full book review"""
name: str = None name: str = None
rating: int = None rating: int = None
@ -79,7 +79,7 @@ class Review(Comment):
@dataclass(init=False) @dataclass(init=False)
class Rating(Comment): class Rating(Comment):
""" just a star rating """ """just a star rating"""
rating: int rating: int
content: str = None content: str = None

View file

@ -7,7 +7,7 @@ from .base_activity import ActivityObject
@dataclass(init=False) @dataclass(init=False)
class OrderedCollection(ActivityObject): class OrderedCollection(ActivityObject):
""" structure of an ordered collection activity """ """structure of an ordered collection activity"""
totalItems: int totalItems: int
first: str first: str
@ -19,7 +19,7 @@ class OrderedCollection(ActivityObject):
@dataclass(init=False) @dataclass(init=False)
class OrderedCollectionPrivate(OrderedCollection): class OrderedCollectionPrivate(OrderedCollection):
""" an ordered collection with privacy settings """ """an ordered collection with privacy settings"""
to: List[str] = field(default_factory=lambda: []) to: List[str] = field(default_factory=lambda: [])
cc: List[str] = field(default_factory=lambda: []) cc: List[str] = field(default_factory=lambda: [])
@ -27,14 +27,14 @@ class OrderedCollectionPrivate(OrderedCollection):
@dataclass(init=False) @dataclass(init=False)
class Shelf(OrderedCollectionPrivate): class Shelf(OrderedCollectionPrivate):
""" structure of an ordered collection activity """ """structure of an ordered collection activity"""
type: str = "Shelf" type: str = "Shelf"
@dataclass(init=False) @dataclass(init=False)
class BookList(OrderedCollectionPrivate): class BookList(OrderedCollectionPrivate):
""" structure of an ordered collection activity """ """structure of an ordered collection activity"""
summary: str = None summary: str = None
curation: str = "closed" curation: str = "closed"
@ -43,7 +43,7 @@ class BookList(OrderedCollectionPrivate):
@dataclass(init=False) @dataclass(init=False)
class OrderedCollectionPage(ActivityObject): class OrderedCollectionPage(ActivityObject):
""" structure of an ordered collection activity """ """structure of an ordered collection activity"""
partOf: str partOf: str
orderedItems: List orderedItems: List
@ -54,7 +54,7 @@ class OrderedCollectionPage(ActivityObject):
@dataclass(init=False) @dataclass(init=False)
class CollectionItem(ActivityObject): class CollectionItem(ActivityObject):
""" an item in a collection """ """an item in a collection"""
actor: str actor: str
type: str = "CollectionItem" type: str = "CollectionItem"
@ -62,7 +62,7 @@ class CollectionItem(ActivityObject):
@dataclass(init=False) @dataclass(init=False)
class ListItem(CollectionItem): class ListItem(CollectionItem):
""" a book on a list """ """a book on a list"""
book: str book: str
notes: str = None notes: str = None
@ -73,7 +73,7 @@ class ListItem(CollectionItem):
@dataclass(init=False) @dataclass(init=False)
class ShelfItem(CollectionItem): class ShelfItem(CollectionItem):
""" a book on a list """ """a book on a list"""
book: str book: str
type: str = "ShelfItem" type: str = "ShelfItem"

View file

@ -8,7 +8,7 @@ from .image import Image
@dataclass(init=False) @dataclass(init=False)
class PublicKey(ActivityObject): class PublicKey(ActivityObject):
""" public key block """ """public key block"""
owner: str owner: str
publicKeyPem: str publicKeyPem: str
@ -17,7 +17,7 @@ class PublicKey(ActivityObject):
@dataclass(init=False) @dataclass(init=False)
class Person(ActivityObject): class Person(ActivityObject):
""" actor activitypub json """ """actor activitypub json"""
preferredUsername: str preferredUsername: str
inbox: str inbox: str

View file

@ -9,13 +9,13 @@ from .ordered_collection import CollectionItem
@dataclass(init=False) @dataclass(init=False)
class Verb(ActivityObject): class Verb(ActivityObject):
"""generic fields for activities """ """generic fields for activities"""
actor: str actor: str
object: ActivityObject object: ActivityObject
def action(self): 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 # self.object may return None if the object is invalid in an expected way
# ie, Question type # ie, Question type
if self.object: if self.object:
@ -24,7 +24,7 @@ class Verb(ActivityObject):
@dataclass(init=False) @dataclass(init=False)
class Create(Verb): class Create(Verb):
""" Create activity """ """Create activity"""
to: List[str] to: List[str]
cc: List[str] = field(default_factory=lambda: []) cc: List[str] = field(default_factory=lambda: [])
@ -34,14 +34,14 @@ class Create(Verb):
@dataclass(init=False) @dataclass(init=False)
class Delete(Verb): class Delete(Verb):
""" Create activity """ """Create activity"""
to: List[str] to: List[str]
cc: List[str] = field(default_factory=lambda: []) cc: List[str] = field(default_factory=lambda: [])
type: str = "Delete" type: str = "Delete"
def action(self): def action(self):
""" find and delete the activity object """ """find and delete the activity object"""
if not self.object: if not self.object:
return return
@ -59,25 +59,25 @@ class Delete(Verb):
@dataclass(init=False) @dataclass(init=False)
class Update(Verb): class Update(Verb):
""" Update activity """ """Update activity"""
to: List[str] to: List[str]
type: str = "Update" type: str = "Update"
def action(self): def action(self):
""" update a model instance from the dataclass """ """update a model instance from the dataclass"""
if self.object: if self.object:
self.object.to_model(allow_create=False) self.object.to_model(allow_create=False)
@dataclass(init=False) @dataclass(init=False)
class Undo(Verb): class Undo(Verb):
""" Undo an activity """ """Undo an activity"""
type: str = "Undo" type: str = "Undo"
def action(self): def action(self):
""" find and remove the activity object """ """find and remove the activity object"""
if isinstance(self.object, str): if isinstance(self.object, str):
# it may be that sometihng should be done with these, but idk what # it may be that sometihng should be done with these, but idk what
# this seems just to be coming from pleroma # this seems just to be coming from pleroma
@ -103,64 +103,64 @@ class Undo(Verb):
@dataclass(init=False) @dataclass(init=False)
class Follow(Verb): class Follow(Verb):
""" Follow activity """ """Follow activity"""
object: str object: str
type: str = "Follow" type: str = "Follow"
def action(self): def action(self):
""" relationship save """ """relationship save"""
self.to_model() self.to_model()
@dataclass(init=False) @dataclass(init=False)
class Block(Verb): class Block(Verb):
""" Block activity """ """Block activity"""
object: str object: str
type: str = "Block" type: str = "Block"
def action(self): def action(self):
""" relationship save """ """relationship save"""
self.to_model() self.to_model()
@dataclass(init=False) @dataclass(init=False)
class Accept(Verb): class Accept(Verb):
""" Accept activity """ """Accept activity"""
object: Follow object: Follow
type: str = "Accept" type: str = "Accept"
def action(self): 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 = self.object.to_model(save=False, allow_create=False)
obj.accept() obj.accept()
@dataclass(init=False) @dataclass(init=False)
class Reject(Verb): class Reject(Verb):
""" Reject activity """ """Reject activity"""
object: Follow object: Follow
type: str = "Reject" type: str = "Reject"
def action(self): 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 = self.object.to_model(save=False, allow_create=False)
obj.reject() obj.reject()
@dataclass(init=False) @dataclass(init=False)
class Add(Verb): class Add(Verb):
"""Add activity """ """Add activity"""
target: ActivityObject target: ActivityObject
object: CollectionItem object: CollectionItem
type: str = "Add" type: str = "Add"
def action(self): 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) target = resolve_remote_id(self.target)
item = self.object.to_model(save=False) item = self.object.to_model(save=False)
setattr(item, item.collection_field, target) setattr(item, item.collection_field, target)
@ -169,31 +169,32 @@ class Add(Verb):
@dataclass(init=False) @dataclass(init=False)
class Remove(Add): class Remove(Add):
"""Remove activity """ """Remove activity"""
type: str = "Remove" type: str = "Remove"
def action(self): 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 = self.object.to_model(save=False, allow_create=False)
obj.delete() if obj:
obj.delete()
@dataclass(init=False) @dataclass(init=False)
class Like(Verb): class Like(Verb):
""" a user faving an object """ """a user faving an object"""
object: str object: str
type: str = "Like" type: str = "Like"
def action(self): def action(self):
""" like """ """like"""
self.to_model() self.to_model()
@dataclass(init=False) @dataclass(init=False)
class Announce(Verb): class Announce(Verb):
""" boosting a status """ """boosting a status"""
published: str published: str
to: List[str] = field(default_factory=lambda: []) to: List[str] = field(default_factory=lambda: [])
@ -202,5 +203,5 @@ class Announce(Verb):
type: str = "Announce" type: str = "Announce"
def action(self): def action(self):
""" boost """ """boost"""
self.to_model() self.to_model()

View file

@ -8,22 +8,22 @@ from bookwyrm.views.helpers import privacy_filter
class ActivityStream(RedisStore): 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): 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) return "{}-{}".format(user.id, self.key)
def unread_id(self, user): 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)) return "{}-unread".format(self.stream_id(user))
def get_rank(self, obj): # pylint: disable=no-self-use 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() return obj.published_date.timestamp()
def add_status(self, status): 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 # the pipeline contains all the add-to-stream activities
pipeline = self.add_object_to_related_stores(status, execute=False) pipeline = self.add_object_to_related_stores(status, execute=False)
@ -35,19 +35,19 @@ class ActivityStream(RedisStore):
pipeline.execute() pipeline.execute()
def add_user_statuses(self, viewer, user): 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) # only add the statuses that the viewer should be able to see (ie, not dms)
statuses = privacy_filter(viewer, user.status_set.all()) statuses = privacy_filter(viewer, user.status_set.all())
self.bulk_add_objects_to_store(statuses, self.stream_id(viewer)) self.bulk_add_objects_to_store(statuses, self.stream_id(viewer))
def remove_user_statuses(self, viewer, user): 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 # remove all so that followers only statuses are removed
statuses = user.status_set.all() statuses = user.status_set.all()
self.bulk_remove_objects_from_store(statuses, self.stream_id(viewer)) self.bulk_remove_objects_from_store(statuses, self.stream_id(viewer))
def get_activity_stream(self, user): def get_activity_stream(self, user):
""" load the statuses to be displayed """ """load the statuses to be displayed"""
# clear unreads for this feed # clear unreads for this feed
r.set(self.unread_id(user), 0) r.set(self.unread_id(user), 0)
@ -59,15 +59,15 @@ class ActivityStream(RedisStore):
) )
def get_unread_count(self, user): 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) return int(r.get(self.unread_id(user)) or 0)
def populate_streams(self, user): def populate_streams(self, user):
""" go from zero to a timeline """ """go from zero to a timeline"""
self.populate_store(self.stream_id(user)) self.populate_store(self.stream_id(user))
def get_audience(self, status): # pylint: disable=no-self-use 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 # direct messages don't appeard in feeds, direct comments/reviews/etc do
if status.privacy == "direct" and status.status_type == "Note": if status.privacy == "direct" and status.status_type == "Note":
return [] return []
@ -98,7 +98,7 @@ class ActivityStream(RedisStore):
return [self.stream_id(u) for u in self.get_audience(obj)] return [self.stream_id(u) for u in self.get_audience(obj)]
def get_statuses_for_user(self, user): # pylint: disable=no-self-use 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( return privacy_filter(
user, user,
models.Status.objects.select_subclasses(), models.Status.objects.select_subclasses(),
@ -111,7 +111,7 @@ class ActivityStream(RedisStore):
class HomeStream(ActivityStream): class HomeStream(ActivityStream):
""" users you follow """ """users you follow"""
key = "home" key = "home"
@ -134,7 +134,7 @@ class HomeStream(ActivityStream):
class LocalStream(ActivityStream): class LocalStream(ActivityStream):
""" users you follow """ """users you follow"""
key = "local" key = "local"
@ -154,7 +154,7 @@ class LocalStream(ActivityStream):
class FederatedStream(ActivityStream): class FederatedStream(ActivityStream):
""" users you follow """ """users you follow"""
key = "federated" key = "federated"
@ -182,7 +182,7 @@ streams = {
@receiver(signals.post_save) @receiver(signals.post_save)
# pylint: disable=unused-argument # pylint: disable=unused-argument
def add_status_on_create(sender, instance, created, *args, **kwargs): 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 # we're only interested in new statuses
if not issubclass(sender, models.Status): if not issubclass(sender, models.Status):
return return
@ -203,7 +203,7 @@ def add_status_on_create(sender, instance, created, *args, **kwargs):
@receiver(signals.post_delete, sender=models.Boost) @receiver(signals.post_delete, sender=models.Boost)
# pylint: disable=unused-argument # pylint: disable=unused-argument
def remove_boost_on_delete(sender, instance, *args, **kwargs): def remove_boost_on_delete(sender, instance, *args, **kwargs):
""" boosts are deleted """ """boosts are deleted"""
# we're only interested in new statuses # we're only interested in new statuses
for stream in streams.values(): for stream in streams.values():
stream.remove_object_from_related_stores(instance) 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) @receiver(signals.post_save, sender=models.UserFollows)
# pylint: disable=unused-argument # pylint: disable=unused-argument
def add_statuses_on_follow(sender, instance, created, *args, **kwargs): 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: if not created or not instance.user_subject.local:
return return
HomeStream().add_user_statuses(instance.user_subject, instance.user_object) 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) @receiver(signals.post_delete, sender=models.UserFollows)
# pylint: disable=unused-argument # pylint: disable=unused-argument
def remove_statuses_on_unfollow(sender, instance, *args, **kwargs): 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: if not instance.user_subject.local:
return return
HomeStream().remove_user_statuses(instance.user_subject, instance.user_object) 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) @receiver(signals.post_save, sender=models.UserBlocks)
# pylint: disable=unused-argument # pylint: disable=unused-argument
def remove_statuses_on_block(sender, instance, *args, **kwargs): 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 # blocks apply ot all feeds
if instance.user_subject.local: if instance.user_subject.local:
for stream in streams.values(): 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) @receiver(signals.post_delete, sender=models.UserBlocks)
# pylint: disable=unused-argument # pylint: disable=unused-argument
def add_statuses_on_unblock(sender, instance, *args, **kwargs): 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()] public_streams = [LocalStream(), FederatedStream()]
# add statuses back to streams with statuses from anyone # add statuses back to streams with statuses from anyone
if instance.user_subject.local: 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) @receiver(signals.post_save, sender=models.User)
# pylint: disable=unused-argument # pylint: disable=unused-argument
def populate_streams_on_account_create(sender, instance, created, *args, **kwargs): 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: if not created or not instance.local:
return return

View file

@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
class AbstractMinimalConnector(ABC): class AbstractMinimalConnector(ABC):
""" just the bare bones, for other bookwyrm instances """ """just the bare bones, for other bookwyrm instances"""
def __init__(self, identifier): def __init__(self, identifier):
# load connector settings # load connector settings
@ -39,7 +39,7 @@ class AbstractMinimalConnector(ABC):
setattr(self, field, getattr(info, field)) setattr(self, field, getattr(info, field))
def search(self, query, min_confidence=None): def search(self, query, min_confidence=None):
""" free text search """ """free text search"""
params = {} params = {}
if min_confidence: if min_confidence:
params["min_confidence"] = min_confidence params["min_confidence"] = min_confidence
@ -55,7 +55,7 @@ class AbstractMinimalConnector(ABC):
return results return results
def isbn_search(self, query): def isbn_search(self, query):
""" isbn search """ """isbn search"""
params = {} params = {}
data = get_data( data = get_data(
"%s%s" % (self.isbn_search_url, query), "%s%s" % (self.isbn_search_url, query),
@ -70,27 +70,27 @@ class AbstractMinimalConnector(ABC):
@abstractmethod @abstractmethod
def get_or_create_book(self, remote_id): 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 @abstractmethod
def parse_search_data(self, data): 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 @abstractmethod
def format_search_result(self, search_result): def format_search_result(self, search_result):
""" create a SearchResult obj from json """ """create a SearchResult obj from json"""
@abstractmethod @abstractmethod
def parse_isbn_search_data(self, data): 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 @abstractmethod
def format_isbn_search_result(self, search_result): def format_isbn_search_result(self, search_result):
""" create a SearchResult obj from json """ """create a SearchResult obj from json"""
class AbstractConnector(AbstractMinimalConnector): class AbstractConnector(AbstractMinimalConnector):
""" generic book data connector """ """generic book data connector"""
def __init__(self, identifier): def __init__(self, identifier):
super().__init__(identifier) super().__init__(identifier)
@ -99,14 +99,14 @@ class AbstractConnector(AbstractMinimalConnector):
self.book_mappings = [] self.book_mappings = []
def is_available(self): 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.max_query_count is not None:
if self.connector.query_count >= self.max_query_count: if self.connector.query_count >= self.max_query_count:
return False return False
return True return True
def get_or_create_book(self, remote_id): 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 # first, check if we have the origin_id saved
existing = models.Edition.find_existing_by_remote_id( existing = models.Edition.find_existing_by_remote_id(
remote_id remote_id
@ -151,7 +151,7 @@ class AbstractConnector(AbstractMinimalConnector):
return edition return edition
def create_edition_from_data(self, work, edition_data): 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 = dict_from_mappings(edition_data, self.book_mappings)
mapped_data["work"] = work.remote_id mapped_data["work"] = work.remote_id
edition_activity = activitypub.Edition(**mapped_data) edition_activity = activitypub.Edition(**mapped_data)
@ -171,7 +171,7 @@ class AbstractConnector(AbstractMinimalConnector):
return edition return edition
def get_or_create_author(self, remote_id): def get_or_create_author(self, remote_id):
""" load that author """ """load that author"""
existing = models.Author.find_existing_by_remote_id(remote_id) existing = models.Author.find_existing_by_remote_id(remote_id)
if existing: if existing:
return existing return existing
@ -189,23 +189,23 @@ class AbstractConnector(AbstractMinimalConnector):
@abstractmethod @abstractmethod
def is_work_data(self, data): def is_work_data(self, data):
""" differentiate works and editions """ """differentiate works and editions"""
@abstractmethod @abstractmethod
def get_edition_from_work_data(self, data): def get_edition_from_work_data(self, data):
""" every work needs at least one edition """ """every work needs at least one edition"""
@abstractmethod @abstractmethod
def get_work_from_edition_data(self, data): def get_work_from_edition_data(self, data):
""" every edition needs a work """ """every edition needs a work"""
@abstractmethod @abstractmethod
def get_authors_from_data(self, data): def get_authors_from_data(self, data):
""" load author data """ """load author data"""
@abstractmethod @abstractmethod
def expand_book_data(self, book): def expand_book_data(self, book):
""" get more info on a book """ """get more info on a book"""
def dict_from_mappings(data, mappings): def dict_from_mappings(data, mappings):
@ -218,7 +218,7 @@ def dict_from_mappings(data, mappings):
def get_data(url, params=None): def get_data(url, params=None):
""" wrapper for request.get """ """wrapper for request.get"""
# check if the url is blocked # check if the url is blocked
if models.FederatedServer.is_blocked(url): if models.FederatedServer.is_blocked(url):
raise ConnectorException( raise ConnectorException(
@ -250,7 +250,7 @@ def get_data(url, params=None):
def get_image(url): def get_image(url):
""" wrapper for requesting an image """ """wrapper for requesting an image"""
try: try:
resp = requests.get( resp = requests.get(
url, url,
@ -268,7 +268,7 @@ def get_image(url):
@dataclass @dataclass
class SearchResult: class SearchResult:
""" standardized search result object """ """standardized search result object"""
title: str title: str
key: str key: str
@ -284,14 +284,14 @@ class SearchResult:
) )
def json(self): def json(self):
""" serialize a connector for json response """ """serialize a connector for json response"""
serialized = asdict(self) serialized = asdict(self)
del serialized["connector"] del serialized["connector"]
return serialized return serialized
class Mapping: 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): def __init__(self, local_field, remote_field=None, formatter=None):
noop = lambda x: x noop = lambda x: x
@ -301,7 +301,7 @@ class Mapping:
self.formatter = formatter or noop self.formatter = formatter or noop
def get_value(self, data): 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) value = data.get(self.remote_field)
if not value: if not value:
return None return None

View file

@ -4,7 +4,7 @@ from .abstract_connector import AbstractMinimalConnector, SearchResult
class Connector(AbstractMinimalConnector): class Connector(AbstractMinimalConnector):
""" this is basically just for search """ """this is basically just for search"""
def get_or_create_book(self, remote_id): def get_or_create_book(self, remote_id):
edition = activitypub.resolve_remote_id(remote_id, model=models.Edition) edition = activitypub.resolve_remote_id(remote_id, model=models.Edition)

View file

@ -16,11 +16,11 @@ logger = logging.getLogger(__name__)
class ConnectorException(HTTPError): 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): def search(query, min_confidence=0.1):
""" find books based on arbitary keywords """ """find books based on arbitary keywords"""
if not query: if not query:
return [] return []
results = [] results = []
@ -68,19 +68,19 @@ def search(query, min_confidence=0.1):
def local_search(query, min_confidence=0.1, raw=False): 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)) connector = load_connector(models.Connector.objects.get(local=True))
return connector.search(query, min_confidence=min_confidence, raw=raw) return connector.search(query, min_confidence=min_confidence, raw=raw)
def isbn_local_search(query, raw=False): 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)) connector = load_connector(models.Connector.objects.get(local=True))
return connector.isbn_search(query, raw=raw) return connector.isbn_search(query, raw=raw)
def first_search_result(query, min_confidence=0.1): 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(): for connector in get_connectors():
result = connector.search(query, min_confidence=min_confidence) result = connector.search(query, min_confidence=min_confidence)
if result: if result:
@ -89,13 +89,13 @@ def first_search_result(query, min_confidence=0.1):
def get_connectors(): def get_connectors():
""" load all connectors """ """load all connectors"""
for info in models.Connector.objects.order_by("priority").all(): for info in models.Connector.objects.order_by("priority").all():
yield load_connector(info) yield load_connector(info)
def get_or_create_connector(remote_id): 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) url = urlparse(remote_id)
identifier = url.netloc identifier = url.netloc
if not identifier: if not identifier:
@ -119,7 +119,7 @@ def get_or_create_connector(remote_id):
@app.task @app.task
def load_more_data(connector_id, book_id): 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_info = models.Connector.objects.get(id=connector_id)
connector = load_connector(connector_info) connector = load_connector(connector_info)
book = models.Book.objects.select_subclasses().get(id=book_id) 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): def load_connector(connector_info):
""" instantiate the connector class """ """instantiate the connector class"""
connector = importlib.import_module( connector = importlib.import_module(
"bookwyrm.connectors.%s" % connector_info.connector_file "bookwyrm.connectors.%s" % connector_info.connector_file
) )
@ -137,6 +137,6 @@ def load_connector(connector_info):
@receiver(signals.post_save, sender="bookwyrm.FederatedServer") @receiver(signals.post_save, sender="bookwyrm.FederatedServer")
# pylint: disable=unused-argument # pylint: disable=unused-argument
def create_connector(sender, instance, created, *args, **kwargs): 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": if instance.application_type == "bookwyrm":
get_or_create_connector("https://{:s}".format(instance.server_name)) get_or_create_connector("https://{:s}".format(instance.server_name))

View file

@ -9,7 +9,7 @@ from .openlibrary_languages import languages
class Connector(AbstractConnector): class Connector(AbstractConnector):
""" instantiate a connector for OL """ """instantiate a connector for OL"""
def __init__(self, identifier): def __init__(self, identifier):
super().__init__(identifier) super().__init__(identifier)
@ -59,7 +59,7 @@ class Connector(AbstractConnector):
] ]
def get_remote_id_from_data(self, data): 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: try:
key = data["key"] key = data["key"]
except KeyError: except KeyError:
@ -87,7 +87,7 @@ class Connector(AbstractConnector):
return get_data(url) return get_data(url)
def get_authors_from_data(self, data): 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", []): for author_blob in data.get("authors", []):
author_blob = author_blob.get("author", author_blob) author_blob = author_blob.get("author", author_blob)
# this id is "/authors/OL1234567A" # this id is "/authors/OL1234567A"
@ -99,7 +99,7 @@ class Connector(AbstractConnector):
yield author yield author
def get_cover_url(self, cover_blob, size="L"): def get_cover_url(self, cover_blob, size="L"):
""" ask openlibrary for the cover """ """ask openlibrary for the cover"""
if not cover_blob: if not cover_blob:
return None return None
cover_id = cover_blob[0] cover_id = cover_blob[0]
@ -141,7 +141,7 @@ class Connector(AbstractConnector):
) )
def load_edition_data(self, olkey): 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) url = "%s/works/%s/editions" % (self.books_url, olkey)
return get_data(url) return get_data(url)
@ -166,7 +166,7 @@ class Connector(AbstractConnector):
def ignore_edition(edition_data): 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 # an isbn, we love to see it
if edition_data.get("isbn_13") or edition_data.get("isbn_10"): if edition_data.get("isbn_13") or edition_data.get("isbn_10"):
return False return False
@ -185,19 +185,19 @@ def ignore_edition(edition_data):
def get_description(description_blob): 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): if isinstance(description_blob, dict):
return description_blob.get("value") return description_blob.get("value")
return description_blob return description_blob
def get_openlibrary_key(key): def get_openlibrary_key(key):
""" convert /books/OL27320736M into OL27320736M """ """convert /books/OL27320736M into OL27320736M"""
return key.split("/")[-1] return key.split("/")[-1]
def get_languages(language_blob): def get_languages(language_blob):
""" /language/eng -> English """ """/language/eng -> English"""
langs = [] langs = []
for lang in language_blob: for lang in language_blob:
langs.append(languages.get(lang.get("key", ""), None)) langs.append(languages.get(lang.get("key", ""), None))
@ -205,7 +205,7 @@ def get_languages(language_blob):
def pick_default_edition(options): def pick_default_edition(options):
""" favor physical copies with covers in english """ """favor physical copies with covers in english"""
if not options: if not options:
return None return None
if len(options) == 1: if len(options) == 1:

View file

@ -10,11 +10,11 @@ from .abstract_connector import AbstractConnector, SearchResult
class Connector(AbstractConnector): class Connector(AbstractConnector):
""" instantiate a connector """ """instantiate a connector"""
# pylint: disable=arguments-differ # pylint: disable=arguments-differ
def search(self, query, min_confidence=0.1, raw=False): def search(self, query, min_confidence=0.1, raw=False):
""" search your local database """ """search your local database"""
if not query: if not query:
return [] return []
# first, try searching unqiue identifiers # first, try searching unqiue identifiers
@ -35,7 +35,7 @@ class Connector(AbstractConnector):
return search_results return search_results
def isbn_search(self, query, raw=False): def isbn_search(self, query, raw=False):
""" search your local database """ """search your local database"""
if not query: if not query:
return [] return []
@ -87,11 +87,11 @@ class Connector(AbstractConnector):
return None return None
def parse_isbn_search_data(self, data): 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 return data
def parse_search_data(self, 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 return data
def expand_book_data(self, book): def expand_book_data(self, book):
@ -99,7 +99,7 @@ class Connector(AbstractConnector):
def search_identifiers(query): 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 = [ filters = [
{f.name: query} {f.name: query}
for f in models.Edition._meta.get_fields() for f in models.Edition._meta.get_fields()
@ -115,7 +115,7 @@ def search_identifiers(query):
def search_title_author(query, min_confidence): def search_title_author(query, min_confidence):
""" searches for title and author """ """searches for title and author"""
vector = ( vector = (
SearchVector("title", weight="A") SearchVector("title", weight="A")
+ SearchVector("subtitle", weight="B") + SearchVector("subtitle", weight="B")

View file

@ -3,5 +3,5 @@ from bookwyrm import models
def site_settings(request): # pylint: disable=unused-argument 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()} return {"site": models.SiteSettings.objects.get()}

View file

@ -8,7 +8,7 @@ from bookwyrm.settings import DOMAIN
def email_data(): def email_data():
""" fields every email needs """ """fields every email needs"""
site = models.SiteSettings.objects.get() site = models.SiteSettings.objects.get()
if site.logo_small: if site.logo_small:
logo_path = "/images/{}".format(site.logo_small.url) logo_path = "/images/{}".format(site.logo_small.url)
@ -24,14 +24,14 @@ def email_data():
def invite_email(invite_request): def invite_email(invite_request):
""" send out an invite code """ """send out an invite code"""
data = email_data() data = email_data()
data["invite_link"] = invite_request.invite.link data["invite_link"] = invite_request.invite.link
send_email.delay(invite_request.email, *format_email("invite", data)) send_email.delay(invite_request.email, *format_email("invite", data))
def password_reset_email(reset_code): def password_reset_email(reset_code):
""" generate a password reset email """ """generate a password reset email"""
data = email_data() data = email_data()
data["reset_link"] = reset_code.link data["reset_link"] = reset_code.link
data["user"] = reset_code.user.display_name data["user"] = reset_code.user.display_name
@ -39,7 +39,7 @@ def password_reset_email(reset_code):
def format_email(email_name, data): def format_email(email_name, data):
""" render the email templates """ """render the email templates"""
subject = ( subject = (
get_template("email/{}/subject.html".format(email_name)).render(data).strip() get_template("email/{}/subject.html".format(email_name)).render(data).strip()
) )
@ -58,7 +58,7 @@ def format_email(email_name, data):
@app.task @app.task
def send_email(recipient, subject, html_content, text_content): 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( email = EmailMultiAlternatives(
subject, text_content, settings.DEFAULT_FROM_EMAIL, [recipient] subject, text_content, settings.DEFAULT_FROM_EMAIL, [recipient]
) )

View file

@ -3,7 +3,7 @@ import datetime
from collections import defaultdict from collections import defaultdict
from django import forms 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.forms.widgets import Textarea
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -12,7 +12,7 @@ from bookwyrm import models
class CustomForm(ModelForm): class CustomForm(ModelForm):
""" add css classes to the forms """ """add css classes to the forms"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
css_classes = defaultdict(lambda: "") css_classes = defaultdict(lambda: "")
@ -150,12 +150,10 @@ class LimitedEditUserForm(CustomForm):
help_texts = {f: None for f in fields} help_texts = {f: None for f in fields}
class TagForm(CustomForm): class UserGroupForm(CustomForm):
class Meta: class Meta:
model = models.Tag model = models.User
fields = ["name"] fields = ["groups"]
help_texts = {f: None for f in fields}
labels = {"name": "Add a tag"}
class CoverForm(CustomForm): class CoverForm(CustomForm):
@ -200,7 +198,7 @@ class ImportForm(forms.Form):
class ExpiryWidget(widgets.Select): class ExpiryWidget(widgets.Select):
def value_from_datadict(self, data, files, name): 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) selected_string = super().value_from_datadict(data, files, name)
if selected_string == "day": if selected_string == "day":
@ -219,7 +217,7 @@ class ExpiryWidget(widgets.Select):
class InviteRequestForm(CustomForm): class InviteRequestForm(CustomForm):
def clean(self): 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() cleaned_data = super().clean()
email = cleaned_data.get("email") email = cleaned_data.get("email")
if email and models.User.objects.filter(email=email).exists(): if email and models.User.objects.filter(email=email).exists():
@ -287,3 +285,20 @@ class ServerForm(CustomForm):
class Meta: class Meta:
model = models.FederatedServer model = models.FederatedServer
exclude = ["remote_id"] 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")),
),
)

View file

@ -9,7 +9,7 @@ class GoodreadsImporter(Importer):
service = "GoodReads" service = "GoodReads"
def parse_fields(self, entry): 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}) entry.update({"import_source": self.service})
# add missing 'Date Started' field # add missing 'Date Started' field
entry.update({"Date Started": None}) entry.update({"Date Started": None})

View file

@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
class Importer: class Importer:
""" Generic class for csv data import from an outside service """ """Generic class for csv data import from an outside service"""
service = "Unknown" service = "Unknown"
delimiter = "," delimiter = ","
@ -18,7 +18,7 @@ class Importer:
mandatory_fields = ["Title", "Author"] mandatory_fields = ["Title", "Author"]
def create_job(self, user, csv_file, include_reviews, privacy): 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( job = ImportJob.objects.create(
user=user, include_reviews=include_reviews, privacy=privacy user=user, include_reviews=include_reviews, privacy=privacy
) )
@ -32,16 +32,16 @@ class Importer:
return job return job
def save_item(self, job, index, data): # pylint: disable=no-self-use 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() ImportItem(job=job, index=index, data=data).save()
def parse_fields(self, entry): def parse_fields(self, entry):
""" updates csv data with additional info """ """updates csv data with additional info"""
entry.update({"import_source": self.service}) entry.update({"import_source": self.service})
return entry return entry
def create_retry_job(self, user, original_job, items): 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( job = ImportJob.objects.create(
user=user, user=user,
include_reviews=original_job.include_reviews, include_reviews=original_job.include_reviews,
@ -53,7 +53,7 @@ class Importer:
return job return job
def start_import(self, job): def start_import(self, job):
""" initalizes a csv import job """ """initalizes a csv import job"""
result = import_data.delay(self.service, job.id) result = import_data.delay(self.service, job.id)
job.task_id = result.id job.task_id = result.id
job.save() job.save()
@ -61,7 +61,7 @@ class Importer:
@app.task @app.task
def import_data(source, job_id): 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) job = ImportJob.objects.get(id=job_id)
try: try:
for item in job.items.all(): 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): 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): if isinstance(item.book, models.Work):
item.book = item.book.default_edition item.book = item.book.default_edition
if not item.book: if not item.book:

View file

@ -6,7 +6,7 @@ from . import Importer
class LibrarythingImporter(Importer): class LibrarythingImporter(Importer):
""" csv downloads from librarything """ """csv downloads from librarything"""
service = "LibraryThing" service = "LibraryThing"
delimiter = "\t" delimiter = "\t"
@ -15,7 +15,7 @@ class LibrarythingImporter(Importer):
mandatory_fields = ["Title", "Primary Author"] mandatory_fields = ["Title", "Primary Author"]
def parse_fields(self, entry): def parse_fields(self, entry):
""" custom parsing for librarything """ """custom parsing for librarything"""
data = {} data = {}
data["import_source"] = self.service data["import_source"] = self.service
data["Book Id"] = entry["Book Id"] data["Book Id"] = entry["Book Id"]

View file

@ -6,7 +6,7 @@ from bookwyrm import models
def update_related(canonical, obj): 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 # move related models to canonical
related_models = [ related_models = [
(r.remote_field.name, r.related_model) for r in canonical._meta.related_objects (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): 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(): for data_field in obj._meta.get_fields():
if not hasattr(data_field, "activitypub_field"): if not hasattr(data_field, "activitypub_field"):
continue continue
@ -38,7 +38,7 @@ def copy_data(canonical, obj):
def dedupe_model(model): def dedupe_model(model):
""" combine duplicate editions and update related models """ """combine duplicate editions and update related models"""
fields = model._meta.get_fields() fields = model._meta.get_fields()
dedupe_fields = [ dedupe_fields = [
f for f in fields if hasattr(f, "deduplication_field") and f.deduplication_field 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): class Command(BaseCommand):
""" dedplucate allllll the book data models """ """dedplucate allllll the book data models"""
help = "merges duplicate book data" help = "merges duplicate book data"
# pylint: disable=no-self-use,unused-argument # pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options): def handle(self, *args, **options):
""" run deudplications """ """run deudplications"""
dedupe_model(models.Edition) dedupe_model(models.Edition)
dedupe_model(models.Work) dedupe_model(models.Work)
dedupe_model(models.Author) dedupe_model(models.Author)

View file

@ -10,15 +10,15 @@ r = redis.Redis(
def erase_streams(): def erase_streams():
""" throw the whole redis away """ """throw the whole redis away"""
r.flushall() r.flushall()
class Command(BaseCommand): class Command(BaseCommand):
""" delete activity streams for all users """ """delete activity streams for all users"""
help = "Delete all the user streams" help = "Delete all the user streams"
# pylint: disable=no-self-use,unused-argument # pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options): def handle(self, *args, **options):
""" flush all, baby """ """flush all, baby"""
erase_streams() erase_streams()

View file

@ -108,7 +108,7 @@ def init_connectors():
def init_federated_servers(): def init_federated_servers():
""" big no to nazis """ """big no to nazis"""
built_in_blocks = ["gab.ai", "gab.com"] built_in_blocks = ["gab.ai", "gab.com"]
for server in built_in_blocks: for server in built_in_blocks:
FederatedServer.objects.create( FederatedServer.objects.create(

View file

@ -10,7 +10,7 @@ r = redis.Redis(
def populate_streams(): def populate_streams():
""" build all the streams for all the users """ """build all the streams for all the users"""
users = models.User.objects.filter( users = models.User.objects.filter(
local=True, local=True,
is_active=True, is_active=True,
@ -21,10 +21,10 @@ def populate_streams():
class Command(BaseCommand): class Command(BaseCommand):
""" start all over with user streams """ """start all over with user streams"""
help = "Populate streams for all users" help = "Populate streams for all users"
# pylint: disable=no-self-use,unused-argument # pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options): def handle(self, *args, **options):
""" run feed builder """ """run feed builder"""
populate_streams() populate_streams()

View file

@ -5,7 +5,7 @@ from bookwyrm import models
def remove_editions(): def remove_editions():
""" combine duplicate editions and update related models """ """combine duplicate editions and update related models"""
# not in use # not in use
filters = { filters = {
"%s__isnull" % r.name: True for r in models.Edition._meta.related_objects "%s__isnull" % r.name: True for r in models.Edition._meta.related_objects
@ -33,10 +33,10 @@ def remove_editions():
class Command(BaseCommand): class Command(BaseCommand):
""" dedplucate allllll the book data models """ """dedplucate allllll the book data models"""
help = "merges duplicate book data" help = "merges duplicate book data"
# pylint: disable=no-self-use,unused-argument # pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options): def handle(self, *args, **options):
""" run deudplications """ """run deudplications"""
remove_editions() remove_editions()

View file

@ -8,7 +8,7 @@ from psycopg2.extras import execute_values
def convert_review_rating(app_registry, schema_editor): 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 db_alias = schema_editor.connection.alias
reviews = ( reviews = (
@ -29,7 +29,7 @@ VALUES %s""",
def unconvert_review_rating(app_registry, schema_editor): 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 # 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 # 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 # no-op function so Django will do its thing

View file

@ -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)]

View file

@ -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")},
),
]

View file

@ -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,
),
),
]

View file

@ -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",
),
]

View file

@ -17,8 +17,6 @@ from .favorite import Favorite
from .notification import Notification from .notification import Notification
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
from .tag import Tag, UserTag
from .user import User, KeyPair, AnnualGoal from .user import User, KeyPair, AnnualGoal
from .relationship import UserFollows, UserFollowRequest, UserBlocks from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .report import Report, ReportComment from .report import Report, ReportComment

View file

@ -31,18 +31,18 @@ PropertyField = namedtuple("PropertyField", ("set_activity_from_field"))
def set_activity_from_property_field(activity, obj, 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]) activity[field[1]] = getattr(obj, field[0])
class ActivitypubMixin: class ActivitypubMixin:
""" add this mixin for models that are AP serializable """ """add this mixin for models that are AP serializable"""
activity_serializer = lambda: {} activity_serializer = lambda: {}
reverse_unfurl = False reverse_unfurl = False
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
""" collect some info on model fields """ """collect some info on model fields"""
self.image_fields = [] self.image_fields = []
self.many_to_many_fields = [] self.many_to_many_fields = []
self.simple_fields = [] # "simple" self.simple_fields = [] # "simple"
@ -85,7 +85,7 @@ class ActivitypubMixin:
@classmethod @classmethod
def find_existing_by_remote_id(cls, remote_id): 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}) return cls.find_existing({"id": remote_id})
@classmethod @classmethod
@ -126,7 +126,7 @@ class ActivitypubMixin:
return match.first() return match.first()
def broadcast(self, activity, sender, software=None): def broadcast(self, activity, sender, software=None):
""" send out an activity """ """send out an activity"""
broadcast_task.delay( broadcast_task.delay(
sender.id, sender.id,
json.dumps(activity, cls=activitypub.ActivityEncoder), json.dumps(activity, cls=activitypub.ActivityEncoder),
@ -134,7 +134,7 @@ class ActivitypubMixin:
) )
def get_recipients(self, software=None): 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 # first we have to figure out who should receive this activity
privacy = self.privacy if hasattr(self, "privacy") else "public" privacy = self.privacy if hasattr(self, "privacy") else "public"
# is this activity owned by a user (statuses, lists, shelves), or is it # 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 [] mentions = self.recipients if hasattr(self, "recipients") else []
# we always send activities to explicitly mentioned users' inboxes # 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 # unless it's a dm, all the followers should receive the activity
if privacy != "direct": if privacy != "direct":
# we will send this out to a subset of all remote users # we will send this out to a subset of all remote users
queryset = user_model.viewer_aware_objects(user).filter( queryset = (
local=False, user_model.viewer_aware_objects(user)
.filter(
local=False,
)
.distinct()
) )
# filter users first by whether they're using the desired software # filter users first by whether they're using the desired software
# this lets us send book updates only to other bw servers # this lets us send book updates only to other bw servers
@ -175,23 +179,23 @@ class ActivitypubMixin:
"inbox", flat=True "inbox", flat=True
) )
recipients += list(shared_inboxes) + list(inboxes) recipients += list(shared_inboxes) + list(inboxes)
return recipients return list(set(recipients))
def to_activity_dataclass(self): def to_activity_dataclass(self):
""" convert from a model to an activity """ """convert from a model to an activity"""
activity = generate_activity(self) activity = generate_activity(self)
return self.activity_serializer(**activity) return self.activity_serializer(**activity)
def to_activity(self, **kwargs): # pylint: disable=unused-argument 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() return self.to_activity_dataclass().serialize()
class ObjectMixin(ActivitypubMixin): 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): 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) broadcast = kwargs.get("broadcast", True)
# this bonus kwarg would cause an error in the base save method # this bonus kwarg would cause an error in the base save method
if "broadcast" in kwargs: if "broadcast" in kwargs:
@ -200,7 +204,9 @@ class ObjectMixin(ActivitypubMixin):
created = created or not bool(self.id) created = created or not bool(self.id)
# first off, we want to save normally no matter what # first off, we want to save normally no matter what
super().save(*args, **kwargs) super().save(*args, **kwargs)
if not broadcast: if not broadcast or (
hasattr(self, "status_type") and self.status_type == "Announce"
):
return return
# this will work for objects owned by a user (lists, shelves) # this will work for objects owned by a user (lists, shelves)
@ -248,7 +254,7 @@ class ObjectMixin(ActivitypubMixin):
self.broadcast(activity, user) self.broadcast(activity, user)
def to_create_activity(self, user, **kwargs): 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) activity_object = self.to_activity_dataclass(**kwargs)
signature = None signature = None
@ -274,7 +280,7 @@ class ObjectMixin(ActivitypubMixin):
).serialize() ).serialize()
def to_delete_activity(self, user): def to_delete_activity(self, user):
""" notice of deletion """ """notice of deletion"""
return activitypub.Delete( return activitypub.Delete(
id=self.remote_id + "/activity", id=self.remote_id + "/activity",
actor=user.remote_id, actor=user.remote_id,
@ -284,7 +290,7 @@ class ObjectMixin(ActivitypubMixin):
).serialize() ).serialize()
def to_update_activity(self, user): 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()) activity_id = "%s#update/%s" % (self.remote_id, uuid4())
return activitypub.Update( return activitypub.Update(
id=activity_id, id=activity_id,
@ -300,13 +306,13 @@ class OrderedCollectionPageMixin(ObjectMixin):
@property @property
def collection_remote_id(self): 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 return self.remote_id
def to_ordered_collection( def to_ordered_collection(
self, queryset, remote_id=None, page=False, collection_only=False, **kwargs 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: if not queryset.ordered:
raise RuntimeError("queryset must be ordered") raise RuntimeError("queryset must be ordered")
@ -335,11 +341,11 @@ class OrderedCollectionPageMixin(ObjectMixin):
class OrderedCollectionMixin(OrderedCollectionPageMixin): class OrderedCollectionMixin(OrderedCollectionPageMixin):
""" extends activitypub models to work as ordered collections """ """extends activitypub models to work as ordered collections"""
@property @property
def collection_queryset(self): 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") raise NotImplementedError("Model must define collection_queryset")
activity_serializer = activitypub.OrderedCollection activity_serializer = activitypub.OrderedCollection
@ -348,24 +354,24 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin):
return self.to_ordered_collection(self.collection_queryset, **kwargs) return self.to_ordered_collection(self.collection_queryset, **kwargs)
def to_activity(self, **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( return self.to_ordered_collection(
self.collection_queryset, **kwargs self.collection_queryset, **kwargs
).serialize() ).serialize()
class CollectionItemMixin(ActivitypubMixin): 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 activity_serializer = activitypub.CollectionItem
def broadcast(self, activity, sender, software="bookwyrm"): 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) super().broadcast(activity, sender, software=software)
@property @property
def privacy(self): 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) collection_field = getattr(self, self.collection_field)
if self.approved: if self.approved:
return collection_field.privacy return collection_field.privacy
@ -373,7 +379,7 @@ class CollectionItemMixin(ActivitypubMixin):
@property @property
def recipients(self): 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) collection_field = getattr(self, self.collection_field)
if collection_field.user.local: if collection_field.user.local:
# don't broadcast to yourself # don't broadcast to yourself
@ -381,7 +387,7 @@ class CollectionItemMixin(ActivitypubMixin):
return [collection_field.user] return [collection_field.user]
def save(self, *args, broadcast=True, **kwargs): def save(self, *args, broadcast=True, **kwargs):
""" broadcast updated """ """broadcast updated"""
# first off, we want to save normally no matter what # first off, we want to save normally no matter what
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -394,14 +400,14 @@ class CollectionItemMixin(ActivitypubMixin):
self.broadcast(activity, self.user) self.broadcast(activity, self.user)
def delete(self, *args, broadcast=True, **kwargs): def delete(self, *args, broadcast=True, **kwargs):
""" broadcast a remove activity """ """broadcast a remove activity"""
activity = self.to_remove_activity(self.user) activity = self.to_remove_activity(self.user)
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
if self.user.local and broadcast: if self.user.local and broadcast:
self.broadcast(activity, self.user) self.broadcast(activity, self.user)
def to_add_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) collection_field = getattr(self, self.collection_field)
return activitypub.Add( return activitypub.Add(
id="{:s}#add".format(collection_field.remote_id), id="{:s}#add".format(collection_field.remote_id),
@ -411,7 +417,7 @@ class CollectionItemMixin(ActivitypubMixin):
).serialize() ).serialize()
def to_remove_activity(self, user): 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) collection_field = getattr(self, self.collection_field)
return activitypub.Remove( return activitypub.Remove(
id="{:s}#remove".format(collection_field.remote_id), id="{:s}#remove".format(collection_field.remote_id),
@ -422,24 +428,24 @@ class CollectionItemMixin(ActivitypubMixin):
class ActivityMixin(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): def save(self, *args, broadcast=True, **kwargs):
""" broadcast activity """ """broadcast activity"""
super().save(*args, **kwargs) super().save(*args, **kwargs)
user = self.user if hasattr(self, "user") else self.user_subject user = self.user if hasattr(self, "user") else self.user_subject
if broadcast and user.local: if broadcast and user.local:
self.broadcast(self.to_activity(), user) self.broadcast(self.to_activity(), user)
def delete(self, *args, broadcast=True, **kwargs): 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 user = self.user if hasattr(self, "user") else self.user_subject
if broadcast and user.local: if broadcast and user.local:
self.broadcast(self.to_undo_activity(), user) self.broadcast(self.to_undo_activity(), user)
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
def to_undo_activity(self): def to_undo_activity(self):
""" undo an action """ """undo an action"""
user = self.user if hasattr(self, "user") else self.user_subject user = self.user if hasattr(self, "user") else self.user_subject
return activitypub.Undo( return activitypub.Undo(
id="%s#undo" % self.remote_id, id="%s#undo" % self.remote_id,
@ -449,7 +455,7 @@ class ActivityMixin(ActivitypubMixin):
def generate_activity(obj): def generate_activity(obj):
""" go through the fields on an object """ """go through the fields on an object"""
activity = {} activity = {}
for field in obj.activity_fields: for field in obj.activity_fields:
field.set_activity_from_field(activity, obj) field.set_activity_from_field(activity, obj)
@ -472,7 +478,7 @@ def generate_activity(obj):
def unfurl_related_field(related_field, sort_field=None): 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"): if sort_field and hasattr(related_field, "all"):
return [ return [
unfurl_related_field(i) for i in related_field.order_by(sort_field).all() 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 @app.task
def broadcast_task(sender_id, activity, recipients): 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) user_model = apps.get_model("bookwyrm.User", require_ready=True)
sender = user_model.objects.get(id=sender_id) sender = user_model.objects.get(id=sender_id)
for recipient in recipients: for recipient in recipients:
@ -499,7 +505,7 @@ def broadcast_task(sender_id, activity, recipients):
def sign_and_send(sender, data, destination): def sign_and_send(sender, data, destination):
""" crpyto whatever and http junk """ """crpyto whatever and http junk"""
now = http_date() now = http_date()
if not sender.key_pair.private_key: if not sender.key_pair.private_key:
@ -528,7 +534,7 @@ def sign_and_send(sender, data, destination):
def to_ordered_collection_page( def to_ordered_collection_page(
queryset, remote_id, id_only=False, page=1, pure=False, **kwargs 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) paginated = Paginator(queryset, PAGE_LENGTH)
activity_page = paginated.get_page(page) activity_page = paginated.get_page(page)

View file

@ -8,7 +8,7 @@ from . import fields
class Attachment(ActivitypubMixin, BookWyrmModel): 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 = models.ForeignKey(
"Status", on_delete=models.CASCADE, related_name="attachments", null=True "Status", on_delete=models.CASCADE, related_name="attachments", null=True
@ -16,13 +16,13 @@ class Attachment(ActivitypubMixin, BookWyrmModel):
reverse_unfurl = True reverse_unfurl = True
class Meta: 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 abstract = True
class Image(Attachment): class Image(Attachment):
""" an image attachment """ """an image attachment"""
image = fields.ImageField( image = fields.ImageField(
upload_to="status/", upload_to="status/",

View file

@ -9,7 +9,7 @@ from . import fields
class Author(BookDataModel): class Author(BookDataModel):
""" basic biographic info """ """basic biographic info"""
wikipedia_link = fields.CharField( wikipedia_link = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True max_length=255, blank=True, null=True, deduplication_field=True
@ -24,7 +24,7 @@ class Author(BookDataModel):
bio = fields.HtmlField(null=True, blank=True) bio = fields.HtmlField(null=True, blank=True)
def get_remote_id(self): 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) return "https://%s/author/%s" % (DOMAIN, self.id)
activity_serializer = activitypub.Author activity_serializer = activitypub.Author

View file

@ -7,14 +7,14 @@ from .fields import RemoteIdField
class BookWyrmModel(models.Model): class BookWyrmModel(models.Model):
""" shared fields """ """shared fields"""
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True) updated_date = models.DateTimeField(auto_now=True)
remote_id = RemoteIdField(null=True, activitypub_field="id") remote_id = RemoteIdField(null=True, activitypub_field="id")
def get_remote_id(self): 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 base_path = "https://%s" % DOMAIN
if hasattr(self, "user"): if hasattr(self, "user"):
base_path = "%s%s" % (base_path, self.user.local_path) 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) return "%s/%s/%d" % (base_path, model_name, self.id)
class Meta: 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 abstract = True
@property @property
def local_path(self): 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, "") return self.get_remote_id().replace("https://%s" % DOMAIN, "")
def visible_to_user(self, viewer): 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 # make sure this is an object with privacy owned by a user
if not hasattr(self, "user") or not hasattr(self, "privacy"): if not hasattr(self, "user") or not hasattr(self, "privacy"):
return None return None
@ -65,7 +65,7 @@ class BookWyrmModel(models.Model):
@receiver(models.signals.post_save) @receiver(models.signals.post_save)
# pylint: disable=unused-argument # pylint: disable=unused-argument
def set_remote_id(sender, instance, created, *args, **kwargs): 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"): if not created or not hasattr(instance, "get_remote_id"):
return return
if not instance.remote_id: if not instance.remote_id:

View file

@ -13,7 +13,7 @@ from . import fields
class BookDataModel(ObjectMixin, BookWyrmModel): 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) origin_id = models.CharField(max_length=255, null=True, blank=True)
openlibrary_key = fields.CharField( openlibrary_key = fields.CharField(
@ -26,15 +26,19 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
max_length=255, blank=True, null=True, deduplication_field=True 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: class Meta:
""" can't initialize this model, that wouldn't make sense """ """can't initialize this model, that wouldn't make sense"""
abstract = True abstract = True
def save(self, *args, **kwargs): 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: if self.id:
self.remote_id = self.get_remote_id() self.remote_id = self.get_remote_id()
else: else:
@ -43,12 +47,12 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
def broadcast(self, activity, sender, software="bookwyrm"): 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) super().broadcast(activity, sender, software=software)
class Book(BookDataModel): 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) connector = models.ForeignKey("Connector", on_delete=models.PROTECT, null=True)
@ -79,17 +83,17 @@ class Book(BookDataModel):
@property @property
def author_text(self): def author_text(self):
""" format a list of authors """ """format a list of authors"""
return ", ".join(a.name for a in self.authors.all()) return ", ".join(a.name for a in self.authors.all())
@property @property
def latest_readthrough(self): def latest_readthrough(self):
""" most recent readthrough activity """ """most recent readthrough activity"""
return self.readthrough_set.order_by("-updated_date").first() return self.readthrough_set.order_by("-updated_date").first()
@property @property
def edition_info(self): def edition_info(self):
""" properties of this edition, as a string """ """properties of this edition, as a string"""
items = [ items = [
self.physical_format if hasattr(self, "physical_format") else None, self.physical_format if hasattr(self, "physical_format") else None,
self.languages[0] + " language" self.languages[0] + " language"
@ -102,20 +106,20 @@ class Book(BookDataModel):
@property @property
def alt_text(self): def alt_text(self):
""" image alt test """ """image alt test"""
text = "%s" % self.title text = "%s" % self.title
if self.edition_info: if self.edition_info:
text += " (%s)" % self.edition_info text += " (%s)" % self.edition_info
return text return text
def save(self, *args, **kwargs): 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): if not isinstance(self, Edition) and not isinstance(self, Work):
raise ValueError("Books should be added as Editions or Works") raise ValueError("Books should be added as Editions or Works")
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
def get_remote_id(self): 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) return "https://%s/book/%d" % (DOMAIN, self.id)
def __repr__(self): def __repr__(self):
@ -127,7 +131,7 @@ class Book(BookDataModel):
class Work(OrderedCollectionPageMixin, Book): 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 # library of congress catalog control number
lccn = fields.CharField( lccn = fields.CharField(
@ -139,19 +143,19 @@ class Work(OrderedCollectionPageMixin, Book):
) )
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" set some fields on the edition object """ """set some fields on the edition object"""
# set rank # set rank
for edition in self.editions.all(): for edition in self.editions.all():
edition.save() edition.save()
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
def get_default_edition(self): 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() return self.default_edition or self.editions.order_by("-edition_rank").first()
@transaction.atomic() @transaction.atomic()
def reset_default_edition(self): 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 self.default_edition = None
# editions are re-ranked implicitly # editions are re-ranked implicitly
self.save() self.save()
@ -159,11 +163,11 @@ class Work(OrderedCollectionPageMixin, Book):
self.save() self.save()
def to_edition_list(self, **kwargs): def to_edition_list(self, **kwargs):
""" an ordered collection of editions """ """an ordered collection of editions"""
return self.to_ordered_collection( return self.to_ordered_collection(
self.editions.order_by("-edition_rank").all(), self.editions.order_by("-edition_rank").all(),
remote_id="%s/editions" % self.remote_id, remote_id="%s/editions" % self.remote_id,
**kwargs **kwargs,
) )
activity_serializer = activitypub.Work activity_serializer = activitypub.Work
@ -172,7 +176,7 @@ class Work(OrderedCollectionPageMixin, Book):
class Edition(Book): class Edition(Book):
""" an edition of a book """ """an edition of a book"""
# these identifiers only apply to editions, not works # these identifiers only apply to editions, not works
isbn_10 = fields.CharField( isbn_10 = fields.CharField(
@ -211,7 +215,7 @@ class Edition(Book):
name_field = "title" name_field = "title"
def get_rank(self, ignore_default=False): 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 ( if (
not ignore_default not ignore_default
and self.parent_work and self.parent_work
@ -231,7 +235,7 @@ class Edition(Book):
return rank return rank
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" set some fields on the edition object """ """set some fields on the edition object"""
# calculate isbn 10/13 # calculate isbn 10/13
if self.isbn_13 and self.isbn_13[:3] == "978" and not self.isbn_10: 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) self.isbn_10 = isbn_13_to_10(self.isbn_13)
@ -245,7 +249,7 @@ class Edition(Book):
def isbn_10_to_13(isbn_10): 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) isbn_10 = re.sub(r"[^0-9X]", "", isbn_10)
# drop the last character of the isbn 10 number (the original checkdigit) # drop the last character of the isbn 10 number (the original checkdigit)
converted = isbn_10[:9] converted = isbn_10[:9]
@ -267,7 +271,7 @@ def isbn_10_to_13(isbn_10):
def isbn_13_to_10(isbn_13): 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": if isbn_13[:3] != "978":
return None return None

View file

@ -9,7 +9,7 @@ ConnectorFiles = models.TextChoices("ConnectorFiles", CONNECTORS)
class Connector(BookWyrmModel): class Connector(BookWyrmModel):
""" book data source connectors """ """book data source connectors"""
identifier = models.CharField(max_length=255, unique=True) identifier = models.CharField(max_length=255, unique=True)
priority = models.IntegerField(default=2) priority = models.IntegerField(default=2)
@ -32,7 +32,7 @@ class Connector(BookWyrmModel):
query_count_expiry = models.DateTimeField(auto_now_add=True, blank=True) query_count_expiry = models.DateTimeField(auto_now_add=True, blank=True)
class Meta: class Meta:
""" check that there's code to actually use this connector """ """check that there's code to actually use this connector"""
constraints = [ constraints = [
models.CheckConstraint( models.CheckConstraint(

View file

@ -11,7 +11,7 @@ from .status import Status
class Favorite(ActivityMixin, BookWyrmModel): class Favorite(ActivityMixin, BookWyrmModel):
""" fav'ing a post """ """fav'ing a post"""
user = fields.ForeignKey( user = fields.ForeignKey(
"User", on_delete=models.PROTECT, activitypub_field="actor" "User", on_delete=models.PROTECT, activitypub_field="actor"
@ -24,11 +24,11 @@ class Favorite(ActivityMixin, BookWyrmModel):
@classmethod @classmethod
def ignore_activity(cls, activity): 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() return not Status.objects.filter(remote_id=activity.object).exists()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" update user active time """ """update user active time"""
self.user.last_active_date = timezone.now() self.user.last_active_date = timezone.now()
self.user.save(broadcast=False) self.user.save(broadcast=False)
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -45,7 +45,7 @@ class Favorite(ActivityMixin, BookWyrmModel):
) )
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
""" delete and delete notifications """ """delete and delete notifications"""
# check for notification # check for notification
if self.status.user.local: if self.status.user.local:
notification_model = apps.get_model( notification_model = apps.get_model(
@ -62,6 +62,6 @@ class Favorite(ActivityMixin, BookWyrmModel):
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
class Meta: class Meta:
""" can't fav things twice """ """can't fav things twice"""
unique_together = ("user", "status") unique_together = ("user", "status")

View file

@ -13,7 +13,7 @@ FederationStatus = models.TextChoices(
class FederatedServer(BookWyrmModel): class FederatedServer(BookWyrmModel):
""" store which servers we federate with """ """store which servers we federate with"""
server_name = models.CharField(max_length=255, unique=True) server_name = models.CharField(max_length=255, unique=True)
status = models.CharField( status = models.CharField(
@ -25,7 +25,7 @@ class FederatedServer(BookWyrmModel):
notes = models.TextField(null=True, blank=True) notes = models.TextField(null=True, blank=True)
def block(self): def block(self):
""" block a server """ """block a server"""
self.status = "blocked" self.status = "blocked"
self.save() self.save()
@ -35,7 +35,7 @@ class FederatedServer(BookWyrmModel):
) )
def unblock(self): def unblock(self):
""" unblock a server """ """unblock a server"""
self.status = "federated" self.status = "federated"
self.save() self.save()
@ -45,7 +45,7 @@ class FederatedServer(BookWyrmModel):
@classmethod @classmethod
def is_blocked(cls, url): def is_blocked(cls, url):
""" look up if a domain is blocked """ """look up if a domain is blocked"""
url = urlparse(url) url = urlparse(url)
domain = url.netloc domain = url.netloc
return cls.objects.filter(server_name=domain, status="blocked").exists() return cls.objects.filter(server_name=domain, status="blocked").exists()

View file

@ -18,7 +18,7 @@ from bookwyrm.settings import DOMAIN
def validate_remote_id(value): 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): if not value or not re.match(r"^http.?:\/\/[^\s]+$", value):
raise ValidationError( raise ValidationError(
_("%(value)s is not a valid remote_id"), _("%(value)s is not a valid remote_id"),
@ -27,7 +27,7 @@ def validate_remote_id(value):
def validate_localname(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): if not re.match(r"^[A-Za-z\-_\.0-9]+$", value):
raise ValidationError( raise ValidationError(
_("%(value)s is not a valid username"), _("%(value)s is not a valid username"),
@ -36,7 +36,7 @@ def validate_localname(value):
def validate_username(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): if not re.match(r"^[A-Za-z\-_\.0-9]+@[A-Za-z\-_\.0-9]+\.[a-z]{2,}$", value):
raise ValidationError( raise ValidationError(
_("%(value)s is not a valid username"), _("%(value)s is not a valid username"),
@ -45,7 +45,7 @@ def validate_username(value):
class ActivitypubFieldMixin: class ActivitypubFieldMixin:
""" make a database field serializable """ """make a database field serializable"""
def __init__( def __init__(
self, self,
@ -64,7 +64,7 @@ class ActivitypubFieldMixin:
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def set_field_from_activity(self, instance, data): 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: try:
value = getattr(data, self.get_activitypub_field()) value = getattr(data, self.get_activitypub_field())
except AttributeError: except AttributeError:
@ -78,7 +78,7 @@ class ActivitypubFieldMixin:
setattr(instance, self.name, formatted) setattr(instance, self.name, formatted)
def set_activity_from_field(self, activity, instance): def set_activity_from_field(self, activity, instance):
""" update the json object """ """update the json object"""
value = getattr(instance, self.name) value = getattr(instance, self.name)
formatted = self.field_to_activity(value) formatted = self.field_to_activity(value)
if formatted is None: if formatted is None:
@ -94,19 +94,19 @@ class ActivitypubFieldMixin:
activity[key] = formatted activity[key] = formatted
def field_to_activity(self, value): 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"): if hasattr(self, "activitypub_wrapper"):
return {self.activitypub_wrapper: value} return {self.activitypub_wrapper: value}
return value return value
def field_from_activity(self, 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"): if value and hasattr(self, "activitypub_wrapper"):
value = value.get(self.activitypub_wrapper) value = value.get(self.activitypub_wrapper)
return value return value
def get_activitypub_field(self): def get_activitypub_field(self):
""" model_field_name to activitypubFieldName """ """model_field_name to activitypubFieldName"""
if self.activitypub_field: if self.activitypub_field:
return self.activitypub_field return self.activitypub_field
name = self.name.split(".")[-1] name = self.name.split(".")[-1]
@ -115,7 +115,7 @@ class ActivitypubFieldMixin:
class ActivitypubRelatedFieldMixin(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): def __init__(self, *args, load_remote=True, **kwargs):
self.load_remote = load_remote self.load_remote = load_remote
@ -146,7 +146,7 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
class RemoteIdField(ActivitypubFieldMixin, models.CharField): 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): def __init__(self, *args, max_length=255, validators=None, **kwargs):
validators = validators or [validate_remote_id] validators = validators or [validate_remote_id]
@ -156,7 +156,7 @@ class RemoteIdField(ActivitypubFieldMixin, models.CharField):
class UsernameField(ActivitypubFieldMixin, models.CharField): class UsernameField(ActivitypubFieldMixin, models.CharField):
""" activitypub-aware username field """ """activitypub-aware username field"""
def __init__(self, activitypub_field="preferredUsername", **kwargs): def __init__(self, activitypub_field="preferredUsername", **kwargs):
self.activitypub_field = activitypub_field self.activitypub_field = activitypub_field
@ -172,7 +172,7 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
) )
def deconstruct(self): def deconstruct(self):
""" implementation of models.Field deconstruct """ """implementation of models.Field deconstruct"""
name, path, args, kwargs = super().deconstruct() name, path, args, kwargs = super().deconstruct()
del kwargs["verbose_name"] del kwargs["verbose_name"]
del kwargs["max_length"] del kwargs["max_length"]
@ -191,7 +191,7 @@ PrivacyLevels = models.TextChoices(
class PrivacyField(ActivitypubFieldMixin, models.CharField): 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" public = "https://www.w3.org/ns/activitystreams#Public"
@ -236,7 +236,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey): class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
""" activitypub-aware foreign key field """ """activitypub-aware foreign key field"""
def field_to_activity(self, value): def field_to_activity(self, value):
if not value: if not value:
@ -245,7 +245,7 @@ class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField): class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField):
""" activitypub-aware foreign key field """ """activitypub-aware foreign key field"""
def field_to_activity(self, value): def field_to_activity(self, value):
if not value: if not value:
@ -254,14 +254,14 @@ class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField):
class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField): 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): def __init__(self, *args, link_only=False, **kwargs):
self.link_only = link_only self.link_only = link_only
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def set_field_from_activity(self, instance, data): 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()) value = getattr(data, self.get_activitypub_field())
formatted = self.field_from_activity(value) formatted = self.field_from_activity(value)
if formatted is None or formatted is MISSING: 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()] return [i.remote_id for i in value.all()]
def field_from_activity(self, value): def field_from_activity(self, value):
items = []
if value is None or value is MISSING: 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: for remote_id in value:
try: try:
validate_remote_id(remote_id) validate_remote_id(remote_id)
@ -290,7 +293,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
class TagField(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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -330,7 +333,7 @@ class TagField(ManyToManyField):
def image_serializer(value, alt): def image_serializer(value, alt):
""" helper for serializing images """ """helper for serializing images"""
if value and hasattr(value, "url"): if value and hasattr(value, "url"):
url = value.url url = value.url
else: else:
@ -340,7 +343,7 @@ def image_serializer(value, alt):
class ImageField(ActivitypubFieldMixin, models.ImageField): class ImageField(ActivitypubFieldMixin, models.ImageField):
""" activitypub-aware image field """ """activitypub-aware image field"""
def __init__(self, *args, alt_field=None, **kwargs): def __init__(self, *args, alt_field=None, **kwargs):
self.alt_field = alt_field self.alt_field = alt_field
@ -348,7 +351,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
# pylint: disable=arguments-differ # pylint: disable=arguments-differ
def set_field_from_activity(self, instance, data, save=True): 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()) value = getattr(data, self.get_activitypub_field())
formatted = self.field_from_activity(value) formatted = self.field_from_activity(value)
if formatted is None or formatted is MISSING: if formatted is None or formatted is MISSING:
@ -394,7 +397,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
class DateTimeField(ActivitypubFieldMixin, models.DateTimeField): class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
""" activitypub-aware datetime field """ """activitypub-aware datetime field"""
def field_to_activity(self, value): def field_to_activity(self, value):
if not value: if not value:
@ -413,7 +416,7 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
class HtmlField(ActivitypubFieldMixin, models.TextField): class HtmlField(ActivitypubFieldMixin, models.TextField):
""" a text field for storing html """ """a text field for storing html"""
def field_from_activity(self, value): def field_from_activity(self, value):
if not value or value == MISSING: if not value or value == MISSING:
@ -424,30 +427,30 @@ class HtmlField(ActivitypubFieldMixin, models.TextField):
class ArrayField(ActivitypubFieldMixin, DjangoArrayField): class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
""" activitypub-aware array field """ """activitypub-aware array field"""
def field_to_activity(self, value): def field_to_activity(self, value):
return [str(i) for i in value] return [str(i) for i in value]
class CharField(ActivitypubFieldMixin, models.CharField): class CharField(ActivitypubFieldMixin, models.CharField):
""" activitypub-aware char field """ """activitypub-aware char field"""
class TextField(ActivitypubFieldMixin, models.TextField): class TextField(ActivitypubFieldMixin, models.TextField):
""" activitypub-aware text field """ """activitypub-aware text field"""
class BooleanField(ActivitypubFieldMixin, models.BooleanField): class BooleanField(ActivitypubFieldMixin, models.BooleanField):
""" activitypub-aware boolean field """ """activitypub-aware boolean field"""
class IntegerField(ActivitypubFieldMixin, models.IntegerField): class IntegerField(ActivitypubFieldMixin, models.IntegerField):
""" activitypub-aware boolean field """ """activitypub-aware boolean field"""
class DecimalField(ActivitypubFieldMixin, models.DecimalField): class DecimalField(ActivitypubFieldMixin, models.DecimalField):
""" activitypub-aware boolean field """ """activitypub-aware boolean field"""
def field_to_activity(self, value): def field_to_activity(self, value):
if not value: if not value:

View file

@ -20,7 +20,7 @@ GOODREADS_SHELVES = {
def unquote_string(text): def unquote_string(text):
""" resolve csv quote weirdness """ """resolve csv quote weirdness"""
match = re.match(r'="([^"]*)"', text) match = re.match(r'="([^"]*)"', text)
if match: if match:
return match.group(1) return match.group(1)
@ -28,7 +28,7 @@ def unquote_string(text):
def construct_search_term(title, author): 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) # Strip brackets (usually series title from search term)
title = re.sub(r"\s*\([^)]*\)\s*", "", title) title = re.sub(r"\s*\([^)]*\)\s*", "", title)
# Open library doesn't like including author initials in search term. # 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): 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) user = models.ForeignKey(User, on_delete=models.CASCADE)
created_date = models.DateTimeField(default=timezone.now) created_date = models.DateTimeField(default=timezone.now)
@ -51,7 +51,7 @@ class ImportJob(models.Model):
retry = models.BooleanField(default=False) retry = models.BooleanField(default=False)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" save and notify """ """save and notify"""
super().save(*args, **kwargs) super().save(*args, **kwargs)
if self.complete: if self.complete:
notification_model = apps.get_model( notification_model = apps.get_model(
@ -65,7 +65,7 @@ class ImportJob(models.Model):
class ImportItem(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") job = models.ForeignKey(ImportJob, on_delete=models.CASCADE, related_name="items")
index = models.IntegerField() index = models.IntegerField()
@ -74,11 +74,11 @@ class ImportItem(models.Model):
fail_reason = models.TextField(null=True) fail_reason = models.TextField(null=True)
def resolve(self): 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() self.book = self.get_book_from_isbn() or self.get_book_from_title_author()
def get_book_from_isbn(self): def get_book_from_isbn(self):
""" search by isbn """ """search by isbn"""
search_result = connector_manager.first_search_result( search_result = connector_manager.first_search_result(
self.isbn, min_confidence=0.999 self.isbn, min_confidence=0.999
) )
@ -88,7 +88,7 @@ class ImportItem(models.Model):
return None return None
def get_book_from_title_author(self): 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_term = construct_search_term(self.title, self.author)
search_result = connector_manager.first_search_result( search_result = connector_manager.first_search_result(
search_term, min_confidence=0.999 search_term, min_confidence=0.999
@ -100,60 +100,60 @@ class ImportItem(models.Model):
@property @property
def title(self): def title(self):
""" get the book title """ """get the book title"""
return self.data["Title"] return self.data["Title"]
@property @property
def author(self): def author(self):
""" get the book title """ """get the book title"""
return self.data["Author"] return self.data["Author"]
@property @property
def isbn(self): 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"]) return unquote_string(self.data["ISBN13"])
@property @property
def shelf(self): def shelf(self):
""" the goodreads shelf field """ """the goodreads shelf field"""
if self.data["Exclusive Shelf"]: if self.data["Exclusive Shelf"]:
return GOODREADS_SHELVES.get(self.data["Exclusive Shelf"]) return GOODREADS_SHELVES.get(self.data["Exclusive Shelf"])
return None return None
@property @property
def review(self): 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"] return self.data["My Review"]
@property @property
def rating(self): def rating(self):
""" x/5 star rating for a book """ """x/5 star rating for a book"""
return int(self.data["My Rating"]) return int(self.data["My Rating"])
@property @property
def date_added(self): 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"]: if self.data["Date Added"]:
return timezone.make_aware(dateutil.parser.parse(self.data["Date Added"])) return timezone.make_aware(dateutil.parser.parse(self.data["Date Added"]))
return None return None
@property @property
def date_started(self): 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"]: if "Date Started" in self.data and self.data["Date Started"]:
return timezone.make_aware(dateutil.parser.parse(self.data["Date Started"])) return timezone.make_aware(dateutil.parser.parse(self.data["Date Started"]))
return None return None
@property @property
def date_read(self): def date_read(self):
""" the date a book was completed """ """the date a book was completed"""
if self.data["Date Read"]: if self.data["Date Read"]:
return timezone.make_aware(dateutil.parser.parse(self.data["Date Read"])) return timezone.make_aware(dateutil.parser.parse(self.data["Date Read"]))
return None return None
@property @property
def reads(self): 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 start_date = self.date_started
# Goodreads special case (no 'date started' field) # Goodreads special case (no 'date started' field)

View file

@ -21,7 +21,7 @@ CurationType = models.TextChoices(
class List(OrderedCollectionMixin, BookWyrmModel): class List(OrderedCollectionMixin, BookWyrmModel):
""" a list of books """ """a list of books"""
name = fields.CharField(max_length=100) name = fields.CharField(max_length=100)
user = fields.ForeignKey( user = fields.ForeignKey(
@ -41,22 +41,22 @@ class List(OrderedCollectionMixin, BookWyrmModel):
activity_serializer = activitypub.BookList activity_serializer = activitypub.BookList
def get_remote_id(self): 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) return "https://%s/list/%d" % (DOMAIN, self.id)
@property @property
def collection_queryset(self): def collection_queryset(self):
""" list of books for this shelf, overrides OrderedCollectionMixin """ """list of books for this shelf, overrides OrderedCollectionMixin"""
return self.books.filter(listitem__approved=True).all().order_by("listitem") return self.books.filter(listitem__approved=True).order_by("listitem")
class Meta: class Meta:
""" default sorting """ """default sorting"""
ordering = ("-updated_date",) ordering = ("-updated_date",)
class ListItem(CollectionItemMixin, BookWyrmModel): class ListItem(CollectionItemMixin, BookWyrmModel):
""" ok """ """ok"""
book = fields.ForeignKey( book = fields.ForeignKey(
"Edition", on_delete=models.PROTECT, activitypub_field="book" "Edition", on_delete=models.PROTECT, activitypub_field="book"
@ -67,14 +67,14 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
) )
notes = fields.TextField(blank=True, null=True) notes = fields.TextField(blank=True, null=True)
approved = models.BooleanField(default=True) approved = models.BooleanField(default=True)
order = fields.IntegerField(blank=True, null=True) order = fields.IntegerField()
endorsement = models.ManyToManyField("User", related_name="endorsers") endorsement = models.ManyToManyField("User", related_name="endorsers")
activity_serializer = activitypub.ListItem activity_serializer = activitypub.ListItem
collection_field = "book_list" collection_field = "book_list"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" create a notification too """ """create a notification too"""
created = not bool(self.id) created = not bool(self.id)
super().save(*args, **kwargs) super().save(*args, **kwargs)
# tick the updated date on the parent list # tick the updated date on the parent list
@ -93,7 +93,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
) )
class Meta: class Meta:
""" an opinionated constraint! you can't put a book on a list twice """ # 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") unique_together = (("book", "book_list"), ("order", "book_list"))
ordering = ("-created_date",) ordering = ("-created_date",)

View file

@ -10,7 +10,7 @@ NotificationType = models.TextChoices(
class Notification(BookWyrmModel): 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) user = models.ForeignKey("User", on_delete=models.CASCADE)
related_book = models.ForeignKey("Edition", on_delete=models.CASCADE, null=True) related_book = models.ForeignKey("Edition", on_delete=models.CASCADE, null=True)
@ -29,7 +29,7 @@ class Notification(BookWyrmModel):
) )
def save(self, *args, **kwargs): 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 # there's probably a better way to do this
if self.__class__.objects.filter( if self.__class__.objects.filter(
user=self.user, user=self.user,
@ -45,7 +45,7 @@ class Notification(BookWyrmModel):
super().save(*args, **kwargs) super().save(*args, **kwargs)
class Meta: class Meta:
""" checks if notifcation is in enum list for valid types """ """checks if notifcation is in enum list for valid types"""
constraints = [ constraints = [
models.CheckConstraint( models.CheckConstraint(

View file

@ -7,14 +7,14 @@ from .base_model import BookWyrmModel
class ProgressMode(models.TextChoices): class ProgressMode(models.TextChoices):
""" types of prgress available """ """types of prgress available"""
PAGE = "PG", "page" PAGE = "PG", "page"
PERCENT = "PCT", "percent" PERCENT = "PCT", "percent"
class ReadThrough(BookWyrmModel): 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) user = models.ForeignKey("User", on_delete=models.PROTECT)
book = models.ForeignKey("Edition", 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) finish_date = models.DateTimeField(blank=True, null=True)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" update user active time """ """update user active time"""
self.user.last_active_date = timezone.now() self.user.last_active_date = timezone.now()
self.user.save(broadcast=False) self.user.save(broadcast=False)
super().save(*args, **kwargs) super().save(*args, **kwargs)
def create_update(self): def create_update(self):
""" add update to the readthrough """ """add update to the readthrough"""
if self.progress: if self.progress:
return self.progressupdate_set.create( return self.progressupdate_set.create(
user=self.user, progress=self.progress, mode=self.progress_mode user=self.user, progress=self.progress, mode=self.progress_mode
@ -43,7 +43,7 @@ class ReadThrough(BookWyrmModel):
class ProgressUpdate(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) user = models.ForeignKey("User", on_delete=models.PROTECT)
readthrough = models.ForeignKey("ReadThrough", on_delete=models.CASCADE) readthrough = models.ForeignKey("ReadThrough", on_delete=models.CASCADE)
@ -53,7 +53,7 @@ class ProgressUpdate(BookWyrmModel):
) )
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" update user active time """ """update user active time"""
self.user.last_active_date = timezone.now() self.user.last_active_date = timezone.now()
self.user.save(broadcast=False) self.user.save(broadcast=False)
super().save(*args, **kwargs) super().save(*args, **kwargs)

View file

@ -11,7 +11,7 @@ from . import fields
class UserRelationship(BookWyrmModel): class UserRelationship(BookWyrmModel):
""" many-to-many through table for followers """ """many-to-many through table for followers"""
user_subject = fields.ForeignKey( user_subject = fields.ForeignKey(
"User", "User",
@ -28,16 +28,16 @@ class UserRelationship(BookWyrmModel):
@property @property
def privacy(self): def privacy(self):
""" all relationships are handled directly with the participants """ """all relationships are handled directly with the participants"""
return "direct" return "direct"
@property @property
def recipients(self): 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] return [u for u in [self.user_subject, self.user_object] if not u.local]
class Meta: class Meta:
""" relationships should be unique """ """relationships should be unique"""
abstract = True abstract = True
constraints = [ constraints = [
@ -50,24 +50,23 @@ class UserRelationship(BookWyrmModel):
), ),
] ]
def get_remote_id(self, status=None): # pylint: disable=arguments-differ def get_remote_id(self):
""" use shelf identifier in remote_id """ """use shelf identifier in remote_id"""
status = status or "follows"
base_path = self.user_subject.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): class UserFollows(ActivityMixin, UserRelationship):
""" Following a user """ """Following a user"""
status = "follows" status = "follows"
def to_activity(self): # pylint: disable=arguments-differ 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)) return activitypub.Follow(**generate_activity(self))
def save(self, *args, **kwargs): 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 # blocking in either direction is a no-go
if UserBlocks.objects.filter( if UserBlocks.objects.filter(
Q( Q(
@ -86,7 +85,7 @@ class UserFollows(ActivityMixin, UserRelationship):
@classmethod @classmethod
def from_request(cls, follow_request): 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( return cls.objects.create(
user_subject=follow_request.user_subject, user_subject=follow_request.user_subject,
user_object=follow_request.user_object, user_object=follow_request.user_object,
@ -95,19 +94,22 @@ class UserFollows(ActivityMixin, UserRelationship):
class UserFollowRequest(ActivitypubMixin, UserRelationship): class UserFollowRequest(ActivitypubMixin, UserRelationship):
""" following a user requires manual or automatic confirmation """ """following a user requires manual or automatic confirmation"""
status = "follow_request" status = "follow_request"
activity_serializer = activitypub.Follow activity_serializer = activitypub.Follow
def save(self, *args, broadcast=True, **kwargs): def save(self, *args, broadcast=True, **kwargs):
""" make sure the follow or block relationship doesn't already exist """ """make sure the follow or block relationship doesn't already exist"""
# don't create a request if a follow already exists # if there's a request for a follow that already exists, accept it
# without changing the local database state
if UserFollows.objects.filter( if UserFollows.objects.filter(
user_subject=self.user_subject, user_subject=self.user_subject,
user_object=self.user_object, user_object=self.user_object,
).exists(): ).exists():
raise IntegrityError() self.accept(broadcast_only=True)
return
# blocking in either direction is a no-go # blocking in either direction is a no-go
if UserBlocks.objects.filter( if UserBlocks.objects.filter(
Q( Q(
@ -138,25 +140,34 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
notification_type=notification_type, notification_type=notification_type,
) )
def accept(self): def get_accept_reject_id(self, status):
""" turn this request into the real deal""" """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 user = self.user_object
if not self.user_subject.local: if not self.user_subject.local:
activity = activitypub.Accept( activity = activitypub.Accept(
id=self.get_remote_id(status="accepts"), id=self.get_accept_reject_id(status="accepts"),
actor=self.user_object.remote_id, actor=self.user_object.remote_id,
object=self.to_activity(), object=self.to_activity(),
).serialize() ).serialize()
self.broadcast(activity, user) self.broadcast(activity, user)
if broadcast_only:
return
with transaction.atomic(): with transaction.atomic():
UserFollows.from_request(self) UserFollows.from_request(self)
self.delete() self.delete()
def reject(self): def reject(self):
""" generate a Reject for this follow request """ """generate a Reject for this follow request"""
if self.user_object.local: if self.user_object.local:
activity = activitypub.Reject( activity = activitypub.Reject(
id=self.get_remote_id(status="rejects"), id=self.get_accept_reject_id(status="rejects"),
actor=self.user_object.remote_id, actor=self.user_object.remote_id,
object=self.to_activity(), object=self.to_activity(),
).serialize() ).serialize()
@ -166,13 +177,13 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
class UserBlocks(ActivityMixin, 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" status = "blocks"
activity_serializer = activitypub.Block activity_serializer = activitypub.Block
def save(self, *args, **kwargs): 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) super().save(*args, **kwargs)
UserFollows.objects.filter( UserFollows.objects.filter(

View file

@ -6,7 +6,7 @@ from .base_model import BookWyrmModel
class Report(BookWyrmModel): class Report(BookWyrmModel):
""" reported status or user """ """reported status or user"""
reporter = models.ForeignKey( reporter = models.ForeignKey(
"User", related_name="reporter", on_delete=models.PROTECT "User", related_name="reporter", on_delete=models.PROTECT
@ -17,7 +17,7 @@ class Report(BookWyrmModel):
resolved = models.BooleanField(default=False) resolved = models.BooleanField(default=False)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" notify admins when a report is created """ """notify admins when a report is created"""
super().save(*args, **kwargs) super().save(*args, **kwargs)
user_model = apps.get_model("bookwyrm.User", require_ready=True) user_model = apps.get_model("bookwyrm.User", require_ready=True)
# moderators and superusers should be notified # moderators and superusers should be notified
@ -34,7 +34,7 @@ class Report(BookWyrmModel):
) )
class Meta: class Meta:
""" don't let users report themselves """ """don't let users report themselves"""
constraints = [ constraints = [
models.CheckConstraint(check=~Q(reporter=F("user")), name="self_report") models.CheckConstraint(check=~Q(reporter=F("user")), name="self_report")
@ -43,13 +43,13 @@ class Report(BookWyrmModel):
class ReportComment(BookWyrmModel): class ReportComment(BookWyrmModel):
""" updates on a report """ """updates on a report"""
user = models.ForeignKey("User", on_delete=models.PROTECT) user = models.ForeignKey("User", on_delete=models.PROTECT)
note = models.TextField() note = models.TextField()
report = models.ForeignKey(Report, on_delete=models.PROTECT) report = models.ForeignKey(Report, on_delete=models.PROTECT)
class Meta: class Meta:
""" sort comments """ """sort comments"""
ordering = ("-created_date",) ordering = ("-created_date",)

View file

@ -9,7 +9,7 @@ from . import fields
class Shelf(OrderedCollectionMixin, BookWyrmModel): class Shelf(OrderedCollectionMixin, BookWyrmModel):
""" a list of books owned by a user """ """a list of books owned by a user"""
TO_READ = "to-read" TO_READ = "to-read"
READING = "reading" READING = "reading"
@ -34,36 +34,36 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
activity_serializer = activitypub.Shelf activity_serializer = activitypub.Shelf
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" set the identifier """ """set the identifier"""
super().save(*args, **kwargs) super().save(*args, **kwargs)
if not self.identifier: if not self.identifier:
self.identifier = self.get_identifier() self.identifier = self.get_identifier()
super().save(*args, **kwargs, broadcast=False) super().save(*args, **kwargs, broadcast=False)
def get_identifier(self): def get_identifier(self):
""" custom-shelf-123 for the url """ """custom-shelf-123 for the url"""
slug = re.sub(r"[^\w]", "", self.name).lower() slug = re.sub(r"[^\w]", "", self.name).lower()
return "{:s}-{:d}".format(slug, self.id) return "{:s}-{:d}".format(slug, self.id)
@property @property
def collection_queryset(self): def collection_queryset(self):
""" list of books for this shelf, overrides OrderedCollectionMixin """ """list of books for this shelf, overrides OrderedCollectionMixin"""
return self.books.all().order_by("shelfbook") return self.books.order_by("shelfbook")
def get_remote_id(self): def get_remote_id(self):
""" shelf identifier instead of id """ """shelf identifier instead of id"""
base_path = self.user.remote_id base_path = self.user.remote_id
identifier = self.identifier or self.get_identifier() identifier = self.identifier or self.get_identifier()
return "%s/books/%s" % (base_path, identifier) return "%s/books/%s" % (base_path, identifier)
class Meta: class Meta:
""" user/shelf unqiueness """ """user/shelf unqiueness"""
unique_together = ("user", "identifier") unique_together = ("user", "identifier")
class ShelfBook(CollectionItemMixin, BookWyrmModel): 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( book = fields.ForeignKey(
"Edition", on_delete=models.PROTECT, activitypub_field="book" "Edition", on_delete=models.PROTECT, activitypub_field="book"

View file

@ -12,7 +12,7 @@ from .user import User
class SiteSettings(models.Model): class SiteSettings(models.Model):
""" customized settings for this instance """ """customized settings for this instance"""
name = models.CharField(default="BookWyrm", max_length=100) name = models.CharField(default="BookWyrm", max_length=100)
instance_tagline = models.CharField( instance_tagline = models.CharField(
@ -35,7 +35,7 @@ class SiteSettings(models.Model):
@classmethod @classmethod
def get(cls): def get(cls):
""" gets the site settings db entry or defaults """ """gets the site settings db entry or defaults"""
try: try:
return cls.objects.get(id=1) return cls.objects.get(id=1)
except cls.DoesNotExist: except cls.DoesNotExist:
@ -45,12 +45,12 @@ class SiteSettings(models.Model):
def new_access_code(): 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") return base64.b32encode(Random.get_random_bytes(5)).decode("ascii")
class SiteInvite(models.Model): 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) created_date = models.DateTimeField(auto_now_add=True)
code = models.CharField(max_length=32, default=new_access_code) 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") invitees = models.ManyToManyField(User, related_name="invitees")
def valid(self): 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 ( return (self.expiry is None or self.expiry > timezone.now()) and (
self.use_limit is None or self.times_used < self.use_limit self.use_limit is None or self.times_used < self.use_limit
) )
@property @property
def link(self): def link(self):
""" formats the invite link """ """formats the invite link"""
return "https://{}/invite/{}".format(DOMAIN, self.code) return "https://{}/invite/{}".format(DOMAIN, self.code)
class InviteRequest(BookWyrmModel): class InviteRequest(BookWyrmModel):
""" prospective users can request an invite """ """prospective users can request an invite"""
email = models.EmailField(max_length=255, unique=True) email = models.EmailField(max_length=255, unique=True)
invite = models.ForeignKey( invite = models.ForeignKey(
@ -83,30 +83,30 @@ class InviteRequest(BookWyrmModel):
ignored = models.BooleanField(default=False) ignored = models.BooleanField(default=False)
def save(self, *args, **kwargs): 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(): if not self.id and User.objects.filter(email=self.email).exists():
raise IntegrityError() raise IntegrityError()
super().save(*args, **kwargs) super().save(*args, **kwargs)
def get_passowrd_reset_expiry(): 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() now = timezone.now()
return now + datetime.timedelta(days=1) return now + datetime.timedelta(days=1)
class PasswordReset(models.Model): 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) code = models.CharField(max_length=32, default=new_access_code)
expiry = models.DateTimeField(default=get_passowrd_reset_expiry) expiry = models.DateTimeField(default=get_passowrd_reset_expiry)
user = models.OneToOneField(User, on_delete=models.CASCADE) user = models.OneToOneField(User, on_delete=models.CASCADE)
def valid(self): 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() return self.expiry > timezone.now()
@property @property
def link(self): def link(self):
""" formats the invite link """ """formats the invite link"""
return "https://{}/password-reset/{}".format(DOMAIN, self.code) return "https://{}/password-reset/{}".format(DOMAIN, self.code)

View file

@ -19,7 +19,7 @@ from . import fields
class Status(OrderedCollectionPageMixin, BookWyrmModel): 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 = fields.ForeignKey(
"User", on_delete=models.PROTECT, activitypub_field="attributedTo" "User", on_delete=models.PROTECT, activitypub_field="attributedTo"
@ -59,12 +59,12 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
deserialize_reverse_fields = [("attachments", "attachment")] deserialize_reverse_fields = [("attachments", "attachment")]
class Meta: class Meta:
""" default sorting """ """default sorting"""
ordering = ("-published_date",) ordering = ("-published_date",)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" save and notify """ """save and notify"""
super().save(*args, **kwargs) super().save(*args, **kwargs)
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True) 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 def delete(self, *args, **kwargs): # pylint: disable=unused-argument
""" "delete" a status """ """ "delete" a status"""
if hasattr(self, "boosted_status"): if hasattr(self, "boosted_status"):
# okay but if it's a boost really delete it # okay but if it's a boost really delete it
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
@ -109,7 +109,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
@property @property
def recipients(self): 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] mentions = [u for u in self.mention_users.all() if not u.local]
if ( if (
hasattr(self, "reply_parent") hasattr(self, "reply_parent")
@ -121,7 +121,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
@classmethod @classmethod
def ignore_activity(cls, activity): # pylint: disable=too-many-return-statements 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": if activity.type == "Announce":
try: try:
boosted = activitypub.resolve_remote_id( boosted = activitypub.resolve_remote_id(
@ -163,16 +163,16 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
@property @property
def status_type(self): 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__ return self.activity_serializer.__name__
@property @property
def boostable(self): def boostable(self):
""" you can't boost dms """ """you can't boost dms"""
return self.privacy in ["unlisted", "public"] return self.privacy in ["unlisted", "public"]
def to_replies(self, **kwargs): 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( return self.to_ordered_collection(
self.replies(self), self.replies(self),
remote_id="%s/replies" % self.remote_id, remote_id="%s/replies" % self.remote_id,
@ -181,7 +181,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
).serialize() ).serialize()
def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ 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: if self.deleted:
return activitypub.Tombstone( return activitypub.Tombstone(
id=self.remote_id, id=self.remote_id,
@ -210,16 +210,16 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
return activity return activity
def to_activity(self, pure=False): # pylint: disable=arguments-differ 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() return self.to_activity_dataclass(pure=pure).serialize()
class GeneratedNote(Status): class GeneratedNote(Status):
""" these are app-generated messages about user activity """ """these are app-generated messages about user activity"""
@property @property
def pure_content(self): 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 message = self.content
books = ", ".join( books = ", ".join(
'<a href="%s">"%s"</a>' % (book.remote_id, book.title) '<a href="%s">"%s"</a>' % (book.remote_id, book.title)
@ -232,7 +232,7 @@ class GeneratedNote(Status):
class Comment(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( book = fields.ForeignKey(
"Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook" "Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
@ -253,7 +253,7 @@ class Comment(Status):
@property @property
def pure_content(self): 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<p>(comment on <a href="%s">"%s"</a>)</p>' % ( return '%s<p>(comment on <a href="%s">"%s"</a>)</p>' % (
self.content, self.content,
self.book.remote_id, self.book.remote_id,
@ -265,7 +265,7 @@ class Comment(Status):
class Quotation(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() quote = fields.HtmlField()
book = fields.ForeignKey( book = fields.ForeignKey(
@ -274,7 +274,7 @@ class Quotation(Status):
@property @property
def pure_content(self): 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"^<p>", '<p>"', self.quote) quote = re.sub(r"^<p>", '<p>"', self.quote)
quote = re.sub(r"</p>$", '"</p>', quote) quote = re.sub(r"</p>$", '"</p>', quote)
return '%s <p>-- <a href="%s">"%s"</a></p>%s' % ( return '%s <p>-- <a href="%s">"%s"</a></p>%s' % (
@ -289,7 +289,7 @@ class Quotation(Status):
class Review(Status): class Review(Status):
""" a book review """ """a book review"""
name = fields.CharField(max_length=255, null=True) name = fields.CharField(max_length=255, null=True)
book = fields.ForeignKey( book = fields.ForeignKey(
@ -306,7 +306,7 @@ class Review(Status):
@property @property
def pure_name(self): 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") template = get_template("snippets/generated_status/review_pure_name.html")
return template.render( return template.render(
{"book": self.book, "rating": self.rating, "name": self.name} {"book": self.book, "rating": self.rating, "name": self.name}
@ -314,7 +314,7 @@ class Review(Status):
@property @property
def pure_content(self): 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 return self.content
activity_serializer = activitypub.Review activity_serializer = activitypub.Review
@ -322,7 +322,7 @@ class Review(Status):
class ReviewRating(Review): 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): def save(self, *args, **kwargs):
if not self.rating: if not self.rating:
@ -339,7 +339,7 @@ class ReviewRating(Review):
class Boost(ActivityMixin, Status): class Boost(ActivityMixin, Status):
""" boost'ing a post """ """boost'ing a post"""
boosted_status = fields.ForeignKey( boosted_status = fields.ForeignKey(
"Status", "Status",
@ -350,7 +350,17 @@ class Boost(ActivityMixin, Status):
activity_serializer = activitypub.Announce activity_serializer = activitypub.Announce
def save(self, *args, **kwargs): 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) super().save(*args, **kwargs)
if not self.boosted_status.user.local or self.boosted_status.user == self.user: if not self.boosted_status.user.local or self.boosted_status.user == self.user:
return return
@ -364,7 +374,7 @@ class Boost(ActivityMixin, Status):
) )
def delete(self, *args, **kwargs): 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 = apps.get_model("bookwyrm.Notification", require_ready=True)
notification_model.objects.filter( notification_model.objects.filter(
user=self.boosted_status.user, user=self.boosted_status.user,
@ -375,7 +385,7 @@ class Boost(ActivityMixin, Status):
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
def __init__(self, *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) super().__init__(*args, **kwargs)
reserve_fields = ["user", "boosted_status", "published_date", "privacy"] reserve_fields = ["user", "boosted_status", "published_date", "privacy"]

View file

@ -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")

View file

@ -35,7 +35,7 @@ DeactivationReason = models.TextChoices(
class User(OrderedCollectionPageMixin, AbstractUser): class User(OrderedCollectionPageMixin, AbstractUser):
""" a user who wants to read books """ """a user who wants to read books"""
username = fields.UsernameField() username = fields.UsernameField()
email = models.EmailField(unique=True, null=True) email = models.EmailField(unique=True, null=True)
@ -130,38 +130,38 @@ class User(OrderedCollectionPageMixin, AbstractUser):
@property @property
def following_link(self): 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) return "{:s}/following".format(self.remote_id)
@property @property
def alt_text(self): def alt_text(self):
""" alt text with username """ """alt text with username"""
return "avatar for %s" % (self.localname or self.username) return "avatar for %s" % (self.localname or self.username)
@property @property
def display_name(self): 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 != "": if self.name and self.name != "":
return self.name return self.name
return self.localname or self.username return self.localname or self.username
@property @property
def deleted(self): def deleted(self):
""" for consistent naming """ """for consistent naming"""
return not self.is_active return not self.is_active
activity_serializer = activitypub.Person activity_serializer = activitypub.Person
@classmethod @classmethod
def viewer_aware_objects(cls, viewer): 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) queryset = cls.objects.filter(is_active=True)
if viewer and viewer.is_authenticated: if viewer and viewer.is_authenticated:
queryset = queryset.exclude(blocks=viewer) queryset = queryset.exclude(blocks=viewer)
return queryset return queryset
def to_outbox(self, filter_type=None, **kwargs): def to_outbox(self, filter_type=None, **kwargs):
""" an ordered collection of statuses """ """an ordered collection of statuses"""
if filter_type: if filter_type:
filter_class = apps.get_model( filter_class = apps.get_model(
"bookwyrm.%s" % filter_type, require_ready=True "bookwyrm.%s" % filter_type, require_ready=True
@ -188,7 +188,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
).serialize() ).serialize()
def to_following_activity(self, **kwargs): def to_following_activity(self, **kwargs):
""" activitypub following list """ """activitypub following list"""
remote_id = "%s/following" % self.remote_id remote_id = "%s/following" % self.remote_id
return self.to_ordered_collection( return self.to_ordered_collection(
self.following.order_by("-updated_date").all(), self.following.order_by("-updated_date").all(),
@ -198,7 +198,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
) )
def to_followers_activity(self, **kwargs): def to_followers_activity(self, **kwargs):
""" activitypub followers list """ """activitypub followers list"""
remote_id = "%s/followers" % self.remote_id remote_id = "%s/followers" % self.remote_id
return self.to_ordered_collection( return self.to_ordered_collection(
self.followers.order_by("-updated_date").all(), self.followers.order_by("-updated_date").all(),
@ -227,7 +227,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
return activity_object return activity_object
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" populate fields for new local users """ """populate fields for new local users"""
created = not bool(self.id) created = not bool(self.id)
if not self.local and not re.match(regex.full_username, self.username): if not self.local and not re.match(regex.full_username, self.username):
# generate a username that uses the domain (webfinger format) # generate a username that uses the domain (webfinger format)
@ -292,19 +292,19 @@ class User(OrderedCollectionPageMixin, AbstractUser):
).save(broadcast=False) ).save(broadcast=False)
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
""" deactivate rather than delete a user """ """deactivate rather than delete a user"""
self.is_active = False self.is_active = False
# skip the logic in this class's save() # skip the logic in this class's save()
super().save(*args, **kwargs) super().save(*args, **kwargs)
@property @property
def local_path(self): 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) return "/user/%s" % (self.localname or self.username)
class KeyPair(ActivitypubMixin, BookWyrmModel): 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) private_key = models.TextField(blank=True, null=True)
public_key = fields.TextField( public_key = fields.TextField(
@ -319,7 +319,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
return "%s/#main-key" % self.owner.remote_id return "%s/#main-key" % self.owner.remote_id
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" create a key pair """ """create a key pair"""
# no broadcasting happening here # no broadcasting happening here
if "broadcast" in kwargs: if "broadcast" in kwargs:
del kwargs["broadcast"] del kwargs["broadcast"]
@ -337,7 +337,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
class AnnualGoal(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) user = models.ForeignKey("User", on_delete=models.PROTECT)
goal = models.IntegerField(validators=[MinValueValidator(1)]) goal = models.IntegerField(validators=[MinValueValidator(1)])
@ -347,17 +347,17 @@ class AnnualGoal(BookWyrmModel):
) )
class Meta: class Meta:
""" unqiueness constraint """ """unqiueness constraint"""
unique_together = ("user", "year") unique_together = ("user", "year")
def get_remote_id(self): 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) return "%s/goal/%d" % (self.user.remote_id, self.year)
@property @property
def books(self): def books(self):
""" the books you've read this year """ """the books you've read this year"""
return ( return (
self.user.readthrough_set.filter(finish_date__year__gte=self.year) self.user.readthrough_set.filter(finish_date__year__gte=self.year)
.order_by("-finish_date") .order_by("-finish_date")
@ -366,7 +366,7 @@ class AnnualGoal(BookWyrmModel):
@property @property
def ratings(self): 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] book_ids = [r.book.id for r in self.books]
reviews = Review.objects.filter( reviews = Review.objects.filter(
user=self.user, user=self.user,
@ -376,12 +376,12 @@ class AnnualGoal(BookWyrmModel):
@property @property
def progress_percent(self): 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) return int(float(self.book_count / self.goal) * 100)
@property @property
def book_count(self): 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( return self.user.readthrough_set.filter(
finish_date__year__gte=self.year finish_date__year__gte=self.year
).count() ).count()
@ -389,7 +389,7 @@ class AnnualGoal(BookWyrmModel):
@app.task @app.task
def set_remote_server(user_id): 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) user = User.objects.get(id=user_id)
actor_parts = urlparse(user.remote_id) actor_parts = urlparse(user.remote_id)
user.federated_server = get_or_create_remote_server(actor_parts.netloc) 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): def get_or_create_remote_server(domain):
""" get info on a remote server """ """get info on a remote server"""
try: try:
return FederatedServer.objects.get(server_name=domain) return FederatedServer.objects.get(server_name=domain)
except FederatedServer.DoesNotExist: except FederatedServer.DoesNotExist:
@ -428,7 +428,7 @@ def get_or_create_remote_server(domain):
@app.task @app.task
def get_remote_reviews(outbox): 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" outbox_page = outbox + "?page=true&type=Review"
data = get_data(outbox_page) data = get_data(outbox_page)

View file

@ -10,16 +10,16 @@ r = redis.Redis(
class RedisStore(ABC): 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 max_length = settings.MAX_STREAM_LENGTH
def get_value(self, obj): def get_value(self, obj):
""" the object and rank """ """the object and rank"""
return {obj.id: self.get_rank(obj)} return {obj.id: self.get_rank(obj)}
def add_object_to_related_stores(self, obj, execute=True): 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) value = self.get_value(obj)
# we want to do this as a bulk operation, hence "pipeline" # we want to do this as a bulk operation, hence "pipeline"
pipeline = r.pipeline() pipeline = r.pipeline()
@ -34,14 +34,14 @@ class RedisStore(ABC):
return pipeline.execute() return pipeline.execute()
def remove_object_from_related_stores(self, obj): def remove_object_from_related_stores(self, obj):
""" remove an object from all stores """ """remove an object from all stores"""
pipeline = r.pipeline() pipeline = r.pipeline()
for store in self.get_stores_for_object(obj): for store in self.get_stores_for_object(obj):
pipeline.zrem(store, -1, obj.id) pipeline.zrem(store, -1, obj.id)
pipeline.execute() pipeline.execute()
def bulk_add_objects_to_store(self, objs, store): 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() pipeline = r.pipeline()
for obj in objs[: self.max_length]: for obj in objs[: self.max_length]:
pipeline.zadd(store, self.get_value(obj)) pipeline.zadd(store, self.get_value(obj))
@ -50,18 +50,18 @@ class RedisStore(ABC):
pipeline.execute() pipeline.execute()
def bulk_remove_objects_from_store(self, objs, store): 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() pipeline = r.pipeline()
for obj in objs[: self.max_length]: for obj in objs[: self.max_length]:
pipeline.zrem(store, -1, obj.id) pipeline.zrem(store, -1, obj.id)
pipeline.execute() pipeline.execute()
def get_store(self, store): # pylint: disable=no-self-use 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) return r.zrevrange(store, 0, -1)
def populate_store(self, store): def populate_store(self, store):
""" go from zero to a store """ """go from zero to a store"""
pipeline = r.pipeline() pipeline = r.pipeline()
queryset = self.get_objects_for_store(store) queryset = self.get_objects_for_store(store)
@ -75,12 +75,12 @@ class RedisStore(ABC):
@abstractmethod @abstractmethod
def get_objects_for_store(self, store): 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 @abstractmethod
def get_stores_for_object(self, obj): def get_stores_for_object(self, obj):
""" the stores that an object belongs in """ """the stores that an object belongs in"""
@abstractmethod @abstractmethod
def get_rank(self, obj): def get_rank(self, obj):
""" how to rank an object """ """how to rank an object"""

View file

@ -3,7 +3,7 @@ from html.parser import HTMLParser
class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method 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): def __init__(self):
HTMLParser.__init__(self) HTMLParser.__init__(self)
@ -28,7 +28,7 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
self.allow_html = True self.allow_html = True
def handle_starttag(self, tag, attrs): 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: if self.allow_html and tag in self.allowed_tags:
self.output.append(("tag", self.get_starttag_text())) self.output.append(("tag", self.get_starttag_text()))
self.tag_stack.append(tag) self.tag_stack.append(tag)
@ -36,7 +36,7 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
self.output.append(("data", "")) self.output.append(("data", ""))
def handle_endtag(self, tag): 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: if not self.allow_html or tag not in self.allowed_tags:
self.output.append(("data", "")) self.output.append(("data", ""))
return return
@ -51,11 +51,11 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
self.output.append(("tag", "</%s>" % tag)) self.output.append(("tag", "</%s>" % tag))
def handle_data(self, data): 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)) self.output.append(("data", data))
def get_output(self): 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: if self.tag_stack:
self.allow_html = False self.allow_html = False
if not self.allow_html: if not self.allow_html:

View file

@ -153,7 +153,7 @@ LANGUAGES = [
("de-de", _("German")), ("de-de", _("German")),
("es", _("Spanish")), ("es", _("Spanish")),
("fr-fr", _("French")), ("fr-fr", _("French")),
("zh-cn", _("Simplified Chinese")), ("zh-hans", _("Simplified Chinese")),
] ]

View file

@ -13,7 +13,7 @@ MAX_SIGNATURE_AGE = 300
def create_key_pair(): 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 random_generator = Random.new().read
key = RSA.generate(1024, random_generator) key = RSA.generate(1024, random_generator)
private_key = key.export_key().decode("utf8") private_key = key.export_key().decode("utf8")
@ -23,7 +23,7 @@ def create_key_pair():
def make_signature(sender, destination, date, digest): 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) inbox_parts = urlparse(destination)
signature_headers = [ signature_headers = [
"(request-target): post %s" % inbox_parts.path, "(request-target): post %s" % inbox_parts.path,
@ -44,14 +44,14 @@ def make_signature(sender, destination, date, digest):
def make_digest(data): 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( return "SHA-256=" + b64encode(hashlib.sha256(data.encode("utf-8")).digest()).decode(
"utf-8" "utf-8"
) )
def verify_digest(request): 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) algorithm, digest = request.headers["digest"].split("=", 1)
if algorithm == "SHA-256": if algorithm == "SHA-256":
hash_function = hashlib.sha256 hash_function = hashlib.sha256
@ -66,7 +66,7 @@ def verify_digest(request):
class Signature: class Signature:
""" read and validate incoming signatures """ """read and validate incoming signatures"""
def __init__(self, key_id, headers, signature): def __init__(self, key_id, headers, signature):
self.key_id = key_id self.key_id = key_id
@ -75,7 +75,7 @@ class Signature:
@classmethod @classmethod
def parse(cls, request): def parse(cls, request):
""" extract and parse a signature from an http request """ """extract and parse a signature from an http request"""
signature_dict = {} signature_dict = {}
for pair in request.headers["Signature"].split(","): for pair in request.headers["Signature"].split(","):
k, v = pair.split("=", 1) k, v = pair.split("=", 1)
@ -92,7 +92,7 @@ class Signature:
return cls(key_id, headers, signature) return cls(key_id, headers, signature)
def verify(self, public_key, request): def verify(self, public_key, request):
""" verify rsa signature """ """verify rsa signature"""
if http_date_age(request.headers["date"]) > MAX_SIGNATURE_AGE: if http_date_age(request.headers["date"]) > MAX_SIGNATURE_AGE:
raise ValueError("Request too old: %s" % (request.headers["date"],)) raise ValueError("Request too old: %s" % (request.headers["date"],))
public_key = RSA.import_key(public_key) public_key = RSA.import_key(public_key)
@ -118,7 +118,7 @@ class Signature:
def http_date_age(datestr): 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") parsed = datetime.datetime.strptime(datestr, "%a, %d %b %Y %H:%M:%S GMT")
delta = datetime.datetime.utcnow() - parsed delta = datetime.datetime.utcnow() - parsed
return delta.total_seconds() return delta.total_seconds()

View file

@ -1,6 +1,5 @@
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
scroll-padding-top: 20%;
} }
body { body {
@ -30,6 +29,40 @@ body {
min-width: 75% !important; 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 /** Shelving
******************************************************************************/ ******************************************************************************/
@ -86,6 +119,13 @@ body {
} }
} }
/** Stars
******************************************************************************/
.stars {
white-space: nowrap;
}
/** Stars in a review form /** Stars in a review form
* *
* Specificity makes hovering taking over checked inputs. * Specificity makes hovering taking over checked inputs.
@ -256,3 +296,53 @@ body {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; 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;
}
}

View file

@ -6,7 +6,7 @@ from bookwyrm.sanitize_html import InputHtmlParser
def create_generated_note(user, content, mention_books=None, privacy="public"): 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 # sanitize input html
parser = InputHtmlParser() parser = InputHtmlParser()
parser.feed(content) parser.feed(content)

View file

@ -67,31 +67,16 @@
</div> </div>
{% endif %} {% endif %}
<section class="content is-clipped"> <section class="is-clipped">
<dl> {% with book=book %}
{% if book.isbn_13 %} <div class="content">
<div class="is-flex is-justify-content-space-between is-align-items-center"> {% include 'book/publisher_info.html' %}
<dt>{% trans "ISBN:" %}</dt>
<dd itemprop="isbn">{{ book.isbn_13 }}</dd>
</div> </div>
{% endif %}
{% if book.oclc_number %} <div class="my-3">
<div class="is-flex is-justify-content-space-between is-align-items-center"> {% include 'book/book_identifiers.html' %}
<dt>{% trans "OCLC Number:" %}</dt>
<dd>{{ book.oclc_number }}</dd>
</div> </div>
{% endif %} {% endwith %}
{% if book.asin %}
<div class="is-flex is-justify-content-space-between is-align-items-center">
<dt>{% trans "ASIN:" %}</dt>
<dd>{{ book.asin }}</dd>
</div>
{% endif %}
</dl>
{% include 'book/publisher_info.html' with book=book %}
{% if book.openlibrary_key %} {% if book.openlibrary_key %}
<p><a href="https://openlibrary.org/books/{{ book.openlibrary_key }}" target="_blank" rel="noopener">{% trans "View on OpenLibrary" %}</a></p> <p><a href="https://openlibrary.org/books/{{ book.openlibrary_key }}" target="_blank" rel="noopener">{% trans "View on OpenLibrary" %}</a></p>
@ -261,7 +246,36 @@
</div> </div>
<div class="block" id="reviews"> <div class="block" id="reviews">
{% for review in reviews %} {% if request.user.is_authenticated %}
<nav class="tabs">
<ul>
{% url 'book' book.id as tab_url %}
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
<a href="{{ tab_url }}">{% trans "Reviews" %} ({{ review_count }})</a>
</li>
{% if user_statuses.review_count %}
{% url 'book-user-statuses' book.id 'review' as tab_url %}
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
<a href="{{ tab_url }}">{% trans "Your reviews" %} ({{ user_statuses.review_count }})</a>
</li>
{% endif %}
{% if user_statuses.comment_count %}
{% url 'book-user-statuses' book.id 'comment' as tab_url %}
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
<a href="{{ tab_url }}">{% trans "Your comments" %} ({{ user_statuses.comment_count }})</a>
</li>
{% endif %}
{% if user_statuses.quotation_count %}
{% url 'book-user-statuses' book.id 'quote' as tab_url %}
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
<a href="{{ tab_url }}">{% trans "Your quotes" %} ({{ user_statuses.quotation_count }})</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% for review in statuses %}
<div <div
class="block" class="block"
itemprop="review" itemprop="review"
@ -302,7 +316,7 @@
{% endfor %} {% endfor %}
</div> </div>
<div class="block"> <div class="block">
{% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %} {% include 'snippets/pagination.html' with page=statuses path=request.path anchor="#reviews" %}
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,27 @@
{% spaceless %}
{% load i18n %}
<dl>
{% if book.isbn_13 %}
<div class="is-flex">
<dt class="mr-1">{% trans "ISBN:" %}</dt>
<dd itemprop="isbn">{{ book.isbn_13 }}</dd>
</div>
{% endif %}
{% if book.oclc_number %}
<div class="is-flex">
<dt class="mr-1">{% trans "OCLC Number:" %}</dt>
<dd>{{ book.oclc_number }}</dd>
</div>
{% endif %}
{% if book.asin %}
<div class="is-flex">
<dt class="mr-1">{% trans "ASIN:" %}</dt>
<dd>{{ book.asin }}</dd>
</div>
{% endif %}
</dl>
{% endspaceless %}

View file

@ -109,7 +109,10 @@
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
<p class="mb-2"><label class="label" for="id_series">{% trans "Series:" %}</label> {{ form.series }} </p> <p class="mb-2">
<label class="label" for="id_series">{% trans "Series:" %}</label>
<input type="text" class="input" name="series" id="id_series" value="{{ form.series.value|default:'' }}">
</p>
{% for error in form.series.errors %} {% for error in form.series.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}

View file

@ -25,7 +25,18 @@
{{ book.title }} {{ book.title }}
</a> </a>
</h2> </h2>
{% include 'book/publisher_info.html' with book=book %}
{% with book=book %}
<div class="columns is-multiline">
<div class="column is-half">
{% include 'book/publisher_info.html' %}
</div>
<div class="column is-half ">
{% include 'book/book_identifiers.html' %}
</div>
</div>
{% endwith %}
</div> </div>
<div class="column is-3"> <div class="column is-3">
{% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True %} {% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True %}

View file

@ -1,6 +1,7 @@
{% spaceless %} {% spaceless %}
{% load i18n %} {% load i18n %}
{% load humanize %}
<p> <p>
{% with format=book.physical_format pages=book.pages %} {% with format=book.physical_format pages=book.pages %}
@ -39,7 +40,7 @@
{% endif %} {% endif %}
<p> <p>
{% 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 %} {% if date or book.first_published_date %}
<meta <meta
itemprop="datePublished" itemprop="datePublished"

View file

@ -23,7 +23,7 @@
<div class="dropdown-menu"> <div class="dropdown-menu">
<ul <ul
id="menu-options-{{ uuid }}" id="menu-options-{{ uuid }}"
class="dropdown-content" class="dropdown-content p-0 is-clipped"
role="menu" role="menu"
> >
{% block dropdown-list %}{% endblock %} {% block dropdown-list %}{% endblock %}

View file

@ -41,7 +41,7 @@
<div class="columns is-multiline"> <div class="columns is-multiline">
{% for user in users %} {% for user in users %}
<div class="column is-one-third"> <div class="column is-one-third">
<div class="card block"> <div class="card is-stretchable">
<div class="card-content"> <div class="card-content">
<div class="media"> <div class="media">
<a href="{{ user.local_path }}" class="media-left"> <a href="{{ user.local_path }}" class="media-left">
@ -56,13 +56,13 @@
</div> </div>
</div> </div>
<div class="content"> <div>
{% if user.summary %} {% if user.summary %}
{{ user.summary | to_markdown | safe | truncatechars_html:40 }} {{ user.summary | to_markdown | safe | truncatechars_html:40 }}
{% else %}&nbsp;{% endif %} {% else %}&nbsp;{% endif %}
</div> </div>
</div> </div>
<footer class="card-footer content"> <footer class="card-footer">
{% if user != request.user %} {% if user != request.user %}
{% if user.mutuals %} {% if user.mutuals %}
<div class="card-footer-item"> <div class="card-footer-item">

View file

@ -1,7 +1,7 @@
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% load i18n %} {% load i18n %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="{% get_lang %}">
<head> <head>
<title>{% block title %}BookWyrm{% endblock %} | {{ site.name }}</title> <title>{% block title %}BookWyrm{% endblock %} | {{ site.name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">

View file

@ -13,10 +13,10 @@
<div class="columns mt-3"> <div class="columns mt-3">
<section class="column is-three-quarters"> <section class="column is-three-quarters">
{% if not items.exists %} {% if not items.object_list.exists %}
<p>{% trans "This list is currently empty" %}</p> <p>{% trans "This list is currently empty" %}</p>
{% else %} {% else %}
<ol> <ol start="{{ items.start_index }}">
{% for item in items %} {% for item in items %}
<li class="block pb-3"> <li class="block pb-3">
<div class="card"> <div class="card">
@ -30,11 +30,27 @@
{% include 'snippets/shelve_button/shelve_button.html' with book=item.book %} {% include 'snippets/shelve_button/shelve_button.html' with book=item.book %}
</div> </div>
</div> </div>
<div class="card-footer has-background-white-bis"> <div class="card-footer has-background-white-bis is-align-items-baseline">
<div class="card-footer-item"> <div class="card-footer-item">
<div>
<p>{% blocktrans with username=item.user.display_name user_path=user.local_path %}Added by <a href="{{ user_path }}">{{ username }}</a>{% endblocktrans %}</p> <p>{% blocktrans with username=item.user.display_name user_path=user.local_path %}Added by <a href="{{ user_path }}">{{ username }}</a>{% endblocktrans %}</p>
</div>
</div> </div>
{% if list.user == request.user or list.curation == 'open' and item.user == request.user %} {% if list.user == request.user or list.curation == 'open' and item.user == request.user %}
<div class="card-footer-item">
<form name="set-position" method="post" action="{% url 'list-set-book-position' item.id %}">
<div class="field has-addons mb-0">
{% csrf_token %}
<div class="control">
<input id="input-list-position" class="input is-small" type="number" min="1" name="position" value="{{ item.order }}">
</div>
<div class="control">
<button type="submit" class="button is-info is-small is-tablet">{% trans "Set" %}</button>
</div>
</div>
<label for="input-list-position" class="help">{% trans "List position" %}</label>
</form>
</div>
<form name="add-book" method="post" action="{% url 'list-remove-book' list.id %}" class="card-footer-item"> <form name="add-book" method="post" action="{% url 'list-remove-book' list.id %}" class="card-footer-item">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="item" value="{{ item.id }}"> <input type="hidden" name="item" value="{{ item.id }}">
@ -47,10 +63,27 @@
{% endfor %} {% endfor %}
</ol> </ol>
{% endif %} {% endif %}
{% include "snippets/pagination.html" with page=items %}
</section> </section>
{% if request.user.is_authenticated and not list.curation == 'closed' or request.user == list.user %}
<section class="column is-one-quarter content"> <section class="column is-one-quarter content">
<h2>{% trans "Sort List" %}</h2>
<form name="sort" action="{% url 'list' list.id %}" method="GET" class="block">
<label class="label" for="id_sort_by">{% trans "Sort By" %}</label>
<div class="select is-fullwidth">
{{ sort_form.sort_by }}
</div>
<label class="label" for="id_direction">{% trans "Direction" %}</label>
<div class="select is-fullwidth">
{{ sort_form.direction }}
</div>
<div>
<button class="button is-primary is-fullwidth" type="submit">
{% trans "Sort List" %}
</button>
</div>
</form>
{% if request.user.is_authenticated and not list.curation == 'closed' or request.user == list.user %}
<h2>{% if list.curation == 'open' or request.user == list.user %}{% trans "Add Books" %}{% else %}{% trans "Suggest Books" %}{% endif %}</h2> <h2>{% if list.curation == 'open' or request.user == list.user %}{% trans "Add Books" %}{% else %}{% trans "Suggest Books" %}{% endif %}</h2>
<form name="search" action="{% url 'list' list.id %}" method="GET" class="block"> <form name="search" action="{% url 'list' list.id %}" method="GET" class="block">
<div class="field has-addons"> <div class="field has-addons">
@ -93,7 +126,7 @@
</div> </div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</section>
{% endif %} {% endif %}
</section>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -15,76 +15,9 @@
{% include 'moderation/report_preview.html' with report=report %} {% include 'moderation/report_preview.html' with report=report %}
</div> </div>
<div class="block columns"> {% include 'user_admin/user_info.html' with user=report.user %}
<div class="column is-flex is-flex-direction-column">
<h4 class="title is-4">{% trans "User details" %}</h4>
<div class="box is-flex-grow-1">
{% include 'user/user_preview.html' with user=report.user %}
{% if report.user.summary %}
<div class="box content has-background-white-ter is-shadowless">
{{ report.user.summary | to_markdown | safe }}
</div>
{% endif %}
<p class="mt-2"><a href="{{ report.user.local_path }}">{% trans "View user profile" %}</a></p> {% include 'user_admin/user_moderation_actions.html' with user=report.user %}
</div>
</div>
{% if not report.user.local %}
{% with server=report.user.federated_server %}
<div class="column is-half is-flex is-flex-direction-column">
<h4 class="title is-4">{% trans "Instance details" %}</h4>
<div class="box content is-flex-grow-1">
{% if server %}
<h5>{{ server.server_name }}</h5>
<dl>
<div class="is-flex">
<dt>{% trans "Software:" %}</dt>
<dd>{{ server.application_type }}</dd>
</div>
<div class="is-flex">
<dt>{% trans "Version:" %}</dt>
<dd>{{ server.application_version }}</dd>
</div>
<div class="is-flex">
<dt>{% trans "Status:" %}</dt>
<dd>{{ server.status }}</dd>
</div>
</dl>
{% if server.notes %}
<h5>{% trans "Notes" %}</h5>
<div class="box content has-background-white-ter is-shadowless">
{{ server.notes }}
</div>
{% endif %}
<p>
<a href="{% url 'settings-federated-server' server.id %}">{% trans "View instance" %}</a>
</p>
{% else %}
<em>{% trans "Not set" %}</em>
{% endif %}
</div>
</div>
{% endwith %}
{% endif %}
</div>
<div class="block content">
<h3>{% trans "Actions" %}</h3>
<div class="is-flex">
<p class="mr-1">
<a class="button" href="{% url 'direct-messages-user' report.user.username %}">{% trans "Send direct message" %}</a>
</p>
<form name="deactivate" method="post" action="{% url 'settings-report-deactivate' report.id %}">
{% csrf_token %}
{% if report.user.is_active %}
<button type="submit" class="button is-danger is-light">{% trans "Deactivate user" %}</button>
{% else %}
<button class="button">{% trans "Reactivate user" %}</button>
{% endif %}
</form>
</div>
</div>
<div class="block"> <div class="block">
<h3 class="title is-4">{% trans "Moderator Comments" %}</h3> <h3 class="title is-4">{% trans "Moderator Comments" %}</h3>
@ -118,7 +51,7 @@
{% for status in report.statuses.select_subclasses.all %} {% for status in report.statuses.select_subclasses.all %}
<li> <li>
{% if status.deleted %} {% if status.deleted %}
<em>{% trans "Statuses has been deleted" %}</em> <em>{% trans "Status has been deleted" %}</em>
{% else %} {% else %}
{% include 'snippets/status/status.html' with status=status moderation_mode=True %} {% include 'snippets/status/status.html' with status=status moderation_mode=True %}
{% endif %} {% endif %}

View file

@ -30,7 +30,7 @@
</ul> </ul>
</div> </div>
{% include 'settings/user_admin_filters.html' %} {% include 'user_admin/user_admin_filters.html' %}
<div class="block"> <div class="block">
{% if not reports %} {% if not reports %}

View file

@ -123,7 +123,7 @@
{% include 'snippets/status_preview.html' with status=related_status %} {% include 'snippets/status_preview.html' with status=related_status %}
</div> </div>
<div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}"> <div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}">
{{ related_status.published_date | post_date }} {{ related_status.published_date|timesince }}
{% include 'snippets/privacy-icons.html' with item=related_status %} {% include 'snippets/privacy-icons.html' with item=related_status %}
</div> </div>
</div> </div>

View file

@ -1,7 +1,8 @@
{% load i18n %} {% load i18n %}
{% load bookwyrm_tags %}
{% if book.authors %} {% if book.authors %}
{% blocktrans with path=book.local_path title=book.title %}<a href="{{ path }}">{{ title }}</a> by {% endblocktrans %}{% include 'snippets/authors.html' with book=book %} {% blocktrans with path=book.local_path title=book|title %}<a href="{{ path }}">{{ title }}</a> by {% endblocktrans %}{% include 'snippets/authors.html' with book=book %}
{% else %} {% else %}
<a href="{{ book.local_path }}">{{ book.title }}</a> <a href="{{ book.local_path }}">{{ book|title }}</a>
{% endif %} {% endif %}

View file

@ -4,18 +4,16 @@
{% with status.id|uuid as uuid %} {% with status.id|uuid as uuid %}
<form name="boost" action="/boost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}is-hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}"> <form name="boost" action="/boost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}is-hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
{% csrf_token %} {% csrf_token %}
<button class="button is-small" type="submit" {% if not status.boostable %}disabled{% endif %}> <button class="button is-small is-light is-transparent" type="submit" {% if not status.boostable %}disabled{% endif %}>
<span class="icon icon-boost" title="{% trans 'Boost status' %}"> <span class="icon icon-boost m-0-mobile" title="{% trans 'Boost' %}"></span>
<span class="is-sr-only">{% trans "Boost status" %}</span> <span class="is-sr-only-mobile">{% trans "Boost" %}</span>
</span>
</button> </button>
</form> </form>
<form name="unboost" action="/unboost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} active {% if not request.user|boosted:status %}is-hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}"> <form name="unboost" action="/unboost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} active {% if not request.user|boosted:status %}is-hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
{% csrf_token %} {% csrf_token %}
<button class="button is-small is-primary" type="submit"> <button class="button is-small is-light is-transparent" type="submit">
<span class="icon icon-boost" title="{% trans 'Un-boost status' %}"> <span class="icon icon-boost has-text-primary m-0-mobile" title="{% trans 'Un-boost' %}"></span>
<span class="is-sr-only">{% trans "Un-boost status" %}</span> <span class="is-sr-only-mobile">{% trans "Un-boost" %}</span>
</span>
</button> </button>
</form> </form>
{% endwith %} {% endwith %}

View file

@ -6,14 +6,16 @@
<input type="hidden" name="user" value="{{ request.user.id }}"> <input type="hidden" name="user" value="{{ request.user.id }}">
<input type="hidden" name="reply_parent" value="{% firstof draft.reply_parent.id reply_parent.id %}"> <input type="hidden" name="reply_parent" value="{% firstof draft.reply_parent.id reply_parent.id %}">
{% if type == 'review' %} {% if type == 'review' %}
<div class="control"> <div class="field">
<label class="label" for="id_name_{{ book.id }}_{{ type }}">{% trans "Title" %}:</label> <label class="label" for="id_name_{{ book.id }}_{{ type }}">{% trans "Title" %}:</label>
<input type="text" name="name" maxlength="255" class="input" required="" id="id_name_{{ book.id }}_{{ type }}" placeholder="My {{ type }} of '{{ book.title }}'" value="{% firstof draft.name ''%}"> <div class="control">
<input type="text" name="name" maxlength="255" class="input" required="" id="id_name_{{ book.id }}_{{ type }}" placeholder="My {{ type }} of '{{ book.title }}'" value="{% firstof draft.name ''%}">
</div>
</div> </div>
{% endif %} {% endif %}
<div class="control"> <div class="field">
{% if type != 'reply' and type != 'direct' %} {% if type != 'reply' and type != 'direct' %}
<label class="label" for="id_{% if type == 'quotation' %}quote{% else %}content{% endif %}_{{ book.id }}_{{ type }}"> <label class="label{% if type == 'review' %} mb-0{% endif %}" for="id_{% if type == 'quotation' %}quote{% else %}content{% endif %}_{{ book.id }}_{{ type }}">
{% if type == 'comment' %} {% if type == 'comment' %}
{% trans "Comment:" %} {% trans "Comment:" %}
{% elif type == 'quotation' %} {% elif type == 'quotation' %}
@ -25,28 +27,37 @@
{% endif %} {% endif %}
{% if type == 'review' %} {% if type == 'review' %}
<fieldset> <fieldset class="mb-1">
<legend class="is-sr-only">{% trans "Rating" %}</legend> <legend class="is-sr-only">{% trans "Rating" %}</legend>
{% include 'snippets/form_rate_stars.html' with book=book type=type|default:'summary' default_rating=draft.rating %} {% include 'snippets/form_rate_stars.html' with book=book type=type|default:'summary' default_rating=draft.rating %}
</fieldset> </fieldset>
{% endif %} {% endif %}
{% if type == 'quotation' %} <div class="control">
<textarea name="quote" class="textarea" id="id_quote_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required>{{ draft.quote|default:'' }}</textarea> {% if type == 'quotation' %}
{% else %} <textarea name="quote" class="textarea" id="id_quote_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required>{{ draft.quote|default:'' }}</textarea>
{% include 'snippets/content_warning_field.html' with parent_status=status %} {% elif type == 'reply' %}
<textarea name="content" class="textarea" id="id_content_{{ type }}-{{ book.id }}{{reply_parent.id}}" placeholder="{{ placeholder }}" {% if type == 'reply' %} aria-label="Reply"{% endif %} required>{% if reply_parent %}{{ reply_parent|mentions:request.user }}{% endif %}{% if mentions %}@{{ mentions|username }} {% endif %}{{ draft.content|default:'' }}</textarea> {% include 'snippets/content_warning_field.html' with parent_status=status %}
{% endif %} <textarea name="content" class="textarea" id="id_content_{{ type }}-{{ book.id }}{{reply_parent.id}}" placeholder="{{ placeholder }}" aria-label="{% trans 'Reply' %}" required>{% if reply_parent %}{{ reply_parent|mentions:request.user }}{% endif %}{% if mentions %}@{{ mentions|username }} {% endif %}{{ draft.content|default:'' }}</textarea>
{% else %}
{% include 'snippets/content_warning_field.html' with parent_status=status %}
<textarea name="content" class="textarea" id="id_content_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required>{{ draft.content|default:'' }}</textarea>
{% endif %}
</div>
</div> </div>
{# Supplemental fields #}
{% if type == 'quotation' %} {% if type == 'quotation' %}
<div class="control"> <div class="field">
<label class="label" for="id_content_quote-{{ book.id }}">{% trans "Comment" %}:</label> <label class="label" for="id_content_quote-{{ book.id }}">{% trans "Comment" %}:</label>
{% include 'snippets/content_warning_field.html' with parent_status=status %} {% include 'snippets/content_warning_field.html' with parent_status=status %}
<textarea name="content" class="textarea is-small" id="id_content_quote-{{ book.id }}">{{ draft.content|default:'' }}</textarea> <div class="control">
<textarea name="content" class="textarea" rows="3" id="id_content_quote-{{ book.id }}">{{ draft.content|default:'' }}</textarea>
</div>
</div> </div>
{% elif type == 'comment' %} {% elif type == 'comment' %}
<div class="control"> <div>
{% active_shelf book as active_shelf %} {% active_shelf book as active_shelf %}
{% if active_shelf.shelf.identifier == 'reading' and book.latest_readthrough %} {% if active_shelf.shelf.identifier == 'reading' and book.latest_readthrough %}
@ -58,11 +69,13 @@
<div class="control"> <div class="control">
<input aria-label="{% if draft.progress_mode == 'PG' or readthrough.progress_mode == 'PG' %}Current page{% else %}Percent read{% endif %}" class="input" type="number" min="0" name="progress" size="3" value="{% firstof draft.progress readthrough.progress '' %}" id="progress-{{ uuid }}"> <input aria-label="{% if draft.progress_mode == 'PG' or readthrough.progress_mode == 'PG' %}Current page{% else %}Percent read{% endif %}" class="input" type="number" min="0" name="progress" size="3" value="{% firstof draft.progress readthrough.progress '' %}" id="progress-{{ uuid }}">
</div> </div>
<div class="control select"> <div class="control">
<select name="progress_mode" aria-label="Progress mode"> <div class="select">
<option value="PG" {% if draft.progress_mode == 'PG' or readthrough.progress_mode == 'PG' %}selected{% endif %}>{% trans "pages" %}</option> <select name="progress_mode" aria-label="Progress mode">
<option value="PCT" {% if draft.progress_mode == 'PCT' or readthrough.progress_mode == 'PCT' %}selected{% endif %}>{% trans "percent" %}</option> <option value="PG" {% if draft.progress_mode == 'PG' or readthrough.progress_mode == 'PG' %}selected{% endif %}>{% trans "pages" %}</option>
</select> <option value="PCT" {% if draft.progress_mode == 'PCT' or readthrough.progress_mode == 'PCT' %}selected{% endif %}>{% trans "percent" %}</option>
</select>
</div>
</div> </div>
</div> </div>
{% if readthrough.progress_mode == 'PG' and book.pages %} {% if readthrough.progress_mode == 'PG' and book.pages %}
@ -73,9 +86,12 @@
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
<input type="checkbox" class="is-hidden" name="sensitive" id="id_show_spoilers-{{ uuid }}" {% if draft.content_warning or status.content_warning %}checked{% endif %} aria-hidden="true">
{# bottom bar #} {# bottom bar #}
<div class="columns pt-1"> <input type="checkbox" class="is-hidden" name="sensitive" id="id_show_spoilers-{{ uuid }}" {% if draft.content_warning or status.content_warning %}checked{% endif %} aria-hidden="true">
<div class="columns mt-1">
<div class="field has-addons column"> <div class="field has-addons column">
<div class="control"> <div class="control">
{% trans "Include spoiler alert" as button_text %} {% trans "Include spoiler alert" as button_text %}

View file

@ -3,18 +3,17 @@
{% with status.id|uuid as uuid %} {% with status.id|uuid as uuid %}
<form name="favorite" action="/favorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} {% if request.user|liked:status %}is-hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}"> <form name="favorite" action="/favorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} {% if request.user|liked:status %}is-hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
{% csrf_token %} {% csrf_token %}
<button class="button is-small" type="submit"> <button class="button is-small is-light is-transparent" type="submit">
<span class="icon icon-heart" title="{% trans 'Like status' %}"> <span class="icon icon-heart m-0-mobile" title="{% trans 'Like' %}">
<span class="is-sr-only">{% trans "Like status" %}</span>
</span> </span>
<span class="is-sr-only-mobile">{% trans "Like" %}</span>
</button> </button>
</form> </form>
<form name="unfavorite" action="/unfavorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} active {% if not request.user|liked:status %}is-hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}"> <form name="unfavorite" action="/unfavorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} active {% if not request.user|liked:status %}is-hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
{% csrf_token %} {% csrf_token %}
<button class="button is-primary is-small" type="submit"> <button class="button is-light is-transparent is-small" type="submit">
<span class="icon icon-heart" title="{% trans 'Un-like status' %}"> <span class="icon icon-heart has-text-primary m-0-mobile" title="{% trans 'Un-like' %}"></span>
<span class="is-sr-only">{% trans "Un-like status" %}</span> <span class="is-sr-only-mobile">{% trans "Un-like" %}</span>
</span>
</button> </button>
</form> </form>
{% endwith %} {% endwith %}

View file

@ -1,10 +1,10 @@
{% load i18n %} {% load i18n %}
{% if rating %} {% if rating %}
{% blocktrans with book_title=book.title display_rating=rating|floatformat:"0" review_title=name count counter=rating %}Review of "{{ book_title }}" ({{ display_rating }} star): {{ review_title }}{% plural %}Review of "{{ book_title }}" ({{ display_rating }} stars): {{ review_title }}{% endblocktrans %} {% blocktrans with book_title=book.title|safe display_rating=rating|floatformat:"0" review_title=name|safe count counter=rating %}Review of "{{ book_title }}" ({{ display_rating }} star): {{ review_title }}{% plural %}Review of "{{ book_title }}" ({{ display_rating }} stars): {{ review_title }}{% endblocktrans %}
{% else %} {% else %}
{% blocktrans with book_title=book.title review_title=name %}Review of "{{ book_title }}": {{ review_title }}{% endblocktrans %} {% blocktrans with book_title=book.title|safe review_title=name|safe %}Review of "{{ book_title }}": {{ review_title }}{% endblocktrans %}
{% endif %} {% endif %}

View file

@ -7,23 +7,23 @@
{% block dropdown-list %} {% block dropdown-list %}
{% for shelf in request.user.shelf_set.all %} {% for shelf in request.user.shelf_set.all %}
<li role="menuitem"> <li role="menuitem" class="dropdown-item p-0">
<form class="dropdown-item pt-0 pb-0" name="shelve" action="/shelve/" method="post"> <form name="shelve" action="/shelve/" method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}"> <input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="change-shelf-from" value="{{ current.identifier }}"> <input type="hidden" name="change-shelf-from" value="{{ current.identifier }}">
<input type="hidden" name="shelf" value="{{ shelf.identifier }}"> <input type="hidden" name="shelf" value="{{ shelf.identifier }}">
<button class="button is-fullwidth is-small shelf-option" type="submit" {% if shelf.identifier == current.identifier %}disabled{% endif %}><span>{{ shelf.name }}</span></button> <button class="button is-fullwidth is-small shelf-option is-radiusless is-white" type="submit" {% if shelf.identifier == current.identifier %}disabled{% endif %}><span>{{ shelf.name }}</span></button>
</form> </form>
</li> </li>
{% endfor %} {% endfor %}
<li class="navbar-divider" role="presentation"></li> <li class="navbar-divider" role="separator"></li>
<li> <li role="menuitem" class="dropdown-item p-0">
<form class="dropdown-item pt-0 pb-0" name="shelve" action="/unshelve/" method="post"> <form name="shelve" action="/unshelve/" method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}"> <input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="shelf" value="{{ current.id }}"> <input type="hidden" name="shelf" value="{{ current.id }}">
<button class="button is-fullwidth is-small is-danger is-light" type="submit">{% trans "Remove" %}</button> <button class="button is-fullwidth is-small is-radiusless is-danger is-light" type="submit">{% trans "Remove" %}</button>
</form> </form>
</li> </li>
{% endblock %} {% endblock %}

View file

@ -7,5 +7,5 @@
{% endblock %} {% endblock %}
{% block dropdown-list %} {% block dropdown-list %}
{% include 'snippets/shelve_button/shelve_button_options.html' with active_shelf=active_shelf shelves=request.user.shelf_set.all dropdown=True class="shelf-option is-fullwidth is-small" %} {% include 'snippets/shelve_button/shelve_button_options.html' with active_shelf=active_shelf shelves=request.user.shelf_set.all dropdown=True class="shelf-option is-fullwidth is-small is-radiusless is-white" %}
{% endblock %} {% endblock %}

View file

@ -2,8 +2,8 @@
{% load i18n %} {% load i18n %}
{% for shelf in shelves %} {% for shelf in shelves %}
{% comparison_bool shelf.identifier active_shelf.shelf.identifier as is_current %} {% comparison_bool shelf.identifier active_shelf.shelf.identifier as is_current %}
{% if dropdown %}<li role="menuitem">{% endif %} {% if dropdown %}<li role="menuitem" class="dropdown-item p-0">{% endif %}
<div class="{% if dropdown %}dropdown-item pt-0 pb-0{% elif active_shelf.shelf.identifier|next_shelf != shelf.identifier %}is-hidden{% endif %}"> <div class="{% if not dropdown and active_shelf.shelf.identifier|next_shelf != shelf.identifier %}is-hidden{% endif %}">
{% if shelf.identifier == 'reading' %}{% if not dropdown or active_shelf.shelf.identifier|next_shelf != shelf.identifier %} {% if shelf.identifier == 'reading' %}{% if not dropdown or active_shelf.shelf.identifier|next_shelf != shelf.identifier %}
{% trans "Start reading" as button_text %} {% trans "Start reading" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="start-reading" controls_uid=button_uuid focus="modal-title-start-reading" disabled=is_current %} {% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="start-reading" controls_uid=button_uuid focus="modal-title-start-reading" disabled=is_current %}
@ -30,24 +30,20 @@
{% if dropdown %} {% if dropdown %}
{% if readthrough and active_shelf.shelf.identifier != 'read' %} {% if readthrough and active_shelf.shelf.identifier != 'read' %}
<li role="menuitem"> <li role="menuitem" class="dropdown-item p-0">
<div class="dropdown-item pt-0 pb-0"> {% trans "Update progress" as button_text %}
{% trans "Update progress" as button_text %} {% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="progress-update" controls_uid=button_uuid focus="modal-title-progress-update" %}
{% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="progress-update" controls_uid=button_uuid focus="modal-title-progress-update" %}
</div>
</li> </li>
{% endif %} {% endif %}
{% if active_shelf.shelf %} {% if active_shelf.shelf %}
<li role="menuitem"> <li role="menuitem" class="dropdown-item p-0">
<div class="dropdown-item pt-0 pb-0"> <form name="shelve" action="/unshelve/" method="post">
<form name="shelve" action="/unshelve/" method="post"> {% csrf_token %}
{% csrf_token %} <input type="hidden" name="book" value="{{ active_shelf.book.id }}">
<input type="hidden" name="book" value="{{ active_shelf.book.id }}"> <input type="hidden" name="shelf" value="{{ active_shelf.shelf.id }}">
<input type="hidden" name="shelf" value="{{ active_shelf.shelf.id }}"> <button class="button is-fullwidth is-small{% if dropdown %} is-radiusless{% endif %} is-danger is-light" type="submit">{% blocktrans with name=active_shelf.shelf.name %}Remove from {{ name }}{% endblocktrans %}</button>
<button class="button is-fullwidth is-small is-danger is-light" type="submit">{% blocktrans with name=active_shelf.shelf.name %}Remove from {{ name }}{% endblocktrans %}</button> </form>
</form>
</div>
</li> </li>
{% endif %} {% endif %}

View file

@ -1,7 +1,7 @@
{% spaceless %} {% spaceless %}
{% load i18n %} {% load i18n %}
<p class="stars"> <span class="stars">
<span class="is-sr-only"> <span class="is-sr-only">
{% if rating %} {% if rating %}
{% blocktranslate trimmed with rating=rating|floatformat count counter=rating|length %} {% blocktranslate trimmed with rating=rating|floatformat count counter=rating|length %}
@ -23,5 +23,5 @@
aria-hidden="true" aria-hidden="true"
></span> ></span>
{% endfor %} {% endfor %}
</p> </span>
{% endspaceless %} {% endspaceless %}

View file

@ -1,14 +0,0 @@
{% load bookwyrm_tags %}
<div class="columns">
<div class="column is-narrow">
<div>
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book %}</a>
{% include 'snippets/stars.html' with rating=book|rating:request.user %}
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div>
</div>
<div class="column">
<h3 class="title is-6">{% include 'snippets/book_titleby.html' with book=book %}</h3>
{% include 'snippets/trimmed_text.html' with full=book|book_description %}
</div>
</div>

View file

@ -0,0 +1,135 @@
{% load bookwyrm_tags %}
{% load i18n %}
{% with status_type=status.status_type %}
<div
class="block"
{% if status_type == "Review" %}
itemprop="rating"
itemtype="https://schema.org/Rating"
{% endif %}
>
<div class="columns">
{% if not hide_book %}
{% with book=status.book|default:status.mention_books.first %}
{% if book %}
<div class="column is-narrow">
<div class="columns is-mobile">
<div class="column is-narrow">
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book %}</a>
{% include 'snippets/stars.html' with rating=book|rating:request.user %}
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div>
<div class="column is-hidden-tablet">
<p>{{ book|book_description|to_markdown|default:""|safe|truncatewords_html:15 }}</p>
</div>
</div>
</div>
{% endif %}
{% endwith %}
{% endif %}
<article class="column">
{% if status_type == 'Review' %}
<header class="mb-2">
<h3
class="title is-5 has-subtitle"
dir="auto"
itemprop="name"
>
{{ status.name|escape }}
</h3>
<h4 class="subtitle is-6">
<span
class="is-hidden"
{% if status_type == "Review" %}
itemprop="reviewRating"
itemscope
itemtype="https://schema.org/Rating"
{% endif %}
>
<meta itemprop="ratingValue" content="{{ status.rating|floatformat }}">
{# @todo Is it possible to not hard-code the value? #}
<meta itemprop="bestRating" content="5">
</span>
{% include 'snippets/stars.html' with rating=status.rating %}
</h4>
</header>
{% endif %}
{% if status.content_warning %}
<div>
<p>{{ status.content_warning }}</p>
{% trans "Show more" as button_text %}
{% with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
{% include 'snippets/toggle/open_button.html' %}
{% endwith %}
</div>
{% endif %}
<div
{% if status.content_warning %}
id="show-status-cw-{{ status.id }}"
class="is-hidden"
{% endif %}
>
{% if status.content_warning %}
{% trans "Show less" as button_text %}
{% with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
{% include 'snippets/toggle/close_button.html' %}
{% endwith %}
{% endif %}
{% if status.quote %}
<div class="quote block">
<blockquote dir="auto" class="content mb-2">{{ status.quote | safe }}</blockquote>
<p> &mdash; {% include 'snippets/book_titleby.html' with book=status.book %}</p>
</div>
{% endif %}
{% if status.content and status_type != 'GeneratedNote' and status_type != 'Announce' %}
{% with full=status.content|safe no_trim=status.content_warning itemprop="reviewBody" %}
{% include 'snippets/trimmed_text.html' %}
{% endwith %}
{% endif %}
{% if status.attachments.exists %}
<div class="block">
<div class="columns">
{% for attachment in status.attachments.all %}
<div class="column is-narrow">
<figure class="image is-128x128">
<a
href="/images/{{ attachment.image }}"
target="_blank"
aria-label="{% trans 'Open image in new window' %}"
>
<img
src="/images/{{ attachment.image }}"
{% if attachment.caption %}
alt="{{ attachment.caption }}"
title="{{ attachment.caption }}"
{% endif %}
>
</a>
</figure>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</article>
</div>
</div>
{% endwith %}

View file

@ -0,0 +1,23 @@
{% spaceless %}
{% load bookwyrm_tags %}
{% load i18n %}
{% if not hide_book %}
{% with book=status.book|default:status.mention_books.first %}
<div class="columns is-mobile">
<div class="column is-narrow">
<div>
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a>
</div>
</div>
<div class="column">
<h3 class="title is-6 mb-1">{% include 'snippets/book_titleby.html' with book=book %}</h3>
<p>{{ book|book_description|to_markdown|default:""|safe|truncatewords_html:20 }}</p>
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div>
</div>
{% endwith %}
{% endif %}
{% endspaceless %}

View file

@ -0,0 +1,76 @@
{% extends 'components/card.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load humanize %}
{% block card-header %}
<div class="card-header-title has-background-white-ter is-block">
{% include 'snippets/status/status_header.html' with status=status %}
</div>
{% endblock %}
{% block card-content %}{% endblock %}
{% block card-footer %}
{% if moderation_mode and perms.bookwyrm.moderate_post %}
<div class="card-footer-item">
{# moderation options #}
<form name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post">
{% csrf_token %}
<button class="button is-danger is-light" type="submit">
{% trans "Delete status" %}
</button>
</form>
</div>
{% elif no_interact %}
{# nothing here #}
{% elif request.user.is_authenticated %}
<div class="card-footer-item">
{% trans "Reply" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with controls_text="show-comment" controls_uid=status.id text=button_text icon_with_text="comment" class="is-small is-light is-transparent toggle-button" focus="id_content_reply" %}
</div>
<div class="card-footer-item">
{% include 'snippets/boost_button.html' with status=status %}
</div>
<div class="card-footer-item">
{% include 'snippets/fav_button.html' with status=status %}
</div>
{% if not moderation_mode %}
<div class="card-footer-item">
{% include 'snippets/status/status_options.html' with class="is-small is-light is-transparent" right=True %}
</div>
{% endif %}
{% else %}
<div class="card-footer-item">
<a href="/login">
<span class="icon icon-comment is-small" title="{% trans 'Reply' %}">
<span class="is-sr-only">{% trans "Reply" %}</span>
</span>
<span class="icon icon-boost is-small ml-4" title="{% trans 'Boost status' %}">
<span class="is-sr-only">{% trans "Boost status" %}</span>
</span>
<span class="icon icon-heart is-small ml-4" title="{% trans 'Like status' %}">
<span class="is-sr-only">{% trans "Like status" %}</span>
</span>
</a>
</div>
{% endif %}
{% endblock %}
{% block card-bonus %}
{% if request.user.is_authenticated and not moderation_mode %}
{% with status.id|uuid as uuid %}
<section class="is-hidden" id="show-comment-{{ status.id }}">
<div class="card-footer">
<div class="card-footer-item">
{% include 'snippets/create_status_form.html' with reply_parent=status type="reply" %}
</div>
</div>
</section>
{% endwith %}
{% endif %}
{% endblock %}

View file

@ -1,90 +1,14 @@
{% extends 'components/card.html' %} {% extends 'snippets/status/layout.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load humanize %}
{% block card-header %}
<h3 class="card-header-title has-background-white-ter is-block">
{% include 'snippets/status/status_header.html' with status=status %}
</h3>
{% endblock %}
{% block card-content %} {% block card-content %}
{% include 'snippets/status/status_content.html' with status=status %} {% with status_type=status.status_type %}
{% endblock %}
{% if status_type == 'GeneratedNote' or status_type == 'Rating' %}
{% block card-footer %} {% include 'snippets/status/generated_status.html' with status=status %}
<div class="card-footer-item"> {% else %}
{% if moderation_mode and perms.bookwyrm.moderate_post %} {% include 'snippets/status/content_status.html' with status=status %}
{# moderation options #}
<form class="dropdown-item pt-0 pb-0" name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post">
{% csrf_token %}
<button class="button is-danger is-light" type="submit">
{% trans "Delete status" %}
</button>
</form>
{% elif no_interact %}
{# nothing here #}
{% elif request.user.is_authenticated %}
<div class="field has-addons">
<div class="control">
{% trans "Reply" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with controls_text="show-comment" controls_uid=status.id text=button_text icon="comment" class="is-small toggle-button" focus="id_content_reply" %}
</div>
<div class="control">
{% include 'snippets/boost_button.html' with status=status %}
</div>
<div class="control">
{% include 'snippets/fav_button.html' with status=status %}
</div>
</div>
{% else %}
<a href="/login">
<span class="icon icon-comment" title="{% trans 'Reply' %}">
<span class="is-sr-only">{% trans "Reply" %}</span>
</span>
<span class="icon icon-boost" title="{% trans 'Boost status' %}">
<span class="is-sr-only">{% trans "Boost status" %}</span>
</span>
<span class="icon icon-heart" title="{% trans 'Like status' %}">
<span class="is-sr-only">{% trans "Like status" %}</span>
</span>
</a>
{% endif %}
</div>
<div class="card-footer-item">
{% include 'snippets/privacy-icons.html' with item=status %}
</div>
<div class="card-footer-item">
<a href="{{ status.remote_id }}">{{ status.published_date | post_date }}</a>
</div>
{% if not moderation_mode %}
<div class="card-footer-item">
{% include 'snippets/status/status_options.html' with class="is-small" right=True %}
</div>
{% endif %} {% endif %}
{% endblock %}
{% block card-bonus %}
{% if request.user.is_authenticated and not moderation_mode %}
{% with status.id|uuid as uuid %}
<section class="is-hidden" id="show-comment-{{ status.id }}">
<div class="card-footer">
<div class="card-footer-item">
{% include 'snippets/create_status_form.html' with reply_parent=status type="reply" %}
</div>
</div>
</section>
{% endwith %} {% endwith %}
{% endif %}
{% endblock %} {% endblock %}

View file

@ -1,137 +0,0 @@
{% spaceless %}
{% load bookwyrm_tags %}
{% load i18n %}
{% with status_type=status.status_type %}
<div
class="block"
{% if status_type == 'Review' %}
{% firstof "reviewBody" as body_prop %}
{% firstof 'itemprop="reviewRating" itemscope itemtype="https://schema.org/Rating"' as rating_type %}
{% endif %}
{% if status_type == 'Rating' %}
itemprop="rating"
itemtype="https://schema.org/Rating"
{% endif %}
>
{% if status_type == 'Review' or status_type == 'Rating' %}
<div>
{% if status.name %}
<h3
class="title is-5 has-subtitle"
dir="auto"
itemprop="name"
>
{{ status.name|escape }}
</h3>
{% endif %}
<span
class="is-sr-only"
{{ rating_type }}
>
<meta itemprop="ratingValue" content="{{ status.rating|floatformat }}">
{% if status_type == 'Rating' %}
{# @todo Is it possible to not hard-code the value? #}
<meta itemprop="bestRating" content="5">
{% endif %}
</span>
{% include 'snippets/stars.html' with rating=status.rating %}
</div>
{% endif %}
{% if status.content_warning %}
<div>
<p>{{ status.content_warning }}</p>
{% trans "Show more" as button_text %}
{% with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
{% include 'snippets/toggle/open_button.html' %}
{% endwith %}
</div>
{% endif %}
<div
{% if status.content_warning %}
id="show-status-cw-{{ status.id }}"
class="is-hidden"
{% endif %}
>
{% if status.content_warning %}
{% trans "Show less" as button_text %}
{% with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
{% include 'snippets/toggle/close_button.html' %}
{% endwith %}
{% endif %}
{% if status.quote %}
<div class="quote block">
<blockquote dir="auto" class="content mb-2">{{ status.quote | safe }}</blockquote>
<p> &mdash; {% include 'snippets/book_titleby.html' with book=status.book %}</p>
</div>
{% endif %}
{% if status.content and status_type != 'GeneratedNote' and status_type != 'Announce' %}
{% with full=status.content|safe no_trim=status.content_warning itemprop=body_prop %}
{% include 'snippets/trimmed_text.html' %}
{% endwith %}
{% endif %}
{% if status.attachments.exists %}
<div class="block">
<div class="columns">
{% for attachment in status.attachments.all %}
<div class="column is-narrow">
<figure class="image is-128x128">
<a
href="/images/{{ attachment.image }}"
target="_blank"
aria-label="{% trans 'Open image in new window' %}"
>
<img
src="/images/{{ attachment.image }}"
{% if attachment.caption %}
alt="{{ attachment.caption }}"
title="{{ attachment.caption }}"
{% endif %}
>
</a>
</figure>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% if not hide_book %}
{% if status.book or status.mention_books.count %}
<div
{% if status_type != 'GeneratedNote' %}
class="box has-background-white-bis"
{% endif %}
>
{% if status.book %}
{% with book=status.book %}
{% include 'snippets/status/book_preview.html' %}
{% endwith %}
{% elif status.mention_books.count %}
{% with book=status.mention_books.first %}
{% include 'snippets/status/book_preview.html' %}
{% endwith %}
{% endif %}
</div>
{% endif %}
{% endif %}
{% endwith %}
{% endspaceless %}

View file

@ -1,53 +1,108 @@
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% load i18n %} {% load i18n %}
<span {% load humanize %}
itemprop="author"
itemscope
itemtype="https://schema.org/Person"
>
<a
href="{{ status.user.local_path }}"
itemprop="url"
>
{% include 'snippets/avatar.html' with user=status.user ariaHide="true" %}
<span itemprop="name">{{ status.user.display_name }}</span> <div class="media">
</a> <figure class="media-left" aria-hidden="true">
</span> <a class="image is-48x48" href="{{ status.user.local_path }}">
{% include 'snippets/avatar.html' with user=status.user ariaHide="true" medium="true" %}
</a>
</figure>
{% if status.status_type == 'GeneratedNote' %} <div class="media-content">
{{ status.content | safe }} <h3 class="has-text-weight-bold">
{% elif status.status_type == 'Rating' %} <span
{% trans "rated" %} itemprop="author"
{% elif status.status_type == 'Review' %} itemscope
{% trans "reviewed" %} itemtype="https://schema.org/Person"
{% elif status.status_type == 'Comment' %} >
{% trans "commented on" %} {% if status.user.avatar %}
{% elif status.status_type == 'Quotation' %} <meta itemprop="image" content="/images/{{ status.user.avatar }}">
{% trans "quoted" %} {% endif %}
{% elif status.reply_parent %}
{% with parent_status=status|parent %}
{% if parent_status.status_type == 'Review' %} <a
{% blocktrans with username=parent_status.user.display_name user_path=parent_status.user.local_path status_path=parent_status.local_path %}replied to <a href="{{ user_path }}">{{ username}}'s</a> <a href="{{ status_path }}">review</a>{% endblocktrans %} href="{{ status.user.local_path }}"
{% elif parent_status.status_type == 'Comment' %} itemprop="url"
{% blocktrans with username=parent_status.user.display_name user_path=parent_status.user.local_path status_path=parent_status.local_path %}replied to <a href="{{ user_path }}">{{ username}}'s</a> <a href="{{ status_path }}">comment</a>{% endblocktrans %} >
{% elif parent_status.status_type == 'Quotation' %} <span itemprop="name">{{ status.user.display_name }}</span>
{% blocktrans with username=parent_status.user.display_name user_path=parent_status.user.local_path status_path=parent_status.local_path %}replied to <a href="{{ user_path }}">{{ username}}'s</a> <a href="{{ status_path }}">quote</a>{% endblocktrans %} </a>
{% else %} </span>
{% blocktrans with username=parent_status.user.display_name user_path=parent_status.user.local_path status_path=parent_status.local_path %}replied to <a href="{{ user_path }}">{{ username}}'s</a> <a href="{{ status_path }}">status</a>{% endblocktrans %}
{% endif %}
{% endwith %} {% if status.status_type == 'GeneratedNote' %}
{% endif %} {{ status.content | safe }}
{% if status.book %} {% elif status.status_type == 'Rating' %}
<a href="/book/{{ status.book.id }}">{{ status.book.title }}</a> {% trans "rated" %}
{% elif status.mention_books %} {% elif status.status_type == 'Review' %}
<a href="/book/{{ status.mention_books.first.id }}">{{ status.mention_books.first.title }}</a> {% trans "reviewed" %}
{% endif %} {% elif status.status_type == 'Comment' %}
{% trans "commented on" %}
{% elif status.status_type == 'Quotation' %}
{% trans "quoted" %}
{% elif status.reply_parent %}
{% with parent_status=status|parent %}
{% if status.progress %} {% blocktrans with username=parent_status.user.display_name user_path=parent_status.user.local_path status_path=parent_status.local_path %}replied to <a href="{{ user_path }}">{{ username}}'s</a> <a href="{{ status_path }}">status</a>{% endblocktrans %}
<p class="help"> {% endwith %}
({% if status.progress_mode == 'PG' %}{% include 'snippets/page_text.html' with page=status.progress total_pages=status.book.pages %}{% else %}{{ status.progress }}%{% endif %}) {% endif %}
</p>
{% endif %} {% if status.book %}
{% if status.status_type == 'GeneratedNote' or status.status_type == 'Rating' %}
<a href="/book/{{ status.book.id }}">{{ status.book|title }}</a>{% if status.status_type == 'Rating' %}:
<span
itemprop="reviewRating"
itemscope
itemtype="https://schema.org/Rating"
>
<span class="is-hidden" {{ rating_type }}>
<meta itemprop="ratingValue" content="{{ status.rating|floatformat }}">
{% if status.book %}
{% if status.status_type == 'GeneratedNote' or status.status_type == 'Rating' %}
<a href="/book/{{ status.book.id }}">{{ status.book|title }}</a>{% if status.status_type == 'Rating' %}:
<span
itemprop="reviewRating"
itemscope
itemtype="https://schema.org/Rating"
>
<span class="is-hidden" {{ rating_type }}>
<meta itemprop="ratingValue" content="{{ status.rating|floatformat }}">
{# @todo Is it possible to not hard-code the value? #}
<meta itemprop="bestRating" content="5">
</span>
{% include 'snippets/stars.html' with rating=status.rating %}
{% endif %}
{% else %}
{% include 'snippets/book_titleby.html' with book=status.book %}
{% endif %}
{% elif status.mention_books %}
<a href="/book/{{ status.mention_books.first.id }}">
{{ status.mention_books.first.title }}
</a>
{% endif %}
{% include 'snippets/stars.html' with rating=status.rating %}
{% endif %}
{% else %}
{% include 'snippets/book_titleby.html' with book=status.book %}
{% endif %}
{% elif status.mention_books %}
<a href="/book/{{ status.mention_books.first.id }}">{{ status.mention_books.first|title }}</a>
{% endif %}
</h3>
<p class="is-size-7 is-flex is-align-items-center">
<a href="{{ status.remote_id }}">{{ status.published_date|timesince }}</a>
{% if status.progress %}
<span class="ml-1">
{% if status.progress_mode == 'PG' %}
({% include 'snippets/page_text.html' with page=status.progress total_pages=status.book.pages %})
{% else %}
({{ status.progress }}%)
{% endif %}
</span>
{% endif %}
{% include 'snippets/privacy-icons.html' with item=status %}
</p>
</div>
</div>

View file

@ -3,27 +3,26 @@
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% block dropdown-trigger %} {% block dropdown-trigger %}
<span class="icon icon-dots-three"> <span class="icon icon-dots-three m-0-mobile"></span>
<span class="is-sr-only">{% trans "More options" %}</span> <span class="is-sr-only-mobile">{% trans "More options" %}</span>
</span>
{% endblock %} {% endblock %}
{% block dropdown-list %} {% block dropdown-list %}
{% if status.user == request.user %} {% if status.user == request.user %}
{# things you can do to your own statuses #} {# things you can do to your own statuses #}
<li role="menuitem"> <li role="menuitem" class="dropdown-item p-0">
<form class="dropdown-item pt-0 pb-0" name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post"> <form name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post">
{% csrf_token %} {% csrf_token %}
<button class="button is-danger is-light is-fullwidth is-small" type="submit"> <button class="button is-radiusless is-danger is-light is-fullwidth is-small" type="submit">
{% trans "Delete status" %} {% trans "Delete status" %}
</button> </button>
</form> </form>
</li> </li>
{% if status.status_type != 'GeneratedNote' and status.status_type != 'Rating' %} {% if status.status_type != 'GeneratedNote' and status.status_type != 'Rating' %}
<li role="menuitem"> <li role="menuitem" class="dropdown-item p-0">
<form class="dropdown-item pt-0 pb-0" name="delete-{{status.id}}" action="{% url 'redraft' status.id %}" method="post"> <form class="" name="delete-{{status.id}}" action="{% url 'redraft' status.id %}" method="post">
{% csrf_token %} {% csrf_token %}
<button class="button is-danger is-light is-fullwidth is-small" type="submit"> <button class="button is-radiusless is-danger is-light is-fullwidth is-small" type="submit">
{% trans "Delete & re-draft" %} {% trans "Delete & re-draft" %}
</button> </button>
</form> </form>
@ -31,13 +30,15 @@
{% endif %} {% endif %}
{% else %} {% else %}
{# things you can do to other people's statuses #} {# things you can do to other people's statuses #}
<li role="menuitem"> <li role="menuitem" class="dropdown-item p-0">
<a href="{% url 'direct-messages-user' status.user|username %}" class="button is-small is-fullwidth">{% trans "Send direct message" %}</a> <a href="{% url 'direct-messages-user' status.user|username %}" class="button is-small is-white is-radiusless is-fullwidth">
{% trans "Send direct message" %}
</a>
</li> </li>
<li role="menuitem"> <li role="menuitem" class="dropdown-item p-0">
{% include 'snippets/report_button.html' with user=status.user status=status %} {% include 'snippets/report_button.html' with user=status.user status=status %}
</li> </li>
<li role="menuitem"> <li role="menuitem" class="dropdown-item p-0">
{% include 'snippets/block_button.html' with user=status.user class="is-fullwidth" %} {% include 'snippets/block_button.html' with user=status.user class="is-fullwidth" %}
</li> </li>
{% endif %} {% endif %}

View file

@ -1,23 +0,0 @@
{% load i18n %}
<div class="control">
<form name="tag" action="/{% if tag.tag.identifier in user_tags %}untag{% else %}tag{% endif %}/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="name" value="{{ tag.tag.name }}">
<div class="tags has-addons">
<a class="tag" href="{{ tag.tag.local_path }}">
{{ tag.tag.name }}
</a>
{% if tag.tag.identifier in user_tags %}
<button class="tag is-delete" type="submit">
<span class="is-sr-only">{% trans "Remove tag" %}</span>
</button>
{% else %}
<button class="tag" type="submit">+
<span class="is-sr-only">{% trans "Add tag" %}</span>
</button>
{% endif %}
</div>
</form>
</div>

View file

@ -10,9 +10,12 @@
> >
{% if icon %} {% if icon %}
<span class="icon icon-{{ icon }}" title="{{ text }}"> <span class="icon icon-{{ icon }} m-0-mobile" title="{{ text }}">
<span class="is-sr-only">{{ text }}</span> <span class="is-sr-only">{{ text }}</span>
</span> </span>
{% elif icon_with_text %}
<span class="icon icon-{{ icon_with_text }} m-0-mobile" title="{{ text }}"></span>
<span class="is-sr-only-mobile">{{ text }}</span>
{% else %} {% else %}
<span>{{ text }}</span> <span>{{ text }}</span>
{% endif %} {% endif %}

View file

@ -1,11 +1,10 @@
{% spaceless %}
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% load i18n %} {% load i18n %}
{% with 0|uuid as uuid %} {% with 0|uuid as uuid %}
{% if full %} {% if full %}
{% with full|to_markdown|safe as full %} {% with full|to_markdown|safe as full %}
{% with full|to_markdown|safe|truncatewords_html:60 as trimmed %} {% with full|to_markdown|safe|truncatewords_html:150 as trimmed %}
{% if not no_trim and trimmed != full %} {% if not no_trim and trimmed != full %}
<div id="hide-full-{{ uuid }}"> <div id="hide-full-{{ uuid }}">
<div class="content" id="trimmed-{{ uuid }}"> <div class="content" id="trimmed-{{ uuid }}">
@ -46,4 +45,3 @@
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% endspaceless %}

View file

@ -1,14 +0,0 @@
{% extends 'layout.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% block title %}{{ tag.name }}{% endblock %}
{% block content %}
<div class="block">
<h1 class="title">{% blocktrans %}Books tagged "{{ tag.name }}"{% endblocktrans %}</h1>
{% include 'snippets/book_tiles.html' with books=books.all %}
</div>
{% endblock %}

View file

@ -68,63 +68,66 @@
<div class="block"> <div class="block">
<div> <div>
{% if books|length > 0 %} {% if books|length > 0 %}
<div class="scroll-x"> <table class="table is-striped is-fullwidth is-mobile">
<table class="table is-striped is-fullwidth"> <thead>
<tr>
<tr class="book-preview"> <th>{% trans "Cover" %}</th>
<th>{% trans "Cover" %}</th> <th>{% trans "Title" %}</th>
<th>{% trans "Title" %}</th> <th>{% trans "Author" %}</th>
<th>{% trans "Author" %}</th> <th>{% trans "Shelved" %}</th>
<th>{% trans "Shelved" %}</th> <th>{% trans "Started" %}</th>
<th>{% trans "Started" %}</th> <th>{% trans "Finished" %}</th>
<th>{% trans "Finished" %}</th> {% if ratings %}<th>{% trans "Rating" %}</th>{% endif %}
{% if ratings %}<th>{% trans "Rating" %}</th>{% endif %} {% if shelf.user == request.user %}
{% if shelf.user == request.user %} <th aria-hidden="true"></th>
<th aria-hidden="true"></th> {% endif %}
{% endif %} </tr>
</tr> </thead>
{% for book in books %} <tbody>
<tr class="book-preview"> {% for book in books %}
<td> {% spaceless %}
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a> <tr class="book-preview">
</td> <td class="book-preview-top-row">
<td> <a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a>
<a href="{{ book.local_path }}">{{ book.title }}</a> </td>
</td> <td data-title="{% trans "Title" %}">
<td> <a href="{{ book.local_path }}">{{ book.title }}</a>
{% include 'snippets/authors.html' %} </td>
</td> <td data-title="{% trans "Author" %}">
<td> {% include 'snippets/authors.html' %}
{{ book.created_date | naturalday }} </td>
</td> <td data-title="{% trans "Shelved" %}">
{% latest_read_through book user as read_through %} {{ book.created_date | naturalday }}
<td> </td>
{{ read_through.start_date | naturalday |default_if_none:""}} {% latest_read_through book user as read_through %}
</td> <td data-title="{% trans "Started" %}">
<td> {{ read_through.start_date | naturalday |default_if_none:""}}
{{ read_through.finish_date | naturalday |default_if_none:""}} </td>
</td> <td data-title="{% trans "Finished" %}">
{% if ratings %} {{ read_through.finish_date | naturalday |default_if_none:""}}
<td> </td>
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %} {% if ratings %}
</td> <td data-title="{% trans "Rating" %}">
{% endif %} {% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %}
{% if shelf.user == request.user %} </td>
<td> {% endif %}
{% with right=True %} {% if shelf.user == request.user %}
{% if not shelf.id %} <td class="book-preview-top-row has-text-right">
{% active_shelf book as current %} {% with right=True %}
{% include 'snippets/shelf_selector.html' with current=current.shelf class="is-small" %} {% if not shelf.id %}
{% else %} {% active_shelf book as current %}
{% include 'snippets/shelf_selector.html' with current=shelf class="is-small" %} {% include 'snippets/shelf_selector.html' with current=current.shelf class="is-small" %}
{% endif %} {% else %}
{% endwith %} {% include 'snippets/shelf_selector.html' with current=shelf class="is-small" %}
</td> {% endif %}
{% endif %} {% endwith %}
</tr> </td>
{% endfor %} {% endif %}
</tr>
{% endspaceless %}
{% endfor %}
</tbody>
</table> </table>
</div>
{% else %} {% else %}
<p>{% trans "This shelf is empty." %}</p> <p>{% trans "This shelf is empty." %}</p>
{% if shelf.id and shelf.editable %} {% if shelf.id and shelf.editable %}

View file

@ -0,0 +1,19 @@
{% extends 'settings/admin_layout.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load humanize %}
{% block title %}{{ user.username }}{% endblock %}
{% block header %}{{ user.username }}{% endblock %}
{% block panel %}
<div class="block">
<a href="{% url 'settings-users' %}">{% trans "Back to users" %}</a>
</div>
{% include 'user_admin/user_info.html' with user=user %}
{% include 'user_admin/user_moderation_actions.html' with user=user %}
{% endblock %}

View file

@ -13,7 +13,7 @@
{% block panel %} {% block panel %}
{% include 'settings/user_admin_filters.html' %} {% include 'user_admin/user_admin_filters.html' %}
<table class="table is-striped"> <table class="table is-striped">
<tr> <tr>
@ -41,7 +41,7 @@
</tr> </tr>
{% for user in users %} {% for user in users %}
<tr> <tr>
<td>{{ user.username }}</td> <td><a href="{% url 'settings-user' user.id %}">{{ user.username }}</a></td>
<td>{{ user.created_date }}</td> <td>{{ user.created_date }}</td>
<td>{{ user.last_active_date }}</td> <td>{{ user.last_active_date }}</td>
<td>{% if user.is_active %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}</td> <td>{% if user.is_active %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}</td>

View file

@ -1,6 +1,6 @@
{% extends 'snippets/filters_panel/filters_panel.html' %} {% extends 'snippets/filters_panel/filters_panel.html' %}
{% block filter_fields %} {% block filter_fields %}
{% include 'settings/server_filter.html' %} {% include 'user_admin/server_filter.html' %}
{% include 'settings/username_filter.html' %} {% include 'user_admin/username_filter.html' %}
{% endblock %} {% endblock %}

View file

@ -0,0 +1,56 @@
{% load i18n %}
{% load bookwyrm_tags %}
<div class="block columns">
<div class="column is-flex is-flex-direction-column">
<h4 class="title is-4">{% trans "User details" %}</h4>
<div class="box is-flex-grow-1">
{% include 'user/user_preview.html' with user=user %}
{% if user.summary %}
<div class="box content has-background-white-ter is-shadowless">
{{ user.summary | to_markdown | safe }}
</div>
{% endif %}
<p class="mt-2"><a href="{{ user.local_path }}">{% trans "View user profile" %}</a></p>
</div>
</div>
{% if not user.local %}
{% with server=user.federated_server %}
<div class="column is-half is-flex is-flex-direction-column">
<h4 class="title is-4">{% trans "Instance details" %}</h4>
<div class="box content is-flex-grow-1">
{% if server %}
<h5>{{ server.server_name }}</h5>
<dl>
<div class="is-flex">
<dt>{% trans "Software:" %}</dt>
<dd>{{ server.application_type }}</dd>
</div>
<div class="is-flex">
<dt>{% trans "Version:" %}</dt>
<dd>{{ server.application_version }}</dd>
</div>
<div class="is-flex">
<dt>{% trans "Status:" %}</dt>
<dd>{{ server.status }}</dd>
</div>
</dl>
{% if server.notes %}
<h5>{% trans "Notes" %}</h5>
<div class="box content has-background-white-ter is-shadowless">
{{ server.notes }}
</div>
{% endif %}
<p>
<a href="{% url 'settings-federated-server' server.id %}">{% trans "View instance" %}</a>
</p>
{% else %}
<em>{% trans "Not set" %}</em>
{% endif %}
</div>
</div>
{% endwith %}
{% endif %}
</div>

View file

@ -0,0 +1,42 @@
{% load i18n %}
<div class="block content">
<h3>{% trans "Actions" %}</h3>
<div class="is-flex">
<p class="mr-1">
<a class="button" href="{% url 'direct-messages-user' user.username %}">{% trans "Send direct message" %}</a>
</p>
<form name="suspend" method="post" action="{% url 'settings-report-suspend' user.id %}">
{% csrf_token %}
{% if user.is_active %}
<button type="submit" class="button is-danger is-light">{% trans "Suspend user" %}</button>
{% else %}
<button class="button">{% trans "Un-suspend user" %}</button>
{% endif %}
</form>
</div>
{% if user.local %}
<div>
<form name="permission" method="post" action="{% url 'settings-user' user.id %}">
{% csrf_token %}
<label class="label" for="id_user_group">{% trans "Access level:" %}</label>
{% if group_form.non_field_errors %}
{{ group_form.non_field_errors }}
{% endif %}
{% with group=user.groups.first %}
<div class="select">
<select name="groups" id="id_user_group">
{% for value, name in group_form.fields.groups.choices %}
<option value="{{ value }}" {% if name == group.name %}selected{% endif %}>{{ name|title }}</option>
{% endfor %}
<option value="" {% if not group %}selected{% endif %}>User</option>
</select>
</div>
{% for error in group_form.groups.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% endwith %}
<button class="button">{% trans "Save" %}</button>
</form>
</div>
{% endif %}
</div>

View file

@ -1,11 +1,8 @@
""" template filters """ """ template filters """
from uuid import uuid4 from uuid import uuid4
from datetime import datetime
from dateutil.relativedelta import relativedelta from django import template, utils
from django import template
from django.db.models import Avg from django.db.models import Avg
from django.utils import timezone
from bookwyrm import models, views from bookwyrm import models, views
from bookwyrm.views.status import to_markdown from bookwyrm.views.status import to_markdown
@ -16,13 +13,13 @@ register = template.Library()
@register.filter(name="dict_key") @register.filter(name="dict_key")
def dict_key(d, k): def dict_key(d, k):
""" Returns the given key from a dictionary. """ """Returns the given key from a dictionary."""
return d.get(k) or 0 return d.get(k) or 0
@register.filter(name="rating") @register.filter(name="rating")
def get_rating(book, user): def get_rating(book, user):
""" get the overall rating of a book """ """get the overall rating of a book"""
queryset = views.helpers.privacy_filter( queryset = views.helpers.privacy_filter(
user, models.Review.objects.filter(book=book) user, models.Review.objects.filter(book=book)
) )
@ -31,7 +28,7 @@ def get_rating(book, user):
@register.filter(name="user_rating") @register.filter(name="user_rating")
def get_user_rating(book, user): def get_user_rating(book, user):
""" get a user's rating of a book """ """get a user's rating of a book"""
rating = ( rating = (
models.Review.objects.filter( models.Review.objects.filter(
user=user, user=user,
@ -48,33 +45,29 @@ def get_user_rating(book, user):
@register.filter(name="username") @register.filter(name="username")
def get_user_identifier(user): def get_user_identifier(user):
""" use localname for local users, username for remote """ """use localname for local users, username for remote"""
return user.localname if user.localname else user.username return user.localname if user.localname else user.username
@register.filter(name="notification_count") @register.filter(name="notification_count")
def get_notification_count(user): def get_notification_count(user):
""" how many UNREAD notifications are there """ """how many UNREAD notifications are there"""
return user.notification_set.filter(read=False).count() return user.notification_set.filter(read=False).count()
@register.filter(name="replies") @register.filter(name="replies")
def get_replies(status): def get_replies(status):
""" get all direct replies to a status """ """get all direct replies to a status"""
# TODO: this limit could cause problems # TODO: this limit could cause problems
return ( return models.Status.objects.filter(
models.Status.objects.filter( reply_parent=status,
reply_parent=status, deleted=False,
deleted=False, ).select_subclasses()[:10]
)
.select_subclasses()
.all()[:10]
)
@register.filter(name="parent") @register.filter(name="parent")
def get_parent(status): def get_parent(status):
""" get the reply parent for a status """ """get the reply parent for a status"""
return ( return (
models.Status.objects.filter(id=status.reply_parent_id) models.Status.objects.filter(id=status.reply_parent_id)
.select_subclasses() .select_subclasses()
@ -84,7 +77,7 @@ def get_parent(status):
@register.filter(name="liked") @register.filter(name="liked")
def get_user_liked(user, status): def get_user_liked(user, status):
""" did the given user fav a status? """ """did the given user fav a status?"""
try: try:
models.Favorite.objects.get(user=user, status=status) models.Favorite.objects.get(user=user, status=status)
return True return True
@ -94,13 +87,13 @@ def get_user_liked(user, status):
@register.filter(name="boosted") @register.filter(name="boosted")
def get_user_boosted(user, status): def get_user_boosted(user, status):
""" did the given user fav a status? """ """did the given user fav a status?"""
return user.id in status.boosters.all().values_list("user", flat=True) return user.id in status.boosters.all().values_list("user", flat=True)
@register.filter(name="follow_request_exists") @register.filter(name="follow_request_exists")
def follow_request_exists(user, requester): def follow_request_exists(user, requester):
""" see if there is a pending follow request for a user """ """see if there is a pending follow request for a user"""
try: try:
models.UserFollowRequest.objects.filter( models.UserFollowRequest.objects.filter(
user_subject=requester, user_subject=requester,
@ -113,7 +106,7 @@ def follow_request_exists(user, requester):
@register.filter(name="boosted_status") @register.filter(name="boosted_status")
def get_boosted(boost): def get_boosted(boost):
""" load a boosted status. have to do this or it wont get foregin keys """ """load a boosted status. have to do this or it wont get foregin keys"""
return ( return (
models.Status.objects.select_subclasses() models.Status.objects.select_subclasses()
.filter(id=boost.boosted_status.id) .filter(id=boost.boosted_status.id)
@ -123,41 +116,19 @@ def get_boosted(boost):
@register.filter(name="book_description") @register.filter(name="book_description")
def get_book_description(book): def get_book_description(book):
""" use the work's text if the book doesn't have it """ """use the work's text if the book doesn't have it"""
return book.description or book.parent_work.description return book.description or book.parent_work.description
@register.filter(name="uuid") @register.filter(name="uuid")
def get_uuid(identifier): def get_uuid(identifier):
""" for avoiding clashing ids when there are many forms """ """for avoiding clashing ids when there are many forms"""
return "%s%s" % (identifier, uuid4()) return "%s%s" % (identifier, uuid4())
@register.filter(name="post_date")
def time_since(date):
""" concise time ago function """
if not isinstance(date, datetime):
return ""
now = timezone.now()
if date < (now - relativedelta(weeks=1)):
formatter = "%b %-d"
if date.year != now.year:
formatter += " %Y"
return date.strftime(formatter)
delta = relativedelta(now, date)
if delta.days:
return "%dd" % delta.days
if delta.hours:
return "%dh" % delta.hours
if delta.minutes:
return "%dm" % delta.minutes
return "%ds" % delta.seconds
@register.filter(name="to_markdown") @register.filter(name="to_markdown")
def get_markdown(content): def get_markdown(content):
""" convert markdown to html """ """convert markdown to html"""
if content: if content:
return to_markdown(content) return to_markdown(content)
return None return None
@ -165,7 +136,7 @@ def get_markdown(content):
@register.filter(name="mentions") @register.filter(name="mentions")
def get_mentions(status, user): def get_mentions(status, user):
""" people to @ in a reply: the parent and all mentions """ """people to @ in a reply: the parent and all mentions"""
mentions = set([status.user] + list(status.mention_users.all())) mentions = set([status.user] + list(status.mention_users.all()))
return ( return (
" ".join("@" + get_user_identifier(m) for m in mentions if not m == user) + " " " ".join("@" + get_user_identifier(m) for m in mentions if not m == user) + " "
@ -174,7 +145,7 @@ def get_mentions(status, user):
@register.filter(name="status_preview_name") @register.filter(name="status_preview_name")
def get_status_preview_name(obj): def get_status_preview_name(obj):
""" text snippet with book context for a status """ """text snippet with book context for a status"""
name = obj.__class__.__name__.lower() name = obj.__class__.__name__.lower()
if name == "review": if name == "review":
return "%s of <em>%s</em>" % (name, obj.book.title) return "%s of <em>%s</em>" % (name, obj.book.title)
@ -187,7 +158,7 @@ def get_status_preview_name(obj):
@register.filter(name="next_shelf") @register.filter(name="next_shelf")
def get_next_shelf(current_shelf): def get_next_shelf(current_shelf):
""" shelf you'd use to update reading progress """ """shelf you'd use to update reading progress"""
if current_shelf == "to-read": if current_shelf == "to-read":
return "reading" return "reading"
if current_shelf == "reading": if current_shelf == "reading":
@ -197,9 +168,20 @@ def get_next_shelf(current_shelf):
return "to-read" return "to-read"
@register.filter(name="title")
def get_title(book):
"""display the subtitle if the title is short"""
if not book:
return ""
title = book.title
if len(title) < 6 and book.subtitle:
title = "{:s}: {:s}".format(title, book.subtitle)
return title
@register.simple_tag(takes_context=False) @register.simple_tag(takes_context=False)
def related_status(notification): def related_status(notification):
""" for notifications """ """for notifications"""
if not notification.related_status: if not notification.related_status:
return None return None
if hasattr(notification.related_status, "quotation"): if hasattr(notification.related_status, "quotation"):
@ -213,7 +195,7 @@ def related_status(notification):
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def active_shelf(context, book): def active_shelf(context, book):
""" check what shelf a user has a book on, if any """ """check what shelf a user has a book on, if any"""
shelf = models.ShelfBook.objects.filter( shelf = models.ShelfBook.objects.filter(
shelf__user=context["request"].user, book__in=book.parent_work.editions.all() shelf__user=context["request"].user, book__in=book.parent_work.editions.all()
).first() ).first()
@ -222,7 +204,7 @@ def active_shelf(context, book):
@register.simple_tag(takes_context=False) @register.simple_tag(takes_context=False)
def latest_read_through(book, user): def latest_read_through(book, user):
""" the most recent read activity """ """the most recent read activity"""
return ( return (
models.ReadThrough.objects.filter(user=user, book=book) models.ReadThrough.objects.filter(user=user, book=book)
.order_by("-start_date") .order_by("-start_date")
@ -232,7 +214,7 @@ def latest_read_through(book, user):
@register.simple_tag(takes_context=False) @register.simple_tag(takes_context=False)
def active_read_through(book, user): def active_read_through(book, user):
""" the most recent read activity """ """the most recent read activity"""
return ( return (
models.ReadThrough.objects.filter( models.ReadThrough.objects.filter(
user=user, book=book, finish_date__isnull=True user=user, book=book, finish_date__isnull=True
@ -244,5 +226,12 @@ def active_read_through(book, user):
@register.simple_tag(takes_context=False) @register.simple_tag(takes_context=False)
def comparison_bool(str1, str2): def comparison_bool(str1, str2):
""" idk why I need to write a tag for this, it reutrns a bool """ """idk why I need to write a tag for this, it reutrns a bool"""
return str1 == str2 return str1 == str2
@register.simple_tag(takes_context=False)
def get_lang():
"""get current language, strip to the first two letters"""
language = utils.translation.get_language()
return language[0 : language.find("-")]

View file

@ -21,10 +21,10 @@ from bookwyrm import models
@patch("bookwyrm.activitystreams.ActivityStream.add_status") @patch("bookwyrm.activitystreams.ActivityStream.add_status")
class BaseActivity(TestCase): class BaseActivity(TestCase):
""" the super class for model-linked activitypub dataclasses """ """the super class for model-linked activitypub dataclasses"""
def setUp(self): def setUp(self):
""" we're probably going to re-use this so why copy/paste """ """we're probably going to re-use this so why copy/paste"""
self.user = models.User.objects.create_user( self.user = models.User.objects.create_user(
"mouse", "mouse@mouse.mouse", "mouseword", local=True, localname="mouse" "mouse", "mouse@mouse.mouse", "mouseword", local=True, localname="mouse"
) )
@ -45,28 +45,28 @@ class BaseActivity(TestCase):
self.image_data = output.getvalue() self.image_data = output.getvalue()
def test_init(self, _): def test_init(self, _):
""" simple successfuly init """ """simple successfuly init"""
instance = ActivityObject(id="a", type="b") instance = ActivityObject(id="a", type="b")
self.assertTrue(hasattr(instance, "id")) self.assertTrue(hasattr(instance, "id"))
self.assertTrue(hasattr(instance, "type")) self.assertTrue(hasattr(instance, "type"))
def test_init_missing(self, _): def test_init_missing(self, _):
""" init with missing required params """ """init with missing required params"""
with self.assertRaises(ActivitySerializerError): with self.assertRaises(ActivitySerializerError):
ActivityObject() ActivityObject()
def test_init_extra_fields(self, _): def test_init_extra_fields(self, _):
""" init ignoring additional fields """ """init ignoring additional fields"""
instance = ActivityObject(id="a", type="b", fish="c") instance = ActivityObject(id="a", type="b", fish="c")
self.assertTrue(hasattr(instance, "id")) self.assertTrue(hasattr(instance, "id"))
self.assertTrue(hasattr(instance, "type")) self.assertTrue(hasattr(instance, "type"))
def test_init_default_field(self, _): def test_init_default_field(self, _):
""" replace an existing required field with a default field """ """replace an existing required field with a default field"""
@dataclass(init=False) @dataclass(init=False)
class TestClass(ActivityObject): class TestClass(ActivityObject):
""" test class with default field """ """test class with default field"""
type: str = "TestObject" type: str = "TestObject"
@ -75,7 +75,7 @@ class BaseActivity(TestCase):
self.assertEqual(instance.type, "TestObject") self.assertEqual(instance.type, "TestObject")
def test_serialize(self, _): def test_serialize(self, _):
""" simple function for converting dataclass to dict """ """simple function for converting dataclass to dict"""
instance = ActivityObject(id="a", type="b") instance = ActivityObject(id="a", type="b")
serialized = instance.serialize() serialized = instance.serialize()
self.assertIsInstance(serialized, dict) self.assertIsInstance(serialized, dict)
@ -84,7 +84,7 @@ class BaseActivity(TestCase):
@responses.activate @responses.activate
def test_resolve_remote_id(self, _): def test_resolve_remote_id(self, _):
""" look up or load remote data """ """look up or load remote data"""
# existing item # existing item
result = resolve_remote_id("http://example.com/a/b", model=models.User) result = resolve_remote_id("http://example.com/a/b", model=models.User)
self.assertEqual(result, self.user) self.assertEqual(result, self.user)
@ -106,14 +106,14 @@ class BaseActivity(TestCase):
self.assertEqual(result.name, "MOUSE?? MOUSE!!") self.assertEqual(result.name, "MOUSE?? MOUSE!!")
def test_to_model_invalid_model(self, _): def test_to_model_invalid_model(self, _):
""" catch mismatch between activity type and model type """ """catch mismatch between activity type and model type"""
instance = ActivityObject(id="a", type="b") instance = ActivityObject(id="a", type="b")
with self.assertRaises(ActivitySerializerError): with self.assertRaises(ActivitySerializerError):
instance.to_model(model=models.User) instance.to_model(model=models.User)
@responses.activate @responses.activate
def test_to_model_image(self, _): def test_to_model_image(self, _):
""" update an image field """ """update an image field"""
activity = activitypub.Person( activity = activitypub.Person(
id=self.user.remote_id, id=self.user.remote_id,
name="New Name", name="New Name",
@ -146,7 +146,7 @@ class BaseActivity(TestCase):
self.assertEqual(self.user.key_pair.public_key, "hi") self.assertEqual(self.user.key_pair.public_key, "hi")
def test_to_model_many_to_many(self, _): def test_to_model_many_to_many(self, _):
""" annoying that these all need special handling """ """annoying that these all need special handling"""
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
status = models.Status.objects.create( status = models.Status.objects.create(
content="test status", content="test status",
@ -216,7 +216,7 @@ class BaseActivity(TestCase):
@responses.activate @responses.activate
def test_set_related_field(self, _): def test_set_related_field(self, _):
""" celery task to add back-references to created objects """ """celery task to add back-references to created objects"""
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
status = models.Status.objects.create( status = models.Status.objects.create(
content="test status", content="test status",

Some files were not shown because too many files have changed in this diff Show more