Merge branch 'main' into shelve-buttons

This commit is contained in:
Mouse Reeve 2021-02-09 13:28:00 -08:00 committed by GitHub
commit 485de039cf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
84 changed files with 1873 additions and 326 deletions

View file

@ -3,7 +3,8 @@
Social reading and reviewing, decentralized with ActivityPub
## Contents
- [The overall idea](#the-overall-idea)
- [Joining BookWyrm](#joining-bookwyrm)
- [The overall idea](#the-overall-idea)
- [What it is and isn't](#what-it-is-and-isnt)
- [The role of federation](#the-role-of-federation)
- [Features](#features)
@ -13,42 +14,46 @@ Social reading and reviewing, decentralized with ActivityPub
- [Book data](#book-data)
- [Contributing](#contributing)
## Joining BookWyrm
BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://github.com/mouse-reeve/bookwyrm/blob/main/instances.md) list.
I, the maintianer of this project, run https://bookwyrm.social, and I generally give out invite codes to those who ask by [email](mailto:mousereeve@riseup.net), [Mastodon direct message](https://friend.camp/@tripofmice), or [Twitter direct message](https://twitter.com/tripofmice).
## The overall idea
### What it is and isn't
BookWyrm is a platform for social reading! You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a datasource for books, but it does do both of those things to some degree.
### The role of federation
BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon and Pixelfed. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance.
BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance.
Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular type of literature, be just for use by people who are in a book club together, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks.
Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular interest, be just for a group of friends, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks.
### Features
Since the project is still in its early stages, not everything here is fully implemented. There is plenty of room for suggestions and ideas. Open an [issue](https://github.com/mouse-reeve/bookwyrm/issues) to get the conversation going!
Since the project is still in its early stages, the features are growing every day, and there is plenty of room for suggestions and ideas. Open an [issue](https://github.com/mouse-reeve/bookwyrm/issues) to get the conversation going!
- Posting about books
- Compose reviews, with or without ratings, which are aggregated in the book page
- Compose other kinds of statuses about books, such as:
- Comments on a book
- Quotes or excerpts
- Recommenations of other books
- Reply to statuses
- Aggregate reviews of a book across connected BookWyrm instances
- Differentiate local and federated reviews and rating
- View aggregate reviews of a book across connected BookWyrm instances
- Differentiate local and federated reviews and rating in your activity feed
- Track reading activity
- Shelve books on default "to-read," "currently reading," and "read" shelves
- Create custom shelves
- Store started reading/finished reading dates
- Store started reading/finished reading dates, as well as progress updates along the way
- Update followers about reading activity (optionally, and with granular privacy controls)
- Create lists of books which can be open to submissions from anyone, curated, or only edited by the creator
- Federation with ActivityPub
- Broadcast and receive user statuses and activity
- Broadcast copies of books that can be used as canonical data sources
- Share book data between instances to create a networked database of metadata
- Identify shared books across instances and aggregate related content
- Follow and interact with users across BookWyrm instances
- Inter-operate with non-BookWyrm ActivityPub services
- Inter-operate with non-BookWyrm ActivityPub services (currently, Mastodon is supported)
- Granular privacy controls
- Local-only, followers-only, and public posting
- Private, followers-only, and public privacy levels for posting, shelves, and lists
- Option for users to manually approve followers
- Allow blocking and flagging for moderation
- Control which instances you want to federate with
## Setting up the developer environment
@ -88,6 +93,7 @@ This project is still young and isn't, at the momoment, very stable, so please p
`cp .env.example .env`
- Add your domain, email address, mailgun credentials
- Set a secure redis password and secret key
- Set a secure database password for postgres
- Update your nginx configuration in `nginx/default.conf`
- Replace `your-domain.com` with your domain name
- Run the application (this should also set up a Certbot ssl cert for your domain)
@ -99,6 +105,7 @@ This project is still young and isn't, at the momoment, very stable, so please p
`docker-compose up -d`
- Initialize the database
`./bw-dev initdb`
- Set up schedule backups with cron that runs that `docker-compose exec db pg_dump -U <databasename>` and saves the backup to a safe locationgi
- Congrats! You did it, go to your domain and enjoy the fruits of your labors
### Configure your instance
- Register a user account in the applcation UI
@ -114,21 +121,11 @@ This project is still young and isn't, at the momoment, very stable, so please p
user.is_superuser = True
user.save()
```
- Go to the admin panel (`/admin/bookwyrm/sitesettings/1/change` on your domain) and set your instance name, description, code of conduct, and toggle whether registration is open on your instance
## Project structure
All the url routing is in `bookwyrm/urls.py`. This includes the application views (your home page, user page, book page, etc), application endpoints (things that happen when you click buttons), and federation api endpoints (inboxes, outboxes, webfinger, etc).
The application views and actions are in `bookwyrm/views.py`. The internal actions call api handlers which deal with federating content. Outgoing messages (any action done by a user that is federated out), as well as outboxes, live in `bookwyrm/outgoing.py`, and all handlers for incoming messages, as well as inboxes and webfinger, live in `bookwyrm/incoming.py`. Connection to openlibrary.org to get book data is handled in `bookwyrm/connectors/openlibrary.py`. ActivityPub serialization is handled in the `bookwyrm/activitypub/` directory.
Celery is used for background tasks, which includes receiving incoming ActivityPub activities, ActivityPub broadcasting, and external data import.
The UI is all django templates because that is the default. You can replace it with a complex javascript framework over my ~dead body~ mild objections.
- Go to the site settings (`/settings/site-settings` on your domain) and configure your instance name, description, code of conduct, and toggle whether registration is open on your instance
## Book data
The application is set up to get book data from arbitrary outside sources -- right now, it's only able to connect to OpenLibrary, but other connectors could be written. By default, a book is non-canonical copy of an OpenLibrary book, and will be updated with OpenLibrary if the data there changes. However, a book can edited and decoupled from its original data source, or added locally with no external data source.
The application is set up to share book and author data between instances, and get book data from arbitrary outside sources. Right now, the only connector is to OpenLibrary, but other connectors could be written.
There are three concepts in the book data model:
- `Book`, an abstract, high-level concept that could mean either a `Work` or an `Edition`. No data is saved as a `Book`, it serves as shared model for `Work` and `Edition`

View file

@ -10,6 +10,7 @@ from .note import Note, GeneratedNote, Article, Comment, Review, Quotation
from .note import Tombstone
from .interaction import Boost, Like
from .ordered_collection import OrderedCollection, OrderedCollectionPage
from .ordered_collection import BookList, Shelf
from .person import Person, PublicKey
from .response import ActivitypubResponse
from .book import Edition, Work, Author

View file

@ -130,6 +130,7 @@ class ActivityObject:
def serialize(self):
''' convert to dictionary with context attr '''
data = self.__dict__
data = {k:v for (k, v) in data.items() if v is not None}
data['@context'] = 'https://www.w3.org/ns/activitystreams'
return data

View file

@ -1,5 +1,5 @@
''' defines activitypub collections (lists) '''
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import List
from .base_activity import ActivityObject
@ -10,11 +10,28 @@ class OrderedCollection(ActivityObject):
''' structure of an ordered collection activity '''
totalItems: int
first: str
last: str = ''
name: str = ''
owner: str = ''
last: str = None
name: str = None
owner: str = None
type: str = 'OrderedCollection'
@dataclass(init=False)
class OrderedCollectionPrivate(OrderedCollection):
to: List[str] = field(default_factory=lambda: [])
cc: List[str] = field(default_factory=lambda: [])
@dataclass(init=False)
class Shelf(OrderedCollectionPrivate):
''' structure of an ordered collection activity '''
type: str = 'Shelf'
@dataclass(init=False)
class BookList(OrderedCollectionPrivate):
''' structure of an ordered collection activity '''
summary: str = None
curation: str = 'closed'
type: str = 'BookList'
@dataclass(init=False)
class OrderedCollectionPage(ActivityObject):

View file

@ -18,7 +18,7 @@ class Create(Verb):
''' Create activity '''
to: List
cc: List
signature: Signature
signature: Signature = None
type: str = 'Create'

View file

@ -35,10 +35,10 @@ def search(query, min_confidence=0.1):
return results
def local_search(query, min_confidence=0.1):
def local_search(query, min_confidence=0.1, raw=False):
''' only look at local search results '''
connector = load_connector(models.Connector.objects.get(local=True))
return connector.search(query, min_confidence=min_confidence)
return connector.search(query, min_confidence=min_confidence, raw=raw)
def first_search_result(query, min_confidence=0.1):

View file

@ -27,9 +27,9 @@ class Connector(AbstractConnector):
Mapping('series', formatter=get_first),
Mapping('seriesNumber', remote_field='series_number'),
Mapping('subjects'),
Mapping('subjectPlaces'),
Mapping('isbn13', formatter=get_first),
Mapping('isbn10', formatter=get_first),
Mapping('subjectPlaces', remote_field='subject_places'),
Mapping('isbn13', remote_field='isbn_13', formatter=get_first),
Mapping('isbn10', remote_field='isbn_10', formatter=get_first),
Mapping('lccn', formatter=get_first),
Mapping(
'oclcNumber', remote_field='oclc_numbers',
@ -144,9 +144,34 @@ class Connector(AbstractConnector):
# we can mass download edition data from OL to avoid repeatedly querying
edition_options = self.load_edition_data(work.openlibrary_key)
for edition_data in edition_options.get('entries'):
# does this edition have ANY interesting data?
if ignore_edition(edition_data):
continue
self.create_edition_from_data(work, edition_data)
def ignore_edition(edition_data):
''' don't load a million editions that have no metadata '''
# an isbn, we love to see it
if edition_data.get('isbn_13') or edition_data.get('isbn_10'):
print(edition_data.get('isbn_10'))
return False
# grudgingly, oclc can stay
if edition_data.get('oclc_numbers'):
print(edition_data.get('oclc_numbers'))
return False
# if it has a cover it can stay
if edition_data.get('covers'):
print(edition_data.get('covers'))
return False
# keep non-english editions
if edition_data.get('languages') and \
'languages/eng' not in str(edition_data.get('languages')):
print(edition_data.get('languages'))
return False
return True
def get_description(description_blob):
''' descriptions can be a string or a dict '''
if isinstance(description_blob, dict):

View file

@ -11,7 +11,8 @@ from .abstract_connector import AbstractConnector, SearchResult
class Connector(AbstractConnector):
''' instantiate a connector '''
def search(self, query, min_confidence=0.1):
# pylint: disable=arguments-differ
def search(self, query, min_confidence=0.1, raw=False):
''' search your local database '''
if not query:
return []
@ -22,10 +23,14 @@ class Connector(AbstractConnector):
results = search_title_author(query, min_confidence)
search_results = []
for result in results:
search_results.append(self.format_search_result(result))
if raw:
search_results.append(result)
else:
search_results.append(self.format_search_result(result))
if len(search_results) >= 10:
break
search_results.sort(key=lambda r: r.confidence, reverse=True)
if not raw:
search_results.sort(key=lambda r: r.confidence, reverse=True)
return search_results

View file

@ -206,3 +206,9 @@ class SiteForm(CustomForm):
class Meta:
model = models.SiteSettings
exclude = []
class ListForm(CustomForm):
class Meta:
model = models.List
fields = ['user', 'name', 'description', 'curation', 'privacy']

View file

@ -47,12 +47,20 @@ def shared_inbox(request):
return HttpResponse()
return HttpResponse(status=401)
# if this isn't a file ripe for refactor, I don't know what is.
handlers = {
'Follow': handle_follow,
'Accept': handle_follow_accept,
'Reject': handle_follow_reject,
'Block': handle_block,
'Create': handle_create,
'Create': {
'BookList': handle_create_list,
'Note': handle_create_status,
'Article': handle_create_status,
'Review': handle_create_status,
'Comment': handle_create_status,
'Quotation': handle_create_status,
},
'Delete': handle_delete_status,
'Like': handle_favorite,
'Announce': handle_boost,
@ -69,6 +77,7 @@ def shared_inbox(request):
'Person': handle_update_user,
'Edition': handle_update_edition,
'Work': handle_update_work,
'BookList': handle_update_list,
},
}
activity_type = activity['type']
@ -204,7 +213,25 @@ def handle_unblock(activity):
@app.task
def handle_create(activity):
def handle_create_list(activity):
''' a new list '''
activity = activity['object']
activitypub.BookList(**activity).to_model(models.List)
@app.task
def handle_update_list(activity):
''' update a list '''
try:
book_list = models.List.objects.get(id=activity['object']['id'])
except models.List.DoesNotExist:
return
activitypub.BookList(
**activity['object']).to_model(models.List, instance=book_list)
@app.task
def handle_create_status(activity):
''' someone did something, good on them '''
# deduplicate incoming activities
activity = activity['object']

View file

@ -0,0 +1,34 @@
''' PROCEED WITH CAUTION: this permanently deletes book data '''
from django.core.management.base import BaseCommand
from django.db.models import Count, Q
from bookwyrm import models
def remove_editions():
''' combine duplicate editions and update related models '''
# not in use
filters = {'%s__isnull' % r.name: True \
for r in models.Edition._meta.related_objects}
# no cover, no identifying fields
filters['cover'] = ''
null_fields = {'%s__isnull' % f: True for f in \
['isbn_10', 'isbn_13', 'oclc_number']}
editions = models.Edition.objects.filter(
Q(languages=[]) | Q(languages__contains=['English']),
**filters, **null_fields
).annotate(Count('parent_work__editions')).filter(
# mustn't be the only edition for the work
parent_work__editions__count__gt=1
)
print(editions.count())
editions.delete()
class Command(BaseCommand):
''' dedplucate allllll the book data models '''
help = 'merges duplicate book data'
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
''' run deudplications '''
remove_editions()

View file

@ -0,0 +1,65 @@
# Generated by Django 3.0.7 on 2021-01-31 16:14
import bookwyrm.models.base_model
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', '0040_auto_20210122_0057'),
]
operations = [
migrations.CreateModel(
name='List',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('remote_id', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])),
('name', bookwyrm.models.fields.CharField(max_length=100)),
('description', bookwyrm.models.fields.TextField(blank=True, null=True)),
('privacy', bookwyrm.models.fields.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255)),
('curation', bookwyrm.models.fields.CharField(choices=[('closed', 'Closed'), ('open', 'Open'), ('curated', 'Curated')], default='closed', max_length=255)),
],
options={
'abstract': False,
},
bases=(bookwyrm.models.base_model.OrderedCollectionMixin, models.Model),
),
migrations.CreateModel(
name='ListItem',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('remote_id', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])),
('notes', bookwyrm.models.fields.TextField(blank=True, null=True)),
('approved', models.BooleanField(default=True)),
('order', bookwyrm.models.fields.IntegerField(blank=True, null=True)),
('added_by', bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('book', bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition')),
('book_list', bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.List')),
('endorsement', models.ManyToManyField(related_name='endorsers', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ('-created_date',),
'unique_together': {('book', 'book_list')},
},
bases=(bookwyrm.models.base_model.ActivitypubMixin, models.Model),
),
migrations.AddField(
model_name='list',
name='books',
field=models.ManyToManyField(through='bookwyrm.ListItem', to='bookwyrm.Edition'),
),
migrations.AddField(
model_name='list',
name='user',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
]

View file

@ -0,0 +1,28 @@
# Generated by Django 3.0.7 on 2021-02-01 21:08
import bookwyrm.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0041_auto_20210131_1614'),
]
operations = [
migrations.AlterModelOptions(
name='list',
options={'ordering': ('-updated_date',)},
),
migrations.AlterField(
model_name='list',
name='privacy',
field=bookwyrm.models.fields.PrivacyField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
),
migrations.AlterField(
model_name='shelf',
name='privacy',
field=bookwyrm.models.fields.PrivacyField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
),
]

View file

@ -7,6 +7,7 @@ from .author import Author
from .connector import Connector
from .shelf import Shelf, ShelfBook
from .list import List, ListItem
from .status import Status, GeneratedNote, Review, Comment, Quotation
from .status import Boost

View file

@ -140,20 +140,7 @@ class ActivitypubMixin:
def to_activity(self):
''' convert from a model to an activity '''
activity = {}
for field in self.activity_fields:
field.set_activity_from_field(activity, self)
if hasattr(self, 'serialize_reverse_fields'):
# for example, editions of a work
for model_field_name, activity_field_name, sort_field in \
self.serialize_reverse_fields:
related_field = getattr(self, model_field_name)
activity[activity_field_name] = \
unfurl_related_field(related_field, sort_field)
if not activity.get('id'):
activity['id'] = self.get_remote_id()
activity = generate_activity(self)
return self.activity_serializer(**activity).serialize()
@ -161,16 +148,18 @@ class ActivitypubMixin:
''' returns the object wrapped in a Create activity '''
activity_object = self.to_activity(**kwargs)
signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
content = activity_object['content']
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
signature = None
create_id = self.remote_id + '/activity'
if 'content' in activity_object:
signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
content = activity_object['content']
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
signature = activitypub.Signature(
creator='%s#main-key' % user.remote_id,
created=activity_object['published'],
signatureValue=b64encode(signed_message).decode('utf8')
)
signature = activitypub.Signature(
creator='%s#main-key' % user.remote_id,
created=activity_object['published'],
signatureValue=b64encode(signed_message).decode('utf8')
)
return activitypub.Create(
id=create_id,
@ -223,7 +212,7 @@ class OrderedCollectionPageMixin(ActivitypubMixin):
def to_ordered_collection(self, queryset, \
remote_id=None, page=False, **kwargs):
remote_id=None, page=False, collection_only=False, **kwargs):
''' an ordered collection of whatevers '''
if not queryset.ordered:
raise RuntimeError('queryset must be ordered')
@ -232,18 +221,25 @@ class OrderedCollectionPageMixin(ActivitypubMixin):
if page:
return to_ordered_collection_page(
queryset, remote_id, **kwargs)
name = self.name if hasattr(self, 'name') else None
owner = self.user.remote_id if hasattr(self, 'user') else ''
if collection_only or not hasattr(self, 'activity_serializer'):
serializer = activitypub.OrderedCollection
activity = {}
else:
serializer = self.activity_serializer
# a dict from the model fields
activity = generate_activity(self)
if remote_id:
activity['id'] = remote_id
paginated = Paginator(queryset, PAGE_LENGTH)
return activitypub.OrderedCollection(
id=remote_id,
totalItems=paginated.count,
name=name,
owner=owner,
first='%s?page=1' % remote_id,
last='%s?page=%d' % (remote_id, paginated.num_pages)
).serialize()
# add computed fields specific to orderd collections
activity['totalItems'] = paginated.count
activity['first'] = '%s?page=1' % remote_id
activity['last'] = '%s?page=%d' % (remote_id, paginated.num_pages)
return serializer(**activity).serialize()
# pylint: disable=unused-argument
@ -285,3 +281,22 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin):
def to_activity(self, **kwargs):
''' an ordered collection of the specified model queryset '''
return self.to_ordered_collection(self.collection_queryset, **kwargs)
def generate_activity(obj):
''' go through the fields on an object '''
activity = {}
for field in obj.activity_fields:
field.set_activity_from_field(activity, obj)
if hasattr(obj, 'serialize_reverse_fields'):
# for example, editions of a work
for model_field_name, activity_field_name, sort_field in \
obj.serialize_reverse_fields:
related_field = getattr(obj, model_field_name)
activity[activity_field_name] = \
unfurl_related_field(related_field, sort_field)
if not activity.get('id'):
activity['id'] = obj.get_remote_id()
return activity

View file

@ -213,7 +213,10 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
setattr(instance, self.name, 'followers')
def set_activity_from_field(self, activity, instance):
mentions = [u.remote_id for u in instance.mention_users.all()]
# explicitly to anyone mentioned (statuses only)
mentions = []
if hasattr(instance, 'mention_users'):
mentions = [u.remote_id for u in instance.mention_users.all()]
# this is a link to the followers list
followers = instance.user.__class__._meta.get_field('followers')\
.field_to_activity(instance.user.followers)

93
bookwyrm/models/list.py Normal file
View file

@ -0,0 +1,93 @@
''' make a list of books!! '''
from django.db import models
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from .base_model import ActivitypubMixin, BookWyrmModel
from .base_model import OrderedCollectionMixin
from . import fields
CurationType = models.TextChoices('Curation', [
'closed',
'open',
'curated',
])
class List(OrderedCollectionMixin, BookWyrmModel):
''' a list of books '''
name = fields.CharField(max_length=100)
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='owner')
description = fields.TextField(
blank=True, null=True, activitypub_field='summary')
privacy = fields.PrivacyField()
curation = fields.CharField(
max_length=255,
default='closed',
choices=CurationType.choices
)
books = models.ManyToManyField(
'Edition',
symmetrical=False,
through='ListItem',
through_fields=('book_list', 'book'),
)
activity_serializer = activitypub.BookList
def get_remote_id(self):
''' don't want the user to be in there in this case '''
return 'https://%s/list/%d' % (DOMAIN, self.id)
@property
def collection_queryset(self):
''' list of books for this shelf, overrides OrderedCollectionMixin '''
return self.books.filter(
listitem__approved=True
).all().order_by('listitem')
class Meta:
''' default sorting '''
ordering = ('-updated_date',)
class ListItem(ActivitypubMixin, BookWyrmModel):
''' ok '''
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='object')
book_list = fields.ForeignKey(
'List', on_delete=models.CASCADE, activitypub_field='target')
added_by = fields.ForeignKey(
'User',
on_delete=models.PROTECT,
activitypub_field='actor'
)
notes = fields.TextField(blank=True, null=True)
approved = models.BooleanField(default=True)
order = fields.IntegerField(blank=True, null=True)
endorsement = models.ManyToManyField('User', related_name='endorsers')
activity_serializer = activitypub.AddBook
def to_add_activity(self, user):
''' AP for shelving a book'''
return activitypub.Add(
id='%s#add' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.book_list.remote_id,
).serialize()
def to_remove_activity(self, user):
''' AP for un-shelving a book'''
return activitypub.Remove(
id='%s#remove' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.book_list.remote_id
).serialize()
class Meta:
''' an opinionated constraint! you can't put a book on a list twice '''
unique_together = ('book', 'book_list')
ordering = ('-created_date',)

View file

@ -15,11 +15,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='owner')
editable = models.BooleanField(default=True)
privacy = fields.CharField(
max_length=255,
default='public',
choices=fields.PrivacyLevels.choices
)
privacy = fields.PrivacyField()
books = models.ManyToManyField(
'Edition',
symmetrical=False,
@ -27,6 +23,8 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
through_fields=('shelf', 'book')
)
activity_serializer = activitypub.Shelf
def save(self, *args, **kwargs):
''' set the identifier '''
saved = super().save(*args, **kwargs)

View file

@ -94,6 +94,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
return self.to_ordered_collection(
self.replies(self),
remote_id='%s/replies' % self.remote_id,
collection_only=True,
**kwargs
)

View file

@ -131,7 +131,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
privacy__in=['public', 'unlisted'],
).select_subclasses().order_by('-published_date')
return self.to_ordered_collection(queryset, \
remote_id=self.outbox, **kwargs)
collection_only=True, remote_id=self.outbox, **kwargs)
def to_following_activity(self, **kwargs):
''' activitypub following list '''
@ -266,6 +266,7 @@ class AnnualGoal(BookWyrmModel):
@property
def progress_percent(self):
''' how close to your goal, in percent form '''
return int(float(self.book_count / self.goal) * 100)

View file

@ -36,6 +36,10 @@
<glyph unicode="&#xe91a;" glyph-name="stars" d="M726.857 664.381l-38.857 103.619-38.857-103.619-73.143-24.381 73.143-24.381 38.857-103.619 38.857 103.619 73.143 24.381-73.143 24.381zM662.857 280.381l-38.857 103.619-38.857-103.619-73.143-24.381 73.143-24.381 38.857-103.619 38.857 103.619 73.143 24.381-73.143 24.381zM430.703 528.432l-62.703 175.568-62.703-175.568-145.297-48.432 145.297-48.432 62.703-175.568 62.703 175.568 145.297 48.432-145.297 48.432z" />
<glyph unicode="&#xe91b;" glyph-name="warning" d="M907.5 204.9l-345.1 558.4c-27.8 44.9-73 45-100.8 0v0l-345.1-558.4c-37.3-60.3-10.2-108.9 60.4-108.9h670.2c70.5 0 97.6 48.7 60.4 108.9zM512 192c-17.7 0-32 14.3-32 32s14.3 32 32 32 32-14.3 32-32-14.3-32-32-32zM544 351.9c0-17.4-14.3-31.9-32-31.9-17.8 0-32 14.3-32 31.9v192.2c0 17.4 14.3 31.9 32 31.9v0c17.8 0 32-14.3 32-31.9v-192.2z" />
<glyph unicode="&#xe91c;" glyph-name="bookmark" d="M800 32h-512v704h224v-292l113.312 86.016 110.688-86.016v292h128v-640c0-35.328-28.672-64-64-64zM625.312 572l-81.312-64v260h160v-260l-78.688 64zM192 800v-32c0-17.664 14.336-32 32-32h32v-704h-32c-35.328 0-64 28.672-64 64v704c0 35.328 28.672 64 64 64h576c23.616 0 44.032-12.928 55.136-32h-631.136c-17.664 0-32-14.304-32-32z" />
<glyph unicode="&#xe91d;" glyph-name="rss" horiz-adv-x="805" d="M219.429 182.857c0-60.571-49.143-109.714-109.714-109.714s-109.714 49.143-109.714 109.714 49.143 109.714 109.714 109.714 109.714-49.143 109.714-109.714zM512 112.571c0.571-10.286-2.857-20-9.714-27.429-6.857-8-16.571-12-26.857-12h-77.143c-18.857 0-34.286 14.286-36 33.143-16.571 174.286-154.857 312.571-329.143 329.143-18.857 1.714-33.143 17.143-33.143 36v77.143c0 10.286 4 20 12 26.857 6.286 6.286 15.429 9.714 24.571 9.714h2.857c121.714-9.714 236.571-62.857 322.857-149.714 86.857-86.286 140-201.143 149.714-322.857zM804.571 111.428c0.571-9.714-2.857-19.429-10.286-26.857-6.857-7.429-16-11.429-26.286-11.429h-81.714c-19.429 0-35.429 14.857-36.571 34.286-18.857 332-283.429 596.571-615.429 616-19.429 1.143-34.286 17.143-34.286 36v81.714c0 10.286 4 19.429 11.429 26.286 6.857 6.857 16 10.286 25.143 10.286h1.714c200-10.286 388-94.286 529.714-236.571 142.286-141.714 226.286-329.714 236.571-529.714z" />
<glyph unicode="&#xe91e;" glyph-name="heart1" d="M934.176 791.52c-116.128 115.072-301.824 117.472-422.112 9.216-120.32 108.256-305.952 105.856-422.144-9.216-119.712-118.528-119.712-310.688 0-429.28 34.208-33.888 353.696-350.112 353.696-350.112 37.856-37.504 99.072-37.504 136.896 0 0 0 349.824 346.304 353.696 350.112 119.744 118.592 119.744 310.752-0.032 429.28zM888.576 407.424l-353.696-350.112c-12.576-12.512-33.088-12.512-45.6 0l-353.696 350.112c-94.4 93.44-94.4 245.472 0 338.912 91.008 90.080 237.312 93.248 333.088 7.104l43.392-39.040 43.36 39.040c95.808 86.144 242.112 83.008 333.12-7.104 94.4-93.408 94.4-245.44 0.032-338.912zM296.096 719.968c8.864 0 16-7.168 16-16s-7.168-16-16-16h-0.032c-57.408 0-103.968-46.56-103.968-103.968v-0.032c0-8.832-7.168-16-16-16s-16 7.168-16 16v0c0 75.072 60.832 135.904 135.872 135.968 0.064 0 0.064 0.032 0.128 0.032z" />
<glyph unicode="&#xe91f;" glyph-name="paperplane" d="M1009.376 954.88c-5.312 3.424-11.36 5.12-17.376 5.12-6.176 0-12.384-1.76-17.76-5.376l-960-640c-9.888-6.56-15.328-18.112-14.048-29.952 1.216-11.808 8.896-22.016 19.936-26.368l250.368-100.192 117.728-206.016c5.632-9.888 16.096-16 27.424-16.128 0.128 0 0.224 0 0.352 0 11.232 0 21.664 5.952 27.424 15.552l66.464 110.816 310.24-124.064c3.808-1.536 7.808-2.272 11.872-2.272 5.44 0 10.816 1.376 15.68 4.128 8.448 4.736 14.24 13.056 15.872 22.624l160 960c2.080 12.576-3.488 25.184-14.176 32.128zM100.352 295.136l741.6 494.432-539.2-577.184c-2.848 1.696-5.376 3.936-8.512 5.184l-193.888 77.568zM326.048 189.888c-0.064 0.128-0.16 0.192-0.224 0.32l606.176 648.8-516.768-805.184-89.184 156.064zM806.944 12.512l-273.312 109.312c-6.496 2.56-13.248 3.424-19.936 3.808l420.864 652.416-127.616-765.536z" />
<glyph unicode="&#xe920;" glyph-name="banknote" d="M1005.28 621.248l-320 320c-15.872 15.872-38.88 22.24-60.672 16.864-11.488-2.816-21.76-8.736-29.888-16.864-7.264-7.264-12.736-16.256-15.872-26.304-14.496-47.008-39.552-87.872-76.64-124.928-49.536-49.504-114.048-87.008-182.304-126.656-72.448-41.984-147.296-85.504-208.64-146.816-52.128-52.192-87.616-110.24-108.416-177.632-7.008-22.752-0.896-47.36 15.872-64.192l320-320c15.872-15.872 38.88-22.24 60.672-16.864 11.488 2.88 21.76 8.736 29.888 16.864 7.264 7.264 12.736 16.256 15.872 26.368 14.528 47.008 39.584 87.872 76.704 124.928 49.504 49.504 113.984 86.944 182.304 126.56 72.384 42.048 147.264 85.568 208.576 146.88 52.128 52.128 87.616 110.24 108.448 177.632 6.976 22.72 0.832 47.424-15.904 64.16zM384 0c-105.984 105.984-214.016 214.048-320 320 90.944 294.432 485.12 281.568 576 576 105.984-105.952 214.048-214.016 320.064-320-90.976-294.368-485.152-281.568-576.064-576zM625.984 483.2c-10.432 8.736-20.928 14.688-31.488 17.632-10.496 2.944-20.992 4.128-31.616 3.36-10.496-0.8-21.248-3.2-32-7.328-10.752-4.192-21.568-8.736-32.448-14.016-17.184 19.744-34.368 39.264-51.552 57.376 7.744 7.008 15.264 10.56 22.496 10.816 7.264 0.32 14.24-0.448 20.864-2.112 6.752-1.696 12.928-3.136 18.624-4.256 5.76-1.12 10.752 0.128 15.136 3.808 4.64 4 7.2 9.184 7.552 15.424 0.32 6.304-2.048 12.448-7.328 18.432-6.752 7.744-14.88 12.448-24.64 14.176-9.632 1.696-19.488 1.568-29.76-0.672-10.112-2.304-19.744-6.112-28.864-11.488s-16.448-10.88-21.888-16.256c-2.080 1.984-4.16 3.936-6.24 5.888-2.304 2.112-5.184 3.264-8.64 3.2-3.488 0-6.368-1.504-8.736-4.256-2.304-2.688-3.36-5.824-2.944-9.12 0.32-3.424 1.696-6.048 4.064-8.064 2.080-1.76 4.16-3.488 6.24-5.312-8.192-9.888-14.944-20.8-20.256-32.32-5.376-11.552-8.576-23.008-9.76-34.112-1.248-11.2-0.064-21.44 3.36-30.944 3.424-9.568 9.76-17.696 19.008-25.376 15.072-12.512 32.8-17.824 53.376-16.64 20.512 1.248 42.624 7.36 66.4 20.128 18.88-21.824 37.824-43.488 56.736-63.616-8-6.752-15.008-10.624-21.184-11.872-6.176-1.312-11.68-1.184-16.672 0.32-4.992 1.568-9.632 3.808-13.888 6.688-4.256 2.944-8.448 5.44-12.64 7.488-4.128 2.048-8.384 3.2-12.736 3.264s-8.992-2.048-14.112-6.432c-5.248-4.576-7.872-9.888-7.872-15.872 0-5.952 2.752-12 8.128-18.112 5.44-6.112 12.512-11.264 21.056-15.328s18.208-6.624 28.832-7.328c10.624-0.736 21.824 0.864 33.632 5.248 11.872 4.32 23.616 12.128 35.2 23.744 5.568-5.44 11.2-10.624 16.8-15.616 2.368-2.048 5.248-3.072 8.736-2.816 3.36 0.128 6.304 1.696 8.64 4.512 2.368 2.88 3.36 6.048 3.008 9.376-0.32 3.36-1.696 5.952-4 7.808-5.632 4.512-11.264 9.248-16.864 14.24 9.568 11.744 17.248 24.128 22.944 36.384 5.696 12.32 9.056 24.192 10.176 35.2 1.12 11.072-0.192 21.056-3.808 30.112-3.584 9.184-9.952 17.056-19.072 24.64zM447.072 461.504c-9.056-0.384-16.96 2.624-23.872 9.312-2.944 2.816-4.992 6.24-6.24 10.304-1.312 4.064-1.76 8.512-1.248 13.376 0.448 4.8 1.888 9.824 4.384 14.88 2.368 5.056 5.888 10.112 10.368 15.008 16.224-16.128 32.416-33.824 48.64-52.128-12.288-6.752-22.976-10.368-32.032-10.752zM598.016 397.44c-2.88-5.312-6.176-10.048-10.048-14.176-17.952 18.112-35.872 38.016-53.76 58.432 4.576 2.048 9.376 4.192 14.56 6.368s10.368 3.616 15.552 4.512c5.312 0.8 10.56 0.576 15.808-0.672 5.184-1.312 10.112-4.128 14.688-8.576 4.512-4.512 7.36-9.184 8.512-14.24 1.248-5.12 1.312-10.304 0.448-15.616-0.928-5.344-2.816-10.656-5.76-16.032zM470.944 250.24c6.304 5.088 15.584 4.832 21.376-1.056 6.272-6.24 6.272-16.448 0-22.688-0.512-0.512-1.056-0.864-1.632-1.312l0.064-0.064c-20.256-15.392-36.896-29.248-54.848-47.2-16.224-16.192-30.88-33.248-43.552-50.56l-20.448-28c-0.64-1.152-1.408-2.208-2.368-3.2-6.272-6.24-16.48-6.24-22.72 0-5.44 5.44-6.112 13.824-2.112 20.064l-0.064 0.064 21.888 29.888c13.664 18.688 29.376 36.992 46.752 54.368 18.080 18.144 37.6 34.336 57.6 49.696h0.064zM588.096 713.12c16.192 16.192 30.816 33.184 43.52 50.592l21.248 29.12c0.768 1.376 1.632 2.752 2.816 3.936 6.304 6.304 16.512 6.304 22.816 0 5.984-6.016 6.24-15.52 0.8-21.888l0.064-0.064-21.888-30.016c-13.696-18.688-29.376-36.928-46.752-54.304-18.080-18.080-37.568-34.336-57.568-49.696l-0.128 0.064c-6.368-5.856-16.256-5.728-22.368 0.448-6.304 6.304-6.304 16.576 0 22.88 1.12 1.184 2.432 2.016 3.744 2.752 18.816 14.368 36.96 29.44 53.696 46.176z" />
<glyph unicode="&#xe986;" glyph-name="search" d="M992.262 88.604l-242.552 206.294c-25.074 22.566-51.89 32.926-73.552 31.926 57.256 67.068 91.842 154.078 91.842 249.176 0 212.078-171.922 384-384 384-212.076 0-384-171.922-384-384s171.922-384 384-384c95.098 0 182.108 34.586 249.176 91.844-1-21.662 9.36-48.478 31.926-73.552l206.294-242.552c35.322-39.246 93.022-42.554 128.22-7.356s31.892 92.898-7.354 128.22zM384 320c-141.384 0-256 114.616-256 256s114.616 256 256 256 256-114.616 256-256-114.614-256-256-256z" />
<glyph unicode="&#xe9d7;" glyph-name="star-empty" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538zM512 206.502l-223.462-117.48 42.676 248.83-180.786 176.222 249.84 36.304 111.732 226.396 111.736-226.396 249.836-36.304-180.788-176.222 42.678-248.83-223.462 117.48z" />
<glyph unicode="&#xe9d8;" glyph-name="star-half" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538zM512 206.502l-0.942-0.496 0.942 570.768 111.736-226.396 249.836-36.304-180.788-176.222 42.678-248.83-223.462 117.48z" />

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View file

@ -1,10 +1,10 @@
@font-face {
font-family: 'icomoon';
src: url('fonts/icomoon.eot?uh765c');
src: url('fonts/icomoon.eot?uh765c#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?uh765c') format('truetype'),
url('fonts/icomoon.woff?uh765c') format('woff'),
url('fonts/icomoon.svg?uh765c#icomoon') format('svg');
src: url('fonts/icomoon.eot?n5x55');
src: url('fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?n5x55') format('truetype'),
url('fonts/icomoon.woff?n5x55') format('woff'),
url('fonts/icomoon.svg?n5x55#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
@ -25,7 +25,16 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-sparkle:before {
.icon-graphic-heart:before {
content: "\e91e";
}
.icon-graphic-paperplane:before {
content: "\e91f";
}
.icon-graphic-banknote:before {
content: "\e920";
}
.icon-stars:before {
content: "\e91a";
}
.icon-warning:before {
@ -37,6 +46,9 @@
.icon-bookmark:before {
content: "\e91c";
}
.icon-rss:before {
content: "\e91d";
}
.icon-envelope:before {
content: "\e901";
}

View file

@ -46,13 +46,14 @@ function back(e) {
history.back();
}
function polling(el) {
let delay = 10000 + (Math.random() * 1000);
function polling(el, delay) {
delay = delay || 10000;
delay += (Math.random() * 1000);
setTimeout(function() {
fetch('/api/updates/' + el.getAttribute('data-poll'))
.then(response => response.json())
.then(data => updateCountElement(el, data));
polling(el);
polling(el, delay * 1.25);
}, delay, el);
}

View file

@ -147,7 +147,7 @@
{% endfor %}
</div>
{% if readthroughs.exists %}
{% if request.user.is_authenticated %}
<section class="block">
<header class="columns">
<h2 class="column title is-5 mb-1">Your reading activity</h2>
@ -155,6 +155,9 @@
{% include 'snippets/toggle/open_button.html' with text="Add read dates" icon="plus" class="is-small" controls_text="add-readthrough" %}
</div>
</header>
{% if not readthroughs.exists %}
<p>You don't have any reading activity for this book.</p>
{% endif %}
<section class="hidden box" id="add-readthrough">
<form name="add-readthrough" action="/create-readthrough" method="post">
{% include 'snippets/readthrough_form.html' with readthrough=None %}

View file

@ -0,0 +1,13 @@
<section class="card hidden {{ class }}" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}">
<header class="card-header has-background-white-ter">
<h2 class="card-header-title" tabindex="0" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}-header">
{% block header %}{% endblock %}
</h2>
<span class="card-header-icon">
{% include 'snippets/toggle/toggle_button.html' with label="Close" class="delete" nonbutton=True controls_text=controls_text %}
</span>
</header>
<section class="card-content content">
{% block form %}{% endblock %}
</section>
</section>

View file

@ -2,9 +2,31 @@
{% block content %}
{% if not request.user.is_authenticated %}
<div class="block">
<h1 class="title has-text-centered">{{ site.name }}: {{ site.instance_tagline }}</h1>
</div>
<header class="block has-text-centered">
<h1 class="title">{{ site.name }}</h1>
<h2 class="subtitle">{{ site.instance_tagline }}</h2>
</header>
<section class="level is-mobile">
<div class="level-item has-text-centered">
<div>
<p class="title has-text-weight-normal"><span class="icon icon-graphic-paperplane"></span></p>
<p class="heading">Decentralized</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="title has-text-weight-normal"><span class="icon icon-graphic-heart"></span></p>
<p class="heading">Friendly</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="title has-text-weight-normal"><span class="icon icon-graphic-banknote"></span></p>
<p class="heading">Anti-Corporate</p>
</div>
</div>
</section>
<section class="tile is-ancestor">
<div class="tile is-7 is-parent">
@ -26,6 +48,7 @@
</div>
</div>
</section>
{% else %}
<div class="block">
<h1 class="title has-text-centered">Discover</h1>

View file

@ -1,13 +1,22 @@
{% extends 'layout.html' %}
{% block content %}
{% extends 'user/user_layout.html' %}
{% block header %}
<div class="columns is-mobile">
<div class="column">
<h1 class="title">{{ year }} Reading Progress</h1>
</div>
{% if is_self and goal %}
<div class="column is-narrow">
{% include 'snippets/toggle/open_button.html' with text="Edit goal" icon="pencil" controls_text="show-edit-goal" focus="edit-form-header" %}
</div>
{% endif %}
</div>
{% endblock %}
{% block panel %}
<section class="block">
<h1 class="title">{{ year }} Reading Progress</h1>
{% if user == request.user %}
<div class="block">
{% if goal %}
{% include 'snippets/toggle/open_button.html' with text="Edit goal" controls_text="show-edit-goal" focus="edit-form-header" %}
{% endif %}
{% now 'Y' as year %}
<section class="card {% if goal %}hidden{% endif %}" id="show-edit-goal">
<header class="card-header">
@ -34,8 +43,8 @@
</section>
{% if goal.books %}
<section>
<h2 class="title">{% if goal.user == request.user %}Your{% else %}{{ goal.user.display_name }}'s{% endif %} {{ year }} Books</h2>
<section class="content">
<h2>{% if goal.user == request.user %}Your{% else %}{{ goal.user.display_name }}'s{% endif %} {{ year }} Books</h2>
<div class="columns is-multiline">
{% for book in goal.books %}
<div class="column is-narrow">

View file

@ -53,12 +53,15 @@
<div class="navbar-menu" id="main-nav">
<div class="navbar-start">
{% if request.user.is_authenticated %}
<a href="/user/{{ request.user.localname }}/shelves" class="navbar-item">
<a href="{% url 'user-shelves' request.user.localname %}" class="navbar-item">
Your shelves
</a>
<a href="/#feed" class="navbar-item">
Feed
</a>
<a href="{% url 'lists' %}" class="navbar-item">
Lists
</a>
{% endif %}
</div>

View file

@ -0,0 +1,11 @@
{% extends 'components/inline_form.html' %}
{% block header %}
Create List
{% endblock %}
{% block form %}
<form name="create-list" method="post" action="{% url 'lists' %}">
{% include 'lists/form.html' %}
</form>
{% endblock %}

View file

@ -0,0 +1,49 @@
{% extends 'lists/list_layout.html' %}
{% block panel %}
<section class="content block">
<h2>Pending Books</h2>
<p><a href="{% url 'list' list.id %}">Go to list</a></p>
{% if not pending.exists %}
<p>You're all set!</p>
{% else %}
<table class="table is-striped">
<tr>
<th></th>
<th>Book</th>
<th>Suggested by</th>
<th></th>
</tr>
{% for item in pending %}
<tr>
<td>
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=item.book size="small" %}</a>
</td>
<td>
{% include 'snippets/book_titleby.html' with book=item.book %}
</td>
<td>
{% include 'snippets/username.html' with user=item.added_by %}
</td>
<td>
<div class="field has-addons">
<form class="control" method="POST" action="{% url 'list-curate' list.id %}">
{% csrf_token %}
<input type="hidden" name="item" value="{{ item.id }}">
<input type="hidden" name="approved" value="true">
<button class="button">Approve</button>
</form>
<form class="control" method="POST" action="{% url 'list-curate' list.id %}">
{% csrf_token %}
<input type="hidden" name="item" value="{{ item.id }}">
<input type="hidden" name="approved" value="false">
<button class="button is-danger is-light">Discard</button>
</div>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
</section>
{% endblock %}

View file

@ -0,0 +1,11 @@
{% extends 'components/inline_form.html' %}
{% block header %}
Edit List
{% endblock %}
{% block form %}
<form name="edit-list" method="post" action="{% url 'list' list.id %}">
{% include 'lists/form.html' %}
</form>
{% endblock %}

View file

@ -0,0 +1,44 @@
{% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
<div class="columns">
<div class="column">
<div class="field">
<label class="label" for="id_name">Name:</label>
{{ list_form.name }}
</div>
<div class="field">
<label class="label" for="id_description">Description:</label>
{{ list_form.description }}
</div>
</div>
<div class="column">
<fieldset class="field">
<legend class="label">List curation:</legend>
<label class="field">
<input type="radio" name="curation" value="closed"{% if not list or list.curation == 'closed' %} checked{% endif %}> Closed
<p class="help mb-2">Only you can add and remove books to this list</p>
</label>
<label class="field">
<input type="radio" name="curation" value="curated"{% if list.curation == 'curated' %} checked{% endif %}> Curated
<p class="help mb-2">Anyone can suggest books, subject to your approval</p>
</label>
<label class="field">
<input type="radio" name="curation" value="open"{% if list.curation == 'open' %} checked{% endif %}> Open
<p class="help mb-2">Anyone can add books to this list</p>
</label>
</fieldset>
</div>
</div>
<div class="field has-addons">
<div class="control">
{% include 'snippets/privacy_select.html' with current=list.privacy %}
</div>
<div class="control">
<button type="submit" class="button is-primary">Save</button>
</div>
</div>

View file

@ -0,0 +1,92 @@
{% extends 'lists/list_layout.html' %}
{% load bookwyrm_tags %}
{% block panel %}
{% if request.user == list.user and pending_count %}
<div class="block content">
<p>
<a href="{% url 'list-curate' list.id %}">{{ pending_count }} book{{ pending_count | pluralize }} awaiting your approval</a>
</p>
</div>
{% endif %}
<div class="columns mt-3">
<section class="column is-three-quarters">
{% if not items.exists %}
<p>This list is currently empty</p>
{% else %}
<ol>
{% for item in items %}
<li class="block pb-3">
<div class="card">
<div class="card-content columns p-0 mb-0">
<div class="column is-narrow pt-0 pb-0">
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=item.book size="medium" %}</a>
</div>
<div class="column is-flex-direction-column is-align-items-self-start">
<span>{% include 'snippets/book_titleby.html' with book=item.book %}</span>
{% include 'snippets/stars.html' with rating=item.book|rating:request.user %}
{% include 'snippets/shelve_button.html' with book=item.book %}
</div>
</div>
<div class="card-footer has-background-white-bis">
<div class="card-footer-item">
<p>Added by {% include 'snippets/username.html' with user=item.added_by %}</p>
</div>
{% if list.user == request.user or list.curation == 'open' and item.added_by == request.user %}
<form name="add-book" method="post" action="{% url 'list-remove-book' list.id %}" class="card-footer-item">
{% csrf_token %}
<input type="hidden" name="item" value="{{ item.id }}">
<button type="submit" class="button is-small is-danger">Remove</button>
</form>
{% endif %}
</div>
</div>
</li>
{% endfor %}
</ol>
{% endif %}
</section>
{% if request.user.is_authenticated and not list.curation == 'closed' or request.user == list.user %}
<section class="column is-one-quarter content">
<h2>{% if list.curation == 'open' or request.user == list.user %}Add{% else %}Suggest{% endif %} Books</h2>
<form name="search" action="{% url 'list' list.id %}" method="GET" class="block">
<div class="field has-addons">
<div class="control">
<input aria-label="Search for a book" class="input" type="text" name="q" placeholder="Search for a book" value="{{ query }}">
</div>
<div class="control">
<button class="button" type="submit">
<span class="icon icon-search" title="Search">
<span class="is-sr-only">search</span>
</span>
</button>
</div>
</div>
{% if query %}
<p class="help"><a href="{% url 'list' list.id %}">Clear search</a></p>
{% endif %}
</form>
{% if not suggested_books %}
<p>No books found{% if query %} matching the query "{{ query }}"{% endif %}</p>
{% endif %}
{% for book in suggested_books %}
<div class="block columns">
<div class="column is-narrow">
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a>
</div>
<div class="column">
<p>{% include 'snippets/book_titleby.html' with book=book %}</p>
<form name="add-book" method="post" action="{% url 'list-add-book' list.id %}">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}">
<button type="submit" class="button is-small is-link">{% if list.curation == 'open' or request.user == list.user %}Add{% else %}Suggest{% endif %}</button>
</form>
</div>
</div>
{% endfor %}
</section>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,23 @@
{% load bookwyrm_tags %}
<div class="columns is-multiline">
{% for list in lists %}
<div class="column is-one-quarter">
<div class="card">
<header class="card-header">
<h4 class="card-header-title">
<a href="{{ list.local_path }}">{{ list.name }}</a> <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span>
</h4>
</header>
<div class="card-image is-flex">
{% for book in list.listitem_set.all|slice:5 %}
{% include 'snippets/book_cover.html' with book=book.book size="small" %}
{% endfor %}
</div>
<div class="card-content is-flex-grow-0">
{% if list.description %}{{ list.description | to_markdown | safe | truncatewords_html:20 }}{% endif %}
<p class="subtitle help">Created {% if list.curation != 'open' %} and curated{% endif %} by {% include 'snippets/username.html' with user=list.user %}</p>
</div>
</div>
</div>
{% endfor %}
</div>

View file

@ -0,0 +1,24 @@
{% extends 'layout.html' %}
{% load bookwyrm_tags %}
{% block content %}
<header class="columns content">
<div class="column">
<h1 class="title">{{ list.name }} <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span></h1>
<p class="subtitle help">Created {% if list.curation != 'open' %} and curated{% endif %} by {% include 'snippets/username.html' with user=list.user %}</p>
{% include 'snippets/trimmed_text.html' with full=list.description %}
</div>
{% if request.user == list.user %}
<div class="column is-narrow">
{% include 'snippets/toggle/open_button.html' with text="Edit list" icon="pencil" controls_text="edit-list" focus="edit-list-header" %}
</div>
{% endif %}
</header>
<div class="block">
{% include 'lists/edit_form.html' with controls_text="edit-list" %}
</div>
{% block panel %}{% endblock %}
{% endblock %}

View file

@ -0,0 +1,43 @@
{% extends 'layout.html' %}
{% block content %}
<header class="block">
<h1 class="title">Lists</h1>
</header>
{% if request.user.is_authenticated and not lists.has_previous %}
<header class="block columns">
<div class="column">
<h2 class="title">Your lists</h2>
</div>
<div class="column is-narrow">
{% include 'snippets/toggle/open_button.html' with controls_text="create-list" icon="plus" text="Create new list" focus="create-list-header" %}
</div>
</header>
<div class="block">
{% include 'lists/create_form.html' with controls_text="create-list" %}
</div>
<section class="block content">
{% if request.user.list_set.exists %}
{% include 'lists/list_items.html' with lists=request.user.list_set.all|slice:4 %}
{% endif %}
{% if request.user.list_set.count > 4 %}
<a href="{% url 'user-lists' request.user.localname %}">See all {{ request.user.list_set.count}} lists</a>
{% endif %}
</section>
{% endif %}
{% if lists %}
<section class="block content">
<h2 class="title">Recent Lists</h2>
{% include 'lists/list_items.html' with lists=lists %}
</section>
<div>
{% include 'snippets/pagination.html' with page=lists path=path %}
</div>
{% endif %}
{% endblock %}

View file

@ -7,7 +7,7 @@ Edit Profile
{% if form.non_field_errors %}
<p class="notification is-danger">{{ form.non_field_errors }}</p>
{% endif %}
<form name="edit-profile" action="/edit-profile/" method="post" enctype="multipart/form-data">
<form name="edit-profile" action="{% url 'prefs-profile' %}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="block">
<label class="label" for="id_avatar">Avatar:</label>

View file

@ -22,7 +22,8 @@
{% endif %}
</section>
{% if book_results|slice:":1" and local_results.results and request.user.is_authenticated %}
{% if request.user.is_authenticated %}
{% if book_results|slice:":1" and local_results.results %}
<div class="block">
<p>
Didn't find what you were looking for?
@ -61,19 +62,39 @@
{% include 'snippets/toggle/close_button.html' with text="Hide results from other catalogues" small=True controls_text="more-results" %}
{% endif %}
</div>
{% endif %}
</div>
<div class="column">
<h2 class="title">Matching Users</h2>
{% if not user_results %}
<p>No users found for "{{ query }}"</p>
{% endif %}
{% for result in user_results %}
<div class="block">
{% include 'snippets/avatar.html' with user=result %}</h2>
{% include 'snippets/username.html' with user=result show_full=True %}</h2>
{% include 'snippets/follow_button.html' with user=result %}
</div>
{% endfor %}
<section class="block">
<h2 class="title">Matching Users</h2>
{% if not user_results %}
<p>No users found for "{{ query }}"</p>
{% endif %}
<ul>
{% for result in user_results %}
<li class="block">
{% include 'snippets/avatar.html' with user=result %}</h2>
{% include 'snippets/username.html' with user=result show_full=True %}</h2>
{% include 'snippets/follow_button.html' with user=result %}
</li>
{% endfor %}
</ul>
</section>
<section class="block">
<h2 class="title">Lists</h2>
{% if not list_results %}
<p>No lists found for "{{ query }}"</p>
{% endif %}
{% for result in list_results %}
<div class="block">
<ul>
<li>
<a href="{% url 'list' result.id %}">{{ result.name }}</a>
</li>
</ul>
</div>
{% endfor %}
</section>
</div>
</div>
{% endwith %}

View file

@ -2,8 +2,8 @@
<div class="columns">
<div class="column is-narrow">
<div>
<a href="/book/{{ book.id }}">{% include 'snippets/book_cover.html' with book=book %}</a>
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book %}</a>
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div>
</div>
<div class="column">

View file

@ -1,18 +1,18 @@
{% if item.privacy == 'public' %}
<span class="icon icon-globe" title="Public post">
<span class="is-sr-only">Public post</span>
<span class="icon icon-globe" title="Public">
<span class="is-sr-only">Public</span>
</span>
{% elif item.privacy == 'unlisted' %}
<span class="icon icon-unlock" title="Unlisted post">
<span class="is-sr-only">Unlisted post</span>
<span class="icon icon-unlock" title="Unlisted">
<span class="is-sr-only">Unlisted</span>
</span>
{% elif item.privacy == 'followers' %}
<span class="icon icon-lock" title="Followers-only post">
<span class="is-sr-only">Followers-only post</span>
<span class="icon icon-lock" title="Followers-only">
<span class="is-sr-only">Followers-only</span>
</span>
{% else %}
<span class="icon icon-envelope" title="Private post">
<span class="is-sr-only">Private post</span>
<span class="icon icon-envelope" title="Private">
<span class="is-sr-only">Private</span>
</span>
{% endif %}

View file

@ -13,7 +13,7 @@
<label class="is-sr-only" for="rating-no-rating-{{ book.id }}">No rating</label>
<input class="is-sr-only" type="radio" name="rating" value="" id="rating-no-rating-{{ book.id }}" checked>
{% for i in '12345'|make_list %}
<input class="is-sr-only" id="rating-book{{book.id}}-star-{{ forloop.counter }}" type="radio" name="rating" value="{{ forloop.counter }}" {% if book|rating:user == forloop.counter %}checked{% endif %}>
<input class="is-sr-only" id="rating-book{{book.id}}-star-{{ forloop.counter }}" type="radio" name="rating" value="{{ forloop.counter }}" {% if book|user_rating:user == forloop.counter %}checked{% endif %}>
<label class="icon icon-star-empty" for="rating-book{{book.id}}-star-{{ forloop.counter }}">
<span class="is-sr-only">{{ forloop.counter }} star{{ forloop.counter | pluralize }}</span>
</label>

View file

@ -1,8 +1,7 @@
<div class="stars">
<p class="stars">
<span class="is-sr-only">{% if rating %}{{ rating|floatformat }} star{{ rating|floatformat | pluralize }}{% else %}No rating{% endif %}</span>
{% for i in '12345'|make_list %}
<span class="icon icon-star-{% if rating >= forloop.counter %}full{% elif rating|floatformat:0 >= forloop.counter|floatformat:0 %}half{% else %}empty{% endif %}">
<span class="icon icon-star-{% if rating >= forloop.counter %}full{% elif rating|floatformat:0 >= forloop.counter|floatformat:0 %}half{% else %}empty{% endif %}" aria-hidden="true">
</span>
{% endfor %}
</div>
</p>

View file

@ -20,7 +20,7 @@
{% if request.user.is_authenticated %}
<div class="field has-addons">
<div class="control">
{% include 'snippets/toggle/toggle_button.html' with controls_text="show-comment" controls_uid=status.id text="Reply" icon="comment" class="is-small" focus="id_content_reply" %}
{% include 'snippets/toggle/toggle_button.html' with controls_text="show-comment" controls_uid=status.id text="Reply" icon="comment" class="is-small toggle-button" focus="id_content_reply" %}
</div>
<div class="control">
{% include 'snippets/boost_button.html' with status=status %}

View file

@ -1,24 +1,26 @@
{% load bookwyrm_tags %}
{% with 0|uuid as uuid %}
{% if full %}
{% with full|to_markdown|safe as full %}
{% with full|to_markdown|safe|truncatewords_html:60 as trimmed %}
{% if trimmed != full %}
<div id="hide-full-{{ uuid }}">
<blockquote class="content" id="trimmed-{{ uuid }}"><span dir="auto">{{ trimmed }}</span>
<div class="content" id="trimmed-{{ uuid }}"><span dir="auto">{{ trimmed }}</span>
{% include 'snippets/toggle/open_button.html' with text="show more" controls_text="full" controls_uid=uuid class="is-small" %}
</blockquote>
</div>
</div>
<div id="full-{{ uuid }}" class="hidden">
<blockquote class="content"><span dir="auto">{{ full | to_markdown | safe }}</span>
<div class="content"><span dir="auto">{{ full }}</span>
{% include 'snippets/toggle/close_button.html' with text="show less" controls_text="full" controls_uid=uuid class="is-small" %}
</blockquote>
</div>
</div>
{% else %}
<blockquote class="content"><span dir="auto">{{ full | to_markdown | safe }}</span></blockquote>
<div class="content"><span dir="auto">{{ full }}</span></div>
{% endif %}
{% endwith %}
{% endwith %}
{% endif %}
{% endwith %}

View file

@ -0,0 +1,26 @@
{% extends 'components/inline_form.html' %}
{% block header %}
Create New Shelf
{% endblock %}
{% block form %}
<form name="create-shelf" action="{% url 'shelf-create' %}" method="post">
{% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
<div class="field">
<label class="label" for="id_name_create">Name:</label>
<input type="text" name="name" maxlength="100" class="input" required="true" id="id_name_create">
</div>
<div class="field has-addons">
<div class="control">
{% include 'snippets/privacy_select.html' %}
</div>
<div class="control">
<button class="button is-primary" type="submit">Create shelf</button>
</div>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,31 @@
{% extends 'components/inline_form.html' %}
{% block header %}
Edit Shelf
{% endblock %}
{% block form %}
<form name="edit-shelf" action="{{ shelf.local_path }}" method="post">
{% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
{% if shelf.editable %}
<div class="field">
<label class="label" for="id_name">Name:</label>
<input type="text" name="name" maxlength="100" class="input" required="true" value="{{ shelf.name }}" id="id_name">
</div>
{% else %}
<input type="hidden" name="name" required="true" value="{{ shelf.name }}">
{% endif %}
<div class="field has-addons">
<div class="control">
{% include 'snippets/privacy_select.html' with current=shelf.privacy %}
</div>
<div class="control">
<button class="button is-primary" type="submit">Update shelf</button>
</div>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,40 @@
{% extends 'user/user_layout.html' %}
{% block header %}
<div class="columns">
<div class="column">
<h1 class="title">
{% if is_self %}Your
{% else %}
{% include 'snippets/username.html' with user=user %}'s
{% endif %}
Lists
</h1>
</div>
{% if is_self %}
<div class="column is-narrow">
{% include 'snippets/toggle/open_button.html' with controls_text="create-list" icon="plus" text="Create new list" %}
</div>
{% endif %}
</div>
{% endblock %}
{% block panel %}
<section class="block content">
<form name="create-list" method="post" action="{% url 'lists' %}" class="box hidden" id="create-list">
<header class="columns">
<h3 class="title column">Create list</h3>
<div class="column is-narrow">
{% include 'snippets/toggle/toggle_button.html' with controls_text="create-list" label="close" class="delete" nonbutton=True %}
</div>
</header>
{% include 'lists/form.html' %}
</form>
{% include 'lists/list_items.html' with lists=lists %}
</section>
<div>
{% include 'snippets/pagination.html' with page=lists path=path %}
</div>
{% endblock %}

View file

@ -18,41 +18,24 @@
<div class="column">
<div class="tabs" role="tablist">
<ul>
{% for shelf_tab in shelves %}
<li class="{% if shelf_tab.identifier == shelf.identifier %}is-active{% endif %}">
{% for shelf_tab in shelves %}
<li class="{% if shelf_tab.identifier == shelf.identifier %}is-active{% endif %}">
<a href="/user/{{ user | username }}/shelf/{{ shelf_tab.identifier }}" role="tab" aria-selected="{% if shelf_tab.identifier == shelf.identifier %}true{% else %}false{% endif %}">{{ shelf_tab.name }}</a>
</li>
{% endfor %}
{% endfor %}
</ul>
</div>
</div>
{% if is_self %}
<div class="column is-narrow">
{% include 'snippets/toggle/open_button.html' with text="Create shelf" icon="plus" class="is-clickable" controls_text="create-shelf-form" %}
{% include 'snippets/toggle/open_button.html' with text="Create shelf" icon="plus" controls_text="create-shelf-form" focus="create-shelf-form-header" %}
</div>
{% endif %}
</div>
<div class="hidden box mb-5" id="create-shelf-form">
<h2 class="title is-4">Create new shelf</h2>
<form name="create-shelf" action="/create-shelf/" method="post">
{% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
<div class="field">
<label class="label" for="id_name_create">Name:</label>
<input type="text" name="name" maxlength="100" class="input" required="true" id="id_name_create">
</div>
<label class="label">
<p>Shelf privacy:</p>
{% include 'snippets/privacy_select.html' with no_label=True %}
</label>
<div class="field is-grouped">
<button class="button is-primary" type="submit">Create shelf</button>
{% include 'snippets/toggle/close_button.html' with text="Cancel" controls_text="create-shelf-form" %}
</div>
</form>
<div class="block">
{% include 'user/create_shelf_form.html' with controls_text='create-shelf-form' %}
</div>
<div class="block columns">
@ -66,34 +49,13 @@
</div>
{% if is_self %}
<div class="column is-narrow">
{% include 'snippets/toggle/open_button.html' with text="Edit shelf" icon="pencil" class="is-clickable" controls_text="edit-shelf-form" %}
{% include 'snippets/toggle/open_button.html' with text="Edit shelf" icon="pencil" controls_text="edit-shelf-form" focus="edit-shelf-form-header" %}
</div>
{% endif %}
</div>
<div class="hidden box mb-5" id="edit-shelf-form">
<h2 class="title is-4">Edit shelf</h2>
<form name="create-shelf" action="{{ shelf.local_path }}" method="post">
{% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
{% if shelf.editable %}
<div class="field">
<label class="label" for="id_name">Name:</label>
<input type="text" name="name" maxlength="100" class="input" required="true" value="{{ shelf.name }}" id="id_name">
</div>
{% else %}
<input type="hidden" name="name" required="true" value="{{ shelf.name }}">
{% endif %}
<label class="label">
<p>Shelf privacy:</p>
{% include 'snippets/privacy_select.html' with no_label=True current=shelf.privacy %}
</label>
<div class="field is-grouped">
<button class="button is-primary" type="submit">Update shelf</button>
{% include 'snippets/toggle/close_button.html' with text="Cancel" controls_text="edit-shelf-form" %}
</div>
</form>
<div class="block">
{% include 'user/edit_shelf_form.html' with controls_text="edit-shelf-form" %}
</div>
<div class="block">

View file

@ -67,6 +67,31 @@
</div>
{% endif %}
</div>
{% with user|username as username %}
{% if 'user/'|add:username|add:'/shelf' not in request.path and 'user/'|add:username|add:'/shelves' not in request.path %}
<nav class="tabs">
<ul>
{% url 'user-feed' user|username as url %}
<li{% if url == request.path or url == request.path|add:'/' %} class="is-active"{% endif %}>
<a href="{{ url }}">Activity</a>
</li>
{% now 'Y' as year %}
{% url 'user-goal' user|username year as url %}
<li{% if url in request.path %} class="is-active"{% endif %}>
<a href="{{ url }}">Reading Goal</a>
</li>
{% url 'user-lists' user|username as url %}
<li{% if url in request.path %} class="is-active"{% endif %}>
<a href="{{ url }}">Lists</a>
</li>
{% url 'user-shelves' user|username as url %}
<li{% if url in request.path %} class="is-active"{% endif %}>
<a href="{{ url }}">Shelves</a>
</li>
</ul>
</nav>
{% endif %}
{% endwith %}
{% block panel %}{% endblock %}

View file

@ -4,9 +4,10 @@ from datetime import datetime
from dateutil.relativedelta import relativedelta
from django import template
from django.db.models import Avg
from django.utils import timezone
from bookwyrm import models
from bookwyrm import models, views
from bookwyrm.views.status import to_markdown
@ -20,6 +21,17 @@ def dict_key(d, k):
@register.filter(name='rating')
def get_rating(book, user):
''' get the overall rating of a book '''
queryset = views.helpers.get_activity_feed(
user,
['public', 'followers', 'unlisted', 'direct'],
queryset=models.Review.objects.filter(book=book),
)
return queryset.aggregate(Avg('rating'))['rating__avg']
@register.filter(name='user_rating')
def get_user_rating(book, user):
''' get a user's rating of a book '''
rating = models.Review.objects.filter(
user=user,

View file

@ -190,3 +190,25 @@ class Openlibrary(TestCase):
''' detect if the loaded json is an edition '''
edition = pick_default_edition(self.edition_list_data['entries'])
self.assertEqual(edition['key'], '/books/OL9788823M')
@responses.activate
def test_create_edition_from_data(self):
''' okay but can it actually create an edition with proper metadata '''
work = models.Work.objects.create(title='Hello')
responses.add(
responses.GET,
'https://openlibrary.org/authors/OL382982A',
json={'hi': 'there'},
status=200)
result = self.connector.create_edition_from_data(
work, self.edition_data)
self.assertEqual(result.parent_work, work)
self.assertEqual(result.title, 'Sabriel')
self.assertEqual(result.isbn_10, '0060273224')
self.assertIsNotNone(result.description)
self.assertEqual(result.languages[0], 'English')
self.assertEqual(result.publishers[0], 'Harper Trophy')
self.assertEqual(result.pages, 491)
self.assertEqual(result.subjects[0], 'Fantasy.')
self.assertEqual(result.physical_format, 'Hardcover')

View file

@ -9,6 +9,7 @@
"518848"
]
},
"physical_format": "Hardcover",
"lc_classifications": [
"PZ7.N647 Sab 1995"
],

View file

@ -0,0 +1,54 @@
''' testing models '''
from django.test import TestCase
from bookwyrm import models, settings
class List(TestCase):
''' some activitypub oddness ahead '''
def setUp(self):
''' look, a list '''
self.user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword',
local=True, localname='mouse')
self.list = models.List.objects.create(
name='Test List', user=self.user)
def test_remote_id(self):
''' shelves use custom remote ids '''
expected_id = 'https://%s/list/%d' % \
(settings.DOMAIN, self.list.id)
self.assertEqual(self.list.get_remote_id(), expected_id)
def test_to_activity(self):
''' jsonify it '''
activity_json = self.list.to_activity()
self.assertIsInstance(activity_json, dict)
self.assertEqual(activity_json['id'], self.list.remote_id)
self.assertEqual(activity_json['totalItems'], 0)
self.assertEqual(activity_json['type'], 'BookList')
self.assertEqual(activity_json['name'], 'Test List')
self.assertEqual(activity_json['owner'], self.user.remote_id)
def test_list_item(self):
''' a list entry '''
work = models.Work.objects.create(title='hello')
book = models.Edition.objects.create(title='hi', parent_work=work)
item = models.ListItem.objects.create(
book_list=self.list,
book=book,
added_by=self.user,
)
self.assertTrue(item.approved)
add_activity = item.to_add_activity(self.user)
self.assertEqual(add_activity['actor'], self.user.remote_id)
self.assertEqual(add_activity['object']['id'], book.remote_id)
self.assertEqual(add_activity['target'], self.list.remote_id)
remove_activity = item.to_remove_activity(self.user)
self.assertEqual(remove_activity['actor'], self.user.remote_id)
self.assertEqual(remove_activity['object']['id'], book.remote_id)
self.assertEqual(remove_activity['target'], self.list.remote_id)

View file

@ -26,6 +26,6 @@ class Shelf(TestCase):
self.assertIsInstance(activity_json, dict)
self.assertEqual(activity_json['id'], self.shelf.remote_id)
self.assertEqual(activity_json['totalItems'], 0)
self.assertEqual(activity_json['type'], 'OrderedCollection')
self.assertEqual(activity_json['type'], 'Shelf')
self.assertEqual(activity_json['name'], 'Test Shelf')
self.assertEqual(activity_json['owner'], self.user.remote_id)

View file

@ -10,7 +10,7 @@ class User(TestCase):
def setUp(self):
self.user = models.User.objects.create_user(
'mouse@%s' % DOMAIN, 'mouse@mouse.mouse', 'mouseword',
local=True, localname='mouse')
local=True, localname='mouse', name='hi')
def test_computed_fields(self):
''' username instead of id here '''

View file

@ -248,7 +248,70 @@ class Incoming(TestCase):
self.assertEqual(follows.count(), 0)
def test_handle_create(self):
def test_handle_create_list(self):
''' a new list '''
activity = {
'object': {
"id": "https://example.com/list/22",
"type": "BookList",
"totalItems": 1,
"first": "https://example.com/list/22?page=1",
"last": "https://example.com/list/22?page=1",
"name": "Test List",
"owner": "https://example.com/user/mouse",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://example.com/user/mouse/followers"
],
"summary": "summary text",
"curation": "curated",
"@context": "https://www.w3.org/ns/activitystreams"
}
}
incoming.handle_create_list(activity)
book_list = models.List.objects.get()
self.assertEqual(book_list.name, 'Test List')
self.assertEqual(book_list.curation, 'curated')
self.assertEqual(book_list.description, 'summary text')
self.assertEqual(book_list.remote_id, 'https://example.com/list/22')
def test_handle_update_list(self):
''' a new list '''
book_list = models.List.objects.create(
name='hi', remote_id='https://example.com/list/22',
user=self.local_user)
activity = {
'object': {
"id": "https://example.com/list/22",
"type": "BookList",
"totalItems": 1,
"first": "https://example.com/list/22?page=1",
"last": "https://example.com/list/22?page=1",
"name": "Test List",
"owner": "https://example.com/user/mouse",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://example.com/user/mouse/followers"
],
"summary": "summary text",
"curation": "curated",
"@context": "https://www.w3.org/ns/activitystreams"
}
}
incoming.handle_create_list(activity)
book_list.refresh_from_db()
self.assertEqual(book_list.name, 'Test List')
self.assertEqual(book_list.curation, 'curated')
self.assertEqual(book_list.description, 'summary text')
self.assertEqual(book_list.remote_id, 'https://example.com/list/22')
def test_handle_create_status(self):
''' the "it justs works" mode '''
self.assertEqual(models.Status.objects.count(), 1)
@ -259,7 +322,7 @@ class Incoming(TestCase):
title='Test Book', remote_id='https://example.com/book/1')
activity = {'object': status_data, 'type': 'Create'}
incoming.handle_create(activity)
incoming.handle_create_status(activity)
status = models.Quotation.objects.get()
self.assertEqual(
@ -270,16 +333,16 @@ class Incoming(TestCase):
self.assertEqual(models.Status.objects.count(), 2)
# while we're here, lets ensure we avoid dupes
incoming.handle_create(activity)
incoming.handle_create_status(activity)
self.assertEqual(models.Status.objects.count(), 2)
def test_handle_create_unknown_type(self):
def test_handle_create_status_unknown_type(self):
''' folks send you all kinds of things '''
activity = {'object': {'id': 'hi'}, 'type': 'Fish'}
result = incoming.handle_create(activity)
result = incoming.handle_create_status(activity)
self.assertIsNone(result)
def test_handle_create_remote_note_with_mention(self):
def test_handle_create_status_remote_note_with_mention(self):
''' should only create it under the right circumstances '''
self.assertEqual(models.Status.objects.count(), 1)
self.assertFalse(
@ -290,7 +353,7 @@ class Incoming(TestCase):
status_data = json.loads(datafile.read_bytes())
activity = {'object': status_data, 'type': 'Create'}
incoming.handle_create(activity)
incoming.handle_create_status(activity)
status = models.Status.objects.last()
self.assertEqual(status.content, 'test content in note')
self.assertEqual(status.mention_users.first(), self.local_user)
@ -299,7 +362,7 @@ class Incoming(TestCase):
self.assertEqual(
models.Notification.objects.get().notification_type, 'MENTION')
def test_handle_create_remote_note_with_reply(self):
def test_handle_create_status_remote_note_with_reply(self):
''' should only create it under the right circumstances '''
self.assertEqual(models.Status.objects.count(), 1)
self.assertFalse(
@ -312,7 +375,7 @@ class Incoming(TestCase):
status_data['inReplyTo'] = self.status.remote_id
activity = {'object': status_data, 'type': 'Create'}
incoming.handle_create(activity)
incoming.handle_create_status(activity)
status = models.Status.objects.last()
self.assertEqual(status.content, 'test content in note')
self.assertEqual(status.reply_parent, self.status)
@ -566,20 +629,3 @@ class Incoming(TestCase):
self.assertFalse(models.UserFollows.objects.exists())
self.assertFalse(models.UserFollowRequest.objects.exists())
def test_handle_unblock(self):
''' undoing a block '''
activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://friend.camp/users/tripofmice#blocks/1155/undo",
"type": "Undo",
"actor": "https://friend.camp/users/tripofmice",
"object": {
"id": "https://friend.camp/0a7d85f7-6359-4c03-8ab6-74e61a8fb678",
"type": "Block",
"actor": "https://friend.camp/users/tripofmice",
"object": "https://1b1a78582461.ngrok.io/user/mouse"
}
}
self.remote_user.blocks.add(self.local_user)

View file

@ -33,18 +33,18 @@ class TemplateTags(TestCase):
bookwyrm_tags.dict_key(test_dict, 'c'), 0)
def test_get_rating(self):
def test_get_user_rating(self):
''' get a user's most recent rating of a book '''
models.Review.objects.create(
user=self.user, book=self.book, rating=3)
self.assertEqual(
bookwyrm_tags.get_rating(self.book, self.user), 3)
bookwyrm_tags.get_user_rating(self.book, self.user), 3)
def test_get_rating_doesnt_exist(self):
def test_get_user_rating_doesnt_exist(self):
''' there is no rating available '''
self.assertEqual(
bookwyrm_tags.get_rating(self.book, self.user), 0)
bookwyrm_tags.get_user_rating(self.book, self.user), 0)
def test_get_user_identifer_local(self):

View file

@ -33,7 +33,7 @@ class AuthenticationViews(TestCase):
result = login(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'login.html')
result.render()
self.assertEqual(result.status_code, 200)
request.user = self.local_user
@ -94,7 +94,7 @@ class AuthenticationViews(TestCase):
})
response = view(request)
self.assertEqual(models.User.objects.count(), 1)
self.assertEqual(response.template_name, 'login.html')
response.render()
def test_register_invalid_username(self):
''' gotta have an email '''
@ -109,7 +109,7 @@ class AuthenticationViews(TestCase):
})
response = view(request)
self.assertEqual(models.User.objects.count(), 1)
self.assertEqual(response.template_name, 'login.html')
response.render()
request = self.factory.post(
'register/',
@ -120,7 +120,7 @@ class AuthenticationViews(TestCase):
})
response = view(request)
self.assertEqual(models.User.objects.count(), 1)
self.assertEqual(response.template_name, 'login.html')
response.render()
request = self.factory.post(
'register/',
@ -131,7 +131,7 @@ class AuthenticationViews(TestCase):
})
response = view(request)
self.assertEqual(models.User.objects.count(), 1)
self.assertEqual(response.template_name, 'login.html')
response.render()
def test_register_closed_instance(self):

View file

@ -34,6 +34,7 @@ class AuthorViews(TestCase):
remote_id='https://example.com/book/1',
parent_work=self.work
)
models.SiteSettings.objects.create()
def test_author_page(self):
@ -45,7 +46,8 @@ class AuthorViews(TestCase):
is_api.return_value = False
result = view(request, author.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'author.html')
result.render()
self.assertEqual(result.status_code, 200)
self.assertEqual(result.status_code, 200)
request = self.factory.get('')
@ -66,7 +68,8 @@ class AuthorViews(TestCase):
result = view(request, author.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'edit_author.html')
result.render()
self.assertEqual(result.status_code, 200)
self.assertEqual(result.status_code, 200)
@ -116,4 +119,5 @@ class AuthorViews(TestCase):
resp = view(request, author.id)
author.refresh_from_db()
self.assertEqual(author.name, 'Test Author')
self.assertEqual(resp.template_name, 'edit_author.html')
resp.render()
self.assertEqual(resp.status_code, 200)

View file

@ -23,6 +23,7 @@ class BlockViews(TestCase):
inbox='https://example.com/users/rat/inbox',
outbox='https://example.com/users/rat/outbox',
)
models.SiteSettings.objects.create()
def test_block_get(self):
@ -32,7 +33,7 @@ class BlockViews(TestCase):
request.user = self.local_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'preferences/blocks.html')
result.render()
self.assertEqual(result.status_code, 200)
def test_block_post(self):

View file

@ -33,6 +33,7 @@ class BookViews(TestCase):
remote_id='https://example.com/book/1',
parent_work=self.work
)
models.SiteSettings.objects.create()
def test_book_page(self):
@ -44,7 +45,7 @@ class BookViews(TestCase):
is_api.return_value = False
result = view(request, self.book.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'book.html')
result.render()
self.assertEqual(result.status_code, 200)
request = self.factory.get('')
@ -63,7 +64,7 @@ class BookViews(TestCase):
request.user.is_superuser = True
result = view(request, self.book.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'edit_book.html')
result.render()
self.assertEqual(result.status_code, 200)
@ -116,7 +117,7 @@ class BookViews(TestCase):
is_api.return_value = False
result = view(request, self.work.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'editions.html')
result.render()
self.assertEqual(result.status_code, 200)
request = self.factory.get('')

View file

@ -15,6 +15,7 @@ class FederationViews(TestCase):
self.local_user = models.User.objects.create_user(
'mouse@local.com', 'mouse@mouse.mouse', 'password',
local=True, localname='mouse')
models.SiteSettings.objects.create()
def test_federation_page(self):
@ -25,5 +26,5 @@ class FederationViews(TestCase):
request.user.is_superuser = True
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'settings/federation.html')
result.render()
self.assertEqual(result.status_code, 200)

View file

@ -21,6 +21,7 @@ class FeedMessageViews(TestCase):
title='Example Edition',
remote_id='https://example.com/book/1',
)
models.SiteSettings.objects.create()
def test_feed(self):
@ -30,7 +31,7 @@ class FeedMessageViews(TestCase):
request.user = self.local_user
result = view(request, 'local')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'feed/feed.html')
result.render()
self.assertEqual(result.status_code, 200)
@ -45,7 +46,7 @@ class FeedMessageViews(TestCase):
is_api.return_value = False
result = view(request, 'mouse', status.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'feed/status.html')
result.render()
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.feed.is_api_request') as is_api:
@ -66,7 +67,7 @@ class FeedMessageViews(TestCase):
is_api.return_value = False
result = view(request, 'mouse', status.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'feed/status.html')
result.render()
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.feed.is_api_request') as is_api:
@ -83,7 +84,7 @@ class FeedMessageViews(TestCase):
request.user = self.local_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'feed/direct_messages.html')
result.render()
self.assertEqual(result.status_code, 200)

View file

@ -16,6 +16,7 @@ class ImportViews(TestCase):
self.local_user = models.User.objects.create_user(
'mouse@local.com', 'mouse@mouse.mouse', 'password',
local=True, localname='mouse')
models.SiteSettings.objects.create()
def test_import_page(self):
@ -25,7 +26,7 @@ class ImportViews(TestCase):
request.user = self.local_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'import.html')
result.render()
self.assertEqual(result.status_code, 200)
@ -39,5 +40,5 @@ class ImportViews(TestCase):
async_result.return_value = []
result = view(request, import_job.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'import_status.html')
result.render()
self.assertEqual(result.status_code, 200)

View file

@ -18,6 +18,7 @@ class InviteViews(TestCase):
self.local_user = models.User.objects.create_user(
'mouse@local.com', 'mouse@mouse.mouse', 'password',
local=True, localname='mouse')
models.SiteSettings.objects.create()
def test_invite_page(self):
@ -32,7 +33,7 @@ class InviteViews(TestCase):
invite.return_value = True
result = view(request, 'hi')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'invite.html')
result.render()
self.assertEqual(result.status_code, 200)
@ -44,5 +45,5 @@ class InviteViews(TestCase):
request.user.is_superuser = True
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'settings/manage_invites.html')
result.render()
self.assertEqual(result.status_code, 200)

View file

@ -18,6 +18,7 @@ class LandingViews(TestCase):
local=True, localname='mouse')
self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False
models.SiteSettings.objects.create()
def test_home_page(self):
@ -27,13 +28,13 @@ class LandingViews(TestCase):
request.user = self.local_user
result = view(request)
self.assertEqual(result.status_code, 200)
self.assertEqual(result.template_name, 'feed/feed.html')
result.render()
request.user = self.anonymous_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.status_code, 200)
self.assertEqual(result.template_name, 'discover.html')
result.render()
def test_about_page(self):
@ -43,7 +44,7 @@ class LandingViews(TestCase):
request.user = self.local_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'about.html')
result.render()
self.assertEqual(result.status_code, 200)
@ -53,5 +54,3 @@ class LandingViews(TestCase):
request = self.factory.get('')
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'discover.html')
self.assertEqual(result.status_code, 200)

View file

@ -0,0 +1,299 @@
''' test for app action functionality '''
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
from bookwyrm.activitypub import ActivitypubResponse
@patch('bookwyrm.broadcast.broadcast_task.delay')
class ListViews(TestCase):
''' tag views'''
def setUp(self):
''' we need basic test data and mocks '''
self.factory = RequestFactory()
self.local_user = models.User.objects.create_user(
'mouse@local.com', 'mouse@mouse.com', 'mouseword',
local=True, localname='mouse',
remote_id='https://example.com/users/mouse',
)
self.rat = models.User.objects.create_user(
'rat@local.com', 'rat@rat.com', 'ratword',
local=True, localname='rat',
remote_id='https://example.com/users/rat',
)
work = models.Work.objects.create(title='Work')
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
parent_work=work,
)
self.list = models.List.objects.create(
name='Test List', user=self.local_user)
self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False
models.SiteSettings.objects.create()
def test_lists_page(self, _):
''' there are so many views, this just makes sure it LOADS '''
view = views.Lists.as_view()
models.List.objects.create(name='Public list', user=self.local_user)
models.List.objects.create(
name='Private list', privacy='private', user=self.local_user)
request = self.factory.get('')
request.user = self.local_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
request.user = self.anonymous_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
def test_lists_create(self, _):
''' create list view '''
view = views.Lists.as_view()
request = self.factory.post('', {
'name': 'A list',
'description': 'wow',
'privacy': 'unlisted',
'curation': 'open',
'user': self.local_user.id,
})
request.user = self.local_user
result = view(request)
self.assertEqual(result.status_code, 302)
new_list = models.List.objects.filter(name='A list').get()
self.assertEqual(new_list.description, 'wow')
self.assertEqual(new_list.privacy, 'unlisted')
self.assertEqual(new_list.curation, 'open')
def test_list_page(self, _):
''' there are so many views, this just makes sure it LOADS '''
view = views.List.as_view()
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.list.is_api_request') as is_api:
is_api.return_value = False
result = view(request, self.list.id)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
request.user = self.anonymous_user
with patch('bookwyrm.views.list.is_api_request') as is_api:
is_api.return_value = False
result = view(request, self.list.id)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.list.is_api_request') as is_api:
is_api.return_value = True
result = view(request, self.list.id)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
request = self.factory.get('/?page=1')
request.user = self.local_user
with patch('bookwyrm.views.list.is_api_request') as is_api:
is_api.return_value = True
result = view(request, self.list.id)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_list_edit(self, _):
''' edit a list '''
view = views.List.as_view()
request = self.factory.post('', {
'name': 'New Name',
'description': 'wow',
'privacy': 'direct',
'curation': 'curated',
'user': self.local_user.id,
})
request.user = self.local_user
result = view(request, self.list.id)
self.assertEqual(result.status_code, 302)
self.list.refresh_from_db()
self.assertEqual(self.list.name, 'New Name')
self.assertEqual(self.list.description, 'wow')
self.assertEqual(self.list.privacy, 'direct')
self.assertEqual(self.list.curation, 'curated')
def test_curate_page(self, _):
''' there are so many views, this just makes sure it LOADS '''
view = views.Curate.as_view()
models.List.objects.create(name='Public list', user=self.local_user)
models.List.objects.create(
name='Private list', privacy='private', user=self.local_user)
request = self.factory.get('')
request.user = self.local_user
result = view(request, self.list.id)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
request.user = self.anonymous_user
result = view(request, self.list.id)
self.assertEqual(result.status_code, 302)
def test_curate_approve(self, _):
''' approve a pending item '''
view = views.Curate.as_view()
pending = models.ListItem.objects.create(
book_list=self.list,
added_by=self.local_user,
book=self.book,
approved=False
)
request = self.factory.post('', {
'item': pending.id,
'approved': 'true',
})
request.user = self.local_user
view(request, self.list.id)
pending.refresh_from_db()
self.assertEqual(self.list.books.count(), 1)
self.assertEqual(self.list.listitem_set.first(), pending)
self.assertTrue(pending.approved)
def test_curate_reject(self, _):
''' approve a pending item '''
view = views.Curate.as_view()
pending = models.ListItem.objects.create(
book_list=self.list,
added_by=self.local_user,
book=self.book,
approved=False
)
request = self.factory.post('', {
'item': pending.id,
'approved': 'false',
})
request.user = self.local_user
view(request, self.list.id)
self.assertFalse(self.list.books.exists())
self.assertFalse(models.ListItem.objects.exists())
def test_add_book(self, _):
''' put a book on a list '''
request = self.factory.post('', {
'book': self.book.id,
})
request.user = self.local_user
views.list.add_book(request, self.list.id)
item = self.list.listitem_set.get()
self.assertEqual(item.book, self.book)
self.assertEqual(item.added_by, self.local_user)
self.assertTrue(item.approved)
def test_add_book_outsider(self, _):
''' put a book on a list '''
self.list.curation = 'open'
self.list.save()
request = self.factory.post('', {
'book': self.book.id,
})
request.user = self.rat
views.list.add_book(request, self.list.id)
item = self.list.listitem_set.get()
self.assertEqual(item.book, self.book)
self.assertEqual(item.added_by, self.rat)
self.assertTrue(item.approved)
def test_add_book_pending(self, _):
''' put a book on a list '''
self.list.curation = 'curated'
self.list.save()
request = self.factory.post('', {
'book': self.book.id,
})
request.user = self.rat
views.list.add_book(request, self.list.id)
item = self.list.listitem_set.get()
self.assertEqual(item.book, self.book)
self.assertEqual(item.added_by, self.rat)
self.assertFalse(item.approved)
def test_add_book_self_curated(self, _):
''' put a book on a list '''
self.list.curation = 'curated'
self.list.save()
request = self.factory.post('', {
'book': self.book.id,
})
request.user = self.local_user
views.list.add_book(request, self.list.id)
item = self.list.listitem_set.get()
self.assertEqual(item.book, self.book)
self.assertEqual(item.added_by, self.local_user)
self.assertTrue(item.approved)
def test_remove_book(self, _):
''' take an item off a list '''
item = models.ListItem.objects.create(
book_list=self.list,
added_by=self.local_user,
book=self.book,
)
self.assertTrue(self.list.listitem_set.exists())
request = self.factory.post('', {
'item': item.id,
})
request.user = self.local_user
views.list.remove_book(request, self.list.id)
self.assertFalse(self.list.listitem_set.exists())
def test_remove_book_unauthorized(self, _):
''' take an item off a list '''
item = models.ListItem.objects.create(
book_list=self.list,
added_by=self.local_user,
book=self.book,
)
self.assertTrue(self.list.listitem_set.exists())
request = self.factory.post('', {
'item': item.id,
})
request.user = self.rat
views.list.remove_book(request, self.list.id)
self.assertTrue(self.list.listitem_set.exists())

View file

@ -15,6 +15,7 @@ class NotificationViews(TestCase):
self.local_user = models.User.objects.create_user(
'mouse@local.com', 'mouse@mouse.mouse', 'password',
local=True, localname='mouse')
models.SiteSettings.objects.create()
def test_notifications_page(self):
''' there are so many views, this just makes sure it LOADS '''
@ -23,7 +24,7 @@ class NotificationViews(TestCase):
request.user = self.local_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'notifications.html')
result.render()
self.assertEqual(result.status_code, 200)
def test_clear_notifications(self):

View file

@ -19,6 +19,7 @@ class PasswordViews(TestCase):
local=True, localname='mouse')
self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False
models.SiteSettings.objects.create(id=1)
def test_password_reset_request(self):
@ -29,7 +30,7 @@ class PasswordViews(TestCase):
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'password_reset_request.html')
result.render()
self.assertEqual(result.status_code, 200)
@ -43,7 +44,7 @@ class PasswordViews(TestCase):
request = self.factory.post('', {'email': 'mouse@mouse.com'})
with patch('bookwyrm.emailing.send_email.delay'):
resp = view(request)
self.assertEqual(resp.template_name, 'password_reset_request.html')
resp.render()
self.assertEqual(
models.PasswordReset.objects.get().user, self.local_user)
@ -56,7 +57,7 @@ class PasswordViews(TestCase):
request.user = self.anonymous_user
result = view(request, code.code)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'password_reset.html')
result.render()
self.assertEqual(result.status_code, 200)
@ -82,7 +83,7 @@ class PasswordViews(TestCase):
'confirm-password': 'hi'
})
resp = view(request, 'jhgdkfjgdf')
self.assertEqual(resp.template_name, 'password_reset.html')
resp.render()
self.assertTrue(models.PasswordReset.objects.exists())
def test_password_reset_mismatch(self):
@ -94,7 +95,7 @@ class PasswordViews(TestCase):
'confirm-password': 'hihi'
})
resp = view(request, code.code)
self.assertEqual(resp.template_name, 'password_reset.html')
resp.render()
self.assertTrue(models.PasswordReset.objects.exists())
@ -106,7 +107,7 @@ class PasswordViews(TestCase):
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'preferences/change_password.html')
result.render()
self.assertEqual(result.status_code, 200)

View file

@ -33,6 +33,7 @@ class ShelfViews(TestCase):
connector_file='self_connector',
local=True
)
models.SiteSettings.objects.create()
def test_search_json_response(self):
@ -82,6 +83,7 @@ class ShelfViews(TestCase):
)
request = self.factory.get('', {'q': 'Test Book'})
request.user = self.local_user
with patch('bookwyrm.views.search.is_api_request') as is_api:
is_api.return_value = False
with patch(
@ -89,7 +91,7 @@ class ShelfViews(TestCase):
manager.return_value = [search_result]
response = view(request)
self.assertIsInstance(response, TemplateResponse)
self.assertEqual(response.template_name, 'search_results.html')
response.render()
self.assertEqual(
response.context_data['book_results'][0].title, 'Gideon the Ninth')
@ -98,11 +100,12 @@ class ShelfViews(TestCase):
''' searches remote connectors '''
view = views.Search.as_view()
request = self.factory.get('', {'q': 'mouse'})
request.user = self.local_user
with patch('bookwyrm.views.search.is_api_request') as is_api:
is_api.return_value = False
with patch('bookwyrm.connectors.connector_manager.search'):
response = view(request)
self.assertIsInstance(response, TemplateResponse)
self.assertEqual(response.template_name, 'search_results.html')
response.render()
self.assertEqual(
response.context_data['user_results'][0], self.local_user)

View file

@ -8,6 +8,7 @@ from bookwyrm import models, views
from bookwyrm.activitypub import ActivitypubResponse
@patch('bookwyrm.broadcast.broadcast_task.delay')
class ShelfViews(TestCase):
''' tag views'''
def setUp(self):
@ -29,9 +30,10 @@ class ShelfViews(TestCase):
identifier='test-shelf',
user=self.local_user
)
models.SiteSettings.objects.create()
def test_shelf_page(self):
def test_shelf_page(self, _):
''' there are so many views, this just makes sure it LOADS '''
view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.first()
@ -41,7 +43,7 @@ class ShelfViews(TestCase):
is_api.return_value = False
result = view(request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'user/shelf.html')
result.render()
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.shelf.is_api_request') as is_api:
@ -61,7 +63,7 @@ class ShelfViews(TestCase):
self.assertEqual(result.status_code, 200)
def test_edit_shelf_privacy(self):
def test_edit_shelf_privacy(self, _):
''' set name or privacy on shelf '''
view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.get(identifier='to-read')
@ -80,7 +82,7 @@ class ShelfViews(TestCase):
self.assertEqual(shelf.privacy, 'unlisted')
def test_edit_shelf_name(self):
def test_edit_shelf_name(self, _):
''' change the name of an editable shelf '''
view = views.Shelf.as_view()
shelf = models.Shelf.objects.create(
@ -101,7 +103,7 @@ class ShelfViews(TestCase):
self.assertEqual(shelf.identifier, 'testshelf-%d' % shelf.id)
def test_edit_shelf_name_not_editable(self):
def test_edit_shelf_name_not_editable(self, _):
''' can't change the name of an non-editable shelf '''
view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.get(identifier='to-read')
@ -119,20 +121,19 @@ class ShelfViews(TestCase):
self.assertEqual(shelf.name, 'To Read')
def test_handle_shelve(self):
def test_handle_shelve(self, _):
''' shelve a book '''
request = self.factory.post('', {
'book': self.book.id,
'shelf': self.shelf.identifier
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.shelve(request)
views.shelve(request)
# make sure the book is on the shelf
self.assertEqual(self.shelf.books.get(), self.book)
def test_handle_shelve_to_read(self):
def test_handle_shelve_to_read(self, _):
''' special behavior for the to-read shelf '''
shelf = models.Shelf.objects.get(identifier='to-read')
request = self.factory.post('', {
@ -141,13 +142,12 @@ class ShelfViews(TestCase):
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.shelve(request)
views.shelve(request)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
def test_handle_shelve_reading(self):
def test_handle_shelve_reading(self, _):
''' special behavior for the reading shelf '''
shelf = models.Shelf.objects.get(identifier='reading')
request = self.factory.post('', {
@ -156,13 +156,12 @@ class ShelfViews(TestCase):
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.shelve(request)
views.shelve(request)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
def test_handle_shelve_read(self):
def test_handle_shelve_read(self, _):
''' special behavior for the read shelf '''
shelf = models.Shelf.objects.get(identifier='read')
request = self.factory.post('', {
@ -177,7 +176,7 @@ class ShelfViews(TestCase):
self.assertEqual(shelf.books.get(), self.book)
def test_handle_unshelve(self):
def test_handle_unshelve(self, _):
''' remove a book from a shelf '''
self.shelf.books.add(self.book)
self.shelf.save()

View file

@ -33,6 +33,7 @@ class TagViews(TestCase):
remote_id='https://example.com/book/1',
parent_work=self.work
)
models.SiteSettings.objects.create()
def test_tag_page(self):
@ -46,7 +47,7 @@ class TagViews(TestCase):
is_api.return_value = False
result = view(request, tag.identifier)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'tag.html')
result.render()
self.assertEqual(result.status_code, 200)
request = self.factory.get('')

View file

@ -23,6 +23,7 @@ class UserViews(TestCase):
self.rat = models.User.objects.create_user(
'rat@local.com', 'rat@rat.rat', 'password',
local=True, localname='rat')
models.SiteSettings.objects.create()
def test_user_page(self):
@ -34,7 +35,7 @@ class UserViews(TestCase):
is_api.return_value = False
result = view(request, 'mouse')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'user/user.html')
result.render()
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.user.is_api_request') as is_api:
@ -65,7 +66,7 @@ class UserViews(TestCase):
is_api.return_value = False
result = view(request, 'mouse')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'user/followers.html')
result.render()
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.user.is_api_request') as is_api:
@ -96,7 +97,7 @@ class UserViews(TestCase):
is_api.return_value = False
result = view(request, 'mouse')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'user/following.html')
result.render()
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.user.is_api_request') as is_api:
@ -125,7 +126,7 @@ class UserViews(TestCase):
request.user = self.local_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'preferences/edit_user.html')
result.render()
self.assertEqual(result.status_code, 200)

View file

@ -78,23 +78,40 @@ urlpatterns = [
re_path(r'^import/(\d+)/?$', views.ImportStatus.as_view()),
# users
re_path(r'%s/?$' % user_path, views.User.as_view()),
re_path(r'%s/?$' % user_path, views.User.as_view(), name='user-feed'),
re_path(r'%s\.json$' % user_path, views.User.as_view()),
re_path(r'%s/shelves/?$' % user_path, views.user_shelves_page),
re_path(r'%s/followers(.json)?/?$' % user_path, views.Followers.as_view()),
re_path(r'%s/following(.json)?/?$' % user_path, views.Following.as_view()),
re_path(r'%s/rss' % user_path, views.rss_feed.RssFeed()),
re_path(r'%s/rss' % user_path, views.rss_feed.RssFeed(), name='user-rss'),
re_path(r'%s/followers(.json)?/?$' % user_path,
views.Followers.as_view(), name='user-followers'),
re_path(r'%s/following(.json)?/?$' % user_path,
views.Following.as_view(), name='user-following'),
re_path(r'%s/shelves/?$' % user_path,
views.user_shelves_page, name='user-shelves'),
re_path(r'%s/lists/?$' % user_path,
views.UserLists.as_view(), name='user-lists'),
re_path(r'%s/goal/(?P<year>\d{4})/?$' % user_path,
views.Goal.as_view(), name='user-goal'),
# lists
re_path(r'^list/?$', views.Lists.as_view(), name='lists'),
re_path(r'^list/(?P<list_id>\d+)(.json)?/?$',
views.List.as_view(), name='list'),
re_path(r'^list/(?P<list_id>\d+)/add/?$',
views.list.add_book, name='list-add-book'),
re_path(r'^list/(?P<list_id>\d+)/remove/?$',
views.list.remove_book, name='list-remove-book'),
re_path(r'^list/(?P<list_id>\d+)/curate/?$',
views.Curate.as_view(), name='list-curate'),
# preferences
re_path(r'^preferences/profile/?$', views.EditUser.as_view()),
re_path(r'^preferences/profile/?$',
views.EditUser.as_view(), name='prefs-profile'),
re_path(r'^preferences/password/?$', views.ChangePassword.as_view()),
re_path(r'^preferences/block/?$', views.Block.as_view()),
re_path(r'^block/(?P<user_id>\d+)/?$', views.Block.as_view()),
re_path(r'^unblock/(?P<user_id>\d+)/?$', views.unblock),
# reading goals
re_path(r'%s/goal/(?P<year>\d{4})/?$' % user_path, views.Goal.as_view()),
# statuses
re_path(r'%s(.json)?/?$' % status_path, views.Status.as_view()),
re_path(r'%s/activity/?$' % status_path, views.Status.as_view()),
@ -130,10 +147,10 @@ urlpatterns = [
# shelf
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % \
user_path, views.Shelf.as_view()),
user_path, views.Shelf.as_view(), name='shelf'),
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % \
local_user_path, views.Shelf.as_view()),
re_path(r'^create-shelf/?$', views.create_shelf),
re_path(r'^create-shelf/?$', views.create_shelf, name='shelf-create'),
re_path(r'^delete-shelf/(?P<shelf_id>\d+)?$', views.delete_shelf),
re_path(r'^shelve/?$', views.shelve),
re_path(r'^unshelve/?$', views.unshelve),

View file

@ -14,6 +14,7 @@ from .import_data import Import, ImportStatus
from .interaction import Favorite, Unfavorite, Boost, Unboost
from .invite import ManageInvites, Invite
from .landing import About, Home, Discover
from .list import Lists, List, Curate, UserLists
from .notifications import Notifications
from .outbox import Outbox
from .reading import edit_readthrough, create_readthrough, delete_readthrough

View file

@ -35,6 +35,7 @@ class Goal(View):
'goal': goal,
'user': user,
'year': year,
'is_self': request.user == user,
}
return TemplateResponse(request, 'goal.html', data)
@ -70,10 +71,15 @@ class Goal(View):
broadcast(
request.user,
status.to_create_activity(request.user),
privacy=status.privacy,
software='bookwyrm')
# re-format the activity for non-bookwyrm servers
remote_activity = status.to_create_activity(request.user, pure=True)
broadcast(request.user, remote_activity, software='other')
broadcast(
request.user,
remote_activity,
privacy=status.privacy,
software='other')
return redirect(request.headers.get('Referer', '/'))

View file

@ -59,11 +59,55 @@ def object_visible_to_user(viewer, obj):
return True
return False
def privacy_filter(viewer, queryset, privacy_levels, following_only=False):
''' filter objects that have "user" and "privacy" fields '''
# exclude blocks from both directions
if not viewer.is_anonymous:
blocked = models.User.objects.filter(id__in=viewer.blocks.all()).all()
queryset = queryset.exclude(
Q(user__in=blocked) | Q(user__blocks=viewer))
# you can't see followers only or direct messages if you're not logged in
if viewer.is_anonymous:
privacy_levels = [p for p in privacy_levels if \
not p in ['followers', 'direct']]
# filter to only privided privacy levels
queryset = queryset.filter(privacy__in=privacy_levels)
# only include statuses the user follows
if following_only:
queryset = queryset.exclude(
~Q(# remove everythign except
Q(user__in=viewer.following.all()) | # user following
Q(user=viewer) |# is self
Q(mention_users=viewer)# mentions user
),
)
# exclude followers-only statuses the user doesn't follow
elif 'followers' in privacy_levels:
queryset = queryset.exclude(
~Q(# user isn't following and it isn't their own status
Q(user__in=viewer.following.all()) | Q(user=viewer)
),
privacy='followers' # and the status is followers only
)
# exclude direct messages not intended for the user
if 'direct' in privacy_levels:
queryset = queryset.exclude(
~Q(
Q(user=viewer) | Q(mention_users=viewer)
), privacy='direct'
)
return queryset
def get_activity_feed(
user, privacy, local_only=False, following_only=False,
queryset=models.Status.objects):
''' get a filtered queryset of statuses '''
privacy = privacy if isinstance(privacy, list) else [privacy]
# if we're looking at Status, we need this. We don't if it's Comment
if hasattr(queryset, 'select_subclasses'):
queryset = queryset.select_subclasses()
@ -71,44 +115,10 @@ def get_activity_feed(
# exclude deleted
queryset = queryset.exclude(deleted=True).order_by('-published_date')
# exclude blocks from both directions
if not user.is_anonymous:
blocked = models.User.objects.filter(id__in=user.blocks.all()).all()
queryset = queryset.exclude(
Q(user__in=blocked) | Q(user__blocks=user))
# you can't see followers only or direct messages if you're not logged in
if user.is_anonymous:
privacy = [p for p in privacy if not p in ['followers', 'direct']]
# filter to only privided privacy levels
queryset = queryset.filter(privacy__in=privacy)
# only include statuses the user follows
if following_only:
queryset = queryset.exclude(
~Q(# remove everythign except
Q(user__in=user.following.all()) | # user follwoing
Q(user=user) |# is self
Q(mention_users=user)# mentions user
),
)
# exclude followers-only statuses the user doesn't follow
elif 'followers' in privacy:
queryset = queryset.exclude(
~Q(# user isn't following and it isn't their own status
Q(user__in=user.following.all()) | Q(user=user)
),
privacy='followers' # and the status is followers only
)
# exclude direct messages not intended for the user
if 'direct' in privacy:
queryset = queryset.exclude(
~Q(
Q(user=user) | Q(mention_users=user)
), privacy='direct'
)
# apply privacy filters
privacy = privacy if isinstance(privacy, list) else [privacy]
queryset = privacy_filter(
user, queryset, privacy, following_only=following_only)
# filter for only local status
if local_only:

253
bookwyrm/views/list.py Normal file
View file

@ -0,0 +1,253 @@
''' book list views'''
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Count, Q
from django.http import HttpResponseNotFound, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.http import require_POST
from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.broadcast import broadcast
from bookwyrm.connectors import connector_manager
from .helpers import is_api_request, object_visible_to_user, privacy_filter
from .helpers import get_user_from_username
# pylint: disable=no-self-use
class Lists(View):
''' book list page '''
def get(self, request):
''' display a book list '''
try:
page = int(request.GET.get('page', 1))
except ValueError:
page = 1
user = request.user if request.user.is_authenticated else None
# hide lists with no approved books
lists = models.List.objects.filter(
~Q(user=user),
).annotate(
item_count=Count('listitem', filter=Q(listitem__approved=True))
).filter(
item_count__gt=0
).distinct().all()
lists = privacy_filter(request.user, lists, ['public', 'followers'])
paginated = Paginator(lists, 12)
data = {
'title': 'Lists',
'lists': paginated.page(page),
'list_form': forms.ListForm(),
'path': '/list',
}
return TemplateResponse(request, 'lists/lists.html', data)
@method_decorator(login_required, name='dispatch')
# pylint: disable=unused-argument
def post(self, request):
''' create a book_list '''
form = forms.ListForm(request.POST)
if not form.is_valid():
return redirect('lists')
book_list = form.save()
# let the world know
broadcast(
request.user,
book_list.to_create_activity(request.user),
privacy=book_list.privacy,
software='bookwyrm'
)
return redirect(book_list.local_path)
class UserLists(View):
''' a user's book list page '''
def get(self, request, username):
''' display a book list '''
try:
page = int(request.GET.get('page', 1))
except ValueError:
page = 1
user = get_user_from_username(username)
lists = models.List.objects.filter(user=user).all()
lists = privacy_filter(
request.user, lists, ['public', 'followers', 'unlisted'])
paginated = Paginator(lists, 12)
data = {
'title': '%s: Lists' % user.name,
'user': user,
'is_self': request.user.id == user.id,
'lists': paginated.page(page),
'list_form': forms.ListForm(),
'path': user.local_path + '/lists',
}
return TemplateResponse(request, 'user/lists.html', data)
class List(View):
''' book list page '''
def get(self, request, list_id):
''' display a book list '''
book_list = get_object_or_404(models.List, id=list_id)
if not object_visible_to_user(request.user, book_list):
return HttpResponseNotFound()
if is_api_request(request):
return ActivitypubResponse(book_list.to_activity(**request.GET))
query = request.GET.get('q')
suggestions = None
if query and request.user.is_authenticated:
# search for books
suggestions = connector_manager.local_search(query, raw=True)
elif request.user.is_authenticated:
# just suggest whatever books are nearby
suggestions = request.user.shelfbook_set.filter(
~Q(book__in=book_list.books.all())
)
suggestions = [s.book for s in suggestions[:5]]
if len(suggestions) < 5:
suggestions += [
s.default_edition for s in \
models.Work.objects.filter(
~Q(editions__in=book_list.books.all()),
).order_by('-updated_date')
][:5 - len(suggestions)]
data = {
'title': '%s | Lists' % book_list.name,
'list': book_list,
'items': book_list.listitem_set.filter(approved=True),
'pending_count': book_list.listitem_set.filter(
approved=False).count(),
'suggested_books': suggestions,
'list_form': forms.ListForm(instance=book_list),
'query': query or ''
}
return TemplateResponse(request, 'lists/list.html', data)
@method_decorator(login_required, name='dispatch')
# pylint: disable=unused-argument
def post(self, request, list_id):
''' edit a book_list '''
book_list = get_object_or_404(models.List, id=list_id)
form = forms.ListForm(request.POST, instance=book_list)
if not form.is_valid():
return redirect('list', book_list.id)
book_list = form.save()
# let the world know
broadcast(
request.user,
book_list.to_update_activity(request.user),
privacy=book_list.privacy,
software='bookwyrm'
)
return redirect(book_list.local_path)
class Curate(View):
''' approve or discard list suggestsions '''
@method_decorator(login_required, name='dispatch')
def get(self, request, list_id):
''' display a pending list '''
book_list = get_object_or_404(models.List, id=list_id)
if not book_list.user == request.user:
# only the creater can curate the list
return HttpResponseNotFound()
data = {
'title': 'Curate "%s" | Lists' % book_list.name,
'list': book_list,
'pending': book_list.listitem_set.filter(approved=False),
'list_form': forms.ListForm(instance=book_list),
}
return TemplateResponse(request, 'lists/curate.html', data)
@method_decorator(login_required, name='dispatch')
# pylint: disable=unused-argument
def post(self, request, list_id):
''' edit a book_list '''
book_list = get_object_or_404(models.List, id=list_id)
suggestion = get_object_or_404(
models.ListItem, id=request.POST.get('item'))
approved = request.POST.get('approved') == 'true'
if approved:
suggestion.approved = True
suggestion.save()
# let the world know
broadcast(
request.user,
suggestion.to_add_activity(request.user),
privacy=book_list.privacy,
software='bookwyrm'
)
else:
suggestion.delete()
return redirect('list-curate', book_list.id)
@require_POST
def add_book(request, list_id):
''' put a book on a list '''
book_list = get_object_or_404(models.List, id=list_id)
if not object_visible_to_user(request.user, book_list):
return HttpResponseNotFound()
book = get_object_or_404(models.Edition, id=request.POST.get('book'))
# do you have permission to add to the list?
if request.user == book_list.user or book_list.curation == 'open':
# go ahead and add it
item = models.ListItem.objects.create(
book=book,
book_list=book_list,
added_by=request.user,
)
# let the world know
broadcast(
request.user,
item.to_add_activity(request.user),
privacy=book_list.privacy,
software='bookwyrm'
)
elif book_list.curation == 'curated':
# make a pending entry
models.ListItem.objects.create(
approved=False,
book=book,
book_list=book_list,
added_by=request.user,
)
else:
# you can't add to this list, what were you THINKING
return HttpResponseBadRequest()
return redirect('list', list_id)
@require_POST
def remove_book(request, list_id):
''' put a book on a list '''
book_list = get_object_or_404(models.List, id=list_id)
item = get_object_or_404(models.ListItem, id=request.POST.get('item'))
if not book_list.user == request.user and not item.added_by == request.user:
return HttpResponseNotFound()
activity = item.to_remove_activity(request.user)
item.delete()
# let the world know
broadcast(
request.user,
activity,
privacy=book_list.privacy,
software='bookwyrm'
)
return redirect('list', list_id)

View file

@ -10,7 +10,7 @@ from django.views import View
from bookwyrm import models
from bookwyrm.connectors import connector_manager
from bookwyrm.utils import regex
from .helpers import is_api_request
from .helpers import is_api_request, privacy_filter
from .helpers import handle_remote_webfinger
@ -32,7 +32,7 @@ class Search(View):
if re.match(r'\B%s' % regex.full_username, query):
handle_remote_webfinger(query)
# do a local user search
# do a user search
user_results = models.User.objects.annotate(
similarity=Greatest(
TrigramSimilarity('username', query),
@ -42,12 +42,25 @@ class Search(View):
similarity__gt=0.5,
).order_by('-similarity')[:10]
# any relevent lists?
list_results = privacy_filter(
request.user, models.List.objects, ['public', 'followers']
).annotate(
similarity=Greatest(
TrigramSimilarity('name', query),
TrigramSimilarity('description', query),
)
).filter(
similarity__gt=0.1,
).order_by('-similarity')[:10]
book_results = connector_manager.search(
query, min_confidence=min_confidence)
data = {
'title': 'Search Results',
'book_results': book_results,
'user_results': user_results,
'list_results': list_results,
'query': query,
}
return TemplateResponse(request, 'search_results.html', data)

View file

@ -140,7 +140,12 @@ def shelve(request):
pass
shelfbook = models.ShelfBook.objects.create(
book=book, shelf=desired_shelf, added_by=request.user)
broadcast(request.user, shelfbook.to_add_activity(request.user))
broadcast(
request.user,
shelfbook.to_add_activity(request.user),
privacy=shelfbook.shelf.privacy,
software='bookwyrm'
)
# post about "want to read" shelves
if desired_shelf.identifier == 'to-read' and \
@ -173,4 +178,4 @@ def handle_unshelve(user, book, shelf):
activity = row.to_remove_activity(user)
row.delete()
broadcast(user, activity)
broadcast(user, activity, privacy=shelf.privacy, software='bookwyrm')

4
instances.md Normal file
View file

@ -0,0 +1,4 @@
| name | url | admin contact | open registration |
| :--- | :-- | :------------ | :---------------- |
| bookwyrm.social | http://bookwyrm.social/ | mousereeve@riseup.net / @tripofmice@friend.camp | ❌ |