Commit 684e504c authored by Jason Robinson's avatar Jason Robinson

Merge branch 'tag-following' into 'master'

Add Tags API and allow following tags

See merge request !511
parents 605abd8e 72065e61
......@@ -11,7 +11,7 @@ from rest_framework_swagger.renderers import OpenAPIRenderer, SwaggerUIRenderer
from django_js_reverse.views import urls_js
from socialhome.content.views import ContentBookmarkletView
from socialhome.content.viewsets import ContentViewSet
from socialhome.content.viewsets import ContentViewSet, TagViewSet
from socialhome.enums import PolicyDocumentType
from socialhome.viewsets import ImageUploadView
from socialhome.views import (
......@@ -27,6 +27,7 @@ from socialhome.users.viewsets import UserViewSet, ProfileViewSet
router = DefaultRouter()
router.register(r"content", ContentViewSet)
router.register(r"profiles", ProfileViewSet)
router.register(r"tags", TagViewSet)
router.register(r"users", UserViewSet)
# API docs
......
......@@ -15,6 +15,10 @@ Added
Since this limited support breaks compatibility with platforms that prefer ActivityPub, it is disabled by default. Add the environment variable ``SOCIALHOME_ACTIVITYPUB_ALPHA=1`` to activate it.
* Added Tags API. In addition to listing Tag objects, it allows authenticated users to follow and unfollow tags.
* Profile API now includes a list of tags followed for logged in users.
Changed
.......
......@@ -49,6 +53,11 @@ Fixed
* Fix internal server error for anonymous user for certain internal user pages (`#518 <https://git.feneas.org/socialhome/socialhome/issues/518>`_)
Internal changes
................
* Removed ``User`` relationship fields. These were migrated to ``Profile`` a long time ago.
0.9.3 (2018-08-29)
------------------
......
from django.contrib import admin
from django.contrib.admin import ModelAdmin
from socialhome.content.models import Content
from socialhome.content.models import Content, Tag
@admin.register(Content)
......@@ -12,3 +12,11 @@ class ContentAdmin(ModelAdmin):
readonly_fields = ('content_type', 'local', 'rendered', 'reply_count', 'shares_count', 'uuid', 'fid', 'guid')
search_fields = ('uuid', 'id', 'author__handle', 'author__name', 'author__fid')
list_select_related = ('author',)
@admin.register(Tag)
class TagAdmin(ModelAdmin):
list_display = ("id", "uuid", "name", "created")
list_filter = ("id", "uuid", "name", "created")
readonly_fields = ("id", "uuid", "created", "name")
search_fields = ("id", "uuid", "name")
# Generated by Django 2.0.8 on 2019-02-04 19:34
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('content', '0032_make_content_uuid_editable_false'),
]
operations = [
migrations.AddField(
model_name='tag',
name='uuid',
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
]
......@@ -50,6 +50,7 @@ class OEmbedCache(models.Model):
class Tag(models.Model):
name = models.CharField(_("Name"), max_length=255, unique=True)
created = AutoCreatedField(_('Created'))
uuid = models.UUIDField(unique=True, default=uuid4, editable=False)
objects = TagQuerySet.as_manager()
......
......@@ -3,7 +3,7 @@ from rest_framework import serializers
from rest_framework.fields import SerializerMethodField
from socialhome.content.enums import ContentType
from socialhome.content.models import Content
from socialhome.content.models import Content, Tag
from socialhome.enums import Visibility
from socialhome.users.serializers import LimitedProfileSerializer
......@@ -132,3 +132,10 @@ class ContentSerializer(serializers.ModelSerializer):
if value == Visibility.LIMITED and not self.instance:
raise serializers.ValidationError("Limited content creation not yet supported via the API.")
return value
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ("name", "created", "uuid")
read_only_fields = ("created", "uuid")
from socialhome.content.models import Content
from socialhome.content.tests.factories import PublicContentFactory
from socialhome.content.tests.factories import PublicContentFactory, TagFactory
from socialhome.enums import Visibility
from socialhome.tests.utils import SocialhomeAPITestCase
from socialhome.users.tests.factories import UserFactory, AdminUserFactory, ProfileFactory
......@@ -273,3 +273,65 @@ class TestContentViewSet(SocialhomeAPITestCase):
self.get("api:content-shares", pk=self.public_content.id)
self.assertEqual(len(self.last_response.data), 1)
self.assertEqual(self.last_response.data[0].get("id"), self.share.id)
class TestTagViewset(SocialhomeAPITestCase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.user = UserFactory()
cls.profile = cls.user.profile
cls.tag = TagFactory()
cls.tag2 = TagFactory()
def test_create(self):
with self.login(self.user):
self.post("api:tag-list", data={"name": "creatingatag"})
self.response_405()
def test_delete(self):
with self.login(self.user):
self.delete("api:tag-detail", uuid=self.tag.uuid)
self.response_405()
def test_detail(self):
self.get("api:tag-detail", uuid=self.tag.uuid)
self.response_200()
self.assertEqual(self.last_response.data["name"], self.tag.name)
self.assertEqual(self.last_response.data["uuid"], str(self.tag.uuid))
def test_follow(self):
self.post("api:tag-follow", uuid=self.tag.uuid)
self.response_401()
with self.login(self.user):
self.post("api:tag-follow", uuid=self.tag.uuid)
self.response_200()
self.assertEqual(self.profile.followed_tags.count(), 1)
self.assertEqual(self.profile.followed_tags.first(), self.tag)
def test_list(self):
self.get("api:tag-list")
self.response_200()
self.assertEqual(len(self.last_response.data["results"]), 2)
def test_unfollow(self):
self.profile.followed_tags.add(self.tag)
self.post("api:tag-unfollow", uuid=self.tag.uuid)
self.response_401()
with self.login(self.user):
self.post("api:tag-unfollow", uuid=self.tag.uuid)
self.response_200()
self.assertEqual(self.profile.followed_tags.count(), 0)
# Second unfollow fails silently
with self.login(self.user):
self.post("api:tag-unfollow", uuid=self.tag.uuid)
self.response_200()
def test_update(self):
with self.login(self.user):
self.patch("api:tag-detail", uuid=self.tag.uuid, data={"name": "newnameyo"})
self.response_405()
from django.core.exceptions import ValidationError
from rest_framework import exceptions
from rest_framework import exceptions, status
from rest_framework import mixins
from rest_framework.decorators import detail_route
from rest_framework.permissions import BasePermission, SAFE_METHODS
......@@ -7,8 +7,8 @@ from rest_framework.response import Response
from rest_framework.status import HTTP_201_CREATED, HTTP_204_NO_CONTENT
from rest_framework.viewsets import GenericViewSet
from socialhome.content.models import Content
from socialhome.content.serializers import ContentSerializer
from socialhome.content.models import Content, Tag
from socialhome.content.serializers import ContentSerializer, TagSerializer
class IsOwnContentOrReadOnly(BasePermission):
......@@ -125,3 +125,42 @@ class ContentViewSet(mixins.CreateModelMixin, mixins.RetrieveModelMixin, mixins.
queryset = self.filter_queryset(self.get_queryset(share_of=content)).order_by("created")
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
class TagViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, GenericViewSet):
"""
list:
List Tag objects.
follow:
Follow a Tag.
Requires being logged in.
unfollow:
Unfollow a Tag.
Requires being logged in.
"""
lookup_field = "uuid"
queryset = Tag.objects.all().order_by("name")
serializer_class = TagSerializer
@detail_route(methods=["post"])
def follow(self, request, uuid=None):
if not request.user.is_authenticated:
return Response({"detail": "Must be authenticated to follow a tag."}, status=status.HTTP_401_UNAUTHORIZED)
tag = self.get_object()
request.user.profile.followed_tags.add(tag)
return Response({"status": "ok"}, status=status.HTTP_200_OK)
@detail_route(methods=["post"])
def unfollow(self, request, uuid=None):
if not request.user.is_authenticated:
return Response({"detail": "Must be authenticated to follow a tag."}, status=status.HTTP_401_UNAUTHORIZED)
tag = self.get_object()
request.user.profile.followed_tags.remove(tag)
return Response({"status": "ok"}, status=status.HTTP_200_OK)
This diff is collapsed.
......@@ -8,7 +8,7 @@ from socialhome.users.models import User, Profile
class ProfileAdmin(ModelAdmin):
list_display = ('id', 'uuid', 'name', 'handle', 'fid', 'visibility', 'user')
list_filter = ('visibility',)
raw_id_fields = ('user', 'following')
raw_id_fields = ('user', 'following', 'followed_tags')
readonly_fields = ('uuid', 'fid', 'guid', 'handle', 'rsa_public_key')
search_fields = ('id', 'uuid', 'name', 'handle', 'email', 'fid')
list_select_related = ('user',)
......@@ -18,5 +18,4 @@ class ProfileAdmin(ModelAdmin):
class UserAdmin(ModelAdmin):
list_display = ('id', 'username', 'name', 'trusted_editor')
list_filter = ('trusted_editor',)
raw_id_fields = ('followers', 'following')
search_fields = ('id', 'name', 'username')
# Generated by Django 2.0.8 on 2019-02-04 19:04
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('users', '0031_make_profile_uuid_editable_false'),
]
operations = [
migrations.RemoveField(
model_name='user',
name='followers',
),
migrations.RemoveField(
model_name='user',
name='following',
),
]
# Generated by Django 2.0.8 on 2019-02-04 19:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0032_make_content_uuid_editable_false'),
('users', '0032_remove_user_relationship_fields'),
]
operations = [
migrations.AddField(
model_name='profile',
name='followed_tags',
field=models.ManyToManyField(related_name='following_profiles', to='content.Tag', verbose_name='Followed tags'),
),
]
......@@ -37,11 +37,6 @@ class User(AbstractUser):
# with unlimited usage of HTML tags.
trusted_editor = models.BooleanField(_("Trusted editor"), default=False)
# Relationships
# TODO remove in favour of Profile.following
followers = models.ManyToManyField("users.Profile", verbose_name=_("Followers"), related_name="following_set")
following = models.ManyToManyField("users.Profile", verbose_name=_("Following"), related_name="followers_set")
# Picture
picture = VersatileImageField(
_("Picture"), upload_to="profiles/", width_field="picture_width", height_field="picture_height",
......@@ -143,6 +138,11 @@ class Profile(TimeStampedModel):
# Following
following = models.ManyToManyField("self", verbose_name=_("Following"), related_name="followers", symmetrical=False)
# Tags
followed_tags = models.ManyToManyField(
"content.Tag", verbose_name=_("Followed tags"), related_name="following_profiles",
)
objects = ProfileQuerySet.as_manager()
def __str__(self) -> str:
......
from typing import List
from enumfields.drf import EnumField
from rest_framework import serializers
from rest_framework.fields import SerializerMethodField
from rest_framework.serializers import ModelSerializer
......@@ -39,6 +42,7 @@ class LimitedProfileSerializer(ModelSerializer):
class ProfileSerializer(ModelSerializer):
followed_tags = SerializerMethodField()
followers_count = SerializerMethodField()
following_count = SerializerMethodField()
has_pinned_content = SerializerMethodField()
......@@ -49,6 +53,7 @@ class ProfileSerializer(ModelSerializer):
model = Profile
fields = (
"fid",
"followed_tags",
"followers_count",
"following_count",
"uuid",
......@@ -69,6 +74,7 @@ class ProfileSerializer(ModelSerializer):
)
read_only_fields = (
"fid",
"followed_tags",
"followers_count",
"following_count",
"uuid",
......@@ -84,6 +90,15 @@ class ProfileSerializer(ModelSerializer):
"user_following",
)
def get_followed_tags(self, obj: Profile) -> List:
"""
Return list of followed tags if owned by logged in user.
"""
user = self.context.get("request").user
if (hasattr(user, "profile") and user.profile.id == obj.id) or user.is_staff:
return list(obj.followed_tags.values_list('name', flat=True))
return []
def get_following_count(self, obj):
return obj.following.count()
......
......@@ -122,6 +122,7 @@ class TestProfileDetailView(SocialhomeTestCase):
"id": profile.id,
"uuid": str(profile.uuid),
"fid": profile.fid,
"followed_tags": [],
"followers_count": 0,
"following_count": profile.following.count(),
"handle": profile.handle,
......@@ -152,6 +153,7 @@ class TestProfileDetailView(SocialhomeTestCase):
"id": profile.id,
"uuid": str(profile.uuid),
"fid": profile.fid,
"followed_tags": [],
"followers_count": 0,
"following_count": profile.following.count(),
"handle": profile.handle,
......@@ -394,6 +396,7 @@ class TestProfileAllContentView(SocialhomeTestCase):
"id": profile.id,
"uuid": str(profile.uuid),
"fid": profile.fid,
"followed_tags": [],
"followers_count": 0,
"following_count": profile.following.count(),
"handle": profile.handle,
......@@ -424,6 +427,7 @@ class TestProfileAllContentView(SocialhomeTestCase):
"id": profile.id,
"uuid": str(profile.uuid),
"fid": profile.fid,
"followed_tags": [],
"followers_count": 0,
"following_count": profile.following.count(),
"handle": profile.handle,
......
from django.test import override_settings
from django.urls import reverse
from socialhome.content.tests.factories import TagFactory
from socialhome.enums import Visibility
from socialhome.tests.utils import SocialhomeAPITestCase
from socialhome.users.models import Profile
......@@ -56,10 +57,13 @@ class TestProfileViewSet(SocialhomeAPITestCase):
cls.profile = cls.user.profile
cls.staff_user = UserFactory(is_staff=True)
cls.staff_profile = cls.staff_user.profile
cls.other_user = UserFactory()
cls.site_profile = ProfileFactory(visibility=Visibility.SITE)
cls.self_profile = ProfileFactory(visibility=Visibility.SELF)
cls.limited_profile = ProfileFactory(visibility=Visibility.LIMITED)
Profile.objects.filter(id=cls.profile.id).update(visibility=Visibility.PUBLIC)
cls.tag = TagFactory()
cls.profile.followed_tags.add(cls.tag)
def test_create_export__permissions(self):
self.post("api:profile-create-export")
......@@ -70,6 +74,21 @@ class TestProfileViewSet(SocialhomeAPITestCase):
self.response_200()
self.assertEqual(self.last_response.data.get('status'), 'Data export job queued.')
def test_followed_tags__self(self):
with self.login(self.user):
self.get("api:profile-detail", uuid=self.profile.uuid)
self.assertEqual(self.last_response.data["followed_tags"], [self.tag.name])
def test_followed_tags__other_user(self):
with self.login(self.other_user):
self.get("api:profile-detail", uuid=self.profile.uuid)
self.assertEqual(self.last_response.data["followed_tags"], [])
def test_followed_tags__staff_user(self):
with self.login(self.staff_user):
self.get("api:profile-detail", uuid=self.profile.uuid)
self.assertEqual(self.last_response.data["followed_tags"], [self.tag.name])
def test_profile_list(self):
self.get("api:profile-list")
self.response_404()
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment