forked from mirrors/bookwyrm
Merge branch 'main' into django-3-2
This commit is contained in:
commit
0889c57b86
214 changed files with 4734 additions and 3265 deletions
|
@ -27,5 +27,5 @@ activity_objects = {c[0]: c[1] for c in cls_members if hasattr(c[1], "to_model")
|
|||
|
||||
|
||||
def parse(activity_json):
|
||||
""" figure out what activity this is and parse it """
|
||||
"""figure out what activity this is and parse it"""
|
||||
return naive_parse(activity_objects, activity_json)
|
||||
|
|
|
@ -10,11 +10,11 @@ from bookwyrm.tasks import app
|
|||
|
||||
|
||||
class ActivitySerializerError(ValueError):
|
||||
""" routine problems serializing activitypub json """
|
||||
"""routine problems serializing activitypub json"""
|
||||
|
||||
|
||||
class ActivityEncoder(JSONEncoder):
|
||||
""" used to convert an Activity object into json """
|
||||
"""used to convert an Activity object into json"""
|
||||
|
||||
def default(self, o):
|
||||
return o.__dict__
|
||||
|
@ -22,7 +22,7 @@ class ActivityEncoder(JSONEncoder):
|
|||
|
||||
@dataclass
|
||||
class Link:
|
||||
""" for tagging a book in a status """
|
||||
"""for tagging a book in a status"""
|
||||
|
||||
href: str
|
||||
name: str
|
||||
|
@ -31,14 +31,14 @@ class Link:
|
|||
|
||||
@dataclass
|
||||
class Mention(Link):
|
||||
""" a subtype of Link for mentioning an actor """
|
||||
"""a subtype of Link for mentioning an actor"""
|
||||
|
||||
type: str = "Mention"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Signature:
|
||||
""" public key block """
|
||||
"""public key block"""
|
||||
|
||||
creator: str
|
||||
created: str
|
||||
|
@ -47,7 +47,7 @@ class Signature:
|
|||
|
||||
|
||||
def naive_parse(activity_objects, activity_json, serializer=None):
|
||||
""" this navigates circular import issues """
|
||||
"""this navigates circular import issues"""
|
||||
if not serializer:
|
||||
if activity_json.get("publicKeyPem"):
|
||||
# ugh
|
||||
|
@ -67,7 +67,7 @@ def naive_parse(activity_objects, activity_json, serializer=None):
|
|||
|
||||
@dataclass(init=False)
|
||||
class ActivityObject:
|
||||
""" actor activitypub json """
|
||||
"""actor activitypub json"""
|
||||
|
||||
id: str
|
||||
type: str
|
||||
|
@ -106,7 +106,7 @@ class ActivityObject:
|
|||
setattr(self, field.name, value)
|
||||
|
||||
def to_model(self, model=None, instance=None, allow_create=True, save=True):
|
||||
""" convert from an activity to a model instance """
|
||||
"""convert from an activity to a model instance"""
|
||||
model = model or get_model_from_type(self.type)
|
||||
|
||||
# only reject statuses if we're potentially creating them
|
||||
|
@ -181,7 +181,7 @@ class ActivityObject:
|
|||
return instance
|
||||
|
||||
def serialize(self):
|
||||
""" convert to dictionary with context attr """
|
||||
"""convert to dictionary with context attr"""
|
||||
data = self.__dict__.copy()
|
||||
# recursively serialize
|
||||
for (k, v) in data.items():
|
||||
|
@ -200,7 +200,7 @@ class ActivityObject:
|
|||
def set_related_field(
|
||||
model_name, origin_model_name, related_field_name, related_remote_id, data
|
||||
):
|
||||
""" load reverse related fields (editions, attachments) without blocking """
|
||||
"""load reverse related fields (editions, attachments) without blocking"""
|
||||
model = apps.get_model("bookwyrm.%s" % model_name, require_ready=True)
|
||||
origin_model = apps.get_model("bookwyrm.%s" % origin_model_name, require_ready=True)
|
||||
|
||||
|
@ -236,7 +236,7 @@ def set_related_field(
|
|||
|
||||
|
||||
def get_model_from_type(activity_type):
|
||||
""" given the activity, what type of model """
|
||||
"""given the activity, what type of model"""
|
||||
models = apps.get_models()
|
||||
model = [
|
||||
m
|
||||
|
@ -255,7 +255,7 @@ def get_model_from_type(activity_type):
|
|||
def resolve_remote_id(
|
||||
remote_id, model=None, refresh=False, save=True, get_activity=False
|
||||
):
|
||||
""" take a remote_id and return an instance, creating if necessary """
|
||||
"""take a remote_id and return an instance, creating if necessary"""
|
||||
if model: # a bonus check we can do if we already know the model
|
||||
result = model.find_existing_by_remote_id(remote_id)
|
||||
if result and not refresh:
|
||||
|
|
|
@ -8,9 +8,10 @@ from .image import Document
|
|||
|
||||
@dataclass(init=False)
|
||||
class Book(ActivityObject):
|
||||
""" serializes an edition or work, abstract """
|
||||
"""serializes an edition or work, abstract"""
|
||||
|
||||
title: str
|
||||
lastEditedBy: str = None
|
||||
sortTitle: str = ""
|
||||
subtitle: str = ""
|
||||
description: str = ""
|
||||
|
@ -34,7 +35,7 @@ class Book(ActivityObject):
|
|||
|
||||
@dataclass(init=False)
|
||||
class Edition(Book):
|
||||
""" Edition instance of a book object """
|
||||
"""Edition instance of a book object"""
|
||||
|
||||
work: str
|
||||
isbn10: str = ""
|
||||
|
@ -51,7 +52,7 @@ class Edition(Book):
|
|||
|
||||
@dataclass(init=False)
|
||||
class Work(Book):
|
||||
""" work instance of a book object """
|
||||
"""work instance of a book object"""
|
||||
|
||||
lccn: str = ""
|
||||
defaultEdition: str = ""
|
||||
|
@ -61,9 +62,10 @@ class Work(Book):
|
|||
|
||||
@dataclass(init=False)
|
||||
class Author(ActivityObject):
|
||||
""" author of a book """
|
||||
"""author of a book"""
|
||||
|
||||
name: str
|
||||
lastEditedBy: str = None
|
||||
born: str = None
|
||||
died: str = None
|
||||
aliases: List[str] = field(default_factory=lambda: [])
|
||||
|
|
|
@ -5,7 +5,7 @@ from .base_activity import ActivityObject
|
|||
|
||||
@dataclass(init=False)
|
||||
class Document(ActivityObject):
|
||||
""" a document """
|
||||
"""a document"""
|
||||
|
||||
url: str
|
||||
name: str = ""
|
||||
|
@ -15,6 +15,6 @@ class Document(ActivityObject):
|
|||
|
||||
@dataclass(init=False)
|
||||
class Image(Document):
|
||||
""" an image """
|
||||
"""an image"""
|
||||
|
||||
type: str = "Image"
|
||||
|
|
|
@ -9,19 +9,19 @@ from .image import Document
|
|||
|
||||
@dataclass(init=False)
|
||||
class Tombstone(ActivityObject):
|
||||
""" the placeholder for a deleted status """
|
||||
"""the placeholder for a deleted status"""
|
||||
|
||||
type: str = "Tombstone"
|
||||
|
||||
def to_model(self, *args, **kwargs): # pylint: disable=unused-argument
|
||||
""" this should never really get serialized, just searched for """
|
||||
"""this should never really get serialized, just searched for"""
|
||||
model = apps.get_model("bookwyrm.Status")
|
||||
return model.find_existing_by_remote_id(self.id)
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Note(ActivityObject):
|
||||
""" Note activity """
|
||||
"""Note activity"""
|
||||
|
||||
published: str
|
||||
attributedTo: str
|
||||
|
@ -39,7 +39,7 @@ class Note(ActivityObject):
|
|||
|
||||
@dataclass(init=False)
|
||||
class Article(Note):
|
||||
""" what's an article except a note with more fields """
|
||||
"""what's an article except a note with more fields"""
|
||||
|
||||
name: str
|
||||
type: str = "Article"
|
||||
|
@ -47,14 +47,14 @@ class Article(Note):
|
|||
|
||||
@dataclass(init=False)
|
||||
class GeneratedNote(Note):
|
||||
""" just a re-typed note """
|
||||
"""just a re-typed note"""
|
||||
|
||||
type: str = "GeneratedNote"
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Comment(Note):
|
||||
""" like a note but with a book """
|
||||
"""like a note but with a book"""
|
||||
|
||||
inReplyToBook: str
|
||||
type: str = "Comment"
|
||||
|
@ -62,7 +62,7 @@ class Comment(Note):
|
|||
|
||||
@dataclass(init=False)
|
||||
class Quotation(Comment):
|
||||
""" a quote and commentary on a book """
|
||||
"""a quote and commentary on a book"""
|
||||
|
||||
quote: str
|
||||
type: str = "Quotation"
|
||||
|
@ -70,7 +70,7 @@ class Quotation(Comment):
|
|||
|
||||
@dataclass(init=False)
|
||||
class Review(Comment):
|
||||
""" a full book review """
|
||||
"""a full book review"""
|
||||
|
||||
name: str = None
|
||||
rating: int = None
|
||||
|
@ -79,7 +79,7 @@ class Review(Comment):
|
|||
|
||||
@dataclass(init=False)
|
||||
class Rating(Comment):
|
||||
""" just a star rating """
|
||||
"""just a star rating"""
|
||||
|
||||
rating: int
|
||||
content: str = None
|
||||
|
|
|
@ -7,7 +7,7 @@ from .base_activity import ActivityObject
|
|||
|
||||
@dataclass(init=False)
|
||||
class OrderedCollection(ActivityObject):
|
||||
""" structure of an ordered collection activity """
|
||||
"""structure of an ordered collection activity"""
|
||||
|
||||
totalItems: int
|
||||
first: str
|
||||
|
@ -19,7 +19,7 @@ class OrderedCollection(ActivityObject):
|
|||
|
||||
@dataclass(init=False)
|
||||
class OrderedCollectionPrivate(OrderedCollection):
|
||||
""" an ordered collection with privacy settings """
|
||||
"""an ordered collection with privacy settings"""
|
||||
|
||||
to: List[str] = field(default_factory=lambda: [])
|
||||
cc: List[str] = field(default_factory=lambda: [])
|
||||
|
@ -27,14 +27,14 @@ class OrderedCollectionPrivate(OrderedCollection):
|
|||
|
||||
@dataclass(init=False)
|
||||
class Shelf(OrderedCollectionPrivate):
|
||||
""" structure of an ordered collection activity """
|
||||
"""structure of an ordered collection activity"""
|
||||
|
||||
type: str = "Shelf"
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class BookList(OrderedCollectionPrivate):
|
||||
""" structure of an ordered collection activity """
|
||||
"""structure of an ordered collection activity"""
|
||||
|
||||
summary: str = None
|
||||
curation: str = "closed"
|
||||
|
@ -43,7 +43,7 @@ class BookList(OrderedCollectionPrivate):
|
|||
|
||||
@dataclass(init=False)
|
||||
class OrderedCollectionPage(ActivityObject):
|
||||
""" structure of an ordered collection activity """
|
||||
"""structure of an ordered collection activity"""
|
||||
|
||||
partOf: str
|
||||
orderedItems: List
|
||||
|
@ -54,7 +54,7 @@ class OrderedCollectionPage(ActivityObject):
|
|||
|
||||
@dataclass(init=False)
|
||||
class CollectionItem(ActivityObject):
|
||||
""" an item in a collection """
|
||||
"""an item in a collection"""
|
||||
|
||||
actor: str
|
||||
type: str = "CollectionItem"
|
||||
|
@ -62,7 +62,7 @@ class CollectionItem(ActivityObject):
|
|||
|
||||
@dataclass(init=False)
|
||||
class ListItem(CollectionItem):
|
||||
""" a book on a list """
|
||||
"""a book on a list"""
|
||||
|
||||
book: str
|
||||
notes: str = None
|
||||
|
@ -73,7 +73,7 @@ class ListItem(CollectionItem):
|
|||
|
||||
@dataclass(init=False)
|
||||
class ShelfItem(CollectionItem):
|
||||
""" a book on a list """
|
||||
"""a book on a list"""
|
||||
|
||||
book: str
|
||||
type: str = "ShelfItem"
|
||||
|
|
|
@ -8,7 +8,7 @@ from .image import Image
|
|||
|
||||
@dataclass(init=False)
|
||||
class PublicKey(ActivityObject):
|
||||
""" public key block """
|
||||
"""public key block"""
|
||||
|
||||
owner: str
|
||||
publicKeyPem: str
|
||||
|
@ -17,7 +17,7 @@ class PublicKey(ActivityObject):
|
|||
|
||||
@dataclass(init=False)
|
||||
class Person(ActivityObject):
|
||||
""" actor activitypub json """
|
||||
"""actor activitypub json"""
|
||||
|
||||
preferredUsername: str
|
||||
inbox: str
|
||||
|
|
|
@ -9,13 +9,13 @@ from .ordered_collection import CollectionItem
|
|||
|
||||
@dataclass(init=False)
|
||||
class Verb(ActivityObject):
|
||||
"""generic fields for activities """
|
||||
"""generic fields for activities"""
|
||||
|
||||
actor: str
|
||||
object: ActivityObject
|
||||
|
||||
def action(self):
|
||||
""" usually we just want to update and save """
|
||||
"""usually we just want to update and save"""
|
||||
# self.object may return None if the object is invalid in an expected way
|
||||
# ie, Question type
|
||||
if self.object:
|
||||
|
@ -24,7 +24,7 @@ class Verb(ActivityObject):
|
|||
|
||||
@dataclass(init=False)
|
||||
class Create(Verb):
|
||||
""" Create activity """
|
||||
"""Create activity"""
|
||||
|
||||
to: List[str]
|
||||
cc: List[str] = field(default_factory=lambda: [])
|
||||
|
@ -34,14 +34,14 @@ class Create(Verb):
|
|||
|
||||
@dataclass(init=False)
|
||||
class Delete(Verb):
|
||||
""" Create activity """
|
||||
"""Create activity"""
|
||||
|
||||
to: List[str]
|
||||
cc: List[str] = field(default_factory=lambda: [])
|
||||
type: str = "Delete"
|
||||
|
||||
def action(self):
|
||||
""" find and delete the activity object """
|
||||
"""find and delete the activity object"""
|
||||
if not self.object:
|
||||
return
|
||||
|
||||
|
@ -59,25 +59,25 @@ class Delete(Verb):
|
|||
|
||||
@dataclass(init=False)
|
||||
class Update(Verb):
|
||||
""" Update activity """
|
||||
"""Update activity"""
|
||||
|
||||
to: List[str]
|
||||
type: str = "Update"
|
||||
|
||||
def action(self):
|
||||
""" update a model instance from the dataclass """
|
||||
"""update a model instance from the dataclass"""
|
||||
if self.object:
|
||||
self.object.to_model(allow_create=False)
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Undo(Verb):
|
||||
""" Undo an activity """
|
||||
"""Undo an activity"""
|
||||
|
||||
type: str = "Undo"
|
||||
|
||||
def action(self):
|
||||
""" find and remove the activity object """
|
||||
"""find and remove the activity object"""
|
||||
if isinstance(self.object, str):
|
||||
# it may be that sometihng should be done with these, but idk what
|
||||
# this seems just to be coming from pleroma
|
||||
|
@ -103,64 +103,64 @@ class Undo(Verb):
|
|||
|
||||
@dataclass(init=False)
|
||||
class Follow(Verb):
|
||||
""" Follow activity """
|
||||
"""Follow activity"""
|
||||
|
||||
object: str
|
||||
type: str = "Follow"
|
||||
|
||||
def action(self):
|
||||
""" relationship save """
|
||||
"""relationship save"""
|
||||
self.to_model()
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Block(Verb):
|
||||
""" Block activity """
|
||||
"""Block activity"""
|
||||
|
||||
object: str
|
||||
type: str = "Block"
|
||||
|
||||
def action(self):
|
||||
""" relationship save """
|
||||
"""relationship save"""
|
||||
self.to_model()
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Accept(Verb):
|
||||
""" Accept activity """
|
||||
"""Accept activity"""
|
||||
|
||||
object: Follow
|
||||
type: str = "Accept"
|
||||
|
||||
def action(self):
|
||||
""" find and remove the activity object """
|
||||
"""find and remove the activity object"""
|
||||
obj = self.object.to_model(save=False, allow_create=False)
|
||||
obj.accept()
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Reject(Verb):
|
||||
""" Reject activity """
|
||||
"""Reject activity"""
|
||||
|
||||
object: Follow
|
||||
type: str = "Reject"
|
||||
|
||||
def action(self):
|
||||
""" find and remove the activity object """
|
||||
"""find and remove the activity object"""
|
||||
obj = self.object.to_model(save=False, allow_create=False)
|
||||
obj.reject()
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Add(Verb):
|
||||
"""Add activity """
|
||||
"""Add activity"""
|
||||
|
||||
target: ActivityObject
|
||||
object: CollectionItem
|
||||
type: str = "Add"
|
||||
|
||||
def action(self):
|
||||
""" figure out the target to assign the item to a collection """
|
||||
"""figure out the target to assign the item to a collection"""
|
||||
target = resolve_remote_id(self.target)
|
||||
item = self.object.to_model(save=False)
|
||||
setattr(item, item.collection_field, target)
|
||||
|
@ -169,31 +169,32 @@ class Add(Verb):
|
|||
|
||||
@dataclass(init=False)
|
||||
class Remove(Add):
|
||||
"""Remove activity """
|
||||
"""Remove activity"""
|
||||
|
||||
type: str = "Remove"
|
||||
|
||||
def action(self):
|
||||
""" find and remove the activity object """
|
||||
"""find and remove the activity object"""
|
||||
obj = self.object.to_model(save=False, allow_create=False)
|
||||
obj.delete()
|
||||
if obj:
|
||||
obj.delete()
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Like(Verb):
|
||||
""" a user faving an object """
|
||||
"""a user faving an object"""
|
||||
|
||||
object: str
|
||||
type: str = "Like"
|
||||
|
||||
def action(self):
|
||||
""" like """
|
||||
"""like"""
|
||||
self.to_model()
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Announce(Verb):
|
||||
""" boosting a status """
|
||||
"""boosting a status"""
|
||||
|
||||
published: str
|
||||
to: List[str] = field(default_factory=lambda: [])
|
||||
|
@ -202,5 +203,5 @@ class Announce(Verb):
|
|||
type: str = "Announce"
|
||||
|
||||
def action(self):
|
||||
""" boost """
|
||||
"""boost"""
|
||||
self.to_model()
|
||||
|
|
|
@ -8,22 +8,22 @@ from bookwyrm.views.helpers import privacy_filter
|
|||
|
||||
|
||||
class ActivityStream(RedisStore):
|
||||
""" a category of activity stream (like home, local, federated) """
|
||||
"""a category of activity stream (like home, local, federated)"""
|
||||
|
||||
def stream_id(self, user):
|
||||
""" the redis key for this user's instance of this stream """
|
||||
"""the redis key for this user's instance of this stream"""
|
||||
return "{}-{}".format(user.id, self.key)
|
||||
|
||||
def unread_id(self, user):
|
||||
""" the redis key for this user's unread count for this stream """
|
||||
"""the redis key for this user's unread count for this stream"""
|
||||
return "{}-unread".format(self.stream_id(user))
|
||||
|
||||
def get_rank(self, obj): # pylint: disable=no-self-use
|
||||
""" statuses are sorted by date published """
|
||||
"""statuses are sorted by date published"""
|
||||
return obj.published_date.timestamp()
|
||||
|
||||
def add_status(self, status):
|
||||
""" add a status to users' feeds """
|
||||
"""add a status to users' feeds"""
|
||||
# the pipeline contains all the add-to-stream activities
|
||||
pipeline = self.add_object_to_related_stores(status, execute=False)
|
||||
|
||||
|
@ -35,19 +35,19 @@ class ActivityStream(RedisStore):
|
|||
pipeline.execute()
|
||||
|
||||
def add_user_statuses(self, viewer, user):
|
||||
""" add a user's statuses to another user's feed """
|
||||
"""add a user's statuses to another user's feed"""
|
||||
# only add the statuses that the viewer should be able to see (ie, not dms)
|
||||
statuses = privacy_filter(viewer, user.status_set.all())
|
||||
self.bulk_add_objects_to_store(statuses, self.stream_id(viewer))
|
||||
|
||||
def remove_user_statuses(self, viewer, user):
|
||||
""" remove a user's status from another user's feed """
|
||||
"""remove a user's status from another user's feed"""
|
||||
# remove all so that followers only statuses are removed
|
||||
statuses = user.status_set.all()
|
||||
self.bulk_remove_objects_from_store(statuses, self.stream_id(viewer))
|
||||
|
||||
def get_activity_stream(self, user):
|
||||
""" load the statuses to be displayed """
|
||||
"""load the statuses to be displayed"""
|
||||
# clear unreads for this feed
|
||||
r.set(self.unread_id(user), 0)
|
||||
|
||||
|
@ -59,15 +59,15 @@ class ActivityStream(RedisStore):
|
|||
)
|
||||
|
||||
def get_unread_count(self, user):
|
||||
""" get the unread status count for this user's feed """
|
||||
"""get the unread status count for this user's feed"""
|
||||
return int(r.get(self.unread_id(user)) or 0)
|
||||
|
||||
def populate_streams(self, user):
|
||||
""" go from zero to a timeline """
|
||||
"""go from zero to a timeline"""
|
||||
self.populate_store(self.stream_id(user))
|
||||
|
||||
def get_audience(self, status): # pylint: disable=no-self-use
|
||||
""" given a status, what users should see it """
|
||||
"""given a status, what users should see it"""
|
||||
# direct messages don't appeard in feeds, direct comments/reviews/etc do
|
||||
if status.privacy == "direct" and status.status_type == "Note":
|
||||
return []
|
||||
|
@ -98,7 +98,7 @@ class ActivityStream(RedisStore):
|
|||
return [self.stream_id(u) for u in self.get_audience(obj)]
|
||||
|
||||
def get_statuses_for_user(self, user): # pylint: disable=no-self-use
|
||||
""" given a user, what statuses should they see on this stream """
|
||||
"""given a user, what statuses should they see on this stream"""
|
||||
return privacy_filter(
|
||||
user,
|
||||
models.Status.objects.select_subclasses(),
|
||||
|
@ -111,7 +111,7 @@ class ActivityStream(RedisStore):
|
|||
|
||||
|
||||
class HomeStream(ActivityStream):
|
||||
""" users you follow """
|
||||
"""users you follow"""
|
||||
|
||||
key = "home"
|
||||
|
||||
|
@ -134,7 +134,7 @@ class HomeStream(ActivityStream):
|
|||
|
||||
|
||||
class LocalStream(ActivityStream):
|
||||
""" users you follow """
|
||||
"""users you follow"""
|
||||
|
||||
key = "local"
|
||||
|
||||
|
@ -154,7 +154,7 @@ class LocalStream(ActivityStream):
|
|||
|
||||
|
||||
class FederatedStream(ActivityStream):
|
||||
""" users you follow """
|
||||
"""users you follow"""
|
||||
|
||||
key = "federated"
|
||||
|
||||
|
@ -182,7 +182,7 @@ streams = {
|
|||
@receiver(signals.post_save)
|
||||
# pylint: disable=unused-argument
|
||||
def add_status_on_create(sender, instance, created, *args, **kwargs):
|
||||
""" add newly created statuses to activity feeds """
|
||||
"""add newly created statuses to activity feeds"""
|
||||
# we're only interested in new statuses
|
||||
if not issubclass(sender, models.Status):
|
||||
return
|
||||
|
@ -203,7 +203,7 @@ def add_status_on_create(sender, instance, created, *args, **kwargs):
|
|||
@receiver(signals.post_delete, sender=models.Boost)
|
||||
# pylint: disable=unused-argument
|
||||
def remove_boost_on_delete(sender, instance, *args, **kwargs):
|
||||
""" boosts are deleted """
|
||||
"""boosts are deleted"""
|
||||
# we're only interested in new statuses
|
||||
for stream in streams.values():
|
||||
stream.remove_object_from_related_stores(instance)
|
||||
|
@ -212,7 +212,7 @@ def remove_boost_on_delete(sender, instance, *args, **kwargs):
|
|||
@receiver(signals.post_save, sender=models.UserFollows)
|
||||
# pylint: disable=unused-argument
|
||||
def add_statuses_on_follow(sender, instance, created, *args, **kwargs):
|
||||
""" add a newly followed user's statuses to feeds """
|
||||
"""add a newly followed user's statuses to feeds"""
|
||||
if not created or not instance.user_subject.local:
|
||||
return
|
||||
HomeStream().add_user_statuses(instance.user_subject, instance.user_object)
|
||||
|
@ -221,7 +221,7 @@ def add_statuses_on_follow(sender, instance, created, *args, **kwargs):
|
|||
@receiver(signals.post_delete, sender=models.UserFollows)
|
||||
# pylint: disable=unused-argument
|
||||
def remove_statuses_on_unfollow(sender, instance, *args, **kwargs):
|
||||
""" remove statuses from a feed on unfollow """
|
||||
"""remove statuses from a feed on unfollow"""
|
||||
if not instance.user_subject.local:
|
||||
return
|
||||
HomeStream().remove_user_statuses(instance.user_subject, instance.user_object)
|
||||
|
@ -230,7 +230,7 @@ def remove_statuses_on_unfollow(sender, instance, *args, **kwargs):
|
|||
@receiver(signals.post_save, sender=models.UserBlocks)
|
||||
# pylint: disable=unused-argument
|
||||
def remove_statuses_on_block(sender, instance, *args, **kwargs):
|
||||
""" remove statuses from all feeds on block """
|
||||
"""remove statuses from all feeds on block"""
|
||||
# blocks apply ot all feeds
|
||||
if instance.user_subject.local:
|
||||
for stream in streams.values():
|
||||
|
@ -245,7 +245,7 @@ def remove_statuses_on_block(sender, instance, *args, **kwargs):
|
|||
@receiver(signals.post_delete, sender=models.UserBlocks)
|
||||
# pylint: disable=unused-argument
|
||||
def add_statuses_on_unblock(sender, instance, *args, **kwargs):
|
||||
""" remove statuses from all feeds on block """
|
||||
"""remove statuses from all feeds on block"""
|
||||
public_streams = [LocalStream(), FederatedStream()]
|
||||
# add statuses back to streams with statuses from anyone
|
||||
if instance.user_subject.local:
|
||||
|
@ -261,7 +261,7 @@ def add_statuses_on_unblock(sender, instance, *args, **kwargs):
|
|||
@receiver(signals.post_save, sender=models.User)
|
||||
# pylint: disable=unused-argument
|
||||
def populate_streams_on_account_create(sender, instance, created, *args, **kwargs):
|
||||
""" build a user's feeds when they join """
|
||||
"""build a user's feeds when they join"""
|
||||
if not created or not instance.local:
|
||||
return
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
class AbstractMinimalConnector(ABC):
|
||||
""" just the bare bones, for other bookwyrm instances """
|
||||
"""just the bare bones, for other bookwyrm instances"""
|
||||
|
||||
def __init__(self, identifier):
|
||||
# load connector settings
|
||||
|
@ -39,7 +39,7 @@ class AbstractMinimalConnector(ABC):
|
|||
setattr(self, field, getattr(info, field))
|
||||
|
||||
def search(self, query, min_confidence=None):
|
||||
""" free text search """
|
||||
"""free text search"""
|
||||
params = {}
|
||||
if min_confidence:
|
||||
params["min_confidence"] = min_confidence
|
||||
|
@ -55,7 +55,7 @@ class AbstractMinimalConnector(ABC):
|
|||
return results
|
||||
|
||||
def isbn_search(self, query):
|
||||
""" isbn search """
|
||||
"""isbn search"""
|
||||
params = {}
|
||||
data = get_data(
|
||||
"%s%s" % (self.isbn_search_url, query),
|
||||
|
@ -70,27 +70,27 @@ class AbstractMinimalConnector(ABC):
|
|||
|
||||
@abstractmethod
|
||||
def get_or_create_book(self, remote_id):
|
||||
""" pull up a book record by whatever means possible """
|
||||
"""pull up a book record by whatever means possible"""
|
||||
|
||||
@abstractmethod
|
||||
def parse_search_data(self, data):
|
||||
""" turn the result json from a search into a list """
|
||||
"""turn the result json from a search into a list"""
|
||||
|
||||
@abstractmethod
|
||||
def format_search_result(self, search_result):
|
||||
""" create a SearchResult obj from json """
|
||||
"""create a SearchResult obj from json"""
|
||||
|
||||
@abstractmethod
|
||||
def parse_isbn_search_data(self, data):
|
||||
""" turn the result json from a search into a list """
|
||||
"""turn the result json from a search into a list"""
|
||||
|
||||
@abstractmethod
|
||||
def format_isbn_search_result(self, search_result):
|
||||
""" create a SearchResult obj from json """
|
||||
"""create a SearchResult obj from json"""
|
||||
|
||||
|
||||
class AbstractConnector(AbstractMinimalConnector):
|
||||
""" generic book data connector """
|
||||
"""generic book data connector"""
|
||||
|
||||
def __init__(self, identifier):
|
||||
super().__init__(identifier)
|
||||
|
@ -99,14 +99,14 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
self.book_mappings = []
|
||||
|
||||
def is_available(self):
|
||||
""" check if you're allowed to use this connector """
|
||||
"""check if you're allowed to use this connector"""
|
||||
if self.max_query_count is not None:
|
||||
if self.connector.query_count >= self.max_query_count:
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_or_create_book(self, remote_id):
|
||||
""" translate arbitrary json into an Activitypub dataclass """
|
||||
"""translate arbitrary json into an Activitypub dataclass"""
|
||||
# first, check if we have the origin_id saved
|
||||
existing = models.Edition.find_existing_by_remote_id(
|
||||
remote_id
|
||||
|
@ -151,7 +151,7 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
return edition
|
||||
|
||||
def create_edition_from_data(self, work, edition_data):
|
||||
""" if we already have the work, we're ready """
|
||||
"""if we already have the work, we're ready"""
|
||||
mapped_data = dict_from_mappings(edition_data, self.book_mappings)
|
||||
mapped_data["work"] = work.remote_id
|
||||
edition_activity = activitypub.Edition(**mapped_data)
|
||||
|
@ -171,7 +171,7 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
return edition
|
||||
|
||||
def get_or_create_author(self, remote_id):
|
||||
""" load that author """
|
||||
"""load that author"""
|
||||
existing = models.Author.find_existing_by_remote_id(remote_id)
|
||||
if existing:
|
||||
return existing
|
||||
|
@ -189,23 +189,23 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
|
||||
@abstractmethod
|
||||
def is_work_data(self, data):
|
||||
""" differentiate works and editions """
|
||||
"""differentiate works and editions"""
|
||||
|
||||
@abstractmethod
|
||||
def get_edition_from_work_data(self, data):
|
||||
""" every work needs at least one edition """
|
||||
"""every work needs at least one edition"""
|
||||
|
||||
@abstractmethod
|
||||
def get_work_from_edition_data(self, data):
|
||||
""" every edition needs a work """
|
||||
"""every edition needs a work"""
|
||||
|
||||
@abstractmethod
|
||||
def get_authors_from_data(self, data):
|
||||
""" load author data """
|
||||
"""load author data"""
|
||||
|
||||
@abstractmethod
|
||||
def expand_book_data(self, book):
|
||||
""" get more info on a book """
|
||||
"""get more info on a book"""
|
||||
|
||||
|
||||
def dict_from_mappings(data, mappings):
|
||||
|
@ -218,7 +218,7 @@ def dict_from_mappings(data, mappings):
|
|||
|
||||
|
||||
def get_data(url, params=None):
|
||||
""" wrapper for request.get """
|
||||
"""wrapper for request.get"""
|
||||
# check if the url is blocked
|
||||
if models.FederatedServer.is_blocked(url):
|
||||
raise ConnectorException(
|
||||
|
@ -250,7 +250,7 @@ def get_data(url, params=None):
|
|||
|
||||
|
||||
def get_image(url):
|
||||
""" wrapper for requesting an image """
|
||||
"""wrapper for requesting an image"""
|
||||
try:
|
||||
resp = requests.get(
|
||||
url,
|
||||
|
@ -268,7 +268,7 @@ def get_image(url):
|
|||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
""" standardized search result object """
|
||||
"""standardized search result object"""
|
||||
|
||||
title: str
|
||||
key: str
|
||||
|
@ -284,14 +284,14 @@ class SearchResult:
|
|||
)
|
||||
|
||||
def json(self):
|
||||
""" serialize a connector for json response """
|
||||
"""serialize a connector for json response"""
|
||||
serialized = asdict(self)
|
||||
del serialized["connector"]
|
||||
return serialized
|
||||
|
||||
|
||||
class Mapping:
|
||||
""" associate a local database field with a field in an external dataset """
|
||||
"""associate a local database field with a field in an external dataset"""
|
||||
|
||||
def __init__(self, local_field, remote_field=None, formatter=None):
|
||||
noop = lambda x: x
|
||||
|
@ -301,7 +301,7 @@ class Mapping:
|
|||
self.formatter = formatter or noop
|
||||
|
||||
def get_value(self, data):
|
||||
""" pull a field from incoming json and return the formatted version """
|
||||
"""pull a field from incoming json and return the formatted version"""
|
||||
value = data.get(self.remote_field)
|
||||
if not value:
|
||||
return None
|
||||
|
|
|
@ -4,7 +4,7 @@ from .abstract_connector import AbstractMinimalConnector, SearchResult
|
|||
|
||||
|
||||
class Connector(AbstractMinimalConnector):
|
||||
""" this is basically just for search """
|
||||
"""this is basically just for search"""
|
||||
|
||||
def get_or_create_book(self, remote_id):
|
||||
edition = activitypub.resolve_remote_id(remote_id, model=models.Edition)
|
||||
|
|
|
@ -16,11 +16,11 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
class ConnectorException(HTTPError):
|
||||
""" when the connector can't do what was asked """
|
||||
"""when the connector can't do what was asked"""
|
||||
|
||||
|
||||
def search(query, min_confidence=0.1):
|
||||
""" find books based on arbitary keywords """
|
||||
"""find books based on arbitary keywords"""
|
||||
if not query:
|
||||
return []
|
||||
results = []
|
||||
|
@ -68,19 +68,19 @@ def search(query, min_confidence=0.1):
|
|||
|
||||
|
||||
def local_search(query, min_confidence=0.1, raw=False):
|
||||
""" only look at local search results """
|
||||
"""only look at local search results"""
|
||||
connector = load_connector(models.Connector.objects.get(local=True))
|
||||
return connector.search(query, min_confidence=min_confidence, raw=raw)
|
||||
|
||||
|
||||
def isbn_local_search(query, raw=False):
|
||||
""" only look at local search results """
|
||||
"""only look at local search results"""
|
||||
connector = load_connector(models.Connector.objects.get(local=True))
|
||||
return connector.isbn_search(query, raw=raw)
|
||||
|
||||
|
||||
def first_search_result(query, min_confidence=0.1):
|
||||
""" search until you find a result that fits """
|
||||
"""search until you find a result that fits"""
|
||||
for connector in get_connectors():
|
||||
result = connector.search(query, min_confidence=min_confidence)
|
||||
if result:
|
||||
|
@ -89,13 +89,13 @@ def first_search_result(query, min_confidence=0.1):
|
|||
|
||||
|
||||
def get_connectors():
|
||||
""" load all connectors """
|
||||
"""load all connectors"""
|
||||
for info in models.Connector.objects.order_by("priority").all():
|
||||
yield load_connector(info)
|
||||
|
||||
|
||||
def get_or_create_connector(remote_id):
|
||||
""" get the connector related to the object's server """
|
||||
"""get the connector related to the object's server"""
|
||||
url = urlparse(remote_id)
|
||||
identifier = url.netloc
|
||||
if not identifier:
|
||||
|
@ -119,7 +119,7 @@ def get_or_create_connector(remote_id):
|
|||
|
||||
@app.task
|
||||
def load_more_data(connector_id, book_id):
|
||||
""" background the work of getting all 10,000 editions of LoTR """
|
||||
"""background the work of getting all 10,000 editions of LoTR"""
|
||||
connector_info = models.Connector.objects.get(id=connector_id)
|
||||
connector = load_connector(connector_info)
|
||||
book = models.Book.objects.select_subclasses().get(id=book_id)
|
||||
|
@ -127,7 +127,7 @@ def load_more_data(connector_id, book_id):
|
|||
|
||||
|
||||
def load_connector(connector_info):
|
||||
""" instantiate the connector class """
|
||||
"""instantiate the connector class"""
|
||||
connector = importlib.import_module(
|
||||
"bookwyrm.connectors.%s" % connector_info.connector_file
|
||||
)
|
||||
|
@ -137,6 +137,6 @@ def load_connector(connector_info):
|
|||
@receiver(signals.post_save, sender="bookwyrm.FederatedServer")
|
||||
# pylint: disable=unused-argument
|
||||
def create_connector(sender, instance, created, *args, **kwargs):
|
||||
""" create a connector to an external bookwyrm server """
|
||||
"""create a connector to an external bookwyrm server"""
|
||||
if instance.application_type == "bookwyrm":
|
||||
get_or_create_connector("https://{:s}".format(instance.server_name))
|
||||
|
|
|
@ -9,7 +9,7 @@ from .openlibrary_languages import languages
|
|||
|
||||
|
||||
class Connector(AbstractConnector):
|
||||
""" instantiate a connector for OL """
|
||||
"""instantiate a connector for OL"""
|
||||
|
||||
def __init__(self, identifier):
|
||||
super().__init__(identifier)
|
||||
|
@ -59,7 +59,7 @@ class Connector(AbstractConnector):
|
|||
]
|
||||
|
||||
def get_remote_id_from_data(self, data):
|
||||
""" format a url from an openlibrary id field """
|
||||
"""format a url from an openlibrary id field"""
|
||||
try:
|
||||
key = data["key"]
|
||||
except KeyError:
|
||||
|
@ -87,7 +87,7 @@ class Connector(AbstractConnector):
|
|||
return get_data(url)
|
||||
|
||||
def get_authors_from_data(self, data):
|
||||
""" parse author json and load or create authors """
|
||||
"""parse author json and load or create authors"""
|
||||
for author_blob in data.get("authors", []):
|
||||
author_blob = author_blob.get("author", author_blob)
|
||||
# this id is "/authors/OL1234567A"
|
||||
|
@ -99,7 +99,7 @@ class Connector(AbstractConnector):
|
|||
yield author
|
||||
|
||||
def get_cover_url(self, cover_blob, size="L"):
|
||||
""" ask openlibrary for the cover """
|
||||
"""ask openlibrary for the cover"""
|
||||
if not cover_blob:
|
||||
return None
|
||||
cover_id = cover_blob[0]
|
||||
|
@ -141,7 +141,7 @@ class Connector(AbstractConnector):
|
|||
)
|
||||
|
||||
def load_edition_data(self, olkey):
|
||||
""" query openlibrary for editions of a work """
|
||||
"""query openlibrary for editions of a work"""
|
||||
url = "%s/works/%s/editions" % (self.books_url, olkey)
|
||||
return get_data(url)
|
||||
|
||||
|
@ -166,7 +166,7 @@ class Connector(AbstractConnector):
|
|||
|
||||
|
||||
def ignore_edition(edition_data):
|
||||
""" don't load a million editions that have no metadata """
|
||||
"""don't load a million editions that have no metadata"""
|
||||
# an isbn, we love to see it
|
||||
if edition_data.get("isbn_13") or edition_data.get("isbn_10"):
|
||||
return False
|
||||
|
@ -185,19 +185,19 @@ def ignore_edition(edition_data):
|
|||
|
||||
|
||||
def get_description(description_blob):
|
||||
""" descriptions can be a string or a dict """
|
||||
"""descriptions can be a string or a dict"""
|
||||
if isinstance(description_blob, dict):
|
||||
return description_blob.get("value")
|
||||
return description_blob
|
||||
|
||||
|
||||
def get_openlibrary_key(key):
|
||||
""" convert /books/OL27320736M into OL27320736M """
|
||||
"""convert /books/OL27320736M into OL27320736M"""
|
||||
return key.split("/")[-1]
|
||||
|
||||
|
||||
def get_languages(language_blob):
|
||||
""" /language/eng -> English """
|
||||
"""/language/eng -> English"""
|
||||
langs = []
|
||||
for lang in language_blob:
|
||||
langs.append(languages.get(lang.get("key", ""), None))
|
||||
|
@ -205,7 +205,7 @@ def get_languages(language_blob):
|
|||
|
||||
|
||||
def pick_default_edition(options):
|
||||
""" favor physical copies with covers in english """
|
||||
"""favor physical copies with covers in english"""
|
||||
if not options:
|
||||
return None
|
||||
if len(options) == 1:
|
||||
|
|
|
@ -10,11 +10,11 @@ from .abstract_connector import AbstractConnector, SearchResult
|
|||
|
||||
|
||||
class Connector(AbstractConnector):
|
||||
""" instantiate a connector """
|
||||
"""instantiate a connector"""
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
def search(self, query, min_confidence=0.1, raw=False):
|
||||
""" search your local database """
|
||||
"""search your local database"""
|
||||
if not query:
|
||||
return []
|
||||
# first, try searching unqiue identifiers
|
||||
|
@ -35,7 +35,7 @@ class Connector(AbstractConnector):
|
|||
return search_results
|
||||
|
||||
def isbn_search(self, query, raw=False):
|
||||
""" search your local database """
|
||||
"""search your local database"""
|
||||
if not query:
|
||||
return []
|
||||
|
||||
|
@ -87,11 +87,11 @@ class Connector(AbstractConnector):
|
|||
return None
|
||||
|
||||
def parse_isbn_search_data(self, data):
|
||||
""" it's already in the right format, don't even worry about it """
|
||||
"""it's already in the right format, don't even worry about it"""
|
||||
return data
|
||||
|
||||
def parse_search_data(self, data):
|
||||
""" it's already in the right format, don't even worry about it """
|
||||
"""it's already in the right format, don't even worry about it"""
|
||||
return data
|
||||
|
||||
def expand_book_data(self, book):
|
||||
|
@ -99,7 +99,7 @@ class Connector(AbstractConnector):
|
|||
|
||||
|
||||
def search_identifiers(query):
|
||||
""" tries remote_id, isbn; defined as dedupe fields on the model """
|
||||
"""tries remote_id, isbn; defined as dedupe fields on the model"""
|
||||
filters = [
|
||||
{f.name: query}
|
||||
for f in models.Edition._meta.get_fields()
|
||||
|
@ -115,7 +115,7 @@ def search_identifiers(query):
|
|||
|
||||
|
||||
def search_title_author(query, min_confidence):
|
||||
""" searches for title and author """
|
||||
"""searches for title and author"""
|
||||
vector = (
|
||||
SearchVector("title", weight="A")
|
||||
+ SearchVector("subtitle", weight="B")
|
||||
|
|
|
@ -3,5 +3,5 @@ from bookwyrm import models
|
|||
|
||||
|
||||
def site_settings(request): # pylint: disable=unused-argument
|
||||
""" include the custom info about the site """
|
||||
"""include the custom info about the site"""
|
||||
return {"site": models.SiteSettings.objects.get()}
|
||||
|
|
|
@ -8,7 +8,7 @@ from bookwyrm.settings import DOMAIN
|
|||
|
||||
|
||||
def email_data():
|
||||
""" fields every email needs """
|
||||
"""fields every email needs"""
|
||||
site = models.SiteSettings.objects.get()
|
||||
if site.logo_small:
|
||||
logo_path = "/images/{}".format(site.logo_small.url)
|
||||
|
@ -24,14 +24,14 @@ def email_data():
|
|||
|
||||
|
||||
def invite_email(invite_request):
|
||||
""" send out an invite code """
|
||||
"""send out an invite code"""
|
||||
data = email_data()
|
||||
data["invite_link"] = invite_request.invite.link
|
||||
send_email.delay(invite_request.email, *format_email("invite", data))
|
||||
|
||||
|
||||
def password_reset_email(reset_code):
|
||||
""" generate a password reset email """
|
||||
"""generate a password reset email"""
|
||||
data = email_data()
|
||||
data["reset_link"] = reset_code.link
|
||||
data["user"] = reset_code.user.display_name
|
||||
|
@ -39,7 +39,7 @@ def password_reset_email(reset_code):
|
|||
|
||||
|
||||
def format_email(email_name, data):
|
||||
""" render the email templates """
|
||||
"""render the email templates"""
|
||||
subject = (
|
||||
get_template("email/{}/subject.html".format(email_name)).render(data).strip()
|
||||
)
|
||||
|
@ -58,7 +58,7 @@ def format_email(email_name, data):
|
|||
|
||||
@app.task
|
||||
def send_email(recipient, subject, html_content, text_content):
|
||||
""" use a task to send the email """
|
||||
"""use a task to send the email"""
|
||||
email = EmailMultiAlternatives(
|
||||
subject, text_content, settings.DEFAULT_FROM_EMAIL, [recipient]
|
||||
)
|
||||
|
|
|
@ -3,7 +3,7 @@ import datetime
|
|||
from collections import defaultdict
|
||||
|
||||
from django import forms
|
||||
from django.forms import ModelForm, PasswordInput, widgets
|
||||
from django.forms import ModelForm, PasswordInput, widgets, ChoiceField
|
||||
from django.forms.widgets import Textarea
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
@ -12,7 +12,7 @@ from bookwyrm import models
|
|||
|
||||
|
||||
class CustomForm(ModelForm):
|
||||
""" add css classes to the forms """
|
||||
"""add css classes to the forms"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
css_classes = defaultdict(lambda: "")
|
||||
|
@ -150,12 +150,10 @@ class LimitedEditUserForm(CustomForm):
|
|||
help_texts = {f: None for f in fields}
|
||||
|
||||
|
||||
class TagForm(CustomForm):
|
||||
class UserGroupForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Tag
|
||||
fields = ["name"]
|
||||
help_texts = {f: None for f in fields}
|
||||
labels = {"name": "Add a tag"}
|
||||
model = models.User
|
||||
fields = ["groups"]
|
||||
|
||||
|
||||
class CoverForm(CustomForm):
|
||||
|
@ -200,7 +198,7 @@ class ImportForm(forms.Form):
|
|||
|
||||
class ExpiryWidget(widgets.Select):
|
||||
def value_from_datadict(self, data, files, name):
|
||||
""" human-readable exiration time buckets """
|
||||
"""human-readable exiration time buckets"""
|
||||
selected_string = super().value_from_datadict(data, files, name)
|
||||
|
||||
if selected_string == "day":
|
||||
|
@ -219,7 +217,7 @@ class ExpiryWidget(widgets.Select):
|
|||
|
||||
class InviteRequestForm(CustomForm):
|
||||
def clean(self):
|
||||
""" make sure the email isn't in use by a registered user """
|
||||
"""make sure the email isn't in use by a registered user"""
|
||||
cleaned_data = super().clean()
|
||||
email = cleaned_data.get("email")
|
||||
if email and models.User.objects.filter(email=email).exists():
|
||||
|
@ -287,3 +285,20 @@ class ServerForm(CustomForm):
|
|||
class Meta:
|
||||
model = models.FederatedServer
|
||||
exclude = ["remote_id"]
|
||||
|
||||
|
||||
class SortListForm(forms.Form):
|
||||
sort_by = ChoiceField(
|
||||
choices=(
|
||||
("order", _("List Order")),
|
||||
("title", _("Book Title")),
|
||||
("rating", _("Rating")),
|
||||
),
|
||||
label=_("Sort By"),
|
||||
)
|
||||
direction = ChoiceField(
|
||||
choices=(
|
||||
("ascending", _("Ascending")),
|
||||
("descending", _("Descending")),
|
||||
),
|
||||
)
|
||||
|
|
|
@ -9,7 +9,7 @@ class GoodreadsImporter(Importer):
|
|||
service = "GoodReads"
|
||||
|
||||
def parse_fields(self, entry):
|
||||
""" handle the specific fields in goodreads csvs """
|
||||
"""handle the specific fields in goodreads csvs"""
|
||||
entry.update({"import_source": self.service})
|
||||
# add missing 'Date Started' field
|
||||
entry.update({"Date Started": None})
|
||||
|
|
|
@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
class Importer:
|
||||
""" Generic class for csv data import from an outside service """
|
||||
"""Generic class for csv data import from an outside service"""
|
||||
|
||||
service = "Unknown"
|
||||
delimiter = ","
|
||||
|
@ -18,7 +18,7 @@ class Importer:
|
|||
mandatory_fields = ["Title", "Author"]
|
||||
|
||||
def create_job(self, user, csv_file, include_reviews, privacy):
|
||||
""" check over a csv and creates a database entry for the job"""
|
||||
"""check over a csv and creates a database entry for the job"""
|
||||
job = ImportJob.objects.create(
|
||||
user=user, include_reviews=include_reviews, privacy=privacy
|
||||
)
|
||||
|
@ -32,16 +32,16 @@ class Importer:
|
|||
return job
|
||||
|
||||
def save_item(self, job, index, data): # pylint: disable=no-self-use
|
||||
""" creates and saves an import item """
|
||||
"""creates and saves an import item"""
|
||||
ImportItem(job=job, index=index, data=data).save()
|
||||
|
||||
def parse_fields(self, entry):
|
||||
""" updates csv data with additional info """
|
||||
"""updates csv data with additional info"""
|
||||
entry.update({"import_source": self.service})
|
||||
return entry
|
||||
|
||||
def create_retry_job(self, user, original_job, items):
|
||||
""" retry items that didn't import """
|
||||
"""retry items that didn't import"""
|
||||
job = ImportJob.objects.create(
|
||||
user=user,
|
||||
include_reviews=original_job.include_reviews,
|
||||
|
@ -53,7 +53,7 @@ class Importer:
|
|||
return job
|
||||
|
||||
def start_import(self, job):
|
||||
""" initalizes a csv import job """
|
||||
"""initalizes a csv import job"""
|
||||
result = import_data.delay(self.service, job.id)
|
||||
job.task_id = result.id
|
||||
job.save()
|
||||
|
@ -61,7 +61,7 @@ class Importer:
|
|||
|
||||
@app.task
|
||||
def import_data(source, job_id):
|
||||
""" does the actual lookup work in a celery task """
|
||||
"""does the actual lookup work in a celery task"""
|
||||
job = ImportJob.objects.get(id=job_id)
|
||||
try:
|
||||
for item in job.items.all():
|
||||
|
@ -89,7 +89,7 @@ def import_data(source, job_id):
|
|||
|
||||
|
||||
def handle_imported_book(source, user, item, include_reviews, privacy):
|
||||
""" process a csv and then post about it """
|
||||
"""process a csv and then post about it"""
|
||||
if isinstance(item.book, models.Work):
|
||||
item.book = item.book.default_edition
|
||||
if not item.book:
|
||||
|
|
|
@ -6,7 +6,7 @@ from . import Importer
|
|||
|
||||
|
||||
class LibrarythingImporter(Importer):
|
||||
""" csv downloads from librarything """
|
||||
"""csv downloads from librarything"""
|
||||
|
||||
service = "LibraryThing"
|
||||
delimiter = "\t"
|
||||
|
@ -15,7 +15,7 @@ class LibrarythingImporter(Importer):
|
|||
mandatory_fields = ["Title", "Primary Author"]
|
||||
|
||||
def parse_fields(self, entry):
|
||||
""" custom parsing for librarything """
|
||||
"""custom parsing for librarything"""
|
||||
data = {}
|
||||
data["import_source"] = self.service
|
||||
data["Book Id"] = entry["Book Id"]
|
||||
|
|
|
@ -6,7 +6,7 @@ from bookwyrm import models
|
|||
|
||||
|
||||
def update_related(canonical, obj):
|
||||
""" update all the models with fk to the object being removed """
|
||||
"""update all the models with fk to the object being removed"""
|
||||
# move related models to canonical
|
||||
related_models = [
|
||||
(r.remote_field.name, r.related_model) for r in canonical._meta.related_objects
|
||||
|
@ -24,7 +24,7 @@ def update_related(canonical, obj):
|
|||
|
||||
|
||||
def copy_data(canonical, obj):
|
||||
""" try to get the most data possible """
|
||||
"""try to get the most data possible"""
|
||||
for data_field in obj._meta.get_fields():
|
||||
if not hasattr(data_field, "activitypub_field"):
|
||||
continue
|
||||
|
@ -38,7 +38,7 @@ def copy_data(canonical, obj):
|
|||
|
||||
|
||||
def dedupe_model(model):
|
||||
""" combine duplicate editions and update related models """
|
||||
"""combine duplicate editions and update related models"""
|
||||
fields = model._meta.get_fields()
|
||||
dedupe_fields = [
|
||||
f for f in fields if hasattr(f, "deduplication_field") and f.deduplication_field
|
||||
|
@ -68,12 +68,12 @@ def dedupe_model(model):
|
|||
|
||||
|
||||
class Command(BaseCommand):
|
||||
""" dedplucate allllll the book data models """
|
||||
"""dedplucate allllll the book data models"""
|
||||
|
||||
help = "merges duplicate book data"
|
||||
# pylint: disable=no-self-use,unused-argument
|
||||
def handle(self, *args, **options):
|
||||
""" run deudplications """
|
||||
"""run deudplications"""
|
||||
dedupe_model(models.Edition)
|
||||
dedupe_model(models.Work)
|
||||
dedupe_model(models.Author)
|
||||
|
|
|
@ -10,15 +10,15 @@ r = redis.Redis(
|
|||
|
||||
|
||||
def erase_streams():
|
||||
""" throw the whole redis away """
|
||||
"""throw the whole redis away"""
|
||||
r.flushall()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
""" delete activity streams for all users """
|
||||
"""delete activity streams for all users"""
|
||||
|
||||
help = "Delete all the user streams"
|
||||
# pylint: disable=no-self-use,unused-argument
|
||||
def handle(self, *args, **options):
|
||||
""" flush all, baby """
|
||||
"""flush all, baby"""
|
||||
erase_streams()
|
||||
|
|
|
@ -108,7 +108,7 @@ def init_connectors():
|
|||
|
||||
|
||||
def init_federated_servers():
|
||||
""" big no to nazis """
|
||||
"""big no to nazis"""
|
||||
built_in_blocks = ["gab.ai", "gab.com"]
|
||||
for server in built_in_blocks:
|
||||
FederatedServer.objects.create(
|
||||
|
|
|
@ -10,7 +10,7 @@ r = redis.Redis(
|
|||
|
||||
|
||||
def populate_streams():
|
||||
""" build all the streams for all the users """
|
||||
"""build all the streams for all the users"""
|
||||
users = models.User.objects.filter(
|
||||
local=True,
|
||||
is_active=True,
|
||||
|
@ -21,10 +21,10 @@ def populate_streams():
|
|||
|
||||
|
||||
class Command(BaseCommand):
|
||||
""" start all over with user streams """
|
||||
"""start all over with user streams"""
|
||||
|
||||
help = "Populate streams for all users"
|
||||
# pylint: disable=no-self-use,unused-argument
|
||||
def handle(self, *args, **options):
|
||||
""" run feed builder """
|
||||
"""run feed builder"""
|
||||
populate_streams()
|
||||
|
|
|
@ -5,7 +5,7 @@ from bookwyrm import models
|
|||
|
||||
|
||||
def remove_editions():
|
||||
""" combine duplicate editions and update related models """
|
||||
"""combine duplicate editions and update related models"""
|
||||
# not in use
|
||||
filters = {
|
||||
"%s__isnull" % r.name: True for r in models.Edition._meta.related_objects
|
||||
|
@ -33,10 +33,10 @@ def remove_editions():
|
|||
|
||||
|
||||
class Command(BaseCommand):
|
||||
""" dedplucate allllll the book data models """
|
||||
"""dedplucate allllll the book data models"""
|
||||
|
||||
help = "merges duplicate book data"
|
||||
# pylint: disable=no-self-use,unused-argument
|
||||
def handle(self, *args, **options):
|
||||
""" run deudplications """
|
||||
"""run deudplications"""
|
||||
remove_editions()
|
||||
|
|
|
@ -8,7 +8,7 @@ from psycopg2.extras import execute_values
|
|||
|
||||
|
||||
def convert_review_rating(app_registry, schema_editor):
|
||||
""" take rating type Reviews and convert them to ReviewRatings """
|
||||
"""take rating type Reviews and convert them to ReviewRatings"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
reviews = (
|
||||
|
@ -29,7 +29,7 @@ VALUES %s""",
|
|||
|
||||
|
||||
def unconvert_review_rating(app_registry, schema_editor):
|
||||
""" undo the conversion from ratings back to reviews"""
|
||||
"""undo the conversion from ratings back to reviews"""
|
||||
# All we need to do to revert this is drop the table, which Django will do
|
||||
# on its own, as long as we have a valid reverse function. So, this is a
|
||||
# no-op function so Django will do its thing
|
||||
|
|
30
bookwyrm/migrations/0067_denullify_list_item_order.py
Normal file
30
bookwyrm/migrations/0067_denullify_list_item_order.py
Normal 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)]
|
23
bookwyrm/migrations/0068_ordering_for_list_items.py
Normal file
23
bookwyrm/migrations/0068_ordering_for_list_items.py
Normal 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")},
|
||||
),
|
||||
]
|
34
bookwyrm/migrations/0069_auto_20210422_1604.py
Normal file
34
bookwyrm/migrations/0069_auto_20210422_1604.py
Normal 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,
|
||||
),
|
||||
),
|
||||
]
|
35
bookwyrm/migrations/0070_auto_20210423_0121.py
Normal file
35
bookwyrm/migrations/0070_auto_20210423_0121.py
Normal 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",
|
||||
),
|
||||
]
|
|
@ -17,8 +17,6 @@ from .favorite import Favorite
|
|||
from .notification import Notification
|
||||
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
|
||||
|
||||
from .tag import Tag, UserTag
|
||||
|
||||
from .user import User, KeyPair, AnnualGoal
|
||||
from .relationship import UserFollows, UserFollowRequest, UserBlocks
|
||||
from .report import Report, ReportComment
|
||||
|
|
|
@ -31,18 +31,18 @@ PropertyField = namedtuple("PropertyField", ("set_activity_from_field"))
|
|||
|
||||
|
||||
def set_activity_from_property_field(activity, obj, field):
|
||||
""" assign a model property value to the activity json """
|
||||
"""assign a model property value to the activity json"""
|
||||
activity[field[1]] = getattr(obj, field[0])
|
||||
|
||||
|
||||
class ActivitypubMixin:
|
||||
""" add this mixin for models that are AP serializable """
|
||||
"""add this mixin for models that are AP serializable"""
|
||||
|
||||
activity_serializer = lambda: {}
|
||||
reverse_unfurl = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
""" collect some info on model fields """
|
||||
"""collect some info on model fields"""
|
||||
self.image_fields = []
|
||||
self.many_to_many_fields = []
|
||||
self.simple_fields = [] # "simple"
|
||||
|
@ -85,7 +85,7 @@ class ActivitypubMixin:
|
|||
|
||||
@classmethod
|
||||
def find_existing_by_remote_id(cls, remote_id):
|
||||
""" look up a remote id in the db """
|
||||
"""look up a remote id in the db"""
|
||||
return cls.find_existing({"id": remote_id})
|
||||
|
||||
@classmethod
|
||||
|
@ -126,7 +126,7 @@ class ActivitypubMixin:
|
|||
return match.first()
|
||||
|
||||
def broadcast(self, activity, sender, software=None):
|
||||
""" send out an activity """
|
||||
"""send out an activity"""
|
||||
broadcast_task.delay(
|
||||
sender.id,
|
||||
json.dumps(activity, cls=activitypub.ActivityEncoder),
|
||||
|
@ -134,7 +134,7 @@ class ActivitypubMixin:
|
|||
)
|
||||
|
||||
def get_recipients(self, software=None):
|
||||
""" figure out which inbox urls to post to """
|
||||
"""figure out which inbox urls to post to"""
|
||||
# first we have to figure out who should receive this activity
|
||||
privacy = self.privacy if hasattr(self, "privacy") else "public"
|
||||
# is this activity owned by a user (statuses, lists, shelves), or is it
|
||||
|
@ -148,13 +148,17 @@ class ActivitypubMixin:
|
|||
mentions = self.recipients if hasattr(self, "recipients") else []
|
||||
|
||||
# we always send activities to explicitly mentioned users' inboxes
|
||||
recipients = [u.inbox for u in mentions or []]
|
||||
recipients = [u.inbox for u in mentions or [] if not u.local]
|
||||
|
||||
# unless it's a dm, all the followers should receive the activity
|
||||
if privacy != "direct":
|
||||
# we will send this out to a subset of all remote users
|
||||
queryset = user_model.viewer_aware_objects(user).filter(
|
||||
local=False,
|
||||
queryset = (
|
||||
user_model.viewer_aware_objects(user)
|
||||
.filter(
|
||||
local=False,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
# filter users first by whether they're using the desired software
|
||||
# this lets us send book updates only to other bw servers
|
||||
|
@ -175,23 +179,23 @@ class ActivitypubMixin:
|
|||
"inbox", flat=True
|
||||
)
|
||||
recipients += list(shared_inboxes) + list(inboxes)
|
||||
return recipients
|
||||
return list(set(recipients))
|
||||
|
||||
def to_activity_dataclass(self):
|
||||
""" convert from a model to an activity """
|
||||
"""convert from a model to an activity"""
|
||||
activity = generate_activity(self)
|
||||
return self.activity_serializer(**activity)
|
||||
|
||||
def to_activity(self, **kwargs): # pylint: disable=unused-argument
|
||||
""" convert from a model to a json activity """
|
||||
"""convert from a model to a json activity"""
|
||||
return self.to_activity_dataclass().serialize()
|
||||
|
||||
|
||||
class ObjectMixin(ActivitypubMixin):
|
||||
""" add this mixin for object models that are AP serializable """
|
||||
"""add this mixin for object models that are AP serializable"""
|
||||
|
||||
def save(self, *args, created=None, **kwargs):
|
||||
""" broadcast created/updated/deleted objects as appropriate """
|
||||
"""broadcast created/updated/deleted objects as appropriate"""
|
||||
broadcast = kwargs.get("broadcast", True)
|
||||
# this bonus kwarg would cause an error in the base save method
|
||||
if "broadcast" in kwargs:
|
||||
|
@ -200,7 +204,9 @@ class ObjectMixin(ActivitypubMixin):
|
|||
created = created or not bool(self.id)
|
||||
# first off, we want to save normally no matter what
|
||||
super().save(*args, **kwargs)
|
||||
if not broadcast:
|
||||
if not broadcast or (
|
||||
hasattr(self, "status_type") and self.status_type == "Announce"
|
||||
):
|
||||
return
|
||||
|
||||
# this will work for objects owned by a user (lists, shelves)
|
||||
|
@ -248,7 +254,7 @@ class ObjectMixin(ActivitypubMixin):
|
|||
self.broadcast(activity, user)
|
||||
|
||||
def to_create_activity(self, user, **kwargs):
|
||||
""" returns the object wrapped in a Create activity """
|
||||
"""returns the object wrapped in a Create activity"""
|
||||
activity_object = self.to_activity_dataclass(**kwargs)
|
||||
|
||||
signature = None
|
||||
|
@ -274,7 +280,7 @@ class ObjectMixin(ActivitypubMixin):
|
|||
).serialize()
|
||||
|
||||
def to_delete_activity(self, user):
|
||||
""" notice of deletion """
|
||||
"""notice of deletion"""
|
||||
return activitypub.Delete(
|
||||
id=self.remote_id + "/activity",
|
||||
actor=user.remote_id,
|
||||
|
@ -284,7 +290,7 @@ class ObjectMixin(ActivitypubMixin):
|
|||
).serialize()
|
||||
|
||||
def to_update_activity(self, user):
|
||||
""" wrapper for Updates to an activity """
|
||||
"""wrapper for Updates to an activity"""
|
||||
activity_id = "%s#update/%s" % (self.remote_id, uuid4())
|
||||
return activitypub.Update(
|
||||
id=activity_id,
|
||||
|
@ -300,13 +306,13 @@ class OrderedCollectionPageMixin(ObjectMixin):
|
|||
|
||||
@property
|
||||
def collection_remote_id(self):
|
||||
""" this can be overriden if there's a special remote id, ie outbox """
|
||||
"""this can be overriden if there's a special remote id, ie outbox"""
|
||||
return self.remote_id
|
||||
|
||||
def to_ordered_collection(
|
||||
self, queryset, remote_id=None, page=False, collection_only=False, **kwargs
|
||||
):
|
||||
""" an ordered collection of whatevers """
|
||||
"""an ordered collection of whatevers"""
|
||||
if not queryset.ordered:
|
||||
raise RuntimeError("queryset must be ordered")
|
||||
|
||||
|
@ -335,11 +341,11 @@ class OrderedCollectionPageMixin(ObjectMixin):
|
|||
|
||||
|
||||
class OrderedCollectionMixin(OrderedCollectionPageMixin):
|
||||
""" extends activitypub models to work as ordered collections """
|
||||
"""extends activitypub models to work as ordered collections"""
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
""" usually an ordered collection model aggregates a different model """
|
||||
"""usually an ordered collection model aggregates a different model"""
|
||||
raise NotImplementedError("Model must define collection_queryset")
|
||||
|
||||
activity_serializer = activitypub.OrderedCollection
|
||||
|
@ -348,24 +354,24 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin):
|
|||
return self.to_ordered_collection(self.collection_queryset, **kwargs)
|
||||
|
||||
def to_activity(self, **kwargs):
|
||||
""" an ordered collection of the specified model queryset """
|
||||
"""an ordered collection of the specified model queryset"""
|
||||
return self.to_ordered_collection(
|
||||
self.collection_queryset, **kwargs
|
||||
).serialize()
|
||||
|
||||
|
||||
class CollectionItemMixin(ActivitypubMixin):
|
||||
""" for items that are part of an (Ordered)Collection """
|
||||
"""for items that are part of an (Ordered)Collection"""
|
||||
|
||||
activity_serializer = activitypub.CollectionItem
|
||||
|
||||
def broadcast(self, activity, sender, software="bookwyrm"):
|
||||
""" only send book collection updates to other bookwyrm instances """
|
||||
"""only send book collection updates to other bookwyrm instances"""
|
||||
super().broadcast(activity, sender, software=software)
|
||||
|
||||
@property
|
||||
def privacy(self):
|
||||
""" inherit the privacy of the list, or direct if pending """
|
||||
"""inherit the privacy of the list, or direct if pending"""
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
if self.approved:
|
||||
return collection_field.privacy
|
||||
|
@ -373,7 +379,7 @@ class CollectionItemMixin(ActivitypubMixin):
|
|||
|
||||
@property
|
||||
def recipients(self):
|
||||
""" the owner of the list is a direct recipient """
|
||||
"""the owner of the list is a direct recipient"""
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
if collection_field.user.local:
|
||||
# don't broadcast to yourself
|
||||
|
@ -381,7 +387,7 @@ class CollectionItemMixin(ActivitypubMixin):
|
|||
return [collection_field.user]
|
||||
|
||||
def save(self, *args, broadcast=True, **kwargs):
|
||||
""" broadcast updated """
|
||||
"""broadcast updated"""
|
||||
# first off, we want to save normally no matter what
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
@ -394,14 +400,14 @@ class CollectionItemMixin(ActivitypubMixin):
|
|||
self.broadcast(activity, self.user)
|
||||
|
||||
def delete(self, *args, broadcast=True, **kwargs):
|
||||
""" broadcast a remove activity """
|
||||
"""broadcast a remove activity"""
|
||||
activity = self.to_remove_activity(self.user)
|
||||
super().delete(*args, **kwargs)
|
||||
if self.user.local and broadcast:
|
||||
self.broadcast(activity, self.user)
|
||||
|
||||
def to_add_activity(self, user):
|
||||
""" AP for shelving a book"""
|
||||
"""AP for shelving a book"""
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
return activitypub.Add(
|
||||
id="{:s}#add".format(collection_field.remote_id),
|
||||
|
@ -411,7 +417,7 @@ class CollectionItemMixin(ActivitypubMixin):
|
|||
).serialize()
|
||||
|
||||
def to_remove_activity(self, user):
|
||||
""" AP for un-shelving a book"""
|
||||
"""AP for un-shelving a book"""
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
return activitypub.Remove(
|
||||
id="{:s}#remove".format(collection_field.remote_id),
|
||||
|
@ -422,24 +428,24 @@ class CollectionItemMixin(ActivitypubMixin):
|
|||
|
||||
|
||||
class ActivityMixin(ActivitypubMixin):
|
||||
""" add this mixin for models that are AP serializable """
|
||||
"""add this mixin for models that are AP serializable"""
|
||||
|
||||
def save(self, *args, broadcast=True, **kwargs):
|
||||
""" broadcast activity """
|
||||
"""broadcast activity"""
|
||||
super().save(*args, **kwargs)
|
||||
user = self.user if hasattr(self, "user") else self.user_subject
|
||||
if broadcast and user.local:
|
||||
self.broadcast(self.to_activity(), user)
|
||||
|
||||
def delete(self, *args, broadcast=True, **kwargs):
|
||||
""" nevermind, undo that activity """
|
||||
"""nevermind, undo that activity"""
|
||||
user = self.user if hasattr(self, "user") else self.user_subject
|
||||
if broadcast and user.local:
|
||||
self.broadcast(self.to_undo_activity(), user)
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
def to_undo_activity(self):
|
||||
""" undo an action """
|
||||
"""undo an action"""
|
||||
user = self.user if hasattr(self, "user") else self.user_subject
|
||||
return activitypub.Undo(
|
||||
id="%s#undo" % self.remote_id,
|
||||
|
@ -449,7 +455,7 @@ class ActivityMixin(ActivitypubMixin):
|
|||
|
||||
|
||||
def generate_activity(obj):
|
||||
""" go through the fields on an object """
|
||||
"""go through the fields on an object"""
|
||||
activity = {}
|
||||
for field in obj.activity_fields:
|
||||
field.set_activity_from_field(activity, obj)
|
||||
|
@ -472,7 +478,7 @@ def generate_activity(obj):
|
|||
|
||||
|
||||
def unfurl_related_field(related_field, sort_field=None):
|
||||
""" load reverse lookups (like public key owner or Status attachment """
|
||||
"""load reverse lookups (like public key owner or Status attachment"""
|
||||
if sort_field and hasattr(related_field, "all"):
|
||||
return [
|
||||
unfurl_related_field(i) for i in related_field.order_by(sort_field).all()
|
||||
|
@ -488,7 +494,7 @@ def unfurl_related_field(related_field, sort_field=None):
|
|||
|
||||
@app.task
|
||||
def broadcast_task(sender_id, activity, recipients):
|
||||
""" the celery task for broadcast """
|
||||
"""the celery task for broadcast"""
|
||||
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
||||
sender = user_model.objects.get(id=sender_id)
|
||||
for recipient in recipients:
|
||||
|
@ -499,7 +505,7 @@ def broadcast_task(sender_id, activity, recipients):
|
|||
|
||||
|
||||
def sign_and_send(sender, data, destination):
|
||||
""" crpyto whatever and http junk """
|
||||
"""crpyto whatever and http junk"""
|
||||
now = http_date()
|
||||
|
||||
if not sender.key_pair.private_key:
|
||||
|
@ -528,7 +534,7 @@ def sign_and_send(sender, data, destination):
|
|||
def to_ordered_collection_page(
|
||||
queryset, remote_id, id_only=False, page=1, pure=False, **kwargs
|
||||
):
|
||||
""" serialize and pagiante a queryset """
|
||||
"""serialize and pagiante a queryset"""
|
||||
paginated = Paginator(queryset, PAGE_LENGTH)
|
||||
|
||||
activity_page = paginated.get_page(page)
|
||||
|
|
|
@ -8,7 +8,7 @@ from . import fields
|
|||
|
||||
|
||||
class Attachment(ActivitypubMixin, BookWyrmModel):
|
||||
""" an image (or, in the future, video etc) associated with a status """
|
||||
"""an image (or, in the future, video etc) associated with a status"""
|
||||
|
||||
status = models.ForeignKey(
|
||||
"Status", on_delete=models.CASCADE, related_name="attachments", null=True
|
||||
|
@ -16,13 +16,13 @@ class Attachment(ActivitypubMixin, BookWyrmModel):
|
|||
reverse_unfurl = True
|
||||
|
||||
class Meta:
|
||||
""" one day we'll have other types of attachments besides images """
|
||||
"""one day we'll have other types of attachments besides images"""
|
||||
|
||||
abstract = True
|
||||
|
||||
|
||||
class Image(Attachment):
|
||||
""" an image attachment """
|
||||
"""an image attachment"""
|
||||
|
||||
image = fields.ImageField(
|
||||
upload_to="status/",
|
||||
|
|
|
@ -9,7 +9,7 @@ from . import fields
|
|||
|
||||
|
||||
class Author(BookDataModel):
|
||||
""" basic biographic info """
|
||||
"""basic biographic info"""
|
||||
|
||||
wikipedia_link = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
|
@ -24,7 +24,7 @@ class Author(BookDataModel):
|
|||
bio = fields.HtmlField(null=True, blank=True)
|
||||
|
||||
def get_remote_id(self):
|
||||
""" editions and works both use "book" instead of model_name """
|
||||
"""editions and works both use "book" instead of model_name"""
|
||||
return "https://%s/author/%s" % (DOMAIN, self.id)
|
||||
|
||||
activity_serializer = activitypub.Author
|
||||
|
|
|
@ -7,14 +7,14 @@ from .fields import RemoteIdField
|
|||
|
||||
|
||||
class BookWyrmModel(models.Model):
|
||||
""" shared fields """
|
||||
"""shared fields"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
remote_id = RemoteIdField(null=True, activitypub_field="id")
|
||||
|
||||
def get_remote_id(self):
|
||||
""" generate a url that resolves to the local object """
|
||||
"""generate a url that resolves to the local object"""
|
||||
base_path = "https://%s" % DOMAIN
|
||||
if hasattr(self, "user"):
|
||||
base_path = "%s%s" % (base_path, self.user.local_path)
|
||||
|
@ -22,17 +22,17 @@ class BookWyrmModel(models.Model):
|
|||
return "%s/%s/%d" % (base_path, model_name, self.id)
|
||||
|
||||
class Meta:
|
||||
""" this is just here to provide default fields for other models """
|
||||
"""this is just here to provide default fields for other models"""
|
||||
|
||||
abstract = True
|
||||
|
||||
@property
|
||||
def local_path(self):
|
||||
""" how to link to this object in the local app """
|
||||
"""how to link to this object in the local app"""
|
||||
return self.get_remote_id().replace("https://%s" % DOMAIN, "")
|
||||
|
||||
def visible_to_user(self, viewer):
|
||||
""" is a user authorized to view an object? """
|
||||
"""is a user authorized to view an object?"""
|
||||
# make sure this is an object with privacy owned by a user
|
||||
if not hasattr(self, "user") or not hasattr(self, "privacy"):
|
||||
return None
|
||||
|
@ -65,7 +65,7 @@ class BookWyrmModel(models.Model):
|
|||
@receiver(models.signals.post_save)
|
||||
# pylint: disable=unused-argument
|
||||
def set_remote_id(sender, instance, created, *args, **kwargs):
|
||||
""" set the remote_id after save (when the id is available) """
|
||||
"""set the remote_id after save (when the id is available)"""
|
||||
if not created or not hasattr(instance, "get_remote_id"):
|
||||
return
|
||||
if not instance.remote_id:
|
||||
|
|
|
@ -13,7 +13,7 @@ from . import fields
|
|||
|
||||
|
||||
class BookDataModel(ObjectMixin, BookWyrmModel):
|
||||
""" fields shared between editable book data (books, works, authors) """
|
||||
"""fields shared between editable book data (books, works, authors)"""
|
||||
|
||||
origin_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
openlibrary_key = fields.CharField(
|
||||
|
@ -26,15 +26,19 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
|
|||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
|
||||
last_edited_by = models.ForeignKey("User", on_delete=models.PROTECT, null=True)
|
||||
last_edited_by = fields.ForeignKey(
|
||||
"User",
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
""" can't initialize this model, that wouldn't make sense """
|
||||
"""can't initialize this model, that wouldn't make sense"""
|
||||
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" ensure that the remote_id is within this instance """
|
||||
"""ensure that the remote_id is within this instance"""
|
||||
if self.id:
|
||||
self.remote_id = self.get_remote_id()
|
||||
else:
|
||||
|
@ -43,12 +47,12 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
|
|||
return super().save(*args, **kwargs)
|
||||
|
||||
def broadcast(self, activity, sender, software="bookwyrm"):
|
||||
""" only send book data updates to other bookwyrm instances """
|
||||
"""only send book data updates to other bookwyrm instances"""
|
||||
super().broadcast(activity, sender, software=software)
|
||||
|
||||
|
||||
class Book(BookDataModel):
|
||||
""" a generic book, which can mean either an edition or a work """
|
||||
"""a generic book, which can mean either an edition or a work"""
|
||||
|
||||
connector = models.ForeignKey("Connector", on_delete=models.PROTECT, null=True)
|
||||
|
||||
|
@ -79,17 +83,17 @@ class Book(BookDataModel):
|
|||
|
||||
@property
|
||||
def author_text(self):
|
||||
""" format a list of authors """
|
||||
"""format a list of authors"""
|
||||
return ", ".join(a.name for a in self.authors.all())
|
||||
|
||||
@property
|
||||
def latest_readthrough(self):
|
||||
""" most recent readthrough activity """
|
||||
"""most recent readthrough activity"""
|
||||
return self.readthrough_set.order_by("-updated_date").first()
|
||||
|
||||
@property
|
||||
def edition_info(self):
|
||||
""" properties of this edition, as a string """
|
||||
"""properties of this edition, as a string"""
|
||||
items = [
|
||||
self.physical_format if hasattr(self, "physical_format") else None,
|
||||
self.languages[0] + " language"
|
||||
|
@ -102,20 +106,20 @@ class Book(BookDataModel):
|
|||
|
||||
@property
|
||||
def alt_text(self):
|
||||
""" image alt test """
|
||||
"""image alt test"""
|
||||
text = "%s" % self.title
|
||||
if self.edition_info:
|
||||
text += " (%s)" % self.edition_info
|
||||
return text
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" can't be abstract for query reasons, but you shouldn't USE it """
|
||||
"""can't be abstract for query reasons, but you shouldn't USE it"""
|
||||
if not isinstance(self, Edition) and not isinstance(self, Work):
|
||||
raise ValueError("Books should be added as Editions or Works")
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def get_remote_id(self):
|
||||
""" editions and works both use "book" instead of model_name """
|
||||
"""editions and works both use "book" instead of model_name"""
|
||||
return "https://%s/book/%d" % (DOMAIN, self.id)
|
||||
|
||||
def __repr__(self):
|
||||
|
@ -127,7 +131,7 @@ class Book(BookDataModel):
|
|||
|
||||
|
||||
class Work(OrderedCollectionPageMixin, Book):
|
||||
""" a work (an abstract concept of a book that manifests in an edition) """
|
||||
"""a work (an abstract concept of a book that manifests in an edition)"""
|
||||
|
||||
# library of congress catalog control number
|
||||
lccn = fields.CharField(
|
||||
|
@ -139,19 +143,19 @@ class Work(OrderedCollectionPageMixin, Book):
|
|||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" set some fields on the edition object """
|
||||
"""set some fields on the edition object"""
|
||||
# set rank
|
||||
for edition in self.editions.all():
|
||||
edition.save()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def get_default_edition(self):
|
||||
""" in case the default edition is not set """
|
||||
"""in case the default edition is not set"""
|
||||
return self.default_edition or self.editions.order_by("-edition_rank").first()
|
||||
|
||||
@transaction.atomic()
|
||||
def reset_default_edition(self):
|
||||
""" sets a new default edition based on computed rank """
|
||||
"""sets a new default edition based on computed rank"""
|
||||
self.default_edition = None
|
||||
# editions are re-ranked implicitly
|
||||
self.save()
|
||||
|
@ -159,11 +163,11 @@ class Work(OrderedCollectionPageMixin, Book):
|
|||
self.save()
|
||||
|
||||
def to_edition_list(self, **kwargs):
|
||||
""" an ordered collection of editions """
|
||||
"""an ordered collection of editions"""
|
||||
return self.to_ordered_collection(
|
||||
self.editions.order_by("-edition_rank").all(),
|
||||
remote_id="%s/editions" % self.remote_id,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
activity_serializer = activitypub.Work
|
||||
|
@ -172,7 +176,7 @@ class Work(OrderedCollectionPageMixin, Book):
|
|||
|
||||
|
||||
class Edition(Book):
|
||||
""" an edition of a book """
|
||||
"""an edition of a book"""
|
||||
|
||||
# these identifiers only apply to editions, not works
|
||||
isbn_10 = fields.CharField(
|
||||
|
@ -211,7 +215,7 @@ class Edition(Book):
|
|||
name_field = "title"
|
||||
|
||||
def get_rank(self, ignore_default=False):
|
||||
""" calculate how complete the data is on this edition """
|
||||
"""calculate how complete the data is on this edition"""
|
||||
if (
|
||||
not ignore_default
|
||||
and self.parent_work
|
||||
|
@ -231,7 +235,7 @@ class Edition(Book):
|
|||
return rank
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" set some fields on the edition object """
|
||||
"""set some fields on the edition object"""
|
||||
# calculate isbn 10/13
|
||||
if self.isbn_13 and self.isbn_13[:3] == "978" and not self.isbn_10:
|
||||
self.isbn_10 = isbn_13_to_10(self.isbn_13)
|
||||
|
@ -245,7 +249,7 @@ class Edition(Book):
|
|||
|
||||
|
||||
def isbn_10_to_13(isbn_10):
|
||||
""" convert an isbn 10 into an isbn 13 """
|
||||
"""convert an isbn 10 into an isbn 13"""
|
||||
isbn_10 = re.sub(r"[^0-9X]", "", isbn_10)
|
||||
# drop the last character of the isbn 10 number (the original checkdigit)
|
||||
converted = isbn_10[:9]
|
||||
|
@ -267,7 +271,7 @@ def isbn_10_to_13(isbn_10):
|
|||
|
||||
|
||||
def isbn_13_to_10(isbn_13):
|
||||
""" convert isbn 13 to 10, if possible """
|
||||
"""convert isbn 13 to 10, if possible"""
|
||||
if isbn_13[:3] != "978":
|
||||
return None
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ ConnectorFiles = models.TextChoices("ConnectorFiles", CONNECTORS)
|
|||
|
||||
|
||||
class Connector(BookWyrmModel):
|
||||
""" book data source connectors """
|
||||
"""book data source connectors"""
|
||||
|
||||
identifier = models.CharField(max_length=255, unique=True)
|
||||
priority = models.IntegerField(default=2)
|
||||
|
@ -32,7 +32,7 @@ class Connector(BookWyrmModel):
|
|||
query_count_expiry = models.DateTimeField(auto_now_add=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
""" check that there's code to actually use this connector """
|
||||
"""check that there's code to actually use this connector"""
|
||||
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
|
|
|
@ -11,7 +11,7 @@ from .status import Status
|
|||
|
||||
|
||||
class Favorite(ActivityMixin, BookWyrmModel):
|
||||
""" fav'ing a post """
|
||||
"""fav'ing a post"""
|
||||
|
||||
user = fields.ForeignKey(
|
||||
"User", on_delete=models.PROTECT, activitypub_field="actor"
|
||||
|
@ -24,11 +24,11 @@ class Favorite(ActivityMixin, BookWyrmModel):
|
|||
|
||||
@classmethod
|
||||
def ignore_activity(cls, activity):
|
||||
""" don't bother with incoming favs of unknown statuses """
|
||||
"""don't bother with incoming favs of unknown statuses"""
|
||||
return not Status.objects.filter(remote_id=activity.object).exists()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" update user active time """
|
||||
"""update user active time"""
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save(broadcast=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
@ -45,7 +45,7 @@ class Favorite(ActivityMixin, BookWyrmModel):
|
|||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
""" delete and delete notifications """
|
||||
"""delete and delete notifications"""
|
||||
# check for notification
|
||||
if self.status.user.local:
|
||||
notification_model = apps.get_model(
|
||||
|
@ -62,6 +62,6 @@ class Favorite(ActivityMixin, BookWyrmModel):
|
|||
super().delete(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
""" can't fav things twice """
|
||||
"""can't fav things twice"""
|
||||
|
||||
unique_together = ("user", "status")
|
||||
|
|
|
@ -13,7 +13,7 @@ FederationStatus = models.TextChoices(
|
|||
|
||||
|
||||
class FederatedServer(BookWyrmModel):
|
||||
""" store which servers we federate with """
|
||||
"""store which servers we federate with"""
|
||||
|
||||
server_name = models.CharField(max_length=255, unique=True)
|
||||
status = models.CharField(
|
||||
|
@ -25,7 +25,7 @@ class FederatedServer(BookWyrmModel):
|
|||
notes = models.TextField(null=True, blank=True)
|
||||
|
||||
def block(self):
|
||||
""" block a server """
|
||||
"""block a server"""
|
||||
self.status = "blocked"
|
||||
self.save()
|
||||
|
||||
|
@ -35,7 +35,7 @@ class FederatedServer(BookWyrmModel):
|
|||
)
|
||||
|
||||
def unblock(self):
|
||||
""" unblock a server """
|
||||
"""unblock a server"""
|
||||
self.status = "federated"
|
||||
self.save()
|
||||
|
||||
|
@ -45,7 +45,7 @@ class FederatedServer(BookWyrmModel):
|
|||
|
||||
@classmethod
|
||||
def is_blocked(cls, url):
|
||||
""" look up if a domain is blocked """
|
||||
"""look up if a domain is blocked"""
|
||||
url = urlparse(url)
|
||||
domain = url.netloc
|
||||
return cls.objects.filter(server_name=domain, status="blocked").exists()
|
||||
|
|
|
@ -18,7 +18,7 @@ from bookwyrm.settings import DOMAIN
|
|||
|
||||
|
||||
def validate_remote_id(value):
|
||||
""" make sure the remote_id looks like a url """
|
||||
"""make sure the remote_id looks like a url"""
|
||||
if not value or not re.match(r"^http.?:\/\/[^\s]+$", value):
|
||||
raise ValidationError(
|
||||
_("%(value)s is not a valid remote_id"),
|
||||
|
@ -27,7 +27,7 @@ def validate_remote_id(value):
|
|||
|
||||
|
||||
def validate_localname(value):
|
||||
""" make sure localnames look okay """
|
||||
"""make sure localnames look okay"""
|
||||
if not re.match(r"^[A-Za-z\-_\.0-9]+$", value):
|
||||
raise ValidationError(
|
||||
_("%(value)s is not a valid username"),
|
||||
|
@ -36,7 +36,7 @@ def validate_localname(value):
|
|||
|
||||
|
||||
def validate_username(value):
|
||||
""" make sure usernames look okay """
|
||||
"""make sure usernames look okay"""
|
||||
if not re.match(r"^[A-Za-z\-_\.0-9]+@[A-Za-z\-_\.0-9]+\.[a-z]{2,}$", value):
|
||||
raise ValidationError(
|
||||
_("%(value)s is not a valid username"),
|
||||
|
@ -45,7 +45,7 @@ def validate_username(value):
|
|||
|
||||
|
||||
class ActivitypubFieldMixin:
|
||||
""" make a database field serializable """
|
||||
"""make a database field serializable"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
@ -64,7 +64,7 @@ class ActivitypubFieldMixin:
|
|||
super().__init__(*args, **kwargs)
|
||||
|
||||
def set_field_from_activity(self, instance, data):
|
||||
""" helper function for assinging a value to the field """
|
||||
"""helper function for assinging a value to the field"""
|
||||
try:
|
||||
value = getattr(data, self.get_activitypub_field())
|
||||
except AttributeError:
|
||||
|
@ -78,7 +78,7 @@ class ActivitypubFieldMixin:
|
|||
setattr(instance, self.name, formatted)
|
||||
|
||||
def set_activity_from_field(self, activity, instance):
|
||||
""" update the json object """
|
||||
"""update the json object"""
|
||||
value = getattr(instance, self.name)
|
||||
formatted = self.field_to_activity(value)
|
||||
if formatted is None:
|
||||
|
@ -94,19 +94,19 @@ class ActivitypubFieldMixin:
|
|||
activity[key] = formatted
|
||||
|
||||
def field_to_activity(self, value):
|
||||
""" formatter to convert a model value into activitypub """
|
||||
"""formatter to convert a model value into activitypub"""
|
||||
if hasattr(self, "activitypub_wrapper"):
|
||||
return {self.activitypub_wrapper: value}
|
||||
return value
|
||||
|
||||
def field_from_activity(self, value):
|
||||
""" formatter to convert activitypub into a model value """
|
||||
"""formatter to convert activitypub into a model value"""
|
||||
if value and hasattr(self, "activitypub_wrapper"):
|
||||
value = value.get(self.activitypub_wrapper)
|
||||
return value
|
||||
|
||||
def get_activitypub_field(self):
|
||||
""" model_field_name to activitypubFieldName """
|
||||
"""model_field_name to activitypubFieldName"""
|
||||
if self.activitypub_field:
|
||||
return self.activitypub_field
|
||||
name = self.name.split(".")[-1]
|
||||
|
@ -115,7 +115,7 @@ class ActivitypubFieldMixin:
|
|||
|
||||
|
||||
class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
|
||||
""" default (de)serialization for foreign key and one to one """
|
||||
"""default (de)serialization for foreign key and one to one"""
|
||||
|
||||
def __init__(self, *args, load_remote=True, **kwargs):
|
||||
self.load_remote = load_remote
|
||||
|
@ -146,7 +146,7 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
|
|||
|
||||
|
||||
class RemoteIdField(ActivitypubFieldMixin, models.CharField):
|
||||
""" a url that serves as a unique identifier """
|
||||
"""a url that serves as a unique identifier"""
|
||||
|
||||
def __init__(self, *args, max_length=255, validators=None, **kwargs):
|
||||
validators = validators or [validate_remote_id]
|
||||
|
@ -156,7 +156,7 @@ class RemoteIdField(ActivitypubFieldMixin, models.CharField):
|
|||
|
||||
|
||||
class UsernameField(ActivitypubFieldMixin, models.CharField):
|
||||
""" activitypub-aware username field """
|
||||
"""activitypub-aware username field"""
|
||||
|
||||
def __init__(self, activitypub_field="preferredUsername", **kwargs):
|
||||
self.activitypub_field = activitypub_field
|
||||
|
@ -172,7 +172,7 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
|
|||
)
|
||||
|
||||
def deconstruct(self):
|
||||
""" implementation of models.Field deconstruct """
|
||||
"""implementation of models.Field deconstruct"""
|
||||
name, path, args, kwargs = super().deconstruct()
|
||||
del kwargs["verbose_name"]
|
||||
del kwargs["max_length"]
|
||||
|
@ -191,7 +191,7 @@ PrivacyLevels = models.TextChoices(
|
|||
|
||||
|
||||
class PrivacyField(ActivitypubFieldMixin, models.CharField):
|
||||
""" this maps to two differente activitypub fields """
|
||||
"""this maps to two differente activitypub fields"""
|
||||
|
||||
public = "https://www.w3.org/ns/activitystreams#Public"
|
||||
|
||||
|
@ -236,7 +236,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
|
|||
|
||||
|
||||
class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
|
||||
""" activitypub-aware foreign key field """
|
||||
"""activitypub-aware foreign key field"""
|
||||
|
||||
def field_to_activity(self, value):
|
||||
if not value:
|
||||
|
@ -245,7 +245,7 @@ class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
|
|||
|
||||
|
||||
class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField):
|
||||
""" activitypub-aware foreign key field """
|
||||
"""activitypub-aware foreign key field"""
|
||||
|
||||
def field_to_activity(self, value):
|
||||
if not value:
|
||||
|
@ -254,14 +254,14 @@ class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField):
|
|||
|
||||
|
||||
class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
||||
""" activitypub-aware many to many field """
|
||||
"""activitypub-aware many to many field"""
|
||||
|
||||
def __init__(self, *args, link_only=False, **kwargs):
|
||||
self.link_only = link_only
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def set_field_from_activity(self, instance, data):
|
||||
""" helper function for assinging a value to the field """
|
||||
"""helper function for assinging a value to the field"""
|
||||
value = getattr(data, self.get_activitypub_field())
|
||||
formatted = self.field_from_activity(value)
|
||||
if formatted is None or formatted is MISSING:
|
||||
|
@ -275,9 +275,12 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
|||
return [i.remote_id for i in value.all()]
|
||||
|
||||
def field_from_activity(self, value):
|
||||
items = []
|
||||
if value is None or value is MISSING:
|
||||
return []
|
||||
return None
|
||||
if not isinstance(value, list):
|
||||
# If this is a link, we currently aren't doing anything with it
|
||||
return None
|
||||
items = []
|
||||
for remote_id in value:
|
||||
try:
|
||||
validate_remote_id(remote_id)
|
||||
|
@ -290,7 +293,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
|||
|
||||
|
||||
class TagField(ManyToManyField):
|
||||
""" special case of many to many that uses Tags """
|
||||
"""special case of many to many that uses Tags"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
@ -330,7 +333,7 @@ class TagField(ManyToManyField):
|
|||
|
||||
|
||||
def image_serializer(value, alt):
|
||||
""" helper for serializing images """
|
||||
"""helper for serializing images"""
|
||||
if value and hasattr(value, "url"):
|
||||
url = value.url
|
||||
else:
|
||||
|
@ -340,7 +343,7 @@ def image_serializer(value, alt):
|
|||
|
||||
|
||||
class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||
""" activitypub-aware image field """
|
||||
"""activitypub-aware image field"""
|
||||
|
||||
def __init__(self, *args, alt_field=None, **kwargs):
|
||||
self.alt_field = alt_field
|
||||
|
@ -348,7 +351,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
|||
|
||||
# pylint: disable=arguments-differ
|
||||
def set_field_from_activity(self, instance, data, save=True):
|
||||
""" helper function for assinging a value to the field """
|
||||
"""helper function for assinging a value to the field"""
|
||||
value = getattr(data, self.get_activitypub_field())
|
||||
formatted = self.field_from_activity(value)
|
||||
if formatted is None or formatted is MISSING:
|
||||
|
@ -394,7 +397,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
|||
|
||||
|
||||
class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
|
||||
""" activitypub-aware datetime field """
|
||||
"""activitypub-aware datetime field"""
|
||||
|
||||
def field_to_activity(self, value):
|
||||
if not value:
|
||||
|
@ -413,7 +416,7 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
|
|||
|
||||
|
||||
class HtmlField(ActivitypubFieldMixin, models.TextField):
|
||||
""" a text field for storing html """
|
||||
"""a text field for storing html"""
|
||||
|
||||
def field_from_activity(self, value):
|
||||
if not value or value == MISSING:
|
||||
|
@ -424,30 +427,30 @@ class HtmlField(ActivitypubFieldMixin, models.TextField):
|
|||
|
||||
|
||||
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
|
||||
""" activitypub-aware array field """
|
||||
"""activitypub-aware array field"""
|
||||
|
||||
def field_to_activity(self, value):
|
||||
return [str(i) for i in value]
|
||||
|
||||
|
||||
class CharField(ActivitypubFieldMixin, models.CharField):
|
||||
""" activitypub-aware char field """
|
||||
"""activitypub-aware char field"""
|
||||
|
||||
|
||||
class TextField(ActivitypubFieldMixin, models.TextField):
|
||||
""" activitypub-aware text field """
|
||||
"""activitypub-aware text field"""
|
||||
|
||||
|
||||
class BooleanField(ActivitypubFieldMixin, models.BooleanField):
|
||||
""" activitypub-aware boolean field """
|
||||
"""activitypub-aware boolean field"""
|
||||
|
||||
|
||||
class IntegerField(ActivitypubFieldMixin, models.IntegerField):
|
||||
""" activitypub-aware boolean field """
|
||||
"""activitypub-aware boolean field"""
|
||||
|
||||
|
||||
class DecimalField(ActivitypubFieldMixin, models.DecimalField):
|
||||
""" activitypub-aware boolean field """
|
||||
"""activitypub-aware boolean field"""
|
||||
|
||||
def field_to_activity(self, value):
|
||||
if not value:
|
||||
|
|
|
@ -20,7 +20,7 @@ GOODREADS_SHELVES = {
|
|||
|
||||
|
||||
def unquote_string(text):
|
||||
""" resolve csv quote weirdness """
|
||||
"""resolve csv quote weirdness"""
|
||||
match = re.match(r'="([^"]*)"', text)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
@ -28,7 +28,7 @@ def unquote_string(text):
|
|||
|
||||
|
||||
def construct_search_term(title, author):
|
||||
""" formulate a query for the data connector """
|
||||
"""formulate a query for the data connector"""
|
||||
# Strip brackets (usually series title from search term)
|
||||
title = re.sub(r"\s*\([^)]*\)\s*", "", title)
|
||||
# Open library doesn't like including author initials in search term.
|
||||
|
@ -38,7 +38,7 @@ def construct_search_term(title, author):
|
|||
|
||||
|
||||
class ImportJob(models.Model):
|
||||
""" entry for a specific request for book data import """
|
||||
"""entry for a specific request for book data import"""
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_date = models.DateTimeField(default=timezone.now)
|
||||
|
@ -51,7 +51,7 @@ class ImportJob(models.Model):
|
|||
retry = models.BooleanField(default=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" save and notify """
|
||||
"""save and notify"""
|
||||
super().save(*args, **kwargs)
|
||||
if self.complete:
|
||||
notification_model = apps.get_model(
|
||||
|
@ -65,7 +65,7 @@ class ImportJob(models.Model):
|
|||
|
||||
|
||||
class ImportItem(models.Model):
|
||||
""" a single line of a csv being imported """
|
||||
"""a single line of a csv being imported"""
|
||||
|
||||
job = models.ForeignKey(ImportJob, on_delete=models.CASCADE, related_name="items")
|
||||
index = models.IntegerField()
|
||||
|
@ -74,11 +74,11 @@ class ImportItem(models.Model):
|
|||
fail_reason = models.TextField(null=True)
|
||||
|
||||
def resolve(self):
|
||||
""" try various ways to lookup a book """
|
||||
"""try various ways to lookup a book"""
|
||||
self.book = self.get_book_from_isbn() or self.get_book_from_title_author()
|
||||
|
||||
def get_book_from_isbn(self):
|
||||
""" search by isbn """
|
||||
"""search by isbn"""
|
||||
search_result = connector_manager.first_search_result(
|
||||
self.isbn, min_confidence=0.999
|
||||
)
|
||||
|
@ -88,7 +88,7 @@ class ImportItem(models.Model):
|
|||
return None
|
||||
|
||||
def get_book_from_title_author(self):
|
||||
""" search by title and author """
|
||||
"""search by title and author"""
|
||||
search_term = construct_search_term(self.title, self.author)
|
||||
search_result = connector_manager.first_search_result(
|
||||
search_term, min_confidence=0.999
|
||||
|
@ -100,60 +100,60 @@ class ImportItem(models.Model):
|
|||
|
||||
@property
|
||||
def title(self):
|
||||
""" get the book title """
|
||||
"""get the book title"""
|
||||
return self.data["Title"]
|
||||
|
||||
@property
|
||||
def author(self):
|
||||
""" get the book title """
|
||||
"""get the book title"""
|
||||
return self.data["Author"]
|
||||
|
||||
@property
|
||||
def isbn(self):
|
||||
""" pulls out the isbn13 field from the csv line data """
|
||||
"""pulls out the isbn13 field from the csv line data"""
|
||||
return unquote_string(self.data["ISBN13"])
|
||||
|
||||
@property
|
||||
def shelf(self):
|
||||
""" the goodreads shelf field """
|
||||
"""the goodreads shelf field"""
|
||||
if self.data["Exclusive Shelf"]:
|
||||
return GOODREADS_SHELVES.get(self.data["Exclusive Shelf"])
|
||||
return None
|
||||
|
||||
@property
|
||||
def review(self):
|
||||
""" a user-written review, to be imported with the book data """
|
||||
"""a user-written review, to be imported with the book data"""
|
||||
return self.data["My Review"]
|
||||
|
||||
@property
|
||||
def rating(self):
|
||||
""" x/5 star rating for a book """
|
||||
"""x/5 star rating for a book"""
|
||||
return int(self.data["My Rating"])
|
||||
|
||||
@property
|
||||
def date_added(self):
|
||||
""" when the book was added to this dataset """
|
||||
"""when the book was added to this dataset"""
|
||||
if self.data["Date Added"]:
|
||||
return timezone.make_aware(dateutil.parser.parse(self.data["Date Added"]))
|
||||
return None
|
||||
|
||||
@property
|
||||
def date_started(self):
|
||||
""" when the book was started """
|
||||
"""when the book was started"""
|
||||
if "Date Started" in self.data and self.data["Date Started"]:
|
||||
return timezone.make_aware(dateutil.parser.parse(self.data["Date Started"]))
|
||||
return None
|
||||
|
||||
@property
|
||||
def date_read(self):
|
||||
""" the date a book was completed """
|
||||
"""the date a book was completed"""
|
||||
if self.data["Date Read"]:
|
||||
return timezone.make_aware(dateutil.parser.parse(self.data["Date Read"]))
|
||||
return None
|
||||
|
||||
@property
|
||||
def reads(self):
|
||||
""" formats a read through dataset for the book in this line """
|
||||
"""formats a read through dataset for the book in this line"""
|
||||
start_date = self.date_started
|
||||
|
||||
# Goodreads special case (no 'date started' field)
|
||||
|
|
|
@ -21,7 +21,7 @@ CurationType = models.TextChoices(
|
|||
|
||||
|
||||
class List(OrderedCollectionMixin, BookWyrmModel):
|
||||
""" a list of books """
|
||||
"""a list of books"""
|
||||
|
||||
name = fields.CharField(max_length=100)
|
||||
user = fields.ForeignKey(
|
||||
|
@ -41,22 +41,22 @@ class List(OrderedCollectionMixin, BookWyrmModel):
|
|||
activity_serializer = activitypub.BookList
|
||||
|
||||
def get_remote_id(self):
|
||||
""" don't want the user to be in there in this case """
|
||||
"""don't want the user to be in there in this case"""
|
||||
return "https://%s/list/%d" % (DOMAIN, self.id)
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
""" list of books for this shelf, overrides OrderedCollectionMixin """
|
||||
return self.books.filter(listitem__approved=True).all().order_by("listitem")
|
||||
"""list of books for this shelf, overrides OrderedCollectionMixin"""
|
||||
return self.books.filter(listitem__approved=True).order_by("listitem")
|
||||
|
||||
class Meta:
|
||||
""" default sorting """
|
||||
"""default sorting"""
|
||||
|
||||
ordering = ("-updated_date",)
|
||||
|
||||
|
||||
class ListItem(CollectionItemMixin, BookWyrmModel):
|
||||
""" ok """
|
||||
"""ok"""
|
||||
|
||||
book = fields.ForeignKey(
|
||||
"Edition", on_delete=models.PROTECT, activitypub_field="book"
|
||||
|
@ -67,14 +67,14 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
|||
)
|
||||
notes = fields.TextField(blank=True, null=True)
|
||||
approved = models.BooleanField(default=True)
|
||||
order = fields.IntegerField(blank=True, null=True)
|
||||
order = fields.IntegerField()
|
||||
endorsement = models.ManyToManyField("User", related_name="endorsers")
|
||||
|
||||
activity_serializer = activitypub.ListItem
|
||||
collection_field = "book_list"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" create a notification too """
|
||||
"""create a notification too"""
|
||||
created = not bool(self.id)
|
||||
super().save(*args, **kwargs)
|
||||
# tick the updated date on the parent list
|
||||
|
@ -93,7 +93,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
|||
)
|
||||
|
||||
class Meta:
|
||||
""" an opinionated constraint! you can't put a book on a list twice """
|
||||
|
||||
unique_together = ("book", "book_list")
|
||||
# A book may only be placed into a list once, and each order in the list may be used only
|
||||
# once
|
||||
unique_together = (("book", "book_list"), ("order", "book_list"))
|
||||
ordering = ("-created_date",)
|
||||
|
|
|
@ -10,7 +10,7 @@ NotificationType = models.TextChoices(
|
|||
|
||||
|
||||
class Notification(BookWyrmModel):
|
||||
""" you've been tagged, liked, followed, etc """
|
||||
"""you've been tagged, liked, followed, etc"""
|
||||
|
||||
user = models.ForeignKey("User", on_delete=models.CASCADE)
|
||||
related_book = models.ForeignKey("Edition", on_delete=models.CASCADE, null=True)
|
||||
|
@ -29,7 +29,7 @@ class Notification(BookWyrmModel):
|
|||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" save, but don't make dupes """
|
||||
"""save, but don't make dupes"""
|
||||
# there's probably a better way to do this
|
||||
if self.__class__.objects.filter(
|
||||
user=self.user,
|
||||
|
@ -45,7 +45,7 @@ class Notification(BookWyrmModel):
|
|||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
""" checks if notifcation is in enum list for valid types """
|
||||
"""checks if notifcation is in enum list for valid types"""
|
||||
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
|
|
|
@ -7,14 +7,14 @@ from .base_model import BookWyrmModel
|
|||
|
||||
|
||||
class ProgressMode(models.TextChoices):
|
||||
""" types of prgress available """
|
||||
"""types of prgress available"""
|
||||
|
||||
PAGE = "PG", "page"
|
||||
PERCENT = "PCT", "percent"
|
||||
|
||||
|
||||
class ReadThrough(BookWyrmModel):
|
||||
""" Store a read through a book in the database. """
|
||||
"""Store a read through a book in the database."""
|
||||
|
||||
user = models.ForeignKey("User", on_delete=models.PROTECT)
|
||||
book = models.ForeignKey("Edition", on_delete=models.PROTECT)
|
||||
|
@ -28,13 +28,13 @@ class ReadThrough(BookWyrmModel):
|
|||
finish_date = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" update user active time """
|
||||
"""update user active time"""
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save(broadcast=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def create_update(self):
|
||||
""" add update to the readthrough """
|
||||
"""add update to the readthrough"""
|
||||
if self.progress:
|
||||
return self.progressupdate_set.create(
|
||||
user=self.user, progress=self.progress, mode=self.progress_mode
|
||||
|
@ -43,7 +43,7 @@ class ReadThrough(BookWyrmModel):
|
|||
|
||||
|
||||
class ProgressUpdate(BookWyrmModel):
|
||||
""" Store progress through a book in the database. """
|
||||
"""Store progress through a book in the database."""
|
||||
|
||||
user = models.ForeignKey("User", on_delete=models.PROTECT)
|
||||
readthrough = models.ForeignKey("ReadThrough", on_delete=models.CASCADE)
|
||||
|
@ -53,7 +53,7 @@ class ProgressUpdate(BookWyrmModel):
|
|||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" update user active time """
|
||||
"""update user active time"""
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save(broadcast=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
|
|
@ -11,7 +11,7 @@ from . import fields
|
|||
|
||||
|
||||
class UserRelationship(BookWyrmModel):
|
||||
""" many-to-many through table for followers """
|
||||
"""many-to-many through table for followers"""
|
||||
|
||||
user_subject = fields.ForeignKey(
|
||||
"User",
|
||||
|
@ -28,16 +28,16 @@ class UserRelationship(BookWyrmModel):
|
|||
|
||||
@property
|
||||
def privacy(self):
|
||||
""" all relationships are handled directly with the participants """
|
||||
"""all relationships are handled directly with the participants"""
|
||||
return "direct"
|
||||
|
||||
@property
|
||||
def recipients(self):
|
||||
""" the remote user needs to recieve direct broadcasts """
|
||||
"""the remote user needs to recieve direct broadcasts"""
|
||||
return [u for u in [self.user_subject, self.user_object] if not u.local]
|
||||
|
||||
class Meta:
|
||||
""" relationships should be unique """
|
||||
"""relationships should be unique"""
|
||||
|
||||
abstract = True
|
||||
constraints = [
|
||||
|
@ -50,24 +50,23 @@ class UserRelationship(BookWyrmModel):
|
|||
),
|
||||
]
|
||||
|
||||
def get_remote_id(self, status=None): # pylint: disable=arguments-differ
|
||||
""" use shelf identifier in remote_id """
|
||||
status = status or "follows"
|
||||
def get_remote_id(self):
|
||||
"""use shelf identifier in remote_id"""
|
||||
base_path = self.user_subject.remote_id
|
||||
return "%s#%s/%d" % (base_path, status, self.id)
|
||||
return "%s#follows/%d" % (base_path, self.id)
|
||||
|
||||
|
||||
class UserFollows(ActivityMixin, UserRelationship):
|
||||
""" Following a user """
|
||||
"""Following a user"""
|
||||
|
||||
status = "follows"
|
||||
|
||||
def to_activity(self): # pylint: disable=arguments-differ
|
||||
""" overrides default to manually set serializer """
|
||||
"""overrides default to manually set serializer"""
|
||||
return activitypub.Follow(**generate_activity(self))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" really really don't let a user follow someone who blocked them """
|
||||
"""really really don't let a user follow someone who blocked them"""
|
||||
# blocking in either direction is a no-go
|
||||
if UserBlocks.objects.filter(
|
||||
Q(
|
||||
|
@ -86,7 +85,7 @@ class UserFollows(ActivityMixin, UserRelationship):
|
|||
|
||||
@classmethod
|
||||
def from_request(cls, follow_request):
|
||||
""" converts a follow request into a follow relationship """
|
||||
"""converts a follow request into a follow relationship"""
|
||||
return cls.objects.create(
|
||||
user_subject=follow_request.user_subject,
|
||||
user_object=follow_request.user_object,
|
||||
|
@ -95,19 +94,22 @@ class UserFollows(ActivityMixin, UserRelationship):
|
|||
|
||||
|
||||
class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
||||
""" following a user requires manual or automatic confirmation """
|
||||
"""following a user requires manual or automatic confirmation"""
|
||||
|
||||
status = "follow_request"
|
||||
activity_serializer = activitypub.Follow
|
||||
|
||||
def save(self, *args, broadcast=True, **kwargs):
|
||||
""" make sure the follow or block relationship doesn't already exist """
|
||||
# don't create a request if a follow already exists
|
||||
"""make sure the follow or block relationship doesn't already exist"""
|
||||
# if there's a request for a follow that already exists, accept it
|
||||
# without changing the local database state
|
||||
if UserFollows.objects.filter(
|
||||
user_subject=self.user_subject,
|
||||
user_object=self.user_object,
|
||||
).exists():
|
||||
raise IntegrityError()
|
||||
self.accept(broadcast_only=True)
|
||||
return
|
||||
|
||||
# blocking in either direction is a no-go
|
||||
if UserBlocks.objects.filter(
|
||||
Q(
|
||||
|
@ -138,25 +140,34 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
|||
notification_type=notification_type,
|
||||
)
|
||||
|
||||
def accept(self):
|
||||
""" turn this request into the real deal"""
|
||||
def get_accept_reject_id(self, status):
|
||||
"""get id for sending an accept or reject of a local user"""
|
||||
|
||||
base_path = self.user_object.remote_id
|
||||
return "%s#%s/%d" % (base_path, status, self.id or 0)
|
||||
|
||||
def accept(self, broadcast_only=False):
|
||||
"""turn this request into the real deal"""
|
||||
user = self.user_object
|
||||
if not self.user_subject.local:
|
||||
activity = activitypub.Accept(
|
||||
id=self.get_remote_id(status="accepts"),
|
||||
id=self.get_accept_reject_id(status="accepts"),
|
||||
actor=self.user_object.remote_id,
|
||||
object=self.to_activity(),
|
||||
).serialize()
|
||||
self.broadcast(activity, user)
|
||||
if broadcast_only:
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
UserFollows.from_request(self)
|
||||
self.delete()
|
||||
|
||||
def reject(self):
|
||||
""" generate a Reject for this follow request """
|
||||
"""generate a Reject for this follow request"""
|
||||
if self.user_object.local:
|
||||
activity = activitypub.Reject(
|
||||
id=self.get_remote_id(status="rejects"),
|
||||
id=self.get_accept_reject_id(status="rejects"),
|
||||
actor=self.user_object.remote_id,
|
||||
object=self.to_activity(),
|
||||
).serialize()
|
||||
|
@ -166,13 +177,13 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
|||
|
||||
|
||||
class UserBlocks(ActivityMixin, UserRelationship):
|
||||
""" prevent another user from following you and seeing your posts """
|
||||
"""prevent another user from following you and seeing your posts"""
|
||||
|
||||
status = "blocks"
|
||||
activity_serializer = activitypub.Block
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" remove follow or follow request rels after a block is created """
|
||||
"""remove follow or follow request rels after a block is created"""
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
UserFollows.objects.filter(
|
||||
|
|
|
@ -6,7 +6,7 @@ from .base_model import BookWyrmModel
|
|||
|
||||
|
||||
class Report(BookWyrmModel):
|
||||
""" reported status or user """
|
||||
"""reported status or user"""
|
||||
|
||||
reporter = models.ForeignKey(
|
||||
"User", related_name="reporter", on_delete=models.PROTECT
|
||||
|
@ -17,7 +17,7 @@ class Report(BookWyrmModel):
|
|||
resolved = models.BooleanField(default=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" notify admins when a report is created """
|
||||
"""notify admins when a report is created"""
|
||||
super().save(*args, **kwargs)
|
||||
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
||||
# moderators and superusers should be notified
|
||||
|
@ -34,7 +34,7 @@ class Report(BookWyrmModel):
|
|||
)
|
||||
|
||||
class Meta:
|
||||
""" don't let users report themselves """
|
||||
"""don't let users report themselves"""
|
||||
|
||||
constraints = [
|
||||
models.CheckConstraint(check=~Q(reporter=F("user")), name="self_report")
|
||||
|
@ -43,13 +43,13 @@ class Report(BookWyrmModel):
|
|||
|
||||
|
||||
class ReportComment(BookWyrmModel):
|
||||
""" updates on a report """
|
||||
"""updates on a report"""
|
||||
|
||||
user = models.ForeignKey("User", on_delete=models.PROTECT)
|
||||
note = models.TextField()
|
||||
report = models.ForeignKey(Report, on_delete=models.PROTECT)
|
||||
|
||||
class Meta:
|
||||
""" sort comments """
|
||||
"""sort comments"""
|
||||
|
||||
ordering = ("-created_date",)
|
||||
|
|
|
@ -9,7 +9,7 @@ from . import fields
|
|||
|
||||
|
||||
class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
||||
""" a list of books owned by a user """
|
||||
"""a list of books owned by a user"""
|
||||
|
||||
TO_READ = "to-read"
|
||||
READING = "reading"
|
||||
|
@ -34,36 +34,36 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
|||
activity_serializer = activitypub.Shelf
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" set the identifier """
|
||||
"""set the identifier"""
|
||||
super().save(*args, **kwargs)
|
||||
if not self.identifier:
|
||||
self.identifier = self.get_identifier()
|
||||
super().save(*args, **kwargs, broadcast=False)
|
||||
|
||||
def get_identifier(self):
|
||||
""" custom-shelf-123 for the url """
|
||||
"""custom-shelf-123 for the url"""
|
||||
slug = re.sub(r"[^\w]", "", self.name).lower()
|
||||
return "{:s}-{:d}".format(slug, self.id)
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
""" list of books for this shelf, overrides OrderedCollectionMixin """
|
||||
return self.books.all().order_by("shelfbook")
|
||||
"""list of books for this shelf, overrides OrderedCollectionMixin"""
|
||||
return self.books.order_by("shelfbook")
|
||||
|
||||
def get_remote_id(self):
|
||||
""" shelf identifier instead of id """
|
||||
"""shelf identifier instead of id"""
|
||||
base_path = self.user.remote_id
|
||||
identifier = self.identifier or self.get_identifier()
|
||||
return "%s/books/%s" % (base_path, identifier)
|
||||
|
||||
class Meta:
|
||||
""" user/shelf unqiueness """
|
||||
"""user/shelf unqiueness"""
|
||||
|
||||
unique_together = ("user", "identifier")
|
||||
|
||||
|
||||
class ShelfBook(CollectionItemMixin, BookWyrmModel):
|
||||
""" many to many join table for books and shelves """
|
||||
"""many to many join table for books and shelves"""
|
||||
|
||||
book = fields.ForeignKey(
|
||||
"Edition", on_delete=models.PROTECT, activitypub_field="book"
|
||||
|
|
|
@ -12,7 +12,7 @@ from .user import User
|
|||
|
||||
|
||||
class SiteSettings(models.Model):
|
||||
""" customized settings for this instance """
|
||||
"""customized settings for this instance"""
|
||||
|
||||
name = models.CharField(default="BookWyrm", max_length=100)
|
||||
instance_tagline = models.CharField(
|
||||
|
@ -35,7 +35,7 @@ class SiteSettings(models.Model):
|
|||
|
||||
@classmethod
|
||||
def get(cls):
|
||||
""" gets the site settings db entry or defaults """
|
||||
"""gets the site settings db entry or defaults"""
|
||||
try:
|
||||
return cls.objects.get(id=1)
|
||||
except cls.DoesNotExist:
|
||||
|
@ -45,12 +45,12 @@ class SiteSettings(models.Model):
|
|||
|
||||
|
||||
def new_access_code():
|
||||
""" the identifier for a user invite """
|
||||
"""the identifier for a user invite"""
|
||||
return base64.b32encode(Random.get_random_bytes(5)).decode("ascii")
|
||||
|
||||
|
||||
class SiteInvite(models.Model):
|
||||
""" gives someone access to create an account on the instance """
|
||||
"""gives someone access to create an account on the instance"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
code = models.CharField(max_length=32, default=new_access_code)
|
||||
|
@ -61,19 +61,19 @@ class SiteInvite(models.Model):
|
|||
invitees = models.ManyToManyField(User, related_name="invitees")
|
||||
|
||||
def valid(self):
|
||||
""" make sure it hasn't expired or been used """
|
||||
"""make sure it hasn't expired or been used"""
|
||||
return (self.expiry is None or self.expiry > timezone.now()) and (
|
||||
self.use_limit is None or self.times_used < self.use_limit
|
||||
)
|
||||
|
||||
@property
|
||||
def link(self):
|
||||
""" formats the invite link """
|
||||
"""formats the invite link"""
|
||||
return "https://{}/invite/{}".format(DOMAIN, self.code)
|
||||
|
||||
|
||||
class InviteRequest(BookWyrmModel):
|
||||
""" prospective users can request an invite """
|
||||
"""prospective users can request an invite"""
|
||||
|
||||
email = models.EmailField(max_length=255, unique=True)
|
||||
invite = models.ForeignKey(
|
||||
|
@ -83,30 +83,30 @@ class InviteRequest(BookWyrmModel):
|
|||
ignored = models.BooleanField(default=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" don't create a request for a registered email """
|
||||
"""don't create a request for a registered email"""
|
||||
if not self.id and User.objects.filter(email=self.email).exists():
|
||||
raise IntegrityError()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
def get_passowrd_reset_expiry():
|
||||
""" give people a limited time to use the link """
|
||||
"""give people a limited time to use the link"""
|
||||
now = timezone.now()
|
||||
return now + datetime.timedelta(days=1)
|
||||
|
||||
|
||||
class PasswordReset(models.Model):
|
||||
""" gives someone access to create an account on the instance """
|
||||
"""gives someone access to create an account on the instance"""
|
||||
|
||||
code = models.CharField(max_length=32, default=new_access_code)
|
||||
expiry = models.DateTimeField(default=get_passowrd_reset_expiry)
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||
|
||||
def valid(self):
|
||||
""" make sure it hasn't expired or been used """
|
||||
"""make sure it hasn't expired or been used"""
|
||||
return self.expiry > timezone.now()
|
||||
|
||||
@property
|
||||
def link(self):
|
||||
""" formats the invite link """
|
||||
"""formats the invite link"""
|
||||
return "https://{}/password-reset/{}".format(DOMAIN, self.code)
|
||||
|
|
|
@ -19,7 +19,7 @@ from . import fields
|
|||
|
||||
|
||||
class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||
""" any post, like a reply to a review, etc """
|
||||
"""any post, like a reply to a review, etc"""
|
||||
|
||||
user = fields.ForeignKey(
|
||||
"User", on_delete=models.PROTECT, activitypub_field="attributedTo"
|
||||
|
@ -59,12 +59,12 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
deserialize_reverse_fields = [("attachments", "attachment")]
|
||||
|
||||
class Meta:
|
||||
""" default sorting """
|
||||
"""default sorting"""
|
||||
|
||||
ordering = ("-published_date",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" save and notify """
|
||||
"""save and notify"""
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
|
@ -98,7 +98,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
)
|
||||
|
||||
def delete(self, *args, **kwargs): # pylint: disable=unused-argument
|
||||
""" "delete" a status """
|
||||
""" "delete" a status"""
|
||||
if hasattr(self, "boosted_status"):
|
||||
# okay but if it's a boost really delete it
|
||||
super().delete(*args, **kwargs)
|
||||
|
@ -109,7 +109,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
|
||||
@property
|
||||
def recipients(self):
|
||||
""" tagged users who definitely need to get this status in broadcast """
|
||||
"""tagged users who definitely need to get this status in broadcast"""
|
||||
mentions = [u for u in self.mention_users.all() if not u.local]
|
||||
if (
|
||||
hasattr(self, "reply_parent")
|
||||
|
@ -121,7 +121,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
|
||||
@classmethod
|
||||
def ignore_activity(cls, activity): # pylint: disable=too-many-return-statements
|
||||
""" keep notes if they are replies to existing statuses """
|
||||
"""keep notes if they are replies to existing statuses"""
|
||||
if activity.type == "Announce":
|
||||
try:
|
||||
boosted = activitypub.resolve_remote_id(
|
||||
|
@ -163,16 +163,16 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
|
||||
@property
|
||||
def status_type(self):
|
||||
""" expose the type of status for the ui using activity type """
|
||||
"""expose the type of status for the ui using activity type"""
|
||||
return self.activity_serializer.__name__
|
||||
|
||||
@property
|
||||
def boostable(self):
|
||||
""" you can't boost dms """
|
||||
"""you can't boost dms"""
|
||||
return self.privacy in ["unlisted", "public"]
|
||||
|
||||
def to_replies(self, **kwargs):
|
||||
""" helper function for loading AP serialized replies to a status """
|
||||
"""helper function for loading AP serialized replies to a status"""
|
||||
return self.to_ordered_collection(
|
||||
self.replies(self),
|
||||
remote_id="%s/replies" % self.remote_id,
|
||||
|
@ -181,7 +181,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
).serialize()
|
||||
|
||||
def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ
|
||||
""" return tombstone if the status is deleted """
|
||||
"""return tombstone if the status is deleted"""
|
||||
if self.deleted:
|
||||
return activitypub.Tombstone(
|
||||
id=self.remote_id,
|
||||
|
@ -210,16 +210,16 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
return activity
|
||||
|
||||
def to_activity(self, pure=False): # pylint: disable=arguments-differ
|
||||
""" json serialized activitypub class """
|
||||
"""json serialized activitypub class"""
|
||||
return self.to_activity_dataclass(pure=pure).serialize()
|
||||
|
||||
|
||||
class GeneratedNote(Status):
|
||||
""" these are app-generated messages about user activity """
|
||||
"""these are app-generated messages about user activity"""
|
||||
|
||||
@property
|
||||
def pure_content(self):
|
||||
""" indicate the book in question for mastodon (or w/e) users """
|
||||
"""indicate the book in question for mastodon (or w/e) users"""
|
||||
message = self.content
|
||||
books = ", ".join(
|
||||
'<a href="%s">"%s"</a>' % (book.remote_id, book.title)
|
||||
|
@ -232,7 +232,7 @@ class GeneratedNote(Status):
|
|||
|
||||
|
||||
class Comment(Status):
|
||||
""" like a review but without a rating and transient """
|
||||
"""like a review but without a rating and transient"""
|
||||
|
||||
book = fields.ForeignKey(
|
||||
"Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
|
||||
|
@ -253,7 +253,7 @@ class Comment(Status):
|
|||
|
||||
@property
|
||||
def pure_content(self):
|
||||
""" indicate the book in question for mastodon (or w/e) users """
|
||||
"""indicate the book in question for mastodon (or w/e) users"""
|
||||
return '%s<p>(comment on <a href="%s">"%s"</a>)</p>' % (
|
||||
self.content,
|
||||
self.book.remote_id,
|
||||
|
@ -265,7 +265,7 @@ class Comment(Status):
|
|||
|
||||
|
||||
class Quotation(Status):
|
||||
""" like a review but without a rating and transient """
|
||||
"""like a review but without a rating and transient"""
|
||||
|
||||
quote = fields.HtmlField()
|
||||
book = fields.ForeignKey(
|
||||
|
@ -274,7 +274,7 @@ class Quotation(Status):
|
|||
|
||||
@property
|
||||
def pure_content(self):
|
||||
""" indicate the book in question for mastodon (or w/e) users """
|
||||
"""indicate the book in question for mastodon (or w/e) users"""
|
||||
quote = re.sub(r"^<p>", '<p>"', self.quote)
|
||||
quote = re.sub(r"</p>$", '"</p>', quote)
|
||||
return '%s <p>-- <a href="%s">"%s"</a></p>%s' % (
|
||||
|
@ -289,7 +289,7 @@ class Quotation(Status):
|
|||
|
||||
|
||||
class Review(Status):
|
||||
""" a book review """
|
||||
"""a book review"""
|
||||
|
||||
name = fields.CharField(max_length=255, null=True)
|
||||
book = fields.ForeignKey(
|
||||
|
@ -306,7 +306,7 @@ class Review(Status):
|
|||
|
||||
@property
|
||||
def pure_name(self):
|
||||
""" clarify review names for mastodon serialization """
|
||||
"""clarify review names for mastodon serialization"""
|
||||
template = get_template("snippets/generated_status/review_pure_name.html")
|
||||
return template.render(
|
||||
{"book": self.book, "rating": self.rating, "name": self.name}
|
||||
|
@ -314,7 +314,7 @@ class Review(Status):
|
|||
|
||||
@property
|
||||
def pure_content(self):
|
||||
""" indicate the book in question for mastodon (or w/e) users """
|
||||
"""indicate the book in question for mastodon (or w/e) users"""
|
||||
return self.content
|
||||
|
||||
activity_serializer = activitypub.Review
|
||||
|
@ -322,7 +322,7 @@ class Review(Status):
|
|||
|
||||
|
||||
class ReviewRating(Review):
|
||||
""" a subtype of review that only contains a rating """
|
||||
"""a subtype of review that only contains a rating"""
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.rating:
|
||||
|
@ -339,7 +339,7 @@ class ReviewRating(Review):
|
|||
|
||||
|
||||
class Boost(ActivityMixin, Status):
|
||||
""" boost'ing a post """
|
||||
"""boost'ing a post"""
|
||||
|
||||
boosted_status = fields.ForeignKey(
|
||||
"Status",
|
||||
|
@ -350,7 +350,17 @@ class Boost(ActivityMixin, Status):
|
|||
activity_serializer = activitypub.Announce
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" save and notify """
|
||||
"""save and notify"""
|
||||
# This constraint can't work as it would cross tables.
|
||||
# class Meta:
|
||||
# unique_together = ('user', 'boosted_status')
|
||||
if (
|
||||
Boost.objects.filter(boosted_status=self.boosted_status, user=self.user)
|
||||
.exclude(id=self.id)
|
||||
.exists()
|
||||
):
|
||||
return
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
if not self.boosted_status.user.local or self.boosted_status.user == self.user:
|
||||
return
|
||||
|
@ -364,7 +374,7 @@ class Boost(ActivityMixin, Status):
|
|||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
""" delete and un-notify """
|
||||
"""delete and un-notify"""
|
||||
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
notification_model.objects.filter(
|
||||
user=self.boosted_status.user,
|
||||
|
@ -375,7 +385,7 @@ class Boost(ActivityMixin, Status):
|
|||
super().delete(*args, **kwargs)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
""" the user field is "actor" here instead of "attributedTo" """
|
||||
"""the user field is "actor" here instead of "attributedTo" """
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
reserve_fields = ["user", "boosted_status", "published_date", "privacy"]
|
||||
|
|
|
@ -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")
|
|
@ -35,7 +35,7 @@ DeactivationReason = models.TextChoices(
|
|||
|
||||
|
||||
class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
""" a user who wants to read books """
|
||||
"""a user who wants to read books"""
|
||||
|
||||
username = fields.UsernameField()
|
||||
email = models.EmailField(unique=True, null=True)
|
||||
|
@ -130,38 +130,38 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
|
||||
@property
|
||||
def following_link(self):
|
||||
""" just how to find out the following info """
|
||||
"""just how to find out the following info"""
|
||||
return "{:s}/following".format(self.remote_id)
|
||||
|
||||
@property
|
||||
def alt_text(self):
|
||||
""" alt text with username """
|
||||
"""alt text with username"""
|
||||
return "avatar for %s" % (self.localname or self.username)
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
""" show the cleanest version of the user's name possible """
|
||||
"""show the cleanest version of the user's name possible"""
|
||||
if self.name and self.name != "":
|
||||
return self.name
|
||||
return self.localname or self.username
|
||||
|
||||
@property
|
||||
def deleted(self):
|
||||
""" for consistent naming """
|
||||
"""for consistent naming"""
|
||||
return not self.is_active
|
||||
|
||||
activity_serializer = activitypub.Person
|
||||
|
||||
@classmethod
|
||||
def viewer_aware_objects(cls, viewer):
|
||||
""" the user queryset filtered for the context of the logged in user """
|
||||
"""the user queryset filtered for the context of the logged in user"""
|
||||
queryset = cls.objects.filter(is_active=True)
|
||||
if viewer and viewer.is_authenticated:
|
||||
queryset = queryset.exclude(blocks=viewer)
|
||||
return queryset
|
||||
|
||||
def to_outbox(self, filter_type=None, **kwargs):
|
||||
""" an ordered collection of statuses """
|
||||
"""an ordered collection of statuses"""
|
||||
if filter_type:
|
||||
filter_class = apps.get_model(
|
||||
"bookwyrm.%s" % filter_type, require_ready=True
|
||||
|
@ -188,7 +188,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
).serialize()
|
||||
|
||||
def to_following_activity(self, **kwargs):
|
||||
""" activitypub following list """
|
||||
"""activitypub following list"""
|
||||
remote_id = "%s/following" % self.remote_id
|
||||
return self.to_ordered_collection(
|
||||
self.following.order_by("-updated_date").all(),
|
||||
|
@ -198,7 +198,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
)
|
||||
|
||||
def to_followers_activity(self, **kwargs):
|
||||
""" activitypub followers list """
|
||||
"""activitypub followers list"""
|
||||
remote_id = "%s/followers" % self.remote_id
|
||||
return self.to_ordered_collection(
|
||||
self.followers.order_by("-updated_date").all(),
|
||||
|
@ -227,7 +227,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
return activity_object
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" populate fields for new local users """
|
||||
"""populate fields for new local users"""
|
||||
created = not bool(self.id)
|
||||
if not self.local and not re.match(regex.full_username, self.username):
|
||||
# generate a username that uses the domain (webfinger format)
|
||||
|
@ -292,19 +292,19 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
).save(broadcast=False)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
""" deactivate rather than delete a user """
|
||||
"""deactivate rather than delete a user"""
|
||||
self.is_active = False
|
||||
# skip the logic in this class's save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def local_path(self):
|
||||
""" this model doesn't inherit bookwyrm model, so here we are """
|
||||
"""this model doesn't inherit bookwyrm model, so here we are"""
|
||||
return "/user/%s" % (self.localname or self.username)
|
||||
|
||||
|
||||
class KeyPair(ActivitypubMixin, BookWyrmModel):
|
||||
""" public and private keys for a user """
|
||||
"""public and private keys for a user"""
|
||||
|
||||
private_key = models.TextField(blank=True, null=True)
|
||||
public_key = fields.TextField(
|
||||
|
@ -319,7 +319,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
|
|||
return "%s/#main-key" % self.owner.remote_id
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" create a key pair """
|
||||
"""create a key pair"""
|
||||
# no broadcasting happening here
|
||||
if "broadcast" in kwargs:
|
||||
del kwargs["broadcast"]
|
||||
|
@ -337,7 +337,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
|
|||
|
||||
|
||||
class AnnualGoal(BookWyrmModel):
|
||||
""" set a goal for how many books you read in a year """
|
||||
"""set a goal for how many books you read in a year"""
|
||||
|
||||
user = models.ForeignKey("User", on_delete=models.PROTECT)
|
||||
goal = models.IntegerField(validators=[MinValueValidator(1)])
|
||||
|
@ -347,17 +347,17 @@ class AnnualGoal(BookWyrmModel):
|
|||
)
|
||||
|
||||
class Meta:
|
||||
""" unqiueness constraint """
|
||||
"""unqiueness constraint"""
|
||||
|
||||
unique_together = ("user", "year")
|
||||
|
||||
def get_remote_id(self):
|
||||
""" put the year in the path """
|
||||
"""put the year in the path"""
|
||||
return "%s/goal/%d" % (self.user.remote_id, self.year)
|
||||
|
||||
@property
|
||||
def books(self):
|
||||
""" the books you've read this year """
|
||||
"""the books you've read this year"""
|
||||
return (
|
||||
self.user.readthrough_set.filter(finish_date__year__gte=self.year)
|
||||
.order_by("-finish_date")
|
||||
|
@ -366,7 +366,7 @@ class AnnualGoal(BookWyrmModel):
|
|||
|
||||
@property
|
||||
def ratings(self):
|
||||
""" ratings for books read this year """
|
||||
"""ratings for books read this year"""
|
||||
book_ids = [r.book.id for r in self.books]
|
||||
reviews = Review.objects.filter(
|
||||
user=self.user,
|
||||
|
@ -376,12 +376,12 @@ class AnnualGoal(BookWyrmModel):
|
|||
|
||||
@property
|
||||
def progress_percent(self):
|
||||
""" how close to your goal, in percent form """
|
||||
"""how close to your goal, in percent form"""
|
||||
return int(float(self.book_count / self.goal) * 100)
|
||||
|
||||
@property
|
||||
def book_count(self):
|
||||
""" how many books you've read this year """
|
||||
"""how many books you've read this year"""
|
||||
return self.user.readthrough_set.filter(
|
||||
finish_date__year__gte=self.year
|
||||
).count()
|
||||
|
@ -389,7 +389,7 @@ class AnnualGoal(BookWyrmModel):
|
|||
|
||||
@app.task
|
||||
def set_remote_server(user_id):
|
||||
""" figure out the user's remote server in the background """
|
||||
"""figure out the user's remote server in the background"""
|
||||
user = User.objects.get(id=user_id)
|
||||
actor_parts = urlparse(user.remote_id)
|
||||
user.federated_server = get_or_create_remote_server(actor_parts.netloc)
|
||||
|
@ -399,7 +399,7 @@ def set_remote_server(user_id):
|
|||
|
||||
|
||||
def get_or_create_remote_server(domain):
|
||||
""" get info on a remote server """
|
||||
"""get info on a remote server"""
|
||||
try:
|
||||
return FederatedServer.objects.get(server_name=domain)
|
||||
except FederatedServer.DoesNotExist:
|
||||
|
@ -428,7 +428,7 @@ def get_or_create_remote_server(domain):
|
|||
|
||||
@app.task
|
||||
def get_remote_reviews(outbox):
|
||||
""" ingest reviews by a new remote bookwyrm user """
|
||||
"""ingest reviews by a new remote bookwyrm user"""
|
||||
outbox_page = outbox + "?page=true&type=Review"
|
||||
data = get_data(outbox_page)
|
||||
|
||||
|
|
|
@ -10,16 +10,16 @@ r = redis.Redis(
|
|||
|
||||
|
||||
class RedisStore(ABC):
|
||||
""" sets of ranked, related objects, like statuses for a user's feed """
|
||||
"""sets of ranked, related objects, like statuses for a user's feed"""
|
||||
|
||||
max_length = settings.MAX_STREAM_LENGTH
|
||||
|
||||
def get_value(self, obj):
|
||||
""" the object and rank """
|
||||
"""the object and rank"""
|
||||
return {obj.id: self.get_rank(obj)}
|
||||
|
||||
def add_object_to_related_stores(self, obj, execute=True):
|
||||
""" add an object to all suitable stores """
|
||||
"""add an object to all suitable stores"""
|
||||
value = self.get_value(obj)
|
||||
# we want to do this as a bulk operation, hence "pipeline"
|
||||
pipeline = r.pipeline()
|
||||
|
@ -34,14 +34,14 @@ class RedisStore(ABC):
|
|||
return pipeline.execute()
|
||||
|
||||
def remove_object_from_related_stores(self, obj):
|
||||
""" remove an object from all stores """
|
||||
"""remove an object from all stores"""
|
||||
pipeline = r.pipeline()
|
||||
for store in self.get_stores_for_object(obj):
|
||||
pipeline.zrem(store, -1, obj.id)
|
||||
pipeline.execute()
|
||||
|
||||
def bulk_add_objects_to_store(self, objs, store):
|
||||
""" add a list of objects to a given store """
|
||||
"""add a list of objects to a given store"""
|
||||
pipeline = r.pipeline()
|
||||
for obj in objs[: self.max_length]:
|
||||
pipeline.zadd(store, self.get_value(obj))
|
||||
|
@ -50,18 +50,18 @@ class RedisStore(ABC):
|
|||
pipeline.execute()
|
||||
|
||||
def bulk_remove_objects_from_store(self, objs, store):
|
||||
""" remoev a list of objects from a given store """
|
||||
"""remoev a list of objects from a given store"""
|
||||
pipeline = r.pipeline()
|
||||
for obj in objs[: self.max_length]:
|
||||
pipeline.zrem(store, -1, obj.id)
|
||||
pipeline.execute()
|
||||
|
||||
def get_store(self, store): # pylint: disable=no-self-use
|
||||
""" load the values in a store """
|
||||
"""load the values in a store"""
|
||||
return r.zrevrange(store, 0, -1)
|
||||
|
||||
def populate_store(self, store):
|
||||
""" go from zero to a store """
|
||||
"""go from zero to a store"""
|
||||
pipeline = r.pipeline()
|
||||
queryset = self.get_objects_for_store(store)
|
||||
|
||||
|
@ -75,12 +75,12 @@ class RedisStore(ABC):
|
|||
|
||||
@abstractmethod
|
||||
def get_objects_for_store(self, store):
|
||||
""" a queryset of what should go in a store, used for populating it """
|
||||
"""a queryset of what should go in a store, used for populating it"""
|
||||
|
||||
@abstractmethod
|
||||
def get_stores_for_object(self, obj):
|
||||
""" the stores that an object belongs in """
|
||||
"""the stores that an object belongs in"""
|
||||
|
||||
@abstractmethod
|
||||
def get_rank(self, obj):
|
||||
""" how to rank an object """
|
||||
"""how to rank an object"""
|
||||
|
|
|
@ -3,7 +3,7 @@ from html.parser import HTMLParser
|
|||
|
||||
|
||||
class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
|
||||
""" Removes any html that isn't allowed_tagsed from a block """
|
||||
"""Removes any html that isn't allowed_tagsed from a block"""
|
||||
|
||||
def __init__(self):
|
||||
HTMLParser.__init__(self)
|
||||
|
@ -28,7 +28,7 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
|
|||
self.allow_html = True
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
""" check if the tag is valid """
|
||||
"""check if the tag is valid"""
|
||||
if self.allow_html and tag in self.allowed_tags:
|
||||
self.output.append(("tag", self.get_starttag_text()))
|
||||
self.tag_stack.append(tag)
|
||||
|
@ -36,7 +36,7 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
|
|||
self.output.append(("data", ""))
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
""" keep the close tag """
|
||||
"""keep the close tag"""
|
||||
if not self.allow_html or tag not in self.allowed_tags:
|
||||
self.output.append(("data", ""))
|
||||
return
|
||||
|
@ -51,11 +51,11 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
|
|||
self.output.append(("tag", "</%s>" % tag))
|
||||
|
||||
def handle_data(self, data):
|
||||
""" extract the answer, if we're in an answer tag """
|
||||
"""extract the answer, if we're in an answer tag"""
|
||||
self.output.append(("data", data))
|
||||
|
||||
def get_output(self):
|
||||
""" convert the output from a list of tuples to a string """
|
||||
"""convert the output from a list of tuples to a string"""
|
||||
if self.tag_stack:
|
||||
self.allow_html = False
|
||||
if not self.allow_html:
|
||||
|
|
|
@ -153,7 +153,7 @@ LANGUAGES = [
|
|||
("de-de", _("German")),
|
||||
("es", _("Spanish")),
|
||||
("fr-fr", _("French")),
|
||||
("zh-cn", _("Simplified Chinese")),
|
||||
("zh-hans", _("Simplified Chinese")),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ MAX_SIGNATURE_AGE = 300
|
|||
|
||||
|
||||
def create_key_pair():
|
||||
""" a new public/private key pair, used for creating new users """
|
||||
"""a new public/private key pair, used for creating new users"""
|
||||
random_generator = Random.new().read
|
||||
key = RSA.generate(1024, random_generator)
|
||||
private_key = key.export_key().decode("utf8")
|
||||
|
@ -23,7 +23,7 @@ def create_key_pair():
|
|||
|
||||
|
||||
def make_signature(sender, destination, date, digest):
|
||||
""" uses a private key to sign an outgoing message """
|
||||
"""uses a private key to sign an outgoing message"""
|
||||
inbox_parts = urlparse(destination)
|
||||
signature_headers = [
|
||||
"(request-target): post %s" % inbox_parts.path,
|
||||
|
@ -44,14 +44,14 @@ def make_signature(sender, destination, date, digest):
|
|||
|
||||
|
||||
def make_digest(data):
|
||||
""" creates a message digest for signing """
|
||||
"""creates a message digest for signing"""
|
||||
return "SHA-256=" + b64encode(hashlib.sha256(data.encode("utf-8")).digest()).decode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
|
||||
def verify_digest(request):
|
||||
""" checks if a digest is syntactically valid and matches the message """
|
||||
"""checks if a digest is syntactically valid and matches the message"""
|
||||
algorithm, digest = request.headers["digest"].split("=", 1)
|
||||
if algorithm == "SHA-256":
|
||||
hash_function = hashlib.sha256
|
||||
|
@ -66,7 +66,7 @@ def verify_digest(request):
|
|||
|
||||
|
||||
class Signature:
|
||||
""" read and validate incoming signatures """
|
||||
"""read and validate incoming signatures"""
|
||||
|
||||
def __init__(self, key_id, headers, signature):
|
||||
self.key_id = key_id
|
||||
|
@ -75,7 +75,7 @@ class Signature:
|
|||
|
||||
@classmethod
|
||||
def parse(cls, request):
|
||||
""" extract and parse a signature from an http request """
|
||||
"""extract and parse a signature from an http request"""
|
||||
signature_dict = {}
|
||||
for pair in request.headers["Signature"].split(","):
|
||||
k, v = pair.split("=", 1)
|
||||
|
@ -92,7 +92,7 @@ class Signature:
|
|||
return cls(key_id, headers, signature)
|
||||
|
||||
def verify(self, public_key, request):
|
||||
""" verify rsa signature """
|
||||
"""verify rsa signature"""
|
||||
if http_date_age(request.headers["date"]) > MAX_SIGNATURE_AGE:
|
||||
raise ValueError("Request too old: %s" % (request.headers["date"],))
|
||||
public_key = RSA.import_key(public_key)
|
||||
|
@ -118,7 +118,7 @@ class Signature:
|
|||
|
||||
|
||||
def http_date_age(datestr):
|
||||
""" age of a signature in seconds """
|
||||
"""age of a signature in seconds"""
|
||||
parsed = datetime.datetime.strptime(datestr, "%a, %d %b %Y %H:%M:%S GMT")
|
||||
delta = datetime.datetime.utcnow() - parsed
|
||||
return delta.total_seconds()
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
html {
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 20%;
|
||||
}
|
||||
|
||||
body {
|
||||
|
@ -30,6 +29,40 @@ body {
|
|||
min-width: 75% !important;
|
||||
}
|
||||
|
||||
/** Utilities not covered by Bulma
|
||||
******************************************************************************/
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.is-sr-only-mobile {
|
||||
border: none !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
height: 0.01em !important;
|
||||
overflow: hidden !important;
|
||||
padding: 0 !important;
|
||||
position: absolute !important;
|
||||
white-space: nowrap !important;
|
||||
width: 0.01em !important;
|
||||
}
|
||||
|
||||
.m-0-mobile {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.button.is-transparent {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.card.is-stretchable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card.is-stretchable .card-content {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/** Shelving
|
||||
******************************************************************************/
|
||||
|
||||
|
@ -86,6 +119,13 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
/** Stars
|
||||
******************************************************************************/
|
||||
|
||||
.stars {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/** Stars in a review form
|
||||
*
|
||||
* Specificity makes hovering taking over checked inputs.
|
||||
|
@ -256,3 +296,53 @@ body {
|
|||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Book preview table
|
||||
******************************************************************************/
|
||||
|
||||
.book-preview td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
table.is-mobile,
|
||||
table.is-mobile tbody {
|
||||
display: block;
|
||||
}
|
||||
|
||||
table.is-mobile tr {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid #dbdbdb;
|
||||
}
|
||||
|
||||
table.is-mobile td {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
flex: 1 0 100%;
|
||||
order: 2;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
table.is-mobile td.book-preview-top-row {
|
||||
order: 1;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
table.is-mobile td[data-title]:not(:empty)::before {
|
||||
content: attr(data-title);
|
||||
display: block;
|
||||
font-size: 0.75em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
table.is-mobile td:empty {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
table.is-mobile th,
|
||||
table.is-mobile thead {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ from bookwyrm.sanitize_html import InputHtmlParser
|
|||
|
||||
|
||||
def create_generated_note(user, content, mention_books=None, privacy="public"):
|
||||
""" a note created by the app about user activity """
|
||||
"""a note created by the app about user activity"""
|
||||
# sanitize input html
|
||||
parser = InputHtmlParser()
|
||||
parser.feed(content)
|
||||
|
|
|
@ -67,31 +67,16 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="content is-clipped">
|
||||
<dl>
|
||||
{% if book.isbn_13 %}
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center">
|
||||
<dt>{% trans "ISBN:" %}</dt>
|
||||
<dd itemprop="isbn">{{ book.isbn_13 }}</dd>
|
||||
<section class="is-clipped">
|
||||
{% with book=book %}
|
||||
<div class="content">
|
||||
{% include 'book/publisher_info.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if book.oclc_number %}
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center">
|
||||
<dt>{% trans "OCLC Number:" %}</dt>
|
||||
<dd>{{ book.oclc_number }}</dd>
|
||||
<div class="my-3">
|
||||
{% include 'book/book_identifiers.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% 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 %}
|
||||
{% endwith %}
|
||||
|
||||
{% if book.openlibrary_key %}
|
||||
<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 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
|
||||
class="block"
|
||||
itemprop="review"
|
||||
|
@ -302,7 +316,7 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
<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>
|
||||
|
|
27
bookwyrm/templates/book/book_identifiers.html
Normal file
27
bookwyrm/templates/book/book_identifiers.html
Normal 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 %}
|
|
@ -109,7 +109,10 @@
|
|||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% 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 %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
|
|
@ -25,7 +25,18 @@
|
|||
{{ book.title }}
|
||||
</a>
|
||||
</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 class="column is-3">
|
||||
{% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True %}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{% spaceless %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
|
||||
<p>
|
||||
{% with format=book.physical_format pages=book.pages %}
|
||||
|
@ -39,7 +40,7 @@
|
|||
{% endif %}
|
||||
|
||||
<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 %}
|
||||
<meta
|
||||
itemprop="datePublished"
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
<div class="dropdown-menu">
|
||||
<ul
|
||||
id="menu-options-{{ uuid }}"
|
||||
class="dropdown-content"
|
||||
class="dropdown-content p-0 is-clipped"
|
||||
role="menu"
|
||||
>
|
||||
{% block dropdown-list %}{% endblock %}
|
||||
|
|
|
@ -41,7 +41,7 @@
|
|||
<div class="columns is-multiline">
|
||||
{% for user in users %}
|
||||
<div class="column is-one-third">
|
||||
<div class="card block">
|
||||
<div class="card is-stretchable">
|
||||
<div class="card-content">
|
||||
<div class="media">
|
||||
<a href="{{ user.local_path }}" class="media-left">
|
||||
|
@ -56,13 +56,13 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div>
|
||||
{% if user.summary %}
|
||||
{{ user.summary | to_markdown | safe | truncatechars_html:40 }}
|
||||
{% else %} {% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<footer class="card-footer content">
|
||||
<footer class="card-footer">
|
||||
{% if user != request.user %}
|
||||
{% if user.mutuals %}
|
||||
<div class="card-footer-item">
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{% load bookwyrm_tags %}
|
||||
{% load i18n %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{% get_lang %}">
|
||||
<head>
|
||||
<title>{% block title %}BookWyrm{% endblock %} | {{ site.name }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
|
|
@ -13,10 +13,10 @@
|
|||
|
||||
<div class="columns mt-3">
|
||||
<section class="column is-three-quarters">
|
||||
{% if not items.exists %}
|
||||
{% if not items.object_list.exists %}
|
||||
<p>{% trans "This list is currently empty" %}</p>
|
||||
{% else %}
|
||||
<ol>
|
||||
<ol start="{{ items.start_index }}">
|
||||
{% for item in items %}
|
||||
<li class="block pb-3">
|
||||
<div class="card">
|
||||
|
@ -30,11 +30,27 @@
|
|||
{% include 'snippets/shelve_button/shelve_button.html' with book=item.book %}
|
||||
</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>
|
||||
<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>
|
||||
{% 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">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="item" value="{{ item.id }}">
|
||||
|
@ -47,10 +63,27 @@
|
|||
{% endfor %}
|
||||
</ol>
|
||||
{% endif %}
|
||||
{% include "snippets/pagination.html" with page=items %}
|
||||
</section>
|
||||
|
||||
{% if request.user.is_authenticated and not list.curation == 'closed' or request.user == list.user %}
|
||||
<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>
|
||||
<form name="search" action="{% url 'list' list.id %}" method="GET" class="block">
|
||||
<div class="field has-addons">
|
||||
|
@ -93,7 +126,7 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -15,76 +15,9 @@
|
|||
{% include 'moderation/report_preview.html' with report=report %}
|
||||
</div>
|
||||
|
||||
<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=report.user %}
|
||||
{% if report.user.summary %}
|
||||
<div class="box content has-background-white-ter is-shadowless">
|
||||
{{ report.user.summary | to_markdown | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include 'user_admin/user_info.html' with user=report.user %}
|
||||
|
||||
<p class="mt-2"><a href="{{ report.user.local_path }}">{% trans "View user profile" %}</a></p>
|
||||
</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>
|
||||
{% include 'user_admin/user_moderation_actions.html' with user=report.user %}
|
||||
|
||||
<div class="block">
|
||||
<h3 class="title is-4">{% trans "Moderator Comments" %}</h3>
|
||||
|
@ -118,7 +51,7 @@
|
|||
{% for status in report.statuses.select_subclasses.all %}
|
||||
<li>
|
||||
{% if status.deleted %}
|
||||
<em>{% trans "Statuses has been deleted" %}</em>
|
||||
<em>{% trans "Status has been deleted" %}</em>
|
||||
{% else %}
|
||||
{% include 'snippets/status/status.html' with status=status moderation_mode=True %}
|
||||
{% endif %}
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
{% include 'settings/user_admin_filters.html' %}
|
||||
{% include 'user_admin/user_admin_filters.html' %}
|
||||
|
||||
<div class="block">
|
||||
{% if not reports %}
|
||||
|
|
|
@ -123,7 +123,7 @@
|
|||
{% include 'snippets/status_preview.html' with status=related_status %}
|
||||
</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 %}">
|
||||
{{ related_status.published_date | post_date }}
|
||||
{{ related_status.published_date|timesince }}
|
||||
{% include 'snippets/privacy-icons.html' with item=related_status %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
{% load i18n %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% 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 %}
|
||||
<a href="{{ book.local_path }}">{{ book.title }}</a>
|
||||
<a href="{{ book.local_path }}">{{ book|title }}</a>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
@ -4,18 +4,16 @@
|
|||
{% 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 }}">
|
||||
{% csrf_token %}
|
||||
<button class="button is-small" type="submit" {% if not status.boostable %}disabled{% endif %}>
|
||||
<span class="icon icon-boost" title="{% trans 'Boost status' %}">
|
||||
<span class="is-sr-only">{% trans "Boost status" %}</span>
|
||||
</span>
|
||||
<button class="button is-small is-light is-transparent" type="submit" {% if not status.boostable %}disabled{% endif %}>
|
||||
<span class="icon icon-boost m-0-mobile" title="{% trans 'Boost' %}"></span>
|
||||
<span class="is-sr-only-mobile">{% trans "Boost" %}</span>
|
||||
</button>
|
||||
</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 }}">
|
||||
{% csrf_token %}
|
||||
<button class="button is-small is-primary" type="submit">
|
||||
<span class="icon icon-boost" title="{% trans 'Un-boost status' %}">
|
||||
<span class="is-sr-only">{% trans "Un-boost status" %}</span>
|
||||
</span>
|
||||
<button class="button is-small is-light is-transparent" type="submit">
|
||||
<span class="icon icon-boost has-text-primary m-0-mobile" title="{% trans 'Un-boost' %}"></span>
|
||||
<span class="is-sr-only-mobile">{% trans "Un-boost" %}</span>
|
||||
</button>
|
||||
</form>
|
||||
{% endwith %}
|
||||
|
|
|
@ -6,14 +6,16 @@
|
|||
<input type="hidden" name="user" value="{{ request.user.id }}">
|
||||
<input type="hidden" name="reply_parent" value="{% firstof draft.reply_parent.id reply_parent.id %}">
|
||||
{% if type == 'review' %}
|
||||
<div class="control">
|
||||
<div class="field">
|
||||
<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>
|
||||
{% endif %}
|
||||
<div class="control">
|
||||
<div class="field">
|
||||
{% 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' %}
|
||||
{% trans "Comment:" %}
|
||||
{% elif type == 'quotation' %}
|
||||
|
@ -25,28 +27,37 @@
|
|||
{% endif %}
|
||||
|
||||
{% if type == 'review' %}
|
||||
<fieldset>
|
||||
<fieldset class="mb-1">
|
||||
<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 %}
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
|
||||
{% if type == 'quotation' %}
|
||||
<textarea name="quote" class="textarea" id="id_quote_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required>{{ draft.quote|default:'' }}</textarea>
|
||||
{% else %}
|
||||
{% include 'snippets/content_warning_field.html' with parent_status=status %}
|
||||
<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>
|
||||
{% endif %}
|
||||
<div class="control">
|
||||
{% if type == 'quotation' %}
|
||||
<textarea name="quote" class="textarea" id="id_quote_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required>{{ draft.quote|default:'' }}</textarea>
|
||||
{% elif type == 'reply' %}
|
||||
{% include 'snippets/content_warning_field.html' with parent_status=status %}
|
||||
<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>
|
||||
|
||||
{# Supplemental fields #}
|
||||
{% if type == 'quotation' %}
|
||||
<div class="control">
|
||||
<div class="field">
|
||||
<label class="label" for="id_content_quote-{{ book.id }}">{% trans "Comment" %}:</label>
|
||||
{% 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>
|
||||
{% elif type == 'comment' %}
|
||||
<div class="control">
|
||||
<div>
|
||||
{% active_shelf book as active_shelf %}
|
||||
{% if active_shelf.shelf.identifier == 'reading' and book.latest_readthrough %}
|
||||
|
||||
|
@ -58,11 +69,13 @@
|
|||
<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 }}">
|
||||
</div>
|
||||
<div class="control select">
|
||||
<select name="progress_mode" aria-label="Progress mode">
|
||||
<option value="PG" {% if draft.progress_mode == 'PG' or readthrough.progress_mode == 'PG' %}selected{% endif %}>{% trans "pages" %}</option>
|
||||
<option value="PCT" {% if draft.progress_mode == 'PCT' or readthrough.progress_mode == 'PCT' %}selected{% endif %}>{% trans "percent" %}</option>
|
||||
</select>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select name="progress_mode" aria-label="Progress mode">
|
||||
<option value="PG" {% if draft.progress_mode == 'PG' or readthrough.progress_mode == 'PG' %}selected{% endif %}>{% trans "pages" %}</option>
|
||||
<option value="PCT" {% if draft.progress_mode == 'PCT' or readthrough.progress_mode == 'PCT' %}selected{% endif %}>{% trans "percent" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if readthrough.progress_mode == 'PG' and book.pages %}
|
||||
|
@ -73,9 +86,12 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
{% 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 #}
|
||||
<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="control">
|
||||
{% trans "Include spoiler alert" as button_text %}
|
||||
|
|
|
@ -3,18 +3,17 @@
|
|||
{% 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 }}">
|
||||
{% csrf_token %}
|
||||
<button class="button is-small" type="submit">
|
||||
<span class="icon icon-heart" title="{% trans 'Like status' %}">
|
||||
<span class="is-sr-only">{% trans "Like status" %}</span>
|
||||
<button class="button is-small is-light is-transparent" type="submit">
|
||||
<span class="icon icon-heart m-0-mobile" title="{% trans 'Like' %}">
|
||||
</span>
|
||||
<span class="is-sr-only-mobile">{% trans "Like" %}</span>
|
||||
</button>
|
||||
</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 }}">
|
||||
{% csrf_token %}
|
||||
<button class="button is-primary is-small" type="submit">
|
||||
<span class="icon icon-heart" title="{% trans 'Un-like status' %}">
|
||||
<span class="is-sr-only">{% trans "Un-like status" %}</span>
|
||||
</span>
|
||||
<button class="button is-light is-transparent is-small" type="submit">
|
||||
<span class="icon icon-heart has-text-primary m-0-mobile" title="{% trans 'Un-like' %}"></span>
|
||||
<span class="is-sr-only-mobile">{% trans "Un-like" %}</span>
|
||||
</button>
|
||||
</form>
|
||||
{% endwith %}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
{% load i18n %}
|
||||
{% 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 %}
|
||||
|
||||
{% 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 %}
|
||||
|
|
|
@ -7,23 +7,23 @@
|
|||
|
||||
{% block dropdown-list %}
|
||||
{% for shelf in request.user.shelf_set.all %}
|
||||
<li role="menuitem">
|
||||
<form class="dropdown-item pt-0 pb-0" name="shelve" action="/shelve/" method="post">
|
||||
<li role="menuitem" class="dropdown-item p-0">
|
||||
<form name="shelve" action="/shelve/" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="book" value="{{ book.id }}">
|
||||
<input type="hidden" name="change-shelf-from" value="{{ current.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>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="navbar-divider" role="presentation"></li>
|
||||
<li>
|
||||
<form class="dropdown-item pt-0 pb-0" name="shelve" action="/unshelve/" method="post">
|
||||
<li class="navbar-divider" role="separator"></li>
|
||||
<li role="menuitem" class="dropdown-item p-0">
|
||||
<form name="shelve" action="/unshelve/" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="book" value="{{ book.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>
|
||||
</li>
|
||||
{% endblock %}
|
||||
|
|
|
@ -7,5 +7,5 @@
|
|||
{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
{% load i18n %}
|
||||
{% for shelf in shelves %}
|
||||
{% comparison_bool shelf.identifier active_shelf.shelf.identifier as is_current %}
|
||||
{% if dropdown %}<li role="menuitem">{% endif %}
|
||||
<div class="{% if dropdown %}dropdown-item pt-0 pb-0{% elif active_shelf.shelf.identifier|next_shelf != shelf.identifier %}is-hidden{% endif %}">
|
||||
{% if dropdown %}<li role="menuitem" class="dropdown-item p-0">{% 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 %}
|
||||
{% 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 %}
|
||||
|
@ -30,24 +30,20 @@
|
|||
{% if dropdown %}
|
||||
|
||||
{% if readthrough and active_shelf.shelf.identifier != 'read' %}
|
||||
<li role="menuitem">
|
||||
<div class="dropdown-item pt-0 pb-0">
|
||||
{% 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" %}
|
||||
</div>
|
||||
<li role="menuitem" class="dropdown-item p-0">
|
||||
{% 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" %}
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if active_shelf.shelf %}
|
||||
<li role="menuitem">
|
||||
<div class="dropdown-item pt-0 pb-0">
|
||||
<form name="shelve" action="/unshelve/" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="book" value="{{ active_shelf.book.id }}">
|
||||
<input type="hidden" name="shelf" value="{{ active_shelf.shelf.id }}">
|
||||
<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>
|
||||
</div>
|
||||
<li role="menuitem" class="dropdown-item p-0">
|
||||
<form name="shelve" action="/unshelve/" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="book" value="{{ active_shelf.book.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>
|
||||
</form>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{% spaceless %}
|
||||
{% load i18n %}
|
||||
|
||||
<p class="stars">
|
||||
<span class="stars">
|
||||
<span class="is-sr-only">
|
||||
{% if rating %}
|
||||
{% blocktranslate trimmed with rating=rating|floatformat count counter=rating|length %}
|
||||
|
@ -23,5 +23,5 @@
|
|||
aria-hidden="true"
|
||||
></span>
|
||||
{% endfor %}
|
||||
</p>
|
||||
</span>
|
||||
{% endspaceless %}
|
||||
|
|
|
@ -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>
|
135
bookwyrm/templates/snippets/status/content_status.html
Normal file
135
bookwyrm/templates/snippets/status/content_status.html
Normal 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> — {% 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 %}
|
||||
|
23
bookwyrm/templates/snippets/status/generated_status.html
Normal file
23
bookwyrm/templates/snippets/status/generated_status.html
Normal 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 %}
|
76
bookwyrm/templates/snippets/status/layout.html
Normal file
76
bookwyrm/templates/snippets/status/layout.html
Normal 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 %}
|
|
@ -1,90 +1,14 @@
|
|||
{% extends 'components/card.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 %}
|
||||
|
||||
{% extends 'snippets/status/layout.html' %}
|
||||
|
||||
{% block card-content %}
|
||||
{% include 'snippets/status/status_content.html' with status=status %}
|
||||
{% endblock %}
|
||||
{% with status_type=status.status_type %}
|
||||
|
||||
|
||||
{% block card-footer %}
|
||||
<div class="card-footer-item">
|
||||
{% if moderation_mode and perms.bookwyrm.moderate_post %}
|
||||
|
||||
{# 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>
|
||||
{% if status_type == 'GeneratedNote' or status_type == 'Rating' %}
|
||||
{% include 'snippets/status/generated_status.html' with status=status %}
|
||||
{% else %}
|
||||
{% include 'snippets/status/content_status.html' with status=status %}
|
||||
{% 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 %}
|
||||
|
||||
|
|
|
@ -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> — {% 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 %}
|
|
@ -1,53 +1,108 @@
|
|||
{% load bookwyrm_tags %}
|
||||
{% load i18n %}
|
||||
<span
|
||||
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" %}
|
||||
{% load humanize %}
|
||||
|
||||
<span itemprop="name">{{ status.user.display_name }}</span>
|
||||
</a>
|
||||
</span>
|
||||
<div class="media">
|
||||
<figure class="media-left" aria-hidden="true">
|
||||
<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' %}
|
||||
{{ status.content | safe }}
|
||||
{% elif status.status_type == 'Rating' %}
|
||||
{% trans "rated" %}
|
||||
{% elif status.status_type == 'Review' %}
|
||||
{% trans "reviewed" %}
|
||||
{% elif status.status_type == 'Comment' %}
|
||||
{% trans "commented on" %}
|
||||
{% elif status.status_type == 'Quotation' %}
|
||||
{% trans "quoted" %}
|
||||
{% elif status.reply_parent %}
|
||||
{% with parent_status=status|parent %}
|
||||
<div class="media-content">
|
||||
<h3 class="has-text-weight-bold">
|
||||
<span
|
||||
itemprop="author"
|
||||
itemscope
|
||||
itemtype="https://schema.org/Person"
|
||||
>
|
||||
{% if status.user.avatar %}
|
||||
<meta itemprop="image" content="/images/{{ status.user.avatar }}">
|
||||
{% endif %}
|
||||
|
||||
{% if parent_status.status_type == 'Review' %}
|
||||
{% 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 %}
|
||||
{% elif parent_status.status_type == 'Comment' %}
|
||||
{% 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' %}
|
||||
{% 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 %}
|
||||
{% else %}
|
||||
{% 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 %}
|
||||
<a
|
||||
href="{{ status.user.local_path }}"
|
||||
itemprop="url"
|
||||
>
|
||||
<span itemprop="name">{{ status.user.display_name }}</span>
|
||||
</a>
|
||||
</span>
|
||||
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% if status.book %}
|
||||
<a href="/book/{{ status.book.id }}">{{ status.book.title }}</a>
|
||||
{% elif status.mention_books %}
|
||||
<a href="/book/{{ status.mention_books.first.id }}">{{ status.mention_books.first.title }}</a>
|
||||
{% endif %}
|
||||
{% if status.status_type == 'GeneratedNote' %}
|
||||
{{ status.content | safe }}
|
||||
{% elif status.status_type == 'Rating' %}
|
||||
{% trans "rated" %}
|
||||
{% elif status.status_type == 'Review' %}
|
||||
{% trans "reviewed" %}
|
||||
{% 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 %}
|
||||
<p class="help">
|
||||
({% if status.progress_mode == 'PG' %}{% include 'snippets/page_text.html' with page=status.progress total_pages=status.book.pages %}{% else %}{{ status.progress }}%{% endif %})
|
||||
</p>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
{% endwith %}
|
||||
{% 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>
|
||||
|
|
|
@ -3,27 +3,26 @@
|
|||
{% load bookwyrm_tags %}
|
||||
|
||||
{% block dropdown-trigger %}
|
||||
<span class="icon icon-dots-three">
|
||||
<span class="is-sr-only">{% trans "More options" %}</span>
|
||||
</span>
|
||||
<span class="icon icon-dots-three m-0-mobile"></span>
|
||||
<span class="is-sr-only-mobile">{% trans "More options" %}</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block dropdown-list %}
|
||||
{% if status.user == request.user %}
|
||||
{# things you can do to your own statuses #}
|
||||
<li role="menuitem">
|
||||
<form class="dropdown-item pt-0 pb-0" name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post">
|
||||
<li role="menuitem" class="dropdown-item p-0">
|
||||
<form name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post">
|
||||
{% 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" %}
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% if status.status_type != 'GeneratedNote' and status.status_type != 'Rating' %}
|
||||
<li role="menuitem">
|
||||
<form class="dropdown-item pt-0 pb-0" name="delete-{{status.id}}" action="{% url 'redraft' status.id %}" method="post">
|
||||
<li role="menuitem" class="dropdown-item p-0">
|
||||
<form class="" name="delete-{{status.id}}" action="{% url 'redraft' status.id %}" method="post">
|
||||
{% 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" %}
|
||||
</button>
|
||||
</form>
|
||||
|
@ -31,13 +30,15 @@
|
|||
{% endif %}
|
||||
{% else %}
|
||||
{# things you can do to other people's statuses #}
|
||||
<li role="menuitem">
|
||||
<a href="{% url 'direct-messages-user' status.user|username %}" class="button is-small is-fullwidth">{% trans "Send direct message" %}</a>
|
||||
<li role="menuitem" class="dropdown-item p-0">
|
||||
<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 role="menuitem">
|
||||
<li role="menuitem" class="dropdown-item p-0">
|
||||
{% include 'snippets/report_button.html' with user=status.user status=status %}
|
||||
</li>
|
||||
<li role="menuitem">
|
||||
<li role="menuitem" class="dropdown-item p-0">
|
||||
{% include 'snippets/block_button.html' with user=status.user class="is-fullwidth" %}
|
||||
</li>
|
||||
{% endif %}
|
||||
|
|
|
@ -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>
|
|
@ -10,9 +10,12 @@
|
|||
>
|
||||
|
||||
{% 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>
|
||||
{% 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 %}
|
||||
<span>{{ text }}</span>
|
||||
{% endif %}
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
{% spaceless %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% load i18n %}
|
||||
|
||||
{% with 0|uuid as uuid %}
|
||||
{% if 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 %}
|
||||
<div id="hide-full-{{ uuid }}">
|
||||
<div class="content" id="trimmed-{{ uuid }}">
|
||||
|
@ -46,4 +45,3 @@
|
|||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endspaceless %}
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
@ -68,63 +68,66 @@
|
|||
<div class="block">
|
||||
<div>
|
||||
{% if books|length > 0 %}
|
||||
<div class="scroll-x">
|
||||
<table class="table is-striped is-fullwidth">
|
||||
|
||||
<tr class="book-preview">
|
||||
<th>{% trans "Cover" %}</th>
|
||||
<th>{% trans "Title" %}</th>
|
||||
<th>{% trans "Author" %}</th>
|
||||
<th>{% trans "Shelved" %}</th>
|
||||
<th>{% trans "Started" %}</th>
|
||||
<th>{% trans "Finished" %}</th>
|
||||
{% if ratings %}<th>{% trans "Rating" %}</th>{% endif %}
|
||||
{% if shelf.user == request.user %}
|
||||
<th aria-hidden="true"></th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% for book in books %}
|
||||
<tr class="book-preview">
|
||||
<td>
|
||||
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ book.local_path }}">{{ book.title }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{% include 'snippets/authors.html' %}
|
||||
</td>
|
||||
<td>
|
||||
{{ book.created_date | naturalday }}
|
||||
</td>
|
||||
{% latest_read_through book user as read_through %}
|
||||
<td>
|
||||
{{ read_through.start_date | naturalday |default_if_none:""}}
|
||||
</td>
|
||||
<td>
|
||||
{{ read_through.finish_date | naturalday |default_if_none:""}}
|
||||
</td>
|
||||
{% if ratings %}
|
||||
<td>
|
||||
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if shelf.user == request.user %}
|
||||
<td>
|
||||
{% with right=True %}
|
||||
{% if not shelf.id %}
|
||||
{% active_shelf book as current %}
|
||||
{% include 'snippets/shelf_selector.html' with current=current.shelf class="is-small" %}
|
||||
{% else %}
|
||||
{% include 'snippets/shelf_selector.html' with current=shelf class="is-small" %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<table class="table is-striped is-fullwidth is-mobile">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Cover" %}</th>
|
||||
<th>{% trans "Title" %}</th>
|
||||
<th>{% trans "Author" %}</th>
|
||||
<th>{% trans "Shelved" %}</th>
|
||||
<th>{% trans "Started" %}</th>
|
||||
<th>{% trans "Finished" %}</th>
|
||||
{% if ratings %}<th>{% trans "Rating" %}</th>{% endif %}
|
||||
{% if shelf.user == request.user %}
|
||||
<th aria-hidden="true"></th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for book in books %}
|
||||
{% spaceless %}
|
||||
<tr class="book-preview">
|
||||
<td class="book-preview-top-row">
|
||||
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a>
|
||||
</td>
|
||||
<td data-title="{% trans "Title" %}">
|
||||
<a href="{{ book.local_path }}">{{ book.title }}</a>
|
||||
</td>
|
||||
<td data-title="{% trans "Author" %}">
|
||||
{% include 'snippets/authors.html' %}
|
||||
</td>
|
||||
<td data-title="{% trans "Shelved" %}">
|
||||
{{ book.created_date | naturalday }}
|
||||
</td>
|
||||
{% latest_read_through book user as read_through %}
|
||||
<td data-title="{% trans "Started" %}">
|
||||
{{ read_through.start_date | naturalday |default_if_none:""}}
|
||||
</td>
|
||||
<td data-title="{% trans "Finished" %}">
|
||||
{{ read_through.finish_date | naturalday |default_if_none:""}}
|
||||
</td>
|
||||
{% if ratings %}
|
||||
<td data-title="{% trans "Rating" %}">
|
||||
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if shelf.user == request.user %}
|
||||
<td class="book-preview-top-row has-text-right">
|
||||
{% with right=True %}
|
||||
{% if not shelf.id %}
|
||||
{% active_shelf book as current %}
|
||||
{% include 'snippets/shelf_selector.html' with current=current.shelf class="is-small" %}
|
||||
{% else %}
|
||||
{% include 'snippets/shelf_selector.html' with current=shelf class="is-small" %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endspaceless %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{% trans "This shelf is empty." %}</p>
|
||||
{% if shelf.id and shelf.editable %}
|
||||
|
|
19
bookwyrm/templates/user_admin/user.html
Normal file
19
bookwyrm/templates/user_admin/user.html
Normal 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 %}
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
{% block panel %}
|
||||
|
||||
{% include 'settings/user_admin_filters.html' %}
|
||||
{% include 'user_admin/user_admin_filters.html' %}
|
||||
|
||||
<table class="table is-striped">
|
||||
<tr>
|
||||
|
@ -41,7 +41,7 @@
|
|||
</tr>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.username }}</td>
|
||||
<td><a href="{% url 'settings-user' user.id %}">{{ user.username }}</a></td>
|
||||
<td>{{ user.created_date }}</td>
|
||||
<td>{{ user.last_active_date }}</td>
|
||||
<td>{% if user.is_active %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}</td>
|
|
@ -1,6 +1,6 @@
|
|||
{% extends 'snippets/filters_panel/filters_panel.html' %}
|
||||
|
||||
{% block filter_fields %}
|
||||
{% include 'settings/server_filter.html' %}
|
||||
{% include 'settings/username_filter.html' %}
|
||||
{% include 'user_admin/server_filter.html' %}
|
||||
{% include 'user_admin/username_filter.html' %}
|
||||
{% endblock %}
|
56
bookwyrm/templates/user_admin/user_info.html
Normal file
56
bookwyrm/templates/user_admin/user_info.html
Normal 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>
|
||||
|
42
bookwyrm/templates/user_admin/user_moderation_actions.html
Normal file
42
bookwyrm/templates/user_admin/user_moderation_actions.html
Normal 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>
|
|
@ -1,11 +1,8 @@
|
|||
""" template filters """
|
||||
from uuid import uuid4
|
||||
from datetime import datetime
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django import template
|
||||
from django import template, utils
|
||||
from django.db.models import Avg
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import models, views
|
||||
from bookwyrm.views.status import to_markdown
|
||||
|
@ -16,13 +13,13 @@ register = template.Library()
|
|||
|
||||
@register.filter(name="dict_key")
|
||||
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
|
||||
|
||||
|
||||
@register.filter(name="rating")
|
||||
def get_rating(book, user):
|
||||
""" get the overall rating of a book """
|
||||
"""get the overall rating of a book"""
|
||||
queryset = views.helpers.privacy_filter(
|
||||
user, models.Review.objects.filter(book=book)
|
||||
)
|
||||
|
@ -31,7 +28,7 @@ def get_rating(book, user):
|
|||
|
||||
@register.filter(name="user_rating")
|
||||
def get_user_rating(book, user):
|
||||
""" get a user's rating of a book """
|
||||
"""get a user's rating of a book"""
|
||||
rating = (
|
||||
models.Review.objects.filter(
|
||||
user=user,
|
||||
|
@ -48,33 +45,29 @@ def get_user_rating(book, user):
|
|||
|
||||
@register.filter(name="username")
|
||||
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
|
||||
|
||||
|
||||
@register.filter(name="notification_count")
|
||||
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()
|
||||
|
||||
|
||||
@register.filter(name="replies")
|
||||
def get_replies(status):
|
||||
""" get all direct replies to a status """
|
||||
"""get all direct replies to a status"""
|
||||
# TODO: this limit could cause problems
|
||||
return (
|
||||
models.Status.objects.filter(
|
||||
reply_parent=status,
|
||||
deleted=False,
|
||||
)
|
||||
.select_subclasses()
|
||||
.all()[:10]
|
||||
)
|
||||
return models.Status.objects.filter(
|
||||
reply_parent=status,
|
||||
deleted=False,
|
||||
).select_subclasses()[:10]
|
||||
|
||||
|
||||
@register.filter(name="parent")
|
||||
def get_parent(status):
|
||||
""" get the reply parent for a status """
|
||||
"""get the reply parent for a status"""
|
||||
return (
|
||||
models.Status.objects.filter(id=status.reply_parent_id)
|
||||
.select_subclasses()
|
||||
|
@ -84,7 +77,7 @@ def get_parent(status):
|
|||
|
||||
@register.filter(name="liked")
|
||||
def get_user_liked(user, status):
|
||||
""" did the given user fav a status? """
|
||||
"""did the given user fav a status?"""
|
||||
try:
|
||||
models.Favorite.objects.get(user=user, status=status)
|
||||
return True
|
||||
|
@ -94,13 +87,13 @@ def get_user_liked(user, status):
|
|||
|
||||
@register.filter(name="boosted")
|
||||
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)
|
||||
|
||||
|
||||
@register.filter(name="follow_request_exists")
|
||||
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:
|
||||
models.UserFollowRequest.objects.filter(
|
||||
user_subject=requester,
|
||||
|
@ -113,7 +106,7 @@ def follow_request_exists(user, requester):
|
|||
|
||||
@register.filter(name="boosted_status")
|
||||
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 (
|
||||
models.Status.objects.select_subclasses()
|
||||
.filter(id=boost.boosted_status.id)
|
||||
|
@ -123,41 +116,19 @@ def get_boosted(boost):
|
|||
|
||||
@register.filter(name="book_description")
|
||||
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
|
||||
|
||||
|
||||
@register.filter(name="uuid")
|
||||
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())
|
||||
|
||||
|
||||
@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")
|
||||
def get_markdown(content):
|
||||
""" convert markdown to html """
|
||||
"""convert markdown to html"""
|
||||
if content:
|
||||
return to_markdown(content)
|
||||
return None
|
||||
|
@ -165,7 +136,7 @@ def get_markdown(content):
|
|||
|
||||
@register.filter(name="mentions")
|
||||
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()))
|
||||
return (
|
||||
" ".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")
|
||||
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()
|
||||
if name == "review":
|
||||
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")
|
||||
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":
|
||||
return "reading"
|
||||
if current_shelf == "reading":
|
||||
|
@ -197,9 +168,20 @@ def get_next_shelf(current_shelf):
|
|||
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)
|
||||
def related_status(notification):
|
||||
""" for notifications """
|
||||
"""for notifications"""
|
||||
if not notification.related_status:
|
||||
return None
|
||||
if hasattr(notification.related_status, "quotation"):
|
||||
|
@ -213,7 +195,7 @@ def related_status(notification):
|
|||
|
||||
@register.simple_tag(takes_context=True)
|
||||
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__user=context["request"].user, book__in=book.parent_work.editions.all()
|
||||
).first()
|
||||
|
@ -222,7 +204,7 @@ def active_shelf(context, book):
|
|||
|
||||
@register.simple_tag(takes_context=False)
|
||||
def latest_read_through(book, user):
|
||||
""" the most recent read activity """
|
||||
"""the most recent read activity"""
|
||||
return (
|
||||
models.ReadThrough.objects.filter(user=user, book=book)
|
||||
.order_by("-start_date")
|
||||
|
@ -232,7 +214,7 @@ def latest_read_through(book, user):
|
|||
|
||||
@register.simple_tag(takes_context=False)
|
||||
def active_read_through(book, user):
|
||||
""" the most recent read activity """
|
||||
"""the most recent read activity"""
|
||||
return (
|
||||
models.ReadThrough.objects.filter(
|
||||
user=user, book=book, finish_date__isnull=True
|
||||
|
@ -244,5 +226,12 @@ def active_read_through(book, user):
|
|||
|
||||
@register.simple_tag(takes_context=False)
|
||||
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
|
||||
|
||||
|
||||
@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("-")]
|
||||
|
|
|
@ -21,10 +21,10 @@ from bookwyrm import models
|
|||
|
||||
@patch("bookwyrm.activitystreams.ActivityStream.add_status")
|
||||
class BaseActivity(TestCase):
|
||||
""" the super class for model-linked activitypub dataclasses """
|
||||
"""the super class for model-linked activitypub dataclasses"""
|
||||
|
||||
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(
|
||||
"mouse", "mouse@mouse.mouse", "mouseword", local=True, localname="mouse"
|
||||
)
|
||||
|
@ -45,28 +45,28 @@ class BaseActivity(TestCase):
|
|||
self.image_data = output.getvalue()
|
||||
|
||||
def test_init(self, _):
|
||||
""" simple successfuly init """
|
||||
"""simple successfuly init"""
|
||||
instance = ActivityObject(id="a", type="b")
|
||||
self.assertTrue(hasattr(instance, "id"))
|
||||
self.assertTrue(hasattr(instance, "type"))
|
||||
|
||||
def test_init_missing(self, _):
|
||||
""" init with missing required params """
|
||||
"""init with missing required params"""
|
||||
with self.assertRaises(ActivitySerializerError):
|
||||
ActivityObject()
|
||||
|
||||
def test_init_extra_fields(self, _):
|
||||
""" init ignoring additional fields """
|
||||
"""init ignoring additional fields"""
|
||||
instance = ActivityObject(id="a", type="b", fish="c")
|
||||
self.assertTrue(hasattr(instance, "id"))
|
||||
self.assertTrue(hasattr(instance, "type"))
|
||||
|
||||
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)
|
||||
class TestClass(ActivityObject):
|
||||
""" test class with default field """
|
||||
"""test class with default field"""
|
||||
|
||||
type: str = "TestObject"
|
||||
|
||||
|
@ -75,7 +75,7 @@ class BaseActivity(TestCase):
|
|||
self.assertEqual(instance.type, "TestObject")
|
||||
|
||||
def test_serialize(self, _):
|
||||
""" simple function for converting dataclass to dict """
|
||||
"""simple function for converting dataclass to dict"""
|
||||
instance = ActivityObject(id="a", type="b")
|
||||
serialized = instance.serialize()
|
||||
self.assertIsInstance(serialized, dict)
|
||||
|
@ -84,7 +84,7 @@ class BaseActivity(TestCase):
|
|||
|
||||
@responses.activate
|
||||
def test_resolve_remote_id(self, _):
|
||||
""" look up or load remote data """
|
||||
"""look up or load remote data"""
|
||||
# existing item
|
||||
result = resolve_remote_id("http://example.com/a/b", model=models.User)
|
||||
self.assertEqual(result, self.user)
|
||||
|
@ -106,14 +106,14 @@ class BaseActivity(TestCase):
|
|||
self.assertEqual(result.name, "MOUSE?? MOUSE!!")
|
||||
|
||||
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")
|
||||
with self.assertRaises(ActivitySerializerError):
|
||||
instance.to_model(model=models.User)
|
||||
|
||||
@responses.activate
|
||||
def test_to_model_image(self, _):
|
||||
""" update an image field """
|
||||
"""update an image field"""
|
||||
activity = activitypub.Person(
|
||||
id=self.user.remote_id,
|
||||
name="New Name",
|
||||
|
@ -146,7 +146,7 @@ class BaseActivity(TestCase):
|
|||
self.assertEqual(self.user.key_pair.public_key, "hi")
|
||||
|
||||
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"):
|
||||
status = models.Status.objects.create(
|
||||
content="test status",
|
||||
|
@ -216,7 +216,7 @@ class BaseActivity(TestCase):
|
|||
|
||||
@responses.activate
|
||||
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"):
|
||||
status = models.Status.objects.create(
|
||||
content="test status",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue