Adds commenting

works on #59
This commit is contained in:
Mouse Reeve 2020-03-21 16:50:49 -07:00
parent 3f96c8cd9d
commit 7862af9729
16 changed files with 222 additions and 31 deletions

View file

@ -1,8 +1,12 @@
''' bring activitypub functions into the namespace ''' ''' bring activitypub functions into the namespace '''
from .actor import get_actor from .actor import get_actor
from .collection import get_outbox, get_outbox_page, get_add, get_remove, \ from .collection import get_outbox, get_outbox_page
get_following, get_followers from .collection import get_add, get_remove
from .collection import get_following, get_followers
from .create import get_create from .create import get_create
from .follow import get_follow_request, get_unfollow, get_accept, get_reject from .follow import get_follow_request, get_unfollow, get_accept, get_reject
from .status import get_review, get_review_article, get_status, get_replies, \ from .status import get_review, get_review_article
get_favorite, get_unfavorite, get_add_tag, get_remove_tag, get_replies_page from .status import get_comment, get_comment_article
from .status import get_status, get_replies, get_replies_page
from .status import get_favorite, get_unfavorite
from .status import get_add_tag, get_remove_tag

View file

@ -12,6 +12,15 @@ def get_review(review):
return status return status
def get_comment(comment):
''' fedireads json for book reviews '''
status = get_status(comment)
status['inReplyToBook'] = comment.book.absolute_id
status['fedireadsType'] = comment.status_type
status['name'] = comment.name
return status
def get_review_article(review): def get_review_article(review):
''' a book review formatted for a non-fedireads isntance (mastodon) ''' ''' a book review formatted for a non-fedireads isntance (mastodon) '''
status = get_status(review) status = get_status(review)
@ -24,6 +33,17 @@ def get_review_article(review):
return status return status
def get_comment_article(comment):
''' a book comment formatted for a non-fedireads isntance (mastodon) '''
status = get_status(comment)
name = '%s (comment on "%s")' % (
comment.name,
comment.book.title
)
status['name'] = name
return status
def get_status(status): def get_status(status):
''' create activitypub json for a status ''' ''' create activitypub json for a status '''
user = status.user user = status.user

View file

@ -41,6 +41,17 @@ class ReviewForm(ModelForm):
class CommentForm(ModelForm): class CommentForm(ModelForm):
class Meta:
model = models.Comment
fields = ['name', 'content']
help_texts = {f: None for f in fields}
labels = {
'name': 'Title',
'content': 'Comment',
}
class ReplyForm(ModelForm):
class Meta: class Meta:
model = models.Status model = models.Status
fields = ['content'] fields = ['content']

View file

