Merge branch 'main' into production

This commit is contained in:
Mouse Reeve 2020-12-27 14:45:53 -08:00
commit 2d21a31c13
34 changed files with 767 additions and 367 deletions

2
.github/FUNDING.yml vendored
View file

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

View file

@ -3,7 +3,7 @@ from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder from json import JSONEncoder
from django.apps import apps from django.apps import apps
from django.db import transaction from django.db import IntegrityError, transaction
from bookwyrm.connectors import ConnectorException, get_data from bookwyrm.connectors import ConnectorException, get_data
from bookwyrm.tasks import app from bookwyrm.tasks import app
@ -92,7 +92,10 @@ class ActivityObject:
with transaction.atomic(): with transaction.atomic():
# we can't set many to many and reverse fields on an unsaved object # we can't set many to many and reverse fields on an unsaved object
instance.save() try:
instance.save()
except IntegrityError as e:
raise ActivitySerializerError(e)
# add many to many fields, which have to be set post-save # add many to many fields, which have to be set post-save
for field in instance.many_to_many_fields: for field in instance.many_to_many_fields:
@ -108,15 +111,10 @@ class ActivityObject:
continue continue
model_field = getattr(model, model_field_name) model_field = getattr(model, model_field_name)
try: # creating a Work, model_field is 'editions'
# this is for one to many # creating a User, model field is 'key_pair'
related_model = model_field.field.model related_model = model_field.field.model
related_field_name = model_field.field.name related_field_name = model_field.field.name
except AttributeError:
# it's a one to one or foreign key
related_model = model_field.related.related_model
related_field_name = model_field.related.related_name
values = [values]
for item in values: for item in values:
set_related_field.delay( set_related_field.delay(
@ -139,8 +137,8 @@ class ActivityObject:
@app.task @app.task
@transaction.atomic @transaction.atomic
def set_related_field( def set_related_field(
model_name, origin_model_name, model_name, origin_model_name, related_field_name,
related_field_name, related_remote_id, data): related_remote_id, data):
''' load reverse related fields (editions, attachments) without blocking ''' ''' load reverse related fields (editions, attachments) without blocking '''
model = apps.get_model('bookwyrm.%s' % model_name, require_ready=True) model = apps.get_model('bookwyrm.%s' % model_name, require_ready=True)
origin_model = apps.get_model( origin_model = apps.get_model(
@ -150,23 +148,36 @@ def set_related_field(
with transaction.atomic(): with transaction.atomic():
if isinstance(data, str): if isinstance(data, str):
item = resolve_remote_id(model, data, save=False) existing = model.find_existing_by_remote_id(data)
else: if existing:
# look for a match based on all the available data data = existing.to_activity()
item = model.find_existing(data) else:
if not item: data = get_data(data)
# create a new model instance activity = model.activity_serializer(**data)
item = model.activity_serializer(**data)
item = item.to_model(model, save=False)
# this must exist because it's the object that triggered this function # this must exist because it's the object that triggered this function
instance = origin_model.find_existing_by_remote_id(related_remote_id) instance = origin_model.find_existing_by_remote_id(related_remote_id)
if not instance: if not instance:
raise ValueError( raise ValueError(
'Invalid related remote id: %s' % related_remote_id) 'Invalid related remote id: %s' % related_remote_id)
# edition.parent_work = instance, for example # set the origin's remote id on the activity so it will be there when
setattr(item, related_field_name, instance) # the model instance is created
item.save() # edition.parentWork = instance, for example
model_field = getattr(model, related_field_name)
if hasattr(model_field, 'activitypub_field'):
setattr(
activity,
getattr(model_field, 'activitypub_field'),
instance.remote_id
)
item = activity.to_model(model)
# if the related field isn't serialized (attachments on Status), then
# we have to set it post-creation
if not hasattr(model_field, 'activitypub_field'):
setattr(item, related_field_name, instance)
item.save()
def resolve_remote_id(model, remote_id, refresh=False, save=True): def resolve_remote_id(model, remote_id, refresh=False, save=True):

View file

@ -63,5 +63,7 @@ class Author(ActivityObject):
aliases: List[str] = field(default_factory=lambda: []) aliases: List[str] = field(default_factory=lambda: [])
bio: str = '' bio: str = ''
openlibraryKey: str = '' openlibraryKey: str = ''
librarythingKey: str = ''
goodreadsKey: str = ''
wikipediaLink: str = '' wikipediaLink: str = ''
type: str = 'Person' type: str = 'Person'

View file

@ -168,7 +168,7 @@ class AbstractConnector(AbstractMinimalConnector):
''' every work needs at least one edition ''' ''' every work needs at least one edition '''
@abstractmethod @abstractmethod
def get_work_from_edition_date(self, data): def get_work_from_edition_data(self, data):
''' every edition needs a work ''' ''' every edition needs a work '''
@abstractmethod @abstractmethod
@ -228,6 +228,7 @@ class SearchResult:
key: str key: str
author: str author: str
year: str year: str
connector: object
confidence: int = 1 confidence: int = 1
def __repr__(self): def __repr__(self):

View file

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

View file

@ -85,7 +85,7 @@ class Connector(AbstractConnector):
return pick_default_edition(data['entries']) return pick_default_edition(data['entries'])
def get_work_from_edition_date(self, data): def get_work_from_edition_data(self, data):
try: try:
key = data['works'][0]['key'] key = data['works'][0]['key']
except (IndexError, KeyError): except (IndexError, KeyError):
@ -123,6 +123,7 @@ class Connector(AbstractConnector):
title=search_result.get('title'), title=search_result.get('title'),
key=key, key=key,
author=', '.join(author), author=', '.join(author),
connector=self,
year=search_result.get('first_publish_year'), year=search_result.get('first_publish_year'),
) )

View file

@ -51,28 +51,23 @@ class Connector(AbstractConnector):
author=search_result.author_text, author=search_result.author_text,
year=search_result.published_date.year if \ year=search_result.published_date.year if \
search_result.published_date else None, search_result.published_date else None,
connector=self,
confidence=search_result.rank, confidence=search_result.rank,
) )
def get_remote_id_from_data(self, data):
pass
def is_work_data(self, data): def is_work_data(self, data):
pass pass
def get_edition_from_work_data(self, data): def get_edition_from_work_data(self, data):
pass pass
def get_work_from_edition_date(self, data): def get_work_from_edition_data(self, data):
pass pass
def get_authors_from_data(self, data): def get_authors_from_data(self, data):
return None return None
def get_cover_from_data(self, data):
return None
def parse_search_data(self, data): def parse_search_data(self, data):
''' it's already in the right format, don't even worry about it ''' ''' it's already in the right format, don't even worry about it '''
return data return data

View file

@ -30,6 +30,7 @@ class CustomForm(ModelForm):
visible.field.widget.attrs['rows'] = None visible.field.widget.attrs['rows'] = None
visible.field.widget.attrs['class'] = css_classes[input_type] visible.field.widget.attrs['class'] = css_classes[input_type]
# pylint: disable=missing-class-docstring # pylint: disable=missing-class-docstring
class LoginForm(CustomForm): class LoginForm(CustomForm):
class Meta: class Meta:
@ -121,14 +122,13 @@ class EditionForm(CustomForm):
model = models.Edition model = models.Edition
exclude = [ exclude = [
'remote_id', 'remote_id',
'origin_id',
'created_date', 'created_date',
'updated_date', 'updated_date',
'last_sync_date',
'authors',# TODO 'authors',# TODO
'parent_work', 'parent_work',
'shelves', 'shelves',
'misc_identifiers',
'subjects',# TODO 'subjects',# TODO
'subject_places',# TODO 'subject_places',# TODO
@ -136,6 +136,16 @@ class EditionForm(CustomForm):
'connector', 'connector',
] ]
class AuthorForm(CustomForm):
class Meta:
model = models.Author
exclude = [
'remote_id',
'origin_id',
'created_date',
'updated_date',
]
class ImportForm(forms.Form): class ImportForm(forms.Form):
csv_file = forms.FileField() csv_file = forms.FileField()

View file

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

View file

@ -2,7 +2,7 @@
import inspect import inspect
import sys import sys
from .book import Book, Work, Edition from .book import Book, Work, Edition, BookDataModel
from .author import Author from .author import Author
from .connector import Connector from .connector import Connector

View file

@ -1,21 +1,15 @@
''' database schema for info about authors ''' ''' database schema for info about authors '''
from django.db import models from django.db import models
from django.utils import timezone
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
from .base_model import ActivitypubMixin, BookWyrmModel from .book import BookDataModel
from . import fields from . import fields
class Author(ActivitypubMixin, BookWyrmModel): class Author(BookDataModel):
''' basic biographic info ''' ''' basic biographic info '''
origin_id = models.CharField(max_length=255, null=True)
openlibrary_key = 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( wikipedia_link = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True) max_length=255, blank=True, null=True, deduplication_field=True)
# idk probably other keys would be useful here? # idk probably other keys would be useful here?
@ -27,15 +21,6 @@ class Author(ActivitypubMixin, BookWyrmModel):
) )
bio = fields.HtmlField(null=True, blank=True) bio = fields.HtmlField(null=True, blank=True)
def save(self, *args, **kwargs):
''' handle remote vs origin ids '''
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)
def get_remote_id(self): def get_remote_id(self):
''' editions and works both use "book" instead of model_name ''' ''' editions and works both use "book" instead of model_name '''
return 'https://%s/author/%s' % (DOMAIN, self.id) return 'https://%s/author/%s' % (DOMAIN, self.id)

