Skip to content

Commit cc86869

Browse files
committed
OpenConceptLab/ocl_online#56 | Add user content activity summary endpoint
1 parent a9436a4 commit cc86869

4 files changed

Lines changed: 141 additions & 2 deletions

File tree

core/users/serializers.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,3 +266,16 @@ def update(self, instance, validated_data):
266266
if instance.id:
267267
instance.set_checksums()
268268
return instance
269+
270+
271+
class UserContentSummarySerializer(serializers.Serializer): # pylint: disable=abstract-method
272+
username = serializers.CharField(read_only=True)
273+
concepts_created = IntegerField(read_only=True)
274+
concepts_updated = IntegerField(read_only=True)
275+
mappings_created = IntegerField(read_only=True)
276+
mappings_updated = IntegerField(read_only=True)
277+
sources_owned = IntegerField(read_only=True)
278+
collections_owned = IntegerField(read_only=True)
279+
references_added = IntegerField(read_only=True)
280+
versions_created = IntegerField(read_only=True)
281+
expansions_created = IntegerField(read_only=True)

core/users/tests/tests.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,3 +382,80 @@ def test_send_user_reset_password_email(self, send_mail_mock):
382382
self.assertTrue(user.reset_password_url in mail.body)
383383
self.assertTrue(f'Hi {user.username},' in mail.body)
384384
send_mail_mock.assert_called_once()
385+
386+
387+
class UserContentSummaryViewTest(OCLAPITestCase):
388+
def setUp(self):
389+
super().setUp()
390+
self.user = UserProfileFactory(username='summaryuser')
391+
self.token = self.user.get_token()
392+
393+
def test_unauthenticated(self):
394+
response = self.client.get('/users/summaryuser/content-summary/')
395+
self.assertEqual(response.status_code, 401)
396+
397+
def test_self_user(self):
398+
response = self.client.get(
399+
'/users/summaryuser/content-summary/',
400+
HTTP_AUTHORIZATION='Token ' + self.token,
401+
)
402+
self.assertEqual(response.status_code, 200)
403+
self.assertEqual(response.data['username'], 'summaryuser')
404+
self.assertEqual(response.data['concepts_created'], 0)
405+
self.assertEqual(response.data['concepts_updated'], 0)
406+
self.assertEqual(response.data['mappings_created'], 0)
407+
self.assertEqual(response.data['mappings_updated'], 0)
408+
self.assertEqual(response.data['sources_owned'], 0)
409+
self.assertEqual(response.data['collections_owned'], 0)
410+
self.assertEqual(response.data['references_added'], 0)
411+
self.assertEqual(response.data['versions_created'], 0)
412+
self.assertEqual(response.data['expansions_created'], 0)
413+
414+
def test_other_user_forbidden(self):
415+
other_user = UserProfileFactory(username='otheruser')
416+
other_token = other_user.get_token()
417+
418+
response = self.client.get(
419+
'/users/summaryuser/content-summary/',
420+
HTTP_AUTHORIZATION='Token ' + other_token,
421+
)
422+
self.assertEqual(response.status_code, 403)
423+
424+
def test_staff_can_view_other_user(self):
425+
admin = UserProfileFactory(username='adminuser', is_staff=True)
426+
admin_token = admin.get_token()
427+
428+
response = self.client.get(
429+
'/users/summaryuser/content-summary/',
430+
HTTP_AUTHORIZATION='Token ' + admin_token,
431+
)
432+
self.assertEqual(response.status_code, 200)
433+
self.assertEqual(response.data['username'], 'summaryuser')
434+
435+
def test_nonexistent_user(self):
436+
response = self.client.get(
437+
'/users/doesnotexist/content-summary/',
438+
HTTP_AUTHORIZATION='Token ' + self.token,
439+
)
440+
self.assertEqual(response.status_code, 404)
441+
442+
def test_with_content(self):
443+
from core.sources.tests.factories import UserSourceFactory
444+
from core.concepts.tests.factories import ConceptFactory
445+
from core.mappings.tests.factories import MappingFactory
446+
from core.collections.tests.factories import UserCollectionFactory
447+
448+
source = UserSourceFactory(user=self.user, created_by=self.user)
449+
ConceptFactory(parent=source, created_by=self.user, updated_by=self.user)
450+
MappingFactory(parent=source, created_by=self.user, updated_by=self.user)
451+
UserCollectionFactory(user=self.user, created_by=self.user)
452+
453+
response = self.client.get(
454+
'/users/summaryuser/content-summary/',
455+
HTTP_AUTHORIZATION='Token ' + self.token,
456+
)
457+
self.assertEqual(response.status_code, 200)
458+
self.assertEqual(response.data['sources_owned'], 1)
459+
self.assertEqual(response.data['collections_owned'], 1)
460+
self.assertGreaterEqual(response.data['concepts_created'], 1)
461+
self.assertGreaterEqual(response.data['mappings_created'], 1)

