Merge branch 'main' into inventaire
This commit is contained in:
@ -5,11 +5,12 @@ import sys
|
||||
from .base_activity import ActivityEncoder, Signature, naive_parse
|
||||
from .base_activity import Link, Mention
|
||||
from .base_activity import ActivitySerializerError, resolve_remote_id
|
||||
from .image import Image
|
||||
from .image import Document, Image
|
||||
from .note import Note, GeneratedNote, Article, Comment, Quotation
|
||||
from .note import Review, Rating
|
||||
from .note import Tombstone
|
||||
from .ordered_collection import OrderedCollection, OrderedCollectionPage
|
||||
from .ordered_collection import CollectionItem, ListItem, ShelfItem
|
||||
from .ordered_collection import BookList, Shelf
|
||||
from .person import Person, PublicKey
|
||||
from .response import ActivitypubResponse
|
||||
@ -26,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,15 +47,19 @@ 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
|
||||
activity_json["type"] = "PublicKey"
|
||||
|
||||
activity_type = activity_json.get("type")
|
||||
try:
|
||||
activity_type = activity_json["type"]
|
||||
serializer = activity_objects[activity_type]
|
||||
except KeyError as e:
|
||||
# we know this exists and that we can't handle it
|
||||
if activity_type in ["Question"]:
|
||||
return None
|
||||
raise ActivitySerializerError(e)
|
||||
|
||||
return serializer(activity_objects=activity_objects, **activity_json)
|
||||
@ -63,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
|
||||
@ -102,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
|
||||
@ -111,7 +115,7 @@ class ActivityObject:
|
||||
and hasattr(model, "ignore_activity")
|
||||
and model.ignore_activity(self)
|
||||
):
|
||||
raise ActivitySerializerError()
|
||||
return None
|
||||
|
||||
# check for an existing instance
|
||||
instance = instance or model.find_existing(self.serialize())
|
||||
@ -177,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():
|
||||
@ -196,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)
|
||||
|
||||
@ -232,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
|
||||
@ -251,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:
|
||||
|
@ -3,7 +3,7 @@ from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
|
||||
from .base_activity import ActivityObject
|
||||
from .image import Image
|
||||
from .image import Document
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
@ -15,11 +15,12 @@ class BookData(ActivityObject):
|
||||
librarythingKey: str = None
|
||||
goodreadsKey: str = None
|
||||
bnfId: str = None
|
||||
lastEditedBy: str = None
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Book(BookData):
|
||||
""" serializes an edition or work, abstract """
|
||||
"""serializes an edition or work, abstract"""
|
||||
|
||||
title: str
|
||||
sortTitle: str = ""
|
||||
@ -35,13 +36,13 @@ class Book(BookData):
|
||||
firstPublishedDate: str = ""
|
||||
publishedDate: str = ""
|
||||
|
||||
cover: Image = None
|
||||
cover: Document = None
|
||||
type: str = "Book"
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Edition(Book):
|
||||
""" Edition instance of a book object """
|
||||
"""Edition instance of a book object"""
|
||||
|
||||
work: str
|
||||
isbn10: str = ""
|
||||
@ -58,7 +59,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 = ""
|
||||
@ -68,7 +69,7 @@ class Work(Book):
|
||||
|
||||
@dataclass(init=False)
|
||||
class Author(BookData):
|
||||
""" author of a book """
|
||||
"""author of a book"""
|
||||
|
||||
name: str
|
||||
isni: str = None
|
||||
|
@ -4,10 +4,17 @@ from .base_activity import ActivityObject
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Image(ActivityObject):
|
||||
""" image block """
|
||||
class Document(ActivityObject):
|
||||
"""a document"""
|
||||
|
||||
url: str
|
||||
name: str = ""
|
||||
type: str = "Document"
|
||||
id: str = None
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Image(Document):
|
||||
"""an image"""
|
||||
|
||||
type: str = "Image"
|
||||
|
@ -4,24 +4,24 @@ from typing import Dict, List
|
||||
from django.apps import apps
|
||||
|
||||
from .base_activity import ActivityObject, Link
|
||||
from .image import Image
|
||||
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
|
||||
@ -32,14 +32,14 @@ class Note(ActivityObject):
|
||||
inReplyTo: str = ""
|
||||
summary: str = ""
|
||||
tag: List[Link] = field(default_factory=lambda: [])
|
||||
attachment: List[Image] = field(default_factory=lambda: [])
|
||||
attachment: List[Document] = field(default_factory=lambda: [])
|
||||
sensitive: bool = False
|
||||
type: str = "Note"
|
||||
|
||||
|
||||
@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,10 +43,37 @@ 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
|
||||
next: str = None
|
||||
prev: str = None
|
||||
type: str = "OrderedCollectionPage"
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class CollectionItem(ActivityObject):
|
||||
"""an item in a collection"""
|
||||
|
||||
actor: str
|
||||
type: str = "CollectionItem"
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class ListItem(CollectionItem):
|
||||
"""a book on a list"""
|
||||
|
||||
book: str
|
||||
notes: str = None
|
||||
approved: bool = True
|
||||
order: int = None
|
||||
type: str = "ListItem"
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class ShelfItem(CollectionItem):
|
||||
"""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,12 +17,13 @@ class PublicKey(ActivityObject):
|
||||
|
||||
@dataclass(init=False)
|
||||
class Person(ActivityObject):
|
||||
""" actor activitypub json """
|
||||
"""actor activitypub json"""
|
||||
|
||||
preferredUsername: str
|
||||
inbox: str
|
||||
publicKey: PublicKey
|
||||
followers: str = None
|
||||
following: str = None
|
||||
outbox: str = None
|
||||
endpoints: Dict = None
|
||||
name: str = None
|
||||
|
@ -1,69 +1,83 @@
|
||||
""" undo wrapper activity """
|
||||
""" activities that do things """
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
from django.apps import apps
|
||||
|
||||
from .base_activity import ActivityObject, Signature, resolve_remote_id
|
||||
from .book import Edition
|
||||
from .ordered_collection import CollectionItem
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Verb(ActivityObject):
|
||||
"""generic fields for activities - maybe an unecessary level of
|
||||
abstraction but w/e"""
|
||||
"""generic fields for activities"""
|
||||
|
||||
actor: str
|
||||
object: ActivityObject
|
||||
|
||||
def action(self):
|
||||
""" usually we just want to save, this can be overridden as needed """
|
||||
self.object.to_model()
|
||||
"""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:
|
||||
self.object.to_model()
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Create(Verb):
|
||||
""" Create activity """
|
||||
"""Create activity"""
|
||||
|
||||
to: List
|
||||
cc: List
|
||||
to: List[str]
|
||||
cc: List[str] = field(default_factory=lambda: [])
|
||||
signature: Signature = None
|
||||
type: str = "Create"
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Delete(Verb):
|
||||
""" Create activity """
|
||||
"""Create activity"""
|
||||
|
||||
to: List
|
||||
cc: List
|
||||
to: List[str]
|
||||
cc: List[str] = field(default_factory=lambda: [])
|
||||
type: str = "Delete"
|
||||
|
||||
def action(self):
|
||||
""" find and delete the activity object """
|
||||
obj = self.object.to_model(save=False, allow_create=False)
|
||||
obj.delete()
|
||||
"""find and delete the activity object"""
|
||||
if not self.object:
|
||||
return
|
||||
|
||||
if isinstance(self.object, str):
|
||||
# Deleted users are passed as strings. Not wild about this fix
|
||||
model = apps.get_model("bookwyrm.User")
|
||||
obj = model.find_existing_by_remote_id(self.object)
|
||||
else:
|
||||
obj = self.object.to_model(save=False, allow_create=False)
|
||||
|
||||
if obj:
|
||||
obj.delete()
|
||||
# if we can't find it, we don't need to delete it because we don't have it
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Update(Verb):
|
||||
""" Update activity """
|
||||
"""Update activity"""
|
||||
|
||||
to: List
|
||||
to: List[str]
|
||||
type: str = "Update"
|
||||
|
||||
def action(self):
|
||||
""" update a model instance from the dataclass """
|
||||
self.object.to_model(allow_create=False)
|
||||
"""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
|
||||
@ -89,107 +103,98 @@ 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: str
|
||||
object: Edition
|
||||
target: ActivityObject
|
||||
object: CollectionItem
|
||||
type: str = "Add"
|
||||
notes: str = None
|
||||
order: int = 0
|
||||
approved: bool = True
|
||||
|
||||
def action(self):
|
||||
""" add obj to collection """
|
||||
target = resolve_remote_id(self.target, refresh=False)
|
||||
# we want to get the related field that isn't the book, this is janky af sorry
|
||||
model = [t for t in type(target)._meta.related_objects if t.name != "edition"][
|
||||
0
|
||||
].related_model
|
||||
self.to_model(model=model)
|
||||
"""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)
|
||||
item.save()
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Remove(Verb):
|
||||
"""Remove activity """
|
||||
class Remove(Add):
|
||||
"""Remove activity"""
|
||||
|
||||
target: ActivityObject
|
||||
type: str = "Remove"
|
||||
|
||||
def action(self):
|
||||
""" find and remove the activity object """
|
||||
target = resolve_remote_id(self.target, refresh=False)
|
||||
model = [t for t in type(target)._meta.related_objects if t.name != "edition"][
|
||||
0
|
||||
].related_model
|
||||
obj = self.to_model(model=model, save=False, allow_create=False)
|
||||
obj.delete()
|
||||
"""find and remove the activity object"""
|
||||
obj = self.object.to_model(save=False, allow_create=False)
|
||||
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: [])
|
||||
@ -198,5 +203,5 @@ class Announce(Verb):
|
||||
type: str = "Announce"
|
||||
|
||||
def action(self):
|
||||
""" boost """
|
||||
"""boost"""
|
||||
self.to_model()
|
||||
|
@ -1,80 +1,57 @@
|
||||
""" access the activity streams stored in redis """
|
||||
from abc import ABC
|
||||
from django.dispatch import receiver
|
||||
from django.db.models import signals, Q
|
||||
import redis
|
||||
|
||||
from bookwyrm import models, settings
|
||||
from bookwyrm import models
|
||||
from bookwyrm.redis_store import RedisStore, r
|
||||
from bookwyrm.views.helpers import privacy_filter
|
||||
|
||||
r = redis.Redis(
|
||||
host=settings.REDIS_ACTIVITY_HOST, port=settings.REDIS_ACTIVITY_PORT, db=0
|
||||
)
|
||||
|
||||
|
||||
class ActivityStream(ABC):
|
||||
""" a category of activity stream (like home, local, federated) """
|
||||
class ActivityStream(RedisStore):
|
||||
"""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_value(self, status): # pylint: disable=no-self-use
|
||||
""" the status id and the rank (ie, published date) """
|
||||
return {status.id: status.published_date.timestamp()}
|
||||
def get_rank(self, obj): # pylint: disable=no-self-use
|
||||
"""statuses are sorted by date published"""
|
||||
return obj.published_date.timestamp()
|
||||
|
||||
def add_status(self, status):
|
||||
""" add a status to users' feeds """
|
||||
value = self.get_value(status)
|
||||
# we want to do this as a bulk operation, hence "pipeline"
|
||||
pipeline = r.pipeline()
|
||||
for user in self.stream_users(status):
|
||||
# add the status to the feed
|
||||
pipeline.zadd(self.stream_id(user), value)
|
||||
pipeline.zremrangebyrank(
|
||||
self.stream_id(user), 0, -1 * settings.MAX_STREAM_LENGTH
|
||||
)
|
||||
"""add a status to users' feeds"""
|
||||
# the pipeline contains all the add-to-stream activities
|
||||
pipeline = self.add_object_to_related_stores(status, execute=False)
|
||||
|
||||
for user in self.get_audience(status):
|
||||
# add to the unread status count
|
||||
pipeline.incr(self.unread_id(user))
|
||||
|
||||
# and go!
|
||||
pipeline.execute()
|
||||
|
||||
def remove_status(self, status):
|
||||
""" remove a status from all feeds """
|
||||
pipeline = r.pipeline()
|
||||
for user in self.stream_users(status):
|
||||
pipeline.zrem(self.stream_id(user), -1, status.id)
|
||||
pipeline.execute()
|
||||
|
||||
def add_user_statuses(self, viewer, user):
|
||||
""" add a user's statuses to another user's feed """
|
||||
pipeline = r.pipeline()
|
||||
statuses = user.status_set.all()[: settings.MAX_STREAM_LENGTH]
|
||||
for status in statuses:
|
||||
pipeline.zadd(self.stream_id(viewer), self.get_value(status))
|
||||
if statuses:
|
||||
pipeline.zremrangebyrank(
|
||||
self.stream_id(user), 0, -1 * settings.MAX_STREAM_LENGTH
|
||||
)
|
||||
pipeline.execute()
|
||||
"""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 """
|
||||
pipeline = r.pipeline()
|
||||
for status in user.status_set.all()[: settings.MAX_STREAM_LENGTH]:
|
||||
pipeline.lrem(self.stream_id(viewer), -1, status.id)
|
||||
pipeline.execute()
|
||||
"""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 ids for statuses to be displayed """
|
||||
"""load the statuses to be displayed"""
|
||||
# clear unreads for this feed
|
||||
r.set(self.unread_id(user), 0)
|
||||
|
||||
statuses = r.zrevrange(self.stream_id(user), 0, -1)
|
||||
statuses = self.get_store(self.stream_id(user))
|
||||
return (
|
||||
models.Status.objects.select_subclasses()
|
||||
.filter(id__in=statuses)
|
||||
@ -82,27 +59,15 @@ class ActivityStream(ABC):
|
||||
)
|
||||
|
||||
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_stream(self, user):
|
||||
""" go from zero to a timeline """
|
||||
pipeline = r.pipeline()
|
||||
statuses = self.stream_statuses(user)
|
||||
def populate_streams(self, user):
|
||||
"""go from zero to a timeline"""
|
||||
self.populate_store(self.stream_id(user))
|
||||
|
||||
stream_id = self.stream_id(user)
|
||||
for status in statuses.all()[: settings.MAX_STREAM_LENGTH]:
|
||||
pipeline.zadd(stream_id, self.get_value(status))
|
||||
|
||||
# only trim the stream if statuses were added
|
||||
if statuses.exists():
|
||||
pipeline.zremrangebyrank(
|
||||
self.stream_id(user), 0, -1 * settings.MAX_STREAM_LENGTH
|
||||
)
|
||||
pipeline.execute()
|
||||
|
||||
def stream_users(self, status): # pylint: disable=no-self-use
|
||||
""" given a status, what users should see it """
|
||||
def get_audience(self, status): # pylint: disable=no-self-use
|
||||
"""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 []
|
||||
@ -129,22 +94,29 @@ class ActivityStream(ABC):
|
||||
)
|
||||
return audience.distinct()
|
||||
|
||||
def stream_statuses(self, user): # pylint: disable=no-self-use
|
||||
""" given a user, what statuses should they see on this stream """
|
||||
def get_stores_for_object(self, obj):
|
||||
return [self.stream_id(u) for u in self.get_audience(obj)]
|
||||
|
||||
def get_statuses_for_user(self, user): # pylint: disable=no-self-use
|
||||
"""given a user, what statuses should they see on this stream"""
|
||||
return privacy_filter(
|
||||
user,
|
||||
models.Status.objects.select_subclasses(),
|
||||
privacy_levels=["public", "unlisted", "followers"],
|
||||
)
|
||||
|
||||
def get_objects_for_store(self, store):
|
||||
user = models.User.objects.get(id=store.split("-")[0])
|
||||
return self.get_statuses_for_user(user)
|
||||
|
||||
|
||||
class HomeStream(ActivityStream):
|
||||
""" users you follow """
|
||||
"""users you follow"""
|
||||
|
||||
key = "home"
|
||||
|
||||
def stream_users(self, status):
|
||||
audience = super().stream_users(status)
|
||||
def get_audience(self, status):
|
||||
audience = super().get_audience(status)
|
||||
if not audience:
|
||||
return []
|
||||
return audience.filter(
|
||||
@ -152,7 +124,7 @@ class HomeStream(ActivityStream):
|
||||
| Q(following=status.user) # if the user is following the author
|
||||
).distinct()
|
||||
|
||||
def stream_statuses(self, user):
|
||||
def get_statuses_for_user(self, user):
|
||||
return privacy_filter(
|
||||
user,
|
||||
models.Status.objects.select_subclasses(),
|
||||
@ -162,17 +134,17 @@ class HomeStream(ActivityStream):
|
||||
|
||||
|
||||
class LocalStream(ActivityStream):
|
||||
""" users you follow """
|
||||
"""users you follow"""
|
||||
|
||||
key = "local"
|
||||
|
||||
def stream_users(self, status):
|
||||
def get_audience(self, status):
|
||||
# this stream wants no part in non-public statuses
|
||||
if status.privacy != "public" or not status.user.local:
|
||||
return []
|
||||
return super().stream_users(status)
|
||||
return super().get_audience(status)
|
||||
|
||||
def stream_statuses(self, user):
|
||||
def get_statuses_for_user(self, user):
|
||||
# all public statuses by a local user
|
||||
return privacy_filter(
|
||||
user,
|
||||
@ -182,17 +154,17 @@ class LocalStream(ActivityStream):
|
||||
|
||||
|
||||
class FederatedStream(ActivityStream):
|
||||
""" users you follow """
|
||||
"""users you follow"""
|
||||
|
||||
key = "federated"
|
||||
|
||||
def stream_users(self, status):
|
||||
def get_audience(self, status):
|
||||
# this stream wants no part in non-public statuses
|
||||
if status.privacy != "public":
|
||||
return []
|
||||
return super().stream_users(status)
|
||||
return super().get_audience(status)
|
||||
|
||||
def stream_statuses(self, user):
|
||||
def get_statuses_for_user(self, user):
|
||||
return privacy_filter(
|
||||
user,
|
||||
models.Status.objects.select_subclasses(),
|
||||
@ -210,14 +182,14 @@ 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
|
||||
|
||||
if instance.deleted:
|
||||
for stream in streams.values():
|
||||
stream.remove_status(instance)
|
||||
stream.remove_object_from_related_stores(instance)
|
||||
return
|
||||
|
||||
if not created:
|
||||
@ -231,16 +203,16 @@ 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_status(instance)
|
||||
stream.remove_object_from_related_stores(instance)
|
||||
|
||||
|
||||
@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)
|
||||
@ -249,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)
|
||||
@ -258,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():
|
||||
@ -273,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:
|
||||
@ -289,9 +261,9 @@ 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
|
||||
|
||||
for stream in streams.values():
|
||||
stream.populate_stream(instance)
|
||||
stream.populate_streams(instance)
|
||||
|
@ -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 = self.get_search_data(
|
||||
"%s%s" % (self.isbn_search_url, query),
|
||||
@ -74,27 +74,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)
|
||||
@ -103,14 +103,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
|
||||
@ -159,7 +159,7 @@ class AbstractConnector(AbstractMinimalConnector):
|
||||
return get_data(remote_id)
|
||||
|
||||
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)
|
||||
@ -179,7 +179,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
|
||||
@ -187,29 +187,33 @@ class AbstractConnector(AbstractMinimalConnector):
|
||||
data = self.get_book_data(remote_id)
|
||||
|
||||
mapped_data = dict_from_mappings(data, self.author_mappings)
|
||||
activity = activitypub.Author(**mapped_data)
|
||||
try:
|
||||
activity = activitypub.Author(**mapped_data)
|
||||
except activitypub.ActivitySerializerError:
|
||||
return None
|
||||
|
||||
# this will dedupe
|
||||
return activity.to_model(model=models.Author)
|
||||
|
||||
@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):
|
||||
@ -222,7 +226,13 @@ 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(
|
||||
"Attempting to load data from blocked url: {:s}".format(url)
|
||||
)
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
url,
|
||||
@ -248,7 +258,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,
|
||||
@ -266,7 +276,7 @@ def get_image(url):
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
""" standardized search result object """
|
||||
"""standardized search result object"""
|
||||
|
||||
title: str
|
||||
key: str
|
||||
@ -283,14 +293,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
|
||||
@ -300,7 +310,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)
|
||||
|
@ -1,5 +1,6 @@
|
||||
""" interface with whatever connectors the app has """
|
||||
import importlib
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@ -11,13 +12,15 @@ from requests import HTTPError
|
||||
from bookwyrm import models
|
||||
from bookwyrm.tasks import app
|
||||
|
||||
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 = []
|
||||
@ -37,14 +40,17 @@ def search(query, min_confidence=0.1):
|
||||
else:
|
||||
try:
|
||||
result_set = connector.isbn_search(isbn)
|
||||
except (HTTPError, ConnectorException):
|
||||
pass
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
logger.exception(e)
|
||||
continue
|
||||
|
||||
# if no isbn search or results, we fallback to generic search
|
||||
if result_set in (None, []):
|
||||
try:
|
||||
result_set = connector.search(query, min_confidence=min_confidence)
|
||||
except (HTTPError, ConnectorException):
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
# we don't want *any* error to crash the whole search page
|
||||
logger.exception(e)
|
||||
continue
|
||||
|
||||
# if the search results look the same, ignore them
|
||||
@ -61,20 +67,22 @@ def search(query, min_confidence=0.1):
|
||||
return results
|
||||
|
||||
|
||||
def local_search(query, min_confidence=0.1, raw=False):
|
||||
""" only look at local search results """
|
||||
def local_search(query, min_confidence=0.1, raw=False, filters=None):
|
||||
"""only look at local search results"""
|
||||
connector = load_connector(models.Connector.objects.get(local=True))
|
||||
return connector.search(query, min_confidence=min_confidence, raw=raw)
|
||||
return connector.search(
|
||||
query, min_confidence=min_confidence, raw=raw, filters=filters
|
||||
)
|
||||
|
||||
|
||||
def isbn_local_search(query, raw=False):
|
||||
""" only look at local search results """
|
||||
"""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:
|
||||
@ -83,13 +91,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:
|
||||
@ -113,7 +121,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)
|
||||
@ -121,7 +129,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
|
||||
)
|
||||
@ -131,6 +139,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,16 +87,19 @@ 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"
|
||||
author_id = author_blob["key"]
|
||||
url = "%s%s" % (self.base_url, author_id)
|
||||
yield self.get_or_create_author(url)
|
||||
author = self.get_or_create_author(url)
|
||||
if not author:
|
||||
continue
|
||||
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]
|
||||
@ -138,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)
|
||||
|
||||
@ -163,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
|
||||
@ -182,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))
|
||||
@ -202,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,18 +10,19 @@ 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 """
|
||||
def search(self, query, min_confidence=0.1, raw=False, filters=None):
|
||||
"""search your local database"""
|
||||
filters = filters or []
|
||||
if not query:
|
||||
return []
|
||||
# first, try searching unqiue identifiers
|
||||
results = search_identifiers(query)
|
||||
results = search_identifiers(query, *filters)
|
||||
if not results:
|
||||
# then try searching title/author
|
||||
results = search_title_author(query, min_confidence)
|
||||
results = search_title_author(query, min_confidence, *filters)
|
||||
search_results = []
|
||||
for result in results:
|
||||
if raw:
|
||||
@ -35,7 +36,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,26 +88,26 @@ 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):
|
||||
pass
|
||||
|
||||
|
||||
def search_identifiers(query):
|
||||
""" tries remote_id, isbn; defined as dedupe fields on the model """
|
||||
filters = [
|
||||
def search_identifiers(query, *filters):
|
||||
"""tries remote_id, isbn; defined as dedupe fields on the model"""
|
||||
or_filters = [
|
||||
{f.name: query}
|
||||
for f in models.Edition._meta.get_fields()
|
||||
if hasattr(f, "deduplication_field") and f.deduplication_field
|
||||
]
|
||||
results = models.Edition.objects.filter(
|
||||
reduce(operator.or_, (Q(**f) for f in filters))
|
||||
*filters, reduce(operator.or_, (Q(**f) for f in or_filters))
|
||||
).distinct()
|
||||
|
||||
# when there are multiple editions of the same work, pick the default.
|
||||
@ -114,8 +115,8 @@ def search_identifiers(query):
|
||||
return results.filter(parent_work__default_edition__id=F("id")) or results
|
||||
|
||||
|
||||
def search_title_author(query, min_confidence):
|
||||
""" searches for title and author """
|
||||
def search_title_author(query, min_confidence, *filters):
|
||||
"""searches for title and author"""
|
||||
vector = (
|
||||
SearchVector("title", weight="A")
|
||||
+ SearchVector("subtitle", weight="B")
|
||||
@ -126,7 +127,7 @@ def search_title_author(query, min_confidence):
|
||||
results = (
|
||||
models.Edition.objects.annotate(search=vector)
|
||||
.annotate(rank=SearchRank(vector, query))
|
||||
.filter(rank__gt=min_confidence)
|
||||
.filter(*filters, rank__gt=min_confidence)
|
||||
.order_by("-rank")
|
||||
)
|
||||
|
||||
|
@ -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():
|
||||
@ -281,3 +279,26 @@ class ReportForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Report
|
||||
fields = ["user", "reporter", "statuses", "note"]
|
||||
|
||||
|
||||
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:
|
||||
@ -116,24 +116,33 @@ def handle_imported_book(source, user, item, include_reviews, privacy):
|
||||
read.save()
|
||||
|
||||
if include_reviews and (item.rating or item.review):
|
||||
review_title = (
|
||||
"Review of {!r} on {!r}".format(
|
||||
item.book.title,
|
||||
source,
|
||||
)
|
||||
if item.review
|
||||
else ""
|
||||
)
|
||||
|
||||
# we don't know the publication date of the review,
|
||||
# but "now" is a bad guess
|
||||
published_date_guess = item.date_read or item.date_added
|
||||
models.Review.objects.create(
|
||||
user=user,
|
||||
book=item.book,
|
||||
name=review_title,
|
||||
content=item.review,
|
||||
rating=item.rating,
|
||||
published_date=published_date_guess,
|
||||
privacy=privacy,
|
||||
)
|
||||
if item.review:
|
||||
review_title = (
|
||||
"Review of {!r} on {!r}".format(
|
||||
item.book.title,
|
||||
source,
|
||||
)
|
||||
if item.review
|
||||
else ""
|
||||
)
|
||||
models.Review.objects.create(
|
||||
user=user,
|
||||
book=item.book,
|
||||
name=review_title,
|
||||
content=item.review,
|
||||
rating=item.rating,
|
||||
published_date=published_date_guess,
|
||||
privacy=privacy,
|
||||
)
|
||||
else:
|
||||
# just a rating
|
||||
models.ReviewRating.objects.create(
|
||||
user=user,
|
||||
book=item.book,
|
||||
rating=item.rating,
|
||||
published_date=published_date_guess,
|
||||
privacy=privacy,
|
||||
)
|
||||
|
@ -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()
|
||||
|
@ -2,7 +2,7 @@ from django.core.management.base import BaseCommand, CommandError
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from bookwyrm.models import Connector, SiteSettings, User
|
||||
from bookwyrm.models import Connector, FederatedServer, SiteSettings, User
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
|
||||
@ -119,6 +119,16 @@ def init_connectors():
|
||||
)
|
||||
|
||||
|
||||
def init_federated_servers():
|
||||
"""big no to nazis"""
|
||||
built_in_blocks = ["gab.ai", "gab.com"]
|
||||
for server in built_in_blocks:
|
||||
FederatedServer.objects.create(
|
||||
server_name=server,
|
||||
status="blocked",
|
||||
)
|
||||
|
||||
|
||||
def init_settings():
|
||||
SiteSettings.objects.create()
|
||||
|
||||
@ -130,4 +140,5 @@ class Command(BaseCommand):
|
||||
init_groups()
|
||||
init_permissions()
|
||||
init_connectors()
|
||||
init_federated_servers()
|
||||
init_settings()
|
||||
|
@ -10,21 +10,21 @@ 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,
|
||||
)
|
||||
for user in users:
|
||||
for stream in activitystreams.streams.values():
|
||||
stream.populate_stream(user)
|
||||
stream.populate_streams(user)
|
||||
|
||||
|
||||
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
|
||||
|
33
bookwyrm/migrations/0062_auto_20210407_1545.py
Normal file
33
bookwyrm/migrations/0062_auto_20210407_1545.py
Normal file
@ -0,0 +1,33 @@
|
||||
# Generated by Django 3.1.6 on 2021-04-07 15:45
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0061_auto_20210402_1435"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="series",
|
||||
field=bookwyrm.models.fields.TextField(
|
||||
blank=True, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="subtitle",
|
||||
field=bookwyrm.models.fields.TextField(
|
||||
blank=True, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="title",
|
||||
field=bookwyrm.models.fields.TextField(max_length=255),
|
||||
),
|
||||
]
|
37
bookwyrm/migrations/0063_auto_20210407_1827.py
Normal file
37
bookwyrm/migrations/0063_auto_20210407_1827.py
Normal file
@ -0,0 +1,37 @@
|
||||
# Generated by Django 3.1.6 on 2021-04-07 18:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0062_auto_20210407_1545"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="federatedserver",
|
||||
name="notes",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="federatedserver",
|
||||
name="application_type",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="federatedserver",
|
||||
name="application_version",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="federatedserver",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[("federated", "Federated"), ("blocked", "Blocked")],
|
||||
default="federated",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
]
|
27
bookwyrm/migrations/0063_auto_20210408_1556.py
Normal file
27
bookwyrm/migrations/0063_auto_20210408_1556.py
Normal file
@ -0,0 +1,27 @@
|
||||
# Generated by Django 3.1.6 on 2021-04-08 15:56
|
||||
|
||||
import bookwyrm.models.fields
|
||||
import django.contrib.postgres.fields.citext
|
||||
import django.contrib.postgres.operations
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0062_auto_20210407_1545"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
django.contrib.postgres.operations.CITextExtension(),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="localname",
|
||||
field=django.contrib.postgres.fields.citext.CICharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
unique=True,
|
||||
validators=[bookwyrm.models.fields.validate_localname],
|
||||
),
|
||||
),
|
||||
]
|
28
bookwyrm/migrations/0064_auto_20210408_2208.py
Normal file
28
bookwyrm/migrations/0064_auto_20210408_2208.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Generated by Django 3.1.6 on 2021-04-08 22:08
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0063_auto_20210408_1556"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="listitem",
|
||||
name="book_list",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="bookwyrm.list"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="shelfbook",
|
||||
name="shelf",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.shelf"
|
||||
),
|
||||
),
|
||||
]
|
13
bookwyrm/migrations/0064_merge_20210410_1633.py
Normal file
13
bookwyrm/migrations/0064_merge_20210410_1633.py
Normal file
@ -0,0 +1,13 @@
|
||||
# Generated by Django 3.1.8 on 2021-04-10 16:33
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0063_auto_20210408_1556"),
|
||||
("bookwyrm", "0063_auto_20210407_1827"),
|
||||
]
|
||||
|
||||
operations = []
|
13
bookwyrm/migrations/0065_merge_20210411_1702.py
Normal file
13
bookwyrm/migrations/0065_merge_20210411_1702.py
Normal file
@ -0,0 +1,13 @@
|
||||
# Generated by Django 3.1.8 on 2021-04-11 17:02
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0064_auto_20210408_2208"),
|
||||
("bookwyrm", "0064_merge_20210410_1633"),
|
||||
]
|
||||
|
||||
operations = []
|
27
bookwyrm/migrations/0066_user_deactivation_reason.py
Normal file
27
bookwyrm/migrations/0066_user_deactivation_reason.py
Normal file
@ -0,0 +1,27 @@
|
||||
# Generated by Django 3.1.8 on 2021-04-12 15:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0065_merge_20210411_1702"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="deactivation_reason",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("self_deletion", "Self Deletion"),
|
||||
("moderator_deletion", "Moderator Deletion"),
|
||||
("domain_block", "Domain Block"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
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
|
||||
|
@ -1,5 +1,6 @@
|
||||
""" activitypub model functionality """
|
||||
from base64 import b64encode
|
||||
from collections import namedtuple
|
||||
from functools import reduce
|
||||
import json
|
||||
import operator
|
||||
@ -25,14 +26,23 @@ from bookwyrm.models.fields import ImageField, ManyToManyField
|
||||
logger = logging.getLogger(__name__)
|
||||
# I tried to separate these classes into mutliple files but I kept getting
|
||||
# circular import errors so I gave up. I'm sure it could be done though!
|
||||
|
||||
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"""
|
||||
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"
|
||||
@ -52,6 +62,12 @@ class ActivitypubMixin:
|
||||
self.activity_fields = (
|
||||
self.image_fields + self.many_to_many_fields + self.simple_fields
|
||||
)
|
||||
if hasattr(self, "property_fields"):
|
||||
self.activity_fields += [
|
||||
# pylint: disable=cell-var-from-loop
|
||||
PropertyField(lambda a, o: set_activity_from_property_field(a, o, f))
|
||||
for f in self.property_fields
|
||||
]
|
||||
|
||||
# these are separate to avoid infinite recursion issues
|
||||
self.deserialize_reverse_fields = (
|
||||
@ -69,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
|
||||
@ -110,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),
|
||||
@ -118,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
|
||||
@ -132,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.objects.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
|
||||
@ -159,32 +179,34 @@ 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 woul cause an error in the base save method
|
||||
# this bonus kwarg would cause an error in the base save method
|
||||
if "broadcast" in kwargs:
|
||||
del kwargs["broadcast"]
|
||||
|
||||
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)
|
||||
@ -232,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
|
||||
@ -258,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,
|
||||
@ -268,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,
|
||||
@ -284,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")
|
||||
|
||||
@ -319,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
|
||||
@ -332,81 +354,98 @@ 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.Add
|
||||
object_field = collection_field = None
|
||||
activity_serializer = activitypub.CollectionItem
|
||||
|
||||
def broadcast(self, activity, sender, software="bookwyrm"):
|
||||
"""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"""
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
if self.approved:
|
||||
return collection_field.privacy
|
||||
return "direct"
|
||||
|
||||
@property
|
||||
def recipients(self):
|
||||
"""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
|
||||
return []
|
||||
return [collection_field.user]
|
||||
|
||||
def save(self, *args, broadcast=True, **kwargs):
|
||||
""" broadcast updated """
|
||||
created = not bool(self.id)
|
||||
"""broadcast updated"""
|
||||
# first off, we want to save normally no matter what
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# these shouldn't be edited, only created and deleted
|
||||
if not broadcast or not created or not self.user.local:
|
||||
# list items can be updateda, normally you would only broadcast on created
|
||||
if not broadcast or not self.user.local:
|
||||
return
|
||||
|
||||
# adding an obj to the collection
|
||||
activity = self.to_add_activity()
|
||||
activity = self.to_add_activity(self.user)
|
||||
self.broadcast(activity, self.user)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
""" broadcast a remove activity """
|
||||
activity = self.to_remove_activity()
|
||||
def delete(self, *args, broadcast=True, **kwargs):
|
||||
"""broadcast a remove activity"""
|
||||
activity = self.to_remove_activity(self.user)
|
||||
super().delete(*args, **kwargs)
|
||||
if self.user.local:
|
||||
if self.user.local and broadcast:
|
||||
self.broadcast(activity, self.user)
|
||||
|
||||
def to_add_activity(self):
|
||||
""" AP for shelving a book"""
|
||||
object_field = getattr(self, self.object_field)
|
||||
def to_add_activity(self, user):
|
||||
"""AP for shelving a book"""
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
return activitypub.Add(
|
||||
id=self.remote_id,
|
||||
actor=self.user.remote_id,
|
||||
object=object_field,
|
||||
id="{:s}#add".format(collection_field.remote_id),
|
||||
actor=user.remote_id,
|
||||
object=self.to_activity_dataclass(),
|
||||
target=collection_field.remote_id,
|
||||
).serialize()
|
||||
|
||||
def to_remove_activity(self):
|
||||
""" AP for un-shelving a book"""
|
||||
object_field = getattr(self, self.object_field)
|
||||
def to_remove_activity(self, user):
|
||||
"""AP for un-shelving a book"""
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
return activitypub.Remove(
|
||||
id=self.remote_id,
|
||||
actor=self.user.remote_id,
|
||||
object=object_field,
|
||||
id="{:s}#remove".format(collection_field.remote_id),
|
||||
actor=user.remote_id,
|
||||
object=self.to_activity_dataclass(),
|
||||
target=collection_field.remote_id,
|
||||
).serialize()
|
||||
|
||||
|
||||
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,
|
||||
@ -416,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)
|
||||
@ -430,7 +469,7 @@ def generate_activity(obj):
|
||||
) in obj.serialize_reverse_fields:
|
||||
related_field = getattr(obj, model_field_name)
|
||||
activity[activity_field_name] = unfurl_related_field(
|
||||
related_field, sort_field
|
||||
related_field, sort_field=sort_field
|
||||
)
|
||||
|
||||
if not activity.get("id"):
|
||||
@ -439,8 +478,8 @@ def generate_activity(obj):
|
||||
|
||||
|
||||
def unfurl_related_field(related_field, sort_field=None):
|
||||
""" load reverse lookups (like public key owner or Status attachment """
|
||||
if hasattr(related_field, "all"):
|
||||
"""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()
|
||||
]
|
||||
@ -455,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:
|
||||
@ -466,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:
|
||||
@ -495,10 +534,10 @@ 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.page(page)
|
||||
activity_page = paginated.get_page(page)
|
||||
if id_only:
|
||||
items = [s.remote_id for s in activity_page.object_list]
|
||||
else:
|
||||
|
@ -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/",
|
||||
@ -33,4 +33,4 @@ class Image(Attachment):
|
||||
)
|
||||
caption = fields.TextField(null=True, blank=True, activitypub_field="name")
|
||||
|
||||
activity_serializer = activitypub.Image
|
||||
activity_serializer = activitypub.Document
|
||||
|
@ -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
|
||||
@ -33,7 +33,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,20 +22,50 @@ 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?"""
|
||||
# make sure this is an object with privacy owned by a user
|
||||
if not hasattr(self, "user") or not hasattr(self, "privacy"):
|
||||
return None
|
||||
|
||||
# viewer can't see it if the object's owner blocked them
|
||||
if viewer in self.user.blocks.all():
|
||||
return False
|
||||
|
||||
# you can see your own posts and any public or unlisted posts
|
||||
if viewer == self.user or self.privacy in ["public", "unlisted"]:
|
||||
return True
|
||||
|
||||
# you can see the followers only posts of people you follow
|
||||
if (
|
||||
self.privacy == "followers"
|
||||
and self.user.followers.filter(id=viewer.id).first()
|
||||
):
|
||||
return True
|
||||
|
||||
# you can see dms you are tagged in
|
||||
if hasattr(self, "mention_users"):
|
||||
if (
|
||||
self.privacy == "direct"
|
||||
and self.mention_users.filter(id=viewer.id).first()
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@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(
|
||||
@ -32,15 +32,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:
|
||||
@ -49,24 +53,24 @@ 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)
|
||||
|
||||
# book/work metadata
|
||||
title = fields.CharField(max_length=255)
|
||||
title = fields.TextField(max_length=255)
|
||||
sort_title = fields.CharField(max_length=255, blank=True, null=True)
|
||||
subtitle = fields.CharField(max_length=255, blank=True, null=True)
|
||||
subtitle = fields.TextField(max_length=255, blank=True, null=True)
|
||||
description = fields.HtmlField(blank=True, null=True)
|
||||
languages = fields.ArrayField(
|
||||
models.CharField(max_length=255), blank=True, default=list
|
||||
)
|
||||
series = fields.CharField(max_length=255, blank=True, null=True)
|
||||
series = fields.TextField(max_length=255, blank=True, null=True)
|
||||
series_number = fields.CharField(max_length=255, blank=True, null=True)
|
||||
subjects = fields.ArrayField(
|
||||
models.CharField(max_length=255), blank=True, null=True, default=list
|
||||
@ -85,17 +89,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"
|
||||
@ -108,20 +112,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):
|
||||
@ -133,7 +137,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(
|
||||
@ -145,19 +149,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()
|
||||
@ -165,11 +169,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
|
||||
@ -178,7 +182,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(
|
||||
@ -217,7 +221,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
|
||||
@ -237,7 +241,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)
|
||||
@ -251,7 +255,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]
|
||||
@ -273,7 +277,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)
|
||||
|
@ -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")
|
||||
|
@ -1,17 +1,51 @@
|
||||
""" connections to external ActivityPub servers """
|
||||
from urllib.parse import urlparse
|
||||
from django.db import models
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
FederationStatus = models.TextChoices(
|
||||
"Status",
|
||||
[
|
||||
"federated",
|
||||
"blocked",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class FederatedServer(BookWyrmModel):
|
||||
""" store which servers we federate with """
|
||||
"""store which servers we federate with"""
|
||||
|
||||
server_name = models.CharField(max_length=255, unique=True)
|
||||
# federated, blocked, whatever else
|
||||
status = models.CharField(max_length=255, default="federated")
|
||||
status = models.CharField(
|
||||
max_length=255, default="federated", choices=FederationStatus.choices
|
||||
)
|
||||
# is it mastodon, bookwyrm, etc
|
||||
application_type = models.CharField(max_length=255, null=True)
|
||||
application_version = models.CharField(max_length=255, null=True)
|
||||
application_type = models.CharField(max_length=255, null=True, blank=True)
|
||||
application_version = models.CharField(max_length=255, null=True, blank=True)
|
||||
notes = models.TextField(null=True, blank=True)
|
||||
|
||||
def block(self):
|
||||
"""block a server"""
|
||||
self.status = "blocked"
|
||||
self.save()
|
||||
|
||||
# TODO: blocked servers
|
||||
# deactivate all associated users
|
||||
self.user_set.filter(is_active=True).update(
|
||||
is_active=False, deactivation_reason="domain_block"
|
||||
)
|
||||
|
||||
def unblock(self):
|
||||
"""unblock a server"""
|
||||
self.status = "federated"
|
||||
self.save()
|
||||
|
||||
self.user_set.filter(deactivation_reason="domain_block").update(
|
||||
is_active=True, deactivation_reason=None
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def is_blocked(cls, url):
|
||||
"""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,17 +333,17 @@ 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:
|
||||
return None
|
||||
url = "https://%s%s" % (DOMAIN, url)
|
||||
return activitypub.Image(url=url, name=alt)
|
||||
return activitypub.Document(url=url, name=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,43 +41,40 @@ 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="object"
|
||||
)
|
||||
book_list = fields.ForeignKey(
|
||||
"List", on_delete=models.CASCADE, activitypub_field="target"
|
||||
"Edition", on_delete=models.PROTECT, activitypub_field="book"
|
||||
)
|
||||
book_list = models.ForeignKey("List", on_delete=models.CASCADE)
|
||||
user = fields.ForeignKey(
|
||||
"User", on_delete=models.PROTECT, activitypub_field="actor"
|
||||
)
|
||||
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.Add
|
||||
object_field = "book"
|
||||
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
|
||||
@ -96,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,49 +34,46 @@ 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="object"
|
||||
)
|
||||
shelf = fields.ForeignKey(
|
||||
"Shelf", on_delete=models.PROTECT, activitypub_field="target"
|
||||
"Edition", on_delete=models.PROTECT, activitypub_field="book"
|
||||
)
|
||||
shelf = models.ForeignKey("Shelf", on_delete=models.PROTECT)
|
||||
user = fields.ForeignKey(
|
||||
"User", on_delete=models.PROTECT, activitypub_field="actor"
|
||||
)
|
||||
|
||||
activity_serializer = activitypub.Add
|
||||
object_field = "book"
|
||||
activity_serializer = activitypub.ShelfItem
|
||||
collection_field = "shelf"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
@ -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")
|
@ -4,6 +4,7 @@ from urllib.parse import urlparse
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.models import AbstractUser, Group
|
||||
from django.contrib.postgres.fields import CICharField
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
@ -23,8 +24,18 @@ from .federated_server import FederatedServer
|
||||
from . import fields, Review
|
||||
|
||||
|
||||
DeactivationReason = models.TextChoices(
|
||||
"DeactivationReason",
|
||||
[
|
||||
"self_deletion",
|
||||
"moderator_deletion",
|
||||
"domain_block",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@ -54,7 +65,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
summary = fields.HtmlField(null=True, blank=True)
|
||||
local = models.BooleanField(default=False)
|
||||
bookwyrm_user = fields.BooleanField(default=True)
|
||||
localname = models.CharField(
|
||||
localname = CICharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
unique=True,
|
||||
@ -110,33 +121,47 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
default=str(pytz.utc),
|
||||
max_length=255,
|
||||
)
|
||||
deactivation_reason = models.CharField(
|
||||
max_length=255, choices=DeactivationReason.choices, null=True, blank=True
|
||||
)
|
||||
|
||||
name_field = "username"
|
||||
property_fields = [("following_link", "following")]
|
||||
|
||||
@property
|
||||
def following_link(self):
|
||||
"""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"""
|
||||
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.is_authenticated:
|
||||
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
|
||||
@ -163,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(),
|
||||
@ -173,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(),
|
||||
@ -185,6 +210,9 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
def to_activity(self, **kwargs):
|
||||
"""override default AP serializer to add context object
|
||||
idk if this is the best way to go about this"""
|
||||
if not self.is_active:
|
||||
return self.remote_id
|
||||
|
||||
activity_object = super().to_activity(**kwargs)
|
||||
activity_object["@context"] = [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
@ -199,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)
|
||||
@ -263,14 +291,20 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
editable=False,
|
||||
).save(broadcast=False)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""deactivate rather than delete a user"""
|
||||
self.is_active = False
|
||||
# skip the logic in this class's save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def local_path(self):
|
||||
""" this model doesn't inherit bookwyrm model, so here we are """
|
||||
"""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(
|
||||
@ -285,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"]
|
||||
@ -303,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)])
|
||||
@ -313,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")
|
||||
@ -332,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,
|
||||
@ -342,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()
|
||||
@ -355,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)
|
||||
@ -365,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:
|
||||
@ -394,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)
|
||||
|
||||
|
86
bookwyrm/redis_store.py
Normal file
86
bookwyrm/redis_store.py
Normal file
@ -0,0 +1,86 @@
|
||||
""" access the activity stores stored in redis """
|
||||
from abc import ABC, abstractmethod
|
||||
import redis
|
||||
|
||||
from bookwyrm import settings
|
||||
|
||||
r = redis.Redis(
|
||||
host=settings.REDIS_ACTIVITY_HOST, port=settings.REDIS_ACTIVITY_PORT, db=0
|
||||
)
|
||||
|
||||
|
||||
class RedisStore(ABC):
|
||||
"""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"""
|
||||
return {obj.id: self.get_rank(obj)}
|
||||
|
||||
def add_object_to_related_stores(self, obj, execute=True):
|
||||
"""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()
|
||||
for store in self.get_stores_for_object(obj):
|
||||
# add the status to the feed
|
||||
pipeline.zadd(store, value)
|
||||
# trim the store
|
||||
pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
|
||||
if not execute:
|
||||
return pipeline
|
||||
# and go!
|
||||
return pipeline.execute()
|
||||
|
||||
def remove_object_from_related_stores(self, obj):
|
||||
"""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"""
|
||||
pipeline = r.pipeline()
|
||||
for obj in objs[: self.max_length]:
|
||||
pipeline.zadd(store, self.get_value(obj))
|
||||
if objs:
|
||||
pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
|
||||
pipeline.execute()
|
||||
|
||||
def bulk_remove_objects_from_store(self, objs, 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"""
|
||||
return r.zrevrange(store, 0, -1)
|
||||
|
||||
def populate_store(self, store):
|
||||
"""go from zero to a store"""
|
||||
pipeline = r.pipeline()
|
||||
queryset = self.get_objects_for_store(store)
|
||||
|
||||
for obj in queryset[: self.max_length]:
|
||||
pipeline.zadd(store, self.get_value(obj))
|
||||
|
||||
# only trim the store if objects were added
|
||||
if queryset.exists():
|
||||
pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
|
||||
pipeline.execute()
|
||||
|
||||
@abstractmethod
|
||||
def get_objects_for_store(self, store):
|
||||
"""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"""
|
||||
|
||||
@abstractmethod
|
||||
def get_rank(self, obj):
|
||||
"""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:
|
||||
|
@ -24,7 +24,8 @@ EMAIL_HOST = env("EMAIL_HOST")
|
||||
EMAIL_PORT = env("EMAIL_PORT", 587)
|
||||
EMAIL_HOST_USER = env("EMAIL_HOST_USER")
|
||||
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
|
||||
EMAIL_USE_TLS = env("EMAIL_USE_TLS", True)
|
||||
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", True)
|
||||
EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False)
|
||||
DEFAULT_FROM_EMAIL = "admin@{:s}".format(env("DOMAIN"))
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
@ -33,6 +34,8 @@ LOCALE_PATHS = [
|
||||
os.path.join(BASE_DIR, "locale"),
|
||||
]
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/
|
||||
|
||||
@ -97,6 +100,7 @@ WSGI_APPLICATION = "bookwyrm.wsgi.application"
|
||||
# redis/activity streams settings
|
||||
REDIS_ACTIVITY_HOST = env("REDIS_ACTIVITY_HOST", "localhost")
|
||||
REDIS_ACTIVITY_PORT = env("REDIS_ACTIVITY_PORT", 6379)
|
||||
REDIS_ACTIVITY_PASSWORD = env("REDIS_ACTIVITY_PASSWORD", None)
|
||||
|
||||
MAX_STREAM_LENGTH = int(env("MAX_STREAM_LENGTH", 200))
|
||||
STREAMS = ["home", "local", "federated"]
|
||||
@ -151,7 +155,7 @@ LANGUAGES = [
|
||||
("de-de", _("German")),
|
||||
("es", _("Spanish")),
|
||||
("fr-fr", _("French")),
|
||||
("zh-cn", _("Simplified Chinese")),
|
||||
("zh-hans", _("Simplified Chinese")),
|
||||
]
|
||||
|
||||
|
||||
@ -165,7 +169,7 @@ USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/2.0/howto/static-files/
|
||||
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
||||
|
||||
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STATIC_URL = "/static/"
|
||||
|
@ -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,9 +1,13 @@
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 20%;
|
||||
}
|
||||
|
||||
/* --- --- */
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.image {
|
||||
overflow: hidden;
|
||||
}
|
||||
@ -25,17 +29,42 @@ html {
|
||||
min-width: 75% !important;
|
||||
}
|
||||
|
||||
/* --- "disabled" for non-buttons --- */
|
||||
.is-disabled {
|
||||
background-color: #dbdbdb;
|
||||
border-color: #dbdbdb;
|
||||
box-shadow: none;
|
||||
color: #7a7a7a;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- SHELVING --- */
|
||||
.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
|
||||
******************************************************************************/
|
||||
|
||||
/** @todo Replace icons with SVG symbols.
|
||||
@see https://www.youtube.com/watch?v=9xXBYcWgCHA */
|
||||
@ -45,7 +74,9 @@ html {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
/* --- TOGGLES --- */
|
||||
/** Toggles
|
||||
******************************************************************************/
|
||||
|
||||
.toggle-button[aria-pressed=true],
|
||||
.toggle-button[aria-pressed=true]:hover {
|
||||
background-color: hsl(171, 100%, 41%);
|
||||
@ -57,12 +88,8 @@ html {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.hidden.transition-y,
|
||||
.hidden.transition-x {
|
||||
.transition-x.is-hidden,
|
||||
.transition-y.is-hidden {
|
||||
display: block !important;
|
||||
visibility: hidden !important;
|
||||
height: 0;
|
||||
@ -71,16 +98,18 @@ html {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.transition-x,
|
||||
.transition-y {
|
||||
transition-property: height, margin-top, margin-bottom, padding-top, padding-bottom;
|
||||
transition-duration: 0.5s;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
|
||||
.transition-x {
|
||||
transition-property: width, margin-left, margin-right, padding-left, padding-right;
|
||||
transition-duration: 0.5s;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
|
||||
.transition-y {
|
||||
transition-property: height, margin-top, margin-bottom, padding-top, padding-bottom;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@ -90,6 +119,13 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stars
|
||||
******************************************************************************/
|
||||
|
||||
.stars {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/** Stars in a review form
|
||||
*
|
||||
* Specificity makes hovering taking over checked inputs.
|
||||
@ -121,7 +157,9 @@ html {
|
||||
content: '\e9d7';
|
||||
}
|
||||
|
||||
/* --- BOOK COVERS --- */
|
||||
/** Book covers
|
||||
******************************************************************************/
|
||||
|
||||
.cover-container {
|
||||
height: 250px;
|
||||
width: max-content;
|
||||
@ -186,7 +224,9 @@ html {
|
||||
padding: 0.1em;
|
||||
}
|
||||
|
||||
/* --- AVATAR --- */
|
||||
/** Avatars
|
||||
******************************************************************************/
|
||||
|
||||
.avatar {
|
||||
vertical-align: middle;
|
||||
display: inline;
|
||||
@ -202,25 +242,107 @@ html {
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
/* --- QUOTES --- */
|
||||
.quote blockquote {
|
||||
/** Statuses: Quotes
|
||||
*
|
||||
* \e906: icon-quote-open
|
||||
* \e905: icon-quote-close
|
||||
*
|
||||
* The `content` class on the blockquote allows to apply styles to markdown
|
||||
* generated HTML in the quote: https://bulma.io/documentation/elements/content/
|
||||
*
|
||||
* ```html
|
||||
* <div class="quote block">
|
||||
* <blockquote dir="auto" class="content mb-2">
|
||||
* User generated quote in markdown…
|
||||
* </blockquote>
|
||||
*
|
||||
* <p> — <a…>Book Title</a> by <a…class="author">Author</a></p>
|
||||
* </div>
|
||||
* ```
|
||||
******************************************************************************/
|
||||
|
||||
.quote > blockquote {
|
||||
position: relative;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.quote blockquote::before,
|
||||
.quote blockquote::after {
|
||||
.quote > blockquote::before,
|
||||
.quote > blockquote::after {
|
||||
font-family: 'icomoon';
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.quote blockquote::before {
|
||||
.quote > blockquote::before {
|
||||
content: "\e906";
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.quote blockquote::after {
|
||||
.quote > blockquote::after {
|
||||
content: "\e905";
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* States
|
||||
******************************************************************************/
|
||||
|
||||
/* "disabled" for non-buttons */
|
||||
|
||||
.is-disabled {
|
||||
background-color: #dbdbdb;
|
||||
border-color: #dbdbdb;
|
||||
box-shadow: none;
|
||||
color: #7a7a7a;
|
||||
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;
|
||||
}
|
||||
}
|
@ -1,10 +1,13 @@
|
||||
|
||||
/** @todo Replace icons with SVG symbols.
|
||||
@see https://www.youtube.com/watch?v=9xXBYcWgCHA */
|
||||
@font-face {
|
||||
font-family: 'icomoon';
|
||||
src: url('fonts/icomoon.eot?n5x55');
|
||||
src: url('fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'),
|
||||
url('fonts/icomoon.ttf?n5x55') format('truetype'),
|
||||
url('fonts/icomoon.woff?n5x55') format('woff'),
|
||||
url('fonts/icomoon.svg?n5x55#icomoon') format('svg');
|
||||
src: url('../fonts/icomoon.eot?n5x55');
|
||||
src: url('../fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'),
|
||||
url('../fonts/icomoon.ttf?n5x55') format('truetype'),
|
||||
url('../fonts/icomoon.woff?n5x55') format('woff'),
|
||||
url('../fonts/icomoon.svg?n5x55#icomoon') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
285
bookwyrm/static/js/bookwyrm.js
Normal file
285
bookwyrm/static/js/bookwyrm.js
Normal file
@ -0,0 +1,285 @@
|
||||
/* exported BookWyrm */
|
||||
/* globals TabGroup */
|
||||
|
||||
let BookWyrm = new class {
|
||||
constructor() {
|
||||
this.initOnDOMLoaded();
|
||||
this.initReccuringTasks();
|
||||
this.initEventListeners();
|
||||
}
|
||||
|
||||
initEventListeners() {
|
||||
document.querySelectorAll('[data-controls]')
|
||||
.forEach(button => button.addEventListener(
|
||||
'click',
|
||||
this.toggleAction.bind(this))
|
||||
);
|
||||
|
||||
document.querySelectorAll('.interaction')
|
||||
.forEach(button => button.addEventListener(
|
||||
'submit',
|
||||
this.interact.bind(this))
|
||||
);
|
||||
|
||||
document.querySelectorAll('.hidden-form input')
|
||||
.forEach(button => button.addEventListener(
|
||||
'change',
|
||||
this.revealForm.bind(this))
|
||||
);
|
||||
|
||||
document.querySelectorAll('[data-back]')
|
||||
.forEach(button => button.addEventListener(
|
||||
'click',
|
||||
this.back)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute code once the DOM is loaded.
|
||||
*/
|
||||
initOnDOMLoaded() {
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.tab-group')
|
||||
.forEach(tabs => new TabGroup(tabs));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute recurring tasks.
|
||||
*/
|
||||
initReccuringTasks() {
|
||||
// Polling
|
||||
document.querySelectorAll('[data-poll]')
|
||||
.forEach(liveArea => this.polling(liveArea));
|
||||
}
|
||||
|
||||
/**
|
||||
* Go back in browser history.
|
||||
*
|
||||
* @param {Event} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
back(event) {
|
||||
event.preventDefault();
|
||||
history.back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a counter with recurring requests to the API
|
||||
* The delay is slightly randomized and increased on each cycle.
|
||||
*
|
||||
* @param {Object} counter - DOM node
|
||||
* @param {int} delay - frequency for polling in ms
|
||||
* @return {undefined}
|
||||
*/
|
||||
polling(counter, delay) {
|
||||
const bookwyrm = this;
|
||||
|
||||
delay = delay || 10000;
|
||||
delay += (Math.random() * 1000);
|
||||
|
||||
setTimeout(function() {
|
||||
fetch('/api/updates/' + counter.dataset.poll)
|
||||
.then(response => response.json())
|
||||
.then(data => bookwyrm.updateCountElement(counter, data));
|
||||
|
||||
bookwyrm.polling(counter, delay * 1.25);
|
||||
}, delay, counter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a counter.
|
||||
*
|
||||
* @param {object} counter - DOM node
|
||||
* @param {object} data - json formatted response from a fetch
|
||||
* @return {undefined}
|
||||
*/
|
||||
updateCountElement(counter, data) {
|
||||
const currentCount = counter.innerText;
|
||||
const count = data.count;
|
||||
|
||||
if (count != currentCount) {
|
||||
this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-hidden', count < 1);
|
||||
counter.innerText = count;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle form.
|
||||
*
|
||||
* @param {Event} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
revealForm(event) {
|
||||
let trigger = event.currentTarget;
|
||||
let hidden = trigger.closest('.hidden-form').querySelectorAll('.is-hidden')[0];
|
||||
|
||||
this.addRemoveClass(hidden, 'is-hidden', !hidden);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute actions on targets based on triggers.
|
||||
*
|
||||
* @param {Event} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
toggleAction(event) {
|
||||
let trigger = event.currentTarget;
|
||||
let pressed = trigger.getAttribute('aria-pressed') === 'false';
|
||||
let targetId = trigger.dataset.controls;
|
||||
|
||||
// Toggle pressed status on all triggers controlling the same target.
|
||||
document.querySelectorAll('[data-controls="' + targetId + '"]')
|
||||
.forEach(otherTrigger => otherTrigger.setAttribute(
|
||||
'aria-pressed',
|
||||
otherTrigger.getAttribute('aria-pressed') === 'false'
|
||||
));
|
||||
|
||||
// @todo Find a better way to handle the exception.
|
||||
if (targetId && ! trigger.classList.contains('pulldown-menu')) {
|
||||
let target = document.getElementById(targetId);
|
||||
|
||||
this.addRemoveClass(target, 'is-hidden', !pressed);
|
||||
this.addRemoveClass(target, 'is-active', pressed);
|
||||
}
|
||||
|
||||
// Show/hide pulldown-menus.
|
||||
if (trigger.classList.contains('pulldown-menu')) {
|
||||
this.toggleMenu(trigger, targetId);
|
||||
}
|
||||
|
||||
// Show/hide container.
|
||||
let container = document.getElementById('hide-' + targetId);
|
||||
|
||||
if (container) {
|
||||
this.toggleContainer(container, pressed);
|
||||
}
|
||||
|
||||
// Check checkbox, if appropriate.
|
||||
let checkbox = trigger.dataset.controlsCheckbox;
|
||||
|
||||
if (checkbox) {
|
||||
this.toggleCheckbox(checkbox, pressed);
|
||||
}
|
||||
|
||||
// Set focus, if appropriate.
|
||||
let focus = trigger.dataset.focusTarget;
|
||||
|
||||
if (focus) {
|
||||
this.toggleFocus(focus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide menus.
|
||||
*
|
||||
* @param {Event} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
toggleMenu(trigger, targetId) {
|
||||
let expanded = trigger.getAttribute('aria-expanded') == 'false';
|
||||
|
||||
trigger.setAttribute('aria-expanded', expanded);
|
||||
|
||||
if (targetId) {
|
||||
let target = document.getElementById(targetId);
|
||||
|
||||
this.addRemoveClass(target, 'is-active', expanded);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide generic containers.
|
||||
*
|
||||
* @param {object} container - DOM node
|
||||
* @param {boolean} pressed - Is the trigger pressed?
|
||||
* @return {undefined}
|
||||
*/
|
||||
toggleContainer(container, pressed) {
|
||||
this.addRemoveClass(container, 'is-hidden', pressed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check or uncheck a checbox.
|
||||
*
|
||||
* @param {object} checkbox - DOM node
|
||||
* @param {boolean} pressed - Is the trigger pressed?
|
||||
* @return {undefined}
|
||||
*/
|
||||
toggleCheckbox(checkbox, pressed) {
|
||||
document.getElementById(checkbox).checked = !!pressed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give the focus to an element.
|
||||
* Only move the focus based on user interactions.
|
||||
*
|
||||
* @param {string} nodeId - ID of the DOM node to focus (button, link…)
|
||||
* @return {undefined}
|
||||
*/
|
||||
toggleFocus(nodeId) {
|
||||
let node = document.getElementById(nodeId);
|
||||
|
||||
node.focus();
|
||||
|
||||
setTimeout(function() {
|
||||
node.selectionStart = node.selectionEnd = 10000;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request and update the UI accordingly.
|
||||
* This function is used for boosts, favourites, follows and unfollows.
|
||||
*
|
||||
* @param {Event} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
interact(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const bookwyrm = this;
|
||||
const form = event.currentTarget;
|
||||
const relatedforms = document.querySelectorAll(`.${form.dataset.id}`);
|
||||
|
||||
// Toggle class on all related forms.
|
||||
relatedforms.forEach(relatedForm => bookwyrm.addRemoveClass(
|
||||
relatedForm,
|
||||
'is-hidden',
|
||||
relatedForm.className.indexOf('is-hidden') == -1
|
||||
));
|
||||
|
||||
this.ajaxPost(form).catch(error => {
|
||||
// @todo Display a notification in the UI instead.
|
||||
console.warn('Request failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a form using POST.
|
||||
*
|
||||
* @param {object} form - Form to be submitted
|
||||
* @return {Promise}
|
||||
*/
|
||||
ajaxPost(form) {
|
||||
return fetch(form.action, {
|
||||
method : "POST",
|
||||
body: new FormData(form)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or remove a class based on a boolean condition.
|
||||
*
|
||||
* @param {object} node - DOM node to change class on
|
||||
* @param {string} classname - Name of the class
|
||||
* @param {boolean} add - Add?
|
||||
* @return {undefined}
|
||||
*/
|
||||
addRemoveClass(node, classname, add) {
|
||||
if (add) {
|
||||
node.classList.add(classname);
|
||||
} else {
|
||||
node.classList.remove(classname);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,17 +1,34 @@
|
||||
/* exported toggleAllCheckboxes */
|
||||
|
||||
/**
|
||||
* Toggle all descendant checkboxes of a target.
|
||||
*
|
||||
* Use `data-target="ID_OF_TARGET"` on the node being listened to.
|
||||
*
|
||||
* @param {Event} event - change Event
|
||||
* @return {undefined}
|
||||
*/
|
||||
function toggleAllCheckboxes(event) {
|
||||
const mainCheckbox = event.target;
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Toggle all descendant checkboxes of a target.
|
||||
*
|
||||
* Use `data-target="ID_OF_TARGET"` on the node on which the event is listened
|
||||
* to (checkbox, button, link…), where_ID_OF_TARGET_ should be the ID of an
|
||||
* ancestor for the checkboxes.
|
||||
*
|
||||
* @example
|
||||
* <input
|
||||
* type="checkbox"
|
||||
* data-action="toggle-all"
|
||||
* data-target="failed-imports"
|
||||
* >
|
||||
* @param {Event} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
function toggleAllCheckboxes(event) {
|
||||
const mainCheckbox = event.target;
|
||||
|
||||
document
|
||||
.querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`)
|
||||
.forEach(checkbox => checkbox.checked = mainCheckbox.checked);
|
||||
}
|
||||
|
||||
document
|
||||
.querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`)
|
||||
.forEach(checkbox => {checkbox.checked = mainCheckbox.checked;});
|
||||
}
|
||||
.querySelectorAll('[data-action="toggle-all"]')
|
||||
.forEach(input => {
|
||||
input.addEventListener('change', toggleAllCheckboxes);
|
||||
});
|
||||
})();
|
||||
|
@ -1,20 +1,43 @@
|
||||
/* exported updateDisplay */
|
||||
/* globals addRemoveClass */
|
||||
/* exported LocalStorageTools */
|
||||
/* globals BookWyrm */
|
||||
|
||||
// set javascript listeners
|
||||
function updateDisplay(e) {
|
||||
// used in set reading goal
|
||||
var key = e.target.getAttribute('data-id');
|
||||
var value = e.target.getAttribute('data-value');
|
||||
window.localStorage.setItem(key, value);
|
||||
let LocalStorageTools = new class {
|
||||
constructor() {
|
||||
document.querySelectorAll('[data-hide]')
|
||||
.forEach(t => this.setDisplay(t));
|
||||
|
||||
document.querySelectorAll('[data-hide="' + key + '"]')
|
||||
.forEach(t => setDisplay(t));
|
||||
}
|
||||
|
||||
function setDisplay(el) {
|
||||
// used in set reading goal
|
||||
var key = el.getAttribute('data-hide');
|
||||
var value = window.localStorage.getItem(key);
|
||||
addRemoveClass(el, 'hidden', value);
|
||||
document.querySelectorAll('.set-display')
|
||||
.forEach(t => t.addEventListener('click', this.updateDisplay.bind(this)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update localStorage, then display content based on keys in localStorage.
|
||||
*
|
||||
* @param {Event} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
updateDisplay(event) {
|
||||
// used in set reading goal
|
||||
let key = event.target.dataset.id;
|
||||
let value = event.target.dataset.value;
|
||||
|
||||
window.localStorage.setItem(key, value);
|
||||
|
||||
document.querySelectorAll('[data-hide="' + key + '"]')
|
||||
.forEach(node => this.setDisplay(node));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle display of a DOM node based on its value in the localStorage.
|
||||
*
|
||||
* @param {object} node - DOM node to toggle.
|
||||
* @return {undefined}
|
||||
*/
|
||||
setDisplay(node) {
|
||||
// used in set reading goal
|
||||
let key = node.dataset.hide;
|
||||
let value = window.localStorage.getItem(key);
|
||||
|
||||
BookWyrm.addRemoveClass(node, 'is-hidden', value);
|
||||
}
|
||||
}
|
||||
|
@ -1,169 +0,0 @@
|
||||
/* globals setDisplay TabGroup toggleAllCheckboxes updateDisplay */
|
||||
|
||||
// set up javascript listeners
|
||||
window.onload = function() {
|
||||
// buttons that display or hide content
|
||||
document.querySelectorAll('[data-controls]')
|
||||
.forEach(t => t.onclick = toggleAction);
|
||||
|
||||
// javascript interactions (boost/fav)
|
||||
Array.from(document.getElementsByClassName('interaction'))
|
||||
.forEach(t => t.onsubmit = interact);
|
||||
|
||||
// handle aria settings on menus
|
||||
Array.from(document.getElementsByClassName('pulldown-menu'))
|
||||
.forEach(t => t.onclick = toggleMenu);
|
||||
|
||||
// hidden submit button in a form
|
||||
document.querySelectorAll('.hidden-form input')
|
||||
.forEach(t => t.onchange = revealForm);
|
||||
|
||||
// polling
|
||||
document.querySelectorAll('[data-poll]')
|
||||
.forEach(el => polling(el));
|
||||
|
||||
// browser back behavior
|
||||
document.querySelectorAll('[data-back]')
|
||||
.forEach(t => t.onclick = back);
|
||||
|
||||
Array.from(document.getElementsByClassName('tab-group'))
|
||||
.forEach(t => new TabGroup(t));
|
||||
|
||||
// display based on localstorage vars
|
||||
document.querySelectorAll('[data-hide]')
|
||||
.forEach(t => setDisplay(t));
|
||||
|
||||
// update localstorage
|
||||
Array.from(document.getElementsByClassName('set-display'))
|
||||
.forEach(t => t.onclick = updateDisplay);
|
||||
|
||||
// Toggle all checkboxes.
|
||||
document
|
||||
.querySelectorAll('[data-action="toggle-all"]')
|
||||
.forEach(input => {
|
||||
input.addEventListener('change', toggleAllCheckboxes);
|
||||
});
|
||||
};
|
||||
|
||||
function back(e) {
|
||||
e.preventDefault();
|
||||
history.back();
|
||||
}
|
||||
|
||||
function polling(el, delay) {
|
||||
delay = delay || 10000;
|
||||
delay += (Math.random() * 1000);
|
||||
setTimeout(function() {
|
||||
fetch('/api/updates/' + el.getAttribute('data-poll'))
|
||||
.then(response => response.json())
|
||||
.then(data => updateCountElement(el, data));
|
||||
polling(el, delay * 1.25);
|
||||
}, delay, el);
|
||||
}
|
||||
|
||||
function updateCountElement(el, data) {
|
||||
const currentCount = el.innerText;
|
||||
const count = data.count;
|
||||
if (count != currentCount) {
|
||||
addRemoveClass(el.closest('[data-poll-wrapper]'), 'hidden', count < 1);
|
||||
el.innerText = count;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function revealForm(e) {
|
||||
var hidden = e.currentTarget.closest('.hidden-form').getElementsByClassName('hidden')[0];
|
||||
if (hidden) {
|
||||
removeClass(hidden, 'hidden');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function toggleAction(e) {
|
||||
var el = e.currentTarget;
|
||||
var pressed = el.getAttribute('aria-pressed') == 'false';
|
||||
|
||||
var targetId = el.getAttribute('data-controls');
|
||||
document.querySelectorAll('[data-controls="' + targetId + '"]')
|
||||
.forEach(t => t.setAttribute('aria-pressed', (t.getAttribute('aria-pressed') == 'false')));
|
||||
|
||||
if (targetId) {
|
||||
var target = document.getElementById(targetId);
|
||||
addRemoveClass(target, 'hidden', !pressed);
|
||||
addRemoveClass(target, 'is-active', pressed);
|
||||
}
|
||||
|
||||
// show/hide container
|
||||
var container = document.getElementById('hide-' + targetId);
|
||||
if (container) {
|
||||
addRemoveClass(container, 'hidden', pressed);
|
||||
}
|
||||
|
||||
// set checkbox, if appropriate
|
||||
var checkbox = el.getAttribute('data-controls-checkbox');
|
||||
if (checkbox) {
|
||||
document.getElementById(checkbox).checked = !!pressed;
|
||||
}
|
||||
|
||||
// set focus, if appropriate
|
||||
var focus = el.getAttribute('data-focus-target');
|
||||
if (focus) {
|
||||
var focusEl = document.getElementById(focus);
|
||||
focusEl.focus();
|
||||
setTimeout(function(){ focusEl.selectionStart = focusEl.selectionEnd = 10000; }, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function interact(e) {
|
||||
e.preventDefault();
|
||||
ajaxPost(e.target);
|
||||
var identifier = e.target.getAttribute('data-id');
|
||||
Array.from(document.getElementsByClassName(identifier))
|
||||
.forEach(t => addRemoveClass(t, 'hidden', t.className.indexOf('hidden') == -1));
|
||||
}
|
||||
|
||||
function toggleMenu(e) {
|
||||
var el = e.currentTarget;
|
||||
var expanded = el.getAttribute('aria-expanded') == 'false';
|
||||
el.setAttribute('aria-expanded', expanded);
|
||||
var targetId = el.getAttribute('data-controls');
|
||||
if (targetId) {
|
||||
var target = document.getElementById(targetId);
|
||||
addRemoveClass(target, 'is-active', expanded);
|
||||
}
|
||||
}
|
||||
|
||||
function ajaxPost(form) {
|
||||
fetch(form.action, {
|
||||
method : "POST",
|
||||
body: new FormData(form)
|
||||
});
|
||||
}
|
||||
|
||||
function addRemoveClass(el, classname, bool) {
|
||||
if (bool) {
|
||||
addClass(el, classname);
|
||||
} else {
|
||||
removeClass(el, classname);
|
||||
}
|
||||
}
|
||||
|
||||
function addClass(el, classname) {
|
||||
var classes = el.className.split(' ');
|
||||
if (classes.indexOf(classname) > -1) {
|
||||
return;
|
||||
}
|
||||
el.className = classes.concat(classname).join(' ');
|
||||
}
|
||||
|
||||
function removeClass(el, className) {
|
||||
var classes = [];
|
||||
if (el.className) {
|
||||
classes = el.className.split(' ');
|
||||
}
|
||||
const idx = classes.indexOf(className);
|
||||
if (idx > -1) {
|
||||
classes.splice(idx, 1);
|
||||
}
|
||||
el.className = classes.join(' ');
|
||||
}
|
@ -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)
|
||||
|
@ -6,24 +6,36 @@
|
||||
{% block title %}{{ book.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="block">
|
||||
{% with user_authenticated=request.user.is_authenticated can_edit_book=perms.bookwyrm.edit_book %}
|
||||
<div class="block" itemscope itemtype="https://schema.org/Book">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<h1 class="title">
|
||||
{{ book.title }}{% if book.subtitle %}:
|
||||
<small>{{ book.subtitle }}</small>{% endif %}
|
||||
<span itemprop="name">
|
||||
{{ book.title }}{% if book.subtitle %}:
|
||||
<small>{{ book.subtitle }}</small>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
{% if book.series %}
|
||||
<small class="has-text-grey-dark">({{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %})</small><br>
|
||||
<meta itemprop="isPartOf" content="{{ book.series }}">
|
||||
<meta itemprop="volumeNumber" content="{{ book.series_number }}">
|
||||
|
||||
<small class="has-text-grey-dark">
|
||||
({{ book.series }}
|
||||
{% if book.series_number %} #{{ book.series_number }}{% endif %})
|
||||
</small>
|
||||
<br>
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% if book.authors %}
|
||||
<h2 class="subtitle">
|
||||
{% trans "by" %} {% include 'snippets/authors.html' with book=book %}
|
||||
{% trans "by" %} {% include 'snippets/authors.html' with book=book %}
|
||||
</h2>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
||||
{% if user_authenticated and can_edit_book %}
|
||||
<div class="column is-narrow">
|
||||
<a href="{{ book.id }}/edit">
|
||||
<span class="icon icon-pencil" title="{% trans "Edit Book" %}">
|
||||
@ -44,7 +56,7 @@
|
||||
{% include 'snippets/shelve_button/shelve_button.html' %}
|
||||
</div>
|
||||
|
||||
{% if request.user.is_authenticated and not book.cover %}
|
||||
{% if user_authenticated and not book.cover %}
|
||||
<div class="block">
|
||||
{% trans "Add cover" as button_text %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="add-cover" controls_uid=book.id focus="modal-title-add-cover" class="is-small" %}
|
||||
@ -55,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>{{ 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>
|
||||
@ -89,18 +86,35 @@
|
||||
|
||||
<div class="column is-three-fifths">
|
||||
<div class="block">
|
||||
<h3 class="field is-grouped">
|
||||
<h3
|
||||
class="field is-grouped"
|
||||
itemprop="aggregateRating"
|
||||
itemscope
|
||||
itemtype="https://schema.org/AggregateRating"
|
||||
>
|
||||
<meta itemprop="ratingValue" content="{{ rating|floatformat }}">
|
||||
{# @todo Is it possible to not hard-code the value? #}
|
||||
<meta itemprop="bestRating" content="5">
|
||||
<meta itemprop="reviewCount" content="{{ review_count }}">
|
||||
|
||||
{% include 'snippets/stars.html' with rating=rating %}
|
||||
{% blocktrans count counter=review_count %}({{ review_count }} review){% plural %}({{ review_count }} reviews){% endblocktrans %}
|
||||
|
||||
{% blocktrans count counter=review_count trimmed %}
|
||||
({{ review_count }} review)
|
||||
{% plural %}
|
||||
({{ review_count }} reviews)
|
||||
{% endblocktrans %}
|
||||
</h3>
|
||||
|
||||
{% include 'snippets/trimmed_text.html' with full=book|book_description %}
|
||||
{% with full=book|book_description itemprop='abstract' %}
|
||||
{% include 'snippets/trimmed_text.html' %}
|
||||
{% endwith %}
|
||||
|
||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book and not book|book_description %}
|
||||
{% if user_authenticated and can_edit_book and not book|book_description %}
|
||||
{% trans 'Add Description' as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add-description" controls_uid=book.id focus="id_description" hide_active=True id="hide-description" %}
|
||||
|
||||
<div class="box hidden" id="add-description-{{ book.id }}">
|
||||
<div class="box is-hidden" id="add-description-{{ book.id }}">
|
||||
<form name="add-description" method="POST" action="/add-description/{{ book.id }}">
|
||||
{% csrf_token %}
|
||||
<p class="fields is-grouped">
|
||||
@ -138,7 +152,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if request.user.is_authenticated %}
|
||||
{% if user_authenticated %}
|
||||
<section class="block">
|
||||
<header class="columns">
|
||||
<h2 class="column title is-5 mb-1">{% trans "Your reading activity" %}</h2>
|
||||
@ -150,7 +164,7 @@
|
||||
{% if not readthroughs.exists %}
|
||||
<p>{% trans "You don't have any reading activity for this book." %}</p>
|
||||
{% endif %}
|
||||
<section class="hidden box" id="add-readthrough">
|
||||
<section class="is-hidden box" id="add-readthrough">
|
||||
<form name="add-readthrough" action="/create-readthrough" method="post">
|
||||
{% include 'snippets/readthrough_form.html' with readthrough=None %}
|
||||
<div class="field is-grouped">
|
||||
@ -176,14 +190,15 @@
|
||||
</div>
|
||||
<div class="column is-one-fifth">
|
||||
{% if book.subjects %}
|
||||
<section class="content block">
|
||||
<h2 class="title is-5">{% trans "Subjects" %}</h2>
|
||||
<ul>
|
||||
{% for subject in book.subjects %}
|
||||
<li>{{ subject }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
<section class="content block">
|
||||
<h2 class="title is-5">{% trans "Subjects" %}</h2>
|
||||
|
||||
<ul>
|
||||
{% for subject in book.subjects %}
|
||||
<li itemprop="about">{{ subject }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if book.subject_places %}
|
||||
@ -229,43 +244,85 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block" id="reviews">
|
||||
{% for review in reviews %}
|
||||
<div class="block">
|
||||
{% include 'snippets/status/status.html' with status=review hide_book=True depth=1 %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="block" id="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 %}
|
||||
|
||||
<div class="block is-flex is-flex-wrap-wrap">
|
||||
{% for rating in ratings %}
|
||||
<div class="block mr-5">
|
||||
<div class="media">
|
||||
<div class="media-left">{% include 'snippets/avatar.html' with user=rating.user %}</div>
|
||||
<div class="media-content">
|
||||
<div>
|
||||
<a href="{{ rating.user.local_path }}">{{ rating.user.display_name }}</a>
|
||||
</div>
|
||||
<div class="is-flex">
|
||||
<p class="mr-1">{% trans "rated it" %}</p>
|
||||
{% include 'snippets/stars.html' with rating=rating.rating %}
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ rating.remote_id }}">{{ rating.published_date | naturaltime }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% for review in statuses %}
|
||||
<div
|
||||
class="block"
|
||||
itemprop="review"
|
||||
itemscope
|
||||
itemtype="https://schema.org/Review"
|
||||
>
|
||||
{% with status=review hide_book=True depth=1 %}
|
||||
{% include 'snippets/status/status.html' %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="block">
|
||||
{% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %}
|
||||
|
||||
<div class="block is-flex is-flex-wrap-wrap">
|
||||
{% for rating in ratings %}
|
||||
{% with user=rating.user %}
|
||||
<div class="block mr-5">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
{% include 'snippets/avatar.html' %}
|
||||
</div>
|
||||
|
||||
<div class="media-content">
|
||||
<div>
|
||||
<a href="{{ user.local_path }}">{{ user.display_name }}</a>
|
||||
</div>
|
||||
<div class="is-flex">
|
||||
<p class="mr-1">{% trans "rated it" %}</p>
|
||||
|
||||
{% include 'snippets/stars.html' with rating=rating.rating %}
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ rating.remote_id }}">{{ rating.published_date | naturaltime }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="block">
|
||||
{% include 'snippets/pagination.html' with page=statuses path=request.path anchor="#reviews" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/tabs.js"></script>
|
||||
<script src="/static/js/vendor/tabs.js"></script>
|
||||
{% endblock %}
|
||||
|
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 %}
|
@ -88,12 +88,18 @@
|
||||
<div class="column is-half">
|
||||
<section class="block">
|
||||
<h2 class="title is-4">{% trans "Metadata" %}</h2>
|
||||
<p class="mb-2"><label class="label" for="id_title">{% trans "Title:" %}</label> {{ form.title }} </p>
|
||||
<p class="mb-2">
|
||||
<label class="label" for="id_title">{% trans "Title:" %}</label>
|
||||
<input type="text" name="title" value="{{ form.title.value|default:'' }}" maxlength="255" class="input" required="" id="id_title">
|
||||
</p>
|
||||
{% for error in form.title.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
<p class="mb-2"><label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label> {{ form.subtitle }} </p>
|
||||
<p class="mb-2">
|
||||
<label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label>
|
||||
<input type="text" name="subtitle" value="{{ form.subtitle.value|default:'' }}" maxlength="255" class="input" id="id_subtitle">
|
||||
</p>
|
||||
{% for error in form.subtitle.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
@ -103,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 %}
|
||||
@ -124,7 +133,7 @@
|
||||
|
||||
<p class="mb-2">
|
||||
<label class="label" for="id_first_published_date">{% trans "First published date:" %}</label>
|
||||
<input type="date" name="first_published_date" class="input" id="id_first_published_date"{% if book.first_published_date %} value="{{ book.first_published_date|date:'Y-m-d' }}"{% endif %}>
|
||||
<input type="date" name="first_published_date" class="input" id="id_first_published_date"{% if form.first_published_date.value %} value="{{ form.first_published_date.value|date:'Y-m-d' }}"{% endif %}>
|
||||
</p>
|
||||
{% for error in form.first_published_date.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
@ -132,7 +141,7 @@
|
||||
|
||||
<p class="mb-2">
|
||||
<label class="label" for="id_published_date">{% trans "Published date:" %}</label>
|
||||
<input type="date" name="published_date" class="input" id="id_published_date"{% if book.published_date %} value="{{ book.published_date|date:'Y-m-d' }}"{% endif %}>
|
||||
<input type="date" name="published_date" class="input" id="id_published_date"{% if form.published_date.value %} value="{{ form.published_date.value|date:'Y-m-d'}}"{% endif %}>
|
||||
</p>
|
||||
{% for error in form.published_date.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
|
@ -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,24 +1,70 @@
|
||||
{% spaceless %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
|
||||
<p>
|
||||
{% if book.physical_format and not book.pages %}
|
||||
{{ book.physical_format | title }}
|
||||
{% elif book.physical_format and book.pages %}
|
||||
{% blocktrans with format=book.physical_format|title pages=book.pages %}{{ format }}, {{ pages }} pages{% endblocktrans %}
|
||||
{% elif book.pages %}
|
||||
{% blocktrans with pages=book.pages %}{{ pages }} pages{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% with format=book.physical_format pages=book.pages %}
|
||||
{% if format %}
|
||||
{% comment %}
|
||||
@todo The bookFormat property is limited to a list of values whereas the book edition is free text.
|
||||
@see https://schema.org/bookFormat
|
||||
{% endcomment %}
|
||||
<meta itemprop="bookFormat" content="{{ format }}">
|
||||
{% endif %}
|
||||
|
||||
{% if pages %}
|
||||
<meta itemprop="numberOfPages" content="{{ pages }}">
|
||||
{% endif %}
|
||||
|
||||
{% if format and not pages %}
|
||||
{% blocktrans %}{{ format }}{% endblocktrans %}
|
||||
{% elif format and pages %}
|
||||
{% blocktrans %}{{ format }}, {{ pages }} pages{% endblocktrans %}
|
||||
{% elif pages %}
|
||||
{% blocktrans %}{{ pages }} pages{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</p>
|
||||
|
||||
{% if book.languages %}
|
||||
<p>
|
||||
{% blocktrans with languages=book.languages|join:", " %}{{ languages }} language{% endblocktrans %}
|
||||
</p>
|
||||
{% for language in book.languages %}
|
||||
<meta itemprop="inLanguage" content="{{ language }}">
|
||||
{% endfor %}
|
||||
|
||||
<p>
|
||||
{% with languages=book.languages|join:", " %}
|
||||
{% blocktrans %}{{ languages }} language{% endblocktrans %}
|
||||
{% endwith %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
{% if book.published_date and book.publishers %}
|
||||
{% blocktrans with date=book.published_date|date:'M jS Y' publisher=book.publishers|join:', ' %}Published {{ date }} by {{ publisher }}.{% endblocktrans %}
|
||||
{% elif book.published_date %}
|
||||
{% blocktrans with date=book.published_date|date:'M jS Y' %}Published {{ date }}{% endblocktrans %}
|
||||
{% elif book.publishers %}
|
||||
{% blocktrans with publisher=book.publishers|join:', ' %}Published by {{ publisher }}.{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% with date=book.published_date|naturalday publisher=book.publishers|join:', ' %}
|
||||
{% if date or book.first_published_date %}
|
||||
<meta
|
||||
itemprop="datePublished"
|
||||
content="{{ book.first_published_date|default:book.published_date|date:'Y-m-d' }}"
|
||||
>
|
||||
{% endif %}
|
||||
|
||||
{% comment %}
|
||||
@todo The publisher property needs to be an Organization or a Person. We’ll be using Thing which is the more generic ancestor.
|
||||
@see https://schema.org/Publisher
|
||||
{% endcomment %}
|
||||
{% if book.publishers %}
|
||||
{% for publisher in book.publishers %}
|
||||
<meta itemprop="publisher" content="{{ publisher }}">
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if date and publisher %}
|
||||
{% blocktrans %}Published {{ date }} by {{ publisher }}.{% endblocktrans %}
|
||||
{% elif date %}
|
||||
{% blocktrans %}Published {{ date }}{% endblocktrans %}
|
||||
{% elif publisher %}
|
||||
{% blocktrans %}Published by {{ publisher }}.{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</p>
|
||||
{% endspaceless %}
|
||||
|
@ -1,13 +1,34 @@
|
||||
{% spaceless %}
|
||||
{% load bookwyrm_tags %}
|
||||
|
||||
{% with 0|uuid as uuid %}
|
||||
<div class="dropdown control{% if right %} is-right{% endif %}" id="menu-{{ uuid }}">
|
||||
<button type="button" class="button dropdown-trigger pulldown-menu {{ class }}" aria-expanded="false" class="pulldown-menu" aria-haspopup="true" aria-controls="menu-options-{{ uuid }}" data-controls="menu-{{ uuid }}">
|
||||
<div
|
||||
id="menu-{{ uuid }}"
|
||||
class="
|
||||
dropdown control
|
||||
{% if right %}is-right{% endif %}
|
||||
"
|
||||
>
|
||||
<button
|
||||
class="button dropdown-trigger pulldown-menu {{ class }}"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-controls="menu-options-{{ uuid }}"
|
||||
data-controls="menu-{{ uuid }}"
|
||||
>
|
||||
{% block dropdown-trigger %}{% endblock %}
|
||||
</button>
|
||||
|
||||
<div class="dropdown-menu">
|
||||
<ul class="dropdown-content" role="menu" id="menu-options-{{ uuid }}">
|
||||
<ul
|
||||
id="menu-options-{{ uuid }}"
|
||||
class="dropdown-content p-0 is-clipped"
|
||||
role="menu"
|
||||
>
|
||||
{% block dropdown-list %}{% endblock %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endspaceless %}
|
||||
|
@ -1,5 +1,5 @@
|
||||
{% load i18n %}
|
||||
<section class="card hidden {{ class }}" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}">
|
||||
<section class="card is-hidden {{ class }}" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}">
|
||||
<header class="card-header has-background-white-ter">
|
||||
<h2 class="card-header-title" tabindex="0" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}-header">
|
||||
{% block header %}{% endblock %}
|
||||
|
@ -1,7 +1,7 @@
|
||||
{% load i18n %}
|
||||
<div
|
||||
role="dialog"
|
||||
class="modal hidden"
|
||||
class="modal is-hidden"
|
||||
id="{{ controls_text }}-{{ controls_uid }}"
|
||||
aria-labelledby="modal-card-title-{{ controls_text }}-{{ controls_uid }}"
|
||||
aria-modal="true"
|
||||
|
@ -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">
|
||||
|
@ -29,7 +29,7 @@
|
||||
|
||||
{# announcements and system messages #}
|
||||
{% if not activities.number > 1 %}
|
||||
<a href="{{ request.path }}" class="transition-y hidden notification is-primary is-block" data-poll-wrapper>
|
||||
<a href="{{ request.path }}" class="transition-y is-hidden notification is-primary is-block" data-poll-wrapper>
|
||||
{% blocktrans %}load <span data-poll="stream/{{ tab }}">0</span> unread status(es){% endblocktrans %}
|
||||
</a>
|
||||
|
||||
|
@ -104,5 +104,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/tabs.js"></script>
|
||||
<script src="/static/js/vendor/tabs.js"></script>
|
||||
{% endblock %}
|
||||
|
@ -20,7 +20,7 @@
|
||||
{% if user == request.user %}
|
||||
<div class="block">
|
||||
{% now 'Y' as year %}
|
||||
<section class="card {% if goal %}hidden{% endif %}" id="show-edit-goal">
|
||||
<section class="card {% if goal %}is-hidden{% endif %}" id="show-edit-goal">
|
||||
<header class="card-header">
|
||||
<h2 class="card-header-title has-background-primary has-text-white" tabindex="0" id="edit-form-header">
|
||||
<span class="icon icon-book is-size-3 mr-2" aria-hidden="true"></span> {% blocktrans %}{{ year }} Reading Goal{% endblocktrans %}
|
||||
|
@ -26,7 +26,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="id_csv_field">{% trans "Data file:" %}</label>
|
||||
<label class="label" for="id_csv_file">{% trans "Data file:" %}</label>
|
||||
{{ import_form.csv_file }}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,13 +1,13 @@
|
||||
{% 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">
|
||||
<link type="text/css" rel="stylesheet" href="/static/css/bulma.min.css">
|
||||
<link type="text/css" rel="stylesheet" href="/static/css/format.css">
|
||||
<link type="text/css" rel="stylesheet" href="/static/css/icons.css">
|
||||
<link rel="stylesheet" href="/static/css/vendor/bulma.min.css">
|
||||
<link rel="stylesheet" href="/static/css/vendor/icons.css">
|
||||
<link rel="stylesheet" href="/static/css/bookwyrm.css">
|
||||
|
||||
<link rel="shortcut icon" type="image/x-icon" href="{% if site.favicon %}/images/{{ site.favicon }}{% else %}/static/images/favicon.ico{% endif %}">
|
||||
|
||||
@ -22,165 +22,169 @@
|
||||
<meta name="twitter:image:alt" content="BookWyrm Logo">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar container" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item" href="/">
|
||||
<img class="image logo" src="{% if site.logo_small %}/images/{{ site.logo_small }}{% else %}/static/images/logo-small.png{% endif %}" alt="Home page">
|
||||
</a>
|
||||
<form class="navbar-item column" action="/search/">
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<input aria-label="{% trans 'Search for a book or user' %}" id="search-input" class="input" type="text" name="q" placeholder="{% trans 'Search for a book or user' %}" value="{{ query }}">
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button" type="submit">
|
||||
<span class="icon icon-search" title="{% trans 'Search' %}">
|
||||
<span class="is-sr-only">{% trans "Search" %}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div role="button" tabindex="0" class="navbar-burger pulldown-menu" data-controls="main-nav" aria-expanded="false">
|
||||
<div class="navbar-item mt-3">
|
||||
<div class="icon icon-dots-three-vertical" title="{% trans 'Main navigation menu' %}">
|
||||
<span class="is-sr-only">{% trans "Main navigation menu" %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-menu" id="main-nav">
|
||||
<div class="navbar-start">
|
||||
{% if request.user.is_authenticated %}
|
||||
<a href="{% url 'user-shelves' request.user.localname %}" class="navbar-item">
|
||||
{% trans "Your books" %}
|
||||
<nav class="navbar" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item" href="/">
|
||||
<img class="image logo" src="{% if site.logo_small %}/images/{{ site.logo_small }}{% else %}/static/images/logo-small.png{% endif %}" alt="Home page">
|
||||
</a>
|
||||
<a href="/#feed" class="navbar-item">
|
||||
{% trans "Feed" %}
|
||||
</a>
|
||||
<a href="{% url 'lists' %}" class="navbar-item">
|
||||
{% trans "Lists" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
{% if request.user.is_authenticated %}
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a
|
||||
href="{{ user.local_path }}"
|
||||
class="navbar-link pulldown-menu"
|
||||
role="button"
|
||||
aria-expanded="false"
|
||||
tabindex="0"
|
||||
aria-haspopup="true"
|
||||
aria-controls="navbar-dropdown"
|
||||
>
|
||||
{% include 'snippets/avatar.html' with user=request.user %}
|
||||
<span class="ml-2">{{ request.user.display_name }}</span>
|
||||
</a>
|
||||
<ul class="navbar-dropdown" id="navbar-dropdown">
|
||||
<li>
|
||||
<a href="{% url 'direct-messages' %}" class="navbar-item">
|
||||
{% trans "Direct Messages" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'directory' %}" class="navbar-item">
|
||||
{% trans 'Directory' %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/import" class="navbar-item">
|
||||
{% trans 'Import Books' %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/preferences/profile" class="navbar-item">
|
||||
{% trans 'Settings' %}
|
||||
</a>
|
||||
</li>
|
||||
{% if perms.bookwyrm.create_invites or perms.moderate_users %}
|
||||
<li class="navbar-divider" role="presentation"></li>
|
||||
{% endif %}
|
||||
{% if perms.bookwyrm.create_invites %}
|
||||
<li>
|
||||
<a href="{% url 'settings-invite-requests' %}" class="navbar-item">
|
||||
{% trans 'Invites' %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.bookwyrm.moderate_users %}
|
||||
<li>
|
||||
<a href="{% url 'settings-users' %}" class="navbar-item">
|
||||
{% trans 'Admin' %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="navbar-divider" role="presentation"></li>
|
||||
<li>
|
||||
<a href="/logout" class="navbar-item">
|
||||
{% trans 'Log out' %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-item">
|
||||
<a href="/notifications" class="tags has-addons">
|
||||
<span class="tag is-medium">
|
||||
<span class="icon icon-bell" title="{% trans 'Notifications' %}">
|
||||
<span class="is-sr-only">{% trans "Notifications" %}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="{% if not request.user|notification_count %}hidden {% endif %}tag is-danger is-medium transition-x" data-poll-wrapper>
|
||||
<span data-poll="notifications">{{ request.user | notification_count }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="navbar-item">
|
||||
{% if request.path != '/login' and request.path != '/login/' %}
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<form name="login" method="post" action="/login">
|
||||
{% csrf_token %}
|
||||
<div class="columns is-variable is-1">
|
||||
<div class="column">
|
||||
<label class="is-sr-only" for="id_localname">{% trans "Username:" %}</label>
|
||||
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname" placeholder="{% trans 'username' %}">
|
||||
</div>
|
||||
<div class="column">
|
||||
<label class="is-sr-only" for="id_password">{% trans "Username:" %}</label>
|
||||
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="{% trans 'password' %}">
|
||||
<p class="help"><a href="/password-reset">{% trans "Forgot your password?" %}</a></p>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<button class="button is-primary" type="submit">{% trans "Log in" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form class="navbar-item column" action="/search/">
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<input aria-label="{% trans 'Search for a book or user' %}" id="search-input" class="input" type="text" name="q" placeholder="{% trans 'Search for a book or user' %}" value="{{ query }}">
|
||||
</div>
|
||||
{% if site.allow_registration and request.path != '' and request.path != '/' %}
|
||||
<div class="column is-narrow">
|
||||
<a href="/" class="button is-link">
|
||||
{% trans "Join" %}
|
||||
</a>
|
||||
<div class="control">
|
||||
<button class="button" type="submit">
|
||||
<span class="icon icon-search" title="{% trans 'Search' %}">
|
||||
<span class="is-sr-only">{% trans "Search" %}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div role="button" tabindex="0" class="navbar-burger pulldown-menu" data-controls="main-nav" aria-expanded="false">
|
||||
<div class="navbar-item mt-3">
|
||||
<div class="icon icon-dots-three-vertical" title="{% trans 'Main navigation menu' %}">
|
||||
<span class="is-sr-only">{% trans "Main navigation menu" %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-menu" id="main-nav">
|
||||
<div class="navbar-start">
|
||||
{% if request.user.is_authenticated %}
|
||||
<a href="{% url 'user-shelves' request.user.localname %}" class="navbar-item">
|
||||
{% trans "Your books" %}
|
||||
</a>
|
||||
<a href="/#feed" class="navbar-item">
|
||||
{% trans "Feed" %}
|
||||
</a>
|
||||
<a href="{% url 'lists' %}" class="navbar-item">
|
||||
{% trans "Lists" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
{% if request.user.is_authenticated %}
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a
|
||||
href="{{ request.user.local_path }}"
|
||||
class="navbar-link pulldown-menu"
|
||||
role="button"
|
||||
aria-expanded="false"
|
||||
tabindex="0"
|
||||
aria-haspopup="true"
|
||||
aria-controls="navbar-dropdown"
|
||||
>
|
||||
{% include 'snippets/avatar.html' with user=request.user %}
|
||||
<span class="ml-2">{{ request.user.display_name }}</span>
|
||||
</a>
|
||||
<ul class="navbar-dropdown" id="navbar-dropdown">
|
||||
<li>
|
||||
<a href="{% url 'direct-messages' %}" class="navbar-item">
|
||||
{% trans "Direct Messages" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'directory' %}" class="navbar-item">
|
||||
{% trans 'Directory' %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/import" class="navbar-item">
|
||||
{% trans 'Import Books' %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/preferences/profile" class="navbar-item">
|
||||
{% trans 'Settings' %}
|
||||
</a>
|
||||
</li>
|
||||
{% if perms.bookwyrm.create_invites or perms.moderate_users %}
|
||||
<li class="navbar-divider" role="presentation"></li>
|
||||
{% endif %}
|
||||
{% if perms.bookwyrm.create_invites %}
|
||||
<li>
|
||||
<a href="{% url 'settings-invite-requests' %}" class="navbar-item">
|
||||
{% trans 'Invites' %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.bookwyrm.moderate_users %}
|
||||
<li>
|
||||
<a href="{% url 'settings-users' %}" class="navbar-item">
|
||||
{% trans 'Admin' %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="navbar-divider" role="presentation"></li>
|
||||
<li>
|
||||
<a href="/logout" class="navbar-item">
|
||||
{% trans 'Log out' %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-item">
|
||||
<a href="/notifications" class="tags has-addons">
|
||||
<span class="tag is-medium">
|
||||
<span class="icon icon-bell" title="{% trans 'Notifications' %}">
|
||||
<span class="is-sr-only">{% trans "Notifications" %}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="{% if not request.user|notification_count %}is-hidden {% endif %}tag is-danger is-medium transition-x" data-poll-wrapper>
|
||||
<span data-poll="notifications">{{ request.user | notification_count }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="navbar-item">
|
||||
{% if request.path != '/login' and request.path != '/login/' %}
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<form name="login" method="post" action="/login">
|
||||
{% csrf_token %}
|
||||
<div class="columns is-variable is-1">
|
||||
<div class="column">
|
||||
<label class="is-sr-only" for="id_localname">{% trans "Username:" %}</label>
|
||||
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname" placeholder="{% trans 'username' %}">
|
||||
</div>
|
||||
<div class="column">
|
||||
<label class="is-sr-only" for="id_password">{% trans "Username:" %}</label>
|
||||
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="{% trans 'password' %}">
|
||||
<p class="help"><a href="/password-reset">{% trans "Forgot your password?" %}</a></p>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<button class="button is-primary" type="submit">{% trans "Log in" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% if site.allow_registration and request.path != '' and request.path != '/' %}
|
||||
<div class="column is-narrow">
|
||||
<a href="/" class="button is-link">
|
||||
{% trans "Join" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
<div class="section container">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
<div class="section is-flex-grow-1">
|
||||
<div class="container">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
@ -212,7 +216,7 @@
|
||||
<script>
|
||||
var csrf_token = '{{ csrf_token }}';
|
||||
</script>
|
||||
<script src="/static/js/shared.js"></script>
|
||||
<script src="/static/js/bookwyrm.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
@ -13,10 +13,20 @@
|
||||
|
||||
<div class="columns mt-3">
|
||||
<section class="column is-three-quarters">
|
||||
{% if not items.exists %}
|
||||
{% if request.GET.updated %}
|
||||
<div class="notification is-primary">
|
||||
{% if list.curation != "open" and request.user != list.user %}
|
||||
{% trans "You successfully suggested a book for this list!" %}
|
||||
{% else %}
|
||||
{% trans "You successfully added a book to this list!" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% 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 +40,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 +73,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">
|
||||
@ -83,7 +126,7 @@
|
||||
</div>
|
||||
<div class="column">
|
||||
<p>{% include 'snippets/book_titleby.html' with book=book %}</p>
|
||||
<form name="add-book" method="post" action="{% url 'list-add-book' %}">
|
||||
<form name="add-book" method="post" action="{% url 'list-add-book' %}{% if query %}?q={{ query }}{% endif %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="book" value="{{ book.id }}">
|
||||
<input type="hidden" name="list" value="{{ list.id }}">
|
||||
@ -93,7 +136,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -15,10 +15,12 @@
|
||||
{% endif %}
|
||||
</h1>
|
||||
</div>
|
||||
{% if request.user.is_authenticated %}
|
||||
<div class="column is-narrow">
|
||||
{% trans "Create List" as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with controls_text="create-list" icon="plus" text=button_text focus="create-list-header" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<div class="block">
|
||||
|
@ -1,5 +1,6 @@
|
||||
{% extends 'settings/admin_layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block title %}{% blocktrans with report_id=report.id username=report.user.username %}Report #{{ report_id }}: {{ username }}{% endblocktrans %}{% endblock %}
|
||||
@ -14,23 +15,9 @@
|
||||
{% include 'moderation/report_preview.html' with report=report %}
|
||||
</div>
|
||||
|
||||
<div class="block content">
|
||||
<h3>{% trans "Actions" %}</h3>
|
||||
<p><a href="{{ report.user.local_path }}">{% trans "View user profile" %}</a></p>
|
||||
<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_info.html' with user=report.user %}
|
||||
|
||||
{% include 'user_admin/user_moderation_actions.html' with user=report.user %}
|
||||
|
||||
<div class="block">
|
||||
<h3 class="title is-4">{% trans "Moderator Comments" %}</h3>
|
||||
@ -64,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 %}
|
||||
|
@ -15,7 +15,9 @@
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="reporter" value="{{ reporter.id }}">
|
||||
<input type="hidden" name="user" value="{{ user.id }}">
|
||||
{% if status %}
|
||||
<input type="hidden" name="statuses" value="{{ status.id }}">
|
||||
{% endif %}
|
||||
|
||||
<section class="content">
|
||||
<p>{% blocktrans with site_name=site.name %}This report will be sent to {{ site_name }}'s moderators for review.{% endblocktrans %}</p>
|
||||
|
@ -8,6 +8,7 @@
|
||||
{% trans "Reports" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
{% if server %}
|
||||
{% blocktrans with server_name=server.server_name %}Reports: <small>{{ server_name }}</small>{% endblocktrans %}
|
||||
@ -29,6 +30,8 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% include 'user_admin/user_admin_filters.html' %}
|
||||
|
||||
<div class="block">
|
||||
{% if not reports %}
|
||||
<em>{% trans "No reports found." %}</em>
|
||||
|
@ -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>
|
||||
|
@ -37,7 +37,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="{% if local_results.results %}hidden{% endif %}" id="more-results">
|
||||
<div class="{% if local_results.results %}is-hidden{% endif %}" id="more-results">
|
||||
{% for result_set in book_results|slice:"1:" %}
|
||||
{% if result_set.results %}
|
||||
<section class="block">
|
||||
|
@ -6,7 +6,14 @@
|
||||
{% block content %}
|
||||
|
||||
<header class="block column is-offset-one-quarter pl-1">
|
||||
<h1 class="title">{% block header %}{% endblock %}</h1>
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<h1 class="title">{% block header %}{% endblock %}</h1>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
{% block edit-button %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="block columns">
|
||||
|
71
bookwyrm/templates/settings/edit_server.html
Normal file
71
bookwyrm/templates/settings/edit_server.html
Normal file
@ -0,0 +1,71 @@
|
||||
{% extends 'settings/admin_layout.html' %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Add server" %}{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
{% trans "Add server" %}
|
||||
<a href="{% url 'settings-federation' %}" class="has-text-weight-normal help">{% trans "Back to server list" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
|
||||
<div class="tabs">
|
||||
<ul>
|
||||
{% url 'settings-import-blocklist' as url %}
|
||||
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
|
||||
<a href="{{ url }}">{% trans "Import block list" %}</a>
|
||||
</li>
|
||||
{% url 'settings-add-federated-server' as url %}
|
||||
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
|
||||
<a href="{{ url }}">{% trans "Add server" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{% url 'settings-add-federated-server' %}">
|
||||
{% csrf_token %}
|
||||
<div class="columns">
|
||||
<div class="column is-half">
|
||||
<div>
|
||||
<label class="label" for="id_server_name">{% trans "Instance:" %}</label>
|
||||
<input type="text" name="server_name" maxlength="255" class="input" required id="id_server_name" value="{{ form.server_name.value|default:'' }}" placeholder="domain.com">
|
||||
{% for error in form.server_name.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="id_status">{% trans "Status:" %}</label>
|
||||
<div class="select">
|
||||
<select name="status" class="" id="id_status">
|
||||
<option value="federated" {% if form.status.value == "federated" %}selected=""{% endif %}>{% trans "Federated" %}</option>
|
||||
<option value="blocked" {% if form.status.value == "blocked" or not form.status.value %}selected{% endif %}>{% trans "Blocked" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<div>
|
||||
<label class="label" for="id_application_type">{% trans "Software:" %}</label>
|
||||
<input type="text" name="application_type" maxlength="255" class="input" id="id_application_type" value="{{ form.application_type.value|default:'' }}">
|
||||
{% for error in form.application_type.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="id_application_version">{% trans "Version:" %}</label>
|
||||
<input type="text" name="application_version" maxlength="255" class="input" id="id_application_version" value="{{ form.application_version.value|default:'' }}">
|
||||
{% for error in form.application_version.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
<label class="label" for="id_notes">{% trans "Notes:" %}</label>
|
||||
<textarea name="notes" cols="None" rows="None" class="textarea" id="id_notes">{{ form.notes.value|default:'' }}</textarea>
|
||||
</p>
|
||||
|
||||
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
@ -1,67 +1,116 @@
|
||||
{% extends 'settings/admin_layout.html' %}
|
||||
{% block title %}{{ server.server_name }}{% endblock %}
|
||||
{% load i18n %}
|
||||
{% load bookwyrm_tags %}
|
||||
|
||||
{% block header %}
|
||||
{{ server.server_name }}
|
||||
|
||||
{% if server.status == "blocked" %}<span class="icon icon-x has-text-danger is-size-5" title="{% trans 'Blocked' %}"><span class="is-sr-only">{% trans "Blocked" %}</span></span>
|
||||
{% endif %}
|
||||
|
||||
<a href="{% url 'settings-federation' %}" class="has-text-weight-normal help">{% trans "Back to server list" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="columns">
|
||||
<section class="column is-half content">
|
||||
<h2 class="title is-4">{% trans "Details" %}</h2>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="column is-half content">
|
||||
<h2 class="title is-4">{% trans "Activity" %}</h2>
|
||||
<dl>
|
||||
<div class="is-flex">
|
||||
<dt>{% trans "Users:" %}</dt>
|
||||
<dd>
|
||||
{{ users.count }}
|
||||
{% if server.user_set.count %}(<a href="{% url 'settings-users' %}?server={{ server.server_name }}">{% trans "View all" %}</a>){% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="is-flex">
|
||||
<dt>{% trans "Reports:" %}</dt>
|
||||
<dd>
|
||||
{{ reports.count }}
|
||||
{% if reports.count %}(<a href="{% url 'settings-reports' %}?server={{ server.server_name }}">{% trans "View all" %}</a>){% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="is-flex">
|
||||
<dt>{% trans "Followed by us:" %}</dt>
|
||||
<dd>
|
||||
{{ followed_by_us.count }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="is-flex">
|
||||
<dt>{% trans "Followed by them:" %}</dt>
|
||||
<dd>
|
||||
{{ followed_by_them.count }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="is-flex">
|
||||
<dt>{% trans "Blocked by us:" %}</dt>
|
||||
<dd>
|
||||
{{ blocked_by_us.count }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="block content">
|
||||
<h2 class="title is-4">{% trans "Details" %}</h2>
|
||||
<dl>
|
||||
<div class="is-flex">
|
||||
<dt>{% trans "Software:" %}</dt>
|
||||
<dd>{{ server.application_type }}</dd>
|
||||
<header class="columns is-mobile">
|
||||
<div class="column">
|
||||
<h2 class="title is-4 mb-0">{% trans "Notes" %}</h2>
|
||||
</div>
|
||||
<div class="is-flex">
|
||||
<dt>{% trans "Version:" %}</dt>
|
||||
<dd>{{ server.application_version }}</dd>
|
||||
<div class="column is-narrow">
|
||||
{% trans "Edit" as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text icon="pencil" controls_text="edit-notes" %}
|
||||
</div>
|
||||
<div class="is-flex">
|
||||
<dt>{% trans "Status:" %}</dt>
|
||||
<dd>Federated</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
{% if server.notes %}
|
||||
<p id="hide-edit-notes">{{ server.notes|to_markdown|safe }}</p>
|
||||
{% endif %}
|
||||
<form class="box is-hidden" method="POST" action="{% url 'settings-federated-server' server.id %}" id="edit-notes">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
<label class="is-sr-only" for="id_notes">Notes:</label>
|
||||
<textarea name="notes" cols="None" rows="None" class="textarea" id="id_notes">{{ server.notes|default:"" }}</textarea>
|
||||
</p>
|
||||
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
|
||||
{% trans "Cancel" as button_text %}
|
||||
{% include 'snippets/toggle/close_button.html' with text=button_text controls_text="edit-notes" %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="block content">
|
||||
<h2 class="title is-4">{% trans "Activity" %}</h2>
|
||||
<dl>
|
||||
<div class="is-flex">
|
||||
<dt>{% trans "Users:" %}</dt>
|
||||
<dd>
|
||||
{{ users.count }}
|
||||
{% if server.user_set.count %}(<a href="{% url 'settings-users' %}?server={{ server.id }}">{% trans "View all" %}</a>){% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="is-flex">
|
||||
<dt>{% trans "Reports:" %}</dt>
|
||||
<dd>
|
||||
{{ reports.count }}
|
||||
{% if reports.count %}(<a href="{% url 'settings-reports' %}?server={{ server.id }}">{% trans "View all" %}</a>){% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="is-flex">
|
||||
<dt>{% trans "Followed by us:" %}</dt>
|
||||
<dd>
|
||||
{{ followed_by_us.count }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="is-flex">
|
||||
<dt>{% trans "Followed by them:" %}</dt>
|
||||
<dd>
|
||||
{{ followed_by_them.count }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="is-flex">
|
||||
<dt>{% trans "Blocked by us:" %}</dt>
|
||||
<dd>
|
||||
{{ blocked_by_us.count }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<h2 class="title is-4">{% trans "Actions" %}</h2>
|
||||
{% if server.status != 'blocked' %}
|
||||
<form class="block" method="post" action="{% url 'settings-federated-server-block' server.id %}">
|
||||
{% csrf_token %}
|
||||
<button class="button is-danger">{% trans "Block" %}</button>
|
||||
<p class="help">{% trans "All users from this instance will be deactivated." %}</p>
|
||||
</form>
|
||||
{% else %}
|
||||
<form class="block" method="post" action="{% url 'settings-federated-server-unblock' server.id %}">
|
||||
{% csrf_token %}
|
||||
<button class="button">{% trans "Un-block" %}</button>
|
||||
<p class="help">{% trans "All users from this instance will be re-activated." %}</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
|
@ -4,8 +4,15 @@
|
||||
|
||||
{% block header %}{% trans "Federated Servers" %}{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
{% block edit-button %}
|
||||
<a href="{% url 'settings-import-blocklist' %}">
|
||||
<span class="icon icon-plus" title="{% trans 'Add server' %}">
|
||||
<span class="is-sr-only">{% trans "Add server" %}</span>
|
||||
</span>
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
<table class="table is-striped">
|
||||
<tr>
|
||||
{% url 'settings-federation' as url %}
|
||||
|
67
bookwyrm/templates/settings/server_blocklist.html
Normal file
67
bookwyrm/templates/settings/server_blocklist.html
Normal file
@ -0,0 +1,67 @@
|
||||
{% extends 'settings/admin_layout.html' %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Add server" %}{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
{% trans "Import Blocklist" %}
|
||||
<a href="{% url 'settings-federation' %}" class="has-text-weight-normal help">{% trans "Back to server list" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
|
||||
<div class="tabs">
|
||||
<ul>
|
||||
{% url 'settings-import-blocklist' as url %}
|
||||
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
|
||||
<a href="{{ url }}">{% trans "Import block list" %}</a>
|
||||
</li>
|
||||
{% url 'settings-add-federated-server' as url %}
|
||||
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
|
||||
<a href="{{ url }}">{% trans "Add server" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% if succeeded and not failed %}
|
||||
<p class="notification is-primary">{% trans "Success!" %}</p>
|
||||
{% elif succeeded or failed %}
|
||||
<div class="block content">
|
||||
{% if succeeded %}
|
||||
<p>{% trans "Successfully blocked:" %} {{ succeeded }}</p>
|
||||
{% endif %}
|
||||
<p>{% trans "Failed:" %}</p>
|
||||
<ul>
|
||||
{% for item in failed %}
|
||||
<li>
|
||||
<pre>
|
||||
{{ item }}
|
||||
</pre>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{% url 'settings-import-blocklist' %}" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label" for="id_file">JSON data:</label>
|
||||
<aside class="help">
|
||||
Expects a json file in the format provided by <a href="https://fediblock.org/" target="_blank" rel=”noopener”>FediBlock</a>, with a list of entries that have <code>instance</code> and <code>url</code> fields. For example:
|
||||
<pre>
|
||||
[
|
||||
{
|
||||
"instance": "example.server.com",
|
||||
"url": "https://link.to.more/info"
|
||||
},
|
||||
...
|
||||
]
|
||||
</pre>
|
||||
</aside>
|
||||
<input type="file" name="json_file" required="" id="id_file">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button is-primary">{% trans "Import" %}</button>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
@ -1 +1,17 @@
|
||||
{% for author in book.authors.all %}<a href="/author/{{ author.id }}" class="author">{{ author.name }}</a>{% if not forloop.last %}, {% endif %}{% endfor %}
|
||||
{% spaceless %}
|
||||
{% comment %}
|
||||
@todo The author property needs to be an Organization or a Person. We’ll be using Thing which is the more generic ancestor.
|
||||
@see https://schema.org/Author
|
||||
{% endcomment %}
|
||||
{% for author in book.authors.all %}
|
||||
<a
|
||||
href="/author/{{ author.id }}"
|
||||
class="author"
|
||||
itemprop="author"
|
||||
itemscope
|
||||
itemtype="https://schema.org/Thing"
|
||||
><span
|
||||
itemprop="name"
|
||||
>{{ author.name }}<span></a>{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% endspaceless %}
|
||||
|
@ -1,13 +1,29 @@
|
||||
{% spaceless %}
|
||||
|
||||
{% load bookwyrm_tags %}
|
||||
{% load i18n %}
|
||||
|
||||
<div class="cover-container is-{{ size }}">
|
||||
{% if book.cover %}
|
||||
<img class="book-cover" src="/images/{{ book.cover }}" alt="{{ book.alt_text }}" title="{{ book.alt_text }}">
|
||||
{% else %}
|
||||
<div class="no-cover book-cover">
|
||||
<img class="book-cover" src="/static/images/no_cover.jpg" alt="No cover">
|
||||
<div>
|
||||
<p>{{ book.alt_text }}</p>
|
||||
{% if book.cover %}
|
||||
<img
|
||||
class="book-cover"
|
||||
src="/images/{{ book.cover }}"
|
||||
alt="{{ book.alt_text }}"
|
||||
title="{{ book.alt_text }}"
|
||||
itemprop="thumbnailUrl"
|
||||
>
|
||||
{% else %}
|
||||
<div class="no-cover book-cover">
|
||||
<img
|
||||
class="book-cover"
|
||||
src="/static/images/no_cover.jpg"
|
||||
alt="{% trans "No cover" %}"
|
||||
>
|
||||
|
||||
<div>
|
||||
<p>{{ book.alt_text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endspaceless %}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user