forked from mirrors/bookwyrm
Merge branch 'main' into shelve-buttons
This commit is contained in:
commit
485de039cf
84 changed files with 1873 additions and 326 deletions
45
README.md
45
README.md
|
@ -3,7 +3,8 @@
|
|||
Social reading and reviewing, decentralized with ActivityPub
|
||||
|
||||
## Contents
|
||||
- [The overall idea](#the-overall-idea)
|
||||
- [Joining BookWyrm](#joining-bookwyrm)
|
||||
- [The overall idea](#the-overall-idea)
|
||||
- [What it is and isn't](#what-it-is-and-isnt)
|
||||
- [The role of federation](#the-role-of-federation)
|
||||
- [Features](#features)
|
||||
|
@ -13,42 +14,46 @@ Social reading and reviewing, decentralized with ActivityPub
|
|||
- [Book data](#book-data)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
## Joining BookWyrm
|
||||
BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://github.com/mouse-reeve/bookwyrm/blob/main/instances.md) list.
|
||||
|
||||
I, the maintianer of this project, run https://bookwyrm.social, and I generally give out invite codes to those who ask by [email](mailto:mousereeve@riseup.net), [Mastodon direct message](https://friend.camp/@tripofmice), or [Twitter direct message](https://twitter.com/tripofmice).
|
||||
|
||||
## The overall idea
|
||||
### What it is and isn't
|
||||
BookWyrm is a platform for social reading! You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a datasource for books, but it does do both of those things to some degree.
|
||||
|
||||
### The role of federation
|
||||
BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon and Pixelfed. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance.
|
||||
BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance.
|
||||
|
||||
Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular type of literature, be just for use by people who are in a book club together, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks.
|
||||
Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular interest, be just for a group of friends, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks.
|
||||
|
||||
### Features
|
||||
Since the project is still in its early stages, not everything here is fully implemented. There is plenty of room for suggestions and ideas. Open an [issue](https://github.com/mouse-reeve/bookwyrm/issues) to get the conversation going!
|
||||
Since the project is still in its early stages, the features are growing every day, and there is plenty of room for suggestions and ideas. Open an [issue](https://github.com/mouse-reeve/bookwyrm/issues) to get the conversation going!
|
||||
- Posting about books
|
||||
- Compose reviews, with or without ratings, which are aggregated in the book page
|
||||
- Compose other kinds of statuses about books, such as:
|
||||
- Comments on a book
|
||||
- Quotes or excerpts
|
||||
- Recommenations of other books
|
||||
- Reply to statuses
|
||||
- Aggregate reviews of a book across connected BookWyrm instances
|
||||
- Differentiate local and federated reviews and rating
|
||||
- View aggregate reviews of a book across connected BookWyrm instances
|
||||
- Differentiate local and federated reviews and rating in your activity feed
|
||||
- Track reading activity
|
||||
- Shelve books on default "to-read," "currently reading," and "read" shelves
|
||||
- Create custom shelves
|
||||
- Store started reading/finished reading dates
|
||||
- Store started reading/finished reading dates, as well as progress updates along the way
|
||||
- Update followers about reading activity (optionally, and with granular privacy controls)
|
||||
- Create lists of books which can be open to submissions from anyone, curated, or only edited by the creator
|
||||
- Federation with ActivityPub
|
||||
- Broadcast and receive user statuses and activity
|
||||
- Broadcast copies of books that can be used as canonical data sources
|
||||
- Share book data between instances to create a networked database of metadata
|
||||
- Identify shared books across instances and aggregate related content
|
||||
- Follow and interact with users across BookWyrm instances
|
||||
- Inter-operate with non-BookWyrm ActivityPub services
|
||||
- Inter-operate with non-BookWyrm ActivityPub services (currently, Mastodon is supported)
|
||||
- Granular privacy controls
|
||||
- Local-only, followers-only, and public posting
|
||||
- Private, followers-only, and public privacy levels for posting, shelves, and lists
|
||||
- Option for users to manually approve followers
|
||||
- Allow blocking and flagging for moderation
|
||||
- Control which instances you want to federate with
|
||||
|
||||
## Setting up the developer environment
|
||||
|
||||
|
@ -88,6 +93,7 @@ This project is still young and isn't, at the momoment, very stable, so please p
|
|||
`cp .env.example .env`
|
||||
- Add your domain, email address, mailgun credentials
|
||||
- Set a secure redis password and secret key
|
||||
- Set a secure database password for postgres
|
||||
- Update your nginx configuration in `nginx/default.conf`
|
||||
- Replace `your-domain.com` with your domain name
|
||||
- Run the application (this should also set up a Certbot ssl cert for your domain)
|
||||
|
@ -99,6 +105,7 @@ This project is still young and isn't, at the momoment, very stable, so please p
|
|||
`docker-compose up -d`
|
||||
- Initialize the database
|
||||
`./bw-dev initdb`
|
||||
- Set up schedule backups with cron that runs that `docker-compose exec db pg_dump -U <databasename>` and saves the backup to a safe locationgi
|
||||
- Congrats! You did it, go to your domain and enjoy the fruits of your labors
|
||||
### Configure your instance
|
||||
- Register a user account in the applcation UI
|
||||
|
@ -114,21 +121,11 @@ This project is still young and isn't, at the momoment, very stable, so please p
|
|||
user.is_superuser = True
|
||||
user.save()
|
||||
```
|
||||
- Go to the admin panel (`/admin/bookwyrm/sitesettings/1/change` on your domain) and set your instance name, description, code of conduct, and toggle whether registration is open on your instance
|
||||
|
||||
|
||||
## Project structure
|
||||
All the url routing is in `bookwyrm/urls.py`. This includes the application views (your home page, user page, book page, etc), application endpoints (things that happen when you click buttons), and federation api endpoints (inboxes, outboxes, webfinger, etc).
|
||||
|
||||
The application views and actions are in `bookwyrm/views.py`. The internal actions call api handlers which deal with federating content. Outgoing messages (any action done by a user that is federated out), as well as outboxes, live in `bookwyrm/outgoing.py`, and all handlers for incoming messages, as well as inboxes and webfinger, live in `bookwyrm/incoming.py`. Connection to openlibrary.org to get book data is handled in `bookwyrm/connectors/openlibrary.py`. ActivityPub serialization is handled in the `bookwyrm/activitypub/` directory.
|
||||
|
||||
Celery is used for background tasks, which includes receiving incoming ActivityPub activities, ActivityPub broadcasting, and external data import.
|
||||
|
||||
The UI is all django templates because that is the default. You can replace it with a complex javascript framework over my ~dead body~ mild objections.
|
||||
- Go to the site settings (`/settings/site-settings` on your domain) and configure your instance name, description, code of conduct, and toggle whether registration is open on your instance
|
||||
|
||||
|
||||
## Book data
|
||||
The application is set up to get book data from arbitrary outside sources -- right now, it's only able to connect to OpenLibrary, but other connectors could be written. By default, a book is non-canonical copy of an OpenLibrary book, and will be updated with OpenLibrary if the data there changes. However, a book can edited and decoupled from its original data source, or added locally with no external data source.
|
||||
The application is set up to share book and author data between instances, and get book data from arbitrary outside sources. Right now, the only connector is to OpenLibrary, but other connectors could be written.
|
||||
|
||||
There are three concepts in the book data model:
|
||||
- `Book`, an abstract, high-level concept that could mean either a `Work` or an `Edition`. No data is saved as a `Book`, it serves as shared model for `Work` and `Edition`
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -27,9 +27,9 @@ class Connector(AbstractConnector):
|
|||
Mapping('series', formatter=get_first),
|
||||
Mapping('seriesNumber', remote_field='series_number'),
|
||||
Mapping('subjects'),
|
||||
Mapping('subjectPlaces'),
|
||||
Mapping('isbn13', formatter=get_first),
|
||||
Mapping('isbn10', formatter=get_first),
|
||||
Mapping('subjectPlaces', remote_field='subject_places'),
|
||||
Mapping('isbn13', remote_field='isbn_13', formatter=get_first),
|
||||
Mapping('isbn10', remote_field='isbn_10', formatter=get_first),
|
||||
Mapping('lccn', formatter=get_first),
|
||||
Mapping(
|
||||
'oclcNumber', remote_field='oclc_numbers',
|
||||
|
@ -144,9 +144,34 @@ class Connector(AbstractConnector):
|
|||
# we can mass download edition data from OL to avoid repeatedly querying
|
||||
edition_options = self.load_edition_data(work.openlibrary_key)
|
||||
for edition_data in edition_options.get('entries'):
|
||||
# does this edition have ANY interesting data?
|
||||
if ignore_edition(edition_data):
|
||||
continue
|
||||
self.create_edition_from_data(work, edition_data)
|
||||
|
||||
|
||||
def ignore_edition(edition_data):
|
||||
''' don't load a million editions that have no metadata '''
|
||||
# an isbn, we love to see it
|
||||
if edition_data.get('isbn_13') or edition_data.get('isbn_10'):
|
||||
print(edition_data.get('isbn_10'))
|
||||
return False
|
||||
# grudgingly, oclc can stay
|
||||
if edition_data.get('oclc_numbers'):
|
||||
print(edition_data.get('oclc_numbers'))
|
||||
return False
|
||||
# if it has a cover it can stay
|
||||
if edition_data.get('covers'):
|
||||
print(edition_data.get('covers'))
|
||||
return False
|
||||
# keep non-english editions
|
||||
if edition_data.get('languages') and \
|
||||
'languages/eng' not in str(edition_data.get('languages')):
|
||||
print(edition_data.get('languages'))
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_description(description_blob):
|
||||
''' descriptions can be a string or a dict '''
|
||||
if isinstance(description_blob, dict):
|
||||
|
|
|
@ -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,9 +23,13 @@ class Connector(AbstractConnector):
|
|||
results = search_title_author(query, min_confidence)
|
||||
search_results = []
|
||||
for result in results:
|
||||
if raw:
|
||||
search_results.append(result)
|
||||
else:
|
||||
search_results.append(self.format_search_result(result))
|
||||
if len(search_results) >= 10:
|
||||
break
|
||||
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']
|
||||
|
|
34
bookwyrm/management/commands/remove_editions.py
Normal file
34
bookwyrm/management/commands/remove_editions.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
''' PROCEED WITH CAUTION: this permanently deletes book data '''
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Count, Q
|
||||
from bookwyrm import models
|
||||
|
||||
|
||||
def remove_editions():
|
||||
''' combine duplicate editions and update related models '''
|
||||
# not in use
|
||||
filters = {'%s__isnull' % r.name: True \
|
||||
for r in models.Edition._meta.related_objects}
|
||||
# no cover, no identifying fields
|
||||
filters['cover'] = ''
|
||||
null_fields = {'%s__isnull' % f: True for f in \
|
||||
['isbn_10', 'isbn_13', 'oclc_number']}
|
||||
|
||||
editions = models.Edition.objects.filter(
|
||||
Q(languages=[]) | Q(languages__contains=['English']),
|
||||
**filters, **null_fields
|
||||
).annotate(Count('parent_work__editions')).filter(
|
||||
# mustn't be the only edition for the work
|
||||
parent_work__editions__count__gt=1
|
||||
)
|
||||
print(editions.count())
|
||||
editions.delete()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
''' dedplucate allllll the book data models '''
|
||||
help = 'merges duplicate book data'
|
||||
# pylint: disable=no-self-use,unused-argument
|
||||
def handle(self, *args, **options):
|
||||
''' run deudplications '''
|
||||
remove_editions()
|
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,10 +148,12 @@ class ActivitypubMixin:
|
|||
''' returns the object wrapped in a Create activity '''
|
||||
activity_object = self.to_activity(**kwargs)
|
||||
|
||||
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')))
|
||||
create_id = self.remote_id + '/activity'
|
||||
|
||||
signature = activitypub.Signature(
|
||||
creator='%s#main-key' % user.remote_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,6 +213,9 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
|
|||
setattr(instance, self.name, 'followers')
|
||||
|
||||
def set_activity_from_field(self, activity, instance):
|
||||
# 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')\
|
||||
|
|
93
bookwyrm/models/list.py
Normal file
93
bookwyrm/models/list.py
Normal file
|
@ -0,0 +1,93 @@
|
|||
''' make a list of books!! '''
|
||||
from django.db import models
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from .base_model import ActivitypubMixin, BookWyrmModel
|
||||
from .base_model import OrderedCollectionMixin
|
||||
from . import fields
|
||||
|
||||
|
||||
CurationType = models.TextChoices('Curation', [
|
||||
'closed',
|
||||
'open',
|
||||
'curated',
|
||||
])
|
||||
|
||||
class List(OrderedCollectionMixin, BookWyrmModel):
|
||||
''' a list of books '''
|
||||
name = fields.CharField(max_length=100)
|
||||
user = fields.ForeignKey(
|
||||
'User', on_delete=models.PROTECT, activitypub_field='owner')
|
||||
description = fields.TextField(
|
||||
blank=True, null=True, activitypub_field='summary')
|
||||
privacy = fields.PrivacyField()
|
||||
curation = fields.CharField(
|
||||
max_length=255,
|
||||
default='closed',
|
||||
choices=CurationType.choices
|
||||
)
|
||||
books = models.ManyToManyField(
|
||||
'Edition',
|
||||
symmetrical=False,
|
||||
through='ListItem',
|
||||
through_fields=('book_list', 'book'),
|
||||
)
|
||||
activity_serializer = activitypub.BookList
|
||||
|
||||
def get_remote_id(self):
|
||||
''' don't want the user to be in there in this case '''
|
||||
return 'https://%s/list/%d' % (DOMAIN, self.id)
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
''' list of books for this shelf, overrides OrderedCollectionMixin '''
|
||||
return self.books.filter(
|
||||
listitem__approved=True
|
||||
).all().order_by('listitem')
|
||||
|
||||
class Meta:
|
||||
''' default sorting '''
|
||||
ordering = ('-updated_date',)
|
||||
|
||||
|
||||
class ListItem(ActivitypubMixin, BookWyrmModel):
|
||||
''' ok '''
|
||||
book = fields.ForeignKey(
|
||||
'Edition', on_delete=models.PROTECT, activitypub_field='object')
|
||||
book_list = fields.ForeignKey(
|
||||
'List', on_delete=models.CASCADE, activitypub_field='target')
|
||||
added_by = fields.ForeignKey(
|
||||
'User',
|
||||
on_delete=models.PROTECT,
|
||||
activitypub_field='actor'
|
||||
)
|
||||
notes = fields.TextField(blank=True, null=True)
|
||||
approved = models.BooleanField(default=True)
|
||||
order = fields.IntegerField(blank=True, null=True)
|
||||
endorsement = models.ManyToManyField('User', related_name='endorsers')
|
||||
|
||||
activity_serializer = activitypub.AddBook
|
||||
|
||||
def to_add_activity(self, user):
|
||||
''' AP for shelving a book'''
|
||||
return activitypub.Add(
|
||||
id='%s#add' % self.remote_id,
|
||||
actor=user.remote_id,
|
||||
object=self.book.to_activity(),
|
||||
target=self.book_list.remote_id,
|
||||
).serialize()
|
||||
|
||||
def to_remove_activity(self, user):
|
||||
''' AP for un-shelving a book'''
|
||||
return activitypub.Remove(
|
||||
id='%s#remove' % self.remote_id,
|
||||
actor=user.remote_id,
|
||||
object=self.book.to_activity(),
|
||||
target=self.book_list.remote_id
|
||||
).serialize()
|
||||
|
||||
class Meta:
|
||||
''' an opinionated constraint! you can't put a book on a list twice '''
|
||||
unique_together = ('book', 'book_list')
|
||||
ordering = ('-created_date',)
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
Binary file not shown.
|
@ -36,6 +36,10 @@
|
|||
<glyph unicode="" glyph-name="stars" d="M726.857 664.381l-38.857 103.619-38.857-103.619-73.143-24.381 73.143-24.381 38.857-103.619 38.857 103.619 73.143 24.381-73.143 24.381zM662.857 280.381l-38.857 103.619-38.857-103.619-73.143-24.381 73.143-24.381 38.857-103.619 38.857 103.619 73.143 24.381-73.143 24.381zM430.703 528.432l-62.703 175.568-62.703-175.568-145.297-48.432 145.297-48.432 62.703-175.568 62.703 175.568 145.297 48.432-145.297 48.432z" />
|
||||
<glyph unicode="" glyph-name="warning" d="M907.5 204.9l-345.1 558.4c-27.8 44.9-73 45-100.8 0v0l-345.1-558.4c-37.3-60.3-10.2-108.9 60.4-108.9h670.2c70.5 0 97.6 48.7 60.4 108.9zM512 192c-17.7 0-32 14.3-32 32s14.3 32 32 32 32-14.3 32-32-14.3-32-32-32zM544 351.9c0-17.4-14.3-31.9-32-31.9-17.8 0-32 14.3-32 31.9v192.2c0 17.4 14.3 31.9 32 31.9v0c17.8 0 32-14.3 32-31.9v-192.2z" />
|
||||
<glyph unicode="" glyph-name="bookmark" d="M800 32h-512v704h224v-292l113.312 86.016 110.688-86.016v292h128v-640c0-35.328-28.672-64-64-64zM625.312 572l-81.312-64v260h160v-260l-78.688 64zM192 800v-32c0-17.664 14.336-32 32-32h32v-704h-32c-35.328 0-64 28.672-64 64v704c0 35.328 28.672 64 64 64h576c23.616 0 44.032-12.928 55.136-32h-631.136c-17.664 0-32-14.304-32-32z" />
|
||||
<glyph unicode="" glyph-name="rss" horiz-adv-x="805" d="M219.429 182.857c0-60.571-49.143-109.714-109.714-109.714s-109.714 49.143-109.714 109.714 49.143 109.714 109.714 109.714 109.714-49.143 109.714-109.714zM512 112.571c0.571-10.286-2.857-20-9.714-27.429-6.857-8-16.571-12-26.857-12h-77.143c-18.857 0-34.286 14.286-36 33.143-16.571 174.286-154.857 312.571-329.143 329.143-18.857 1.714-33.143 17.143-33.143 36v77.143c0 10.286 4 20 12 26.857 6.286 6.286 15.429 9.714 24.571 9.714h2.857c121.714-9.714 236.571-62.857 322.857-149.714 86.857-86.286 140-201.143 149.714-322.857zM804.571 111.428c0.571-9.714-2.857-19.429-10.286-26.857-6.857-7.429-16-11.429-26.286-11.429h-81.714c-19.429 0-35.429 14.857-36.571 34.286-18.857 332-283.429 596.571-615.429 616-19.429 1.143-34.286 17.143-34.286 36v81.714c0 10.286 4 19.429 11.429 26.286 6.857 6.857 16 10.286 25.143 10.286h1.714c200-10.286 388-94.286 529.714-236.571 142.286-141.714 226.286-329.714 236.571-529.714z" />
|
||||
<glyph unicode="" glyph-name="heart1" d="M934.176 791.52c-116.128 115.072-301.824 117.472-422.112 9.216-120.32 108.256-305.952 105.856-422.144-9.216-119.712-118.528-119.712-310.688 0-429.28 34.208-33.888 353.696-350.112 353.696-350.112 37.856-37.504 99.072-37.504 136.896 0 0 0 349.824 346.304 353.696 350.112 119.744 118.592 119.744 310.752-0.032 429.28zM888.576 407.424l-353.696-350.112c-12.576-12.512-33.088-12.512-45.6 0l-353.696 350.112c-94.4 93.44-94.4 245.472 0 338.912 91.008 90.080 237.312 93.248 333.088 7.104l43.392-39.040 43.36 39.040c95.808 86.144 242.112 83.008 333.12-7.104 94.4-93.408 94.4-245.44 0.032-338.912zM296.096 719.968c8.864 0 16-7.168 16-16s-7.168-16-16-16h-0.032c-57.408 0-103.968-46.56-103.968-103.968v-0.032c0-8.832-7.168-16-16-16s-16 7.168-16 16v0c0 75.072 60.832 135.904 135.872 135.968 0.064 0 0.064 0.032 0.128 0.032z" />
|
||||
<glyph unicode="" glyph-name="paperplane" d="M1009.376 954.88c-5.312 3.424-11.36 5.12-17.376 5.12-6.176 0-12.384-1.76-17.76-5.376l-960-640c-9.888-6.56-15.328-18.112-14.048-29.952 1.216-11.808 8.896-22.016 19.936-26.368l250.368-100.192 117.728-206.016c5.632-9.888 16.096-16 27.424-16.128 0.128 0 0.224 0 0.352 0 11.232 0 21.664 5.952 27.424 15.552l66.464 110.816 310.24-124.064c3.808-1.536 7.808-2.272 11.872-2.272 5.44 0 10.816 1.376 15.68 4.128 8.448 4.736 14.24 13.056 15.872 22.624l160 960c2.080 12.576-3.488 25.184-14.176 32.128zM100.352 295.136l741.6 494.432-539.2-577.184c-2.848 1.696-5.376 3.936-8.512 5.184l-193.888 77.568zM326.048 189.888c-0.064 0.128-0.16 0.192-0.224 0.32l606.176 648.8-516.768-805.184-89.184 156.064zM806.944 12.512l-273.312 109.312c-6.496 2.56-13.248 3.424-19.936 3.808l420.864 652.416-127.616-765.536z" />
|
||||
<glyph unicode="" glyph-name="banknote" d="M1005.28 621.248l-320 320c-15.872 15.872-38.88 22.24-60.672 16.864-11.488-2.816-21.76-8.736-29.888-16.864-7.264-7.264-12.736-16.256-15.872-26.304-14.496-47.008-39.552-87.872-76.64-124.928-49.536-49.504-114.048-87.008-182.304-126.656-72.448-41.984-147.296-85.504-208.64-146.816-52.128-52.192-87.616-110.24-108.416-177.632-7.008-22.752-0.896-47.36 15.872-64.192l320-320c15.872-15.872 38.88-22.24 60.672-16.864 11.488 2.88 21.76 8.736 29.888 16.864 7.264 7.264 12.736 16.256 15.872 26.368 14.528 47.008 39.584 87.872 76.704 124.928 49.504 49.504 113.984 86.944 182.304 126.56 72.384 42.048 147.264 85.568 208.576 146.88 52.128 52.128 87.616 110.24 108.448 177.632 6.976 22.72 0.832 47.424-15.904 64.16zM384 0c-105.984 105.984-214.016 214.048-320 320 90.944 294.432 485.12 281.568 576 576 105.984-105.952 214.048-214.016 320.064-320-90.976-294.368-485.152-281.568-576.064-576zM625.984 483.2c-10.432 8.736-20.928 14.688-31.488 17.632-10.496 2.944-20.992 4.128-31.616 3.36-10.496-0.8-21.248-3.2-32-7.328-10.752-4.192-21.568-8.736-32.448-14.016-17.184 19.744-34.368 39.264-51.552 57.376 7.744 7.008 15.264 10.56 22.496 10.816 7.264 0.32 14.24-0.448 20.864-2.112 6.752-1.696 12.928-3.136 18.624-4.256 5.76-1.12 10.752 0.128 15.136 3.808 4.64 4 7.2 9.184 7.552 15.424 0.32 6.304-2.048 12.448-7.328 18.432-6.752 7.744-14.88 12.448-24.64 14.176-9.632 1.696-19.488 1.568-29.76-0.672-10.112-2.304-19.744-6.112-28.864-11.488s-16.448-10.88-21.888-16.256c-2.080 1.984-4.16 3.936-6.24 5.888-2.304 2.112-5.184 3.264-8.64 3.2-3.488 0-6.368-1.504-8.736-4.256-2.304-2.688-3.36-5.824-2.944-9.12 0.32-3.424 1.696-6.048 4.064-8.064 2.080-1.76 4.16-3.488 6.24-5.312-8.192-9.888-14.944-20.8-20.256-32.32-5.376-11.552-8.576-23.008-9.76-34.112-1.248-11.2-0.064-21.44 3.36-30.944 3.424-9.568 9.76-17.696 19.008-25.376 15.072-12.512 32.8-17.824 53.376-16.64 20.512 1.248 42.624 7.36 66.4 20.128 18.88-21.824 37.824-43.488 56.736-63.616-8-6.752-15.008-10.624-21.184-11.872-6.176-1.312-11.68-1.184-16.672 0.32-4.992 1.568-9.632 3.808-13.888 6.688-4.256 2.944-8.448 5.44-12.64 7.488-4.128 2.048-8.384 3.2-12.736 3.264s-8.992-2.048-14.112-6.432c-5.248-4.576-7.872-9.888-7.872-15.872 0-5.952 2.752-12 8.128-18.112 5.44-6.112 12.512-11.264 21.056-15.328s18.208-6.624 28.832-7.328c10.624-0.736 21.824 0.864 33.632 5.248 11.872 4.32 23.616 12.128 35.2 23.744 5.568-5.44 11.2-10.624 16.8-15.616 2.368-2.048 5.248-3.072 8.736-2.816 3.36 0.128 6.304 1.696 8.64 4.512 2.368 2.88 3.36 6.048 3.008 9.376-0.32 3.36-1.696 5.952-4 7.808-5.632 4.512-11.264 9.248-16.864 14.24 9.568 11.744 17.248 24.128 22.944 36.384 5.696 12.32 9.056 24.192 10.176 35.2 1.12 11.072-0.192 21.056-3.808 30.112-3.584 9.184-9.952 17.056-19.072 24.64zM447.072 461.504c-9.056-0.384-16.96 2.624-23.872 9.312-2.944 2.816-4.992 6.24-6.24 10.304-1.312 4.064-1.76 8.512-1.248 13.376 0.448 4.8 1.888 9.824 4.384 14.88 2.368 5.056 5.888 10.112 10.368 15.008 16.224-16.128 32.416-33.824 48.64-52.128-12.288-6.752-22.976-10.368-32.032-10.752zM598.016 397.44c-2.88-5.312-6.176-10.048-10.048-14.176-17.952 18.112-35.872 38.016-53.76 58.432 4.576 2.048 9.376 4.192 14.56 6.368s10.368 3.616 15.552 4.512c5.312 0.8 10.56 0.576 15.808-0.672 5.184-1.312 10.112-4.128 14.688-8.576 4.512-4.512 7.36-9.184 8.512-14.24 1.248-5.12 1.312-10.304 0.448-15.616-0.928-5.344-2.816-10.656-5.76-16.032zM470.944 250.24c6.304 5.088 15.584 4.832 21.376-1.056 6.272-6.24 6.272-16.448 0-22.688-0.512-0.512-1.056-0.864-1.632-1.312l0.064-0.064c-20.256-15.392-36.896-29.248-54.848-47.2-16.224-16.192-30.88-33.248-43.552-50.56l-20.448-28c-0.64-1.152-1.408-2.208-2.368-3.2-6.272-6.24-16.48-6.24-22.72 0-5.44 5.44-6.112 13.824-2.112 20.064l-0.064 0.064 21.888 29.888c13.664 18.688 29.376 36.992 46.752 54.368 18.080 18.144 37.6 34.336 57.6 49.696h0.064zM588.096 713.12c16.192 16.192 30.816 33.184 43.52 50.592l21.248 29.12c0.768 1.376 1.632 2.752 2.816 3.936 6.304 6.304 16.512 6.304 22.816 0 5.984-6.016 6.24-15.52 0.8-21.888l0.064-0.064-21.888-30.016c-13.696-18.688-29.376-36.928-46.752-54.304-18.080-18.080-37.568-34.336-57.568-49.696l-0.128 0.064c-6.368-5.856-16.256-5.728-22.368 0.448-6.304 6.304-6.304 16.576 0 22.88 1.12 1.184 2.432 2.016 3.744 2.752 18.816 14.368 36.96 29.44 53.696 46.176z" />
|
||||
<glyph unicode="" glyph-name="search" d="M992.262 88.604l-242.552 206.294c-25.074 22.566-51.89 32.926-73.552 31.926 57.256 67.068 91.842 154.078 91.842 249.176 0 212.078-171.922 384-384 384-212.076 0-384-171.922-384-384s171.922-384 384-384c95.098 0 182.108 34.586 249.176 91.844-1-21.662 9.36-48.478 31.926-73.552l206.294-242.552c35.322-39.246 93.022-42.554 128.22-7.356s31.892 92.898-7.354 128.22zM384 320c-141.384 0-256 114.616-256 256s114.616 256 256 256 256-114.616 256-256-114.614-256-256-256z" />
|
||||
<glyph unicode="" glyph-name="star-empty" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538zM512 206.502l-223.462-117.48 42.676 248.83-180.786 176.222 249.84 36.304 111.732 226.396 111.736-226.396 249.836-36.304-180.788-176.222 42.678-248.83-223.462 117.48z" />
|
||||
<glyph unicode="" glyph-name="star-half" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538zM512 206.502l-0.942-0.496 0.942 570.768 111.736-226.396 249.836-36.304-180.788-176.222 42.678-248.83-223.462 117.48z" />
|
||||
|
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 33 KiB |
Binary file not shown.
Binary file not shown.
|
@ -1,10 +1,10 @@
|
|||
@font-face {
|
||||
font-family: 'icomoon';
|
||||
src: url('fonts/icomoon.eot?uh765c');
|
||||
src: url('fonts/icomoon.eot?uh765c#iefix') format('embedded-opentype'),
|
||||
url('fonts/icomoon.ttf?uh765c') format('truetype'),
|
||||
url('fonts/icomoon.woff?uh765c') format('woff'),
|
||||
url('fonts/icomoon.svg?uh765c#icomoon') format('svg');
|
||||
src: url('fonts/icomoon.eot?n5x55');
|
||||
src: url('fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'),
|
||||
url('fonts/icomoon.ttf?n5x55') format('truetype'),
|
||||
url('fonts/icomoon.woff?n5x55') format('woff'),
|
||||
url('fonts/icomoon.svg?n5x55#icomoon') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
|
@ -25,7 +25,16 @@
|
|||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-sparkle:before {
|
||||
.icon-graphic-heart:before {
|
||||
content: "\e91e";
|
||||
}
|
||||
.icon-graphic-paperplane:before {
|
||||
content: "\e91f";
|
||||
}
|
||||
.icon-graphic-banknote:before {
|
||||
content: "\e920";
|
||||
}
|
||||
.icon-stars:before {
|
||||
content: "\e91a";
|
||||
}
|
||||
.icon-warning:before {
|
||||
|
@ -37,6 +46,9 @@
|
|||
.icon-bookmark:before {
|
||||
content: "\e91c";
|
||||
}
|
||||
.icon-rss:before {
|
||||
content: "\e91d";
|
||||
}
|
||||
.icon-envelope:before {
|
||||
content: "\e901";
|
||||
}
|
||||
|
|
|
@ -46,13 +46,14 @@ function back(e) {
|
|||
history.back();
|
||||
}
|
||||
|
||||
function polling(el) {
|
||||
let delay = 10000 + (Math.random() * 1000);
|
||||
function polling(el, delay) {
|
||||
delay = delay || 10000;
|
||||
delay += (Math.random() * 1000);
|
||||
setTimeout(function() {
|
||||
fetch('/api/updates/' + el.getAttribute('data-poll'))
|
||||
.then(response => response.json())
|
||||
.then(data => updateCountElement(el, data));
|
||||
polling(el);
|
||||
polling(el, delay * 1.25);
|
||||
}, delay, el);
|
||||
}
|
||||
|
||||
|
|
|
@ -147,7 +147,7 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if readthroughs.exists %}
|
||||
{% if request.user.is_authenticated %}
|
||||
<section class="block">
|
||||
<header class="columns">
|
||||
<h2 class="column title is-5 mb-1">Your reading activity</h2>
|
||||
|
@ -155,6 +155,9 @@
|
|||
{% include 'snippets/toggle/open_button.html' with text="Add read dates" icon="plus" class="is-small" controls_text="add-readthrough" %}
|
||||
</div>
|
||||
</header>
|
||||
{% if not readthroughs.exists %}
|
||||
<p>You don't have any reading activity for this book.</p>
|
||||
{% endif %}
|
||||
<section class="hidden box" id="add-readthrough">
|
||||
<form name="add-readthrough" action="/create-readthrough" method="post">
|
||||
{% include 'snippets/readthrough_form.html' with readthrough=None %}
|
||||
|
|
13
bookwyrm/templates/components/inline_form.html
Normal file
13
bookwyrm/templates/components/inline_form.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
<section class="card hidden {{ class }}" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}">
|
||||
<header class="card-header has-background-white-ter">
|
||||
<h2 class="card-header-title" tabindex="0" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}-header">
|
||||
{% block header %}{% endblock %}
|
||||
</h2>
|
||||
<span class="card-header-icon">
|
||||
{% include 'snippets/toggle/toggle_button.html' with label="Close" class="delete" nonbutton=True controls_text=controls_text %}
|
||||
</span>
|
||||
</header>
|
||||
<section class="card-content content">
|
||||
{% block form %}{% endblock %}
|
||||
</section>
|
||||
</section>
|
|
@ -2,9 +2,31 @@
|
|||
{% block content %}
|
||||
|
||||
{% if not request.user.is_authenticated %}
|
||||
<div class="block">
|
||||
<h1 class="title has-text-centered">{{ site.name }}: {{ site.instance_tagline }}</h1>
|
||||
</div>
|
||||
<header class="block has-text-centered">
|
||||
<h1 class="title">{{ site.name }}</h1>
|
||||
<h2 class="subtitle">{{ site.instance_tagline }}</h2>
|
||||
</header>
|
||||
|
||||
<section class="level is-mobile">
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title has-text-weight-normal"><span class="icon icon-graphic-paperplane"></span></p>
|
||||
<p class="heading">Decentralized</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title has-text-weight-normal"><span class="icon icon-graphic-heart"></span></p>
|
||||
<p class="heading">Friendly</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="title has-text-weight-normal"><span class="icon icon-graphic-banknote"></span></p>
|
||||
<p class="heading">Anti-Corporate</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tile is-ancestor">
|
||||
<div class="tile is-7 is-parent">
|
||||
|
@ -26,6 +48,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% else %}
|
||||
<div class="block">
|
||||
<h1 class="title has-text-centered">Discover</h1>
|
||||
|
|
|
@ -1,13 +1,22 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% block content %}
|
||||
{% extends 'user/user_layout.html' %}
|
||||
|
||||
<section class="block">
|
||||
{% 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">
|
||||
{% 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>
|
||||
|
||||
|
|
11
bookwyrm/templates/lists/create_form.html
Normal file
11
bookwyrm/templates/lists/create_form.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
{% extends 'components/inline_form.html' %}
|
||||
|
||||
{% block header %}
|
||||
Create List
|
||||
{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form name="create-list" method="post" action="{% url 'lists' %}">
|
||||
{% include 'lists/form.html' %}
|
||||
</form>
|
||||
{% endblock %}
|
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 %}
|
11
bookwyrm/templates/lists/edit_form.html
Normal file
11
bookwyrm/templates/lists/edit_form.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
{% extends 'components/inline_form.html' %}
|
||||
|
||||
{% block header %}
|
||||
Edit List
|
||||
{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form name="edit-list" method="post" action="{% url 'list' list.id %}">
|
||||
{% include 'lists/form.html' %}
|
||||
</form>
|
||||
{% endblock %}
|
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.listitem_set.all|slice:5 %}
|
||||
{% include 'snippets/book_cover.html' with book=book.book size="small" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="card-content is-flex-grow-0">
|
||||
{% if list.description %}{{ list.description | to_markdown | safe | truncatewords_html:20 }}{% endif %}
|
||||
<p class="subtitle help">Created {% if list.curation != 'open' %} and curated{% endif %} by {% include 'snippets/username.html' with user=list.user %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
24
bookwyrm/templates/lists/list_layout.html
Normal file
24
bookwyrm/templates/lists/list_layout.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% block content %}
|
||||
|
||||
<header class="columns content">
|
||||
<div class="column">
|
||||
<h1 class="title">{{ list.name }} <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span></h1>
|
||||
<p class="subtitle help">Created {% if list.curation != 'open' %} and curated{% endif %} by {% include 'snippets/username.html' with user=list.user %}</p>
|
||||
{% include 'snippets/trimmed_text.html' with full=list.description %}
|
||||
</div>
|
||||
{% if request.user == list.user %}
|
||||
<div class="column is-narrow">
|
||||
{% include 'snippets/toggle/open_button.html' with text="Edit list" icon="pencil" controls_text="edit-list" focus="edit-list-header" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<div class="block">
|
||||
{% include 'lists/edit_form.html' with controls_text="edit-list" %}
|
||||
</div>
|
||||
|
||||
{% block panel %}{% endblock %}
|
||||
|
||||
{% endblock %}
|
43
bookwyrm/templates/lists/lists.html
Normal file
43
bookwyrm/templates/lists/lists.html
Normal file
|
@ -0,0 +1,43 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% block content %}
|
||||
|
||||
<header class="block">
|
||||
<h1 class="title">Lists</h1>
|
||||
</header>
|
||||
{% if request.user.is_authenticated and not lists.has_previous %}
|
||||
<header class="block columns">
|
||||
<div class="column">
|
||||
<h2 class="title">Your lists</h2>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
{% include 'snippets/toggle/open_button.html' with controls_text="create-list" icon="plus" text="Create new list" focus="create-list-header" %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="block">
|
||||
{% include 'lists/create_form.html' with controls_text="create-list" %}
|
||||
</div>
|
||||
|
||||
<section class="block content">
|
||||
{% if request.user.list_set.exists %}
|
||||
{% include 'lists/list_items.html' with lists=request.user.list_set.all|slice:4 %}
|
||||
{% endif %}
|
||||
|
||||
{% if request.user.list_set.count > 4 %}
|
||||
<a href="{% url 'user-lists' request.user.localname %}">See all {{ request.user.list_set.count}} lists</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if lists %}
|
||||
<section class="block content">
|
||||
<h2 class="title">Recent Lists</h2>
|
||||
{% include 'lists/list_items.html' with lists=lists %}
|
||||
</section>
|
||||
<div>
|
||||
{% include 'snippets/pagination.html' with page=lists path=path %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
|
@ -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>
|
||||
|
|
|
@ -22,7 +22,8 @@
|
|||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if book_results|slice:":1" and local_results.results and request.user.is_authenticated %}
|
||||
{% if request.user.is_authenticated %}
|
||||
{% if book_results|slice:":1" and local_results.results %}
|
||||
<div class="block">
|
||||
<p>
|
||||
Didn't find what you were looking for?
|
||||
|
@ -61,19 +62,39 @@
|
|||
{% include 'snippets/toggle/close_button.html' with text="Hide results from other catalogues" small=True controls_text="more-results" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="column">
|
||||
<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 %}
|
||||
<div class="block">
|
||||
<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,7 +2,7 @@
|
|||
<div class="columns">
|
||||
<div class="column is-narrow">
|
||||
<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/shelve_button.html' with book=book %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
26
bookwyrm/templates/user/create_shelf_form.html
Normal file
26
bookwyrm/templates/user/create_shelf_form.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
{% extends 'components/inline_form.html' %}
|
||||
|
||||
{% block header %}
|
||||
Create New Shelf
|
||||
{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form name="create-shelf" action="{% url 'shelf-create' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="user" value="{{ request.user.id }}">
|
||||
<div class="field">
|
||||
<label class="label" for="id_name_create">Name:</label>
|
||||
<input type="text" name="name" maxlength="100" class="input" required="true" id="id_name_create">
|
||||
</div>
|
||||
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
{% include 'snippets/privacy_select.html' %}
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Create shelf</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
31
bookwyrm/templates/user/edit_shelf_form.html
Normal file
31
bookwyrm/templates/user/edit_shelf_form.html
Normal file
|
@ -0,0 +1,31 @@
|
|||
{% extends 'components/inline_form.html' %}
|
||||
|
||||
{% block header %}
|
||||
Edit Shelf
|
||||
{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form name="edit-shelf" action="{{ shelf.local_path }}" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="user" value="{{ request.user.id }}">
|
||||
{% if shelf.editable %}
|
||||
<div class="field">
|
||||
<label class="label" for="id_name">Name:</label>
|
||||
<input type="text" name="name" maxlength="100" class="input" required="true" value="{{ shelf.name }}" id="id_name">
|
||||
</div>
|
||||
{% else %}
|
||||
<input type="hidden" name="name" required="true" value="{{ shelf.name }}">
|
||||
{% endif %}
|
||||
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
{% include 'snippets/privacy_select.html' with current=shelf.privacy %}
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Update shelf</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
|
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 %}
|
|
@ -29,30 +29,13 @@
|
|||
|
||||
{% if is_self %}
|
||||
<div class="column is-narrow">
|
||||
{% include 'snippets/toggle/open_button.html' with text="Create shelf" icon="plus" class="is-clickable" controls_text="create-shelf-form" %}
|
||||
{% include 'snippets/toggle/open_button.html' with text="Create shelf" icon="plus" controls_text="create-shelf-form" focus="create-shelf-form-header" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="hidden box mb-5" id="create-shelf-form">
|
||||
<h2 class="title is-4">Create new shelf</h2>
|
||||
<form name="create-shelf" action="/create-shelf/" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="user" value="{{ request.user.id }}">
|
||||
<div class="field">
|
||||
<label class="label" for="id_name_create">Name:</label>
|
||||
<input type="text" name="name" maxlength="100" class="input" required="true" id="id_name_create">
|
||||
</div>
|
||||
|
||||
<label class="label">
|
||||
<p>Shelf privacy:</p>
|
||||
{% include 'snippets/privacy_select.html' with no_label=True %}
|
||||
</label>
|
||||
<div class="field is-grouped">
|
||||
<button class="button is-primary" type="submit">Create shelf</button>
|
||||
{% include 'snippets/toggle/close_button.html' with text="Cancel" controls_text="create-shelf-form" %}
|
||||
</div>
|
||||
</form>
|
||||
<div class="block">
|
||||
{% include 'user/create_shelf_form.html' with controls_text='create-shelf-form' %}
|
||||
</div>
|
||||
|
||||
<div class="block columns">
|
||||
|
@ -66,34 +49,13 @@
|
|||
</div>
|
||||
{% if is_self %}
|
||||
<div class="column is-narrow">
|
||||
{% include 'snippets/toggle/open_button.html' with text="Edit shelf" icon="pencil" class="is-clickable" controls_text="edit-shelf-form" %}
|
||||
{% include 'snippets/toggle/open_button.html' with text="Edit shelf" icon="pencil" controls_text="edit-shelf-form" focus="edit-shelf-form-header" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="hidden box mb-5" id="edit-shelf-form">
|
||||
<h2 class="title is-4">Edit shelf</h2>
|
||||
<form name="create-shelf" action="{{ shelf.local_path }}" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="user" value="{{ request.user.id }}">
|
||||
{% if shelf.editable %}
|
||||
<div class="field">
|
||||
<label class="label" for="id_name">Name:</label>
|
||||
<input type="text" name="name" maxlength="100" class="input" required="true" value="{{ shelf.name }}" id="id_name">
|
||||
</div>
|
||||
{% else %}
|
||||
<input type="hidden" name="name" required="true" value="{{ shelf.name }}">
|
||||
{% endif %}
|
||||
|
||||
<label class="label">
|
||||
<p>Shelf privacy:</p>
|
||||
{% include 'snippets/privacy_select.html' with no_label=True current=shelf.privacy %}
|
||||
</label>
|
||||
<div class="field is-grouped">
|
||||
<button class="button is-primary" type="submit">Update shelf</button>
|
||||
{% include 'snippets/toggle/close_button.html' with text="Cancel" controls_text="edit-shelf-form" %}
|
||||
</div>
|
||||
</form>
|
||||
<div class="block">
|
||||
{% include 'user/edit_shelf_form.html' with controls_text="edit-shelf-form" %}
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
|
|
|
@ -67,6 +67,31 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% with user|username as username %}
|
||||
{% if 'user/'|add:username|add:'/shelf' not in request.path and 'user/'|add:username|add:'/shelves' not in request.path %}
|
||||
<nav class="tabs">
|
||||
<ul>
|
||||
{% url 'user-feed' user|username as url %}
|
||||
<li{% if url == request.path or url == request.path|add:'/' %} class="is-active"{% endif %}>
|
||||
<a href="{{ url }}">Activity</a>
|
||||
</li>
|
||||
{% now 'Y' as year %}
|
||||
{% url 'user-goal' user|username year as url %}
|
||||
<li{% if url in request.path %} class="is-active"{% endif %}>
|
||||
<a href="{{ url }}">Reading Goal</a>
|
||||
</li>
|
||||
{% url 'user-lists' user|username as url %}
|
||||
<li{% if url in request.path %} class="is-active"{% endif %}>
|
||||
<a href="{{ url }}">Lists</a>
|
||||
</li>
|
||||
{% url 'user-shelves' user|username as url %}
|
||||
<li{% if url in request.path %} class="is-active"{% endif %}>
|
||||
<a href="{{ url }}">Shelves</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block panel %}{% endblock %}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -190,3 +190,25 @@ class Openlibrary(TestCase):
|
|||
''' detect if the loaded json is an edition '''
|
||||
edition = pick_default_edition(self.edition_list_data['entries'])
|
||||
self.assertEqual(edition['key'], '/books/OL9788823M')
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_create_edition_from_data(self):
|
||||
''' okay but can it actually create an edition with proper metadata '''
|
||||
work = models.Work.objects.create(title='Hello')
|
||||
responses.add(
|
||||
responses.GET,
|
||||
'https://openlibrary.org/authors/OL382982A',
|
||||
json={'hi': 'there'},
|
||||
status=200)
|
||||
result = self.connector.create_edition_from_data(
|
||||
work, self.edition_data)
|
||||
self.assertEqual(result.parent_work, work)
|
||||
self.assertEqual(result.title, 'Sabriel')
|
||||
self.assertEqual(result.isbn_10, '0060273224')
|
||||
self.assertIsNotNone(result.description)
|
||||
self.assertEqual(result.languages[0], 'English')
|
||||
self.assertEqual(result.publishers[0], 'Harper Trophy')
|
||||
self.assertEqual(result.pages, 491)
|
||||
self.assertEqual(result.subjects[0], 'Fantasy.')
|
||||
self.assertEqual(result.physical_format, 'Hardcover')
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
"518848"
|
||||
]
|
||||
},
|
||||
"physical_format": "Hardcover",
|
||||
"lc_classifications": [
|
||||
"PZ7.N647 Sab 1995"
|
||||
],
|
||||
|
|
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):
|
||||
|
|
|
@ -33,7 +33,7 @@ class AuthenticationViews(TestCase):
|
|||
|
||||
result = login(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'login.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
request.user = self.local_user
|
||||
|
@ -94,7 +94,7 @@ class AuthenticationViews(TestCase):
|
|||
})
|
||||
response = view(request)
|
||||
self.assertEqual(models.User.objects.count(), 1)
|
||||
self.assertEqual(response.template_name, 'login.html')
|
||||
response.render()
|
||||
|
||||
def test_register_invalid_username(self):
|
||||
''' gotta have an email '''
|
||||
|
@ -109,7 +109,7 @@ class AuthenticationViews(TestCase):
|
|||
})
|
||||
response = view(request)
|
||||
self.assertEqual(models.User.objects.count(), 1)
|
||||
self.assertEqual(response.template_name, 'login.html')
|
||||
response.render()
|
||||
|
||||
request = self.factory.post(
|
||||
'register/',
|
||||
|
@ -120,7 +120,7 @@ class AuthenticationViews(TestCase):
|
|||
})
|
||||
response = view(request)
|
||||
self.assertEqual(models.User.objects.count(), 1)
|
||||
self.assertEqual(response.template_name, 'login.html')
|
||||
response.render()
|
||||
|
||||
request = self.factory.post(
|
||||
'register/',
|
||||
|
@ -131,7 +131,7 @@ class AuthenticationViews(TestCase):
|
|||
})
|
||||
response = view(request)
|
||||
self.assertEqual(models.User.objects.count(), 1)
|
||||
self.assertEqual(response.template_name, 'login.html')
|
||||
response.render()
|
||||
|
||||
|
||||
def test_register_closed_instance(self):
|
||||
|
|
|
@ -34,6 +34,7 @@ class AuthorViews(TestCase):
|
|||
remote_id='https://example.com/book/1',
|
||||
parent_work=self.work
|
||||
)
|
||||
models.SiteSettings.objects.create()
|
||||
|
||||
|
||||
def test_author_page(self):
|
||||
|
@ -45,7 +46,8 @@ class AuthorViews(TestCase):
|
|||
is_api.return_value = False
|
||||
result = view(request, author.id)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'author.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
request = self.factory.get('')
|
||||
|
@ -66,7 +68,8 @@ class AuthorViews(TestCase):
|
|||
|
||||
result = view(request, author.id)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'edit_author.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
|
@ -116,4 +119,5 @@ class AuthorViews(TestCase):
|
|||
resp = view(request, author.id)
|
||||
author.refresh_from_db()
|
||||
self.assertEqual(author.name, 'Test Author')
|
||||
self.assertEqual(resp.template_name, 'edit_author.html')
|
||||
resp.render()
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
|
|
@ -23,6 +23,7 @@ class BlockViews(TestCase):
|
|||
inbox='https://example.com/users/rat/inbox',
|
||||
outbox='https://example.com/users/rat/outbox',
|
||||
)
|
||||
models.SiteSettings.objects.create()
|
||||
|
||||
|
||||
def test_block_get(self):
|
||||
|
@ -32,7 +33,7 @@ class BlockViews(TestCase):
|
|||
request.user = self.local_user
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'preferences/blocks.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
def test_block_post(self):
|
||||
|
|
|
@ -33,6 +33,7 @@ class BookViews(TestCase):
|
|||
remote_id='https://example.com/book/1',
|
||||
parent_work=self.work
|
||||
)
|
||||
models.SiteSettings.objects.create()
|
||||
|
||||
|
||||
def test_book_page(self):
|
||||
|
@ -44,7 +45,7 @@ class BookViews(TestCase):
|
|||
is_api.return_value = False
|
||||
result = view(request, self.book.id)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'book.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
request = self.factory.get('')
|
||||
|
@ -63,7 +64,7 @@ class BookViews(TestCase):
|
|||
request.user.is_superuser = True
|
||||
result = view(request, self.book.id)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'edit_book.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
|
@ -116,7 +117,7 @@ class BookViews(TestCase):
|
|||
is_api.return_value = False
|
||||
result = view(request, self.work.id)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'editions.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
request = self.factory.get('')
|
||||
|
|
|
@ -15,6 +15,7 @@ class FederationViews(TestCase):
|
|||
self.local_user = models.User.objects.create_user(
|
||||
'mouse@local.com', 'mouse@mouse.mouse', 'password',
|
||||
local=True, localname='mouse')
|
||||
models.SiteSettings.objects.create()
|
||||
|
||||
|
||||
def test_federation_page(self):
|
||||
|
@ -25,5 +26,5 @@ class FederationViews(TestCase):
|
|||
request.user.is_superuser = True
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'settings/federation.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
|
|
@ -21,6 +21,7 @@ class FeedMessageViews(TestCase):
|
|||
title='Example Edition',
|
||||
remote_id='https://example.com/book/1',
|
||||
)
|
||||
models.SiteSettings.objects.create()
|
||||
|
||||
|
||||
def test_feed(self):
|
||||
|
@ -30,7 +31,7 @@ class FeedMessageViews(TestCase):
|
|||
request.user = self.local_user
|
||||
result = view(request, 'local')
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'feed/feed.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
|
@ -45,7 +46,7 @@ class FeedMessageViews(TestCase):
|
|||
is_api.return_value = False
|
||||
result = view(request, 'mouse', status.id)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'feed/status.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
with patch('bookwyrm.views.feed.is_api_request') as is_api:
|
||||
|
@ -66,7 +67,7 @@ class FeedMessageViews(TestCase):
|
|||
is_api.return_value = False
|
||||
result = view(request, 'mouse', status.id)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'feed/status.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
with patch('bookwyrm.views.feed.is_api_request') as is_api:
|
||||
|
@ -83,7 +84,7 @@ class FeedMessageViews(TestCase):
|
|||
request.user = self.local_user
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'feed/direct_messages.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ class ImportViews(TestCase):
|
|||
self.local_user = models.User.objects.create_user(
|
||||
'mouse@local.com', 'mouse@mouse.mouse', 'password',
|
||||
local=True, localname='mouse')
|
||||
models.SiteSettings.objects.create()
|
||||
|
||||
|
||||
def test_import_page(self):
|
||||
|
@ -25,7 +26,7 @@ class ImportViews(TestCase):
|
|||
request.user = self.local_user
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'import.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
|
@ -39,5 +40,5 @@ class ImportViews(TestCase):
|
|||
async_result.return_value = []
|
||||
result = view(request, import_job.id)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'import_status.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
|
|
@ -18,6 +18,7 @@ class InviteViews(TestCase):
|
|||
self.local_user = models.User.objects.create_user(
|
||||
'mouse@local.com', 'mouse@mouse.mouse', 'password',
|
||||
local=True, localname='mouse')
|
||||
models.SiteSettings.objects.create()
|
||||
|
||||
|
||||
def test_invite_page(self):
|
||||
|
@ -32,7 +33,7 @@ class InviteViews(TestCase):
|
|||
invite.return_value = True
|
||||
result = view(request, 'hi')
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'invite.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
|
@ -44,5 +45,5 @@ class InviteViews(TestCase):
|
|||
request.user.is_superuser = True
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'settings/manage_invites.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
|
|
@ -18,6 +18,7 @@ class LandingViews(TestCase):
|
|||
local=True, localname='mouse')
|
||||
self.anonymous_user = AnonymousUser
|
||||
self.anonymous_user.is_authenticated = False
|
||||
models.SiteSettings.objects.create()
|
||||
|
||||
|
||||
def test_home_page(self):
|
||||
|
@ -27,13 +28,13 @@ class LandingViews(TestCase):
|
|||
request.user = self.local_user
|
||||
result = view(request)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(result.template_name, 'feed/feed.html')
|
||||
result.render()
|
||||
|
||||
request.user = self.anonymous_user
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(result.template_name, 'discover.html')
|
||||
result.render()
|
||||
|
||||
|
||||
def test_about_page(self):
|
||||
|
@ -43,7 +44,7 @@ class LandingViews(TestCase):
|
|||
request.user = self.local_user
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'about.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
|
@ -53,5 +54,3 @@ class LandingViews(TestCase):
|
|||
request = self.factory.get('')
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'discover.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
|
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())
|
|
@ -15,6 +15,7 @@ class NotificationViews(TestCase):
|
|||
self.local_user = models.User.objects.create_user(
|
||||
'mouse@local.com', 'mouse@mouse.mouse', 'password',
|
||||
local=True, localname='mouse')
|
||||
models.SiteSettings.objects.create()
|
||||
|
||||
def test_notifications_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
|
@ -23,7 +24,7 @@ class NotificationViews(TestCase):
|
|||
request.user = self.local_user
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'notifications.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
def test_clear_notifications(self):
|
||||
|
|
|
@ -19,6 +19,7 @@ class PasswordViews(TestCase):
|
|||
local=True, localname='mouse')
|
||||
self.anonymous_user = AnonymousUser
|
||||
self.anonymous_user.is_authenticated = False
|
||||
models.SiteSettings.objects.create(id=1)
|
||||
|
||||
|
||||
def test_password_reset_request(self):
|
||||
|
@ -29,7 +30,7 @@ class PasswordViews(TestCase):
|
|||
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'password_reset_request.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
|
@ -43,7 +44,7 @@ class PasswordViews(TestCase):
|
|||
request = self.factory.post('', {'email': 'mouse@mouse.com'})
|
||||
with patch('bookwyrm.emailing.send_email.delay'):
|
||||
resp = view(request)
|
||||
self.assertEqual(resp.template_name, 'password_reset_request.html')
|
||||
resp.render()
|
||||
|
||||
self.assertEqual(
|
||||
models.PasswordReset.objects.get().user, self.local_user)
|
||||
|
@ -56,7 +57,7 @@ class PasswordViews(TestCase):
|
|||
request.user = self.anonymous_user
|
||||
result = view(request, code.code)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'password_reset.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
|
@ -82,7 +83,7 @@ class PasswordViews(TestCase):
|
|||
'confirm-password': 'hi'
|
||||
})
|
||||
resp = view(request, 'jhgdkfjgdf')
|
||||
self.assertEqual(resp.template_name, 'password_reset.html')
|
||||
resp.render()
|
||||
self.assertTrue(models.PasswordReset.objects.exists())
|
||||
|
||||
def test_password_reset_mismatch(self):
|
||||
|
@ -94,7 +95,7 @@ class PasswordViews(TestCase):
|
|||
'confirm-password': 'hihi'
|
||||
})
|
||||
resp = view(request, code.code)
|
||||
self.assertEqual(resp.template_name, 'password_reset.html')
|
||||
resp.render()
|
||||
self.assertTrue(models.PasswordReset.objects.exists())
|
||||
|
||||
|
||||
|
@ -106,7 +107,7 @@ class PasswordViews(TestCase):
|
|||
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'preferences/change_password.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
|
|
|
@ -33,6 +33,7 @@ class ShelfViews(TestCase):
|
|||
connector_file='self_connector',
|
||||
local=True
|
||||
)
|
||||
models.SiteSettings.objects.create()
|
||||
|
||||
|
||||
def test_search_json_response(self):
|
||||
|
@ -82,6 +83,7 @@ class ShelfViews(TestCase):
|
|||
)
|
||||
|
||||
request = self.factory.get('', {'q': 'Test Book'})
|
||||
request.user = self.local_user
|
||||
with patch('bookwyrm.views.search.is_api_request') as is_api:
|
||||
is_api.return_value = False
|
||||
with patch(
|
||||
|
@ -89,7 +91,7 @@ class ShelfViews(TestCase):
|
|||
manager.return_value = [search_result]
|
||||
response = view(request)
|
||||
self.assertIsInstance(response, TemplateResponse)
|
||||
self.assertEqual(response.template_name, 'search_results.html')
|
||||
response.render()
|
||||
self.assertEqual(
|
||||
response.context_data['book_results'][0].title, 'Gideon the Ninth')
|
||||
|
||||
|
@ -98,11 +100,12 @@ class ShelfViews(TestCase):
|
|||
''' searches remote connectors '''
|
||||
view = views.Search.as_view()
|
||||
request = self.factory.get('', {'q': 'mouse'})
|
||||
request.user = self.local_user
|
||||
with patch('bookwyrm.views.search.is_api_request') as is_api:
|
||||
is_api.return_value = False
|
||||
with patch('bookwyrm.connectors.connector_manager.search'):
|
||||
response = view(request)
|
||||
self.assertIsInstance(response, TemplateResponse)
|
||||
self.assertEqual(response.template_name, 'search_results.html')
|
||||
response.render()
|
||||
self.assertEqual(
|
||||
response.context_data['user_results'][0], self.local_user)
|
||||
|
|
|
@ -8,6 +8,7 @@ from bookwyrm import models, views
|
|||
from bookwyrm.activitypub import ActivitypubResponse
|
||||
|
||||
|
||||
@patch('bookwyrm.broadcast.broadcast_task.delay')
|
||||
class ShelfViews(TestCase):
|
||||
''' tag views'''
|
||||
def setUp(self):
|
||||
|
@ -29,9 +30,10 @@ class ShelfViews(TestCase):
|
|||
identifier='test-shelf',
|
||||
user=self.local_user
|
||||
)
|
||||
models.SiteSettings.objects.create()
|
||||
|
||||
|
||||
def test_shelf_page(self):
|
||||
def test_shelf_page(self, _):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
view = views.Shelf.as_view()
|
||||
shelf = self.local_user.shelf_set.first()
|
||||
|
@ -41,7 +43,7 @@ class ShelfViews(TestCase):
|
|||
is_api.return_value = False
|
||||
result = view(request, self.local_user.username, shelf.identifier)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'user/shelf.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
with patch('bookwyrm.views.shelf.is_api_request') as is_api:
|
||||
|
@ -61,7 +63,7 @@ class ShelfViews(TestCase):
|
|||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_edit_shelf_privacy(self):
|
||||
def test_edit_shelf_privacy(self, _):
|
||||
''' set name or privacy on shelf '''
|
||||
view = views.Shelf.as_view()
|
||||
shelf = self.local_user.shelf_set.get(identifier='to-read')
|
||||
|
@ -80,7 +82,7 @@ class ShelfViews(TestCase):
|
|||
self.assertEqual(shelf.privacy, 'unlisted')
|
||||
|
||||
|
||||
def test_edit_shelf_name(self):
|
||||
def test_edit_shelf_name(self, _):
|
||||
''' change the name of an editable shelf '''
|
||||
view = views.Shelf.as_view()
|
||||
shelf = models.Shelf.objects.create(
|
||||
|
@ -101,7 +103,7 @@ class ShelfViews(TestCase):
|
|||
self.assertEqual(shelf.identifier, 'testshelf-%d' % shelf.id)
|
||||
|
||||
|
||||
def test_edit_shelf_name_not_editable(self):
|
||||
def test_edit_shelf_name_not_editable(self, _):
|
||||
''' can't change the name of an non-editable shelf '''
|
||||
view = views.Shelf.as_view()
|
||||
shelf = self.local_user.shelf_set.get(identifier='to-read')
|
||||
|
@ -119,20 +121,19 @@ class ShelfViews(TestCase):
|
|||
self.assertEqual(shelf.name, 'To Read')
|
||||
|
||||
|
||||
def test_handle_shelve(self):
|
||||
def test_handle_shelve(self, _):
|
||||
''' shelve a book '''
|
||||
request = self.factory.post('', {
|
||||
'book': self.book.id,
|
||||
'shelf': self.shelf.identifier
|
||||
})
|
||||
request.user = self.local_user
|
||||
with patch('bookwyrm.broadcast.broadcast_task.delay'):
|
||||
views.shelve(request)
|
||||
# make sure the book is on the shelf
|
||||
self.assertEqual(self.shelf.books.get(), self.book)
|
||||
|
||||
|
||||
def test_handle_shelve_to_read(self):
|
||||
def test_handle_shelve_to_read(self, _):
|
||||
''' special behavior for the to-read shelf '''
|
||||
shelf = models.Shelf.objects.get(identifier='to-read')
|
||||
request = self.factory.post('', {
|
||||
|
@ -141,13 +142,12 @@ class ShelfViews(TestCase):
|
|||
})
|
||||
request.user = self.local_user
|
||||
|
||||
with patch('bookwyrm.broadcast.broadcast_task.delay'):
|
||||
views.shelve(request)
|
||||
# make sure the book is on the shelf
|
||||
self.assertEqual(shelf.books.get(), self.book)
|
||||
|
||||
|
||||
def test_handle_shelve_reading(self):
|
||||
def test_handle_shelve_reading(self, _):
|
||||
''' special behavior for the reading shelf '''
|
||||
shelf = models.Shelf.objects.get(identifier='reading')
|
||||
request = self.factory.post('', {
|
||||
|
@ -156,13 +156,12 @@ class ShelfViews(TestCase):
|
|||
})
|
||||
request.user = self.local_user
|
||||
|
||||
with patch('bookwyrm.broadcast.broadcast_task.delay'):
|
||||
views.shelve(request)
|
||||
# make sure the book is on the shelf
|
||||
self.assertEqual(shelf.books.get(), self.book)
|
||||
|
||||
|
||||
def test_handle_shelve_read(self):
|
||||
def test_handle_shelve_read(self, _):
|
||||
''' special behavior for the read shelf '''
|
||||
shelf = models.Shelf.objects.get(identifier='read')
|
||||
request = self.factory.post('', {
|
||||
|
@ -177,7 +176,7 @@ class ShelfViews(TestCase):
|
|||
self.assertEqual(shelf.books.get(), self.book)
|
||||
|
||||
|
||||
def test_handle_unshelve(self):
|
||||
def test_handle_unshelve(self, _):
|
||||
''' remove a book from a shelf '''
|
||||
self.shelf.books.add(self.book)
|
||||
self.shelf.save()
|
||||
|
|
|
@ -33,6 +33,7 @@ class TagViews(TestCase):
|
|||
remote_id='https://example.com/book/1',
|
||||
parent_work=self.work
|
||||
)
|
||||
models.SiteSettings.objects.create()
|
||||
|
||||
|
||||
def test_tag_page(self):
|
||||
|
@ -46,7 +47,7 @@ class TagViews(TestCase):
|
|||
is_api.return_value = False
|
||||
result = view(request, tag.identifier)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'tag.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
request = self.factory.get('')
|
||||
|
|
|
@ -23,6 +23,7 @@ class UserViews(TestCase):
|
|||
self.rat = models.User.objects.create_user(
|
||||
'rat@local.com', 'rat@rat.rat', 'password',
|
||||
local=True, localname='rat')
|
||||
models.SiteSettings.objects.create()
|
||||
|
||||
|
||||
def test_user_page(self):
|
||||
|
@ -34,7 +35,7 @@ class UserViews(TestCase):
|
|||
is_api.return_value = False
|
||||
result = view(request, 'mouse')
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'user/user.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
with patch('bookwyrm.views.user.is_api_request') as is_api:
|
||||
|
@ -65,7 +66,7 @@ class UserViews(TestCase):
|
|||
is_api.return_value = False
|
||||
result = view(request, 'mouse')
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'user/followers.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
with patch('bookwyrm.views.user.is_api_request') as is_api:
|
||||
|
@ -96,7 +97,7 @@ class UserViews(TestCase):
|
|||
is_api.return_value = False
|
||||
result = view(request, 'mouse')
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'user/following.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
with patch('bookwyrm.views.user.is_api_request') as is_api:
|
||||
|
@ -125,7 +126,7 @@ class UserViews(TestCase):
|
|||
request.user = self.local_user
|
||||
result = view(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'preferences/edit_user.html')
|
||||
result.render()
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
|
|
|
@ -78,23 +78,40 @@ urlpatterns = [
|
|||
re_path(r'^import/(\d+)/?$', views.ImportStatus.as_view()),
|
||||
|
||||
# users
|
||||
re_path(r'%s/?$' % user_path, views.User.as_view()),
|
||||
re_path(r'%s/?$' % user_path, views.User.as_view(), name='user-feed'),
|
||||
re_path(r'%s\.json$' % user_path, views.User.as_view()),
|
||||
re_path(r'%s/shelves/?$' % user_path, views.user_shelves_page),
|
||||
re_path(r'%s/followers(.json)?/?$' % user_path, views.Followers.as_view()),
|
||||
re_path(r'%s/following(.json)?/?$' % user_path, views.Following.as_view()),
|
||||
re_path(r'%s/rss' % user_path, views.rss_feed.RssFeed()),
|
||||
re_path(r'%s/rss' % user_path, views.rss_feed.RssFeed(), name='user-rss'),
|
||||
re_path(r'%s/followers(.json)?/?$' % user_path,
|
||||
views.Followers.as_view(), name='user-followers'),
|
||||
re_path(r'%s/following(.json)?/?$' % user_path,
|
||||
views.Following.as_view(), name='user-following'),
|
||||
re_path(r'%s/shelves/?$' % user_path,
|
||||
views.user_shelves_page, name='user-shelves'),
|
||||
re_path(r'%s/lists/?$' % user_path,
|
||||
views.UserLists.as_view(), name='user-lists'),
|
||||
re_path(r'%s/goal/(?P<year>\d{4})/?$' % user_path,
|
||||
views.Goal.as_view(), name='user-goal'),
|
||||
|
||||
|
||||
# lists
|
||||
re_path(r'^list/?$', views.Lists.as_view(), name='lists'),
|
||||
re_path(r'^list/(?P<list_id>\d+)(.json)?/?$',
|
||||
views.List.as_view(), name='list'),
|
||||
re_path(r'^list/(?P<list_id>\d+)/add/?$',
|
||||
views.list.add_book, name='list-add-book'),
|
||||
re_path(r'^list/(?P<list_id>\d+)/remove/?$',
|
||||
views.list.remove_book, name='list-remove-book'),
|
||||
re_path(r'^list/(?P<list_id>\d+)/curate/?$',
|
||||
views.Curate.as_view(), name='list-curate'),
|
||||
|
||||
# preferences
|
||||
re_path(r'^preferences/profile/?$', views.EditUser.as_view()),
|
||||
re_path(r'^preferences/profile/?$',
|
||||
views.EditUser.as_view(), name='prefs-profile'),
|
||||
re_path(r'^preferences/password/?$', views.ChangePassword.as_view()),
|
||||
re_path(r'^preferences/block/?$', views.Block.as_view()),
|
||||
re_path(r'^block/(?P<user_id>\d+)/?$', views.Block.as_view()),
|
||||
re_path(r'^unblock/(?P<user_id>\d+)/?$', views.unblock),
|
||||
|
||||
# reading goals
|
||||
re_path(r'%s/goal/(?P<year>\d{4})/?$' % user_path, views.Goal.as_view()),
|
||||
|
||||
# statuses
|
||||
re_path(r'%s(.json)?/?$' % status_path, views.Status.as_view()),
|
||||
re_path(r'%s/activity/?$' % status_path, views.Status.as_view()),
|
||||
|
@ -130,10 +147,10 @@ urlpatterns = [
|
|||
|
||||
# shelf
|
||||
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % \
|
||||
user_path, views.Shelf.as_view()),
|
||||
user_path, views.Shelf.as_view(), name='shelf'),
|
||||
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % \
|
||||
local_user_path, views.Shelf.as_view()),
|
||||
re_path(r'^create-shelf/?$', views.create_shelf),
|
||||
re_path(r'^create-shelf/?$', views.create_shelf, name='shelf-create'),
|
||||
re_path(r'^delete-shelf/(?P<shelf_id>\d+)?$', views.delete_shelf),
|
||||
re_path(r'^shelve/?$', views.shelve),
|
||||
re_path(r'^unshelve/?$', views.unshelve),
|
||||
|
|
|
@ -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:
|
||||
|
|
253
bookwyrm/views/list.py
Normal file
253
bookwyrm/views/list.py
Normal file
|
@ -0,0 +1,253 @@
|
|||
''' book list views'''
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Count, Q
|
||||
from django.http import HttpResponseNotFound, HttpResponseBadRequest
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from bookwyrm import forms, models
|
||||
from bookwyrm.activitypub import ActivitypubResponse
|
||||
from bookwyrm.broadcast import broadcast
|
||||
from bookwyrm.connectors import connector_manager
|
||||
from .helpers import is_api_request, object_visible_to_user, privacy_filter
|
||||
from .helpers import get_user_from_username
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
class Lists(View):
|
||||
''' book list page '''
|
||||
def get(self, request):
|
||||
''' display a book list '''
|
||||
try:
|
||||
page = int(request.GET.get('page', 1))
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
user = request.user if request.user.is_authenticated else None
|
||||
# hide lists with no approved books
|
||||
lists = models.List.objects.filter(
|
||||
~Q(user=user),
|
||||
).annotate(
|
||||
item_count=Count('listitem', filter=Q(listitem__approved=True))
|
||||
).filter(
|
||||
item_count__gt=0
|
||||
).distinct().all()
|
||||
lists = privacy_filter(request.user, lists, ['public', 'followers'])
|
||||
|
||||
paginated = Paginator(lists, 12)
|
||||
data = {
|
||||
'title': 'Lists',
|
||||
'lists': paginated.page(page),
|
||||
'list_form': forms.ListForm(),
|
||||
'path': '/list',
|
||||
}
|
||||
return TemplateResponse(request, 'lists/lists.html', data)
|
||||
|
||||
@method_decorator(login_required, name='dispatch')
|
||||
# pylint: disable=unused-argument
|
||||
def post(self, request):
|
||||
''' create a book_list '''
|
||||
form = forms.ListForm(request.POST)
|
||||
if not form.is_valid():
|
||||
return redirect('lists')
|
||||
book_list = form.save()
|
||||
|
||||
# let the world know
|
||||
broadcast(
|
||||
request.user,
|
||||
book_list.to_create_activity(request.user),
|
||||
privacy=book_list.privacy,
|
||||
software='bookwyrm'
|
||||
)
|
||||
return redirect(book_list.local_path)
|
||||
|
||||
class UserLists(View):
|
||||
''' a user's book list page '''
|
||||
def get(self, request, username):
|
||||
''' display a book list '''
|
||||
try:
|
||||
page = int(request.GET.get('page', 1))
|
||||
except ValueError:
|
||||
page = 1
|
||||
user = get_user_from_username(username)
|
||||
lists = models.List.objects.filter(user=user).all()
|
||||
lists = privacy_filter(
|
||||
request.user, lists, ['public', 'followers', 'unlisted'])
|
||||
paginated = Paginator(lists, 12)
|
||||
|
||||
data = {
|
||||
'title': '%s: Lists' % user.name,
|
||||
'user': user,
|
||||
'is_self': request.user.id == user.id,
|
||||
'lists': paginated.page(page),
|
||||
'list_form': forms.ListForm(),
|
||||
'path': user.local_path + '/lists',
|
||||
}
|
||||
return TemplateResponse(request, 'user/lists.html', data)
|
||||
|
||||
|
||||
class List(View):
|
||||
''' book list page '''
|
||||
def get(self, request, list_id):
|
||||
''' display a book list '''
|
||||
book_list = get_object_or_404(models.List, id=list_id)
|
||||
if not object_visible_to_user(request.user, book_list):
|
||||
return HttpResponseNotFound()
|
||||
|
||||
if is_api_request(request):
|
||||
return ActivitypubResponse(book_list.to_activity(**request.GET))
|
||||
|
||||
query = request.GET.get('q')
|
||||
suggestions = None
|
||||
if query and request.user.is_authenticated:
|
||||
# search for books
|
||||
suggestions = connector_manager.local_search(query, raw=True)
|
||||
elif request.user.is_authenticated:
|
||||
# just suggest whatever books are nearby
|
||||
suggestions = request.user.shelfbook_set.filter(
|
||||
~Q(book__in=book_list.books.all())
|
||||
)
|
||||
suggestions = [s.book for s in suggestions[:5]]
|
||||
if len(suggestions) < 5:
|
||||
suggestions += [
|
||||
s.default_edition for s in \
|
||||
models.Work.objects.filter(
|
||||
~Q(editions__in=book_list.books.all()),
|
||||
).order_by('-updated_date')
|
||||
][:5 - len(suggestions)]
|
||||
|
||||
|
||||
data = {
|
||||
'title': '%s | Lists' % book_list.name,
|
||||
'list': book_list,
|
||||
'items': book_list.listitem_set.filter(approved=True),
|
||||
'pending_count': book_list.listitem_set.filter(
|
||||
approved=False).count(),
|
||||
'suggested_books': suggestions,
|
||||
'list_form': forms.ListForm(instance=book_list),
|
||||
'query': query or ''
|
||||
}
|
||||
return TemplateResponse(request, 'lists/list.html', data)
|
||||
|
||||
|
||||
@method_decorator(login_required, name='dispatch')
|
||||
# pylint: disable=unused-argument
|
||||
def post(self, request, list_id):
|
||||
''' edit a book_list '''
|
||||
book_list = get_object_or_404(models.List, id=list_id)
|
||||
form = forms.ListForm(request.POST, instance=book_list)
|
||||
if not form.is_valid():
|
||||
return redirect('list', book_list.id)
|
||||
book_list = form.save()
|
||||
# let the world know
|
||||
broadcast(
|
||||
request.user,
|
||||
book_list.to_update_activity(request.user),
|
||||
privacy=book_list.privacy,
|
||||
software='bookwyrm'
|
||||
)
|
||||
return redirect(book_list.local_path)
|
||||
|
||||
|
||||
class Curate(View):
|
||||
''' approve or discard list suggestsions '''
|
||||
@method_decorator(login_required, name='dispatch')
|
||||
def get(self, request, list_id):
|
||||
''' display a pending list '''
|
||||
book_list = get_object_or_404(models.List, id=list_id)
|
||||
if not book_list.user == request.user:
|
||||
# only the creater can curate the list
|
||||
return HttpResponseNotFound()
|
||||
|
||||
data = {
|
||||
'title': 'Curate "%s" | Lists' % book_list.name,
|
||||
'list': book_list,
|
||||
'pending': book_list.listitem_set.filter(approved=False),
|
||||
'list_form': forms.ListForm(instance=book_list),
|
||||
}
|
||||
return TemplateResponse(request, 'lists/curate.html', data)
|
||||
|
||||
|
||||
@method_decorator(login_required, name='dispatch')
|
||||
# pylint: disable=unused-argument
|
||||
def post(self, request, list_id):
|
||||
''' edit a book_list '''
|
||||
book_list = get_object_or_404(models.List, id=list_id)
|
||||
suggestion = get_object_or_404(
|
||||
models.ListItem, id=request.POST.get('item'))
|
||||
approved = request.POST.get('approved') == 'true'
|
||||
if approved:
|
||||
suggestion.approved = True
|
||||
suggestion.save()
|
||||
# let the world know
|
||||
broadcast(
|
||||
request.user,
|
||||
suggestion.to_add_activity(request.user),
|
||||
privacy=book_list.privacy,
|
||||
software='bookwyrm'
|
||||
)
|
||||
else:
|
||||
suggestion.delete()
|
||||
return redirect('list-curate', book_list.id)
|
||||
|
||||
|
||||
@require_POST
|
||||
def add_book(request, list_id):
|
||||
''' put a book on a list '''
|
||||
book_list = get_object_or_404(models.List, id=list_id)
|
||||
if not object_visible_to_user(request.user, book_list):
|
||||
return HttpResponseNotFound()
|
||||
|
||||
book = get_object_or_404(models.Edition, id=request.POST.get('book'))
|
||||
# do you have permission to add to the list?
|
||||
if request.user == book_list.user or book_list.curation == 'open':
|
||||
# go ahead and add it
|
||||
item = models.ListItem.objects.create(
|
||||
book=book,
|
||||
book_list=book_list,
|
||||
added_by=request.user,
|
||||
)
|
||||
# let the world know
|
||||
broadcast(
|
||||
request.user,
|
||||
item.to_add_activity(request.user),
|
||||
privacy=book_list.privacy,
|
||||
software='bookwyrm'
|
||||
)
|
||||
elif book_list.curation == 'curated':
|
||||
# make a pending entry
|
||||
models.ListItem.objects.create(
|
||||
approved=False,
|
||||
book=book,
|
||||
book_list=book_list,
|
||||
added_by=request.user,
|
||||
)
|
||||
else:
|
||||
# you can't add to this list, what were you THINKING
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
return redirect('list', list_id)
|
||||
|
||||
|
||||
@require_POST
|
||||
def remove_book(request, list_id):
|
||||
''' put a book on a list '''
|
||||
book_list = get_object_or_404(models.List, id=list_id)
|
||||
item = get_object_or_404(models.ListItem, id=request.POST.get('item'))
|
||||
|
||||
if not book_list.user == request.user and not item.added_by == request.user:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
activity = item.to_remove_activity(request.user)
|
||||
item.delete()
|
||||
# let the world know
|
||||
broadcast(
|
||||
request.user,
|
||||
activity,
|
||||
privacy=book_list.privacy,
|
||||
software='bookwyrm'
|
||||
)
|
||||
return redirect('list', list_id)
|
|
@ -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)
|
||||
|
|
|
@ -140,7 +140,12 @@ def shelve(request):
|
|||
pass
|
||||
shelfbook = models.ShelfBook.objects.create(
|
||||
book=book, shelf=desired_shelf, added_by=request.user)
|
||||
broadcast(request.user, shelfbook.to_add_activity(request.user))
|
||||
broadcast(
|
||||
request.user,
|
||||
shelfbook.to_add_activity(request.user),
|
||||
privacy=shelfbook.shelf.privacy,
|
||||
software='bookwyrm'
|
||||
)
|
||||
|
||||
# post about "want to read" shelves
|
||||
if desired_shelf.identifier == 'to-read' and \
|
||||
|
@ -173,4 +178,4 @@ def handle_unshelve(user, book, shelf):
|
|||
activity = row.to_remove_activity(user)
|
||||
row.delete()
|
||||
|
||||
broadcast(user, activity)
|
||||
broadcast(user, activity, privacy=shelf.privacy, software='bookwyrm')
|
||||
|
|
4
instances.md
Normal file
4
instances.md
Normal file
|
@ -0,0 +1,4 @@
|
|||
|
||||
| name | url | admin contact | open registration |
|
||||
| :--- | :-- | :------------ | :---------------- |
|
||||
| bookwyrm.social | http://bookwyrm.social/ | mousereeve@riseup.net / @tripofmice@friend.camp | ❌ |
|
Loading…
Reference in a new issue