diff --git a/apps/analysis_framework/admin.py b/apps/analysis_framework/admin.py index 0c23fc8d45..9c50badab6 100644 --- a/apps/analysis_framework/admin.py +++ b/apps/analysis_framework/admin.py @@ -13,6 +13,7 @@ from .models import ( AnalysisFramework, + AnalysisFrameworkTag, AnalysisFrameworkRole, AnalysisFrameworkMembership, Section, @@ -113,3 +114,8 @@ class AnalysisFrameworkRoleAdmin(admin.ModelAdmin): def has_add_permission(self, request, obj=None): return False + + +@admin.register(AnalysisFrameworkTag) +class AnalysisFrameworkTagAdmin(admin.ModelAdmin): + list_display = ('id', 'title',) diff --git a/apps/analysis_framework/dataloaders.py b/apps/analysis_framework/dataloaders.py index 61ce9ccfdb..3e60585cdd 100644 --- a/apps/analysis_framework/dataloaders.py +++ b/apps/analysis_framework/dataloaders.py @@ -12,6 +12,7 @@ Filter, Exportable, AnalysisFrameworkMembership, + AnalysisFramework, ) @@ -90,6 +91,17 @@ def batch_load_fn(self, keys): return Promise.resolve([_map[key] for key in keys]) +class AnalysisFrameworkTagsLoader(DataLoaderWithContext): + def batch_load_fn(self, keys): + qs = AnalysisFramework.tags.through.objects.filter( + analysisframework__in=keys, + ).select_related('analysisframeworktag') + _map = defaultdict(list) + for row in qs: + _map[row.analysisframework_id].append(row.analysisframeworktag) + return Promise.resolve([_map[key] for key in keys]) + + class DataLoaders(WithContextMixin): @cached_property def secondary_widgets(self): @@ -114,3 +126,7 @@ def exportables(self): @cached_property def members(self): return MembershipLoader(context=self.context) + + @cached_property + def af_tags(self): + return AnalysisFrameworkTagsLoader(context=self.context) diff --git a/apps/analysis_framework/factories.py b/apps/analysis_framework/factories.py index 1fc3f4027c..33b8552113 100644 --- a/apps/analysis_framework/factories.py +++ b/apps/analysis_framework/factories.py @@ -1,15 +1,32 @@ import factory from factory import fuzzy from factory.django import DjangoModelFactory +from django.core.files.base import ContentFile from .models import ( AnalysisFramework, + AnalysisFrameworkTag, Section, Widget, Filter, ) +class AnalysisFrameworkTagFactory(DjangoModelFactory): + title = factory.Sequence(lambda n: f'AF-Tag-{n}') + description = factory.Faker('sentence', nb_words=20) + icon = factory.LazyAttribute( + lambda n: ContentFile( + factory.django.ImageField()._make_data( + {'width': 100, 'height': 100} + ), f'example_{n.title}.png' + ) + ) + + class Meta: + model = AnalysisFrameworkTag + + class AnalysisFrameworkFactory(DjangoModelFactory): title = factory.Sequence(lambda n: f'AF-{n}') description = factory.Faker('sentence', nb_words=20) @@ -17,6 +34,14 @@ class AnalysisFrameworkFactory(DjangoModelFactory): class Meta: model = AnalysisFramework + @factory.post_generation + def tags(self, create, extracted, **_): + if not create: + return + if extracted: + for tag in extracted: + self.tags.add(tag) + class SectionFactory(DjangoModelFactory): title = factory.Sequence(lambda n: f'Section-{n}') diff --git a/apps/analysis_framework/filter_set.py b/apps/analysis_framework/filter_set.py index 6f41bea999..8186f1c798 100644 --- a/apps/analysis_framework/filter_set.py +++ b/apps/analysis_framework/filter_set.py @@ -5,8 +5,11 @@ UserResourceFilterSet, UserResourceGqlFilterSet, ) +from utils.graphene.filters import IDListFilter + from .models import ( AnalysisFramework, + AnalysisFrameworkTag, ) from entry.models import Entry from django.utils import timezone @@ -28,7 +31,22 @@ class Meta: }, } + # ----------------------------- Graphql Filters --------------------------------------- +class AnalysisFrameworkTagGqFilterSet(django_filters.FilterSet): + search = django_filters.CharFilter(method='search_filter') + + class Meta: + model = AnalysisFrameworkTag + fields = ['id'] + + def search_filter(self, qs, _, value): + if value: + return qs.filter( + models.Q(title__icontains=value) | + models.Q(description__icontains=value) + ) + return qs class AnalysisFrameworkGqFilterSet(UserResourceGqlFilterSet): @@ -39,6 +57,7 @@ class AnalysisFrameworkGqFilterSet(UserResourceGqlFilterSet): method='filter_recently_used', label='Recently Used', ) + tags = IDListFilter(distinct=True) class Meta: model = AnalysisFramework diff --git a/apps/analysis_framework/migrations/0040_auto_20231109_1208.py b/apps/analysis_framework/migrations/0040_auto_20231109_1208.py new file mode 100644 index 0000000000..1c9924582b --- /dev/null +++ b/apps/analysis_framework/migrations/0040_auto_20231109_1208.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.17 on 2023-11-09 12:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('analysis_framework', '0039_analysisframework_cloned_from'), + ] + + operations = [ + migrations.CreateModel( + name='AnalysisFrameworkTag', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), + ('icon', models.FileField(max_length=255, upload_to='af-tag-icon/')), + ], + ), + migrations.AddField( + model_name='analysisframework', + name='tags', + field=models.ManyToManyField(blank=True, related_name='_analysis_framework_analysisframework_tags_+', to='analysis_framework.AnalysisFrameworkTag'), + ), + ] diff --git a/apps/analysis_framework/models.py b/apps/analysis_framework/models.py index bd12485d54..7c95fa99ca 100644 --- a/apps/analysis_framework/models.py +++ b/apps/analysis_framework/models.py @@ -11,6 +11,12 @@ from .widgets import store as widgets_store +class AnalysisFrameworkTag(models.Model): + title = models.CharField(max_length=255) + description = models.TextField(blank=True) + icon = models.FileField(upload_to='af-tag-icon/', max_length=255) + + class AnalysisFramework(UserResource): """ Analysis framework defining framework to do analysis @@ -19,6 +25,7 @@ class AnalysisFramework(UserResource): """ title = models.CharField(max_length=255) description = models.TextField(blank=True, null=True) + tags = models.ManyToManyField(AnalysisFrameworkTag, related_name='+', blank=True) is_private = models.BooleanField(default=False) assisted_tagging_enabled = models.BooleanField(default=False) diff --git a/apps/analysis_framework/schema.py b/apps/analysis_framework/schema.py index 54a1e38c0d..33f7a249f3 100644 --- a/apps/analysis_framework/schema.py +++ b/apps/analysis_framework/schema.py @@ -15,6 +15,7 @@ from assisted_tagging.models import PredictionTagAnalysisFrameworkWidgetMapping from .models import ( AnalysisFramework, + AnalysisFrameworkTag, Section, Widget, Filter, @@ -29,7 +30,7 @@ AnalysisFrameworkRoleTypeEnum, ) from .serializers import AnalysisFrameworkPropertiesGqlSerializer -from .filter_set import AnalysisFrameworkGqFilterSet +from .filter_set import AnalysisFrameworkGqFilterSet, AnalysisFrameworkTagGqFilterSet from .public_schema import PublicAnalysisFrameworkListType @@ -84,6 +85,18 @@ def resolve_widgets(root, info): return info.context.dl.analysis_framework.sections_widgets.load(root.id) +class AnalysisFrameworkTagType(DjangoObjectType): + class Meta: + model = AnalysisFrameworkTag + only_fields = ( + 'id', + 'title', + 'description', + ) + + icon = graphene.Field(FileFieldType, required=False) + + # NOTE: We have AnalysisFrameworkDetailType for detailed AF Type. class AnalysisFrameworkType(DjangoObjectType): class Meta: @@ -96,12 +109,19 @@ class Meta: current_user_role = graphene.Field(AnalysisFrameworkRoleTypeEnum) preview_image = graphene.Field(FileFieldType) export = graphene.Field(FileFieldType) + cloned_from = graphene.ID(source='cloned_from_id') allowed_permissions = graphene.List( graphene.NonNull( graphene.Enum.from_enum(AfP.Permission), - ), required=True + ), + required=True, + ) + tags = graphene.List( + graphene.NonNull( + AnalysisFrameworkTagType, + ), + required=True, ) - cloned_from = graphene.ID(source='cloned_from_id') @staticmethod def get_custom_node(_, info, id): @@ -123,6 +143,10 @@ def resolve_allowed_permissions(root, info): is_public=not root.is_private, ) + @staticmethod + def resolve_tags(root, info): + return info.context.dl.analysis_framework.af_tags.load(root.id) + class AnalysisFrameworkRoleType(DjangoObjectType): class Meta: @@ -251,6 +275,12 @@ class Meta: filterset_class = AnalysisFrameworkGqFilterSet +class AnalysisFrameworkTagListType(CustomDjangoListObjectType): + class Meta: + model = AnalysisFrameworkTag + filterset_class = AnalysisFrameworkTagGqFilterSet + + class Query: analysis_framework = DjangoObjectField(AnalysisFrameworkDetailType) analysis_frameworks = DjangoPaginatedListObjectField( @@ -265,6 +295,12 @@ class Query: page_size_query_param='pageSize' ) ) + analysis_framework_tags = DjangoPaginatedListObjectField( + AnalysisFrameworkTagListType, + pagination=PageGraphqlPagination( + page_size_query_param='pageSize' + ) + ) @staticmethod def resolve_analysis_frameworks(root, info, **kwargs) -> QuerySet: diff --git a/apps/analysis_framework/tests/snapshots/snap_test_schemas.py b/apps/analysis_framework/tests/snapshots/snap_test_schemas.py index 4b747b7df3..9a1b0b4cc4 100644 --- a/apps/analysis_framework/tests/snapshots/snap_test_schemas.py +++ b/apps/analysis_framework/tests/snapshots/snap_test_schemas.py @@ -344,3 +344,120 @@ } } } + +snapshots['TestAnalysisFrameworkQuery::test_analysis_framework_list response-01'] = [ + { + 'clonedFrom': None, + 'description': 'Investment on gun young catch management sense technology check civil quite others his other life edge network wall quite boy those seem shoulder future fall citizen.', + 'id': '2', + 'isPrivate': False, + 'tags': [ + { + 'description': 'Future choice whatever from behavior benefit suggest page southern role movie win her need stop peace technology officer relate animal direction eye.', + 'icon': { + 'name': 'af-tag-icon/example_AF-Tag-1.png', + 'url': 'http://testserver/media/af-tag-icon/example_AF-Tag-1.png' + }, + 'id': '2', + 'title': 'AF-Tag-1' + } + ], + 'title': 'AF-1' + }, + { + 'clonedFrom': None, + 'description': 'West then enjoy may condition tree that fear police participant check several.', + 'id': '3', + 'isPrivate': False, + 'tags': [ + ], + 'title': 'AF-2' + } +] + +snapshots['TestAnalysisFrameworkQuery::test_analysis_framework_list response-02'] = [ + { + 'clonedFrom': None, + 'description': 'Investment on gun young catch management sense technology check civil quite others his other life edge network wall quite boy those seem shoulder future fall citizen.', + 'id': '2', + 'isPrivate': False, + 'tags': [ + { + 'description': 'Future choice whatever from behavior benefit suggest page southern role movie win her need stop peace technology officer relate animal direction eye.', + 'icon': { + 'name': 'af-tag-icon/example_AF-Tag-1.png', + 'url': 'http://testserver/media/af-tag-icon/example_AF-Tag-1.png' + }, + 'id': '2', + 'title': 'AF-Tag-1' + } + ], + 'title': 'AF-1' + }, + { + 'clonedFrom': None, + 'description': 'West then enjoy may condition tree that fear police participant check several.', + 'id': '3', + 'isPrivate': False, + 'tags': [ + ], + 'title': 'AF-2' + } +] + +snapshots['TestAnalysisFrameworkQuery::test_analysis_framework_list response-03'] = [ + { + 'clonedFrom': None, + 'description': 'Here writer policy news range successful simply director allow firm environment decision wall then fire pretty how trip learn enter east no enjoy.', + 'id': '1', + 'isPrivate': True, + 'tags': [ + { + 'description': 'Each cause bill scientist nation opportunity all behavior discussion own night respond red information last everything thank serve civil.', + 'icon': { + 'name': 'af-tag-icon/example_AF-Tag-0.png', + 'url': 'http://testserver/media/af-tag-icon/example_AF-Tag-0.png' + }, + 'id': '1', + 'title': 'AF-Tag-0' + }, + { + 'description': 'Future choice whatever from behavior benefit suggest page southern role movie win her need stop peace technology officer relate animal direction eye.', + 'icon': { + 'name': 'af-tag-icon/example_AF-Tag-1.png', + 'url': 'http://testserver/media/af-tag-icon/example_AF-Tag-1.png' + }, + 'id': '2', + 'title': 'AF-Tag-1' + } + ], + 'title': 'AF-0' + }, + { + 'clonedFrom': None, + 'description': 'Investment on gun young catch management sense technology check civil quite others his other life edge network wall quite boy those seem shoulder future fall citizen.', + 'id': '2', + 'isPrivate': False, + 'tags': [ + { + 'description': 'Future choice whatever from behavior benefit suggest page southern role movie win her need stop peace technology officer relate animal direction eye.', + 'icon': { + 'name': 'af-tag-icon/example_AF-Tag-1.png', + 'url': 'http://testserver/media/af-tag-icon/example_AF-Tag-1.png' + }, + 'id': '2', + 'title': 'AF-Tag-1' + } + ], + 'title': 'AF-1' + }, + { + 'clonedFrom': None, + 'description': 'West then enjoy may condition tree that fear police participant check several.', + 'id': '3', + 'isPrivate': False, + 'tags': [ + ], + 'title': 'AF-2' + } +] diff --git a/apps/analysis_framework/tests/test_filters.py b/apps/analysis_framework/tests/test_filters.py index e605bea27f..1e34fc32f1 100644 --- a/apps/analysis_framework/tests/test_filters.py +++ b/apps/analysis_framework/tests/test_filters.py @@ -3,7 +3,7 @@ from utils.graphene.tests import GraphQLTestCase from analysis_framework.filter_set import AnalysisFrameworkGqFilterSet -from analysis_framework.factories import AnalysisFrameworkFactory +from analysis_framework.factories import AnalysisFrameworkFactory, AnalysisFrameworkTagFactory from entry.factories import EntryFactory from lead.factories import LeadFactory @@ -57,3 +57,18 @@ def test_filter_recently_used(self, now_patch): )) expected = set([af1.pk, af2.pk]) self.assertEqual(obtained, expected) + + def test_tags_filter(self): + tag1, tag2, _ = AnalysisFrameworkTagFactory.create_batch(3) + af1 = AnalysisFrameworkFactory.create(title='one', tags=[tag1]) + af2 = AnalysisFrameworkFactory.create(title='two', tags=[tag1, tag2]) + AnalysisFrameworkFactory.create(title='twoo') + for tags, expected in [ + ([tag1, tag2], [af1, af2]), + ([tag1], [af1, af2]), + ([tag2], [af2]), + ]: + obtained = self.filter_class(data=dict( + tags=[tag.id for tag in tags] + )).qs + self.assertQuerySetIdEqual(expected, obtained) diff --git a/apps/analysis_framework/tests/test_schemas.py b/apps/analysis_framework/tests/test_schemas.py index 0e80aeaaa5..21509feee2 100644 --- a/apps/analysis_framework/tests/test_schemas.py +++ b/apps/analysis_framework/tests/test_schemas.py @@ -7,13 +7,14 @@ from lead.factories import LeadFactory from analysis_framework.factories import ( AnalysisFrameworkFactory, + AnalysisFrameworkTagFactory, SectionFactory, WidgetFactory, ) class TestAnalysisFrameworkQuery(GraphQLSnapShotTestCase): - factories_used = [AnalysisFrameworkFactory, SectionFactory, WidgetFactory, ProjectFactory] + factories_used = [AnalysisFrameworkFactory, AnalysisFrameworkTagFactory, SectionFactory, WidgetFactory, ProjectFactory] def test_analysis_framework_list(self): query = ''' @@ -28,14 +29,24 @@ def test_analysis_framework_list(self): description isPrivate clonedFrom + tags { + id + title + description + icon { + url + name + } + } } } } ''' user = UserFactory.create() - private_af = AnalysisFrameworkFactory.create(is_private=True) - normal_af = AnalysisFrameworkFactory.create() + tag1, tag2, _ = AnalysisFrameworkTagFactory.create_batch(3) + private_af = AnalysisFrameworkFactory.create(is_private=True, tags=[tag1, tag2]) + normal_af = AnalysisFrameworkFactory.create(tags=[tag2]) member_af = AnalysisFrameworkFactory.create() member_af.add_member(user) @@ -51,6 +62,7 @@ def test_analysis_framework_list(self): self.assertIdEqual(results[0]['id'], normal_af.id) self.assertIdEqual(results[1]['id'], member_af.id) self.assertNotIn(str(private_af.id), [d['id'] for d in results]) # Can't see private project. + self.assertMatchSnapshot(results, 'response-01') project = ProjectFactory.create(analysis_framework=private_af) # It shouldn't list private AF after adding to a project. @@ -58,6 +70,7 @@ def test_analysis_framework_list(self): results = content['data']['analysisFrameworks']['results'] self.assertEqual(content['data']['analysisFrameworks']['totalCount'], 2) self.assertNotIn(str(private_af.id), [d['id'] for d in results]) # Can't see private project. + self.assertMatchSnapshot(results, 'response-02') project.add_member(user) # It should list private AF after user is member of the project. @@ -65,6 +78,7 @@ def test_analysis_framework_list(self): results = content['data']['analysisFrameworks']['results'] self.assertEqual(content['data']['analysisFrameworks']['totalCount'], 3) self.assertIn(str(private_af.id), [d['id'] for d in results]) # Can see private project now. + self.assertMatchSnapshot(results, 'response-03') def test_public_analysis_framework(self): query = ''' diff --git a/schema.graphql b/schema.graphql index ab47f78ade..c2da331db2 100644 --- a/schema.graphql +++ b/schema.graphql @@ -47,8 +47,9 @@ type AnalysisFrameworkDetailType { currentUserRole: AnalysisFrameworkRoleTypeEnum previewImage: FileFieldType export: FileFieldType - allowedPermissions: [AnalysisFrameworkPermission!]! clonedFrom: ID + allowedPermissions: [AnalysisFrameworkPermission!]! + tags: [AnalysisFrameworkTagType!]! primaryTagging: [SectionType!] secondaryTagging: [WidgetType!] members: [AnalysisFrameworkMembershipType!] @@ -186,6 +187,20 @@ enum AnalysisFrameworkRoleTypeEnum { UNKNOWN } +type AnalysisFrameworkTagListType { + results: [AnalysisFrameworkTagType!] + totalCount: Int + page: Int + pageSize: Int +} + +type AnalysisFrameworkTagType { + id: ID! + title: String! + description: String! + icon: FileFieldType +} + type AnalysisFrameworkType { id: ID! title: String! @@ -200,8 +215,9 @@ type AnalysisFrameworkType { currentUserRole: AnalysisFrameworkRoleTypeEnum previewImage: FileFieldType export: FileFieldType - allowedPermissions: [AnalysisFrameworkPermission!]! clonedFrom: ID + allowedPermissions: [AnalysisFrameworkPermission!]! + tags: [AnalysisFrameworkTagType!]! } type AnalysisFrameworkVisibleProjectType { @@ -3362,8 +3378,9 @@ type Query { user(id: ID!): UserType users(id: Float, search: String, membersExcludeProject: ID, membersExcludeFramework: ID, membersExcludeUsergroup: ID, page: Int = 1, ordering: String, pageSize: Int): UserListType analysisFramework(id: ID!): AnalysisFrameworkDetailType - analysisFrameworks(id: Float, createdAt: DateTime, createdAtGte: DateTime, createdAtLte: DateTime, modifiedAt: DateTime, modifiedAtGte: DateTime, modifiedAtLte: DateTime, createdBy: [ID!], modifiedBy: [ID!], search: String, isCurrentUserMember: Boolean, recentlyUsed: Boolean, page: Int = 1, ordering: String, pageSize: Int): AnalysisFrameworkListType - publicAnalysisFrameworks(id: Float, createdAt: DateTime, createdAtGte: DateTime, createdAtLte: DateTime, modifiedAt: DateTime, modifiedAtGte: DateTime, modifiedAtLte: DateTime, createdBy: [ID!], modifiedBy: [ID!], search: String, isCurrentUserMember: Boolean, recentlyUsed: Boolean, page: Int = 1, ordering: String, pageSize: Int): PublicAnalysisFrameworkListType + analysisFrameworks(id: Float, createdAt: DateTime, createdAtGte: DateTime, createdAtLte: DateTime, modifiedAt: DateTime, modifiedAtGte: DateTime, modifiedAtLte: DateTime, createdBy: [ID!], modifiedBy: [ID!], search: String, isCurrentUserMember: Boolean, recentlyUsed: Boolean, tags: [ID!], page: Int = 1, ordering: String, pageSize: Int): AnalysisFrameworkListType + publicAnalysisFrameworks(id: Float, createdAt: DateTime, createdAtGte: DateTime, createdAtLte: DateTime, modifiedAt: DateTime, modifiedAtGte: DateTime, modifiedAtLte: DateTime, createdBy: [ID!], modifiedBy: [ID!], search: String, isCurrentUserMember: Boolean, recentlyUsed: Boolean, tags: [ID!], page: Int = 1, ordering: String, pageSize: Int): PublicAnalysisFrameworkListType + analysisFrameworkTags(id: Float, search: String, page: Int = 1, ordering: String, pageSize: Int): AnalysisFrameworkTagListType project(id: ID!): ProjectDetailType projects(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: [ProjectOrderingEnum!], isTest: Boolean, page: Int = 1, pageSize: Int): ProjectListType recentProjects: [ProjectDetailType!]