diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..a9725b7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ +Addresses xxxxxx (eg: #1 the-deep/questionnaire-builder-backend#1) \ +Depends on xxxxxx (eg: #1 the-deep/questionnaire-builder-backend#1) + +## Changes + +* Detailed list or prose of changes +* Breaking changes +* Changes to configurations + +**Mention related users here if any.** + +## This PR doesn't introduce any: + +- [ ] temporary files, auto-generated files or secret keys +- [ ] n+1 queries +- [ ] flake8 issues +- [ ] `print` +- [ ] typos +- [ ] unwanted comments + +## This PR contains valid: + +- [ ] tests +- [ ] permission checks (tests here too) +- [ ] translations diff --git a/apps/project/mutations.py b/apps/project/mutations.py index 8a10597..855112e 100644 --- a/apps/project/mutations.py +++ b/apps/project/mutations.py @@ -64,9 +64,9 @@ async def leave_project( @strawberry.mutation async def update_memberships( self, - items: list[ProjectMembershipBulkMutation.PartialInputType] | None, - delete_ids: list[strawberry.ID] | None, info: Info, + items: list[ProjectMembershipBulkMutation.PartialInputType] | None = [], + delete_ids: list[strawberry.ID] | None = [], ) -> BulkMutationResponseType[ProjectMembershipType]: queryset = ProjectMembership.objects.filter( project=info.context.active_project.project, diff --git a/apps/project/types.py b/apps/project/types.py index 7f5a9b5..de7aced 100644 --- a/apps/project/types.py +++ b/apps/project/types.py @@ -6,6 +6,7 @@ from utils.common import get_queryset_for_model from utils.strawberry.paginations import CountList, pagination_field from apps.common.types import ClientIdMixin, UserResourceTypeMixin +from apps.user.types import UserType from .models import Project, ProjectMembership from .filters import ProjectMembershipFilter @@ -35,6 +36,15 @@ def get_queryset(self, queryset, info: Info): project=info.context.active_project.project, ) + @strawberry.field + def member(self, info: Info) -> UserType: + return info.context.dl.user.load_users.load(self.member_id) + + @strawberry.field + def added_by(self, info: Info) -> UserType | None: + if self.added_by_id: + return info.context.dl.user.load_users.load(self.added_by_id) + @strawberry_django.ordering.order(Project) class ProjectOrder: diff --git a/apps/user/filters.py b/apps/user/filters.py new file mode 100644 index 0000000..66feeca --- /dev/null +++ b/apps/user/filters.py @@ -0,0 +1,47 @@ +import strawberry +import strawberry_django +from strawberry.types import Info +from django.db import models +from django.db.models.functions import Concat + +from .models import User + + +@strawberry_django.filters.filter(User, lookups=True) +class UserFilter: + id: strawberry.auto + search: str | None + members_exclude_project: strawberry.ID | None + exclude_me: bool = False + + def filter_search(self, queryset): + value = self.search + if value: + queryset = queryset.annotate( + full_name=Concat( + models.F("first_name"), + models.Value(" "), + models.F("last_name"), + output_field=models.CharField(), + ) + ).filter( + models.Q(full_name__icontains=value) | + models.Q(first_name__icontains=value) | + models.Q(last_name__icontains=value) | + models.Q(email__icontains=value) + ) + return queryset + + def filter_members_exclude_project(self, queryset): + value = self.members_exclude_project + if value: + queryset = queryset.filter( + ~models.Q(projectmembership__project_id=value) + ).distinct() + return queryset + + def filter_exclude_me(self, queryset, info: Info): + value = self.exclude_me + if value: + queryset = queryset.exclude(id=info.context.request.user.id) + return queryset diff --git a/apps/user/mutations.py b/apps/user/mutations.py index db6f6a1..62464ca 100644 --- a/apps/user/mutations.py +++ b/apps/user/mutations.py @@ -5,7 +5,12 @@ from django.contrib.auth import login, logout, update_session_auth_hash from utils.strawberry.transformers import generate_type_for_serializer -from utils.strawberry.mutations import mutation_is_not_valid, MutationResponseType, MutationEmptyResponseType +from utils.strawberry.mutations import ( + process_input_data, + mutation_is_not_valid, + MutationResponseType, + MutationEmptyResponseType, +) from .serializers import ( LoginSerializer, @@ -32,7 +37,7 @@ class PublicMutation: @strawberry.mutation @sync_to_async def register(self, data: RegisterInput, info: Info) -> MutationResponseType[UserMeType]: - serializer = RegisterSerializer(data=data.__dict__, context={'request': info.context.request}) + serializer = RegisterSerializer(data=process_input_data(data), context={'request': info.context.request}) if errors := mutation_is_not_valid(serializer): return MutationResponseType( ok=False, @@ -44,7 +49,7 @@ def register(self, data: RegisterInput, info: Info) -> MutationResponseType[User @strawberry.mutation @sync_to_async def login(self, data: LoginInput, info: Info) -> MutationResponseType[UserMeType]: - serializer = LoginSerializer(data=data.__dict__, context={'request': info.context.request}) + serializer = LoginSerializer(data=process_input_data(data), context={'request': info.context.request}) if errors := mutation_is_not_valid(serializer): return MutationResponseType( ok=False, @@ -67,7 +72,7 @@ def logout(self, info: Info) -> MutationEmptyResponseType: @strawberry.mutation @sync_to_async def password_reset_trigger(self, data: PasswordResetTriggerInput, info: Info) -> MutationEmptyResponseType: - serializer = PasswordResetTriggerSerializer(data=data.__dict__, context={'request': info.context.request}) + serializer = PasswordResetTriggerSerializer(data=process_input_data(data), context={'request': info.context.request}) if errors := mutation_is_not_valid(serializer): return MutationEmptyResponseType( ok=False, @@ -79,7 +84,7 @@ def password_reset_trigger(self, data: PasswordResetTriggerInput, info: Info) -> @strawberry.mutation @sync_to_async def password_reset_confirm(self, data: PasswordResetConfirmInput, info: Info) -> MutationEmptyResponseType: - serializer = PasswordResetConfirmSerializer(data=data.__dict__, context={'request': info.context.request}) + serializer = PasswordResetConfirmSerializer(data=process_input_data(data), context={'request': info.context.request}) if errors := mutation_is_not_valid(serializer): return MutationEmptyResponseType( ok=False, @@ -95,7 +100,7 @@ class PrivateMutation: @strawberry.mutation @sync_to_async def change_user_password(self, data: PasswordChangeInput, info: Info) -> MutationEmptyResponseType: - serializer = PasswordChangeSerializer(data=data.__dict__, context={'request': info.context.request}) + serializer = PasswordChangeSerializer(data=process_input_data(data), context={'request': info.context.request}) if errors := mutation_is_not_valid(serializer): return MutationEmptyResponseType( ok=False, @@ -108,7 +113,7 @@ def change_user_password(self, data: PasswordChangeInput, info: Info) -> Mutatio @strawberry.mutation @sync_to_async def update_me(self, data: UserMeInput, info: Info) -> MutationResponseType[UserMeType]: - serializer = UserMeSerializer(data=data.__dict__, context={'request': info.context.request}, partial=True) + serializer = UserMeSerializer(data=process_input_data(data), context={'request': info.context.request}, partial=True) if errors := mutation_is_not_valid(serializer): return MutationResponseType( ok=False, diff --git a/apps/user/queries.py b/apps/user/queries.py index 7803c6e..5733804 100644 --- a/apps/user/queries.py +++ b/apps/user/queries.py @@ -3,8 +3,10 @@ from strawberry.types import Info from asgiref.sync import sync_to_async +from utils.strawberry.paginations import CountList, pagination_field -from .types import UserType, UserMeType +from .types import UserType, UserMeType, UserOrder +from .filters import UserFilter @strawberry.type @@ -20,3 +22,9 @@ def me(self, info: Info) -> UserMeType | None: @strawberry.type class PrivateQuery: user: UserType = strawberry_django.field() + + users: CountList[UserType] = pagination_field( + pagination=True, + filters=UserFilter, + order=UserOrder, + ) diff --git a/apps/user/tests/test_queries.py b/apps/user/tests/test_queries.py index 66e358b..43e1a18 100644 --- a/apps/user/tests/test_queries.py +++ b/apps/user/tests/test_queries.py @@ -1,12 +1,14 @@ from main.tests import TestCase from apps.user.models import User + from apps.user.factories import UserFactory +from apps.project.factories import ProjectFactory class TestUserQuery(TestCase): - def test_me(self): - query = ''' + class Query: + ME = ''' query meQuery { public { me { @@ -21,21 +23,46 @@ def test_me(self): } ''' - User.objects.all().delete() # Clear all users if exists - # Create some users - user = UserFactory.create( + USERS = ''' + query MyQuery($filters: UserFilter) { + private { + users(order: {id: ASC}, pagination: {limit: 10, offset: 0}, filters: $filters) { + limit + offset + count + items { + id + firstName + lastName + displayName + } + } + } + } + ''' + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = UserFactory.create( email_opt_outs=[User.OptEmailNotificationType.NEWS_AND_OFFERS], ) # Some other users as well - UserFactory.create_batch(3) + cls.users = ( + UserFactory.create(first_name='Test', last_name='Hero', email='sample@test.com'), + UserFactory.create(first_name='Example', last_name='Villain', email='sample@vil.com'), + UserFactory.create(first_name='Test', last_name='Hero'), + ) + def test_me(self): # Without authentication ----- - content = self.query_check(query) + content = self.query_check(self.Query.ME) assert content['data']['public']['me'] is None + user = self.user # With authentication ----- self.force_login(user) - content = self.query_check(query) + content = self.query_check(self.Query.ME) assert content['data']['public']['me'] == dict( id=str(user.id), email=user.email, @@ -47,3 +74,42 @@ def test_me(self): for opt in user.email_opt_outs ], ) + + def test_users(self): + user1, user2, user3 = self.users + project = ProjectFactory.create(created_by=user1, modified_by=user1) + project.add_member(user1) + + # Without authentication ----- + content = self.query_check(self.Query.USERS, assert_errors=True) + assert content['data'] is None + + # With authentication ----- + self.force_login(self.user) + for filters, expected_users in [ + ({'id': {'exact': str(user1.id)}}, [user1]), + # Free text search tests + ({'search': 'hero'}, [user1, user3]), + ({'search': 'test'}, [user1, user3]), + ({'search': '@vil'}, [user2]), + ({'search': 'sample'}, [user1, user2]), + ({'search': 'sample@'}, [user1, user2]), + ({'membersExcludeProject': str(project.pk)}, [self.user, user2, user3]), + ({}, [self.user, *self.users]), + ({'excludeMe': True}, self.users), + ]: + content = self.query_check(self.Query.USERS, variables={'filters': filters}) + assert content['data']['private']['users'] == { + 'count': len(expected_users), + 'limit': 10, + 'offset': 0, + 'items': [ + { + 'id': str(user.id), + 'firstName': user.first_name, + 'lastName': user.last_name, + 'displayName': f'{user.first_name} {user.last_name}', + } + for user in expected_users + ] + }, (filters, expected_users) diff --git a/apps/user/types.py b/apps/user/types.py index 0370c86..b316b22 100644 --- a/apps/user/types.py +++ b/apps/user/types.py @@ -4,6 +4,11 @@ from .enums import OptEmailNotificationTypeEnum +@strawberry_django.ordering.order(User) +class UserOrder: + id: strawberry.auto + + @strawberry_django.type(User) class UserType: id: strawberry.ID diff --git a/schema.graphql b/schema.graphql index 3fa3c98..10f0884 100644 --- a/schema.graphql +++ b/schema.graphql @@ -88,6 +88,7 @@ type PrivateMutation { type PrivateQuery { user: UserType! + users(filters: UserFilter, order: UserOrder, pagination: OffsetPaginationInput): UserTypeCountList! projects(filters: ProjectFilter, order: ProjectOrder, pagination: OffsetPaginationInput): ProjectTypeCountList! projectScope(pk: ID!): ProjectScopeType id: ID! @@ -123,7 +124,9 @@ type ProjectMembershipType { joinedAt: DateTime! memberId: ID! addedById: ID + addedBy: UserType clientId: String! + member: UserType! } type ProjectMembershipTypeBulkMutationResponseType { @@ -140,10 +143,10 @@ type ProjectMembershipTypeCountList { } input ProjectMembershipUpdateInput { - id: ID = null - clientId: String = "" - member: ID = null - role: ProjectMembershipRoleTypeEnum = null + id: ID + clientId: String + member: ID + role: ProjectMembershipRoleTypeEnum } input ProjectOrder { @@ -158,7 +161,7 @@ type ProjectScopeMutation { deleteQuestionnaire(id: ID!): QuestionnaireTypeListMutationResponseType! updateProject(data: ProjectUpdateInput!): ProjectTypeMutationResponseType! leaveProject(confirmPassword: String!): MutationEmptyResponseType! - updateMemberships(items: [ProjectMembershipUpdateInput!], deleteIds: [ID!]): ProjectMembershipTypeBulkMutationResponseType! + updateMemberships(items: [ProjectMembershipUpdateInput!] = [], deleteIds: [ID!] = []): ProjectMembershipTypeBulkMutationResponseType! } type ProjectScopeType { @@ -193,7 +196,7 @@ type ProjectTypeMutationResponseType { } input ProjectUpdateInput { - title: String = "" + title: String } type PublicMutation { @@ -255,20 +258,27 @@ type QuestionnaireTypeMutationResponseType { } input QuestionnaireUpdateInput { - title: String = "" + title: String } input RegisterInput { email: String! captcha: String! - firstName: String = "" - lastName: String = "" + firstName: String + lastName: String +} + +input UserFilter { + id: IDFilterLookup + search: String + membersExcludeProject: ID + excludeMe: Boolean = false } input UserMeInput { - firstName: String = "" - lastName: String = "" - emailOptOuts: [OptEmailNotificationTypeEnum!] = null + firstName: String + lastName: String + emailOptOuts: [OptEmailNotificationTypeEnum!] } type UserMeType { @@ -286,9 +296,20 @@ type UserMeTypeMutationResponseType { result: UserMeType } +input UserOrder { + id: Ordering +} + type UserType { id: ID! firstName: String! lastName: String! displayName: String! +} + +type UserTypeCountList { + limit: Int! + offset: Int! + count: Int! + items: [UserType!]! } \ No newline at end of file diff --git a/utils/strawberry/enums.py b/utils/strawberry/enums.py index 8492949..90c7afc 100644 --- a/utils/strawberry/enums.py +++ b/utils/strawberry/enums.py @@ -28,15 +28,15 @@ def _get_serializer_name(_field): return type(_field.parent).__name__ if field_name is None or model_name is None: - if type(field) == models.query_utils.DeferredAttribute: + if isinstance(field, models.query_utils.DeferredAttribute): return get_enum_name_from_django_field( field.field, field_name=field_name, model_name=model_name, serializer_name=serializer_name, ) - if type(field) == serializers.ChoiceField: - if type(field.parent) == serializers.ListField: + if isinstance(field, serializers.ChoiceField): + if isinstance(field.parent, serializers.ListField): if _have_model(field.parent.parent): model_name = model_name or field.parent.parent.Meta.model.__name__ serializer_name = _get_serializer_name(field.parent) @@ -46,17 +46,17 @@ def _get_serializer_name(_field): model_name = model_name or field.parent.Meta.model.__name__ serializer_name = _get_serializer_name(field) field_name = field_name or field.field_name - elif type(field) == ArrayField: + elif isinstance(field, ArrayField): if _have_model(field): model_name = model_name or field.model.__name__ serializer_name = _get_serializer_name(field) field_name = field_name or field.base_field.name - elif type(field) in [ + elif isinstance(field, ( models.CharField, models.SmallIntegerField, models.IntegerField, models.PositiveSmallIntegerField, - ]: + )): if _have_model(field): model_name = model_name or field.model.__name__ serializer_name = _get_serializer_name(field) diff --git a/utils/strawberry/mutations.py b/utils/strawberry/mutations.py index 3046566..3bae1e6 100644 --- a/utils/strawberry/mutations.py +++ b/utils/strawberry/mutations.py @@ -28,6 +28,17 @@ ) +def process_input_data(data) -> dict: + """ + Return dict from Strawberry Input Object + """ + return { + key: value + for key, value in data.__dict__.items() + if value != strawberry.UNSET + } + + @strawberry.type class ArrayNestedErrorType: client_id: str @@ -261,7 +272,7 @@ async def handle_create_mutation(self, data, info: Info, permission) -> Mutation return MutationResponseType(ok=False, errors=errors) errors, saved_instance = await self.handle_mutation( self.serializer_class, - data.__dict__, + process_input_data(data), info, ) if errors: @@ -279,7 +290,7 @@ async def handle_update_mutation( return MutationResponseType(ok=False, errors=errors) errors, saved_instance = await self.handle_mutation( self.serializer_class, - data.__dict__, + process_input_data(data), info, instance=instance, partial=True, @@ -327,7 +338,7 @@ async def handle_bulk_mutation( # Create/Update - Then results = [] for data in items or []: - _data = data.__dict__ + _data = process_input_data(data) _id = _data.pop('id', None) instance = None if _id: diff --git a/utils/strawberry/transformers.py b/utils/strawberry/transformers.py index 2e8493f..2fad574 100644 --- a/utils/strawberry/transformers.py +++ b/utils/strawberry/transformers.py @@ -193,10 +193,7 @@ def convert_serializer_field(field, convert_choices_to_enum=True, force_optional if not is_required: if 'default' not in kwargs or 'default_factory' not in kwargs: - if graphql_type == str: - kwargs['default'] = '' - else: - kwargs['default'] = None + kwargs['default'] = strawberry.UNSET graphql_type = typing.Optional[graphql_type] return graphql_type, StrawberryField(