View file

@ -2,7 +2,6 @@
import re import re
from django.db import models from django.db import models
from django.utils import timezone
from model_utils.managers import InheritanceManager from model_utils.managers import InheritanceManager
from bookwyrm import activitypub from bookwyrm import activitypub
@ -12,10 +11,9 @@ from .base_model import BookWyrmModel
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
from . import fields from . import fields
class Book(ActivitypubMixin, BookWyrmModel): class BookDataModel(ActivitypubMixin, BookWyrmModel):
''' a generic book, which can mean either an edition or a work ''' ''' fields shared between editable book data (books, works, authors) '''
origin_id = models.CharField(max_length=255, null=True, blank=True) origin_id = models.CharField(max_length=255, null=True, blank=True)
# these identifiers apply to both works and editions
openlibrary_key = fields.CharField( openlibrary_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True) max_length=255, blank=True, null=True, deduplication_field=True)
librarything_key = fields.CharField( librarything_key = fields.CharField(
@ -23,15 +21,28 @@ class Book(ActivitypubMixin, BookWyrmModel):
goodreads_key = fields.CharField( goodreads_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True) max_length=255, blank=True, null=True, deduplication_field=True)
# info about where the data comes from and where/if to sync last_edited_by = models.ForeignKey(
sync = models.BooleanField(default=True) 'User', on_delete=models.PROTECT, null=True)
sync_cover = models.BooleanField(default=True)
last_sync_date = models.DateTimeField(default=timezone.now) class Meta:
''' can't initialize this model, that wouldn't make sense '''
abstract = True
def save(self, *args, **kwargs):
''' ensure that the remote_id is within this instance '''
if self.id:
self.remote_id = self.get_remote_id()
else:
self.origin_id = self.remote_id
self.remote_id = None
return super().save(*args, **kwargs)
class Book(BookDataModel):
''' a generic book, which can mean either an edition or a work '''
connector = models.ForeignKey( connector = models.ForeignKey(
'Connector', on_delete=models.PROTECT, null=True) 'Connector', on_delete=models.PROTECT, null=True)
# TODO: edit history
# book/work metadata # book/work metadata
title = fields.CharField(max_length=255) title = fields.CharField(max_length=255)
sort_title = fields.CharField(max_length=255, blank=True, null=True) sort_title = fields.CharField(max_length=255, blank=True, null=True)
@ -48,9 +59,7 @@ class Book(ActivitypubMixin, BookWyrmModel):
subject_places = fields.ArrayField( subject_places = fields.ArrayField(
models.CharField(max_length=255), blank=True, null=True, default=list models.CharField(max_length=255), blank=True, null=True, default=list
) )
# TODO: include an annotation about the type of authorship (ie, translator)
authors = fields.ManyToManyField('Author') authors = fields.ManyToManyField('Author')
# preformatted authorship string for search and easier display
cover = fields.ImageField( cover = fields.ImageField(
upload_to='covers/', blank=True, null=True, alt_field='alt_text') upload_to='covers/', blank=True, null=True, alt_field='alt_text')
first_published_date = fields.DateTimeField(blank=True, null=True) first_published_date = fields.DateTimeField(blank=True, null=True)
@ -86,12 +95,6 @@ class Book(ActivitypubMixin, BookWyrmModel):
''' can't be abstract for query reasons, but you shouldn't USE it ''' ''' can't be abstract for query reasons, but you shouldn't USE it '''
if not isinstance(self, Edition) and not isinstance(self, Work): if not isinstance(self, Edition) and not isinstance(self, Work):
raise ValueError('Books should be added as Editions or Works') raise ValueError('Books should be added as Editions or Works')
if self.id:
self.remote_id = self.get_remote_id()
else:
self.origin_id = self.remote_id
self.remote_id = None
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
def get_remote_id(self): def get_remote_id(self):

