Merge branch 'main' into logo-default

This commit is contained in:
Mouse Reeve 2021-01-04 12:18:40 -08:00
commit 7cc2dfe517
151 changed files with 5866 additions and 2043 deletions

2
.github/FUNDING.yml vendored
View file

@ -1,7 +1,7 @@
# These are supported funding model platforms # These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: bookwrym patreon: bookwyrm
open_collective: # Replace with a single Open Collective username open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel

View file

@ -60,8 +60,6 @@ cp .env.example .env
For most testing, you'll want to use ngrok. Remember to set the DOMAIN in `.env` to your ngrok domain. For most testing, you'll want to use ngrok. Remember to set the DOMAIN in `.env` to your ngrok domain.
#### With Docker
You'll have to install the Docker and docker-compose. When you're ready, run: You'll have to install the Docker and docker-compose. When you're ready, run:
```bash ```bash
@ -70,33 +68,7 @@ docker-compose run --rm web python manage.py migrate
docker-compose run --rm web python manage.py initdb docker-compose run --rm web python manage.py initdb
``` ```
### Without Docker Once the build is complete, you can access the instance at `localhost:1333`
You will need postgres installed and running on your computer.
``` bash
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
createdb bookwyrm
```
Create the psql user in `psql bookwyrm`:
``` psql
CREATE ROLE bookwyrm WITH LOGIN PASSWORD 'bookwyrm';
GRANT ALL PRIVILEGES ON DATABASE bookwyrm TO bookwyrm;
```
Initialize the database (or, more specifically, delete the existing database, run migrations, and start fresh):
``` bash
./rebuilddb.sh
```
This creates two users, `mouse` with password `password123` and `rat` with password `ratword`.
The application uses Celery and Redis for task management, which must also be installed and configured.
And go to the app at `localhost:8000`
## Installing in Production ## Installing in Production

View file

@ -11,6 +11,7 @@ from .note import Tombstone
from .interaction import Boost, Like from .interaction import Boost, Like
from .ordered_collection import OrderedCollection, OrderedCollectionPage from .ordered_collection import OrderedCollection, OrderedCollectionPage
from .person import Person, PublicKey from .person import Person, PublicKey
from .response import ActivitypubResponse
from .book import Edition, Work, Author from .book import Edition, Work, Author
from .verbs import Create, Delete, Undo, Update from .verbs import Create, Delete, Undo, Update
from .verbs import Follow, Accept, Reject from .verbs import Follow, Accept, Reject

View file

@ -3,9 +3,7 @@ from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder from json import JSONEncoder
from django.apps import apps from django.apps import apps
from django.db import transaction from django.db import IntegrityError, transaction
from django.db.models.fields.files import ImageFileDescriptor
from django.db.models.fields.related_descriptors import ManyToManyDescriptor
from bookwyrm.connectors import ConnectorException, get_data from bookwyrm.connectors import ConnectorException, get_data
from bookwyrm.tasks import app from bookwyrm.tasks import app
@ -65,7 +63,6 @@ class ActivityObject:
setattr(self, field.name, value) setattr(self, field.name, value)
@transaction.atomic
def to_model(self, model, instance=None, save=True): def to_model(self, model, instance=None, save=True):
''' convert from an activity to a model instance ''' ''' convert from an activity to a model instance '''
if not isinstance(self, model.activity_serializer): if not isinstance(self, model.activity_serializer):
@ -76,74 +73,54 @@ class ActivityObject:
model.activity_serializer) model.activity_serializer)
) )
if hasattr(model, 'ignore_activity') and model.ignore_activity(self):
return instance
# check for an existing instance, if we're not updating a known obj # check for an existing instance, if we're not updating a known obj
if not instance: instance = instance or model.find_existing(self.serialize()) or model()
instance = model.find_existing(self.serialize()) or model()
many_to_many_fields = {} for field in instance.simple_fields:
image_fields = {} field.set_field_from_activity(instance, self)
for field in model._meta.get_fields():
# check if it's an activitypub field
if not hasattr(field, 'field_to_activity'):
continue
# call the formatter associated with the model field class
value = field.field_from_activity(
getattr(self, field.get_activitypub_field())
)
if value is None or value is MISSING:
continue
model_field = getattr(model, field.name) # image fields have to be set after other fields because they can save
# too early and jank up users
if isinstance(model_field, ManyToManyDescriptor): for field in instance.image_fields:
# status mentions book/users for example, stash this for later field.set_field_from_activity(instance, self, save=save)
many_to_many_fields[field.name] = value
elif isinstance(model_field, ImageFileDescriptor):
# image fields need custom handling
image_fields[field.name] = value
else:
# just a good old fashioned model.field = value
setattr(instance, field.name, value)
# if this isn't here, it messes up saving users. who even knows.
for (model_key, value) in image_fields.items():
getattr(instance, model_key).save(*value, save=save)
if not save: if not save:
return instance
with transaction.atomic():
# we can't set many to many and reverse fields on an unsaved object # we can't set many to many and reverse fields on an unsaved object
return instance try:
instance.save()
except IntegrityError as e:
raise ActivitySerializerError(e)
instance.save() # add many to many fields, which have to be set post-save
for field in instance.many_to_many_fields:
# add many to many fields, which have to be set post-save # mention books/users, for example
for (model_key, values) in many_to_many_fields.items(): field.set_field_from_activity(instance, self)
# mention books/users, for example
getattr(instance, model_key).set(values)
if not save or not hasattr(model, 'deserialize_reverse_fields'):
return instance
# reversed relationships in the models # reversed relationships in the models
for (model_field_name, activity_field_name) in \ for (model_field_name, activity_field_name) in \
model.deserialize_reverse_fields: instance.deserialize_reverse_fields:
# attachments on Status, for example # attachments on Status, for example
values = getattr(self, activity_field_name) values = getattr(self, activity_field_name)
if values is None or values is MISSING: if values is None or values is MISSING:
continue continue
try:
# this is for one to many model_field = getattr(model, model_field_name)
related_model = getattr(model, model_field_name).field.model # creating a Work, model_field is 'editions'
except AttributeError: # creating a User, model field is 'key_pair'
# it's a one to one or foreign key related_model = model_field.field.model
related_model = getattr(model, model_field_name)\ related_field_name = model_field.field.name
.related.related_model
values = [values]
for item in values: for item in values:
set_related_field.delay( set_related_field.delay(
related_model.__name__, related_model.__name__,
instance.__class__.__name__, instance.__class__.__name__,
instance.__class__.__name__.lower(), related_field_name,
instance.remote_id, instance.remote_id,
item item
) )
@ -160,8 +137,8 @@ class ActivityObject:
@app.task @app.task
@transaction.atomic @transaction.atomic
def set_related_field( def set_related_field(
model_name, origin_model_name, model_name, origin_model_name, related_field_name,
related_field_name, related_remote_id, data): related_remote_id, data):
''' load reverse related fields (editions, attachments) without blocking ''' ''' load reverse related fields (editions, attachments) without blocking '''
model = apps.get_model('bookwyrm.%s' % model_name, require_ready=True) model = apps.get_model('bookwyrm.%s' % model_name, require_ready=True)
origin_model = apps.get_model( origin_model = apps.get_model(
@ -169,23 +146,38 @@ def set_related_field(
require_ready=True require_ready=True
) )
if isinstance(data, str): with transaction.atomic():
item = resolve_remote_id(model, data, save=False) if isinstance(data, str):
else: existing = model.find_existing_by_remote_id(data)
# look for a match based on all the available data if existing:
item = model.find_existing(data) data = existing.to_activity()
if not item: else:
# create a new model instance data = get_data(data)
item = model.activity_serializer(**data) activity = model.activity_serializer(**data)
item = item.to_model(model, save=False)
# this must exist because it's the object that triggered this function
instance = origin_model.find_existing_by_remote_id(related_remote_id)
if not instance:
raise ValueError('Invalid related remote id: %s' % related_remote_id)
# edition.parent_work = instance, for example # this must exist because it's the object that triggered this function
setattr(item, related_field_name, instance) instance = origin_model.find_existing_by_remote_id(related_remote_id)
item.save() if not instance:
raise ValueError(
'Invalid related remote id: %s' % related_remote_id)
# set the origin's remote id on the activity so it will be there when
# the model instance is created
# edition.parentWork = instance, for example
model_field = getattr(model, related_field_name)
if hasattr(model_field, 'activitypub_field'):
setattr(
activity,
getattr(model_field, 'activitypub_field'),
instance.remote_id
)
item = activity.to_model(model)
# if the related field isn't serialized (attachments on Status), then
# we have to set it post-creation
if not hasattr(model_field, 'activitypub_field'):
setattr(item, related_field_name, instance)
item.save()
def resolve_remote_id(model, remote_id, refresh=False, save=True): def resolve_remote_id(model, remote_id, refresh=False, save=True):

View file

@ -38,7 +38,7 @@ class Edition(Book):
isbn13: str = '' isbn13: str = ''
oclcNumber: str = '' oclcNumber: str = ''
asin: str = '' asin: str = ''
pages: str = '' pages: int = None
physicalFormat: str = '' physicalFormat: str = ''
publishers: List[str] = field(default_factory=lambda: []) publishers: List[str] = field(default_factory=lambda: [])
@ -50,7 +50,7 @@ class Work(Book):
''' work instance of a book object ''' ''' work instance of a book object '''
lccn: str = '' lccn: str = ''
defaultEdition: str = '' defaultEdition: str = ''
editions: List[str] editions: List[str] = field(default_factory=lambda: [])
type: str = 'Work' type: str = 'Work'
@ -58,10 +58,12 @@ class Work(Book):
class Author(ActivityObject): class Author(ActivityObject):
''' author of a book ''' ''' author of a book '''
name: str name: str
born: str = '' born: str = None
died: str = '' died: str = None
aliases: str = '' aliases: List[str] = field(default_factory=lambda: [])
bio: str = '' bio: str = ''
openlibraryKey: str = '' openlibraryKey: str = ''
librarythingKey: str = ''
goodreadsKey: str = ''
wikipediaLink: str = '' wikipediaLink: str = ''
type: str = 'Person' type: str = 'Person'

View file

@ -23,6 +23,7 @@ class Note(ActivityObject):
cc: List[str] = field(default_factory=lambda: []) cc: List[str] = field(default_factory=lambda: [])
replies: Dict = field(default_factory=lambda: {}) replies: Dict = field(default_factory=lambda: {})
inReplyTo: str = '' inReplyTo: str = ''
summary: str = ''
tag: List[Link] = field(default_factory=lambda: []) tag: List[Link] = field(default_factory=lambda: [])
attachment: List[Image] = field(default_factory=lambda: []) attachment: List[Image] = field(default_factory=lambda: [])
sensitive: bool = False sensitive: bool = False
@ -52,8 +53,8 @@ class Comment(Note):
@dataclass(init=False) @dataclass(init=False)
class Review(Comment): class Review(Comment):
''' a full book review ''' ''' a full book review '''
name: str name: str = None
rating: int rating: int = None
type: str = 'Review' type: str = 'Review'

View file

@ -18,13 +18,13 @@ class PublicKey(ActivityObject):
class Person(ActivityObject): class Person(ActivityObject):
''' actor activitypub json ''' ''' actor activitypub json '''
preferredUsername: str preferredUsername: str
name: str
inbox: str inbox: str
outbox: str outbox: str
followers: str followers: str
summary: str
publicKey: PublicKey publicKey: PublicKey
endpoints: Dict endpoints: Dict
name: str = None
summary: str = None
icon: Image = field(default_factory=lambda: {}) icon: Image = field(default_factory=lambda: {})
bookwyrmUser: bool = False bookwyrmUser: bool = False
manuallyApprovesFollowers: str = False manuallyApprovesFollowers: str = False

View file

@ -0,0 +1,18 @@
from django.http import JsonResponse
from .base_activity import ActivityEncoder
class ActivitypubResponse(JsonResponse):
"""
A class to be used in any place that's serializing responses for
Activitypub enabled clients. Uses JsonResponse under the hood, but already
configures some stuff beforehand. Made to be a drop-in replacement of
JsonResponse.
"""
def __init__(self, data, encoder=ActivityEncoder, safe=True,
json_dumps_params=None, **kwargs):
if 'content_type' not in kwargs:
kwargs['content_type'] = 'application/activity+json'
super().__init__(data, encoder, safe, json_dumps_params, **kwargs)

View file

@ -3,7 +3,7 @@ from dataclasses import dataclass
from typing import List from typing import List
from .base_activity import ActivityObject, Signature from .base_activity import ActivityObject, Signature
from .book import Book from .book import Edition
@dataclass(init=False) @dataclass(init=False)
class Verb(ActivityObject): class Verb(ActivityObject):
@ -73,7 +73,7 @@ class Add(Verb):
@dataclass(init=False) @dataclass(init=False)
class AddBook(Verb): class AddBook(Verb):
'''Add activity that's aware of the book obj ''' '''Add activity that's aware of the book obj '''
target: Book target: Edition
type: str = 'Add' type: str = 'Add'

View file

@ -3,7 +3,7 @@ import json
from django.utils.http import http_date from django.utils.http import http_date
import requests import requests
from bookwyrm import models from bookwyrm import models, settings
from bookwyrm.activitypub import ActivityEncoder from bookwyrm.activitypub import ActivityEncoder
from bookwyrm.tasks import app from bookwyrm.tasks import app
from bookwyrm.signatures import make_signature, make_digest from bookwyrm.signatures import make_signature, make_digest
@ -79,6 +79,7 @@ def sign_and_send(sender, data, destination):
'Digest': digest, 'Digest': digest,
'Signature': make_signature(sender, destination, now, digest), 'Signature': make_signature(sender, destination, now, digest),
'Content-Type': 'application/activity+json; charset=utf-8', 'Content-Type': 'application/activity+json; charset=utf-8',
'User-Agent': settings.USER_AGENT,
}, },
) )
if not response.ok: if not response.ok:

View file

@ -2,3 +2,5 @@
from .settings import CONNECTORS from .settings import CONNECTORS
from .abstract_connector import ConnectorException from .abstract_connector import ConnectorException
from .abstract_connector import get_data, get_image from .abstract_connector import get_data, get_image
from .connector_manager import search, local_search, first_search_result

View file

@ -1,22 +1,18 @@
''' functionality outline for a book data connector ''' ''' functionality outline for a book data connector '''
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import asdict, dataclass
import pytz import logging
from urllib3.exceptions import RequestError from urllib3.exceptions import RequestError
from django.db import transaction from django.db import transaction
from dateutil import parser
import requests import requests
from requests import HTTPError
from requests.exceptions import SSLError from requests.exceptions import SSLError
from bookwyrm import models from bookwyrm import activitypub, models, settings
from .connector_manager import load_more_data, ConnectorException
class ConnectorException(HTTPError):
''' when the connector can't do what was asked '''
logger = logging.getLogger(__name__)
class AbstractMinimalConnector(ABC): class AbstractMinimalConnector(ABC):
''' just the bare bones, for other bookwyrm instances ''' ''' just the bare bones, for other bookwyrm instances '''
def __init__(self, identifier): def __init__(self, identifier):
@ -38,17 +34,22 @@ class AbstractMinimalConnector(ABC):
for field in self_fields: for field in self_fields:
setattr(self, field, getattr(info, field)) setattr(self, field, getattr(info, field))
def search(self, query, min_confidence=None): def search(self, query, min_confidence=None):# pylint: disable=unused-argument
''' free text search ''' ''' free text search '''
resp = requests.get( resp = requests.get(
'%s%s' % (self.search_url, query), '%s%s' % (self.search_url, query),
headers={ headers={
'Accept': 'application/json; charset=utf-8', 'Accept': 'application/json; charset=utf-8',
'User-Agent': settings.USER_AGENT,
}, },
) )
if not resp.ok: if not resp.ok:
resp.raise_for_status() resp.raise_for_status()
data = resp.json() try:
data = resp.json()
except ValueError as e:
logger.exception(e)
raise ConnectorException('Unable to parse json response', e)
results = [] results = []
for doc in self.parse_search_data(data)[:10]: for doc in self.parse_search_data(data)[:10]:
@ -72,9 +73,6 @@ class AbstractConnector(AbstractMinimalConnector):
''' generic book data connector ''' ''' generic book data connector '''
def __init__(self, identifier): def __init__(self, identifier):
super().__init__(identifier) super().__init__(identifier)
self.key_mappings = []
# fields we want to look for in book data to copy over # fields we want to look for in book data to copy over
# title we handle separately. # title we handle separately.
self.book_mappings = [] self.book_mappings = []
@ -89,216 +87,112 @@ class AbstractConnector(AbstractMinimalConnector):
def get_or_create_book(self, remote_id): def get_or_create_book(self, remote_id):
# try to load the book ''' translate arbitrary json into an Activitypub dataclass '''
book = models.Book.objects.select_subclasses().filter( # first, check if we have the origin_id saved
origin_id=remote_id existing = models.Edition.find_existing_by_remote_id(remote_id) or \
).first() models.Work.find_existing_by_remote_id(remote_id)
if book: if existing:
if isinstance(book, models.Work): if hasattr(existing, 'get_default_editon'):
return book.default_edition return existing.get_default_editon()
return book return existing
# no book was found, so we start creating a new one # load the json
data = get_data(remote_id) data = get_data(remote_id)
mapped_data = dict_from_mappings(data, self.book_mappings)
work = None
edition = None
if self.is_work_data(data): if self.is_work_data(data):
work_data = data
# if we requested a work and there's already an edition, we're set
work = self.match_from_mappings(work_data, models.Work)
if work and work.default_edition:
return work.default_edition
# no such luck, we need more information.
try: try:
edition_data = self.get_edition_from_work_data(work_data) edition_data = self.get_edition_from_work_data(data)
except KeyError: except KeyError:
# hack: re-use the work data as the edition data # hack: re-use the work data as the edition data
# this is why remote ids aren't necessarily unique # this is why remote ids aren't necessarily unique
edition_data = data edition_data = data
work_data = mapped_data
else: else:
edition_data = data
edition = self.match_from_mappings(edition_data, models.Edition)
# no need to figure out about the work if we already know about it
if edition and edition.parent_work:
return edition
# no such luck, we need more information.
try: try:
work_data = self.get_work_from_edition_date(edition_data) work_data = self.get_work_from_edition_data(data)
work_data = dict_from_mappings(work_data, self.book_mappings)
except KeyError: except KeyError:
# remember this hack: re-use the work data as the edition data work_data = mapped_data
work_data = data edition_data = data
if not work_data or not edition_data: if not work_data or not edition_data:
raise ConnectorException('Unable to load book data: %s' % remote_id) raise ConnectorException('Unable to load book data: %s' % remote_id)
# at this point, we need to figure out the work, edition, or both
# atomic so that we don't save a work with no edition for vice versa
with transaction.atomic(): with transaction.atomic():
if not work: # create activitypub object
work_key = self.get_remote_id_from_data(work_data) work_activity = activitypub.Work(**work_data)
work = self.create_book(work_key, work_data, models.Work) # this will dedupe automatically
work = work_activity.to_model(models.Work)
for author in self.get_authors_from_data(data):
work.authors.add(author)
if not edition: edition = self.create_edition_from_data(work, edition_data)
ed_key = self.get_remote_id_from_data(edition_data) load_more_data.delay(self.connector.id, work.id)
edition = self.create_book(ed_key, edition_data, models.Edition) return edition
edition.parent_work = work
edition.save()
work.default_edition = edition
work.save()
# now's our change to fill in author gaps
def create_edition_from_data(self, work, edition_data):
''' 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)
edition = edition_activity.to_model(models.Edition)
edition.connector = self.connector
edition.save()
work.default_edition = edition
work.save()
for author in self.get_authors_from_data(edition_data):
edition.authors.add(author)
if not edition.authors.exists() and work.authors.exists(): if not edition.authors.exists() and work.authors.exists():
edition.authors.set(work.authors.all()) edition.authors.set(work.authors.all())
edition.author_text = work.author_text
edition.save()
if not edition:
raise ConnectorException('Unable to create book: %s' % remote_id)
return edition return edition
def create_book(self, remote_id, data, model): def get_or_create_author(self, remote_id):
''' create a work or edition from data ''' ''' load that author '''
book = model.objects.create( existing = models.Author.find_existing_by_remote_id(remote_id)
origin_id=remote_id, if existing:
title=data['title'], return existing
connector=self.connector,
)
return self.update_book_from_data(book, data)
data = get_data(remote_id)
def update_book_from_data(self, book, data, update_cover=True): mapped_data = dict_from_mappings(data, self.author_mappings)
''' for creating a new book or syncing with data ''' activity = activitypub.Author(**mapped_data)
book = update_from_mappings(book, data, self.book_mappings) # this will dedupe
return activity.to_model(models.Author)
author_text = []
for author in self.get_authors_from_data(data):
book.authors.add(author)
author_text.append(author.name)
book.author_text = ', '.join(author_text)
book.save()
if not update_cover:
return book
cover = self.get_cover_from_data(data)
if cover:
book.cover.save(*cover, save=True)
return book
def update_book(self, book, data=None):
''' load new data '''
if not book.sync and not book.sync_cover:
return book
if not data:
key = getattr(book, self.key_name)
data = self.load_book_data(key)
if book.sync:
book = self.update_book_from_data(
book, data, update_cover=book.sync_cover)
else:
cover = self.get_cover_from_data(data)
if cover:
book.cover.save(*cover, save=True)
return book
def match_from_mappings(self, data, model):
''' try to find existing copies of this book using various keys '''
relevent_mappings = [m for m in self.key_mappings if \
not m.model or model == m.model]
for mapping in relevent_mappings:
# check if this field is present in the data
value = data.get(mapping.remote_field)
if not value:
continue
# extract the value in the right format
value = mapping.formatter(value)
# search our database for a matching book
kwargs = {mapping.local_field: value}
match = model.objects.filter(**kwargs).first()
if match:
return match
return None
@abstractmethod
def get_remote_id_from_data(self, data):
''' otherwise we won't properly set the remote_id in the db '''
@abstractmethod @abstractmethod
def is_work_data(self, data): def is_work_data(self, data):
''' differentiate works and editions ''' ''' differentiate works and editions '''
@abstractmethod @abstractmethod
def get_edition_from_work_data(self, data): def get_edition_from_work_data(self, data):
''' every work needs at least one edition ''' ''' every work needs at least one edition '''
@abstractmethod @abstractmethod
def get_work_from_edition_date(self, data): def get_work_from_edition_data(self, data):
''' every edition needs a work ''' ''' every edition needs a work '''
@abstractmethod @abstractmethod
def get_authors_from_data(self, data): def get_authors_from_data(self, data):
''' load author data ''' ''' load author data '''
@abstractmethod
def get_cover_from_data(self, data):
''' load cover '''
@abstractmethod @abstractmethod
def expand_book_data(self, book): def expand_book_data(self, book):
''' get more info on a book ''' ''' get more info on a book '''
def update_from_mappings(obj, data, mappings): def dict_from_mappings(data, mappings):
''' assign data to model with mappings ''' ''' create a dict in Activitypub format, using mappings supplies by
the subclass '''
result = {}
for mapping in mappings: for mapping in mappings:
# check if this field is present in the data result[mapping.local_field] = mapping.get_value(data)
value = data.get(mapping.remote_field) return result
if not value:
continue
# extract the value in the right format
try:
value = mapping.formatter(value)
except:
continue
# assign the formatted value to the model
obj.__setattr__(mapping.local_field, value)
return obj
def get_date(date_string):
''' helper function to try to interpret dates '''
if not date_string:
return None
try:
return pytz.utc.localize(parser.parse(date_string))
except ValueError:
pass
try:
return parser.parse(date_string)
except ValueError:
return None
def get_data(url): def get_data(url):
@ -308,9 +202,10 @@ def get_data(url):
url, url,
headers={ headers={
'Accept': 'application/json; charset=utf-8', 'Accept': 'application/json; charset=utf-8',
'User-Agent': settings.USER_AGENT,
}, },
) )
except RequestError: except (RequestError, SSLError):
raise ConnectorException() raise ConnectorException()
if not resp.ok: if not resp.ok:
resp.raise_for_status() resp.raise_for_status()
@ -325,7 +220,12 @@ def get_data(url):
def get_image(url): def get_image(url):
''' wrapper for requesting an image ''' ''' wrapper for requesting an image '''
try: try:
resp = requests.get(url) resp = requests.get(
url,
headers={
'User-Agent': settings.USER_AGENT,
},
)
except (RequestError, SSLError): except (RequestError, SSLError):
return None return None
if not resp.ok: if not resp.ok:
@ -340,20 +240,35 @@ class SearchResult:
key: str key: str
author: str author: str
year: str year: str
connector: object
confidence: int = 1 confidence: int = 1
def __repr__(self): def __repr__(self):
return "<SearchResult key={!r} title={!r} author={!r}>".format( return "<SearchResult key={!r} title={!r} author={!r}>".format(
self.key, self.title, self.author) self.key, self.title, self.author)
def json(self):
''' serialize a connector for json response '''
serialized = asdict(self)
del serialized['connector']
return serialized
class Mapping: class Mapping:
''' associate a local database field with a field in an external dataset ''' ''' associate a local database field with a field in an external dataset '''
def __init__( def __init__(self, local_field, remote_field=None, formatter=None):
self, local_field, remote_field=None, formatter=None, model=None):
noop = lambda x: x noop = lambda x: x
self.local_field = local_field self.local_field = local_field
self.remote_field = remote_field or local_field self.remote_field = remote_field or local_field
self.formatter = formatter or noop self.formatter = formatter or noop
self.model = model
def get_value(self, data):
''' pull a field from incoming json and return the formatted version '''
value = data.get(self.remote_field)
if not value:
return None
try:
return self.formatter(value)
except:# pylint: disable=bare-except
return None

