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

View file

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

View file

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

View file

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

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)
# 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(
'<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)

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

View file

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

View file

@ -1,6 +1,7 @@
{% load humanize %}
{% load fr_display %}
{% if not status.deleted %}
<div class="card">
<header class="card-header">
{% include 'snippets/status_header.html' with status=status %}
@ -25,7 +26,29 @@
<span class="icon icon-public">
<span class="is-sr-only">Public post</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>
</div>
</footer>
</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 '''
#TODO: this limit could cause problems
return models.Status.objects.filter(
reply_parent=status
reply_parent=status,
deleted=False,
).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):
''' 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())

View file

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

View file

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

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
import json
import pathlib
from bookwyrm import activitypub, models
from bookwyrm import models
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
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+)' % \
(local_user_path, '|'.join(status_types))
@ -107,6 +107,8 @@ urlpatterns = [
re_path(r'^unfavorite/(?P<status_id>\d+)/?$', actions.unfavorite),
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'^follow/?$', actions.follow),

View file

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

View file

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