From 850ea0b14945caab7eb0b6416506225d0e07a81d Mon Sep 17 00:00:00 2001 From: sudan45 Date: Mon, 11 Dec 2023 14:52:16 +0545 Subject: [PATCH 1/3] project pinned by user --- apps/project/migrations/0005_projectpinned.py | 26 +++++++++++++++++++ apps/project/models.py | 7 +++++ apps/project/mutation.py | 16 ++++++++++++ apps/project/serializers.py | 20 ++++++++++++++ 4 files changed, 69 insertions(+) create mode 100644 apps/project/migrations/0005_projectpinned.py diff --git a/apps/project/migrations/0005_projectpinned.py b/apps/project/migrations/0005_projectpinned.py new file mode 100644 index 0000000000..b133f9caf3 --- /dev/null +++ b/apps/project/migrations/0005_projectpinned.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.17 on 2023-12-11 08:29 + +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), + ('project', '0004_project_enable_publicly_viewable_analysis_report_snapshot'), + ] + + operations = [ + migrations.CreateModel( + name='ProjectPinned', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order', models.PositiveIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.project')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/apps/project/models.py b/apps/project/models.py index 10cc596a5f..620249be83 100644 --- a/apps/project/models.py +++ b/apps/project/models.py @@ -835,3 +835,10 @@ class Action(models.IntegerChoices): user = models.ForeignKey(User, on_delete=models.PROTECT) action = models.SmallIntegerField(choices=Action.choices) diff = models.JSONField(null=True, blank=True) + + +class ProjectPinned(models.Model): + project = models.ForeignKey(Project, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + order = models.PositiveIntegerField() + created_at = models.DateTimeField(auto_now_add=True) diff --git a/apps/project/mutation.py b/apps/project/mutation.py index f0d2a67027..7b8b239083 100644 --- a/apps/project/mutation.py +++ b/apps/project/mutation.py @@ -37,6 +37,7 @@ ProjectMembership, ProjectUserGroupMembership, ProjectRole, + ProjectPinned ) from .serializers import ( ProjectGqSerializer, @@ -45,6 +46,7 @@ ProjectMembershipGqlSerializer as ProjectMembershipSerializer, ProjectUserGroupMembershipGqlSerializer as ProjectUserGroupMembershipSerializer, ProjectVizConfigurationSerializer, + ProjectPinnedSerializer ) from .schema import ( ProjectDetailType, @@ -91,6 +93,11 @@ serializer_class=ProjectVizConfigurationSerializer, ) +ProjectPinnedInputType = generate_input_type_for_serializer( + 'ProjectPinnedInputType', + serializer_class=ProjectPinnedSerializer +) + class CreateProject(GrapheneMutation): class Arguments: @@ -316,6 +323,14 @@ class Arguments: permissions = [PP.Permission.UPDATE_PROJECT] +class ProjectPinnedByUser(PsGrapheneMutation): + class Arguments: + data = ProjectPinnedInputType(required=True) + model = ProjectPinned + serializer_class = ProjectPinnedSerializer + permissions = [PP.Permission.UPDATE_PROJECT] + + class ProjectMutationType( # --Begin Project Scoped Mutation LeadMutation, @@ -345,6 +360,7 @@ class Meta: project_viz_configuration_update = UpdateProjectVizConfiguration.Field() unified_connector = graphene.Field(UnifiedConnectorMutationType) assisted_tagging = graphene.Field(AssistedTaggingMutationType) + project_pinned = ProjectPinnedByUser.Field() @staticmethod def get_custom_node(_, info, id): diff --git a/apps/project/serializers.py b/apps/project/serializers.py index f91da8be27..cf5ab567cf 100644 --- a/apps/project/serializers.py +++ b/apps/project/serializers.py @@ -38,6 +38,7 @@ Project, ProjectMembership, ProjectJoinRequest, + ProjectPinned, ProjectRole, ProjectUserGroupMembership, ProjectOrganization, @@ -890,3 +891,22 @@ def create(self, validated_data): ) ProjectChangeManager.log_project_created(project, self.current_user) return project + + +class ProjectPinnedSerializer(serializers.ModelSerializer): + class Meta: + model = ProjectPinned + fields = ( + 'order', + ) + + def validate(self, user): + if len(self.instance.user) > 5: + raise serializers.ValidationError('Only 5 project can be pinned') + return user + + def create(self, validated_data): + return super().create(validated_data) + + def update(self, validated_data): + pass From dbe6d0b0c8e393081e9d954b080535445c67efdc Mon Sep 17 00:00:00 2001 From: sudan45 Date: Tue, 12 Dec 2023 15:35:47 +0545 Subject: [PATCH 2/3] Add Pinned Project Mutation - Create - Reorder - Delete --- apps/project/admin.py | 6 ++ .../0006_projectpinned_modified_at.py | 18 +++++ apps/project/models.py | 1 + apps/project/mutation.py | 81 +++++++++++++++++-- apps/project/schema.py | 17 ++++ apps/project/serializers.py | 65 +++++++++++++-- schema.graphql | 40 +++++++++ 7 files changed, 213 insertions(+), 15 deletions(-) create mode 100644 apps/project/migrations/0006_projectpinned_modified_at.py diff --git a/apps/project/admin.py b/apps/project/admin.py index 3aac885d50..c36346ae2b 100644 --- a/apps/project/admin.py +++ b/apps/project/admin.py @@ -24,6 +24,7 @@ ProjectJoinRequest, ProjectOrganization, ProjectChangeLog, + ProjectPinned ) TRIGGER_LIMIT = 5 @@ -213,3 +214,8 @@ def has_add_permission(self, request, obj=None): @admin.display(description='Diff pretty JSON') def diff_pretty(self, obj): return mark_safe(f'
{json.dumps(obj.diff, indent=2)}
') + + +@admin.register(ProjectPinned) +class ProjectPinnedAdmin(admin.ModelAdmin): + list_display = ('id', 'project', 'user', 'order') diff --git a/apps/project/migrations/0006_projectpinned_modified_at.py b/apps/project/migrations/0006_projectpinned_modified_at.py new file mode 100644 index 0000000000..2bc8d22f87 --- /dev/null +++ b/apps/project/migrations/0006_projectpinned_modified_at.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.17 on 2024-01-09 06:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('project', '0005_projectpinned'), + ] + + operations = [ + migrations.AddField( + model_name='projectpinned', + name='modified_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/apps/project/models.py b/apps/project/models.py index 620249be83..fc1ad73d3f 100644 --- a/apps/project/models.py +++ b/apps/project/models.py @@ -842,3 +842,4 @@ class ProjectPinned(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) order = models.PositiveIntegerField() created_at = models.DateTimeField(auto_now_add=True) + modified_at = models.DateTimeField(auto_now=True) diff --git a/apps/project/mutation.py b/apps/project/mutation.py index 7b8b239083..21cc2c0497 100644 --- a/apps/project/mutation.py +++ b/apps/project/mutation.py @@ -1,3 +1,4 @@ +from django.db import transaction from django.utils.translation import gettext import graphene @@ -46,7 +47,8 @@ ProjectMembershipGqlSerializer as ProjectMembershipSerializer, ProjectUserGroupMembershipGqlSerializer as ProjectUserGroupMembershipSerializer, ProjectVizConfigurationSerializer, - ProjectPinnedSerializer + UserPinnedProjectSerializer, + BulkProjectPinnedSerializer ) from .schema import ( ProjectDetailType, @@ -54,6 +56,7 @@ ProjectMembershipType, ProjectUserGroupMembershipType, ProjectVizDataType, + UserPinnedProjectType ) @@ -95,7 +98,12 @@ ProjectPinnedInputType = generate_input_type_for_serializer( 'ProjectPinnedInputType', - serializer_class=ProjectPinnedSerializer + serializer_class=UserPinnedProjectSerializer +) + +UserPinnedProjectReOrderInputType = generate_input_type_for_serializer( + 'BulkUpdateProjectPinned', + serializer_class=BulkProjectPinnedSerializer, ) @@ -323,12 +331,13 @@ class Arguments: permissions = [PP.Permission.UPDATE_PROJECT] -class ProjectPinnedByUser(PsGrapheneMutation): +class CreateUserPinnedProject(PsGrapheneMutation): class Arguments: - data = ProjectPinnedInputType(required=True) + data = ProjectPinnedInputType(required=False) model = ProjectPinned - serializer_class = ProjectPinnedSerializer - permissions = [PP.Permission.UPDATE_PROJECT] + result = graphene.Field(UserPinnedProjectType) + serializer_class = UserPinnedProjectSerializer + permissions = [] class ProjectMutationType( @@ -360,7 +369,6 @@ class Meta: project_viz_configuration_update = UpdateProjectVizConfiguration.Field() unified_connector = graphene.Field(UnifiedConnectorMutationType) assisted_tagging = graphene.Field(AssistedTaggingMutationType) - project_pinned = ProjectPinnedByUser.Field() @staticmethod def get_custom_node(_, info, id): @@ -383,8 +391,67 @@ def resolve_assisted_tagging(root, info, **kwargs): return {} +class ReorderPinnedProjects(PsGrapheneMutation): + class Arguments: + items = graphene.List(graphene.NonNull(UserPinnedProjectReOrderInputType)) + model = ProjectPinned + result = graphene.List(UserPinnedProjectType) + serializer_class = BulkProjectPinnedSerializer + permissions = [] + + @classmethod + @transaction.atomic() + def perform_mutate(cls, root, info, **kwargs): + errors_data = [] + serializers_data = [] + results = [] + for data in kwargs['items']: + instance, errors = cls.get_object(info, id=data['id']) + if errors: + errors_data.append(errors) + serializer = cls.serializer_class(data=data, instance=instance, context={'request': info.context.request}) + errors_data.append(mutation_is_not_valid(serializer)) # errors_data also add empty list + serializers_data.append(serializer) + errors_data = [items for items in errors_data if items] # list compreshive removing empty list + if errors_data: + return cls(errors=errors_data, ok=False) + for serializer in serializers_data: + results.append(serializer.save()) + return cls(result=results, ok=True) + + +class DeleteUserPinnedProject(DeleteMutation): + class Arguments: + id = graphene.ID(required=True) + model = ProjectPinned + result = graphene.Field(UserPinnedProjectType) + permissions = [] + + @staticmethod + def mutate(root, info, id): + + project_pinned_qs = ProjectPinned.objects.filter( + id=id, + user=info.context.user + ) + if not project_pinned_qs.exists(): + return DeleteUserPinnedProject(errors=[ + dict( + field='nonFieldErrors', + messages=gettext( + 'Not authorize the unpinned project ' + ), + ) + ], ok=False) + project_pinned_qs.delete() + return DeleteUserPinnedProject(result=root, errors=None, ok=True) + + class Mutation(object): project_create = CreateProject.Field() join_project = CreateProjectJoin.Field() project_join_request_delete = ProjectJoinRequestDelete.Field() project = DjangoObjectField(ProjectMutationType) + create_user_pinned_project = CreateUserPinnedProject.Field() + reorder_pinned_projects = ReorderPinnedProjects.Field() + delete_user_pinned_project = DeleteUserPinnedProject.Field() diff --git a/apps/project/schema.py b/apps/project/schema.py index b649cb819b..6343027deb 100644 --- a/apps/project/schema.py +++ b/apps/project/schema.py @@ -53,6 +53,7 @@ ProjectOrganization, ProjectStats, RecentActivityType as ActivityTypes, + ProjectPinned ) from .enums import ( ProjectPermissionEnum, @@ -383,6 +384,17 @@ def resolve_public_url(root, info, **_): return root.get_public_url(info.context.request) +class UserPinnedProjectType(ClientIdMixin, DjangoObjectType): + class Meta: + model = ProjectPinned + only_fields = ( + "project", + "user", + "order", + "client_id", + ) + + class ProjectDetailType( # -- Start --Project scopped entities LeadQuery, @@ -576,6 +588,7 @@ class Query: page_size_query_param='pageSize' ) ) + pinned_project = DjangoListField(UserPinnedProjectType, required=True) # NOTE: This is a custom feature, see https://github.com/the-deep/graphene-django-extras # see: https://github.com/eamigo86/graphene-django-extras/compare/graphene-v2...the-deep:graphene-v2 @@ -612,3 +625,7 @@ def resolve_public_projects(root, info, **kwargs) -> QuerySet: @staticmethod def resolve_public_projects_by_region(*args, **kwargs): return Query.resolve_projects_by_region(*args, **kwargs) + + @staticmethod + def resolve_pinned_project(root, info, **kwargs): + return ProjectPinned.objects.filter(user=info.context.user) diff --git a/apps/project/serializers.py b/apps/project/serializers.py index cf5ab567cf..f485cd9193 100644 --- a/apps/project/serializers.py +++ b/apps/project/serializers.py @@ -893,20 +893,69 @@ def create(self, validated_data): return project -class ProjectPinnedSerializer(serializers.ModelSerializer): +class UserPinnedProjectSerializer(serializers.ModelSerializer): + class Meta: model = ProjectPinned fields = ( - 'order', + 'project', ) - def validate(self, user): - if len(self.instance.user) > 5: - raise serializers.ValidationError('Only 5 project can be pinned') - return user + @cached_property + def current_user(self): + return self.context['request'].user + + @cached_property + def get_queryset(self): + pinned_project = ProjectPinned.objects.filter(user=self.current_user) + return pinned_project + + def validate(self, data): + if (self.get_queryset.count() >= 5): + raise serializers.ValidationError("User can pinned 5 project only!!!") + return data def create(self, validated_data): + if self.get_queryset.filter(project=validated_data['project']).exists(): + raise serializers.ValidationError("Project already pinned!!") + validated_data['user'] = self.current_user + if self.get_queryset: + validated_data['order'] = self.get_queryset.latest('order').order + 1 + return super().create(validated_data) + validated_data['order'] = 1 return super().create(validated_data) - def update(self, validated_data): - pass + def update(self): + raise serializers.ValidationError("not allowed for update") + + +class BulkProjectPinnedSerializer(TempClientIdMixin, UserResourceSerializer): + id = serializers.IntegerField(required=True) + + class Meta: + model = ProjectPinned + fields = ( + 'order', + 'client_id', + 'id' + ) + + @cached_property + def current_user(self): + return self.context['request'].user + + @cached_property + def get_queryset(self): + pinned_project = ProjectPinned.objects.filter(user=self.current_user) + return pinned_project + + def validate(self, data): + if (self.get_queryset.count() >= 5): + raise serializers.ValidationError("User can pinned 5 project only!!!") + return data + + def create(self, validated_data): + raise serializers.ValidationError("Create not allowed") + + def update(self, instance, validated_data): + return super().update(instance, validated_data) diff --git a/schema.graphql b/schema.graphql index c60674b687..e310f3c2ea 100644 --- a/schema.graphql +++ b/schema.graphql @@ -2628,6 +2628,12 @@ type BulkUpdateProjectMembership { deletedResult: [ProjectMembershipType!] } +input BulkUpdateProjectPinned { + order: Int! + clientId: String + id: Int! +} + type BulkUpdateProjectRegion { errors: [[GenericScalar!]] result: [RegionDetailType!] @@ -2919,6 +2925,12 @@ type CreateUserGroup { result: UserGroupType } +type CreateUserPinnedProject { + errors: [GenericScalar!] + ok: Boolean + result: ProjectPinnedDataType +} + type CreateWrongPredictionReview { errors: [GenericScalar!] ok: Boolean @@ -4266,6 +4278,9 @@ type Mutation { joinProject(data: ProjectJoinRequestInputType!): CreateProjectJoin projectJoinRequestDelete(projectId: ID!): ProjectJoinRequestDelete project(id: ID!): ProjectMutationType + createUserPinnedProject(data: ProjectPinnedInputType): CreateUserPinnedProject + reorderPinnedProjects(items: [BulkUpdateProjectPinned!]): ReorderPinnedProjects + deleteUserPinnedProject(id: ID!): deleteUserPinnedProject userGroupCreate(data: UserGroupInputType!): CreateUserGroup userGroup(id: ID!): UserGroupMutationType login(data: LoginInputType!): Login @@ -4724,6 +4739,17 @@ enum ProjectPermission { UPDATE_ASSESSMENT_REGISTRY } +type ProjectPinnedDataType { + project: ProjectType! + user: UserType! + order: Int! + clientId: String! +} + +input ProjectPinnedInputType { + project: ID! +} + type ProjectRoleType { id: ID! title: String! @@ -5039,6 +5065,7 @@ type Query { projectsByRegion(projectFilter: RegionProjectFilterData, ordering: String): ProjectByRegionListType publicProjects(createdAt: DateTime, createdAtGte: DateTime, createdAtLte: DateTime, modifiedAt: DateTime, modifiedAtGte: DateTime, modifiedAtLte: DateTime, createdBy: [ID!], modifiedBy: [ID!], ids: [ID!], excludeIds: [ID!], status: ProjectStatusEnum, organizations: [ID!], analysisFrameworks: [ID!], regions: [ID!], search: String, isCurrentUserMember: Boolean, hasPermissionAccess: ProjectPermission, ordering: [PublicProjectOrderingEnum!], isTest: Boolean, page: Int = 1, pageSize: Int): PublicProjectListType publicProjectsByRegion(projectFilter: RegionProjectFilterData, page: Int = 1, ordering: String, pageSize: Int): PublicProjectByRegionListType + pinnedProject: [ProjectPinnedDataType!]! assistedTagging: AssistedTaggingRootQueryType enums: AppEnumCollection _debug: DjangoDebug @@ -5148,6 +5175,13 @@ input RegisterInputType { captcha: String! } +type ReorderPinnedProjects { + errors: [GenericScalar!] + ok: Boolean + result: [ProjectPinnedDataType] + deletedResult: [ProjectPinnedDataType] +} + type ResetPassword { errors: [GenericScalar!] ok: Boolean! @@ -5877,3 +5911,9 @@ type WrongPredictionReviewType { modifiedBy: UserType prediction: ID! } + +type deleteUserPinnedProject { + errors: [GenericScalar!] + ok: Boolean + result: ProjectPinnedDataType +} From 04ccec454ebff9d379f95ea2beeb666d90500bec Mon Sep 17 00:00:00 2001 From: sudan45 Date: Thu, 18 Jan 2024 10:27:39 +0545 Subject: [PATCH 3/3] Update testcase of mutation --- apps/project/factories.py | 6 ++ .../migrations/0007_merge_20240218_0618.py | 14 +++ apps/project/mutation.py | 6 +- apps/project/schema.py | 47 +++++--- apps/project/serializers.py | 4 +- apps/project/tests/test_mutations.py | 102 +++++++++++++++++- schema.graphql | 56 +++++----- 7 files changed, 190 insertions(+), 45 deletions(-) create mode 100644 apps/project/migrations/0007_merge_20240218_0618.py diff --git a/apps/project/factories.py b/apps/project/factories.py index 8002de113a..e91cf68abb 100644 --- a/apps/project/factories.py +++ b/apps/project/factories.py @@ -5,6 +5,7 @@ Project, ProjectJoinRequest, ProjectOrganization, + ProjectPinned, ) @@ -31,3 +32,8 @@ class Meta: class ProjectOrganizationFactory(DjangoModelFactory): class Meta: model = ProjectOrganization + + +class ProjectPinnedFactory(DjangoModelFactory): + class Meta: + model = ProjectPinned diff --git a/apps/project/migrations/0007_merge_20240218_0618.py b/apps/project/migrations/0007_merge_20240218_0618.py new file mode 100644 index 0000000000..4daa314785 --- /dev/null +++ b/apps/project/migrations/0007_merge_20240218_0618.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.17 on 2024-02-18 06:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('project', '0005_merge_20231227_0610'), + ('project', '0006_projectpinned_modified_at'), + ] + + operations = [ + ] diff --git a/apps/project/mutation.py b/apps/project/mutation.py index 21cc2c0497..506affa4f2 100644 --- a/apps/project/mutation.py +++ b/apps/project/mutation.py @@ -102,7 +102,7 @@ ) UserPinnedProjectReOrderInputType = generate_input_type_for_serializer( - 'BulkUpdateProjectPinned', + 'UserPinnedProjectReOrderInputType', serializer_class=BulkProjectPinnedSerializer, ) @@ -333,7 +333,7 @@ class Arguments: class CreateUserPinnedProject(PsGrapheneMutation): class Arguments: - data = ProjectPinnedInputType(required=False) + data = ProjectPinnedInputType(required=True) model = ProjectPinned result = graphene.Field(UserPinnedProjectType) serializer_class = UserPinnedProjectSerializer @@ -412,7 +412,7 @@ def perform_mutate(cls, root, info, **kwargs): serializer = cls.serializer_class(data=data, instance=instance, context={'request': info.context.request}) errors_data.append(mutation_is_not_valid(serializer)) # errors_data also add empty list serializers_data.append(serializer) - errors_data = [items for items in errors_data if items] # list compreshive removing empty list + errors_data = [items for items in errors_data if items] # list comprehension removing empty list if errors_data: return cls(errors=errors_data, ok=False) for serializer in serializers_data: diff --git a/apps/project/schema.py b/apps/project/schema.py index 6343027deb..8bece763c9 100644 --- a/apps/project/schema.py +++ b/apps/project/schema.py @@ -251,6 +251,8 @@ class Meta: status_display = EnumDescription(source='get_status_display', required=True) organizations = graphene.List(graphene.NonNull(ProjectOrganizationType)) has_analysis_framework = graphene.Boolean(required=True) + has_assessment_template = graphene.Boolean(required=True) + is_project_pinned = graphene.Boolean(required=True) # NOTE: This is a custom feature # see: https://github.com/eamigo86/graphene-django-extras/compare/graphene-v2...the-deep:graphene-v2 @@ -298,6 +300,12 @@ def resolve_regions(root, info, **kwargs): return info.context.dl.project.geo_region.load(root.pk) return info.context.dl.project.public_geo_region.load(root.pk) + def resolve_is_project_pinned(root, info, **kwargs): + return ProjectPinned.objects.filter( + project=root, + user=info.context.request.user + ).exists() + class RecentActivityType(graphene.ObjectType): id = graphene.ID(required=True) @@ -384,17 +392,6 @@ def resolve_public_url(root, info, **_): return root.get_public_url(info.context.request) -class UserPinnedProjectType(ClientIdMixin, DjangoObjectType): - class Meta: - model = ProjectPinned - only_fields = ( - "project", - "user", - "order", - "client_id", - ) - - class ProjectDetailType( # -- Start --Project scopped entities LeadQuery, @@ -458,6 +455,10 @@ class Meta: # Other scoped queries unified_connector = graphene.Field(UnifiedConnectorQueryType) assisted_tagging = graphene.Field(AssistedTaggingQueryType) + is_project_pinned = graphene.Boolean( + required=True, + description='Check if user have pinned the project' + ) @staticmethod def resolve_user_members(root, info, **kwargs): @@ -507,6 +508,26 @@ def resolve_assisted_tagging(root, info, **kwargs): if root.get_current_user_role(info.context.request.user) is not None: return {} + @staticmethod + def resolve_is_project_pinned(root, info, **kwargs): + return ProjectPinned.objects.filter( + project=root, + user=info.context.request.user + ).exists() + + +class UserPinnedProjectType(ClientIdMixin, DjangoObjectType): + class Meta: + model = ProjectPinned + only_fields = ( + 'id', + "project", + "user", + "order", + "client_id", + ) + project = graphene.Field(graphene.NonNull(ProjectDetailType)) + class ProjectByRegion(graphene.ObjectType): id = graphene.ID(required=True, description='Region\'s ID') @@ -588,7 +609,7 @@ class Query: page_size_query_param='pageSize' ) ) - pinned_project = DjangoListField(UserPinnedProjectType, required=True) + user_pinned_projects = DjangoListField(UserPinnedProjectType, required=True) # NOTE: This is a custom feature, see https://github.com/the-deep/graphene-django-extras # see: https://github.com/eamigo86/graphene-django-extras/compare/graphene-v2...the-deep:graphene-v2 @@ -627,5 +648,5 @@ def resolve_public_projects_by_region(*args, **kwargs): return Query.resolve_projects_by_region(*args, **kwargs) @staticmethod - def resolve_pinned_project(root, info, **kwargs): + def resolve_user_pinned_project(root, info, **kwargs): return ProjectPinned.objects.filter(user=info.context.user) diff --git a/apps/project/serializers.py b/apps/project/serializers.py index f485cd9193..4e50d4a555 100644 --- a/apps/project/serializers.py +++ b/apps/project/serializers.py @@ -15,7 +15,7 @@ URLCachedFileField, IntegerIDField, TempClientIdMixin, - ProjectPropertySerializerMixin, + ProjectPropertySerializerMixin ) from geo.models import Region from geo.serializers import SimpleRegionSerializer @@ -930,7 +930,7 @@ def update(self): class BulkProjectPinnedSerializer(TempClientIdMixin, UserResourceSerializer): - id = serializers.IntegerField(required=True) + id = IntegerIDField(required=True) class Meta: model = ProjectPinned diff --git a/apps/project/tests/test_mutations.py b/apps/project/tests/test_mutations.py index 6d5fc65e3f..e6c5b2a68e 100644 --- a/apps/project/tests/test_mutations.py +++ b/apps/project/tests/test_mutations.py @@ -26,7 +26,12 @@ from entry.factories import EntryFactory, EntryAttributeFactory from analysis_framework.factories import AnalysisFrameworkFactory, WidgetFactory from user_group.factories import UserGroupFactory -from project.factories import ProjectFactory, ProjectJoinRequestFactory, ProjectOrganizationFactory +from project.factories import ( + ProjectFactory, + ProjectJoinRequestFactory, + ProjectOrganizationFactory, + ProjectPinnedFactory, +) from organization.factories import OrganizationFactory from geo.factories import RegionFactory @@ -1381,3 +1386,98 @@ def _get_project_ids(): project6.id, ] ) + + def test_create_user_pinned_project(self): + query = ''' + mutation MyMutation($project: ID!) { + createUserPinnedProject(data: {project: $project}) { + ok + errors + result { + clientId + order + user{ + id + } + project{ + id + } + } + } + } + ''' + project1 = ProjectFactory.create( + title='Test Project 1', + ) + project2 = ProjectFactory.create( + title='Test Project 2', + ) + member_user = UserFactory.create() + owner_user = UserFactory.create() + project1.add_member(member_user, role=self.project_role_member) + project2.add_member(owner_user, role=self.project_role_owner) + minput = dict( + project=project1.id + ) + + def _query_check(**kwargs): + return self.query_check( + query, + variables=minput, + **kwargs, + ) + self.force_login(member_user) + response = _query_check()['data']['createUserPinnedProject']['result'] + self.assertEqual(response['clientId'], str(project1.id)) + self.assertEqual(response['order'], 1) + self.assertEqual(response['user']['id'], str(member_user.id)) + self.assertEqual(response['project']['id'], str(project1.id)) + # pin project which is already pinned by user + response = _query_check(assert_for_error=True)['errors'] + self.assertIn("Project already pinned!!", response[0]['message']) + # pin another project + minput['project'] = project2.id + response = _query_check()['data']['createUserPinnedProject']['result'] + self.assertEqual(response['clientId'], str(project2.id)) + self.assertEqual(response['order'], 2) + self.assertEqual(response['project']['id'], str(project2.id)) + + def test_bulk_reorder_pinned_project(self): + project1 = ProjectFactory.create(title='Test project 3') + project2 = ProjectFactory.create(title='Test project 4') + member_user = UserFactory.create() + project1.add_member(member_user, role=self.project_role_member) + project2.add_member(member_user, role=self.project_role_member) + pinned_project1 = ProjectPinnedFactory.create(project=project1, user=member_user, order=10) + # pinned_project2 = ProjectPinnedFactory.create(project=project2, user=member_user, order=12) + minput = dict( + order=14, + id=pinned_project1.id + ) + query = ''' + mutation MyMutation($bulkReorder: UserPinnedProjectReOrderInputType!) { + reorderPinnedProjects(items: $bulkReorder) { + errors + ok + result { + clientId + order + project { + title + id + } + user { + id + } + } + } + } + ''' + + def _query_check(**kwargs): + return self.query_check( + query, + variable=minput, + **kwargs + ) + self.force_login(member_user) diff --git a/schema.graphql b/schema.graphql index e310f3c2ea..044a077f8d 100644 --- a/schema.graphql +++ b/schema.graphql @@ -2628,12 +2628,6 @@ type BulkUpdateProjectMembership { deletedResult: [ProjectMembershipType!] } -input BulkUpdateProjectPinned { - order: Int! - clientId: String - id: Int! -} - type BulkUpdateProjectRegion { errors: [[GenericScalar!]] result: [RegionDetailType!] @@ -2928,7 +2922,7 @@ type CreateUserGroup { type CreateUserPinnedProject { errors: [GenericScalar!] ok: Boolean - result: ProjectPinnedDataType + result: UserPinnedProjectType } type CreateWrongPredictionReview { @@ -3026,6 +3020,12 @@ type DeleteUserGroup { result: UserGroupType } +type DeleteUserPinnedProject { + errors: [GenericScalar!] + ok: Boolean + result: UserPinnedProjectType +} + type DeleteWrongPredictionReview { errors: [GenericScalar!] ok: Boolean @@ -4278,9 +4278,9 @@ type Mutation { joinProject(data: ProjectJoinRequestInputType!): CreateProjectJoin projectJoinRequestDelete(projectId: ID!): ProjectJoinRequestDelete project(id: ID!): ProjectMutationType - createUserPinnedProject(data: ProjectPinnedInputType): CreateUserPinnedProject - reorderPinnedProjects(items: [BulkUpdateProjectPinned!]): ReorderPinnedProjects - deleteUserPinnedProject(id: ID!): deleteUserPinnedProject + createUserPinnedProject(data: ProjectPinnedInputType!): CreateUserPinnedProject + reorderPinnedProjects(items: [UserPinnedProjectReOrderInputType!]): ReorderPinnedProjects + deleteUserPinnedProject(id: ID!): DeleteUserPinnedProject userGroupCreate(data: UserGroupInputType!): CreateUserGroup userGroup(id: ID!): UserGroupMutationType login(data: LoginInputType!): Login @@ -4484,6 +4484,8 @@ type ProjectDetailType { statusDisplay: EnumDescription! organizations: [ProjectOrganizationType!] hasAnalysisFramework: Boolean! + hasAssessmentTemplate: Boolean! + isProjectPinned: Boolean! assessmentDashboardStatistics(filter: AssessmentDashboardFilterInputType!): AssessmentDashboardStatisticsType assessmentRegistry(id: ID!): AssessmentRegistryType assessmentRegistries(createdAt: DateTime, createdAtGte: DateTime, createdAtLte: DateTime, modifiedAt: DateTime, modifiedAtGte: DateTime, modifiedAtLte: DateTime, createdBy: [ID], modifiedBy: [ID!], dateFrom: Date, dateTo: Date, project: [ID], lead: [ID], publicationDateLte: Date, publicationDateGte: Date, search: String, page: Int = 1, pageSize: Int): AssessmentRegistryListType @@ -4739,13 +4741,6 @@ enum ProjectPermission { UPDATE_ASSESSMENT_REGISTRY } -type ProjectPinnedDataType { - project: ProjectType! - user: UserType! - order: Int! - clientId: String! -} - input ProjectPinnedInputType { project: ID! } @@ -4837,6 +4832,8 @@ type ProjectType { statusDisplay: EnumDescription! organizations: [ProjectOrganizationType!] hasAnalysisFramework: Boolean! + hasAssessmentTemplate: Boolean! + isProjectPinned: Boolean! } input ProjectUpdateInputType { @@ -5065,7 +5062,7 @@ type Query { projectsByRegion(projectFilter: RegionProjectFilterData, ordering: String): ProjectByRegionListType publicProjects(createdAt: DateTime, createdAtGte: DateTime, createdAtLte: DateTime, modifiedAt: DateTime, modifiedAtGte: DateTime, modifiedAtLte: DateTime, createdBy: [ID!], modifiedBy: [ID!], ids: [ID!], excludeIds: [ID!], status: ProjectStatusEnum, organizations: [ID!], analysisFrameworks: [ID!], regions: [ID!], search: String, isCurrentUserMember: Boolean, hasPermissionAccess: ProjectPermission, ordering: [PublicProjectOrderingEnum!], isTest: Boolean, page: Int = 1, pageSize: Int): PublicProjectListType publicProjectsByRegion(projectFilter: RegionProjectFilterData, page: Int = 1, ordering: String, pageSize: Int): PublicProjectByRegionListType - pinnedProject: [ProjectPinnedDataType!]! + userPinnedProjects: [UserPinnedProjectType!]! assistedTagging: AssistedTaggingRootQueryType enums: AppEnumCollection _debug: DjangoDebug @@ -5178,8 +5175,7 @@ input RegisterInputType { type ReorderPinnedProjects { errors: [GenericScalar!] ok: Boolean - result: [ProjectPinnedDataType] - deletedResult: [ProjectPinnedDataType] + result: [UserPinnedProjectType] } type ResetPassword { @@ -5799,6 +5795,20 @@ type UserMeType { onlyMemberProjects: [UserMeProjectType] } +input UserPinnedProjectReOrderInputType { + order: Int! + clientId: String + id: ID! +} + +type UserPinnedProjectType { + id: ID! + project: ProjectDetailType! + user: UserType! + order: Int! + clientId: ID! +} + type UserProfileType { id: ID! displayPictureUrl: String @@ -5911,9 +5921,3 @@ type WrongPredictionReviewType { modifiedBy: UserType prediction: ID! } - -type deleteUserPinnedProject { - errors: [GenericScalar!] - ok: Boolean - result: ProjectPinnedDataType -}