From 870d0b96977fa24018fdc77cb9aa4c8ce55b6f52 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Thu, 20 Feb 2020 22:19:19 -0800 Subject: [PATCH] Tagging Works on #55 --- fedireads/activitypub/__init__.py | 2 +- fedireads/activitypub/collection.py | 1 + fedireads/activitypub/status.py | 55 ++++++++++++++++++++++++--- fedireads/forms.py | 2 + fedireads/incoming.py | 19 ++++++++- fedireads/migrations/0004_tag.py | 29 ++++++++++++++ fedireads/models/__init__.py | 2 +- fedireads/models/activity.py | 19 +++++---- fedireads/outgoing.py | 23 +++++++++-- fedireads/static/format.css | 11 ++++++ fedireads/status.py | 11 ++---- fedireads/templates/book.html | 17 ++++++--- fedireads/templates/snippets/tag.html | 20 ++++++++++ fedireads/urls.py | 1 + fedireads/views.py | 30 +++++++++++++-- 15 files changed, 205 insertions(+), 37 deletions(-) create mode 100644 fedireads/migrations/0004_tag.py create mode 100644 fedireads/templates/snippets/tag.html diff --git a/fedireads/activitypub/__init__.py b/fedireads/activitypub/__init__.py index 22fd6afd..b74c645e 100644 --- a/fedireads/activitypub/__init__.py +++ b/fedireads/activitypub/__init__.py @@ -5,4 +5,4 @@ from .collection import get_outbox, get_outbox_page, get_add, get_remove, \ from .create import get_create from .follow import get_follow_request, get_unfollow, get_accept from .status import get_review, get_review_article, get_status, get_replies, \ - get_favorite + get_favorite, get_add_tag, get_remove_tag diff --git a/fedireads/activitypub/collection.py b/fedireads/activitypub/collection.py index 426553b9..3c7019b4 100644 --- a/fedireads/activitypub/collection.py +++ b/fedireads/activitypub/collection.py @@ -118,6 +118,7 @@ def get_add_remove(user, book, shelf, action='Add'): 'type': action, 'actor': user.actor, 'object': { + # TODO: document?? 'type': 'Document', 'name': book.data['title'], 'url': book.openlibrary_key diff --git a/fedireads/activitypub/status.py b/fedireads/activitypub/status.py index 95429e29..5e89638e 100644 --- a/fedireads/activitypub/status.py +++ b/fedireads/activitypub/status.py @@ -1,4 +1,7 @@ ''' status serializers ''' +from uuid import uuid4 + + def get_review(review): ''' fedireads json for book reviews ''' status = get_status(review) @@ -76,9 +79,51 @@ def get_replies(status, replies): def get_favorite(favorite): ''' like a post ''' return { - "@context": "https://www.w3.org/ns/activitystreams", - "id": favorite.absolute_id, - "type": "Like", - "actor": favorite.user.actor, - "object": favorite.status.absolute_id, + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': favorite.absolute_id, + 'type': 'Like', + 'actor': favorite.user.actor, + '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, + } + } + + diff --git a/fedireads/forms.py b/fedireads/forms.py index dc59a8a9..0dd5f4d6 100644 --- a/fedireads/forms.py +++ b/fedireads/forms.py @@ -54,9 +54,11 @@ class EditUserForm(ModelForm): fields = ['avatar', 'name', 'summary'] 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'} diff --git a/fedireads/incoming.py b/fedireads/incoming.py index f75fa7ef..e03b0c94 100644 --- a/fedireads/incoming.py +++ b/fedireads/incoming.py @@ -12,7 +12,8 @@ import requests from fedireads import activitypub from fedireads import models 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 @@ -49,6 +50,9 @@ def shared_inbox(request): elif activity['type'] == 'Like': response = handle_incoming_favorite(activity) + elif activity['type'] == 'Add': + response = handle_incoming_add(activity) + # TODO: Add, Undo, Remove, etc return response @@ -274,6 +278,19 @@ def handle_incoming_favorite(activity): 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): ''' someone is accepting a follow request ''' # our local user diff --git a/fedireads/migrations/0004_tag.py b/fedireads/migrations/0004_tag.py new file mode 100644 index 00000000..3a69c6bc --- /dev/null +++ b/fedireads/migrations/0004_tag.py @@ -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')}, + }, + ), + ] diff --git a/fedireads/models/__init__.py b/fedireads/models/__init__.py index ace5e08b..c71fa3a3 100644 --- a/fedireads/models/__init__.py +++ b/fedireads/models/__init__.py @@ -1,5 +1,5 @@ ''' bring all the models into the app namespace ''' from .book import Shelf, ShelfBook, Book, Author from .user import User, UserRelationship, FederatedServer -from .activity import Status, Review, Favorite +from .activity import Status, Review, Favorite, Tag diff --git a/fedireads/models/activity.py b/fedireads/models/activity.py index 93e70313..bff660ad 100644 --- a/fedireads/models/activity.py +++ b/fedireads/models/activity.py @@ -1,6 +1,7 @@ ''' models for storing different kinds of Activities ''' from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.dispatch import receiver from model_utils.managers import InheritanceManager from fedireads.utils.models import FedireadsModel @@ -46,14 +47,6 @@ class Review(Status): self.activity_type = 'Article' super().save(*args, **kwargs) - -class Tag(FedireadsModel): - ''' freeform tags for books ''' - users = models.ManyToManyField('User') - books = models.ManyToManyField('Book') - name = models.CharField(max_length=140, unique=True) - - class Favorite(FedireadsModel): ''' fav'ing a post ''' user = models.ForeignKey('User', on_delete=models.PROTECT) @@ -62,3 +55,13 @@ class Favorite(FedireadsModel): class Meta: 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') + diff --git a/fedireads/outgoing.py b/fedireads/outgoing.py index e75e7576..d032ac05 100644 --- a/fedireads/outgoing.py +++ b/fedireads/outgoing.py @@ -160,11 +160,26 @@ def handle_review(user, book, name, content, rating): other_recipients = get_recipients(user, 'public', limit='other') broadcast(user, article_create_activity, other_recipients) -def handle_tag(user, book, name): - tag = create_tag(user, book, name) - tag_activity = activitypub.get_tag(tag) - book_object = activitypub.get_book(book) +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): ''' respond to a review or status ''' diff --git a/fedireads/static/format.css b/fedireads/static/format.css index 2f22571a..b4f16177 100644 --- a/fedireads/static/format.css +++ b/fedireads/static/format.css @@ -126,6 +126,17 @@ h2 { 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 { width: 30rem; height: 10rem; diff --git a/fedireads/status.py b/fedireads/status.py index 190167e9..c5969289 100644 --- a/fedireads/status.py +++ b/fedireads/status.py @@ -2,6 +2,7 @@ from fedireads import models from fedireads.openlibrary import get_or_create_book from fedireads.sanitize_html import InputHtmlParser +from django.db import IntegrityError def create_review(user, possible_book, name, content, rating): @@ -50,13 +51,9 @@ def create_tag(user, possible_book, name): book = get_or_create_book(possible_book) try: - # check for an existing tag with this text - tag = models.Tag.objects.get(name=name) - except models.Tag.DoesNotExist(): - # create a new one if there isn't an existing one - tag = models.Tag.objects.create(name=name) - tag.users.add(user) - tag.books.add(book) + 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 diff --git a/fedireads/templates/book.html b/fedireads/templates/book.html index b6292945..32508ca1 100644 --- a/fedireads/templates/book.html +++ b/fedireads/templates/book.html @@ -6,18 +6,23 @@
{% include 'snippets/book.html' with book=book size=large rating=rating description=True %}
- -
-