core/users/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
path('<str:user>/following/<int:id>/', views.UserFollowingView.as_view(), name='userprofile-following'),
3030
path('<str:user>/orgs/', org_views.OrganizationListView.as_view(), name='userprofile-orgs'),
3131
path('<str:user>/extras/', views.UserExtrasView.as_view(), name='user-extras'),
32+
path('<str:user>/content-summary/', views.UserContentSummaryView.as_view(), name='user-content-summary'),
3233
path(
3334
'<str:user>/orgs/sources/',
3435
org_views.OrganizationSourceListView.as_view(), name='userprofile-organization-source-list'),

core/users/views.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from rest_framework.views import APIView
2020

2121
from core.common.constants import NOT_FOUND, MUST_SPECIFY_EXTRA_PARAM_IN_BODY, LAST_LOGIN_SINCE_PARAM, \
22-
LAST_LOGIN_BEFORE_PARAM, DATE_JOINED_SINCE_PARAM, DATE_JOINED_BEFORE_PARAM, UPDATED_BY_USERNAME_PARAM
22+
LAST_LOGIN_BEFORE_PARAM, DATE_JOINED_SINCE_PARAM, DATE_JOINED_BEFORE_PARAM, UPDATED_BY_USERNAME_PARAM, HEAD
2323
from core.common.exceptions import Http400
2424
from core.common.mixins import ListWithHeadersMixin
2525
from core.common.swagger_parameters import last_login_before_param, last_login_since_param, updated_since_param, \
@@ -34,7 +34,7 @@
3434
from core.users.documents import UserProfileDocument
3535
from core.users.search import UserProfileFacetedSearch
3636
from core.users.serializers import UserDetailSerializer, UserCreateSerializer, UserListSerializer, \
37-
UserSummarySerializer, FollowingSerializer
37+
UserSummarySerializer, FollowingSerializer, UserContentSummarySerializer
3838
from .models import UserProfile, Follow
3939
from ..common import ERRBIT_LOGGER
4040
from ..common.throttling import ThrottleUtil
@@ -624,3 +624,51 @@ def perform_destroy(self, instance):
624624
follower.unfollow(instance.following)
625625

626626
return Response(status=status.HTTP_204_NO_CONTENT)
627+
628+
629+
class UserContentSummaryView(APIView):
630+
permission_classes = (IsAuthenticated,)
631+
serializer_class = UserContentSummarySerializer
632+
633+
def get(self, request, user):
634+
from django.db.models import F
635+
from core.collections.models import Collection, CollectionReference, Expansion
636+
from core.concepts.models import Concept
637+
from core.mappings.models import Mapping
638+
from core.sources.models import Source
639+
640+
try:
641+
profile = UserProfile.objects.get(username=user)
642+
except UserProfile.DoesNotExist:
643+
return Response(status=status.HTTP_404_NOT_FOUND)
644+
645+
is_self = request.user.username == profile.username
646+
if not is_self and not request.user.is_staff:
647+
raise PermissionDenied()
648+
649+
head_concepts = Concept.objects.filter(id=F('versioned_object_id'))
650+
head_mappings = Mapping.objects.filter(id=F('versioned_object_id'))
651+
user_head_sources = Source.objects.filter(user=profile, version=HEAD)
652+
user_head_collections = Collection.objects.filter(user=profile, version=HEAD)
653+
654+
# Non-HEAD versions created by this user (version releases)
655+
user_source_versions = Source.objects.filter(
656+
created_by=profile).exclude(version=HEAD)
657+
user_collection_versions = Collection.objects.filter(
658+
created_by=profile).exclude(version=HEAD)
659+
660+
data = {
661+
'username': profile.username,
662+
'concepts_created': head_concepts.filter(created_by=profile).count(),
663+
'concepts_updated': head_concepts.filter(updated_by=profile).count(),
664+
'mappings_created': head_mappings.filter(created_by=profile).count(),
665+
'mappings_updated': head_mappings.filter(updated_by=profile).count(),
666+
'sources_owned': user_head_sources.count(),
667+
'collections_owned': user_head_collections.count(),
668+
'references_added': CollectionReference.objects.filter(
669+
collection__in=user_head_collections, created_by=profile).count(),
670+
'versions_created': user_source_versions.count() + user_collection_versions.count(),
671+
'expansions_created': Expansion.objects.filter(created_by=profile).count(),
672+
}
673+
serializer = self.serializer_class(data)
674+
return Response(serializer.data)

0 commit comments

Comments
 (0)