Merge branch 'main' into production

This commit is contained in:
Mouse Reeve 2020-10-16 17:09:43 -07:00
commit 5446a5b238
22 changed files with 354 additions and 29 deletions

View file

@ -4,11 +4,12 @@ import sys
from .base_activity import ActivityEncoder, Image, PublicKey, Signature from .base_activity import ActivityEncoder, Image, PublicKey, Signature
from .note import Note, GeneratedNote, Article, Comment, Review, Quotation from .note import Note, GeneratedNote, Article, Comment, Review, Quotation
from .note import Tombstone
from .interaction import Boost, Like from .interaction import Boost, Like
from .ordered_collection import OrderedCollection, OrderedCollectionPage from .ordered_collection import OrderedCollection, OrderedCollectionPage
from .person import Person from .person import Person
from .book import Edition, Work, Author from .book import Edition, Work, Author
from .verbs import Create, Undo, Update from .verbs import Create, Delete, Undo, Update
from .verbs import Follow, Accept, Reject from .verbs import Follow, Accept, Reject
from .verbs import Add, Remove from .verbs import Add, Remove

View file

@ -4,6 +4,14 @@ from typing import Dict, List
from .base_activity import ActivityObject, Image from .base_activity import ActivityObject, Image
@dataclass(init=False)
class Tombstone(ActivityObject):
url: str
published: str
deleted: str
type: str = 'Tombstone'
@dataclass(init=False) @dataclass(init=False)
class Note(ActivityObject): class Note(ActivityObject):
''' Note activity ''' ''' Note activity '''

View file

@ -21,6 +21,15 @@ class Create(Verb):
type: str = 'Create' type: str = 'Create'
@dataclass(init=False)
class Delete(Verb):
''' Create activity '''
to: List
cc: List
signature: Signature
type: str = 'Delete'
@dataclass(init=False) @dataclass(init=False)
class Update(Verb): class Update(Verb):
''' Update activity ''' ''' Update activity '''

View file

@ -57,6 +57,7 @@ def shared_inbox(request):
'Accept': handle_follow_accept, 'Accept': handle_follow_accept,
'Reject': handle_follow_reject, 'Reject': handle_follow_reject,
'Create': handle_create, 'Create': handle_create,
'Delete': handle_delete_status,
'Like': handle_favorite, 'Like': handle_favorite,
'Announce': handle_boost, 'Announce': handle_boost,
'Add': { 'Add': {
@ -134,7 +135,9 @@ def handle_follow(activity):
except django.db.utils.IntegrityError as err: except django.db.utils.IntegrityError as err:
if err.__cause__.diag.constraint_name != 'userfollowrequest_unique': if err.__cause__.diag.constraint_name != 'userfollowrequest_unique':
raise raise
relationship = models.UserFollowRequest.objects.get(remote_id=activity['id']) relationship = models.UserFollowRequest.objects.get(
remote_id=activity['id']
)
# send the accept normally for a duplicate request # send the accept normally for a duplicate request
if not to_follow.manually_approves_followers: if not to_follow.manually_approves_followers:
@ -143,7 +146,7 @@ def handle_follow(activity):
'FOLLOW', 'FOLLOW',
related_user=actor related_user=actor
) )
outgoing.handle_accept(actor, to_follow, relationship) outgoing.handle_accept(relationship)
else: else:
# Accept will be triggered manually # Accept will be triggered manually
status_builder.create_notification( status_builder.create_notification(
@ -194,7 +197,7 @@ def handle_follow_reject(activity):
user_object=rejecter user_object=rejecter
) )
request.delete() request.delete()
#raises models.UserFollowRequest.DoesNotExist: #raises models.UserFollowRequest.DoesNotExist
@app.task @app.task
@ -235,6 +238,20 @@ def handle_create(activity):
) )
@app.task
def handle_delete_status(activity):
''' remove a status '''
status_id = activity['object']['id']
try:
status = models.Status.objects.select_subclasses().get(
remote_id=status_id
)
except models.Status.DoesNotExist:
return
status_builder.delete_status(status)
@app.task @app.task
def handle_favorite(activity): def handle_favorite(activity):
''' approval of your good good post ''' ''' approval of your good good post '''

View file