View file

@ -76,7 +76,7 @@ class ImportItem(models.Model):
) )
if search_result: if search_result:
# raises ConnectorException # raises ConnectorException
return books_manager.get_or_create_book(search_result.key) return search_result.connector.get_or_create_book(search_result.key)
return None return None
@ -91,7 +91,7 @@ class ImportItem(models.Model):
) )
if search_result: if search_result:
# raises ConnectorException # raises ConnectorException
return books_manager.get_or_create_book(search_result.key) return search_result.connector.get_or_create_book(search_result.key)
return None return None

View file

@ -54,7 +54,7 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
def to_reject_activity(self): def to_reject_activity(self):
''' generate an Accept for this follow request ''' ''' generate a Reject for this follow request '''
return activitypub.Reject( return activitypub.Reject(
id=self.get_remote_id(status='rejects'), id=self.get_remote_id(status='rejects'),
actor=self.user_object.remote_id, actor=self.user_object.remote_id,

View file

@ -375,9 +375,9 @@ def handle_unboost(user, status):
broadcast(user, activity) broadcast(user, activity)
def handle_update_book(user, book): def handle_update_book_data(user, item):
''' broadcast the news about our book ''' ''' broadcast the news about our book '''
broadcast(user, book.to_update_activity(user)) broadcast(user, item.to_update_activity(user))
def handle_update_user(user): def handle_update_user(user):

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
<div class="cover-container is-{{ size }}"> <div class="cover-container is-{{ size }}">
{% if book.cover %} {% if book.cover %}
<img class="book-cover" src="/images/{{ book.cover }}" alt="{{ book.alt_text }}"> <img class="book-cover" src="/images/{{ book.cover }}" alt="{{ book.alt_text }}" title="{{ book.alt_text }}">
{% else %} {% else %}
<div class="no-cover book-cover"> <div class="no-cover book-cover">
<img class="book-cover" src="/static/images/no_cover.jpg" alt="No cover"> <img class="book-cover" src="/static/images/no_cover.jpg" alt="No cover">

View file

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

View file

@ -43,7 +43,7 @@
<div class="column is-narrow"> <div class="column is-narrow">
<figure class="image is-128x128"> <figure class="image is-128x128">
<a href="/images/{{ attachment.image }}" target="_blank" aria-label="open image in new window"> <a href="/images/{{ attachment.image }}" target="_blank" aria-label="open image in new window">
<img src="/images/{{ attachment.image }}" alt="{{ attachment.caption }}"> <img src="/images/{{ attachment.image }}"{% if attachment.caption %} alt="{{ attachment.caption }}" title="{{ attachment.caption }}"{% endif %}>
</a> </a>
</figure> </figure>
</div> </div>

View file

@ -0,0 +1,52 @@
{
"id": "https://example.com/users/rat/generatednote/2567/activity",
"type": "Create",
"actor": "https://example.com/users/rat",
"object": {
"id": "https://example.com/users/rat/generatednote/2567",
"type": "GeneratedNote",
"url": null,
"inReplyTo": null,
"published": "2020-12-16T01:45:19.662734+00:00",
"attributedTo": "https://example.com/users/rat",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://example.com/users/rat/followers"
],
"content": "wants to read",
"replies": {
"id": "https://example.com/users/rat/generatednote/2567/replies",
"type": "OrderedCollection",
"totalItems": 0,
"first": "https://example.com/users/rat/generatednote/2567/replies?page=true",
"last": "https://example.com/users/rat/generatednote/2567/replies?page=true",
"name": "",
"@context": "https://www.w3.org/ns/activitystreams"
},
"tag": [
{
"href": "https://bookwyrm.social/book/37292",
"name": "Female Husbands",
"type": "Book"
}
],
"attachment": [],
"sensitive": false,
"@context": "https://www.w3.org/ns/activitystreams"
},
"to": [
"https://example.com/users/rat/followers"
],
"cc": [
"https://www.w3.org/ns/activitystreams#Public"
],
"signature": {
"creator": "https://example.com/users/rat#main-key",
"created": "2020-12-16T01:45:19.662734+00:00",
"signatureValue": "R+W8nN1CQAlREjSUeaQwJXZrXTOOLvpHQi9n/3vd8QKq+l6HJEpu7eAht9fjpk8YOKEgV3OUQ7w3E42wM4t+sFiaPoQjY6Xy9IOvx/2LcOZjSOtTkiZ1XnnVb3DSbl8BOBH02+cPvoR6k4LIPHm2IHYZ1UL02WdDWaicHEwl7bw=",
"type": "RsaSignature2017"
},
"@context": "https://www.w3.org/ns/activitystreams"
}

View file

@ -1,9 +1,15 @@
''' testing models ''' ''' testing models '''
import datetime import datetime
import json
import pathlib
from unittest.mock import patch
from django.utils import timezone from django.utils import timezone
from django.test import TestCase from django.test import TestCase
import responses
from bookwyrm import models from bookwyrm import books_manager, models
from bookwyrm.connectors.abstract_connector import SearchResult
class ImportJob(TestCase): class ImportJob(TestCase):
@ -54,11 +60,11 @@ class ImportJob(TestCase):
user = models.User.objects.create_user( user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword', local=True) 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
job = models.ImportJob.objects.create(user=user) job = models.ImportJob.objects.create(user=user)
models.ImportItem.objects.create( self.item_1 = models.ImportItem.objects.create(
job=job, index=1, data=currently_reading_data) job=job, index=1, data=currently_reading_data)
models.ImportItem.objects.create( self.item_2 = models.ImportItem.objects.create(
job=job, index=2, data=read_data) job=job, index=2, data=read_data)
models.ImportItem.objects.create( self.item_3 = models.ImportItem.objects.create(
job=job, index=3, data=unknown_read_data) job=job, index=3, data=unknown_read_data)
@ -72,8 +78,7 @@ class ImportJob(TestCase):
def test_shelf(self): def test_shelf(self):
''' converts to the local shelf typology ''' ''' converts to the local shelf typology '''
expected = 'reading' expected = 'reading'
item = models.ImportItem.objects.get(index=1) self.assertEqual(self.item_1.shelf, expected)
self.assertEqual(item.shelf, expected)
def test_date_added(self): def test_date_added(self):
@ -91,21 +96,75 @@ class ImportJob(TestCase):
def test_currently_reading_reads(self): def test_currently_reading_reads(self):
''' infer currently reading dates where available '''
expected = [models.ReadThrough( expected = [models.ReadThrough(
start_date=datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc))] start_date=datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc)
)]
actual = models.ImportItem.objects.get(index=1) actual = models.ImportItem.objects.get(index=1)
self.assertEqual(actual.reads[0].start_date, expected[0].start_date) self.assertEqual(actual.reads[0].start_date, expected[0].start_date)
self.assertEqual(actual.reads[0].finish_date, expected[0].finish_date) self.assertEqual(actual.reads[0].finish_date, expected[0].finish_date)
def test_read_reads(self): def test_read_reads(self):
actual = models.ImportItem.objects.get(index=2) ''' infer read dates where available '''
self.assertEqual(actual.reads[0].start_date, datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc)) actual = self.item_2
self.assertEqual(actual.reads[0].finish_date, datetime.datetime(2019, 4, 12, 0, 0, tzinfo=timezone.utc)) self.assertEqual(
actual.reads[0].start_date,
datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc))
self.assertEqual(
actual.reads[0].finish_date,
datetime.datetime(2019, 4, 12, 0, 0, tzinfo=timezone.utc))
def test_unread_reads(self): def test_unread_reads(self):
''' handle books with no read dates '''
expected = [] expected = []
actual = models.ImportItem.objects.get(index=3) actual = models.ImportItem.objects.get(index=3)
self.assertEqual(actual.reads, expected) self.assertEqual(actual.reads, expected)
@responses.activate
def test_get_book_from_isbn(self):
''' search and load books by isbn (9780356506999) '''
connector_info = models.Connector.objects.create(
identifier='openlibrary.org',
name='OpenLibrary',
connector_file='openlibrary',
base_url='https://openlibrary.org',
books_url='https://openlibrary.org',
covers_url='https://covers.openlibrary.org',
search_url='https://openlibrary.org/search?q=',
priority=3,
)
connector = books_manager.load_connector(connector_info)
result = SearchResult(
title='Test Result',
key='https://openlibrary.org/works/OL1234W',
author='An Author',
year='1980',
connector=connector,
)
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ol_edition.json')
bookdata = json.loads(datafile.read_bytes())
responses.add(
responses.GET,
'https://openlibrary.org/works/OL1234W',
json=bookdata,
status=200)
responses.add(
responses.GET,
'https://openlibrary.org//works/OL15832982W',
json=bookdata,
status=200)
responses.add(
responses.GET,
'https://openlibrary.org//authors/OL382982A.json',
json={'name': 'test author'},
status=200)
with patch('bookwyrm.books_manager.first_search_result') as search:
search.return_value = result
book = self.item_1.get_book_from_isbn()
self.assertEqual(book.title, 'Sabriel')

