From d43124740acee56dca9db19fef452dbc76c7ae40 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 1 Sep 2023 16:17:55 +0545 Subject: [PATCH] Move leaf groups into Quesionnair node - Add Enum Display fields - Update test cases --- apps/questionnaire/filters.py | 11 -- apps/questionnaire/orders.py | 8 -- apps/questionnaire/queries.py | 8 -- apps/questionnaire/tests/test_queries.py | 130 +++++++++++++---------- apps/questionnaire/types.py | 72 +++++++------ apps/user/admin.py | 50 +++++++-- apps/user/types.py | 7 +- main/template_tags.py | 6 +- schema.graphql | 39 +++---- utils/strawberry/enums.py | 96 +++++++++++++++++ 10 files changed, 273 insertions(+), 154 deletions(-) diff --git a/apps/questionnaire/filters.py b/apps/questionnaire/filters.py index ff49858..e9d3b1c 100644 --- a/apps/questionnaire/filters.py +++ b/apps/questionnaire/filters.py @@ -3,12 +3,10 @@ from .enums import ( QuestionTypeEnum, - QuestionLeafGroupTypeEnum, ) from .models import ( Questionnaire, Question, - QuestionLeafGroup, ChoiceCollection, ) @@ -20,15 +18,6 @@ class QuestionnaireFilter: title: strawberry.auto -@strawberry_django.filters.filter(QuestionLeafGroup, lookups=True) -class QuestionLeafGroupFilter: - id: strawberry.auto - questionnaire: strawberry.auto - name: strawberry.auto - is_hidden: strawberry.auto - type: QuestionLeafGroupTypeEnum - - @strawberry_django.filters.filter(ChoiceCollection, lookups=True) class QuestionChoiceCollectionFilter: id: strawberry.auto diff --git a/apps/questionnaire/orders.py b/apps/questionnaire/orders.py index ff880e4..fc8505d 100644 --- a/apps/questionnaire/orders.py +++ b/apps/questionnaire/orders.py @@ -4,7 +4,6 @@ from .models import ( Questionnaire, Question, - QuestionLeafGroup, ChoiceCollection, ) @@ -15,13 +14,6 @@ class QuestionnaireOrder: created_at: strawberry.auto -@strawberry_django.ordering.order(QuestionLeafGroup) -class QuestionLeafGroupOrder: - id: strawberry.auto - order: strawberry.auto - created_at: strawberry.auto - - @strawberry_django.ordering.order(ChoiceCollection) class QuestionChoiceCollectionOrder: id: strawberry.auto diff --git a/apps/questionnaire/queries.py b/apps/questionnaire/queries.py index 97bfcb8..b4635ed 100644 --- a/apps/questionnaire/queries.py +++ b/apps/questionnaire/queries.py @@ -8,13 +8,11 @@ from .filters import ( QuestionnaireFilter, QuestionFilter, - QuestionLeafGroupFilter, QuestionChoiceCollectionFilter, ) from .orders import ( QuestionnaireOrder, QuestionOrder, - QuestionLeafGroupOrder, QuestionChoiceCollectionOrder, ) from .types import ( @@ -33,12 +31,6 @@ class PrivateProjectQuery: order=QuestionnaireOrder, ) - leafGroups: CountList[QuestionLeafGroupType] = pagination_field( - pagination=True, - filters=QuestionLeafGroupFilter, - order=QuestionLeafGroupOrder, - ) - choice_collections: CountList[QuestionChoiceCollectionType] = pagination_field( pagination=True, filters=QuestionChoiceCollectionFilter, diff --git a/apps/questionnaire/tests/test_queries.py b/apps/questionnaire/tests/test_queries.py index abcb53b..b6c13de 100644 --- a/apps/questionnaire/tests/test_queries.py +++ b/apps/questionnaire/tests/test_queries.py @@ -191,21 +191,19 @@ def test_questionnaire(self): class TestQuestionGroupQuery(TestCase): class Query: QuestionGroupList = ''' - query MyQuery($projectId: ID!, $filterData: QuestionLeafGroupFilter) { + query MyQuery($projectId: ID!, $questionnaireId: ID!) { private { + id projectScope(pk: $projectId) { - leafGroups(order: {id: ASC}, filters: $filterData) { - count - items { + questionnaire(pk: $questionnaireId) { + leafGroups { id questionnaireId name + order + isHidden type - category1 - category2 - category3 - category4 - relevant + typeDisplay createdAt createdBy { id @@ -214,6 +212,15 @@ class Query: modifiedBy { id } + category1 + category2 + category3 + category4 + category1Display + category2Display + category3Display + category4Display + relevant } } } @@ -229,12 +236,10 @@ class Query: id questionnaireId name - relevant + order + isHidden type - category1 - category2 - category3 - category4 + typeDisplay createdAt createdBy { id @@ -243,6 +248,15 @@ class Query: modifiedBy { id } + category1 + category2 + category3 + category4 + category1Display + category2Display + category3Display + category4Display + relevant } } } @@ -261,8 +275,6 @@ def test_leaf_groups(self): q1_groups = QuestionLeafGroupFactory.static_generator(2, **user_resource_params, questionnaire=q1) q2_groups = QuestionLeafGroupFactory.static_generator(3, **user_resource_params, questionnaire=q2) q3_groups = QuestionLeafGroupFactory.static_generator(5, **user_resource_params, questionnaire=q3) - q3_groups[0].name = 'question-group-unique-0001' - q3_groups[0].save(update_fields=('name',)) variables = {'projectId': self.gID(project.id)} # Without authentication ----- @@ -275,46 +287,47 @@ def test_leaf_groups(self): # With authentication ----- self.force_login(user) - for filter_data, question_leaf_groups in [ - ({'questionnaire': {'pk': self.gID(q1.id)}}, q1_groups), - ({'questionnaire': {'pk': self.gID(q2.id)}}, q2_groups), - ({'questionnaire': {'pk': self.gID(q3.id)}}, q3_groups), - ({'name': {'exact': 'question-group-unique-0001'}}, [q3_groups[0]]), + for questionnaire_id, question_leaf_groups in [ + (q1, q1_groups), + (q2, q2_groups), + (q3, q3_groups), ]: + variables['questionnaireId'] = self.gID(questionnaire_id.id) content = self.query_check( self.Query.QuestionGroupList, - variables={ - **variables, - 'filterData': filter_data, - }, + variables=variables, ) - assert_msg = (content, user, filter_data, question_leaf_groups) - assert content['data']['private']['projectScope'] is not None, assert_msg - assert content['data']['private']['projectScope']['leafGroups'] == { - 'count': len(question_leaf_groups), - 'items': [ - { - 'id': self.gID(question_leaf_group.pk), - 'questionnaireId': self.gID(question_leaf_group.questionnaire_id), - 'createdAt': self.gdatetime(question_leaf_group.created_at), - 'createdBy': { - 'id': self.gID(question_leaf_group.created_by_id), - }, - 'modifiedAt': self.gdatetime(question_leaf_group.modified_at), - 'modifiedBy': { - 'id': self.gID(question_leaf_group.modified_by_id), - }, - 'name': question_leaf_group.name, - 'relevant': question_leaf_group.relevant, - 'type': self.genum(question_leaf_group.type), - 'category1': self.genum(question_leaf_group.category_1), - 'category2': self.genum(question_leaf_group.category_2), - 'category3': self.genum(question_leaf_group.category_3), - 'category4': self.genum(question_leaf_group.category_4), - } - for question_leaf_group in question_leaf_groups - ] - }, assert_msg + assert_msg = (content, user, questionnaire_id, question_leaf_groups) + assert content['data']['private']['projectScope']['questionnaire'] is not None, assert_msg + assert content['data']['private']['projectScope']['questionnaire']['leafGroups'] == [ + { + 'id': self.gID(question_leaf_group.pk), + 'questionnaireId': self.gID(question_leaf_group.questionnaire_id), + 'name': question_leaf_group.name, + 'order': question_leaf_group.order, + 'isHidden': question_leaf_group.is_hidden, + 'type': self.genum(question_leaf_group.type), + 'typeDisplay': question_leaf_group.get_type_display(), + 'createdAt': self.gdatetime(question_leaf_group.created_at), + 'createdBy': { + 'id': self.gID(question_leaf_group.created_by_id), + }, + 'modifiedAt': self.gdatetime(question_leaf_group.modified_at), + 'modifiedBy': { + 'id': self.gID(question_leaf_group.modified_by_id), + }, + 'category1': self.genum(question_leaf_group.category_1), + 'category2': self.genum(question_leaf_group.category_2), + 'category3': self.genum(question_leaf_group.category_3), + 'category4': self.genum(question_leaf_group.category_4), + 'category1Display': question_leaf_group.get_category_1_display(), + 'category2Display': question_leaf_group.get_category_2_display(), + 'category3Display': question_leaf_group.get_category_3_display(), + 'category4Display': question_leaf_group.get_category_4_display(), + 'relevant': question_leaf_group.relevant, + } + for question_leaf_group in question_leaf_groups + ], assert_msg def test_leaf_group(self): # Create some users @@ -351,6 +364,11 @@ def test_leaf_group(self): assert content['data']['private']['projectScope']['leafGroup'] == { 'id': self.gID(q1_group.pk), 'questionnaireId': self.gID(q1_group.questionnaire_id), + 'name': q1_group.name, + 'order': q1_group.order, + 'isHidden': q1_group.is_hidden, + 'type': self.genum(q1_group.type), + 'typeDisplay': q1_group.get_type_display(), 'createdAt': self.gdatetime(q1_group.created_at), 'createdBy': { 'id': self.gID(q1_group.created_by_id), @@ -359,13 +377,15 @@ def test_leaf_group(self): 'modifiedBy': { 'id': self.gID(q1_group.modified_by_id), }, - 'name': q1_group.name, - 'relevant': q1_group.relevant, - 'type': self.genum(q1_group.type), 'category1': self.genum(q1_group.category_1), 'category2': self.genum(q1_group.category_2), 'category3': self.genum(q1_group.category_3), 'category4': self.genum(q1_group.category_4), + 'category1Display': q1_group.get_category_1_display(), + 'category2Display': q1_group.get_category_2_display(), + 'category3Display': q1_group.get_category_3_display(), + 'category4Display': q1_group.get_category_4_display(), + 'relevant': q1_group.relevant, }, content # Another project question group diff --git a/apps/questionnaire/types.py b/apps/questionnaire/types.py index cf096da..e925ab7 100644 --- a/apps/questionnaire/types.py +++ b/apps/questionnaire/types.py @@ -6,17 +6,10 @@ from django.db import models from utils.common import get_queryset_for_model +from utils.strawberry.enums import enum_display_field, enum_field from apps.common.types import UserResourceTypeMixin, ClientIdMixin from apps.project.models import Project -from .enums import ( - QuestionTypeEnum, - QuestionLeafGroupTypeEnum, - QuestionLeafGroupCategory1TypeEnum, - QuestionLeafGroupCategory2TypeEnum, - QuestionLeafGroupCategory3TypeEnum, - QuestionLeafGroupCategory4TypeEnum, -) from .models import ( Questionnaire, Question, @@ -26,40 +19,25 @@ ) -@strawberry_django.type(Questionnaire) -class QuestionnaireType(UserResourceTypeMixin): - id: strawberry.ID - title: strawberry.auto - - @staticmethod - def get_queryset(_, queryset: models.QuerySet | None, info: Info): - qs = get_queryset_for_model(Questionnaire, queryset) - if ( - info.context.active_project and - info.context.has_perm(Project.Permission.VIEW_QUESTIONNAIRE) - ): - return qs.filter(project=info.context.active_project.project) - return qs.none() - - @strawberry.field - def project_id(self) -> strawberry.ID: - return strawberry.ID(str(self.project_id)) - - @strawberry_django.type(QuestionLeafGroup) class QuestionLeafGroupType(UserResourceTypeMixin): id: strawberry.ID name: strawberry.auto - type: QuestionLeafGroupTypeEnum + type = enum_field(QuestionLeafGroup.type) + type_display = enum_display_field(QuestionLeafGroup.type) order: strawberry.auto is_hidden: strawberry.auto # Categories # -- For Matrix1D/Matrix2D - category_1: QuestionLeafGroupCategory1TypeEnum - category_2: QuestionLeafGroupCategory2TypeEnum + category_1 = enum_field(QuestionLeafGroup.category_1) + category_1_display = enum_display_field(QuestionLeafGroup.category_1) + category_2 = enum_field(QuestionLeafGroup.category_2) + category_2_display = enum_display_field(QuestionLeafGroup.category_2) # -- For Matrix2D - category_3: typing.Optional[QuestionLeafGroupCategory3TypeEnum] - category_4: typing.Optional[QuestionLeafGroupCategory4TypeEnum] + category_3 = enum_field(QuestionLeafGroup.category_3) + category_3_display = enum_display_field(QuestionLeafGroup.category_3) + category_4 = enum_field(QuestionLeafGroup.category_4) + category_4_display = enum_display_field(QuestionLeafGroup.category_4) # Misc relevant: strawberry.auto @@ -78,6 +56,31 @@ def get_queryset(_, queryset: models.QuerySet | None, info: Info): return qs.none() +@strawberry_django.type(Questionnaire) +class QuestionnaireType(UserResourceTypeMixin): + id: strawberry.ID + title: strawberry.auto + + @staticmethod + def get_queryset(_, queryset: models.QuerySet | None, info: Info): + qs = get_queryset_for_model(Questionnaire, queryset) + if ( + info.context.active_project and + info.context.has_perm(Project.Permission.VIEW_QUESTIONNAIRE) + ): + return qs.filter(project=info.context.active_project.project) + return qs.none() + + @strawberry.field + def project_id(self) -> strawberry.ID: + return strawberry.ID(str(self.project_id)) + + @strawberry_django.field + async def leaf_groups(self, info: Info) -> list[QuestionLeafGroupType]: + queryset = QuestionLeafGroupType.get_queryset(None, None, info).filter(questionnaire=self.pk) + return [q async for q in queryset] + + @strawberry_django.type(Choice) class QuestionChoiceType(ClientIdMixin): id: strawberry.ID @@ -143,7 +146,8 @@ class QuestionType(UserResourceTypeMixin): is_or_other: strawberry.auto or_other_label: strawberry.auto - type: QuestionTypeEnum + type = enum_field(Question.type) + type_display = enum_display_field(Question.type) @staticmethod def get_queryset(_, queryset: models.QuerySet | None, info: Info): diff --git a/apps/user/admin.py b/apps/user/admin.py index cd68fe6..0328286 100644 --- a/apps/user/admin.py +++ b/apps/user/admin.py @@ -10,27 +10,61 @@ @admin.register(User) class CustomUserAdmin(UserAdmin): fieldsets = ( - (None, {'fields': ('email', 'password')}), + (None, { + 'fields': ( + 'email', + 'password' + ) + }), (_('Personal info'), { 'fields': ( - 'first_name', 'last_name', + 'first_name', + 'last_name', ) }), (_('Permissions'), { - 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions'), + 'fields': ( + 'is_active', + 'is_staff', + 'is_superuser', + 'groups', + 'user_permissions' + ), + }), + (_('Important dates'), { + 'fields': ( + 'last_login', + 'date_joined', + ) + }), + (_('Misc'), { + 'fields': ( + 'email_opt_outs', + ) }), - (_('Important dates'), {'fields': ('last_login', 'date_joined')}), ) add_fieldsets = ( (None, { - 'classes': ('wide',), - 'fields': ('email', 'password1', 'password2',), + 'classes': ( + 'wide', + ), + 'fields': ( + 'email', + 'password1', + 'password2', + ), }), ) ordering = list_display = ( - 'email', 'first_name', 'last_name', 'is_staff', 'is_superuser', + 'email', + 'first_name', + 'last_name', + 'is_staff', + 'is_superuser', ) list_filter = ( - 'is_staff', 'is_superuser', 'is_active' + 'is_staff', + 'is_superuser', + 'is_active', ) diff --git a/apps/user/types.py b/apps/user/types.py index b316b22..4712b84 100644 --- a/apps/user/types.py +++ b/apps/user/types.py @@ -1,7 +1,9 @@ import strawberry import strawberry_django + +from utils.strawberry.enums import enum_field, enum_display_field + from .models import User -from .enums import OptEmailNotificationTypeEnum @strawberry_django.ordering.order(User) @@ -23,4 +25,5 @@ def display_name(self) -> str: @strawberry_django.type(User) class UserMeType(UserType): email: strawberry.auto - email_opt_outs: list[OptEmailNotificationTypeEnum] + email_opt_outs = enum_field(User.email_opt_outs) + email_opt_outs_display = enum_display_field(User.email_opt_outs) diff --git a/main/template_tags.py b/main/template_tags.py index f1b69eb..e330c45 100644 --- a/main/template_tags.py +++ b/main/template_tags.py @@ -1,18 +1,18 @@ from django import template from django.conf import settings from django.templatetags.static import static -from django.core.files.storage import FileSystemStorage, get_storage_class +from django.core.files.storage import FileSystemStorage, storages register = template.Library() -StorageClass = get_storage_class() +DEFAULT_BACKEND_STORAGE = storages.backends['staticfiles'] @register.filter(is_safe=True) def static_full_path(path): static_path = static(path) - if StorageClass == FileSystemStorage: + if isinstance(DEFAULT_BACKEND_STORAGE, FileSystemStorage): return f"{settings.APP_HTTP_PROTOCOL}://{settings.APP_DOMAIN}{static_path}" # With s3 storage return static_path diff --git a/schema.graphql b/schema.graphql index 9c54d3e..29425df 100644 --- a/schema.graphql +++ b/schema.graphql @@ -8,6 +8,9 @@ input DjangoModelFilterInput { pk: ID! } +"""Provide label for enum values""" +scalar EnumDescription + input IDFilterLookup { exact: ID iExact: ID @@ -192,7 +195,6 @@ type ProjectScopeMutation { type ProjectScopeType { questionnaires(filters: QuestionnaireFilter, order: QuestionnaireOrder, pagination: OffsetPaginationInput): QuestionnaireTypeCountList! - leafGroups(filters: QuestionLeafGroupFilter, order: QuestionLeafGroupOrder, pagination: OffsetPaginationInput): QuestionLeafGroupTypeCountList! choiceCollections(filters: QuestionChoiceCollectionFilter, order: QuestionChoiceCollectionOrder, pagination: OffsetPaginationInput): QuestionChoiceCollectionTypeCountList! questions(filters: QuestionFilter, order: QuestionOrder, pagination: OffsetPaginationInput): QuestionTypeCountList! questionnaire(pk: ID!): QuestionnaireType @@ -468,20 +470,6 @@ enum QuestionLeafGroupCategory4TypeEnum { INTERIOR_DOMENSTIC_LIFE } -input QuestionLeafGroupFilter { - id: IDFilterLookup - questionnaire: DjangoModelFilterInput - name: StrFilterLookup - isHidden: Boolean - type: QuestionLeafGroupTypeEnum -} - -input QuestionLeafGroupOrder { - id: Ordering - order: Ordering - createdAt: Ordering -} - input QuestionLeafGroupOrderInputType { id: ID! order: Int! @@ -492,17 +480,22 @@ type QuestionLeafGroupType { modifiedAt: DateTime! id: ID! name: String! - type: QuestionLeafGroupTypeEnum! order: Int! isHidden: Boolean! + relevant: String! category1: QuestionLeafGroupCategory1TypeEnum! + category1Display: EnumDescription! category2: QuestionLeafGroupCategory2TypeEnum! + category2Display: EnumDescription! category3: QuestionLeafGroupCategory3TypeEnum + category3Display: EnumDescription category4: QuestionLeafGroupCategory4TypeEnum - relevant: String! + category4Display: EnumDescription createdBy: UserType! modifiedBy: UserType! questionnaireId: ID! + type: QuestionLeafGroupTypeEnum! + typeDisplay: EnumDescription! } type QuestionLeafGroupTypeBulkBasicMutationResponseType { @@ -510,13 +503,6 @@ type QuestionLeafGroupTypeBulkBasicMutationResponseType { results: [QuestionLeafGroupType!] } -type QuestionLeafGroupTypeCountList { - limit: Int! - offset: Int! - count: Int! - items: [QuestionLeafGroupType!]! -} - enum QuestionLeafGroupTypeEnum { MATRIX_1D MATRIX_2D @@ -561,12 +547,13 @@ type QuestionType { video: String! isOrOther: Boolean! orOtherLabel: String! - type: QuestionTypeEnum! choiceCollection: QuestionChoiceCollectionType createdBy: UserType! leafGroupId: ID modifiedBy: UserType! questionnaireId: ID! + type: QuestionTypeEnum! + typeDisplay: EnumDescription! } type QuestionTypeCountList { @@ -649,6 +636,7 @@ type QuestionnaireType { id: ID! title: String! createdBy: UserType! + leafGroups: [QuestionLeafGroupType!]! modifiedBy: UserType! projectId: ID! } @@ -717,6 +705,7 @@ type UserMeType { displayName: String! email: String! emailOptOuts: [OptEmailNotificationTypeEnum!]! + emailOptOutsDisplay: [EnumDescription!]! } type UserMeTypeMutationResponseType { diff --git a/utils/strawberry/enums.py b/utils/strawberry/enums.py index 90c7afc..15bb696 100644 --- a/utils/strawberry/enums.py +++ b/utils/strawberry/enums.py @@ -1,4 +1,9 @@ +import typing +import strawberry + from django.db import models +from django.utils.hashable import make_hashable +from django.utils.encoding import force_str from django.contrib.postgres.fields import ArrayField from rest_framework import serializers @@ -68,3 +73,94 @@ def _get_serializer_name(_field): if serializer_name: return f'{serializer_name}{to_camel_case(field_name.title())}' raise Exception(f'{serializer_name=} should have a value') + + +EnumDescription = strawberry.scalar( + typing.NewType("EnumDescription", str), + description="Provide label for enum values", + serialize=lambda v: v, + parse_value=lambda v: v, +) + + +def enum_display_field(field: models.query_utils.DeferredAttribute) -> typing.Callable[..., EnumDescription]: + _field = field.field + + if is_array := isinstance(_field, ArrayField): + _field = _field.base_field + + def _get_value(root) -> None | str | list[str]: + # https://github.com/django/django/blob/stable/4.2.x/django/db/models/base.py#L1144-L1150 + value = getattr(root, _field.attname) + if value is None: + return + choices_dict = dict(make_hashable(_field.flatchoices)) + # force_str() to coerce lazy strings. + if is_array: + return [ + force_str( + choices_dict.get(make_hashable(v), v), strings_only=True + ) + for v in value or [] + ] + return force_str( + choices_dict.get(make_hashable(value), value), strings_only=True + ) + + @strawberry.field + def array_field_(root) -> list[EnumDescription]: + return _get_value(root) + + if is_array: + return array_field_ + + @strawberry.field + def field_(root) -> EnumDescription: + return _get_value(root) + + @strawberry.field + def nullable_field_(root) -> typing.Optional[EnumDescription]: + return _get_value(root) + + if _field.null: + return nullable_field_ + return field_ + + +def enum_field(field: models.query_utils.DeferredAttribute): + # NOTE: To avoid circular import + from main.enums import ENUM_TO_STRAWBERRY_ENUM_MAP + _field = field.field + FieldEnum = ENUM_TO_STRAWBERRY_ENUM_MAP[get_enum_name_from_django_field(field)] + + if is_array := isinstance(_field, ArrayField): + _field = _field.base_field + + def _get_value(root) -> None | FieldEnum | list[FieldEnum]: + value = getattr(root, _field.attname) + if value is None: + return + if is_array: + return [ + FieldEnum(v) for v in value or [] + ] + return FieldEnum(value) + + @strawberry.field + def array_field_(root) -> list[FieldEnum]: + return _get_value(root) + + if is_array: + return array_field_ + + @strawberry.field + def field_(root) -> FieldEnum: + return _get_value(root) + + @strawberry.field + def nullable_field_(root) -> typing.Optional[FieldEnum]: + return _get_value(root) + + if _field.null: + return nullable_field_ + return field_