Skip to content

Commit

Permalink
Add Dandiset star functionality with UI components
Browse files Browse the repository at this point in the history
- Introduced DandisetStar model and admin interface for managing starred Dandisets.
- Implemented star/unstar actions in the DandisetViewSet and corresponding API endpoints.
- Added star count and starred status to DandisetSerializer.
- Created StarButton component for toggling star status in the UI.
- Developed StarredDandisetsView to display Dandisets starred by the user.
- Updated DandisetList and DandisetLandingView to include star functionality.
- Enhanced DandisetFilterBackend to support ordering by star count.
  • Loading branch information
bendichter committed Dec 26, 2024
1 parent d30aab9 commit 2bfa8d1
Show file tree
Hide file tree
Showing 17 changed files with 7,351 additions and 514 deletions.
10 changes: 10 additions & 0 deletions dandiapi/api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
Upload,
UserMetadata,
Version,
DandisetStar,
)
from dandiapi.api.views.users import social_account_to_dict
from dandiapi.zarr.tasks import ingest_dandiset_zarrs
Expand Down Expand Up @@ -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'
28 changes: 28 additions & 0 deletions dandiapi/api/migrations/0014_dandisetstar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 4.2.17 on 2024-12-26 14:24

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)),
],
options={
'unique_together': {('user', 'dandiset')},
},
),
]
3 changes: 2 additions & 1 deletion dandiapi/api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +16,7 @@
'AssetPathRelation',
'AuditRecord',
'Dandiset',
'DandisetStar',
'StagingApplication',
'Upload',
'UserMetadata',
Expand Down
28 changes: 28 additions & 0 deletions dandiapi/api/models/dandiset.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from django_extensions.db.models import TimeStampedModel
from guardian.models import GroupObjectPermissionBase, UserObjectPermissionBase
from guardian.shortcuts import assign_perm, get_objects_for_user, get_users_with_perms, remove_perm
from django.contrib.auth.models import User
from django.db.models import Count


class DandisetManager(models.Manager):
Expand Down Expand Up @@ -112,10 +114,36 @@ 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()

def star(self, user):
if user.is_authenticated:
self.stars.get_or_create(user=user)

def unstar(self, user):
if user.is_authenticated:
self.stars.filter(user=user).delete()


class DandisetUserObjectPermission(UserObjectPermissionBase):
content_object = models.ForeignKey(Dandiset, on_delete=models.CASCADE)


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:
unique_together = ('user', 'dandiset')
63 changes: 61 additions & 2 deletions dandiapi/api/views/dandiset.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,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):
Expand Down Expand Up @@ -101,6 +101,9 @@ def filter_queryset(self, request, queryset, view):
)
)
return queryset.order_by(ordering)
if ordering.endswith('stars'):
queryset = queryset.annotate(stars_count=Count('stars'))
return queryset.order_by(f"{'-' if ordering.startswith('-') else ''}stars_count")
return queryset


Expand Down Expand Up @@ -502,3 +505,59 @@ 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):
if not request.user.is_authenticated:
raise NotAuthenticated
dandiset = self.get_object()
dandiset.star(request.user)
return Response(None, 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):
if not request.user.is_authenticated:
raise NotAuthenticated
dandiset = self.get_object()
dandiset.unstar(request.user)
return Response(None, status=status.HTTP_200_OK)

@swagger_auto_schema(
methods=['GET'],
responses={200: DandisetListSerializer(many=True)},
operation_summary='List starred dandisets.',
operation_description='List dandisets starred by the authenticated user.',
)
@action(methods=['GET'], detail=False)
def starred(self, request):
if not request.user.is_authenticated:
raise NotAuthenticated
dandisets = Dandiset.objects.filter(stars__user=request.user).order_by('-stars__created')
dandisets = self.paginate_queryset(dandisets)
dandisets_to_versions = self._get_dandiset_to_version_map(dandisets)
serializer = DandisetListSerializer(
dandisets, many=True, context={'dandisets': dandisets_to_versions}
)
return self.get_paginated_response(serializer.data)
13 changes: 13 additions & 0 deletions dandiapi/api/views/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -51,6 +53,8 @@ class Meta:
'modified',
'contact_person',
'embargo_status',
'star_count',
'is_starred',
]
read_only_fields = ['created']

Expand All @@ -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)
Expand Down
Loading

0 comments on commit 2bfa8d1

Please sign in to comment.