@ -0,0 +1,24 @@
# Generated by Django 3.0.7 on 2020-10-06 20:20
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0052_auto_20201005_2145'),
]
operations = [
migrations.AddField(
model_name='status',
name='deleted',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='status',
name='deleted_date',
field=models.DateTimeField(default=django.utils.timezone.now),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2020-10-16 23:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0053_auto_20201006_2020'),
]
operations = [
migrations.AlterField(
model_name='status',
name='deleted_date',
field=models.DateTimeField(),
),
]

View file

@ -22,6 +22,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
sensitive = models.BooleanField(default=False) sensitive = models.BooleanField(default=False)
# the created date can't be this, because of receiving federated posts # the created date can't be this, because of receiving federated posts
published_date = models.DateTimeField(default=timezone.now) published_date = models.DateTimeField(default=timezone.now)
deleted = models.BooleanField(default=False)
deleted_date = models.DateTimeField()
favorites = models.ManyToManyField( favorites = models.ManyToManyField(
'User', 'User',
symmetrical=False, symmetrical=False,
@ -104,6 +106,18 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
**kwargs **kwargs
) )
def to_activity(self, **kwargs):
''' return tombstone if the status is deleted '''
if self.deleted:
return activitypub.Tombstone(
id=self.remote_id,
url=self.remote_id,
deleted=http_date(self.deleted_date.timestamp()),
published=http_date(self.deleted_date.timestamp()),
).serialize()
return ActivitypubMixin.to_activity(self, **kwargs)
class GeneratedStatus(Status): class GeneratedStatus(Status):
''' these are app-generated messages about user activity ''' ''' these are app-generated messages about user activity '''
@property @property
@ -112,7 +126,7 @@ class GeneratedStatus(Status):
message = self.content message = self.content
books = ', '.join( books = ', '.join(
'<a href="%s">"%s"</a>' % (self.book.local_id, self.book.title) \ '<a href="%s">"%s"</a>' % (self.book.local_id, self.book.title) \
for book in self.mention_books for book in self.mention_books.all()
) )
return '%s %s' % (message, books) return '%s %s' % (message, books)

View file

@ -13,6 +13,7 @@ from bookwyrm.status import create_review, create_status
from bookwyrm.status import create_quotation, create_comment from bookwyrm.status import create_quotation, create_comment
from bookwyrm.status import create_tag, create_notification, create_rating from bookwyrm.status import create_tag, create_notification, create_rating
from bookwyrm.status import create_generated_note from bookwyrm.status import create_generated_note
from bookwyrm.status import delete_status
from bookwyrm.remote_user import get_or_create_remote_user from bookwyrm.remote_user import get_or_create_remote_user
@ -80,8 +81,10 @@ def handle_unfollow(user, to_unfollow):
to_unfollow.followers.remove(user) to_unfollow.followers.remove(user)
def handle_accept(user, to_follow, follow_request): def handle_accept(follow_request):
''' send an acceptance message to a follow request ''' ''' send an acceptance message to a follow request '''
user = follow_request.user_subject
to_follow = follow_request.user_object
with transaction.atomic(): with transaction.atomic():
relationship = models.UserFollows.from_request(follow_request) relationship = models.UserFollows.from_request(follow_request)
follow_request.delete() follow_request.delete()
@ -91,10 +94,12 @@ def handle_accept(user, to_follow, follow_request):
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user]) broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
def handle_reject(user, to_follow, relationship): def handle_reject(follow_request):
''' a local user who managed follows rejects a follow request ''' ''' a local user who managed follows rejects a follow request '''
activity = relationship.to_reject_activity(user) user = follow_request.user_subject
relationship.delete() to_follow = follow_request.user_object
activity = follow_request.to_reject_activity()
follow_request.delete()
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user]) broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
@ -107,11 +112,16 @@ def handle_shelve(user, book, shelf):
broadcast(user, shelve.to_add_activity(user)) broadcast(user, shelve.to_add_activity(user))
# tell the world about this cool thing that happened # tell the world about this cool thing that happened
message = { try:
'to-read': 'wants to read', message = {
'reading': 'started reading', 'to-read': 'wants to read',
'read': 'finished reading' 'reading': 'started reading',
}[shelf.identifier] 'read': 'finished reading'
}[shelf.identifier]
except KeyError:
# it's a non-standard shelf, don't worry about it
return
status = create_generated_note(user, message, mention_books=[book]) status = create_generated_note(user, message, mention_books=[book])
status.save() status.save()
@ -193,6 +203,12 @@ def handle_import_books(user, items):
return None return None
def handle_delete_status(user, status):
''' delete a status and broadcast deletion to other servers '''
delete_status(status)
broadcast(user, status.to_activity())
def handle_rate(user, book, rating): def handle_rate(user, book, rating):
''' a review that's just a rating ''' ''' a review that's just a rating '''
builder = create_rating builder = create_rating

