diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index cfbe0524..5662d1d5 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1,7 +1,7 @@
# These are supported funding model platforms
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
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
diff --git a/README.md b/README.md
index 51e39541..2dfedebb 100644
--- a/README.md
+++ b/README.md
@@ -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.
-
-#### With Docker
You'll have to install the Docker and docker-compose. When you're ready, run:
```bash
@@ -70,33 +68,7 @@ docker-compose run --rm web python manage.py migrate
docker-compose run --rm web python manage.py initdb
```
-### Without Docker
-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`
-
-
+Once the build is complete, you can access the instance at `localhost:1333`
## Installing in Production
diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py
index b5b124ec..a4fef41e 100644
--- a/bookwyrm/activitypub/__init__.py
+++ b/bookwyrm/activitypub/__init__.py
@@ -11,6 +11,7 @@ from .note import Tombstone
from .interaction import Boost, Like
from .ordered_collection import OrderedCollection, OrderedCollectionPage
from .person import Person, PublicKey
+from .response import ActivitypubResponse
from .book import Edition, Work, Author
from .verbs import Create, Delete, Undo, Update
from .verbs import Follow, Accept, Reject
diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py
index ed19af99..7ef0920f 100644
--- a/bookwyrm/activitypub/base_activity.py
+++ b/bookwyrm/activitypub/base_activity.py
@@ -3,9 +3,7 @@ from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder
from django.apps import apps
-from django.db import transaction
-from django.db.models.fields.files import ImageFileDescriptor
-from django.db.models.fields.related_descriptors import ManyToManyDescriptor
+from django.db import IntegrityError, transaction
from bookwyrm.connectors import ConnectorException, get_data
from bookwyrm.tasks import app
@@ -65,7 +63,6 @@ class ActivityObject:
setattr(self, field.name, value)
- @transaction.atomic
def to_model(self, model, instance=None, save=True):
''' convert from an activity to a model instance '''
if not isinstance(self, model.activity_serializer):
@@ -76,74 +73,54 @@ class ActivityObject:
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
- if not instance:
- instance = model.find_existing(self.serialize()) or model()
+ instance = instance or model.find_existing(self.serialize()) or model()
- many_to_many_fields = {}
- image_fields = {}
- 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
+ for field in instance.simple_fields:
+ field.set_field_from_activity(instance, self)
- model_field = getattr(model, field.name)
-
- if isinstance(model_field, ManyToManyDescriptor):
- # status mentions book/users for example, stash this for later
- 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)
+ # image fields have to be set after other fields because they can save
+ # too early and jank up users
+ for field in instance.image_fields:
+ field.set_field_from_activity(instance, self, save=save)
if not save:
+ return instance
+
+ with transaction.atomic():
# 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 (model_key, values) in many_to_many_fields.items():
- # mention books/users, for example
- getattr(instance, model_key).set(values)
-
- if not save or not hasattr(model, 'deserialize_reverse_fields'):
- return instance
+ # add many to many fields, which have to be set post-save
+ for field in instance.many_to_many_fields:
+ # mention books/users, for example
+ field.set_field_from_activity(instance, self)
# reversed relationships in the models
for (model_field_name, activity_field_name) in \
- model.deserialize_reverse_fields:
+ instance.deserialize_reverse_fields:
# attachments on Status, for example
values = getattr(self, activity_field_name)
if values is None or values is MISSING:
continue
- try:
- # this is for one to many
- related_model = getattr(model, model_field_name).field.model
- except AttributeError:
- # it's a one to one or foreign key
- related_model = getattr(model, model_field_name)\
- .related.related_model
- values = [values]
+
+ model_field = getattr(model, model_field_name)
+ # creating a Work, model_field is 'editions'
+ # creating a User, model field is 'key_pair'
+ related_model = model_field.field.model
+ related_field_name = model_field.field.name
for item in values:
set_related_field.delay(
related_model.__name__,
instance.__class__.__name__,
- instance.__class__.__name__.lower(),
+ related_field_name,
instance.remote_id,
item
)
@@ -160,8 +137,8 @@ class ActivityObject:
@app.task
@transaction.atomic
def set_related_field(
- model_name, origin_model_name,
- related_field_name, related_remote_id, data):
+ model_name, origin_model_name, related_field_name,
+ related_remote_id, data):
''' load reverse related fields (editions, attachments) without blocking '''
model = apps.get_model('bookwyrm.%s' % model_name, require_ready=True)
origin_model = apps.get_model(
@@ -169,23 +146,38 @@ def set_related_field(
require_ready=True
)
- if isinstance(data, str):
- item = resolve_remote_id(model, data, save=False)
- else:
- # look for a match based on all the available data
- item = model.find_existing(data)
- if not item:
- # create a new model instance
- item = 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)
+ with transaction.atomic():
+ if isinstance(data, str):
+ existing = model.find_existing_by_remote_id(data)
+ if existing:
+ data = existing.to_activity()
+ else:
+ data = get_data(data)
+ activity = model.activity_serializer(**data)
- # edition.parent_work = instance, for example
- setattr(item, related_field_name, instance)
- item.save()
+ # 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)
+
+ # 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):
diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py
index ae9c334d..6fa80b32 100644
--- a/bookwyrm/activitypub/book.py
+++ b/bookwyrm/activitypub/book.py
@@ -38,7 +38,7 @@ class Edition(Book):
isbn13: str = ''
oclcNumber: str = ''
asin: str = ''
- pages: str = ''
+ pages: int = None
physicalFormat: str = ''
publishers: List[str] = field(default_factory=lambda: [])
@@ -50,7 +50,7 @@ class Work(Book):
''' work instance of a book object '''
lccn: str = ''
defaultEdition: str = ''
- editions: List[str]
+ editions: List[str] = field(default_factory=lambda: [])
type: str = 'Work'
@@ -58,10 +58,12 @@ class Work(Book):
class Author(ActivityObject):
''' author of a book '''
name: str
- born: str = ''
- died: str = ''
- aliases: str = ''
+ born: str = None
+ died: str = None
+ aliases: List[str] = field(default_factory=lambda: [])
bio: str = ''
openlibraryKey: str = ''
+ librarythingKey: str = ''
+ goodreadsKey: str = ''
wikipediaLink: str = ''
type: str = 'Person'
diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py
index df28bf8d..72fbe5fc 100644
--- a/bookwyrm/activitypub/note.py
+++ b/bookwyrm/activitypub/note.py
@@ -23,6 +23,7 @@ class Note(ActivityObject):
cc: List[str] = field(default_factory=lambda: [])
replies: Dict = field(default_factory=lambda: {})
inReplyTo: str = ''
+ summary: str = ''
tag: List[Link] = field(default_factory=lambda: [])
attachment: List[Image] = field(default_factory=lambda: [])
sensitive: bool = False
@@ -52,8 +53,8 @@ class Comment(Note):
@dataclass(init=False)
class Review(Comment):
''' a full book review '''
- name: str
- rating: int
+ name: str = None
+ rating: int = None
type: str = 'Review'
diff --git a/bookwyrm/activitypub/person.py b/bookwyrm/activitypub/person.py
index 88349c02..7e7d027e 100644
--- a/bookwyrm/activitypub/person.py
+++ b/bookwyrm/activitypub/person.py
@@ -18,13 +18,13 @@ class PublicKey(ActivityObject):
class Person(ActivityObject):
''' actor activitypub json '''
preferredUsername: str
- name: str
inbox: str
outbox: str
followers: str
- summary: str
publicKey: PublicKey
endpoints: Dict
+ name: str = None
+ summary: str = None
icon: Image = field(default_factory=lambda: {})
bookwyrmUser: bool = False
manuallyApprovesFollowers: str = False
diff --git a/bookwyrm/activitypub/response.py b/bookwyrm/activitypub/response.py
new file mode 100644
index 00000000..bbc44c4d
--- /dev/null
+++ b/bookwyrm/activitypub/response.py
@@ -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)
diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py
index e890d81f..7c627927 100644
--- a/bookwyrm/activitypub/verbs.py
+++ b/bookwyrm/activitypub/verbs.py
@@ -3,7 +3,7 @@ from dataclasses import dataclass
from typing import List
from .base_activity import ActivityObject, Signature
-from .book import Book
+from .book import Edition
@dataclass(init=False)
class Verb(ActivityObject):
@@ -73,7 +73,7 @@ class Add(Verb):
@dataclass(init=False)
class AddBook(Verb):
'''Add activity that's aware of the book obj '''
- target: Book
+ target: Edition
type: str = 'Add'
diff --git a/bookwyrm/broadcast.py b/bookwyrm/broadcast.py
index a98b6774..f4186c4d 100644
--- a/bookwyrm/broadcast.py
+++ b/bookwyrm/broadcast.py
@@ -3,7 +3,7 @@ import json
from django.utils.http import http_date
import requests
-from bookwyrm import models
+from bookwyrm import models, settings
from bookwyrm.activitypub import ActivityEncoder
from bookwyrm.tasks import app
from bookwyrm.signatures import make_signature, make_digest
@@ -79,6 +79,7 @@ def sign_and_send(sender, data, destination):
'Digest': digest,
'Signature': make_signature(sender, destination, now, digest),
'Content-Type': 'application/activity+json; charset=utf-8',
+ 'User-Agent': settings.USER_AGENT,
},
)
if not response.ok:
diff --git a/bookwyrm/connectors/__init__.py b/bookwyrm/connectors/__init__.py
index 4eb91de4..cfafd286 100644
--- a/bookwyrm/connectors/__init__.py
+++ b/bookwyrm/connectors/__init__.py
@@ -2,3 +2,5 @@
from .settings import CONNECTORS
from .abstract_connector import ConnectorException
from .abstract_connector import get_data, get_image
+
+from .connector_manager import search, local_search, first_search_result
diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py
index c9f1ad2e..d63bd135 100644
--- a/bookwyrm/connectors/abstract_connector.py
+++ b/bookwyrm/connectors/abstract_connector.py
@@ -1,22 +1,18 @@
''' functionality outline for a book data connector '''
from abc import ABC, abstractmethod
-from dataclasses import dataclass
-import pytz
+from dataclasses import asdict, dataclass
+import logging
from urllib3.exceptions import RequestError
from django.db import transaction
-from dateutil import parser
import requests
-from requests import HTTPError
from requests.exceptions import SSLError
-from bookwyrm import models
-
-
-class ConnectorException(HTTPError):
- ''' when the connector can't do what was asked '''
+from bookwyrm import activitypub, models, settings
+from .connector_manager import load_more_data, ConnectorException
+logger = logging.getLogger(__name__)
class AbstractMinimalConnector(ABC):
''' just the bare bones, for other bookwyrm instances '''
def __init__(self, identifier):
@@ -38,17 +34,22 @@ class AbstractMinimalConnector(ABC):
for field in self_fields:
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 '''
resp = requests.get(
'%s%s' % (self.search_url, query),
headers={
'Accept': 'application/json; charset=utf-8',
+ 'User-Agent': settings.USER_AGENT,
},
)
if not resp.ok:
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 = []
for doc in self.parse_search_data(data)[:10]:
@@ -72,9 +73,6 @@ class AbstractConnector(AbstractMinimalConnector):
''' generic book data connector '''
def __init__(self, identifier):
super().__init__(identifier)
-
- self.key_mappings = []
-
# fields we want to look for in book data to copy over
# title we handle separately.
self.book_mappings = []
@@ -89,216 +87,112 @@ class AbstractConnector(AbstractMinimalConnector):
def get_or_create_book(self, remote_id):
- # try to load the book
- book = models.Book.objects.select_subclasses().filter(
- origin_id=remote_id
- ).first()
- if book:
- if isinstance(book, models.Work):
- return book.default_edition
- return book
+ ''' translate arbitrary json into an Activitypub dataclass '''
+ # first, check if we have the origin_id saved
+ existing = models.Edition.find_existing_by_remote_id(remote_id) or \
+ models.Work.find_existing_by_remote_id(remote_id)
+ if existing:
+ if hasattr(existing, 'get_default_editon'):
+ return existing.get_default_editon()
+ return existing
- # no book was found, so we start creating a new one
+ # load the json
data = get_data(remote_id)
-
- work = None
- edition = None
+ mapped_data = dict_from_mappings(data, self.book_mappings)
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:
- edition_data = self.get_edition_from_work_data(work_data)
+ edition_data = self.get_edition_from_work_data(data)
except KeyError:
# hack: re-use the work data as the edition data
# this is why remote ids aren't necessarily unique
edition_data = data
+ work_data = mapped_data
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:
- 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:
- # remember this hack: re-use the work data as the edition data
- work_data = data
+ work_data = mapped_data
+ edition_data = data
if not work_data or not edition_data:
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():
- if not work:
- work_key = self.get_remote_id_from_data(work_data)
- work = self.create_book(work_key, work_data, models.Work)
+ # create activitypub object
+ work_activity = activitypub.Work(**work_data)
+ # 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:
- ed_key = self.get_remote_id_from_data(edition_data)
- edition = self.create_book(ed_key, edition_data, models.Edition)
- edition.parent_work = work
- edition.save()
- work.default_edition = edition
- work.save()
+ edition = self.create_edition_from_data(work, edition_data)
+ load_more_data.delay(self.connector.id, work.id)
+ return edition
- # 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():
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
- def create_book(self, remote_id, data, model):
- ''' create a work or edition from data '''
- book = model.objects.create(
- origin_id=remote_id,
- title=data['title'],
- connector=self.connector,
- )
- return self.update_book_from_data(book, data)
+ def get_or_create_author(self, remote_id):
+ ''' load that author '''
+ existing = models.Author.find_existing_by_remote_id(remote_id)
+ if existing:
+ return existing
+ data = get_data(remote_id)
- def update_book_from_data(self, book, data, update_cover=True):
- ''' for creating a new book or syncing with data '''
- book = update_from_mappings(book, data, self.book_mappings)
-
- 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 '''
+ mapped_data = dict_from_mappings(data, self.author_mappings)
+ activity = activitypub.Author(**mapped_data)
+ # this will dedupe
+ return activity.to_model(models.Author)
@abstractmethod
def is_work_data(self, data):
''' differentiate works and editions '''
-
@abstractmethod
def get_edition_from_work_data(self, data):
''' every work needs at least one edition '''
-
@abstractmethod
- def get_work_from_edition_date(self, data):
+ def get_work_from_edition_data(self, data):
''' every edition needs a work '''
-
@abstractmethod
def get_authors_from_data(self, data):
''' load author data '''
-
- @abstractmethod
- def get_cover_from_data(self, data):
- ''' load cover '''
-
@abstractmethod
def expand_book_data(self, book):
''' get more info on a book '''
-def update_from_mappings(obj, data, mappings):
- ''' assign data to model with mappings '''
+def dict_from_mappings(data, mappings):
+ ''' create a dict in Activitypub format, using mappings supplies by
+ the subclass '''
+ result = {}
for mapping in 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
- 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
+ result[mapping.local_field] = mapping.get_value(data)
+ return result
def get_data(url):
@@ -308,9 +202,10 @@ def get_data(url):
url,
headers={
'Accept': 'application/json; charset=utf-8',
+ 'User-Agent': settings.USER_AGENT,
},
)
- except RequestError:
+ except (RequestError, SSLError):
raise ConnectorException()
if not resp.ok:
resp.raise_for_status()
@@ -325,7 +220,12 @@ def get_data(url):
def get_image(url):
''' wrapper for requesting an image '''
try:
- resp = requests.get(url)
+ resp = requests.get(
+ url,
+ headers={
+ 'User-Agent': settings.USER_AGENT,
+ },
+ )
except (RequestError, SSLError):
return None
if not resp.ok:
@@ -340,20 +240,35 @@ class SearchResult:
key: str
author: str
year: str
+ connector: object
confidence: int = 1
def __repr__(self):
return "".format(
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:
''' associate a local database field with a field in an external dataset '''
- def __init__(
- self, local_field, remote_field=None, formatter=None, model=None):
+ def __init__(self, local_field, remote_field=None, formatter=None):
noop = lambda x: x
self.local_field = local_field
self.remote_field = remote_field or local_field
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
diff --git a/bookwyrm/connectors/bookwyrm_connector.py b/bookwyrm/connectors/bookwyrm_connector.py
index e4d32fd3..3c6f4614 100644
--- a/bookwyrm/connectors/bookwyrm_connector.py
+++ b/bookwyrm/connectors/bookwyrm_connector.py
@@ -13,4 +13,5 @@ class Connector(AbstractMinimalConnector):
return data
def format_search_result(self, search_result):
+ search_result['connector'] = self
return SearchResult(**search_result)
diff --git a/bookwyrm/books_manager.py b/bookwyrm/connectors/connector_manager.py
similarity index 87%
rename from bookwyrm/books_manager.py
rename to bookwyrm/connectors/connector_manager.py
index 3b865768..d3b01f7a 100644
--- a/bookwyrm/books_manager.py
+++ b/bookwyrm/connectors/connector_manager.py
@@ -1,4 +1,4 @@
-''' select and call a connector for whatever book task needs doing '''
+''' interface with whatever connectors the app has '''
import importlib
from urllib.parse import urlparse
@@ -8,43 +8,8 @@ from bookwyrm import models
from bookwyrm.tasks import app
-def get_edition(book_id):
- ''' look up a book in the db and return an edition '''
- 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)
+class ConnectorException(HTTPError):
+ ''' when the connector can't do what was asked '''
def search(query, min_confidence=0.1):
@@ -55,7 +20,7 @@ def search(query, min_confidence=0.1):
for connector in get_connectors():
try:
result_set = connector.search(query, min_confidence=min_confidence)
- except HTTPError:
+ except (HTTPError, ConnectorException):
continue
result_set = [r for r in result_set \
@@ -91,6 +56,38 @@ def get_connectors():
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):
''' instantiate the connector class '''
connector = importlib.import_module(
diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py
index 28eb1ea0..55355131 100644
--- a/bookwyrm/connectors/openlibrary.py
+++ b/bookwyrm/connectors/openlibrary.py
@@ -1,13 +1,10 @@
''' openlibrary data connector '''
import re
-import requests
-
-from django.core.files.base import ContentFile
from bookwyrm import models
from .abstract_connector import AbstractConnector, SearchResult, Mapping
-from .abstract_connector import ConnectorException
-from .abstract_connector import get_date, get_data, update_from_mappings
+from .abstract_connector import get_data
+from .connector_manager import ConnectorException
from .openlibrary_languages import languages
@@ -17,67 +14,62 @@ class Connector(AbstractConnector):
super().__init__(identifier)
get_first = lambda a: a[0]
- self.key_mappings = [
- Mapping('isbn_13', model=models.Edition, formatter=get_first),
- Mapping('isbn_10', model=models.Edition, formatter=get_first),
- Mapping('lccn', model=models.Work, formatter=get_first),
+ get_remote_id = lambda a: self.base_url + a
+ self.book_mappings = [
+ Mapping('title'),
+ Mapping('id', remote_field='key', formatter=get_remote_id),
Mapping(
- 'oclc_number',
- remote_field='oclc_numbers',
- 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'),
+ 'cover', remote_field='covers', formatter=self.get_cover_url),
+ Mapping('sortTitle', remote_field='sort_title'),
Mapping('subtitle'),
Mapping('description', formatter=get_description),
Mapping('languages', formatter=get_languages),
Mapping('series', formatter=get_first),
- Mapping('series_number'),
+ Mapping('seriesNumber', remote_field='series_number'),
Mapping('subjects'),
- Mapping('subject_places'),
+ Mapping('subjectPlaces'),
+ Mapping('isbn13', formatter=get_first),
+ Mapping('isbn10', formatter=get_first),
+ Mapping('lccn', formatter=get_first),
Mapping(
- 'first_published_date',
- remote_field='first_publish_date',
- formatter=get_date
+ 'oclcNumber', remote_field='oclc_numbers',
+ formatter=get_first
),
Mapping(
- 'published_date',
- remote_field='publish_date',
- formatter=get_date
+ 'openlibraryKey', remote_field='key',
+ formatter=get_openlibrary_key
),
+ Mapping('goodreadsKey', remote_field='goodreads_key'),
+ Mapping('asin'),
Mapping(
- 'pages',
- model=models.Edition,
- remote_field='number_of_pages'
+ 'firstPublishedDate', remote_field='first_publish_date',
),
- 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'),
]
self.author_mappings = [
+ Mapping('id', remote_field='key', formatter=get_remote_id),
Mapping('name'),
- Mapping('born', remote_field='birth_date', formatter=get_date),
- Mapping('died', remote_field='death_date', formatter=get_date),
+ Mapping(
+ '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),
]
def get_remote_id_from_data(self, data):
+ ''' format a url from an openlibrary id field '''
try:
key = data['key']
except KeyError:
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):
@@ -89,17 +81,17 @@ class Connector(AbstractConnector):
key = data['key']
except KeyError:
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)
return pick_default_edition(data['entries'])
- def get_work_from_edition_date(self, data):
+ def get_work_from_edition_data(self, data):
try:
key = data['works'][0]['key']
except (IndexError, KeyError):
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)
@@ -107,24 +99,17 @@ class Connector(AbstractConnector):
''' parse author json and load or create authors '''
for author_blob in data.get('authors', []):
author_blob = author_blob.get('author', author_blob)
- # this id is "/authors/OL1234567A" and we want just "OL1234567A"
- author_id = author_blob['key'].split('/')[-1]
- yield self.get_or_create_author(author_id)
+ # this id is "/authors/OL1234567A"
+ author_id = author_blob['key']
+ url = '%s%s' % (self.base_url, author_id)
+ yield self.get_or_create_author(url)
- def get_cover_from_data(self, data):
+ def get_cover_url(self, cover_blob):
''' ask openlibrary for the cover '''
- if not data.get('covers'):
- return None
-
- 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]
+ cover_id = cover_blob[0]
+ image_name = '%s-L.jpg' % cover_id
+ return '%s/b/id/%s' % (self.covers_url, image_name)
def parse_search_data(self, data):
@@ -139,13 +124,14 @@ class Connector(AbstractConnector):
title=search_result.get('title'),
key=key,
author=', '.join(author),
+ connector=self,
year=search_result.get('first_publish_year'),
)
def load_edition_data(self, olkey):
''' 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)
@@ -158,44 +144,14 @@ class Connector(AbstractConnector):
# we can mass download edition data from OL to avoid repeatedly querying
edition_options = self.load_edition_data(work.openlibrary_key)
for edition_data in edition_options.get('entries'):
- olkey = edition_data.get('key').split('/')[-1]
- # 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
+ self.create_edition_from_data(work, edition_data)
def get_description(description_blob):
''' descriptions can be a string or a dict '''
if isinstance(description_blob, dict):
return description_blob.get('value')
- return description_blob
+ return description_blob
def get_openlibrary_key(key):
@@ -220,7 +176,7 @@ def pick_default_edition(options):
if len(options) == 1:
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 \
'/languages/eng' in str(e.get('languages'))] or options
formats = ['paperback', 'hardcover', 'mass market paperback']
diff --git a/bookwyrm/connectors/self_connector.py b/bookwyrm/connectors/self_connector.py
index 80d3a67d..0c21e7bc 100644
--- a/bookwyrm/connectors/self_connector.py
+++ b/bookwyrm/connectors/self_connector.py
@@ -1,6 +1,9 @@
''' 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.db.models import F
+from django.db.models import Count, F, Q
from bookwyrm import models
from .abstract_connector import AbstractConnector, SearchResult
@@ -9,38 +12,18 @@ from .abstract_connector import AbstractConnector, SearchResult
class Connector(AbstractConnector):
''' instantiate a connector '''
def search(self, query, min_confidence=0.1):
- ''' right now you can't search bookwyrm sorry, but when
- that gets implemented it will totally rule '''
- vector = SearchVector('title', weight='A') +\
- SearchVector('subtitle', weight='B') +\
- SearchVector('author_text', weight='C') +\
- SearchVector('isbn_13', weight='A') +\
- 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 your local database '''
+ # first, try searching unqiue identifiers
+ results = search_identifiers(query)
+ if not results:
+ # then try searching title/author
+ results = search_title_author(query, min_confidence)
search_results = []
- for book in results[:10]:
- search_results.append(
- self.format_search_result(book)
- )
+ for result in results:
+ search_results.append(self.format_search_result(result))
+ if len(search_results) >= 10:
+ break
+ search_results.sort(key=lambda r: r.confidence, reverse=True)
return search_results
@@ -51,31 +34,74 @@ class Connector(AbstractConnector):
author=search_result.author_text,
year=search_result.published_date.year if \
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):
pass
def get_edition_from_work_data(self, data):
pass
- def get_work_from_edition_date(self, data):
+ def get_work_from_edition_data(self, data):
pass
def get_authors_from_data(self, data):
return None
- def get_cover_from_data(self, data):
- return None
-
def parse_search_data(self, data):
''' it's already in the right format, don't even worry about it '''
return data
def expand_book_data(self, book):
pass
+
+
+def search_identifiers(query):
+ ''' tries remote_id, isbn; defined as dedupe fields on the model '''
+ filters = [{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()
diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py
index 72839dce..a1471ac4 100644
--- a/bookwyrm/context_processors.py
+++ b/bookwyrm/context_processors.py
@@ -1,7 +1,7 @@
''' customize the info available in context for rendering templates '''
from bookwyrm import models
-def site_settings(request):
+def site_settings(request):# pylint: disable=unused-argument
''' include the custom info about the site '''
return {
'site': models.SiteSettings.objects.get()
diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py
index 784f1038..152c2d76 100644
--- a/bookwyrm/forms.py
+++ b/bookwyrm/forms.py
@@ -31,10 +31,11 @@ class CustomForm(ModelForm):
visible.field.widget.attrs['class'] = css_classes[input_type]
+# pylint: disable=missing-class-docstring
class LoginForm(CustomForm):
class Meta:
model = models.User
- fields = ['username', 'password']
+ fields = ['localname', 'password']
help_texts = {f: None for f in fields}
widgets = {
'password': PasswordInput(),
@@ -44,7 +45,7 @@ class LoginForm(CustomForm):
class RegisterForm(CustomForm):
class Meta:
model = models.User
- fields = ['username', 'email', 'password']
+ fields = ['localname', 'email', 'password']
help_texts = {f: None for f in fields}
widgets = {
'password': PasswordInput()
@@ -60,25 +61,36 @@ class RatingForm(CustomForm):
class ReviewForm(CustomForm):
class Meta:
model = models.Review
- fields = ['user', 'book', 'name', 'content', 'rating', 'privacy']
+ fields = [
+ 'user', 'book',
+ 'name', 'content', 'rating',
+ 'content_warning', 'sensitive',
+ 'privacy']
class CommentForm(CustomForm):
class Meta:
model = models.Comment
- fields = ['user', 'book', 'content', 'privacy']
+ fields = [
+ 'user', 'book', 'content',
+ 'content_warning', 'sensitive',
+ 'privacy']
class QuotationForm(CustomForm):
class Meta:
model = models.Quotation
- fields = ['user', 'book', 'quote', 'content', 'privacy']
+ fields = [
+ 'user', 'book', 'quote', 'content',
+ 'content_warning', 'sensitive', 'privacy']
class ReplyForm(CustomForm):
class Meta:
model = models.Status
- fields = ['user', 'content', 'reply_parent', 'privacy']
+ fields = [
+ 'user', 'content', 'content_warning', 'sensitive',
+ 'reply_parent', 'privacy']
class EditUserForm(CustomForm):
@@ -110,14 +122,13 @@ class EditionForm(CustomForm):
model = models.Edition
exclude = [
'remote_id',
+ 'origin_id',
'created_date',
'updated_date',
- 'last_sync_date',
'authors',# TODO
'parent_work',
'shelves',
- 'misc_identifiers',
'subjects',# TODO
'subject_places',# TODO
@@ -125,12 +136,23 @@ class EditionForm(CustomForm):
'connector',
]
+class AuthorForm(CustomForm):
+ class Meta:
+ model = models.Author
+ exclude = [
+ 'remote_id',
+ 'origin_id',
+ 'created_date',
+ 'updated_date',
+ ]
+
class ImportForm(forms.Form):
csv_file = forms.FileField()
class ExpiryWidget(widgets.Select):
def value_from_datadict(self, data, files, name):
+ ''' human-readable exiration time buckets '''
selected_string = super().value_from_datadict(data, files, name)
if selected_string == 'day':
diff --git a/bookwyrm/goodreads_import.py b/bookwyrm/goodreads_import.py
index 3fd330ab..9b8a4f01 100644
--- a/bookwyrm/goodreads_import.py
+++ b/bookwyrm/goodreads_import.py
@@ -8,8 +8,6 @@ from bookwyrm.models import ImportJob, ImportItem
from bookwyrm.status import create_notification
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):
@@ -19,12 +17,13 @@ def create_job(user, csv_file, include_reviews, privacy):
include_reviews=include_reviews,
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')):
raise ValueError('Author, title, and isbn must be in data.')
ImportItem(job=job, index=index, data=entry).save()
return job
+
def create_retry_job(user, original_job, items):
''' retry items that didn't import '''
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()
return job
+
def start_import(job):
''' initalizes a csv import job '''
result = import_data.delay(job.id)
@@ -49,11 +49,10 @@ def import_data(job_id):
''' does the actual lookup work in a celery task '''
job = ImportJob.objects.get(id=job_id)
try:
- results = []
for item in job.items.all():
try:
item.resolve()
- except Exception as e:
+ except Exception as e:# pylint: disable=broad-except
logger.exception(e)
item.fail_reason = 'Error loading book'
item.save()
@@ -61,7 +60,6 @@ def import_data(job_id):
if item.book:
item.save()
- results.append(item)
# shelves book and handles reviews
outgoing.handle_imported_book(
diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py
index bbbebf0f..5e42fe45 100644
--- a/bookwyrm/incoming.py
+++ b/bookwyrm/incoming.py
@@ -6,6 +6,7 @@ import django.db.utils
from django.http import HttpResponse
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.views.decorators.csrf import csrf_exempt
+from django.views.decorators.http import require_POST
import requests
from bookwyrm import activitypub, models, outgoing
@@ -15,11 +16,9 @@ from bookwyrm.signatures import Signature
@csrf_exempt
+@require_POST
def inbox(request, username):
''' 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:
models.User.objects.get(localname=username)
except models.User.DoesNotExist:
@@ -29,11 +28,9 @@ def inbox(request, username):
@csrf_exempt
+@require_POST
def shared_inbox(request):
''' incoming activitypub events '''
- if request.method == 'GET':
- return HttpResponseNotFound()
-
try:
resp = request.body
activity = json.loads(resp)
@@ -60,7 +57,6 @@ def shared_inbox(request):
'Announce': handle_boost,
'Add': {
'Edition': handle_add,
- 'Work': handle_add,
},
'Undo': {
'Follow': handle_unfollow,
@@ -69,8 +65,8 @@ def shared_inbox(request):
},
'Update': {
'Person': handle_update_user,
- 'Edition': handle_update_book,
- 'Work': handle_update_book,
+ 'Edition': handle_update_edition,
+ 'Work': handle_update_work,
},
}
activity_type = activity['type']
@@ -144,7 +140,7 @@ def handle_follow(activity):
def handle_unfollow(activity):
''' unfollow a local user '''
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'])
# raises models.User.DoesNotExist
@@ -188,34 +184,48 @@ def handle_follow_reject(activity):
def handle_create(activity):
''' someone did something, good on them '''
# 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():
return
- serializer = activitypub.activity_objects[activity['type']]
- status = serializer(**activity)
+ try:
+ serializer = activitypub.activity_objects[activity['type']]
+ except KeyError:
+ return
+
+ activity = serializer(**activity)
try:
model = models.activity_models[activity.type]
except KeyError:
# not a type of status we are prepared to deserialize
return
- if activity.type == 'Note':
- reply = models.Status.objects.filter(
- remote_id=activity.inReplyTo
- ).first()
- if not reply:
- return
+ status = activity.to_model(model)
+ if not status:
+ # it was discarded because it's not a bookwyrm type
+ return
- activity.to_model(model)
# create a notification if this is a reply
+ notified = []
if status.reply_parent and status.reply_parent.user.local:
+ notified.append(status.reply_parent.user)
status_builder.create_notification(
status.reply_parent.user,
'REPLY',
related_user=status.user,
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
@@ -228,11 +238,12 @@ def handle_delete_status(activity):
# is trying to delete a user.
return
try:
- status = models.Status.objects.select_subclasses().get(
+ status = models.Status.objects.get(
remote_id=status_id
)
except models.Status.DoesNotExist:
return
+ models.Notification.objects.filter(related_status=status).all().delete()
status_builder.delete_status(status)
@@ -317,6 +328,12 @@ def handle_update_user(activity):
@app.task
-def handle_update_book(activity):
+def handle_update_edition(activity):
''' a remote instance changed a book (Document) '''
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)
diff --git a/bookwyrm/management/commands/deduplicate_book_data.py b/bookwyrm/management/commands/deduplicate_book_data.py
new file mode 100644
index 00000000..044b2a98
--- /dev/null
+++ b/bookwyrm/management/commands/deduplicate_book_data.py
@@ -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)
diff --git a/bookwyrm/migrations/0001_initial.py b/bookwyrm/migrations/0001_initial.py
index b1aba7df..347057e1 100644
--- a/bookwyrm/migrations/0001_initial.py
+++ b/bookwyrm/migrations/0001_initial.py
@@ -7,7 +7,7 @@ import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
-import bookwyrm.utils.fields
+from django.contrib.postgres.fields import JSONField
class Migration(migrations.Migration):
@@ -62,7 +62,7 @@ class Migration(migrations.Migration):
('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('openlibrary_key', models.CharField(max_length=255)),
- ('data', bookwyrm.utils.fields.JSONField()),
+ ('data', JSONField()),
],
options={
'abstract': False,
@@ -75,7 +75,7 @@ class Migration(migrations.Migration):
('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=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/')),
('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')),
diff --git a/bookwyrm/migrations/0006_auto_20200221_1702_squashed_0064_merge_20201101_1913.py b/bookwyrm/migrations/0006_auto_20200221_1702_squashed_0064_merge_20201101_1913.py
index 13cb1406..6a149ab5 100644
--- a/bookwyrm/migrations/0006_auto_20200221_1702_squashed_0064_merge_20201101_1913.py
+++ b/bookwyrm/migrations/0006_auto_20200221_1702_squashed_0064_merge_20201101_1913.py
@@ -2,7 +2,6 @@
import bookwyrm.models.connector
import bookwyrm.models.site
-import bookwyrm.utils.fields
from django.conf import settings
import django.contrib.postgres.operations
import django.core.validators
@@ -10,6 +9,7 @@ from django.db import migrations, models
import django.db.models.deletion
import django.db.models.expressions
import django.utils.timezone
+from django.contrib.postgres.fields import JSONField, ArrayField
import uuid
@@ -148,7 +148,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='book',
name='misc_identifiers',
- field=bookwyrm.utils.fields.JSONField(null=True),
+ field=JSONField(null=True),
),
migrations.AddField(
model_name='book',
@@ -226,7 +226,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='author',
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(
model_name='user',
@@ -394,17 +394,17 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='book',
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(
model_name='book',
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(
model_name='edition',
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(
model_name='connector',
@@ -578,7 +578,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='book',
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(
model_name='edition',
@@ -676,7 +676,7 @@ class Migration(migrations.Migration):
name='ImportItem',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('data', bookwyrm.utils.fields.JSONField()),
+ ('data', JSONField()),
],
),
migrations.CreateModel(
diff --git a/bookwyrm/migrations/0016_auto_20201129_0304.py b/bookwyrm/migrations/0016_auto_20201129_0304.py
index 2bf820e1..1e715969 100644
--- a/bookwyrm/migrations/0016_auto_20201129_0304.py
+++ b/bookwyrm/migrations/0016_auto_20201129_0304.py
@@ -1,10 +1,9 @@
# Generated by Django 3.0.7 on 2020-11-29 03:04
-import bookwyrm.utils.fields
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
-
+from django.contrib.postgres.fields import ArrayField
class Migration(migrations.Migration):
@@ -16,12 +15,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='book',
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(
model_name='book',
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(
model_name='edition',
diff --git a/bookwyrm/migrations/0017_auto_20201212_0059.py b/bookwyrm/migrations/0017_auto_20201212_0059.py
new file mode 100644
index 00000000..c9e3fcf4
--- /dev/null
+++ b/bookwyrm/migrations/0017_auto_20201212_0059.py
@@ -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'),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0023_auto_20201214_0511.py b/bookwyrm/migrations/0023_auto_20201214_0511.py
new file mode 100644
index 00000000..e811bded
--- /dev/null
+++ b/bookwyrm/migrations/0023_auto_20201214_0511.py
@@ -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),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0023_merge_20201216_0112.py b/bookwyrm/migrations/0023_merge_20201216_0112.py
new file mode 100644
index 00000000..e3af4849
--- /dev/null
+++ b/bookwyrm/migrations/0023_merge_20201216_0112.py
@@ -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 = [
+ ]
diff --git a/bookwyrm/migrations/0024_merge_20201216_1721.py b/bookwyrm/migrations/0024_merge_20201216_1721.py
new file mode 100644
index 00000000..41f81335
--- /dev/null
+++ b/bookwyrm/migrations/0024_merge_20201216_1721.py
@@ -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 = [
+ ]
diff --git a/bookwyrm/migrations/0025_auto_20201217_0046.py b/bookwyrm/migrations/0025_auto_20201217_0046.py
new file mode 100644
index 00000000..a3ffe8c1
--- /dev/null
+++ b/bookwyrm/migrations/0025_auto_20201217_0046.py
@@ -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=''),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0026_status_content_warning.py b/bookwyrm/migrations/0026_status_content_warning.py
new file mode 100644
index 00000000..f4e494db
--- /dev/null
+++ b/bookwyrm/migrations/0026_status_content_warning.py
@@ -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),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0027_auto_20201220_2007.py b/bookwyrm/migrations/0027_auto_20201220_2007.py
new file mode 100644
index 00000000..a3ad4dda
--- /dev/null
+++ b/bookwyrm/migrations/0027_auto_20201220_2007.py
@@ -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),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0028_remove_book_author_text.py b/bookwyrm/migrations/0028_remove_book_author_text.py
new file mode 100644
index 00000000..8743c910
--- /dev/null
+++ b/bookwyrm/migrations/0028_remove_book_author_text.py
@@ -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',
+ ),
+ ]
diff --git a/bookwyrm/migrations/0029_auto_20201221_2014.py b/bookwyrm/migrations/0029_auto_20201221_2014.py
new file mode 100644
index 00000000..ebf27a74
--- /dev/null
+++ b/bookwyrm/migrations/0029_auto_20201221_2014.py
@@ -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),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0030_auto_20201224_1939.py b/bookwyrm/migrations/0030_auto_20201224_1939.py
new file mode 100644
index 00000000..6de5d37f
--- /dev/null
+++ b/bookwyrm/migrations/0030_auto_20201224_1939.py
@@ -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]),
+ ),
+ ]
diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py
index b9a2814e..48852cfe 100644
--- a/bookwyrm/models/__init__.py
+++ b/bookwyrm/models/__init__.py
@@ -2,15 +2,18 @@
import inspect
import sys
-from .book import Book, Work, Edition
+from .book import Book, Work, Edition, BookDataModel
from .author import Author
from .connector import Connector
from .shelf import Shelf, ShelfBook
from .status import Status, GeneratedNote, Review, Comment, Quotation
-from .status import Favorite, Boost, Notification, ReadThrough
+from .status import Boost
from .attachment import Image
+from .favorite import Favorite
+from .notification import Notification
+from .readthrough import ReadThrough
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] \
for c in cls_members if hasattr(c[1], 'activity_serializer')}
-def to_activity(activity_json):
- ''' link up models and activities '''
- activity_type = activity_json.get('type')
- return activity_models[activity_type].to_activity(activity_json)
+status_models = [
+ c.__name__ for (_, c) in activity_models.items() if issubclass(c, Status)]
diff --git a/bookwyrm/models/author.py b/bookwyrm/models/author.py
index 79973a37..d0cb8d19 100644
--- a/bookwyrm/models/author.py
+++ b/bookwyrm/models/author.py
@@ -1,40 +1,25 @@
''' database schema for info about authors '''
from django.db import models
-from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
-from .base_model import ActivitypubMixin, BookWyrmModel
+from .book import BookDataModel
from . import fields
-class Author(ActivitypubMixin, BookWyrmModel):
+class Author(BookDataModel):
''' basic biographic info '''
- origin_id = models.CharField(max_length=255, null=True)
- openlibrary_key = fields.CharField(
+ wikipedia_link = fields.CharField(
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?
born = 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(
models.CharField(max_length=255), blank=True, default=list
)
- bio = fields.TextField(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)
+ bio = fields.HtmlField(null=True, blank=True)
def get_remote_id(self):
''' editions and works both use "book" instead of model_name '''
diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py
index f44797ab..b212d693 100644
--- a/bookwyrm/models/base_model.py
+++ b/bookwyrm/models/base_model.py
@@ -14,16 +14,9 @@ from django.dispatch import receiver
from bookwyrm import activitypub
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):
''' shared fields '''
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 '''
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)
+#pylint: disable=unused-argument
def execute_after_save(sender, instance, created, *args, **kwargs):
''' set the remote_id after save (when the id is available) '''
if not created or not hasattr(instance, 'get_remote_id'):
@@ -67,6 +66,33 @@ class ActivitypubMixin:
activity_serializer = lambda: {}
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
def find_existing_by_remote_id(cls, remote_id):
''' look up a remote id in the db '''
@@ -83,7 +109,7 @@ class ActivitypubMixin:
not field.deduplication_field:
continue
- value = data.get(field.activitypub_field)
+ value = data.get(field.get_activitypub_field())
if not value:
continue
filters.append({field.name: value})
@@ -114,19 +140,8 @@ class ActivitypubMixin:
def to_activity(self):
''' convert from a model to an activity '''
activity = {}
- for field in self._meta.get_fields():
- if not hasattr(field, 'field_to_activity'):
- 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
+ for field in self.activity_fields:
+ field.set_activity_from_field(activity, self)
if hasattr(self, 'serialize_reverse_fields'):
# for example, editions of a work
@@ -141,9 +156,9 @@ class ActivitypubMixin:
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 '''
- activity_object = self.to_activity()
+ activity_object = self.to_activity(**kwargs)
signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
content = activity_object['content']
@@ -227,7 +242,9 @@ class OrderedCollectionPageMixin(ActivitypubMixin):
).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 '''
paginated = Paginator(queryset, PAGE_LENGTH)
diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py
index bcd4bc04..08189510 100644
--- a/bookwyrm/models/book.py
+++ b/bookwyrm/models/book.py
@@ -2,7 +2,6 @@
import re
from django.db import models
-from django.utils import timezone
from model_utils.managers import InheritanceManager
from bookwyrm import activitypub
@@ -12,10 +11,9 @@ from .base_model import BookWyrmModel
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
from . import fields
-class Book(ActivitypubMixin, BookWyrmModel):
- ''' a generic book, which can mean either an edition or a work '''
+class BookDataModel(ActivitypubMixin, BookWyrmModel):
+ ''' fields shared between editable book data (books, works, authors) '''
origin_id = models.CharField(max_length=255, null=True, blank=True)
- # these identifiers apply to both works and editions
openlibrary_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
librarything_key = fields.CharField(
@@ -23,20 +21,33 @@ class Book(ActivitypubMixin, BookWyrmModel):
goodreads_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
- # info about where the data comes from and where/if to sync
- sync = models.BooleanField(default=True)
- sync_cover = models.BooleanField(default=True)
- last_sync_date = models.DateTimeField(default=timezone.now)
+ last_edited_by = models.ForeignKey(
+ 'User', on_delete=models.PROTECT, null=True)
+
+ 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', on_delete=models.PROTECT, null=True)
- # TODO: edit history
-
# book/work metadata
title = fields.CharField(max_length=255)
sort_title = 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(
models.CharField(max_length=255), blank=True, default=list
)
@@ -48,27 +59,42 @@ class Book(ActivitypubMixin, BookWyrmModel):
subject_places = fields.ArrayField(
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')
- # preformatted authorship string for search and easier display
- author_text = models.CharField(max_length=255, blank=True, null=True)
- cover = fields.ImageField(upload_to='covers/', blank=True, null=True)
+ cover = fields.ImageField(
+ upload_to='covers/', blank=True, null=True, alt_field='alt_text')
first_published_date = fields.DateTimeField(blank=True, null=True)
published_date = fields.DateTimeField(blank=True, null=True)
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):
''' can't be abstract for query reasons, but you shouldn't USE it '''
if not isinstance(self, Edition) and not isinstance(self, Work):
raise ValueError('Books should be added as Editions or Works')
-
- 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):
@@ -92,13 +118,22 @@ class Work(OrderedCollectionPageMixin, Book):
default_edition = fields.ForeignKey(
'Edition',
on_delete=models.PROTECT,
- null=True
+ null=True,
+ load_remote=False
)
def get_default_edition(self):
''' in case the default edition is not set '''
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
serialize_reverse_fields = [('editions', 'editions')]
deserialize_reverse_fields = [('editions', 'editions')]
diff --git a/bookwyrm/models/favorite.py b/bookwyrm/models/favorite.py
new file mode 100644
index 00000000..8373b016
--- /dev/null
+++ b/bookwyrm/models/favorite.py
@@ -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')
diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py
index e6878fb9..c6571ff4 100644
--- a/bookwyrm/models/fields.py
+++ b/bookwyrm/models/fields.py
@@ -1,10 +1,10 @@
''' activitypub-aware django model fields '''
+from dataclasses import MISSING
import re
from uuid import uuid4
import dateutil.parser
from dateutil.parser import ParserError
-from django.contrib.auth.models import AbstractUser
from django.contrib.postgres.fields import ArrayField as DjangoArrayField
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
@@ -12,8 +12,9 @@ from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from bookwyrm import activitypub
-from bookwyrm.settings import DOMAIN
from bookwyrm.connectors import get_image
+from bookwyrm.sanitize_html import InputHtmlParser
+from bookwyrm.settings import DOMAIN
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:
''' make a database field serializable '''
def __init__(self, *args, \
@@ -38,6 +57,39 @@ class ActivitypubFieldMixin:
self.activitypub_field = activitypub_field
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):
''' formatter to convert a model value into activitypub '''
if hasattr(self, 'activitypub_wrapper'):
@@ -61,12 +113,19 @@ class ActivitypubFieldMixin:
class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
''' 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):
if not value:
return None
related_model = self.related_model
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
activity_serializer = related_model.activity_serializer
return activity_serializer(**value).to_model(related_model)
@@ -77,6 +136,9 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
# we don't know what this is, ignore it
return None
# 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)
@@ -94,7 +156,7 @@ class RemoteIdField(ActivitypubFieldMixin, models.CharField):
class UsernameField(ActivitypubFieldMixin, models.CharField):
''' activitypub-aware username field '''
- def __init__(self, activitypub_field='preferredUsername'):
+ def __init__(self, activitypub_field='preferredUsername', **kwargs):
self.activitypub_field = activitypub_field
# I don't totally know why pylint is mad at this, but it makes it work
super( #pylint: disable=bad-super-call
@@ -103,7 +165,7 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
_('username'),
max_length=150,
unique=True,
- validators=[AbstractUser.username_validator],
+ validators=[validate_username],
error_messages={
'unique': _('A user with that username already exists.'),
},
@@ -123,6 +185,52 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
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):
''' activitypub-aware foreign key field '''
def field_to_activity(self, value):
@@ -145,6 +253,14 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
self.link_only = link_only
super().__init__(*args, **kwargs)
+ def set_field_from_activity(self, instance, data):
+ ''' helper function for assinging a value to the field '''
+ 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):
if self.link_only:
return '%s/%s' % (value.instance.remote_id, self.name)
@@ -152,6 +268,8 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
def field_from_activity(self, value):
items = []
+ if value is None or value is MISSING:
+ return []
for remote_id in value:
try:
validate_remote_id(remote_id)
@@ -189,6 +307,8 @@ class TagField(ManyToManyField):
for link_json in value:
link = activitypub.Link(**link_json)
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:
# tags can contain multiple types
continue
@@ -198,20 +318,45 @@ class TagField(ManyToManyField):
return items
-def image_serializer(value):
+def image_serializer(value, alt):
''' helper for serializing images '''
if value and hasattr(value, 'url'):
url = value.url
else:
return None
url = 'https://%s%s' % (DOMAIN, url)
- return activitypub.Image(url=url)
+ return activitypub.Image(url=url, name=alt)
class ImageField(ActivitypubFieldMixin, models.ImageField):
''' activitypub-aware image field '''
- def field_to_activity(self, value):
- return image_serializer(value)
+ def __init__(self, *args, alt_field=None, **kwargs):
+ 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):
image_slug = value
@@ -255,6 +400,15 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
except (ParserError, TypeError):
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):
''' activitypub-aware array field '''
def field_to_activity(self, value):
diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py
index fe39325f..1ebe9b31 100644
--- a/bookwyrm/models/import_job.py
+++ b/bookwyrm/models/import_job.py
@@ -2,14 +2,13 @@
import re
import dateutil.parser
+from django.contrib.postgres.fields import JSONField
from django.db import models
from django.utils import timezone
-from bookwyrm import books_manager
-from bookwyrm.connectors import ConnectorException
+from bookwyrm.connectors import connector_manager
from bookwyrm.models import ReadThrough, User, Book
-from bookwyrm.utils.fields import JSONField
-from .base_model import PrivacyLevels
+from .fields import PrivacyLevels
# Mapping goodreads -> bookwyrm shelf titles.
@@ -72,12 +71,12 @@ class ImportItem(models.Model):
def get_book_from_isbn(self):
''' search by isbn '''
- search_result = books_manager.first_search_result(
+ search_result = connector_manager.first_search_result(
self.isbn, min_confidence=0.999
)
if search_result:
# 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
@@ -87,12 +86,12 @@ class ImportItem(models.Model):
self.data['Title'],
self.data['Author']
)
- search_result = books_manager.first_search_result(
+ search_result = connector_manager.first_search_result(
search_term, min_confidence=0.999
)
if search_result:
# 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
diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py
new file mode 100644
index 00000000..4ce5dcea
--- /dev/null
+++ b/bookwyrm/models/notification.py
@@ -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",
+ )
+ ]
diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py
new file mode 100644
index 00000000..61cac7e6
--- /dev/null
+++ b/bookwyrm/models/readthrough.py
@@ -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)
diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py
index 8913b9ab..0f3c1dab 100644
--- a/bookwyrm/models/relationship.py
+++ b/bookwyrm/models/relationship.py
@@ -37,7 +37,7 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
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 '''
status = status or 'follows'
base_path = self.user_subject.remote_id
@@ -54,7 +54,7 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
def to_reject_activity(self):
- ''' generate an Accept for this follow request '''
+ ''' generate a Reject for this follow request '''
return activitypub.Reject(
id=self.get_remote_id(status='rejects'),
actor=self.user_object.remote_id,
diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py
index fc63d198..69df43b4 100644
--- a/bookwyrm/models/shelf.py
+++ b/bookwyrm/models/shelf.py
@@ -3,8 +3,8 @@ import re
from django.db import models
from bookwyrm import activitypub
-from .base_model import BookWyrmModel
-from .base_model import OrderedCollectionMixin, PrivacyLevels
+from .base_model import ActivitypubMixin, BookWyrmModel
+from .base_model import OrderedCollectionMixin
from . import fields
@@ -18,7 +18,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
privacy = fields.CharField(
max_length=255,
default='public',
- choices=PrivacyLevels.choices
+ choices=fields.PrivacyLevels.choices
)
books = models.ManyToManyField(
'Edition',
@@ -51,7 +51,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
unique_together = ('user', 'identifier')
-class ShelfBook(BookWyrmModel):
+class ShelfBook(ActivitypubMixin, BookWyrmModel):
''' many to many join table for books and shelves '''
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='object')
diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py
index 55036f2c..2494c458 100644
--- a/bookwyrm/models/status.py
+++ b/bookwyrm/models/status.py
@@ -1,12 +1,16 @@
''' 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.db import models
+from django.utils import timezone
from model_utils.managers import InheritanceManager
from bookwyrm import activitypub
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
-from .base_model import BookWyrmModel, PrivacyLevels
+from .base_model import BookWyrmModel
from . import fields
from .fields import image_serializer
@@ -14,17 +18,15 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
''' any post, like a reply to a review, etc '''
user = fields.ForeignKey(
'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_books = fields.TagField('Edition', related_name='mention_book')
local = models.BooleanField(default=True)
- privacy = models.CharField(
- max_length=255,
- default='public',
- choices=PrivacyLevels.choices
- )
+ content_warning = fields.CharField(
+ max_length=500, blank=True, null=True, activitypub_field='summary')
+ privacy = fields.PrivacyField(max_length=255)
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(
default=timezone.now, activitypub_field='published')
deleted = models.BooleanField(default=False)
@@ -48,18 +50,45 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
serialize_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
def replies(cls, status):
''' load all replies to a status. idk if there's a better way
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
def status_type(self):
''' expose the type of status for the ui using activity type '''
return self.activity_serializer.__name__
+ @property
+ def boostable(self):
+ ''' you can't boost dms '''
+ return self.privacy in ['unlisted', 'public']
+
def to_replies(self, **kwargs):
''' helper function for loading AP serialized replies to a status '''
return self.to_ordered_collection(
@@ -68,7 +97,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
**kwargs
)
- def to_activity(self, pure=False):
+ def to_activity(self, pure=False):# pylint: disable=arguments-differ
''' return tombstone if the status is deleted '''
if self.deleted:
return activitypub.Tombstone(
@@ -80,37 +109,18 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
activity = ActivitypubMixin.to_activity(self)
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
- if pure:
+ if pure and hasattr(self, 'pure_content'):
activity['content'] = self.pure_content
if 'name' in activity:
activity['name'] = self.pure_name
activity['type'] = self.pure_type
activity['attachment'] = [
- image_serializer(b.cover) for b in self.mention_books.all() \
- if b.cover]
- if hasattr(self, 'book'):
+ image_serializer(b.cover, b.alt_text) \
+ for b in self.mention_books.all()[:4] if b.cover]
+ if hasattr(self, 'book') and self.book.cover:
activity['attachment'].append(
- image_serializer(self.book.cover)
+ image_serializer(self.book.cover, self.book.alt_text)
)
return activity
@@ -147,8 +157,8 @@ class Comment(Status):
@property
def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
- return self.content + '
(comment on "%s")' % \
- (self.book.remote_id, self.book.title)
+ return '%s(comment on "%s")
' % \
+ (self.content, self.book.remote_id, self.book.title)
activity_serializer = activitypub.Comment
pure_type = 'Note'
@@ -156,15 +166,17 @@ class Comment(Status):
class Quotation(Status):
''' like a review but without a rating and transient '''
- quote = fields.TextField()
+ quote = fields.HtmlField()
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
@property
def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
- return '"%s"
-- "%s"
%s' % (
- self.quote,
+ quote = re.sub(r'^', '
"', self.quote)
+ quote = re.sub(r'
$', '"
', quote)
+ return '%s -- "%s"
%s' % (
+ quote,
self.book.remote_id,
self.book.title,
self.content,
@@ -190,6 +202,7 @@ class Review(Status):
def pure_name(self):
''' clarify review names for mastodon serialization '''
if self.rating:
+ #pylint: disable=bad-string-format-type
return 'Review of "%s" (%d stars): %s' % (
self.book.title,
self.rating,
@@ -203,33 +216,12 @@ class Review(Status):
@property
def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
- return self.content + '
("%s")' % \
- (self.book.remote_id, self.book.title)
+ return self.content
activity_serializer = activitypub.Review
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):
''' boost'ing a post '''
boosted_status = fields.ForeignKey(
@@ -239,59 +231,20 @@ class Boost(Status):
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
# This constraint can't work as it would cross tables.
# class Meta:
# 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",
- )
- ]
diff --git a/bookwyrm/models/tag.py b/bookwyrm/models/tag.py
index 940b4192..6e0ba8ab 100644
--- a/bookwyrm/models/tag.py
+++ b/bookwyrm/models/tag.py
@@ -17,7 +17,9 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
@classmethod
def book_queryset(cls, identifier):
''' county of books associated with this tag '''
- return cls.objects.filter(identifier=identifier)
+ return cls.objects.filter(
+ identifier=identifier
+ ).order_by('-updated_date')
@property
def collection_queryset(self):
@@ -64,7 +66,7 @@ class UserTag(BookWyrmModel):
id='%s#remove' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
- target=self.to_activity(),
+ target=self.remote_id,
).serialize()
diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py
index 63549d36..4cbe387f 100644
--- a/bookwyrm/models/user.py
+++ b/bookwyrm/models/user.py
@@ -1,6 +1,8 @@
''' database schema for user data '''
+import re
from urllib.parse import urlparse
+from django.apps import apps
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.dispatch import receiver
@@ -12,6 +14,7 @@ from bookwyrm.models.status import Status, Review
from bookwyrm.settings import DOMAIN
from bookwyrm.signatures import create_key_pair
from bookwyrm.tasks import app
+from bookwyrm.utils import regex
from .base_model import OrderedCollectionPageMixin
from .base_model import ActivitypubMixin, BookWyrmModel
from .federated_server import FederatedServer
@@ -42,18 +45,20 @@ class User(OrderedCollectionPageMixin, AbstractUser):
blank=True,
)
outbox = fields.RemoteIdField(unique=True)
- summary = fields.TextField(default='')
+ summary = fields.HtmlField(null=True, blank=True)
local = models.BooleanField(default=False)
bookwyrm_user = fields.BooleanField(default=True)
localname = models.CharField(
max_length=255,
null=True,
- unique=True
+ unique=True,
+ validators=[fields.validate_localname],
)
# 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(
- 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(
'self',
link_only=True,
@@ -90,20 +95,37 @@ class User(OrderedCollectionPageMixin, AbstractUser):
last_active_date = models.DateTimeField(auto_now=True)
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
def display_name(self):
''' show the cleanest version of the user's name possible '''
- if self.name != '':
+ if self.name and self.name != '':
return self.name
return self.localname or self.username
activity_serializer = activitypub.Person
- def to_outbox(self, **kwargs):
+ def to_outbox(self, filter_type=None, **kwargs):
''' 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,
deleted=False,
+ privacy__in=['public', 'unlisted'],
).select_subclasses().order_by('-published_date')
return self.to_ordered_collection(queryset, \
remote_id=self.outbox, **kwargs)
@@ -111,14 +133,22 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def to_following_activity(self, **kwargs):
''' activitypub following list '''
remote_id = '%s/following' % self.remote_id
- return self.to_ordered_collection(self.following.all(), \
- remote_id=remote_id, id_only=True, **kwargs)
+ return self.to_ordered_collection(
+ self.following.order_by('-updated_date').all(),
+ remote_id=remote_id,
+ id_only=True,
+ **kwargs
+ )
def to_followers_activity(self, **kwargs):
''' activitypub followers list '''
remote_id = '%s/followers' % self.remote_id
- return self.to_ordered_collection(self.followers.all(), \
- remote_id=remote_id, id_only=True, **kwargs)
+ return self.to_ordered_collection(
+ self.followers.order_by('-updated_date').all(),
+ remote_id=remote_id,
+ id_only=True,
+ **kwargs
+ )
def to_activity(self):
''' override default AP serializer to add context object
@@ -140,26 +170,28 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def save(self, *args, **kwargs):
''' populate fields for new local users '''
# this user already exists, no need to populate fields
- if self.id:
- return super().save(*args, **kwargs)
-
- if not self.local:
+ if not self.local and not re.match(regex.full_username, self.username):
# generate a username that uses the domain (webfinger format)
actor_parts = urlparse(self.remote_id)
self.username = '%s@%s' % (self.username, actor_parts.netloc)
return super().save(*args, **kwargs)
+ if self.id or not self.local:
+ return super().save(*args, **kwargs)
+
# populate fields for local users
- self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.username)
- self.localname = self.username
- self.username = '%s@%s' % (self.username, DOMAIN)
- self.actor = self.remote_id
+ self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.localname)
self.inbox = '%s/inbox' % self.remote_id
self.shared_inbox = 'https://%s/inbox' % DOMAIN
self.outbox = '%s/outbox' % self.remote_id
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):
''' public and private keys for a user '''
@@ -265,7 +297,7 @@ def get_or_create_remote_server(domain):
@app.task
def get_remote_reviews(outbox):
''' 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)
# TODO: pagination?
diff --git a/bookwyrm/outgoing.py b/bookwyrm/outgoing.py
index 38b48282..88377d33 100644
--- a/bookwyrm/outgoing.py
+++ b/bookwyrm/outgoing.py
@@ -2,14 +2,18 @@
import re
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.http import require_GET
+from markdown import markdown
from requests import HTTPError
from bookwyrm import activitypub
from bookwyrm import models
from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.broadcast import broadcast
+from bookwyrm.sanitize_html import InputHtmlParser
from bookwyrm.status import create_notification
from bookwyrm.status import create_generated_note
from bookwyrm.status import delete_status
@@ -18,19 +22,16 @@ from bookwyrm.utils import regex
@csrf_exempt
+@require_GET
def outbox(request, username):
''' outbox for the requested user '''
- if request.method != 'GET':
- return HttpResponseNotFound()
+ user = get_object_or_404(models.User, localname=username)
+ 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(
- user.to_outbox(**request.GET),
+ user.to_outbox(**request.GET, filter_type=filter_type),
encoder=activitypub.ActivityEncoder
)
@@ -40,6 +41,9 @@ def handle_remote_webfinger(query):
user = None
# usernames could be @user@domain or user@domain
+ if not query:
+ return None
+
if query[0] == '@':
query = query[1:]
@@ -162,22 +166,23 @@ def handle_imported_book(user, item, include_reviews, privacy):
if not item.book:
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(
identifier=item.shelf,
user=user
)
- # shelve the book if it hasn't been shelved already
- shelf_book, created = models.ShelfBook.objects.get_or_create(
+ shelf_book = models.ShelfBook.objects.create(
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:
- read.book = item.book
- read.user = user
- read.save()
+ for read in item.reads:
+ read.book = item.book
+ read.user = user
+ read.save()
if include_reviews and (item.rating or item.review):
review_title = 'Review of {!r} on Goodreads'.format(
@@ -209,15 +214,72 @@ def handle_delete_status(user, status):
def handle_status(user, form):
''' 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
- text = status.content
- matches = re.finditer(
- regex.username,
- text
- )
- for match in matches:
+ content = status.content
+ for (mention_text, mention_user) in find_mentions(content):
+ # add them to status mentions fk
+ status.mention_users.add(mention_user)
+
+ # turn the mention into a link
+ content = re.sub(
+ r'%s([^@]|$)' % mention_text,
+ r'%s\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:]
if len(username) == 1:
# this looks like a local user (@user), fill in the domain
@@ -228,48 +290,21 @@ def handle_status(user, form):
if not mention_user:
# we can ignore users we don't know about
continue
- # add them to status mentions fk
- 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')
+ yield (match.group(), mention_user)
-def handle_tag(user, tag):
- ''' tag a book '''
- broadcast(user, tag.to_add_activity(user))
-
-
-def handle_untag(user, book, name):
- ''' tag a book '''
- book = models.Book.objects.get(id=book)
- tag = models.Tag.objects.get(name=name, book=book, user=user)
- tag_activity = tag.to_remove_activity(user)
- tag.delete()
-
- broadcast(user, tag_activity)
+def to_markdown(content):
+ ''' catch links and convert to markdown '''
+ content = re.sub(
+ r'([^(href=")])(https?:\/\/([A-Za-z\.\-_\/]+' \
+ r'\.[A-Za-z]{2,}[A-Za-z\.\-_\/]+))',
+ r'\g<1>\g<3>',
+ content)
+ content = markdown(content)
+ # sanitize resulting html
+ sanitizer = InputHtmlParser()
+ sanitizer.feed(content)
+ return sanitizer.get_output()
def handle_favorite(user, status):
@@ -312,15 +347,19 @@ def handle_unfavorite(user, status):
def handle_boost(user, status):
''' a user wishes to boost a status '''
+ # is it boostable?
+ if not status.boostable:
+ return
+
if models.Boost.objects.filter(
boosted_status=status, user=user).exists():
# you already boosted that.
return
boost = models.Boost.objects.create(
boosted_status=status,
+ privacy=status.privacy,
user=user,
)
- boost.save()
boost_activity = boost.to_activity()
broadcast(user, boost_activity)
@@ -344,9 +383,9 @@ def handle_unboost(user, status):
broadcast(user, activity)
-def handle_update_book(user, book):
+def handle_update_book_data(user, item):
''' 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):
diff --git a/bookwyrm/sanitize_html.py b/bookwyrm/sanitize_html.py
index 9c5ca73a..de13ede8 100644
--- a/bookwyrm/sanitize_html.py
+++ b/bookwyrm/sanitize_html.py
@@ -1,12 +1,16 @@
''' html parser to clean up incoming text from unknown sources '''
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 '''
def __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.output = []
# if the html appears invalid, we just won't allow any at all
diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py
index 3784158c..46c38b5a 100644
--- a/bookwyrm/settings.py
+++ b/bookwyrm/settings.py
@@ -3,8 +3,11 @@ import os
from environs import Env
+import requests
+
env = Env()
DOMAIN = env('DOMAIN')
+VERSION = '0.0.1'
PAGE_LENGTH = env('PAGE_LENGTH', 15)
@@ -99,10 +102,6 @@ BOOKWYRM_DBS = {
'HOST': env('POSTGRES_HOST', ''),
'PORT': 5432
},
- 'sqlite': {
- 'ENGINE': 'django.db.backends.sqlite3',
- 'NAME': os.path.join(BASE_DIR, 'fedireads.db')
- }
}
DATABASES = {
@@ -154,3 +153,6 @@ STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, env('STATIC_ROOT', 'static'))
MEDIA_URL = '/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)
diff --git a/bookwyrm/static/css/format.css b/bookwyrm/static/css/format.css
index 9e8a24ba..7dab69b0 100644
--- a/bookwyrm/static/css/format.css
+++ b/bookwyrm/static/css/format.css
@@ -65,6 +65,14 @@ input.toggle-control:checked ~ .modal.toggle-content {
.cover-container {
height: 250px;
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 {
height: 150px;
@@ -136,8 +144,3 @@ input.toggle-control:checked ~ .modal.toggle-content {
content: "\e904";
right: 0;
}
-
-/* --- BLOCKQUOTE --- */
-blockquote {
- white-space: pre-line;
-}
diff --git a/bookwyrm/status.py b/bookwyrm/status.py
index 83a106e5..648f2e7d 100644
--- a/bookwyrm/status.py
+++ b/bookwyrm/status.py
@@ -1,7 +1,7 @@
''' Handle user activity '''
from django.utils import timezone
-from bookwyrm import activitypub, books_manager, models
+from bookwyrm import models
from bookwyrm.sanitize_html import InputHtmlParser
diff --git a/bookwyrm/templates/author.html b/bookwyrm/templates/author.html
index 3e3e0018..4235b266 100644
--- a/bookwyrm/templates/author.html
+++ b/bookwyrm/templates/author.html
@@ -1,14 +1,32 @@
{% extends 'layout.html' %}
-{% load fr_display %}
+{% load bookwyrm_tags %}
{% block content %}
-
{{ author.name }}
+
+
+
{{ author.name }}
+
+ {% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
+
+ {% endif %}
+
+
+
{% if author.bio %}
- {{ author.bio }}
+ {{ author.bio | to_markdown | safe }}
{% endif %}
+ {% if author.wikipedia_link %}
+
Wikipedia
+ {% endif %}
diff --git a/bookwyrm/templates/book.html b/bookwyrm/templates/book.html
index 8b21b88c..4bbc8d10 100644
--- a/bookwyrm/templates/book.html
+++ b/bookwyrm/templates/book.html
@@ -1,16 +1,24 @@
{% extends 'layout.html' %}
-{% load fr_display %}
+{% load bookwyrm_tags %}
{% load humanize %}
{% block content %}
-
-
- {% include 'snippets/book_titleby.html' with book=book %}
-
+
+
+
+ {{ book.title }}{% if book.subtitle %}:
+ {{ book.subtitle }}{% endif %}
+
+ {% if book.authors %}
+
+ by {% include 'snippets/authors.html' with book=book %}
+
+ {% endif %}
+
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
-
+
- {% for readthrough in readthroughs %}
-
-
-
-
- {% if readthrough.start_date %}
- - Started reading:
- - {{ readthrough.start_date | naturalday }}
- {% endif %}
- {% if readthrough.finish_date %}
- - Finished reading:
- - {{ readthrough.finish_date | naturalday }}
- {% endif %}
-
-
-
-
-
-
-
-
-
-
+ {# user's relationship to the book #}
-
-
-
-
-
- Delete this read-though?
-
-
-
-
-
-
+ {% for shelf in user_shelves %}
+
+ This edition is on your {{ shelf.shelf.name }} shelf.
+ {% include 'snippets/shelf_selector.html' with current=shelf.shelf %}
+
+ {% endfor %}
+
+ {% for shelf in other_edition_shelves %}
+
+ A different edition of this book is on your {{ shelf.shelf.name }} shelf.
+ {% include 'snippets/switch_edition_button.html' with edition=book %}
+
+ {% endfor %}
+
+ {% for readthrough in readthroughs %}
+ {% include 'snippets/readthrough.html' with readthrough=readthrough %}
+ {% endfor %}
- {% endfor %}
{% if request.user.is_authenticated %}
@@ -219,10 +166,10 @@
{% for rating in ratings %}