View file

@ -1 +0,0 @@
from . import *

View file

@ -1,80 +0,0 @@
from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models, outgoing
from bookwyrm.settings import DOMAIN
class Following(TestCase):
def setUp(self):
with patch('bookwyrm.models.user.set_remote_server'):
self.remote_user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword',
local=False,
remote_id='https://example.com/users/rat',
inbox='https://example.com/users/rat/inbox',
outbox='https://example.com/users/rat/outbox',
)
self.local_user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword',
local=True,
remote_id='http://local.com/users/mouse',
)
def test_handle_follow(self):
self.assertEqual(models.UserFollowRequest.objects.count(), 0)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_follow(self.local_user, self.remote_user)
rel = models.UserFollowRequest.objects.get()
self.assertEqual(rel.user_subject, self.local_user)
self.assertEqual(rel.user_object, self.remote_user)
self.assertEqual(rel.status, 'follow_request')
def test_handle_unfollow(self):
self.remote_user.followers.add(self.local_user)
self.assertEqual(self.remote_user.followers.count(), 1)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_unfollow(self.local_user, self.remote_user)
self.assertEqual(self.remote_user.followers.count(), 0)
def test_handle_accept(self):
rel = models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
rel_id = rel.id
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_accept(rel)
# request should be deleted
self.assertEqual(
models.UserFollowRequest.objects.filter(id=rel_id).count(), 0
)
# follow relationship should exist
self.assertEqual(self.remote_user.followers.first(), self.local_user)
def test_handle_reject(self):
rel = models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
rel_id = rel.id
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_reject(rel)
# request should be deleted
self.assertEqual(
models.UserFollowRequest.objects.filter(id=rel_id).count(), 0
)
# follow relationship should not exist
self.assertEqual(
models.UserFollows.objects.filter(id=rel_id).count(), 0
)