View file

@ -1,4 +1,5 @@
''' Handle user activity ''' ''' Handle user activity '''
from datetime import datetime
from django.db import IntegrityError from django.db import IntegrityError
from bookwyrm import models from bookwyrm import models
@ -6,6 +7,12 @@ from bookwyrm.books_manager import get_or_create_book
from bookwyrm.sanitize_html import InputHtmlParser from bookwyrm.sanitize_html import InputHtmlParser
def delete_status(status):
''' replace the status with a tombstone '''
status.deleted = True
status.deleted_date = datetime.now()
status.save()
def create_rating(user, book, rating): def create_rating(user, book, rating):
''' a review that's just a rating ''' ''' a review that's just a rating '''
if not rating or rating < 1 or rating > 5: if not rating or rating < 1 or rating > 5:

View file

@ -1,6 +1,7 @@
{% load humanize %} {% load humanize %}
{% load fr_display %} {% load fr_display %}
{% if not status.deleted %}
<div class="card"> <div class="card">
<header class="card-header"> <header class="card-header">
{% include 'snippets/status_header.html' with status=status %} {% include 'snippets/status_header.html' with status=status %}
@ -25,7 +26,29 @@
<span class="icon icon-public"> <span class="icon icon-public">
<span class="is-sr-only">Public post</span> <span class="is-sr-only">Public post</span>
</span> </span>
{% if status.user == request.user %}
<form name="delete-{{status.id}}" action="/delete-status" method="post">
{% csrf_token %}
<input type="hidden" name="status" value="{{ status.id }}">
<button type="submit">
<span class="icon icon-cancel">
<span class="is-sr-only">Delete post</span>
</span>
</button>
</form>
{% endif %}
<a href="{{ status.remote_id }}">{{ status.published_date | naturaltime }}</a> <a href="{{ status.remote_id }}">{{ status.published_date | naturaltime }}</a>
</div> </div>
</footer> </footer>
</div> </div>
{% else %}
<div class="card">
<header class="card-header">
<p>
{% include 'snippets/avatar.html' with user=status.user %}
{% include 'snippets/username.html' with user=status.user %}
deleted this status
</p>
</header>
</div>
{% endif %}

View file

@ -42,7 +42,8 @@ def get_replies(status):
''' get all direct replies to a status ''' ''' get all direct replies to a status '''
#TODO: this limit could cause problems #TODO: this limit could cause problems
return models.Status.objects.filter( return models.Status.objects.filter(
reply_parent=status reply_parent=status,
deleted=False,
).select_subclasses().all()[:10] ).select_subclasses().all()[:10]

View file

@ -0,0 +1 @@
from . import *

View file

@ -6,7 +6,6 @@ from bookwyrm import models, incoming
class Favorite(TestCase): class Favorite(TestCase):
''' not too much going on in the books model but here we are '''
def setUp(self): def setUp(self):
self.remote_user = models.User.objects.create_user( self.remote_user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword', 'rat', 'rat@rat.com', 'ratword',
@ -25,7 +24,7 @@ class Favorite(TestCase):
) )
datafile = pathlib.Path(__file__).parent.joinpath( datafile = pathlib.Path(__file__).parent.joinpath(
'data/ap_user.json' '../data/ap_user.json'
) )
self.user_data = json.loads(datafile.read_bytes()) self.user_data = json.loads(datafile.read_bytes())

View file

