diff --git a/dandiapi/api/admin.py b/dandiapi/api/admin.py index 7587db1ea..a84f1fa53 100644 --- a/dandiapi/api/admin.py +++ b/dandiapi/api/admin.py @@ -21,6 +21,7 @@ AssetBlob, AuditRecord, Dandiset, + DandisetStar, Upload, UserMetadata, Version, @@ -266,3 +267,12 @@ def has_change_permission(self, request, obj=None): def has_delete_permission(self, request, obj=None): return False + + +@admin.register(DandisetStar) +class DandisetStarAdmin(admin.ModelAdmin): + list_display = ('user', 'dandiset', 'created') + list_filter = ('created',) + search_fields = ('user__username', 'dandiset__id') + raw_id_fields = ('user', 'dandiset') + date_hierarchy = 'created' diff --git a/dandiapi/api/migrations/0014_dandisetstar.py b/dandiapi/api/migrations/0014_dandisetstar.py new file mode 100644 index 000000000..6067a6b5b --- /dev/null +++ b/dandiapi/api/migrations/0014_dandisetstar.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.17 on 2025-01-03 21:10 +from __future__ import annotations + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0013_remove_assetpath_consistent_slash_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='DandisetStar', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ('created', models.DateTimeField(auto_now_add=True)), + ( + 'dandiset', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='stars', + to='api.dandiset', + ), + ), + ( + 'user', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='starred_dandisets', + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.AddConstraint( + model_name='dandisetstar', + constraint=models.UniqueConstraint( + fields=('user', 'dandiset'), name='unique-user-dandiset-star' + ), + ), + ] diff --git a/dandiapi/api/models/__init__.py b/dandiapi/api/models/__init__.py index 082c7bb95..675b2fc7a 100644 --- a/dandiapi/api/models/__init__.py +++ b/dandiapi/api/models/__init__.py @@ -3,7 +3,7 @@ from .asset import Asset, AssetBlob from .asset_paths import AssetPath, AssetPathRelation from .audit import AuditRecord -from .dandiset import Dandiset +from .dandiset import Dandiset, DandisetStar from .oauth import StagingApplication from .upload import Upload from .user import UserMetadata @@ -16,6 +16,7 @@ 'AssetPathRelation', 'AuditRecord', 'Dandiset', + 'DandisetStar', 'StagingApplication', 'Upload', 'UserMetadata', diff --git a/dandiapi/api/models/dandiset.py b/dandiapi/api/models/dandiset.py index 97cb8bff3..e3acd1988 100644 --- a/dandiapi/api/models/dandiset.py +++ b/dandiapi/api/models/dandiset.py @@ -1,5 +1,6 @@ from __future__ import annotations +from django.contrib.auth.models import User from django.db import models from django_extensions.db.models import TimeStampedModel from guardian.models import GroupObjectPermissionBase, UserObjectPermissionBase @@ -112,6 +113,15 @@ def published_count(cls): def __str__(self) -> str: return self.identifier + @property + def star_count(self): + return self.stars.count() + + def is_starred_by(self, user): + if not user.is_authenticated: + return False + return self.stars.filter(user=user).exists() + class DandisetUserObjectPermission(UserObjectPermissionBase): content_object = models.ForeignKey(Dandiset, on_delete=models.CASCADE) @@ -119,3 +129,17 @@ class DandisetUserObjectPermission(UserObjectPermissionBase): class DandisetGroupObjectPermission(GroupObjectPermissionBase): content_object = models.ForeignKey(Dandiset, on_delete=models.CASCADE) + + +class DandisetStar(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='starred_dandisets') + dandiset = models.ForeignKey(Dandiset, on_delete=models.CASCADE, related_name='stars') + created = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint(name='unique-user-dandiset-star', fields=['user', 'dandiset']) + ] + + def __str__(self) -> str: + return f'Star {self.user.username} ★ {self.dandiset.identifier}' diff --git a/dandiapi/api/services/dandiset/__init__.py b/dandiapi/api/services/dandiset/__init__.py index 20067be60..544487e4a 100644 --- a/dandiapi/api/services/dandiset/__init__.py +++ b/dandiapi/api/services/dandiset/__init__.py @@ -3,12 +3,16 @@ from django.db import transaction from guardian.shortcuts import assign_perm -from dandiapi.api.models.dandiset import Dandiset +from dandiapi.api.models.dandiset import Dandiset, DandisetStar from dandiapi.api.models.version import Version from dandiapi.api.services import audit from dandiapi.api.services.dandiset.exceptions import DandisetAlreadyExistsError from dandiapi.api.services.embargo.exceptions import DandisetUnembargoInProgressError -from dandiapi.api.services.exceptions import AdminOnlyOperationError, NotAllowedError +from dandiapi.api.services.exceptions import ( + AdminOnlyOperationError, + NotAllowedError, + NotAuthenticatedError, +) from dandiapi.api.services.version.metadata import _normalize_version_metadata @@ -74,3 +78,39 @@ def delete_dandiset(*, user, dandiset: Dandiset) -> None: dandiset.versions.all().delete() dandiset.delete() + + +def star_dandiset(*, user, dandiset: Dandiset) -> int: + """ + Star a Dandiset for a user. + + Args: + user: The user starring the Dandiset. + dandiset: The Dandiset to star. + + Returns: + The new star count for the Dandiset. + """ + if not user.is_authenticated: + raise NotAuthenticatedError + + DandisetStar.objects.get_or_create(user=user, dandiset=dandiset) + return dandiset.star_count + + +def unstar_dandiset(*, user, dandiset: Dandiset) -> int: + """ + Unstar a Dandiset for a user. + + Args: + user: The user unstarring the Dandiset. + dandiset: The Dandiset to unstar. + + Returns: + The new star count for the Dandiset. + """ + if not user.is_authenticated: + raise NotAuthenticatedError + + DandisetStar.objects.filter(user=user, dandiset=dandiset).delete() + return dandiset.star_count diff --git a/dandiapi/api/services/exceptions.py b/dandiapi/api/services/exceptions.py index 386869c13..b8166144a 100644 --- a/dandiapi/api/services/exceptions.py +++ b/dandiapi/api/services/exceptions.py @@ -21,5 +21,10 @@ class NotAllowedError(DandiError): http_status_code = status.HTTP_403_FORBIDDEN +class NotAuthenticatedError(DandiError): + message = 'Action requires authentication.' + http_status_code = status.HTTP_401_UNAUTHORIZED + + class AdminOnlyOperationError(DandiError): pass diff --git a/dandiapi/api/tests/test_dandiset.py b/dandiapi/api/tests/test_dandiset.py index 79aa522f9..ada6b52a5 100644 --- a/dandiapi/api/tests/test_dandiset.py +++ b/dandiapi/api/tests/test_dandiset.py @@ -80,6 +80,8 @@ def test_dandiset_rest_list(api_client, user, dandiset): 'most_recent_published_version': None, 'contact_person': '', 'embargo_status': 'OPEN', + 'star_count': 0, + 'is_starred': False, } ], } @@ -162,6 +164,8 @@ def expected_serialization(dandiset: Dandiset): 'modified': TIMESTAMP_RE, 'contact_person': contact_person, 'embargo_status': 'OPEN', + 'star_count': 0, + 'is_starred': False, 'draft_version': { 'version': draft_version.version, 'name': draft_version.name, @@ -223,6 +227,8 @@ def test_dandiset_rest_list_for_user(api_client, user, dandiset_factory): 'most_recent_published_version': None, 'contact_person': '', 'embargo_status': 'OPEN', + 'star_count': 0, + 'is_starred': False, } ], } @@ -238,6 +244,8 @@ def test_dandiset_rest_retrieve(api_client, dandiset): 'most_recent_published_version': None, 'contact_person': '', 'embargo_status': 'OPEN', + 'star_count': 0, + 'is_starred': False, } @@ -279,6 +287,8 @@ def test_dandiset_rest_embargo_access( 'most_recent_published_version': None, 'contact_person': '', 'embargo_status': embargo_status, + 'star_count': 0, + 'is_starred': False, } # This is what unauthorized users should get from the retrieve endpoint expected_error_message = {'detail': 'Not found.'} @@ -354,6 +364,8 @@ def test_dandiset_rest_create(api_client, user): 'modified': TIMESTAMP_RE, 'contact_person': 'Doe, John', 'embargo_status': 'OPEN', + 'star_count': 0, + 'is_starred': False, 'draft_version': { 'version': 'draft', 'name': name, @@ -366,6 +378,8 @@ def test_dandiset_rest_create(api_client, user): 'modified': TIMESTAMP_RE, 'contact_person': 'Doe, John', 'embargo_status': 'OPEN', + 'star_count': 0, + 'is_starred': False, }, 'status': 'Pending', 'created': TIMESTAMP_RE, @@ -460,6 +474,8 @@ def test_dandiset_rest_create_with_identifier(api_client, admin_user): 'modified': TIMESTAMP_RE, 'contact_person': 'Doe, John', 'embargo_status': 'OPEN', + 'star_count': 0, + 'is_starred': False, }, 'status': 'Pending', 'created': TIMESTAMP_RE, @@ -467,6 +483,8 @@ def test_dandiset_rest_create_with_identifier(api_client, admin_user): }, 'contact_person': 'Doe, John', 'embargo_status': 'OPEN', + 'star_count': 0, + 'is_starred': False, } # Creating a Dandiset has side affects. @@ -568,6 +586,8 @@ def test_dandiset_rest_create_with_contributor(api_client, admin_user): 'modified': TIMESTAMP_RE, 'contact_person': 'Jane Doe', 'embargo_status': 'OPEN', + 'star_count': 0, + 'is_starred': False, }, 'status': 'Pending', 'created': TIMESTAMP_RE, @@ -575,6 +595,8 @@ def test_dandiset_rest_create_with_contributor(api_client, admin_user): }, 'contact_person': 'Jane Doe', 'embargo_status': 'OPEN', + 'star_count': 0, + 'is_starred': False, } # Creating a Dandiset has side affects. @@ -647,6 +669,8 @@ def test_dandiset_rest_create_embargoed(api_client, user): 'modified': TIMESTAMP_RE, 'contact_person': 'Doe, John', 'embargo_status': 'EMBARGOED', + 'star_count': 0, + 'is_starred': False, 'draft_version': { 'version': 'draft', 'name': name, @@ -659,6 +683,8 @@ def test_dandiset_rest_create_embargoed(api_client, user): 'modified': TIMESTAMP_RE, 'contact_person': 'Doe, John', 'embargo_status': 'EMBARGOED', + 'star_count': 0, + 'is_starred': False, }, 'status': 'Pending', 'created': TIMESTAMP_RE, @@ -1199,3 +1225,86 @@ def test_dandiset_rest_clear_active_uploads( response = authenticated_api_client.get(f'/api/dandisets/{ds.identifier}/uploads/').json() assert response['count'] == 0 assert len(response['results']) == 0 + + +@pytest.mark.django_db +def test_dandiset_star(api_client, user, dandiset): + api_client.force_authenticate(user=user) + response = api_client.post(f'/api/dandisets/{dandiset.identifier}/star/') + assert response.status_code == 200 + assert response.data == {'count': 1} + assert dandiset.stars.count() == 1 + assert dandiset.stars.first().user == user + + +@pytest.mark.django_db +def test_dandiset_unstar(api_client, user, dandiset): + api_client.force_authenticate(user=user) + # First star it + api_client.post(f'/api/dandisets/{dandiset.identifier}/star/') + assert dandiset.stars.count() == 1 + + # Then unstar it + response = api_client.post(f'/api/dandisets/{dandiset.identifier}/unstar/') + assert response.status_code == 200 + assert response.data == {'count': 0} + assert dandiset.stars.count() == 0 + + +@pytest.mark.django_db +def test_dandiset_star_unauthenticated(api_client, dandiset): + response = api_client.post(f'/api/dandisets/{dandiset.identifier}/star/') + assert response.status_code == 401 + + +@pytest.mark.django_db +def test_dandiset_star_count(api_client, user_factory, dandiset): + users = [user_factory() for _ in range(3)] + for user in users: + api_client.force_authenticate(user=user) + api_client.post(f'/api/dandisets/{dandiset.identifier}/star/') + + response = api_client.get(f'/api/dandisets/{dandiset.identifier}/') + assert response.data['star_count'] == 3 + + +@pytest.mark.django_db +def test_dandiset_is_starred(api_client, user, dandiset): + # Test unauthenticated + response = api_client.get(f'/api/dandisets/{dandiset.identifier}/') + assert response.data['is_starred'] is False + + # Test authenticated but not starred + api_client.force_authenticate(user=user) + response = api_client.get(f'/api/dandisets/{dandiset.identifier}/') + assert response.data['is_starred'] is False + + # Test after starring + api_client.post(f'/api/dandisets/{dandiset.identifier}/star/') + response = api_client.get(f'/api/dandisets/{dandiset.identifier}/') + assert response.data['is_starred'] is True + + +@pytest.mark.django_db +def test_dandiset_list_starred(api_client, user, dandiset_factory): + api_client.force_authenticate(user=user) + dandisets = [dandiset_factory() for _ in range(3)] + + # Star 2 out of 3 dandisets + api_client.post(f'/api/dandisets/{dandisets[0].identifier}/star/') + api_client.post(f'/api/dandisets/{dandisets[1].identifier}/star/') + + # List starred dandisets + response = api_client.get('/api/dandisets/', {'starred': True}) + assert response.status_code == 200 + assert response.data['count'] == 2 + assert {d['identifier'] for d in response.data['results']} == { + dandisets[0].identifier, + dandisets[1].identifier, + } + + +@pytest.mark.django_db +def test_dandiset_list_starred_unauthenticated(api_client): + response = api_client.get('/api/dandisets/', {'starred': True}) + assert response.status_code == 401 diff --git a/dandiapi/api/tests/test_version.py b/dandiapi/api/tests/test_version.py index 78c163a63..29948f6e8 100644 --- a/dandiapi/api/tests/test_version.py +++ b/dandiapi/api/tests/test_version.py @@ -417,6 +417,8 @@ def test_version_rest_list(api_client, version, draft_version_factory): 'modified': TIMESTAMP_RE, 'contact_person': version.metadata['contributor'][0]['name'], 'embargo_status': 'OPEN', + 'star_count': 0, + 'is_starred': False, }, 'version': version.version, 'name': version.name, @@ -455,6 +457,8 @@ def test_version_rest_info(api_client, version): 'modified': TIMESTAMP_RE, 'contact_person': version.metadata['contributor'][0]['name'], 'embargo_status': 'OPEN', + 'star_count': 0, + 'is_starred': False, }, 'version': version.version, 'name': version.name, @@ -506,6 +510,8 @@ def test_version_rest_info_with_asset( 'modified': TIMESTAMP_RE, 'contact_person': version.metadata['contributor'][0]['name'], 'embargo_status': 'OPEN', + 'star_count': 0, + 'is_starred': False, }, 'version': version.version, 'name': version.name, @@ -583,6 +589,8 @@ def test_version_rest_update(api_client, user, draft_version): 'modified': TIMESTAMP_RE, 'contact_person': 'Vargas, Getúlio', 'embargo_status': 'OPEN', + 'star_count': 0, + 'is_starred': False, }, 'version': draft_version.version, 'name': new_name, diff --git a/dandiapi/api/views/dandiset.py b/dandiapi/api/views/dandiset.py index 867dd4319..d6f07b6dd 100644 --- a/dandiapi/api/views/dandiset.py +++ b/dandiapi/api/views/dandiset.py @@ -26,13 +26,20 @@ from dandiapi.api.asset_paths import get_root_paths_many from dandiapi.api.mail import send_ownership_change_emails from dandiapi.api.models import Dandiset, Version +from dandiapi.api.models.dandiset import DandisetStar from dandiapi.api.services import audit -from dandiapi.api.services.dandiset import create_dandiset, delete_dandiset +from dandiapi.api.services.dandiset import ( + create_dandiset, + delete_dandiset, + star_dandiset, + unstar_dandiset, +) from dandiapi.api.services.embargo import kickoff_dandiset_unembargo from dandiapi.api.services.embargo.exceptions import ( DandisetUnembargoInProgressError, UnauthorizedEmbargoAccessError, ) +from dandiapi.api.services.exceptions import NotAuthenticatedError from dandiapi.api.views.common import DANDISET_PK_PARAM from dandiapi.api.views.pagination import DandiPagination from dandiapi.api.views.serializers import ( @@ -56,10 +63,10 @@ class DandisetFilterBackend(filters.OrderingFilter): - ordering_fields = ['id', 'name', 'modified', 'size'] + ordering_fields = ['id', 'name', 'modified', 'size', 'stars'] ordering_description = ( 'Which field to use when ordering the results. ' - 'Options are id, -id, name, -name, modified, -modified, size and -size.' + 'Options are id, -id, name, -name, modified, -modified, size, -size, stars, -stars.' ) def filter_queryset(self, request, queryset, view): @@ -101,6 +108,10 @@ def filter_queryset(self, request, queryset, view): ) ) return queryset.order_by(ordering) + if ordering.endswith('stars'): + queryset = queryset.annotate(stars_count=Count('stars')) + prefix = '-' if ordering.startswith('-') else '' + return queryset.order_by(f'{prefix}stars_count') return queryset @@ -115,49 +126,61 @@ class DandisetViewSet(ReadOnlyModelViewSet): lookup_url_kwarg = 'dandiset__pk' def get_queryset(self): - # Only include embargoed dandisets which belong to the current user queryset = Dandiset.objects - if self.action in ['list', 'search']: - queryset = Dandiset.objects.visible_to(self.request.user).order_by('created') - - query_serializer = DandisetQueryParameterSerializer(data=self.request.query_params) - query_serializer.is_valid(raise_exception=True) - - # TODO: This will filter the dandisets list if there is a query parameter user=me. - # This is not a great solution but it is needed for the My Dandisets page. - user_kwarg = query_serializer.validated_data.get('user') - if user_kwarg == 'me': - # Replace the original, rather inefficient queryset with a more specific one - queryset = get_objects_for_user( - self.request.user, 'owner', Dandiset, with_superuser=False - ).order_by('created') - - show_draft: bool = query_serializer.validated_data['draft'] - show_empty: bool = query_serializer.validated_data['empty'] - show_embargoed: bool = query_serializer.validated_data['embargoed'] - - # Return early if attempting to access embargoed data without authentication - if show_embargoed and not self.request.user.is_authenticated: - raise UnauthorizedEmbargoAccessError - - if not show_draft: - # Only include dandisets that have more than one version, i.e. published dandisets. - queryset = queryset.annotate(version_count=Count('versions')).filter( - version_count__gt=1 - ) - if not show_empty: - # Only include dandisets that have assets in their most recent version. - most_recent_version = ( - Version.objects.filter(dandiset=OuterRef('pk')) - .order_by('-created') - .annotate(asset_count=Count('assets'))[:1] - ) - queryset = queryset.annotate( - asset_count=Subquery(most_recent_version.values('asset_count')) + if self.action not in ['list', 'search']: + return queryset + + # Only include embargoed dandisets which belong to the current user + queryset = Dandiset.objects.visible_to(self.request.user).order_by('created') + + query_serializer = DandisetQueryParameterSerializer(data=self.request.query_params) + query_serializer.is_valid(raise_exception=True) + + # TODO: This will filter the dandisets list if there is a query parameter user=me. + # This is not a great solution but it is needed for the My Dandisets page. + user_kwarg = query_serializer.validated_data.get('user') + if user_kwarg == 'me': + # Replace the original, rather inefficient queryset with a more specific one + queryset = get_objects_for_user( + self.request.user, 'owner', Dandiset, with_superuser=False + ).order_by('created') + + show_draft: bool = query_serializer.validated_data['draft'] + show_empty: bool = query_serializer.validated_data['empty'] + show_embargoed: bool = query_serializer.validated_data['embargoed'] + filter_starred: bool = query_serializer.validated_data['starred'] + + # Return early if attempting to access embargoed data without authentication + if show_embargoed and not self.request.user.is_authenticated: + raise UnauthorizedEmbargoAccessError + + if not show_draft: + # Only include dandisets that have more than one version, i.e. published dandisets. + queryset = queryset.annotate(version_count=Count('versions')).filter( + version_count__gt=1 + ) + if not show_empty: + # Only include dandisets that have assets in their most recent version. + most_recent_version = ( + Version.objects.filter(dandiset=OuterRef('pk')) + .order_by('-created') + .annotate(asset_count=Count('assets'))[:1] + ) + queryset = queryset.annotate( + asset_count=Subquery(most_recent_version.values('asset_count')) + ) + queryset = queryset.filter(asset_count__gt=0) + if not show_embargoed: + queryset = queryset.filter(embargo_status='OPEN') + + if filter_starred: + if not self.request.user.is_authenticated: + raise NotAuthenticatedError( + message='Must be authenticated to filter by starred dandisets.' ) - queryset = queryset.filter(asset_count__gt=0) - if not show_embargoed: - queryset = queryset.filter(embargo_status='OPEN') + + queryset = queryset.filter(stars__user=self.request.user).order_by('-stars__created') + return queryset def require_owner_perm(self, dandiset: Dandiset): @@ -187,6 +210,36 @@ def get_object(self): return dandiset + def _get_dandiset_star_context(self, dandisets): + # Default value for all relevant dandisets + dandisets_to_stars = { + d.id: {'total': 0, 'starred_by_current_user': False} for d in dandisets + } + + # Group the stars for these dandisets by the dandiset ID, + # yielding pairs of (Dandiset ID, Star Count) + dandiset_stars = ( + DandisetStar.objects.filter(dandiset__in=dandisets) + .values_list('dandiset') + .annotate(star_count=Count('id')) + .order_by() + ) + for dandiset_id, star_count in dandiset_stars: + dandisets_to_stars[dandiset_id]['total'] = star_count + + # Only annotate dandisets as starred by current user if user is logged in + user = self.request.user + if user.is_anonymous: + return dandisets_to_stars + user = typing.cast(User, user) + + # Filter previous query to current user stars + user_starred_dandisets = dandiset_stars.filter(user=user) + for dandiset_id, _ in user_starred_dandisets: + dandisets_to_stars[dandiset_id]['starred_by_current_user'] = True + + return dandisets_to_stars + @staticmethod def _get_dandiset_to_version_map(dandisets): """Map Dandiset IDs to that dandiset's draft and most recently published version.""" @@ -257,6 +310,7 @@ def search(self, request, *args, **kwargs): qs = self.get_queryset() dandisets = self.filter_queryset(qs).filter(id__in=relevant_assets.values('dandiset_id')) dandisets = self.paginate_queryset(dandisets) + dandiset_stars = self._get_dandiset_star_context(dandisets) dandisets_to_versions = self._get_dandiset_to_version_map(dandisets) dandisets_to_asset_counts = ( AssetSearch.objects.values('dandiset_id') @@ -278,7 +332,11 @@ def search(self, request, *args, **kwargs): serializer = DandisetSearchResultListSerializer( dandisets, many=True, - context={'dandisets': dandisets_to_versions, 'asset_counts': dandisets_to_asset_counts}, + context={ + 'dandisets': dandisets_to_versions, + 'asset_counts': dandisets_to_asset_counts, + 'stars': dandiset_stars, + }, ) return self.get_paginated_response(serializer.data) @@ -290,8 +348,14 @@ def list(self, request, *args, **kwargs): qs = self.get_queryset() dandisets = self.paginate_queryset(self.filter_queryset(qs)) dandisets_to_versions = self._get_dandiset_to_version_map(dandisets) + dandiset_stars = self._get_dandiset_star_context(dandisets) serializer = DandisetListSerializer( - dandisets, many=True, context={'dandisets': dandisets_to_versions} + dandisets, + many=True, + context={ + 'dandisets': dandisets_to_versions, + 'stars': dandiset_stars, + }, ) return self.get_paginated_response(serializer.data) @@ -382,8 +446,10 @@ def unembargo(self, request, dandiset__pk): 400: 'User not found, or cannot remove all owners', }, operation_summary='Set owners of a dandiset.', - operation_description='Set the owners of a dandiset. The user performing this action must\ - be an owner of the dandiset themself.', + operation_description=( + 'Set the owners of a dandiset. The user performing this action must ' + 'be an owner of the dandiset themself.' + ), ) # TODO: move these into a viewset @action(methods=['GET', 'PUT'], detail=True) @@ -502,3 +568,37 @@ def clear_uploads(self, request, dandiset__pk): dandiset.uploads.all().delete() return Response(status=status.HTTP_204_NO_CONTENT) + + @swagger_auto_schema( + methods=['POST'], + manual_parameters=[DANDISET_PK_PARAM], + request_body=no_body, + responses={ + 200: 'Dandiset starred successfully', + 401: 'Authentication required', + }, + operation_summary='Star a dandiset.', + operation_description='Star a dandiset. User must be authenticated.', + ) + @action(methods=['POST'], detail=True) + def star(self, request, dandiset__pk) -> Response: + dandiset = self.get_object() + star_count = star_dandiset(user=request.user, dandiset=dandiset) + return Response({'count': star_count}, status=status.HTTP_200_OK) + + @swagger_auto_schema( + methods=['POST'], + manual_parameters=[DANDISET_PK_PARAM], + request_body=no_body, + responses={ + 200: 'Dandiset unstarred successfully', + 401: 'Authentication required', + }, + operation_summary='Unstar a dandiset.', + operation_description='Unstar a dandiset. User must be authenticated.', + ) + @action(methods=['POST'], detail=True) + def unstar(self, request, dandiset__pk) -> Response: + dandiset = self.get_object() + star_count = unstar_dandiset(user=request.user, dandiset=dandiset) + return Response({'count': star_count}, status=status.HTTP_200_OK) diff --git a/dandiapi/api/views/serializers.py b/dandiapi/api/views/serializers.py index f12b5ca2a..dde93f522 100644 --- a/dandiapi/api/views/serializers.py +++ b/dandiapi/api/views/serializers.py @@ -42,6 +42,8 @@ class UserDetailSerializer(serializers.Serializer): class DandisetSerializer(serializers.ModelSerializer): contact_person = serializers.SerializerMethodField(method_name='get_contact_person') + star_count = serializers.SerializerMethodField() + is_starred = serializers.SerializerMethodField() class Meta: model = Dandiset @@ -51,6 +53,8 @@ class Meta: 'modified', 'contact_person', 'embargo_status', + 'star_count', + 'is_starred', ] read_only_fields = ['created'] @@ -62,6 +66,15 @@ def get_contact_person(self, dandiset: Dandiset): return extract_contact_person(latest_version) + def get_star_count(self, dandiset): + return dandiset.star_count + + def get_is_starred(self, dandiset): + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return False + return dandiset.is_starred_by(request.user) + class CreateDandisetQueryParameterSerializer(serializers.Serializer): embargo = serializers.BooleanField(required=False, default=False) @@ -156,6 +169,12 @@ def get_contact_person(self, dandiset): return contact + def get_star_count(self, dandiset): + return self.context['stars'][dandiset.id]['total'] + + def get_is_starred(self, dandiset): + return self.context['stars'][dandiset.id]['starred_by_current_user'] + most_recent_published_version = serializers.SerializerMethodField() draft_version = serializers.SerializerMethodField() @@ -193,6 +212,11 @@ class DandisetQueryParameterSerializer(serializers.Serializer): required=False, help_text='Set this value to "me" to only return dandisets owned by the current user.', ) + starred = serializers.BooleanField( + default=False, + help_text='Whether to filter the result to only dandisets' + ' that have been starred by the current user.', + ) class DandisetSearchQueryParameterSerializer(DandisetQueryParameterSerializer): diff --git a/dandiapi/api/views/version.py b/dandiapi/api/views/version.py index 51a6542d2..8e781a51b 100644 --- a/dandiapi/api/views/version.py +++ b/dandiapi/api/views/version.py @@ -79,7 +79,9 @@ def retrieve(self, request, **kwargs): def info(self, request, **kwargs): """Django serialization of a version.""" version = self.get_object() - serializer = VersionDetailSerializer(instance=version) + serializer = VersionDetailSerializer( + instance=version, context=self.get_serializer_context() + ) return Response(serializer.data, status=status.HTTP_200_OK) @swagger_auto_schema( diff --git a/web/src/components/AppBar/AppBar.vue b/web/src/components/AppBar/AppBar.vue index 6a93da574..9acd95c15 100644 --- a/web/src/components/AppBar/AppBar.vue +++ b/web/src/components/AppBar/AppBar.vue @@ -170,6 +170,12 @@ const navItems: NavigationItem[] = [ external: false, if: loggedInFunc, }, + { + text: 'Starred Dandisets', + to: 'starredDandisets', + external: false, + if: loggedInFunc, + }, { text: 'About', to: dandiAboutUrl, diff --git a/web/src/components/DandisetList.vue b/web/src/components/DandisetList.vue index 81b81b78e..29c2c67c9 100644 --- a/web/src/components/DandisetList.vue +++ b/web/src/components/DandisetList.vue @@ -17,6 +17,12 @@ {{ item.name }} + , diff --git a/web/src/components/DandisetsPage.vue b/web/src/components/DandisetsPage.vue index e46608f47..fcdf1ead8 100644 --- a/web/src/components/DandisetsPage.vue +++ b/web/src/components/DandisetsPage.vue @@ -125,6 +125,11 @@ export default defineComponent({ required: false, default: false, }, + starred: { + type: Boolean, + required: false, + default: false, + }, }, setup(props) { const route = useRoute(); @@ -149,6 +154,7 @@ export default defineComponent({ ordering, user: props.user ? 'me' : null, search: props.search ? route.query.search : null, + starred: props.starred ? true : null, draft: props.user ? true : showDrafts.value, empty: props.user ? true : showEmpty.value, embargoed: props.user, diff --git a/web/src/components/StarButton.vue b/web/src/components/StarButton.vue new file mode 100644 index 000000000..0cfa72c55 --- /dev/null +++ b/web/src/components/StarButton.vue @@ -0,0 +1,40 @@ + + + \ No newline at end of file diff --git a/web/src/rest.ts b/web/src/rest.ts index 233bf73d2..d46844fff 100644 --- a/web/src/rest.ts +++ b/web/src/rest.ts @@ -302,6 +302,12 @@ const dandiRest = { `${dandiApiRoot}dandisets/${identifier}/versions/${version}/assets/${uuid}/` ); }, + async starDandiset(identifier: string): Promise { + await client.post(`dandisets/${identifier}/star/`); + }, + async unstarDandiset(identifier: string): Promise { + await client.post(`dandisets/${identifier}/unstar/`); + }, }; // This is done with an interceptor because the value of diff --git a/web/src/router.ts b/web/src/router.ts index 78f602e02..e1e221ebd 100644 --- a/web/src/router.ts +++ b/web/src/router.ts @@ -10,6 +10,7 @@ import DandisetLandingView from '@/views/DandisetLandingView/DandisetLandingView import CreateDandisetView from '@/views/CreateDandisetView/CreateDandisetView.vue'; import FileBrowser from '@/views/FileBrowserView/FileBrowser.vue'; import SearchView from '@/views/SearchView/SearchView.vue'; +import StarredDandisetsView from '@/views/StarredDandisetsView/StarredDandisetsView.vue'; Vue.use(Router); @@ -29,6 +30,14 @@ const routes: RouteConfig[] = [ name: 'myDandisets', component: MyDandisetsView, }, + { + path: '/dandiset/starred', + name: 'starredDandisets', + component: StarredDandisetsView, + meta: { + requiresAuth: true, + }, + }, { path: '/dandiset/search', name: 'searchDandisets', diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 8f040553a..32a45ef58 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -22,6 +22,8 @@ export interface Dandiset { most_recent_published_version?: Version, contact_person?: string, embargo_status: 'EMBARGOED' | 'UNEMBARGOING' | 'OPEN', + star_count: number, + is_starred: boolean, } export interface DandisetSearchResult extends Dandiset { @@ -109,4 +111,4 @@ export interface IncompleteUpload { upload_id: string; etag: string; size: number; -} \ No newline at end of file +} diff --git a/web/src/utils/constants.ts b/web/src/utils/constants.ts index b9c8758a1..c6dada1d0 100644 --- a/web/src/utils/constants.ts +++ b/web/src/utils/constants.ts @@ -44,6 +44,10 @@ const sortingOptions = [ name: 'Size', djangoField: 'size', }, + { + name: 'Stars', + djangoField: 'stars', + }, ]; const DANDISETS_PER_PAGE = 8; diff --git a/web/src/views/DandisetLandingView/DandisetMain.vue b/web/src/views/DandisetLandingView/DandisetMain.vue index 32828e1ee..0ba38ad7b 100644 --- a/web/src/views/DandisetLandingView/DandisetMain.vue +++ b/web/src/views/DandisetLandingView/DandisetMain.vue @@ -13,6 +13,12 @@ {{ meta.name }} + (none) {{ license }} @@ -278,6 +283,7 @@ import OverviewTab from '@/components/DLP/OverviewTab.vue'; import RelatedResourcesTab from '@/components/DLP/RelatedResourcesTab.vue'; import SubjectMatterTab from '@/components/DLP/SubjectMatterTab.vue'; import ShareDialog from './ShareDialog.vue'; +import StarButton from '@/components/StarButton.vue'; // max description length before it's truncated and "see more" button is shown const MAX_DESCRIPTION_LENGTH = 400; @@ -324,6 +330,7 @@ export default defineComponent({ OverviewTab, RelatedResourcesTab, SubjectMatterTab, + StarButton, }, props: { schema: { diff --git a/web/src/views/StarredDandisetsView/StarredDandisetsView.vue b/web/src/views/StarredDandisetsView/StarredDandisetsView.vue new file mode 100644 index 000000000..ec9d01bca --- /dev/null +++ b/web/src/views/StarredDandisetsView/StarredDandisetsView.vue @@ -0,0 +1,19 @@ + + +