View file

@ -1,61 +0,0 @@
''' testing user lookup '''
import json
import pathlib
from unittest.mock import patch
from django.test import TestCase
import responses
from bookwyrm import models, outgoing
from bookwyrm.settings import DOMAIN
class TestOutgoingRemoteWebfinger(TestCase):
''' overwrites standard model feilds to work with activitypub '''
def setUp(self):
''' get user data ready '''
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ap_user.json'
)
self.userdata = json.loads(datafile.read_bytes())
del self.userdata['icon']
def test_existing_user(self):
''' simple database lookup by username '''
user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
result = outgoing.handle_remote_webfinger('@mouse@%s' % DOMAIN)
self.assertEqual(result, user)
result = outgoing.handle_remote_webfinger('mouse@%s' % DOMAIN)
self.assertEqual(result, user)
@responses.activate
def test_load_user(self):
username = 'mouse@example.com'
wellknown = {
"subject": "acct:mouse@example.com",
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": "https://example.com/user/mouse"
}
]
}
responses.add(
responses.GET,
'https://example.com/.well-known/webfinger?resource=acct:%s' \
% username,
json=wellknown,
status=200)
responses.add(
responses.GET,
'https://example.com/user/mouse',
json=self.userdata,
status=200)
with patch('bookwyrm.models.user.set_remote_server.delay'):
result = outgoing.handle_remote_webfinger('@mouse@example.com')
self.assertIsInstance(result, models.User)
self.assertEqual(result.username, 'mouse@example.com')

View file

@ -1,69 +0,0 @@
from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models, outgoing
class Shelving(TestCase):
def setUp(self):
self.user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword',
local=True,
remote_id='http://local.com/users/mouse',
)
work = models.Work.objects.create(
title='Example work',
)
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
parent_work=work,
)
self.shelf = models.Shelf.objects.create(
name='Test Shelf',
identifier='test-shelf',
user=self.user
)
def test_handle_shelve(self):
with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
outgoing.handle_shelve(self.user, self.book, self.shelf)
# make sure the book is on the shelf
self.assertEqual(self.shelf.books.get(), self.book)
def test_handle_shelve_to_read(self):
shelf = models.Shelf.objects.get(identifier='to-read')
with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
outgoing.handle_shelve(self.user, self.book, shelf)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
def test_handle_shelve_reading(self):
shelf = models.Shelf.objects.get(identifier='reading')
with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
outgoing.handle_shelve(self.user, self.book, shelf)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
def test_handle_shelve_read(self):
shelf = models.Shelf.objects.get(identifier='read')
with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
outgoing.handle_shelve(self.user, self.book, shelf)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
def test_handle_unshelve(self):
self.shelf.books.add(self.book)
self.shelf.save()
self.assertEqual(self.shelf.books.count(), 1)
with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
outgoing.handle_unshelve(self.user, self.book, self.shelf)
self.assertEqual(self.shelf.books.count(), 0)

