Merge pull request #60 from mouse-reeve/tagging

Tagging
This commit is contained in:
Mouse Reeve 2020-02-20 22:23:49 -08:00 committed by GitHub
commit 3fdb57e26a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 234 additions and 12 deletions

View file

@ -5,4 +5,4 @@ from .collection import get_outbox, get_outbox_page, get_add, get_remove, \
from .create import get_create from .create import get_create
from .follow import get_follow_request, get_unfollow, get_accept from .follow import get_follow_request, get_unfollow, get_accept
from .status import get_review, get_review_article, get_status, get_replies, \ from .status import get_review, get_review_article, get_status, get_replies, \
get_favorite get_favorite, get_add_tag, get_remove_tag

View file

@ -118,6 +118,7 @@ def get_add_remove(user, book, shelf, action='Add'):
'type': action, 'type': action,
'actor': user.actor, 'actor': user.actor,
'object': { 'object': {
# TODO: document??
'type': 'Document', 'type': 'Document',
'name': book.data['title'], 'name': book.data['title'],
'url': book.openlibrary_key 'url': book.openlibrary_key

View file

@ -1,4 +1,7 @@
''' status serializers ''' ''' status serializers '''
from uuid import uuid4
def get_review(review): def get_review(review):
''' fedireads json for book reviews ''' ''' fedireads json for book reviews '''
status = get_status(review) status = get_status(review)
@ -76,9 +79,51 @@ def get_replies(status, replies):
def get_favorite(favorite): def get_favorite(favorite):
''' like a post ''' ''' like a post '''
return { return {
"@context": "https://www.w3.org/ns/activitystreams", '@context': 'https://www.w3.org/ns/activitystreams',
"id": favorite.absolute_id, 'id': favorite.absolute_id,
"type": "Like", 'type': 'Like',
"actor": favorite.user.actor, 'actor': favorite.user.actor,
"object": favorite.status.absolute_id, 'object': favorite.status.absolute_id,
} }
def get_add_tag(tag):
''' add activity for tagging a book '''
uuid = uuid4()
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': str(uuid),
'type': 'Add',
'actor': tag.user.actor,
'object': {
'type': 'Tag',
'id': tag.absolute_id,
'name': tag.name,
},
'target': {
'type': 'Book',
'id': tag.book.absolute_id,
}
}
def get_remove_tag(tag):
''' add activity for tagging a book '''
uuid = uuid4()
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': str(uuid),
'type': 'Remove',
'actor': tag.user.actor,
'object': {
'type': 'Tag',
'id': tag.absolute_id,
'name': tag.name,
},
'target': {
'type': 'Book',
'id': tag.book.absolute_id,
}
}

View file

@ -54,3 +54,11 @@ class EditUserForm(ModelForm):
fields = ['avatar', 'name', 'summary'] fields = ['avatar', 'name', 'summary']
help_texts = {f: None for f in fields} help_texts = {f: None for f in fields}
class TagForm(ModelForm):
class Meta:
model = models.Tag
fields = ['name']
help_texts = {f: None for f in fields}
labels = {'name': 'Add a tag'}

View file

@ -12,7 +12,8 @@ import requests
from fedireads import activitypub from fedireads import activitypub
from fedireads import models from fedireads import models
from fedireads import outgoing from fedireads import outgoing
from fedireads.status import create_review, create_status from fedireads.openlibrary import get_or_create_book
from fedireads.status import create_review, create_status, create_tag
from fedireads.remote_user import get_or_create_remote_user from fedireads.remote_user import get_or_create_remote_user
@ -49,6 +50,9 @@ def shared_inbox(request):
elif activity['type'] == 'Like': elif activity['type'] == 'Like':
response = handle_incoming_favorite(activity) response = handle_incoming_favorite(activity)
elif activity['type'] == 'Add':
response = handle_incoming_add(activity)
# TODO: Add, Undo, Remove, etc # TODO: Add, Undo, Remove, etc
return response return response
@ -274,6 +278,19 @@ def handle_incoming_favorite(activity):
return HttpResponse() return HttpResponse()
def handle_incoming_add(activity):
''' someone is tagging or shelving a book '''
if activity['object']['type'] == 'Tag':
user = get_or_create_remote_user(activity['actor'])
if not user.local:
book_id = activity['target']['id'].split('/')[-1]
book = get_or_create_book(book_id)
create_tag(user, book, activity['object']['name'])
return HttpResponse()
return HttpResponse()
return HttpResponseNotFound()
def handle_incoming_accept(activity): def handle_incoming_accept(activity):
''' someone is accepting a follow request ''' ''' someone is accepting a follow request '''
# our local user # our local user