View file

@ -13,4 +13,5 @@ class Connector(AbstractMinimalConnector):
return data return data
def format_search_result(self, search_result): def format_search_result(self, search_result):
search_result['connector'] = self
return SearchResult(**search_result) return SearchResult(**search_result)

View file

@ -1,4 +1,4 @@
''' select and call a connector for whatever book task needs doing ''' ''' interface with whatever connectors the app has '''
import importlib import importlib
from urllib.parse import urlparse from urllib.parse import urlparse
@ -8,43 +8,8 @@ from bookwyrm import models
from bookwyrm.tasks import app from bookwyrm.tasks import app
def get_edition(book_id): class ConnectorException(HTTPError):
''' look up a book in the db and return an edition ''' ''' when the connector can't do what was asked '''
book = models.Book.objects.select_subclasses().get(id=book_id)
if isinstance(book, models.Work):
book = book.default_edition
return book
def get_or_create_connector(remote_id):
''' get the connector related to the author's server '''
url = urlparse(remote_id)
identifier = url.netloc
if not identifier:
raise ValueError('Invalid remote id')
try:
connector_info = models.Connector.objects.get(identifier=identifier)
except models.Connector.DoesNotExist:
connector_info = models.Connector.objects.create(
identifier=identifier,
connector_file='bookwyrm_connector',
base_url='https://%s' % identifier,
books_url='https://%s/book' % identifier,
covers_url='https://%s/images/covers' % identifier,
search_url='https://%s/search?q=' % identifier,
priority=2
)
return load_connector(connector_info)
@app.task
def load_more_data(book_id):
''' background the work of getting all 10,000 editions of LoTR '''
book = models.Book.objects.select_subclasses().get(id=book_id)
connector = load_connector(book.connector)
connector.expand_book_data(book)
def search(query, min_confidence=0.1): def search(query, min_confidence=0.1):
@ -55,7 +20,7 @@ def search(query, min_confidence=0.1):
for connector in get_connectors(): for connector in get_connectors():
try: try:
result_set = connector.search(query, min_confidence=min_confidence) result_set = connector.search(query, min_confidence=min_confidence)
except HTTPError: except (HTTPError, ConnectorException):
continue continue
result_set = [r for r in result_set \ result_set = [r for r in result_set \
@ -91,6 +56,38 @@ def get_connectors():
yield load_connector(info) yield load_connector(info)
def get_or_create_connector(remote_id):
''' get the connector related to the author's server '''
url = urlparse(remote_id)
identifier = url.netloc
if not identifier:
raise ValueError('Invalid remote id')
try:
connector_info = models.Connector.objects.get(identifier=identifier)
except models.Connector.DoesNotExist:
connector_info = models.Connector.objects.create(
identifier=identifier,
connector_file='bookwyrm_connector',
base_url='https://%s' % identifier,
books_url='https://%s/book' % identifier,
covers_url='https://%s/images/covers' % identifier,
search_url='https://%s/search?q=' % identifier,
priority=2
)
return load_connector(connector_info)
@app.task
def load_more_data(connector_id, book_id):
''' 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)
connector.expand_book_data(book)
def load_connector(connector_info): def load_connector(connector_info):
''' instantiate the connector class ''' ''' instantiate the connector class '''
connector = importlib.import_module( connector = importlib.import_module(

View file

@ -1,13 +1,10 @@
''' openlibrary data connector ''' ''' openlibrary data connector '''
import re import re
import requests
from django.core.files.base import ContentFile
from bookwyrm import models from bookwyrm import models
from .abstract_connector import AbstractConnector, SearchResult, Mapping from .abstract_connector import AbstractConnector, SearchResult, Mapping
from .abstract_connector import ConnectorException from .abstract_connector import get_data
from .abstract_connector import get_date, get_data, update_from_mappings from .connector_manager import ConnectorException
from .openlibrary_languages import languages from .openlibrary_languages import languages
@ -17,67 +14,62 @@ class Connector(AbstractConnector):
super().__init__(identifier) super().__init__(identifier)
get_first = lambda a: a[0] get_first = lambda a: a[0]
self.key_mappings = [ get_remote_id = lambda a: self.base_url + a
Mapping('isbn_13', model=models.Edition, formatter=get_first), self.book_mappings = [
Mapping('isbn_10', model=models.Edition, formatter=get_first), Mapping('title'),
Mapping('lccn', model=models.Work, formatter=get_first), Mapping('id', remote_field='key', formatter=get_remote_id),
Mapping( Mapping(
'oclc_number', 'cover', remote_field='covers', formatter=self.get_cover_url),
remote_field='oclc_numbers', Mapping('sortTitle', remote_field='sort_title'),
model=models.Edition,
formatter=get_first
),
Mapping(
'openlibrary_key',
remote_field='key',
formatter=get_openlibrary_key
),
Mapping('goodreads_key'),
Mapping('asin'),
]
self.book_mappings = self.key_mappings + [
Mapping('sort_title'),
Mapping('subtitle'), Mapping('subtitle'),
Mapping('description', formatter=get_description), Mapping('description', formatter=get_description),
Mapping('languages', formatter=get_languages), Mapping('languages', formatter=get_languages),
Mapping('series', formatter=get_first), Mapping('series', formatter=get_first),
Mapping('series_number'), Mapping('seriesNumber', remote_field='series_number'),
Mapping('subjects'), Mapping('subjects'),
Mapping('subject_places'), Mapping('subjectPlaces'),
Mapping('isbn13', formatter=get_first),
Mapping('isbn10', formatter=get_first),
Mapping('lccn', formatter=get_first),
Mapping( Mapping(
'first_published_date', 'oclcNumber', remote_field='oclc_numbers',
remote_field='first_publish_date', formatter=get_first
formatter=get_date
), ),
Mapping( Mapping(
'published_date', 'openlibraryKey', remote_field='key',
remote_field='publish_date', formatter=get_openlibrary_key
formatter=get_date
), ),
Mapping('goodreadsKey', remote_field='goodreads_key'),
Mapping('asin'),
Mapping( Mapping(
'pages', 'firstPublishedDate', remote_field='first_publish_date',
model=models.Edition,
remote_field='number_of_pages'
), ),
Mapping('physical_format', model=models.Edition), Mapping('publishedDate', remote_field='publish_date'),
Mapping('pages', remote_field='number_of_pages'),
Mapping('physicalFormat', remote_field='physical_format'),
Mapping('publishers'), Mapping('publishers'),
] ]
self.author_mappings = [ self.author_mappings = [
Mapping('id', remote_field='key', formatter=get_remote_id),
Mapping('name'), Mapping('name'),
Mapping('born', remote_field='birth_date', formatter=get_date), Mapping(
Mapping('died', remote_field='death_date', formatter=get_date), 'openlibraryKey', remote_field='key',
formatter=get_openlibrary_key
),
Mapping('born', remote_field='birth_date'),
Mapping('died', remote_field='death_date'),
Mapping('bio', formatter=get_description), Mapping('bio', formatter=get_description),
] ]
def get_remote_id_from_data(self, data): def get_remote_id_from_data(self, data):
''' format a url from an openlibrary id field '''
try: try:
key = data['key'] key = data['key']
except KeyError: except KeyError:
raise ConnectorException('Invalid book data') raise ConnectorException('Invalid book data')
return '%s/%s' % (self.books_url, key) return '%s%s' % (self.books_url, key)
def is_work_data(self, data): def is_work_data(self, data):
@ -89,17 +81,17 @@ class Connector(AbstractConnector):
key = data['key'] key = data['key']
except KeyError: except KeyError:
raise ConnectorException('Invalid book data') raise ConnectorException('Invalid book data')
url = '%s/%s/editions' % (self.books_url, key) url = '%s%s/editions' % (self.books_url, key)
data = get_data(url) data = get_data(url)
return pick_default_edition(data['entries']) return pick_default_edition(data['entries'])
def get_work_from_edition_date(self, data): def get_work_from_edition_data(self, data):
try: try:
key = data['works'][0]['key'] key = data['works'][0]['key']
except (IndexError, KeyError): except (IndexError, KeyError):
raise ConnectorException('No work found for edition') raise ConnectorException('No work found for edition')
url = '%s/%s' % (self.books_url, key) url = '%s%s' % (self.books_url, key)
return get_data(url) return get_data(url)
@ -107,24 +99,17 @@ class Connector(AbstractConnector):
''' parse author json and load or create authors ''' ''' parse author json and load or create authors '''
for author_blob in data.get('authors', []): for author_blob in data.get('authors', []):
author_blob = author_blob.get('author', author_blob) author_blob = author_blob.get('author', author_blob)
# this id is "/authors/OL1234567A" and we want just "OL1234567A" # this id is "/authors/OL1234567A"
author_id = author_blob['key'].split('/')[-1] author_id = author_blob['key']
yield self.get_or_create_author(author_id) url = '%s%s' % (self.base_url, author_id)
yield self.get_or_create_author(url)
def get_cover_from_data(self, data): def get_cover_url(self, cover_blob):
''' ask openlibrary for the cover ''' ''' ask openlibrary for the cover '''
if not data.get('covers'): cover_id = cover_blob[0]
return None image_name = '%s-L.jpg' % cover_id
return '%s/b/id/%s' % (self.covers_url, image_name)
cover_id = data.get('covers')[0]
image_name = '%s-M.jpg' % cover_id
url = '%s/b/id/%s' % (self.covers_url, image_name)
response = requests.get(url)
if not response.ok:
response.raise_for_status()
image_content = ContentFile(response.content)
return [image_name, image_content]
def parse_search_data(self, data): def parse_search_data(self, data):
@ -139,13 +124,14 @@ class Connector(AbstractConnector):
title=search_result.get('title'), title=search_result.get('title'),
key=key, key=key,
author=', '.join(author), author=', '.join(author),
connector=self,
year=search_result.get('first_publish_year'), year=search_result.get('first_publish_year'),
) )
def load_edition_data(self, olkey): def load_edition_data(self, olkey):
''' query openlibrary for editions of a work ''' ''' query openlibrary for editions of a work '''
url = '%s/works/%s/editions.json' % (self.books_url, olkey) url = '%s/works/%s/editions' % (self.books_url, olkey)
return get_data(url) return get_data(url)
@ -158,44 +144,14 @@ class Connector(AbstractConnector):
# we can mass download edition data from OL to avoid repeatedly querying # we can mass download edition data from OL to avoid repeatedly querying
edition_options = self.load_edition_data(work.openlibrary_key) edition_options = self.load_edition_data(work.openlibrary_key)
for edition_data in edition_options.get('entries'): for edition_data in edition_options.get('entries'):
olkey = edition_data.get('key').split('/')[-1] self.create_edition_from_data(work, edition_data)
# make sure the edition isn't already in the database
if models.Edition.objects.filter(openlibrary_key=olkey).count():
continue
# creates and populates the book from the data
edition = self.create_book(olkey, edition_data, models.Edition)
# ensures that the edition is associated with the work
edition.parent_work = work
edition.save()
# get author data from the work if it's missing from the edition
if not edition.authors and work.authors:
edition.authors.set(work.authors.all())
def get_or_create_author(self, olkey):
''' load that author '''
if not re.match(r'^OL\d+A$', olkey):
raise ValueError('Invalid OpenLibrary author ID')
author = models.Author.objects.filter(openlibrary_key=olkey).first()
if author:
return author
url = '%s/authors/%s.json' % (self.base_url, olkey)
data = get_data(url)
author = models.Author(openlibrary_key=olkey)
author = update_from_mappings(author, data, self.author_mappings)
author.save()
return author
def get_description(description_blob): def get_description(description_blob):
''' descriptions can be a string or a dict ''' ''' descriptions can be a string or a dict '''
if isinstance(description_blob, dict): if isinstance(description_blob, dict):
return description_blob.get('value') return description_blob.get('value')
return description_blob return description_blob
def get_openlibrary_key(key): def get_openlibrary_key(key):
@ -220,7 +176,7 @@ def pick_default_edition(options):
if len(options) == 1: if len(options) == 1:
return options[0] return options[0]
options = [e for e in options if e.get('cover')] or options options = [e for e in options if e.get('covers')] or options
options = [e for e in options if \ options = [e for e in options if \
'/languages/eng' in str(e.get('languages'))] or options '/languages/eng' in str(e.get('languages'))] or options
formats = ['paperback', 'hardcover', 'mass market paperback'] formats = ['paperback', 'hardcover', 'mass market paperback']

View file

@ -1,6 +1,9 @@
''' using a bookwyrm instance as a source of book data ''' ''' using a bookwyrm instance as a source of book data '''
from functools import reduce
import operator
from django.contrib.postgres.search import SearchRank, SearchVector from django.contrib.postgres.search import SearchRank, SearchVector
from django.db.models import F from django.db.models import Count, F, Q
from bookwyrm import models from bookwyrm import models
from .abstract_connector import AbstractConnector, SearchResult from .abstract_connector import AbstractConnector, SearchResult
@ -9,38 +12,18 @@ from .abstract_connector import AbstractConnector, SearchResult
class Connector(AbstractConnector): class Connector(AbstractConnector):
''' instantiate a connector ''' ''' instantiate a connector '''
def search(self, query, min_confidence=0.1): def search(self, query, min_confidence=0.1):
''' right now you can't search bookwyrm sorry, but when ''' search your local database '''
that gets implemented it will totally rule ''' # first, try searching unqiue identifiers
vector = SearchVector('title', weight='A') +\ results = search_identifiers(query)
SearchVector('subtitle', weight='B') +\ if not results:
SearchVector('author_text', weight='C') +\ # then try searching title/author
SearchVector('isbn_13', weight='A') +\ results = search_title_author(query, min_confidence)
SearchVector('isbn_10', weight='A') +\
SearchVector('openlibrary_key', weight='C') +\
SearchVector('goodreads_key', weight='C') +\
SearchVector('asin', weight='C') +\
SearchVector('oclc_number', weight='C') +\
SearchVector('remote_id', weight='C') +\
SearchVector('description', weight='D') +\
SearchVector('series', weight='D')
results = models.Edition.objects.annotate(
search=vector
).annotate(
rank=SearchRank(vector, query)
).filter(
rank__gt=min_confidence
).order_by('-rank')
# remove non-default editions, if possible
results = results.filter(parent_work__default_edition__id=F('id')) \
or results
search_results = [] search_results = []
for book in results[:10]: for result in results:
search_results.append( search_results.append(self.format_search_result(result))
self.format_search_result(book) if len(search_results) >= 10:
) break
search_results.sort(key=lambda r: r.confidence, reverse=True)
return search_results return search_results
@ -51,31 +34,74 @@ class Connector(AbstractConnector):
author=search_result.author_text, author=search_result.author_text,
year=search_result.published_date.year if \ year=search_result.published_date.year if \
search_result.published_date else None, search_result.published_date else None,
confidence=search_result.rank, connector=self,
confidence=search_result.rank if \
hasattr(search_result, 'rank') else 1,
) )
def get_remote_id_from_data(self, data):
pass
def is_work_data(self, data): def is_work_data(self, data):
pass pass
def get_edition_from_work_data(self, data): def get_edition_from_work_data(self, data):
pass pass
def get_work_from_edition_date(self, data): def get_work_from_edition_data(self, data):
pass pass
def get_authors_from_data(self, data): def get_authors_from_data(self, data):
return None return None
def get_cover_from_data(self, data):
return None
def parse_search_data(self, data): def parse_search_data(self, data):
''' it's already in the right format, don't even worry about it ''' ''' it's already in the right format, don't even worry about it '''
return data return data
def expand_book_data(self, book): def expand_book_data(self, book):
pass pass
def search_identifiers(query):
''' tries remote_id, isbn; defined as dedupe fields on the model '''
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))
).distinct()
# when there are multiple editions of the same work, pick the default.
# it would be odd for this to happen.
return results.filter(parent_work__default_edition__id=F('id')) \
or results
def search_title_author(query, min_confidence):
''' searches for title and author '''
vector = SearchVector('title', weight='A') +\
SearchVector('subtitle', weight='B') +\
SearchVector('authors__name', weight='C') +\
SearchVector('series', weight='D')
results = models.Edition.objects.annotate(
search=vector
).annotate(
rank=SearchRank(vector, query)
).filter(
rank__gt=min_confidence
).order_by('-rank')
# when there are multiple editions of the same work, pick the closest
editions_of_work = results.values(
'parent_work'
).annotate(
Count('parent_work')
).values_list('parent_work')
for work_id in set(editions_of_work):
editions = results.filter(parent_work=work_id)
default = editions.filter(parent_work__default_edition=F('id'))
default_rank = default.first().rank if default.exists() else 0
# if mutliple books have the top rank, pick the default edition
if default_rank == editions.first().rank:
yield default.first()
else:
yield editions.first()

View file

