diff --git a/apps/entry/factories.py b/apps/entry/factories.py index 580fb2bb61..6f49b618dc 100644 --- a/apps/entry/factories.py +++ b/apps/entry/factories.py @@ -4,7 +4,11 @@ from gallery.factories import FileFactory -from .models import Entry, Attribute +from .models import ( + Entry, + Attribute, + EntryComment, +) class EntryFactory(DjangoModelFactory): @@ -39,3 +43,8 @@ class EntryAttributeFactory(DjangoModelFactory): class Meta: model = Attribute + + +class EntryCommentFactory(DjangoModelFactory): + class Meta: + model = EntryComment diff --git a/apps/notification/dataloaders.py b/apps/notification/dataloaders.py new file mode 100644 index 0000000000..464956c3ca --- /dev/null +++ b/apps/notification/dataloaders.py @@ -0,0 +1,77 @@ +from django.utils.functional import cached_property +from django.db import models + +from promise import Promise + +from notification.models import Assignment +from lead.models import Lead +from quality_assurance.models import EntryReviewComment + +from utils.graphene.dataloaders import DataLoaderWithContext, WithContextMixin + + +def get_model_name(model: models.Model) -> str: + return model._meta.model_name + + +class AssignmentLoader(DataLoaderWithContext): + def batch_load_fn(self, keys): + assignment_qs = list( + Assignment.objects + .filter(id__in=keys) + .values_list('id', 'content_type__model', 'object_id') + ) + + leads_id = [] + entry_review_comment_id = [] + + for _, content_type, object_id in assignment_qs: + if content_type == get_model_name(Lead): + leads_id.append(object_id) + elif content_type == get_model_name(EntryReviewComment): + entry_review_comment_id.append(object_id) + + _lead_id_map = {} + + for _id, title in Lead.objects.filter(id__in=leads_id).values_list('id', 'title'): + _lead_id_map[_id] = dict( + id=_id, + title=title + ) + + _entry_review_comment_id_map = {} + + for _id, entry_id, lead_id in EntryReviewComment.objects.filter( + id__in=entry_review_comment_id).values_list( + 'id', + 'entry__id', + 'entry__lead_id' + ): + _entry_review_comment_id_map[_id] = dict( + id=_id, + entry_id=entry_id, + lead_id=lead_id + ) + + _result = { + _id: { + 'content_type': content_type, + 'lead': ( + _lead_id_map.get(object_id) + if content_type == get_model_name(Lead) else None + ), + 'entry_review_comment': ( + _entry_review_comment_id_map.get(object_id) + if content_type == get_model_name(EntryReviewComment) else None + ), + } + for _id, content_type, object_id in assignment_qs + } + + return Promise.resolve([_result[key] for key in keys]) + + +class DataLoaders(WithContextMixin): + @cached_property + def assignment(self): + return AssignmentLoader(context=self.context) diff --git a/apps/notification/enums.py b/apps/notification/enums.py index 36cff9e0dd..83dcf2b9ec 100644 --- a/apps/notification/enums.py +++ b/apps/notification/enums.py @@ -1,8 +1,11 @@ +import graphene + from utils.graphene.enums import ( convert_enum_to_graphene_enum, get_enum_name_from_django_field, ) - +from lead.models import Lead +from quality_assurance.models import EntryReviewComment from .models import Notification NotificationTypeEnum = convert_enum_to_graphene_enum(Notification.Type, name='NotificationTypeEnum') @@ -15,3 +18,8 @@ (Notification.status, NotificationStatusEnum), ) } + + +class AssignmentContentTypeEnum(graphene.Enum): + LEAD = Lead._meta.model_name + ENTRY_REVIEW_COMMENT = EntryReviewComment._meta.model_name diff --git a/apps/notification/factories.py b/apps/notification/factories.py index 492aa68c4a..93f884ac23 100644 --- a/apps/notification/factories.py +++ b/apps/notification/factories.py @@ -1,6 +1,7 @@ from factory.django import DjangoModelFactory from notification.models import ( + Assignment, Notification, ) @@ -8,3 +9,8 @@ class NotificationFactory(DjangoModelFactory): class Meta: model = Notification + + +class AssignmentFactory(DjangoModelFactory): + class Meta: + model = Assignment diff --git a/apps/notification/models.py b/apps/notification/models.py index 7f7acadce9..a34d3d5917 100644 --- a/apps/notification/models.py +++ b/apps/notification/models.py @@ -88,4 +88,9 @@ class Meta: @staticmethod def get_for(user): - return Assignment.objects.filter(created_for=user).distinct() + from entry.models import EntryComment + return Assignment.objects.filter( + created_for=user, + ).exclude( + content_type__model=EntryComment._meta.model_name + ) # The EntryComment assignment are excluded need to remove later diff --git a/apps/notification/mutation.py b/apps/notification/mutation.py index 95e10311d0..cc10729b54 100644 --- a/apps/notification/mutation.py +++ b/apps/notification/mutation.py @@ -2,12 +2,12 @@ import graphene -from utils.graphene.mutation import generate_input_type_for_serializer +from utils.graphene.mutation import GrapheneMutation, generate_input_type_for_serializer from utils.graphene.error_types import mutation_is_not_valid, CustomErrorType -from .serializers import NotificationGqSerializer -from .schema import NotificationType -from .models import Notification +from .serializers import AssignmentSerializer, NotificationGqSerializer +from .schema import AssignmentType, NotificationType +from .models import Assignment, Notification NotificationStatusInputType = generate_input_type_for_serializer( 'NotificationStatusInputType', @@ -15,6 +15,11 @@ ) +AssignmentInputType = generate_input_type_for_serializer( + 'AssignmentInputType', + serializer_class=AssignmentSerializer +) + class NotificationStatusUpdate(graphene.Mutation): class Arguments: @@ -44,5 +49,39 @@ def mutate(root, info, data): return NotificationStatusUpdate(result=instance, ok=True, errors=None) +class AssignmentUpdate(GrapheneMutation): + class Arguments: + id = graphene.ID(required=True) + data = AssignmentInputType(required=True) + model = Assignment + result = graphene.Field(AssignmentType) + serializer_class = AssignmentSerializer + + @classmethod + def check_permissions(cls, info, **kwargs): + return True # global permissions is always True + + @classmethod + def filter_queryset(cls, qs, info): + return Assignment.get_for(info.context.user) + + +class AsssignmentBulkStatusMarkAsDone(GrapheneMutation): + model = Assignment + serializer_class = AssignmentSerializer + + @classmethod + def check_permissions(cls, info, **kwargs): + return True + + @classmethod + def perform_mutate(cls, root, info, **kwargs): + instance = Assignment.get_for(info.context.user).filter(is_done=False) + instance.update(is_done=True) + return cls(ok=True) + + class Mutation(object): notification_status_update = NotificationStatusUpdate.Field() + assignment_update = AssignmentUpdate.Field() + assignment_bulk_status_mark_as_done = AsssignmentBulkStatusMarkAsDone.Field() diff --git a/apps/notification/schema.py b/apps/notification/schema.py index e8b91ec4ec..2302bb9e14 100644 --- a/apps/notification/schema.py +++ b/apps/notification/schema.py @@ -9,11 +9,12 @@ from utils.graphene.fields import DjangoPaginatedListObjectField from deep.trackers import track_user -from .models import Notification -from .filter_set import NotificationGqlFilterSet +from .models import Assignment, Notification +from .filter_set import NotificationGqlFilterSet, AssignmentFilterSet from .enums import ( NotificationTypeEnum, NotificationStatusEnum, + AssignmentContentTypeEnum ) @@ -24,6 +25,11 @@ def get_user_notification_qs(info): ) +def get_user_assignment_qs(info): + track_user(info.context.request.user.profile) + return Assignment.get_for(track_user) + + class NotificationType(DjangoObjectType): class Meta: model = Notification @@ -41,12 +47,52 @@ def get_custom_queryset(queryset, info, **kwargs): return get_user_notification_qs(info) +class AssignmentLeadDetailType(graphene.ObjectType): + id = graphene.ID(required=True) + title = graphene.String(required=True) + + +class AssignmentProjectDetailType(graphene.ObjectType): + id = graphene.ID(required=True) + title = graphene.String(required=True) + + +class AssignmentEntryReviewCommentDetailType(graphene.ObjectType): + id = graphene.ID(required=True) + entry_id = graphene.ID(required=True) + lead_id = graphene.ID(required=True) + + +class AssignmentContentDataType(graphene.ObjectType): + content_type = graphene.Field(AssignmentContentTypeEnum) + lead = graphene.Field(AssignmentLeadDetailType) + entry_review_comment = graphene.Field(AssignmentEntryReviewCommentDetailType) + + +class AssignmentType(DjangoObjectType): + class Meta: + model = Assignment + id = graphene.ID(required=True) + project = graphene.Field(AssignmentProjectDetailType) + content_data = graphene.Field(AssignmentContentDataType) + + @staticmethod + def resolve_content_data(root, info): + return info.context.dl.notification.assignment.load(root.pk) + + class NotificationListType(CustomDjangoListObjectType): class Meta: model = Notification filterset_class = NotificationGqlFilterSet +class AssignmentListType(CustomDjangoListObjectType): + class Meta: + model = Assignment + filterset_class = AssignmentFilterSet + + class Query: notification = DjangoObjectField(NotificationType) notifications = DjangoPaginatedListObjectField( @@ -55,7 +101,17 @@ class Query: page_size_query_param='pageSize' ) ) + assignments = DjangoPaginatedListObjectField( + AssignmentListType, + pagination=PageGraphqlPagination( + page_size_query_param='pageSize' + ) + ) @staticmethod def resolve_notifications(root, info, **kwargs) -> QuerySet: return get_user_notification_qs(info) + + @staticmethod + def resolve_assignments(root, info, **kwargs) -> QuerySet: + return Assignment.get_for(info.context.user) diff --git a/apps/notification/tests/test_apis.py b/apps/notification/tests/test_apis.py index d14b16196b..0a5c9ddc2a 100644 --- a/apps/notification/tests/test_apis.py +++ b/apps/notification/tests/test_apis.py @@ -1,5 +1,6 @@ import pytest from datetime import timedelta +from django.contrib.contenttypes.models import ContentType from deep.tests import TestCase from django.utils import timezone @@ -505,12 +506,22 @@ def test_assignment_create_on_entry_comment_assignee_change(self): assert data['count'] == 1 # assignment for user2 def test_assignment_is_done(self): + # XXX: To avoid using content type cache from pre-tests + ContentType.objects.clear_cache() + project = self.create(Project) user1 = self.create(User) user2 = self.create(User) - assignment = self.create(Assignment, created_for=user1, project=project, created_by=user2) - self.create(Assignment, created_for=user1, project=project, created_by=user2) - self.create(Assignment, created_for=user1, project=project, created_by=user2) + lead = self.create(Lead, project=project) + kwargs = { + 'content_object': lead, + 'project': project, + 'created_for': user1, + 'created_by': user2, + } + assignment = self.create(Assignment, **kwargs) + self.create(Assignment, **kwargs) + self.create(Assignment, **kwargs) url = '/api/v1/assignments/' self.authenticate(user1) diff --git a/apps/notification/tests/test_mutation.py b/apps/notification/tests/test_mutation.py index 2e61a9b42a..bca376614f 100644 --- a/apps/notification/tests/test_mutation.py +++ b/apps/notification/tests/test_mutation.py @@ -1,8 +1,12 @@ from utils.graphene.tests import GraphQLTestCase +from django.contrib.contenttypes.models import ContentType from user.factories import UserFactory -from notification.factories import NotificationFactory -from notification.models import Notification +from project.factories import ProjectFactory +from lead.factories import LeadFactory +from notification.factories import NotificationFactory, AssignmentFactory +from notification.models import Assignment, Notification +from lead.models import Lead class NotificationMutation(GraphQLTestCase): @@ -30,7 +34,6 @@ def _query_check(minput, **kwargs): minput=minput, **kwargs ) - minput = dict(id=notification.id, status=self.genum(Notification.Status.SEEN)) # -- Without login _query_check(minput, assert_for_error=True) @@ -48,3 +51,83 @@ def _query_check(minput, **kwargs): self.force_login(another_user) content = _query_check(minput, okay=False)['data']['notificationStatusUpdate']['result'] self.assertEqual(content, None, content) + + +class TestAssignmentMutation(GraphQLTestCase): + def test_assginment_bulk_status_mark_as_done(self): + self.assignment_query = ''' + mutation MyMutation { + assignmentBulkStatusMarkAsDone { + errors + ok + } + } + ''' + project = ProjectFactory.create() + user = UserFactory.create() + lead = LeadFactory.create() + AssignmentFactory.create_batch( + 3, + project=project, + object_id=lead.id, + content_type=ContentType.objects.get_for_model(Lead), + created_for=user, + is_done=False + ) + + def _query_check(**kwargs): + return self.query_check( + self.assignment_query, + **kwargs + ) + + self.force_login(user) + content = _query_check() + assignments_qs = Assignment.get_for(user).filter(is_done=False) + self.assertEqual(content['data']['assignmentBulkStatusMarkAsDone']['errors'], None) + self.assertEqual(len(assignments_qs), 0) + + def test_individual_assignment_update_status(self): + self.indivdual_assignment_query = ''' + mutation Mutation($isDone: Boolean, $id: ID! ){ + assignmentUpdate(id: $id, data: {isDone: $isDone}){ + ok + errors + result{ + id + isDone + } + } + } + ''' + + user = UserFactory.create() + project = ProjectFactory.create() + lead = LeadFactory.create() + assignment = AssignmentFactory.create( + project=project, + object_id=lead.id, + content_type=ContentType.objects.get_for_model(Lead), + created_for=user, + is_done=False + + ) + + def _query_check(**kwargs): + return self.query_check( + self.indivdual_assignment_query, + variables={"isDone": True, "id": assignment.id}, + **kwargs + ) + + # without login + + _query_check(assert_for_error=True) + + # with normal login + + self.force_login(user) + content = _query_check() + assignment_qs = Assignment.get_for(user).filter(id=assignment.id, is_done=False) + self.assertEqual(content['data']['assignmentUpdate']['errors'], None) + self.assertEqual(len(assignment_qs), 0) diff --git a/apps/notification/tests/test_schemas.py b/apps/notification/tests/test_schemas.py index ece686a6fa..eecf3b1bdb 100644 --- a/apps/notification/tests/test_schemas.py +++ b/apps/notification/tests/test_schemas.py @@ -1,12 +1,18 @@ import datetime import pytz +from django.contrib.contenttypes.models import ContentType + from utils.graphene.tests import GraphQLTestCase +from notification.models import Notification + from user.factories import UserFactory from project.factories import ProjectFactory - -from notification.models import Notification -from notification.factories import NotificationFactory +from notification.factories import AssignmentFactory, NotificationFactory +from lead.factories import LeadFactory +from entry.factories import EntryFactory +from quality_assurance.factories import EntryReviewCommentFactory +from analysis_framework.factories import AnalysisFrameworkFactory class TestNotificationQuerySchema(GraphQLTestCase): @@ -186,3 +192,145 @@ def _query_check(filters, **kwargs): content = _query_check(filters) self.assertEqual(content['data']['notifications']['totalCount'], count, f'\n{filters=} \n{content=}') self.assertEqual(len(content['data']['notifications']['results']), count, f'\n{filters=} \n{content=}') + + +class TestAssignmentQuerySchema(GraphQLTestCase): + def test_assignments_query(self): + query = ''' + query MyQuery { + assignments { + results { + contentData { + contentType + entryReviewComment { + entryId + id + leadId + } + lead { + id + title + } + } + createdAt + id + isDone + objectId + project { + id + title + } + createdFor { + id + } + createdBy { + id + } + } + totalCount + } + } + ''' + + # XXX: To avoid using content type cache from pre-tests + ContentType.objects.clear_cache() + + project = ProjectFactory.create() + user = UserFactory.create() + another = UserFactory.create() + lead = LeadFactory.create() + af = AnalysisFrameworkFactory.create() + entry = EntryFactory.create( + analysis_framework=af, + lead=lead + ) + entry_comment = EntryReviewCommentFactory.create( + entry=entry, + created_by=user + ) + + AssignmentFactory.create_batch( + 3, + project=project, + content_object=lead, + created_for=user, + ) + + def _query_check(**kwargs): + return self.query_check(query, **kwargs) + + # -- without login + _query_check(assert_for_error=True) + + # -- with login with different user + self.force_login(another) + content = _query_check() + self.assertEqual(content['data']['assignments']['results'], [], content) + # -- with login normal user + self.force_login(user) + content = _query_check() + self.assertEqual(content['data']['assignments']['totalCount'], 3) + self.assertEqual(content['data']['assignments']['results'][0]['contentData']['contentType'], 'LEAD') + AssignmentFactory.create_batch( + 3, + project=project, + content_object=entry_comment, + created_for=user + ) + content = _query_check() + self.assertEqual(content['data']['assignments']['totalCount'], 6) + + def test_assignments_with_filter_query(self): + query = ''' + query MyQuery($isDone: Boolean) { + assignments(isDone: $isDone) { + totalCount + results { + id + } + } + } + ''' + + # XXX: To avoid using content type cache from pre-tests + ContentType.objects.clear_cache() + + project = ProjectFactory.create() + user = UserFactory.create() + lead = LeadFactory.create() + af = AnalysisFrameworkFactory.create() + entry = EntryFactory.create( + analysis_framework=af, + lead=lead + ) + entry_comment = EntryReviewCommentFactory.create( + entry=entry, + created_by=user + ) + + AssignmentFactory.create_batch( + 3, + project=project, + content_object=lead, + created_for=user, + is_done=False + ) + AssignmentFactory.create_batch( + 5, + project=project, + content_object=entry_comment, + created_for=user, + is_done=True + ) + + def _query_check(filters, **kwargs): + return self.query_check(query, variables=filters, **kwargs) + + self.force_login(user) + for filters, count in [ + ({'isDone': True}, 5), + ({'isDone': False}, 3), + ]: + content = _query_check(filters) + self.assertEqual(content['data']['assignments']['totalCount'], count, f'\n{filters=} \n{content=}') + self.assertEqual(len(content['data']['assignments']['results']), count, f'\n{filters=} \n{content=}') diff --git a/deep/dataloaders.py b/deep/dataloaders.py index 3e18952205..05109008ce 100644 --- a/deep/dataloaders.py +++ b/deep/dataloaders.py @@ -16,6 +16,7 @@ from gallery.dataloaders import DataLoaders as DeepGalleryDataLoaders from assessment_registry.dataloaders import DataLoaders as AssessmentRegistryDataLoaders from assisted_tagging.dataloaders import DataLoaders as AssistedTaggingLoaders +from notification.dataloaders import DataLoaders as AssignmentLoaders class GlobalDataLoaders(WithContextMixin): @@ -74,3 +75,7 @@ def deep_gallery(self): @cached_property def assisted_tagging(self): return AssistedTaggingLoaders(context=self.context) + + @cached_property + def notification(self): + return AssignmentLoaders(context=self.context) diff --git a/schema.graphql b/schema.graphql index c8fe6ed00e..273caa1913 100644 --- a/schema.graphql +++ b/schema.graphql @@ -3081,6 +3081,61 @@ type AssessmentType { modifiedBy: UserType } +type AssignmentContentDataType { + contentType: AssignmentContentTypeEnum + lead: AssignmentLeadDetailType + entryReviewComment: AssignmentEntryReviewCommentDetailType +} + +enum AssignmentContentTypeEnum { + LEAD + ENTRY_REVIEW_COMMENT +} + +type AssignmentEntryReviewCommentDetailType { + id: ID! + entryId: ID! + leadId: ID! +} + +input AssignmentInputType { + isDone: Boolean +} + +type AssignmentLeadDetailType { + id: ID! + title: String! +} + +type AssignmentListType { + results: [AssignmentType!] + totalCount: Int + page: Int + pageSize: Int +} + +type AssignmentProjectDetailType { + id: ID! + title: String! +} + +type AssignmentType { + id: ID! + createdAt: DateTime! + createdBy: UserType + createdFor: UserType! + project: AssignmentProjectDetailType + isDone: Boolean! + objectId: Int! + contentData: AssignmentContentDataType +} + +type AssignmentUpdate { + errors: [GenericScalar!] + ok: Boolean + result: AssignmentType +} + type AssistedTaggingModelPredictionTagType { id: ID! name: String! @@ -3145,6 +3200,11 @@ type AssistedTaggingRootQueryType { predictionTags: [AssistedTaggingModelPredictionTagType!] } +type AsssignmentBulkStatusMarkAsDone { + errors: [GenericScalar!] + ok: Boolean +} + type AtomFeedFieldType { key: String label: String @@ -4914,6 +4974,8 @@ type Mutation { genericExportCreate(data: GenericExportCreateInputType!): CreateUserGenericExport genericExportCancel(id: ID!): CancelUserGenericExport notificationStatusUpdate(data: NotificationStatusInputType!): NotificationStatusUpdate + assignmentUpdate(data: AssignmentInputType!, id: ID!): AssignmentUpdate + assignmentBulkStatusMarkAsDone: AsssignmentBulkStatusMarkAsDone projectCreate(data: ProjectCreateInputType!): CreateProject joinProject(data: ProjectJoinRequestInputType!): CreateProjectJoin projectJoinRequestDelete(projectId: ID!): ProjectJoinRequestDelete @@ -5678,6 +5740,7 @@ type Query { publicLead(uuid: UUID!): PublicLeadMetaType notification(id: ID!): NotificationType notifications(timestamp: DateTime, timestampLte: DateTime, timestampGte: DateTime, isPending: Boolean, notificationType: NotificationTypeEnum, status: NotificationStatusEnum, page: Int = 1, ordering: String, pageSize: Int): NotificationListType + assignments(project: ID, isDone: Boolean, page: Int = 1, ordering: String, pageSize: Int): AssignmentListType region(id: ID!): RegionDetailType regions(id: Float, code: String, title: String, public: Boolean, project: [ID], createdAt: DateTime, createdBy: [ID], modifiedAt: DateTime, modifiedBy: [ID], createdAt_Lt: Date, createdAt_Gte: Date, modifiedAt_Lt: Date, modifiedAt_Gt: Date, excludeProject: [ID!], search: String, page: Int = 1, ordering: String, pageSize: Int): RegionListType organization(id: ID!): OrganizationType