diff --git a/children/schema.py b/children/schema.py index 68c01e2f..d18137c3 100644 --- a/children/schema.py +++ b/children/schema.py @@ -19,10 +19,14 @@ from children.notifications import NotificationType from common.schema import ( - DjangoFilterAndOffsetConnectionField, + ViewFamiliesPermissionRequiredFilterOffsetConnectionField, set_obj_languages_spoken_at_home, ) -from common.utils import login_required, map_enums_to_values_in_kwargs, update_object +from common.utils import ( + login_required, + map_enums_to_values_in_kwargs, + update_object, +) from events.models import Event, EventGroup, EventQueryset, Occurrence from kukkuu.exceptions import ( ApiUsageError, @@ -633,7 +637,9 @@ def mutate_and_get_payload(cls, root, info, **kwargs): class Query: - children = DjangoFilterAndOffsetConnectionField(ChildNode, projectId=graphene.ID()) + children = ViewFamiliesPermissionRequiredFilterOffsetConnectionField( + ChildNode, projectId=graphene.ID() + ) child = relay.Node.Field(ChildNode) child_notes = graphene.Field(ChildNotesNode, id=graphene.ID(required=True)) diff --git a/children/tests/snapshots/snap_test_api.py b/children/tests/snapshots/snap_test_api.py index a5bd7bbd..2fc0a464 100644 --- a/children/tests/snapshots/snap_test_api.py +++ b/children/tests/snapshots/snap_test_api.py @@ -281,37 +281,6 @@ } } -snapshots['test_children_query_normal_user 1'] = { - 'data': { - 'children': { - 'edges': [ - { - 'node': { - 'birthyear': 2019, - 'name': 'Richard Hayes', - 'postalCode': '57776', - 'relationships': { - 'edges': [ - { - 'node': { - 'guardian': { - 'email': 'michellewalker@example.net', - 'firstName': 'Denise', - 'lastName': 'Thompson', - 'phoneNumber': '001-206-575-0649x7638' - }, - 'type': 'OTHER_RELATION' - } - } - ] - } - } - } - ] - } - } -} - snapshots['test_children_query_ordering 1'] = { 'data': { 'children': { @@ -369,7 +338,7 @@ } } -snapshots['test_children_query_project_user 1'] = { +snapshots['test_children_query_project_user_with_global_view_families_perm 1'] = { 'data': { 'children': { 'edges': [ @@ -400,81 +369,6 @@ } } -snapshots['test_children_query_project_user_and_guardian 1'] = { - 'data': { - 'children': { - 'edges': [ - { - 'node': { - 'birthyear': 2021, - 'name': 'Not own child same project - Should be returned 3/3', - 'postalCode': '11715', - 'relationships': { - 'edges': [ - { - 'node': { - 'guardian': { - 'email': 'anthonycross@example.com', - 'firstName': 'Sarah', - 'lastName': 'Larsen', - 'phoneNumber': '4895817101' - }, - 'type': 'OTHER_RELATION' - } - } - ] - } - } - }, - { - 'node': { - 'birthyear': 2022, - 'name': 'Own child another project - Should be returned 2/3', - 'postalCode': '34669', - 'relationships': { - 'edges': [ - { - 'node': { - 'guardian': { - 'email': 'michellewalker@example.net', - 'firstName': 'Michael', - 'lastName': 'Patton', - 'phoneNumber': '235.857.7767x124' - }, - 'type': 'OTHER_GUARDIAN' - } - } - ] - } - } - }, - { - 'node': { - 'birthyear': 2021, - 'name': 'Own child same project - Should be returned 1/3', - 'postalCode': '06497', - 'relationships': { - 'edges': [ - { - 'node': { - 'guardian': { - 'email': 'michellewalker@example.net', - 'firstName': 'Michael', - 'lastName': 'Patton', - 'phoneNumber': '235.857.7767x124' - }, - 'type': 'OTHER_GUARDIAN' - } - } - ] - } - } - } - ] - } - } -} - snapshots['test_children_total_count[None] 1'] = { 'data': { 'children': { diff --git a/children/tests/test_api.py b/children/tests/test_api.py index 4cb4f8e2..78985ec6 100644 --- a/children/tests/test_api.py +++ b/children/tests/test_api.py @@ -63,6 +63,7 @@ ) from languages.models import Language from projects.factories import ProjectFactory +from projects.models import ProjectPermission from users.factories import GuardianFactory from users.models import Guardian @@ -374,17 +375,23 @@ def test_children_query_unauthenticated(api_client): assert_permission_denied(executed) -def test_children_query_normal_user(snapshot, user_api_client, project): +def test_children_query_normal_user(user_api_client, project): ChildWithGuardianFactory( relationship__guardian__user=user_api_client.user, project=project ) executed = user_api_client.execute(CHILDREN_QUERY) - snapshot.assert_match(executed) + assert_permission_denied(executed) -def test_children_query_project_user( +def test_children_query_project_user(project_user_api_client): + executed = project_user_api_client.execute(CHILDREN_QUERY) + + assert_permission_denied(executed) + + +def test_children_query_project_user_with_global_view_families_perm( snapshot, project_user_api_client, project, another_project ): ChildWithGuardianFactory( @@ -395,38 +402,21 @@ def test_children_query_project_user( project=another_project, ) + # Give user global permission to view families + assign_perm( + ProjectPermission.VIEW_FAMILIES.permission_name, project_user_api_client.user + ) + executed = project_user_api_client.execute(CHILDREN_QUERY) snapshot.assert_match(executed) -def test_children_query_project_user_and_guardian( - snapshot, project_user_api_client, project, another_project +def test_children_query_project_user_no_view_families_perm( + project_user_no_view_families_perm_api_client, project ): - guardian = GuardianFactory(user=project_user_api_client.user) - - ChildWithGuardianFactory( - name="Own child same project - Should be returned 1/3", - project=project, - relationship__guardian=guardian, - ) - ChildWithGuardianFactory( - name="Own child another project - Should be returned 2/3", - project=project, - relationship__guardian=guardian, - ) - ChildWithGuardianFactory( - name="Not own child same project - Should be returned 3/3", - project=project, - ) - ChildWithGuardianFactory( - name="Not own child another project - Should NOT be returned", - project=another_project, - ) - - executed = project_user_api_client.execute(CHILDREN_QUERY) - - snapshot.assert_match(executed) + executed = project_user_no_view_families_perm_api_client.execute(CHILDREN_QUERY) + assert_permission_denied(executed) def test_children_project_filter( @@ -483,6 +473,116 @@ def test_child_query_not_own_child_project_user( snapshot.assert_match(executed) +def test_child_query_not_own_child_project_user_no_view_families_perm( + project_user_no_view_families_perm_api_client, project +): + """ + Test that project user without view families permission sees others' child info + using Child query, but the guardian's email and phone number are empty. + """ + child = ChildWithGuardianFactory( + name="Test child name", + birthyear=2020, + postal_code="00100", + relationship__type=Relationship.PARENT, + relationship__guardian=GuardianFactory( + first_name="Test first name", + last_name="Test last name", + email="test@example.org", + phone_number="123456789", + ), + project=project, + ) + + variables = {"id": to_global_id("ChildNode", child.id)} + + executed = project_user_no_view_families_perm_api_client.execute( + CHILD_QUERY, variables=variables + ) + + assert executed == { + "data": { + "child": { + "name": "Test child name", + "birthyear": 2020, + "postalCode": "00100", + "relationships": { + "edges": [ + { + "node": { + "type": "PARENT", + "guardian": { + "firstName": "Test first name", + "lastName": "Test last name", + # Guardian's contact info should be empty + "email": "", + "phoneNumber": "", + }, + } + } + ] + }, + } + } + } + + +def test_child_query_own_child_project_user_no_view_families_perm( + project_user_no_view_families_perm_api_client, project +): + """ + Test that project user without view families permission sees their own child + using Child query, and the guardian's email and phone number are visible too. + """ + user = project_user_no_view_families_perm_api_client.user + child = ChildWithGuardianFactory( + name="Test child name", + birthyear=2020, + postal_code="00100", + relationship__type=Relationship.PARENT, + relationship__guardian=GuardianFactory( + user=user, + first_name="Test first name", + last_name="Test last name", + email="test@example.org", + phone_number="123456789", + ), + project=project, + ) + + variables = {"id": to_global_id("ChildNode", child.id)} + + executed = project_user_no_view_families_perm_api_client.execute( + CHILD_QUERY, variables=variables + ) + + assert executed == { + "data": { + "child": { + "name": "Test child name", + "birthyear": 2020, + "postalCode": "00100", + "relationships": { + "edges": [ + { + "node": { + "type": "PARENT", + "guardian": { + "firstName": "Test first name", + "lastName": "Test last name", + # Guardian's contact info should be visible + "email": "test@example.org", + "phoneNumber": "123456789", + }, + } + } + ] + }, + } + } + } + + ADD_CHILD_VARIABLES = { "input": { "name": "Pekka", @@ -1621,7 +1721,9 @@ def test_children_total_count( snapshot.assert_match(executed) -def test_children_query_ordering(snapshot, project, project_user_api_client): +def test_children_query_ordering( + snapshot, project, project_user_with_global_view_families_perm_api_client +): with freeze_time("2020-12-12"): ChildWithGuardianFactory(name="Alpha", project=project) ChildWithGuardianFactory(name="Bravo", project=project) @@ -1633,7 +1735,7 @@ def test_children_query_ordering(snapshot, project, project_user_api_client): ChildWithGuardianFactory(name="", project=project) ChildWithGuardianFactory(name="Charlie", project=project) - executed = project_user_api_client.execute( + executed = project_user_with_global_view_families_perm_api_client.execute( """ query Children { children { diff --git a/common/schema.py b/common/schema.py index b13c0fd4..27c69f4e 100644 --- a/common/schema.py +++ b/common/schema.py @@ -1,10 +1,13 @@ import graphene from django.conf import settings +from django.core.exceptions import PermissionDenied +from graphene_django import DjangoConnectionField from graphene_django.filter import DjangoFilterConnectionField -from common.utils import get_obj_from_global_id +from common.utils import get_node_id_from_global_id, get_obj_from_global_id from kukkuu.exceptions import ApiUsageError from languages.models import Language +from projects.models import Project, ProjectPermission LanguageEnum = graphene.Enum( "Language", [(lang[0].upper(), lang[0]) for lang in settings.LANGUAGES] @@ -51,3 +54,80 @@ def connection_resolver(cls, *args, **kwargs): raise ApiUsageError("Cannot use both offset and cursor pagination.") kwargs["first"] = limit return super().connection_resolver(*args, **kwargs) + + +class ViewFamiliesPermissionRequiredMixin: + """ + Mixin to be used in connection resolvers to forbid access to the resource + if the current user does not have the permission to view families globally + or in the given project (specified by project_id keyword argument, graphene + should map this from projectId argument to Pythonesque project_id). + + Can be used with e.g. DjangoFilterConnectionField and DjangoConnectionField. + + :raises django.core.exceptions.PermissionDenied: + If the user does not have the permission to view families. + """ + + @classmethod + def connection_resolver( + cls, + resolver, + connection, + default_manager, + queryset_resolver, + max_limit, + enforce_first_or_last, + root, + info, + *args, + **kwargs, + ): + user = info.context.user + try: + project_id = get_node_id_from_global_id(kwargs["project_id"], "ProjectNode") + project = Project.objects.get(id=project_id) + except (KeyError, Project.DoesNotExist): + can_view_families = user.is_authenticated and user.has_global_permission( + ProjectPermission.VIEW_FAMILIES + ) + else: + can_view_families = ( + user.is_authenticated and user.can_view_families_in_project(project) + ) + + if not can_view_families: + raise PermissionDenied("You do not have permission to view families.") + + return super().connection_resolver( + resolver, + connection, + default_manager, + queryset_resolver, + max_limit, + enforce_first_or_last, + root, + info, + *args, + **kwargs, + ) + + +class ViewFamiliesPermissionRequiredFilterOffsetConnectionField( + ViewFamiliesPermissionRequiredMixin, DjangoFilterAndOffsetConnectionField +): + """ + DjangoFilterAndOffsetConnectionField that requires the user to have the permission + to view families in the given project (specified with optional projectId parameter) + or globally, or permission is denied. + """ + + +class ViewFamiliesPermissionRequiredConnectionField( + ViewFamiliesPermissionRequiredMixin, DjangoConnectionField +): + """ + DjangoConnectionField that requires the user to have the permission + to view families in the given project (specified with optional projectId parameter) + or globally, or permission is denied. + """ diff --git a/common/tests/conftest.py b/common/tests/conftest.py index 2ef210e3..9c4754fe 100644 --- a/common/tests/conftest.py +++ b/common/tests/conftest.py @@ -20,7 +20,6 @@ from languages.models import Language from projects.factories import ProjectFactory from projects.models import ( - PERM_CAN_PUBLISH_EVENTS, Project, ProjectPermission, ) @@ -109,13 +108,46 @@ def guardian_api_client(): return create_api_client_with_user(UserFactory(guardian=GuardianFactory())) -@pytest.fixture() -def project_user_api_client(project): +def _projects_user_api_client( + projects: list[Project], + permissions: list[ProjectPermission], + global_permissions: list[ProjectPermission] | None = None, +) -> Client: + """ + Create an API client with a random user with the given permissions + in the given projects and the given, if any, global permissions. + """ user = UserFactory() - assign_perm(ProjectPermission.ADMIN.value, user, project) + for project in projects: + for permission in permissions: + assign_perm(permission.value, user, project) + if global_permissions: + for global_permission in global_permissions: + assign_perm(global_permission.permission_name, user) return create_api_client_with_user(user) +@pytest.fixture() +def project_user_api_client(project): + return _projects_user_api_client( + [project], [ProjectPermission.ADMIN, ProjectPermission.VIEW_FAMILIES] + ) + + +@pytest.fixture +def project_user_with_global_view_families_perm_api_client(project): + return _projects_user_api_client( + [project], + [ProjectPermission.ADMIN, ProjectPermission.VIEW_FAMILIES], + [ProjectPermission.VIEW_FAMILIES], + ) + + +@pytest.fixture() +def project_user_no_view_families_perm_api_client(project): + return _projects_user_api_client([project], [ProjectPermission.ADMIN]) + + @pytest.fixture(params=(False, True), ids=("object_perm", "model_perm")) def publisher_api_client(request, project): user = UserFactory() @@ -124,7 +156,7 @@ def publisher_api_client(request, project): if request.param: assign_perm(ProjectPermission.PUBLISH.value, user, project) else: - assign_perm(PERM_CAN_PUBLISH_EVENTS, user) + assign_perm(ProjectPermission.PUBLISH.permission_name, user) return create_api_client_with_user(user) @@ -188,16 +220,15 @@ def event_group(): @pytest.fixture() def wrong_project_api_client(another_project): - user = UserFactory() - assign_perm(ProjectPermission.ADMIN.value, user, another_project) - return create_api_client_with_user(user) + return _projects_user_api_client([another_project], [ProjectPermission.ADMIN]) @pytest.fixture def two_project_user_api_client(project, another_project): - user = UserFactory() - assign_perm(ProjectPermission.ADMIN.value, user, [project, another_project]) - return create_api_client_with_user(user) + return _projects_user_api_client( + [project, another_project], + [ProjectPermission.ADMIN, ProjectPermission.VIEW_FAMILIES], + ) @pytest.fixture( @@ -209,7 +240,7 @@ def unauthorized_user_api_client( return (api_client, user_api_client, wrong_project_api_client)[request.param] -def create_api_client_with_user(user): +def create_api_client_with_user(user) -> Client: request = RequestFactory().post("/graphql") request.user = user client = Client( diff --git a/projects/apps.py b/projects/apps.py index f6ec8331..849f99d3 100644 --- a/projects/apps.py +++ b/projects/apps.py @@ -7,6 +7,8 @@ from django.db.models.signals import post_migrate from django.utils import translation +from projects.enums import ProjectPermission + logger = logging.getLogger(__name__) @@ -107,7 +109,8 @@ def _set_group_project_permissions(project, group): from projects.models import Project project_perms = [ - f"projects.{project_perm[0]}" for project_perm in Project._meta.permissions + ProjectPermission.get_project_permission_name(project_perm_value) + for project_perm_value, project_perm_name in Project._meta.permissions ] for perm in project_perms: try: diff --git a/projects/enums.py b/projects/enums.py new file mode 100644 index 00000000..3e300929 --- /dev/null +++ b/projects/enums.py @@ -0,0 +1,30 @@ +from enum import Enum + + +class ProjectPermission(Enum): + ADMIN = "admin" + PUBLISH = "publish" + MANAGE_EVENT_GROUPS = "manage_event_groups" + SEND_MESSAGE_TO_ALL_IN_PROJECT = "can_send_to_all_in_project" + VIEW_FAMILIES = "view_families" + + @staticmethod + def get_project_permission_name(permission: str) -> str: + """ + Project permission name for the given permission, + basically just prefixes the given string with "projects.". + + :return: Given permission prefixed with "projects.", + e.g. "projects.admin" for input "admin". + """ + return f"projects.{permission}" + + @property + def permission_name(self): + """ + Permission name for this project permission. + + Example: + ProjectPermission.ADMIN.permission_name == "projects.admin" + """ + return self.get_project_permission_name(self.value) diff --git a/projects/migrations/0010_add_view_families_permission.py b/projects/migrations/0010_add_view_families_permission.py new file mode 100644 index 00000000..21bbf300 --- /dev/null +++ b/projects/migrations/0010_add_view_families_permission.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.17 on 2025-01-20 15:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("projects", "0009_add_projects_data"), + ] + + operations = [ + migrations.AlterModelOptions( + name="project", + options={ + "default_permissions": [], + "ordering": ["year"], + "permissions": ( + ("admin", "Base admin permission"), + ("publish", "Can publish events and event groups"), + ( + "manage_event_groups", + "Can create, update and delete event groups", + ), + ( + "can_send_to_all_in_project", + "Can send messages to all recipients in project", + ), + ("view_families", "Can view families"), + ), + "verbose_name": "project", + "verbose_name_plural": "projects", + }, + ), + ] diff --git a/projects/models.py b/projects/models.py index f8b7af35..d1c67956 100644 --- a/projects/models.py +++ b/projects/models.py @@ -1,5 +1,3 @@ -from enum import Enum - from django.conf import settings from django.db import models from django.utils.translation import gettext_lazy as _ @@ -9,20 +7,7 @@ from common.models import TranslatableQuerySet from common.utils import get_translations_dict - -class ProjectPermission(Enum): - ADMIN = "admin" - PUBLISH = "publish" - MANAGE_EVENT_GROUPS = "manage_event_groups" - SEND_MESSAGE_TO_ALL_IN_PROJECT = "can_send_to_all_in_project" - - -PERM_CAN_ADMINISTRATE_PROJECT = f"projects.{ProjectPermission.ADMIN.value}" -PERM_CAN_PUBLISH_EVENTS = f"projects.{ProjectPermission.PUBLISH.value}" -PERM_CAN_MANAGE_EVENT_GROUPS = f"projects.{ProjectPermission.MANAGE_EVENT_GROUPS.value}" -PERM_CAN_SEND_MESSAGE_TO_ALL_IN_PROJECT = ( - f"projects.{ProjectPermission.SEND_MESSAGE_TO_ALL_IN_PROJECT.value}" -) +from .enums import ProjectPermission class Project(TranslatableModel, SerializableMixin): @@ -72,6 +57,10 @@ class Meta: ProjectPermission.SEND_MESSAGE_TO_ALL_IN_PROJECT.value, _("Can send messages to all recipients in project"), ), + ( + ProjectPermission.VIEW_FAMILIES.value, + _("Can view families"), + ), ) @property diff --git a/projects/schema.py b/projects/schema.py index 4c7d5257..476286d8 100644 --- a/projects/schema.py +++ b/projects/schema.py @@ -14,6 +14,7 @@ class ProjectPermissionsType(ObjectType): publish = graphene.Boolean() manage_event_groups = graphene.Boolean() can_send_to_all_in_project = graphene.Boolean() + view_families = graphene.Boolean() @staticmethod def resolve_publish(parent, info): @@ -30,6 +31,11 @@ def resolve_can_send_to_all_in_project(parent, info): project, user = parent return user.can_send_messages_to_all_in_project(project) + @staticmethod + def resolve_view_families(parent, info): + project, user = parent + return user.can_view_families_in_project(project) + class ProjectTranslationType(DjangoObjectType): language_code = LanguageEnum(required=True) diff --git a/users/models.py b/users/models.py index d316a718..8a3fc03a 100644 --- a/users/models.py +++ b/users/models.py @@ -20,15 +20,11 @@ from gdpr.consts import CLEARED_VALUE from gdpr.models import GDPRModel from languages.models import Language -from projects.models import ( - PERM_CAN_MANAGE_EVENT_GROUPS, - PERM_CAN_PUBLISH_EVENTS, - PERM_CAN_SEND_MESSAGE_TO_ALL_IN_PROJECT, - ProjectPermission, -) +from projects.models import ProjectPermission if TYPE_CHECKING: from children.models import Child + from projects.models import Project from subscriptions.models import FreeSpotNotificationSubscription from verification_tokens.models import VerificationToken @@ -93,29 +89,52 @@ class Meta: def __str__(self): return super().__str__() or self.username + def get_users_projects_with_permission( + self, permission: ProjectPermission + ) -> list["Project"]: + """ + Get all projects where the user has the given permission. + """ + from projects.models import Project + + return list(get_objects_for_user(self, permission.value, Project)) + @cached_property - def administered_projects(self): - from projects.models import Project # noqa + def administered_projects(self) -> list["Project"]: + return self.get_users_projects_with_permission(ProjectPermission.ADMIN) + + @property + def projects_with_family_data_access(self) -> list["Project"]: + return self.get_users_projects_with_permission(ProjectPermission.VIEW_FAMILIES) + + def has_global_permission(self, project_permission: ProjectPermission) -> bool: + return self.has_perm(project_permission.permission_name) - return list(get_objects_for_user(self, ProjectPermission.ADMIN.value, Project)) + def has_project_permission( + self, project_permission: ProjectPermission, project: "Project" + ) -> bool: + return self.has_perm( + project_permission.value, project + ) or self.has_global_permission(project_permission) - def can_administer_project(self, project): + def can_administer_project(self, project: "Project") -> bool: return project in self.administered_projects - def can_publish_in_project(self, project): - return self.has_perm(ProjectPermission.PUBLISH.value, project) or self.has_perm( - PERM_CAN_PUBLISH_EVENTS + def can_publish_in_project(self, project: "Project") -> bool: + return self.has_project_permission(ProjectPermission.PUBLISH, project) + + def can_manage_event_groups_in_project(self, project: "Project") -> bool: + return self.has_project_permission( + ProjectPermission.MANAGE_EVENT_GROUPS, project ) - def can_manage_event_groups_in_project(self, project): - return self.has_perm( - ProjectPermission.MANAGE_EVENT_GROUPS.value, project - ) or self.has_perm(PERM_CAN_MANAGE_EVENT_GROUPS) + def can_send_messages_to_all_in_project(self, project: "Project") -> bool: + return self.has_project_permission( + ProjectPermission.SEND_MESSAGE_TO_ALL_IN_PROJECT, project + ) - def can_send_messages_to_all_in_project(self, project): - return self.has_perm( - ProjectPermission.SEND_MESSAGE_TO_ALL_IN_PROJECT.value, project - ) or self.has_perm(PERM_CAN_SEND_MESSAGE_TO_ALL_IN_PROJECT) + def can_view_families_in_project(self, project: "Project") -> bool: + return self.has_project_permission(ProjectPermission.VIEW_FAMILIES, project) def get_active_verification_tokens( self, verification_type: Optional[str] = None @@ -334,3 +353,13 @@ def delete(self, *args, **kwargs): if child.guardians.count() == 1: child.delete() return super().delete(*args, **kwargs) + + def user_can_view_contact_info(self, user: User) -> bool: + return ( + self.user == user + or user.has_global_permission(ProjectPermission.VIEW_FAMILIES) + or any( + user.can_view_families_in_project(child.project) + for child in self.children.all() + ) + ) diff --git a/users/schema.py b/users/schema.py index 2d245482..a918c94b 100644 --- a/users/schema.py +++ b/users/schema.py @@ -6,7 +6,11 @@ from graphene_django import DjangoConnectionField from graphene_django.types import DjangoObjectType -from common.schema import LanguageEnum, set_obj_languages_spoken_at_home +from common.schema import ( + LanguageEnum, + ViewFamiliesPermissionRequiredConnectionField, + set_obj_languages_spoken_at_home, +) from common.utils import login_required, map_enums_to_values_in_kwargs, update_object from kukkuu.exceptions import ObjectDoesNotExistError from projects.schema import ProjectNode @@ -50,6 +54,18 @@ class Meta: def get_queryset(cls, queryset, info): return queryset.user_can_view(info.context.user).order_by("last_name") + @staticmethod + def resolve_phone_number(guardian: Guardian, info) -> str: + if not guardian.user_can_view_contact_info(info.context.user): + return "" + return guardian.phone_number + + @staticmethod + def resolve_email(guardian: Guardian, info) -> str: + if not guardian.user_can_view_contact_info(info.context.user): + return "" + return guardian.email + class GuardianCommunicationSubscriptionsNode(DjangoObjectType): class Meta: @@ -230,7 +246,7 @@ def mutate_and_get_payload(cls, root, info, has_accepted_communication, **kwargs class Query: - guardians = DjangoConnectionField(GuardianNode) + guardians = ViewFamiliesPermissionRequiredConnectionField(GuardianNode) my_profile = graphene.Field(GuardianNode) my_admin_profile = graphene.Field(AdminNode) my_communication_subscriptions = graphene.Field( diff --git a/users/tests/snapshots/snap_test_api.py b/users/tests/snapshots/snap_test_api.py index 6dfd0690..f07fb3e1 100644 --- a/users/tests/snapshots/snap_test_api.py +++ b/users/tests/snapshots/snap_test_api.py @@ -7,40 +7,7 @@ snapshots = Snapshot() -snapshots['test_guardians_query_normal_user 1'] = { - 'data': { - 'guardians': { - 'edges': [ - { - 'node': { - 'email': 'michellewalker@example.net', - 'firstName': 'Andrew', - 'lastName': 'Eaton', - 'phoneNumber': '001-311-571-5910x23202', - 'relationships': { - 'edges': [ - { - 'node': { - 'child': { - 'birthyear': 2023, - 'name': 'Nicholas Chavez', - 'project': { - 'year': 2020 - } - }, - 'type': 'PARENT' - } - } - ] - } - } - } - ] - } - } -} - -snapshots['test_guardians_query_project_user 1'] = { +snapshots['test_guardians_query_project_user_with_global_view_families_perm 1'] = { 'data': { 'guardians': { 'edges': [ diff --git a/users/tests/test_api.py b/users/tests/test_api.py index 2e7f7ce5..61797e4a 100644 --- a/users/tests/test_api.py +++ b/users/tests/test_api.py @@ -14,13 +14,7 @@ from common.utils import get_global_id from kukkuu.consts import INVALID_EMAIL_FORMAT_ERROR, VERIFICATION_TOKEN_INVALID_ERROR from projects.factories import ProjectFactory -from projects.models import ( - PERM_CAN_ADMINISTRATE_PROJECT, - PERM_CAN_MANAGE_EVENT_GROUPS, - PERM_CAN_PUBLISH_EVENTS, - PERM_CAN_SEND_MESSAGE_TO_ALL_IN_PROJECT, - ProjectPermission, -) +from projects.models import ProjectPermission from users.factories import GuardianFactory from users.models import Guardian from users.tests.mutations import ( @@ -52,22 +46,30 @@ def test_guardians_query_unauthenticated(api_client): assert_permission_denied(executed) -def test_guardians_query_normal_user(snapshot, user_api_client, project): - GuardianFactory(relationships__count=1, relationships__child__project=project) - GuardianFactory( - user=user_api_client.user, - relationships__count=1, - relationships__child__project=project, - ) - +def test_guardians_query_normal_user(user_api_client): executed = user_api_client.execute(GUARDIANS_QUERY) - snapshot.assert_match(executed) + assert_permission_denied(executed) -def test_guardians_query_project_user( +def test_guardians_query_project_user(project_user_api_client): + """ + Test that project user with a project specific permission, but no global + permission, to view families will get permission denied, if trying to + query all guardians in all projects. + """ + executed = project_user_api_client.execute(GUARDIANS_QUERY) + assert_permission_denied(executed) + + +def test_guardians_query_project_user_with_global_view_families_perm( snapshot, project_user_api_client, project, another_project ): + """ + Test that project user with a global permission to view families, and + admin permission to a project, will get all guardians from the project + they have admin permission to, but not from other projects. + """ guardian_1 = GuardianFactory( first_name="Guardian having children in own and another project", last_name="Should be visible 1/2", @@ -95,11 +97,23 @@ def test_guardians_query_project_user( relationships__child__project=another_project, ) + # Give user global permission to view families + assign_perm( + ProjectPermission.VIEW_FAMILIES.permission_name, project_user_api_client.user + ) + executed = project_user_api_client.execute(GUARDIANS_QUERY) snapshot.assert_match(executed) +def test_guardians_query_project_user_no_view_families_perm( + project_user_no_view_families_perm_api_client, +): + executed = project_user_no_view_families_perm_api_client.execute(GUARDIANS_QUERY) + assert_permission_denied(executed) + + def test_my_profile_query_unauthenticated(api_client): GuardianFactory() @@ -427,10 +441,15 @@ def test_my_admin_profile_project_admin( ) if has_also_model_perms: - assign_perm(PERM_CAN_ADMINISTRATE_PROJECT, user_api_client.user) - assign_perm(PERM_CAN_PUBLISH_EVENTS, user_api_client.user) - assign_perm(PERM_CAN_MANAGE_EVENT_GROUPS, user_api_client.user) - assign_perm(PERM_CAN_SEND_MESSAGE_TO_ALL_IN_PROJECT, user_api_client.user) + assign_perm(ProjectPermission.ADMIN.permission_name, user_api_client.user) + assign_perm(ProjectPermission.PUBLISH.permission_name, user_api_client.user) + assign_perm( + ProjectPermission.MANAGE_EVENT_GROUPS.permission_name, user_api_client.user + ) + assign_perm( + ProjectPermission.SEND_MESSAGE_TO_ALL_IN_PROJECT.permission_name, + user_api_client.user, + ) ProjectFactory(year=2030, name="project where no object perms")