diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py
index 03c714a6b..446455fa3 100644
--- a/bookwyrm/activitypub/__init__.py
+++ b/bookwyrm/activitypub/__init__.py
@@ -4,11 +4,12 @@ import sys
from .base_activity import ActivityEncoder, Image, PublicKey, Signature
from .note import Note, GeneratedNote, Article, Comment, Review, Quotation
+from .note import Tombstone
from .interaction import Boost, Like
from .ordered_collection import OrderedCollection, OrderedCollectionPage
from .person import Person
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 Add, Remove
diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py
index 63ac8a6e0..54730fb67 100644
--- a/bookwyrm/activitypub/note.py
+++ b/bookwyrm/activitypub/note.py
@@ -4,6 +4,14 @@ from typing import Dict, List
from .base_activity import ActivityObject, Image
+@dataclass(init=False)
+class Tombstone(ActivityObject):
+ url: str
+ published: str
+ deleted: str
+ type: str = 'Tombstone'
+
+
@dataclass(init=False)
class Note(ActivityObject):
''' Note activity '''
diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py
index 1ae106b0f..bd6d882d3 100644
--- a/bookwyrm/activitypub/verbs.py
+++ b/bookwyrm/activitypub/verbs.py
@@ -21,6 +21,15 @@ class Create(Verb):
type: str = 'Create'
+@dataclass(init=False)
+class Delete(Verb):
+ ''' Create activity '''
+ to: List
+ cc: List
+ signature: Signature
+ type: str = 'Delete'
+
+
@dataclass(init=False)
class Update(Verb):
''' Update activity '''
diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py
index d348e43a7..7fbb99f9b 100644
--- a/bookwyrm/incoming.py
+++ b/bookwyrm/incoming.py
@@ -57,6 +57,7 @@ def shared_inbox(request):
'Accept': handle_follow_accept,
'Reject': handle_follow_reject,
'Create': handle_create,
+ 'Delete': handle_delete_status,
'Like': handle_favorite,
'Announce': handle_boost,
'Add': {
@@ -134,7 +135,9 @@ def handle_follow(activity):
except django.db.utils.IntegrityError as err:
if err.__cause__.diag.constraint_name != 'userfollowrequest_unique':
raise
- relationship = models.UserFollowRequest.objects.get(remote_id=activity['id'])
+ relationship = models.UserFollowRequest.objects.get(
+ remote_id=activity['id']
+ )
# send the accept normally for a duplicate request
if not to_follow.manually_approves_followers:
@@ -143,7 +146,7 @@ def handle_follow(activity):
'FOLLOW',
related_user=actor
)
- outgoing.handle_accept(actor, to_follow, relationship)
+ outgoing.handle_accept(relationship)
else:
# Accept will be triggered manually
status_builder.create_notification(
@@ -194,7 +197,7 @@ def handle_follow_reject(activity):
user_object=rejecter
)
request.delete()
- #raises models.UserFollowRequest.DoesNotExist:
+ #raises models.UserFollowRequest.DoesNotExist
@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
def handle_favorite(activity):
''' approval of your good good post '''
diff --git a/bookwyrm/migrations/0053_auto_20201006_2020.py b/bookwyrm/migrations/0053_auto_20201006_2020.py
new file mode 100644
index 000000000..515fc4463
--- /dev/null
+++ b/bookwyrm/migrations/0053_auto_20201006_2020.py
@@ -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),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0054_auto_20201016_2359.py b/bookwyrm/migrations/0054_auto_20201016_2359.py
new file mode 100644
index 000000000..c8ab34801
--- /dev/null
+++ b/bookwyrm/migrations/0054_auto_20201016_2359.py
@@ -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(),
+ ),
+ ]
diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py
index 6c3369f21..0a70eb77c 100644
--- a/bookwyrm/models/status.py
+++ b/bookwyrm/models/status.py
@@ -22,6 +22,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
sensitive = models.BooleanField(default=False)
# the created date can't be this, because of receiving federated posts
published_date = models.DateTimeField(default=timezone.now)
+ deleted = models.BooleanField(default=False)
+ deleted_date = models.DateTimeField()
favorites = models.ManyToManyField(
'User',
symmetrical=False,
@@ -104,6 +106,18 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
**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):
''' these are app-generated messages about user activity '''
@property
@@ -112,7 +126,7 @@ class GeneratedStatus(Status):
message = self.content
books = ', '.join(
'"%s"' % (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)
diff --git a/bookwyrm/outgoing.py b/bookwyrm/outgoing.py
index 2c4c6def9..f496a2110 100644
--- a/bookwyrm/outgoing.py
+++ b/bookwyrm/outgoing.py
@@ -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_tag, create_notification, create_rating
from bookwyrm.status import create_generated_note
+from bookwyrm.status import delete_status
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)
-def handle_accept(user, to_follow, follow_request):
+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()
@@ -91,10 +94,12 @@ def handle_accept(user, to_follow, follow_request):
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 '''
- activity = relationship.to_reject_activity(user)
- relationship.delete()
+ user = follow_request.user_subject
+ to_follow = follow_request.user_object
+ activity = follow_request.to_reject_activity()
+ follow_request.delete()
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
@@ -107,11 +112,16 @@ def handle_shelve(user, book, shelf):
broadcast(user, shelve.to_add_activity(user))
# tell the world about this cool thing that happened
- message = {
- 'to-read': 'wants to read',
- 'reading': 'started reading',
- 'read': 'finished reading'
- }[shelf.identifier]
+ try:
+ message = {
+ 'to-read': 'wants to read',
+ 'reading': 'started reading',
+ 'read': 'finished reading'
+ }[shelf.identifier]
+ except KeyError:
+ # it's a non-standard shelf, don't worry about it
+ return
+
status = create_generated_note(user, message, mention_books=[book])
status.save()
@@ -193,6 +203,12 @@ def handle_import_books(user, items):
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):
''' a review that's just a rating '''
builder = create_rating
diff --git a/bookwyrm/status.py b/bookwyrm/status.py
index 0c13638e2..256198399 100644
--- a/bookwyrm/status.py
+++ b/bookwyrm/status.py
@@ -1,4 +1,5 @@
''' Handle user activity '''
+from datetime import datetime
from django.db import IntegrityError
from bookwyrm import models
@@ -6,6 +7,12 @@ from bookwyrm.books_manager import get_or_create_book
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):
''' a review that's just a rating '''
if not rating or rating < 1 or rating > 5:
diff --git a/bookwyrm/templates/snippets/status.html b/bookwyrm/templates/snippets/status.html
index f1fab7427..5c570e0d6 100644
--- a/bookwyrm/templates/snippets/status.html
+++ b/bookwyrm/templates/snippets/status.html
@@ -1,6 +1,7 @@
{% load humanize %}
{% load fr_display %}
+{% if not status.deleted %}
+{% else %}
+
+
+
+{% endif %}
diff --git a/bookwyrm/templatetags/fr_display.py b/bookwyrm/templatetags/fr_display.py
index 818eae2af..cb4ee4198 100644
--- a/bookwyrm/templatetags/fr_display.py
+++ b/bookwyrm/templatetags/fr_display.py
@@ -42,7 +42,8 @@ def get_replies(status):
''' get all direct replies to a status '''
#TODO: this limit could cause problems
return models.Status.objects.filter(
- reply_parent=status
+ reply_parent=status,
+ deleted=False,
).select_subclasses().all()[:10]
diff --git a/bookwyrm/tests/incoming/__init__.py b/bookwyrm/tests/incoming/__init__.py
new file mode 100644
index 000000000..b6e690fd5
--- /dev/null
+++ b/bookwyrm/tests/incoming/__init__.py
@@ -0,0 +1 @@
+from . import *
diff --git a/bookwyrm/tests/test_incoming_favorite.py b/bookwyrm/tests/incoming/test_favorite.py
similarity index 95%
rename from bookwyrm/tests/test_incoming_favorite.py
rename to bookwyrm/tests/incoming/test_favorite.py
index 035021452..eeba9000a 100644
--- a/bookwyrm/tests/test_incoming_favorite.py
+++ b/bookwyrm/tests/incoming/test_favorite.py
@@ -6,7 +6,6 @@ from bookwyrm import models, incoming
class Favorite(TestCase):
- ''' not too much going on in the books model but here we are '''
def setUp(self):
self.remote_user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword',
@@ -25,7 +24,7 @@ class Favorite(TestCase):
)
datafile = pathlib.Path(__file__).parent.joinpath(
- 'data/ap_user.json'
+ '../data/ap_user.json'
)
self.user_data = json.loads(datafile.read_bytes())
diff --git a/bookwyrm/tests/test_incoming_follow.py b/bookwyrm/tests/incoming/test_follow.py
similarity index 96%
rename from bookwyrm/tests/test_incoming_follow.py
rename to bookwyrm/tests/incoming/test_follow.py
index b5fbec008..51ab3c43a 100644
--- a/bookwyrm/tests/test_incoming_follow.py
+++ b/bookwyrm/tests/incoming/test_follow.py
@@ -1,11 +1,9 @@
-import json
from django.test import TestCase
from bookwyrm import models, incoming
-class Follow(TestCase):
- ''' not too much going on in the books model but here we are '''
+class IncomingFollow(TestCase):
def setUp(self):
self.remote_user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword',
diff --git a/bookwyrm/tests/test_incoming_follow_accept.py b/bookwyrm/tests/incoming/test_follow_accept.py
similarity index 95%
rename from bookwyrm/tests/test_incoming_follow_accept.py
rename to bookwyrm/tests/incoming/test_follow_accept.py
index c30dc2486..ba88bb40d 100644
--- a/bookwyrm/tests/test_incoming_follow_accept.py
+++ b/bookwyrm/tests/incoming/test_follow_accept.py
@@ -1,11 +1,9 @@
-import json
from django.test import TestCase
from bookwyrm import models, incoming
class IncomingFollowAccept(TestCase):
- ''' not too much going on in the books model but here we are '''
def setUp(self):
self.remote_user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword',
diff --git a/bookwyrm/tests/outgoing/__init__.py b/bookwyrm/tests/outgoing/__init__.py
new file mode 100644
index 000000000..b6e690fd5
--- /dev/null
+++ b/bookwyrm/tests/outgoing/__init__.py
@@ -0,0 +1 @@
+from . import *
diff --git a/bookwyrm/tests/outgoing/test_follow.py b/bookwyrm/tests/outgoing/test_follow.py
new file mode 100644
index 000000000..82a476f62
--- /dev/null
+++ b/bookwyrm/tests/outgoing/test_follow.py
@@ -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
+ )
diff --git a/bookwyrm/tests/outgoing/test_shelving.py b/bookwyrm/tests/outgoing/test_shelving.py
new file mode 100644
index 000000000..acf816e11
--- /dev/null
+++ b/bookwyrm/tests/outgoing/test_shelving.py
@@ -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)
diff --git a/bookwyrm/tests/status/test_quotation.py b/bookwyrm/tests/status/test_quotation.py
index 57755560a..4892e21d3 100644
--- a/bookwyrm/tests/status/test_quotation.py
+++ b/bookwyrm/tests/status/test_quotation.py
@@ -1,8 +1,6 @@
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
diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py
index f1b33877a..331efee5f 100644
--- a/bookwyrm/urls.py
+++ b/bookwyrm/urls.py
@@ -11,7 +11,7 @@ localname_regex = r'(?P[\w\-_]+)'
user_path = r'^user/%s' % username_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\d+)' % \
(local_user_path, '|'.join(status_types))
@@ -107,6 +107,8 @@ urlpatterns = [
re_path(r'^unfavorite/(?P\d+)/?$', actions.unfavorite),
re_path(r'^boost/(?P\d+)/?$', actions.boost),
+ re_path(r'^delete-status/?$', actions.delete_status),
+
re_path(r'^shelve/?$', actions.shelve),
re_path(r'^follow/?$', actions.follow),
diff --git a/bookwyrm/view_actions.py b/bookwyrm/view_actions.py
index 992a270dd..54ed353a0 100644
--- a/bookwyrm/view_actions.py
+++ b/bookwyrm/view_actions.py
@@ -418,6 +418,27 @@ def boost(request, status_id):
outgoing.handle_boost(request.user, status)
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
def follow(request):
''' follow another user, here or abroad '''
@@ -473,7 +494,7 @@ def accept_follow_request(request):
# Request already dealt with.
pass
else:
- outgoing.handle_accept(requester, request.user, follow_request)
+ outgoing.handle_accept(follow_request)
return redirect('/user/%s' % request.user.localname)
@@ -495,7 +516,7 @@ def delete_follow_request(request):
except models.UserFollowRequest.DoesNotExist:
return HttpResponseBadRequest()
- outgoing.handle_reject(requester, request.user, follow_request)
+ outgoing.handle_reject(follow_request)
return redirect('/user/%s' % request.user.localname)
diff --git a/bookwyrm/views.py b/bookwyrm/views.py
index 9217c4b3e..55957ff29 100644
--- a/bookwyrm/views.py
+++ b/bookwyrm/views.py
@@ -118,7 +118,7 @@ def get_activity_feed(user, filter_level, model=models.Status):
activities = model
if hasattr(model, 'objects'):
- activities = model.objects
+ activities = model.objects.filter(deleted=False)
activities = activities.order_by(
'-created_date'