@ -236,6 +236,19 @@ def handle_incoming_create(activity):
) )
except ValueError: except ValueError:
return HttpResponseBadRequest() return HttpResponseBadRequest()
elif activity['object'].get('fedireadsType') == 'Comment' and \
'inReplyToBook' in activity['object']:
if user.local:
comment_id = activity['object']['id'].split('/')[-1]
models.Comment.objects.get(id=comment_id)
else:
try:
status_builder.create_comment_from_activity(
user,
activity['object']
)
except ValueError:
return HttpResponseBadRequest()
elif not user.local: elif not user.local:
try: try:
status = status_builder.create_status_from_activity( status = status_builder.create_status_from_activity(

View file

@ -0,0 +1,26 @@
# Generated by Django 3.0.3 on 2020-03-21 22:43
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('fedireads', '0018_favorite_remote_id'),
]
operations = [
migrations.CreateModel(
name='Comment',
fields=[
('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='fedireads.Status')),
('name', models.CharField(max_length=255)),
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')),
],
options={
'abstract': False,
},
bases=('fedireads.status',),
),
]

View file

@ -1,5 +1,6 @@
''' bring all the models into the app namespace ''' ''' bring all the models into the app namespace '''
from .book import Book, Work, Edition, Author from .book import Book, Work, Edition, Author
from .shelf import Shelf, ShelfBook from .shelf import Shelf, ShelfBook
from .status import Status, Review, Favorite, Tag, Notification from .status import Status, Review, Comment, Favorite, Tag, Notification
from .user import User, FederatedServer, UserFollows, UserFollowRequest, UserBlocks from .user import User, FederatedServer, UserFollows, UserFollowRequest, \
UserBlocks

View file

@ -46,6 +46,17 @@ class Status(FedireadsModel):
return '%s/%s/%d' % (base_path, model_name, self.id) return '%s/%s/%d' % (base_path, model_name, self.id)
class Comment(Status):
''' like a review but without a rating and transient '''
name = models.CharField(max_length=255)
book = models.ForeignKey('Book', on_delete=models.PROTECT)
def save(self, *args, **kwargs):
self.status_type = 'Comment'
self.activity_type = 'Article'
super().save(*args, **kwargs)
class Review(Status): class Review(Status):
''' a book review ''' ''' a book review '''
name = models.CharField(max_length=255) name = models.CharField(max_length=255)

View file

@ -8,7 +8,7 @@ from urllib.parse import urlencode
from fedireads import activitypub from fedireads import activitypub
from fedireads import models from fedireads import models
from fedireads.status import create_review, create_status, create_tag, \ from fedireads.status import create_review, create_status, create_tag, \
create_notification create_notification, create_comment
from fedireads.remote_user import get_or_create_remote_user from fedireads.remote_user import get_or_create_remote_user
from fedireads.broadcast import get_recipients, broadcast from fedireads.broadcast import get_recipients, broadcast
@ -175,6 +175,24 @@ def handle_review(user, book, name, content, rating):
broadcast(user, article_create_activity, other_recipients) broadcast(user, article_create_activity, other_recipients)
def handle_comment(user, book, name, content):
''' post a review '''
# validated and saves the review in the database so it has an id
comment = create_comment(user, book, name, content)
comment_activity = activitypub.get_comment(comment)
comment_create_activity = activitypub.get_create(user, comment_activity)
fr_recipients = get_recipients(user, 'public', limit='fedireads')
broadcast(user, comment_create_activity, fr_recipients)
# re-format the activity for non-fedireads servers
article_activity = activitypub.get_comment_article(comment)
article_create_activity = activitypub.get_create(user, article_activity)
other_recipients = get_recipients(user, 'public', limit='other')
broadcast(user, article_create_activity, other_recipients)
def handle_tag(user, book, name): def handle_tag(user, book, name):
''' tag a book ''' ''' tag a book '''
tag = create_tag(user, book, name) tag = create_tag(user, book, name)
@ -195,19 +213,19 @@ def handle_untag(user, book, name):
broadcast(user, tag_activity, recipients) broadcast(user, tag_activity, recipients)
def handle_comment(user, review, content): def handle_reply(user, review, content):
''' respond to a review or status ''' ''' respond to a review or status '''
# validated and saves the comment in the database so it has an id # validated and saves the comment in the database so it has an id
comment = create_status(user, content, reply_parent=review) reply = create_status(user, content, reply_parent=review)
if comment.reply_parent: if reply.reply_parent:
create_notification( create_notification(
comment.reply_parent.user, reply.reply_parent.user,
'REPLY', 'REPLY',
related_user=user, related_user=user,
related_status=comment, related_status=reply,
) )
comment_activity = activitypub.get_status(comment) reply_activity = activitypub.get_status(reply)
create_activity = activitypub.get_create(user, comment_activity) create_activity = activitypub.get_create(user, reply_activity)
recipients = get_recipients(user, 'public') recipients = get_recipients(user, 'public')
broadcast(user, create_activity, recipients) broadcast(user, create_activity, recipients)

View file

@ -18,13 +18,39 @@ function interact(e) {
return true; return true;
} }
function comment(e) { function reply(e) {
e.preventDefault(); e.preventDefault();
ajaxPost(e.target); ajaxPost(e.target);
// TODO: display comment // TODO: display comment
return true; return true;
} }
function tabChange(e) {
e.preventDefault();
var target = e.target.parentElement;
var identifier = target.getAttribute('data-id');
var options_class = target.getAttribute('data-category');
var options = document.getElementsByClassName(options_class);
for (var i = 0; i < options.length; i++) {
if (!options[i].className.includes('hidden')) {
options[i].className += ' hidden';
}
}
var tabs = target.parentElement.children;
for (i = 0; i < tabs.length; i++) {
if (tabs[i].getAttribute('data-id') == identifier) {
tabs[i].className += ' active';
} else {
tabs[i].className = tabs[i].className.replace('active', '');
}
}
var el = document.getElementById(identifier);
el.className = el.className.replace('hidden', '');
}
function ajaxPost(form) { function ajaxPost(form) {
fetch(form.action, { fetch(form.action, {
method : "POST", method : "POST",

View file

@ -41,6 +41,36 @@ def create_review(user, possible_book, name, content, rating):
) )
def create_comment_from_activity(author, activity):
''' parse an activity json blob into a status '''
book = activity['inReplyToBook']
book = book.split('/')[-1]
name = activity.get('name')
content = activity.get('content')
published = activity.get('published')
remote_id = activity['id']
comment = create_comment(author, book, name, content, rating)
comment.published_date = published
comment.remote_id = remote_id
comment.save()
return comment
def create_comment(user, possible_book, name, content):
''' a book comment has been added '''
# throws a value error if the book is not found
book = get_or_create_book(possible_book)
content = sanitize(content)
return models.Comment.objects.create(
user=user,
book=book,
name=name,
content=content,
)
def create_status_from_activity(author, activity): def create_status_from_activity(author, activity):
''' parse a status object out of an activity json blob ''' ''' parse a status object out of an activity json blob '''
content = activity.get('content') content = activity.get('content')
@ -58,6 +88,7 @@ def create_status_from_activity(author, activity):
def create_favorite_from_activity(user, activity): def create_favorite_from_activity(user, activity):
''' create a new favorite entry '''
status = get_status(activity['object']) status = get_status(activity['object'])
remote_id = activity['id'] remote_id = activity['id']
try: try:
@ -81,6 +112,7 @@ def get_favorite(absolute_id):
def get_by_absolute_id(absolute_id, model): def get_by_absolute_id(absolute_id, model):
''' generalized function to get from a model with a remote_id field '''
# check if it's a remote status # check if it's a remote status
try: try:
return model.objects.get(remote_id=absolute_id) return model.objects.get(remote_id=absolute_id)

View file

@ -9,24 +9,32 @@
</h2> </h2>
<div class="tabs secondary"> <div class="tabs secondary">
<div class="tab active"> <div class="tab active" data-id="tab-review-{{ book.id }}" data-category="tab-option-{{ book.id }}">
Review <a href="{{ book.absolute_id }}/review" onclick="tabChange(event)">Review</a>
</div> </div>
<div class="tab"> <div class="tab" data-id="tab-comment-{{ book.id }}" data-category="tab-option-{{ book.id }}">
Comment <a href="{{ book.absolute_id }}/comment" onclick="tabChange(event)">Comment</a>
</div> </div>
<div class="tab"> <div class="tab" data-id="tab-quote-{{ book.id }}" data-category="tab-option-{{ book.id }}">
Quote Quote
</div> </div>
</div> </div>
<div class="book-preview"> <div class="book-preview">
{% include 'snippets/book_cover.html' with book=book %} {% include 'snippets/book_cover.html' with book=book %}
<form class="review-form" name="review" action="/review/" method="post"> <form class="tab-option-{{ book.id }} review-form" name="review" action="/review/" method="post" id="tab-review-{{ book.id }}">
{% csrf_token %} {% csrf_token %}
{# TODO: this shouldn't use the openlibrary key #} {# todo: this shouldn't use the openlibrary key #}
<input type="hidden" name="book" value="{{ book.openlibrary_key }}"></input> <input type="hidden" name="book" value="{{ book.openlibrary_key }}"></input>
{{ review_form.as_p }} {{ review_form.as_p }}
<button type="submit">Post review</button> <button type="submit">post review</button>
</form>
<form class="hidden tab-option-{{ book.id }} review-form" name="comment" action="/comment/" method="post" id="tab-comment-{{ book.id }}">
{% csrf_token %}
{# todo: this shouldn't use the openlibrary key #}
<input type="hidden" name="book" value="{{ book.openlibrary_key }}"></input>
{{ comment_form.as_p }}
<button type="submit">post comment</button>
</form> </form>
</div> </div>

View file

@ -1,11 +1,11 @@
{% load fr_display %} {% load fr_display %}
<div class="interaction"> <div class="interaction">
<form name="comment" action="/comment" method="post" onsubmit="return comment(e_"> <form name="reply" action="/reply" method="post" onsubmit="return reply(event)">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="parent" value="{{ activity.id }}"></input> <input type="hidden" name="parent" value="{{ activity.id }}"></input>
<textarea name="content" placeholder="Leave a comment..." id="id_content" required="true"></textarea> <textarea name="content" placeholder="Leave a comment..." id="id_content" required="true"></textarea>
<button type="submit" class="comment"> <button type="submit">
<span class="icon icon-comment"> <span class="icon icon-comment">
<span class="hidden-text">Comment</span> <span class="hidden-text">Comment</span>
</span> </span>

View file

@ -8,6 +8,8 @@
{{ status.content | safe }} {{ status.content | safe }}
{% elif status.status_type == 'Review' %} {% elif status.status_type == 'Review' %}
reviewed {{ status.book.title }} reviewed {{ status.book.title }}
{% elif status.status_type == 'Comment' %}
commented on {{ status.book.title }}
{% elif status.reply_parent %} {% elif status.reply_parent %}
{% with parent_status=status|parent %} {% with parent_status=status|parent %}
replied to {% include 'snippets/username.html' with user=parent_status.user possessive=True %} <a href="{{parent_status.absolute_id }}">{{ parent_status.status_type|lower }}</a> replied to {% include 'snippets/username.html' with user=parent_status.user possessive=True %} <a href="{{parent_status.absolute_id }}">{{ parent_status.status_type|lower }}</a>

View file

@ -10,7 +10,7 @@ username_regex = r'(?P<username>[\w@\-_\.]+)'
localname_regex = r'(?P<username>[\w\-_]+)' 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_path = r'%s/(status|review)/(?P<status_id>\d+)' % local_user_path status_path = r'%s/(status|review|comment)/(?P<status_id>\d+)' % local_user_path
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
@ -65,9 +65,10 @@ urlpatterns = [
re_path(r'^user-login/?$', actions.user_login), re_path(r'^user-login/?$', actions.user_login),
re_path(r'^register/?$', actions.register), re_path(r'^register/?$', actions.register),
re_path(r'^review/?$', actions.review), re_path(r'^review/?$', actions.review),
re_path(r'^comment/?$', actions.comment),
re_path(r'^tag/?$', actions.tag), re_path(r'^tag/?$', actions.tag),
re_path(r'^untag/?$', actions.untag), re_path(r'^untag/?$', actions.untag),
re_path(r'^comment/?$', actions.comment), re_path(r'^reply/?$', actions.reply),
re_path(r'^favorite/(?P<status_id>\d+)/?$', actions.favorite), re_path(r'^favorite/(?P<status_id>\d+)/?$', actions.favorite),
re_path(r'^unfavorite/(?P<status_id>\d+)/?$', actions.unfavorite), re_path(r'^unfavorite/(?P<status_id>\d+)/?$', actions.unfavorite),
re_path(r'^shelve/?$', actions.shelve), re_path(r'^shelve/?$', actions.shelve),

View file

@ -100,7 +100,7 @@ def shelve(request):
@login_required @login_required
def review(request): def review(request):
''' create a book review note ''' ''' create a book review '''
form = forms.ReviewForm(request.POST) form = forms.ReviewForm(request.POST)
book_identifier = request.POST.get('book') book_identifier = request.POST.get('book')
# TODO: better failure behavior # TODO: better failure behavior
@ -116,6 +116,23 @@ def review(request):
return redirect('/book/%s' % book_identifier) return redirect('/book/%s' % book_identifier)
@login_required
def comment(request):
''' create a book comment '''
form = forms.CommentForm(request.POST)
book_identifier = request.POST.get('book')
# TODO: better failure behavior
if not form.is_valid():
return redirect('/book/%s' % book_identifier)
# TODO: validation, htmlification
name = form.data.get('name')
content = form.data.get('content')
outgoing.handle_comment(request.user, book_identifier, name, content)
return redirect('/book/%s' % book_identifier)
@login_required @login_required
def tag(request): def tag(request):
''' tag a book ''' ''' tag a book '''
@ -139,15 +156,15 @@ def untag(request):
@login_required @login_required
def comment(request): def reply(request):
''' respond to a book review ''' ''' respond to a book review '''
form = forms.CommentForm(request.POST) form = forms.ReplyForm(request.POST)
# this is a bit of a formality, the form is just one text field # this is a bit of a formality, the form is just one text field
if not form.is_valid(): if not form.is_valid():
return redirect('/') return redirect('/')
parent_id = request.POST['parent'] parent_id = request.POST['parent']
parent = models.Status.objects.get(id=parent_id) parent = models.Status.objects.get(id=parent_id)
outgoing.handle_comment(request.user, parent, form.data['content']) outgoing.handle_reply(request.user, parent, form.data['content'])
return redirect('/') return redirect('/')

View file

@ -91,6 +91,7 @@ def home_tab(request, tab):
], ],
'active_tab': tab, 'active_tab': tab,
'review_form': forms.ReviewForm(), 'review_form': forms.ReviewForm(),
'comment_form': forms.CommentForm(),
} }
return TemplateResponse(request, 'feed.html', data) return TemplateResponse(request, 'feed.html', data)