forked from mirrors/bookwyrm
commit
0ba7c60e83
52 changed files with 1557 additions and 200 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -18,7 +18,7 @@ class Create(Verb):
|
|||
''' Create activity '''
|
||||
to: List
|
||||
cc: List
|
||||
signature: Signature
|
||||
signature: Signature = None
|
||||
type: str = 'Create'
|
||||
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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']
|
||||
|
|
|
@ -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']
|
||||
|
|
65
bookwyrm/migrations/0041_auto_20210131_1614.py
Normal file
65
bookwyrm/migrations/0041_auto_20210131_1614.py
Normal 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),
|
||||
),
|
||||
]
|
28
bookwyrm/migrations/0042_auto_20210201_2108.py
Normal file
28
bookwyrm/migrations/0042_auto_20210201_2108.py
Normal 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),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
91
bookwyrm/models/list.py
Normal file
91
bookwyrm/models/list.py
Normal 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',)
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
49
bookwyrm/templates/lists/curate.html
Normal file
49
bookwyrm/templates/lists/curate.html
Normal 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 %}
|
44
bookwyrm/templates/lists/form.html
Normal file
44
bookwyrm/templates/lists/form.html
Normal 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>
|
||||
|
92
bookwyrm/templates/lists/list.html
Normal file
92
bookwyrm/templates/lists/list.html
Normal 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 %}
|
23
bookwyrm/templates/lists/list_items.html
Normal file
23
bookwyrm/templates/lists/list_items.html
Normal 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>
|
30
bookwyrm/templates/lists/list_layout.html
Normal file
30
bookwyrm/templates/lists/list_layout.html
Normal 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 %}
|
51
bookwyrm/templates/lists/lists.html
Normal file
51
bookwyrm/templates/lists/lists.html
Normal 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 %}
|
|
@ -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>
|
||||
|
|
|
@ -63,17 +63,36 @@
|
|||
</div>
|
||||
</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 %}
|
||||
|
|
|
@ -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.html' with book=book %}
|
||||
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book %}</a>
|
||||
{% include 'snippets/shelve_button.html' with book=book %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
40
bookwyrm/templates/user/lists.html
Normal file
40
bookwyrm/templates/user/lists.html
Normal 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 %}
|
|
@ -18,11 +18,11 @@
|
|||
<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>
|
||||
|
|
|
@ -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' 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 %}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
54
bookwyrm/tests/models/test_list.py
Normal file
54
bookwyrm/tests/models/test_list.py
Normal 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)
|
|
@ -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)
|
||||
|
|
|
@ -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 '''
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
299
bookwyrm/tests/views/test_list.py
Normal file
299
bookwyrm/tests/views/test_list.py
Normal 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())
|
|
@ -83,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(
|
||||
|
@ -99,6 +100,7 @@ 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'):
|
||||
|
|
|
@ -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):
|
||||
|
@ -32,7 +33,7 @@ class ShelfViews(TestCase):
|
|||
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()
|
||||
|
@ -62,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')
|
||||
|
@ -81,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(
|
||||
|
@ -102,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')
|
||||
|
@ -120,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('', {
|
||||
|
@ -142,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('', {
|
||||
|
@ -157,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('', {
|
||||
|
@ -178,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()
|
||||
|
|
|
@ -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,7 +147,7 @@ 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),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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', '/'))
|
||||
|
|
|
@ -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:
|
||||
|
|
248
bookwyrm/views/list.py
Normal file
248
bookwyrm/views/list.py
Normal 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)
|
|
@ -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)
|
||||
|
|
|
@ -138,7 +138,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':
|
||||
|
@ -146,7 +151,7 @@ def shelve(request):
|
|||
request.user,
|
||||
desired_shelf,
|
||||
book,
|
||||
privacy=desired_shelf.privacy
|
||||
privacy=desired_shelf.privacy,
|
||||
)
|
||||
|
||||
return redirect('/')
|
||||
|
@ -169,4 +174,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')
|
||||
|
|
Loading…
Reference in a new issue