Merge branch 'main' into production

This commit is contained in:
Mouse Reeve 2021-02-10 17:24:31 -08:00
commit 5d7bd6a92b
99 changed files with 2298 additions and 1637 deletions

View file

@ -3,7 +3,8 @@
Social reading and reviewing, decentralized with ActivityPub
## Contents
- [The overall idea](#the-overall-idea)
- [Joining BookWyrm](#joining-bookwyrm)
- [The overall idea](#the-overall-idea)
- [What it is and isn't](#what-it-is-and-isnt)
- [The role of federation](#the-role-of-federation)
- [Features](#features)
@ -13,42 +14,46 @@ Social reading and reviewing, decentralized with ActivityPub
- [Book data](#book-data)
- [Contributing](#contributing)
## Joining BookWyrm
BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://github.com/mouse-reeve/bookwyrm/blob/main/instances.md) list.
I, the maintianer of this project, run https://bookwyrm.social, and I generally give out invite codes to those who ask by [email](mailto:mousereeve@riseup.net), [Mastodon direct message](https://friend.camp/@tripofmice), or [Twitter direct message](https://twitter.com/tripofmice).
## The overall idea
### What it is and isn't
BookWyrm is a platform for social reading! You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a datasource for books, but it does do both of those things to some degree.
### The role of federation
BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon and Pixelfed. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance.
BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance.
Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular type of literature, be just for use by people who are in a book club together, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks.
Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular interest, be just for a group of friends, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks.
### Features
Since the project is still in its early stages, not everything here is fully implemented. There is plenty of room for suggestions and ideas. Open an [issue](https://github.com/mouse-reeve/bookwyrm/issues) to get the conversation going!
Since the project is still in its early stages, the features are growing every day, and there is plenty of room for suggestions and ideas. Open an [issue](https://github.com/mouse-reeve/bookwyrm/issues) to get the conversation going!
- Posting about books
- Compose reviews, with or without ratings, which are aggregated in the book page
- Compose other kinds of statuses about books, such as:
- Comments on a book
- Quotes or excerpts
- Recommenations of other books
- Reply to statuses
- Aggregate reviews of a book across connected BookWyrm instances
- Differentiate local and federated reviews and rating
- View aggregate reviews of a book across connected BookWyrm instances
- Differentiate local and federated reviews and rating in your activity feed
- Track reading activity
- Shelve books on default "to-read," "currently reading," and "read" shelves
- Create custom shelves
- Store started reading/finished reading dates
- Store started reading/finished reading dates, as well as progress updates along the way
- Update followers about reading activity (optionally, and with granular privacy controls)
- Create lists of books which can be open to submissions from anyone, curated, or only edited by the creator
- Federation with ActivityPub
- Broadcast and receive user statuses and activity
- Broadcast copies of books that can be used as canonical data sources
- Share book data between instances to create a networked database of metadata
- Identify shared books across instances and aggregate related content
- Follow and interact with users across BookWyrm instances
- Inter-operate with non-BookWyrm ActivityPub services
- Inter-operate with non-BookWyrm ActivityPub services (currently, Mastodon is supported)
- Granular privacy controls
- Local-only, followers-only, and public posting
- Private, followers-only, and public privacy levels for posting, shelves, and lists
- Option for users to manually approve followers
- Allow blocking and flagging for moderation
- Control which instances you want to federate with
## Setting up the developer environment
@ -88,6 +93,7 @@ This project is still young and isn't, at the momoment, very stable, so please p
`cp .env.example .env`
- Add your domain, email address, mailgun credentials
- Set a secure redis password and secret key
- Set a secure database password for postgres
- Update your nginx configuration in `nginx/default.conf`
- Replace `your-domain.com` with your domain name
- Run the application (this should also set up a Certbot ssl cert for your domain)
@ -99,6 +105,7 @@ This project is still young and isn't, at the momoment, very stable, so please p
`docker-compose up -d`
- Initialize the database
`./bw-dev initdb`
- Set up schedule backups with cron that runs that `docker-compose exec db pg_dump -U <databasename>` and saves the backup to a safe locationgi
- Congrats! You did it, go to your domain and enjoy the fruits of your labors
### Configure your instance
- Register a user account in the applcation UI
@ -114,21 +121,11 @@ This project is still young and isn't, at the momoment, very stable, so please p
user.is_superuser = True
user.save()
```
- Go to the admin panel (`/admin/bookwyrm/sitesettings/1/change` on your domain) and set your instance name, description, code of conduct, and toggle whether registration is open on your instance
## Project structure
All the url routing is in `bookwyrm/urls.py`. This includes the application views (your home page, user page, book page, etc), application endpoints (things that happen when you click buttons), and federation api endpoints (inboxes, outboxes, webfinger, etc).
The application views and actions are in `bookwyrm/views.py`. The internal actions call api handlers which deal with federating content. Outgoing messages (any action done by a user that is federated out), as well as outboxes, live in `bookwyrm/outgoing.py`, and all handlers for incoming messages, as well as inboxes and webfinger, live in `bookwyrm/incoming.py`. Connection to openlibrary.org to get book data is handled in `bookwyrm/connectors/openlibrary.py`. ActivityPub serialization is handled in the `bookwyrm/activitypub/` directory.
Celery is used for background tasks, which includes receiving incoming ActivityPub activities, ActivityPub broadcasting, and external data import.
The UI is all django templates because that is the default. You can replace it with a complex javascript framework over my ~dead body~ mild objections.
- Go to the site settings (`/settings/site-settings` on your domain) and configure your instance name, description, code of conduct, and toggle whether registration is open on your instance
## Book data
The application is set up to get book data from arbitrary outside sources -- right now, it's only able to connect to OpenLibrary, but other connectors could be written. By default, a book is non-canonical copy of an OpenLibrary book, and will be updated with OpenLibrary if the data there changes. However, a book can edited and decoupled from its original data source, or added locally with no external data source.
The application is set up to share book and author data between instances, and get book data from arbitrary outside sources. Right now, the only connector is to OpenLibrary, but other connectors could be written.
There are three concepts in the book data model:
- `Book`, an abstract, high-level concept that could mean either a `Work` or an `Edition`. No data is saved as a `Book`, it serves as shared model for `Work` and `Edition`

View file

@ -93,7 +93,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)

View file

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

View file

@ -1,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
@ -145,8 +145,9 @@ class AbstractConnector(AbstractMinimalConnector):
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)
@ -210,13 +211,20 @@ def get_data(url):
'User-Agent': settings.USER_AGENT,
},
)
except (RequestError, SSLError):
except (RequestError, SSLError) as e:
logger.exception(e)
raise ConnectorException()
if not resp.ok:
resp.raise_for_status()
try:
resp.raise_for_status()
except requests.exceptions.HTTPError as e:
logger.exception(e)
raise ConnectorException()
try:
data = resp.json()
except ValueError:
except ValueError as e:
logger.exception(e)
raise ConnectorException()
return data

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

@ -9,7 +9,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
import requests
from bookwyrm import activitypub, models, views
from bookwyrm import activitypub, models
from bookwyrm import status as status_builder
from bookwyrm.tasks import app
from bookwyrm.signatures import Signature
@ -136,15 +136,8 @@ def handle_follow(activity):
)
# send the accept normally for a duplicate request
manually_approves = relationship.user_object.manually_approves_followers
status_builder.create_notification(
relationship.user_object,
'FOLLOW_REQUEST' if manually_approves else 'FOLLOW',
related_user=relationship.user_subject
)
if not manually_approves:
views.handle_accept(relationship)
if not relationship.user_object.manually_approves_followers:
relationship.accept()
@app.task
@ -256,27 +249,6 @@ def handle_create_status(activity):
# it was discarded because it's not a bookwyrm type
return
# create a notification if this is a reply
notified = []
if status.reply_parent and status.reply_parent.user.local:
notified.append(status.reply_parent.user)
status_builder.create_notification(
status.reply_parent.user,
'REPLY',
related_user=status.user,
related_status=status,
)
if status.mention_users.exists():
for mentioned_user in status.mention_users.all():
if not mentioned_user.local or mentioned_user in notified:
continue
status_builder.create_notification(
mentioned_user,
'MENTION',
related_user=status.user,
related_status=status,
)
@app.task
def handle_delete_status(activity):
@ -309,13 +281,6 @@ def handle_favorite(activity):
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):
@ -332,19 +297,11 @@ def handle_unfavorite(activity):
def handle_boost(activity):
''' someone gave us a boost! '''
try:
boost = activitypub.Boost(**activity).to_model(models.Boost)
activitypub.Boost(**activity).to_model(models.Boost)
except activitypub.ActivitySerializerError:
# this probably just means we tried to boost an unknown status
return
if not boost.user.local:
status_builder.create_notification(
boost.boosted_status.user,
'BOOST',
related_user=boost.user,
related_status=boost.boosted_status,
)
@app.task
def handle_unboost(activity):

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

@ -1,6 +1,6 @@
# Generated by Django 3.0.7 on 2021-01-31 16:14
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
@ -29,7 +29,7 @@ class Migration(migrations.Migration):
options={
'abstract': False,
},
bases=(bookwyrm.models.base_model.OrderedCollectionMixin, models.Model),
bases=(bookwyrm.models.activitypub_mixin.OrderedCollectionMixin, models.Model),
),
migrations.CreateModel(
name='ListItem',
@ -50,7 +50,7 @@ class Migration(migrations.Migration):
'ordering': ('-created_date',),
'unique_together': {('book', 'book_list')},
},
bases=(bookwyrm.models.base_model.ActivitypubMixin, models.Model),
bases=(bookwyrm.models.activitypub_mixin.ActivitypubMixin, models.Model),
),
migrations.AddField(
model_name='list',

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,30 @@
# 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
item.save(broadcast=False)
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

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

View file

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

View file

@ -1,20 +1,9 @@
''' base model with default fields '''
from base64 import b64encode
from functools import reduce
import operator
from uuid import uuid4
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from django.core.paginator import Paginator
from django.db import models
from django.db.models import Q
from django.dispatch import receiver
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN, PAGE_LENGTH
from .fields import ImageField, ManyToManyField, RemoteIdField
from bookwyrm.settings import DOMAIN
from .fields import RemoteIdField
class BookWyrmModel(models.Model):
@ -27,7 +16,7 @@ class BookWyrmModel(models.Model):
''' generate a url that resolves to the local object '''
base_path = 'https://%s' % DOMAIN
if hasattr(self, 'user'):
base_path = self.user.remote_id
base_path = '%s%s' % (base_path, self.user.local_path)
model_name = type(self).__name__.lower()
return '%s/%s/%d' % (base_path, model_name, self.id)
@ -49,254 +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 = generate_activity(self)
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)
signature = None
create_id = self.remote_id + '/activity'
if 'content' in activity_object:
signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
content = activity_object['content']
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
signature = activitypub.Signature(
creator='%s#main-key' % user.remote_id,
created=activity_object['published'],
signatureValue=b64encode(signed_message).decode('utf8')
)
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, collection_only=False, **kwargs):
''' an ordered collection of whatevers '''
if not queryset.ordered:
raise RuntimeError('queryset must be ordered')
remote_id = remote_id or self.remote_id
if page:
return to_ordered_collection_page(
queryset, remote_id, **kwargs)
if collection_only or not hasattr(self, 'activity_serializer'):
serializer = activitypub.OrderedCollection
activity = {}
else:
serializer = self.activity_serializer
# a dict from the model fields
activity = generate_activity(self)
if remote_id:
activity['id'] = remote_id
paginated = Paginator(queryset, PAGE_LENGTH)
# add computed fields specific to orderd collections
activity['totalItems'] = paginated.count
activity['first'] = '%s?page=1' % remote_id
activity['last'] = '%s?page=%d' % (remote_id, paginated.num_pages)
return serializer(**activity).serialize()
# 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)
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
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

@ -263,6 +263,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
if formatted is None or formatted is MISSING:
return
getattr(instance, self.name).set(formatted)
instance.save(broadcast=False)
def field_to_activity(self, value):
if self.link_only:

View file

@ -2,6 +2,7 @@
import re
import dateutil.parser
from django.apps import apps
from django.contrib.postgres.fields import JSONField
from django.db import models
from django.utils import timezone
@ -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 '''

View file

@ -1,10 +1,11 @@
''' 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 .base_model import ActivitypubMixin, BookWyrmModel
from .base_model import OrderedCollectionMixin
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
from . import fields
@ -42,20 +43,22 @@ class List(OrderedCollectionMixin, BookWyrmModel):
@property
def collection_queryset(self):
''' list of books for this shelf, overrides OrderedCollectionMixin '''
return self.books.all().order_by('listitem')
return self.books.filter(
listitem__approved=True
).all().order_by('listitem')
class Meta:
''' default sorting '''
ordering = ('-updated_date',)
class ListItem(ActivitypubMixin, BookWyrmModel):
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')
added_by = fields.ForeignKey(
user = fields.ForeignKey(
'User',
on_delete=models.PROTECT,
activitypub_field='actor'
@ -66,24 +69,24 @@ class ListItem(ActivitypubMixin, BookWyrmModel):
endorsement = models.ManyToManyField('User', related_name='endorsers')
activity_serializer = activitypub.AddBook
object_field = 'book'
collection_field = 'book_list'
def to_add_activity(self, user):
''' AP for shelving a book'''
return activitypub.Add(
id='%s#add' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.book_list.remote_id,
).serialize()
def 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',
)
def to_remove_activity(self, user):
''' AP for un-shelving a book'''
return activitypub.Remove(
id='%s#remove' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.book_list.remote_id
).serialize()
class Meta:
''' an opinionated constraint! you can't put a book on a list twice '''

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,14 +1,16 @@
''' defines relationships between users '''
from django.db import models
from django.apps import apps
from django.db import models, transaction
from django.db.models import Q
from django.dispatch import receiver
from bookwyrm import activitypub
from .base_model import ActivitypubMixin, BookWyrmModel
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
from .base_model import BookWyrmModel
from . import fields
class UserRelationship(ActivitypubMixin, BookWyrmModel):
class UserRelationship(BookWyrmModel):
''' many-to-many through table for followers '''
user_subject = fields.ForeignKey(
'User',
@ -23,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
@ -37,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'
@ -46,55 +56,84 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
return '%s#%s/%d' % (base_path, status, self.id)
def to_accept_activity(self):
''' generate an Accept for this follow request '''
return activitypub.Accept(
id=self.get_remote_id(status='accepts'),
actor=self.user_object.remote_id,
object=self.to_activity()
).serialize()
def to_reject_activity(self):
''' generate a Reject for this follow request '''
return activitypub.Reject(
id=self.get_remote_id(status='rejects'),
actor=self.user_object.remote_id,
object=self.to_activity()
).serialize()
class UserFollows(UserRelationship):
class UserFollows(ActivitypubMixin, UserRelationship):
''' Following a user '''
status = 'follows'
activity_serializer = activitypub.Follow
@classmethod
def from_request(cls, follow_request):
''' converts a follow request into a follow relationship '''
return cls(
return cls.objects.create(
user_subject=follow_request.user_subject,
user_object=follow_request.user_object,
remote_id=follow_request.remote_id,
)
class UserFollowRequest(UserRelationship):
class UserFollowRequest(ActivitypubMixin, UserRelationship):
''' following a user requires manual or automatic confirmation '''
status = 'follow_request'
activity_serializer = activitypub.Follow
def save(self, *args, **kwargs):
''' make sure the follow relationship doesn't already exist '''
def save(self, *args, broadcast=True, **kwargs):
''' make sure the follow or block relationship doesn't already exist '''
try:
UserFollows.objects.get(
user_subject=self.user_subject,
user_object=self.user_object
)
UserBlocks.objects.get(
user_subject=self.user_subject,
user_object=self.user_object
)
return None
except UserFollows.DoesNotExist:
return super().save(*args, **kwargs)
except (UserFollows.DoesNotExist, UserBlocks.DoesNotExist):
super().save(*args, **kwargs)
if broadcast and self.user_subject.local and not self.user_object.local:
self.broadcast(self.to_activity(), self.user_subject)
if self.user_object.local:
model = apps.get_model('bookwyrm.Notification', require_ready=True)
notification_type = 'FOLLOW_REQUEST' \
if self.user_object.manually_approves_followers else 'FOLLOW'
model.objects.create(
user=self.user_object,
related_user=self.user_subject,
notification_type=notification_type,
)
class UserBlocks(UserRelationship):
def accept(self):
''' turn this request into the real deal'''
user = self.user_object
activity = activitypub.Accept(
id=self.get_remote_id(status='accepts'),
actor=self.user_object.remote_id,
object=self.to_activity()
).serialize()
with transaction.atomic():
UserFollows.from_request(self)
self.delete()
self.broadcast(activity, user)
def reject(self):
''' generate a Reject for this follow request '''
user = self.user_object
activity = activitypub.Reject(
id=self.get_remote_id(status='rejects'),
actor=self.user_object.remote_id,
object=self.to_activity()
).serialize()
self.delete()
self.broadcast(activity, user)
class UserBlocks(ActivityMixin, UserRelationship):
''' prevent another user from following you and seeing your posts '''
status = 'blocks'
activity_serializer = activitypub.Block

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
@ -27,12 +27,11 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
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):
@ -49,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()
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,6 +52,47 @@ 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,
)
@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 '''
@ -126,14 +169,6 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
return activity
def save(self, *args, **kwargs):
''' update user active time '''
if self.user.local:
self.user.last_active_date = timezone.now()
self.user.save()
return super().save(*args, **kwargs)
class GeneratedNote(Status):
''' these are app-generated messages about user activity '''
@property
@ -223,7 +258,7 @@ class Review(Status):
pure_type = 'Article'
class Boost(Status):
class Boost(ActivityMixin, Status):
''' boost'ing a post '''
boosted_status = fields.ForeignKey(
'Status',
@ -231,6 +266,35 @@ class Boost(Status):
related_name='boosters',
activitypub_field='object',
)
activity_serializer = activitypub.Boost
def save(self, *args, **kwargs):
''' save and notify '''
super().save(*args, **kwargs)
if not self.boosted_status.user.local:
return
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
notification_model.objects.create(
user=self.boosted_status.user,
related_status=self.boosted_status,
related_user=self.user,
notification_type='BOOST',
)
def delete(self, *args, **kwargs):
''' delete and un-notify '''
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
notification_model.objects.filter(
user=self.boosted_status.user,
related_status=self.boosted_status,
related_user=self.user,
notification_type='BOOST',
).delete()
super().delete(*args, **kwargs)
def __init__(self, *args, **kwargs):
''' the user field is "actor" here instead of "attributedTo" '''
@ -244,8 +308,6 @@ class Boost(Status):
self.image_fields = []
self.deserialize_reverse_fields = []
activity_serializer = activitypub.Boost
# This constraint can't work as it would cross tables.
# class Meta:
# unique_together = ('user', 'boosted_status')

View file

@ -5,7 +5,8 @@ from django.db import models
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from .base_model import OrderedCollectionMixin, BookWyrmModel
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
from . import fields
@ -40,7 +41,7 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
super().save(*args, **kwargs)
class UserTag(BookWyrmModel):
class UserTag(CollectionItemMixin, BookWyrmModel):
''' an instance of a tag on a book by a user '''
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='actor')
@ -50,25 +51,8 @@ class UserTag(BookWyrmModel):
'Tag', on_delete=models.PROTECT, activitypub_field='target')
activity_serializer = activitypub.AddBook
def to_add_activity(self, user):
''' AP for shelving a book'''
return activitypub.Add(
id='%s#add' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.remote_id,
).serialize()
def to_remove_activity(self, user):
''' AP for un-shelving a book'''
return activitypub.Remove(
id='%s#remove' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.remote_id,
).serialize()
object_field = 'book'
collection_field = 'tag'
class Meta:
''' unqiueness constraint '''

View file

@ -17,8 +17,8 @@ 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
@ -211,6 +211,9 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
def save(self, *args, **kwargs):
''' create a key pair '''
# no broadcasting happening here
if 'broadcast' in kwargs:
del kwargs['broadcast']
if not self.public_key:
self.private_key, self.public_key = create_key_pair()
return super().save(*args, **kwargs)
@ -291,7 +294,7 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
instance.key_pair = KeyPair.objects.create(
remote_id='%s/#main-key' % instance.remote_id)
instance.save()
instance.save(broadcast=False)
shelves = [{
'name': 'To Read',
@ -310,7 +313,7 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
identifier=shelf['identifier'],
user=instance,
editable=False
).save()
).save(broadcast=False)
@app.task

View file

@ -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%);

View file

@ -46,13 +46,14 @@ function back(e) {
history.back();
}
function polling(el) {
let delay = 10000 + (Math.random() * 1000);
function polling(el, delay) {
delay = delay || 10000;
delay += (Math.random() * 1000);
setTimeout(function() {
fetch('/api/updates/' + el.getAttribute('data-poll'))
.then(response => response.json())
.then(data => updateCountElement(el, data));
polling(el);
polling(el, delay * 1.25);
}, delay, el);
}

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

@ -35,7 +35,7 @@
<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">

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

@ -48,7 +48,7 @@
</div>
</div>
<div class="card-content">
{% include 'snippets/shelve_button.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 %}

View file

@ -23,7 +23,7 @@
{% include 'snippets/book_titleby.html' with book=item.book %}
</td>
<td>
{% include 'snippets/username.html' with user=item.added_by %}
{% include 'snippets/username.html' with user=item.user %}
</td>
<td>
<div class="field has-addons">

View file

@ -21,19 +21,19 @@
<div class="card">
<div class="card-content columns p-0 mb-0">
<div class="column is-narrow pt-0 pb-0">
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=item.book size="medium" %}</a>
<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.html' with book=item.book %}
{% 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.added_by %}</p>
<p>Added by {% include 'snippets/username.html' with user=item.user %}</p>
</div>
{% if list.user == request.user or list.curation == 'open' and item.added_by == request.user %}
{% 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 }}">
@ -72,6 +72,7 @@
<p>No books found{% if query %} matching the query "{{ query }}"{% endif %}</p>
{% endif %}
{% for book in suggested_books %}
{% if book %}
<div class="block columns">
<div class="column is-narrow">
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a>
@ -85,6 +86,7 @@
</form>
</div>
</div>
{% endif %}
{% endfor %}
</section>
{% endif %}

View file

@ -10,7 +10,7 @@
</header>
<div class="card-image is-flex">
{% for book in list.listitem_set.all|slice:5 %}
{% include 'snippets/book_cover.html' with book=book.book size="small" %}
<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">

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

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

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

View file

@ -37,10 +37,10 @@
{% for book in books %}
<tr class="book-preview">
<td>
{% include 'snippets/book_cover.html' with book=book size="small" %}
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a>
</td>
<td>
<a href="/book/{{ book.id }}">{{ book.title }}</a>
<a href="{{ book.local_path }}">{{ book.title }}</a>
</td>
<td>
{{ book.authors.first.name }}

View file

@ -1,40 +0,0 @@
{% load bookwyrm_tags %}
{% if request.user.is_authenticated %}
{% with book.id|uuid as uuid %}
{% active_shelf book as active_shelf %}
<div class="field has-addons">
{% if switch_mode and active_shelf.book != book %}
<div class="control">
{% include 'snippets/switch_edition_button.html' with edition=book size='is-small' %}
</div>
{% else %}
<div class="control">
{% if active_shelf.shelf.identifier == 'read' %}
<button class="button is-small" disabled>
<span>Read</span> <span class="icon icon-check"></span>
</button>
{% elif active_shelf.shelf.identifier == 'reading' %}
{% include 'snippets/toggle/toggle_button.html' with class="is-small" text="I'm done!" controls_text="finish-reading" controls_uid=uuid focus="modal-title-finish-reading" %}
{% elif active_shelf.shelf.identifier == 'to-read' %}
{% include 'snippets/toggle/toggle_button.html' with class="is-small" text="Start reading" controls_text="start-reading" controls_uid=uuid focus="modal-title-start-reading" %}
{% else %}
<form name="shelve" action="/shelve/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ active_shelf.book.id }}">
<input type="hidden" name="shelf" value="to-read">
<button class="button is-small" type="submit">Want to read</button>
</form>
{% endif %}
</div>
{% include 'snippets/shelve_button_dropdown.html' with class="is-small" button_uuid=uuid%}
{% endif %}
</div>
{% include 'snippets/start_reading_modal.html' with book=active_shelf.book controls_text="start-reading" controls_uid=uuid %}
{% latest_read_through book request.user as readthrough %}
{% include 'snippets/finish_reading_modal.html' with book=active_shelf.book controls_text="finish-reading" controls_uid=uuid readthrough=readthrough %}
{% endwith %}
{% endif %}

View file

@ -0,0 +1,27 @@
{% load bookwyrm_tags %}
{% if request.user.is_authenticated %}
{% with book.id|uuid as uuid %}
{% active_shelf book as active_shelf %}
<div class="field has-addons">
{% if switch_mode and active_shelf.book != book %}
<div class="control">
{% include 'snippets/switch_edition_button.html' with edition=book size='is-small' %}
</div>
{% else %}
<div class="control">
{% include 'snippets/shelve_button/shelve_button_options.html' with class="shelf-option is-small" shelves=request.user.shelf_set.all active_shelf=active_shelf button_uuid=uuid %}
</div>
{% include 'snippets/shelve_button/shelve_button_dropdown.html' with class="is-small" button_uuid=uuid%}
{% endif %}
</div>
{% include 'snippets/shelve_button/want_to_read_modal.html' with book=active_shelf.book controls_text="want-to-read" controls_uid=uuid no_body=True %}
{% include 'snippets/shelve_button/start_reading_modal.html' with book=active_shelf.book controls_text="start-reading" controls_uid=uuid %}
{% latest_read_through book request.user as readthrough %}
{% include 'snippets/shelve_button/finish_reading_modal.html' with book=active_shelf.book controls_text="finish-reading" controls_uid=uuid readthrough=readthrough %}
{% endwith %}
{% endif %}

View file

@ -0,0 +1,10 @@
{% extends 'components/dropdown.html' %}
{% block dropdown-trigger %}
<span class="icon icon-arrow-down">
<span class="is-sr-only">More shelves</span>
</span>
{% endblock %}
{% block dropdown-list %}
{% include 'snippets/shelve_button/shelve_button_options.html' with active_shelf=active_shelf shelves=request.user.shelf_set.all dropdown=True class="shelf-option is-fullwidth is-small" %}
{% endblock %}

View file

@ -0,0 +1,25 @@
{% load bookwyrm_tags %}
{% for shelf in shelves %}
{% comparison_bool shelf.identifier active_shelf.shelf.identifier as is_current %}
{% if dropdown %}<li role="menuitem">{% endif %}
<div class="{% if dropdown %}dropdown-item pt-0 pb-0{% elif active_shelf.shelf.identifier|next_shelf != shelf.identifier %}hidden{% endif %}">
{% if shelf.identifier == 'reading' %}{% if not dropdown or active_shelf.shelf.identifier|next_shelf != shelf.identifier %}
{% include 'snippets/toggle/toggle_button.html' with class=class text="Start reading" controls_text="start-reading" controls_uid=button_uuid focus="modal-title-start-reading" disabled=is_current %}
{% endif %}{% elif shelf.identifier == 'read' and active_shelf.shelf.identifier == 'read' %}{% if not dropdown or active_shelf.shelf.identifier|next_shelf != shelf.identifier %}
<button type="button" class="button {{ class }}" disabled><span>Read</span>
{% endif %}{% elif shelf.identifier == 'read' %}{% if not dropdown or active_shelf.shelf.identifier|next_shelf != shelf.identifier %}
{% include 'snippets/toggle/toggle_button.html' with class=class text="Finish reading" controls_text="finish-reading" controls_uid=button_uuid focus="modal-title-finish-reading" disabled=is_current %}
{% endif %}{% elif shelf.identifier == 'to-read' %}{% if not dropdown or active_shelf.shelf.identifier|next_shelf != shelf.identifier %}
{% include 'snippets/toggle/toggle_button.html' with class=class text="Want to read" controls_text="want-to-read" controls_uid=button_uuid focus="modal-title-want-to-read" disabled=is_current %}
{% endif %}{% elif shelf.editable %}
<form name="shelve" action="/shelve/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ active_shelf.book.id }}">
<button class="button {{ class }}" name="shelf" type="submit" value="{{ shelf.identifier }}" {% if shelf in book.shelf_set.all %} disabled {% endif %}>
<span>{{ shelf.name }}</span>
</button>
</form>
{% endif %}
</div>
{% if dropdown %}</li>{% endif %}
{% endfor %}

View file

@ -0,0 +1,31 @@
{% extends 'components/modal.html' %}
{% block modal-title %}
Want to Read "<em>{{ book.title }}</em>"
{% endblock %}
{% block modal-form-open %}
<form name="shelve" action="/shelve/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ active_shelf.book.id }}">
<input type="hidden" name="shelf" value="to-read">
{% endblock %}
{% block modal-footer %}
<div class="columns">
<div class="column field">
<label for="post_status_want-{{ uuid }}">
<input type="checkbox" name="post-status" class="checkbox" id="post_status_want-{{ uuid }}" checked>
Post to feed
</label>
{% include 'snippets/privacy_select.html' %}
</div>
<div class="column">
<button class="button is-success" type="submit">
<span>Want to read</span>
</button>
{% include 'snippets/toggle/toggle_button.html' with text="Cancel" controls_text="want-to-read" controls_uid=uuid %}
</div>
</div>
{% endblock %}
{% block modal-form-close %}</form>{% endblock %}

View file

@ -1,28 +0,0 @@
{% extends 'components/dropdown.html' %}
{% block dropdown-trigger %}
<span class="icon icon-arrow-down">
<span class="is-sr-only">More shelves</span>
</span>
{% endblock %}
{% block dropdown-list %}
{% for shelf in request.user.shelf_set.all %}
<li role="menuitem">
{% if active_shelf.shelf.identifier != 'reading' and shelf.identifier == 'reading' %}
<div class="dropdown-item pt-0 pb-0">
{% include 'snippets/toggle/toggle_button.html' with class="is-fullwidth is-small" text="Start reading" controls_text="start-reading" controls_uid=button_uuid focus="modal-title-start-reading" %}
</div>
{% else %}
<form class="dropdown-item pt-0 pb-0" name="shelve" action="/shelve/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ active_shelf.book.id }}">
<button class="button is-fullwidth is-small" name="shelf" type="submit" value="{{ shelf.identifier }}" {% if shelf in book.shelf_set.all %} disabled {% endif %}>
<span>{{ shelf.name }}</span>
{% if shelf in book.shelf_set.all %}<span class="icon icon-check"></span>{% endif %}
</button>
</form>
{% endif %}
</li>
{% endfor %}
{% endblock %}

View file

@ -6,6 +6,7 @@
{% if checkbox %}data-controls-checkbox="{{ checkbox }}{% if controls_uid %}-{{ controls_uid }}{% endif %}"{% endif %}
{% if label %}aria-label="{{ label }}"{% endif %}
aria-pressed="{% if pressed %}true{% else %}false{% endif %}"
{% if disabled %}disabled{% endif %}
>
{% if icon %}
@ -13,6 +14,6 @@
<span class="is-sr-only">{{ text }}</span>
</span>
{% else %}
{{ text }}
<span>{{ text }}</span>
{% endif %}
</button>

View file

@ -54,8 +54,13 @@
{% endif %}
<div>
<div class="block">
<h2 class="title">User Activity</h2>
<div class="columns">
<h2 class="title column">User Activity</h2>
<div class="column is-narrow">
<a class="icon icon-rss" target="_blank" href="{{ user.local_path }}/rss">
<span class="is-sr-only">RSS feed</span>
</a>
</div>
</div>
{% for activity in activities %}
<div class="block" id="feed">

View file

@ -75,19 +75,25 @@
<li{% if url == request.path or url == request.path|add:'/' %} class="is-active"{% endif %}>
<a href="{{ url }}">Activity</a>
</li>
{% if is_self or user.goal.exists %}
{% now 'Y' as year %}
{% url 'user-goal' user|username year as url %}
<li{% if url in request.path %} class="is-active"{% endif %}>
<a href="{{ url }}">Reading Goal</a>
</li>
{% endif %}
{% if is_self or user.lists.exists %}
{% url 'user-lists' user|username as url %}
<li{% if url in request.path %} class="is-active"{% endif %}>
<a href="{{ url }}">Lists</a>
</li>
{% endif %}
{% if user.shelf_set.exists %}
{% url 'user-shelves' user|username as url %}
<li{% if url in request.path %} class="is-active"{% endif %}>
<a href="{{ url }}">Shelves</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}

View file

@ -171,6 +171,17 @@ def get_status_preview_name(obj):
return '%s from <em>%s</em>' % (name, obj.book.title)
return name
@register.filter(name='next_shelf')
def get_next_shelf(current_shelf):
''' shelf you'd use to update reading progress '''
if current_shelf == 'to-read':
return 'reading'
if current_shelf == 'reading':
return 'read'
if current_shelf == 'read':
return 'read'
return 'to-read'
@register.simple_tag(takes_context=False)
def related_status(notification):
''' for notifications '''
@ -211,3 +222,9 @@ def active_read_through(book, user):
book=book,
finish_date__isnull=True
).order_by('-start_date').first()
@register.simple_tag(takes_context=False)
def comparison_bool(str1, str2):
''' idk why I need to write a tag for this, it reutrns a bool '''
return str1 == str2

View file

@ -23,7 +23,7 @@ class BaseActivity(TestCase):
'mouse', 'mouse@mouse.mouse', 'mouseword',
local=True, localname='mouse')
self.user.remote_id = 'http://example.com/a/b'
self.user.save()
self.user.save(broadcast=False)
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ap_user.json'
@ -167,16 +167,19 @@ class BaseActivity(TestCase):
with self.assertRaises(ValueError):
self.user.avatar.file #pylint: disable=pointless-statement
activity.to_model(models.User, self.user)
# this would trigger a broadcast because it's a local user
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
activity.to_model(models.User, self.user)
self.assertIsNotNone(self.user.avatar.name)
self.assertIsNotNone(self.user.avatar.file)
def test_to_model_many_to_many(self):
''' annoying that these all need special handling '''
status = models.Status.objects.create(
content='test status',
user=self.user,
)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
status = models.Status.objects.create(
content='test status',
user=self.user,
)
book = models.Edition.objects.create(
title='Test Edition', remote_id='http://book.com/book')
update_data = activitypub.Note(
@ -208,10 +211,11 @@ class BaseActivity(TestCase):
def test_to_model_one_to_many(self):
''' these are reversed relationships, where the secondary object
keys the primary object but not vice versa '''
status = models.Status.objects.create(
content='test status',
user=self.user,
)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
status = models.Status.objects.create(
content='test status',
user=self.user,
)
update_data = activitypub.Note(
id=status.remote_id,
content=status.content,
@ -242,10 +246,11 @@ class BaseActivity(TestCase):
@responses.activate
def test_set_related_field(self):
''' celery task to add back-references to created objects '''
status = models.Status.objects.create(
content='test status',
user=self.user,
)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
status = models.Status.objects.create(
content='test status',
user=self.user,
)
data = {
'url': 'http://www.example.com/image.jpg',
'name': 'alt text',

View file

@ -0,0 +1,383 @@
''' testing model activitypub utilities '''
from unittest.mock import patch
from collections import namedtuple
from dataclasses import dataclass
import re
from django import db
from django.test import TestCase
from bookwyrm.activitypub.base_activity import ActivityObject
from bookwyrm import models
from bookwyrm.models import base_model
from bookwyrm.models.activitypub_mixin import ActivitypubMixin
from bookwyrm.models.activitypub_mixin import ActivityMixin, ObjectMixin
class ActivitypubMixins(TestCase):
''' functionality shared across models '''
def setUp(self):
''' shared data '''
self.local_user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword',
local=True, localname='mouse')
self.local_user.remote_id = 'http://example.com/a/b'
self.local_user.save(broadcast=False)
with patch('bookwyrm.models.user.set_remote_server.delay'):
self.remote_user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword',
local=False,
remote_id='https://example.com/users/rat',
inbox='https://example.com/users/rat/inbox',
outbox='https://example.com/users/rat/outbox',
)
# ActivitypubMixin
def test_to_activity(self):
''' model to ActivityPub json '''
@dataclass(init=False)
class TestActivity(ActivityObject):
''' real simple mock '''
type: str = 'Test'
class TestModel(ActivitypubMixin, base_model.BookWyrmModel):
''' real simple mock model because BookWyrmModel is abstract '''
instance = TestModel()
instance.remote_id = 'https://www.example.com/test'
instance.activity_serializer = TestActivity
activity = instance.to_activity()
self.assertIsInstance(activity, dict)
self.assertEqual(activity['id'], 'https://www.example.com/test')
self.assertEqual(activity['type'], 'Test')
def test_find_existing_by_remote_id(self):
''' attempt to match a remote id to an object in the db '''
# uses a different remote id scheme
# this isn't really part of this test directly but it's helpful to state
book = models.Edition.objects.create(
title='Test Edition', remote_id='http://book.com/book')
self.assertEqual(book.origin_id, 'http://book.com/book')
self.assertNotEqual(book.remote_id, 'http://book.com/book')
# uses subclasses
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.Comment.objects.create(
user=self.local_user, content='test status', book=book, \
remote_id='https://comment.net')
result = models.User.find_existing_by_remote_id('hi')
self.assertIsNone(result)
result = models.User.find_existing_by_remote_id(
'http://example.com/a/b')
self.assertEqual(result, self.local_user)
# test using origin id
result = models.Edition.find_existing_by_remote_id(
'http://book.com/book')
self.assertEqual(result, book)
# test subclass match
result = models.Status.find_existing_by_remote_id(
'https://comment.net')
def test_find_existing(self):
''' match a blob of data to a model '''
book = models.Edition.objects.create(
title='Test edition',
openlibrary_key='OL1234',
)
result = models.Edition.find_existing(
{'openlibraryKey': 'OL1234'})
self.assertEqual(result, book)
def test_get_recipients_public_object(self):
''' determines the recipients for an object's broadcast '''
MockSelf = namedtuple('Self', ('privacy'))
mock_self = MockSelf('public')
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertEqual(len(recipients), 1)
self.assertEqual(recipients[0], self.remote_user.inbox)
def test_get_recipients_public_user_object_no_followers(self):
''' determines the recipients for a user's object broadcast '''
MockSelf = namedtuple('Self', ('privacy', 'user'))
mock_self = MockSelf('public', self.local_user)
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertEqual(len(recipients), 0)
def test_get_recipients_public_user_object(self):
''' determines the recipients for a user's object broadcast '''
MockSelf = namedtuple('Self', ('privacy', 'user'))
mock_self = MockSelf('public', self.local_user)
self.local_user.followers.add(self.remote_user)
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertEqual(len(recipients), 1)
self.assertEqual(recipients[0], self.remote_user.inbox)
def test_get_recipients_public_user_object_with_mention(self):
''' determines the recipients for a user's object broadcast '''
MockSelf = namedtuple('Self', ('privacy', 'user'))
mock_self = MockSelf('public', self.local_user)
self.local_user.followers.add(self.remote_user)
with patch('bookwyrm.models.user.set_remote_server.delay'):
another_remote_user = models.User.objects.create_user(
'nutria', 'nutria@nutria.com', 'nutriaword',
local=False,
remote_id='https://example.com/users/nutria',
inbox='https://example.com/users/nutria/inbox',
outbox='https://example.com/users/nutria/outbox',
)
MockSelf = namedtuple('Self', ('privacy', 'user', 'recipients'))
mock_self = MockSelf('public', self.local_user, [another_remote_user])
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertEqual(len(recipients), 2)
self.assertEqual(recipients[0], another_remote_user.inbox)
self.assertEqual(recipients[1], self.remote_user.inbox)
def test_get_recipients_direct(self):
''' determines the recipients for a user's object broadcast '''
MockSelf = namedtuple('Self', ('privacy', 'user'))
mock_self = MockSelf('public', self.local_user)
self.local_user.followers.add(self.remote_user)
with patch('bookwyrm.models.user.set_remote_server.delay'):
another_remote_user = models.User.objects.create_user(
'nutria', 'nutria@nutria.com', 'nutriaword',
local=False,
remote_id='https://example.com/users/nutria',
inbox='https://example.com/users/nutria/inbox',
outbox='https://example.com/users/nutria/outbox',
)
MockSelf = namedtuple('Self', ('privacy', 'user', 'recipients'))
mock_self = MockSelf('direct', self.local_user, [another_remote_user])
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertEqual(len(recipients), 1)
self.assertEqual(recipients[0], another_remote_user.inbox)
def test_get_recipients_combine_inboxes(self):
''' should combine users with the same shared_inbox '''
self.remote_user.shared_inbox = 'http://example.com/inbox'
self.remote_user.save(broadcast=False)
with patch('bookwyrm.models.user.set_remote_server.delay'):
another_remote_user = models.User.objects.create_user(
'nutria', 'nutria@nutria.com', 'nutriaword',
local=False,
remote_id='https://example.com/users/nutria',
inbox='https://example.com/users/nutria/inbox',
shared_inbox='http://example.com/inbox',
outbox='https://example.com/users/nutria/outbox',
)
MockSelf = namedtuple('Self', ('privacy', 'user'))
mock_self = MockSelf('public', self.local_user)
self.local_user.followers.add(self.remote_user)
self.local_user.followers.add(another_remote_user)
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertEqual(len(recipients), 1)
self.assertEqual(recipients[0], 'http://example.com/inbox')
def test_get_recipients_software(self):
''' should differentiate between bookwyrm and other remote users '''
with patch('bookwyrm.models.user.set_remote_server.delay'):
another_remote_user = models.User.objects.create_user(
'nutria', 'nutria@nutria.com', 'nutriaword',
local=False,
remote_id='https://example.com/users/nutria',
inbox='https://example.com/users/nutria/inbox',
outbox='https://example.com/users/nutria/outbox',
bookwyrm_user=False,
)
MockSelf = namedtuple('Self', ('privacy', 'user'))
mock_self = MockSelf('public', self.local_user)
self.local_user.followers.add(self.remote_user)
self.local_user.followers.add(another_remote_user)
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertEqual(len(recipients), 2)
recipients = ActivitypubMixin.get_recipients(
mock_self, software='bookwyrm')
self.assertEqual(len(recipients), 1)
self.assertEqual(recipients[0], self.remote_user.inbox)
recipients = ActivitypubMixin.get_recipients(
mock_self, software='other')
self.assertEqual(len(recipients), 1)
self.assertEqual(recipients[0], another_remote_user.inbox)
# ObjectMixin
def test_object_save_create(self):
''' should save uneventufully when broadcast is disabled '''
class Success(Exception):
''' this means we got to the right method '''
class ObjectModel(ObjectMixin, base_model.BookWyrmModel):
''' real simple mock model because BookWyrmModel is abstract '''
user = models.fields.ForeignKey('User', on_delete=db.models.CASCADE)
def save(self, *args, **kwargs):
with patch('django.db.models.Model.save'):
super().save(*args, **kwargs)
def broadcast(self, activity, sender, **kwargs):#pylint: disable=arguments-differ
''' do something '''
raise Success()
def to_create_activity(self, user):#pylint: disable=arguments-differ
return {}
with self.assertRaises(Success):
ObjectModel(user=self.local_user).save()
ObjectModel(user=self.remote_user).save()
ObjectModel(user=self.local_user).save(broadcast=False)
ObjectModel(user=None).save()
def test_object_save_update(self):
''' should save uneventufully when broadcast is disabled '''
class Success(Exception):
''' this means we got to the right method '''
class UpdateObjectModel(ObjectMixin, base_model.BookWyrmModel):
''' real simple mock model because BookWyrmModel is abstract '''
user = models.fields.ForeignKey('User', on_delete=db.models.CASCADE)
last_edited_by = models.fields.ForeignKey(
'User', on_delete=db.models.CASCADE)
def save(self, *args, **kwargs):
with patch('django.db.models.Model.save'):
super().save(*args, **kwargs)
def to_update_activity(self, user):
raise Success()
with self.assertRaises(Success):
UpdateObjectModel(id=1, user=self.local_user).save()
with self.assertRaises(Success):
UpdateObjectModel(id=1, last_edited_by=self.local_user).save()
def test_object_save_delete(self):
''' should create delete activities when objects are deleted by flag '''
class ActivitySuccess(Exception):
''' this means we got to the right method '''
class DeletableObjectModel(ObjectMixin, base_model.BookWyrmModel):
''' real simple mock model because BookWyrmModel is abstract '''
user = models.fields.ForeignKey('User', on_delete=db.models.CASCADE)
deleted = models.fields.BooleanField()
def save(self, *args, **kwargs):
with patch('django.db.models.Model.save'):
super().save(*args, **kwargs)
def to_delete_activity(self, user):
raise ActivitySuccess()
with self.assertRaises(ActivitySuccess):
DeletableObjectModel(
id=1, user=self.local_user, deleted=True).save()
def test_to_create_activity(self):
''' wrapper for ActivityPub "create" action '''
object_activity = {
'to': 'to field', 'cc': 'cc field',
'content': 'hi',
'published': '2020-12-04T17:52:22.623807+00:00',
}
MockSelf = namedtuple('Self', ('remote_id', 'to_activity'))
mock_self = MockSelf(
'https://example.com/status/1',
lambda *args: object_activity
)
activity = ObjectMixin.to_create_activity(
mock_self, self.local_user)
self.assertEqual(
activity['id'],
'https://example.com/status/1/activity'
)
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(activity['type'], 'Create')
self.assertEqual(activity['to'], 'to field')
self.assertEqual(activity['cc'], 'cc field')
self.assertEqual(activity['object'], object_activity)
self.assertEqual(
activity['signature'].creator,
'%s#main-key' % self.local_user.remote_id
)
def test_to_delete_activity(self):
''' wrapper for Delete activity '''
MockSelf = namedtuple('Self', ('remote_id', 'to_activity'))
mock_self = MockSelf(
'https://example.com/status/1',
lambda *args: {}
)
activity = ObjectMixin.to_delete_activity(
mock_self, self.local_user)
self.assertEqual(
activity['id'],
'https://example.com/status/1/activity'
)
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(activity['type'], 'Delete')
self.assertEqual(
activity['to'],
['%s/followers' % self.local_user.remote_id])
self.assertEqual(
activity['cc'],
['https://www.w3.org/ns/activitystreams#Public'])
def test_to_update_activity(self):
''' ditto above but for Update '''
MockSelf = namedtuple('Self', ('remote_id', 'to_activity'))
mock_self = MockSelf(
'https://example.com/status/1',
lambda *args: {}
)
activity = ObjectMixin.to_update_activity(
mock_self, self.local_user)
self.assertIsNotNone(
re.match(
r'^https:\/\/example\.com\/status\/1#update\/.*',
activity['id']
)
)
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(activity['type'], 'Update')
self.assertEqual(
activity['to'],
['https://www.w3.org/ns/activitystreams#Public'])
self.assertEqual(activity['object'], {})
# Activity mixin
def test_to_undo_activity(self):
''' and again, for Undo '''
MockSelf = namedtuple('Self', ('remote_id', 'to_activity', 'user'))
mock_self = MockSelf(
'https://example.com/status/1',
lambda *args: {},
self.local_user,
)
activity = ActivityMixin.to_undo_activity(mock_self)
self.assertEqual(
activity['id'],
'https://example.com/status/1#undo'
)
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(activity['type'], 'Undo')
self.assertEqual(activity['object'], {})

View file

@ -1,13 +1,8 @@
''' testing models '''
from collections import namedtuple
from dataclasses import dataclass
import re
from django.test import TestCase
from bookwyrm.activitypub.base_activity import ActivityObject
from bookwyrm import models
from bookwyrm.models import base_model
from bookwyrm.models.base_model import ActivitypubMixin
from bookwyrm.settings import DOMAIN
class BaseModel(TestCase):
@ -48,173 +43,3 @@ class BaseModel(TestCase):
instance.remote_id = None
base_model.execute_after_save(None, instance, False)
self.assertIsNone(instance.remote_id)
def test_to_create_activity(self):
''' wrapper for ActivityPub "create" action '''
user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword',
local=True, localname='mouse')
object_activity = {
'to': 'to field', 'cc': 'cc field',
'content': 'hi',
'published': '2020-12-04T17:52:22.623807+00:00',
}
MockSelf = namedtuple('Self', ('remote_id', 'to_activity'))
mock_self = MockSelf(
'https://example.com/status/1',
lambda *args: object_activity
)
activity = ActivitypubMixin.to_create_activity(mock_self, user)
self.assertEqual(
activity['id'],
'https://example.com/status/1/activity'
)
self.assertEqual(activity['actor'], user.remote_id)
self.assertEqual(activity['type'], 'Create')
self.assertEqual(activity['to'], 'to field')
self.assertEqual(activity['cc'], 'cc field')
self.assertEqual(activity['object'], object_activity)
self.assertEqual(
activity['signature'].creator,
'%s#main-key' % user.remote_id
)
def test_to_delete_activity(self):
''' wrapper for Delete activity '''
user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword',
local=True, localname='mouse')
MockSelf = namedtuple('Self', ('remote_id', 'to_activity'))
mock_self = MockSelf(
'https://example.com/status/1',
lambda *args: {}
)
activity = ActivitypubMixin.to_delete_activity(mock_self, user)
self.assertEqual(
activity['id'],
'https://example.com/status/1/activity'
)
self.assertEqual(activity['actor'], user.remote_id)
self.assertEqual(activity['type'], 'Delete')
self.assertEqual(
activity['to'],
['%s/followers' % user.remote_id])
self.assertEqual(
activity['cc'],
['https://www.w3.org/ns/activitystreams#Public'])
def test_to_update_activity(self):
''' ditto above but for Update '''
user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword',
local=True, localname='mouse')
MockSelf = namedtuple('Self', ('remote_id', 'to_activity'))
mock_self = MockSelf(
'https://example.com/status/1',
lambda *args: {}
)
activity = ActivitypubMixin.to_update_activity(mock_self, user)
self.assertIsNotNone(
re.match(
r'^https:\/\/example\.com\/status\/1#update\/.*',
activity['id']
)
)
self.assertEqual(activity['actor'], user.remote_id)
self.assertEqual(activity['type'], 'Update')
self.assertEqual(
activity['to'],
['https://www.w3.org/ns/activitystreams#Public'])
self.assertEqual(activity['object'], {})
def test_to_undo_activity(self):
''' and again, for Undo '''
user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword',
local=True, localname='mouse')
MockSelf = namedtuple('Self', ('remote_id', 'to_activity'))
mock_self = MockSelf(
'https://example.com/status/1',
lambda *args: {}
)
activity = ActivitypubMixin.to_undo_activity(mock_self, user)
self.assertEqual(
activity['id'],
'https://example.com/status/1#undo'
)
self.assertEqual(activity['actor'], user.remote_id)
self.assertEqual(activity['type'], 'Undo')
self.assertEqual(activity['object'], {})
def test_to_activity(self):
''' model to ActivityPub json '''
@dataclass(init=False)
class TestActivity(ActivityObject):
''' real simple mock '''
type: str = 'Test'
class TestModel(ActivitypubMixin, base_model.BookWyrmModel):
''' real simple mock model because BookWyrmModel is abstract '''
instance = TestModel()
instance.remote_id = 'https://www.example.com/test'
instance.activity_serializer = TestActivity
activity = instance.to_activity()
self.assertIsInstance(activity, dict)
self.assertEqual(activity['id'], 'https://www.example.com/test')
self.assertEqual(activity['type'], 'Test')
def test_find_existing_by_remote_id(self):
''' attempt to match a remote id to an object in the db '''
# uses a different remote id scheme
# this isn't really part of this test directly but it's helpful to state
book = models.Edition.objects.create(
title='Test Edition', remote_id='http://book.com/book')
user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword',
local=True, localname='mouse')
user.remote_id = 'http://example.com/a/b'
user.save()
self.assertEqual(book.origin_id, 'http://book.com/book')
self.assertNotEqual(book.remote_id, 'http://book.com/book')
# uses subclasses
models.Comment.objects.create(
user=user, content='test status', book=book, \
remote_id='https://comment.net')
result = models.User.find_existing_by_remote_id('hi')
self.assertIsNone(result)
result = models.User.find_existing_by_remote_id(
'http://example.com/a/b')
self.assertEqual(result, user)
# test using origin id
result = models.Edition.find_existing_by_remote_id(
'http://book.com/book')
self.assertEqual(result, book)
# test subclass match
result = models.Status.find_existing_by_remote_id(
'https://comment.net')
def test_find_existing(self):
''' match a blob of data to a model '''
book = models.Edition.objects.create(
title='Test edition',
openlibrary_key='OL1234',
)
result = models.Edition.find_existing(
{'openlibraryKey': 'OL1234'})
self.assertEqual(result, book)

View file

@ -19,7 +19,8 @@ from django.utils import timezone
from bookwyrm.activitypub.base_activity import ActivityObject
from bookwyrm.models import fields, User, Status
from bookwyrm.models.base_model import ActivitypubMixin, BookWyrmModel
from bookwyrm.models.base_model import BookWyrmModel
from bookwyrm.models.activitypub_mixin import ActivitypubMixin
#pylint: disable=too-many-public-methods
class ActivitypubFields(TestCase):
@ -177,7 +178,8 @@ class ActivitypubFields(TestCase):
self.assertEqual(model_instance.privacy_field, 'unlisted')
def test_privacy_field_set_activity_from_field(self):
@patch('bookwyrm.models.activitypub_mixin.ObjectMixin.broadcast')
def test_privacy_field_set_activity_from_field(self, _):
''' translate between to/cc fields and privacy '''
user = User.objects.create_user(
'rat', 'rat@rat.rat', 'ratword',
@ -194,13 +196,15 @@ class ActivitypubFields(TestCase):
self.assertEqual(activity['to'], [public])
self.assertEqual(activity['cc'], [followers])
model_instance = Status.objects.create(user=user, privacy='unlisted')
model_instance = Status.objects.create(
user=user, content='hi', privacy='unlisted')
activity = {}
instance.set_activity_from_field(activity, model_instance)
self.assertEqual(activity['to'], [followers])
self.assertEqual(activity['cc'], [public])
model_instance = Status.objects.create(user=user, privacy='followers')
model_instance = Status.objects.create(
user=user, content='hi', privacy='followers')
activity = {}
instance.set_activity_from_field(activity, model_instance)
self.assertEqual(activity['to'], [followers])
@ -208,6 +212,7 @@ class ActivitypubFields(TestCase):
model_instance = Status.objects.create(
user=user,
content='hi',
privacy='direct',
)
model_instance.mention_users.set([user])
@ -289,12 +294,13 @@ class ActivitypubFields(TestCase):
'mouse', 'mouse@mouse.mouse', 'mouseword',
local=True, localname='mouse')
user.remote_id = 'https://example.com/user/mouse'
user.save()
user.save(broadcast=False)
User.objects.create_user(
'rat', 'rat@rat.rat', 'ratword',
local=True, localname='rat')
value = instance.field_from_activity(userdata)
with patch('bookwyrm.models.activitypub_mixin.ObjectMixin.broadcast'):
value = instance.field_from_activity(userdata)
self.assertEqual(value, user)
@ -393,7 +399,8 @@ class ActivitypubFields(TestCase):
@responses.activate
def test_image_field(self):
@patch('bookwyrm.models.activitypub_mixin.ObjectMixin.broadcast')
def test_image_field(self, _):
''' storing images '''
user = User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword',

View file

@ -1,9 +1,11 @@
''' testing models '''
from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models, settings
@patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay')
class List(TestCase):
''' some activitypub oddness ahead '''
def setUp(self):
@ -11,17 +13,18 @@ class List(TestCase):
self.user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword',
local=True, localname='mouse')
self.list = models.List.objects.create(
name='Test List', user=self.user)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
self.list = models.List.objects.create(
name='Test List', user=self.user)
def test_remote_id(self):
def test_remote_id(self, _):
''' shelves use custom remote ids '''
expected_id = 'https://%s/list/%d' % \
(settings.DOMAIN, self.list.id)
self.assertEqual(self.list.get_remote_id(), expected_id)
def test_to_activity(self):
def test_to_activity(self, _):
''' jsonify it '''
activity_json = self.list.to_activity()
self.assertIsInstance(activity_json, dict)
@ -31,24 +34,24 @@ class List(TestCase):
self.assertEqual(activity_json['name'], 'Test List')
self.assertEqual(activity_json['owner'], self.user.remote_id)
def test_list_item(self):
def test_list_item(self, _):
''' a list entry '''
work = models.Work.objects.create(title='hello')
book = models.Edition.objects.create(title='hi', parent_work=work)
item = models.ListItem.objects.create(
book_list=self.list,
book=book,
added_by=self.user,
user=self.user,
)
self.assertTrue(item.approved)
add_activity = item.to_add_activity(self.user)
add_activity = item.to_add_activity()
self.assertEqual(add_activity['actor'], self.user.remote_id)
self.assertEqual(add_activity['object']['id'], book.remote_id)
self.assertEqual(add_activity['target'], self.list.remote_id)
remove_activity = item.to_remove_activity(self.user)
remove_activity = item.to_remove_activity()
self.assertEqual(remove_activity['actor'], self.user.remote_id)
self.assertEqual(remove_activity['object']['id'], book.remote_id)
self.assertEqual(remove_activity['target'], self.list.remote_id)

View file

@ -6,7 +6,9 @@ from bookwyrm import models
class Relationship(TestCase):
''' following, blocking, stuff like that '''
def setUp(self):
''' we need some users for this '''
with patch('bookwyrm.models.user.set_remote_server.delay'):
self.remote_user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword',
@ -19,68 +21,31 @@ class Relationship(TestCase):
'mouse', 'mouse@mouse.com', 'mouseword',
local=True, localname='mouse')
self.local_user.remote_id = 'http://local.com/user/mouse'
self.local_user.save()
self.local_user.save(broadcast=False)
def test_user_follows(self):
rel = models.UserFollows.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
self.assertEqual(
rel.remote_id,
'http://local.com/user/mouse#follows/%d' % rel.id
)
''' create a follow relationship '''
with patch('bookwyrm.models.activitypub_mixin.ActivityMixin.broadcast'):
rel = models.UserFollows.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
activity = rel.to_activity()
self.assertEqual(activity['id'], rel.remote_id)
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(activity['object'], self.remote_user.remote_id)
def test_user_follow_accept_serialization(self):
rel = models.UserFollows.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
self.assertEqual(
rel.remote_id,
'http://local.com/user/mouse#follows/%d' % rel.id
)
accept = rel.to_accept_activity()
self.assertEqual(accept['type'], 'Accept')
self.assertEqual(
accept['id'],
'http://local.com/user/mouse#accepts/%d' % rel.id
)
self.assertEqual(accept['actor'], self.remote_user.remote_id)
self.assertEqual(accept['object']['id'], rel.remote_id)
self.assertEqual(accept['object']['actor'], self.local_user.remote_id)
self.assertEqual(accept['object']['object'], self.remote_user.remote_id)
def test_user_follow_reject_serialization(self):
rel = models.UserFollows.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
self.assertEqual(
rel.remote_id,
'http://local.com/user/mouse#follows/%d' % rel.id
)
reject = rel.to_reject_activity()
self.assertEqual(reject['type'], 'Reject')
self.assertEqual(
reject['id'],
'http://local.com/user/mouse#rejects/%d' % rel.id
)
self.assertEqual(reject['actor'], self.remote_user.remote_id)
self.assertEqual(reject['object']['id'], rel.remote_id)
self.assertEqual(reject['object']['actor'], self.local_user.remote_id)
self.assertEqual(reject['object']['object'], self.remote_user.remote_id)
def test_user_follows_from_request(self):
''' convert a follow request into a follow '''
real_broadcast = models.UserFollowRequest.broadcast
def mock_broadcast(_, activity, user):
''' introspect what's being sent out '''
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity['type'], 'Follow')
models.UserFollowRequest.broadcast = mock_broadcast
request = models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
@ -99,14 +64,18 @@ class Relationship(TestCase):
self.assertEqual(rel.status, 'follows')
self.assertEqual(rel.user_subject, self.local_user)
self.assertEqual(rel.user_object, self.remote_user)
models.UserFollowRequest.broadcast = real_broadcast
def test_user_follows_from_request_custom_remote_id(self):
request = models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user,
remote_id='http://antoher.server/sdkfhskdjf/23'
)
''' store a specific remote id for a relationship provided by remote '''
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
request = models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user,
remote_id='http://antoher.server/sdkfhskdjf/23'
)
self.assertEqual(
request.remote_id,
'http://antoher.server/sdkfhskdjf/23'
@ -121,3 +90,67 @@ class Relationship(TestCase):
self.assertEqual(rel.status, 'follows')
self.assertEqual(rel.user_subject, self.local_user)
self.assertEqual(rel.user_object, self.remote_user)
def test_follow_request_activity(self):
''' accept a request and make it a relationship '''
real_broadcast = models.UserFollowRequest.broadcast
def mock_broadcast(_, activity, user):
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(activity['object'], self.remote_user.remote_id)
self.assertEqual(activity['type'], 'Follow')
models.UserFollowRequest.broadcast = mock_broadcast
models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user,
)
models.UserFollowRequest.broadcast = real_broadcast
def test_follow_request_accept(self):
''' accept a request and make it a relationship '''
real_broadcast = models.UserFollowRequest.broadcast
def mock_broadcast(_, activity, user):
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity['type'], 'Accept')
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(
activity['object']['id'], request.remote_id)
models.UserFollowRequest.broadcast = mock_broadcast
request = models.UserFollowRequest.objects.create(
user_subject=self.remote_user,
user_object=self.local_user,
)
request.accept()
self.assertFalse(models.UserFollowRequest.objects.exists())
self.assertTrue(models.UserFollows.objects.exists())
rel = models.UserFollows.objects.get()
self.assertEqual(rel.user_subject, self.remote_user)
self.assertEqual(rel.user_object, self.local_user)
models.UserFollowRequest.broadcast = real_broadcast
def test_follow_request_reject(self):
''' accept a request and make it a relationship '''
real_broadcast = models.UserFollowRequest.broadcast
def mock_reject(_, activity, user):
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity['type'], 'Reject')
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(
activity['object']['id'], request.remote_id)
models.UserFollowRequest.broadcast = mock_reject
request = models.UserFollowRequest.objects.create(
user_subject=self.remote_user,
user_object=self.local_user,
)
request.reject()
self.assertFalse(models.UserFollowRequest.objects.exists())
self.assertFalse(models.UserFollows.objects.exists())
models.UserFollowRequest.broadcast = real_broadcast

View file

@ -8,24 +8,113 @@ class Shelf(TestCase):
''' some activitypub oddness ahead '''
def setUp(self):
''' look, a shelf '''
self.user = models.User.objects.create_user(
self.local_user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword',
local=True, localname='mouse')
self.shelf = models.Shelf.objects.create(
name='Test Shelf', identifier='test-shelf', user=self.user)
work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='test book',
parent_work=work)
def test_remote_id(self):
''' shelves use custom remote ids '''
real_broadcast = models.Shelf.broadcast
def broadcast_mock(_, activity, user, **kwargs):
''' nah '''
models.Shelf.broadcast = broadcast_mock
shelf = models.Shelf.objects.create(
name='Test Shelf', identifier='test-shelf',
user=self.local_user)
expected_id = 'https://%s/user/mouse/shelf/test-shelf' % settings.DOMAIN
self.assertEqual(self.shelf.get_remote_id(), expected_id)
self.assertEqual(shelf.get_remote_id(), expected_id)
models.Shelf.broadcast = real_broadcast
def test_to_activity(self):
''' jsonify it '''
activity_json = self.shelf.to_activity()
real_broadcast = models.Shelf.broadcast
def empty_mock(_, activity, user, **kwargs):
''' nah '''
models.Shelf.broadcast = empty_mock
shelf = models.Shelf.objects.create(
name='Test Shelf', identifier='test-shelf',
user=self.local_user)
activity_json = shelf.to_activity()
self.assertIsInstance(activity_json, dict)
self.assertEqual(activity_json['id'], self.shelf.remote_id)
self.assertEqual(activity_json['id'], shelf.remote_id)
self.assertEqual(activity_json['totalItems'], 0)
self.assertEqual(activity_json['type'], 'Shelf')
self.assertEqual(activity_json['name'], 'Test Shelf')
self.assertEqual(activity_json['owner'], self.user.remote_id)
self.assertEqual(activity_json['owner'], self.local_user.remote_id)
models.Shelf.broadcast = real_broadcast
def test_create_update_shelf(self):
''' create and broadcast shelf creation '''
real_broadcast = models.Shelf.broadcast
def create_mock(_, activity, user, **kwargs):
''' ok '''
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity['type'], 'Create')
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(activity['object']['name'], 'Test Shelf')
models.Shelf.broadcast = create_mock
shelf = models.Shelf.objects.create(
name='Test Shelf', identifier='test-shelf', user=self.local_user)
def update_mock(_, activity, user, **kwargs):
''' ok '''
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity['type'], 'Update')
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(activity['object']['name'], 'arthur russel')
models.Shelf.broadcast = update_mock
shelf.name = 'arthur russel'
shelf.save()
self.assertEqual(shelf.name, 'arthur russel')
models.Shelf.broadcast = real_broadcast
def test_shelve(self):
''' create and broadcast shelf creation '''
real_broadcast = models.Shelf.broadcast
real_shelfbook_broadcast = models.ShelfBook.broadcast
def add_mock(_, activity, user, **kwargs):
''' ok '''
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity['type'], 'Add')
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(activity['object']['id'], self.book.remote_id)
self.assertEqual(activity['target'], shelf.remote_id)
def remove_mock(_, activity, user, **kwargs):
''' ok '''
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity['type'], 'Remove')
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(activity['object']['id'], self.book.remote_id)
self.assertEqual(activity['target'], shelf.remote_id)
def empty_mock(_, activity, user, **kwargs):
''' nah '''
models.Shelf.broadcast = empty_mock
shelf = models.Shelf.objects.create(
name='Test Shelf', identifier='test-shelf', user=self.local_user)
models.ShelfBook.broadcast = add_mock
shelf_book = models.ShelfBook.objects.create(
shelf=shelf,
user=self.local_user,
book=self.book)
self.assertEqual(shelf.books.first(), self.book)
models.ShelfBook.broadcast = remove_mock
shelf_book.delete()
self.assertFalse(shelf.books.exists())
models.ShelfBook.broadcast = real_shelfbook_broadcast
models.Shelf.broadcast = real_broadcast

View file

@ -1,4 +1,5 @@
''' testing models '''
from unittest.mock import patch
from io import BytesIO
import pathlib
@ -11,6 +12,7 @@ from django.utils import timezone
from bookwyrm import models, settings
@patch('bookwyrm.models.Status.broadcast')
class Status(TestCase):
''' lotta types of statuses '''
def setUp(self):
@ -24,13 +26,14 @@ class Status(TestCase):
'../../static/images/default_avi.jpg')
image = Image.open(image_file)
output = BytesIO()
image.save(output, format=image.format)
self.book.cover.save(
'test.jpg',
ContentFile(output.getvalue())
)
with patch('bookwyrm.models.Status.broadcast'):
image.save(output, format=image.format)
self.book.cover.save(
'test.jpg',
ContentFile(output.getvalue())
)
def test_status_generated_fields(self):
def test_status_generated_fields(self, _):
''' setting remote id '''
status = models.Status.objects.create(content='bleh', user=self.user)
expected_id = 'https://%s/user/mouse/status/%d' % \
@ -38,7 +41,7 @@ class Status(TestCase):
self.assertEqual(status.remote_id, expected_id)
self.assertEqual(status.privacy, 'public')
def test_replies(self):
def test_replies(self, _):
''' get a list of replies '''
parent = models.Status.objects.create(content='hi', user=self.user)
child = models.Status.objects.create(
@ -54,7 +57,7 @@ class Status(TestCase):
# should select subclasses
self.assertIsInstance(replies.last(), models.Review)
def test_status_type(self):
def test_status_type(self, _):
''' class name '''
self.assertEqual(models.Status().status_type, 'Note')
self.assertEqual(models.Review().status_type, 'Review')
@ -62,14 +65,14 @@ class Status(TestCase):
self.assertEqual(models.Comment().status_type, 'Comment')
self.assertEqual(models.Boost().status_type, 'Boost')
def test_boostable(self):
def test_boostable(self, _):
''' can a status be boosted, based on privacy '''
self.assertTrue(models.Status(privacy='public').boostable)
self.assertTrue(models.Status(privacy='unlisted').boostable)
self.assertFalse(models.Status(privacy='followers').boostable)
self.assertFalse(models.Status(privacy='direct').boostable)
def test_to_replies(self):
def test_to_replies(self, _):
''' activitypub replies collection '''
parent = models.Status.objects.create(content='hi', user=self.user)
child = models.Status.objects.create(
@ -83,7 +86,7 @@ class Status(TestCase):
self.assertEqual(replies['id'], '%s/replies' % parent.remote_id)
self.assertEqual(replies['totalItems'], 2)
def test_status_to_activity(self):
def test_status_to_activity(self, _):
''' subclass of the base model version with a "pure" serializer '''
status = models.Status.objects.create(
content='test content', user=self.user)
@ -93,7 +96,7 @@ class Status(TestCase):
self.assertEqual(activity['content'], 'test content')
self.assertEqual(activity['sensitive'], False)
def test_status_to_activity_tombstone(self):
def test_status_to_activity_tombstone(self, _):
''' subclass of the base model version with a "pure" serializer '''
status = models.Status.objects.create(
content='test content', user=self.user,
@ -103,7 +106,7 @@ class Status(TestCase):
self.assertEqual(activity['type'], 'Tombstone')
self.assertFalse(hasattr(activity, 'content'))
def test_status_to_pure_activity(self):
def test_status_to_pure_activity(self, _):
''' subclass of the base model version with a "pure" serializer '''
status = models.Status.objects.create(
content='test content', user=self.user)
@ -114,7 +117,7 @@ class Status(TestCase):
self.assertEqual(activity['sensitive'], False)
self.assertEqual(activity['attachment'], [])
def test_generated_note_to_activity(self):
def test_generated_note_to_activity(self, _):
''' subclass of the base model version with a "pure" serializer '''
status = models.GeneratedNote.objects.create(
content='test content', user=self.user)
@ -127,7 +130,7 @@ class Status(TestCase):
self.assertEqual(activity['sensitive'], False)
self.assertEqual(len(activity['tag']), 2)
def test_generated_note_to_pure_activity(self):
def test_generated_note_to_pure_activity(self, _):
''' subclass of the base model version with a "pure" serializer '''
status = models.GeneratedNote.objects.create(
content='test content', user=self.user)
@ -149,7 +152,7 @@ class Status(TestCase):
self.assertEqual(
activity['attachment'][0].name, 'Test Edition cover')
def test_comment_to_activity(self):
def test_comment_to_activity(self, _):
''' subclass of the base model version with a "pure" serializer '''
status = models.Comment.objects.create(
content='test content', user=self.user, book=self.book)
@ -159,7 +162,7 @@ class Status(TestCase):
self.assertEqual(activity['content'], 'test content')
self.assertEqual(activity['inReplyToBook'], self.book.remote_id)
def test_comment_to_pure_activity(self):
def test_comment_to_pure_activity(self, _):
''' subclass of the base model version with a "pure" serializer '''
status = models.Comment.objects.create(
content='test content', user=self.user, book=self.book)
@ -176,7 +179,7 @@ class Status(TestCase):
self.assertEqual(
activity['attachment'][0].name, 'Test Edition cover')
def test_quotation_to_activity(self):
def test_quotation_to_activity(self, _):
''' subclass of the base model version with a "pure" serializer '''
status = models.Quotation.objects.create(
quote='a sickening sense', content='test content',
@ -188,7 +191,7 @@ class Status(TestCase):
self.assertEqual(activity['content'], 'test content')
self.assertEqual(activity['inReplyToBook'], self.book.remote_id)
def test_quotation_to_pure_activity(self):
def test_quotation_to_pure_activity(self, _):
''' subclass of the base model version with a "pure" serializer '''
status = models.Quotation.objects.create(
quote='a sickening sense', content='test content',
@ -206,7 +209,7 @@ class Status(TestCase):
self.assertEqual(
activity['attachment'][0].name, 'Test Edition cover')
def test_review_to_activity(self):
def test_review_to_activity(self, _):
''' subclass of the base model version with a "pure" serializer '''
status = models.Review.objects.create(
name='Review name', content='test content', rating=3,
@ -219,7 +222,7 @@ class Status(TestCase):
self.assertEqual(activity['content'], 'test content')
self.assertEqual(activity['inReplyToBook'], self.book.remote_id)
def test_review_to_pure_activity(self):
def test_review_to_pure_activity(self, _):
''' subclass of the base model version with a "pure" serializer '''
status = models.Review.objects.create(
name='Review name', content='test content', rating=3,
@ -237,8 +240,15 @@ class Status(TestCase):
self.assertEqual(
activity['attachment'][0].name, 'Test Edition cover')
def test_favorite(self):
def test_favorite(self, _):
''' fav a status '''
real_broadcast = models.Favorite.broadcast
def fav_broadcast_mock(_, activity, user):
''' ok '''
self.assertEqual(user.remote_id, self.user.remote_id)
self.assertEqual(activity['type'], 'Like')
models.Favorite.broadcast = fav_broadcast_mock
status = models.Status.objects.create(
content='test content', user=self.user)
fav = models.Favorite.objects.create(status=status, user=self.user)
@ -251,8 +261,9 @@ class Status(TestCase):
self.assertEqual(activity['type'], 'Like')
self.assertEqual(activity['actor'], self.user.remote_id)
self.assertEqual(activity['object'], status.remote_id)
models.Favorite.broadcast = real_broadcast
def test_boost(self):
def test_boost(self, _):
''' boosting, this one's a bit fussy '''
status = models.Status.objects.create(
content='test content', user=self.user)
@ -264,7 +275,7 @@ class Status(TestCase):
self.assertEqual(activity['type'], 'Announce')
self.assertEqual(activity, boost.to_activity(pure=True))
def test_notification(self):
def test_notification(self, _):
''' a simple model '''
notification = models.Notification.objects.create(
user=self.user, notification_type='FAVORITE')

View file

@ -1,80 +0,0 @@
from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models, broadcast
class Book(TestCase):
def setUp(self):
self.user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword',
local=True, localname='mouse')
local_follower = models.User.objects.create_user(
'joe', 'joe@mouse.mouse', 'jeoword',
local=True, localname='joe')
self.user.followers.add(local_follower)
with patch('bookwyrm.models.user.set_remote_server.delay'):
follower = models.User.objects.create_user(
'rat', 'rat@mouse.mouse', 'ratword', local=False,
remote_id='http://example.com/u/1',
outbox='http://example.com/u/1/o',
shared_inbox='http://example.com/inbox',
inbox='http://example.com/u/1/inbox')
self.user.followers.add(follower)
no_inbox_follower = models.User.objects.create_user(
'hamster', 'hamster@mouse.mouse', 'hamword',
shared_inbox=None, local=False,
remote_id='http://example.com/u/2',
outbox='http://example.com/u/2/o',
inbox='http://example.com/u/2/inbox')
self.user.followers.add(no_inbox_follower)
non_bw_follower = models.User.objects.create_user(
'gerbil', 'gerb@mouse.mouse', 'gerbword',
remote_id='http://example.com/u/3',
outbox='http://example2.com/u/3/o',
inbox='http://example2.com/u/3/inbox',
shared_inbox='http://example2.com/inbox',
bookwyrm_user=False, local=False)
self.user.followers.add(non_bw_follower)
models.User.objects.create_user(
'nutria', 'nutria@mouse.mouse', 'nuword',
remote_id='http://example.com/u/4',
outbox='http://example.com/u/4/o',
shared_inbox='http://example.com/inbox',
inbox='http://example.com/u/4/inbox',
local=False)
def test_get_public_recipients(self):
expected = [
'http://example2.com/inbox',
'http://example.com/inbox',
'http://example.com/u/2/inbox',
]
recipients = broadcast.get_public_recipients(self.user)
self.assertEqual(recipients, expected)
def test_get_public_recipients_software(self):
expected = [
'http://example.com/inbox',
'http://example.com/u/2/inbox',
]
recipients = broadcast.get_public_recipients(self.user, software='bookwyrm')
self.assertEqual(recipients, expected)
def test_get_public_recipients_software_other(self):
expected = [
'http://example2.com/inbox',
]
recipients = broadcast.get_public_recipients(self.user, software='mastodon')
self.assertEqual(recipients, expected)

View file

@ -124,7 +124,7 @@ class GoodreadsImport(TestCase):
job_id=import_job.id, index=index, data=entry, book=self.book)
break
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
goodreads_import.handle_imported_book(
self.user, import_item, False, 'public')
@ -144,9 +144,10 @@ class GoodreadsImport(TestCase):
def test_handle_imported_book_already_shelved(self):
''' goodreads import added a book, this adds related connections '''
shelf = self.user.shelf_set.filter(identifier='to-read').first()
models.ShelfBook.objects.create(
shelf=shelf, added_by=self.user, book=self.book)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
shelf = self.user.shelf_set.filter(identifier='to-read').first()
models.ShelfBook.objects.create(
shelf=shelf, user=self.user, book=self.book)
import_job = models.ImportJob.objects.create(user=self.user)
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
@ -156,7 +157,7 @@ class GoodreadsImport(TestCase):
job_id=import_job.id, index=index, data=entry, book=self.book)
break
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
goodreads_import.handle_imported_book(
self.user, import_item, False, 'public')
@ -185,7 +186,7 @@ class GoodreadsImport(TestCase):
job_id=import_job.id, index=index, data=entry, book=self.book)
break
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
goodreads_import.handle_imported_book(
self.user, import_item, False, 'public')
goodreads_import.handle_imported_book(
@ -214,7 +215,7 @@ class GoodreadsImport(TestCase):
import_item = models.ImportItem.objects.create(
job_id=import_job.id, index=0, data=entry, book=self.book)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
goodreads_import.handle_imported_book(
self.user, import_item, True, 'unlisted')
review = models.Review.objects.get(book=self.book, user=self.user)
@ -235,7 +236,7 @@ class GoodreadsImport(TestCase):
import_item = models.ImportItem.objects.create(
job_id=import_job.id, index=0, data=entry, book=self.book)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
goodreads_import.handle_imported_book(
self.user, import_item, False, 'unlisted')
self.assertFalse(models.Review.objects.filter(

View file

@ -22,7 +22,7 @@ class Incoming(TestCase):
'mouse@example.com', 'mouse@mouse.com', 'mouseword',
local=True, localname='mouse')
self.local_user.remote_id = 'https://example.com/user/mouse'
self.local_user.save()
self.local_user.save(broadcast=False)
with patch('bookwyrm.models.user.set_remote_server.delay'):
self.remote_user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword',
@ -31,11 +31,12 @@ class Incoming(TestCase):
inbox='https://example.com/users/rat/inbox',
outbox='https://example.com/users/rat/outbox',
)
self.status = models.Status.objects.create(
user=self.local_user,
content='Test status',
remote_id='https://example.com/status/1',
)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
self.status = models.Status.objects.create(
user=self.local_user,
content='Test status',
remote_id='https://example.com/status/1',
)
self.factory = RequestFactory()
@ -117,7 +118,7 @@ class Incoming(TestCase):
"object": "https://example.com/user/mouse"
}
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
incoming.handle_follow(activity)
# notification created
@ -145,9 +146,9 @@ class Incoming(TestCase):
}
self.local_user.manually_approves_followers = True
self.local_user.save()
self.local_user.save(broadcast=False)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
incoming.handle_follow(activity)
# notification created
@ -177,8 +178,9 @@ class Incoming(TestCase):
"object": "https://example.com/user/mouse"
}
}
models.UserFollows.objects.create(
user_subject=self.remote_user, user_object=self.local_user)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.UserFollows.objects.create(
user_subject=self.remote_user, user_object=self.local_user)
self.assertEqual(self.remote_user, self.local_user.followers.first())
incoming.handle_unfollow(activity)
@ -200,10 +202,11 @@ class Incoming(TestCase):
}
}
models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
self.assertEqual(models.UserFollowRequest.objects.count(), 1)
incoming.handle_follow_accept(activity)
@ -232,10 +235,11 @@ class Incoming(TestCase):
}
}
models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
self.assertEqual(models.UserFollowRequest.objects.count(), 1)
incoming.handle_follow_reject(activity)
@ -280,9 +284,10 @@ class Incoming(TestCase):
def test_handle_update_list(self):
''' a new list '''
book_list = models.List.objects.create(
name='hi', remote_id='https://example.com/list/22',
user=self.local_user)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
book_list = models.List.objects.create(
name='hi', remote_id='https://example.com/list/22',
user=self.local_user)
activity = {
'object': {
"id": "https://example.com/list/22",
@ -387,11 +392,14 @@ class Incoming(TestCase):
def test_handle_delete_status(self):
''' remove a status '''
self.status.user = self.remote_user
self.status.save(broadcast=False)
self.assertFalse(self.status.deleted)
activity = {
'type': 'Delete',
'id': '%s/activity' % self.status.remote_id,
'actor': self.local_user.remote_id,
'actor': self.remote_user.remote_id,
'object': {'id': self.status.remote_id},
}
incoming.handle_delete_status(activity)
@ -403,6 +411,8 @@ class Incoming(TestCase):
def test_handle_delete_status_notifications(self):
''' remove a status with related notifications '''
self.status.user = self.remote_user
self.status.save(broadcast=False)
models.Notification.objects.create(
related_status=self.status,
user=self.local_user,
@ -418,7 +428,7 @@ class Incoming(TestCase):
activity = {
'type': 'Delete',
'id': '%s/activity' % self.status.remote_id,
'actor': self.local_user.remote_id,
'actor': self.remote_user.remote_id,
'object': {'id': self.status.remote_id},
}
incoming.handle_delete_status(activity)
@ -510,7 +520,6 @@ class Incoming(TestCase):
self.assertEqual(models.Boost.objects.count(), 0)
def test_handle_unboost(self):
''' undo a boost '''
activity = {
@ -539,7 +548,7 @@ class Incoming(TestCase):
activity = {
"id": "https://bookwyrm.social/shelfbook/6189#add",
"type": "Add",
"actor": "hhttps://example.com/users/rat",
"actor": "https://example.com/users/rat",
"object": "https://bookwyrm.social/book/37292",
"target": "https://bookwyrm.social/user/mouse/shelf/to-read",
"@context": "https://www.w3.org/ns/activitystreams"
@ -608,9 +617,10 @@ class Incoming(TestCase):
def test_handle_blocks(self):
''' create a "block" database entry from an activity '''
self.local_user.followers.add(self.remote_user)
models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user)
self.assertTrue(models.UserFollows.objects.exists())
self.assertTrue(models.UserFollowRequest.objects.exists())

View file

@ -35,8 +35,9 @@ class TemplateTags(TestCase):
def test_get_user_rating(self):
''' get a user's most recent rating of a book '''
models.Review.objects.create(
user=self.user, book=self.book, rating=3)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.Review.objects.create(
user=self.user, book=self.book, rating=3)
self.assertEqual(
bookwyrm_tags.get_user_rating(self.book, self.user), 3)
@ -64,9 +65,9 @@ class TemplateTags(TestCase):
self.assertEqual(bookwyrm_tags.get_notification_count(self.user), 0)
models.Notification.objects.create(
user=self.user, notification_type='FOLLOW')
user=self.user, notification_type='FAVORITE')
models.Notification.objects.create(
user=self.user, notification_type='FOLLOW')
user=self.user, notification_type='MENTION')
models.Notification.objects.create(
user=self.remote_user, notification_type='FOLLOW')
@ -76,14 +77,16 @@ class TemplateTags(TestCase):
def test_get_replies(self):
''' direct replies to a status '''
parent = models.Review.objects.create(
user=self.user, book=self.book)
first_child = models.Status.objects.create(
reply_parent=parent, user=self.user)
second_child = models.Status.objects.create(
reply_parent=parent, user=self.user)
third_child = models.Status.objects.create(
reply_parent=parent, user=self.user, deleted=True)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
parent = models.Review.objects.create(
user=self.user, book=self.book, content='hi')
first_child = models.Status.objects.create(
reply_parent=parent, user=self.user, content='hi')
second_child = models.Status.objects.create(
reply_parent=parent, user=self.user, content='hi')
third_child = models.Status.objects.create(
reply_parent=parent, user=self.user,
deleted=True, deleted_date=timezone.now())
replies = bookwyrm_tags.get_replies(parent)
self.assertEqual(len(replies), 2)
@ -94,10 +97,11 @@ class TemplateTags(TestCase):
def test_get_parent(self):
''' get the reply parent of a status '''
parent = models.Review.objects.create(
user=self.user, book=self.book)
child = models.Status.objects.create(
reply_parent=parent, user=self.user)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
parent = models.Review.objects.create(
user=self.user, book=self.book, content='hi')
child = models.Status.objects.create(
reply_parent=parent, user=self.user, content='hi')
result = bookwyrm_tags.get_parent(child)
self.assertEqual(result, parent)
@ -110,10 +114,11 @@ class TemplateTags(TestCase):
user=self.remote_user, book=self.book)
self.assertFalse(bookwyrm_tags.get_user_liked(self.user, status))
models.Favorite.objects.create(
user=self.user,
status=status
)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.Favorite.objects.create(
user=self.user,
status=status
)
self.assertTrue(bookwyrm_tags.get_user_liked(self.user, status))
@ -123,10 +128,11 @@ class TemplateTags(TestCase):
user=self.remote_user, book=self.book)
self.assertFalse(bookwyrm_tags.get_user_boosted(self.user, status))
models.Boost.objects.create(
user=self.user,
boosted_status=status
)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.Boost.objects.create(
user=self.user,
boosted_status=status
)
self.assertTrue(bookwyrm_tags.get_user_boosted(self.user, status))
@ -135,9 +141,10 @@ class TemplateTags(TestCase):
self.assertFalse(
bookwyrm_tags.follow_request_exists(self.user, self.remote_user))
models.UserFollowRequest.objects.create(
user_subject=self.user,
user_object=self.remote_user)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.UserFollowRequest.objects.create(
user_subject=self.user,
user_object=self.remote_user)
self.assertFalse(
bookwyrm_tags.follow_request_exists(self.user, self.remote_user))
@ -147,12 +154,13 @@ class TemplateTags(TestCase):
def test_get_boosted(self):
''' load a boosted status '''
status = models.Review.objects.create(
user=self.remote_user, book=self.book)
boost = models.Boost.objects.create(
user=self.user,
boosted_status=status
)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
status = models.Review.objects.create(
user=self.remote_user, book=self.book)
boost = models.Boost.objects.create(
user=self.user,
boosted_status=status
)
boosted = bookwyrm_tags.get_boosted(boost)
self.assertIsInstance(boosted, models.Review)
self.assertEqual(boosted, status)
@ -233,29 +241,31 @@ class TemplateTags(TestCase):
def test_get_status_preview_name(self):
''' status context string '''
status = models.Status.objects.create(content='hi', user=self.user)
result = bookwyrm_tags.get_status_preview_name(status)
self.assertEqual(result, 'status')
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
status = models.Status.objects.create(content='hi', user=self.user)
result = bookwyrm_tags.get_status_preview_name(status)
self.assertEqual(result, 'status')
status = models.Review.objects.create(
content='hi', user=self.user, book=self.book)
result = bookwyrm_tags.get_status_preview_name(status)
self.assertEqual(result, 'review of <em>Test Book</em>')
status = models.Review.objects.create(
content='hi', user=self.user, book=self.book)
result = bookwyrm_tags.get_status_preview_name(status)
self.assertEqual(result, 'review of <em>Test Book</em>')
status = models.Comment.objects.create(
content='hi', user=self.user, book=self.book)
result = bookwyrm_tags.get_status_preview_name(status)
self.assertEqual(result, 'comment on <em>Test Book</em>')
status = models.Comment.objects.create(
content='hi', user=self.user, book=self.book)
result = bookwyrm_tags.get_status_preview_name(status)
self.assertEqual(result, 'comment on <em>Test Book</em>')
status = models.Quotation.objects.create(
content='hi', user=self.user, book=self.book)
status = models.Quotation.objects.create(
content='hi', user=self.user, book=self.book)
result = bookwyrm_tags.get_status_preview_name(status)
self.assertEqual(result, 'quotation from <em>Test Book</em>')
def test_related_status(self):
''' gets the subclass model for a notification status '''
status = models.Status.objects.create(content='hi', user=self.user)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
status = models.Status.objects.create(content='hi', user=self.user)
notification = models.Notification.objects.create(
user=self.user, notification_type='MENTION',
related_status=status)

View file

@ -84,7 +84,7 @@ class AuthorViews(TestCase):
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
view(request, author.id)
author.refresh_from_db()
self.assertEqual(author.name, 'New Name')

View file

@ -40,15 +40,16 @@ class BlockViews(TestCase):
''' create a "block" database entry from an activity '''
view = views.Block.as_view()
self.local_user.followers.add(self.remote_user)
models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user)
self.assertTrue(models.UserFollows.objects.exists())
self.assertTrue(models.UserFollowRequest.objects.exists())
request = self.factory.post('')
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
view(request, self.remote_user.id)
block = models.UserBlocks.objects.get()
self.assertEqual(block.user_subject, self.local_user)
@ -63,7 +64,7 @@ class BlockViews(TestCase):
request = self.factory.post('')
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.block.unblock(request, self.remote_user.id)
self.assertFalse(models.UserBlocks.objects.exists())

View file

@ -77,7 +77,7 @@ class BookViews(TestCase):
form.data['last_edited_by'] = self.local_user.id
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
view(request, self.book.id)
self.book.refresh_from_db()
self.assertEqual(self.book.title, 'New Title')
@ -90,9 +90,14 @@ class BookViews(TestCase):
title='first ed', parent_work=work)
edition2 = models.Edition.objects.create(
title='second ed', parent_work=work)
shelf = models.Shelf.objects.create(
name='Test Shelf', user=self.local_user)
shelf.books.add(edition1)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
shelf = models.Shelf.objects.create(
name='Test Shelf', user=self.local_user)
models.ShelfBook.objects.create(
book=edition1,
user=self.local_user,
shelf=shelf,
)
models.ReadThrough.objects.create(
user=self.local_user, book=edition1)
@ -102,7 +107,7 @@ class BookViews(TestCase):
'edition': edition2.id
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.switch_edition(request)
self.assertEqual(models.ShelfBook.objects.get().book, edition2)

View file

@ -18,6 +18,7 @@ class FeedMessageViews(TestCase):
'mouse@local.com', 'mouse@mouse.mouse', 'password',
local=True, localname='mouse')
self.book = models.Edition.objects.create(
parent_work=models.Work.objects.create(title='hi'),
title='Example Edition',
remote_id='https://example.com/book/1',
)
@ -38,8 +39,9 @@ class FeedMessageViews(TestCase):
def test_status_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Status.as_view()
status = models.Status.objects.create(
content='hi', user=self.local_user)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
status = models.Status.objects.create(
content='hi', user=self.local_user)
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.feed.is_api_request') as is_api:
@ -59,8 +61,9 @@ class FeedMessageViews(TestCase):
def test_replies_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Replies.as_view()
status = models.Status.objects.create(
content='hi', user=self.local_user)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
status = models.Status.objects.create(
content='hi', user=self.local_user)
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.feed.is_api_request') as is_api:
@ -90,11 +93,12 @@ class FeedMessageViews(TestCase):
def test_get_suggested_book(self):
''' gets books the ~*~ algorithm ~*~ thinks you want to post about '''
models.ShelfBook.objects.create(
book=self.book,
added_by=self.local_user,
shelf=self.local_user.shelf_set.get(identifier='reading')
)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.ShelfBook.objects.create(
book=self.book,
user=self.local_user,
shelf=self.local_user.shelf_set.get(identifier='reading')
)
suggestions = views.feed.get_suggested_books(self.local_user)
self.assertEqual(suggestions[0]['name'], 'Currently Reading')
self.assertEqual(suggestions[0]['books'][0], self.book)

View file

@ -46,7 +46,7 @@ class BookViews(TestCase):
request.user = self.local_user
self.assertEqual(models.UserFollowRequest.objects.count(), 0)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.follow(request)
rel = models.UserFollowRequest.objects.get()
@ -62,7 +62,7 @@ class BookViews(TestCase):
request.user = self.local_user
self.remote_user.followers.add(self.local_user)
self.assertEqual(self.remote_user.followers.count(), 1)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.unfollow(request)
self.assertEqual(self.remote_user.followers.count(), 0)
@ -77,7 +77,7 @@ class BookViews(TestCase):
user_object=self.local_user
)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.accept_follow_request(request)
# request should be deleted
self.assertEqual(
@ -96,7 +96,7 @@ class BookViews(TestCase):
user_object=self.local_user
)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.delete_follow_request(request)
# request should be deleted
self.assertEqual(

View file

@ -100,7 +100,7 @@ class GoalViews(TestCase):
'post-status': True
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
view(request, self.local_user.localname, 2020)
goal = models.AnnualGoal.objects.get()

View file

@ -38,11 +38,12 @@ class ViewsHelpers(TestCase):
)
self.userdata = json.loads(datafile.read_bytes())
del self.userdata['icon']
self.shelf = models.Shelf.objects.create(
name='Test Shelf',
identifier='test-shelf',
user=self.local_user
)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
self.shelf = models.Shelf.objects.create(
name='Test Shelf',
identifier='test-shelf',
user=self.local_user
)
def test_get_edition(self):
@ -83,22 +84,23 @@ class ViewsHelpers(TestCase):
rat = models.User.objects.create_user(
'rat', 'rat@rat.rat', 'password', local=True)
public_status = models.Comment.objects.create(
content='public status', book=self.book, user=self.local_user)
direct_status = models.Status.objects.create(
content='direct', user=self.local_user, privacy='direct')
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
public_status = models.Comment.objects.create(
content='public status', book=self.book, user=self.local_user)
direct_status = models.Status.objects.create(
content='direct', user=self.local_user, privacy='direct')
rat_public = models.Status.objects.create(
content='blah blah', user=rat)
rat_unlisted = models.Status.objects.create(
content='blah blah', user=rat, privacy='unlisted')
remote_status = models.Status.objects.create(
content='blah blah', user=self.remote_user)
followers_status = models.Status.objects.create(
content='blah', user=rat, privacy='followers')
rat_mention = models.Status.objects.create(
content='blah blah blah', user=rat, privacy='followers')
rat_mention.mention_users.set([self.local_user])
rat_public = models.Status.objects.create(
content='blah blah', user=rat)
rat_unlisted = models.Status.objects.create(
content='blah blah', user=rat, privacy='unlisted')
remote_status = models.Status.objects.create(
content='blah blah', user=self.remote_user)
followers_status = models.Status.objects.create(
content='blah', user=rat, privacy='followers')
rat_mention = models.Status.objects.create(
content='blah blah blah', user=rat, privacy='followers')
rat_mention.mention_users.set([self.local_user])
statuses = views.helpers.get_activity_feed(
self.local_user,
@ -159,14 +161,15 @@ class ViewsHelpers(TestCase):
rat = models.User.objects.create_user(
'rat', 'rat@rat.rat', 'password', local=True)
public_status = models.Comment.objects.create(
content='public status', book=self.book, user=self.local_user)
rat_public = models.Status.objects.create(
content='blah blah', user=rat)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
public_status = models.Comment.objects.create(
content='public status', book=self.book, user=self.local_user)
rat_public = models.Status.objects.create(
content='blah blah', user=rat)
statuses = views.helpers.get_activity_feed(
self.local_user, ['public'])
self.assertEqual(len(statuses), 2)
statuses = views.helpers.get_activity_feed(
self.local_user, ['public'])
self.assertEqual(len(statuses), 2)
# block relationship
rat.blocks.add(self.local_user)
@ -240,7 +243,7 @@ class ViewsHelpers(TestCase):
def test_handle_reading_status_to_read(self):
''' posts shelve activities '''
shelf = self.local_user.shelf_set.get(identifier='to-read')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.helpers.handle_reading_status(
self.local_user, shelf, self.book, 'public')
status = models.GeneratedNote.objects.get()
@ -251,7 +254,7 @@ class ViewsHelpers(TestCase):
def test_handle_reading_status_reading(self):
''' posts shelve activities '''
shelf = self.local_user.shelf_set.get(identifier='reading')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.helpers.handle_reading_status(
self.local_user, shelf, self.book, 'public')
status = models.GeneratedNote.objects.get()
@ -262,7 +265,7 @@ class ViewsHelpers(TestCase):
def test_handle_reading_status_read(self):
''' posts shelve activities '''
shelf = self.local_user.shelf_set.get(identifier='read')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.helpers.handle_reading_status(
self.local_user, shelf, self.book, 'public')
status = models.GeneratedNote.objects.get()
@ -272,7 +275,7 @@ class ViewsHelpers(TestCase):
def test_handle_reading_status_other(self):
''' posts shelve activities '''
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.helpers.handle_reading_status(
self.local_user, self.shelf, self.book, 'public')
self.assertFalse(models.GeneratedNote.objects.exists())

View file

@ -38,10 +38,10 @@ class InteractionViews(TestCase):
view = views.Favorite.as_view()
request = self.factory.post('')
request.user = self.remote_user
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, status.id)
fav = models.Favorite.objects.get()
self.assertEqual(fav.status, status)
@ -58,15 +58,15 @@ class InteractionViews(TestCase):
view = views.Unfavorite.as_view()
request = self.factory.post('')
request.user = self.remote_user
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
status = models.Status.objects.create(
user=self.local_user, content='hi')
views.Favorite.as_view()(request, status.id)
self.assertEqual(models.Favorite.objects.count(), 1)
self.assertEqual(models.Notification.objects.count(), 1)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
view(request, status.id)
self.assertEqual(models.Favorite.objects.count(), 0)
self.assertEqual(models.Notification.objects.count(), 0)
@ -77,10 +77,10 @@ class InteractionViews(TestCase):
view = views.Boost.as_view()
request = self.factory.post('')
request.user = self.remote_user
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, status.id)
boost = models.Boost.objects.get()
@ -99,10 +99,10 @@ class InteractionViews(TestCase):
view = views.Boost.as_view()
request = self.factory.post('')
request.user = self.local_user
status = models.Status.objects.create(
user=self.local_user, content='hi', privacy='unlisted')
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
status = models.Status.objects.create(
user=self.local_user, content='hi', privacy='unlisted')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, status.id)
boost = models.Boost.objects.get()
@ -113,10 +113,10 @@ class InteractionViews(TestCase):
view = views.Boost.as_view()
request = self.factory.post('')
request.user = self.local_user
status = models.Status.objects.create(
user=self.local_user, content='hi', privacy='followers')
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
status = models.Status.objects.create(
user=self.local_user, content='hi', privacy='followers')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, status.id)
self.assertFalse(models.Boost.objects.exists())
@ -125,10 +125,10 @@ class InteractionViews(TestCase):
view = views.Boost.as_view()
request = self.factory.post('')
request.user = self.local_user
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, status.id)
view(request, status.id)
self.assertEqual(models.Boost.objects.count(), 1)
@ -139,14 +139,14 @@ class InteractionViews(TestCase):
view = views.Unboost.as_view()
request = self.factory.post('')
request.user = self.remote_user
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
status = models.Status.objects.create(
user=self.local_user, content='hi')
views.Boost.as_view()(request, status.id)
self.assertEqual(models.Boost.objects.count(), 1)
self.assertEqual(models.Notification.objects.count(), 1)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
view(request, status.id)
self.assertEqual(models.Boost.objects.count(), 0)
self.assertEqual(models.Notification.objects.count(), 0)

View file

@ -10,7 +10,6 @@ from bookwyrm import models, views
from bookwyrm.activitypub import ActivitypubResponse
@patch('bookwyrm.broadcast.broadcast_task.delay')
class ListViews(TestCase):
''' tag views'''
def setUp(self):
@ -32,19 +31,21 @@ class ListViews(TestCase):
remote_id='https://example.com/book/1',
parent_work=work,
)
self.list = models.List.objects.create(
name='Test List', user=self.local_user)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
self.list = models.List.objects.create(
name='Test List', user=self.local_user)
self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False
models.SiteSettings.objects.create()
def test_lists_page(self, _):
def test_lists_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Lists.as_view()
models.List.objects.create(name='Public list', user=self.local_user)
models.List.objects.create(
name='Private list', privacy='private', user=self.local_user)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.List.objects.create(name='Public list', user=self.local_user)
models.List.objects.create(
name='Private list', privacy='private', user=self.local_user)
request = self.factory.get('')
request.user = self.local_user
@ -61,8 +62,16 @@ class ListViews(TestCase):
self.assertEqual(result.status_code, 200)
def test_lists_create(self, _):
def test_lists_create(self):
''' create list view '''
real_broadcast = models.List.broadcast
def mock_broadcast(_, activity, user, **kwargs):
''' ok '''
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity['type'], 'Create')
self.assertEqual(activity['actor'], self.local_user.remote_id)
models.List.broadcast = mock_broadcast
view = views.Lists.as_view()
request = self.factory.post('', {
'name': 'A list',
@ -78,9 +87,10 @@ class ListViews(TestCase):
self.assertEqual(new_list.description, 'wow')
self.assertEqual(new_list.privacy, 'unlisted')
self.assertEqual(new_list.curation, 'open')
models.List.broadcast = real_broadcast
def test_list_page(self, _):
def test_list_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.List.as_view()
request = self.factory.get('')
@ -116,8 +126,17 @@ class ListViews(TestCase):
self.assertEqual(result.status_code, 200)
def test_list_edit(self, _):
def test_list_edit(self):
''' edit a list '''
real_broadcast = models.List.broadcast
def mock_broadcast(_, activity, user, **kwargs):
''' ok '''
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity['type'], 'Update')
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(activity['object']['id'], self.list.remote_id)
models.List.broadcast = mock_broadcast
view = views.List.as_view()
request = self.factory.post('', {
'name': 'New Name',
@ -136,14 +155,16 @@ class ListViews(TestCase):
self.assertEqual(self.list.description, 'wow')
self.assertEqual(self.list.privacy, 'direct')
self.assertEqual(self.list.curation, 'curated')
models.List.broadcast = real_broadcast
def test_curate_page(self, _):
def test_curate_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Curate.as_view()
models.List.objects.create(name='Public list', user=self.local_user)
models.List.objects.create(
name='Private list', privacy='private', user=self.local_user)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.List.objects.create(name='Public list', user=self.local_user)
models.List.objects.create(
name='Private list', privacy='private', user=self.local_user)
request = self.factory.get('')
request.user = self.local_user
@ -157,15 +178,25 @@ class ListViews(TestCase):
self.assertEqual(result.status_code, 302)
def test_curate_approve(self, _):
def test_curate_approve(self):
''' approve a pending item '''
real_broadcast = models.List.broadcast
def mock_broadcast(_, activity, user, **kwargs):
''' ok '''
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity['type'], 'Add')
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(activity['target'], self.list.remote_id)
models.ListItem.broadcast = mock_broadcast
view = views.Curate.as_view()
pending = models.ListItem.objects.create(
book_list=self.list,
added_by=self.local_user,
book=self.book,
approved=False
)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
pending = models.ListItem.objects.create(
book_list=self.list,
user=self.local_user,
book=self.book,
approved=False
)
request = self.factory.post('', {
'item': pending.id,
@ -178,17 +209,19 @@ class ListViews(TestCase):
self.assertEqual(self.list.books.count(), 1)
self.assertEqual(self.list.listitem_set.first(), pending)
self.assertTrue(pending.approved)
models.ListItem.broadcast = real_broadcast
def test_curate_reject(self, _):
def test_curate_reject(self):
''' approve a pending item '''
view = views.Curate.as_view()
pending = models.ListItem.objects.create(
book_list=self.list,
added_by=self.local_user,
book=self.book,
approved=False
)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
pending = models.ListItem.objects.create(
book_list=self.list,
user=self.local_user,
book=self.book,
approved=False
)
request = self.factory.post('', {
'item': pending.id,
@ -196,13 +229,22 @@ class ListViews(TestCase):
})
request.user = self.local_user
view(request, self.list.id)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
view(request, self.list.id)
self.assertFalse(self.list.books.exists())
self.assertFalse(models.ListItem.objects.exists())
def test_add_book(self, _):
def test_add_book(self):
''' put a book on a list '''
real_broadcast = models.List.broadcast
def mock_broadcast(_, activity, user):
''' ok '''
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity['type'], 'Add')
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(activity['target'], self.list.remote_id)
models.ListItem.broadcast = mock_broadcast
request = self.factory.post('', {
'book': self.book.id,
})
@ -211,14 +253,23 @@ class ListViews(TestCase):
views.list.add_book(request, self.list.id)
item = self.list.listitem_set.get()
self.assertEqual(item.book, self.book)
self.assertEqual(item.added_by, self.local_user)
self.assertEqual(item.user, self.local_user)
self.assertTrue(item.approved)
models.ListItem.broadcast = real_broadcast
def test_add_book_outsider(self, _):
def test_add_book_outsider(self):
''' put a book on a list '''
real_broadcast = models.List.broadcast
def mock_broadcast(_, activity, user):
''' ok '''
self.assertEqual(user.remote_id, self.rat.remote_id)
self.assertEqual(activity['type'], 'Add')
self.assertEqual(activity['actor'], self.rat.remote_id)
self.assertEqual(activity['target'], self.list.remote_id)
models.ListItem.broadcast = mock_broadcast
self.list.curation = 'open'
self.list.save()
self.list.save(broadcast=False)
request = self.factory.post('', {
'book': self.book.id,
})
@ -227,14 +278,24 @@ class ListViews(TestCase):
views.list.add_book(request, self.list.id)
item = self.list.listitem_set.get()
self.assertEqual(item.book, self.book)
self.assertEqual(item.added_by, self.rat)
self.assertEqual(item.user, self.rat)
self.assertTrue(item.approved)
models.ListItem.broadcast = real_broadcast
def test_add_book_pending(self, _):
''' put a book on a list '''
def test_add_book_pending(self):
''' put a book on a list awaiting approval '''
real_broadcast = models.List.broadcast
def mock_broadcast(_, activity, user):
''' ok '''
self.assertEqual(user.remote_id, self.rat.remote_id)
self.assertEqual(activity['type'], 'Add')
self.assertEqual(activity['actor'], self.rat.remote_id)
self.assertEqual(activity['target'], self.list.remote_id)
self.assertEqual(activity['object']['id'], self.book.remote_id)
models.ListItem.broadcast = mock_broadcast
self.list.curation = 'curated'
self.list.save()
self.list.save(broadcast=False)
request = self.factory.post('', {
'book': self.book.id,
})
@ -243,14 +304,24 @@ class ListViews(TestCase):
views.list.add_book(request, self.list.id)
item = self.list.listitem_set.get()
self.assertEqual(item.book, self.book)
self.assertEqual(item.added_by, self.rat)
self.assertEqual(item.user, self.rat)
self.assertFalse(item.approved)
models.ListItem.broadcast = real_broadcast
def test_add_book_self_curated(self, _):
''' put a book on a list '''
def test_add_book_self_curated(self):
''' put a book on a list automatically approved '''
real_broadcast = models.ListItem.broadcast
def mock_broadcast(_, activity, user):
''' ok '''
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity['type'], 'Add')
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(activity['target'], self.list.remote_id)
models.ListItem.broadcast = mock_broadcast
self.list.curation = 'curated'
self.list.save()
self.list.save(broadcast=False)
request = self.factory.post('', {
'book': self.book.id,
})
@ -259,18 +330,30 @@ class ListViews(TestCase):
views.list.add_book(request, self.list.id)
item = self.list.listitem_set.get()
self.assertEqual(item.book, self.book)
self.assertEqual(item.added_by, self.local_user)
self.assertEqual(item.user, self.local_user)
self.assertTrue(item.approved)
models.ListItem.broadcast = real_broadcast
def test_remove_book(self, _):
def test_remove_book(self):
''' take an item off a list '''
item = models.ListItem.objects.create(
book_list=self.list,
added_by=self.local_user,
book=self.book,
)
real_broadcast = models.ListItem.broadcast
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
item = models.ListItem.objects.create(
book_list=self.list,
user=self.local_user,
book=self.book,
)
self.assertTrue(self.list.listitem_set.exists())
def mock_broadcast(_, activity, user):
''' ok '''
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity['type'], 'Remove')
self.assertEqual(activity['actor'], self.local_user.remote_id)
self.assertEqual(activity['target'], self.list.remote_id)
models.ListItem.broadcast = mock_broadcast
request = self.factory.post('', {
'item': item.id,
})
@ -279,15 +362,17 @@ class ListViews(TestCase):
views.list.remove_book(request, self.list.id)
self.assertFalse(self.list.listitem_set.exists())
models.ListItem.broadcast = real_broadcast
def test_remove_book_unauthorized(self, _):
def test_remove_book_unauthorized(self):
''' take an item off a list '''
item = models.ListItem.objects.create(
book_list=self.list,
added_by=self.local_user,
book=self.book,
)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
item = models.ListItem.objects.create(
book_list=self.list,
user=self.local_user,
book=self.book,
)
self.assertTrue(self.list.listitem_set.exists())
request = self.factory.post('', {
'item': item.id,

View file

@ -30,7 +30,7 @@ class NotificationViews(TestCase):
def test_clear_notifications(self):
''' erase notifications '''
models.Notification.objects.create(
user=self.local_user, notification_type='MENTION')
user=self.local_user, notification_type='FAVORITE')
models.Notification.objects.create(
user=self.local_user, notification_type='MENTION', read=True)
self.assertEqual(models.Notification.objects.count(), 2)

View file

@ -1,4 +1,5 @@
''' sending out activities '''
from unittest.mock import patch
import json
from django.http import JsonResponse
@ -49,14 +50,16 @@ class OutboxView(TestCase):
def test_outbox_privacy(self):
''' don't show dms et cetera in outbox '''
models.Status.objects.create(
content='PRIVATE!!', user=self.local_user, privacy='direct')
models.Status.objects.create(
content='bffs ONLY', user=self.local_user, privacy='followers')
models.Status.objects.create(
content='unlisted status', user=self.local_user, privacy='unlisted')
models.Status.objects.create(
content='look at this', user=self.local_user, privacy='public')
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.Status.objects.create(
content='PRIVATE!!', user=self.local_user, privacy='direct')
models.Status.objects.create(
content='bffs ONLY', user=self.local_user, privacy='followers')
models.Status.objects.create(
content='unlisted status', user=self.local_user,
privacy='unlisted')
models.Status.objects.create(
content='look at this', user=self.local_user, privacy='public')
request = self.factory.get('')
result = views.Outbox.as_view()(request, 'mouse')
@ -67,11 +70,12 @@ class OutboxView(TestCase):
def test_outbox_filter(self):
''' if we only care about reviews, only get reviews '''
models.Review.objects.create(
content='look at this', name='hi', rating=1,
book=self.book, user=self.local_user)
models.Status.objects.create(
content='look at this', user=self.local_user)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.Review.objects.create(
content='look at this', name='hi', rating=1,
book=self.book, user=self.local_user)
models.Status.objects.create(
content='look at this', user=self.local_user)
request = self.factory.get('', {'type': 'bleh'})
result = views.Outbox.as_view()(request, 'mouse')

View file

@ -45,7 +45,7 @@ class ReadingViews(TestCase):
'start_date': '2020-01-05',
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.start_reading(request, self.book.id)
self.assertEqual(shelf.books.get(), self.book)
@ -65,8 +65,9 @@ class ReadingViews(TestCase):
def test_start_reading_reshelf(self):
''' begin a book '''
to_read_shelf = self.local_user.shelf_set.get(identifier='to-read')
models.ShelfBook.objects.create(
shelf=to_read_shelf, book=self.book, added_by=self.local_user)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.ShelfBook.objects.create(
shelf=to_read_shelf, book=self.book, user=self.local_user)
shelf = self.local_user.shelf_set.get(identifier='reading')
self.assertEqual(to_read_shelf.books.get(), self.book)
self.assertFalse(shelf.books.exists())
@ -74,7 +75,7 @@ class ReadingViews(TestCase):
request = self.factory.post('')
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.start_reading(request, self.book.id)
self.assertFalse(to_read_shelf.books.exists())
@ -98,7 +99,7 @@ class ReadingViews(TestCase):
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.finish_reading(request, self.book.id)
self.assertEqual(shelf.books.get(), self.book)

View file

@ -1,13 +1,16 @@
''' tests updating reading progress '''
from datetime import datetime
from unittest.mock import patch
from django.test import TestCase, Client
from django.utils import timezone
from datetime import datetime
from bookwyrm import models
@patch('bookwyrm.broadcast.broadcast_task.delay')
@patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay')
class ReadThrough(TestCase):
''' readthrough tests '''
def setUp(self):
''' basic user and book data '''
self.client = Client()
self.work = models.Work.objects.create(
@ -25,7 +28,8 @@ class ReadThrough(TestCase):
'cinco', 'cinco@example.com', 'seissiete',
local=True, localname='cinco')
self.client.force_login(self.user)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
self.client.force_login(self.user)
def test_create_basic_readthrough(self, delay_mock):
"""A basic readthrough doesn't create a progress update"""
@ -38,13 +42,15 @@ class ReadThrough(TestCase):
readthroughs = self.edition.readthrough_set.all()
self.assertEqual(len(readthroughs), 1)
self.assertEqual(readthroughs[0].progressupdate_set.count(), 0)
self.assertEqual(readthroughs[0].start_date,
self.assertEqual(
readthroughs[0].start_date,
datetime(2020, 11, 27, tzinfo=timezone.utc))
self.assertEqual(readthroughs[0].progress, None)
self.assertEqual(readthroughs[0].finish_date, None)
self.assertEqual(delay_mock.call_count, 1)
def test_create_progress_readthrough(self, delay_mock):
''' a readthrough with progress '''
self.assertEqual(self.edition.readthrough_set.count(), 0)
self.client.post('/start-reading/{}'.format(self.edition.id), {
@ -54,7 +60,8 @@ class ReadThrough(TestCase):
readthroughs = self.edition.readthrough_set.all()
self.assertEqual(len(readthroughs), 1)
self.assertEqual(readthroughs[0].start_date,
self.assertEqual(
readthroughs[0].start_date,
datetime(2020, 11, 27, tzinfo=timezone.utc))
self.assertEqual(readthroughs[0].progress, 50)
self.assertEqual(readthroughs[0].finish_date, None)
@ -76,7 +83,9 @@ class ReadThrough(TestCase):
self.assertEqual(len(progress_updates), 2)
self.assertEqual(progress_updates[1].mode, models.ProgressMode.PAGE)
self.assertEqual(progress_updates[1].progress, 100)
self.assertEqual(delay_mock.call_count, 1) # Edit doesn't publish anything
# Edit doesn't publish anything
self.assertEqual(delay_mock.call_count, 1)
self.client.post('/delete-readthrough', {
'id': readthroughs[0].id,

View file

@ -2,16 +2,14 @@
from unittest.mock import patch
from django.test import RequestFactory, TestCase
import responses
from bookwyrm import models
from bookwyrm.views import rss_feed
from bookwyrm.settings import DOMAIN
class RssFeedView(TestCase):
''' rss feed behaves as expected '''
def setUp(self):
''' test data '''
self.site = models.SiteSettings.objects.create()
self.user = models.User.objects.create_user(
@ -24,21 +22,23 @@ class RssFeedView(TestCase):
parent_work=work
)
self.review = models.Review.objects.create(
name='Review name', content='test content', rating=3,
user=self.user, book=self.book)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
self.review = models.Review.objects.create(
name='Review name', content='test content', rating=3,
user=self.user, book=self.book)
self.quote = models.Quotation.objects.create(
quote='a sickening sense', content='test content',
user=self.user, book=self.book)
self.generatednote = models.GeneratedNote.objects.create(
content='test content', user=self.user)
self.quote = models.Quotation.objects.create(
quote='a sickening sense', content='test content',
user=self.user, book=self.book)
self.generatednote = models.GeneratedNote.objects.create(
content='test content', user=self.user)
self.factory = RequestFactory()
def test_rss_feed(self):
''' load an rss feed '''
view = rss_feed.RssFeed()
request = self.factory.get('/user/rss_user/rss')
with patch("bookwyrm.models.SiteSettings.objects.get") as site:
@ -47,6 +47,5 @@ class RssFeedView(TestCase):
self.assertEqual(result.status_code, 200)
self.assertIn(b"Status updates from rss_user", result.content)
self.assertIn( b"a sickening sense", result.content)
self.assertIn(b"a sickening sense", result.content)
self.assertIn(b"Example Edition", result.content)

View file

@ -8,7 +8,7 @@ from bookwyrm import models, views
from bookwyrm.activitypub import ActivitypubResponse
@patch('bookwyrm.broadcast.broadcast_task.delay')
@patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay')
class ShelfViews(TestCase):
''' tag views'''
def setUp(self):
@ -25,11 +25,12 @@ class ShelfViews(TestCase):
remote_id='https://example.com/book/1',
parent_work=self.work
)
self.shelf = models.Shelf.objects.create(
name='Test Shelf',
identifier='test-shelf',
user=self.local_user
)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
self.shelf = models.Shelf.objects.create(
name='Test Shelf',
identifier='test-shelf',
user=self.local_user
)
models.SiteSettings.objects.create()
@ -96,7 +97,8 @@ class ShelfViews(TestCase):
'name': 'cool name'
})
request.user = self.local_user
view(request, request.user.username, shelf.identifier)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
view(request, request.user.username, shelf.identifier)
shelf.refresh_from_db()
self.assertEqual(shelf.name, 'cool name')
@ -116,7 +118,8 @@ class ShelfViews(TestCase):
'name': 'cool name'
})
request.user = self.local_user
view(request, request.user.username, shelf.identifier)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
view(request, request.user.username, shelf.identifier)
self.assertEqual(shelf.name, 'To Read')
@ -128,7 +131,8 @@ class ShelfViews(TestCase):
'shelf': self.shelf.identifier
})
request.user = self.local_user
views.shelve(request)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.shelve(request)
# make sure the book is on the shelf
self.assertEqual(self.shelf.books.get(), self.book)
@ -142,7 +146,8 @@ class ShelfViews(TestCase):
})
request.user = self.local_user
views.shelve(request)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.shelve(request)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
@ -156,7 +161,8 @@ class ShelfViews(TestCase):
})
request.user = self.local_user
views.shelve(request)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.shelve(request)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
@ -170,7 +176,7 @@ class ShelfViews(TestCase):
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.shelve(request)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
@ -178,7 +184,12 @@ class ShelfViews(TestCase):
def test_handle_unshelve(self, _):
''' remove a book from a shelf '''
self.shelf.books.add(self.book)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
models.ShelfBook.objects.create(
book=self.book,
user=self.local_user,
shelf=self.shelf
)
self.shelf.save()
self.assertEqual(self.shelf.books.count(), 1)
request = self.factory.post('', {
@ -186,6 +197,6 @@ class ShelfViews(TestCase):
'shelf': self.shelf.id
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
views.unshelve(request)
self.assertEqual(self.shelf.books.count(), 0)

View file

@ -1,11 +1,9 @@
''' test for app action functionality '''
from unittest.mock import patch
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import forms, models, views
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import DOMAIN
@ -47,7 +45,7 @@ class StatusViews(TestCase):
})
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
view(request, 'comment')
status = models.Comment.objects.get()
self.assertEqual(status.content, '<p>hi</p>')
@ -59,8 +57,9 @@ class StatusViews(TestCase):
view = views.CreateStatus.as_view()
user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'password', local=True)
parent = models.Status.objects.create(
content='parent status', user=self.local_user)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
parent = models.Status.objects.create(
content='parent status', user=self.local_user)
form = forms.ReplyForm({
'content': 'hi',
'user': user.id,
@ -69,7 +68,7 @@ class StatusViews(TestCase):
})
request = self.factory.post('', form.data)
request.user = user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
view(request, 'reply')
status = models.Status.objects.get(user=user)
self.assertEqual(status.content, '<p>hi</p>')
@ -92,7 +91,7 @@ class StatusViews(TestCase):
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
view(request, 'comment')
status = models.Status.objects.get()
self.assertEqual(list(status.mention_users.all()), [user])
@ -116,7 +115,7 @@ class StatusViews(TestCase):
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
view(request, 'comment')
status = models.Status.objects.get()
@ -128,7 +127,7 @@ class StatusViews(TestCase):
})
request = self.factory.post('', form.data)
request.user = user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
view(request, 'reply')
reply = models.Status.replies(status).first()
@ -218,15 +217,26 @@ class StatusViews(TestCase):
'is rad</p>')
def test_to_markdown_link(self):
''' this is mostly handled in other places, but nonetheless '''
text = '[hi](http://fish.com) is <marquee>rad</marquee>'
result = views.status.to_markdown(text)
self.assertEqual(
result,
'<p><a href="http://fish.com">hi</a> ' \
'is rad</p>')
def test_handle_delete_status(self):
''' marks a status as deleted '''
view = views.DeleteStatus.as_view()
status = models.Status.objects.create(
user=self.local_user, content='hi')
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
status = models.Status.objects.create(
user=self.local_user, content='hi')
self.assertFalse(status.deleted)
request = self.factory.post('')
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
view(request, status.id)
status.refresh_from_db()
self.assertTrue(status.deleted)

View file

@ -39,9 +39,10 @@ class TagViews(TestCase):
def test_tag_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Tag.as_view()
tag = models.Tag.objects.create(name='hi there')
models.UserTag.objects.create(
tag=tag, user=self.local_user, book=self.book)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
tag = models.Tag.objects.create(name='hi there')
models.UserTag.objects.create(
tag=tag, user=self.local_user, book=self.book)
request = self.factory.get('')
with patch('bookwyrm.views.tag.is_api_request') as is_api:
is_api.return_value = False
@ -68,7 +69,7 @@ class TagViews(TestCase):
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
view(request)
tag = models.Tag.objects.get()
@ -82,9 +83,10 @@ class TagViews(TestCase):
def test_untag(self):
''' remove a tag from a book '''
view = views.RemoveTag.as_view()
tag = models.Tag.objects.create(name='A Tag!?')
models.UserTag.objects.create(
user=self.local_user, book=self.book, tag=tag)
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
tag = models.Tag.objects.create(name='A Tag!?')
models.UserTag.objects.create(
user=self.local_user, book=self.book, tag=tag)
request = self.factory.post(
'', {
'user': self.local_user.id,
@ -93,7 +95,7 @@ class TagViews(TestCase):
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
view(request)
self.assertTrue(models.Tag.objects.filter(name='A Tag!?').exists())

View file

@ -138,7 +138,7 @@ class UserViews(TestCase):
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay'):
view(request)
self.assertEqual(self.local_user.name, 'New Name')

View file

@ -8,7 +8,7 @@ from .error import not_found_page, server_error_page
from .federation import Federation
from .feed import DirectMessage, Feed, Replies, Status
from .follow import follow, unfollow
from .follow import accept_follow_request, delete_follow_request, handle_accept
from .follow import accept_follow_request, delete_follow_request
from .goal import Goal
from .import_data import Import, ImportStatus
from .interaction import Favorite, Unfavorite, Boost, Unboost

View file

@ -46,6 +46,7 @@ class Login(View):
# successful login
login(request, user)
user.last_active_date = timezone.now()
user.save(broadcast=False)
return redirect(request.GET.get('next', '/'))
# login errors

View file

@ -8,7 +8,6 @@ from django.views import View
from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.broadcast import broadcast
from .helpers import is_api_request
@ -62,5 +61,4 @@ class EditAuthor(View):
return TemplateResponse(request, 'edit_author.html', data)
author = form.save()
broadcast(request.user, author.to_update_activity(request.user))
return redirect('/author/%s' % author.id)

View file

@ -8,7 +8,6 @@ from django.views import View
from django.views.decorators.http import require_POST
from bookwyrm import models
from bookwyrm.broadcast import broadcast
# pylint: disable= no-self-use
@method_decorator(login_required, name='dispatch')
@ -22,15 +21,8 @@ class Block(View):
def post(self, request, user_id):
''' block a user '''
to_block = get_object_or_404(models.User, id=user_id)
block = models.UserBlocks.objects.create(
models.UserBlocks.objects.create(
user_subject=request.user, user_object=to_block)
if not to_block.local:
broadcast(
request.user,
block.to_activity(),
privacy='direct',
direct_recipients=[to_block]
)
return redirect('/preferences/block')
@ -46,13 +38,5 @@ def unblock(request, user_id):
)
except models.UserBlocks.DoesNotExist:
return HttpResponseNotFound()
if not to_unblock.local:
broadcast(
request.user,
block.to_undo_activity(request.user),
privacy='direct',
direct_recipients=[to_unblock]
)
block.delete()
return redirect('/preferences/block')

View file

@ -12,7 +12,6 @@ from django.views.decorators.http import require_POST
from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.broadcast import broadcast
from bookwyrm.connectors import connector_manager
from bookwyrm.settings import PAGE_LENGTH
from .helpers import is_api_request, get_activity_feed, get_edition
@ -78,12 +77,12 @@ class Book(View):
.order_by('-updated_date')
user_shelves = models.ShelfBook.objects.filter(
added_by=request.user, book=book
user=request.user, book=book
)
other_edition_shelves = models.ShelfBook.objects.filter(
~Q(book=book),
added_by=request.user,
user=request.user,
book__parent_work=book.parent_work,
)
@ -136,7 +135,6 @@ class EditBook(View):
return TemplateResponse(request, 'edit_book.html', data)
book = form.save()
broadcast(request.user, book.to_update_activity(request.user))
return redirect('/book/%s' % book.id)
@ -170,7 +168,6 @@ def upload_cover(request, book_id):
book.cover = form.files['cover']
book.save()
broadcast(request.user, book.to_update_activity(request.user))
return redirect('/book/%s' % book.id)
@ -189,7 +186,6 @@ def add_description(request, book_id):
book.description = description
book.save()
broadcast(request.user, book.to_update_activity(request.user))
return redirect('/book/%s' % book.id)
@ -215,12 +211,14 @@ def switch_edition(request):
shelf__user=request.user
)
for shelfbook in shelfbooks.all():
broadcast(request.user, shelfbook.to_remove_activity(request.user))
shelfbook.book = new_edition
shelfbook.save()
broadcast(request.user, shelfbook.to_add_activity(request.user))
with transaction.atomic():
models.ShelfBook.objects.create(
created_date=shelfbook.created_date,
user=shelfbook.user,
shelf=shelfbook.shelf,
book=new_edition
)
shelfbook.delete()
readthroughs = models.ReadThrough.objects.filter(
book__parent_work=new_edition.parent_work,

View file

@ -1,12 +1,10 @@
''' views for actions you can take in the application '''
from django.db import transaction
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseBadRequest
from django.shortcuts import redirect
from django.views.decorators.http import require_POST
from bookwyrm import models
from bookwyrm.broadcast import broadcast
from .helpers import get_user_from_username
@login_required
@ -19,13 +17,10 @@ def follow(request):
except models.User.DoesNotExist:
return HttpResponseBadRequest()
relationship, _ = models.UserFollowRequest.objects.get_or_create(
models.UserFollowRequest.objects.get_or_create(
user_subject=request.user,
user_object=to_follow,
)
activity = relationship.to_activity()
broadcast(
request.user, activity, privacy='direct', direct_recipients=[to_follow])
return redirect(to_follow.local_path)
@ -39,14 +34,10 @@ def unfollow(request):
except models.User.DoesNotExist:
return HttpResponseBadRequest()
relationship = models.UserFollows.objects.get(
models.UserFollows.objects.get(
user_subject=request.user,
user_object=to_unfollow
)
activity = relationship.to_undo_activity(request.user)
broadcast(
request.user, activity,
privacy='direct', direct_recipients=[to_unfollow])
to_unfollow.followers.remove(request.user)
return redirect(to_unfollow.local_path)
@ -70,24 +61,11 @@ def accept_follow_request(request):
except models.UserFollowRequest.DoesNotExist:
# Request already dealt with.
return redirect(request.user.local_path)
handle_accept(follow_request)
follow_request.accept()
return redirect(request.user.local_path)
def handle_accept(follow_request):
''' send an acceptance message to a follow request '''
user = follow_request.user_subject
to_follow = follow_request.user_object
with transaction.atomic():
relationship = models.UserFollows.from_request(follow_request)
follow_request.delete()
relationship.save()
activity = relationship.to_accept_activity()
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
@login_required
@require_POST
def delete_follow_request(request):
@ -106,8 +84,5 @@ def delete_follow_request(request):
except models.UserFollowRequest.DoesNotExist:
return HttpResponseBadRequest()
activity = follow_request.to_reject_activity()
follow_request.delete()
broadcast(
request.user, activity, privacy='direct', direct_recipients=[requester])
return redirect('/user/%s' % request.user.localname)

View file

@ -7,7 +7,6 @@ from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm import forms, models
from bookwyrm.broadcast import broadcast
from bookwyrm.status import create_generated_note
from .helpers import get_user_from_username, object_visible_to_user
@ -63,23 +62,10 @@ class Goal(View):
if request.POST.get('post-status'):
# create status, if appropraite
status = create_generated_note(
create_generated_note(
request.user,
'set a goal to read %d books in %d' % (goal.goal, goal.year),
privacy=goal.privacy
)
broadcast(
request.user,
status.to_create_activity(request.user),
privacy=status.privacy,
software='bookwyrm')
# re-format the activity for non-bookwyrm servers
remote_activity = status.to_create_activity(request.user, pure=True)
broadcast(
request.user,
remote_activity,
privacy=status.privacy,
software='other')
return redirect(request.headers.get('Referer', '/'))

View file

@ -4,7 +4,6 @@ from requests import HTTPError
from django.db.models import Q
from bookwyrm import activitypub, models
from bookwyrm.broadcast import broadcast
from bookwyrm.connectors import ConnectorException, get_data
from bookwyrm.status import create_generated_note
from bookwyrm.utils import regex
@ -199,7 +198,6 @@ def handle_reading_status(user, shelf, book, privacy):
)
status.save()
broadcast(user, status.to_create_activity(user))
def is_blocked(viewer, user):
''' is this viewer blocked by the user? '''

View file

@ -7,8 +7,6 @@ from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm import models
from bookwyrm.broadcast import broadcast
from bookwyrm.status import create_notification
# pylint: disable= no-self-use
@ -19,7 +17,7 @@ class Favorite(View):
''' create a like '''
status = models.Status.objects.get(id=status_id)
try:
favorite = models.Favorite.objects.create(
models.Favorite.objects.create(
status=status,
user=request.user
)
@ -27,17 +25,6 @@ class Favorite(View):
# you already fav'ed that
return HttpResponseBadRequest()
fav_activity = favorite.to_activity()
broadcast(
request.user, fav_activity, privacy='direct',
direct_recipients=[status.user])
if status.user.local:
create_notification(
status.user,
'FAVORITE',
related_user=request.user,
related_status=status
)
return redirect(request.headers.get('Referer', '/'))
@ -56,18 +43,7 @@ class Unfavorite(View):
# can't find that status, idk
return HttpResponseNotFound()
fav_activity = favorite.to_undo_activity(request.user)
favorite.delete()
broadcast(request.user, fav_activity, direct_recipients=[status.user])
# check for notification
if status.user.local:
notification = models.Notification.objects.filter(
user=status.user, related_user=request.user,
related_status=status, notification_type='FAVORITE'
).first()
if notification:
notification.delete()
return redirect(request.headers.get('Referer', '/'))
@ -86,22 +62,11 @@ class Boost(View):
# you already boosted that.
return redirect(request.headers.get('Referer', '/'))
boost = models.Boost.objects.create(
models.Boost.objects.create(
boosted_status=status,
privacy=status.privacy,
user=request.user,
)
boost_activity = boost.to_activity()
broadcast(request.user, boost_activity)
if status.user.local:
create_notification(
status.user,
'BOOST',
related_user=request.user,
related_status=status
)
return redirect(request.headers.get('Referer', '/'))
@ -114,17 +79,6 @@ class Unboost(View):
boost = models.Boost.objects.filter(
boosted_status=status, user=request.user
).first()
activity = boost.to_undo_activity(request.user)
boost.delete()
broadcast(request.user, activity)
# delete related notification
if status.user.local:
notification = models.Notification.objects.filter(
user=status.user, related_user=request.user,
related_status=status, notification_type='BOOST'
).first()
if notification:
notification.delete()
return redirect(request.headers.get('Referer', '/'))

View file

@ -1,7 +1,8 @@
''' book list views'''
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q
from django.db import IntegrityError
from django.db.models import Count, Q
from django.http import HttpResponseNotFound, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
@ -11,7 +12,6 @@ from django.views.decorators.http import require_POST
from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.broadcast import broadcast
from bookwyrm.connectors import connector_manager
from .helpers import is_api_request, object_visible_to_user, privacy_filter
from .helpers import get_user_from_username
@ -27,9 +27,13 @@ class Lists(View):
page = 1
user = request.user if request.user.is_authenticated else None
# hide lists with no approved books
lists = models.List.objects.filter(
~Q(user=user),
books__isnull=False,
).annotate(
item_count=Count('listitem', filter=Q(listitem__approved=True))
).filter(
item_count__gt=0
).distinct().all()
lists = privacy_filter(request.user, lists, ['public', 'followers'])
@ -51,13 +55,6 @@ class Lists(View):
return redirect('lists')
book_list = form.save()
# let the world know
broadcast(
request.user,
book_list.to_create_activity(request.user),
privacy=book_list.privacy,
software='bookwyrm'
)
return redirect(book_list.local_path)
class UserLists(View):
@ -132,19 +129,12 @@ class List(View):
@method_decorator(login_required, name='dispatch')
# pylint: disable=unused-argument
def post(self, request, list_id):
''' edit a book_list '''
''' edit a list '''
book_list = get_object_or_404(models.List, id=list_id)
form = forms.ListForm(request.POST, instance=book_list)
if not form.is_valid():
return redirect('list', book_list.id)
book_list = form.save()
# let the world know
broadcast(
request.user,
book_list.to_update_activity(request.user),
privacy=book_list.privacy,
software='bookwyrm'
)
return redirect(book_list.local_path)
@ -178,13 +168,6 @@ class Curate(View):
if approved:
suggestion.approved = True
suggestion.save()
# let the world know
broadcast(
request.user,
suggestion.to_add_activity(request.user),
privacy=book_list.privacy,
software='bookwyrm'
)
else:
suggestion.delete()
return redirect('list-curate', book_list.id)
@ -199,31 +182,28 @@ def add_book(request, list_id):
book = get_object_or_404(models.Edition, id=request.POST.get('book'))
# do you have permission to add to the list?
if request.user == book_list.user or book_list.curation == 'open':
# go ahead and add it
item = models.ListItem.objects.create(
book=book,
book_list=book_list,
added_by=request.user,
)
# let the world know
broadcast(
request.user,
item.to_add_activity(request.user),
privacy=book_list.privacy,
software='bookwyrm'
)
elif book_list.curation == 'curated':
# make a pending entry
models.ListItem.objects.create(
approved=False,
book=book,
book_list=book_list,
added_by=request.user,
)
else:
# you can't add to this list, what were you THINKING
return HttpResponseBadRequest()
try:
if request.user == book_list.user or book_list.curation == 'open':
# go ahead and add it
models.ListItem.objects.create(
book=book,
book_list=book_list,
user=request.user,
)
elif book_list.curation == 'curated':
# make a pending entry
models.ListItem.objects.create(
approved=False,
book=book,
book_list=book_list,
user=request.user,
)
else:
# you can't add to this list, what were you THINKING
return HttpResponseBadRequest()
except IntegrityError:
# if the book is already on the list, don't flip out
pass
return redirect('list', list_id)
@ -234,16 +214,8 @@ def remove_book(request, list_id):
book_list = get_object_or_404(models.List, id=list_id)
item = get_object_or_404(models.ListItem, id=request.POST.get('item'))
if not book_list.user == request.user and not item.added_by == request.user:
if not book_list.user == request.user and not item.user == request.user:
return HttpResponseNotFound()
activity = item.to_remove_activity(request.user)
item.delete()
# let the world know
broadcast(
request.user,
activity,
privacy=book_list.privacy,
software='bookwyrm'
)
return redirect('list', list_id)

View file

@ -79,7 +79,7 @@ class PasswordReset(View):
return TemplateResponse(request, 'password_reset.html', data)
user.set_password(new_password)
user.save()
user.save(broadcast=False)
login(request, user)
reset_code.delete()
return redirect('/')
@ -106,6 +106,6 @@ class ChangePassword(View):
return redirect('preferences/password')
request.user.set_password(new_password)
request.user.save()
request.user.save(broadcast=False)
login(request, request.user)
return redirect(request.user.local_path)

View file

@ -9,7 +9,6 @@ from django.utils import timezone
from django.views.decorators.http import require_POST
from bookwyrm import models
from bookwyrm.broadcast import broadcast
from .helpers import get_edition, handle_reading_status
from .shelf import handle_unshelve
@ -44,9 +43,8 @@ def start_reading(request, book_id):
except models.Shelf.DoesNotExist:
# this just means it isn't currently on the user's shelves
pass
shelfbook = models.ShelfBook.objects.create(
book=book, shelf=shelf, added_by=request.user)
broadcast(request.user, shelfbook.to_add_activity(request.user))
models.ShelfBook.objects.create(
book=book, shelf=shelf, user=request.user)
# post about it (if you want)
if request.POST.get('post-status'):
@ -82,9 +80,8 @@ def finish_reading(request, book_id):
except models.Shelf.DoesNotExist:
# this just means it isn't currently on the user's shelves
pass
shelfbook = models.ShelfBook.objects.create(
book=book, shelf=shelf, added_by=request.user)
broadcast(request.user, shelfbook.to_add_activity(request.user))
models.ShelfBook.objects.create(
book=book, shelf=shelf, user=request.user)
# post about it (if you want)
if request.POST.get('post-status'):

View file

@ -9,7 +9,6 @@ from django.views.decorators.http import require_POST
from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.broadcast import broadcast
from .helpers import is_api_request, get_edition, get_user_from_username
from .helpers import handle_reading_status
@ -50,7 +49,7 @@ class Shelf(View):
return ActivitypubResponse(shelf.to_activity(**request.GET))
books = models.ShelfBook.objects.filter(
added_by=user, shelf=shelf
user=user, shelf=shelf
).order_by('-updated_date').all()
data = {
@ -125,6 +124,8 @@ def shelve(request):
identifier=request.POST.get('shelf'),
user=request.user
).first()
if not desired_shelf:
return HttpResponseNotFound()
if request.POST.get('reshelve', True):
try:
@ -136,22 +137,18 @@ def shelve(request):
except models.Shelf.DoesNotExist:
# this just means it isn't currently on the user's shelves
pass
shelfbook = models.ShelfBook.objects.create(
book=book, shelf=desired_shelf, added_by=request.user)
broadcast(
request.user,
shelfbook.to_add_activity(request.user),
privacy=shelfbook.shelf.privacy,
software='bookwyrm'
)
models.ShelfBook.objects.create(
book=book, shelf=desired_shelf, user=request.user)
# post about "want to read" shelves
if desired_shelf.identifier == 'to-read':
if desired_shelf.identifier == 'to-read' and \
request.POST.get('post-status'):
privacy = request.POST.get('privacy') or desired_shelf.privacy
handle_reading_status(
request.user,
desired_shelf,
book,
privacy=desired_shelf.privacy,
privacy=privacy
)
return redirect('/')
@ -168,10 +165,8 @@ def unshelve(request):
return redirect(request.headers.get('Referer', '/'))
#pylint: disable=unused-argument
def handle_unshelve(user, book, shelf):
''' unshelve a book '''
row = models.ShelfBook.objects.get(book=book, shelf=shelf)
activity = row.to_remove_activity(user)
row.delete()
broadcast(user, activity, privacy=shelf.privacy, software='bookwyrm')

View file

@ -8,10 +8,9 @@ from django.views import View
from markdown import markdown
from bookwyrm import forms, models
from bookwyrm.broadcast import broadcast
from bookwyrm.sanitize_html import InputHtmlParser
from bookwyrm.settings import DOMAIN
from bookwyrm.status import create_notification, delete_status
from bookwyrm.status import delete_status
from bookwyrm.utils import regex
from .helpers import handle_remote_webfinger
@ -35,7 +34,7 @@ class CreateStatus(View):
if not status.sensitive and status.content_warning:
# the cw text field remains populated when you click "remove"
status.content_warning = None
status.save()
status.save(broadcast=False)
# inspect the text for user tags
content = status.content
@ -49,32 +48,12 @@ class CreateStatus(View):
r'<a href="%s">%s</a>\g<1>' % \
(mention_user.remote_id, mention_text),
content)
# add reply parent to mentions and notify
# add reply parent to mentions
if status.reply_parent:
status.mention_users.add(status.reply_parent.user)
if status.reply_parent.user.local:
create_notification(
status.reply_parent.user,
'REPLY',
related_user=request.user,
related_status=status
)
# deduplicate mentions
status.mention_users.set(set(status.mention_users.all()))
# create mention notifications
for mention_user in status.mention_users.all():
if status.reply_parent and mention_user == status.reply_parent.user:
continue
if mention_user.local:
create_notification(
mention_user,
'MENTION',
related_user=request.user,
related_status=status
)
# don't apply formatting to generated notes
if not isinstance(status, models.GeneratedNote):
@ -83,16 +62,7 @@ class CreateStatus(View):
if hasattr(status, 'quote'):
status.quote = to_markdown(status.quote)
status.save()
broadcast(
request.user,
status.to_create_activity(request.user),
software='bookwyrm')
# re-format the activity for non-bookwyrm servers
remote_activity = status.to_create_activity(request.user, pure=True)
broadcast(request.user, remote_activity, software='other')
status.save(created=True)
return redirect(request.headers.get('Referer', '/'))
@ -108,7 +78,6 @@ class DeleteStatus(View):
# perform deletion
delete_status(status)
broadcast(request.user, status.to_delete_activity(request.user))
return redirect(request.headers.get('Referer', '/'))
def find_mentions(content):
@ -137,8 +106,8 @@ def format_links(content):
def to_markdown(content):
''' catch links and convert to markdown '''
content = format_links(content)
content = markdown(content)
content = format_links(content)
# sanitize resulting html
sanitizer = InputHtmlParser()
sanitizer.feed(content)

View file

@ -8,7 +8,6 @@ from django.views import View
from bookwyrm import models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.broadcast import broadcast
from .helpers import is_api_request
@ -45,17 +44,15 @@ class AddTag(View):
name = request.POST.get('name')
book_id = request.POST.get('book')
book = get_object_or_404(models.Edition, id=book_id)
tag_obj, created = models.Tag.objects.get_or_create(
tag_obj, _ = models.Tag.objects.get_or_create(
name=name,
)
user_tag, _ = models.UserTag.objects.get_or_create(
models.UserTag.objects.get_or_create(
user=request.user,
book=book,
tag=tag_obj,
)
if created:
broadcast(request.user, user_tag.to_add_activity(request.user))
return redirect('/book/%s' % book_id)
@ -71,8 +68,6 @@ class RemoveTag(View):
user_tag = get_object_or_404(
models.UserTag, tag=tag_obj, book=book, user=request.user)
tag_activity = user_tag.to_remove_activity(request.user)
user_tag.delete()
broadcast(request.user, tag_activity)
return redirect('/book/%s' % book_id)

View file

@ -15,7 +15,6 @@ from django.views import View
from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.broadcast import broadcast
from bookwyrm.settings import PAGE_LENGTH
from .helpers import get_activity_feed, get_user_from_username, is_api_request
from .helpers import is_blocked, object_visible_to_user
@ -176,7 +175,6 @@ class EditUser(View):
user.avatar.save(filename, image)
user.save()
broadcast(user, user.to_update_activity(user))
return redirect(user.local_path)

4
instances.md Normal file
View file

@ -0,0 +1,4 @@
| name | url | admin contact | open registration |
| :--- | :-- | :------------ | :---------------- |
| bookwyrm.social | http://bookwyrm.social/ | mousereeve@riseup.net / @tripofmice@friend.camp | ❌ |