Merge pull request #577 from mouse-reeve/lists

Lists
This commit is contained in:
Mouse Reeve 2021-02-03 10:57:19 -08:00 committed by GitHub
commit 0ba7c60e83
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 1557 additions and 200 deletions

View file

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

View file

@ -130,6 +130,7 @@ class ActivityObject:
def serialize(self): def serialize(self):
''' convert to dictionary with context attr ''' ''' convert to dictionary with context attr '''
data = self.__dict__ 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' data['@context'] = 'https://www.w3.org/ns/activitystreams'
return data return data

View file

@ -1,5 +1,5 @@
''' defines activitypub collections (lists) ''' ''' defines activitypub collections (lists) '''
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import List from typing import List
from .base_activity import ActivityObject from .base_activity import ActivityObject
@ -10,11 +10,28 @@ class OrderedCollection(ActivityObject):
''' structure of an ordered collection activity ''' ''' structure of an ordered collection activity '''
totalItems: int totalItems: int
first: str first: str
last: str = '' last: str = None
name: str = '' name: str = None
owner: str = '' owner: str = None
type: str = 'OrderedCollection' 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) @dataclass(init=False)
class OrderedCollectionPage(ActivityObject): class OrderedCollectionPage(ActivityObject):

View file

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

View file

@ -35,10 +35,10 @@ def search(query, min_confidence=0.1):
return results 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 ''' ''' only look at local search results '''
connector = load_connector(models.Connector.objects.get(local=True)) 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): def first_search_result(query, min_confidence=0.1):

View file

@ -11,7 +11,8 @@ from .abstract_connector import AbstractConnector, SearchResult
class Connector(AbstractConnector): class Connector(AbstractConnector):
''' instantiate a connector ''' ''' 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 ''' ''' search your local database '''
if not query: if not query:
return [] return []
@ -22,10 +23,14 @@ class Connector(AbstractConnector):
results = search_title_author(query, min_confidence) results = search_title_author(query, min_confidence)
search_results = [] search_results = []
for result in 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: if len(search_results) >= 10:
break 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 return search_results

View file

@ -206,3 +206,9 @@ class SiteForm(CustomForm):
class Meta: class Meta:
model = models.SiteSettings model = models.SiteSettings
exclude = [] 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()
return HttpResponse(status=401) return HttpResponse(status=401)
# if this isn't a file ripe for refactor, I don't know what is.
handlers = { handlers = {
'Follow': handle_follow, 'Follow': handle_follow,
'Accept': handle_follow_accept, 'Accept': handle_follow_accept,
'Reject': handle_follow_reject, 'Reject': handle_follow_reject,
'Block': handle_block, '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, 'Delete': handle_delete_status,
'Like': handle_favorite, 'Like': handle_favorite,
'Announce': handle_boost, 'Announce': handle_boost,
@ -69,6 +77,7 @@ def shared_inbox(request):
'Person': handle_update_user, 'Person': handle_update_user,
'Edition': handle_update_edition, 'Edition': handle_update_edition,
'Work': handle_update_work, 'Work': handle_update_work,
'BookList': handle_update_list,
}, },
} }
activity_type = activity['type'] activity_type = activity['type']
@ -204,7 +213,25 @@ def handle_unblock(activity):
@app.task @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 ''' ''' someone did something, good on them '''
# deduplicate incoming activities # deduplicate incoming activities
activity = activity['object'] activity = activity['object']

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 .connector import Connector
from .shelf import Shelf, ShelfBook from .shelf import Shelf, ShelfBook
from .list import List, ListItem
from .status import Status, GeneratedNote, Review, Comment, Quotation from .status import Status, GeneratedNote, Review, Comment, Quotation
from .status import Boost from .status import Boost

View file