@ -1,7 +1,7 @@
''' customize the info available in context for rendering templates ''' ''' customize the info available in context for rendering templates '''
from bookwyrm import models from bookwyrm import models
def site_settings(request): def site_settings(request):# pylint: disable=unused-argument
''' include the custom info about the site ''' ''' include the custom info about the site '''
return { return {
'site': models.SiteSettings.objects.get() 'site': models.SiteSettings.objects.get()

View file

@ -31,10 +31,11 @@ class CustomForm(ModelForm):
visible.field.widget.attrs['class'] = css_classes[input_type] visible.field.widget.attrs['class'] = css_classes[input_type]
# pylint: disable=missing-class-docstring
class LoginForm(CustomForm): class LoginForm(CustomForm):
class Meta: class Meta:
model = models.User model = models.User
fields = ['username', 'password'] fields = ['localname', 'password']
help_texts = {f: None for f in fields} help_texts = {f: None for f in fields}
widgets = { widgets = {
'password': PasswordInput(), 'password': PasswordInput(),
@ -44,7 +45,7 @@ class LoginForm(CustomForm):
class RegisterForm(CustomForm): class RegisterForm(CustomForm):
class Meta: class Meta:
model = models.User model = models.User
fields = ['username', 'email', 'password'] fields = ['localname', 'email', 'password']
help_texts = {f: None for f in fields} help_texts = {f: None for f in fields}
widgets = { widgets = {
'password': PasswordInput() 'password': PasswordInput()
@ -60,25 +61,36 @@ class RatingForm(CustomForm):
class ReviewForm(CustomForm): class ReviewForm(CustomForm):
class Meta: class Meta:
model = models.Review model = models.Review
fields = ['user', 'book', 'name', 'content', 'rating', 'privacy'] fields = [
'user', 'book',
'name', 'content', 'rating',
'content_warning', 'sensitive',
'privacy']
class CommentForm(CustomForm): class CommentForm(CustomForm):
class Meta: class Meta:
model = models.Comment model = models.Comment
fields = ['user', 'book', 'content', 'privacy'] fields = [
'user', 'book', 'content',
'content_warning', 'sensitive',
'privacy']
class QuotationForm(CustomForm): class QuotationForm(CustomForm):
class Meta: class Meta:
model = models.Quotation model = models.Quotation
fields = ['user', 'book', 'quote', 'content', 'privacy'] fields = [
'user', 'book', 'quote', 'content',
'content_warning', 'sensitive', 'privacy']
class ReplyForm(CustomForm): class ReplyForm(CustomForm):
class Meta: class Meta:
model = models.Status model = models.Status
fields = ['user', 'content', 'reply_parent', 'privacy'] fields = [
'user', 'content', 'content_warning', 'sensitive',
'reply_parent', 'privacy']
class EditUserForm(CustomForm): class EditUserForm(CustomForm):
@ -110,14 +122,13 @@ class EditionForm(CustomForm):
model = models.Edition model = models.Edition
exclude = [ exclude = [
'remote_id', 'remote_id',
'origin_id',
'created_date', 'created_date',
'updated_date', 'updated_date',
'last_sync_date',
'authors',# TODO 'authors',# TODO
'parent_work', 'parent_work',
'shelves', 'shelves',
'misc_identifiers',
'subjects',# TODO 'subjects',# TODO
'subject_places',# TODO 'subject_places',# TODO
@ -125,12 +136,23 @@ class EditionForm(CustomForm):
'connector', 'connector',
] ]
class AuthorForm(CustomForm):
class Meta:
model = models.Author
exclude = [
'remote_id',
'origin_id',
'created_date',
'updated_date',
]
class ImportForm(forms.Form): class ImportForm(forms.Form):
csv_file = forms.FileField() csv_file = forms.FileField()
class ExpiryWidget(widgets.Select): class ExpiryWidget(widgets.Select):
def value_from_datadict(self, data, files, name): def value_from_datadict(self, data, files, name):
''' human-readable exiration time buckets '''
selected_string = super().value_from_datadict(data, files, name) selected_string = super().value_from_datadict(data, files, name)
if selected_string == 'day': if selected_string == 'day':

View file

@ -8,8 +8,6 @@ from bookwyrm.models import ImportJob, ImportItem
from bookwyrm.status import create_notification from bookwyrm.status import create_notification
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# TODO: remove or increase once we're confident it's not causing problems.
MAX_ENTRIES = 500
def create_job(user, csv_file, include_reviews, privacy): def create_job(user, csv_file, include_reviews, privacy):
@ -19,12 +17,13 @@ def create_job(user, csv_file, include_reviews, privacy):
include_reviews=include_reviews, include_reviews=include_reviews,
privacy=privacy privacy=privacy
) )
for index, entry in enumerate(list(csv.DictReader(csv_file))[:MAX_ENTRIES]): for index, entry in enumerate(list(csv.DictReader(csv_file))):
if not all(x in entry for x in ('ISBN13', 'Title', 'Author')): if not all(x in entry for x in ('ISBN13', 'Title', 'Author')):
raise ValueError('Author, title, and isbn must be in data.') raise ValueError('Author, title, and isbn must be in data.')
ImportItem(job=job, index=index, data=entry).save() ImportItem(job=job, index=index, data=entry).save()
return job return job
def create_retry_job(user, original_job, items): def create_retry_job(user, original_job, items):
''' retry items that didn't import ''' ''' retry items that didn't import '''
job = ImportJob.objects.create( job = ImportJob.objects.create(
@ -37,6 +36,7 @@ def create_retry_job(user, original_job, items):
ImportItem(job=job, index=item.index, data=item.data).save() ImportItem(job=job, index=item.index, data=item.data).save()
return job return job
def start_import(job): def start_import(job):
''' initalizes a csv import job ''' ''' initalizes a csv import job '''
result = import_data.delay(job.id) result = import_data.delay(job.id)
@ -49,11 +49,10 @@ def import_data(job_id):
''' does the actual lookup work in a celery task ''' ''' does the actual lookup work in a celery task '''
job = ImportJob.objects.get(id=job_id) job = ImportJob.objects.get(id=job_id)
try: try:
results = []
for item in job.items.all(): for item in job.items.all():
try: try:
item.resolve() item.resolve()
except Exception as e: except Exception as e:# pylint: disable=broad-except
logger.exception(e) logger.exception(e)
item.fail_reason = 'Error loading book' item.fail_reason = 'Error loading book'
item.save() item.save()
@ -61,7 +60,6 @@ def import_data(job_id):
if item.book: if item.book:
item.save() item.save()
results.append(item)
# shelves book and handles reviews # shelves book and handles reviews
outgoing.handle_imported_book( outgoing.handle_imported_book(

View file

@ -6,6 +6,7 @@ import django.db.utils
from django.http import HttpResponse from django.http import HttpResponse
from django.http import HttpResponseBadRequest, HttpResponseNotFound from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
import requests import requests
from bookwyrm import activitypub, models, outgoing from bookwyrm import activitypub, models, outgoing
@ -15,11 +16,9 @@ from bookwyrm.signatures import Signature
@csrf_exempt @csrf_exempt
@require_POST
def inbox(request, username): def inbox(request, username):
''' incoming activitypub events ''' ''' incoming activitypub events '''
# TODO: should do some kind of checking if the user accepts
# this action from the sender probably? idk
# but this will just throw a 404 if the user doesn't exist
try: try:
models.User.objects.get(localname=username) models.User.objects.get(localname=username)
except models.User.DoesNotExist: except models.User.DoesNotExist:
@ -29,11 +28,9 @@ def inbox(request, username):
@csrf_exempt @csrf_exempt
@require_POST
def shared_inbox(request): def shared_inbox(request):
''' incoming activitypub events ''' ''' incoming activitypub events '''
if request.method == 'GET':
return HttpResponseNotFound()
try: try:
resp = request.body resp = request.body
activity = json.loads(resp) activity = json.loads(resp)
@ -60,7 +57,6 @@ def shared_inbox(request):
'Announce': handle_boost, 'Announce': handle_boost,
'Add': { 'Add': {
'Edition': handle_add, 'Edition': handle_add,
'Work': handle_add,
}, },
'Undo': { 'Undo': {
'Follow': handle_unfollow, 'Follow': handle_unfollow,
@ -69,8 +65,8 @@ def shared_inbox(request):
}, },
'Update': { 'Update': {
'Person': handle_update_user, 'Person': handle_update_user,
'Edition': handle_update_book, 'Edition': handle_update_edition,
'Work': handle_update_book, 'Work': handle_update_work,
}, },
} }
activity_type = activity['type'] activity_type = activity['type']
@ -144,7 +140,7 @@ def handle_follow(activity):
def handle_unfollow(activity): def handle_unfollow(activity):
''' unfollow a local user ''' ''' unfollow a local user '''
obj = activity['object'] obj = activity['object']
requester = activitypub.resolve_remote_id(models.user, obj['actor']) requester = activitypub.resolve_remote_id(models.User, obj['actor'])
to_unfollow = models.User.objects.get(remote_id=obj['object']) to_unfollow = models.User.objects.get(remote_id=obj['object'])
# raises models.User.DoesNotExist # raises models.User.DoesNotExist
@ -188,34 +184,48 @@ def handle_follow_reject(activity):
def handle_create(activity): def handle_create(activity):
''' someone did something, good on them ''' ''' someone did something, good on them '''
# deduplicate incoming activities # deduplicate incoming activities
status_id = activity['object']['id'] activity = activity['object']
status_id = activity.get('id')
if models.Status.objects.filter(remote_id=status_id).count(): if models.Status.objects.filter(remote_id=status_id).count():
return return
serializer = activitypub.activity_objects[activity['type']] try:
status = serializer(**activity) serializer = activitypub.activity_objects[activity['type']]
except KeyError:
return
activity = serializer(**activity)
try: try:
model = models.activity_models[activity.type] model = models.activity_models[activity.type]
except KeyError: except KeyError:
# not a type of status we are prepared to deserialize # not a type of status we are prepared to deserialize
return return
if activity.type == 'Note': status = activity.to_model(model)
reply = models.Status.objects.filter( if not status:
remote_id=activity.inReplyTo # it was discarded because it's not a bookwyrm type
).first() return
if not reply:
return
activity.to_model(model)
# create a notification if this is a reply # create a notification if this is a reply
notified = []
if status.reply_parent and status.reply_parent.user.local: if status.reply_parent and status.reply_parent.user.local:
notified.append(status.reply_parent.user)
status_builder.create_notification( status_builder.create_notification(
status.reply_parent.user, status.reply_parent.user,
'REPLY', 'REPLY',
related_user=status.user, related_user=status.user,
related_status=status, related_status=status,
) )
if status.mention_users.exists():
for mentioned_user in status.mention_users.all():
if not mentioned_user.local or mentioned_user in notified:
continue
status_builder.create_notification(
mentioned_user,
'MENTION',
related_user=status.user,
related_status=status,
)
@app.task @app.task
@ -228,11 +238,12 @@ def handle_delete_status(activity):
# is trying to delete a user. # is trying to delete a user.
return return
try: try:
status = models.Status.objects.select_subclasses().get( status = models.Status.objects.get(
remote_id=status_id remote_id=status_id
) )
except models.Status.DoesNotExist: except models.Status.DoesNotExist:
return return
models.Notification.objects.filter(related_status=status).all().delete()
status_builder.delete_status(status) status_builder.delete_status(status)
@ -317,6 +328,12 @@ def handle_update_user(activity):
@app.task @app.task
def handle_update_book(activity): def handle_update_edition(activity):
''' a remote instance changed a book (Document) ''' ''' a remote instance changed a book (Document) '''
activitypub.Edition(**activity['object']).to_model(models.Edition) activitypub.Edition(**activity['object']).to_model(models.Edition)
@app.task
def handle_update_work(activity):
''' a remote instance changed a book (Document) '''
activitypub.Work(**activity['object']).to_model(models.Work)

View file

@ -0,0 +1,83 @@
''' PROCEED WITH CAUTION: uses deduplication fields to permanently
merge book data objects '''
from django.core.management.base import BaseCommand
from django.db.models import Count
from bookwyrm import models
def update_related(canonical, obj):
''' 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]
for (related_field, related_model) in related_models:
related_objs = related_model.objects.filter(
**{related_field: obj})
for related_obj in related_objs:
print(
'replacing in',
related_model.__name__,
related_field,
related_obj.id
)
try:
setattr(related_obj, related_field, canonical)
related_obj.save()
except TypeError:
getattr(related_obj, related_field).add(canonical)
getattr(related_obj, related_field).remove(obj)
def copy_data(canonical, obj):
''' try to get the most data possible '''
for data_field in obj._meta.get_fields():
if not hasattr(data_field, 'activitypub_field'):
continue
data_value = getattr(obj, data_field.name)
if not data_value:
continue
if not getattr(canonical, data_field.name):
print('setting data field', data_field.name, data_value)
setattr(canonical, data_field.name, data_value)
canonical.save()
def dedupe_model(model):
''' 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]
for field in dedupe_fields:
dupes = model.objects.values(field.name).annotate(
Count(field.name)
).filter(**{'%s__count__gt' % field.name: 1})
for dupe in dupes:
value = dupe[field.name]
if not value or value == '':
continue
print('----------')
print(dupe)
objs = model.objects.filter(
**{field.name: value}
).order_by('id')
canonical = objs.first()
print('keeping', canonical.remote_id)
for obj in objs[1:]:
print(obj.remote_id)
copy_data(canonical, obj)
update_related(canonical, obj)
# remove the outdated entry
obj.delete()
class Command(BaseCommand):
''' 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 '''
dedupe_model(models.Edition)
dedupe_model(models.Work)
dedupe_model(models.Author)

View file

@ -7,7 +7,7 @@ import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import django.utils.timezone import django.utils.timezone
import bookwyrm.utils.fields from django.contrib.postgres.fields import JSONField
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -62,7 +62,7 @@ class Migration(migrations.Migration):
('content', models.TextField(blank=True, null=True)), ('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)), ('created_date', models.DateTimeField(auto_now_add=True)),
('openlibrary_key', models.CharField(max_length=255)), ('openlibrary_key', models.CharField(max_length=255)),
('data', bookwyrm.utils.fields.JSONField()), ('data', JSONField()),
], ],
options={ options={
'abstract': False, 'abstract': False,
@ -75,7 +75,7 @@ class Migration(migrations.Migration):
('content', models.TextField(blank=True, null=True)), ('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)), ('created_date', models.DateTimeField(auto_now_add=True)),
('openlibrary_key', models.CharField(max_length=255, unique=True)), ('openlibrary_key', models.CharField(max_length=255, unique=True)),
('data', bookwyrm.utils.fields.JSONField()), ('data', JSONField()),
('cover', models.ImageField(blank=True, null=True, upload_to='covers/')), ('cover', models.ImageField(blank=True, null=True, upload_to='covers/')),
('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), ('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('authors', models.ManyToManyField(to='bookwyrm.Author')), ('authors', models.ManyToManyField(to='bookwyrm.Author')),

View file

@ -2,7 +2,6 @@
import bookwyrm.models.connector import bookwyrm.models.connector
import bookwyrm.models.site import bookwyrm.models.site
import bookwyrm.utils.fields
from django.conf import settings from django.conf import settings
import django.contrib.postgres.operations import django.contrib.postgres.operations
import django.core.validators import django.core.validators
@ -10,6 +9,7 @@ from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import django.db.models.expressions import django.db.models.expressions
import django.utils.timezone import django.utils.timezone
from django.contrib.postgres.fields import JSONField, ArrayField
import uuid import uuid
@ -148,7 +148,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='book', model_name='book',
name='misc_identifiers', name='misc_identifiers',
field=bookwyrm.utils.fields.JSONField(null=True), field=JSONField(null=True),
), ),
migrations.AddField( migrations.AddField(
model_name='book', model_name='book',
@ -226,7 +226,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='author', model_name='author',
name='aliases', name='aliases',
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
), ),
migrations.AddField( migrations.AddField(
model_name='user', model_name='user',
@ -394,17 +394,17 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='book', model_name='book',
name='subject_places', name='subject_places',
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
), ),
migrations.AddField( migrations.AddField(
model_name='book', model_name='book',
name='subjects', name='subjects',
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
), ),
migrations.AddField( migrations.AddField(
model_name='edition', model_name='edition',
name='publishers', name='publishers',
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
), ),
migrations.AlterField( migrations.AlterField(
model_name='connector', model_name='connector',
@ -578,7 +578,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='book', model_name='book',
name='languages', name='languages',
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
), ),
migrations.AddField( migrations.AddField(
model_name='edition', model_name='edition',
@ -676,7 +676,7 @@ class Migration(migrations.Migration):
name='ImportItem', name='ImportItem',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('data', bookwyrm.utils.fields.JSONField()), ('data', JSONField()),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(

View file

@ -1,10 +1,9 @@
# Generated by Django 3.0.7 on 2020-11-29 03:04 # Generated by Django 3.0.7 on 2020-11-29 03:04
import bookwyrm.utils.fields
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.contrib.postgres.fields import ArrayField
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -16,12 +15,12 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='book', model_name='book',
name='subject_places', name='subject_places',
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None), field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name='book',
name='subjects', name='subjects',
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None), field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None),
), ),
migrations.AlterField( migrations.AlterField(
model_name='edition', model_name='edition',

View file

@ -0,0 +1,19 @@
# Generated by Django 3.0.7 on 2020-12-12 00:59
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0016_auto_20201211_2026'),
]
operations = [
migrations.AlterField(
model_name='readthrough',
name='book',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 3.0.7 on 2020-12-14 05:11
import bookwyrm.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0022_auto_20201212_1744'),
]
operations = [
migrations.AlterField(
model_name='status',
name='privacy',
field=bookwyrm.models.fields.PrivacyField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
),
]

View file

@ -0,0 +1,14 @@
# Generated by Django 3.0.7 on 2020-12-16 01:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0017_auto_20201212_0059'),
('bookwyrm', '0022_auto_20201212_1744'),
]
operations = [
]

View file

@ -0,0 +1,14 @@
# Generated by Django 3.0.7 on 2020-12-16 17:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0023_auto_20201214_0511'),
('bookwyrm', '0023_merge_20201216_0112'),
]
operations = [
]

View file

@ -0,0 +1,39 @@
# Generated by Django 3.0.7 on 2020-12-17 00:46
import bookwyrm.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0024_merge_20201216_1721'),
]
operations = [
migrations.AlterField(
model_name='author',
name='bio',
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
),
migrations.AlterField(
model_name='book',
name='description',
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
),
migrations.AlterField(
model_name='quotation',
name='quote',
field=bookwyrm.models.fields.HtmlField(),
),
migrations.AlterField(
model_name='status',
name='content',
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
),
migrations.AlterField(
model_name='user',
name='summary',
field=bookwyrm.models.fields.HtmlField(default=''),
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 3.0.7 on 2020-12-17 03:17
import bookwyrm.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0025_auto_20201217_0046'),
]
operations = [
migrations.AddField(
model_name='status',
name='content_warning',
field=bookwyrm.models.fields.CharField(blank=True, max_length=500, null=True),
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 3.0.7 on 2020-12-20 20:07
import bookwyrm.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0026_status_content_warning'),
]
operations = [
migrations.AlterField(
model_name='user',
name='name',
field=bookwyrm.models.fields.CharField(blank=True, max_length=100, null=True),
),
migrations.AlterField(
model_name='user',
name='summary',
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 3.0.7 on 2020-12-21 19:57
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0027_auto_20201220_2007'),
]
operations = [
migrations.RemoveField(
model_name='book',
name='author_text',
),
]

View file

@ -0,0 +1,61 @@
# Generated by Django 3.0.7 on 2020-12-21 20:14
import bookwyrm.models.fields
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0028_remove_book_author_text'),
]
operations = [
migrations.RemoveField(
model_name='author',
name='last_sync_date',
),
migrations.RemoveField(
model_name='author',
name='sync',
),
migrations.RemoveField(
model_name='book',
name='last_sync_date',
),
migrations.RemoveField(
model_name='book',
name='sync',
),
migrations.RemoveField(
model_name='book',
name='sync_cover',
),
migrations.AddField(
model_name='author',
name='goodreads_key',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='author',
name='last_edited_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='author',
name='librarything_key',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='book',
name='last_edited_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='author',
name='origin_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 3.0.7 on 2020-12-24 19:39
import bookwyrm.models.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0029_auto_20201221_2014'),
]
operations = [
migrations.AlterField(
model_name='user',
name='localname',
field=models.CharField(max_length=255, null=True, unique=True, validators=[bookwyrm.models.fields.validate_localname]),
),
]

View file

@ -2,15 +2,18 @@
import inspect import inspect
import sys import sys
from .book import Book, Work, Edition from .book import Book, Work, Edition, BookDataModel
from .author import Author from .author import Author
from .connector import Connector from .connector import Connector
from .shelf import Shelf, ShelfBook from .shelf import Shelf, ShelfBook
from .status import Status, GeneratedNote, Review, Comment, Quotation from .status import Status, GeneratedNote, Review, Comment, Quotation
from .status import Favorite, Boost, Notification, ReadThrough from .status import Boost
from .attachment import Image from .attachment import Image
from .favorite import Favorite
from .notification import Notification
from .readthrough import ReadThrough
from .tag import Tag, UserTag from .tag import Tag, UserTag
@ -26,7 +29,5 @@ cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_models = {c[1].activity_serializer.__name__: c[1] \ activity_models = {c[1].activity_serializer.__name__: c[1] \
for c in cls_members if hasattr(c[1], 'activity_serializer')} for c in cls_members if hasattr(c[1], 'activity_serializer')}
def to_activity(activity_json): status_models = [
''' link up models and activities ''' c.__name__ for (_, c) in activity_models.items() if issubclass(c, Status)]
activity_type = activity_json.get('type')
return activity_models[activity_type].to_activity(activity_json)

View file

@ -1,40 +1,25 @@
''' database schema for info about authors ''' ''' database schema for info about authors '''
from django.db import models from django.db import models
from django.utils import timezone
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
from .base_model import ActivitypubMixin, BookWyrmModel from .book import BookDataModel
from . import fields from . import fields
class Author(ActivitypubMixin, BookWyrmModel): class Author(BookDataModel):
''' basic biographic info ''' ''' basic biographic info '''
origin_id = models.CharField(max_length=255, null=True) wikipedia_link = fields.CharField(
openlibrary_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True) max_length=255, blank=True, null=True, deduplication_field=True)
sync = models.BooleanField(default=True)
last_sync_date = models.DateTimeField(default=timezone.now)
wikipedia_link = fields.CharField(max_length=255, blank=True, null=True, deduplication_field=True)
# idk probably other keys would be useful here? # idk probably other keys would be useful here?
born = fields.DateTimeField(blank=True, null=True) born = fields.DateTimeField(blank=True, null=True)
died = fields.DateTimeField(blank=True, null=True) died = fields.DateTimeField(blank=True, null=True)
name = fields.CharField(max_length=255) name = fields.CharField(max_length=255, deduplication_field=True)
aliases = fields.ArrayField( aliases = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list models.CharField(max_length=255), blank=True, default=list
) )
bio = fields.TextField(null=True, blank=True) bio = fields.HtmlField(null=True, blank=True)
def save(self, *args, **kwargs):
''' can't be abstract for query reasons, but you shouldn't USE it '''
if self.id and not self.remote_id:
self.remote_id = self.get_remote_id()
if not self.id:
self.origin_id = self.remote_id
self.remote_id = None
return super().save(*args, **kwargs)
def get_remote_id(self): def get_remote_id(self):
''' editions and works both use "book" instead of model_name ''' ''' editions and works both use "book" instead of model_name '''

View file

@ -14,16 +14,9 @@ from django.dispatch import receiver
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN, PAGE_LENGTH from bookwyrm.settings import DOMAIN, PAGE_LENGTH
from .fields import RemoteIdField from .fields import ImageField, ManyToManyField, RemoteIdField
PrivacyLevels = models.TextChoices('Privacy', [
'public',
'unlisted',
'followers',
'direct'
])
class BookWyrmModel(models.Model): class BookWyrmModel(models.Model):
''' shared fields ''' ''' shared fields '''
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
@ -42,8 +35,14 @@ class BookWyrmModel(models.Model):
''' this is just here to provide default fields for other models ''' ''' this is just here to provide default fields for other models '''
abstract = True abstract = True
@property
def local_path(self):
''' how to link to this object in the local app '''
return self.get_remote_id().replace('https://%s' % DOMAIN, '')
@receiver(models.signals.post_save) @receiver(models.signals.post_save)
#pylint: disable=unused-argument
def execute_after_save(sender, instance, created, *args, **kwargs): def execute_after_save(sender, instance, created, *args, **kwargs):
''' set the remote_id after save (when the id is available) ''' ''' set the remote_id after save (when the id is available) '''
if not created or not hasattr(instance, 'get_remote_id'): if not created or not hasattr(instance, 'get_remote_id'):
@ -67,6 +66,33 @@ class ActivitypubMixin:
activity_serializer = lambda: {} activity_serializer = lambda: {}
reverse_unfurl = False reverse_unfurl = False
def __init__(self, *args, **kwargs):
''' collect some info on model fields '''
self.image_fields = []
self.many_to_many_fields = []
self.simple_fields = [] # "simple"
for field in self._meta.get_fields():
if not hasattr(field, 'field_to_activity'):
continue
if isinstance(field, ImageField):
self.image_fields.append(field)
elif isinstance(field, ManyToManyField):
self.many_to_many_fields.append(field)
else:
self.simple_fields.append(field)
self.activity_fields = self.image_fields + \
self.many_to_many_fields + self.simple_fields
self.deserialize_reverse_fields = self.deserialize_reverse_fields \
if hasattr(self, 'deserialize_reverse_fields') else []
self.serialize_reverse_fields = self.serialize_reverse_fields \
if hasattr(self, 'serialize_reverse_fields') else []
super().__init__(*args, **kwargs)
@classmethod @classmethod
def find_existing_by_remote_id(cls, remote_id): def find_existing_by_remote_id(cls, remote_id):
''' look up a remote id in the db ''' ''' look up a remote id in the db '''
@ -83,7 +109,7 @@ class ActivitypubMixin:
not field.deduplication_field: not field.deduplication_field:
continue continue
value = data.get(field.activitypub_field) value = data.get(field.get_activitypub_field())
if not value: if not value:
continue continue
filters.append({field.name: value}) filters.append({field.name: value})
@ -114,19 +140,8 @@ class ActivitypubMixin:
def to_activity(self): def to_activity(self):
''' convert from a model to an activity ''' ''' convert from a model to an activity '''
activity = {} activity = {}
for field in self._meta.get_fields(): for field in self.activity_fields:
if not hasattr(field, 'field_to_activity'): field.set_activity_from_field(activity, self)
continue
value = field.field_to_activity(getattr(self, field.name))
if value is None:
continue
key = field.get_activitypub_field()
if key in activity and isinstance(activity[key], list):
# handles tags on status, which accumulate across fields
activity[key] += value
else:
activity[key] = value
if hasattr(self, 'serialize_reverse_fields'): if hasattr(self, 'serialize_reverse_fields'):
# for example, editions of a work # for example, editions of a work
@ -141,9 +156,9 @@ class ActivitypubMixin:
return self.activity_serializer(**activity).serialize() return self.activity_serializer(**activity).serialize()
def to_create_activity(self, 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() activity_object = self.to_activity(**kwargs)
signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key)) signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
content = activity_object['content'] content = activity_object['content']
@ -227,7 +242,9 @@ class OrderedCollectionPageMixin(ActivitypubMixin):
).serialize() ).serialize()
def to_ordered_collection_page(queryset, remote_id, id_only=False, page=1): # pylint: disable=unused-argument
def to_ordered_collection_page(
queryset, remote_id, id_only=False, page=1, **kwargs):
''' serialize and pagiante a queryset ''' ''' serialize and pagiante a queryset '''
paginated = Paginator(queryset, PAGE_LENGTH) paginated = Paginator(queryset, PAGE_LENGTH)

View file

@ -2,7 +2,6 @@
import re import re
from django.db import models from django.db import models
from django.utils import timezone
from model_utils.managers import InheritanceManager from model_utils.managers import InheritanceManager
from bookwyrm import activitypub from bookwyrm import activitypub
@ -12,10 +11,9 @@ from .base_model import BookWyrmModel
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
from . import fields from . import fields
class Book(ActivitypubMixin, BookWyrmModel): class BookDataModel(ActivitypubMixin, BookWyrmModel):
''' a generic book, which can mean either an edition or a work ''' ''' fields shared between editable book data (books, works, authors) '''
origin_id = models.CharField(max_length=255, null=True, blank=True) origin_id = models.CharField(max_length=255, null=True, blank=True)
# these identifiers apply to both works and editions
openlibrary_key = fields.CharField( openlibrary_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True) max_length=255, blank=True, null=True, deduplication_field=True)
librarything_key = fields.CharField( librarything_key = fields.CharField(
@ -23,20 +21,33 @@ class Book(ActivitypubMixin, BookWyrmModel):
goodreads_key = fields.CharField( goodreads_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True) max_length=255, blank=True, null=True, deduplication_field=True)
# info about where the data comes from and where/if to sync last_edited_by = models.ForeignKey(
sync = models.BooleanField(default=True) 'User', on_delete=models.PROTECT, null=True)
sync_cover = models.BooleanField(default=True)
last_sync_date = models.DateTimeField(default=timezone.now) class Meta:
''' 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 '''
if self.id:
self.remote_id = self.get_remote_id()
else:
self.origin_id = self.remote_id
self.remote_id = None
return super().save(*args, **kwargs)
class Book(BookDataModel):
''' a generic book, which can mean either an edition or a work '''
connector = models.ForeignKey( connector = models.ForeignKey(
'Connector', on_delete=models.PROTECT, null=True) 'Connector', on_delete=models.PROTECT, null=True)
# TODO: edit history
# book/work metadata # book/work metadata
title = fields.CharField(max_length=255) title = fields.CharField(max_length=255)
sort_title = fields.CharField(max_length=255, blank=True, null=True) sort_title = fields.CharField(max_length=255, blank=True, null=True)
subtitle = fields.CharField(max_length=255, blank=True, null=True) subtitle = fields.CharField(max_length=255, blank=True, null=True)
description = fields.TextField(blank=True, null=True) description = fields.HtmlField(blank=True, null=True)
languages = fields.ArrayField( languages = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list models.CharField(max_length=255), blank=True, default=list
) )
@ -48,27 +59,42 @@ class Book(ActivitypubMixin, BookWyrmModel):
subject_places = fields.ArrayField( subject_places = fields.ArrayField(
models.CharField(max_length=255), blank=True, null=True, default=list models.CharField(max_length=255), blank=True, null=True, default=list
) )
# TODO: include an annotation about the type of authorship (ie, translator)
authors = fields.ManyToManyField('Author') authors = fields.ManyToManyField('Author')
# preformatted authorship string for search and easier display cover = fields.ImageField(
author_text = models.CharField(max_length=255, blank=True, null=True) upload_to='covers/', blank=True, null=True, alt_field='alt_text')
cover = fields.ImageField(upload_to='covers/', blank=True, null=True)
first_published_date = fields.DateTimeField(blank=True, null=True) first_published_date = fields.DateTimeField(blank=True, null=True)
published_date = fields.DateTimeField(blank=True, null=True) published_date = fields.DateTimeField(blank=True, null=True)
objects = InheritanceManager() objects = InheritanceManager()
@property
def author_text(self):
''' format a list of authors '''
return ', '.join(a.name for a in self.authors.all())
@property
def edition_info(self):
''' properties of this edition, as a string '''
items = [
self.physical_format if hasattr(self, 'physical_format') else None,
self.languages[0] + ' language' if self.languages and \
self.languages[0] != 'English' else None,
str(self.published_date.year) if self.published_date else None,
]
return ', '.join(i for i in items if i)
@property
def alt_text(self):
''' image alt test '''
text = '%s cover' % self.title
if self.edition_info:
text += ' (%s)' % self.edition_info
return text
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
''' can't be abstract for query reasons, but you shouldn't USE it ''' ''' can't be abstract for query reasons, but you shouldn't USE it '''
if not isinstance(self, Edition) and not isinstance(self, Work): if not isinstance(self, Edition) and not isinstance(self, Work):
raise ValueError('Books should be added as Editions or Works') raise ValueError('Books should be added as Editions or Works')
if self.id and not self.remote_id:
self.remote_id = self.get_remote_id()
if not self.id:
self.origin_id = self.remote_id
self.remote_id = None
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
def get_remote_id(self): def get_remote_id(self):
@ -92,13 +118,22 @@ class Work(OrderedCollectionPageMixin, Book):
default_edition = fields.ForeignKey( default_edition = fields.ForeignKey(
'Edition', 'Edition',
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True null=True,
load_remote=False
) )
def get_default_edition(self): def get_default_edition(self):
''' in case the default edition is not set ''' ''' in case the default edition is not set '''
return self.default_edition or self.editions.first() return self.default_edition or self.editions.first()
def to_edition_list(self, **kwargs):
''' an ordered collection of editions '''
return self.to_ordered_collection(
self.editions.order_by('-updated_date').all(),
remote_id='%s/editions' % self.remote_id,
**kwargs
)
activity_serializer = activitypub.Work activity_serializer = activitypub.Work
serialize_reverse_fields = [('editions', 'editions')] serialize_reverse_fields = [('editions', 'editions')]
deserialize_reverse_fields = [('editions', 'editions')] deserialize_reverse_fields = [('editions', 'editions')]

