Skip to content

Commit

Permalink
Merge pull request #14 from the-deep/fix/project-membership
Browse files Browse the repository at this point in the history
Fix project memberships issues
  • Loading branch information
subinasr authored Aug 8, 2023
2 parents 0003908 + f441f2d commit f7cb260
Show file tree
Hide file tree
Showing 12 changed files with 238 additions and 43 deletions.
25 changes: 25 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions apps/project/mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions apps/project/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
47 changes: 47 additions & 0 deletions apps/user/filters.py
Original file line number Diff line number Diff line change
@@ -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
19 changes: 12 additions & 7 deletions apps/user/mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion apps/user/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
82 changes: 74 additions & 8 deletions apps/user/tests/test_queries.py
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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='[email protected]'),
UserFactory.create(first_name='Example', last_name='Villain', email='[email protected]'),
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,
Expand All @@ -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)
5 changes: 5 additions & 0 deletions apps/user/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit f7cb260

Please sign in to comment.