View file

@ -8,6 +8,7 @@ from django.http import HttpResponseBadRequest, HttpResponseNotAllowed, \
HttpResponseNotFound HttpResponseNotFound
from django.test import TestCase from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
import responses
from bookwyrm import models, incoming from bookwyrm import models, incoming
@ -421,6 +422,25 @@ class Incoming(TestCase):
self.assertEqual(notification.related_status, self.status) self.assertEqual(notification.related_status, self.status)
@responses.activate
def test_handle_discarded_boost(self):
''' test a boost of a mastodon status that will be discarded '''
activity = {
'type': 'Announce',
'id': 'http://www.faraway.com/boost/12',
'actor': self.remote_user.remote_id,
'object': self.status.to_activity(),
}
responses.add(
responses.GET,
'http://www.faraway.com/boost/12',
json={'id': 'http://www.faraway.com/boost/12'},
status=200)
incoming.handle_boost(activity)
self.assertEqual(models.Boost.objects.count(), 0)
def test_handle_unboost(self): def test_handle_unboost(self):
''' undo a boost ''' ''' undo a boost '''
activity = { activity = {

View file

@ -0,0 +1,193 @@
''' sending out activities '''
import json
import pathlib
from unittest.mock import patch
from django.test import TestCase
import responses
from bookwyrm import models, outgoing
from bookwyrm.settings import DOMAIN
class Outgoing(TestCase):
''' sends out activities '''
def setUp(self):
''' we'll need some data '''
with patch('bookwyrm.models.user.set_remote_server'):
self.remote_user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword',
local=False,
remote_id='https://example.com/users/rat',
inbox='https://example.com/users/rat/inbox',
outbox='https://example.com/users/rat/outbox',
)
self.local_user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword', local=True,
remote_id='https://example.com/users/mouse',
)
datafile = pathlib.Path(__file__).parent.joinpath(
'data/ap_user.json'
)
self.userdata = json.loads(datafile.read_bytes())
del self.userdata['icon']
work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
parent_work=work
)
self.shelf = models.Shelf.objects.create(
name='Test Shelf',
identifier='test-shelf',
user=self.local_user
)
def test_handle_follow(self):
''' send a follow request '''
self.assertEqual(models.UserFollowRequest.objects.count(), 0)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_follow(self.local_user, self.remote_user)
rel = models.UserFollowRequest.objects.get()
self.assertEqual(rel.user_subject, self.local_user)
self.assertEqual(rel.user_object, self.remote_user)
self.assertEqual(rel.status, 'follow_request')
def test_handle_unfollow(self):
''' send an unfollow '''
self.remote_user.followers.add(self.local_user)
self.assertEqual(self.remote_user.followers.count(), 1)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_unfollow(self.local_user, self.remote_user)
self.assertEqual(self.remote_user.followers.count(), 0)
def test_handle_accept(self):
''' accept a follow request '''
rel = models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
rel_id = rel.id
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_accept(rel)
# request should be deleted
self.assertEqual(
models.UserFollowRequest.objects.filter(id=rel_id).count(), 0
)
# follow relationship should exist
self.assertEqual(self.remote_user.followers.first(), self.local_user)
def test_handle_reject(self):
''' reject a follow request '''
rel = models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
rel_id = rel.id
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_reject(rel)
# request should be deleted
self.assertEqual(
models.UserFollowRequest.objects.filter(id=rel_id).count(), 0
)
# follow relationship should not exist
self.assertEqual(
models.UserFollows.objects.filter(id=rel_id).count(), 0
)
def test_existing_user(self):
''' simple database lookup by username '''
result = outgoing.handle_remote_webfinger('@mouse@%s' % DOMAIN)
self.assertEqual(result, self.local_user)
result = outgoing.handle_remote_webfinger('mouse@%s' % DOMAIN)
self.assertEqual(result, self.local_user)
@responses.activate
def test_load_user(self):
''' find a remote user using webfinger '''
username = 'mouse@example.com'
wellknown = {
"subject": "acct:mouse@example.com",
"links": [{
"rel": "self",
"type": "application/activity+json",
"href": "https://example.com/user/mouse"
}]
}
responses.add(
responses.GET,
'https://example.com/.well-known/webfinger?resource=acct:%s' \
% username,
json=wellknown,
status=200)
responses.add(
responses.GET,
'https://example.com/user/mouse',
json=self.userdata,
status=200)
with patch('bookwyrm.models.user.set_remote_server.delay'):
result = outgoing.handle_remote_webfinger('@mouse@example.com')
self.assertIsInstance(result, models.User)
self.assertEqual(result.username, 'mouse@example.com')
def test_handle_shelve(self):
''' shelve a book '''
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_shelve(self.local_user, self.book, self.shelf)
# make sure the book is on the shelf
self.assertEqual(self.shelf.books.get(), self.book)
def test_handle_shelve_to_read(self):
''' special behavior for the to-read shelf '''
shelf = models.Shelf.objects.get(identifier='to-read')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_shelve(self.local_user, self.book, shelf)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
def test_handle_shelve_reading(self):
''' special behavior for the reading shelf '''
shelf = models.Shelf.objects.get(identifier='reading')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_shelve(self.local_user, self.book, shelf)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
def test_handle_shelve_read(self):
''' special behavior for the read shelf '''
shelf = models.Shelf.objects.get(identifier='read')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_shelve(self.local_user, self.book, shelf)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
def test_handle_unshelve(self):
''' remove a book from a shelf '''
self.shelf.books.add(self.book)
self.shelf.save()
self.assertEqual(self.shelf.books.count(), 1)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_unshelve(self.local_user, self.book, self.shelf)
self.assertEqual(self.shelf.books.count(), 0)

