diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index 5fc849d6..afa9cf2b 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -8,6 +8,6 @@ jobs: steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 - - uses: psf/black@20.8b1 + - uses: psf/black@stable with: args: ". --check -l 80 -S" diff --git a/bookwyrm/activitypub/person.py b/bookwyrm/activitypub/person.py index 4ab9f08e..9231bd95 100644 --- a/bookwyrm/activitypub/person.py +++ b/bookwyrm/activitypub/person.py @@ -23,6 +23,7 @@ class Person(ActivityObject): inbox: str publicKey: PublicKey followers: str = None + following: str = None outbox: str = None endpoints: Dict = None name: str = None diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index 00b5c5c9..2483cc62 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -179,7 +179,11 @@ class AbstractConnector(AbstractMinimalConnector): data = get_data(remote_id) mapped_data = dict_from_mappings(data, self.author_mappings) - activity = activitypub.Author(**mapped_data) + try: + activity = activitypub.Author(**mapped_data) + except activitypub.ActivitySerializerError: + return None + # this will dedupe return activity.to_model(model=models.Author) diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py index caf6bcbe..53198c0a 100644 --- a/bookwyrm/connectors/connector_manager.py +++ b/bookwyrm/connectors/connector_manager.py @@ -1,5 +1,6 @@ """ interface with whatever connectors the app has """ import importlib +import logging import re from urllib.parse import urlparse @@ -11,6 +12,8 @@ from requests import HTTPError from bookwyrm import models from bookwyrm.tasks import app +logger = logging.getLogger(__name__) + class ConnectorException(HTTPError): """ when the connector can't do what was asked """ @@ -37,14 +40,17 @@ def search(query, min_confidence=0.1): else: try: result_set = connector.isbn_search(isbn) - except (HTTPError, ConnectorException): - pass + except Exception as e: # pylint: disable=broad-except + logger.exception(e) + continue # if no isbn search or results, we fallback to generic search if result_set in (None, []): try: result_set = connector.search(query, min_confidence=min_confidence) - except (HTTPError, ConnectorException): + except Exception as e: # pylint: disable=broad-except + # we don't want *any* error to crash the whole search page + logger.exception(e) continue # if the search results look the same, ignore them diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py index 9be0266c..8ee738eb 100644 --- a/bookwyrm/connectors/openlibrary.py +++ b/bookwyrm/connectors/openlibrary.py @@ -93,7 +93,10 @@ class Connector(AbstractConnector): # this id is "/authors/OL1234567A" author_id = author_blob["key"] url = "%s%s" % (self.base_url, author_id) - yield self.get_or_create_author(url) + author = self.get_or_create_author(url) + if not author: + continue + yield author def get_cover_url(self, cover_blob, size="L"): """ ask openlibrary for the cover """ diff --git a/bookwyrm/migrations/0062_auto_20210407_1545.py b/bookwyrm/migrations/0062_auto_20210407_1545.py new file mode 100644 index 00000000..3a156637 --- /dev/null +++ b/bookwyrm/migrations/0062_auto_20210407_1545.py @@ -0,0 +1,33 @@ +# Generated by Django 3.1.6 on 2021-04-07 15:45 + +import bookwyrm.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0061_auto_20210402_1435"), + ] + + operations = [ + migrations.AlterField( + model_name="book", + name="series", + field=bookwyrm.models.fields.TextField( + blank=True, max_length=255, null=True + ), + ), + migrations.AlterField( + model_name="book", + name="subtitle", + field=bookwyrm.models.fields.TextField( + blank=True, max_length=255, null=True + ), + ), + migrations.AlterField( + model_name="book", + name="title", + field=bookwyrm.models.fields.TextField(max_length=255), + ), + ] diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py index a253207a..8dce42e4 100644 --- a/bookwyrm/models/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin.py @@ -1,5 +1,6 @@ """ activitypub model functionality """ from base64 import b64encode +from collections import namedtuple from functools import reduce import json import operator @@ -25,6 +26,15 @@ from bookwyrm.models.fields import ImageField, ManyToManyField logger = logging.getLogger(__name__) # I tried to separate these classes into mutliple files but I kept getting # circular import errors so I gave up. I'm sure it could be done though! + +PropertyField = namedtuple("PropertyField", ("set_activity_from_field")) + + +def set_activity_from_property_field(activity, obj, field): + """ assign a model property value to the activity json """ + activity[field[1]] = getattr(obj, field[0]) + + class ActivitypubMixin: """ add this mixin for models that are AP serializable """ @@ -52,6 +62,11 @@ class ActivitypubMixin: self.activity_fields = ( self.image_fields + self.many_to_many_fields + self.simple_fields ) + if hasattr(self, "property_fields"): + self.activity_fields += [ + PropertyField(lambda a, o: set_activity_from_property_field(a, o, f)) + for f in self.property_fields + ] # these are separate to avoid infinite recursion issues self.deserialize_reverse_fields = ( @@ -370,7 +385,7 @@ class CollectionItemMixin(ActivitypubMixin): object_field = getattr(self, self.object_field) collection_field = getattr(self, self.collection_field) return activitypub.Add( - id=self.remote_id, + id=self.get_remote_id(), actor=self.user.remote_id, object=object_field, target=collection_field.remote_id, @@ -381,7 +396,7 @@ class CollectionItemMixin(ActivitypubMixin): object_field = getattr(self, self.object_field) collection_field = getattr(self, self.collection_field) return activitypub.Remove( - id=self.remote_id, + id=self.get_remote_id(), actor=self.user.remote_id, object=object_field, target=collection_field.remote_id, @@ -430,7 +445,7 @@ def generate_activity(obj): ) in obj.serialize_reverse_fields: related_field = getattr(obj, model_field_name) activity[activity_field_name] = unfurl_related_field( - related_field, sort_field + related_field, sort_field=sort_field ) if not activity.get("id"): @@ -440,7 +455,7 @@ def generate_activity(obj): def unfurl_related_field(related_field, sort_field=None): """ load reverse lookups (like public key owner or Status attachment """ - if hasattr(related_field, "all"): + if sort_field and hasattr(related_field, "all"): return [ unfurl_related_field(i) for i in related_field.order_by(sort_field).all() ] diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index 3204c603..a6824c0a 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -53,14 +53,14 @@ class Book(BookDataModel): connector = models.ForeignKey("Connector", on_delete=models.PROTECT, null=True) # book/work metadata - title = fields.CharField(max_length=255) + title = fields.TextField(max_length=255) sort_title = fields.CharField(max_length=255, blank=True, null=True) - subtitle = fields.CharField(max_length=255, blank=True, null=True) + subtitle = fields.TextField(max_length=255, blank=True, null=True) description = fields.HtmlField(blank=True, null=True) languages = fields.ArrayField( models.CharField(max_length=255), blank=True, default=list ) - series = fields.CharField(max_length=255, blank=True, null=True) + series = fields.TextField(max_length=255, blank=True, null=True) series_number = fields.CharField(max_length=255, blank=True, null=True) subjects = fields.ArrayField( models.CharField(max_length=255), blank=True, null=True, default=list diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 33dedc9e..dcc4162e 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -112,6 +112,12 @@ class User(OrderedCollectionPageMixin, AbstractUser): ) name_field = "username" + property_fields = [("following_link", "following")] + + @property + def following_link(self): + """ just how to find out the following info """ + return "{:s}/following".format(self.remote_id) @property def alt_text(self): diff --git a/bookwyrm/templates/book/edit_book.html b/bookwyrm/templates/book/edit_book.html index a9ce651e..1da7c3f7 100644 --- a/bookwyrm/templates/book/edit_book.html +++ b/bookwyrm/templates/book/edit_book.html @@ -88,12 +88,18 @@
{{ form.title }}
++ + +
{% for error in form.title.errors %}{{ error | escape }}
{% endfor %} -{{ form.subtitle }}
++ + +
{% for error in form.subtitle.errors %}{{ error | escape }}
{% endfor %} @@ -124,7 +130,7 @@- +
{% for error in form.first_published_date.errors %}{{ error | escape }}
@@ -132,7 +138,7 @@- +
{% for error in form.published_date.errors %}{{ error | escape }}
diff --git a/bookwyrm/views/books.py b/bookwyrm/views/books.py index 58886cad..b1b2d065 100644 --- a/bookwyrm/views/books.py +++ b/bookwyrm/views/books.py @@ -1,6 +1,8 @@ """ the good stuff! the books! """ +from datetime import datetime from uuid import uuid4 +from dateutil.parser import parse as dateparse from django.contrib.auth.decorators import login_required, permission_required from django.contrib.postgres.search import SearchRank, SearchVector from django.core.files.base import ContentFile @@ -10,6 +12,7 @@ from django.db.models import Avg, Q from django.http import HttpResponseBadRequest, HttpResponseNotFound from django.shortcuts import get_object_or_404, redirect from django.template.response import TemplateResponse +from django.utils.datastructures import MultiValueDictKeyError from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.http import require_POST @@ -172,6 +175,20 @@ class EditBook(View): data["confirm_mode"] = True # this isn't preserved because it isn't part of the form obj data["remove_authors"] = request.POST.getlist("remove_authors") + # we have to make sure the dates are passed in as datetime, they're currently a string + # QueryDicts are immutable, we need to copy + formcopy = data["form"].data.copy() + try: + formcopy["first_published_date"] = dateparse( + formcopy["first_published_date"] + ) + except MultiValueDictKeyError: + pass + try: + formcopy["published_date"] = dateparse(formcopy["published_date"]) + except MultiValueDictKeyError: + pass + data["form"].data = formcopy return TemplateResponse(request, "book/edit_book.html", data) remove_authors = request.POST.getlist("remove_authors") diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py index 75c5da8f..41e6a608 100644 --- a/bookwyrm/views/helpers.py +++ b/bookwyrm/views/helpers.py @@ -138,7 +138,7 @@ def handle_remote_webfinger(query): user = activitypub.resolve_remote_id( link["href"], model=models.User ) - except KeyError: + except (KeyError, activitypub.ActivitySerializerError): return None return user diff --git a/bw-dev b/bw-dev index b9c4b2a1..42fb4a2e 100755 --- a/bw-dev +++ b/bw-dev @@ -19,7 +19,6 @@ function clean { function runweb { docker-compose run --rm web "$@" - clean } function execdb { @@ -64,17 +63,16 @@ case "$CMD" in clean ;; makemigrations) - execweb python manage.py makemigrations "$@" + runweb python manage.py makemigrations "$@" ;; migrate) - execweb python manage.py rename_app fedireads bookwyrm - execweb python manage.py migrate "$@" + runweb python manage.py migrate "$@" ;; bash) - execweb bash + runweb bash ;; shell) - execweb python manage.py shell + runweb python manage.py shell ;; dbshell) execdb psql -U ${POSTGRES_USER} ${POSTGRES_DB} @@ -83,22 +81,19 @@ case "$CMD" in docker-compose restart celery_worker ;; test) - execweb coverage run --source='.' --omit="*/test*,celerywyrm*,bookwyrm/migrations/*" manage.py test "$@" + runweb coverage run --source='.' --omit="*/test*,celerywyrm*,bookwyrm/migrations/*" manage.py test "$@" ;; pytest) - execweb pytest --no-cov-on-fail "$@" - ;; - test_report) - execweb coverage report + runweb pytest --no-cov-on-fail "$@" ;; collectstatic) - execweb python manage.py collectstatic --no-input + runweb python manage.py collectstatic --no-input ;; makemessages) - execweb django-admin makemessages --no-wrap --ignore=venv3 $@ + runweb django-admin makemessages --no-wrap --ignore=venv3 $@ ;; compilemessages) - execweb django-admin compilemessages --ignore venv3 $@ + runweb django-admin compilemessages --ignore venv3 $@ ;; build) docker-compose build @@ -110,7 +105,7 @@ case "$CMD" in makeitblack ;; populate_streams) - execweb python manage.py populate_streams + runweb python manage.py populate_streams ;; *) echo "Unrecognised command. Try: build, clean, up, initdb, resetdb, makemigrations, migrate, bash, shell, dbshell, restart_celery, test, pytest, test_report, black, populate_feeds"