View file

@ -0,0 +1,26 @@
''' like/fav/star a status '''
from django.db import models
from django.utils import timezone
from bookwyrm import activitypub
from .base_model import ActivitypubMixin, BookWyrmModel
from . import fields
class Favorite(ActivitypubMixin, BookWyrmModel):
''' fav'ing a post '''
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='actor')
status = fields.ForeignKey(
'Status', on_delete=models.PROTECT, activitypub_field='object')
activity_serializer = activitypub.Like
def save(self, *args, **kwargs):
''' update user active time '''
self.user.last_active_date = timezone.now()
self.user.save()
super().save(*args, **kwargs)
class Meta:
''' can't fav things twice '''
unique_together = ('user', 'status')

View file

@ -1,10 +1,10 @@
''' activitypub-aware django model fields ''' ''' activitypub-aware django model fields '''
from dataclasses import MISSING
import re import re
from uuid import uuid4 from uuid import uuid4
import dateutil.parser import dateutil.parser
from dateutil.parser import ParserError from dateutil.parser import ParserError
from django.contrib.auth.models import AbstractUser
from django.contrib.postgres.fields import ArrayField as DjangoArrayField from django.contrib.postgres.fields import ArrayField as DjangoArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
@ -12,8 +12,9 @@ from django.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from bookwyrm.connectors import get_image from bookwyrm.connectors import get_image
from bookwyrm.sanitize_html import InputHtmlParser
from bookwyrm.settings import DOMAIN
def validate_remote_id(value): def validate_remote_id(value):
@ -25,6 +26,24 @@ def validate_remote_id(value):
) )
def validate_localname(value):
''' 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'),
params={'value': value},
)
def validate_username(value):
''' 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'),
params={'value': value},
)
class ActivitypubFieldMixin: class ActivitypubFieldMixin:
''' make a database field serializable ''' ''' make a database field serializable '''
def __init__(self, *args, \ def __init__(self, *args, \
@ -38,6 +57,39 @@ class ActivitypubFieldMixin:
self.activitypub_field = activitypub_field self.activitypub_field = activitypub_field
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def set_field_from_activity(self, instance, data):
''' helper function for assinging a value to the field '''
try:
value = getattr(data, self.get_activitypub_field())
except AttributeError:
# masssively hack-y workaround for boosts
if self.get_activitypub_field() != 'attributedTo':
raise
value = getattr(data, 'actor')
formatted = self.field_from_activity(value)
if formatted is None or formatted is MISSING:
return
setattr(instance, self.name, formatted)
def set_activity_from_field(self, activity, instance):
''' update the json object '''
value = getattr(instance, self.name)
formatted = self.field_to_activity(value)
if formatted is None:
return
key = self.get_activitypub_field()
# TODO: surely there's a better way
if instance.__class__.__name__ == 'Boost' and key == 'attributedTo':
key = 'actor'
if isinstance(activity.get(key), list):
activity[key] += formatted
else:
activity[key] = formatted
def field_to_activity(self, value): def field_to_activity(self, value):
''' formatter to convert a model value into activitypub ''' ''' formatter to convert a model value into activitypub '''
if hasattr(self, 'activitypub_wrapper'): if hasattr(self, 'activitypub_wrapper'):
@ -61,12 +113,19 @@ class ActivitypubFieldMixin:
class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin): class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
''' default (de)serialization for foreign key and one to one ''' ''' default (de)serialization for foreign key and one to one '''
def __init__(self, *args, load_remote=True, **kwargs):
self.load_remote = load_remote
super().__init__(*args, **kwargs)
def field_from_activity(self, value): def field_from_activity(self, value):
if not value: if not value:
return None return None
related_model = self.related_model related_model = self.related_model
if isinstance(value, dict) and value.get('id'): if isinstance(value, dict) and value.get('id'):
if not self.load_remote:
# only look in the local database
return related_model.find_existing(value)
# this is an activitypub object, which we can deserialize # this is an activitypub object, which we can deserialize
activity_serializer = related_model.activity_serializer activity_serializer = related_model.activity_serializer
return activity_serializer(**value).to_model(related_model) return activity_serializer(**value).to_model(related_model)
@ -77,6 +136,9 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
# we don't know what this is, ignore it # we don't know what this is, ignore it
return None return None
# gets or creates the model field from the remote id # gets or creates the model field from the remote id
if not self.load_remote:
# only look in the local database
return related_model.find_existing_by_remote_id(value)
return activitypub.resolve_remote_id(related_model, value) return activitypub.resolve_remote_id(related_model, value)
@ -94,7 +156,7 @@ class RemoteIdField(ActivitypubFieldMixin, models.CharField):
class UsernameField(ActivitypubFieldMixin, models.CharField): class UsernameField(ActivitypubFieldMixin, models.CharField):
''' activitypub-aware username field ''' ''' activitypub-aware username field '''
def __init__(self, activitypub_field='preferredUsername'): def __init__(self, activitypub_field='preferredUsername', **kwargs):
self.activitypub_field = activitypub_field self.activitypub_field = activitypub_field
# I don't totally know why pylint is mad at this, but it makes it work # I don't totally know why pylint is mad at this, but it makes it work
super( #pylint: disable=bad-super-call super( #pylint: disable=bad-super-call
@ -103,7 +165,7 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
_('username'), _('username'),
max_length=150, max_length=150,
unique=True, unique=True,
validators=[AbstractUser.username_validator], validators=[validate_username],
error_messages={ error_messages={
'unique': _('A user with that username already exists.'), 'unique': _('A user with that username already exists.'),
}, },
@ -123,6 +185,52 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
return value.split('@')[0] return value.split('@')[0]
PrivacyLevels = models.TextChoices('Privacy', [
'public',
'unlisted',
'followers',
'direct'
])
class PrivacyField(ActivitypubFieldMixin, models.CharField):
''' this maps to two differente activitypub fields '''
public = 'https://www.w3.org/ns/activitystreams#Public'
def __init__(self, *args, **kwargs):
super().__init__(
*args, max_length=255,
choices=PrivacyLevels.choices, default='public')
def set_field_from_activity(self, instance, data):
to = data.to
cc = data.cc
if to == [self.public]:
setattr(instance, self.name, 'public')
elif cc == []:
setattr(instance, self.name, 'direct')
elif self.public in cc:
setattr(instance, self.name, 'unlisted')
else:
setattr(instance, self.name, 'followers')
def set_activity_from_field(self, activity, instance):
mentions = [u.remote_id for u in instance.mention_users.all()]
# this is a link to the followers list
followers = instance.user.__class__._meta.get_field('followers')\
.field_to_activity(instance.user.followers)
if instance.privacy == 'public':
activity['to'] = [self.public]
activity['cc'] = [followers] + mentions
elif instance.privacy == 'unlisted':
activity['to'] = [followers]
activity['cc'] = [self.public] + mentions
elif instance.privacy == 'followers':
activity['to'] = [followers]
activity['cc'] = mentions
if instance.privacy == 'direct':
activity['to'] = mentions
activity['cc'] = []
class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey): class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
''' activitypub-aware foreign key field ''' ''' activitypub-aware foreign key field '''
def field_to_activity(self, value): def field_to_activity(self, value):
@ -145,6 +253,14 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
self.link_only = link_only self.link_only = link_only
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def set_field_from_activity(self, instance, data):
''' 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:
return
getattr(instance, self.name).set(formatted)
def field_to_activity(self, value): def field_to_activity(self, value):
if self.link_only: if self.link_only:
return '%s/%s' % (value.instance.remote_id, self.name) return '%s/%s' % (value.instance.remote_id, self.name)
@ -152,6 +268,8 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
def field_from_activity(self, value): def field_from_activity(self, value):
items = [] items = []
if value is None or value is MISSING:
return []
for remote_id in value: for remote_id in value:
try: try:
validate_remote_id(remote_id) validate_remote_id(remote_id)
@ -189,6 +307,8 @@ class TagField(ManyToManyField):
for link_json in value: for link_json in value:
link = activitypub.Link(**link_json) link = activitypub.Link(**link_json)
tag_type = link.type if link.type != 'Mention' else 'Person' tag_type = link.type if link.type != 'Mention' else 'Person'
if tag_type == 'Book':
tag_type = 'Edition'
if tag_type != self.related_model.activity_serializer.type: if tag_type != self.related_model.activity_serializer.type:
# tags can contain multiple types # tags can contain multiple types
continue continue
@ -198,20 +318,45 @@ class TagField(ManyToManyField):
return items return items
def image_serializer(value): def image_serializer(value, alt):
''' helper for serializing images ''' ''' helper for serializing images '''
if value and hasattr(value, 'url'): if value and hasattr(value, 'url'):
url = value.url url = value.url
else: else:
return None return None
url = 'https://%s%s' % (DOMAIN, url) url = 'https://%s%s' % (DOMAIN, url)
return activitypub.Image(url=url) return activitypub.Image(url=url, name=alt)
class ImageField(ActivitypubFieldMixin, models.ImageField): class ImageField(ActivitypubFieldMixin, models.ImageField):
''' activitypub-aware image field ''' ''' activitypub-aware image field '''
def field_to_activity(self, value): def __init__(self, *args, alt_field=None, **kwargs):
return image_serializer(value) self.alt_field = alt_field
super().__init__(*args, **kwargs)
# pylint: disable=arguments-differ
def set_field_from_activity(self, instance, data, save=True):
''' 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:
return
getattr(instance, self.name).save(*formatted, save=save)
def set_activity_from_field(self, activity, instance):
value = getattr(instance, self.name)
if value is None:
return
alt_text = getattr(instance, self.alt_field)
formatted = self.field_to_activity(value, alt_text)
key = self.get_activitypub_field()
activity[key] = formatted
def field_to_activity(self, value, alt=None):
return image_serializer(value, alt)
def field_from_activity(self, value): def field_from_activity(self, value):
image_slug = value image_slug = value
@ -255,6 +400,15 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
except (ParserError, TypeError): except (ParserError, TypeError):
return None return None
class HtmlField(ActivitypubFieldMixin, models.TextField):
''' a text field for storing html '''
def field_from_activity(self, value):
if not value or value == MISSING:
return None
sanitizer = InputHtmlParser()
sanitizer.feed(value)
return sanitizer.get_output()
class ArrayField(ActivitypubFieldMixin, DjangoArrayField): class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
''' activitypub-aware array field ''' ''' activitypub-aware array field '''
def field_to_activity(self, value): def field_to_activity(self, value):

View file

@ -2,14 +2,13 @@
import re import re
import dateutil.parser import dateutil.parser
from django.contrib.postgres.fields import JSONField
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from bookwyrm import books_manager from bookwyrm.connectors import connector_manager
from bookwyrm.connectors import ConnectorException
from bookwyrm.models import ReadThrough, User, Book from bookwyrm.models import ReadThrough, User, Book
from bookwyrm.utils.fields import JSONField from .fields import PrivacyLevels
from .base_model import PrivacyLevels
# Mapping goodreads -> bookwyrm shelf titles. # Mapping goodreads -> bookwyrm shelf titles.
@ -72,12 +71,12 @@ class ImportItem(models.Model):
def get_book_from_isbn(self): def get_book_from_isbn(self):
''' search by isbn ''' ''' search by isbn '''
search_result = books_manager.first_search_result( search_result = connector_manager.first_search_result(
self.isbn, min_confidence=0.999 self.isbn, min_confidence=0.999
) )
if search_result: if search_result:
# raises ConnectorException # raises ConnectorException
return books_manager.get_or_create_book(search_result.key) return search_result.connector.get_or_create_book(search_result.key)
return None return None
@ -87,12 +86,12 @@ class ImportItem(models.Model):
self.data['Title'], self.data['Title'],
self.data['Author'] self.data['Author']
) )
search_result = books_manager.first_search_result( search_result = connector_manager.first_search_result(
search_term, min_confidence=0.999 search_term, min_confidence=0.999
) )
if search_result: if search_result:
# raises ConnectorException # raises ConnectorException
return books_manager.get_or_create_book(search_result.key) return search_result.connector.get_or_create_book(search_result.key)
return None return None

View file