View file

@ -2,11 +2,13 @@
from unittest.mock import patch from unittest.mock import patch
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.http.response import Http404 from django.http.response import Http404
from django.test import TestCase from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
from bookwyrm import view_actions as actions, models from bookwyrm import forms, models, view_actions as actions
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
@ -19,6 +21,13 @@ class ViewActions(TestCase):
'mouse', 'mouse@mouse.com', 'mouseword', local=True) 'mouse', 'mouse@mouse.com', 'mouseword', local=True)
self.local_user.remote_id = 'https://example.com/user/mouse' self.local_user.remote_id = 'https://example.com/user/mouse'
self.local_user.save() self.local_user.save()
self.group = Group.objects.create(name='editor')
self.group.permissions.add(
Permission.objects.create(
name='edit_book',
codename='edit_book',
content_type=ContentType.objects.get_for_model(models.User)).id
)
with patch('bookwyrm.models.user.set_remote_server.delay'): with patch('bookwyrm.models.user.set_remote_server.delay'):
self.remote_user = models.User.objects.create_user( self.remote_user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword', 'rat', 'rat@rat.com', 'ratword',
@ -238,6 +247,43 @@ class ViewActions(TestCase):
self.assertEqual(resp.template_name, 'password_reset.html') self.assertEqual(resp.template_name, 'password_reset.html')
self.assertTrue(models.PasswordReset.objects.exists()) self.assertTrue(models.PasswordReset.objects.exists())
def test_password_change(self):
''' change password '''
password_hash = self.local_user.password
request = self.factory.post('', {
'password': 'hi',
'confirm-password': 'hi'
})
request.user = self.local_user
with patch('bookwyrm.view_actions.login'):
actions.password_change(request)
self.assertNotEqual(self.local_user.password, password_hash)
def test_password_change_mismatch(self):
''' change password '''
password_hash = self.local_user.password
request = self.factory.post('', {
'password': 'hi',
'confirm-password': 'hihi'
})
request.user = self.local_user
actions.password_change(request)
self.assertEqual(self.local_user.password, password_hash)
def test_edit_user(self):
''' use a form to update a user '''
form = forms.EditUserForm(instance=self.local_user)
form.data['name'] = 'New Name'
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
actions.edit_profile(request)
self.assertEqual(self.local_user.name, 'New Name')
def test_switch_edition(self): def test_switch_edition(self):
''' updates user's relationships to a book ''' ''' updates user's relationships to a book '''
work = models.Work.objects.create(title='test work') work = models.Work.objects.create(title='test work')
@ -262,3 +308,46 @@ class ViewActions(TestCase):
self.assertEqual(models.ShelfBook.objects.get().book, edition2) self.assertEqual(models.ShelfBook.objects.get().book, edition2)
self.assertEqual(models.ReadThrough.objects.get().book, edition2) self.assertEqual(models.ReadThrough.objects.get().book, edition2)
def test_edit_author(self):
''' edit an author '''
author = models.Author.objects.create(name='Test Author')
self.local_user.groups.add(self.group)
form = forms.AuthorForm(instance=author)
form.data['name'] = 'New Name'
form.data['last_edited_by'] = self.local_user.id
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
actions.edit_author(request, author.id)
author.refresh_from_db()
self.assertEqual(author.name, 'New Name')
self.assertEqual(author.last_edited_by, self.local_user)
def test_edit_author_non_editor(self):
''' edit an author with invalid post data'''
author = models.Author.objects.create(name='Test Author')
form = forms.AuthorForm(instance=author)
form.data['name'] = 'New Name'
form.data['last_edited_by'] = self.local_user.id
request = self.factory.post('', form.data)
request.user = self.local_user
with self.assertRaises(PermissionDenied):
actions.edit_author(request, author.id)
author.refresh_from_db()
self.assertEqual(author.name, 'Test Author')
def test_edit_author_invalid_form(self):
''' edit an author with invalid post data'''
author = models.Author.objects.create(name='Test Author')
self.local_user.groups.add(self.group)
form = forms.AuthorForm(instance=author)
form.data['name'] = ''
form.data['last_edited_by'] = self.local_user.id
request = self.factory.post('', form.data)
request.user = self.local_user
resp = actions.edit_author(request, author.id)
author.refresh_from_db()
self.assertEqual(author.name, 'Test Author')
self.assertEqual(resp.template_name, 'edit_author.html')

View file

@ -76,6 +76,7 @@ urlpatterns = [
# books # books
re_path(r'%s(.json)?/?$' % book_path, views.book_page), re_path(r'%s(.json)?/?$' % book_path, views.book_page),
re_path(r'%s/edit/?$' % book_path, views.edit_book_page), re_path(r'%s/edit/?$' % book_path, views.edit_book_page),
re_path(r'^author/(?P<author_id>[\w\-]+)/edit/?$', views.edit_author_page),
re_path(r'%s/editions(.json)?/?$' % book_path, views.editions_page), re_path(r'%s/editions(.json)?/?$' % book_path, views.editions_page),
re_path(r'^author/(?P<author_id>[\w\-]+)(.json)?/?$', views.author_page), re_path(r'^author/(?P<author_id>[\w\-]+)(.json)?/?$', views.author_page),
@ -104,6 +105,7 @@ urlpatterns = [
re_path(r'^edit-book/(?P<book_id>\d+)/?$', actions.edit_book), re_path(r'^edit-book/(?P<book_id>\d+)/?$', actions.edit_book),
re_path(r'^upload-cover/(?P<book_id>\d+)/?$', actions.upload_cover), re_path(r'^upload-cover/(?P<book_id>\d+)/?$', actions.upload_cover),
re_path(r'^add-description/(?P<book_id>\d+)/?$', actions.add_description), re_path(r'^add-description/(?P<book_id>\d+)/?$', actions.add_description),
re_path(r'^edit-author/(?P<author_id>\d+)/?$', actions.edit_author),
re_path(r'^switch-edition/?$', actions.switch_edition), re_path(r'^switch-edition/?$', actions.switch_edition),
re_path(r'^edit-readthrough/?$', actions.edit_readthrough), re_path(r'^edit-readthrough/?$', actions.edit_readthrough),

View file

@ -159,23 +159,21 @@ def password_change(request):
request.user.set_password(new_password) request.user.set_password(new_password)
request.user.save() request.user.save()
login(request, request.user) login(request, request.user)
return redirect('/user-edit') return redirect('/user/%s' % request.user.localname)
@login_required @login_required
@require_POST @require_POST
def edit_profile(request): def edit_profile(request):
''' les get fancy with images ''' ''' les get fancy with images '''
form = forms.EditUserForm(request.POST, request.FILES) form = forms.EditUserForm(
request.POST, request.FILES, instance=request.user)
if not form.is_valid(): if not form.is_valid():
data = { data = {'form': form, 'user': request.user}
'form': form,
'user': request.user,
}
return TemplateResponse(request, 'edit_user.html', data) return TemplateResponse(request, 'edit_user.html', data)
request.user.name = form.data['name'] user = form.save(commit=False)
request.user.email = form.data['email']
if 'avatar' in form.files: if 'avatar' in form.files:
# crop and resize avatar upload # crop and resize avatar upload
image = Image.open(form.files['avatar']) image = Image.open(form.files['avatar'])
@ -201,17 +199,10 @@ def edit_profile(request):
# set the name to a hash # set the name to a hash
extension = form.files['avatar'].name.split('.')[-1] extension = form.files['avatar'].name.split('.')[-1]
filename = '%s.%s' % (uuid4(), extension) filename = '%s.%s' % (uuid4(), extension)
request.user.avatar.save( user.avatar.save(filename, ContentFile(output.getvalue()))
filename, user.save()
ContentFile(output.getvalue())
)
request.user.summary = form.data['summary'] outgoing.handle_update_user(user)
request.user.manually_approves_followers = \
form.cleaned_data['manually_approves_followers']
request.user.save()
outgoing.handle_update_user(request.user)
return redirect('/user/%s' % request.user.localname) return redirect('/user/%s' % request.user.localname)
@ -244,7 +235,7 @@ def edit_book(request, book_id):
return TemplateResponse(request, 'edit_book.html', data) return TemplateResponse(request, 'edit_book.html', data)
book = form.save() book = form.save()
outgoing.handle_update_book(request.user, book) outgoing.handle_update_book_data(request.user, book)
return redirect('/book/%s' % book.id) return redirect('/book/%s' % book.id)
@ -289,10 +280,9 @@ def upload_cover(request, book_id):
return redirect('/book/%d' % book.id) return redirect('/book/%d' % book.id)
book.cover = form.files['cover'] book.cover = form.files['cover']
book.sync_cover = False
book.save() book.save()
outgoing.handle_update_book(request.user, book) outgoing.handle_update_book_data(request.user, book)
return redirect('/book/%s' % book.id) return redirect('/book/%s' % book.id)
@ -311,10 +301,31 @@ def add_description(request, book_id):
book.description = description book.description = description
book.save() book.save()
outgoing.handle_update_book(request.user, book) outgoing.handle_update_book_data(request.user, book)
return redirect('/book/%s' % book.id) return redirect('/book/%s' % book.id)
@login_required
@permission_required('bookwyrm.edit_book', raise_exception=True)
@require_POST
def edit_author(request, author_id):
''' edit a author cool '''
author = get_object_or_404(models.Author, id=author_id)
form = forms.AuthorForm(request.POST, request.FILES, instance=author)
if not form.is_valid():
data = {
'title': 'Edit Author',
'author': author,
'form': form
}
return TemplateResponse(request, 'edit_author.html', data)
author = form.save()
outgoing.handle_update_book_data(request.user, author)
return redirect('/author/%s' % author.id)
@login_required @login_required
@require_POST @require_POST
def create_shelf(request): def create_shelf(request):

View file

@ -657,6 +657,20 @@ def edit_book_page(request, book_id):
return TemplateResponse(request, 'edit_book.html', data) return TemplateResponse(request, 'edit_book.html', data)
@login_required
@permission_required('bookwyrm.edit_book', raise_exception=True)
@require_GET
def edit_author_page(request, author_id):
''' info about a book '''
author = get_object_or_404(models.Author, id=author_id)
data = {
'title': 'Edit Author',
'author': author,
'form': forms.AuthorForm(instance=author)
}
return TemplateResponse(request, 'edit_author.html', data)
@require_GET @require_GET
def editions_page(request, book_id): def editions_page(request, book_id):
''' list of editions of a book ''' ''' list of editions of a book '''