Merge branch 'main' into review-rate

This commit is contained in:
Mouse Reeve 2021-02-12 18:33:05 -08:00
commit 06feef44ad
250 changed files with 11806 additions and 5924 deletions

View file

@ -65,4 +65,4 @@ jobs:
EMAIL_HOST_PASSWORD: ""
EMAIL_USE_TLS: true
run: |
python manage.py test
python manage.py test -v 3

View file

@ -3,7 +3,8 @@
Social reading and reviewing, decentralized with ActivityPub
## Contents
- [The overall idea](#the-overall-idea)
- [Joining BookWyrm](#joining-bookwyrm)
- [The overall idea](#the-overall-idea)
- [What it is and isn't](#what-it-is-and-isnt)
- [The role of federation](#the-role-of-federation)
- [Features](#features)
@ -13,42 +14,46 @@ Social reading and reviewing, decentralized with ActivityPub
- [Book data](#book-data)
- [Contributing](#contributing)
## Joining BookWyrm
BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://github.com/mouse-reeve/bookwyrm/blob/main/instances.md) list.
You can request an invite to https://bookwyrm.social 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
@ -66,6 +71,7 @@ You'll have to install the Docker and docker-compose. When you're ready, run:
docker-compose build
docker-compose run --rm web python manage.py migrate
docker-compose run --rm web python manage.py initdb
docker-compose up
```
Once the build is complete, you can access the instance at `localhost:1333`
@ -87,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)
@ -98,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
@ -108,26 +116,15 @@ This project is still young and isn't, at the momoment, very stable, so please p
```python
from bookwyrm import models
user = models.User.objects.get(id=1)
user.is_admin = True
user.is_staff = True
user.is_superuser = True
user.save()
```
- Go to the admin panel (`/admin/bookwyrm/sitesettings/1/change` on your domain) and set your instance name, description, code of conduct, and toggle whether registration is open on your instance
## Project structure
All the url routing is in `bookwyrm/urls.py`. This includes the application views (your home page, user page, book page, etc), application endpoints (things that happen when you click buttons), and federation api endpoints (inboxes, outboxes, webfinger, etc).
The application views and actions are in `bookwyrm/views.py`. The internal actions call api handlers which deal with federating content. Outgoing messages (any action done by a user that is federated out), as well as outboxes, live in `bookwyrm/outgoing.py`, and all handlers for incoming messages, as well as inboxes and webfinger, live in `bookwyrm/incoming.py`. Connection to openlibrary.org to get book data is handled in `bookwyrm/connectors/openlibrary.py`. ActivityPub serialization is handled in the `bookwyrm/activitypub/` directory.
Celery is used for background tasks, which includes receiving incoming ActivityPub activities, ActivityPub broadcasting, and external data import.
The UI is all django templates because that is the default. You can replace it with a complex javascript framework over my ~dead body~ mild objections.
- Go to the site settings (`/settings/site-settings` on your domain) and configure your instance name, description, code of conduct, and toggle whether registration is open on your instance
## Book data
The application is set up to get book data from arbitrary outside sources -- right now, it's only able to connect to OpenLibrary, but other connectors could be written. By default, a book is non-canonical copy of an OpenLibrary book, and will be updated with OpenLibrary if the data there changes. However, a book can edited and decoupled from its original data source, or added locally with no external data source.
The application is set up to share book and author data between instances, and get book data from arbitrary outside sources. Right now, the only connector is to OpenLibrary, but other connectors could be written.
There are three concepts in the book data model:
- `Book`, an abstract, high-level concept that could mean either a `Work` or an `Edition`. No data is saved as a `Book`, it serves as shared model for `Work` and `Edition`

View file

@ -11,12 +11,13 @@ from .note import Review, Rating
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
from .verbs import Create, Delete, Undo, Update
from .verbs import Follow, Accept, Reject
from .verbs import Add, AddBook, Remove
from .verbs import Follow, Accept, Reject, Block
from .verbs import Add, AddBook, AddListItem, Remove
# this creates a list of all the Activity types that we can serialize,
# so when an Activity comes in from outside, we can check if it's known

View file

@ -65,6 +65,13 @@ class ActivityObject:
def to_model(self, model, instance=None, save=True):
''' convert from an activity to a model instance '''
if self.type != model.activity_serializer.type:
raise ActivitySerializerError(
'Wrong activity type "%s" for activity of type "%s"' % \
(model.activity_serializer.type,
self.type)
)
if not isinstance(self, model.activity_serializer):
raise ActivitySerializerError(
'Wrong activity type "%s" for model "%s" (expects "%s")' % \
@ -93,6 +100,9 @@ class ActivityObject:
with transaction.atomic():
# we can't set many to many and reverse fields on an unsaved object
try:
try:
instance.save(broadcast=False)
except TypeError:
instance.save()
except IntegrityError as e:
raise ActivitySerializerError(e)
@ -130,6 +140,7 @@ class ActivityObject:
def serialize(self):
''' convert to dictionary with context attr '''
data = self.__dict__
data = {k:v for (k, v) in data.items() if v is not None}
data['@context'] = 'https://www.w3.org/ns/activitystreams'
return data

View file

@ -41,6 +41,7 @@ class Edition(Book):
pages: int = None
physicalFormat: str = ''
publishers: List[str] = field(default_factory=lambda: [])
editionRank: int = 0
type: str = 'Edition'

View file

@ -18,7 +18,7 @@ class Note(ActivityObject):
''' Note activity '''
published: str
attributedTo: str
content: str
content: str = ''
to: List[str] = field(default_factory=lambda: [])
cc: List[str] = field(default_factory=lambda: [])
replies: Dict = field(default_factory=lambda: {})

View file

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

View file

@ -18,7 +18,7 @@ class Create(Verb):
''' Create activity '''
to: List
cc: List
signature: Signature
signature: Signature = None
type: str = 'Create'
@ -48,6 +48,10 @@ class Follow(Verb):
''' Follow activity '''
type: str = 'Follow'
@dataclass(init=False)
class Block(Verb):
''' Block activity '''
type: str = 'Block'
@dataclass(init=False)
class Accept(Verb):
@ -66,17 +70,26 @@ class Reject(Verb):
@dataclass(init=False)
class Add(Verb):
'''Add activity '''
target: ActivityObject
target: str
object: ActivityObject
type: str = 'Add'
@dataclass(init=False)
class AddBook(Verb):
class AddBook(Add):
'''Add activity that's aware of the book obj '''
target: Edition
object: Edition
type: str = 'Add'
@dataclass(init=False)
class AddListItem(AddBook):
'''Add activity that's aware of the book obj '''
notes: str = None
order: int = 0
approved: bool = True
@dataclass(init=False)
class Remove(Verb):
'''Remove activity '''

View file

@ -1,87 +0,0 @@
''' send out activitypub messages '''
import json
from django.utils.http import http_date
import requests
from bookwyrm import models, settings
from bookwyrm.activitypub import ActivityEncoder
from bookwyrm.tasks import app
from bookwyrm.signatures import make_signature, make_digest
def get_public_recipients(user, software=None):
''' everybody and their public inboxes '''
followers = user.followers.filter(local=False)
if software:
followers = followers.filter(bookwyrm_user=(software == 'bookwyrm'))
# we want shared inboxes when available
shared = followers.filter(
shared_inbox__isnull=False
).values_list('shared_inbox', flat=True).distinct()
# if a user doesn't have a shared inbox, we need their personal inbox
# iirc pixelfed doesn't have shared inboxes
inboxes = followers.filter(
shared_inbox__isnull=True
).values_list('inbox', flat=True)
return list(shared) + list(inboxes)
def broadcast(sender, activity, software=None, \
privacy='public', direct_recipients=None):
''' send out an event '''
# start with parsing the direct recipients
recipients = [u.inbox for u in direct_recipients or []]
# and then add any other recipients
if privacy == 'public':
recipients += get_public_recipients(sender, software=software)
broadcast_task.delay(
sender.id,
json.dumps(activity, cls=ActivityEncoder),
recipients
)
@app.task
def broadcast_task(sender_id, activity, recipients):
''' the celery task for broadcast '''
sender = models.User.objects.get(id=sender_id)
errors = []
for recipient in recipients:
try:
sign_and_send(sender, activity, recipient)
except requests.exceptions.HTTPError as e:
errors.append({
'error': str(e),
'recipient': recipient,
'activity': activity,
})
return errors
def sign_and_send(sender, data, destination):
''' crpyto whatever and http junk '''
now = http_date()
if not sender.key_pair.private_key:
# this shouldn't happen. it would be bad if it happened.
raise ValueError('No private key found for sender')
digest = make_digest(data)
response = requests.post(
destination,
data=data,
headers={
'Date': now,
'Digest': digest,
'Signature': make_signature(sender, destination, now, digest),
'Content-Type': 'application/activity+json; charset=utf-8',
'User-Agent': settings.USER_AGENT,
},
)
if not response.ok:
response.raise_for_status()
return response

View file

@ -34,10 +34,15 @@ class AbstractMinimalConnector(ABC):
for field in self_fields:
setattr(self, field, getattr(info, field))
def search(self, query, min_confidence=None):# pylint: disable=unused-argument
def search(self, query, min_confidence=None):
''' free text search '''
params = {}
if min_confidence:
params['min_confidence'] = min_confidence
resp = requests.get(
'%s%s' % (self.search_url, query),
params=params,
headers={
'Accept': 'application/json; charset=utf-8',
'User-Agent': settings.USER_AGENT,
@ -102,7 +107,7 @@ class AbstractConnector(AbstractMinimalConnector):
if self.is_work_data(data):
try:
edition_data = self.get_edition_from_work_data(data)
except KeyError:
except (KeyError, ConnectorException):
# hack: re-use the work data as the edition data
# this is why remote ids aren't necessarily unique
edition_data = data
@ -111,7 +116,7 @@ class AbstractConnector(AbstractMinimalConnector):
try:
work_data = self.get_work_from_edition_data(data)
work_data = dict_from_mappings(work_data, self.book_mappings)
except KeyError:
except (KeyError, ConnectorException):
work_data = mapped_data
edition_data = data
@ -140,6 +145,7 @@ class AbstractConnector(AbstractMinimalConnector):
edition.connector = self.connector
edition.save()
if not work.default_edition:
work.default_edition = edition
work.save()
@ -205,13 +211,20 @@ def get_data(url):
'User-Agent': settings.USER_AGENT,
},
)
except (RequestError, SSLError):
except (RequestError, SSLError) as e:
logger.exception(e)
raise ConnectorException()
if not resp.ok:
try:
resp.raise_for_status()
except requests.exceptions.HTTPError as e:
logger.exception(e)
raise ConnectorException()
try:
data = resp.json()
except ValueError:
except ValueError as e:
logger.exception(e)
raise ConnectorException()
return data
@ -226,7 +239,8 @@ def get_image(url):
'User-Agent': settings.USER_AGENT,
},
)
except (RequestError, SSLError):
except (RequestError, SSLError) as e:
logger.exception(e)
return None
if not resp.ok:
return None

View file

@ -7,7 +7,11 @@ class Connector(AbstractMinimalConnector):
''' this is basically just for search '''
def get_or_create_book(self, remote_id):
return activitypub.resolve_remote_id(models.Edition, remote_id)
edition = activitypub.resolve_remote_id(models.Edition, remote_id)
work = edition.parent_work
work.default_edition = work.get_default_edition()
work.save()
return edition
def parse_search_data(self, data):
return data

View file

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

View file

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

View file

@ -11,8 +11,11 @@ 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 []
# first, try searching unqiue identifiers
results = search_identifiers(query)
if not results:
@ -20,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

View file

@ -92,6 +92,12 @@ class ReplyForm(CustomForm):
'user', 'content', 'content_warning', 'sensitive',
'reply_parent', 'privacy']
class StatusForm(CustomForm):
class Meta:
model = models.Status
fields = [
'user', 'content', 'content_warning', 'sensitive', 'privacy']
class EditUserForm(CustomForm):
class Meta:
@ -125,6 +131,7 @@ class EditionForm(CustomForm):
'origin_id',
'created_date',
'updated_date',
'edition_rank',
'authors',# TODO
'parent_work',
@ -187,3 +194,21 @@ class ShelfForm(CustomForm):
class Meta:
model = models.Shelf
fields = ['user', 'name', 'privacy']
class GoalForm(CustomForm):
class Meta:
model = models.AnnualGoal
fields = ['user', 'year', 'goal', 'privacy']
class SiteForm(CustomForm):
class Meta:
model = models.SiteSettings
exclude = []
class ListForm(CustomForm):
class Meta:
model = models.List
fields = ['user', 'name', 'description', 'curation', 'privacy']

View file

@ -2,10 +2,9 @@
import csv
import logging
from bookwyrm import outgoing
from bookwyrm.tasks import app
from bookwyrm import models
from bookwyrm.models import ImportJob, ImportItem
from bookwyrm.status import create_notification
from bookwyrm.tasks import app
logger = logging.getLogger(__name__)
@ -62,10 +61,61 @@ def import_data(job_id):
item.save()
# shelves book and handles reviews
outgoing.handle_imported_book(
handle_imported_book(
job.user, item, job.include_reviews, job.privacy)
else:
item.fail_reason = 'Could not find a match for book'
item.save()
finally:
create_notification(job.user, 'IMPORT', related_import=job)
job.complete = True
job.save()
def handle_imported_book(user, item, include_reviews, privacy):
''' process a goodreads csv and then post about it '''
if isinstance(item.book, models.Work):
item.book = item.book.default_edition
if not item.book:
return
existing_shelf = models.ShelfBook.objects.filter(
book=item.book, user=user).exists()
# shelve the book if it hasn't been shelved already
if item.shelf and not existing_shelf:
desired_shelf = models.Shelf.objects.get(
identifier=item.shelf,
user=user
)
models.ShelfBook.objects.create(
book=item.book, shelf=desired_shelf, user=user)
for read in item.reads:
# check for an existing readthrough with the same dates
if models.ReadThrough.objects.filter(
user=user, book=item.book,
start_date=read.start_date,
finish_date=read.finish_date
).exists():
continue
read.book = item.book
read.user = user
read.save()
if include_reviews and (item.rating or item.review):
review_title = 'Review of {!r} on Goodreads'.format(
item.book.title,
) if item.review else ''
# we don't know the publication date of the review,
# but "now" is a bad guess
published_date_guess = item.date_read or item.date_added
models.Review.objects.create(
user=user,
book=item.book,
name=review_title,
content=item.review,
rating=item.rating,
published_date=published_date_guess,
privacy=privacy,
)

View file

@ -9,7 +9,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
import requests
from bookwyrm import activitypub, models, outgoing
from bookwyrm import activitypub, models
from bookwyrm import status as status_builder
from bookwyrm.tasks import app
from bookwyrm.signatures import Signature
@ -47,11 +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,
'Create': handle_create,
'Block': handle_block,
'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,
@ -62,11 +71,13 @@ def shared_inbox(request):
'Follow': handle_unfollow,
'Like': handle_unfavorite,
'Announce': handle_unboost,
'Block': handle_unblock,
},
'Update': {
'Person': handle_update_user,
'Edition': handle_update_edition,
'Work': handle_update_work,
'BookList': handle_update_list,
},
}
activity_type = activity['type']
@ -125,15 +136,8 @@ def handle_follow(activity):
)
# send the accept normally for a duplicate request
manually_approves = relationship.user_object.manually_approves_followers
status_builder.create_notification(
relationship.user_object,
'FOLLOW_REQUEST' if manually_approves else 'FOLLOW',
related_user=relationship.user_subject
)
if not manually_approves:
outgoing.handle_accept(relationship)
if not relationship.user_object.manually_approves_followers:
relationship.accept()
@app.task
@ -179,9 +183,48 @@ def handle_follow_reject(activity):
request.delete()
#raises models.UserFollowRequest.DoesNotExist
@app.task
def handle_block(activity):
''' blocking a user '''
# create "block" databse entry
activitypub.Block(**activity).to_model(models.UserBlocks)
# the removing relationships is handled in post-save hook in model
@app.task
def handle_create(activity):
def handle_unblock(activity):
''' undoing a block '''
try:
block_id = activity['object']['id']
except KeyError:
return
try:
block = models.UserBlocks.objects.get(remote_id=block_id)
except models.UserBlocks.DoesNotExist:
return
block.delete()
@app.task
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(remote_id=activity['object']['id'])
except models.List.DoesNotExist:
book_list = None
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']
@ -206,27 +249,6 @@ def handle_create(activity):
# it was discarded because it's not a bookwyrm type
return
# create a notification if this is a reply
notified = []
if status.reply_parent and status.reply_parent.user.local:
notified.append(status.reply_parent.user)
status_builder.create_notification(
status.reply_parent.user,
'REPLY',
related_user=status.user,
related_status=status,
)
if status.mention_users.exists():
for mentioned_user in status.mention_users.all():
if not mentioned_user.local or mentioned_user in notified:
continue
status_builder.create_notification(
mentioned_user,
'MENTION',
related_user=status.user,
related_status=status,
)
@app.task
def handle_delete_status(activity):
@ -251,18 +273,14 @@ def handle_delete_status(activity):
def handle_favorite(activity):
''' approval of your good good post '''
fav = activitypub.Like(**activity)
# we dont know this status, we don't care about this status
if not models.Status.objects.filter(remote_id=fav.object).exists():
return
fav = fav.to_model(models.Favorite)
if fav.user.local:
return
status_builder.create_notification(
fav.status.user,
'FAVORITE',
related_user=fav.user,
related_status=fav.status,
)
@app.task
def handle_unfavorite(activity):
@ -279,19 +297,11 @@ def handle_unfavorite(activity):
def handle_boost(activity):
''' someone gave us a boost! '''
try:
boost = activitypub.Boost(**activity).to_model(models.Boost)
activitypub.Boost(**activity).to_model(models.Boost)
except activitypub.ActivitySerializerError:
# this probably just means we tried to boost an unknown status
return
if not boost.user.local:
status_builder.create_notification(
boost.boosted_status.user,
'BOOST',
related_user=boost.user,
related_status=boost.boosted_status,
)
@app.task
def handle_unboost(activity):
@ -309,8 +319,19 @@ def handle_add(activity):
#this is janky as heck but I haven't thought of a better solution
try:
activitypub.AddBook(**activity).to_model(models.ShelfBook)
return
except activitypub.ActivitySerializerError:
activitypub.AddBook(**activity).to_model(models.Tag)
pass
try:
activitypub.AddListItem(**activity).to_model(models.ListItem)
return
except activitypub.ActivitySerializerError:
pass
try:
activitypub.AddBook(**activity).to_model(models.UserTag)
return
except activitypub.ActivitySerializerError:
pass
@app.task

View file

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

View file

@ -0,0 +1,31 @@
# Generated by Django 3.0.7 on 2020-11-17 07:36
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0011_auto_20201113_1727'),
]
operations = [
migrations.CreateModel(
name='ProgressUpdate',
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', models.CharField(max_length=255, null=True)),
('progress', models.IntegerField()),
('mode', models.CharField(choices=[('PG', 'page'), ('PCT', 'percent')], default='PG', max_length=3)),
('readthrough', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.ReadThrough')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View file

@ -0,0 +1,14 @@
# Generated by Django 3.0.7 on 2020-11-28 00:07
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0013_book_origin_id'),
('bookwyrm', '0012_progressupdate'),
]
operations = [
]

View file

@ -0,0 +1,23 @@
# Generated by Django 3.0.7 on 2020-11-28 07:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0014_merge_20201128_0007'),
]
operations = [
migrations.RenameField(
model_name='readthrough',
old_name='pages_read',
new_name='progress',
),
migrations.AddField(
model_name='readthrough',
name='progress_mode',
field=models.CharField(choices=[('PG', 'page'), ('PCT', 'percent')], default='PG', max_length=3),
),
]

View file

@ -1,6 +1,6 @@
# Generated by Django 3.0.7 on 2020-11-30 18:19
import bookwyrm.models.base_model
import bookwyrm.models.activitypub_mixin
import bookwyrm.models.fields
from django.conf import settings
from django.db import migrations, models
@ -38,7 +38,7 @@ class Migration(migrations.Migration):
options={
'abstract': False,
},
bases=(bookwyrm.models.base_model.ActivitypubMixin, models.Model),
bases=(bookwyrm.models.activitypub_mixin.ActivitypubMixin, models.Model),
),
migrations.AddField(
model_name='user',

View file

@ -0,0 +1,20 @@
# Generated by Django 3.0.7 on 2021-01-05 19:08
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0032_auto_20210104_2055'),
]
operations = [
migrations.AddField(
model_name='siteinvite',
name='created_date',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2021-01-07 16:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0033_siteinvite_created_date'),
]
operations = [
migrations.AddField(
model_name='importjob',
name='complete',
field=models.BooleanField(default=False),
),
]

View file

@ -0,0 +1,26 @@
# Generated by Django 3.0.7 on 2021-01-11 17:18
import bookwyrm.models.fields
from django.db import migrations
def set_rank(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
books = app_registry.get_model('bookwyrm', 'Edition')
for book in books.objects.using(db_alias):
book.save()
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0034_importjob_complete'),
]
operations = [
migrations.AddField(
model_name='edition',
name='edition_rank',
field=bookwyrm.models.fields.IntegerField(default=0),
),
migrations.RunPython(set_rank),
]

View file

@ -0,0 +1,32 @@
# Generated by Django 3.0.7 on 2021-01-16 18:43
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', '0035_edition_edition_rank'),
]
operations = [
migrations.CreateModel(
name='AnnualGoal',
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])),
('goal', models.IntegerField()),
('year', models.IntegerField(default=2021)),
('privacy', models.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'year')},
},
),
]

View file

@ -0,0 +1,37 @@
# Generated by Django 3.0.7 on 2021-01-18 19:54
from django.db import migrations, models
def empty_to_null(apps, schema_editor):
User = apps.get_model("bookwyrm", "User")
db_alias = schema_editor.connection.alias
User.objects.using(db_alias).filter(email="").update(email=None)
def null_to_empty(apps, schema_editor):
User = apps.get_model("bookwyrm", "User")
db_alias = schema_editor.connection.alias
User.objects.using(db_alias).filter(email=None).update(email="")
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0036_annualgoal'),
]
operations = [
migrations.AlterModelOptions(
name='shelfbook',
options={'ordering': ('-created_date',)},
),
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(max_length=254, null=True),
),
migrations.RunPython(empty_to_null, null_to_empty),
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(max_length=254, null=True, unique=True),
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 3.0.7 on 2021-01-19 15:34
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0037_auto_20210118_1954'),
]
operations = [
migrations.AlterField(
model_name='annualgoal',
name='goal',
field=models.IntegerField(validators=[django.core.validators.MinValueValidator(1)]),
),
]

View file

@ -0,0 +1,14 @@
# Generated by Django 3.0.7 on 2021-01-20 07:53
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0038_auto_20210119_1534'),
('bookwyrm', '0015_auto_20201128_0734'),
]
operations = [
]

View file

@ -0,0 +1,36 @@
# Generated by Django 3.0.7 on 2021-01-22 00:57
import bookwyrm.models.fields
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0039_merge_20210120_0753'),
]
operations = [
migrations.AlterField(
model_name='progressupdate',
name='progress',
field=models.IntegerField(validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AlterField(
model_name='progressupdate',
name='readthrough',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.ReadThrough'),
),
migrations.AlterField(
model_name='progressupdate',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='readthrough',
name='progress',
field=models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0)]),
),
]

View file

@ -0,0 +1,65 @@
# Generated by Django 3.0.7 on 2021-01-31 16:14
import bookwyrm.models.activitypub_mixin
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.activitypub_mixin.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.activitypub_mixin.ActivitypubMixin, models.Model),
),
migrations.AddField(
model_name='list',
name='books',
field=models.ManyToManyField(through='bookwyrm.ListItem', to='bookwyrm.Edition'),
),
migrations.AddField(
model_name='list',
name='user',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
]

View file

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

View file

@ -0,0 +1,23 @@
# Generated by Django 3.0.7 on 2021-02-04 22:23
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0042_auto_20210201_2108'),
]
operations = [
migrations.RenameField(
model_name='listitem',
old_name='added_by',
new_name='user',
),
migrations.RenameField(
model_name='shelfbook',
old_name='added_by',
new_name='user',
),
]

View file

@ -0,0 +1,33 @@
# Generated by Django 3.0.7 on 2021-02-07 19:24
import bookwyrm.models.fields
from django.conf import settings
from django.db import migrations
import django.db.models.deletion
def set_user(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
shelfbook = app_registry.get_model('bookwyrm', 'ShelfBook')
for item in shelfbook.objects.using(db_alias).filter(user__isnull=True):
item.user = item.shelf.user
try:
item.save(broadcast=False)
except TypeError:
item.save()
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0043_auto_20210204_2223'),
]
operations = [
migrations.RunPython(set_user, lambda x, y: None),
migrations.AlterField(
model_name='shelfbook',
name='user',
field=bookwyrm.models.fields.ForeignKey(default=2, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
preserve_default=False,
),
]

View file

@ -0,0 +1,58 @@
# Generated by Django 3.0.7 on 2021-02-10 21:14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0044_auto_20210207_1924'),
]
operations = [
migrations.RemoveConstraint(
model_name='notification',
name='notification_type_valid',
),
migrations.AddField(
model_name='notification',
name='related_list_item',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.ListItem'),
),
migrations.AlterField(
model_name='notification',
name='notification_type',
field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('MENTION', 'Mention'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost'), ('IMPORT', 'Import'), ('ADD', 'Add')], max_length=255),
),
migrations.AlterField(
model_name='notification',
name='related_book',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.Edition'),
),
migrations.AlterField(
model_name='notification',
name='related_import',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.ImportJob'),
),
migrations.AlterField(
model_name='notification',
name='related_status',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.Status'),
),
migrations.AlterField(
model_name='notification',
name='related_user',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='related_user', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='notification',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AddConstraint(
model_name='notification',
constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'MENTION', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST', 'IMPORT', 'ADD']), name='notification_type_valid'),
),
]

View file

@ -7,6 +7,7 @@ from .author import Author
from .connector import Connector
from .shelf import Shelf, ShelfBook
from .list import List, ListItem
from .status import Status, GeneratedNote, Comment, Quotation
from .status import Review, ReviewRating
@ -14,11 +15,11 @@ from .status import Boost
from .attachment import Image
from .favorite import Favorite
from .notification import Notification
from .readthrough import ReadThrough
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
from .tag import Tag, UserTag
from .user import User, KeyPair
from .user import User, KeyPair, AnnualGoal
from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .federated_server import FederatedServer

View file

@ -0,0 +1,497 @@
''' activitypub model functionality '''
from base64 import b64encode
from functools import reduce
import json
import operator
import logging
from uuid import uuid4
import requests
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from django.apps import apps
from django.core.paginator import Paginator
from django.db.models import Q
from django.utils.http import http_date
from bookwyrm import activitypub
from bookwyrm.settings import USER_AGENT, PAGE_LENGTH
from bookwyrm.signatures import make_signature, make_digest
from bookwyrm.tasks import app
from bookwyrm.models.fields import ImageField, ManyToManyField
logger = logging.getLogger(__name__)
# I tried to separate these classes into mutliple files but I kept getting
# circular import errors so I gave up. I'm sure it could be done though!
class ActivitypubMixin:
''' add this mixin for models that are AP serializable '''
activity_serializer = lambda: {}
reverse_unfurl = False
def __init__(self, *args, **kwargs):
''' collect some info on model fields '''
self.image_fields = []
self.many_to_many_fields = []
self.simple_fields = [] # "simple"
# sort model fields by type
for field in self._meta.get_fields():
if not hasattr(field, 'field_to_activity'):
continue
if isinstance(field, ImageField):
self.image_fields.append(field)
elif isinstance(field, ManyToManyField):
self.many_to_many_fields.append(field)
else:
self.simple_fields.append(field)
# a list of allll the serializable fields
self.activity_fields = self.image_fields + \
self.many_to_many_fields + self.simple_fields
# these are separate to avoid infinite recursion issues
self.deserialize_reverse_fields = self.deserialize_reverse_fields \
if hasattr(self, 'deserialize_reverse_fields') else []
self.serialize_reverse_fields = self.serialize_reverse_fields \
if hasattr(self, 'serialize_reverse_fields') else []
super().__init__(*args, **kwargs)
@classmethod
def find_existing_by_remote_id(cls, remote_id):
''' look up a remote id in the db '''
return cls.find_existing({'id': remote_id})
@classmethod
def find_existing(cls, data):
''' compare data to fields that can be used for deduplation.
This always includes remote_id, but can also be unique identifiers
like an isbn for an edition '''
filters = []
# grabs all the data from the model to create django queryset filters
for field in cls._meta.get_fields():
if not hasattr(field, 'deduplication_field') or \
not field.deduplication_field:
continue
value = data.get(field.get_activitypub_field())
if not value:
continue
filters.append({field.name: value})
if hasattr(cls, 'origin_id') and 'id' in data:
# kinda janky, but this handles special case for books
filters.append({'origin_id': data['id']})
if not filters:
# if there are no deduplication fields, it will match the first
# item no matter what. this shouldn't happen but just in case.
return None
objects = cls.objects
if hasattr(objects, 'select_subclasses'):
objects = objects.select_subclasses()
# an OR operation on all the match fields, sorry for the dense syntax
match = objects.filter(
reduce(operator.or_, (Q(**f) for f in filters))
)
# there OUGHT to be only one match
return match.first()
def broadcast(self, activity, sender, software=None):
''' send out an activity '''
broadcast_task.delay(
sender.id,
json.dumps(activity, cls=activitypub.ActivityEncoder),
self.get_recipients(software=software)
)
def get_recipients(self, software=None):
''' figure out which inbox urls to post to '''
# first we have to figure out who should receive this activity
privacy = self.privacy if hasattr(self, 'privacy') else 'public'
# is this activity owned by a user (statuses, lists, shelves), or is it
# general to the instance (like books)
user = self.user if hasattr(self, 'user') else None
user_model = apps.get_model('bookwyrm.User', require_ready=True)
if not user and isinstance(self, user_model):
# or maybe the thing itself is a user
user = self
# find anyone who's tagged in a status, for example
mentions = self.recipients if hasattr(self, 'recipients') else []
# we always send activities to explicitly mentioned users' inboxes
recipients = [u.inbox for u in mentions or []]
# unless it's a dm, all the followers should receive the activity
if privacy != 'direct':
# we will send this out to a subset of all remote users
queryset = user_model.objects.filter(
local=False,
)
# filter users first by whether they're using the desired software
# this lets us send book updates only to other bw servers
if software:
queryset = queryset.filter(
bookwyrm_user=(software == 'bookwyrm')
)
# if there's a user, we only want to send to the user's followers
if user:
queryset = queryset.filter(following=user)
# ideally, we will send to shared inboxes for efficiency
shared_inboxes = queryset.filter(
shared_inbox__isnull=False
).values_list('shared_inbox', flat=True).distinct()
# but not everyone has a shared inbox
inboxes = queryset.filter(
shared_inbox__isnull=True
).values_list('inbox', flat=True)
recipients += list(shared_inboxes) + list(inboxes)
return recipients
def to_activity(self):
''' convert from a model to an activity '''
activity = generate_activity(self)
return self.activity_serializer(**activity).serialize()
class ObjectMixin(ActivitypubMixin):
''' add this mixin for object models that are AP serializable '''
def save(self, *args, created=None, **kwargs):
''' broadcast created/updated/deleted objects as appropriate '''
broadcast = kwargs.get('broadcast', True)
# this bonus kwarg woul cause an error in the base save method
if 'broadcast' in kwargs:
del kwargs['broadcast']
created = created or not bool(self.id)
# first off, we want to save normally no matter what
super().save(*args, **kwargs)
if not broadcast:
return
# this will work for objects owned by a user (lists, shelves)
user = self.user if hasattr(self, 'user') else None
if created:
# broadcast Create activities for objects owned by a local user
if not user or not user.local:
return
try:
software = None
# do we have a "pure" activitypub version of this for mastodon?
if hasattr(self, 'pure_content'):
pure_activity = self.to_create_activity(user, pure=True)
self.broadcast(pure_activity, user, software='other')
software = 'bookwyrm'
# sends to BW only if we just did a pure version for masto
activity = self.to_create_activity(user)
self.broadcast(activity, user, software=software)
except KeyError:
# janky as heck, this catches the mutliple inheritence chain
# for boosts and ignores this auxilliary broadcast
return
return
# --- updating an existing object
if not user:
# users don't have associated users, they ARE users
user_model = apps.get_model('bookwyrm.User', require_ready=True)
if isinstance(self, user_model):
user = self
# book data tracks last editor
elif hasattr(self, 'last_edited_by'):
user = self.last_edited_by
# again, if we don't know the user or they're remote, don't bother
if not user or not user.local:
return
# is this a deletion?
if hasattr(self, 'deleted') and self.deleted:
activity = self.to_delete_activity(user)
else:
activity = self.to_update_activity(user)
self.broadcast(activity, user)
def to_create_activity(self, user, **kwargs):
''' 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 and activity_object['content']:
signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
content = activity_object['content']
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
signature = activitypub.Signature(
creator='%s#main-key' % user.remote_id,
created=activity_object['published'],
signatureValue=b64encode(signed_message).decode('utf8')
)
return activitypub.Create(
id=create_id,
actor=user.remote_id,
to=activity_object['to'],
cc=activity_object['cc'],
object=activity_object,
signature=signature,
).serialize()
def to_delete_activity(self, user):
''' notice of deletion '''
return activitypub.Delete(
id=self.remote_id + '/activity',
actor=user.remote_id,
to=['%s/followers' % user.remote_id],
cc=['https://www.w3.org/ns/activitystreams#Public'],
object=self.to_activity(),
).serialize()
def to_update_activity(self, user):
''' wrapper for Updates to an activity '''
activity_id = '%s#update/%s' % (self.remote_id, uuid4())
return activitypub.Update(
id=activity_id,
actor=user.remote_id,
to=['https://www.w3.org/ns/activitystreams#Public'],
object=self.to_activity()
).serialize()
class OrderedCollectionPageMixin(ObjectMixin):
''' just the paginator utilities, so you don't HAVE to
override ActivitypubMixin's to_activity (ie, for outbox) '''
@property
def collection_remote_id(self):
''' this can be overriden if there's a special remote id, ie outbox '''
return self.remote_id
def to_ordered_collection(self, queryset, \
remote_id=None, page=False, collection_only=False, **kwargs):
''' an ordered collection of whatevers '''
if not queryset.ordered:
raise RuntimeError('queryset must be ordered')
remote_id = remote_id or self.remote_id
if page:
return to_ordered_collection_page(
queryset, remote_id, **kwargs)
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)
# 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()
class OrderedCollectionMixin(OrderedCollectionPageMixin):
''' extends activitypub models to work as ordered collections '''
@property
def collection_queryset(self):
''' usually an ordered collection model aggregates a different model '''
raise NotImplementedError('Model must define collection_queryset')
activity_serializer = activitypub.OrderedCollection
def to_activity(self, **kwargs):
''' an ordered collection of the specified model queryset '''
return self.to_ordered_collection(self.collection_queryset, **kwargs)
class CollectionItemMixin(ActivitypubMixin):
''' for items that are part of an (Ordered)Collection '''
activity_serializer = activitypub.Add
object_field = collection_field = None
def save(self, *args, broadcast=True, **kwargs):
''' broadcast updated '''
created = not bool(self.id)
# first off, we want to save normally no matter what
super().save(*args, **kwargs)
# these shouldn't be edited, only created and deleted
if not broadcast or not created or not self.user.local:
return
# adding an obj to the collection
activity = self.to_add_activity()
self.broadcast(activity, self.user)
def delete(self, *args, **kwargs):
''' broadcast a remove activity '''
activity = self.to_remove_activity()
super().delete(*args, **kwargs)
self.broadcast(activity, self.user)
def to_add_activity(self):
''' AP for shelving a book'''
object_field = getattr(self, self.object_field)
collection_field = getattr(self, self.collection_field)
return activitypub.Add(
id='%s#add' % self.remote_id,
actor=self.user.remote_id,
object=object_field.to_activity(),
target=collection_field.remote_id
).serialize()
def to_remove_activity(self):
''' AP for un-shelving a book'''
object_field = getattr(self, self.object_field)
collection_field = getattr(self, self.collection_field)
return activitypub.Remove(
id='%s#remove' % self.remote_id,
actor=self.user.remote_id,
object=object_field.to_activity(),
target=collection_field.remote_id
).serialize()
class ActivityMixin(ActivitypubMixin):
''' add this mixin for models that are AP serializable '''
def save(self, *args, broadcast=True, **kwargs):
''' broadcast activity '''
super().save(*args, **kwargs)
user = self.user if hasattr(self, 'user') else self.user_subject
if broadcast and user.local:
self.broadcast(self.to_activity(), user)
def delete(self, *args, broadcast=True, **kwargs):
''' nevermind, undo that activity '''
user = self.user if hasattr(self, 'user') else self.user_subject
if broadcast and user.local:
self.broadcast(self.to_undo_activity(), user)
super().delete(*args, **kwargs)
def to_undo_activity(self):
''' undo an action '''
user = self.user if hasattr(self, 'user') else self.user_subject
return activitypub.Undo(
id='%s#undo' % self.remote_id,
actor=user.remote_id,
object=self.to_activity()
).serialize()
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
def unfurl_related_field(related_field, sort_field=None):
''' load reverse lookups (like public key owner or Status attachment '''
if hasattr(related_field, 'all'):
return [unfurl_related_field(i) for i in related_field.order_by(
sort_field).all()]
if related_field.reverse_unfurl:
return related_field.field_to_activity()
return related_field.remote_id
@app.task
def broadcast_task(sender_id, activity, recipients):
''' the celery task for broadcast '''
user_model = apps.get_model('bookwyrm.User', require_ready=True)
sender = user_model.objects.get(id=sender_id)
for recipient in recipients:
try:
sign_and_send(sender, activity, recipient)
except requests.exceptions.HTTPError as e:
logger.exception(e)
def sign_and_send(sender, data, destination):
''' crpyto whatever and http junk '''
now = http_date()
if not sender.key_pair.private_key:
# this shouldn't happen. it would be bad if it happened.
raise ValueError('No private key found for sender')
digest = make_digest(data)
response = requests.post(
destination,
data=data,
headers={
'Date': now,
'Digest': digest,
'Signature': make_signature(sender, destination, now, digest),
'Content-Type': 'application/activity+json; charset=utf-8',
'User-Agent': USER_AGENT,
},
)
if not response.ok:
response.raise_for_status()
return response
# pylint: disable=unused-argument
def to_ordered_collection_page(
queryset, remote_id, id_only=False, page=1, **kwargs):
''' serialize and pagiante a queryset '''
paginated = Paginator(queryset, PAGE_LENGTH)
activity_page = paginated.page(page)
if id_only:
items = [s.remote_id for s in activity_page.object_list]
else:
items = [s.to_activity() for s in activity_page.object_list]
prev_page = next_page = None
if activity_page.has_next():
next_page = '%s?page=%d' % (remote_id, activity_page.next_page_number())
if activity_page.has_previous():
prev_page = '%s?page=%d' % \
(remote_id, activity_page.previous_page_number())
return activitypub.OrderedCollectionPage(
id='%s?page=%s' % (remote_id, page),
partOf=remote_id,
orderedItems=items,
next=next_page,
prev=prev_page
).serialize()

View file

@ -2,7 +2,7 @@
from django.db import models
from bookwyrm import activitypub
from .base_model import ActivitypubMixin
from .activitypub_mixin import ActivitypubMixin
from .base_model import BookWyrmModel
from . import fields

View file

@ -1,20 +1,9 @@
''' base model with default fields '''
from base64 import b64encode
from functools import reduce
import operator
from uuid import uuid4
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from django.core.paginator import Paginator
from django.db import models
from django.db.models import Q
from django.dispatch import receiver
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN, PAGE_LENGTH
from .fields import ImageField, ManyToManyField, RemoteIdField
from bookwyrm.settings import DOMAIN
from .fields import RemoteIdField
class BookWyrmModel(models.Model):
@ -27,7 +16,7 @@ class BookWyrmModel(models.Model):
''' generate a url that resolves to the local object '''
base_path = 'https://%s' % DOMAIN
if hasattr(self, 'user'):
base_path = self.user.remote_id
base_path = '%s%s' % (base_path, self.user.local_path)
model_name = type(self).__name__.lower()
return '%s/%s/%d' % (base_path, model_name, self.id)
@ -49,235 +38,7 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
return
if not instance.remote_id:
instance.remote_id = instance.get_remote_id()
try:
instance.save(broadcast=False)
except TypeError:
instance.save()
def unfurl_related_field(related_field):
''' load reverse lookups (like public key owner or Status attachment '''
if hasattr(related_field, 'all'):
return [unfurl_related_field(i) for i in related_field.all()]
if related_field.reverse_unfurl:
return related_field.field_to_activity()
return related_field.remote_id
class ActivitypubMixin:
''' add this mixin for models that are AP serializable '''
activity_serializer = lambda: {}
reverse_unfurl = False
def __init__(self, *args, **kwargs):
''' collect some info on model fields '''
self.image_fields = []
self.many_to_many_fields = []
self.simple_fields = [] # "simple"
for field in self._meta.get_fields():
if not hasattr(field, 'field_to_activity'):
continue
if isinstance(field, ImageField):
self.image_fields.append(field)
elif isinstance(field, ManyToManyField):
self.many_to_many_fields.append(field)
else:
self.simple_fields.append(field)
self.activity_fields = self.image_fields + \
self.many_to_many_fields + self.simple_fields
self.deserialize_reverse_fields = self.deserialize_reverse_fields \
if hasattr(self, 'deserialize_reverse_fields') else []
self.serialize_reverse_fields = self.serialize_reverse_fields \
if hasattr(self, 'serialize_reverse_fields') else []
super().__init__(*args, **kwargs)
@classmethod
def find_existing_by_remote_id(cls, remote_id):
''' look up a remote id in the db '''
return cls.find_existing({'id': remote_id})
@classmethod
def find_existing(cls, data):
''' compare data to fields that can be used for deduplation.
This always includes remote_id, but can also be unique identifiers
like an isbn for an edition '''
filters = []
for field in cls._meta.get_fields():
if not hasattr(field, 'deduplication_field') or \
not field.deduplication_field:
continue
value = data.get(field.get_activitypub_field())
if not value:
continue
filters.append({field.name: value})
if hasattr(cls, 'origin_id') and 'id' in data:
# kinda janky, but this handles special case for books
filters.append({'origin_id': data['id']})
if not filters:
# if there are no deduplication fields, it will match the first
# item no matter what. this shouldn't happen but just in case.
return None
objects = cls.objects
if hasattr(objects, 'select_subclasses'):
objects = objects.select_subclasses()
# an OR operation on all the match fields
match = objects.filter(
reduce(
operator.or_, (Q(**f) for f in filters)
)
)
# there OUGHT to be only one match
return match.first()
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 in \
self.serialize_reverse_fields:
related_field = getattr(self, model_field_name)
activity[activity_field_name] = \
unfurl_related_field(related_field)
if not activity.get('id'):
activity['id'] = self.get_remote_id()
return self.activity_serializer(**activity).serialize()
def to_create_activity(self, user, **kwargs):
''' returns the object wrapped in a Create activity '''
activity_object = self.to_activity(**kwargs)
signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
content = activity_object['content']
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
create_id = self.remote_id + '/activity'
signature = activitypub.Signature(
creator='%s#main-key' % user.remote_id,
created=activity_object['published'],
signatureValue=b64encode(signed_message).decode('utf8')
)
return activitypub.Create(
id=create_id,
actor=user.remote_id,
to=activity_object['to'],
cc=activity_object['cc'],
object=activity_object,
signature=signature,
).serialize()
def to_delete_activity(self, user):
''' notice of deletion '''
return activitypub.Delete(
id=self.remote_id + '/activity',
actor=user.remote_id,
to=['%s/followers' % user.remote_id],
cc=['https://www.w3.org/ns/activitystreams#Public'],
object=self.to_activity(),
).serialize()
def to_update_activity(self, user):
''' wrapper for Updates to an activity '''
activity_id = '%s#update/%s' % (self.remote_id, uuid4())
return activitypub.Update(
id=activity_id,
actor=user.remote_id,
to=['https://www.w3.org/ns/activitystreams#Public'],
object=self.to_activity()
).serialize()
def to_undo_activity(self, user):
''' undo an action '''
return activitypub.Undo(
id='%s#undo' % self.remote_id,
actor=user.remote_id,
object=self.to_activity()
).serialize()
class OrderedCollectionPageMixin(ActivitypubMixin):
''' just the paginator utilities, so you don't HAVE to
override ActivitypubMixin's to_activity (ie, for outbox '''
@property
def collection_remote_id(self):
''' this can be overriden if there's a special remote id, ie outbox '''
return self.remote_id
def to_ordered_collection(self, queryset, \
remote_id=None, page=False, **kwargs):
''' an ordered collection of whatevers '''
remote_id = remote_id or self.remote_id
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 ''
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()
# pylint: disable=unused-argument
def to_ordered_collection_page(
queryset, remote_id, id_only=False, page=1, **kwargs):
''' serialize and pagiante a queryset '''
paginated = Paginator(queryset, PAGE_LENGTH)
activity_page = paginated.page(page)
if id_only:
items = [s.remote_id for s in activity_page.object_list]
else:
items = [s.to_activity() for s in activity_page.object_list]
prev_page = next_page = None
if activity_page.has_next():
next_page = '%s?page=%d' % (remote_id, activity_page.next_page_number())
if activity_page.has_previous():
prev_page = '%s?page=%d' % \
(remote_id, activity_page.previous_page_number())
return activitypub.OrderedCollectionPage(
id='%s?page=%s' % (remote_id, page),
partOf=remote_id,
orderedItems=items,
next=next_page,
prev=prev_page
).serialize()
class OrderedCollectionMixin(OrderedCollectionPageMixin):
''' extends activitypub models to work as ordered collections '''
@property
def collection_queryset(self):
''' usually an ordered collection model aggregates a different model '''
raise NotImplementedError('Model must define collection_queryset')
activity_serializer = activitypub.OrderedCollection
def to_activity(self, **kwargs):
''' an ordered collection of the specified model queryset '''
return self.to_ordered_collection(self.collection_queryset, **kwargs)

View file

@ -7,11 +7,11 @@ from model_utils.managers import InheritanceManager
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin
from .base_model import BookWyrmModel
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
from . import fields
class BookDataModel(ActivitypubMixin, BookWyrmModel):
class BookDataModel(ObjectMixin, BookWyrmModel):
''' fields shared between editable book data (books, works, authors) '''
origin_id = models.CharField(max_length=255, null=True, blank=True)
openlibrary_key = fields.CharField(
@ -72,6 +72,11 @@ class Book(BookDataModel):
''' format a list of authors '''
return ', '.join(a.name for a in self.authors.all())
@property
def latest_readthrough(self):
''' most recent readthrough activity '''
return self.readthrough_set.order_by('-updated_date').first()
@property
def edition_info(self):
''' properties of this edition, as a string '''
@ -122,20 +127,29 @@ class Work(OrderedCollectionPageMixin, Book):
load_remote=False
)
def save(self, *args, **kwargs):
''' set some fields on the edition object '''
# set rank
for edition in self.editions.all():
edition.save()
return super().save(*args, **kwargs)
def get_default_edition(self):
''' in case the default edition is not set '''
return self.default_edition or self.editions.first()
return self.default_edition or self.editions.order_by(
'-edition_rank'
).first()
def to_edition_list(self, **kwargs):
''' an ordered collection of editions '''
return self.to_ordered_collection(
self.editions.order_by('-updated_date').all(),
self.editions.order_by('-edition_rank').all(),
remote_id='%s/editions' % self.remote_id,
**kwargs
)
activity_serializer = activitypub.Work
serialize_reverse_fields = [('editions', 'editions')]
serialize_reverse_fields = [('editions', 'editions', '-edition_rank')]
deserialize_reverse_fields = [('editions', 'editions')]
@ -164,17 +178,38 @@ class Edition(Book):
parent_work = fields.ForeignKey(
'Work', on_delete=models.PROTECT, null=True,
related_name='editions', activitypub_field='work')
edition_rank = fields.IntegerField(default=0)
activity_serializer = activitypub.Edition
name_field = 'title'
def get_rank(self):
''' calculate how complete the data is on this edition '''
if self.parent_work and self.parent_work.default_edition == self:
# default edition has the highest rank
return 20
rank = 0
rank += int(bool(self.cover)) * 3
rank += int(bool(self.isbn_13))
rank += int(bool(self.isbn_10))
rank += int(bool(self.oclc_number))
rank += int(bool(self.pages))
rank += int(bool(self.physical_format))
rank += int(bool(self.description))
# max rank is 9
return rank
def save(self, *args, **kwargs):
''' calculate isbn 10/13 '''
''' set some fields on the edition object '''
# calculate isbn 10/13
if self.isbn_13 and self.isbn_13[:3] == '978' and not self.isbn_10:
self.isbn_10 = isbn_13_to_10(self.isbn_13)
if self.isbn_10 and not self.isbn_13:
self.isbn_13 = isbn_10_to_13(self.isbn_10)
# set rank
self.edition_rank = self.get_rank()
return super().save(*args, **kwargs)

View file

@ -1,12 +1,14 @@
''' like/fav/star a status '''
from django.apps import apps
from django.db import models
from django.utils import timezone
from bookwyrm import activitypub
from .base_model import ActivitypubMixin, BookWyrmModel
from .activitypub_mixin import ActivityMixin
from .base_model import BookWyrmModel
from . import fields
class Favorite(ActivitypubMixin, BookWyrmModel):
class Favorite(ActivityMixin, BookWyrmModel):
''' fav'ing a post '''
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='actor')
@ -18,9 +20,33 @@ class Favorite(ActivitypubMixin, BookWyrmModel):
def save(self, *args, **kwargs):
''' update user active time '''
self.user.last_active_date = timezone.now()
self.user.save()
self.user.save(broadcast=False)
super().save(*args, **kwargs)
if self.status.user.local and self.status.user != self.user:
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
notification_model.objects.create(
user=self.status.user,
notification_type='FAVORITE',
related_user=self.user,
related_status=self.status
)
def delete(self, *args, **kwargs):
''' delete and delete notifications '''
# check for notification
if self.status.user.local:
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
notification = notification_model.objects.filter(
user=self.status.user, related_user=self.user,
related_status=self.status, notification_type='FAVORITE'
).first()
if notification:
notification.delete()
super().delete(*args, **kwargs)
class Meta:
''' can't fav things twice '''
unique_together = ('user', 'status')

View file

@ -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')\
@ -260,6 +263,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
if formatted is None or formatted is MISSING:
return
getattr(instance, self.name).set(formatted)
instance.save(broadcast=False)
def field_to_activity(self, value):
if self.link_only:

View file

@ -2,6 +2,7 @@
import re
import dateutil.parser
from django.apps import apps
from django.contrib.postgres.fields import JSONField
from django.db import models
from django.utils import timezone
@ -42,6 +43,7 @@ class ImportJob(models.Model):
created_date = models.DateTimeField(default=timezone.now)
task_id = models.CharField(max_length=100, null=True)
include_reviews = models.BooleanField(default=True)
complete = models.BooleanField(default=False)
privacy = models.CharField(
max_length=255,
default='public',
@ -49,6 +51,18 @@ class ImportJob(models.Model):
)
retry = models.BooleanField(default=False)
def save(self, *args, **kwargs):
''' save and notify '''
super().save(*args, **kwargs)
if self.complete:
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
notification_model.objects.create(
user=self.user,
notification_type='IMPORT',
related_import=self,
)
class ImportItem(models.Model):
''' a single line of a csv being imported '''

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

@ -0,0 +1,94 @@
''' make a list of books!! '''
from django.apps import apps
from django.db import models
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
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(CollectionItemMixin, 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')
user = 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.AddListItem
object_field = 'book'
collection_field = 'book_list'
def save(self, *args, **kwargs):
''' create a notification too '''
created = not bool(self.id)
super().save(*args, **kwargs)
list_owner = self.book_list.user
# create a notification if somoene ELSE added to a local user's list
if created and list_owner.local and list_owner != self.user:
model = apps.get_model('bookwyrm.Notification', require_ready=True)
model.objects.create(
user=list_owner,
related_user=self.user,
related_list_item=self,
notification_type='ADD',
)
class Meta:
''' an opinionated constraint! you can't put a book on a list twice '''
unique_together = ('book', 'book_list')
ordering = ('-created_date',)

View file

@ -5,24 +5,41 @@ from .base_model import BookWyrmModel
NotificationType = models.TextChoices(
'NotificationType',
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD')
class Notification(BookWyrmModel):
''' you've been tagged, liked, followed, etc '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
user = models.ForeignKey('User', on_delete=models.CASCADE)
related_book = models.ForeignKey(
'Edition', on_delete=models.PROTECT, null=True)
'Edition', on_delete=models.CASCADE, null=True)
related_user = models.ForeignKey(
'User',
on_delete=models.PROTECT, null=True, related_name='related_user')
on_delete=models.CASCADE, null=True, related_name='related_user')
related_status = models.ForeignKey(
'Status', on_delete=models.PROTECT, null=True)
'Status', on_delete=models.CASCADE, null=True)
related_import = models.ForeignKey(
'ImportJob', on_delete=models.PROTECT, null=True)
'ImportJob', on_delete=models.CASCADE, null=True)
related_list_item = models.ForeignKey(
'ListItem', on_delete=models.CASCADE, null=True)
read = models.BooleanField(default=False)
notification_type = models.CharField(
max_length=255, choices=NotificationType.choices)
def save(self, *args, **kwargs):
''' save, but don't make dupes '''
# there's probably a better way to do this
if self.__class__.objects.filter(
user=self.user,
related_book=self.related_book,
related_user=self.related_user,
related_status=self.related_status,
related_import=self.related_import,
related_list_item=self.related_list_item,
notification_type=self.notification_type,
).exists():
return
super().save(*args, **kwargs)
class Meta:
''' checks if notifcation is in enum list for valid types '''
constraints = [

View file

@ -1,17 +1,26 @@
''' progress in a book '''
from django.db import models
from django.utils import timezone
from django.core import validators
from .base_model import BookWyrmModel
class ProgressMode(models.TextChoices):
PAGE = 'PG', 'page'
PERCENT = 'PCT', 'percent'
class ReadThrough(BookWyrmModel):
''' Store progress through a book in the database. '''
''' Store a read through a book in the database. '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
pages_read = models.IntegerField(
progress = models.IntegerField(
validators=[validators.MinValueValidator(0)],
null=True,
blank=True)
progress_mode = models.CharField(
max_length=3,
choices=ProgressMode.choices,
default=ProgressMode.PAGE)
start_date = models.DateTimeField(
blank=True,
null=True)
@ -22,5 +31,28 @@ class ReadThrough(BookWyrmModel):
def save(self, *args, **kwargs):
''' update user active time '''
self.user.last_active_date = timezone.now()
self.user.save()
self.user.save(broadcast=False)
super().save(*args, **kwargs)
def create_update(self):
if self.progress:
return self.progressupdate_set.create(
user=self.user,
progress=self.progress,
mode=self.progress_mode)
class ProgressUpdate(BookWyrmModel):
''' Store progress through a book in the database. '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
readthrough = models.ForeignKey('ReadThrough', on_delete=models.CASCADE)
progress = models.IntegerField(validators=[validators.MinValueValidator(0)])
mode = models.CharField(
max_length=3,
choices=ProgressMode.choices,
default=ProgressMode.PAGE)
def save(self, *args, **kwargs):
''' update user active time '''
self.user.last_active_date = timezone.now()
self.user.save(broadcast=False)
super().save(*args, **kwargs)

View file

@ -1,12 +1,16 @@
''' defines relationships between users '''
from django.db import models
from django.apps import apps
from django.db import models, transaction
from django.db.models import Q
from django.dispatch import receiver
from bookwyrm import activitypub
from .base_model import ActivitypubMixin, BookWyrmModel
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
from .base_model import BookWyrmModel
from . import fields
class UserRelationship(ActivitypubMixin, BookWyrmModel):
class UserRelationship(BookWyrmModel):
''' many-to-many through table for followers '''
user_subject = fields.ForeignKey(
'User',
@ -21,6 +25,16 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
activitypub_field='object',
)
@property
def privacy(self):
''' all relationships are handled directly with the participants '''
return 'direct'
@property
def recipients(self):
''' the remote user needs to recieve direct broadcasts '''
return [u for u in [self.user_subject, self.user_object] if not u.local]
class Meta:
''' relationships should be unique '''
abstract = True
@ -35,8 +49,6 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
)
]
activity_serializer = activitypub.Follow
def get_remote_id(self, status=None):# pylint: disable=arguments-differ
''' use shelf identifier in remote_id '''
status = status or 'follows'
@ -44,55 +56,102 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
return '%s#%s/%d' % (base_path, status, self.id)
def to_accept_activity(self):
''' generate an Accept for this follow request '''
return activitypub.Accept(
id=self.get_remote_id(status='accepts'),
actor=self.user_object.remote_id,
object=self.to_activity()
).serialize()
def to_reject_activity(self):
''' generate a Reject for this follow request '''
return activitypub.Reject(
id=self.get_remote_id(status='rejects'),
actor=self.user_object.remote_id,
object=self.to_activity()
).serialize()
class UserFollows(UserRelationship):
class UserFollows(ActivitypubMixin, UserRelationship):
''' Following a user '''
status = 'follows'
activity_serializer = activitypub.Follow
@classmethod
def from_request(cls, follow_request):
''' converts a follow request into a follow relationship '''
return cls(
return cls.objects.create(
user_subject=follow_request.user_subject,
user_object=follow_request.user_object,
remote_id=follow_request.remote_id,
)
class UserFollowRequest(UserRelationship):
class UserFollowRequest(ActivitypubMixin, UserRelationship):
''' following a user requires manual or automatic confirmation '''
status = 'follow_request'
activity_serializer = activitypub.Follow
def save(self, *args, **kwargs):
''' make sure the follow relationship doesn't already exist '''
def save(self, *args, broadcast=True, **kwargs):
''' make sure the follow or block relationship doesn't already exist '''
try:
UserFollows.objects.get(
user_subject=self.user_subject,
user_object=self.user_object
)
UserBlocks.objects.get(
user_subject=self.user_subject,
user_object=self.user_object
)
return None
except UserFollows.DoesNotExist:
return super().save(*args, **kwargs)
except (UserFollows.DoesNotExist, UserBlocks.DoesNotExist):
super().save(*args, **kwargs)
if broadcast and self.user_subject.local and not self.user_object.local:
self.broadcast(self.to_activity(), self.user_subject)
if self.user_object.local:
model = apps.get_model('bookwyrm.Notification', require_ready=True)
notification_type = 'FOLLOW_REQUEST' \
if self.user_object.manually_approves_followers else 'FOLLOW'
model.objects.create(
user=self.user_object,
related_user=self.user_subject,
notification_type=notification_type,
)
class UserBlocks(UserRelationship):
def accept(self):
''' turn this request into the real deal'''
user = self.user_object
activity = activitypub.Accept(
id=self.get_remote_id(status='accepts'),
actor=self.user_object.remote_id,
object=self.to_activity()
).serialize()
with transaction.atomic():
UserFollows.from_request(self)
self.delete()
self.broadcast(activity, user)
def reject(self):
''' generate a Reject for this follow request '''
user = self.user_object
activity = activitypub.Reject(
id=self.get_remote_id(status='rejects'),
actor=self.user_object.remote_id,
object=self.to_activity()
).serialize()
self.delete()
self.broadcast(activity, user)
class UserBlocks(ActivityMixin, UserRelationship):
''' prevent another user from following you and seeing your posts '''
# TODO: not implemented
status = 'blocks'
activity_serializer = activitypub.Block
@receiver(models.signals.post_save, sender=UserBlocks)
#pylint: disable=unused-argument
def execute_after_save(sender, instance, created, *args, **kwargs):
''' remove follow or follow request rels after a block is created '''
UserFollows.objects.filter(
Q(user_subject=instance.user_subject,
user_object=instance.user_object) | \
Q(user_subject=instance.user_object,
user_object=instance.user_subject)
).delete()
UserFollowRequest.objects.filter(
Q(user_subject=instance.user_subject,
user_object=instance.user_object) | \
Q(user_subject=instance.user_object,
user_object=instance.user_subject)
).delete()

View file

@ -3,8 +3,8 @@ import re
from django.db import models
from bookwyrm import activitypub
from .base_model import ActivitypubMixin, BookWyrmModel
from .base_model import OrderedCollectionMixin
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
from . import fields
@ -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,19 +23,20 @@ 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)
super().save(*args, **kwargs)
if not self.identifier:
slug = re.sub(r'[^\w]', '', self.name).lower()
self.identifier = '%s-%d' % (slug, self.id)
return super().save(*args, **kwargs)
return saved
super().save(*args, **kwargs)
@property
def collection_queryset(self):
''' list of books for this shelf, overrides OrderedCollectionMixin '''
return self.books
return self.books.all().order_by('shelfbook')
def get_remote_id(self):
''' shelf identifier instead of id '''
@ -51,42 +48,22 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
unique_together = ('user', 'identifier')
class ShelfBook(ActivitypubMixin, BookWyrmModel):
class ShelfBook(CollectionItemMixin, BookWyrmModel):
''' many to many join table for books and shelves '''
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='object')
shelf = fields.ForeignKey(
'Shelf', on_delete=models.PROTECT, activitypub_field='target')
added_by = fields.ForeignKey(
'User',
blank=True,
null=True,
on_delete=models.PROTECT,
activitypub_field='actor'
)
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='actor')
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.shelf.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.shelf.to_activity()
).serialize()
object_field = 'book'
collection_field = 'shelf'
class Meta:
''' an opinionated constraint!
you can't put a book on shelf twice '''
unique_together = ('book', 'shelf')
ordering = ('-created_date',)

View file

@ -50,6 +50,7 @@ def new_access_code():
class SiteInvite(models.Model):
''' gives someone access to create an account on the instance '''
created_date = models.DateTimeField(auto_now_add=True)
code = models.CharField(max_length=32, default=new_access_code)
expiry = models.DateTimeField(blank=True, null=True)
use_limit = models.IntegerField(blank=True, null=True)

View file

@ -9,10 +9,12 @@ from django.utils import timezone
from model_utils.managers import InheritanceManager
from bookwyrm import activitypub
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
from .activitypub_mixin import OrderedCollectionPageMixin
from .base_model import BookWyrmModel
from . import fields
from .fields import image_serializer
from . import fields
class Status(OrderedCollectionPageMixin, BookWyrmModel):
''' any post, like a reply to a review, etc '''
@ -47,9 +49,50 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
objects = InheritanceManager()
activity_serializer = activitypub.Note
serialize_reverse_fields = [('attachments', 'attachment')]
serialize_reverse_fields = [('attachments', 'attachment', 'id')]
deserialize_reverse_fields = [('attachments', 'attachment')]
def save(self, *args, **kwargs):
''' save and notify '''
super().save(*args, **kwargs)
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
if self.deleted:
notification_model.objects.filter(related_status=self).delete()
if self.reply_parent and self.reply_parent.user != self.user and \
self.reply_parent.user.local:
notification_model.objects.create(
user=self.reply_parent.user,
notification_type='REPLY',
related_user=self.user,
related_status=self,
)
for mention_user in self.mention_users.all():
# avoid double-notifying about this status
if not mention_user.local or \
(self.reply_parent and \
mention_user == self.reply_parent.user):
continue
notification_model.objects.create(
user=mention_user,
notification_type='MENTION',
related_user=self.user,
related_status=self,
)
@property
def recipients(self):
''' tagged users who definitely need to get this status in broadcast '''
mentions = [u for u in self.mention_users.all() if not u.local]
if hasattr(self, 'reply_parent') and self.reply_parent \
and not self.reply_parent.user.local:
mentions.append(self.reply_parent.user)
return list(set(mentions))
@classmethod
def ignore_activity(cls, activity):
''' keep notes if they are replies to existing statuses '''
@ -94,6 +137,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
return self.to_ordered_collection(
self.replies(self),
remote_id='%s/replies' % self.remote_id,
collection_only=True,
**kwargs
)
@ -125,14 +169,6 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
return activity
def save(self, *args, **kwargs):
''' update user active time '''
if self.user.local:
self.user.last_active_date = timezone.now()
self.user.save()
return super().save(*args, **kwargs)
class GeneratedNote(Status):
''' these are app-generated messages about user activity '''
@property
@ -232,13 +268,13 @@ class ReviewRating(Review):
@property
def pure_content(self):
#pylint: disable=bad-string-format-type
return 'Rated "%s": %d' % (self.book.title, self.rating)
return 'Rated "%s": %d stars' % (self.book.title, self.rating)
activity_serializer = activitypub.Rating
pure_type = 'Note'
class Boost(Status):
class Boost(ActivityMixin, Status):
''' boost'ing a post '''
boosted_status = fields.ForeignKey(
'Status',
@ -246,6 +282,35 @@ class Boost(Status):
related_name='boosters',
activitypub_field='object',
)
activity_serializer = activitypub.Boost
def save(self, *args, **kwargs):
''' save and notify '''
super().save(*args, **kwargs)
if not self.boosted_status.user.local:
return
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
notification_model.objects.create(
user=self.boosted_status.user,
related_status=self.boosted_status,
related_user=self.user,
notification_type='BOOST',
)
def delete(self, *args, **kwargs):
''' delete and un-notify '''
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
notification_model.objects.filter(
user=self.boosted_status.user,
related_status=self.boosted_status,
related_user=self.user,
notification_type='BOOST',
).delete()
super().delete(*args, **kwargs)
def __init__(self, *args, **kwargs):
''' the user field is "actor" here instead of "attributedTo" '''
@ -259,8 +324,6 @@ class Boost(Status):
self.image_fields = []
self.deserialize_reverse_fields = []
activity_serializer = activitypub.Boost
# This constraint can't work as it would cross tables.
# class Meta:
# unique_together = ('user', 'boosted_status')

View file

@ -5,7 +5,8 @@ from django.db import models
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from .base_model import OrderedCollectionMixin, BookWyrmModel
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
from . import fields
@ -40,7 +41,7 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
super().save(*args, **kwargs)
class UserTag(BookWyrmModel):
class UserTag(CollectionItemMixin, BookWyrmModel):
''' an instance of a tag on a book by a user '''
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='actor')
@ -50,25 +51,8 @@ class UserTag(BookWyrmModel):
'Tag', on_delete=models.PROTECT, activitypub_field='target')
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.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.remote_id,
).serialize()
object_field = 'book'
collection_field = 'tag'
class Meta:
''' unqiueness constraint '''

View file

@ -4,8 +4,10 @@ from urllib.parse import urlparse
from django.apps import apps
from django.contrib.auth.models import AbstractUser
from django.core.validators import MinValueValidator
from django.db import models
from django.dispatch import receiver
from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.connectors import get_data
@ -15,15 +17,16 @@ from bookwyrm.settings import DOMAIN
from bookwyrm.signatures import create_key_pair
from bookwyrm.tasks import app
from bookwyrm.utils import regex
from .base_model import OrderedCollectionPageMixin
from .base_model import ActivitypubMixin, BookWyrmModel
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
from .base_model import BookWyrmModel
from .federated_server import FederatedServer
from . import fields
from . import fields, Review
class User(OrderedCollectionPageMixin, AbstractUser):
''' a user who wants to read books '''
username = fields.UsernameField()
email = models.EmailField(unique=True, null=True)
key_pair = fields.OneToOneField(
'KeyPair',
@ -128,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 '''
@ -200,7 +203,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
blank=True, null=True, activitypub_field='publicKeyPem')
activity_serializer = activitypub.PublicKey
serialize_reverse_fields = [('owner', 'owner')]
serialize_reverse_fields = [('owner', 'owner', 'id')]
def get_remote_id(self):
# self.owner is set by the OneToOneField on User
@ -208,6 +211,9 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
def save(self, *args, **kwargs):
''' create a key pair '''
# no broadcasting happening here
if 'broadcast' in kwargs:
del kwargs['broadcast']
if not self.public_key:
self.private_key, self.public_key = create_key_pair()
return super().save(*args, **kwargs)
@ -221,6 +227,60 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
return activity_object
class AnnualGoal(BookWyrmModel):
''' set a goal for how many books you read in a year '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
goal = models.IntegerField(
validators=[MinValueValidator(1)]
)
year = models.IntegerField(default=timezone.now().year)
privacy = models.CharField(
max_length=255,
default='public',
choices=fields.PrivacyLevels.choices
)
class Meta:
''' unqiueness constraint '''
unique_together = ('user', 'year')
def get_remote_id(self):
''' put the year in the path '''
return '%s/goal/%d' % (self.user.remote_id, self.year)
@property
def books(self):
''' the books you've read this year '''
return self.user.readthrough_set.filter(
finish_date__year__gte=self.year
).order_by('-finish_date').all()
@property
def ratings(self):
''' ratings for books read this year '''
book_ids = [r.book.id for r in self.books]
reviews = Review.objects.filter(
user=self.user,
book__in=book_ids,
)
return {r.book.id: r.rating for r in reviews}
@property
def progress_percent(self):
''' how close to your goal, in percent form '''
return int(float(self.book_count / self.goal) * 100)
@property
def book_count(self):
''' how many books you've read this year '''
return self.user.readthrough_set.filter(
finish_date__year__gte=self.year).count()
@receiver(models.signals.post_save, sender=User)
#pylint: disable=unused-argument
def execute_after_save(sender, instance, created, *args, **kwargs):
@ -234,7 +294,7 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
instance.key_pair = KeyPair.objects.create(
remote_id='%s/#main-key' % instance.remote_id)
instance.save()
instance.save(broadcast=False)
shelves = [{
'name': 'To Read',
@ -253,7 +313,7 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
identifier=shelf['identifier'],
user=instance,
editable=False
).save()
).save(broadcast=False)
@app.task

View file

@ -1,393 +0,0 @@
''' handles all the activity coming out of the server '''
import re
from django.db import IntegrityError, transaction
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET
from markdown import markdown
from requests import HTTPError
from bookwyrm import activitypub
from bookwyrm import models
from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.broadcast import broadcast
from bookwyrm.sanitize_html import InputHtmlParser
from bookwyrm.status import create_notification
from bookwyrm.status import create_generated_note
from bookwyrm.status import delete_status
from bookwyrm.settings import DOMAIN
from bookwyrm.utils import regex
@csrf_exempt
@require_GET
def outbox(request, username):
''' outbox for the requested user '''
user = get_object_or_404(models.User, localname=username)
filter_type = request.GET.get('type')
if filter_type not in models.status_models:
filter_type = None
return JsonResponse(
user.to_outbox(**request.GET, filter_type=filter_type),
encoder=activitypub.ActivityEncoder
)
def handle_remote_webfinger(query):
''' webfingerin' other servers '''
user = None
# usernames could be @user@domain or user@domain
if not query:
return None
if query[0] == '@':
query = query[1:]
try:
domain = query.split('@')[1]
except IndexError:
return None
try:
user = models.User.objects.get(username=query)
except models.User.DoesNotExist:
url = 'https://%s/.well-known/webfinger?resource=acct:%s' % \
(domain, query)
try:
data = get_data(url)
except (ConnectorException, HTTPError):
return None
for link in data.get('links'):
if link.get('rel') == 'self':
try:
user = activitypub.resolve_remote_id(
models.User, link['href']
)
except KeyError:
return None
return user
def handle_follow(user, to_follow):
''' someone local wants to follow someone '''
relationship, _ = models.UserFollowRequest.objects.get_or_create(
user_subject=user,
user_object=to_follow,
)
activity = relationship.to_activity()
broadcast(user, activity, privacy='direct', direct_recipients=[to_follow])
def handle_unfollow(user, to_unfollow):
''' someone local wants to follow someone '''
relationship = models.UserFollows.objects.get(
user_subject=user,
user_object=to_unfollow
)
activity = relationship.to_undo_activity(user)
broadcast(user, activity, privacy='direct', direct_recipients=[to_unfollow])
to_unfollow.followers.remove(user)
def handle_accept(follow_request):
''' send an acceptance message to a follow request '''
user = follow_request.user_subject
to_follow = follow_request.user_object
with transaction.atomic():
relationship = models.UserFollows.from_request(follow_request)
follow_request.delete()
relationship.save()
activity = relationship.to_accept_activity()
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
def handle_reject(follow_request):
''' a local user who managed follows rejects a follow request '''
user = follow_request.user_subject
to_follow = follow_request.user_object
activity = follow_request.to_reject_activity()
follow_request.delete()
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
def handle_shelve(user, book, shelf):
''' a local user is getting a book put on their shelf '''
# update the database
shelve = models.ShelfBook(book=book, shelf=shelf, added_by=user)
shelve.save()
broadcast(user, shelve.to_add_activity(user))
def handle_unshelve(user, book, shelf):
''' a local user is getting a book put on their shelf '''
# update the database
row = models.ShelfBook.objects.get(book=book, shelf=shelf)
activity = row.to_remove_activity(user)
row.delete()
broadcast(user, activity)
def handle_reading_status(user, shelf, book, privacy):
''' post about a user reading a book '''
# tell the world about this cool thing that happened
try:
message = {
'to-read': 'wants to read',
'reading': 'started reading',
'read': 'finished reading'
}[shelf.identifier]
except KeyError:
# it's a non-standard shelf, don't worry about it
return
status = create_generated_note(
user,
message,
mention_books=[book],
privacy=privacy
)
status.save()
broadcast(user, status.to_create_activity(user))
def handle_imported_book(user, item, include_reviews, privacy):
''' process a goodreads csv and then post about it '''
if isinstance(item.book, models.Work):
item.book = item.book.default_edition
if not item.book:
return
existing_shelf = models.ShelfBook.objects.filter(
book=item.book, added_by=user).exists()
# shelve the book if it hasn't been shelved already
if item.shelf and not existing_shelf:
desired_shelf = models.Shelf.objects.get(
identifier=item.shelf,
user=user
)
shelf_book = models.ShelfBook.objects.create(
book=item.book, shelf=desired_shelf, added_by=user)
broadcast(user, shelf_book.to_add_activity(user), privacy=privacy)
for read in item.reads:
read.book = item.book
read.user = user
read.save()
if include_reviews and (item.rating or item.review):
review_title = 'Review of {!r} on Goodreads'.format(
item.book.title,
) if item.review else ''
# we don't know the publication date of the review,
# but "now" is a bad guess
published_date_guess = item.date_read or item.date_added
review = models.Review.objects.create(
user=user,
book=item.book,
name=review_title,
content=item.review,
rating=item.rating,
published_date=published_date_guess,
privacy=privacy,
)
# we don't need to send out pure activities because non-bookwyrm
# instances don't need this data
broadcast(user, review.to_create_activity(user), privacy=privacy)
def handle_delete_status(user, status):
''' delete a status and broadcast deletion to other servers '''
delete_status(status)
broadcast(user, status.to_delete_activity(user))
def handle_status(user, form):
''' generic handler for statuses '''
status = form.save(commit=False)
if not status.sensitive and status.content_warning:
# the cw text field remains populated when you click "remove"
status.content_warning = None
status.save()
# inspect the text for user tags
content = status.content
for (mention_text, mention_user) in find_mentions(content):
# add them to status mentions fk
status.mention_users.add(mention_user)
# turn the mention into a link
content = re.sub(
r'%s([^@]|$)' % mention_text,
r'<a href="%s">%s</a>\g<1>' % \
(mention_user.remote_id, mention_text),
content)
# add reply parent to mentions and notify
if status.reply_parent:
status.mention_users.add(status.reply_parent.user)
for mention_user in status.reply_parent.mention_users.all():
status.mention_users.add(mention_user)
if status.reply_parent.user.local:
create_notification(
status.reply_parent.user,
'REPLY',
related_user=user,
related_status=status
)
# deduplicate mentions
status.mention_users.set(set(status.mention_users.all()))
# create mention notifications
for mention_user in status.mention_users.all():
if status.reply_parent and mention_user == status.reply_parent.user:
continue
if mention_user.local:
create_notification(
mention_user,
'MENTION',
related_user=user,
related_status=status
)
# don't apply formatting to generated notes
if not isinstance(status, models.GeneratedNote):
status.content = to_markdown(content)
# do apply formatting to quotes
if hasattr(status, 'quote'):
status.quote = to_markdown(status.quote)
status.save()
broadcast(user, status.to_create_activity(user), software='bookwyrm')
# re-format the activity for non-bookwyrm servers
remote_activity = status.to_create_activity(user, pure=True)
broadcast(user, remote_activity, software='other')
def find_mentions(content):
''' detect @mentions in raw status content '''
for match in re.finditer(regex.strict_username, content):
username = match.group().strip().split('@')[1:]
if len(username) == 1:
# this looks like a local user (@user), fill in the domain
username.append(DOMAIN)
username = '@'.join(username)
mention_user = handle_remote_webfinger(username)
if not mention_user:
# we can ignore users we don't know about
continue
yield (match.group(), mention_user)
def to_markdown(content):
''' catch links and convert to markdown '''
content = re.sub(
r'([^(href=")])(https?:\/\/([A-Za-z\.\-_\/]+' \
r'\.[A-Za-z]{2,}[A-Za-z\.\-_\/]+))',
r'\g<1><a href="\g<2>">\g<3></a>',
content)
content = markdown(content)
# sanitize resulting html
sanitizer = InputHtmlParser()
sanitizer.feed(content)
return sanitizer.get_output()
def handle_favorite(user, status):
''' a user likes a status '''
try:
favorite = models.Favorite.objects.create(
status=status,
user=user
)
except IntegrityError:
# you already fav'ed that
return
fav_activity = favorite.to_activity()
broadcast(
user, fav_activity, privacy='direct', direct_recipients=[status.user])
create_notification(
status.user,
'FAVORITE',
related_user=user,
related_status=status
)
def handle_unfavorite(user, status):
''' a user likes a status '''
try:
favorite = models.Favorite.objects.get(
status=status,
user=user
)
except models.Favorite.DoesNotExist:
# can't find that status, idk
return
fav_activity = favorite.to_undo_activity(user)
favorite.delete()
broadcast(user, fav_activity, direct_recipients=[status.user])
def handle_boost(user, status):
''' a user wishes to boost a status '''
# is it boostable?
if not status.boostable:
return
if models.Boost.objects.filter(
boosted_status=status, user=user).exists():
# you already boosted that.
return
boost = models.Boost.objects.create(
boosted_status=status,
privacy=status.privacy,
user=user,
)
boost_activity = boost.to_activity()
broadcast(user, boost_activity)
create_notification(
status.user,
'BOOST',
related_user=user,
related_status=status
)
def handle_unboost(user, status):
''' a user regrets boosting a status '''
boost = models.Boost.objects.filter(
boosted_status=status, user=user
).first()
activity = boost.to_undo_activity(user)
boost.delete()
broadcast(user, activity)
def handle_update_book_data(user, item):
''' broadcast the news about our book '''
broadcast(user, item.to_update_activity(user))
def handle_update_user(user):
''' broadcast editing a user's profile '''
broadcast(user, user.to_update_activity(user))

View file

@ -7,7 +7,7 @@ class InputHtmlParser(HTMLParser):#pylint: disable=abstract-method
def __init__(self):
HTMLParser.__init__(self)
self.allowed_tags = [
'p', 'br',
'p', 'blockquote', 'br',
'b', 'i', 'strong', 'em', 'pre',
'a', 'span', 'ul', 'ol', 'li'
]

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View file

@ -5,21 +5,32 @@
.navbar .logo {
max-height: 50px;
}
.card {
overflow: visible;
}
.card-header-title {
overflow: hidden;
}
/* --- SHELVING --- */
.shelf-option:disabled > *::after {
font-family: "icomoon";
content: "\e918";
margin-left: 0.5em;
}
/* --- TOGGLES --- */
input.toggle-control {
.toggle-button[aria-pressed=true], .toggle-button[aria-pressed=true]:hover {
background-color: hsl(171, 100%, 41%);
color: white;
}
.hide-active[aria-pressed=true], .hide-inactive[aria-pressed=false] {
display: none;
}
.hidden {
display: none;
}
input.toggle-control:checked ~ .toggle-content {
display: block;
}
input.toggle-control:checked ~ .modal.toggle-content {
display: flex;
display: none !important;
}
/* --- STARS --- */
@ -69,10 +80,11 @@ input.toggle-control:checked ~ .modal.toggle-content {
}
.cover-container.is-large {
height: max-content;
max-width: 500px;
max-width: 330px;
}
.cover-container.is-large img {
max-height: 500px;
height: auto;
}
.cover-container.is-medium {
height: 150px;
@ -124,6 +136,9 @@ input.toggle-control:checked ~ .modal.toggle-content {
vertical-align: middle;
display: inline;
}
.navbar .avatar {
max-height: none;
}
/* --- QUOTES --- */
@ -136,11 +151,11 @@ input.toggle-control:checked ~ .modal.toggle-content {
position: absolute;
}
.quote blockquote:before {
content: "\e905";
content: "\e906";
top: 0;
left: 0;
}
.quote blockquote:after {
content: "\e904";
content: "\e905";
right: 0;
}

View file

@ -1,10 +1,10 @@
@font-face {
font-family: 'icomoon';
src: url('fonts/icomoon.eot?rd4abb');
src: url('fonts/icomoon.eot?rd4abb#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?rd4abb') format('truetype'),
url('fonts/icomoon.woff?rd4abb') format('woff'),
url('fonts/icomoon.svg?rd4abb#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,81 +25,114 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-dots-three-vertical:before {
content: "\e918";
.icon-graphic-heart:before {
content: "\e91e";
}
.icon-check:before {
content: "\e917";
.icon-graphic-paperplane:before {
content: "\e91f";
}
.icon-dots-three:before {
content: "\e916";
.icon-graphic-banknote:before {
content: "\e920";
}
.icon-envelope:before {
.icon-stars:before {
content: "\e91a";
}
.icon-warning:before {
content: "\e91b";
}
.icon-book:before {
content: "\e900";
}
.icon-arrow-right:before {
.icon-bookmark:before {
content: "\e91c";
}
.icon-rss:before {
content: "\e91d";
}
.icon-envelope:before {
content: "\e901";
}
.icon-bell:before {
.icon-arrow-right:before {
content: "\e902";
}
.icon-x:before {
.icon-bell:before {
content: "\e903";
}
.icon-quote-close:before {
.icon-x:before {
content: "\e904";
}
.icon-quote-open:before {
.icon-quote-close:before {
content: "\e905";
}
.icon-image:before {
.icon-quote-open:before {
content: "\e906";
}
.icon-pencil:before {
.icon-image:before {
content: "\e907";
}
.icon-list:before {
.icon-pencil:before {
content: "\e908";
}
.icon-unlock:before {
.icon-list:before {
content: "\e909";
}
.icon-globe:before {
.icon-unlock:before {
content: "\e90a";
}
.icon-lock:before {
.icon-unlisted:before {
content: "\e90a";
}
.icon-globe:before {
content: "\e90b";
}
.icon-chain-broken:before {
.icon-public:before {
content: "\e90b";
}
.icon-lock:before {
content: "\e90c";
}
.icon-chain:before {
.icon-followers:before {
content: "\e90c";
}
.icon-chain-broken:before {
content: "\e90d";
}
.icon-comments:before {
.icon-chain:before {
content: "\e90e";
}
.icon-comment:before {
.icon-comments:before {
content: "\e90f";
}
.icon-boost:before {
.icon-comment:before {
content: "\e910";
}
.icon-arrow-left:before {
.icon-boost:before {
content: "\e911";
}
.icon-arrow-up:before {
.icon-arrow-left:before {
content: "\e912";
}
.icon-arrow-down:before {
.icon-arrow-up:before {
content: "\e913";
}
.icon-home:before {
.icon-arrow-down:before {
content: "\e914";
}
.icon-local:before {
.icon-home:before {
content: "\e915";
}
.icon-local:before {
content: "\e916";
}
.icon-dots-three:before {
content: "\e917";
}
.icon-check:before {
content: "\e918";
}
.icon-dots-three-vertical:before {
content: "\e919";
}
.icon-search:before {
content: "\e986";
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,62 +1,172 @@
// set up javascript listeners
window.onload = function() {
// buttons that display or hide content
document.querySelectorAll('[data-controls]')
.forEach(t => t.onclick = toggleAction);
// javascript interactions (boost/fav)
Array.from(document.getElementsByClassName('interaction'))
.forEach(t => t.onsubmit = interact);
// select all
Array.from(document.getElementsByClassName('select-all'))
.forEach(t => t.onclick = selectAll);
// toggle between tabs
Array.from(document.getElementsByClassName('tab-change'))
.forEach(t => t.onclick = tabChange);
// handle aria settings on menus
Array.from(document.getElementsByClassName('pulldown-menu'))
.forEach(t => t.onclick = toggleMenu);
// display based on localstorage vars
document.querySelectorAll('[data-hide]')
.forEach(t => setDisplay(t));
// update localstorage
Array.from(document.getElementsByClassName('set-display'))
.forEach(t => t.onclick = updateDisplay);
// hidden submit button in a form
document.querySelectorAll('.hidden-form input')
.forEach(t => t.onchange = revealForm);
// polling
document.querySelectorAll('[data-poll]')
.forEach(el => polling(el));
// browser back behavior
document.querySelectorAll('[data-back]')
.forEach(t => t.onclick = back);
};
function back(e) {
e.preventDefault();
history.back();
}
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, delay * 1.25);
}, delay, el);
}
function updateCountElement(el, data) {
const currentCount = el.innerText;
const count = data[el.getAttribute('data-poll')];
if (count != currentCount) {
addRemoveClass(el, 'hidden', count < 1);
el.innerText = count;
}
}
function revealForm(e) {
var hidden = e.currentTarget.closest('.hidden-form').getElementsByClassName('hidden')[0];
if (hidden) {
removeClass(hidden, 'hidden');
}
}
function updateDisplay(e) {
// used in set reading goal
var key = e.target.getAttribute('data-id');
var value = e.target.getAttribute('data-value');
window.localStorage.setItem(key, value);
document.querySelectorAll('[data-hide="' + key + '"]')
.forEach(t => setDisplay(t));
}
function setDisplay(el) {
// used in set reading goal
var key = el.getAttribute('data-hide');
var value = window.localStorage.getItem(key);
addRemoveClass(el, 'hidden', value);
}
function toggleAction(e) {
var el = e.currentTarget;
var pressed = el.getAttribute('aria-pressed') == 'false';
var targetId = el.getAttribute('data-controls');
document.querySelectorAll('[data-controls="' + targetId + '"]')
.forEach(t => t.setAttribute('aria-pressed', (t.getAttribute('aria-pressed') == 'false')));
if (targetId) {
var target = document.getElementById(targetId);
addRemoveClass(target, 'hidden', !pressed);
addRemoveClass(target, 'is-active', pressed);
}
// show/hide container
var container = document.getElementById('hide-' + targetId);
if (!!container) {
addRemoveClass(container, 'hidden', pressed);
}
// set checkbox, if appropriate
var checkbox = el.getAttribute('data-controls-checkbox');
if (checkbox) {
document.getElementById(checkbox).checked = !!pressed;
}
// set focus, if appropriate
var focus = el.getAttribute('data-focus-target');
if (focus) {
var focusEl = document.getElementById(focus);
focusEl.focus();
setTimeout(function(){ focusEl.selectionStart = focusEl.selectionEnd = 10000; }, 0);
}
}
function interact(e) {
e.preventDefault();
ajaxPost(e.target);
var identifier = e.target.getAttribute('data-id');
var elements = document.getElementsByClassName(identifier);
for (var i = 0; i < elements.length; i++) {
if (elements[i].className.includes('hidden')) {
elements[i].className = elements[i].className.replace('hidden', '');
} else {
elements[i].className += ' hidden';
}
}
return true;
Array.from(document.getElementsByClassName(identifier))
.forEach(t => addRemoveClass(t, 'hidden', t.className.indexOf('hidden') == -1));
}
function reply(e) {
e.preventDefault();
ajaxPost(e.target);
// TODO: display comment
return true;
}
function selectAll(el) {
el.parentElement.querySelectorAll('[type="checkbox"]')
function selectAll(e) {
e.target.parentElement.parentElement.querySelectorAll('[type="checkbox"]')
.forEach(t => t.checked=true);
}
function rate_stars(e) {
e.preventDefault();
ajaxPost(e.target);
rating = e.target.rating.value;
var stars = e.target.parentElement.getElementsByClassName('icon');
for (var i = 0; i < stars.length ; i++) {
stars[i].className = rating > i ? 'icon icon-star-full' : 'icon icon-star-empty';
}
return true;
}
function tabChange(e) {
var el = e.currentTarget;
var parentElement = el.closest('[role="tablist"]');
function tabChange(e, nested) {
var target = e.target.closest('li')
var identifier = target.getAttribute('data-id');
if (nested) {
var parent_element = target.parentElement.closest('li').parentElement;
} else {
var parent_element = target.parentElement;
}
parent_element.querySelectorAll('[aria-selected="true"]')
parentElement.querySelectorAll('[aria-selected="true"]')
.forEach(t => t.setAttribute("aria-selected", false));
target.querySelector('[role="tab"]').setAttribute("aria-selected", true);
el.setAttribute("aria-selected", true);
parent_element.querySelectorAll('li')
.forEach(t => t.className='');
target.className = 'is-active';
parentElement.querySelectorAll('li')
.forEach(t => removeClass(t, 'is-active'));
addClass(el, 'is-active');
var tabId = el.getAttribute('data-tab');
Array.from(document.getElementsByClassName(el.getAttribute('data-category')))
.forEach(t => addRemoveClass(t, 'hidden', t.id != tabId));
}
function toggleMenu(el) {
el.setAttribute('aria-expanded', el.getAttribute('aria-expanded') == 'false');
function toggleMenu(e) {
var el = e.currentTarget;
var expanded = el.getAttribute('aria-expanded') == 'false';
el.setAttribute('aria-expanded', expanded);
var targetId = el.getAttribute('data-controls');
if (targetId) {
var target = document.getElementById(targetId);
addRemoveClass(target, 'is-active', expanded);
}
}
function ajaxPost(form) {
@ -65,3 +175,31 @@ function ajaxPost(form) {
body: new FormData(form)
});
}
function addRemoveClass(el, classname, bool) {
if (bool) {
addClass(el, classname);
} else {
removeClass(el, classname);
}
}
function addClass(el, classname) {
var classes = el.className.split(' ');
if (classes.indexOf(classname) > -1) {
return;
}
el.className = classes.concat(classname).join(' ');
}
function removeClass(el, className) {
var classes = [];
if (el.className) {
classes = el.className.split(' ');
}
const idx = classes.indexOf(className);
if (idx > -1) {
classes.splice(idx, 1);
}
el.className = classes.join(' ');
}

View file

@ -1,4 +1,5 @@
''' Handle user activity '''
from django.db import transaction
from django.utils import timezone
from bookwyrm import models
@ -19,30 +20,18 @@ def create_generated_note(user, content, mention_books=None, privacy='public'):
parser.feed(content)
content = parser.get_output()
status = models.GeneratedNote.objects.create(
with transaction.atomic():
# create but don't save
status = models.GeneratedNote(
user=user,
content=content,
privacy=privacy
)
# we have to save it to set the related fields, but hold off on telling
# folks about it because it is not ready
status.save(broadcast=False)
if mention_books:
for book in mention_books:
status.mention_books.add(book)
status.mention_books.set(mention_books)
status.save(created=True)
return status
def create_notification(user, notification_type, related_user=None, \
related_book=None, related_status=None, related_import=None):
''' let a user know when someone interacts with their content '''
if user == related_user:
# don't create notification when you interact with your own stuff
return
models.Notification.objects.create(
user=user,
related_book=related_book,
related_user=related_user,
related_status=related_status,
related_import=related_import,
notification_type=notification_type,
)

View file

@ -9,7 +9,7 @@
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
<div class="column is-narrow">
<a href="{{ author.local_path }}/edit">
<span class="icon icon-pencil">
<span class="icon icon-pencil" title="Edit Author">
<span class="is-sr-only">Edit Author</span>
</span>
</a>

View file

@ -9,6 +9,9 @@
<h1 class="title">
{{ book.title }}{% if book.subtitle %}:
<small>{{ book.subtitle }}</small>{% endif %}
{% if book.series %}
<small class="has-text-grey-dark">({{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %})</small><br>
{% endif %}
</h1>
{% if book.authors %}
<h2 class="subtitle">
@ -20,7 +23,7 @@
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
<div class="column is-narrow">
<a href="{{ book.id }}/edit">
<span class="icon icon-pencil">
<span class="icon icon-pencil" title="Edit Book">
<span class="is-sr-only">Edit Book</span>
</span>
</a>
@ -32,51 +35,82 @@
<div class="column is-narrow">
{% include 'snippets/book_cover.html' with book=book size=large %}
{% include 'snippets/rate_action.html' with user=request.user book=book %}
{% include 'snippets/shelve_button.html' %}
{% include 'snippets/shelve_button/shelve_button.html' %}
{% if request.user.is_authenticated and not book.cover %}
<div class="box p-2">
<h3 class="title is-6 mb-1">Add cover</h3>
<form name="add-cover" method="POST" action="/upload-cover/{{ book.id }}" enctype="multipart/form-data">
{% csrf_token %}
<div class="field">
<label class="label" for="id_cover">Cover:</label>
<input type="file" name="cover" accept="image/*" class="" id="id_cover">
<div class="field has-addons">
<div class="control">
<div class="file is-small mb-1">
<label class="file-label">
<input class="file-input" type="file" name="cover" accept="image/*" enctype="multipart/form-data" id="id_cover" required>
<span class="file-cta">
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
<span class="file-label">
Choose file...
</span>
</span>
</label>
</div>
</div>
<div class="control">
<button class="button is-small is-primary" type="submit">Add</button>
</div>
<div class="field">
<button class="button is-small" type="submit">Add cover</button>
</div>
</form>
</div>
{% endif %}
<dl class="content">
{% for field in info_fields %}
{% if field.value %}
<dt>{{ field.name }}:</dt>
<dd>{{ field.value }}</dd>
<section class="content">
<dl>
{% if book.isbn_13 %}
<div class="is-flex is-justify-content-space-between">
<dt>ISBN:</dt>
<dd>{{ book.isbn_13 }}</dd>
</div>
{% endif %}
{% if book.oclc_number %}
<div class="is-flex is-justify-content-space-between">
<dt>OCLC Number:</dt>
<dd>{{ book.oclc_number }}</dd>
</div>
{% endif %}
{% if book.asin %}
<div class="is-flex is-justify-content-space-between">
<dt>ASIN:</dt>
<dd>{{ book.asin }}</dd>
</div>
{% endif %}
{% endfor %}
</dl>
<p>
{% if book.physical_format %}{{ book.physical_format | title }}{% if book.pages %},<br>{% endif %}{% endif %}
{% if book.pages %}{{ book.pages }} pages{% endif %}
</p>
{% if book.openlibrary_key %}
<p><a href="https://openlibrary.org/books/{{ book.openlibrary_key }}" target="_blank" rel="noopener">View on OpenLibrary</a></p>
{% endif %}
</section>
</div>
<div class="column">
<div class="block">
<h3 class="field is-grouped">{% include 'snippets/stars.html' with rating=rating %} ({{ reviews|length }} review{{ reviews|length|pluralize }})</h3>
<h3 class="field is-grouped">{% include 'snippets/stars.html' with rating=rating %} ({{ review_count }} review{{ review_count|pluralize }})</h3>
{% include 'snippets/trimmed_text.html' with full=book|book_description %}
{% if request.user.is_authenticated and perms.bookwyrm.edit_book and not book|book_description %}
<div>
<input class="toggle-control" type="radio" name="add-description" id="hide-description" checked>
<div class="toggle-content hidden">
<label class="button" for="add-description" tabindex="0" role="button">Add description</label>
</div>
</div>
{% include 'snippets/toggle/open_button.html' with text="Add description" controls_text="add-description" controls_uid=book.id focus="id_description" hide_active=True id="hide-description" %}
<div>
<input class="toggle-control" type="radio" name="add-description" id="add-description">
<div class="toggle-content hidden">
<div class="box">
<div class="box hidden" id="add-description-{{ book.id }}">
<form name="add-description" method="POST" action="/add-description/{{ book.id }}">
{% csrf_token %}
<p class="fields is-grouped">
@ -85,12 +119,10 @@
</p>
<div class="field">
<button class="button is-primary" type="submit">Save</button>
<label class="button" for="hide-description" tabindex="0" role="button">Cancel</label>
{% include 'snippets/toggle/close_button.html' with text="Cancel" controls_text="add-description" controls_uid=book.id hide_inactive=True %}
</div>
</form>
</div>
</div>
</div>
{% endif %}
@ -100,32 +132,57 @@
</div>
{# user's relationship to the book #}
<div>
<div class="block">
{% for shelf in user_shelves %}
<p>
This edition is on your <a href="/user/{{ user.localname }}/shelf/{{ shelf.shelf.identifier }}">{{ shelf.shelf.name }}</a> shelf.
{% include 'snippets/shelf_selector.html' with current=shelf.shelf %}
</p>
{% endfor %}
{% for shelf in other_edition_shelves %}
<p>
A <a href="/book/{{ shelf.book.id }}">different edition</a> of this book is on your <a href="/user/{{ user.localname }}/shelf/{{ shelf.shelf.identifier }}">{{ shelf.shelf.name }}</a> shelf.
{% include 'snippets/switch_edition_button.html' with edition=book %}
</p>
{% endfor %}
{% for readthrough in readthroughs %}
{% include 'snippets/readthrough.html' with readthrough=readthrough %}
{% endfor %}
</div>
{% if request.user.is_authenticated %}
<div class="box">
{% include 'snippets/create_status.html' with book=book hide_cover=True %}
<section class="block">
<header class="columns">
<h2 class="column title is-5 mb-1">Your reading activity</h2>
<div class="column is-narrow">
{% 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 %}
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" type="submit">Create</button>
</div>
<div class="control">
{% include 'snippets/toggle/close_button.html' with text="Cancel" controls_text="add-readthrough" %}
</div>
</div>
</form>
</section>
{% for readthrough in readthroughs %}
{% include 'snippets/readthrough.html' with readthrough=readthrough %}
{% endfor %}
</section>
{% endif %}
<div class="block">
{% if request.user.is_authenticated %}
<section class="box">
{% include 'snippets/create_status.html' with book=book hide_cover=True %}
</section>
<section class="block">
<form name="tag" action="/tag/" method="post">
<label for="tags" class="is-3">Tags</label>
{% csrf_token %}
@ -133,7 +190,7 @@
<input id="tags" class="input" type="text" name="name">
<button class="button" type="submit">Add tag</button>
</form>
</div>
</section>
{% endif %}
<div class="block">
@ -145,26 +202,42 @@
</div>
</div>
<div class="column is-narrow">
{% if book.subjects %}
<section class="content block">
<h2 class="title is-5">Subjects</h2>
<ul>
{% for subject in book.subjects %}
<li>{{ subject }}</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% if book.subject_places %}
<section class="content block">
<h2 class="title is-5">Places</h2>
<ul>
{% for place in book.subject_placess %}
<li>{{ place }}</li>
{% endfor %}
</ul>
</section>
{% endif %}
</div>
</div>
</div>
{% if not reviews %}
<div class="block">
<p>No reviews yet!</p>
</div>
{% endif %}
<div class="block">
<div class="block" id="reviews">
{% for review in reviews %}
<div class="block">
{% include 'snippets/status.html' with status=review hide_book=True depth=1 %}
</div>
{% endfor %}
<div class="block columns">
<div class="block is-flex is-flex-wrap-wrap">
{% for rating in ratings %}
<div class="column">
<div class="block mr-5">
<div class="media">
<div class="media-left">{% include 'snippets/avatar.html' with user=rating.user %}</div>
<div class="media-content">
@ -183,8 +256,10 @@
</div>
{% endfor %}
</div>
<div class="block">
{% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,21 @@
<article class="card">
<header class="card-header">
{% block card-header %}
{% endblock %}
</header>
{% if not status or status.status_type != 'GeneratedNote' or status.book or status.mention_books.exists or status.mention_users.exists %}
<section class="card-content">
{% block card-content %}
{% endblock %}
</section>
{% endif %}
<footer class="card-footer has-background-white-bis">
{% block card-footer %}
{% endblock %}
</footer>
{% block card-bonus %}
{% endblock %}
</article>

View file

@ -0,0 +1,13 @@
{% load bookwyrm_tags %}
{% with 0|uuid as uuid %}
<div class="dropdown control{% if right %} is-right{% endif %}" id="menu-{{ uuid }}">
<button type="button" class="button dropdown-trigger pulldown-menu {{ class }}" aria-expanded="false" class="pulldown-menu" aria-haspopup="true" aria-controls="menu-options-{{ uuid }}" data-controls="menu-{{ uuid }}">
{% block dropdown-trigger %}{% endblock %}
</button>
<div class="dropdown-menu">
<ul class="dropdown-content" role="menu" id="menu-options-{{ book.id }}">
{% block dropdown-list %}{% endblock %}
</ul>
</div>
</div>
{% endwith %}

View file

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

View file

@ -0,0 +1,24 @@
<div class="modal hidden" id="{{ controls_text }}-{{ controls_uid }}">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head" tabindex="0" id="modal-title-{{ controls_text }}-{{ controls_uid }}">
<h2 class="modal-card-title">
{% block modal-title %}{% endblock %}
</h2>
{% include 'snippets/toggle/toggle_button.html' with label="close" class="delete" nonbutton=True %}
</header>
{% block modal-form-open %}{% endblock %}
{% if not no_body %}
<section class="modal-card-body">
{% block modal-body %}{% endblock %}
</section>
{% endif %}
<footer class="modal-card-foot">
{% block modal-footer %}{% endblock %}
</footer>
{% block modal-form-close %}{% endblock %}
</div>
<label class="modal-close is-large" for="{{ controls_text }}-{{ controls_uid }}" aria-label="close"></label>
{% include 'snippets/toggle/toggle_button.html' with label="close" class="modal-close is-large" nonbutton=True %}
</div>

View file

@ -1,37 +0,0 @@
{% extends 'layout.html' %}
{% block content %}
<div class="block">
<h1 class="title">Direct Messages</h1>
{% if not activities %}
<p>You have no messages right now.</p>
{% endif %}
{% for activity in activities %}
<div class="block">
{% include 'snippets/status.html' with status=activity %}
</div>
{% endfor %}
<nav class="pagination" role="navigation" aria-label="pagination">
{% if prev %}
<p class="pagination-previous">
<a href="{{ prev }}">
<span class="icon icon-arrow-left"></span>
Previous
</a>
</p>
{% endif %}
{% if next %}
<p class="pagination-next">
<a href="{{ next }}">
Next
<span class="icon icon-arrow-right"></span>
</a>
</p>
{% endif %}
</nav>
</div>
{% endblock %}

View file

@ -2,9 +2,31 @@
{% block content %}
{% if not request.user.is_authenticated %}
<div class="block">
<h1 class="title has-text-centered">{{ site.name }}: {{ site.instance_tagline }}</h1>
</div>
<header class="block has-text-centered">
<h1 class="title">{{ site.name }}</h1>
<h2 class="subtitle">{{ site.instance_tagline }}</h2>
</header>
<section class="level is-mobile">
<div class="level-item has-text-centered">
<div>
<p class="title has-text-weight-normal"><span class="icon icon-graphic-paperplane"></span></p>
<p class="heading">Decentralized</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="title has-text-weight-normal"><span class="icon icon-graphic-heart"></span></p>
<p class="heading">Friendly</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="title has-text-weight-normal"><span class="icon icon-graphic-banknote"></span></p>
<p class="heading">Anti-Corporate</p>
</div>
</div>
</section>
<section class="tile is-ancestor">
<div class="tile is-7 is-parent">
@ -13,19 +35,20 @@
</div>
</div>
<div class="tile is-5 is-parent">
<div class="tile is-child box has-background-primary-light">
<div class="tile is-child box has-background-primary-light content">
{% if site.allow_registration %}
<h2 class="title">Join {{ site.name }}</h2>
<form name="register" method="post" action="/user-register">
<form name="register" method="post" action="/register">
{% include 'snippets/register_form.html' %}
</form>
{% else %}
<h2 class="title">This instance is closed</h2>
<p>{{ site.registration_closed_text }}</p>
<p>{{ site.registration_closed_text | safe}}</p>
{% endif %}
</div>
</div>
</section>
{% else %}
<div class="block">
<h1 class="title has-text-centered">Discover</h1>

View file

@ -1,25 +1,16 @@
{% extends 'layout.html' %}
{% load humanize %}
{% block content %}
<div class="block">
<div class="level">
<header class="block">
<h1 class="title level-left">
Edit "{{ author.name }}"
</h1>
<div class="level-right">
<a href="/author/{{ author.id }}">
<span class="edit-link icon icon-close">
<span class="is-sr-only">Close</span>
</span>
</a>
</div>
</div>
<div>
<p>Added: {{ author.created_date | naturaltime }}</p>
<p>Updated: {{ author.updated_date | naturaltime }}</p>
<p>Last edited by: <a href="{{ author.last_edited_by.remote_id }}">{{ author.last_edited_by.display_name }}</a></p>
</div>
</div>
</header>
{% if form.non_field_errors %}
<div class="block">
@ -27,7 +18,7 @@
</div>
{% endif %}
<form class="block" name="edit-author" action="/edit-author/{{ author.id }}" method="post">
<form class="block" name="edit-author" action="{{ author.local_path }}/edit" method="post">
{% csrf_token %}
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}">

View file

@ -1,25 +1,16 @@
{% extends 'layout.html' %}
{% load humanize %}
{% block content %}
<div class="block">
<div class="level">
<header class="block">
<h1 class="title level-left">
Edit "{{ book.title }}"
</h1>
<div class="level-right">
<a href="/book/{{ book.id }}">
<span class="edit-link icon icon-close">
<span class="is-sr-only">Close</span>
</span>
</a>
</div>
</div>
<div>
<p>Added: {{ book.created_date | naturaltime }}</p>
<p>Updated: {{ book.updated_date | naturaltime }}</p>
<p>Last edited by: <a href="{{ book.last_edited_by.remote_id }}">{{ book.last_edited_by.display_name }}</a></p>
</div>
</div>
</header>
{% if form.non_field_errors %}
<div class="block">
@ -27,7 +18,7 @@
</div>
{% endif %}
<form class="block" name="edit-book" action="/edit-book/{{ book.id }}" method="post" enctype="multipart/form-data">
<form class="block" name="edit-book" action="{{ book.local_path }}/edit" method="post" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}">
<div class="columns">
@ -37,10 +28,6 @@
{% for error in form.title.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p class="fields is-grouped"><label class="label" for="id_sort_title">Sort title:</label> {{ form.sort_title }} </p>
{% for error in form.sort_title.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p class="fields is-grouped"><label class="label" for="id_subtitle">Subtitle:</label> {{ form.subtitle }} </p>
{% for error in form.subtitle.errors %}
<p class="help is-danger">{{ error | escape }}</p>
@ -113,12 +100,12 @@
{% for error in form.openlibrary_key.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p class="fields is-grouped"><label class="label" for="id_librarything_key">Librarything key:</label> {{ form.librarything_key }} </p>
{% for error in form.librarything_key.errors %}
<p class="fields is-grouped"><label class="label" for="id_librarything_key">OCLC Number:</label> {{ form.oclc_number }} </p>
{% for error in form.oclc_number.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p class="fields is-grouped"><label class="label" for="id_goodreads_key">Goodreads key:</label> {{ form.goodreads_key }} </p>
{% for error in form.goodreads_key.errors %}
<p class="fields is-grouped"><label class="label" for="id_asin">ASIN:</label> {{ form.asin }} </p>
{% for error in form.ASIN.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>

View file

@ -1,66 +0,0 @@
{% extends 'layout.html' %}
{% block content %}
<div class="block columns">
<div class="column is-half">
<h1 class="title">Profile</h1>
{% 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">
{% csrf_token %}
<p class="block">
<label class="label" for="id_avatar">Avatar:</label>
{{ form.avatar }}
{% for error in form.avatar.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</p>
<p class="block">
<label class="label" for="id_name">Display name:</label>
{{ form.name }}
{% for error in form.name.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</p>
<p class="block">
<label class="label" for="id_summary">Summary:</label>
{{ form.summary }}
{% for error in form.summary.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</p>
<p class="block">
<label class="label" for="id_email">Email address:</label>
{{ form.email }}
{% for error in form.email.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</p>
<p class="block">
<label class="checkbox label" for="id_manually_approves_followers">
Manually approve followers:
{{ form.manually_approves_followers }}
</label>
</p>
<button class="button is-primary" type="submit">Save</button>
</form>
</div>
<div class="column is-half">
<div class="block">
<h2 class="title">Change password</h2>
<form name="edit-profile" action="/change-password/" method="post" enctype="multipart/form-data">
{% csrf_token %}
<p class="block">
<label class="label" for="id_password">New password:</label>
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password">
</p>
<p class="block">
<label class="label" for="id_confirm_password">Confirm password:</label>
<input type="password" name="confirm-password" maxlength="128" class="input" required="" id="id_confirm_password">
</p>
<button class="button is-primary" type="submit">Change password</button>
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,118 +0,0 @@
{% extends 'layout.html' %}
{% load bookwyrm_tags %}
{% block content %}
<div class="columns">
<div class="column is-one-third">
<h2 class="title is-5">Your books</h2>
{% if not suggested_books %}
<p>There are no books here right now! Try searching for a book to get started</p>
{% else %}
<div class="tabs is-small">
<ul>
{% for shelf in suggested_books %}
{% if shelf.books %}
{% with shelf_counter=forloop.counter %}
<li>
<p>
{{ shelf.name }}
</p>
<div class="tabs is-small is-toggle" role="tablist">
<ul>
{% for book in shelf.books %}
<li class="{% if shelf_counter == 1 and forloop.first %}is-active{% endif %}" data-id="tab-book-{{ book.id }}">
<label for="book-{{ book.id }}" onclick="tabChange(event, nested=true)">
<div role="tab" tabindex="0" aria-selected="{% if shelf_counter == 1 and forloop.first %}true{% else %}false{% endif %}" aria-controls="book-{{ book.id }}-panel">
<a>
{% include 'snippets/book_cover.html' with book=book size="medium" %}
</a>
</div>
</label>
</li>
{% endfor %}
</ul>
</div>
</li>
{% endwith %}
{% endif %}
{% endfor %}
</ul>
</div>
{% for shelf in suggested_books %}
{% with shelf_counter=forloop.counter %}
{% for book in shelf.books %}
<div>
<input class="toggle-control" type="radio" name="recent-books" id="book-{{ book.id }}" {% if shelf_counter == 1 and forloop.first %}checked{% endif %}>
<div class="toggle-content hidden" role="tabpanel" id="book-{{ book.id }}-panel">
<div class="card">
<div class="card-header">
<p class="card-header-title">
<span>{% include 'snippets/book_titleby.html' with book=book %}</span>
</>
<div class="card-header-icon is-hidden-tablet">
<label class="delete" for="no-book" aria-label="close" role="button"></label>
</div>
</div>
<div class="card-content">
{% include 'snippets/shelve_button.html' with book=book %}
{% include 'snippets/create_status.html' with book=book %}
</div>
</div>
</div>
</div>
{% endfor %}
{% endwith %}
{% endfor %}
<div>
<input class="toggle-control" type="radio" name="recent-books" id="no-book">
</div>
{% endif %}
</div>
<div class="column is-two-thirds" id="feed">
<h1 class="title">{{ tab | title }} Timeline</h1>
<div class="tabs">
<ul>
<li class="{% if tab == 'home' %}is-active{% endif %}">
<a href="/#feed">Home</a>
</li>
<li class="{% if tab == 'local' %}is-active{% endif %}">
<a href="/local#feed">Local</a>
</li>
<li class="{% if tab == 'federated' %}is-active{% endif %}">
<a href="/federated#feed">Federated</a>
</li>
</ul>
</div>
{% if not activities %}
<p>There aren't any activities right now! Try following a user to get started</p>
{% endif %}
{% for activity in activities %}
<div class="block">
{% include 'snippets/status.html' with status=activity %}
</div>
{% endfor %}
<nav class="pagination" role="navigation" aria-label="pagination">
{% if prev %}
<p class="pagination-previous">
<a href="{{ prev }}">
<span class="icon icon-arrow-left"></span>
Previous
</a>
</p>
{% endif %}
{% if next %}
<p class="pagination-next">
<a href="{{ next }}">
Next
<span class="icon icon-arrow-right"></span>
</a>
</p>
{% endif %}
</nav>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,26 @@
{% extends 'feed/feed_layout.html' %}
{% block panel %}
<header class="block">
<h1 class="title">Direct Messages{% if partner %} with {% include 'snippets/username.html' with user=partner %}{% endif %}</h1>
{% if partner %}<p class="subtitle"><a href="/direct-messages"><span class="icon icon-arrow-left" aria-hidden="true"></span> All messages</a></p>{% endif %}
</header>
<div class="box">
{% include 'snippets/create_status_form.html' with type="direct" uuid=1 mentions=partner %}
</div>
<section class="block">
{% if not activities %}
<p>You have no messages right now.</p>
{% endif %}
{% for activity in activities %}
<div class="block">
{% include 'snippets/status.html' with status=activity %}
</div>
{% endfor %}
{% include 'snippets/pagination.html' with page=activities path="direct-messages" %}
</section>
{% endblock %}

View file

@ -0,0 +1,39 @@
{% extends 'feed/feed_layout.html' %}
{% load bookwyrm_tags %}
{% block panel %}
<h1 class="title">{{ tab | title }} Timeline</h1>
<div class="tabs">
<ul>
<li class="{% if tab == 'home' %}is-active{% endif %}">
<a href="/#feed">Home</a>
</li>
<li class="{% if tab == 'local' %}is-active{% endif %}">
<a href="/local#feed">Local</a>
</li>
<li class="{% if tab == 'federated' %}is-active{% endif %}">
<a href="/federated#feed">Federated</a>
</li>
</ul>
</div>
{# announcements and system messages #}
{% if not goal and tab == 'home' %}
{% now 'Y' as year %}
<section class="block hidden" aria-title="Announcements" data-hide="hide-{{ year }}-reading-goal">
{% include 'snippets/goal_card.html' with year=year %}
<hr>
</section>
{% endif %}
{# activity feed #}
{% if not activities %}
<p>There aren't any activities right now! Try following a user to get started</p>
{% endif %}
{% for activity in activities %}
<div class="block">
{% include 'snippets/status.html' with status=activity %}
</div>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,84 @@
{% extends 'layout.html' %}
{% load bookwyrm_tags %}
{% block content %}
<div class="columns">
{% if user.is_authenticated %}
<div class="column is-one-third">
<h2 class="title is-5">Your books</h2>
{% if not suggested_books %}
<p>There are no books here right now! Try searching for a book to get started</p>
{% else %}
<div class="tabs is-small">
<ul role="tablist">
{% for shelf in suggested_books %}
{% if shelf.books %}
{% with shelf_counter=forloop.counter %}
<li>
<p>
{{ shelf.name }}
</p>
<div class="tabs is-small is-toggle">
<ul>
{% for book in shelf.books %}
<li class="tab-change{% if shelf_counter == 1 and forloop.first %} is-active{% endif %}" data-tab="book-{{ book.id }}" data-tab="book-{{ book.id }}" role="tab" tabindex="0" aria-selected="{% if shelf_counter == 1 and forloop.first %}true{% else %}false{% endif %}" aria-controls="book-{{ book.id }}" data-category="suggested-tabs">
<a>
{% include 'snippets/book_cover.html' with book=book size="medium" %}
</a>
</li>
{% endfor %}
</ul>
</div>
</li>
{% endwith %}
{% endif %}
{% endfor %}
</ul>
</div>
{% for shelf in suggested_books %}
{% with shelf_counter=forloop.counter %}
{% for book in shelf.books %}
<div class="suggested-tabs card{% if shelf_counter != 1 or not forloop.first %} hidden{% endif %}" role="tabpanel" id="book-{{ book.id }}">
<div class="card-header">
<p class="card-header-title">
<span>{% include 'snippets/book_titleby.html' with book=book %}</span>
</p>
<div class="card-header-icon is-hidden-tablet">
{% include 'snippets/toggle/toggle_button.html' with label="close" controls_text="book" controls_uid=book.id class="delete" nonbutton=True pressed=True %}
</div>
</div>
<div class="card-content">
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
{% active_shelf book as active_shelf %}
{% if active_shelf.shelf.identifier == 'reading' and book.latest_readthrough %}
{% include 'snippets/progress_update.html' with readthrough=book.latest_readthrough %}
{% endif %}
{% include 'snippets/create_status.html' with book=book %}
</div>
</div>
{% endfor %}
{% endwith %}
{% endfor %}
{% endif %}
{% if goal %}
<section class="section">
<div class="block">
<h3 class="title is-4">{{ goal.year }} Reading Goal</h3>
{% include 'snippets/goal_progress.html' with goal=goal %}
</div>
</section>
{% endif %}
</div>
{% endif %}
<div class="column is-two-thirds" id="feed">
{% block panel %}{% endblock %}
{% if activities %}
{% include 'snippets/pagination.html' with page=activities path=path anchor="#feed" %}
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends 'feed/feed_layout.html' %}
{% block panel %}
<header class="block">
<a href="/#feed" class="button" data-back>
<span class="icon icon-arrow-left" aira-hidden="true"></span>
<span>Back</span>
</a>
</header>
{% include 'feed/thread.html' with status=status depth=0 max_depth=6 is_root=True direction=0 %}
{% endblock %}

View file

@ -4,7 +4,7 @@
{% with depth=depth|add:1 %}
{% if depth <= max_depth and status.reply_parent and direction <= 0 %}
{% with direction=-1 %}
{% include 'snippets/thread.html' with status=status|parent is_root=False %}
{% include 'feed/thread.html' with status=status|parent is_root=False %}
{% endwith %}
{% endif %}
@ -13,7 +13,7 @@
{% if depth <= max_depth and direction >= 0 %}
{% for reply in status|replies %}
{% with direction=1 %}
{% include 'snippets/thread.html' with status=reply is_root=False %}
{% include 'feed/thread.html' with status=reply is_root=False %}
{% endwith %}
{% endfor %}
{% endif %}

View file

@ -0,0 +1,61 @@
{% extends 'user/user_layout.html' %}
{% block header %}
<div class="columns is-mobile">
<div class="column">
<h1 class="title">{{ year }} Reading Progress</h1>
</div>
{% if is_self and goal %}
<div class="column is-narrow">
{% include 'snippets/toggle/open_button.html' with text="Edit goal" icon="pencil" controls_text="show-edit-goal" focus="edit-form-header" %}
</div>
{% endif %}
</div>
{% endblock %}
{% block panel %}
<section class="block">
{% if user == request.user %}
<div class="block">
{% now 'Y' as year %}
<section class="card {% if goal %}hidden{% endif %}" id="show-edit-goal">
<header class="card-header">
<h2 class="card-header-title has-background-primary has-text-white" tabindex="0" id="edit-form-header">
<span class="icon icon-book is-size-3 mr-2" aria-hidden="true"></span> {{ year }} reading goal
</h2>
</header>
<section class="card-content content">
<p>Set a goal for how many books you'll finish reading in {{ year }}, and track your progress throughout the year.</p>
{% include 'snippets/goal_form.html' with goal=goal year=year %}
</section>
</section>
</div>
{% endif %}
{% if not goal and user != request.user %}
<p>{{ user.display_name }} hasn't set a reading goal for {{ year }}.</p>
{% endif %}
{% if goal %}
{% include 'snippets/goal_progress.html' with goal=goal %}
{% endif %}
</section>
{% if goal.books %}
<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">
<div class="box">
<a href="{{ book.book.local_path }}">
{% include 'snippets/discover/small-book.html' with book=book.book rating=goal.ratings %}
</a>
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% endblock %}

View file

@ -3,7 +3,7 @@
{% block content %}
<div class="block">
<h1 class="title">Import Books from GoodReads</h1>
<form name="import" action="/import-data/" method="post" enctype="multipart/form-data">
<form name="import" action="/import" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="field">
{{ import_form.as_p }}
@ -30,7 +30,7 @@
{% endif %}
<ul>
{% for job in jobs %}
<li><a href="/import-status/{{ job.id }}">{{ job.created_date | naturaltime }}</a></li>
<li><a href="/import/{{ job.id }}">{{ job.created_date | naturaltime }}</a></li>
{% endfor %}
</ul>
</div>

View file

@ -8,7 +8,7 @@
<p>
Import started: {{ job.created_date | naturaltime }}
</p>
{% if task.successful %}
{% if job.complete %}
<p>
Import completed: {{ task.date_done | naturaltime }}
</p>
@ -18,7 +18,7 @@
</div>
<div class="block">
{% if not task.ready %}
{% if not job.complete %}
Import still in progress.
<p>
(Hit reload to update!)
@ -30,9 +30,8 @@
<div class="block">
<h2 class="title is-4">Failed to load</h2>
{% if not job.retry %}
<form name="retry" action="/retry-import/" method="post">
<form name="retry" action="/import/{{ job.id }}" method="post">
{% csrf_token %}
<input type="hidden" name="import_job" value="{{ job.id }}">
<ul>
<fieldset>
{% for item in failed_items %}
@ -50,7 +49,7 @@
{% endfor %}
</fieldset>
</ul>
<div class="block pt-1" onclick="selectAll(this)">
<div class="block pt-1 select-all">
<label class="label">
<input type="checkbox" class="checkbox">
Select all

View file

@ -3,14 +3,21 @@
<div class="columns">
<div class="column">
<div class="block login">
<div class="block">
{% if valid %}
<h1 class="title">Create an Account</h1>
<div>
<form name="register" method="post" action="/user-register">
<form name="register" method="post" action="/register">
<input type=hidden name="invite_code" value="{{ invite.code }}">
{% include 'snippets/register_form.html' %}
</form>
</div>
{% else %}
<div class="content">
<h1 class="title">Permission Denied</h1>
<p>Sorry! This invite code is no longer valid.</p>
</div>
{% endif %}
</div>
</div>
<div class="column">

View file

@ -33,7 +33,7 @@
</div>
<div class="control">
<button class="button" type="submit">
<span class="icon icon-search">
<span class="icon icon-search" title="Search">
<span class="is-sr-only">search</span>
</span>
</button>
@ -41,32 +41,34 @@
</div>
</form>
<label for="main-nav" role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="mainNav" onclick="toggleMenu(this)" tabindex="0">
<div role="button" tabindex="0" class="navbar-burger pulldown-menu" data-controls="main-nav" aria-expanded="false">
<div class="navbar-item mt-3">
<div class="icon icon-dots-three-vertical">
<div class="icon icon-dots-three-vertical" title="Main navigation menu">
<span class="is-sr-only">Main navigation menu</span>
</div>
</div>
</label>
</div>
</div>
<input class="toggle-control" type="checkbox" id="main-nav">
<div id="mainNav" class="navbar-menu toggle-content">
<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>
<div class="navbar-end">
{% if request.user.is_authenticated %}
<div class="navbar-item has-dropdown is-hoverable">
<div class="navbar-link" role="button" aria-expanded=false" onclick="toggleMenu(this)" tabindex="0" aria-haspopup="true" aria-controls="navbar-dropdown"><p>
<div class="navbar-link pulldown-menu" role="button" aria-expanded="false" tabindex="0" aria-haspopup="true" aria-controls="navbar-dropdown"><p>
{% include 'snippets/avatar.html' with user=request.user %}
{% include 'snippets/username.html' with user=request.user %}
</p></div>
@ -82,7 +84,7 @@
</a>
</li>
<li>
<a href="/user-edit" class="navbar-item">
<a href="/preferences/profile" class="navbar-item">
Settings
</a>
</li>
@ -91,13 +93,23 @@
Import books
</a>
</li>
{% if perms.bookwyrm.create_invites or perms.bookwyrm.edit_instance_settings%}
<hr class="navbar-divider">
{% endif %}
{% if perms.bookwyrm.create_invites %}
<li>
<a href="/invite" class="navbar-item">
<a href="{% url 'settings-invites' %}" class="navbar-item">
Invites
</a>
</li>
{% endif %}
{% if perms.bookwyrm.edit_instance_settings %}
<li>
<a href="{% url 'settings-site' %}" class="navbar-item">
Site Configuration
</a>
</li>
{% endif %}
<hr class="navbar-divider">
<li>
<a href="/logout" class="navbar-item">
@ -107,38 +119,38 @@
</ul>
</div>
<div class="navbar-item">
<a href="/notifications">
<div class="tags has-addons">
<a href="/notifications" class="tags has-addons">
<span class="tag is-medium">
<span class="icon icon-bell">
<span class="icon icon-bell" title="Notifications">
<span class="is-sr-only">Notifications</span>
</span>
</span>
{% if request.user|notification_count %}
<span class="tag is-danger is-medium">{{ request.user | notification_count }}</span>
{% endif %}
</div>
<span class="{% if not request.user|notification_count %}hidden {% endif %}tag is-danger is-medium" data-poll="notifications">
{{ request.user | notification_count }}
</span>
</a>
</div>
{% else %}
<div class="navbar-item">
{% if request.path != '/login' and request.path != '/login/' and request.path != '/user-login' %}
{% if request.path != '/login' and request.path != '/login/' %}
<div class="columns">
<div class="column">
<form name="login" method="post" action="/user-login">
<form name="login" method="post" action="/login">
{% csrf_token %}
<div class="field is-grouped">
<div class="control">
<div class="columns is-variable is-1">
<div class="column">
<label class="is-sr-only" for="id_localname">Username:</label>
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname" placeholder="username">
</div>
<div class="control">
<div class="column">
<label class="is-sr-only" for="id_password">Username:</label>
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="password">
<p class="help"><a href="/password-reset">Forgot your password?</a></p>
</div>
<div class="column is-narrow">
<button class="button is-primary" type="submit">Log in</button>
</div>
</div>
</form>
</div>
{% if site.allow_registration and request.path != '' and request.path != '/' %}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,94 @@
{% 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="{{ item.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/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.user %}</p>
</div>
{% if list.user == request.user or list.curation == 'open' and item.user == 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 %}
{% if book %}
<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>
{% endif %}
{% endfor %}
</section>
{% endif %}
</div>
{% endblock %}

View file

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

View file

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

View file

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

View file

@ -8,7 +8,7 @@
{% if login_form.non_field_errors %}
<p class="notification is-danger">{{ login_form.non_field_errors }}</p>
{% endif %}
<form name="login" method="post" action="/user-login">
<form name="login" method="post" action="/login">
{% csrf_token %}
<div class="field">
<label class="label" for="id_localname">Username:</label>
@ -38,7 +38,7 @@
<div class="box has-background-primary-light">
{% if site.allow_registration %}
<h2 class="title">Create an Account</h2>
<form name="register" method="post" action="/user-register">
<form name="register" method="post" action="/register">
{% include 'snippets/register_form.html' %}
</form>
{% else %}

View file

@ -5,7 +5,7 @@
<div class="block">
<h1 class="title">Notifications</h1>
<form name="clear" action="/clear-notifications" method="POST">
<form name="clear" action="/notifications" method="POST">
{% csrf_token %}
<button class="button is-danger is-light" type="submit" class="secondary">Delete notifications</button>
</form>
@ -13,7 +13,27 @@
<div class="block">
{% for notification in notifications %}
{% related_status notification as related_status %}
<div class="notification {% if notification.id in unread %} is-primary{% endif %}">
<div class="columns is-mobile">
<div class="column is-narrow is-size-3 {% if notification.id in unread%}has-text-white{% else %}has-text-grey{% endif %}">
{% if notification.notification_type == 'MENTION' %}
<span class="icon icon-comment"></span>
{% elif notification.notification_type == 'REPLY' %}
<span class="icon icon-comments"></span>
{% elif notification.notification_type == 'FOLLOW' or notification.notification_type == 'FOLLOW_REQUEST' %}
<span class="icon icon-local"></span>
{% elif notification.notification_type == 'BOOST' %}
<span class="icon icon-boost"></span>
{% elif notification.notification_type == 'FAVORITE' %}
<span class="icon icon-heart"></span>
{% elif notification.notification_type == 'IMPORT' %}
<span class="icon icon-list"></span>
{% elif notification.notification_type == 'ADD' %}
<span class="icon icon-plus"></span>
{% endif %}
</div>
<div class="column">
<div class="block">
<p>
{# DESCRIPTION #}
@ -22,49 +42,59 @@
{% include 'snippets/username.html' with user=notification.related_user %}
{% if notification.notification_type == 'FAVORITE' %}
favorited your
<a href="{{ notification.related_status.local_path }}">status</a>
<a href="{{ related_status.local_path }}">{{ related_status | status_preview_name|safe }}</a>
{% elif notification.notification_type == 'MENTION' %}
mentioned you in a
<a href="{{ notification.related_status.local_path }}">status</a>
<a href="{{ related_status.local_path }}">{{ related_status | status_preview_name|safe }}</a>
{% elif notification.notification_type == 'REPLY' %}
<a href="{{ notification.related_status.local_path }}">replied</a>
<a href="{{ related_status.local_path }}">replied</a>
to your
<a href="{{ notification.related_status.reply_parent.local_path }}">status</a>
<a href="{{ related_status.reply_parent.local_path }}">{{ related_status | status_preview_name|safe }}</a>
{% elif notification.notification_type == 'FOLLOW' %}
followed you
{% include 'snippets/follow_button.html' with user=notification.related_user %}
{% elif notification.notification_type == 'FOLLOW_REQUEST' %}
sent you a follow request
<div class="row shrink">
{% include 'snippets/follow_request_buttons.html' with user=notification.related_user %}
</div>
{% elif notification.notification_type == 'BOOST' %}
boosted your <a href="{{ notification.related_status.local_path }}">status</a>
boosted your <a href="{{ related_status.local_path }}">{{ related_status | status_preview_name|safe }}</a>
{% elif notification.notification_type == 'ADD' %}
{% if notification.related_list_item.approved %}added{% else %}suggested adding{% endif %} {% include 'snippets/book_titleby.html' with book=notification.related_list_item.book %} to your list "<a href="{{ notification.related_list_item.book_list.local_path }}{% if not notification.related_list_item.approved %}/curate{% endif %}">{{ notification.related_list_item.book_list.name }}</a>"
{% endif %}
{% else %}
your <a href="/import-status/{{ notification.related_import.id }}">import</a> completed.
{% elif notification.related_import %}
your <a href="/import/{{ notification.related_import.id }}">import</a> completed.
{% endif %}
</p>
</div>
{% if notification.related_status %}
{% if related_status %}
<div class="block">
{# PREVIEW #}
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-white{% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %} has-text-black{% else %}-bis has-text-grey-dark{% endif %}{% endif %}">
<div class="columns">
<div class="column">
<a href="{{ notification.related_status.local_path }}">{{ notification.related_status.content | safe | truncatewords_html:10 }}</a>
{% if related_status.content %}
<a href="{{ related_status.local_path }}">{{ related_status.content | safe | truncatewords_html:10 }}</a>
{% elif related_status.quote %}
<a href="{{ related_status.local_path }}">{{ related_status.quote | safe | truncatewords_html:10 }}</a>
{% elif related_status.rating %}
{% include 'snippets/stars.html' with rating=related_status.rating %}
{% endif %}
</div>
<div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}">
{{ notification.related_status.published_date | post_date }}
{% include 'snippets/privacy-icons.html' with item=notification.related_status %}
{{ related_status.published_date | post_date }}
{% include 'snippets/privacy-icons.html' with item=related_status %}
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% if not notifications %}

View file

@ -8,9 +8,8 @@
{% for error in errors %}
<p class="is-danger">{{ error }}</p>
{% endfor %}
<form name="reset-password" method="post" action="/reset-password">
<form name="password-reset" method="post" action="/password-reset/{{ code }}">
{% csrf_token %}
<input type="hidden" name="reset-code" value="{{ code }}">
<div class="field">
<label class="label" for="id_password">Password:</label>
<div class="control">

View file

@ -7,7 +7,7 @@
<h1 class="title">Reset Password</h1>
{% if message %}<p>{{ message }}</p>{% endif %}
<p>A link to reset your password will be sent to your email address</p>
<form name="reset-password" method="post" action="/reset-password-request">
<form name="password-reset" method="post" action="/password-reset">
{% csrf_token %}
<div class="field">
<label class="label" for="id_email_register">Email address:</label>

View file

@ -0,0 +1,24 @@
{% extends 'preferences/preferences_layout.html' %}
{% block header %}
Blocked Users
{% endblock %}
{% block panel %}
{% if not request.user.blocks.exists %}
<p>No users currently blocked.</p>
{% else %}
<ul>
{% for user in request.user.blocks.all %}
<li class="is-flex">
<p>
{% include 'snippets/avatar.html' with user=user %} {% include 'snippets/username.html' with user=user %}
</p>
<p class="mr-2">
{% include 'snippets/block_button.html' with user=user %}
</p>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

Some files were not shown because too many files have changed in this diff Show more