@ -0,0 +1,33 @@
''' alert a user to activity '''
from django.db import models
from .base_model import BookWyrmModel
NotificationType = models.TextChoices(
'NotificationType',
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
class Notification(BookWyrmModel):
''' you've been tagged, liked, followed, etc '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
related_book = models.ForeignKey(
'Edition', on_delete=models.PROTECT, null=True)
related_user = models.ForeignKey(
'User',
on_delete=models.PROTECT, null=True, related_name='related_user')
related_status = models.ForeignKey(
'Status', on_delete=models.PROTECT, null=True)
related_import = models.ForeignKey(
'ImportJob', on_delete=models.PROTECT, null=True)
read = models.BooleanField(default=False)
notification_type = models.CharField(
max_length=255, choices=NotificationType.choices)
class Meta:
''' checks if notifcation is in enum list for valid types '''
constraints = [
models.CheckConstraint(
check=models.Q(notification_type__in=NotificationType.values),
name="notification_type_valid",
)
]

View file

@ -0,0 +1,26 @@
''' progress in a book '''
from django.db import models
from django.utils import timezone
from .base_model import BookWyrmModel
class ReadThrough(BookWyrmModel):
''' Store progress through a book in the database. '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
pages_read = models.IntegerField(
null=True,
blank=True)
start_date = models.DateTimeField(
blank=True,
null=True)
finish_date = models.DateTimeField(
blank=True,
null=True)
def save(self, *args, **kwargs):
''' update user active time '''
self.user.last_active_date = timezone.now()
self.user.save()
super().save(*args, **kwargs)

View file

@ -37,7 +37,7 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
activity_serializer = activitypub.Follow activity_serializer = activitypub.Follow
def get_remote_id(self, status=None): def get_remote_id(self, status=None):# pylint: disable=arguments-differ
''' use shelf identifier in remote_id ''' ''' use shelf identifier in remote_id '''
status = status or 'follows' status = status or 'follows'
base_path = self.user_subject.remote_id base_path = self.user_subject.remote_id
@ -54,7 +54,7 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
def to_reject_activity(self): def to_reject_activity(self):
''' generate an Accept for this follow request ''' ''' generate a Reject for this follow request '''
return activitypub.Reject( return activitypub.Reject(
id=self.get_remote_id(status='rejects'), id=self.get_remote_id(status='rejects'),
actor=self.user_object.remote_id, actor=self.user_object.remote_id,

View file

@ -3,8 +3,8 @@ import re
from django.db import models from django.db import models
from bookwyrm import activitypub from bookwyrm import activitypub
from .base_model import BookWyrmModel from .base_model import ActivitypubMixin, BookWyrmModel
from .base_model import OrderedCollectionMixin, PrivacyLevels from .base_model import OrderedCollectionMixin
from . import fields from . import fields
@ -18,7 +18,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
privacy = fields.CharField( privacy = fields.CharField(
max_length=255, max_length=255,
default='public', default='public',
choices=PrivacyLevels.choices choices=fields.PrivacyLevels.choices
) )
books = models.ManyToManyField( books = models.ManyToManyField(
'Edition', 'Edition',
@ -51,7 +51,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
unique_together = ('user', 'identifier') unique_together = ('user', 'identifier')
class ShelfBook(BookWyrmModel): class ShelfBook(ActivitypubMixin, BookWyrmModel):
''' many to many join table for books and shelves ''' ''' many to many join table for books and shelves '''
book = fields.ForeignKey( book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='object') 'Edition', on_delete=models.PROTECT, activitypub_field='object')

View file

@ -1,12 +1,16 @@
''' models for storing different kinds of Activities ''' ''' models for storing different kinds of Activities '''
from django.utils import timezone from dataclasses import MISSING
import re
from django.apps import apps
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.utils import timezone
from model_utils.managers import InheritanceManager from model_utils.managers import InheritanceManager
from bookwyrm import activitypub from bookwyrm import activitypub
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
from .base_model import BookWyrmModel, PrivacyLevels from .base_model import BookWyrmModel
from . import fields from . import fields
from .fields import image_serializer from .fields import image_serializer
@ -14,17 +18,15 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
''' any post, like a reply to a review, etc ''' ''' any post, like a reply to a review, etc '''
user = fields.ForeignKey( user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='attributedTo') 'User', on_delete=models.PROTECT, activitypub_field='attributedTo')
content = fields.TextField(blank=True, null=True) content = fields.HtmlField(blank=True, null=True)
mention_users = fields.TagField('User', related_name='mention_user') mention_users = fields.TagField('User', related_name='mention_user')
mention_books = fields.TagField('Edition', related_name='mention_book') mention_books = fields.TagField('Edition', related_name='mention_book')
local = models.BooleanField(default=True) local = models.BooleanField(default=True)
privacy = models.CharField( content_warning = fields.CharField(
max_length=255, max_length=500, blank=True, null=True, activitypub_field='summary')
default='public', privacy = fields.PrivacyField(max_length=255)
choices=PrivacyLevels.choices
)
sensitive = fields.BooleanField(default=False) sensitive = fields.BooleanField(default=False)
# the created date can't be this, because of receiving federated posts # created date is different than publish date because of federated posts
published_date = fields.DateTimeField( published_date = fields.DateTimeField(
default=timezone.now, activitypub_field='published') default=timezone.now, activitypub_field='published')
deleted = models.BooleanField(default=False) deleted = models.BooleanField(default=False)
@ -48,18 +50,45 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
serialize_reverse_fields = [('attachments', 'attachment')] serialize_reverse_fields = [('attachments', 'attachment')]
deserialize_reverse_fields = [('attachments', 'attachment')] deserialize_reverse_fields = [('attachments', 'attachment')]
#----- replies collection activitypub ----# @classmethod
def ignore_activity(cls, activity):
''' keep notes if they are replies to existing statuses '''
if activity.type != 'Note':
return False
if cls.objects.filter(
remote_id=activity.inReplyTo).exists():
return False
# keep notes if they mention local users
if activity.tag == MISSING or activity.tag is None:
return True
tags = [l['href'] for l in activity.tag if l['type'] == 'Mention']
for tag in tags:
user_model = apps.get_model('bookwyrm.User', require_ready=True)
if user_model.objects.filter(
remote_id=tag, local=True).exists():
# we found a mention of a known use boost
return False
return True
@classmethod @classmethod
def replies(cls, status): def replies(cls, status):
''' load all replies to a status. idk if there's a better way ''' load all replies to a status. idk if there's a better way
to write this so it's just a property ''' to write this so it's just a property '''
return cls.objects.filter(reply_parent=status).select_subclasses() return cls.objects.filter(
reply_parent=status
).select_subclasses().order_by('published_date')
@property @property
def status_type(self): def status_type(self):
''' expose the type of status for the ui using activity type ''' ''' expose the type of status for the ui using activity type '''
return self.activity_serializer.__name__ return self.activity_serializer.__name__
@property
def boostable(self):
''' you can't boost dms '''
return self.privacy in ['unlisted', 'public']
def to_replies(self, **kwargs): def to_replies(self, **kwargs):
''' helper function for loading AP serialized replies to a status ''' ''' helper function for loading AP serialized replies to a status '''
return self.to_ordered_collection( return self.to_ordered_collection(
@ -68,7 +97,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
**kwargs **kwargs
) )
def to_activity(self, pure=False): def to_activity(self, pure=False):# pylint: disable=arguments-differ
''' return tombstone if the status is deleted ''' ''' return tombstone if the status is deleted '''
if self.deleted: if self.deleted:
return activitypub.Tombstone( return activitypub.Tombstone(
@ -80,37 +109,18 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
activity = ActivitypubMixin.to_activity(self) activity = ActivitypubMixin.to_activity(self)
activity['replies'] = self.to_replies() activity['replies'] = self.to_replies()
# privacy controls
public = 'https://www.w3.org/ns/activitystreams#Public'
mentions = [u.remote_id for u in self.mention_users.all()]
# this is a link to the followers list:
followers = self.user.__class__._meta.get_field('followers')\
.field_to_activity(self.user.followers)
if self.privacy == 'public':
activity['to'] = [public]
activity['cc'] = [followers] + mentions
elif self.privacy == 'unlisted':
activity['to'] = [followers]
activity['cc'] = [public] + mentions
elif self.privacy == 'followers':
activity['to'] = [followers]
activity['cc'] = mentions
if self.privacy == 'direct':
activity['to'] = mentions
activity['cc'] = []
# "pure" serialization for non-bookwyrm instances # "pure" serialization for non-bookwyrm instances
if pure: if pure and hasattr(self, 'pure_content'):
activity['content'] = self.pure_content activity['content'] = self.pure_content
if 'name' in activity: if 'name' in activity:
activity['name'] = self.pure_name activity['name'] = self.pure_name
activity['type'] = self.pure_type activity['type'] = self.pure_type
activity['attachment'] = [ activity['attachment'] = [
image_serializer(b.cover) for b in self.mention_books.all() \ image_serializer(b.cover, b.alt_text) \
if b.cover] for b in self.mention_books.all()[:4] if b.cover]
if hasattr(self, 'book'): if hasattr(self, 'book') and self.book.cover:
activity['attachment'].append( activity['attachment'].append(
image_serializer(self.book.cover) image_serializer(self.book.cover, self.book.alt_text)
) )
return activity return activity
@ -147,8 +157,8 @@ class Comment(Status):
@property @property
def pure_content(self): def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users ''' ''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(comment on <a href="%s">"%s"</a>)' % \ return '%s<p>(comment on <a href="%s">"%s"</a>)</p>' % \
(self.book.remote_id, self.book.title) (self.content, self.book.remote_id, self.book.title)
activity_serializer = activitypub.Comment activity_serializer = activitypub.Comment
pure_type = 'Note' pure_type = 'Note'
@ -156,15 +166,17 @@ class Comment(Status):
class Quotation(Status): class Quotation(Status):
''' like a review but without a rating and transient ''' ''' like a review but without a rating and transient '''
quote = fields.TextField() quote = fields.HtmlField()
book = fields.ForeignKey( book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook') 'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
@property @property
def pure_content(self): def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users ''' ''' indicate the book in question for mastodon (or w/e) users '''
return '"%s"<br>-- <a href="%s">"%s"</a><br><br>%s' % ( quote = re.sub(r'^<p>', '<p>"', self.quote)
self.quote, quote = re.sub(r'</p>$', '"</p>', quote)
return '%s <p>-- <a href="%s">"%s"</a></p>%s' % (
quote,
self.book.remote_id, self.book.remote_id,
self.book.title, self.book.title,
self.content, self.content,
@ -190,6 +202,7 @@ class Review(Status):
def pure_name(self): def pure_name(self):
''' clarify review names for mastodon serialization ''' ''' clarify review names for mastodon serialization '''
if self.rating: if self.rating:
#pylint: disable=bad-string-format-type
return 'Review of "%s" (%d stars): %s' % ( return 'Review of "%s" (%d stars): %s' % (
self.book.title, self.book.title,
self.rating, self.rating,
@ -203,33 +216,12 @@ class Review(Status):
@property @property
def pure_content(self): def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users ''' ''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(<a href="%s">"%s"</a>)' % \ return self.content
(self.book.remote_id, self.book.title)
activity_serializer = activitypub.Review activity_serializer = activitypub.Review
pure_type = 'Article' pure_type = 'Article'
class Favorite(ActivitypubMixin, BookWyrmModel):
''' fav'ing a post '''
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='actor')
status = fields.ForeignKey(
'Status', on_delete=models.PROTECT, activitypub_field='object')
activity_serializer = activitypub.Like
def save(self, *args, **kwargs):
''' update user active time '''
self.user.last_active_date = timezone.now()
self.user.save()
super().save(*args, **kwargs)
class Meta:
''' can't fav things twice '''
unique_together = ('user', 'status')
class Boost(Status): class Boost(Status):
''' boost'ing a post ''' ''' boost'ing a post '''
boosted_status = fields.ForeignKey( boosted_status = fields.ForeignKey(
@ -239,59 +231,20 @@ class Boost(Status):
activitypub_field='object', activitypub_field='object',
) )
def __init__(self, *args, **kwargs):
''' the user field is "actor" here instead of "attributedTo" '''
super().__init__(*args, **kwargs)
reserve_fields = ['user', 'boosted_status']
self.simple_fields = [f for f in self.simple_fields if \
f.name in reserve_fields]
self.activity_fields = self.simple_fields
self.many_to_many_fields = []
self.image_fields = []
self.deserialize_reverse_fields = []
activity_serializer = activitypub.Boost activity_serializer = activitypub.Boost
# This constraint can't work as it would cross tables. # This constraint can't work as it would cross tables.
# class Meta: # class Meta:
# unique_together = ('user', 'boosted_status') # unique_together = ('user', 'boosted_status')
class ReadThrough(BookWyrmModel):
''' Store progress through a book in the database. '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
book = models.ForeignKey('Book', on_delete=models.PROTECT)
pages_read = models.IntegerField(
null=True,
blank=True)
start_date = models.DateTimeField(
blank=True,
null=True)
finish_date = models.DateTimeField(
blank=True,
null=True)
def save(self, *args, **kwargs):
''' update user active time '''
self.user.last_active_date = timezone.now()
self.user.save()
super().save(*args, **kwargs)
NotificationType = models.TextChoices(
'NotificationType',
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
class Notification(BookWyrmModel):
''' you've been tagged, liked, followed, etc '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
related_book = models.ForeignKey(
'Edition', on_delete=models.PROTECT, null=True)
related_user = models.ForeignKey(
'User',
on_delete=models.PROTECT, null=True, related_name='related_user')
related_status = models.ForeignKey(
'Status', on_delete=models.PROTECT, null=True)
related_import = models.ForeignKey(
'ImportJob', on_delete=models.PROTECT, null=True)
read = models.BooleanField(default=False)
notification_type = models.CharField(
max_length=255, choices=NotificationType.choices)
class Meta:
''' checks if notifcation is in enum list for valid types '''
constraints = [
models.CheckConstraint(
check=models.Q(notification_type__in=NotificationType.values),
name="notification_type_valid",
)
]

View file

@ -17,7 +17,9 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
@classmethod @classmethod
def book_queryset(cls, identifier): def book_queryset(cls, identifier):
''' county of books associated with this tag ''' ''' county of books associated with this tag '''
return cls.objects.filter(identifier=identifier) return cls.objects.filter(
identifier=identifier
).order_by('-updated_date')
@property @property
def collection_queryset(self): def collection_queryset(self):
@ -64,7 +66,7 @@ class UserTag(BookWyrmModel):
id='%s#remove' % self.remote_id, id='%s#remove' % self.remote_id,
actor=user.remote_id, actor=user.remote_id,
object=self.book.to_activity(), object=self.book.to_activity(),
target=self.to_activity(), target=self.remote_id,
).serialize() ).serialize()

View file

@ -1,6 +1,8 @@
''' database schema for user data ''' ''' database schema for user data '''
import re
from urllib.parse import urlparse from urllib.parse import urlparse
from django.apps import apps
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.dispatch import receiver from django.dispatch import receiver
@ -12,6 +14,7 @@ from bookwyrm.models.status import Status, Review
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
from bookwyrm.signatures import create_key_pair from bookwyrm.signatures import create_key_pair
from bookwyrm.tasks import app from bookwyrm.tasks import app
from bookwyrm.utils import regex
from .base_model import OrderedCollectionPageMixin from .base_model import OrderedCollectionPageMixin
from .base_model import ActivitypubMixin, BookWyrmModel from .base_model import ActivitypubMixin, BookWyrmModel
from .federated_server import FederatedServer from .federated_server import FederatedServer
@ -42,18 +45,20 @@ class User(OrderedCollectionPageMixin, AbstractUser):
blank=True, blank=True,
) )
outbox = fields.RemoteIdField(unique=True) outbox = fields.RemoteIdField(unique=True)
summary = fields.TextField(default='') summary = fields.HtmlField(null=True, blank=True)
local = models.BooleanField(default=False) local = models.BooleanField(default=False)
bookwyrm_user = fields.BooleanField(default=True) bookwyrm_user = fields.BooleanField(default=True)
localname = models.CharField( localname = models.CharField(
max_length=255, max_length=255,
null=True, null=True,
unique=True unique=True,
validators=[fields.validate_localname],
) )
# name is your display name, which you can change at will # name is your display name, which you can change at will
name = fields.CharField(max_length=100, default='') name = fields.CharField(max_length=100, null=True, blank=True)
avatar = fields.ImageField( avatar = fields.ImageField(
upload_to='avatars/', blank=True, null=True, activitypub_field='icon') upload_to='avatars/', blank=True, null=True,
activitypub_field='icon', alt_field='alt_text')
followers = fields.ManyToManyField( followers = fields.ManyToManyField(
'self', 'self',
link_only=True, link_only=True,
@ -90,20 +95,37 @@ class User(OrderedCollectionPageMixin, AbstractUser):
last_active_date = models.DateTimeField(auto_now=True) last_active_date = models.DateTimeField(auto_now=True)
manually_approves_followers = fields.BooleanField(default=False) manually_approves_followers = fields.BooleanField(default=False)
name_field = 'username'
@property
def alt_text(self):
''' alt text with username '''
return 'avatar for %s' % (self.localname or self.username)
@property @property
def display_name(self): def display_name(self):
''' show the cleanest version of the user's name possible ''' ''' show the cleanest version of the user's name possible '''
if self.name != '': if self.name and self.name != '':
return self.name return self.name
return self.localname or self.username return self.localname or self.username
activity_serializer = activitypub.Person activity_serializer = activitypub.Person
def to_outbox(self, **kwargs): def to_outbox(self, filter_type=None, **kwargs):
''' an ordered collection of statuses ''' ''' an ordered collection of statuses '''
queryset = Status.objects.filter( if filter_type:
filter_class = apps.get_model(
'bookwyrm.%s' % filter_type, require_ready=True)
if not issubclass(filter_class, Status):
raise TypeError(
'filter_status_class must be a subclass of models.Status')
queryset = filter_class.objects
else:
queryset = Status.objects
queryset = queryset.filter(
user=self, user=self,
deleted=False, deleted=False,
privacy__in=['public', 'unlisted'],
).select_subclasses().order_by('-published_date') ).select_subclasses().order_by('-published_date')
return self.to_ordered_collection(queryset, \ return self.to_ordered_collection(queryset, \
remote_id=self.outbox, **kwargs) remote_id=self.outbox, **kwargs)
@ -111,14 +133,22 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def to_following_activity(self, **kwargs): def to_following_activity(self, **kwargs):
''' activitypub following list ''' ''' activitypub following list '''
remote_id = '%s/following' % self.remote_id remote_id = '%s/following' % self.remote_id
return self.to_ordered_collection(self.following.all(), \ return self.to_ordered_collection(
remote_id=remote_id, id_only=True, **kwargs) self.following.order_by('-updated_date').all(),
remote_id=remote_id,
id_only=True,
**kwargs
)
def to_followers_activity(self, **kwargs): def to_followers_activity(self, **kwargs):
''' activitypub followers list ''' ''' activitypub followers list '''
remote_id = '%s/followers' % self.remote_id remote_id = '%s/followers' % self.remote_id
return self.to_ordered_collection(self.followers.all(), \ return self.to_ordered_collection(
remote_id=remote_id, id_only=True, **kwargs) self.followers.order_by('-updated_date').all(),
remote_id=remote_id,
id_only=True,
**kwargs
)
def to_activity(self): def to_activity(self):
''' override default AP serializer to add context object ''' override default AP serializer to add context object
@ -140,26 +170,28 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
''' populate fields for new local users ''' ''' populate fields for new local users '''
# this user already exists, no need to populate fields # this user already exists, no need to populate fields
if self.id: if not self.local and not re.match(regex.full_username, self.username):
return super().save(*args, **kwargs)
if not self.local:
# generate a username that uses the domain (webfinger format) # generate a username that uses the domain (webfinger format)
actor_parts = urlparse(self.remote_id) actor_parts = urlparse(self.remote_id)
self.username = '%s@%s' % (self.username, actor_parts.netloc) self.username = '%s@%s' % (self.username, actor_parts.netloc)
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
if self.id or not self.local:
return super().save(*args, **kwargs)
# populate fields for local users # populate fields for local users
self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.username) self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.localname)
self.localname = self.username
self.username = '%s@%s' % (self.username, DOMAIN)
self.actor = self.remote_id
self.inbox = '%s/inbox' % self.remote_id self.inbox = '%s/inbox' % self.remote_id
self.shared_inbox = 'https://%s/inbox' % DOMAIN self.shared_inbox = 'https://%s/inbox' % DOMAIN
self.outbox = '%s/outbox' % self.remote_id self.outbox = '%s/outbox' % self.remote_id
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
@property
def local_path(self):
''' this model doesn't inherit bookwyrm model, so here we are '''
return '/user/%s' % (self.localname or self.username)
class KeyPair(ActivitypubMixin, BookWyrmModel): class KeyPair(ActivitypubMixin, BookWyrmModel):
''' public and private keys for a user ''' ''' public and private keys for a user '''
@ -265,7 +297,7 @@ def get_or_create_remote_server(domain):
@app.task @app.task
def get_remote_reviews(outbox): def get_remote_reviews(outbox):
''' ingest reviews by a new remote bookwyrm user ''' ''' ingest reviews by a new remote bookwyrm user '''
outbox_page = outbox + '?page=true' outbox_page = outbox + '?page=true&type=Review'
data = get_data(outbox_page) data = get_data(outbox_page)
# TODO: pagination? # TODO: pagination?

View file

@ -2,14 +2,18 @@
import re import re
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.http import HttpResponseNotFound, JsonResponse from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET
from markdown import markdown
from requests import HTTPError from requests import HTTPError
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm import models from bookwyrm import models
from bookwyrm.connectors import get_data, ConnectorException from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.broadcast import broadcast from bookwyrm.broadcast import broadcast
from bookwyrm.sanitize_html import InputHtmlParser
from bookwyrm.status import create_notification from bookwyrm.status import create_notification
from bookwyrm.status import create_generated_note from bookwyrm.status import create_generated_note
from bookwyrm.status import delete_status from bookwyrm.status import delete_status
@ -18,19 +22,16 @@ from bookwyrm.utils import regex
@csrf_exempt @csrf_exempt
@require_GET
def outbox(request, username): def outbox(request, username):
''' outbox for the requested user ''' ''' outbox for the requested user '''
if request.method != 'GET': user = get_object_or_404(models.User, localname=username)
return HttpResponseNotFound() filter_type = request.GET.get('type')
if filter_type not in models.status_models:
filter_type = None
try:
user = models.User.objects.get(localname=username)
except models.User.DoesNotExist:
return HttpResponseNotFound()
# collection overview
return JsonResponse( return JsonResponse(
user.to_outbox(**request.GET), user.to_outbox(**request.GET, filter_type=filter_type),
encoder=activitypub.ActivityEncoder encoder=activitypub.ActivityEncoder
) )
@ -40,6 +41,9 @@ def handle_remote_webfinger(query):
user = None user = None
# usernames could be @user@domain or user@domain # usernames could be @user@domain or user@domain
if not query:
return None
if query[0] == '@': if query[0] == '@':
query = query[1:] query = query[1:]
@ -162,22 +166,23 @@ def handle_imported_book(user, item, include_reviews, privacy):
if not item.book: if not item.book:
return return
if item.shelf: existing_shelf = models.ShelfBook.objects.filter(
book=item.book, added_by=user).exists()
# shelve the book if it hasn't been shelved already
if item.shelf and not existing_shelf:
desired_shelf = models.Shelf.objects.get( desired_shelf = models.Shelf.objects.get(
identifier=item.shelf, identifier=item.shelf,
user=user user=user
) )
# shelve the book if it hasn't been shelved already shelf_book = models.ShelfBook.objects.create(
shelf_book, created = models.ShelfBook.objects.get_or_create(
book=item.book, shelf=desired_shelf, added_by=user) book=item.book, shelf=desired_shelf, added_by=user)
if created: broadcast(user, shelf_book.to_add_activity(user), privacy=privacy)
broadcast(user, shelf_book.to_add_activity(user), privacy=privacy)
# only add new read-throughs if the item isn't already shelved for read in item.reads:
for read in item.reads: read.book = item.book
read.book = item.book read.user = user
read.user = user read.save()
read.save()
if include_reviews and (item.rating or item.review): if include_reviews and (item.rating or item.review):
review_title = 'Review of {!r} on Goodreads'.format( review_title = 'Review of {!r} on Goodreads'.format(
@ -209,15 +214,72 @@ def handle_delete_status(user, status):
def handle_status(user, form): def handle_status(user, form):
''' generic handler for statuses ''' ''' generic handler for statuses '''
status = form.save() status = form.save(commit=False)
if not status.sensitive and status.content_warning:
# the cw text field remains populated when you click "remove"
status.content_warning = None
status.save()
# inspect the text for user tags # inspect the text for user tags
text = status.content content = status.content
matches = re.finditer( for (mention_text, mention_user) in find_mentions(content):
regex.username, # add them to status mentions fk
text status.mention_users.add(mention_user)
)
for match in matches: # turn the mention into a link
content = re.sub(
r'%s([^@]|$)' % mention_text,
r'<a href="%s">%s</a>\g<1>' % \
(mention_user.remote_id, mention_text),
content)
# add reply parent to mentions and notify
if status.reply_parent:
status.mention_users.add(status.reply_parent.user)
for mention_user in status.reply_parent.mention_users.all():
status.mention_users.add(mention_user)
if status.reply_parent.user.local:
create_notification(
status.reply_parent.user,
'REPLY',
related_user=user,
related_status=status
)
# deduplicate mentions
status.mention_users.set(set(status.mention_users.all()))
# create mention notifications
for mention_user in status.mention_users.all():
if status.reply_parent and mention_user == status.reply_parent.user:
continue
if mention_user.local:
create_notification(
mention_user,
'MENTION',
related_user=user,
related_status=status
)
# don't apply formatting to generated notes
if not isinstance(status, models.GeneratedNote):
status.content = to_markdown(content)
# do apply formatting to quotes
if hasattr(status, 'quote'):
status.quote = to_markdown(status.quote)
status.save()
broadcast(user, status.to_create_activity(user), software='bookwyrm')
# re-format the activity for non-bookwyrm servers
remote_activity = status.to_create_activity(user, pure=True)
broadcast(user, remote_activity, software='other')
def find_mentions(content):
''' detect @mentions in raw status content '''
for match in re.finditer(regex.strict_username, content):
username = match.group().strip().split('@')[1:] username = match.group().strip().split('@')[1:]
if len(username) == 1: if len(username) == 1:
# this looks like a local user (@user), fill in the domain # this looks like a local user (@user), fill in the domain
@ -228,48 +290,21 @@ def handle_status(user, form):
if not mention_user: if not mention_user:
# we can ignore users we don't know about # we can ignore users we don't know about
continue continue
# add them to status mentions fk yield (match.group(), mention_user)
status.mention_users.add(mention_user)
# create notification if the mentioned user is local
if mention_user.local:
create_notification(
mention_user,
'MENTION',
related_user=user,
related_status=status
)
status.save()
# notify reply parent or tagged users
if status.reply_parent and status.reply_parent.user.local:
create_notification(
status.reply_parent.user,
'REPLY',
related_user=user,
related_status=status
)
broadcast(user, status.to_create_activity(user), software='bookwyrm')
# re-format the activity for non-bookwyrm servers
if hasattr(status, 'pure_activity_serializer'):
remote_activity = status.to_create_activity(user, pure=True)
broadcast(user, remote_activity, software='other')
def handle_tag(user, tag): def to_markdown(content):
''' tag a book ''' ''' catch links and convert to markdown '''
broadcast(user, tag.to_add_activity(user)) content = re.sub(
r'([^(href=")])(https?:\/\/([A-Za-z\.\-_\/]+' \
r'\.[A-Za-z]{2,}[A-Za-z\.\-_\/]+))',
def handle_untag(user, book, name): r'\g<1><a href="\g<2>">\g<3></a>',
''' tag a book ''' content)
book = models.Book.objects.get(id=book) content = markdown(content)
tag = models.Tag.objects.get(name=name, book=book, user=user) # sanitize resulting html
tag_activity = tag.to_remove_activity(user) sanitizer = InputHtmlParser()
tag.delete() sanitizer.feed(content)
return sanitizer.get_output()
broadcast(user, tag_activity)
def handle_favorite(user, status): def handle_favorite(user, status):
@ -312,15 +347,19 @@ def handle_unfavorite(user, status):
def handle_boost(user, status): def handle_boost(user, status):
''' a user wishes to boost a status ''' ''' a user wishes to boost a status '''
# is it boostable?
if not status.boostable:
return
if models.Boost.objects.filter( if models.Boost.objects.filter(
boosted_status=status, user=user).exists(): boosted_status=status, user=user).exists():
# you already boosted that. # you already boosted that.
return return
boost = models.Boost.objects.create( boost = models.Boost.objects.create(
boosted_status=status, boosted_status=status,
privacy=status.privacy,
user=user, user=user,
) )
boost.save()
boost_activity = boost.to_activity() boost_activity = boost.to_activity()
broadcast(user, boost_activity) broadcast(user, boost_activity)
@ -344,9 +383,9 @@ def handle_unboost(user, status):
broadcast(user, activity) broadcast(user, activity)
def handle_update_book(user, book): def handle_update_book_data(user, item):
''' broadcast the news about our book ''' ''' broadcast the news about our book '''
broadcast(user, book.to_update_activity(user)) broadcast(user, item.to_update_activity(user))
def handle_update_user(user): def handle_update_user(user):

View file

@ -1,12 +1,16 @@
''' html parser to clean up incoming text from unknown sources ''' ''' html parser to clean up incoming text from unknown sources '''
from html.parser import HTMLParser from html.parser import HTMLParser
class InputHtmlParser(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): def __init__(self):
HTMLParser.__init__(self) HTMLParser.__init__(self)
self.allowed_tags = ['p', 'b', 'i', 'pre', 'a', 'span'] self.allowed_tags = [
'p', 'br',
'b', 'i', 'strong', 'em', 'pre',
'a', 'span', 'ul', 'ol', 'li'
]
self.tag_stack = [] self.tag_stack = []
self.output = [] self.output = []
# if the html appears invalid, we just won't allow any at all # if the html appears invalid, we just won't allow any at all

View file

@ -3,8 +3,11 @@ import os
from environs import Env from environs import Env
import requests
env = Env() env = Env()
DOMAIN = env('DOMAIN') DOMAIN = env('DOMAIN')
VERSION = '0.0.1'
PAGE_LENGTH = env('PAGE_LENGTH', 15) PAGE_LENGTH = env('PAGE_LENGTH', 15)
@ -99,10 +102,6 @@ BOOKWYRM_DBS = {
'HOST': env('POSTGRES_HOST', ''), 'HOST': env('POSTGRES_HOST', ''),
'PORT': 5432 'PORT': 5432
}, },
'sqlite': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'fedireads.db')
}
} }
DATABASES = { DATABASES = {
@ -154,3 +153,6 @@ STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, env('STATIC_ROOT', 'static')) STATIC_ROOT = os.path.join(BASE_DIR, env('STATIC_ROOT', 'static'))
MEDIA_URL = '/images/' MEDIA_URL = '/images/'
MEDIA_ROOT = os.path.join(BASE_DIR, env('MEDIA_ROOT', 'images')) MEDIA_ROOT = os.path.join(BASE_DIR, env('MEDIA_ROOT', 'images'))
USER_AGENT = "%s (BookWyrm/%s; +https://%s/)" % (
requests.utils.default_user_agent(), VERSION, DOMAIN)

View file

@ -65,6 +65,14 @@ input.toggle-control:checked ~ .modal.toggle-content {
.cover-container { .cover-container {
height: 250px; height: 250px;
width: max-content; width: max-content;
max-width: 250px;
}
.cover-container.is-large {
height: max-content;
max-width: 500px;
}
.cover-container.is-large img {
max-height: 500px;
} }
.cover-container.is-medium { .cover-container.is-medium {
height: 150px; height: 150px;
@ -136,8 +144,3 @@ input.toggle-control:checked ~ .modal.toggle-content {
content: "\e904"; content: "\e904";
right: 0; right: 0;
} }
/* --- BLOCKQUOTE --- */
blockquote {
white-space: pre-line;
}

View file

@ -1,7 +1,7 @@
''' Handle user activity ''' ''' Handle user activity '''
from django.utils import timezone from django.utils import timezone
from bookwyrm import activitypub, books_manager, models from bookwyrm import models
from bookwyrm.sanitize_html import InputHtmlParser from bookwyrm.sanitize_html import InputHtmlParser

View file

@ -1,14 +1,32 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% load fr_display %} {% load bookwyrm_tags %}
{% block content %} {% block content %}
<div class="block"> <div class="block">
<h1 class="title">{{ author.name }}</h1> <div class="columns">
<div class="column">
<h1 class="title">{{ author.name }}</h1>
</div>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
<div class="column is-narrow">
<a href="{{ author.local_path }}/edit">
<span class="icon icon-pencil">
<span class="is-sr-only">Edit Author</span>
</span>
</a>
</div>
{% endif %}
</div>
</div>
<div class="block">
{% if author.bio %} {% if author.bio %}
<p> <p>
{{ author.bio }} {{ author.bio | to_markdown | safe }}
</p> </p>
{% endif %} {% endif %}
{% if author.wikipedia_link %}
<p><a href="{{ author.wikipedia_link }}" rel=”noopener” target="_blank">Wikipedia</a></p>
{% endif %}
</div> </div>
<div class="block"> <div class="block">

View file

@ -1,16 +1,24 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% load fr_display %} {% load bookwyrm_tags %}
{% load humanize %} {% load humanize %}
{% block content %} {% block content %}
<div class="block"> <div class="block">
<div class="level"> <div class="columns">
<h1 class="title level-left"> <div class="column">
<span>{% include 'snippets/book_titleby.html' with book=book %}</span> <h1 class="title">
</h1> {{ book.title }}{% if book.subtitle %}:
<small>{{ book.subtitle }}</small>{% endif %}
</h1>
{% if book.authors %}
<h2 class="subtitle">
by {% include 'snippets/authors.html' with book=book %}
</h2>
{% endif %}
</div>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %} {% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
<div class="level-right"> <div class="column is-narrow">
<a href="{{ book.id }}/edit"> <a href="{{ book.id }}/edit">
<span class="icon icon-pencil"> <span class="icon icon-pencil">
<span class="is-sr-only">Edit Book</span> <span class="is-sr-only">Edit Book</span>
@ -91,87 +99,26 @@
{% endif %} {% endif %}
</div> </div>
{% for readthrough in readthroughs %} {# user's relationship to the book #}
<div class="content block">
<input class="toggle-control" type="radio" name="show-edit-readthrough" id="show-readthrough-{{ readthrough.id }}" checked>
<div class="toggle-content hidden">
<dl>
{% if readthrough.start_date %}
<dt>Started reading:</dt>
<dd>{{ readthrough.start_date | naturalday }}</dd>
{% endif %}
{% if readthrough.finish_date %}
<dt>Finished reading:</dt>
<dd>{{ readthrough.finish_date | naturalday }}</dd>
{% endif %}
</dl>
<div class="field is-grouped">
<label class="button is-small" for="edit-readthrough-{{ readthrough.id }}" role="button" tabindex="0">
<span class="icon icon-pencil">
<span class="is-sr-only">Edit read-through dates</span>
</span>
</label>
<label class="button is-small" for="delete-readthrough-{{ readthrough.id }}" role="button" tabindex="0">
<span class="icon icon-x">
<span class="is-sr-only">Delete this read-through</span>
</span>
</label>
</div>
</div>
</div>
<div class="block">
<input class="toggle-control" type="radio" name="show-edit-readthrough" id="edit-readthrough-{{ readthrough.id }}">
<div class="toggle-content hidden">
<div class="box">
<form name="edit-readthrough" action="/edit-readthrough" method="post">
{% csrf_token %}
<input type="hidden" name="id" value="{{ readthrough.id }}">
<div class="field">
<label class="label">
Started reading
<input type="date" name="start_date" class="input" id="id_start_date-{{ readthrough.id }}" value="{{ readthrough.start_date | date:"Y-m-d" }}">
</label>
</div>
<div class="field">
<label class="label">
Finished reading
<input type="date" name="finish_date" class="input" id="id_finish_date-{{ readthrough.id }}" value="{{ readthrough.finish_date | date:"Y-m-d" }}">
</label>
</div>
<div class="field is-grouped">
<button class="button is-primary" type="submit">Save</button>
<label class="button" for="show-readthrough-{{ readthrough.id }}" role="button" tabindex="0">Cancel</label>
</div>
</form>
</div>
</div>
</div>
<div> <div>
<input class="toggle-control" type="checkbox" name="delete-readthrough-{{ readthrough.id }}" id="delete-readthrough-{{ readthrough.id }}"> {% for shelf in user_shelves %}
<div class="modal toggle-content hidden"> <p>
<div class="modal-background"></div> This edition is on your <a href="/user/{{ user.localname }}/shelf/{{ shelf.shelf.identifier }}">{{ shelf.shelf.name }}</a> shelf.
<div class="modal-card"> {% include 'snippets/shelf_selector.html' with current=shelf.shelf %}
<header class="modal-card-head"> </p>
<p class="modal-card-title">Delete this read-though?</p> {% endfor %}
<label class="delete" for="delete-readthrough-{{ readthrough.id }}" aria-label="close"></label>
</header> {% for shelf in other_edition_shelves %}
<footer class="modal-card-foot"> <p>
<form name="delete-readthrough-{{ readthrough.id }}" action="/delete-readthrough" method="POST"> A <a href="/book/{{ shelf.book.id }}">different edition</a> of this book is on your <a href="/user/{{ user.localname }}/shelf/{{ shelf.shelf.identifier }}">{{ shelf.shelf.name }}</a> shelf.
{% csrf_token %} {% include 'snippets/switch_edition_button.html' with edition=book %}
<input type="hidden" name="id" value="{{ readthrough.id }}"> </p>
<button class="button is-danger is-light" type="submit"> {% endfor %}
Delete
</button> {% for readthrough in readthroughs %}
<label for="delete-readthrough-{{ readthrough.id }}" class="button" role="button" tabindex="0">Cancel</button> {% include 'snippets/readthrough.html' with readthrough=readthrough %}
</form> {% endfor %}
</footer>
</div>
<label class="modal-close is-large" for="delete-readthrough-{{ readthrough.id }}" aria-label="close"></label>
</div>
</div> </div>
{% endfor %}
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<div class="box"> <div class="box">
@ -219,10 +166,10 @@
{% for rating in ratings %} {% for rating in ratings %}
<div class="column"> <div class="column">
<div class="media"> <div class="media">
<div class="media-left">{% include 'snippets/avatar.html' %}</div> <div class="media-left">{% include 'snippets/avatar.html' with user=rating.user %}</div>
<div class="media-content"> <div class="media-content">
<div> <div>
{% include 'snippets/username.html' %} {% include 'snippets/username.html' with user=rating.user %}
</div> </div>
<div class="field is-grouped mb-0"> <div class="field is-grouped mb-0">
<div>rated it</div> <div>rated it</div>

View file

@ -0,0 +1,37 @@
{% extends 'layout.html' %}
{% block content %}
<div class="block">
<h1 class="title">Direct Messages</h1>
{% if not activities %}
<p>You have no messages right now.</p>
{% endif %}
{% for activity in activities %}
<div class="block">
{% include 'snippets/status.html' with status=activity %}
</div>
{% endfor %}
<nav class="pagination" role="navigation" aria-label="pagination">
{% if prev %}
<p class="pagination-previous">
<a href="{{ prev }}">
<span class="icon icon-arrow-left"></span>
Previous
</a>
</p>
{% endif %}
{% if next %}
<p class="pagination-next">
<a href="{{ next }}">
Next
<span class="icon icon-arrow-right"></span>
</a>
</p>
{% endif %}
</nav>
</div>
{% endblock %}

View file

@ -0,0 +1,80 @@
{% extends 'layout.html' %}
{% block content %}
{% if not request.user.is_authenticated %}
<div class="block">
<h1 class="title has-text-centered">{{ site.name }}: Social Reading and Reviewing</h1>
</div>
<section class="tile is-ancestor">
<div class="tile is-7 is-parent">
<div class="tile is-child box">
{% include 'snippets/about.html' %}
</div>
</div>
<div class="tile is-5 is-parent">
<div class="tile is-child box has-background-primary-light">
{% if site.allow_registration %}
<h2 class="title">Join {{ site.name }}</h2>
<form name="register" method="post" action="/user-register">
{% include 'snippets/register_form.html' %}
</form>
{% else %}
<h2 class="title">This instance is closed</h2>
<p>Contact an administrator to get an invite</p>
{% endif %}
</div>
</div>
</section>
{% else %}
<div class="block">
<h1 class="title has-text-centered">Discover</h1>
</div>
{% endif %}
<div class="block is-hidden-tablet">
<h2 class="title has-text-centered">Recent Books</h2>
</div>
<section class="tile is-ancestor">
<div class="tile is-vertical">
<div class="tile is-parent">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/large-book.html' with book=books.0 %}
</div>
</div>
<div class="tile">
<div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.1 %}
</div>
</div>
<div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.2 %}
</div>
</div>
</div>
</div>
<div class="tile is-vertical">
<div class="tile">
<div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.3 %}
</div>
</div>
<div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.4 %}
</div>
</div>
</div>
<div class="tile is-parent">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/large-book.html' with book=books.5 %}
</div>
</div>
</div>
</section>
{% endblock %}

