diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml new file mode 100644 index 000000000..3ce368ecd --- /dev/null +++ b/.github/workflows/django-tests.yml @@ -0,0 +1,68 @@ +name: Run Python Tests +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-20.04 + strategy: + max-parallel: 4 + matrix: + db: [postgres] + python-version: [3.9] + include: + - db: postgres + db_port: 5432 + + services: + postgres: + image: postgres:10 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: hunter2 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run Tests + env: + DB: ${{ matrix.db }} + DB_HOST: 127.0.0.1 + DB_PORT: ${{ matrix.db_port }} + DB_PASSWORD: hunter2 + SECRET_KEY: beepbeep + DEBUG: true + DOMAIN: your.domain.here + OL_URL: https://openlibrary.org + BOOKWYRM_DATABASE_BACKEND: postgres + MEDIA_ROOT: images/ + POSTGRES_PASSWORD: hunter2 + POSTGRES_USER: postgres + POSTGRES_DB: github_actions + POSTGRES_HOST: 127.0.0.1 + CELERY_BROKER: "" + CELERY_RESULT_BACKEND: "" + EMAIL_HOST: "smtp.mailgun.org" + EMAIL_PORT: 587 + EMAIL_HOST_USER: "" + EMAIL_HOST_PASSWORD: "" + EMAIL_USE_TLS: true + run: | + python manage.py test diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py index 852db345c..85245929b 100644 --- a/bookwyrm/activitypub/__init__.py +++ b/bookwyrm/activitypub/__init__.py @@ -2,11 +2,11 @@ import inspect import sys -from .base_activity import ActivityEncoder, Image, PublicKey, Signature +from .base_activity import ActivityEncoder, PublicKey, Signature from .base_activity import Link, Mention from .base_activity import ActivitySerializerError from .base_activity import tag_formatter -from .base_activity import image_formatter, image_attachments_formatter +from .image import Image from .note import Note, GeneratedNote, Article, Comment, Review, Quotation from .note import Tombstone from .interaction import Boost, Like diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 62fce70b2..caa4aeb80 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -4,6 +4,7 @@ from json import JSONEncoder from uuid import uuid4 from django.core.files.base import ContentFile +from django.db import transaction from django.db.models.fields.related_descriptors \ import ForwardManyToOneDescriptor, ManyToManyDescriptor, \ ReverseManyToOneDescriptor @@ -23,13 +24,6 @@ class ActivityEncoder(JSONEncoder): return o.__dict__ -@dataclass -class Image: - ''' image block ''' - url: str - type: str = 'Image' - - @dataclass class Link(): ''' for tagging a book in a status ''' @@ -74,7 +68,8 @@ class ActivityObject: try: value = kwargs[field.name] except KeyError: - if field.default == MISSING: + if field.default == MISSING and \ + field.default_factory == MISSING: raise ActivitySerializerError(\ 'Missing required field: %s' % field.name) value = field.default @@ -112,14 +107,15 @@ class ActivityObject: formatted_value = mapping.model_formatter(value) if isinstance(model_field, ForwardManyToOneDescriptor) and \ formatted_value: - # foreign key remote id reolver + # foreign key remote id reolver (work on Edition, for example) fk_model = model_field.field.related_model reference = resolve_foreign_key(fk_model, formatted_value) mapped_fields[mapping.model_key] = reference elif isinstance(model_field, ManyToManyDescriptor): + # status mentions book/users many_to_many_fields[mapping.model_key] = formatted_value elif isinstance(model_field, ReverseManyToOneDescriptor): - # attachments on statuses, for example + # attachments on Status, for example one_to_many_fields[mapping.model_key] = formatted_value elif isinstance(model_field, ImageFileDescriptor): # image fields need custom handling @@ -127,35 +123,41 @@ class ActivityObject: else: mapped_fields[mapping.model_key] = formatted_value - if instance: - # updating an existing model isntance - for k, v in mapped_fields.items(): - setattr(instance, k, v) - instance.save() - else: - # creating a new model instance - instance = model.objects.create(**mapped_fields) - - # add many-to-many fields - for (model_key, values) in many_to_many_fields.items(): - getattr(instance, model_key).set(values) - instance.save() - - # add images - for (model_key, value) in image_fields.items(): - getattr(instance, model_key).save(*value, save=True) - - # add one to many fields - for (model_key, values) in one_to_many_fields.items(): - items = [] - for item in values: - # the reference id wasn't available at creation time - setattr(item, instance.__class__.__name__.lower(), instance) - item.save() - items.append(item) - if items: - getattr(instance, model_key).set(items) + with transaction.atomic(): + if instance: + # updating an existing model isntance + for k, v in mapped_fields.items(): + setattr(instance, k, v) instance.save() + else: + # creating a new model instance + instance = model.objects.create(**mapped_fields) + + # add images + for (model_key, value) in image_fields.items(): + formatted_value = image_formatter(value) + if not formatted_value: + continue + getattr(instance, model_key).save(*formatted_value, save=True) + + for (model_key, values) in many_to_many_fields.items(): + # mention books, mention users + getattr(instance, model_key).set(values) + + # add one to many fields + for (model_key, values) in one_to_many_fields.items(): + if values == MISSING: + continue + model_field = getattr(instance, model_key) + model = model_field.model + for item in values: + item = model.activity_serializer(**item) + field_name = instance.__class__.__name__.lower() + with transaction.atomic(): + item = item.to_model(model) + setattr(item, field_name, instance) + item.save() + return instance @@ -188,6 +190,8 @@ def resolve_foreign_key(model, remote_id): def tag_formatter(tags, tag_type): ''' helper function to extract foreign keys from tag activity json ''' + if not isinstance(tags, list): + return [] items = [] types = { 'Book': models.Book, @@ -205,12 +209,18 @@ def tag_formatter(tags, tag_type): return items -def image_formatter(image_json): +def image_formatter(image_slug): ''' helper function to load images and format them for a model ''' - url = image_json.get('url') + # when it's an inline image (User avatar/icon, Book cover), it's a json + # blob, but when it's an attached image, it's just a url + if isinstance(image_slug, dict): + url = image_slug.get('url') + elif isinstance(image_slug, str): + url = image_slug + else: + return None if not url: return None - try: response = requests.get(url) except ConnectionError: @@ -221,15 +231,3 @@ def image_formatter(image_json): image_name = str(uuid4()) + '.' + url.split('.')[-1] image_content = ContentFile(response.content) return [image_name, image_content] - - -def image_attachments_formatter(images_json): - ''' deserialize a list of images ''' - attachments = [] - for image in images_json: - caption = image.get('name') - attachment = models.Attachment(caption=caption) - image_field = image_formatter(image) - attachment.image.save(*image_field, save=False) - attachments.append(attachment) - return attachments diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index 60d36bd02..02cab2818 100644 --- a/bookwyrm/activitypub/book.py +++ b/bookwyrm/activitypub/book.py @@ -2,42 +2,43 @@ from dataclasses import dataclass, field from typing import List -from .base_activity import ActivityObject, Image +from .base_activity import ActivityObject +from .image import Image @dataclass(init=False) class Book(ActivityObject): ''' serializes an edition or work, abstract ''' - authors: List[str] - first_published_date: str - published_date: str - title: str - sort_title: str - subtitle: str - description: str + sortTitle: str = '' + subtitle: str = '' + description: str = '' languages: List[str] - series: str - series_number: str + series: str = '' + seriesNumber: str = '' subjects: List[str] - subject_places: List[str] + subjectPlaces: List[str] - openlibrary_key: str - librarything_key: str - goodreads_key: str + authors: List[str] + firstPublishedDate: str = '' + publishedDate: str = '' - attachment: List[Image] = field(default=lambda: []) + openlibraryKey: str = '' + librarythingKey: str = '' + goodreadsKey: str = '' + + cover: Image = field(default_factory=lambda: {}) type: str = 'Book' @dataclass(init=False) class Edition(Book): ''' Edition instance of a book object ''' - isbn_10: str - isbn_13: str - oclc_number: str + isbn10: str + isbn13: str + oclcNumber: str asin: str pages: str - physical_format: str + physicalFormat: str publishers: List[str] work: str @@ -56,10 +57,10 @@ class Work(Book): class Author(ActivityObject): ''' author of a book ''' name: str - born: str - died: str - aliases: str - bio: str - openlibrary_key: str - wikipedia_link: str + born: str = '' + died: str = '' + aliases: str = '' + bio: str = '' + openlibraryKey: str = '' + wikipediaLink: str = '' type: str = 'Person' diff --git a/bookwyrm/activitypub/image.py b/bookwyrm/activitypub/image.py new file mode 100644 index 000000000..569f83c5d --- /dev/null +++ b/bookwyrm/activitypub/image.py @@ -0,0 +1,11 @@ +''' an image, nothing fancy ''' +from dataclasses import dataclass +from .base_activity import ActivityObject + +@dataclass(init=False) +class Image(ActivityObject): + ''' image block ''' + url: str + name: str = '' + type: str = 'Image' + id: str = '' diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py index ebc0cf3ce..aeb078dcc 100644 --- a/bookwyrm/activitypub/note.py +++ b/bookwyrm/activitypub/note.py @@ -2,7 +2,8 @@ from dataclasses import dataclass, field from typing import Dict, List -from .base_activity import ActivityObject, Image, Link +from .base_activity import ActivityObject, Link +from .image import Image @dataclass(init=False) class Tombstone(ActivityObject): @@ -24,8 +25,8 @@ class Note(ActivityObject): cc: List[str] content: str replies: Dict - tag: List[Link] = field(default=lambda: []) - attachment: List[Image] = field(default=lambda: []) + tag: List[Link] = field(default_factory=lambda: []) + attachment: List[Image] = field(default_factory=lambda: []) sensitive: bool = False type: str = 'Note' diff --git a/bookwyrm/activitypub/person.py b/bookwyrm/activitypub/person.py index 118774a27..e7d720ecf 100644 --- a/bookwyrm/activitypub/person.py +++ b/bookwyrm/activitypub/person.py @@ -2,7 +2,8 @@ from dataclasses import dataclass, field from typing import Dict -from .base_activity import ActivityObject, Image, PublicKey +from .base_activity import ActivityObject, PublicKey +from .image import Image @dataclass(init=False) class Person(ActivityObject): @@ -15,7 +16,7 @@ class Person(ActivityObject): summary: str publicKey: PublicKey endpoints: Dict - icon: Image = field(default=lambda: {}) + icon: Image = field(default_factory=lambda: {}) bookwyrmUser: bool = False manuallyApprovesFollowers: str = False discoverable: str = True diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index 7fc4596b3..d709b075a 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -157,7 +157,7 @@ class AbstractConnector(ABC): 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) + book = self.update_from_mappings(book, data, self.book_mappings) author_text = [] for author in self.get_authors_from_data(data): @@ -262,23 +262,23 @@ class AbstractConnector(ABC): ''' get more info on a book ''' -def update_from_mappings(obj, data, mappings): - ''' assign data to model with mappings ''' - for mapping in mappings: - # check if this field is present in the data - value = data.get(mapping.remote_field) - if not value: - continue + def update_from_mappings(self, obj, data, mappings): + ''' assign data to model with mappings ''' + 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 + # 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 + # assign the formatted value to the model + obj.__setattr__(mapping.local_field, value) + return obj def get_date(date_string): diff --git a/bookwyrm/connectors/bookwyrm_connector.py b/bookwyrm/connectors/bookwyrm_connector.py index 6ed9dda1d..1bc81450d 100644 --- a/bookwyrm/connectors/bookwyrm_connector.py +++ b/bookwyrm/connectors/bookwyrm_connector.py @@ -1,55 +1,21 @@ ''' using another bookwyrm instance as a source of book data ''' -from uuid import uuid4 - -from django.core.exceptions import ObjectDoesNotExist -from django.core.files.base import ContentFile from django.db import transaction -import requests -from bookwyrm import models -from .abstract_connector import AbstractConnector, SearchResult, Mapping -from .abstract_connector import update_from_mappings, get_date, get_data +from bookwyrm import activitypub, models +from .abstract_connector import AbstractConnector, SearchResult +from .abstract_connector import get_data class Connector(AbstractConnector): ''' interact with other instances ''' - def __init__(self, identifier): - super().__init__(identifier) - self.key_mappings = [ - Mapping('isbn_13', model=models.Edition), - Mapping('isbn_10', model=models.Edition), - Mapping('lccn', model=models.Work), - Mapping('oclc_number', model=models.Edition), - Mapping('openlibrary_key'), - Mapping('goodreads_key'), - Mapping('asin'), - ] - self.book_mappings = self.key_mappings + [ - Mapping('sort_title'), - Mapping('subtitle'), - Mapping('description'), - Mapping('languages'), - Mapping('series'), - Mapping('series_number'), - Mapping('subjects'), - Mapping('subject_places'), - Mapping('first_published_date'), - Mapping('published_date'), - Mapping('pages'), - Mapping('physical_format'), - Mapping('publishers'), - ] - - self.author_mappings = [ - Mapping('name'), - Mapping('bio'), - Mapping('openlibrary_key'), - Mapping('wikipedia_link'), - Mapping('aliases'), - Mapping('born', formatter=get_date), - Mapping('died', formatter=get_date), - ] + def update_from_mappings(self, obj, data, mappings): + ''' serialize book data into a model ''' + if self.is_work_data(data): + work_data = activitypub.Work(**data) + return work_data.to_model(models.Work, instance=obj) + edition_data = activitypub.Edition(**data) + return edition_data.to_model(models.Edition, instance=obj) def get_remote_id_from_data(self, data): @@ -57,7 +23,7 @@ class Connector(AbstractConnector): def is_work_data(self, data): - return data['type'] == 'Work' + return data.get('type') == 'Work' def get_edition_from_work_data(self, data): @@ -71,46 +37,20 @@ class Connector(AbstractConnector): def get_authors_from_data(self, data): - for author_url in data.get('authors', []): - yield self.get_or_create_author(author_url) + ''' load author data ''' + for author_id in data.get('authors', []): + try: + yield models.Author.objects.get(origin_id=author_id) + except models.Author.DoesNotExist: + pass + data = get_data(author_id) + author_data = activitypub.Author(**data) + author = author_data.to_model(models.Author) + yield author def get_cover_from_data(self, data): - cover_data = data.get('attachment') - if not cover_data: - return None - try: - cover_url = cover_data[0].get('url') - except IndexError: - return None - try: - response = requests.get(cover_url) - except ConnectionError: - return None - - if not response.ok: - return None - - image_name = str(uuid4()) + '.' + cover_url.split('.')[-1] - image_content = ContentFile(response.content) - return [image_name, image_content] - - - def get_or_create_author(self, remote_id): - ''' load that author ''' - try: - return models.Author.objects.get(origin_id=remote_id) - except ObjectDoesNotExist: - pass - - data = get_data(remote_id) - - # ingest a new author - author = models.Author(origin_id=remote_id) - author = update_from_mappings(author, data, self.author_mappings) - author.save() - - return author + pass def parse_search_data(self, data): diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py index 5c26ad45b..5e18616d5 100644 --- a/bookwyrm/connectors/openlibrary.py +++ b/bookwyrm/connectors/openlibrary.py @@ -7,7 +7,6 @@ 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 update_from_mappings from .abstract_connector import get_date, get_data from .openlibrary_languages import languages @@ -185,7 +184,7 @@ class Connector(AbstractConnector): data = get_data(url) author = models.Author(openlibrary_key=olkey) - author = update_from_mappings(author, data, self.author_mappings) + author = self.update_from_mappings(author, data, self.author_mappings) name = data.get('name') # TODO this is making some BOLD assumption if name: diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py index 29c6b6de7..784f10382 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -5,6 +5,7 @@ from collections import defaultdict from django import forms from django.forms import ModelForm, PasswordInput, widgets from django.forms.widgets import Textarea +from django.utils import timezone from bookwyrm import models @@ -143,7 +144,7 @@ class ExpiryWidget(widgets.Select): else: return selected_string # "This will raise - return datetime.datetime.now() + interval + return timezone.now() + interval class CreateInviteForm(CustomForm): class Meta: 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 980b66142..13cb1406a 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 @@ -3,7 +3,6 @@ import bookwyrm.models.connector import bookwyrm.models.site import bookwyrm.utils.fields -import datetime from django.conf import settings import django.contrib.postgres.operations import django.core.validators @@ -37,7 +36,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='status', name='published_date', - field=models.DateTimeField(default=datetime.datetime.now), + field=models.DateTimeField(default=django.utils.timezone.now), ), migrations.CreateModel( name='Edition', @@ -129,7 +128,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='book', name='last_sync_date', - field=models.DateTimeField(default=datetime.datetime.now), + field=models.DateTimeField(default=django.utils.timezone.now), ), migrations.AddField( model_name='book', diff --git a/bookwyrm/migrations/0014_auto_20201128_0118.py b/bookwyrm/migrations/0014_auto_20201128_0118.py new file mode 100644 index 000000000..babdd7805 --- /dev/null +++ b/bookwyrm/migrations/0014_auto_20201128_0118.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.7 on 2020-11-28 01:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0013_book_origin_id'), + ] + + operations = [ + migrations.RenameModel( + old_name='Attachment', + new_name='Image', + ), + ] diff --git a/bookwyrm/migrations/0015_auto_20201128_0349.py b/bookwyrm/migrations/0015_auto_20201128_0349.py new file mode 100644 index 000000000..52b155186 --- /dev/null +++ b/bookwyrm/migrations/0015_auto_20201128_0349.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.7 on 2020-11-28 03:49 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0014_auto_20201128_0118'), + ] + + operations = [ + migrations.AlterField( + model_name='image', + name='status', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='bookwyrm.Status'), + ), + ] diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py index 8a93b805a..3d8544788 100644 --- a/bookwyrm/models/__init__.py +++ b/bookwyrm/models/__init__.py @@ -2,17 +2,24 @@ import inspect import sys -from .book import Book, Work, Edition, Author +from .book import Book, Work, Edition +from .author import Author from .connector import Connector -from .relationship import UserFollows, UserFollowRequest, UserBlocks + from .shelf import Shelf, ShelfBook + from .status import Status, GeneratedNote, Review, Comment, Quotation -from .status import Attachment, Favorite, Boost, Notification, ReadThrough +from .status import Favorite, Boost, Notification, ReadThrough +from .attachment import Image + from .tag import Tag + from .user import User +from .relationship import UserFollows, UserFollowRequest, UserBlocks from .federated_server import FederatedServer from .import_job import ImportJob, ImportItem + from .site import SiteSettings, SiteInvite, PasswordReset cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass) diff --git a/bookwyrm/models/attachment.py b/bookwyrm/models/attachment.py new file mode 100644 index 000000000..7329e65d6 --- /dev/null +++ b/bookwyrm/models/attachment.py @@ -0,0 +1,32 @@ +''' media that is posted in the app ''' +from django.db import models + +from bookwyrm import activitypub +from .base_model import ActivitypubMixin +from .base_model import ActivityMapping, BookWyrmModel + + +class Attachment(ActivitypubMixin, BookWyrmModel): + ''' an image (or, in the future, video etc) associated with a status ''' + status = models.ForeignKey( + 'Status', + on_delete=models.CASCADE, + related_name='attachments', + null=True + ) + class Meta: + ''' one day we'll have other types of attachments besides images ''' + abstract = True + + activity_mappings = [ + ActivityMapping('id', 'remote_id'), + ActivityMapping('url', 'image'), + ActivityMapping('name', 'caption'), + ] + +class Image(Attachment): + ''' an image attachment ''' + image = models.ImageField(upload_to='status/', null=True, blank=True) + caption = models.TextField(null=True, blank=True) + + activity_serializer = activitypub.Image diff --git a/bookwyrm/models/author.py b/bookwyrm/models/author.py new file mode 100644 index 000000000..1d7017974 --- /dev/null +++ b/bookwyrm/models/author.py @@ -0,0 +1,50 @@ +''' database schema for info about authors ''' +from django.db import models +from django.utils import timezone + +from bookwyrm import activitypub +from bookwyrm.utils.fields import ArrayField + +from .base_model import ActivitypubMixin, ActivityMapping, BookWyrmModel + + +class Author(ActivitypubMixin, BookWyrmModel): + ''' basic biographic info ''' + origin_id = models.CharField(max_length=255, null=True) + ''' copy of an author from OL ''' + openlibrary_key = models.CharField(max_length=255, blank=True, null=True) + sync = models.BooleanField(default=True) + last_sync_date = models.DateTimeField(default=timezone.now) + wikipedia_link = models.CharField(max_length=255, blank=True, null=True) + # idk probably other keys would be useful here? + born = models.DateTimeField(blank=True, null=True) + died = models.DateTimeField(blank=True, null=True) + name = models.CharField(max_length=255) + last_name = models.CharField(max_length=255, blank=True, null=True) + first_name = models.CharField(max_length=255, blank=True, null=True) + aliases = ArrayField( + models.CharField(max_length=255), blank=True, default=list + ) + bio = models.TextField(null=True, blank=True) + + @property + def display_name(self): + ''' Helper to return a displayable name''' + if self.name: + return self.name + # don't want to return a spurious space if all of these are None + if self.first_name and self.last_name: + return self.first_name + ' ' + self.last_name + return self.last_name or self.first_name + + activity_mappings = [ + ActivityMapping('id', 'remote_id'), + ActivityMapping('name', 'name'), + ActivityMapping('born', 'born'), + ActivityMapping('died', 'died'), + ActivityMapping('aliases', 'aliases'), + ActivityMapping('bio', 'bio'), + ActivityMapping('openlibraryKey', 'openlibrary_key'), + ActivityMapping('wikipediaLink', 'wikipedia_link'), + ] + activity_serializer = activitypub.Author diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index e56d21f66..4109a49b9 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -10,6 +10,7 @@ from Crypto.PublicKey import RSA from Crypto.Signature import pkcs1_15 from Crypto.Hash import SHA256 from django.db import models +from django.db.models.fields.files import ImageFieldFile from django.dispatch import receiver from bookwyrm import activitypub @@ -59,27 +60,36 @@ class ActivitypubMixin: def to_activity(self, pure=False): ''' convert from a model to an activity ''' if pure: + # works around bookwyrm-specific fields for vanilla AP services mappings = self.pure_activity_mappings else: + # may include custom fields that bookwyrm instances will understand mappings = self.activity_mappings fields = {} for mapping in mappings: if not hasattr(self, mapping.model_key) or not mapping.activity_key: + # this field on the model isn't serialized continue value = getattr(self, mapping.model_key) if hasattr(value, 'remote_id'): + # this is probably a foreign key field, which we want to + # serialize as just the remote_id url reference value = value.remote_id - if isinstance(value, datetime): + elif isinstance(value, datetime): value = value.isoformat() - result = mapping.activity_formatter(value) + elif isinstance(value, ImageFieldFile): + value = image_formatter(value) + + # run the custom formatter function set in the model + formatted_value = mapping.activity_formatter(value) if mapping.activity_key in fields and \ isinstance(fields[mapping.activity_key], list): - # there are two database fields that map to the same AP list - # this happens in status, which combines user and book tags - fields[mapping.activity_key] += result + # there can be two database fields that map to the same AP list + # this happens in status tags, which combines user and book tags + fields[mapping.activity_key] += formatted_value else: - fields[mapping.activity_key] = result + fields[mapping.activity_key] = formatted_value if pure: return self.pure_activity_serializer( @@ -263,12 +273,10 @@ def tag_formatter(items, name_field, activity_type): return tags -def image_formatter(image, default_path=None): +def image_formatter(image): ''' convert images into activitypub json ''' - if image: + if image and hasattr(image, 'url'): url = image.url - elif default_path: - url = default_path else: return None url = 'https://%s%s' % (DOMAIN, url) diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index c8643f07e..132b4c070 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -12,7 +12,6 @@ from bookwyrm.utils.fields import ArrayField from .base_model import ActivityMapping, BookWyrmModel from .base_model import ActivitypubMixin, OrderedCollectionPageMixin -from .base_model import image_attachments_formatter class Book(ActivitypubMixin, BookWyrmModel): ''' a generic book, which can mean either an edition or a work ''' @@ -61,49 +60,39 @@ class Book(ActivitypubMixin, BookWyrmModel): ''' the activitypub serialization should be a list of author ids ''' return [a.remote_id for a in self.authors.all()] - @property - def ap_parent_work(self): - ''' reference the work via local id not remote ''' - return self.parent_work.remote_id - activity_mappings = [ ActivityMapping('id', 'remote_id'), ActivityMapping('authors', 'ap_authors'), - ActivityMapping('first_published_date', 'first_published_date'), - ActivityMapping('published_date', 'published_date'), + ActivityMapping('firstPublishedDate', 'firstpublished_date'), + ActivityMapping('publishedDate', 'published_date'), ActivityMapping('title', 'title'), - ActivityMapping('sort_title', 'sort_title'), + ActivityMapping('sortTitle', 'sort_title'), ActivityMapping('subtitle', 'subtitle'), ActivityMapping('description', 'description'), ActivityMapping('languages', 'languages'), ActivityMapping('series', 'series'), - ActivityMapping('series_number', 'series_number'), + ActivityMapping('seriesNumber', 'series_number'), ActivityMapping('subjects', 'subjects'), - ActivityMapping('subject_places', 'subject_places'), + ActivityMapping('subjectPlaces', 'subject_places'), - ActivityMapping('openlibrary_key', 'openlibrary_key'), - ActivityMapping('librarything_key', 'librarything_key'), - ActivityMapping('goodreads_key', 'goodreads_key'), + ActivityMapping('openlibraryKey', 'openlibrary_key'), + ActivityMapping('librarythingKey', 'librarything_key'), + ActivityMapping('goodreadsKey', 'goodreads_key'), - ActivityMapping('work', 'ap_parent_work'), - ActivityMapping('isbn_10', 'isbn_10'), - ActivityMapping('isbn_13', 'isbn_13'), - ActivityMapping('oclc_number', 'oclc_number'), + ActivityMapping('work', 'parent_work'), + ActivityMapping('isbn10', 'isbn_10'), + ActivityMapping('isbn13', 'isbn_13'), + ActivityMapping('oclcNumber', 'oclc_number'), ActivityMapping('asin', 'asin'), ActivityMapping('pages', 'pages'), - ActivityMapping('physical_format', 'physical_format'), + ActivityMapping('physicalFormat', 'physical_format'), ActivityMapping('publishers', 'publishers'), ActivityMapping('lccn', 'lccn'), ActivityMapping('editions', 'editions_path'), - ActivityMapping( - 'attachment', 'cover', - # this expects an iterable and the field is just an image - lambda x: image_attachments_formatter([x]), - lambda x: activitypub.image_attachments_formatter(x)[0] - ), + ActivityMapping('cover', 'cover'), ] def save(self, *args, **kwargs): @@ -190,7 +179,7 @@ class Edition(Book): if self.isbn_10 and not self.isbn_13: self.isbn_13 = isbn_10_to_13(self.isbn_10) - super().save(*args, **kwargs) + return super().save(*args, **kwargs) def isbn_10_to_13(isbn_10): @@ -234,44 +223,3 @@ def isbn_13_to_10(isbn_13): if checkdigit == 10: checkdigit = 'X' return converted + str(checkdigit) - - -class Author(ActivitypubMixin, BookWyrmModel): - origin_id = models.CharField(max_length=255, null=True) - ''' copy of an author from OL ''' - openlibrary_key = models.CharField(max_length=255, blank=True, null=True) - sync = models.BooleanField(default=True) - last_sync_date = models.DateTimeField(default=timezone.now) - wikipedia_link = models.CharField(max_length=255, blank=True, null=True) - # idk probably other keys would be useful here? - born = models.DateTimeField(blank=True, null=True) - died = models.DateTimeField(blank=True, null=True) - name = models.CharField(max_length=255) - last_name = models.CharField(max_length=255, blank=True, null=True) - first_name = models.CharField(max_length=255, blank=True, null=True) - aliases = ArrayField( - models.CharField(max_length=255), blank=True, default=list - ) - bio = models.TextField(null=True, blank=True) - - @property - def display_name(self): - ''' Helper to return a displayable name''' - if self.name: - return self.name - # don't want to return a spurious space if all of these are None - if self.first_name and self.last_name: - return self.first_name + ' ' + self.last_name - return self.last_name or self.first_name - - activity_mappings = [ - ActivityMapping('id', 'remote_id'), - ActivityMapping('name', 'display_name'), - ActivityMapping('born', 'born'), - ActivityMapping('died', 'died'), - ActivityMapping('aliases', 'aliases'), - ActivityMapping('bio', 'bio'), - ActivityMapping('openlibrary_key', 'openlibrary_key'), - ActivityMapping('wikipedia_link', 'wikipedia_link'), - ] - activity_serializer = activitypub.Author diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py index b35f79921..fe39325f0 100644 --- a/bookwyrm/models/import_job.py +++ b/bookwyrm/models/import_job.py @@ -132,14 +132,16 @@ class ImportItem(models.Model): def date_added(self): ''' when the book was added to this dataset ''' if self.data['Date Added']: - return dateutil.parser.parse(self.data['Date Added']) + return timezone.make_aware( + dateutil.parser.parse(self.data['Date Added'])) return None @property def date_read(self): ''' the date a book was completed ''' if self.data['Date Read']: - return dateutil.parser.parse(self.data['Date Read']) + return timezone.make_aware( + dateutil.parser.parse(self.data['Date Read'])) return None @property diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py index 24eb673c4..aa2e2a675 100644 --- a/bookwyrm/models/site.py +++ b/bookwyrm/models/site.py @@ -54,7 +54,7 @@ class SiteInvite(models.Model): def get_passowrd_reset_expiry(): ''' give people a limited time to use the link ''' - now = datetime.datetime.now() + now = timezone.now() return now + datetime.timedelta(days=1) diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 6f534f506..9d45379cc 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -90,7 +90,6 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): ActivityMapping( 'attachment', 'attachments', lambda x: image_attachments_formatter(x.all()), - activitypub.image_attachments_formatter ) ] @@ -151,17 +150,6 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): return super().save(*args, **kwargs) -class Attachment(BookWyrmModel): - ''' an image (or, in the future, video etc) associated with a status ''' - status = models.ForeignKey( - 'Status', - on_delete=models.CASCADE, - related_name='attachments' - ) - image = models.ImageField(upload_to='status/', null=True, blank=True) - caption = models.TextField(null=True, blank=True) - - class GeneratedNote(Status): ''' these are app-generated messages about user activity ''' @property diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index b38a4b192..4d511d561 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -112,11 +112,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): activity_formatter=lambda x: {'sharedInbox': x}, model_formatter=lambda x: x.get('sharedInbox') ), - ActivityMapping( - 'icon', 'avatar', - lambda x: image_formatter(x, '/static/images/default_avi.jpg'), - activitypub.image_formatter - ), + ActivityMapping('icon', 'avatar'), ActivityMapping( 'manuallyApprovesFollowers', 'manually_approves_followers' diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 5adf960f6..ecabb8d71 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -15,6 +15,13 @@ CELERY_ACCEPT_CONTENT = ['application/json'] CELERY_TASK_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json' +# email +EMAIL_HOST = env('EMAIL_HOST') +EMAIL_PORT = env('EMAIL_PORT', 587) +EMAIL_HOST_USER = env('EMAIL_HOST_USER') +EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD') +EMAIL_USE_TLS = env('EMAIL_USE_TLS', True) + # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) diff --git a/bookwyrm/status.py b/bookwyrm/status.py index c950f4ab3..6a86209f4 100644 --- a/bookwyrm/status.py +++ b/bookwyrm/status.py @@ -1,5 +1,5 @@ ''' Handle user activity ''' -from datetime import datetime +from django.utils import timezone from bookwyrm import activitypub, books_manager, models from bookwyrm.sanitize_html import InputHtmlParser @@ -8,7 +8,7 @@ from bookwyrm.sanitize_html import InputHtmlParser def delete_status(status): ''' replace the status with a tombstone ''' status.deleted = True - status.deleted_date = datetime.now() + status.deleted_date = timezone.now() status.save() diff --git a/bookwyrm/templates/book.html b/bookwyrm/templates/book.html index b0064e1fd..51fbdafc6 100644 --- a/bookwyrm/templates/book.html +++ b/bookwyrm/templates/book.html @@ -57,6 +57,35 @@ {% include 'snippets/trimmed_text.html' with full=book|book_description %} + {% if request.user.is_authenticated and perms.bookwyrm.edit_book and not book|book_description %} +
{{ book.parent_work.edition_set.count }} editions
{% endif %} @@ -112,7 +141,7 @@