Reviews

- {% if not reviews %} -

No reviews yet!

- {% endif %} +
+ {% for tag in tags %} + {% include 'snippets/tag.html' with tag=tag user=request.user %} + {% endfor %} +
{% csrf_token %} {{ tag_form.as_p }}
+
+
+

Reviews

+ {% if not reviews %} +

No reviews yet!

+ {% endif %}
{% csrf_token %} diff --git a/fedireads/templates/snippets/tag.html b/fedireads/templates/snippets/tag.html new file mode 100644 index 00000000..c3465b5e --- /dev/null +++ b/fedireads/templates/snippets/tag.html @@ -0,0 +1,20 @@ +
+ {{ tag.name }} + {% if tag.name in user_tags %} + + {% csrf_token %} + + + + + {% else %} +
+ {% csrf_token %} + + + +
+ {% endif %} +
+ + diff --git a/fedireads/urls.py b/fedireads/urls.py index fb694461..1489ac1d 100644 --- a/fedireads/urls.py +++ b/fedireads/urls.py @@ -52,6 +52,7 @@ urlpatterns = [ # internal action endpoints 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'^favorite/(?P\d+)/?$', views.favorite), re_path( diff --git a/fedireads/views.py b/fedireads/views.py index d0a8161c..94ba36f9 100644 --- a/fedireads/views.py +++ b/fedireads/views.py @@ -202,12 +202,25 @@ def book_page(request, book_identifier): # TODO: again, post privacy? reviews = models.Review.objects.filter(book=book) 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() + tag_form = forms.TagForm() data = { 'book': book, 'reviews': reviews, 'rating': rating['rating__avg'], + 'tags': tags, + 'user_tags': user_tags, 'review_form': review_form, + 'tag_form': tag_form, } return TemplateResponse(request, 'book.html', data) @@ -276,16 +289,25 @@ def review(request): @login_required def tag(request): ''' tag a book ''' - form = forms.ReviewForm(request.POST) + # 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') - if not form.is_valid(): - return redirect('/book/%s' % book_identifier) - name = form.data.get('name') 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 def comment(request): ''' respond to a book review '''