@ -1,11 +1,9 @@
import json
from django.test import TestCase from django.test import TestCase
from bookwyrm import models, incoming from bookwyrm import models, incoming
class Follow(TestCase): class IncomingFollow(TestCase):
''' not too much going on in the books model but here we are '''
def setUp(self): def setUp(self):
self.remote_user = models.User.objects.create_user( self.remote_user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword', 'rat', 'rat@rat.com', 'ratword',

View file

@ -1,11 +1,9 @@
import json
from django.test import TestCase from django.test import TestCase
from bookwyrm import models, incoming from bookwyrm import models, incoming
class IncomingFollowAccept(TestCase): class IncomingFollowAccept(TestCase):
''' not too much going on in the books model but here we are '''
def setUp(self): def setUp(self):
self.remote_user = models.User.objects.create_user( self.remote_user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword', 'rat', 'rat@rat.com', 'ratword',

View file

@ -0,0 +1 @@
from . import *

View file

@ -0,0 +1,72 @@
from django.test import TestCase
from bookwyrm import models, outgoing
class Following(TestCase):
def setUp(self):
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',
)
self.local_user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword',
local=True,
remote_id='http://local.com/users/mouse',
)
def test_handle_follow(self):
self.assertEqual(models.UserFollowRequest.objects.count(), 0)
outgoing.handle_follow(self.local_user, self.remote_user)
rel = models.UserFollowRequest.objects.get()
self.assertEqual(rel.user_subject, self.local_user)
self.assertEqual(rel.user_object, self.remote_user)
self.assertEqual(rel.status, 'follow_request')
def test_handle_unfollow(self):
self.remote_user.followers.add(self.local_user)
self.assertEqual(self.remote_user.followers.count(), 1)
outgoing.handle_unfollow(self.local_user, self.remote_user)
self.assertEqual(self.remote_user.followers.count(), 0)
def test_handle_accept(self):
rel = models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
rel_id = rel.id
outgoing.handle_accept(rel)
# request should be deleted
self.assertEqual(
models.UserFollowRequest.objects.filter(id=rel_id).count(), 0
)
# follow relationship should exist
self.assertEqual(self.remote_user.followers.first(), self.local_user)
def test_handle_reject(self):
rel = models.UserFollowRequest.objects.create(
user_subject=self.local_user,
user_object=self.remote_user
)
rel_id = rel.id
outgoing.handle_reject(rel)
# request should be deleted
self.assertEqual(
models.UserFollowRequest.objects.filter(id=rel_id).count(), 0
)
# follow relationship should not exist
self.assertEqual(
models.UserFollows.objects.filter(id=rel_id).count(), 0
)

View file

@ -0,0 +1,97 @@
from django.test import TestCase
from bookwyrm import models, outgoing
class Shelving(TestCase):
def setUp(self):
self.user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword',
local=True,
remote_id='http://local.com/users/mouse',
)
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
)
self.shelf = models.Shelf.objects.create(
name='Test Shelf',
identifier='test-shelf',
user=self.user
)
def test_handle_shelve(self):
outgoing.handle_shelve(self.user, self.book, self.shelf)
# make sure the book is on the shelf
self.assertEqual(self.shelf.books.get(), self.book)
def test_handle_shelve_to_read(self):
shelf = models.Shelf.objects.get(identifier='to-read')
outgoing.handle_shelve(self.user, self.book, shelf)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
# it should have posted a status about this
status = models.GeneratedStatus.objects.get()
self.assertEqual(status.content, 'wants to read')
self.assertEqual(status.user, self.user)
self.assertEqual(status.mention_books.count(), 1)
self.assertEqual(status.mention_books.first(), self.book)
# and it should not create a read-through
self.assertEqual(models.ReadThrough.objects.count(), 0)
def test_handle_shelve_reading(self):
shelf = models.Shelf.objects.get(identifier='reading')
outgoing.handle_shelve(self.user, self.book, shelf)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
# it should have posted a status about this
status = models.GeneratedStatus.objects.order_by('-published_date').first()
self.assertEqual(status.content, 'started reading')
self.assertEqual(status.user, self.user)
self.assertEqual(status.mention_books.count(), 1)
self.assertEqual(status.mention_books.first(), self.book)
# and it should create a read-through
readthrough = models.ReadThrough.objects.get()
self.assertEqual(readthrough.user, self.user)
self.assertEqual(readthrough.book.id, self.book.id)
self.assertIsNotNone(readthrough.start_date)
self.assertIsNone(readthrough.finish_date)
def test_handle_shelve_read(self):
shelf = models.Shelf.objects.get(identifier='read')
outgoing.handle_shelve(self.user, self.book, shelf)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
# it should have posted a status about this
status = models.GeneratedStatus.objects.order_by('-published_date').first()
self.assertEqual(status.content, 'finished reading')
self.assertEqual(status.user, self.user)
self.assertEqual(status.mention_books.count(), 1)
self.assertEqual(status.mention_books.first(), self.book)
# and it should update the existing read-through
readthrough = models.ReadThrough.objects.get()
self.assertEqual(readthrough.user, self.user)
self.assertEqual(readthrough.book.id, self.book.id)
self.assertIsNotNone(readthrough.start_date)
self.assertIsNotNone(readthrough.finish_date)
def test_handle_unshelve(self):
self.shelf.books.add(self.book)
self.shelf.save()
self.assertEqual(self.shelf.books.count(), 1)
outgoing.handle_unshelve(self.user, self.book, self.shelf)
self.assertEqual(self.shelf.books.count(), 0)

