Skip to content

Commit

Permalink
feat: Use MembershipList with Projects (#793)
Browse files Browse the repository at this point in the history
* feat: Switch projects to use membership list; skip failing tests

* feat: Create mutation to update membership list of project

* feat: Restrict membership roles during creation

* feat: Add mutation for removing users from project

* test: Test permissions for deleting memberships

* test: Test user not member of project cannot update

* test: Test user can remove themself from project

* feat: Add mutation for updating membership role

* test: Add test for user not part of project updating role

* test: Reactivate skipped project tests

* feat: Move projects graphql into projects app

* refactor: Define and use project management collaboration roles

* chore: Make migration for project membership list

* fix: Enum types for ProjectMemberships, PR fixups

* feat: Use mixins to allow customizing MembershipNodes

* feat: Use enum for project update mutations

* fix: Restore missing membership_lists filter

This can be done be reusing the filterset class with
DjangoFilterConnectionField. It would be worthwhile checking if this
work can be encapsulated in a resuable function, as there is now a lot
of custom code whose function is not obvious.

* fix: Fix schema errors with ProjectMembership

The ProjectMembershipList was being a bunch of places where we did not
want it to be used, like Story Maps! I tried creating using some
Django proxy inheritance and that seems to have helped Graphene figure
things out.

* fix: Change Project model to point to proxy

* fix: Fix malfunctioning ProjectMembershipListNode

Somewhat confusing.

* fix: Fix project filter to correct memberships

Previously it was just returning all memberships for every project!

* chore: Reorder migrations
  • Loading branch information
David Code Howard authored Sep 27, 2023
1 parent 98bcf54 commit 4170565
Show file tree
Hide file tree
Showing 26 changed files with 1,121 additions and 225 deletions.
38 changes: 34 additions & 4 deletions terraso_backend/apps/collaboration/graphql/memberships.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,34 @@
from graphene import relay
from graphene_django import DjangoObjectType

from apps.graphql.schema.commons import TerrasoConnection

from ..models import Membership, MembershipList

# from apps.graphql.schema.commons import TerrasoConnection


logger = structlog.get_logger(__name__)


class CollaborationMembershipListNode(DjangoObjectType):
# TODO: trying to import this from apps.graphql.schema.commons causes a circular import
# Created an issue to move the module to apps.graphql.commons, as that seems simplest
# https://github.com/techmatters/terraso-backend/issues/820
class TerrasoConnection(graphene.Connection):
class Meta:
abstract = True

total_count = graphene.Int(required=True)

def resolve_total_count(self, info, **kwargs):
queryset = self.iterable
return queryset.count()

@classmethod
def __init_subclass_with_meta__(cls, **options):
options["strict_types"] = options.pop("strict_types", True)
super().__init_subclass_with_meta__(**options)


class MembershipListNodeMixin:
id = graphene.ID(source="pk", required=True)
account_membership = graphene.Field("apps.collaboration.graphql.CollaborationMembershipNode")
memberships_count = graphene.Int()
Expand All @@ -53,6 +73,11 @@ def resolve_memberships_count(self, info):
return self.memberships.approved_only().count()


class CollaborationMembershipListNode(MembershipListNodeMixin, DjangoObjectType):
class Meta(MembershipListNodeMixin.Meta):
pass


class CollaborationMembershipFilterSet(django_filters.FilterSet):
user__email__not = django_filters.CharFilter(method="filter_user_email_not")

Expand All @@ -69,7 +94,7 @@ def filter_user_email_not(self, queryset, name, value):
return queryset.exclude(user__email=value)


class CollaborationMembershipNode(DjangoObjectType):
class MembershipNodeMixin:
id = graphene.ID(source="pk", required=True)

class Meta:
Expand All @@ -86,3 +111,8 @@ def get_queryset(cls, queryset, info):
return queryset.none()

return queryset


class CollaborationMembershipNode(MembershipNodeMixin, DjangoObjectType):
class Meta(MembershipNodeMixin.Meta):
pass
22 changes: 14 additions & 8 deletions terraso_backend/apps/graphql/schema/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@
import graphene
from graphene_django.filter import DjangoFilterConnectionField

from apps.project_management.graphql.projects import (
ProjectAddMutation,
ProjectAddUserMutation,
ProjectArchiveMutation,
ProjectDeleteMutation,
ProjectDeleteUserMutation,
ProjectMarkSeenMutation,
ProjectNode,
ProjectUpdateMutation,
ProjectUpdateUserRoleMutation,
)
from apps.soil_id.graphql.soil_data import (
DepthDependentSoilDataUpdateMutation,
SoilDataUpdateMutation,
Expand Down Expand Up @@ -57,14 +68,6 @@
MembershipNode,
MembershipUpdateMutation,
)
from .projects import (
ProjectAddMutation,
ProjectArchiveMutation,
ProjectDeleteMutation,
ProjectMarkSeenMutation,
ProjectNode,
ProjectUpdateMutation,
)
from .sites import (
SiteAddMutation,
SiteDeleteMutation,
Expand Down Expand Up @@ -169,6 +172,9 @@ class Mutations(graphene.ObjectType):
update_project = ProjectUpdateMutation.Field()
archive_project = ProjectArchiveMutation.Field()
delete_project = ProjectDeleteMutation.Field()
add_user_to_project = ProjectAddUserMutation.Field()
delete_user_from_project = ProjectDeleteUserMutation.Field()
update_user_role_in_project = ProjectUpdateUserRoleMutation.Field()
mark_project_seen = ProjectMarkSeenMutation.Field()
update_soil_data = SoilDataUpdateMutation.Field()
update_depth_dependent_soil_data = DepthDependentSoilDataUpdateMutation.Field()
Expand Down
4 changes: 0 additions & 4 deletions terraso_backend/apps/graphql/schema/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,6 @@ class GroupNode(DjangoObjectType):
account_membership = graphene.Field("apps.graphql.schema.memberships.MembershipNode")
memberships_count = graphene.Int()

@classmethod
def get_queryset(cls, queryset, info):
return queryset.filter(project__isnull=True)

class Meta:
model = Group
fields = (
Expand Down
87 changes: 86 additions & 1 deletion terraso_backend/apps/graphql/schema/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ type ProjectNode implements Node {
updatedAt: DateTime!
name: String!
description: String!
group: GroupNode!
membershipList: ProjectMembershipListNode!
privacy: ProjectManagementProjectPrivacyChoices!
archived: Boolean!
siteSet(
Expand All @@ -658,6 +658,47 @@ type ProjectNode implements Node {
seen: Boolean!
}

type ProjectMembershipListNode implements Node {
membershipType: CollaborationMembershipListMembershipTypeChoices!
id: ID!
accountMembership: CollaborationMembershipNode
membershipsCount: Int
memberships(offset: Int, before: String, after: String, first: Int, last: Int, user: ID, user_In: [ID], userRole: String, user_Email_Icontains: String, user_Email_In: [String], membershipStatus: CollaborationMembershipMembershipStatusChoices, user_Email_Not: String): ProjectMembershipNodeConnection!
}

type ProjectMembershipNodeConnection {
"""Pagination data for this connection."""
pageInfo: PageInfo!

"""Contains the nodes in this connection."""
edges: [ProjectMembershipNodeEdge!]!
totalCount: Int!
}

"""A Relay edge containing a `ProjectMembershipNode` and its cursor."""
type ProjectMembershipNodeEdge {
"""The item at the end of the edge"""
node: ProjectMembershipNode!

"""A cursor for use in pagination"""
cursor: String!
}

type ProjectMembershipNode implements Node {
membershipList: CollaborationMembershipListNode!
user: UserNode
userRole: UserRole!
membershipStatus: CollaborationMembershipMembershipStatusChoices!
pendingEmail: String
id: ID!
}

enum UserRole {
viewer
contributor
manager
}

"""An enumeration."""
enum ProjectManagementProjectPrivacyChoices {
"""Private"""
Expand Down Expand Up @@ -1303,6 +1344,9 @@ type Mutations {
updateProject(input: ProjectUpdateMutationInput!): ProjectUpdateMutationPayload!
archiveProject(input: ProjectArchiveMutationInput!): ProjectArchiveMutationPayload!
deleteProject(input: ProjectDeleteMutationInput!): ProjectDeleteMutationPayload!
addUserToProject(input: ProjectAddUserMutationInput!): ProjectAddUserMutationPayload!
deleteUserFromProject(input: ProjectDeleteUserMutationInput!): ProjectDeleteUserMutationPayload!
updateUserRoleInProject(input: ProjectUpdateUserRoleMutationInput!): ProjectUpdateUserRoleMutationPayload!
markProjectSeen(input: ProjectMarkSeenMutationInput!): ProjectMarkSeenMutationPayload!
updateSoilData(input: SoilDataUpdateMutationInput!): SoilDataUpdateMutationPayload!
updateDepthDependentSoilData(input: DepthDependentSoilDataUpdateMutationInput!): DepthDependentSoilDataUpdateMutationPayload!
Expand Down Expand Up @@ -1814,6 +1858,47 @@ input ProjectDeleteMutationInput {
clientMutationId: String
}

type ProjectAddUserMutationPayload {
errors: GenericScalar
project: ProjectNode!
membership: CollaborationMembershipNode!
clientMutationId: String
}

input ProjectAddUserMutationInput {
projectId: ID!
userId: ID!
role: UserRole!
clientMutationId: String
}

type ProjectDeleteUserMutationPayload {
errors: GenericScalar
project: ProjectNode!
membership: CollaborationMembershipNode!
clientMutationId: String
}

input ProjectDeleteUserMutationInput {
projectId: ID!
userId: ID!
clientMutationId: String
}

type ProjectUpdateUserRoleMutationPayload {
errors: GenericScalar
project: ProjectNode!
membership: CollaborationMembershipNode!
clientMutationId: String
}

input ProjectUpdateUserRoleMutationInput {
projectId: ID!
userId: ID!
newRole: UserRole!
clientMutationId: String
}

type ProjectMarkSeenMutationPayload {
errors: GenericScalar
project: ProjectNode!
Expand Down
2 changes: 1 addition & 1 deletion terraso_backend/apps/graphql/schema/sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
class SiteFilter(django_filters.FilterSet):
project = TypedFilter()
owner = TypedFilter()
project__member = TypedFilter(field_name="project__group__memberships__user")
project__member = TypedFilter(field_name="project__membership_list__memberships__user")

class Meta:
model = Site
Expand Down
1 change: 1 addition & 0 deletions terraso_backend/apps/graphql/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@

membership_added_signal = Signal()
membership_updated_signal = Signal()
membership_deleted_signal = Signal()
2 changes: 1 addition & 1 deletion terraso_backend/apps/project_management/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@

@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
readonly_fields = ("group", "settings")
readonly_fields = ("membership_list", "settings")
18 changes: 18 additions & 0 deletions terraso_backend/apps/project_management/collaboration_roles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Copyright © 2023 Technology Matters
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see https://www.gnu.org/licenses/.

ROLE_MANAGER = "manager"
ROLE_CONTRIBUTOR = "contributor"
ROLE_VIEWER = "viewer"
Loading

0 comments on commit 4170565

Please sign in to comment.