View file

@ -0,0 +1,29 @@
# Generated by Django 3.0.3 on 2020-02-21 05:54
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('fedireads', '0003_auto_20200221_0131'),
]
operations = [
migrations.CreateModel(
name='Tag',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=140)),
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'book', 'name')},
},
),
]

View file

@ -1,5 +1,5 @@
''' bring all the models into the app namespace ''' ''' bring all the models into the app namespace '''
from .book import Shelf, ShelfBook, Book, Author from .book import Shelf, ShelfBook, Book, Author
from .user import User, UserRelationship, FederatedServer from .user import User, UserRelationship, FederatedServer
from .activity import Status, Review, Favorite from .activity import Status, Review, Favorite, Tag

View file

@ -1,6 +1,7 @@
''' models for storing different kinds of Activities ''' ''' models for storing different kinds of Activities '''
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.dispatch import receiver
from model_utils.managers import InheritanceManager from model_utils.managers import InheritanceManager
from fedireads.utils.models import FedireadsModel from fedireads.utils.models import FedireadsModel
@ -46,7 +47,6 @@ class Review(Status):
self.activity_type = 'Article' self.activity_type = 'Article'
super().save(*args, **kwargs) super().save(*args, **kwargs)
class Favorite(FedireadsModel): class Favorite(FedireadsModel):
''' fav'ing a post ''' ''' fav'ing a post '''
user = models.ForeignKey('User', on_delete=models.PROTECT) user = models.ForeignKey('User', on_delete=models.PROTECT)
@ -55,3 +55,13 @@ class Favorite(FedireadsModel):
class Meta: class Meta:
unique_together = ('user', 'status') unique_together = ('user', 'status')
class Tag(FedireadsModel):
''' freeform tags for books '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
book = models.ForeignKey('Book', on_delete=models.PROTECT)
name = models.CharField(max_length=140)
class Meta:
unique_together = ('user', 'book', 'name')

View file

@ -7,7 +7,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 from fedireads.status import create_review, create_status, create_tag
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
@ -161,6 +161,26 @@ def handle_review(user, book, name, content, rating):
broadcast(user, article_create_activity, other_recipients) broadcast(user, article_create_activity, other_recipients)
def handle_tag(user, book, name):
''' tag a book '''
tag = create_tag(user, book, name)
tag_activity = activitypub.get_add_tag(tag)
recipients = get_recipients(user, 'public')
broadcast(user, tag_activity, recipients)
def handle_untag(user, book, name):
''' tag a book '''
book = models.Book.objects.get(openlibrary_key=book)
tag = models.Tag.objects.get(name=name, book=book, user=user)
tag_activity = activitypub.get_remove_tag(tag)
tag.delete()
recipients = get_recipients(user, 'public')
broadcast(user, tag_activity, recipients)
def handle_comment(user, review, content): def handle_comment(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

View file

@ -126,6 +126,17 @@ h2 {
padding: 1rem; padding: 1rem;
} }
.tag {
border: 1px solid black;
display: inline-block;
padding: 0.2em;
border-radius: 0.2em;
background-color: #F3FFBD;
}
.tag form {
display: inline;
}
.review-form textarea { .review-form textarea {
width: 30rem; width: 30rem;
height: 10rem; height: 10rem;

View file

@ -2,6 +2,7 @@
from fedireads import models from fedireads import models
from fedireads.openlibrary import get_or_create_book from fedireads.openlibrary import get_or_create_book
from fedireads.sanitize_html import InputHtmlParser from fedireads.sanitize_html import InputHtmlParser
from django.db import IntegrityError
def create_review(user, possible_book, name, content, rating): def create_review(user, possible_book, name, content, rating):
@ -45,6 +46,17 @@ def create_status(user, content, reply_parent=None, mention_books=None):
return status return status
def create_tag(user, possible_book, name):
''' add a tag to a book '''
book = get_or_create_book(possible_book)
try:
tag = models.Tag.objects.create(name=name, book=book, user=user)
except IntegrityError:
return models.Tag.objects.get(name=name, book=book, user=user)
return tag
def sanitize(content): def sanitize(content):
''' remove invalid html from free text ''' ''' remove invalid html from free text '''
parser = InputHtmlParser() parser = InputHtmlParser()

View file

@ -6,6 +6,17 @@
<div class="book-preview"> <div class="book-preview">
{% include 'snippets/book.html' with book=book size=large rating=rating description=True %} {% include 'snippets/book.html' with book=book size=large rating=rating description=True %}
</div> </div>
<div id="tag-cloud">
{% for tag in tags %}
{% include 'snippets/tag.html' with tag=tag user=request.user %}
{% endfor %}
</div>
<form class="tag-form" name="tag" action="/tag/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.openlibrary_key }}"></input>
{{ tag_form.as_p }}
<button type="submit">Add tag</button>
</form>
</div> </div>
<div class="reviews"> <div class="reviews">
<h2>Reviews</h2> <h2>Reviews</h2>

View file

@ -0,0 +1,20 @@
<div class="tag">
{{ tag.name }}
{% if tag.name in user_tags %}
<form class="tag-form" name="tag" action="/untag/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.openlibrary_key }}"></input>
<input type="hidden" name="name" value="{{ tag.name }}"></input>
<button type="submit">x</button>
</form>
{% else %}
<form class="tag-form" name="tag" action="/tag/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.openlibrary_key }}"></input>
<input type="hidden" name="name" value="{{ tag.name }}"></input>
<button type="submit">+</button>
</form>
{% endif %}
</div>

View file

@ -51,6 +51,8 @@ urlpatterns = [
# internal action endpoints # internal action endpoints
re_path(r'^review/?$', views.review), re_path(r'^review/?$', views.review),
re_path(r'^tag/?$', views.tag),
re_path(r'^untag/?$', views.untag),
re_path(r'^comment/?$', views.comment), re_path(r'^comment/?$', views.comment),
re_path(r'^favorite/(?P<status_id>\d+)/?$', views.favorite), re_path(r'^favorite/(?P<status_id>\d+)/?$', views.favorite),
re_path( re_path(

View file

@ -11,7 +11,7 @@ class FedireadsModel(models.Model):
def absolute_id(self): def absolute_id(self):
''' constructs the absolute reference to any db object ''' ''' constructs the absolute reference to any db object '''
base_path = 'https://%s' % DOMAIN base_path = 'https://%s' % DOMAIN
if self.user: if hasattr(self, 'user'):
base_path = self.user.absolute_id base_path = self.user.absolute_id
model_name = type(self).__name__.lower() model_name = type(self).__name__.lower()
return '%s/%s/%d' % (base_path, model_name, self.id) return '%s/%s/%d' % (base_path, model_name, self.id)

View file

@ -50,7 +50,8 @@ def home_tab(request, tab):
if tab == 'home': if tab == 'home':
# people you follow and direct mentions # people you follow and direct mentions
activities = activities.filter( activities = activities.filter(
Q(user__in=following, privacy='public') | Q(mention_users=request.user) Q(user__in=following, privacy='public') | \
Q(mention_users=request.user)
) )
elif tab == 'local': elif tab == 'local':
# everyone on this instance # everyone on this instance
@ -201,12 +202,25 @@ def book_page(request, book_identifier):
# TODO: again, post privacy? # TODO: again, post privacy?
reviews = models.Review.objects.filter(book=book) reviews = models.Review.objects.filter(book=book)
rating = reviews.aggregate(Avg('rating')) rating = reviews.aggregate(Avg('rating'))
tags = models.Tag.objects.filter(
book=book
).values(
'book', 'name'
).distinct().all()
user_tags = models.Tag.objects.filter(
book=book, user=request.user
).values_list('name', flat=True)
review_form = forms.ReviewForm() review_form = forms.ReviewForm()
tag_form = forms.TagForm()
data = { data = {
'book': book, 'book': book,
'reviews': reviews, 'reviews': reviews,
'rating': rating['rating__avg'], 'rating': rating['rating__avg'],
'tags': tags,
'user_tags': user_tags,
'review_form': review_form, 'review_form': review_form,
'tag_form': tag_form,
} }
return TemplateResponse(request, 'book.html', data) return TemplateResponse(request, 'book.html', data)
@ -272,6 +286,28 @@ def review(request):
return redirect('/book/%s' % book_identifier) return redirect('/book/%s' % book_identifier)
@login_required
def tag(request):
''' tag a book '''
# I'm not using a form here because sometimes "name" is sent as a hidden
# field which doesn't validate
name = request.POST.get('name')
book_identifier = request.POST.get('book')
outgoing.handle_tag(request.user, book_identifier, name)
return redirect('/book/%s' % book_identifier)
@login_required
def untag(request):
''' untag a book '''
name = request.POST.get('name')
book_identifier = request.POST.get('book')
outgoing.handle_untag(request.user, book_identifier, name)
return redirect('/book/%s' % book_identifier)
@login_required @login_required
def comment(request): def comment(request):
''' respond to a book review ''' ''' respond to a book review '''