View file

@ -0,0 +1,89 @@
{% extends 'layout.html' %}
{% load humanize %}
{% block content %}
<div class="block">
<div class="level">
<h1 class="title level-left">
Edit "{{ author.name }}"
</h1>
<div class="level-right">
<a href="/author/{{ author.id }}">
<span class="edit-link icon icon-close">
<span class="is-sr-only">Close</span>
</span>
</a>
</div>
</div>
<div>
<p>Added: {{ author.created_date | naturaltime }}</p>
<p>Updated: {{ author.updated_date | naturaltime }}</p>
<p>Last edited by: <a href="{{ author.last_edited_by.remote_id }}">{{ author.last_edited_by.display_name }}</a></p>
</div>
</div>
{% if form.non_field_errors %}
<div class="block">
<p class="notification is-danger">{{ form.non_field_errors }}</p>
</div>
{% endif %}
<form class="block" name="edit-author" action="/edit-author/{{ author.id }}" method="post">
{% csrf_token %}
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}">
<div class="columns">
<div class="column">
<h2 class="title is-4">Metadata</h2>
<p><label class="label" for="id_name">Name:</label> {{ form.name }}</p>
{% for error in form.name.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p><label class="label" for="id_bio">Bio:</label> {{ form.bio }}</p>
{% for error in form.bio.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p><label class="label" for="id_wikipedia_link">Wikipedia link:</label> {{ form.wikipedia_link }}</p>
{% for error in form.wikipedia_link.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p><label class="label" for="id_born">Birth date:</label> {{ form.born }}</p>
{% for error in form.born.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p><label class="label" for="id_died">Death date:</label> {{ form.died }}</p>
{% for error in form.died.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<div class="column">
<h2 class="title is-4">Author Identifiers</h2>
<p><label class="label" for="id_openlibrary_key">Openlibrary key:</label> {{ form.openlibrary_key }}</p>
{% for error in form.openlibrary_key.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p><label class="label" for="id_librarything_key">Librarything key:</label> {{ form.librarything_key }}</p>
{% for error in form.librarything_key.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p><label class="label" for="id_goodreads_key">Goodreads key:</label> {{ form.goodreads_key }}</p>
{% for error in form.goodreads_key.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
</div>
<div class="block">
<button class="button is-primary" type="submit">Save</button>
<a class="button" href="/author/{{ author.id }}">Cancel</a>
</div>
</form>
{% endblock %}

View file

@ -17,63 +17,51 @@
<div> <div>
<p>Added: {{ book.created_date | naturaltime }}</p> <p>Added: {{ book.created_date | naturaltime }}</p>
<p>Updated: {{ book.updated_date | naturaltime }}</p> <p>Updated: {{ book.updated_date | naturaltime }}</p>
<p>Last edited by: <a href="{{ book.last_edited_by.remote_id }}">{{ book.last_edited_by.display_name }}</a></p>
</div> </div>
</div> </div>
{% if login_form.non_field_errors %} {% if form.non_field_errors %}
<div class="block"> <div class="block">
<p class="notification is-danger">{{ login_form.non_field_errors }}</p> <p class="notification is-danger">{{ form.non_field_errors }}</p>
</div> </div>
{% endif %} {% endif %}
<form class="block" name="edit-book" action="/edit-book/{{ book.id }}" method="post" enctype="multipart/form-data"> <form class="block" name="edit-book" action="/edit-book/{{ book.id }}" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<div class="block"> <input type="hidden" name="last_edited_by" value="{{ request.user.id }}">
<h2 class="title is-4">Data sync
<p class="subtitle is-6">If sync is enabled, any changes will be over-written</p>
</h2>
<div class="columns">
<div class="column is-narrow">
<label class="checkbox" for="id_sync"><input class="checkbox" type="checkbox" name="sync" id="id_sync"> Sync</label>
</div>
<div class="column is-narrow">
<label class="checkbox" for="id_sync_cover"><input class="checkbox" type="checkbox" name="sync_cover" id="id_sync_cover"> Sync cover</label>
</div>
</div>
</div>
<div class="columns"> <div class="columns">
<div class="column"> <div class="column">
<h2 class="title is-4">Metadata</h2> <h2 class="title is-4">Metadata</h2>
<p class="fields is-grouped"><label class="label"for="id_title">Title:</label> {{ form.title }} </p> <p class="fields is-grouped"><label class="label" for="id_title">Title:</label> {{ form.title }} </p>
{% for error in form.title.errors %} {% for error in form.title.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
<p class="fields is-grouped"><label class="label"for="id_sort_title">Sort title:</label> {{ form.sort_title }} </p> <p class="fields is-grouped"><label class="label" for="id_sort_title">Sort title:</label> {{ form.sort_title }} </p>
{% for error in form.sort_title.errors %} {% for error in form.sort_title.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
<p class="fields is-grouped"><label class="label"for="id_subtitle">Subtitle:</label> {{ form.subtitle }} </p> <p class="fields is-grouped"><label class="label" for="id_subtitle">Subtitle:</label> {{ form.subtitle }} </p>
{% for error in form.subtitle.errors %} {% for error in form.subtitle.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
<p class="fields is-grouped"><label class="label"for="id_description">Description:</label> {{ form.description }} </p> <p class="fields is-grouped"><label class="label" for="id_description">Description:</label> {{ form.description }} </p>
{% for error in form.description.errors %} {% for error in form.description.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
<p class="fields is-grouped"><label class="label"for="id_series">Series:</label> {{ form.series }} </p> <p class="fields is-grouped"><label class="label" for="id_series">Series:</label> {{ form.series }} </p>
{% for error in form.series.errors %} {% for error in form.series.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
<p class="fields is-grouped"><label class="label"for="id_series_number">Series number:</label> {{ form.series_number }} </p> <p class="fields is-grouped"><label class="label" for="id_series_number">Series number:</label> {{ form.series_number }} </p>
{% for error in form.series_number.errors %} {% for error in form.series_number.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
<p class="fields is-grouped"><label class="label"for="id_first_published_date">First published date:</label> {{ form.first_published_date }} </p> <p class="fields is-grouped"><label class="label" for="id_first_published_date">First published date:</label> {{ form.first_published_date }} </p>
{% for error in form.first_published_date.errors %} {% for error in form.first_published_date.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
<p class="fields is-grouped"><label class="label"for="id_published_date">Published date:</label> {{ form.published_date }} </p> <p class="fields is-grouped"><label class="label" for="id_published_date">Published date:</label> {{ form.published_date }} </p>
{% for error in form.published_date.errors %} {% for error in form.published_date.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
@ -97,7 +85,7 @@
<div class="block"> <div class="block">
<h2 class="title is-4">Physical Properties</h2> <h2 class="title is-4">Physical Properties</h2>
<p class="fields is-grouped"><label class="label"for="id_physical_format">Format:</label> {{ form.physical_format }} </p> <p class="fields is-grouped"><label class="label" for="id_physical_format">Format:</label> {{ form.physical_format }} </p>
{% for error in form.physical_format.errors %} {% for error in form.physical_format.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
@ -105,7 +93,7 @@
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
<p class="fields is-grouped"><label class="label"for="id_pages">Pages:</label> {{ form.pages }} </p> <p class="fields is-grouped"><label class="label" for="id_pages">Pages:</label> {{ form.pages }} </p>
{% for error in form.pages.errors %} {% for error in form.pages.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
@ -113,23 +101,23 @@
<div class="block"> <div class="block">
<h2 class="title is-4">Book Identifiers</h2> <h2 class="title is-4">Book Identifiers</h2>
<p class="fields is-grouped"><label class="label"for="id_isbn_13">ISBN 13:</label> {{ form.isbn_13 }} </p> <p class="fields is-grouped"><label class="label" for="id_isbn_13">ISBN 13:</label> {{ form.isbn_13 }} </p>
{% for error in form.isbn_13.errors %} {% for error in form.isbn_13.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
<p class="fields is-grouped"><label class="label"for="id_isbn_10">ISBN 10:</label> {{ form.isbn_10 }} </p> <p class="fields is-grouped"><label class="label" for="id_isbn_10">ISBN 10:</label> {{ form.isbn_10 }} </p>
{% for error in form.isbn_10.errors %} {% for error in form.isbn_10.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
<p class="fields is-grouped"><label class="label"for="id_openlibrary_key">Openlibrary key:</label> {{ form.openlibrary_key }} </p> <p class="fields is-grouped"><label class="label" for="id_openlibrary_key">Openlibrary key:</label> {{ form.openlibrary_key }} </p>
{% for error in form.openlibrary_key.errors %} {% for error in form.openlibrary_key.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
<p class="fields is-grouped"><label class="label"for="id_librarything_key">Librarything key:</label> {{ form.librarything_key }} </p> <p class="fields is-grouped"><label class="label" for="id_librarything_key">Librarything key:</label> {{ form.librarything_key }} </p>
{% for error in form.librarything_key.errors %} {% for error in form.librarything_key.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
<p class="fields is-grouped"><label class="label"for="id_goodreads_key">Goodreads key:</label> {{ form.goodreads_key }} </p> <p class="fields is-grouped"><label class="label" for="id_goodreads_key">Goodreads key:</label> {{ form.goodreads_key }} </p>
{% for error in form.goodreads_key.errors %} {% for error in form.goodreads_key.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}

View file

@ -1,5 +1,5 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% load fr_display %} {% load bookwyrm_tags %}
{% block content %} {% block content %}
<div class="block"> <div class="block">
<h1 class="title">Editions of <a href="/book/{{ work.id }}">"{{ work.title }}"</a></h1> <h1 class="title">Editions of <a href="/book/{{ work.id }}">"{{ work.title }}"</a></h1>

View file

@ -1,5 +1,5 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% load fr_display %} {% load bookwyrm_tags %}
{% block content %} {% block content %}
<div class="columns"> <div class="columns">
@ -44,16 +44,28 @@
<div> <div>
<input class="toggle-control" type="radio" name="recent-books" id="book-{{ book.id }}" {% if shelf_counter == 1 and forloop.first %}checked{% endif %}> <input class="toggle-control" type="radio" name="recent-books" id="book-{{ book.id }}" {% if shelf_counter == 1 and forloop.first %}checked{% endif %}>
<div class="toggle-content hidden" role="tabpanel" id="book-{{ book.id }}-panel"> <div class="toggle-content hidden" role="tabpanel" id="book-{{ book.id }}-panel">
<div class="block"> <div class="card">
{% include 'snippets/book_titleby.html' with book=book %} <div class="card-header">
{% include 'snippets/shelve_button.html' with book=book %} <p class="card-header-title">
<span>{% include 'snippets/book_titleby.html' with book=book %}</span>
</>
<div class="card-header-icon is-hidden-tablet">
<label class="delete" for="no-book" aria-label="close" role="button"></label>
</div>
</div>
<div class="card-content">
{% include 'snippets/shelve_button.html' with book=book %}
{% include 'snippets/create_status.html' with book=book %}
</div>
</div> </div>
{% include 'snippets/create_status.html' with book=book %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
<div>
<input class="toggle-control" type="radio" name="recent-books" id="no-book">
</div>
{% endif %} {% endif %}
</div> </div>

View file

@ -1,5 +1,5 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% load fr_display %} {% load bookwyrm_tags %}
{% block content %} {% block content %}
<div class="block"> <div class="block">
<h1 class="title"> <h1 class="title">

View file

@ -1,5 +1,5 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% load fr_display %} {% load bookwyrm_tags %}
{% block content %} {% block content %}
<div class="block"> <div class="block">
<h1 class="title"> <h1 class="title">
@ -31,7 +31,7 @@
</div> </div>
{% endfor %} {% endfor %}
{% if not following.count %} {% if not following.count %}
<div>No one is following {{ user|username }}</div> <div>{{ user|username }} isn't following any users</div>
{% endif %} {% endif %}
</div> </div>

View file

@ -21,8 +21,6 @@
</div> </div>
<button class="button is-primary" type="submit">Import</button> <button class="button is-primary" type="submit">Import</button>
</form> </form>
<p>
Imports are limited in size, and only the first {{ limit }} items will be imported.
</div> </div>
<div class="content block"> <div class="content block">

View file

@ -1,5 +1,5 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% load fr_display %} {% load bookwyrm_tags %}
{% load humanize %} {% load humanize %}
{% block content %} {% block content %}
<div class="block"> <div class="block">

View file

@ -1,4 +1,4 @@
{% load fr_display %} {% load bookwyrm_tags %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -18,7 +18,7 @@
</head> </head>
<body> <body>
<nav class="navbar" role="navigation" aria-label="main navigation"> <nav class="navbar container" role="navigation" aria-label="main navigation">
<div class="navbar-brand"> <div class="navbar-brand">
<a class="navbar-item" href="/"> <a class="navbar-item" href="/">
<img class="image logo" src="/static/images/logo-small.png" alt="Home page"> <img class="image logo" src="/static/images/logo-small.png" alt="Home page">
@ -63,30 +63,45 @@
<div class="navbar-end"> <div class="navbar-end">
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
<div class="navbar-link"><p> <div class="navbar-link" role="button" aria-expanded=false" onclick="toggleMenu(this)" tabindex="0" aria-haspopup="true" aria-controls="navbar-dropdown"><p>
{% include 'snippets/avatar.html' with user=request.user %} {% include 'snippets/avatar.html' with user=request.user %}
{% include 'snippets/username.html' with user=request.user %} {% include 'snippets/username.html' with user=request.user %}
</p></div> </p></div>
<div class="navbar-dropdown"> <ul class="navbar-dropdown" id="navbar-dropdown">
<a href="/user/{{request.user.localname}}" class="navbar-item"> <li>
Profile <a href="/direct-messages" class="navbar-item">
</a> Direct messages
<a href="/user-edit" class="navbar-item"> </a>
Settings </li>
</a> <li>
<a href="/import" class="navbar-item"> <a href="/user/{{request.user.localname}}" class="navbar-item">
Import books Profile
</a> </a>
</li>
<li>
<a href="/user-edit" class="navbar-item">
Settings
</a>
</li>
<li>
<a href="/import" class="navbar-item">
Import books
</a>
</li>
{% if perms.bookwyrm.create_invites %} {% if perms.bookwyrm.create_invites %}
<a href="/invite" class="navbar-item"> <li>
Invites <a href="/invite" class="navbar-item">
</a> Invites
</a>
</li>
{% endif %} {% endif %}
<hr class="navbar-divider"> <hr class="navbar-divider">
<a href="/logout" class="navbar-item"> <li>
Log out <a href="/logout" class="navbar-item">
</a> Log out
</div> </a>
</li>
</ul>
</div> </div>
<div class="navbar-item"> <div class="navbar-item">
<a href="/notifications"> <a href="/notifications">
@ -102,48 +117,73 @@
</div> </div>
</a> </a>
</div> </div>
{% else %} {% else %}
<div class="navbar-item"> <div class="navbar-item">
<div class="buttons"> {% if request.path != '/login' and request.path != '/login/' and request.path != '/user-login' %}
<a href="/login" class="button is-primary"> <div class="columns">
Join <div class="column">
</a> <form name="login" method="post" action="/user-login">
{% csrf_token %}
<div class="field is-grouped">
<div class="control">
<label class="is-sr-only" for="id_localname">Username:</label>
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname" placeholder="username">
</div>
<div class="control">
<label class="is-sr-only" for="id_password">Username:</label>
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="password">
<p class="help"><a href="/password-reset">Forgot your password?</a></p>
</div>
<button class="button is-primary" type="submit">Log in</button>
</div>
</form>
</div>
{% if site.allow_registration and request.path != '' and request.path != '/' %}
<div class="column is-narrow">
<a href="/" class="button is-link">
Join
</a>
</div>
{% endif %}
</div> </div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</nav> </nav>
<div class="section"> <div class="section container">
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</div> </div>
<div class="footer"> <footer class="footer">
<div class="columns"> <div class="container">
<div class="column"> <div class="columns">
<p> <div class="column">
<a href="/about">About this server</a> <p>
</p> <a href="/about">About this server</a>
{% if site.admin_email %} </p>
<p> {% if site.admin_email %}
<a href="mailto:{{ site.admin_email }}">Contact site admin</a> <p>
</p> <a href="mailto:{{ site.admin_email }}">Contact site admin</a>
</p>
{% endif %}
</div>
{% if site.support_link %}
<div class="column">
<span class="icon icon-heart"></span>
Support {{ site.name }} on <a href="{{ site.support_link }}" target="_blank">{{ site.support_title }}</a>
</div>
{% endif %} {% endif %}
</div> <div class="column">
{% if site.support_link %} BookWyrm is open source software. You can contribute or report issues on <a href="https://github.com/mouse-reeve/bookwyrm">GitHub</a>.
<div class="column"> </div>
<span class="icon icon-heart"></span>
Support {{ site.name }} on <a href="{{ site.support_link }}" target="_blank">{{ site.support_title }}</a>
</div>
{% endif %}
<div class="column">
BookWyrm is open source software. You can contribute or report issues on <a href="https://github.com/mouse-reeve/bookwyrm">GitHub</a>.
</div> </div>
</div> </div>
</div> </footer>
<script> <script>
var csrf_token = '{{ csrf_token }}'; var csrf_token = '{{ csrf_token }}';
@ -151,4 +191,3 @@
<script src="/static/js/shared.js"></script> <script src="/static/js/shared.js"></script>
</body> </body>
</html> </html>

View file

@ -11,9 +11,9 @@
<form name="login" method="post" action="/user-login"> <form name="login" method="post" action="/user-login">
{% csrf_token %} {% csrf_token %}
<div class="field"> <div class="field">
<label class="label" for="id_username">Username:</label> <label class="label" for="id_localname">Username:</label>
<div class="control"> <div class="control">
{{ login_form.username }} {{ login_form.localname }}
</div> </div>
</div> </div>
<div class="field"> <div class="field">

View file

@ -1,6 +1,6 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% load humanize %} {% load humanize %}
{% load fr_display %} {% load bookwyrm_tags %}
{% block content %} {% block content %}
<div class="block"> <div class="block">
<h1 class="title">Notifications</h1> <h1 class="title">Notifications</h1>
@ -22,16 +22,16 @@
{% include 'snippets/username.html' with user=notification.related_user %} {% include 'snippets/username.html' with user=notification.related_user %}
{% if notification.notification_type == 'FAVORITE' %} {% if notification.notification_type == 'FAVORITE' %}
favorited your favorited your
<a href="{{ notification.related_status.remote_id}}">status</a> <a href="{{ notification.related_status.local_path }}">status</a>
{% elif notification.notification_type == 'MENTION' %} {% elif notification.notification_type == 'MENTION' %}
mentioned you in a mentioned you in a
<a href="{{ notification.related_status.remote_id}}">status</a> <a href="{{ notification.related_status.local_path }}">status</a>
{% elif notification.notification_type == 'REPLY' %} {% elif notification.notification_type == 'REPLY' %}
<a href="{{ notification.related_status.remote_id}}">replied</a> <a href="{{ notification.related_status.local_path }}">replied</a>
to your to your
<a href="{{ notification.related_status.reply_parent.remote_id}}">status</a> <a href="{{ notification.related_status.reply_parent.local_path }}">status</a>
{% elif notification.notification_type == 'FOLLOW' %} {% elif notification.notification_type == 'FOLLOW' %}
followed you followed you
{% elif notification.notification_type == 'FOLLOW_REQUEST' %} {% elif notification.notification_type == 'FOLLOW_REQUEST' %}
@ -41,7 +41,7 @@
</div> </div>
{% elif notification.notification_type == 'BOOST' %} {% elif notification.notification_type == 'BOOST' %}
boosted your <a href="{{ notification.related_status.remote_id}}">status</a> boosted your <a href="{{ notification.related_status.local_path }}">status</a>
{% endif %} {% endif %}
{% else %} {% else %}
your <a href="/import-status/{{ notification.related_import.id }}">import</a> completed. your <a href="/import-status/{{ notification.related_import.id }}">import</a> completed.
@ -54,7 +54,7 @@
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-white{% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %} has-text-black{% else %}-bis has-text-grey-dark{% endif %}{% endif %}"> <div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-white{% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %} has-text-black{% else %}-bis has-text-grey-dark{% endif %}{% endif %}">
<div class="columns"> <div class="columns">
<div class="column"> <div class="column">
<a href="{{ notification.related_status.remote_id }}">{{ notification.related_status.content | truncatewords_html:10 }}</a> <a href="{{ notification.related_status.local_path }}">{{ notification.related_status.content | safe | truncatewords_html:10 }}</a>
</div> </div>
<div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}"> <div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}">
{{ notification.related_status.published_date | post_date }} {{ notification.related_status.published_date | post_date }}

View file

@ -1,5 +1,5 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% load fr_display %} {% load bookwyrm_tags %}
{% block content %} {% block content %}
<div class="columns"> <div class="columns">
@ -122,7 +122,7 @@
<div class="block"> <div class="block">
<div> <div>
{% include 'snippets/shelf.html' with shelf=shelf ratings=ratings %} {% include 'snippets/shelf.html' with shelf=shelf books=books ratings=ratings %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -1,7 +1,12 @@
<h1 class="title">About {{ site.name }}</h1> <div class="columns">
<div class="block"> <div class="column is-narrow is-hidden-mobile">
<img src="/static/images/logo.png" alt="BookWyrm"> <figure class="block">
<img src="/static/images/logo.png" alt="BookWyrm">
</figure>
</div>
<div class="content">
<p class="block">
{{ site.instance_description | safe }}
</p>
</div>
</div> </div>
<p class="block">
{{ site.instance_description }}
</p>

View file

@ -1 +1 @@
<a href="/author/{{ book.authors.first.id }}" class="author">{{ book.authors.first.name }}</a> {% for author in book.authors.all %}<a href="/author/{{ author.id }}" class="author">{{ author.name }}</a>{% if not forloop.last %}, {% endif %}{% endfor %}

View file

@ -1,3 +1,3 @@
{% load fr_display %} {% load bookwyrm_tags %}
<img class="avatar image {% if large %}is-96x96{% else %}is-32x32{% endif %}" src="{% if user.avatar %}/images/{{ user.avatar }}{% else %}/static/images/default_avi.jpg{% endif %}" alt="avatar for {{ user|username }}"> <img class="avatar image {% if large %}is-96x96{% else %}is-32x32{% endif %}" src="{% if user.avatar %}/images/{{ user.avatar }}{% else %}/static/images/default_avi.jpg{% endif %}" alt="{{ user.alt_text }}">

View file

@ -1,13 +1,13 @@
{% load fr_display %} {% load bookwyrm_tags %}
<div class="cover-container is-{{ size }}"> <div class="cover-container is-{{ size }}">
{% if book.cover %} {% if book.cover %}
<img class="book-cover" src="/images/{{ book.cover }}" alt="{% include 'snippets/cover_alt.html' with book=book %}"> <img class="book-cover" src="/images/{{ book.cover }}" alt="{{ book.alt_text }}" title="{{ book.alt_text }}">
{% else %} {% else %}
<div class="no-cover book-cover"> <div class="no-cover book-cover">
<img class="book-cover" src="/static/images/no_cover.jpg" alt="No cover"> <img class="book-cover" src="/static/images/no_cover.jpg" alt="No cover">
<div> <div>
<p>{{ book.title }}</p> <p>{{ book.title }}</p>
<p>({{ book|edition_info }})</p> <p>({{ book.edition_info }})</p>
</div> </div>
</div> </div>
{% endif %} {% endif %}

View file

@ -1,4 +1,4 @@
{% load fr_display %} {% load bookwyrm_tags %}
<div class="columns"> <div class="columns">
<div class="column is-narrow"> <div class="column is-narrow">
<div> <div>

View file

@ -1,16 +1,11 @@
<div class="columns"> <div class="columns is-multiline">
{% for book in books %} {% for book in books %}
{% if forloop.counter0|divisibleby:"4" %}
</div>
<div class="columns">
{% endif %}
<div class="column is-narrow"> <div class="column is-narrow">
<div class="box"> <div class="box">
<a href="/book/{{ book.id }}"> <a href="/book/{{ book.id }}">
{% include 'snippets/book_cover.html' with book=book %} {% include 'snippets/book_cover.html' with book=book %}
</a> </a>
{% include 'snippets/rate_action.html' with user=request.user book=book %} {% include 'snippets/shelve_button.html' with book=book switch_mode=True %}
{% include 'snippets/shelve_button.html' with book=book %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View file

@ -1,9 +1,5 @@
<span> <a href="/book/{{ book.id }}">{{ book.title }}</a>
<a href="/book/{{ book.id }}">{{ book.title }}</a>
</span>
{% if book.authors %} {% if book.authors %}
<span> by {% include 'snippets/authors.html' with book=book %}
by {% include 'snippets/authors.html' with book=book %}
</span>
{% endif %} {% endif %}

View file

@ -1,8 +1,9 @@
{% load fr_display %} {% load bookwyrm_tags %}
{% with status.id|uuid as uuid %} {% with status.id|uuid as uuid %}
<form name="boost" action="/boost/{{ status.id }}" method="post" onsubmit="return interact(event)" class="boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}"> <form name="boost" action="/boost/{{ status.id }}" method="post" onsubmit="return interact(event)" class="boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
{% csrf_token %} {% csrf_token %}
<button class="button is-small" type="submit"> <button class="button is-small" type="submit" {% if not status.boostable %}disabled{% endif %}>
<span class="icon icon-boost"> <span class="icon icon-boost">
<span class="is-sr-only">Boost status</span> <span class="is-sr-only">Boost status</span>
</span> </span>

View file

@ -0,0 +1,20 @@
{% load bookwyrm_tags %}
{% with 0|uuid as uuid %}
<div class="control">
<div>
<input type="radio" class="toggle-control" name="sensitive" value="false" id="hide-spoilers-{{ uuid }}" {% if not parent_status.content_warning %}checked{% endif %}>
<div class="toggle-content hidden">
<label class="button is-small" role="button" tabindex="0" for="include-spoilers-{{ uuid }}">Add spoilers/content warning</label>
</div>
</div>
<div>
<input type="radio" class="toggle-control" id="include-spoilers-{{ uuid }}" name="sensitive" value="true" {% if parent_status.content_warning %}checked{% endif %}>
<div class="toggle-content hidden">
<label class="button is-small" role="button" tabindex="0" for="hide-spoilers-{{ uuid }}">Remove spoilers/content warning</label>
<label class="is-sr-only" for="id_content_warning_{{ uuid }}">Spoilers/content warning:</label>
<input type="text" name="content_warning" maxlength="255" class="input" id="id_content_warning_{{ uuid }}" placeholder="Spoilers ahead!"{% if parent_status.content_warning %} value="{{ parent_status.content_warning }}"{% endif %}>
</div>
</div>
</div>
{% endwith %}

View file

@ -1,2 +0,0 @@
{% load fr_display %}
'{{ book.title }}' Cover ({{ book|edition_info }})

View file

@ -1,5 +1,5 @@
{% load humanize %} {% load humanize %}
{% load fr_display %} {% load bookwyrm_tags %}
<div class="tabs is-boxed"> <div class="tabs is-boxed">
<ul role="tablist"> <ul role="tablist">

View file

@ -26,6 +26,9 @@
</div> </div>
</fieldset> </fieldset>
{% endif %} {% endif %}
{% include 'snippets/content_warning_field.html' %}
{% if type == 'quote' %} {% if type == 'quote' %}
<textarea name="quote" class="textarea" id="id_quote_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required></textarea> <textarea name="quote" class="textarea" id="id_quote_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required></textarea>
{% else %} {% else %}

View file

@ -0,0 +1,18 @@
{% load bookwyrm_tags %}
{% if book %}
<div class="columns">
<div class="column is-narrow">
{% include 'snippets/book_cover.html' with book=book size="large" %}
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %}
</div>
<div class="column">
<h3 class="title is-5"><a href="/book/{{ book.id }}">{{ book.title }}</a></h3>
{% if book.authors %}
<p class="subtitle is-5">by {% include 'snippets/authors.html' with book=book %}</p>
{% endif %}
{% if book|book_description %}
<blockquote class="content">{{ book|book_description|to_markdown|safe|truncatewords_html:50 }}</blockquote>
{% endif %}
</div>
</div>
{% endif %}

View file

@ -0,0 +1,11 @@
{% load bookwyrm_tags %}
{% if book %}
{% include 'snippets/book_cover.html' with book=book %}
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %}
<h3 class="title is-6"><a href="/book/{{ book.id }}">{{ book.title }}</a></h3>
{% if book.authors %}
<p class="subtitle is-6">by {% include 'snippets/authors.html' with book=book %}</p>
{% endif %}
{% endif %}

View file

@ -1,4 +1,4 @@
{% load fr_display %} {% load bookwyrm_tags %}
{% with status.id|uuid as uuid %} {% with status.id|uuid as uuid %}
<form name="favorite" action="/favorite/{{ status.id }}" method="POST" onsubmit="return interact(event)" class="fav-{{ status.id }}-{{ uuid }} {% if request.user|liked:status %}hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}"> <form name="favorite" action="/favorite/{{ status.id }}" method="POST" onsubmit="return interact(event)" class="fav-{{ status.id }}-{{ uuid }} {% if request.user|liked:status %}hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
{% csrf_token %} {% csrf_token %}

View file

@ -1,4 +1,4 @@
{% load fr_display %} {% load bookwyrm_tags %}
<div> <div>
<input class="toggle-control" type="checkbox" name="finish-reading-{{ uuid }}" id="finish-reading-{{ uuid }}"> <input class="toggle-control" type="checkbox" name="finish-reading-{{ uuid }}" id="finish-reading-{{ uuid }}">
<div class="modal toggle-content hidden"> <div class="modal toggle-content hidden">

View file

@ -1,13 +1,15 @@
{% load fr_display %} {% load bookwyrm_tags %}
{% if request.user|follow_request_exists:user %} {% if request.user|follow_request_exists:user %}
<form action="/accept-follow-request/" method="POST"> <div class="field is-grouped">
{% csrf_token %} <form action="/accept-follow-request/" method="POST">
<input type="hidden" name="user" value="{{ user.username }}"> {% csrf_token %}
<button class="button is-primary is-small" type="submit">Accept</button> <input type="hidden" name="user" value="{{ user.username }}">
</form> <button class="button is-link is-small" type="submit">Accept</button>
<form action="/delete-follow-request/" method="POST"> </form>
{% csrf_token %} <form action="/delete-follow-request/" method="POST">
<input type="hidden" name="user" value="{{ user.username }}"> {% csrf_token %}
<button class="button is-danger is-light is-small" type="submit" class="warning">Delete</button> <input type="hidden" name="user" value="{{ user.username }}">
</form> <button class="button is-danger is-light is-small" type="submit" class="warning">Delete</button>
</form>
</div>
{% endif %} {% endif %}

View file

@ -1,4 +1,4 @@
{% load fr_display %} {% load bookwyrm_tags %}
<div class="select"> <div class="select">
{% with 0|uuid as uuid %} {% with 0|uuid as uuid %}
{% if not no_label %} {% if not no_label %}

View file

@ -1,10 +1,12 @@
{% load fr_display %} {% load bookwyrm_tags %}
<span class="is-sr-only">Leave a rating</span> <span class="is-sr-only">Leave a rating</span>
<div class="field is-grouped stars rate-stars"> <div class="field is-grouped stars rate-stars">
{% for i in '12345'|make_list %} {% for i in '12345'|make_list %}
<form name="rate" action="/rate/" method="POST" onsubmit="return rate_stars(event)"> <form name="rate" action="/rate/" method="POST" onsubmit="return rate_stars(event)">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
<input type="hidden" name="book" value="{{ book.id }}"> <input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="privacy" value="public">
<input type="hidden" name="rating" value="{{ forloop.counter }}"> <input type="hidden" name="rating" value="{{ forloop.counter }}">
<button type="submit" class="icon icon-star-{% if book|rating:user < forloop.counter %}empty{% else %}full{% endif %}"> <button type="submit" class="icon icon-star-{% if book|rating:user < forloop.counter %}empty{% else %}full{% endif %}">
<span class="is-sr-only">{{ forloop.counter }} star{{ forloop.counter | pluralize }}</span> <span class="is-sr-only">{{ forloop.counter }} star{{ forloop.counter | pluralize }}</span>

View file

@ -0,0 +1,80 @@
{% load humanize %}
<div class="content block">
<input class="toggle-control" type="radio" name="show-edit-readthrough" id="show-readthrough-{{ readthrough.id }}" checked>
<div class="toggle-content hidden">
<dl>
{% if readthrough.start_date %}
<dt>Started reading:</dt>
<dd>{{ readthrough.start_date | naturalday }}</dd>
{% endif %}
{% if readthrough.finish_date %}
<dt>Finished reading:</dt>
<dd>{{ readthrough.finish_date | naturalday }}</dd>
{% endif %}
</dl>
<div class="field is-grouped">
<label class="button is-small" for="edit-readthrough-{{ readthrough.id }}" role="button" tabindex="0">
<span class="icon icon-pencil">
<span class="is-sr-only">Edit read-through dates</span>
</span>
</label>
<label class="button is-small" for="delete-readthrough-{{ readthrough.id }}" role="button" tabindex="0">
<span class="icon icon-x">
<span class="is-sr-only">Delete this read-through</span>
</span>
</label>
</div>
</div>
</div>
<div class="block">
<input class="toggle-control" type="radio" name="show-edit-readthrough" id="edit-readthrough-{{ readthrough.id }}">
<div class="toggle-content hidden">
<div class="box">
<form name="edit-readthrough" action="/edit-readthrough" method="post">
{% csrf_token %}
<input type="hidden" name="id" value="{{ readthrough.id }}">
<div class="field">
<label class="label">
Started reading
<input type="date" name="start_date" class="input" id="id_start_date-{{ readthrough.id }}" value="{{ readthrough.start_date | date:"Y-m-d" }}">
</label>
</div>
<div class="field">
<label class="label">
Finished reading
<input type="date" name="finish_date" class="input" id="id_finish_date-{{ readthrough.id }}" value="{{ readthrough.finish_date | date:"Y-m-d" }}">
</label>
</div>
<div class="field is-grouped">
<button class="button is-primary" type="submit">Save</button>
<label class="button" for="show-readthrough-{{ readthrough.id }}" role="button" tabindex="0">Cancel</label>
</div>
</form>
</div>
</div>
</div>
<div>
<input class="toggle-control" type="checkbox" name="delete-readthrough-{{ readthrough.id }}" id="delete-readthrough-{{ readthrough.id }}">
<div class="modal toggle-content hidden">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Delete this read-though?</p>
<label class="delete" for="delete-readthrough-{{ readthrough.id }}" aria-label="close"></label>
</header>
<footer class="modal-card-foot">
<form name="delete-readthrough-{{ readthrough.id }}" action="/delete-readthrough" method="POST">
{% csrf_token %}
<input type="hidden" name="id" value="{{ readthrough.id }}">
<button class="button is-danger is-light" type="submit">
Delete
</button>
<label for="delete-readthrough-{{ readthrough.id }}" class="button" role="button" tabindex="0">Cancel</button>
</form>
</footer>
</div>
<label class="modal-close is-large" for="delete-readthrough-{{ readthrough.id }}" aria-label="close"></label>
</div>
</div>

View file

@ -1,10 +1,10 @@
{% csrf_token %} {% csrf_token %}
<div class="field"> <div class="field">
<label class="label" for="id_username_register">Username:</label> <label class="label" for="id_localname_register">Username:</label>
<div class="control"> <div class="control">
<input type="text" name="username" maxlength="150" class="input" required="" id="id_username_register" value="{% if register_form.username.value %}{{ register_form.username.value }} {% endif %}"> <input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname_register" value="{% if register_form.localname.value %}{{ register_form.localname.value }}{% endif %}">
</div> </div>
{% for error in register_form.username.errors %} {% for error in register_form.localname.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
</div> </div>

View file

@ -1,19 +1,21 @@
{% load fr_display %} {% load bookwyrm_tags %}
{% with activity.id|uuid as uuid %} {% with status.id|uuid as uuid %}
<form class="is-flex-grow-1" name="reply" action="/reply" method="post" onsubmit="return reply(event)"> <form class="is-flex-grow-1" name="reply" action="/reply" method="post" onsubmit="return reply(event)">
<div class="columns"> <div class="columns">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="reply_parent" value="{{ activity.id }}"> <input type="hidden" name="reply_parent" value="{{ status.id }}">
<input type="hidden" name="user" value="{{ request.user.id }}"> <input type="hidden" name="user" value="{{ request.user.id }}">
<div class="column"> <div class="column">
{% include 'snippets/content_warning_field.html' with parent_status=status %}
<label for="id_content_{{ status.id }}-{{ uuid }}" class="is-sr-only">Reply</label>
<div class="field"> <div class="field">
<textarea class="textarea" name="content" placeholder="Leave a comment..." id="id_content_{{ activity.id }}-{{ uuid }}" required="true"></textarea> <textarea class="textarea" name="content" placeholder="Leave a comment..." id="id_content_{{ status.id }}-{{ uuid }}" required="true"></textarea>
</div> </div>
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow">
<div class="field"> <div class="field">
{% include 'snippets/privacy_select.html' %} {% include 'snippets/privacy_select.html' with current=status.privacy %}
</div> </div>
<div class="field"> <div class="field">
<button class="button is-primary" type="submit"> <button class="button is-primary" type="submit">

View file

@ -1,6 +1,6 @@
{% load humanize %} {% load humanize %}
{% load fr_display %} {% load bookwyrm_tags %}
{% if shelf.books.all|length > 0 %} {% if books|length > 0 %}
<table class="table is-striped is-fullwidth"> <table class="table is-striped is-fullwidth">
<tr class="book-preview"> <tr class="book-preview">
@ -34,7 +34,7 @@
</th> </th>
{% endif %} {% endif %}
</tr> </tr>
{% for book in shelf.books.all %} {% for book in books %}
<tr class="book-preview"> <tr class="book-preview">
<td> <td>
{% include 'snippets/book_cover.html' with book=book size="small" %} {% include 'snippets/book_cover.html' with book=book size="small" %}

View file

@ -1,27 +1,31 @@
{% load fr_display %} {% load bookwyrm_tags %}
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
{% with book.id|uuid as uuid %} {% with book.id|uuid as uuid %}
{% active_shelf book as active_shelf %} {% active_shelf book as active_shelf %}
<div class="field is-grouped"> <div class="field is-grouped">
{% if active_shelf.identifier == 'read' %} {% if switch_mode and active_shelf.book != book %}
{% include 'snippets/switch_edition_button.html' with edition=book size='is-small' %}
{% else %}
{% if active_shelf.shelf.identifier == 'read' %}
<button class="button is-small" disabled> <button class="button is-small" disabled>
<span>Read</span> <span class="icon icon-check"></span> <span>Read</span> <span class="icon icon-check"></span>
</button> </button>
{% elif active_shelf.identifier == 'reading' %} {% elif active_shelf.shelf.identifier == 'reading' %}
<label class="button is-small" for="finish-reading-{{ uuid }}" role="button" tabindex="0"> <label class="button is-small" for="finish-reading-{{ uuid }}" role="button" tabindex="0">
I'm done! I'm done!
</label> </label>
{% include 'snippets/finish_reading_modal.html' %} {% include 'snippets/finish_reading_modal.html' with book=active_shelf.book %}
{% elif active_shelf.identifier == 'to-read' %} {% elif active_shelf.shelf.identifier == 'to-read' %}
<label class="button is-small" for="start-reading-{{ uuid }}" role="button" tabindex="0"> <label class="button is-small" for="start-reading-{{ uuid }}" role="button" tabindex="0">
Start reading Start reading
</label> </label>
{% include 'snippets/start_reading_modal.html' %} {% include 'snippets/start_reading_modal.html' with book=active_shelf.book %}
{% else %} {% else %}
<form name="shelve" action="/shelve/" method="post"> <form name="shelve" action="/shelve/" method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}"> <input type="hidden" name="book" value="{{ active_shelf.book.id }}">
<input type="hidden" name="shelf" value="to-read"> <input type="hidden" name="shelf" value="to-read">
<button class="button is-small" type="submit">Want to read</button> <button class="button is-small" type="submit">Want to read</button>
</form> </form>
@ -40,17 +44,17 @@
<ul class="dropdown-content"> <ul class="dropdown-content">
{% for shelf in request.user.shelf_set.all %} {% for shelf in request.user.shelf_set.all %}
<li role="menuitem"> <li role="menuitem">
{% if shelf.identifier == 'to-read' %} {% if active_shelf.shelf.identifier == 'to-read' and shelf.identifier == 'reading' %}
<div class="dropdown-item pt-0 pb-0"> <div class="dropdown-item pt-0 pb-0">
<label class="button is-small" for="start-reading-{{ uuid }}" role="button" tabindex="0"> <label class="button is-small" for="start-reading-{{ uuid }}" role="button" tabindex="0">
Start reading Start reading
</label> </label>
{% include 'snippets/start_reading_modal.html' %} {% include 'snippets/start_reading_modal.html' with book=active_shelf.book %}
</div> </div>
{% else %} {% else %}
<form class="dropdown-item pt-0 pb-0" name="shelve" action="/shelve/" method="post"> <form class="dropdown-item pt-0 pb-0" name="shelve" action="/shelve/" method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}"> <input type="hidden" name="book" value="{{ active_shelf.book.id }}">
<button class="button is-small" name="shelf" type="submit" value="{{ shelf.identifier }}" {% if shelf in book.shelf_set.all %} disabled {% endif %}> <button class="button is-small" name="shelf" type="submit" value="{{ shelf.identifier }}" {% if shelf in book.shelf_set.all %} disabled {% endif %}>
<span>{{ shelf.name }}</span> <span>{{ shelf.name }}</span>
{% if shelf in book.shelf_set.all %}<span class="icon icon-check"></span>{% endif %} {% if shelf in book.shelf_set.all %}<span class="icon icon-check"></span>{% endif %}
@ -62,6 +66,7 @@
</ul> </ul>
</div> </div>
</div> </div>
{% endif %}
</div> </div>
{% endwith %} {% endwith %}
{% endif %} {% endif %}

View file

@ -1,4 +1,4 @@
{% load fr_display %} {% load bookwyrm_tags %}
{% if not status.deleted %} {% if not status.deleted %}
{% if status.status_type == 'Boost' %} {% if status.status_type == 'Boost' %}
{% include 'snippets/avatar.html' with user=status.user %} {% include 'snippets/avatar.html' with user=status.user %}

View file

@ -1,4 +1,4 @@
{% load fr_display %} {% load bookwyrm_tags %}
{% load humanize %} {% load humanize %}
{% if not status.deleted %} {% if not status.deleted %}

View file

@ -1,38 +1,57 @@
{% load fr_display %} {% load bookwyrm_tags %}
<div class="block"> <div class="block">
{% if status.status_type == 'Review' %} {% if status.status_type == 'Review' %}
<h3> <div>
{% if status.name %}{{ status.name }}<br>{% endif %} <h3 class="title is-5 has-subtitle">
{% include 'snippets/stars.html' with rating=status.rating %} {% if status.name %}{{ status.name }}<br>{% endif %}
</h3> </h3>
{% endif %} <p class="subtitle">{% include 'snippets/stars.html' with rating=status.rating %}</p>
{% if status.quote %}
<div class="quote block">
<blockquote>{{ status.quote }}</blockquote>
<p> &mdash; {% include 'snippets/book_titleby.html' with book=status.book %}</p>
</div> </div>
{% endif %} {% endif %}
{% if status.content and status.status_type != 'GeneratedNote' and status.status_type != 'Boost' %} {% if status.content_warning %}
{% include 'snippets/trimmed_text.html' with full=status.content|safe %} <div class="toggle-content">
{% endif %} <p>{{ status.content_warning }}</p>
{% if status.attachments %} <input class="toggle-control" type="radio" name="toggle-status-cw-{{ status.id }}" id="hide-status-cw-{{ status.id }}" checked>
<div class="block"> <div class="toggle-content hidden">
<div class="columns"> <label class="button is-small" for="show-status-cw-{{ status.id }}" tabindex="0" role="button">Show More</label>
{% for attachment in status.attachments.all %}
<div class="column is-narrow">
<figure class="image is-128x128">
<a href="/images/{{ attachment.image }}" target="_blank" aria-label="open image in new window">
<img src="/images/{{ attachment.image }}" alt="{{ attachment.caption }}">
</a>
</figure>
</div>
{% endfor %}
</div> </div>
</div> </div>
<input class="toggle-control" type="radio" name="toggle-status-cw-{{ status.id }}" id="show-status-cw-{{ status.id }}">
{% endif %} {% endif %}
<div{% if status.content_warning %} class="toggle-content hidden"{% endif %}>
{% if status.content_warning %}
<label class="button is-small" for="hide-status-cw-{{ status.id }}" tabindex="0" role="button">Show Less</label>
{% endif %}
{% if status.quote %}
<div class="quote block">
<blockquote>{{ status.quote | safe }}</blockquote>
<p> &mdash; {% include 'snippets/book_titleby.html' with book=status.book %}</p>
</div>
{% endif %}
{% if status.content and status.status_type != 'GeneratedNote' and status.status_type != 'Boost' %}
{% include 'snippets/trimmed_text.html' with full=status.content|safe %}
{% endif %}
{% if status.attachments %}
<div class="block">
<div class="columns">
{% for attachment in status.attachments.all %}
<div class="column is-narrow">
<figure class="image is-128x128">
<a href="/images/{{ attachment.image }}" target="_blank" aria-label="open image in new window">
<img src="/images/{{ attachment.image }}"{% if attachment.caption %} alt="{{ attachment.caption }}" title="{{ attachment.caption }}"{% endif %}>
</a>
</figure>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div> </div>
{% if not hide_book %} {% if not hide_book %}

View file

@ -1,4 +1,4 @@
{% load fr_display %} {% load bookwyrm_tags %}
{% include 'snippets/avatar.html' with user=status.user %} {% include 'snippets/avatar.html' with user=status.user %}
{% include 'snippets/username.html' with user=status.user %} {% include 'snippets/username.html' with user=status.user %}

View file

@ -0,0 +1,5 @@
<form name="switch-edition" action="/switch-edition" method="POST">
{% csrf_token %}
<input type="hidden" name="edition" value="{{ edition.id }}">
<button class="button {{ size }}">Switch to this edition</button>
</form>

View file

@ -5,7 +5,7 @@
<input type="hidden" name="name" value="{{ tag.tag.name }}"> <input type="hidden" name="name" value="{{ tag.tag.name }}">
<div class="tags has-addons"> <div class="tags has-addons">
<a class="tag" href="/tag/{{ tag.tag.identifier|urlencode }}"> <a class="tag" href="{{ tag.tag.local_path }}">
{{ tag.tag.name }} {{ tag.tag.name }}
</a> </a>
{% if tag.tag.identifier in user_tags %} {% if tag.tag.identifier in user_tags %}

View file

@ -1,4 +1,4 @@
{% load fr_display %} {% load bookwyrm_tags %}
<div class="block"> <div class="block">
{% with depth=depth|add:1 %} {% with depth=depth|add:1 %}

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