@ -140,20 +140,7 @@ class ActivitypubMixin:
def to_activity(self): def to_activity(self):
''' convert from a model to an activity ''' ''' convert from a model to an activity '''
activity = {} activity = generate_activity(self)
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()
return self.activity_serializer(**activity).serialize() return self.activity_serializer(**activity).serialize()
@ -161,16 +148,18 @@ class ActivitypubMixin:
''' returns the object wrapped in a Create activity ''' ''' returns the object wrapped in a Create activity '''
activity_object = self.to_activity(**kwargs) activity_object = self.to_activity(**kwargs)
signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key)) signature = None
content = activity_object['content']
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
create_id = self.remote_id + '/activity' 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( signature = activitypub.Signature(
creator='%s#main-key' % user.remote_id, creator='%s#main-key' % user.remote_id,
created=activity_object['published'], created=activity_object['published'],
signatureValue=b64encode(signed_message).decode('utf8') signatureValue=b64encode(signed_message).decode('utf8')
) )
return activitypub.Create( return activitypub.Create(
id=create_id, id=create_id,
@ -223,7 +212,7 @@ class OrderedCollectionPageMixin(ActivitypubMixin):
def to_ordered_collection(self, queryset, \ 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 ''' ''' an ordered collection of whatevers '''
if not queryset.ordered: if not queryset.ordered:
raise RuntimeError('queryset must be ordered') raise RuntimeError('queryset must be ordered')
@ -232,18 +221,25 @@ class OrderedCollectionPageMixin(ActivitypubMixin):
if page: if page:
return to_ordered_collection_page( return to_ordered_collection_page(
queryset, remote_id, **kwargs) 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) paginated = Paginator(queryset, PAGE_LENGTH)
return activitypub.OrderedCollection( # add computed fields specific to orderd collections
id=remote_id, activity['totalItems'] = paginated.count
totalItems=paginated.count, activity['first'] = '%s?page=1' % remote_id
name=name, activity['last'] = '%s?page=%d' % (remote_id, paginated.num_pages)
owner=owner,
first='%s?page=1' % remote_id, return serializer(**activity).serialize()
last='%s?page=%d' % (remote_id, paginated.num_pages)
).serialize()
# pylint: disable=unused-argument # pylint: disable=unused-argument
@ -285,3 +281,22 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin):
def to_activity(self, **kwargs): def to_activity(self, **kwargs):
''' an ordered collection of the specified model queryset ''' ''' an ordered collection of the specified model queryset '''
return self.to_ordered_collection(self.collection_queryset, **kwargs) 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') setattr(instance, self.name, 'followers')
def set_activity_from_field(self, activity, instance): 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 # this is a link to the followers list
followers = instance.user.__class__._meta.get_field('followers')\ followers = instance.user.__class__._meta.get_field('followers')\
.field_to_activity(instance.user.followers) .field_to_activity(instance.user.followers)

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

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

View file

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

View file

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

View file

@ -1,13 +1,22 @@
{% extends 'layout.html' %} {% extends 'user/user_layout.html' %}
{% block content %}
{% 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"> <section class="block">
<h1 class="title">{{ year }} Reading Progress</h1>
{% if user == request.user %} {% if user == request.user %}
<div class="block"> <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 %} {% now 'Y' as year %}
<section class="card {% if goal %}hidden{% endif %}" id="show-edit-goal"> <section class="card {% if goal %}hidden{% endif %}" id="show-edit-goal">
<header class="card-header"> <header class="card-header">
@ -34,8 +43,8 @@
</section> </section>
{% if goal.books %} {% if goal.books %}
<section> <section class="content">
<h2 class="title">{% if goal.user == request.user %}Your{% else %}{{ goal.user.display_name }}'s{% endif %} {{ year }} Books</h2> <h2>{% if goal.user == request.user %}Your{% else %}{{ goal.user.display_name }}'s{% endif %} {{ year }} Books</h2>
<div class="columns is-multiline"> <div class="columns is-multiline">
{% for book in goal.books %} {% for book in goal.books %}
<div class="column is-narrow"> <div class="column is-narrow">

View file

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

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,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.books.all|slice:5 %}
{% include 'snippets/book_cover.html' with 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,30 @@
{% 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>
<form name="edit-list" method="post" action="{% url 'list' list.id %}" class="box hidden" id="edit-list">
<header class="columns">
<h3 class="title column" tabindex="0" id="edit-list-header">Edit list</h3>
<div class="column is-narrow">
{% include 'snippets/toggle/toggle_button.html' with controls_text="edit-list" label="close" class="delete" nonbutton=True %}
</div>
</header>
{% include 'lists/form.html' %}
</form>
{% block panel %}{% endblock %}
{% endblock %}

View file

@ -0,0 +1,51 @@
{% extends 'layout.html' %}
{% block content %}
<header class="block">
<h1 class="title">Lists</h1>
</header>
{% if request.user.is_authenticated and not lists.has_previous %}
<section class="block content">
<header class="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>
<form name="create-list" method="post" action="{% url 'lists' %}" class="box hidden" id="create-list">
<header class="columns">
<h3 class="title column" tabindex="0" id="create-list-header">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>
{% 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>
{% if request.user.list_set.exists %}
{% include 'lists/list_items.html' with lists=lists %}
{% endif %}
</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 %} {% if form.non_field_errors %}
<p class="notification is-danger">{{ form.non_field_errors }}</p> <p class="notification is-danger">{{ form.non_field_errors }}</p>
{% endif %} {% 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 %} {% csrf_token %}
<div class="block"> <div class="block">
<label class="label" for="id_avatar">Avatar:</label> <label class="label" for="id_avatar">Avatar:</label>

View file

@ -63,17 +63,36 @@
</div> </div>
</div> </div>
<div class="column"> <div class="column">
<h2 class="title">Matching Users</h2> <section class="block">
{% if not user_results %} <h2 class="title">Matching Users</h2>
<p>No users found for "{{ query }}"</p> {% if not user_results %}
{% endif %} <p>No users found for "{{ query }}"</p>
{% for result in user_results %} {% endif %}
<div class="block"> <ul>
{% include 'snippets/avatar.html' with user=result %}</h2> {% for result in user_results %}
{% include 'snippets/username.html' with user=result show_full=True %}</h2> <li class="block">
{% include 'snippets/follow_button.html' with user=result %} {% include 'snippets/avatar.html' with user=result %}</h2>
</div> {% include 'snippets/username.html' with user=result show_full=True %}</h2>
{% endfor %} {% 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>
</div> </div>
{% endwith %} {% endwith %}

View file

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

View file

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

View file

@ -13,7 +13,7 @@
<label class="is-sr-only" for="rating-no-rating-{{ book.id }}">No rating</label> <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> <input class="is-sr-only" type="radio" name="rating" value="" id="rating-no-rating-{{ book.id }}" checked>
{% for i in '12345'|make_list %} {% 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 }}"> <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> <span class="is-sr-only">{{ forloop.counter }} star{{ forloop.counter | pluralize }}</span>
</label> </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> <span class="is-sr-only">{% if rating %}{{ rating|floatformat }} star{{ rating|floatformat | pluralize }}{% else %}No rating{% endif %}</span>
{% for i in '12345'|make_list %} {% 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> </span>
{% endfor %} {% endfor %}
</div> </p>

View file

@ -20,7 +20,7 @@
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<div class="field has-addons"> <div class="field has-addons">
<div class="control"> <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>
<div class="control"> <div class="control">
{% include 'snippets/boost_button.html' with status=status %} {% include 'snippets/boost_button.html' with status=status %}

View file

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

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,11 +18,11 @@
<div class="column"> <div class="column">
<div class="tabs" role="tablist"> <div class="tabs" role="tablist">
<ul> <ul>
{% for shelf_tab in shelves %} {% for shelf_tab in shelves %}
<li class="{% if shelf_tab.identifier == shelf.identifier %}is-active{% endif %}"> <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> <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> </li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
</div> </div>

View file

@ -67,6 +67,31 @@
</div> </div>
{% endif %} {% endif %}
</div> </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' request.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' request.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' request.user|username as url %}
<li{% if url in request.path %} class="is-active"{% endif %}>
<a href="{{ url }}">Lists</a>
</li>
{% url 'user-shelves' request.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 %} {% block panel %}{% endblock %}

View file

@ -4,9 +4,10 @@ from datetime import datetime
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import template from django import template
from django.db.models import Avg
from django.utils import timezone from django.utils import timezone
from bookwyrm import models from bookwyrm import models, views
from bookwyrm.views.status import to_markdown from bookwyrm.views.status import to_markdown
@ -20,6 +21,17 @@ def dict_key(d, k):
@register.filter(name='rating') @register.filter(name='rating')
def get_rating(book, user): 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 ''' ''' get a user's rating of a book '''
rating = models.Review.objects.filter( rating = models.Review.objects.filter(
user=user, user=user,

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.assertIsInstance(activity_json, dict)
self.assertEqual(activity_json['id'], self.shelf.remote_id) self.assertEqual(activity_json['id'], self.shelf.remote_id)
self.assertEqual(activity_json['totalItems'], 0) 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['name'], 'Test Shelf')
self.assertEqual(activity_json['owner'], self.user.remote_id) self.assertEqual(activity_json['owner'], self.user.remote_id)

View file

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

View file

@ -248,7 +248,70 @@ class Incoming(TestCase):
self.assertEqual(follows.count(), 0) 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 ''' ''' the "it justs works" mode '''
self.assertEqual(models.Status.objects.count(), 1) self.assertEqual(models.Status.objects.count(), 1)
@ -259,7 +322,7 @@ class Incoming(TestCase):
title='Test Book', remote_id='https://example.com/book/1') title='Test Book', remote_id='https://example.com/book/1')
activity = {'object': status_data, 'type': 'Create'} activity = {'object': status_data, 'type': 'Create'}
incoming.handle_create(activity) incoming.handle_create_status(activity)
status = models.Quotation.objects.get() status = models.Quotation.objects.get()
self.assertEqual( self.assertEqual(
@ -270,16 +333,16 @@ class Incoming(TestCase):
self.assertEqual(models.Status.objects.count(), 2) self.assertEqual(models.Status.objects.count(), 2)
# while we're here, lets ensure we avoid dupes # 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) 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 ''' ''' folks send you all kinds of things '''
activity = {'object': {'id': 'hi'}, 'type': 'Fish'} activity = {'object': {'id': 'hi'}, 'type': 'Fish'}
result = incoming.handle_create(activity) result = incoming.handle_create_status(activity)
self.assertIsNone(result) 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 ''' ''' should only create it under the right circumstances '''
self.assertEqual(models.Status.objects.count(), 1) self.assertEqual(models.Status.objects.count(), 1)
self.assertFalse( self.assertFalse(
@ -290,7 +353,7 @@ class Incoming(TestCase):
status_data = json.loads(datafile.read_bytes()) status_data = json.loads(datafile.read_bytes())
activity = {'object': status_data, 'type': 'Create'} activity = {'object': status_data, 'type': 'Create'}
incoming.handle_create(activity) incoming.handle_create_status(activity)
status = models.Status.objects.last() status = models.Status.objects.last()
self.assertEqual(status.content, 'test content in note') self.assertEqual(status.content, 'test content in note')
self.assertEqual(status.mention_users.first(), self.local_user) self.assertEqual(status.mention_users.first(), self.local_user)
@ -299,7 +362,7 @@ class Incoming(TestCase):
self.assertEqual( self.assertEqual(
models.Notification.objects.get().notification_type, 'MENTION') 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 ''' ''' should only create it under the right circumstances '''
self.assertEqual(models.Status.objects.count(), 1) self.assertEqual(models.Status.objects.count(), 1)
self.assertFalse( self.assertFalse(
@ -312,7 +375,7 @@ class Incoming(TestCase):
status_data['inReplyTo'] = self.status.remote_id status_data['inReplyTo'] = self.status.remote_id
activity = {'object': status_data, 'type': 'Create'} activity = {'object': status_data, 'type': 'Create'}
incoming.handle_create(activity) incoming.handle_create_status(activity)
status = models.Status.objects.last() status = models.Status.objects.last()
self.assertEqual(status.content, 'test content in note') self.assertEqual(status.content, 'test content in note')
self.assertEqual(status.reply_parent, self.status) self.assertEqual(status.reply_parent, self.status)
@ -566,20 +629,3 @@ class Incoming(TestCase):
self.assertFalse(models.UserFollows.objects.exists()) self.assertFalse(models.UserFollows.objects.exists())
self.assertFalse(models.UserFollowRequest.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) 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 ''' ''' get a user's most recent rating of a book '''
models.Review.objects.create( models.Review.objects.create(
user=self.user, book=self.book, rating=3) user=self.user, book=self.book, rating=3)
self.assertEqual( 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 ''' ''' there is no rating available '''
self.assertEqual( 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): def test_get_user_identifer_local(self):

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

@ -83,6 +83,7 @@ class ShelfViews(TestCase):
) )
request = self.factory.get('', {'q': 'Test Book'}) request = self.factory.get('', {'q': 'Test Book'})
request.user = self.local_user
with patch('bookwyrm.views.search.is_api_request') as is_api: with patch('bookwyrm.views.search.is_api_request') as is_api:
is_api.return_value = False is_api.return_value = False
with patch( with patch(
@ -99,6 +100,7 @@ class ShelfViews(TestCase):
''' searches remote connectors ''' ''' searches remote connectors '''
view = views.Search.as_view() view = views.Search.as_view()
request = self.factory.get('', {'q': 'mouse'}) request = self.factory.get('', {'q': 'mouse'})
request.user = self.local_user
with patch('bookwyrm.views.search.is_api_request') as is_api: with patch('bookwyrm.views.search.is_api_request') as is_api:
is_api.return_value = False is_api.return_value = False
with patch('bookwyrm.connectors.connector_manager.search'): with patch('bookwyrm.connectors.connector_manager.search'):

View file

@ -8,6 +8,7 @@ from bookwyrm import models, views
from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.activitypub import ActivitypubResponse
@patch('bookwyrm.broadcast.broadcast_task.delay')
class ShelfViews(TestCase): class ShelfViews(TestCase):
''' tag views''' ''' tag views'''
def setUp(self): def setUp(self):
@ -32,7 +33,7 @@ class ShelfViews(TestCase):
models.SiteSettings.objects.create() 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 ''' ''' there are so many views, this just makes sure it LOADS '''
view = views.Shelf.as_view() view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.first() shelf = self.local_user.shelf_set.first()
@ -62,7 +63,7 @@ class ShelfViews(TestCase):
self.assertEqual(result.status_code, 200) self.assertEqual(result.status_code, 200)
def test_edit_shelf_privacy(self): def test_edit_shelf_privacy(self, _):
''' set name or privacy on shelf ''' ''' set name or privacy on shelf '''
view = views.Shelf.as_view() view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.get(identifier='to-read') shelf = self.local_user.shelf_set.get(identifier='to-read')
@ -81,7 +82,7 @@ class ShelfViews(TestCase):
self.assertEqual(shelf.privacy, 'unlisted') self.assertEqual(shelf.privacy, 'unlisted')
def test_edit_shelf_name(self): def test_edit_shelf_name(self, _):
''' change the name of an editable shelf ''' ''' change the name of an editable shelf '''
view = views.Shelf.as_view() view = views.Shelf.as_view()
shelf = models.Shelf.objects.create( shelf = models.Shelf.objects.create(
@ -102,7 +103,7 @@ class ShelfViews(TestCase):
self.assertEqual(shelf.identifier, 'testshelf-%d' % shelf.id) 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 ''' ''' can't change the name of an non-editable shelf '''
view = views.Shelf.as_view() view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.get(identifier='to-read') shelf = self.local_user.shelf_set.get(identifier='to-read')
@ -120,20 +121,19 @@ class ShelfViews(TestCase):
self.assertEqual(shelf.name, 'To Read') self.assertEqual(shelf.name, 'To Read')
def test_handle_shelve(self): def test_handle_shelve(self, _):
''' shelve a book ''' ''' shelve a book '''
request = self.factory.post('', { request = self.factory.post('', {
'book': self.book.id, 'book': self.book.id,
'shelf': self.shelf.identifier 'shelf': self.shelf.identifier
}) })
request.user = self.local_user 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 # make sure the book is on the shelf
self.assertEqual(self.shelf.books.get(), self.book) 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 ''' ''' special behavior for the to-read shelf '''
shelf = models.Shelf.objects.get(identifier='to-read') shelf = models.Shelf.objects.get(identifier='to-read')
request = self.factory.post('', { request = self.factory.post('', {
@ -142,13 +142,12 @@ class ShelfViews(TestCase):
}) })
request.user = self.local_user 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 # make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book) 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 ''' ''' special behavior for the reading shelf '''
shelf = models.Shelf.objects.get(identifier='reading') shelf = models.Shelf.objects.get(identifier='reading')
request = self.factory.post('', { request = self.factory.post('', {
@ -157,13 +156,12 @@ class ShelfViews(TestCase):
}) })
request.user = self.local_user 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 # make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book) 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 ''' ''' special behavior for the read shelf '''
shelf = models.Shelf.objects.get(identifier='read') shelf = models.Shelf.objects.get(identifier='read')
request = self.factory.post('', { request = self.factory.post('', {
@ -178,7 +176,7 @@ class ShelfViews(TestCase):
self.assertEqual(shelf.books.get(), self.book) self.assertEqual(shelf.books.get(), self.book)
def test_handle_unshelve(self): def test_handle_unshelve(self, _):
''' remove a book from a shelf ''' ''' remove a book from a shelf '''
self.shelf.books.add(self.book) self.shelf.books.add(self.book)
self.shelf.save() self.shelf.save()

View file

@ -78,23 +78,40 @@ urlpatterns = [
re_path(r'^import/(\d+)/?$', views.ImportStatus.as_view()), re_path(r'^import/(\d+)/?$', views.ImportStatus.as_view()),
# users # 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\.json$' % user_path, views.User.as_view()),
re_path(r'%s/shelves/?$' % user_path, views.user_shelves_page), 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()), re_path(r'%s/followers(.json)?/?$' % user_path,
re_path(r'%s/following(.json)?/?$' % user_path, views.Following.as_view()), views.Followers.as_view(), name='user-followers'),
re_path(r'%s/rss' % user_path, views.rss_feed.RssFeed()), 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 # 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/password/?$', views.ChangePassword.as_view()),
re_path(r'^preferences/block/?$', views.Block.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'^block/(?P<user_id>\d+)/?$', views.Block.as_view()),
re_path(r'^unblock/(?P<user_id>\d+)/?$', views.unblock), 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 # statuses
re_path(r'%s(.json)?/?$' % status_path, views.Status.as_view()), re_path(r'%s(.json)?/?$' % status_path, views.Status.as_view()),
re_path(r'%s/activity/?$' % status_path, views.Status.as_view()), re_path(r'%s/activity/?$' % status_path, views.Status.as_view()),
@ -130,7 +147,7 @@ urlpatterns = [
# shelf # shelf
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % \ 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)?/?$' % \ re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % \
local_user_path, views.Shelf.as_view()), local_user_path, views.Shelf.as_view()),
re_path(r'^create-shelf/?$', views.create_shelf), re_path(r'^create-shelf/?$', views.create_shelf),

View file

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

View file

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

View file

@ -59,11 +59,55 @@ def object_visible_to_user(viewer, obj):
return True return True
return False 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( def get_activity_feed(
user, privacy, local_only=False, following_only=False, user, privacy, local_only=False, following_only=False,
queryset=models.Status.objects): queryset=models.Status.objects):
''' get a filtered queryset of statuses ''' ''' 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 we're looking at Status, we need this. We don't if it's Comment
if hasattr(queryset, 'select_subclasses'): if hasattr(queryset, 'select_subclasses'):
queryset = queryset.select_subclasses() queryset = queryset.select_subclasses()
@ -71,44 +115,10 @@ def get_activity_feed(
# exclude deleted # exclude deleted
queryset = queryset.exclude(deleted=True).order_by('-published_date') queryset = queryset.exclude(deleted=True).order_by('-published_date')
# exclude blocks from both directions # apply privacy filters
if not user.is_anonymous: privacy = privacy if isinstance(privacy, list) else [privacy]
blocked = models.User.objects.filter(id__in=user.blocks.all()).all() queryset = privacy_filter(
queryset = queryset.exclude( user, queryset, privacy, following_only=following_only)
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'
)
# filter for only local status # filter for only local status
if local_only: if local_only:

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

@ -0,0 +1,248 @@
''' book list views'''
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import 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
lists = models.List.objects.filter(
~Q(user=user),
).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 import models
from bookwyrm.connectors import connector_manager from bookwyrm.connectors import connector_manager
from bookwyrm.utils import regex 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 from .helpers import handle_remote_webfinger
@ -32,7 +32,7 @@ class Search(View):
if re.match(r'\B%s' % regex.full_username, query): if re.match(r'\B%s' % regex.full_username, query):
handle_remote_webfinger(query) handle_remote_webfinger(query)
# do a local user search # do a user search
user_results = models.User.objects.annotate( user_results = models.User.objects.annotate(
similarity=Greatest( similarity=Greatest(
TrigramSimilarity('username', query), TrigramSimilarity('username', query),
@ -42,12 +42,25 @@ class Search(View):
similarity__gt=0.5, similarity__gt=0.5,
).order_by('-similarity')[:10] ).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( book_results = connector_manager.search(
query, min_confidence=min_confidence) query, min_confidence=min_confidence)
data = { data = {
'title': 'Search Results', 'title': 'Search Results',
'book_results': book_results, 'book_results': book_results,
'user_results': user_results, 'user_results': user_results,
'list_results': list_results,
'query': query, 'query': query,
} }
return TemplateResponse(request, 'search_results.html', data) return TemplateResponse(request, 'search_results.html', data)

View file

@ -138,7 +138,12 @@ def shelve(request):
pass pass
shelfbook = models.ShelfBook.objects.create( shelfbook = models.ShelfBook.objects.create(
book=book, shelf=desired_shelf, added_by=request.user) 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 # post about "want to read" shelves
if desired_shelf.identifier == 'to-read': if desired_shelf.identifier == 'to-read':
@ -146,7 +151,7 @@ def shelve(request):
request.user, request.user,
desired_shelf, desired_shelf,
book, book,
privacy=desired_shelf.privacy privacy=desired_shelf.privacy,
) )
return redirect('/') return redirect('/')
@ -169,4 +174,4 @@ def handle_unshelve(user, book, shelf):
activity = row.to_remove_activity(user) activity = row.to_remove_activity(user)
row.delete() row.delete()
broadcast(user, activity) broadcast(user, activity, privacy=shelf.privacy, software='bookwyrm')