Merge branch 'upstream' into tab-keyboard-accessibility

This commit is contained in:
Ned Zimmerman 2021-02-27 11:53:27 -04:00
commit bc7f830b2b
218 changed files with 7197 additions and 3530 deletions

2
.github/FUNDING.yml vendored
View file

@ -2,7 +2,7 @@
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: bookwyrm
open_collective: # Replace with a single Open Collective username
open_collective: bookwyrm
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry

119
README.md
View file

@ -3,52 +3,90 @@
Social reading and reviewing, decentralized with ActivityPub
## Contents
- [The overall idea](#the-overall-idea)
- [Joining BookWyrm](#joining-bookwyrm)
- [Contributing](#contributing)
- [About BookWyrm](#about-bookwyrm)
- [What it is and isn't](#what-it-is-and-isnt)
- [The role of federation](#the-role-of-federation)
- [Features](#features)
- [Setting up the developer environment](#setting-up-the-developer-environment)
- [Installing in Production](#installing-in-production)
- [Project structure](#project-structure)
- [Book data](#book-data)
- [Contributing](#contributing)
## The overall idea
## 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).
## Contributing
There are many ways you can contribute to this project, regardless of your level of technical expertise.
### Feedback and feature requests
Please feel encouraged and welcome to point out bugs, suggestions, feature requests, and ideas for how things ought to work using [GitHub issues](https://github.com/mouse-reeve/bookwyrm/issues).
### Code contributions
Code contributons are gladly welcomed! If you're not sure where to start, take a look at the ["Good first issue"](https://github.com/mouse-reeve/bookwyrm/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) tag. Because BookWyrm is a small project, there isn't a lot of formal structure, but there is a huge capacity for one-on-one support, which can look like asking questions as you go, pair programming, video chats, et cetera, so please feel free to reach out.
If you have questions about the project or contributing, you can seet up a video call during BookWyrm ["office hours"](https://calendly.com/mouse-reeve/30min).
### Financial Support
BookWyrm is an ad-free passion project with no intentions of seeking out venture funding or corporate financial relationships. If you want to help keep the project going, you can donate to the [Patreon](https://www.patreon.com/bookwyrm), or make a one time gift via [PayPal](https://paypal.me/oulipo).
## About BookWyrm
### 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
### The Tech Stack
Web backend
- [Django](https://www.djangoproject.com/) web server
- [PostgreSQL](https://www.postgresql.org/) database
- [ActivityPub](http://activitypub.rocks/) federation
- [Celery](http://celeryproject.org/) task queuing
- [Redis](https://redis.io/) task backend
Front end
- Django templates
- [Bulma.io](https://bulma.io/) css framework
- Vanilla JavaScript, in moderation
Deployment
- [Docker](https://www.docker.com/) and docker-compose
- [Gunicorn](https://gunicorn.org/) web runner
- [Flower](https://github.com/mher/flower) celery monitoring
- [Nginx](https://nginx.org/en/) HTTP server
## Setting up the developer environment
@ -74,34 +112,42 @@ Once the build is complete, you can access the instance at `localhost:1333`
## Installing in Production
This project is still young and isn't, at the momoment, very stable, so please procede with caution when running in production.
### Server setup
- Get a domain name and set up DNS for your server
- Set your server up with appropriate firewalls for running a web application (this instruction set is tested again Ubuntu 20.04)
- Set up a mailgun account and the appropriate DNS settings
- Set up an email service (such as mailgun) and the appropriate SMTP/DNS settings
- Install Docker and docker-compose
### Install and configure BookWyrm
The `production` branch of BookWyrm contains a number of tools not on the `main` branch that are suited for running in production, such as `docker-compose` changes to update the default commands or configuration of containers, and indivudal changes to container config to enable things like SSL or regular backups.
Instructions for running BookWyrm in production:
- Get the application code:
`git clone git@github.com:mouse-reeve/bookwyrm.git`
- Switch to the `production` branch
`git checkout production`
- Create your environment variables file
`cp .env.example .env`
- Add your domain, email address, mailgun credentials
- Add your domain, email address, SMTP 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)
`docker-compose up --build`
Make sure all the images build successfully
- Run the application (this should also set up a Certbot ssl cert for your domain) with
`docker-compose up --build`, and make sure all the images build successfully
- When docker has built successfully, stop the process with `CTRL-C`
- Comment out the `command: certonly...` line in `docker-compose.yml`
- Run docker-compose in the background
`docker-compose up -d`
- Initialize the database
`./bw-dev initdb`
- Congrats! You did it, go to your domain and enjoy the fruits of your labors
- Run docker-compose in the background with: `docker-compose up -d`
- Initialize the database with: `./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 location
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
- Register a user account in the application UI
- Make your account a superuser (warning: do *not* use django's `createsuperuser` command)
- On your server, open the django shell
`./bw-dev shell`
@ -109,26 +155,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`
@ -136,9 +171,3 @@ There are three concepts in the book data model:
- `Edition`, a concrete, actually published version of a book
Whenever a user interacts with a book, they are interacting with a specific edition. Every work has a default edition, but the user can select other editions. Reviews aggregated for all editions of a work when you view an edition's page.
## Contributing
There are many ways you can contribute to this project! You are welcome and encouraged to create or contribute an issue to report a bug, request a feature, make a usability suggestion, or express a nebulous desire.
If you'd like to add to the codebase, that's super rad and you should do it! At this point, there isn't a formalized process, but you can take a look at the open issues, or contact me directly and chat about it.

View file

@ -2,23 +2,28 @@
import inspect
import sys
from .base_activity import ActivityEncoder, Signature
from .base_activity import ActivityEncoder, Signature, naive_parse
from .base_activity import Link, Mention
from .base_activity import ActivitySerializerError, resolve_remote_id
from .image import Image
from .note import Note, GeneratedNote, Article, Comment, Review, Quotation
from .note import Tombstone
from .interaction import Boost, Like
from .ordered_collection import OrderedCollection, OrderedCollectionPage
from .ordered_collection import BookList, Shelf
from .person import Person, PublicKey
from .response import ActivitypubResponse
from .book import Edition, Work, Author
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, Remove
from .verbs import Announce, Like
# 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
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_objects = {c[0]: c[1] for c in cls_members \
if hasattr(c[1], 'to_model')}
def parse(activity_json):
''' figure out what activity this is and parse it '''
return naive_parse(activity_objects, activity_json)

View file

@ -40,6 +40,20 @@ class Signature:
signatureValue: str
type: str = 'RsaSignature2017'
def naive_parse(activity_objects, activity_json, serializer=None):
''' this navigates circular import issues '''
if not serializer:
if activity_json.get('publicKeyPem'):
# ugh
activity_json['type'] = 'PublicKey'
try:
activity_type = activity_json['type']
serializer = activity_objects[activity_type]
except KeyError as e:
raise ActivitySerializerError(e)
return serializer(activity_objects=activity_objects, **activity_json)
@dataclass(init=False)
class ActivityObject:
@ -47,13 +61,30 @@ class ActivityObject:
id: str
type: str
def __init__(self, **kwargs):
def __init__(self, activity_objects=None, **kwargs):
''' this lets you pass in an object with fields that aren't in the
dataclass, which it ignores. Any field in the dataclass is required or
has a default value '''
for field in fields(self):
try:
value = kwargs[field.name]
if value in (None, MISSING):
raise KeyError()
try:
is_subclass = issubclass(field.type, ActivityObject)
except TypeError:
is_subclass = False
# serialize a model obj
if hasattr(value, 'to_activity'):
value = value.to_activity()
# parse a dict into the appropriate activity
elif is_subclass and isinstance(value, dict):
if activity_objects:
value = naive_parse(activity_objects, value)
else:
value = naive_parse(
activity_objects, value, serializer=field.type)
except KeyError:
if field.default == MISSING and \
field.default_factory == MISSING:
@ -63,24 +94,29 @@ class ActivityObject:
setattr(self, field.name, value)
def to_model(self, model, instance=None, save=True):
def to_model(self, model=None, instance=None, allow_create=True, save=True):
''' convert from an activity to a model instance '''
if not isinstance(self, model.activity_serializer):
raise ActivitySerializerError(
'Wrong activity type "%s" for model "%s" (expects "%s")' % \
(self.__class__,
model.__name__,
model.activity_serializer)
)
model = model or get_model_from_type(self.type)
if hasattr(model, 'ignore_activity') and model.ignore_activity(self):
return instance
# only reject statuses if we're potentially creating them
if allow_create and \
hasattr(model, 'ignore_activity') and \
model.ignore_activity(self):
return None
# check for an existing instance, if we're not updating a known obj
instance = instance or model.find_existing(self.serialize()) or model()
# check for an existing instance
instance = instance or model.find_existing(self.serialize())
if not instance and not allow_create:
# so that we don't create when we want to delete or update
return None
instance = instance or model()
for field in instance.simple_fields:
field.set_field_from_activity(instance, self)
try:
field.set_field_from_activity(instance, self)
except AttributeError as e:
raise ActivitySerializerError(e)
# image fields have to be set after other fields because they can save
# too early and jank up users
@ -93,7 +129,10 @@ class ActivityObject:
with transaction.atomic():
# we can't set many to many and reverse fields on an unsaved object
try:
instance.save()
try:
instance.save(broadcast=False)
except TypeError:
instance.save()
except IntegrityError as e:
raise ActivitySerializerError(e)
@ -129,7 +168,15 @@ class ActivityObject:
def serialize(self):
''' convert to dictionary with context attr '''
data = self.__dict__
data = self.__dict__.copy()
# recursively serialize
for (k, v) in data.items():
try:
if issubclass(type(v), ActivityObject):
data[k] = v.serialize()
except TypeError:
pass
data = {k:v for (k, v) in data.items() if v is not None}
data['@context'] = 'https://www.w3.org/ns/activitystreams'
return data
@ -171,7 +218,7 @@ def set_related_field(
getattr(model_field, 'activitypub_field'),
instance.remote_id
)
item = activity.to_model(model)
item = activity.to_model()
# if the related field isn't serialized (attachments on Status), then
# we have to set it post-creation
@ -180,11 +227,24 @@ def set_related_field(
item.save()
def resolve_remote_id(model, remote_id, refresh=False, save=True):
def get_model_from_type(activity_type):
''' given the activity, what type of model '''
models = apps.get_models()
model = [m for m in models if hasattr(m, 'activity_serializer') and \
hasattr(m.activity_serializer, 'type') and \
m.activity_serializer.type == activity_type]
if not model:
raise ActivitySerializerError(
'No model found for activity type "%s"' % activity_type)
return model[0]
def resolve_remote_id(remote_id, model=None, refresh=False, save=True):
''' take a remote_id and return an instance, creating if necessary '''
result = model.find_existing_by_remote_id(remote_id)
if result and not refresh:
return result
if model:# a bonus check we can do if we already know the model
result = model.find_existing_by_remote_id(remote_id)
if result and not refresh:
return result
# load the data and create the object
try:
@ -193,13 +253,15 @@ def resolve_remote_id(model, remote_id, refresh=False, save=True):
raise ActivitySerializerError(
'Could not connect to host for remote_id in %s model: %s' % \
(model.__name__, remote_id))
# determine the model implicitly, if not provided
if not model:
model = get_model_from_type(data.get('type'))
# check for existing items with shared unique identifiers
if not result:
result = model.find_existing(data)
if result and not refresh:
return result
result = model.find_existing(data)
if result and not refresh:
return result
item = model.activity_serializer(**data)
# if we're refreshing, "result" will be set and we'll update it
return item.to_model(model, instance=result, save=save)
return item.to_model(model=model, instance=result, save=save)

View file

@ -67,4 +67,4 @@ class Author(ActivityObject):
librarythingKey: str = ''
goodreadsKey: str = ''
wikipediaLink: str = ''
type: str = 'Person'
type: str = 'Author'

View file

@ -1,20 +0,0 @@
''' boosting and liking posts '''
from dataclasses import dataclass
from .base_activity import ActivityObject
@dataclass(init=False)
class Like(ActivityObject):
''' a user faving an object '''
actor: str
object: str
type: str = 'Like'
@dataclass(init=False)
class Boost(ActivityObject):
''' boosting a status '''
actor: str
object: str
type: str = 'Announce'

View file

@ -1,6 +1,7 @@
''' note serializer and children thereof '''
from dataclasses import dataclass, field
from typing import Dict, List
from django.apps import apps
from .base_activity import ActivityObject, Link
from .image import Image
@ -8,17 +9,20 @@ from .image import Image
@dataclass(init=False)
class Tombstone(ActivityObject):
''' the placeholder for a deleted status '''
published: str
deleted: str
type: str = 'Tombstone'
def to_model(self, *args, **kwargs):
''' this should never really get serialized, just searched for '''
model = apps.get_model('bookwyrm.Status')
return model.find_existing_by_remote_id(self.id)
@dataclass(init=False)
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,17 +10,35 @@ 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):
''' an ordered collection with privacy settings '''
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):
''' structure of an ordered collection activity '''
partOf: str
orderedItems: List
next: str
prev: str
next: str = None
prev: str = None
type: str = 'OrderedCollectionPage'

View file

@ -9,7 +9,7 @@ class ActivitypubResponse(JsonResponse):
configures some stuff beforehand. Made to be a drop-in replacement of
JsonResponse.
"""
def __init__(self, data, encoder=ActivityEncoder, safe=True,
def __init__(self, data, encoder=ActivityEncoder, safe=False,
json_dumps_params=None, **kwargs):
if 'content_type' not in kwargs:

View file

@ -1,10 +1,12 @@
''' undo wrapper activity '''
from dataclasses import dataclass
from typing import List
from django.apps import apps
from .base_activity import ActivityObject, Signature
from .base_activity import ActivityObject, Signature, resolve_remote_id
from .book import Edition
@dataclass(init=False)
class Verb(ActivityObject):
''' generic fields for activities - maybe an unecessary level of
@ -12,13 +14,17 @@ class Verb(ActivityObject):
actor: str
object: ActivityObject
def action(self):
''' usually we just want to save, this can be overridden as needed '''
self.object.to_model()
@dataclass(init=False)
class Create(Verb):
''' Create activity '''
to: List
cc: List
signature: Signature
signature: Signature = None
type: str = 'Create'
@ -29,6 +35,12 @@ class Delete(Verb):
cc: List
type: str = 'Delete'
def action(self):
''' find and delete the activity object '''
obj = self.object.to_model(save=False, allow_create=False)
obj.delete()
@dataclass(init=False)
class Update(Verb):
@ -36,18 +48,48 @@ class Update(Verb):
to: List
type: str = 'Update'
def action(self):
''' update a model instance from the dataclass '''
self.object.to_model(allow_create=False)
@dataclass(init=False)
class Undo(Verb):
''' Undo an activity '''
type: str = 'Undo'
def action(self):
''' find and remove the activity object '''
# this is so hacky but it does make it work....
# (because you Reject a request and Undo a follow
model = None
if self.object.type == 'Follow':
model = apps.get_model('bookwyrm.UserFollows')
obj = self.object.to_model(model=model, save=False, allow_create=False)
obj.delete()
@dataclass(init=False)
class Follow(Verb):
''' Follow activity '''
object: str
type: str = 'Follow'
def action(self):
''' relationship save '''
self.to_model()
@dataclass(init=False)
class Block(Verb):
''' Block activity '''
object: str
type: str = 'Block'
def action(self):
''' relationship save '''
self.to_model()
@dataclass(init=False)
class Accept(Verb):
@ -55,6 +97,11 @@ class Accept(Verb):
object: Follow
type: str = 'Accept'
def action(self):
''' find and remove the activity object '''
obj = self.object.to_model(save=False, allow_create=False)
obj.accept()
@dataclass(init=False)
class Reject(Verb):
@ -62,19 +109,29 @@ class Reject(Verb):
object: Follow
type: str = 'Reject'
def action(self):
''' find and remove the activity object '''
obj = self.object.to_model(save=False, allow_create=False)
obj.reject()
@dataclass(init=False)
class Add(Verb):
'''Add activity '''
target: ActivityObject
target: str
object: Edition
type: str = 'Add'
notes: str = None
order: int = 0
approved: bool = True
@dataclass(init=False)
class AddBook(Verb):
'''Add activity that's aware of the book obj '''
target: Edition
type: str = 'Add'
def action(self):
''' add obj to collection '''
target = resolve_remote_id(self.target, refresh=False)
# we want to related field that isn't the book, this is janky af sorry
model = [t for t in type(target)._meta.related_objects \
if t.name != 'edition'][0].related_model
self.to_model(model=model)
@dataclass(init=False)
@ -82,3 +139,30 @@ class Remove(Verb):
'''Remove activity '''
target: ActivityObject
type: str = 'Remove'
def action(self):
''' find and remove the activity object '''
obj = self.object.to_model(save=False, allow_create=False)
obj.delete()
@dataclass(init=False)
class Like(Verb):
''' a user faving an object '''
object: str
type: str = 'Like'
def action(self):
''' like '''
self.to_model()
@dataclass(init=False)
class Announce(Verb):
''' boosting a status '''
object: str
type: str = 'Announce'
def action(self):
''' boost '''
self.to_model()

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

@ -107,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
@ -116,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
@ -127,7 +127,7 @@ class AbstractConnector(AbstractMinimalConnector):
# create activitypub object
work_activity = activitypub.Work(**work_data)
# this will dedupe automatically
work = work_activity.to_model(models.Work)
work = work_activity.to_model(model=models.Work)
for author in self.get_authors_from_data(data):
work.authors.add(author)
@ -141,12 +141,13 @@ class AbstractConnector(AbstractMinimalConnector):
mapped_data = dict_from_mappings(edition_data, self.book_mappings)
mapped_data['work'] = work.remote_id
edition_activity = activitypub.Edition(**mapped_data)
edition = edition_activity.to_model(models.Edition)
edition = edition_activity.to_model(model=models.Edition)
edition.connector = self.connector
edition.save()
work.default_edition = edition
work.save()
if not work.default_edition:
work.default_edition = edition
work.save()
for author in self.get_authors_from_data(edition_data):
edition.authors.add(author)
@ -167,7 +168,7 @@ class AbstractConnector(AbstractMinimalConnector):
mapped_data = dict_from_mappings(data, self.author_mappings)
activity = activitypub.Author(**mapped_data)
# this will dedupe
return activity.to_model(models.Author)
return activity.to_model(model=models.Author)
@abstractmethod
@ -210,13 +211,16 @@ 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:
resp.raise_for_status()
raise ConnectorException()
try:
data = resp.json()
except ValueError:
except ValueError as e:
logger.exception(e)
raise ConnectorException()
return data
@ -231,7 +235,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,7 @@ class Connector(AbstractMinimalConnector):
''' this is basically just for search '''
def get_or_create_book(self, remote_id):
edition = activitypub.resolve_remote_id(models.Edition, remote_id)
edition = activitypub.resolve_remote_id(remote_id, model=models.Edition)
work = edition.parent_work
work.default_edition = work.get_default_edition()
work.save()

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

View file

@ -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:
@ -189,7 +195,20 @@ class ShelfForm(CustomForm):
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

@ -3,9 +3,7 @@ import csv
import logging
from bookwyrm import models
from bookwyrm.broadcast import broadcast
from bookwyrm.models import ImportJob, ImportItem
from bookwyrm.status import create_notification
from bookwyrm.tasks import app
logger = logging.getLogger(__name__)
@ -69,7 +67,6 @@ def import_data(job_id):
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()
@ -82,7 +79,7 @@ def handle_imported_book(user, item, include_reviews, privacy):
return
existing_shelf = models.ShelfBook.objects.filter(
book=item.book, added_by=user).exists()
book=item.book, user=user).exists()
# shelve the book if it hasn't been shelved already
if item.shelf and not existing_shelf:
@ -90,9 +87,8 @@ def handle_imported_book(user, item, include_reviews, privacy):
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)
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
@ -114,7 +110,7 @@ def handle_imported_book(user, item, include_reviews, privacy):
# 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(
models.Review.objects.create(
user=user,
book=item.book,
name=review_title,
@ -123,6 +119,3 @@ def handle_imported_book(user, item, include_reviews, privacy):
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)

View file

@ -1,342 +0,0 @@
''' handles all of the activity coming in to the server '''
import json
from urllib.parse import urldefrag
import django.db.utils
from django.http import HttpResponse
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
import requests
from bookwyrm import activitypub, models, views
from bookwyrm import status as status_builder
from bookwyrm.tasks import app
from bookwyrm.signatures import Signature
@csrf_exempt
@require_POST
def inbox(request, username):
''' incoming activitypub events '''
try:
models.User.objects.get(localname=username)
except models.User.DoesNotExist:
return HttpResponseNotFound()
return shared_inbox(request)
@csrf_exempt
@require_POST
def shared_inbox(request):
''' incoming activitypub events '''
try:
resp = request.body
activity = json.loads(resp)
if isinstance(activity, str):
activity = json.loads(activity)
activity_object = activity['object']
except (json.decoder.JSONDecodeError, KeyError):
return HttpResponseBadRequest()
if not has_valid_signature(request, activity):
if activity['type'] == 'Delete':
# Pretend that unauth'd deletes succeed. Auth may be failing because
# the resource or owner of the resource might have been deleted.
return HttpResponse()
return HttpResponse(status=401)
handlers = {
'Follow': handle_follow,
'Accept': handle_follow_accept,
'Reject': handle_follow_reject,
'Create': handle_create,
'Delete': handle_delete_status,
'Like': handle_favorite,
'Announce': handle_boost,
'Add': {
'Edition': handle_add,
},
'Undo': {
'Follow': handle_unfollow,
'Like': handle_unfavorite,
'Announce': handle_unboost,
},
'Update': {
'Person': handle_update_user,
'Edition': handle_update_edition,
'Work': handle_update_work,
},
}
activity_type = activity['type']
handler = handlers.get(activity_type, None)
if isinstance(handler, dict):
handler = handler.get(activity_object['type'], None)
if not handler:
return HttpResponseNotFound()
handler.delay(activity)
return HttpResponse()
def has_valid_signature(request, activity):
''' verify incoming signature '''
try:
signature = Signature.parse(request)
key_actor = urldefrag(signature.key_id).url
if key_actor != activity.get('actor'):
raise ValueError("Wrong actor created signature.")
remote_user = activitypub.resolve_remote_id(models.User, key_actor)
if not remote_user:
return False
try:
signature.verify(remote_user.key_pair.public_key, request)
except ValueError:
old_key = remote_user.key_pair.public_key
remote_user = activitypub.resolve_remote_id(
models.User, remote_user.remote_id, refresh=True
)
if remote_user.key_pair.public_key == old_key:
raise # Key unchanged.
signature.verify(remote_user.key_pair.public_key, request)
except (ValueError, requests.exceptions.HTTPError):
return False
return True
@app.task
def handle_follow(activity):
''' someone wants to follow a local user '''
try:
relationship = activitypub.Follow(
**activity
).to_model(models.UserFollowRequest)
except django.db.utils.IntegrityError as err:
if err.__cause__.diag.constraint_name != 'userfollowrequest_unique':
raise
relationship = models.UserFollowRequest.objects.get(
remote_id=activity['id']
)
# 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:
views.handle_accept(relationship)
@app.task
def handle_unfollow(activity):
''' unfollow a local user '''
obj = activity['object']
requester = activitypub.resolve_remote_id(models.User, obj['actor'])
to_unfollow = models.User.objects.get(remote_id=obj['object'])
# raises models.User.DoesNotExist
to_unfollow.followers.remove(requester)
@app.task
def handle_follow_accept(activity):
''' hurray, someone remote accepted a follow request '''
# figure out who they want to follow
requester = models.User.objects.get(remote_id=activity['object']['actor'])
# figure out who they are
accepter = activitypub.resolve_remote_id(models.User, activity['actor'])
try:
request = models.UserFollowRequest.objects.get(
user_subject=requester,
user_object=accepter
)
request.delete()
except models.UserFollowRequest.DoesNotExist:
pass
accepter.followers.add(requester)
@app.task
def handle_follow_reject(activity):
''' someone is rejecting a follow request '''
requester = models.User.objects.get(remote_id=activity['object']['actor'])
rejecter = activitypub.resolve_remote_id(models.User, activity['actor'])
request = models.UserFollowRequest.objects.get(
user_subject=requester,
user_object=rejecter
)
request.delete()
#raises models.UserFollowRequest.DoesNotExist
@app.task
def handle_create(activity):
''' someone did something, good on them '''
# deduplicate incoming activities
activity = activity['object']
status_id = activity.get('id')
if models.Status.objects.filter(remote_id=status_id).count():
return
try:
serializer = activitypub.activity_objects[activity['type']]
except KeyError:
return
activity = serializer(**activity)
try:
model = models.activity_models[activity.type]
except KeyError:
# not a type of status we are prepared to deserialize
return
status = activity.to_model(model)
if not status:
# 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):
''' remove a status '''
try:
status_id = activity['object']['id']
except TypeError:
# this isn't a great fix, because you hit this when mastadon
# is trying to delete a user.
return
try:
status = models.Status.objects.get(
remote_id=status_id
)
except models.Status.DoesNotExist:
return
models.Notification.objects.filter(related_status=status).all().delete()
status_builder.delete_status(status)
@app.task
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):
''' approval of your good good post '''
like = models.Favorite.objects.filter(
remote_id=activity['object']['id']
).first()
if not like:
return
like.delete()
@app.task
def handle_boost(activity):
''' someone gave us a boost! '''
try:
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):
''' someone gave us a boost! '''
boost = models.Boost.objects.filter(
remote_id=activity['object']['id']
).first()
if boost:
boost.delete()
@app.task
def handle_add(activity):
''' putting a book on a shelf '''
#this is janky as heck but I haven't thought of a better solution
try:
activitypub.AddBook(**activity).to_model(models.ShelfBook)
except activitypub.ActivitySerializerError:
activitypub.AddBook(**activity).to_model(models.Tag)
@app.task
def handle_update_user(activity):
''' receive an updated user Person activity object '''
try:
user = models.User.objects.get(remote_id=activity['object']['id'])
except models.User.DoesNotExist:
# who is this person? who cares
return
activitypub.Person(
**activity['object']
).to_model(models.User, instance=user)
# model save() happens in the to_model function
@app.task
def handle_update_edition(activity):
''' a remote instance changed a book (Document) '''
activitypub.Edition(**activity['object']).to_model(models.Edition)
@app.task
def handle_update_work(activity):
''' a remote instance changed a book (Document) '''
activitypub.Work(**activity['object']).to_model(models.Work)

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

@ -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

@ -2,6 +2,15 @@
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):
@ -14,6 +23,12 @@ class Migration(migrations.Migration):
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',

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, Review, Comment, Quotation
from .status import Boost

View file

@ -0,0 +1,506 @@
''' activitypub model functionality '''
from base64 import b64encode
from functools import reduce
import json
import operator
import logging
from uuid import uuid4
import requests
from requests.exceptions import HTTPError, SSLError
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_dataclass(self):
''' convert from a model to an activity '''
activity = generate_activity(self)
return self.activity_serializer(**activity)
def to_activity(self, **kwargs): # pylint: disable=unused-argument
''' convert from a model to a json activity '''
return self.to_activity_dataclass().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 AttributeError:
# 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_dataclass(**kwargs)
signature = None
create_id = self.remote_id + '/activity'
if hasattr(activity_object, 'content') 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,
).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
).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)
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_dataclass(self, **kwargs):
return self.to_ordered_collection(self.collection_queryset, **kwargs)
def to_activity(self, **kwargs):
''' an ordered collection of the specified model queryset '''
return self.to_ordered_collection(
self.collection_queryset, **kwargs).serialize()
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,
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,
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,
).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 (HTTPError, SSLError) 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, pure=False, **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(pure=pure) 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
)

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,239 +38,7 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
return
if not instance.remote_id:
instance.remote_id = instance.get_remote_id()
instance.save()
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
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, sort_field in \
self.serialize_reverse_fields:
related_field = getattr(self, model_field_name)
activity[activity_field_name] = \
unfurl_related_field(related_field, sort_field)
if not activity.get('id'):
activity['id'] = self.get_remote_id()
return self.activity_serializer(**activity).serialize()
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 '''
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)
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)
try:
instance.save(broadcast=False)
except TypeError:
instance.save()

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(
@ -74,6 +74,7 @@ class Book(BookDataModel):
@property
def latest_readthrough(self):
''' most recent readthrough activity '''
return self.readthrough_set.order_by('-updated_date').first()
@property

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

@ -122,13 +122,12 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
return None
related_model = self.related_model
if isinstance(value, dict) and value.get('id'):
if hasattr(value, 'id') and value.id:
if not self.load_remote:
# only look in the local database
return related_model.find_existing(value)
return related_model.find_existing(value.serialize())
# this is an activitypub object, which we can deserialize
activity_serializer = related_model.activity_serializer
return activity_serializer(**value).to_model(related_model)
return value.to_model(model=related_model)
try:
# make sure the value looks like a remote id
validate_remote_id(value)
@ -139,7 +138,7 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
if not self.load_remote:
# only look in the local database
return related_model.find_existing_by_remote_id(value)
return activitypub.resolve_remote_id(related_model, value)
return activitypub.resolve_remote_id(value, model=related_model)
class RemoteIdField(ActivitypubFieldMixin, models.CharField):
@ -213,7 +212,10 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
setattr(instance, self.name, 'followers')
def set_activity_from_field(self, activity, instance):
mentions = [u.remote_id for u in instance.mention_users.all()]
# explicitly to anyone mentioned (statuses only)
mentions = []
if hasattr(instance, 'mention_users'):
mentions = [u.remote_id for u in instance.mention_users.all()]
# this is a link to the followers list
followers = instance.user.__class__._meta.get_field('followers')\
.field_to_activity(instance.user.followers)
@ -260,6 +262,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:
@ -276,7 +279,8 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
except ValidationError:
continue
items.append(
activitypub.resolve_remote_id(self.related_model, remote_id)
activitypub.resolve_remote_id(
remote_id, model=self.related_model)
)
return items
@ -313,7 +317,8 @@ class TagField(ManyToManyField):
# tags can contain multiple types
continue
items.append(
activitypub.resolve_remote_id(self.related_model, link.href)
activitypub.resolve_remote_id(
link.href, model=self.related_model)
)
return items
@ -362,8 +367,8 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
image_slug = value
# when it's an inline image (User avatar/icon, Book cover), it's a json
# blob, but when it's an attached image, it's just a url
if isinstance(image_slug, dict):
url = image_slug.get('url')
if hasattr(image_slug, 'url'):
url = image_slug.url
elif isinstance(image_slug, str):
url = image_slug
else:

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
@ -50,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.Add
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

@ -31,7 +31,7 @@ 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):
@ -54,5 +54,5 @@ class ProgressUpdate(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)

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, IntegrityError
from django.db.models import Q
from bookwyrm import activitypub
from .base_model import ActivitypubMixin, BookWyrmModel
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
from .activitypub_mixin import generate_activity
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,128 @@ 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(ActivityMixin, UserRelationship):
''' Following a user '''
status = 'follows'
def to_activity(self):
''' overrides default to manually set serializer '''
return activitypub.Follow(**generate_activity(self))
def save(self, *args, **kwargs):
''' really really don't let a user follow someone who blocked them '''
# blocking in either direction is a no-go
if UserBlocks.objects.filter(
Q(
user_subject=self.user_subject,
user_object=self.user_object,
) | Q(
user_subject=self.user_object,
user_object=self.user_subject,
)
).exists():
raise IntegrityError()
# don't broadcast this type of relationship -- accepts and requests
# are handled by the UserFollowRequest model
super().save(*args, broadcast=False, **kwargs)
@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, broadcast=True, **kwargs):
''' make sure the follow or block relationship doesn't already exist '''
# don't create a request if a follow already exists
if UserFollows.objects.filter(
user_subject=self.user_subject,
user_object=self.user_object,
).exists():
raise IntegrityError()
# blocking in either direction is a no-go
if UserBlocks.objects.filter(
Q(
user_subject=self.user_subject,
user_object=self.user_object,
) | Q(
user_subject=self.user_object,
user_object=self.user_subject,
)
).exists():
raise IntegrityError()
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:
manually_approves = self.user_object.manually_approves_followers
if not manually_approves:
self.accept()
model = apps.get_model('bookwyrm.Notification', require_ready=True)
notification_type = 'FOLLOW_REQUEST' if \
manually_approves else 'FOLLOW'
model.objects.create(
user=self.user_object,
related_user=self.user_subject,
notification_type=notification_type,
)
def accept(self):
''' turn this request into the real deal'''
user = self.user_object
if not self.user_subject.local:
activity = activitypub.Accept(
id=self.get_remote_id(status='accepts'),
actor=self.user_object.remote_id,
object=self.to_activity()
).serialize()
self.broadcast(activity, user)
with transaction.atomic():
UserFollows.from_request(self)
self.delete()
def reject(self):
''' generate a Reject for this follow request '''
if self.user_object.local:
activity = activitypub.Reject(
id=self.get_remote_id(status='rejects'),
actor=self.user_object.remote_id,
object=self.to_activity()
).serialize()
self.broadcast(activity, self.user_object)
self.delete()
class UserBlocks(ActivityMixin, UserRelationship):
''' prevent another user from following you and seeing your posts '''
status = 'blocks'
activity_serializer = activitypub.Block
def save(self, *args, **kwargs):
''' make sure the follow relationship doesn't already exist '''
try:
UserFollows.objects.get(
user_subject=self.user_subject,
user_object=self.user_object
)
return None
except UserFollows.DoesNotExist:
return super().save(*args, **kwargs)
''' remove follow or follow request rels after a block is created '''
super().save(*args, **kwargs)
class UserBlocks(UserRelationship):
''' prevent another user from following you and seeing your posts '''
# TODO: not implemented
status = 'blocks'
UserFollows.objects.filter(
Q(user_subject=self.user_subject, user_object=self.user_object) | \
Q(user_subject=self.user_object, user_object=self.user_subject)
).delete()
UserFollowRequest.objects.filter(
Q(user_subject=self.user_subject, user_object=self.user_object) | \
Q(user_subject=self.user_object, user_object=self.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,14 +23,15 @@ 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):
@ -51,39 +48,18 @@ 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()
activity_serializer = activitypub.Add
object_field = 'book'
collection_field = 'shelf'
class Meta:

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 '''
@ -50,9 +52,66 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
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,
)
def delete(self, *args, **kwargs):#pylint: disable=unused-argument
''' "delete" a status '''
if hasattr(self, 'boosted_status'):
# okay but if it's a boost really delete it
super().delete(*args, **kwargs)
return
self.deleted = True
self.deleted_date = timezone.now()
self.save()
@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 '''
if activity.type == 'Announce':
# keep it if the booster or the boosted are local
boosted = activitypub.resolve_remote_id(activity.object, save=False)
return cls.ignore_activity(boosted.to_activity_dataclass())
# keep if it if it's a custom type
if activity.type != 'Note':
return False
if cls.objects.filter(
@ -63,8 +122,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
if activity.tag == MISSING or activity.tag is None:
return True
tags = [l['href'] for l in activity.tag if l['type'] == 'Mention']
user_model = apps.get_model('bookwyrm.User', require_ready=True)
for tag in tags:
user_model = apps.get_model('bookwyrm.User', require_ready=True)
if user_model.objects.filter(
remote_id=tag, local=True).exists():
# we found a mention of a known use boost
@ -94,10 +153,11 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
return self.to_ordered_collection(
self.replies(self),
remote_id='%s/replies' % self.remote_id,
collection_only=True,
**kwargs
)
).serialize()
def to_activity(self, pure=False):# pylint: disable=arguments-differ
def to_activity_dataclass(self, pure=False):# pylint: disable=arguments-differ
''' return tombstone if the status is deleted '''
if self.deleted:
return activitypub.Tombstone(
@ -105,32 +165,28 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
url=self.remote_id,
deleted=self.deleted_date.isoformat(),
published=self.deleted_date.isoformat()
).serialize()
activity = ActivitypubMixin.to_activity(self)
activity['replies'] = self.to_replies()
)
activity = ActivitypubMixin.to_activity_dataclass(self)
activity.replies = self.to_replies()
# "pure" serialization for non-bookwyrm instances
if pure and hasattr(self, 'pure_content'):
activity['content'] = self.pure_content
if 'name' in activity:
activity['name'] = self.pure_name
activity['type'] = self.pure_type
activity['attachment'] = [
activity.content = self.pure_content
if hasattr(activity, 'name'):
activity.name = self.pure_name
activity.type = self.pure_type
activity.attachment = [
image_serializer(b.cover, b.alt_text) \
for b in self.mention_books.all()[:4] if b.cover]
if hasattr(self, 'book') and self.book.cover:
activity['attachment'].append(
activity.attachment.append(
image_serializer(self.book.cover, self.book.alt_text)
)
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)
def to_activity(self, pure=False):# pylint: disable=arguments-differ
''' json serialized activitypub class '''
return self.to_activity_dataclass(pure=pure).serialize()
class GeneratedNote(Status):
@ -222,7 +278,7 @@ class Review(Status):
pure_type = 'Article'
class Boost(Status):
class Boost(ActivityMixin, Status):
''' boost'ing a post '''
boosted_status = fields.ForeignKey(
'Status',
@ -230,6 +286,35 @@ class Boost(Status):
related_name='boosters',
activitypub_field='object',
)
activity_serializer = activitypub.Announce
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" '''
@ -243,8 +328,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

@ -1,11 +1,13 @@
''' models for storing different kinds of Activities '''
import urllib.parse
from django.apps import apps
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
@ -14,17 +16,15 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
name = fields.CharField(max_length=100, unique=True)
identifier = models.CharField(max_length=100)
@classmethod
def book_queryset(cls, identifier):
''' county of books associated with this tag '''
return cls.objects.filter(
identifier=identifier
).order_by('-updated_date')
@property
def collection_queryset(self):
''' books associated with this tag '''
return self.book_queryset(self.identifier)
def books(self):
''' count of books associated with this tag '''
edition_model = apps.get_model('bookwyrm.Edition', require_ready=True)
return edition_model.objects.filter(
usertag__tag__identifier=self.identifier
).order_by('-created_date').distinct()
collection_queryset = books
def get_remote_id(self):
''' tag should use identifier not id in remote_id '''
@ -40,7 +40,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')
@ -49,26 +49,9 @@ class UserTag(BookWyrmModel):
tag = fields.ForeignKey(
'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()
activity_serializer = activitypub.Add
object_field = 'book'
collection_field = 'tag'
class Meta:
''' unqiueness constraint '''

View file

@ -6,19 +6,18 @@ 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
from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.models.shelf import Shelf
from bookwyrm.models.status import Status, Review
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, Review
@ -113,6 +112,16 @@ class User(OrderedCollectionPageMixin, AbstractUser):
activity_serializer = activitypub.Person
@classmethod
def viewer_aware_objects(cls, viewer):
''' the user queryset filtered for the context of the logged in user '''
queryset = cls.objects.filter(is_active=True)
if viewer.is_authenticated:
queryset = queryset.exclude(
blocks=viewer
)
return queryset
def to_outbox(self, filter_type=None, **kwargs):
''' an ordered collection of statuses '''
if filter_type:
@ -131,7 +140,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).serialize()
def to_following_activity(self, **kwargs):
''' activitypub following list '''
@ -172,15 +181,23 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def save(self, *args, **kwargs):
''' populate fields for new local users '''
# this user already exists, no need to populate fields
created = not bool(self.id)
if not self.local and not re.match(regex.full_username, self.username):
# generate a username that uses the domain (webfinger format)
actor_parts = urlparse(self.remote_id)
self.username = '%s@%s' % (self.username, actor_parts.netloc)
return super().save(*args, **kwargs)
super().save(*args, **kwargs)
if self.id or not self.local:
return super().save(*args, **kwargs)
# this user already exists, no need to populate fields
if not created:
super().save(*args, **kwargs)
return
# this is a new remote user, we need to set their remote server field
if not self.local:
super().save(*args, **kwargs)
set_remote_server.delay(self.id)
return
# populate fields for local users
self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.localname)
@ -188,7 +205,32 @@ class User(OrderedCollectionPageMixin, AbstractUser):
self.shared_inbox = 'https://%s/inbox' % DOMAIN
self.outbox = '%s/outbox' % self.remote_id
return super().save(*args, **kwargs)
# an id needs to be set before we can proceed with related models
super().save(*args, **kwargs)
# create keys and shelves for new local users
self.key_pair = KeyPair.objects.create(
remote_id='%s/#main-key' % self.remote_id)
self.save(broadcast=False)
shelves = [{
'name': 'To Read',
'identifier': 'to-read',
}, {
'name': 'Currently Reading',
'identifier': 'reading',
}, {
'name': 'Read',
'identifier': 'read',
}]
for shelf in shelves:
Shelf(
name=shelf['name'],
identifier=shelf['identifier'],
user=self,
editable=False
).save(broadcast=False)
@property
def local_path(self):
@ -211,6 +253,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)
@ -266,6 +311,7 @@ class AnnualGoal(BookWyrmModel):
@property
def progress_percent(self):
''' how close to your goal, in percent form '''
return int(float(self.book_count / self.goal) * 100)
@ -276,42 +322,6 @@ class AnnualGoal(BookWyrmModel):
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):
''' create shelves for new users '''
if not created:
return
if not instance.local:
set_remote_server.delay(instance.id)
return
instance.key_pair = KeyPair.objects.create(
remote_id='%s/#main-key' % instance.remote_id)
instance.save()
shelves = [{
'name': 'To Read',
'identifier': 'to-read',
}, {
'name': 'Currently Reading',
'identifier': 'reading',
}, {
'name': 'Read',
'identifier': 'read',
}]
for shelf in shelves:
Shelf(
name=shelf['name'],
identifier=shelf['identifier'],
user=instance,
editable=False
).save()
@app.task
def set_remote_server(user_id):
''' figure out the user's remote server in the background '''
@ -319,7 +329,7 @@ def set_remote_server(user_id):
actor_parts = urlparse(user.remote_id)
user.federated_server = \
get_or_create_remote_server(actor_parts.netloc)
user.save()
user.save(broadcast=False)
if user.bookwyrm_user:
get_remote_reviews.delay(user.outbox)
@ -333,19 +343,24 @@ def get_or_create_remote_server(domain):
except FederatedServer.DoesNotExist:
pass
data = get_data('https://%s/.well-known/nodeinfo' % domain)
try:
nodeinfo_url = data.get('links')[0].get('href')
except (TypeError, KeyError):
return None
data = get_data('https://%s/.well-known/nodeinfo' % domain)
try:
nodeinfo_url = data.get('links')[0].get('href')
except (TypeError, KeyError):
raise ConnectorException()
data = get_data(nodeinfo_url)
application_type = data.get('software', {}).get('name')
application_version = data.get('software', {}).get('version')
except ConnectorException:
application_type = application_version = None
data = get_data(nodeinfo_url)
server = FederatedServer.objects.create(
server_name=domain,
application_type=data['software']['name'],
application_version=data['software']['version'],
application_type=application_type,
application_version=application_version,
)
return server
@ -360,4 +375,4 @@ def get_remote_reviews(outbox):
for activity in data['orderedItems']:
if not activity['type'] == 'Review':
continue
activitypub.Review(**activity).to_model(Review)
activitypub.Review(**activity).to_model()

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'
]

View file

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

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View file

@ -13,6 +13,13 @@
overflow: hidden;
}
/* --- SHELVING --- */
.shelf-option:disabled > *::after {
font-family: "icomoon";
content: "\e918";
margin-left: 0.5em;
}
/* --- TOGGLES --- */
.toggle-button[aria-pressed=true], .toggle-button[aria-pressed=true]:hover {
background-color: hsl(171, 100%, 41%);
@ -93,9 +100,6 @@
.cover-container.is-medium {
height: 100px;
}
.cover-container.is-small {
height: 70px;
}
}
.cover-container.is-medium .no-cover div {

View file

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

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

@ -31,15 +31,25 @@ window.onload = function() {
// polling
document.querySelectorAll('[data-poll]')
.forEach(el => polling(el));
// browser back behavior
document.querySelectorAll('[data-back]')
.forEach(t => t.onclick = back);
};
function polling(el) {
let delay = 10000 + (Math.random() * 1000);
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);
polling(el, delay * 1.25);
}, delay, el);
}

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(
user=user,
content=content,
privacy=privacy
)
if mention_books:
for book in mention_books:
status.mention_books.add(book)
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:
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

@ -1,16 +0,0 @@
{% extends 'layout.html' %}
{% block content %}
<div class="columns">
<div class="column block">
{% include 'snippets/about.html' %}
</div>
<div class="column block">
<h2 class="title">Code of Conduct</h2>
<div class="content">
{{ site.code_of_conduct | safe }}
</div>
</div>
</div>
{% endblock %}

View file

@ -2,7 +2,7 @@
{% load bookwyrm_tags %}
{% block content %}
<div class="block">
<div class="columns">
<div class="columns is-mobile">
<div class="column">
<h1 class="title">{{ author.name }}</h1>
</div>

View file

@ -4,7 +4,7 @@
{% block content %}
<div class="block">
<div class="columns">
<div class="columns is-mobile">
<div class="column">
<h1 class="title">
{{ book.title }}{% if book.subtitle %}:
@ -35,33 +35,17 @@
<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 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>
<label class="label">
<input type="file" name="cover" accept="image/*" enctype="multipart/form-data" id="id_cover" required>
</label>
<button class="button is-small is-primary" type="submit">Add</button>
</form>
</div>
{% endif %}
@ -103,7 +87,7 @@
<div class="column">
<div class="block">
<h3 class="field is-grouped">{% include 'snippets/stars.html' with rating=rating %} ({{ review_count }} 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 %}
@ -147,7 +131,7 @@
{% endfor %}
</div>
{% if readthroughs.exists %}
{% if request.user.is_authenticated %}
<section class="block">
<header class="columns">
<h2 class="column title is-5 mb-1">Your reading activity</h2>
@ -155,6 +139,9 @@
{% include 'snippets/toggle/open_button.html' with text="Add read dates" icon="plus" class="is-small" controls_text="add-readthrough" %}
</div>
</header>
{% if not readthroughs.exists %}
<p>You don't have any reading activity for this book.</p>
{% endif %}
<section class="hidden box" id="add-readthrough">
<form name="add-readthrough" action="/create-readthrough" method="post">
{% include 'snippets/readthrough_form.html' with readthrough=None %}
@ -221,6 +208,17 @@
</ul>
</section>
{% endif %}
{% if lists.exists %}
<section class="content block">
<h2 class="title is-5">Lists</h2>
<ul>
{% for list in lists %}
<li><a href="{{ list.local_path }}">{{ list.name }}</a></li>
{% endfor %}
</ul>
</section>
{% endif %}
</div>
</div>
</div>
@ -228,7 +226,7 @@
<div class="block" id="reviews">
{% for review in reviews %}
<div class="block">
{% include 'snippets/status.html' with status=review hide_book=True depth=1 %}
{% include 'snippets/status/status.html' with status=review hide_book=True depth=1 %}
</div>
{% endfor %}

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

@ -8,15 +8,17 @@
{% 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 }}-{{ readthrough.id }}" aria-label="close"></label>
<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,19 +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 %}
{% include 'snippets/pagination.html' with page=activities path="direct-messages" %}
</div>
{% endblock %}

View file

@ -0,0 +1,22 @@
{% extends 'layout.html' %}
{% block content %}
<header class="block has-text-centered">
<h1 class="title">{{ site.name }}</h1>
<h2 class="subtitle">{{ site.instance_tagline }}</h2>
</header>
{% include 'discover/icons.html' %}
<section class="block">
{% include 'snippets/about.html' %}
</section>
<div class="block">
<h2 class="title">Code of Conduct</h2>
<div class="content">
{{ site.code_of_conduct | safe }}
</div>
</div>
{% endblock %}

View file

@ -1,11 +1,14 @@
{% extends 'layout.html' %}
{% 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>
{% include 'discover/icons.html' %}
{% if not request.user.is_authenticated %}
<section class="tile is-ancestor">
<div class="tile is-7 is-parent">
<div class="tile is-child box">
@ -26,10 +29,7 @@
</div>
</div>
</section>
{% else %}
<div class="block">
<h1 class="title has-text-centered">Discover</h1>
</div>
{% endif %}
<div class="block is-hidden-tablet">
@ -40,18 +40,18 @@
<div class="tile is-vertical">
<div class="tile is-parent">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/large-book.html' with book=books.0 %}
{% include 'discover/large-book.html' with book=books.0 %}
</div>
</div>
<div class="tile">
<div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.1 %}
{% include 'discover/small-book.html' with book=books.1 %}
</div>
</div>
<div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.2 %}
{% include 'discover/small-book.html' with book=books.2 %}
</div>
</div>
</div>
@ -60,18 +60,18 @@
<div class="tile">
<div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.3 %}
{% include 'discover/small-book.html' with book=books.3 %}
</div>
</div>
<div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.4 %}
{% include 'discover/small-book.html' with book=books.4 %}
</div>
</div>
</div>
<div class="tile is-parent">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/large-book.html' with book=books.5 %}
{% include 'discover/large-book.html' with book=books.5 %}
</div>
</div>
</div>

View file

@ -0,0 +1,21 @@
<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>

View file

@ -2,8 +2,8 @@
{% if book %}
<div class="columns">
<div class="column is-narrow">
{% include 'snippets/book_cover.html' with book=book size="large" %}
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %}
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="large" %}</a>
{% include 'snippets/stars.html' with rating=book|rating:request.user %}
</div>
<div class="column">
<h3 class="title is-5"><a href="/book/{{ book.id }}">{{ book.title }}</a></h3>

View file

@ -1,9 +1,7 @@
{% load bookwyrm_tags %}
{% if book %}
{% include 'snippets/book_cover.html' with book=book %}
{% if ratings %}
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %}
{% endif %}
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book %}</a>
{% include 'snippets/stars.html' with rating=book|rating:request.user %}
<h3 class="title is-6"><a href="/book/{{ book.id }}">{{ book.title }}</a></h3>
{% if book.authors %}

View file

@ -62,7 +62,7 @@
<div class="column is-narrow">
<div class="block">
<h2 class="title is-4">Cover</h2>
<p>{{ form.cover }} </p>
<p>{{ form.cover }}</p>
{% for error in form.cover.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}

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 %}
<div 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 %}
</div>
<div 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 %}
</div>
<div 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 %}
</div>
<div 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 %}
</div>
<div class="block">
<label class="checkbox label" for="id_manually_approves_followers">
Manually approve followers:
{{ form.manually_approves_followers }}
</label>
</div>
<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 %}
<div class="block">
<label class="label" for="id_password">New password:</label>
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password">
</div>
<div 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">
</div>
<button class="button is-primary" type="submit">Change password</button>
</form>
</div>
</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/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/status.html' with status=activity %}
</div>
{% endfor %}
{% endblock %}

View file

@ -3,6 +3,7 @@
{% 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 %}
@ -48,12 +49,12 @@
</div>
</div>
<div class="card-content">
{% include 'snippets/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 %}
{% 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 %}
@ -71,43 +72,15 @@
</section>
{% endif %}
</div>
{% endif %}
<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>
{% block panel %}{% endblock %}
{# 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>
{% if activities %}
{% include 'snippets/pagination.html' with page=activities path=path anchor="#feed" %}
{% 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 %}
{% include 'snippets/pagination.html' with page=activities path='/'|add:tab anchor="#feed" %}
</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,16 +4,16 @@
{% 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 %}
{% include 'snippets/status.html' with status=status main=is_root %}
{% include 'snippets/status/status.html' with status=status main=is_root %}
{% 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

@ -1,38 +0,0 @@
{% extends 'layout.html' %}
{% load bookwyrm_tags %}
{% block content %}
<div class="block">
<h1 class="title">
{% if is_self %}Your
{% else %}
{% include 'snippets/username.html' with user=user possessive=True %}
{% endif %}
followers
</h1>
</div>
{% include 'snippets/user_header.html' with user=user %}
<div class="block">
<h2 class="title">Followers</h2>
{% for followers in followers %}
<div class="block">
<div class="field is-grouped">
<div class="control">
{% include 'snippets/avatar.html' with user=followers %}
</div>
<div class="control">
{% include 'snippets/username.html' with user=followers show_full=True %}
</div>
<div class="control">
{% include 'snippets/follow_button.html' with user=followers %}
</div>
</div>
</div>
{% endfor %}
{% if not followers.count %}
<div>{{ user|username }} has no followers</div>
{% endif %}
</div>
{% endblock %}

View file

@ -1,38 +0,0 @@
{% extends 'layout.html' %}
{% load bookwyrm_tags %}
{% block content %}
<div class="block">
<h1 class="title">
Users following
{% if is_self %}you
{% else %}
{% include 'snippets/username.html' with user=user %}
{% endif %}
</h1>
</div>
{% include 'snippets/user_header.html' with user=user %}
<div class="block">
<h2 class="title">Following</h2>
{% for follower in user.following.all %}
<div class="block">
<div class="field is-grouped">
<div class="control">
{% include 'snippets/avatar.html' with user=follower %}
</div>
<div class="control">
{% include 'snippets/username.html' with user=follower show_full=True %}
</div>
<div class="control">
{% include 'snippets/follow_button.html' with user=follower %}
</div>
</div>
</div>
{% endfor %}
{% if not following.count %}
<div>{{ user|username }} isn't following any users</div>
{% endif %}
</div>
{% endblock %}

View file

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

View file

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

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 is-mobile">
<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 is-mobile">
<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 is-mobile">
<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 is-mobile">
<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

@ -29,6 +29,8 @@
<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">
@ -36,33 +38,34 @@
<p>
{# DESCRIPTION #}
{% if notification.related_user %}
{% include 'snippets/avatar.html' with user=notification.related_user %}
{% include 'snippets/username.html' with user=notification.related_user %}
{% if notification.notification_type == 'FAVORITE' %}
favorited your
<a href="{{ related_status.local_path }}">{{ related_status | status_preview_name|safe }}</a>
{% include 'snippets/avatar.html' with user=notification.related_user %}
{% include 'snippets/username.html' with user=notification.related_user %}
{% if notification.notification_type == 'FAVORITE' %}
favorited your
<a href="{{ related_status.local_path }}">{{ related_status | status_preview_name|safe }}</a>
{% elif notification.notification_type == 'MENTION' %}
mentioned you in 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="{{ related_status.local_path }}">{{ related_status | status_preview_name|safe }}</a>
{% elif notification.notification_type == 'REPLY' %}
<a href="{{ related_status.local_path }}">replied</a>
to your
<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="{{ related_status.local_path }}">{{ related_status | status_preview_name|safe }}</a>
{% endif %}
{% else %}
{% elif notification.notification_type == 'REPLY' %}
<a href="{{ related_status.local_path }}">replied</a>
to your
<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="{{ 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 %}
{% elif notification.related_import %}
your <a href="/import/{{ notification.related_import.id }}">import</a> completed.
{% endif %}
</p>

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 %}

View file

@ -0,0 +1,19 @@
{% extends 'preferences/preferences_layout.html' %}
{% block header %}
Change Password
{% endblock %}
{% block panel %}
<form name="edit-profile" action="/change-password/" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="block">
<label class="label" for="id_password">New password:</label>
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password">
</div>
<div 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">
</div>
<button class="button is-primary" type="submit">Change password</button>
</form>
{% endblock %}

View file

@ -0,0 +1,48 @@
{% extends 'preferences/preferences_layout.html' %}
{% block header %}
Edit Profile
{% endblock %}
{% block panel %}
{% if form.non_field_errors %}
<p class="notification is-danger">{{ form.non_field_errors }}</p>
{% endif %}
<form name="edit-profile" action="{% url 'prefs-profile' %}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="block">
<label class="label" for="id_avatar">Avatar:</label>
{{ form.avatar }}
{% for error in form.avatar.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<div 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 %}
</div>
<div 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 %}
</div>
<div 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 %}
</div>
<div class="block">
<label class="checkbox label" for="id_manually_approves_followers">
Manually approve followers:
{{ form.manually_approves_followers }}
</label>
</div>
<button class="button is-primary" type="submit">Save</button>
</form>
{% endblock %}

View file

@ -0,0 +1,31 @@
{% extends 'layout.html' %}
{% block content %}
<header class="block column is-offset-one-quarter pl-1">
<h1 class="title">{% block header %}{% endblock %}</h1>
</header>
<div class="block columns">
<nav class="menu column is-one-quarter">
<h2 class="menu-label">Account</h2>
<ul class="menu-list">
<li>
<a href="/preferences/profile"{% if '/preferences/profile' in request.path %} class="is-active" aria-selected="true"{% endif %}>Profile</a>
</li>
<li>
<a href="/preferences/password"{% if '/preferences/password' in request.path %} class="is-active" aria-selected="true"{% endif %}>Change password</a>
</li>
</ul>
<h2 class="menu-label">Relationships</h2>
<ul class="menu-list">
<li>
<a href="/preferences/block"{% if '/preferences/block' in request.path %} class="is-active" aria-selected="true"{% endif %}>Blocked users</a>
</li>
</ul>
</nav>
<div class="column content">
{% block panel %}{% endblock %}
</div>
</div>
{% endblock %}

View file

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

View file

@ -0,0 +1,46 @@
{% extends 'layout.html' %}
{% block content %}
<header class="block column is-offset-one-quarter pl-1">
<h1 class="title">{% block header %}{% endblock %}</h1>
</header>
<div class="block columns">
<nav class="menu column is-one-quarter">
{% if perms.bookwyrm.create_invites %}
<h2 class="menu-label">Manage Users</h2>
<ul class="menu-list">
<li>
{% url 'settings-invites' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>Invites</a>
</li>
<li>
{% url 'settings-federation' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>Federated Servers</a>
</li>
</ul>
{% endif %}
{% if perms.bookwyrm.edit_instance_settings %}
<h2 class="menu-label">Instance Settings</h2>
<ul class="menu-list">
<li>
{% url 'settings-site' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>Site Configuration</a>
{% if url in request.path %}
<ul class="emnu-list">
<li><a href="{{ url }}#instance-info">Instance Info</a></li>
<li><a href="{{ url }}#images">Images</a></li>
<li><a href="{{ url }}#footer">Footer Content</a></li>
<li><a href="{{ url }}#registration">Registration</a></li>
</ul>
{% endif %}
</li>
</ul>
{% endif %}
</nav>
<div class="column content">
{% block panel %}{% endblock %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,21 @@
{% extends 'settings/admin_layout.html' %}
{% block header %}Federated Servers{% endblock %}
{% block panel %}
<table class="table is-striped">
<tr>
<th>Server name</th>
<th>Software</th>
<th>Status</th>
</tr>
{% for server in servers %}
<tr>
<td>{{ server.server_name }}</td>
<td>{{ server.application_type }} ({{ server.application_version }})</td>
<td>{{ server.status }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View file

@ -1,8 +1,32 @@
{% extends 'layout.html' %}
{% extends 'settings/admin_layout.html' %}
{% block header %}Invites{% endblock %}
{% load humanize %}
{% block content %}
<div class="block">
<h1 class="title">Invites</h1>
{% block panel %}
<section class="block">
<h2 class="title is-4">Generate New Invite</h2>
<form name="invite" action="{% url 'settings-invites' %}" method="post">
{% csrf_token %}
<div class="field is-grouped">
<div class="control">
<label class="label" for="id_expiry">Expiry:</label>
<div class="select">
{{ form.expiry }}
</div>
</div>
<div class="control">
<label class="label" for="id_use_limit">Use limit:</label>
<div class="select">
{{ form.use_limit }}
</div>
</div>
</div>
<button class="button is-primary" type="submit">Create Invite</button>
</form>
</section>
<section class="block">
<table class="table is-striped">
<tr>
<th>Link</th>
@ -22,29 +46,6 @@
</tr>
{% endfor %}
</table>
</div>
<div class="block">
<h2 class="title is-4">Generate New Invite</h2>
<form name="invite" action="/invite/" method="post">
{% csrf_token %}
<div class="field is-grouped">
<div class="control">
<label class="label" for="id_expiry">Expiry:</label>
<div class="select">
{{ form.expiry }}
</div>
</div>
<div class="control">
<label class="label" for="id_use_limit">Use limit:</label>
<div class="select">
{{ form.use_limit }}
</div>
</div>
</div>
<button class="button is-primary" type="submit">Create Invite</button>
</form>
</div>
{% include 'snippets/pagination.html' with page=invites path=request.path %}
</section>
{% endblock %}

View file

@ -0,0 +1,84 @@
{% extends 'settings/admin_layout.html' %}
{% block header %}Site Configuration{% endblock %}
{% block panel %}
<form action="{% url 'settings-site' %}" method="POST" class="content">
{% csrf_token %}
<section class="block" id="instance-info">
<h2 class="title is-4">Instance Info</h2>
<div class="control">
<label class="label" for="id_name">Instance Name:</label>
{{ site_form.name }}
</div>
<div class="control">
<label class="label" for="id_instance_tagline">Tagline:</label>
{{ site_form.instance_tagline }}
</div>
<div class="control">
<label class="label" for="id_instance_description">Instance description:</label>
{{ site_form.instance_description }}
</div>
<div class="control">
<label class="label" for="id_code_of_conduct">Code of conduct:</label>
{{ site_form.code_of_conduct }}
</div>
</section>
<hr aria-hidden="true">
<section class="block" id="images">
<h2 class="title is-4">Images</h2>
<div class="field is-grouped">
<div class="control">
<label class="label" for="id_logo">Logo:</label>
{{ site_form.logo }}
</div>
<div class="control">
<label class="label" for="id_logo_small">Logo small:</label>
{{ site_form.logo_small }}
</div>
<div class="control">
<label class="label" for="id_favicon">Favicon:</label>
{{ site_form.favicon }}
</div>
</div>
</section>
<hr aria-hidden="true">
<section class="block" id="footer">
<h2 class="title is-4">Footer Content</h2>
<div class="control">
<label class="label" for="id_support_link">Support link:</label>
<input type="text" name="support_link" maxlength="255" class="input" id="id_support_link" placeholder="https://www.patreon.com/bookwyrm"{% if site.support_link %} value="{{ site.support_link }}"{% endif %}>
</div>
<div class="control">
<label class="label" for="id_support_title">Support title:</label>
<input type="text" name="support_title" maxlength="100" class="input" id="id_support_title" placeholder="Patreon"{% if site.support_title %} value="{{ site.support_title }}"{% endif %}>
</div>
<div class="control">
<label class="label" for="id_admin_email">Admin email:</label>
{{ site_form.admin_email }}
</div>
</section>
<hr aria-hidden="true">
<section class="block" id="registration">
<h2 class="title is-4">Registration</h2>
<div class="control">
<label class="label" for="id_allow_registration">Allow registration:
{{ site_form.allow_registration }}
</div>
<div class="control">
<label class="label" for="id_registration_closed_text">Registration closed text:</label>
{{ site_form.registration_closed_text }}
</div>
</section>
<footer class="block">
<button class="button is-primary" type="submit">Save Changes</button>
</footer>
</form>
{% endblock %}

View file

@ -1,106 +0,0 @@
{% extends 'layout.html' %}
{% load bookwyrm_tags %}
{% block content %}
<div class="columns">
<div class="column">
<h1 class="title">
{% if is_self %}Your
{% else %}
{% include 'snippets/username.html' with user=user possessive=True %}
{% endif %}
shelves
</h1>
</div>
</div>
{% include 'snippets/user_header.html' with user=user %}
<div class="block columns">
<div class="column">
<div class="tabs">
<ul>
{% for shelf_tab in shelves %}
<li class="{% if shelf_tab.identifier == shelf.identifier %}is-active{% endif %}">
<a href="/user/{{ user | username }}/shelf/{{ shelf_tab.identifier }}"{% if shelf_tab.identifier == shelf.identifier %} aria-current="page"{% endif %}>{{ shelf_tab.name }}</a>
</li>
{% endfor %}
</ul>
</div>
</div>
{% if is_self %}
<div class="column is-narrow">
{% include 'snippets/toggle/open_button.html' with text="Create shelf" icon="plus" class="is-clickable" controls_text="create-shelf-form" %}
</div>
{% endif %}
</div>
<div class="hidden box mb-5" id="create-shelf-form">
<h2 class="title is-4">Create new shelf</h2>
<form name="create-shelf" action="/create-shelf/" method="post">
{% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
<div class="field">
<label class="label" for="id_name_create">Name:</label>
<input type="text" name="name" maxlength="100" class="input" required="true" id="id_name_create">
</div>
<label class="label">
<p>Shelf privacy:</p>
{% include 'snippets/privacy_select.html' with no_label=True %}
</label>
<div class="field is-grouped">
<button class="button is-primary" type="submit">Create shelf</button>
{% include 'snippets/toggle/close_button.html' with text="Cancel" controls_text="create-shelf-form" %}
</div>
</form>
</div>
<div class="block columns">
<div class="column">
<h2 class="title is-3">
{{ shelf.name }}
<span class="subtitle">
{% include 'snippets/privacy-icons.html' with item=shelf %}
</span>
</h2>
</div>
{% if is_self %}
<div class="column is-narrow">
{% include 'snippets/toggle/open_button.html' with text="Edit shelf" icon="pencil" class="is-clickable" controls_text="edit-shelf-form" %}
</div>
{% endif %}
</div>
<div class="hidden box mb-5" id="edit-shelf-form">
<h2 class="title is-4">Edit shelf</h2>
<form name="create-shelf" action="{{ shelf.local_path }}" method="post">
{% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
{% if shelf.editable %}
<div class="field">
<label class="label" for="id_name">Name:</label>
<input type="text" name="name" maxlength="100" class="input" required="true" value="{{ shelf.name }}" id="id_name">
</div>
{% else %}
<input type="hidden" name="name" required="true" value="{{ shelf.name }}">
{% endif %}
<label class="label">
<p>Shelf privacy:</p>
{% include 'snippets/privacy_select.html' with no_label=True current=shelf.privacy %}
</label>
<div class="field is-grouped">
<button class="button is-primary" type="submit">Update shelf</button>
{% include 'snippets/toggle/close_button.html' with text="Cancel" controls_text="edit-shelf-form" %}
</div>
</form>
</div>
<div class="block">
<div>
{% include 'snippets/shelf.html' with shelf=shelf books=books ratings=ratings %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,11 @@
{% if not user in request.user.blocks.all %}
<form name="blocks" method="post" action="/block/{{ user.id }}">
{% csrf_token %}
<button class="button is-danger is-light is-small {{ class }}" type="submit">Block</button>
</form>
{% else %}
<form name="unblocks" method="post" action="/unblock/{{ user.id }}">
{% csrf_token %}
<button class="button is-small {{ class }}" type="submit">Un-block</button>
</form>
{% endif %}

View file

@ -5,7 +5,7 @@
<a href="/book/{{ book.id }}">
{% include 'snippets/book_cover.html' with book=book %}
</a>
{% include 'snippets/shelve_button.html' with book=book switch_mode=True %}
{% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True %}
</div>
</div>
{% endfor %}

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