View file

@ -1,8 +1,6 @@
from django.test import TestCase from django.test import TestCase
import json
import pathlib
from bookwyrm import activitypub, models from bookwyrm import models
from bookwyrm import status as status_builder from bookwyrm import status as status_builder

View file

@ -11,7 +11,7 @@ localname_regex = r'(?P<username>[\w\-_]+)'
user_path = r'^user/%s' % username_regex user_path = r'^user/%s' % username_regex
local_user_path = r'^user/%s' % localname_regex local_user_path = r'^user/%s' % localname_regex
status_types = ['status', 'review', 'comment', 'quotation', 'boost'] status_types = ['status', 'review', 'comment', 'quotation', 'boost', 'generatedstatus']
status_path = r'%s/(%s)/(?P<status_id>\d+)' % \ status_path = r'%s/(%s)/(?P<status_id>\d+)' % \
(local_user_path, '|'.join(status_types)) (local_user_path, '|'.join(status_types))
@ -107,6 +107,8 @@ urlpatterns = [
re_path(r'^unfavorite/(?P<status_id>\d+)/?$', actions.unfavorite), re_path(r'^unfavorite/(?P<status_id>\d+)/?$', actions.unfavorite),
re_path(r'^boost/(?P<status_id>\d+)/?$', actions.boost), re_path(r'^boost/(?P<status_id>\d+)/?$', actions.boost),
re_path(r'^delete-status/?$', actions.delete_status),
re_path(r'^shelve/?$', actions.shelve), re_path(r'^shelve/?$', actions.shelve),
re_path(r'^follow/?$', actions.follow), re_path(r'^follow/?$', actions.follow),

View file

@ -418,6 +418,27 @@ def boost(request, status_id):
outgoing.handle_boost(request.user, status) outgoing.handle_boost(request.user, status)
return redirect(request.headers.get('Referer', '/')) return redirect(request.headers.get('Referer', '/'))
@login_required
def delete_status(request):
''' delete and tombstone a status '''
status_id = request.POST.get('status')
if not status_id:
return HttpResponseBadRequest()
try:
status = models.Status.objects.get(id=status_id)
except models.Status.DoesNotExist:
return HttpResponseBadRequest()
# don't let people delete other people's statuses
if status.user != request.user:
return HttpResponseBadRequest()
# perform deletion
outgoing.handle_delete_status(request.user, status)
return redirect(request.headers.get('Referer', '/'))
@login_required @login_required
def follow(request): def follow(request):
''' follow another user, here or abroad ''' ''' follow another user, here or abroad '''
@ -473,7 +494,7 @@ def accept_follow_request(request):
# Request already dealt with. # Request already dealt with.
pass pass
else: else:
outgoing.handle_accept(requester, request.user, follow_request) outgoing.handle_accept(follow_request)
return redirect('/user/%s' % request.user.localname) return redirect('/user/%s' % request.user.localname)
@ -495,7 +516,7 @@ def delete_follow_request(request):
except models.UserFollowRequest.DoesNotExist: except models.UserFollowRequest.DoesNotExist:
return HttpResponseBadRequest() return HttpResponseBadRequest()
outgoing.handle_reject(requester, request.user, follow_request) outgoing.handle_reject(follow_request)
return redirect('/user/%s' % request.user.localname) return redirect('/user/%s' % request.user.localname)

View file

@ -118,7 +118,7 @@ def get_activity_feed(user, filter_level, model=models.Status):
activities = model activities = model
if hasattr(model, 'objects'): if hasattr(model, 'objects'):
activities = model.objects activities = model.objects.filter(deleted=False)
activities = activities.order_by( activities = activities.order_by(
'-created_date' '-created_date'