diff --git a/terraso_backend/apps/graphql/schema/schema.graphql b/terraso_backend/apps/graphql/schema/schema.graphql index 74f7db91e..eebd98ddb 100644 --- a/terraso_backend/apps/graphql/schema/schema.graphql +++ b/terraso_backend/apps/graphql/schema/schema.graphql @@ -642,7 +642,6 @@ type ProjectNode implements Node { membershipList: ProjectMembershipListNode! privacy: ProjectManagementProjectPrivacyChoices! archived: Boolean! - measurementUnits: ProjectManagementProjectMeasurementUnitsChoices! siteInstructions: String siteSet( offset: Int @@ -694,16 +693,17 @@ type ProjectMembershipNodeEdge { type ProjectMembershipNode implements Node { membershipList: CollaborationMembershipListNode! user: UserNode! - userRole: UserRole! + userRole: ProjectManagementProjectRoleChoices! membershipStatus: CollaborationMembershipMembershipStatusChoices! pendingEmail: String id: ID! } -enum UserRole { - viewer - contributor - manager +"""An enumeration.""" +enum ProjectManagementProjectRoleChoices { + VIEWER + CONTRIBUTOR + MANAGER } """An enumeration.""" @@ -715,15 +715,6 @@ enum ProjectManagementProjectPrivacyChoices { PUBLIC } -"""An enumeration.""" -enum ProjectManagementProjectMeasurementUnitsChoices { - """Metric""" - METRIC - - """Imperial""" - IMPERIAL -} - type SiteNodeConnection { """Pagination data for this connection.""" pageInfo: PageInfo! @@ -2039,24 +2030,13 @@ type ProjectAddMutationPayload { input ProjectAddMutationInput { name: String! - privacy: ProjectPrivacy! + privacy: ProjectManagementProjectPrivacyChoices description: String - measurementUnits: MeasurementUnits! siteInstructions: String createSoilSettings: Boolean clientMutationId: String } -enum ProjectPrivacy { - PRIVATE - PUBLIC -} - -enum MeasurementUnits { - METRIC - IMPERIAL -} - type ProjectUpdateMutationPayload { errors: GenericScalar project: ProjectNode @@ -2066,9 +2046,8 @@ type ProjectUpdateMutationPayload { input ProjectUpdateMutationInput { id: ID! name: String - privacy: ProjectPrivacy = null + privacy: ProjectManagementProjectPrivacyChoices description: String - measurementUnits: MeasurementUnits = null siteInstructions: String clientMutationId: String } @@ -2107,7 +2086,7 @@ type ProjectAddUserMutationPayload { input ProjectAddUserMutationInput { projectId: ID! userId: ID! - role: UserRole! + role: ProjectManagementProjectRoleChoices! clientMutationId: String } @@ -2134,7 +2113,7 @@ type ProjectUpdateUserRoleMutationPayload { input ProjectUpdateUserRoleMutationInput { projectId: ID! userId: ID! - newRole: UserRole! + newRole: ProjectManagementProjectRoleChoices! clientMutationId: String } diff --git a/terraso_backend/apps/project_management/collaboration_roles.py b/terraso_backend/apps/project_management/collaboration_roles.py index 180928ecf..ff062d151 100644 --- a/terraso_backend/apps/project_management/collaboration_roles.py +++ b/terraso_backend/apps/project_management/collaboration_roles.py @@ -13,6 +13,10 @@ # 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" +from django.db import models + + +class ProjectRole(models.TextChoices): + VIEWER = "VIEWER" + CONTRIBUTOR = "CONTRIBUTOR" + MANAGER = "MANAGER" diff --git a/terraso_backend/apps/project_management/graphql/projects.py b/terraso_backend/apps/project_management/graphql/projects.py index fff7d667d..9cb9c9505 100644 --- a/terraso_backend/apps/project_management/graphql/projects.py +++ b/terraso_backend/apps/project_management/graphql/projects.py @@ -46,7 +46,7 @@ membership_deleted_signal, membership_updated_signal, ) -from apps.project_management import collaboration_roles +from apps.project_management.collaboration_roles import ProjectRole from apps.project_management.models import ( Project, ProjectMembership, @@ -55,11 +55,9 @@ from apps.project_management.models.sites import Site from apps.soil_id.models.project_soil_settings import ProjectSoilSettings - -class UserRole(graphene.Enum): - viewer = collaboration_roles.ROLE_VIEWER - contributor = collaboration_roles.ROLE_CONTRIBUTOR - manager = collaboration_roles.ROLE_MANAGER +ProjectRoleEnum = graphene.Enum( + "ProjectManagementProjectRoleChoices", [(c[0], c[1]) for c in ProjectRole.choices] +) class ProjectMembershipNode(DjangoObjectType, MembershipNodeMixin): @@ -67,18 +65,7 @@ class Meta(MembershipNodeMixin.Meta): model = ProjectMembership user = graphene.Field(UserNode, required=True) - user_role = graphene.Field(UserRole, required=True) - - def resolve_user_role(self, info): - match self.user_role: - case "viewer": - return UserRole.viewer - case "contributor": - return UserRole.contributor - case "manager": - return UserRole.manager - case _: - raise Exception(f"Unexpected user role: {self.user_role}") + user_role = graphene.Field(ProjectRoleEnum, required=True) class ProjectMembershipFilterSet(FilterSet): @@ -140,7 +127,6 @@ class Meta: "site_set", "archived", "membership_list", - "measurement_units", "site_instructions", ) @@ -153,6 +139,10 @@ def resolve_seen(self, info): return True return self.seen_by.filter(id=user.id).exists() + @classmethod + def privacy_enum(cls): + return cls._meta.fields["privacy"].type.of_type() + @classmethod def get_queryset(cls, queryset, info): # limit queries to membership lists of projects to which the user belongs @@ -163,16 +153,6 @@ def get_queryset(cls, queryset, info): ) -class ProjectPrivacy(graphene.Enum): - PRIVATE = Project.PRIVATE - PUBLIC = Project.PUBLIC - - -class MeasurementUnits(graphene.Enum): - METRIC = "METRIC" - IMPERIAL = "IMPERIAL" - - class ProjectAddMutation(BaseWriteMutation): skip_field_validation = ["membership_list", "settings"] project = graphene.Field(ProjectNode, required=True) @@ -181,9 +161,8 @@ class ProjectAddMutation(BaseWriteMutation): class Input: name = graphene.String(required=True) - privacy = graphene.Field(ProjectPrivacy, required=True) + privacy = ProjectNode.privacy_enum() description = graphene.String() - measurement_units = graphene.Field(MeasurementUnits, required=True) site_instructions = graphene.String() create_soil_settings = graphene.Boolean() @@ -294,9 +273,8 @@ class ProjectUpdateMutation(BaseWriteMutation): class Input: id = graphene.ID(required=True) name = graphene.String() - privacy = graphene.Field(ProjectPrivacy) + privacy = ProjectNode.privacy_enum() description = graphene.String() - measurement_units = graphene.Field(MeasurementUnits) site_instructions = graphene.String() @classmethod @@ -338,7 +316,7 @@ class ProjectAddUserMutation(BaseWriteMutation): class Input: project_id = graphene.ID(required=True) user_id = graphene.ID(required=True) - role = graphene.Field(UserRole, required=True) + role = graphene.Field(ProjectRoleEnum, required=True) @classmethod def mutate_and_get_payload(cls, root, info, project_id, user_id, role): @@ -437,7 +415,7 @@ class ProjectUpdateUserRoleMutation(BaseWriteMutation): class Input: project_id = graphene.ID(required=True) user_id = graphene.ID(required=True) - new_role = graphene.Field(UserRole, required=True) + new_role = graphene.Field(ProjectRoleEnum, required=True) @classmethod def mutate_and_get_payload(cls, root, info, project_id, user_id, new_role): diff --git a/terraso_backend/apps/project_management/migrations/0026_alter_project_privacy.py b/terraso_backend/apps/project_management/migrations/0026_alter_project_privacy.py new file mode 100644 index 000000000..effefc3d7 --- /dev/null +++ b/terraso_backend/apps/project_management/migrations/0026_alter_project_privacy.py @@ -0,0 +1,69 @@ +# Copyright © 2024 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/. + +# Generated by Django 5.0.2 on 2024-03-14 23:15 + +from django.db import migrations, models + + +def migrate_data(apps, schema_editor): + Project = apps.get_model("project_management", "Project") + Project.objects.filter(privacy_old="private").update(privacy="PRIVATE") + Project.objects.filter(privacy_old="public").update(privacy="PUBLIC") + + Membership = apps.get_model("collaboration", "Membership") + project_memberships = Membership.objects.filter(membership_list__project__isnull=False) + project_memberships.filter(user_role="manager").update(user_role="MANAGER") + project_memberships.filter(user_role="contributor").update(user_role="CONTRIBUTOR") + project_memberships.filter(user_role="viewer").update(user_role="VIEWER") + + +def unmigrate_data(apps, schema_editor): + Project = apps.get_model("project_management", "Project") + Project.objects.filter(privacy="PRIVATE").update(privacy_old="private") + Project.objects.filter(privacy="PUBLIC").update(privacy_old="public") + + Membership = apps.get_model("collaboration", "Membership") + project_memberships = Membership.objects.filter(membership_list__project__isnull=False) + project_memberships.filter(user_role="MANAGER").update(user_role="manager") + project_memberships.filter(user_role="CONTRIBUTOR").update(user_role="contributor") + project_memberships.filter(user_role="VIEWER").update(user_role="viewer") + + +class Migration(migrations.Migration): + + dependencies = [ + ("project_management", "0025_sitenote_deleted_at_sitenote_deleted_by_cascade_and_more"), + ("collaboration", "0006_membership_status_approved_lowercase"), + ] + + operations = [ + migrations.RenameField(model_name="project", old_name="privacy", new_name="privacy_old"), + migrations.AddField( + model_name="project", + name="privacy", + field=models.CharField( + choices=[("PRIVATE", "Private"), ("PUBLIC", "Public")], + default="PRIVATE", + max_length=32, + ), + ), + migrations.RunPython(migrate_data, unmigrate_data), + migrations.RemoveField(model_name="project", name="privacy_old"), + migrations.RemoveField( + model_name="project", + name="measurement_units", + ), + ] diff --git a/terraso_backend/apps/project_management/models/projects.py b/terraso_backend/apps/project_management/models/projects.py index 8ca322f14..5a67a8aab 100644 --- a/terraso_backend/apps/project_management/models/projects.py +++ b/terraso_backend/apps/project_management/models/projects.py @@ -19,11 +19,7 @@ from apps.core.models import User from apps.core.models.commons import BaseModel from apps.project_management import permission_rules -from apps.project_management.collaboration_roles import ( - ROLE_CONTRIBUTOR, - ROLE_MANAGER, - ROLE_VIEWER, -) +from apps.project_management.collaboration_roles import ProjectRole class ProjectSettings(BaseModel): @@ -37,24 +33,19 @@ class Meta(BaseModel.Meta): class ProjectMembership(Membership): - """A proxy class created soley for graphene schema reasons""" + """A proxy class created solely for graphene schema reasons""" class Meta: proxy = True class ProjectMembershipList(MembershipList): - """A proxy class created soley for graphql schema reasons""" + """A proxy class created solely for graphql schema reasons""" class Meta: proxy = True -class MeasurementUnits(models.TextChoices): - METRIC = "METRIC" - IMPERIAL = "IMPERIAL" - - class Project(BaseModel): class Meta(BaseModel.Meta): abstract = False @@ -67,20 +58,15 @@ class Meta(BaseModel.Meta): "archive": permission_rules.allowed_to_archive_project, } - ROLES = (ROLE_VIEWER, ROLE_CONTRIBUTOR, ROLE_MANAGER) - - PRIVATE = "private" - PUBLIC = "public" - DEFAULT_PRIVACY_STATUS = PRIVATE - - PRIVACY_STATUS = ((PRIVATE, _("Private")), (PUBLIC, _("Public"))) - name = models.CharField(max_length=120) description = models.CharField(max_length=512, default="", blank=True) membership_list = models.OneToOneField(ProjectMembershipList, on_delete=models.CASCADE) - privacy = models.CharField( - max_length=32, choices=PRIVACY_STATUS, default=DEFAULT_PRIVACY_STATUS - ) + + class Privacy(models.TextChoices): + PRIVATE = "PRIVATE" + PUBLIC = "PUBLIC" + + privacy = models.CharField(max_length=32, choices=Privacy.choices, default=Privacy.PRIVATE) seen_by = models.ManyToManyField(User, related_name="+") archived = models.BooleanField( @@ -88,9 +74,7 @@ class Meta(BaseModel.Meta): ) settings = models.OneToOneField(ProjectSettings, on_delete=models.PROTECT) - measurement_units = models.CharField( - default=MeasurementUnits.METRIC, choices=MeasurementUnits.choices - ) + site_instructions = models.TextField(null=True, blank=True) @staticmethod @@ -115,43 +99,52 @@ def create_membership_list() -> MembershipList: enroll_method=MembershipList.ENROLL_METHOD_JOIN, ) - def is_manager(self, user: User) -> bool: - return self.manager_memberships.filter(user=user).exists() + def user_has_role(self, user: User, role: ProjectRole) -> bool: + return self.memberships_by_role(role).filter(user=user).exists() - def is_viewer(self, user: User) -> bool: - return self.viewer_memberships.filter(user=user).exists() + def is_manager(self, user: User) -> bool: + return self.user_has_role(user, ProjectRole.MANAGER) def is_contributor(self, user: User) -> bool: - return self.contributor_memberships.filter(user=user).exists() + return self.user_has_role(user, ProjectRole.CONTRIBUTOR) + + def is_viewer(self, user: User) -> bool: + return self.user_has_role(user, ProjectRole.VIEWER) def is_member(self, user: User) -> bool: return self.membership_list.is_member(user) @property def manager_memberships(self): - return self.membership_list.memberships.by_role(ROLE_MANAGER) + return self.memberships_by_role(ProjectRole.MANAGER) @property - def viewer_memberships(self): - return self.membership_list.memberships.by_role(ROLE_VIEWER) + def contributor_memberships(self): + return self.memberships_by_role(ProjectRole.CONTRIBUTOR) @property - def contributor_memberships(self): - return self.membership_list.memberships.by_role(ROLE_CONTRIBUTOR) + def viewer_memberships(self): + return self.memberships_by_role(ProjectRole.VIEWER) + + def memberships_by_role(self, role: ProjectRole): + return self.membership_list.memberships.by_role(role.value) + def add_manager(self, user: User): - return self.add_user_with_role(user, ROLE_MANAGER) + return self.add_user_with_role(user, ProjectRole.MANAGER) + + def add_contributor(self, user: User): + return self.add_user_with_role(user, ProjectRole.CONTRIBUTOR) def add_viewer(self, user: User): - return self.add_user_with_role(user, ROLE_VIEWER) + return self.add_user_with_role(user, ProjectRole.VIEWER) - def add_user_with_role(self, user: User, role: str): - assert role in self.ROLES + def add_user_with_role(self, user: User, role: ProjectRole): return Membership.objects.create( membership_list=self.membership_list, user=user, membership_status=Membership.APPROVED, - user_role=role, + user_role=role.value, pending_email=None, ) diff --git a/terraso_backend/apps/project_management/permission_rules.py b/terraso_backend/apps/project_management/permission_rules.py index 7987fbc91..b8dc6d556 100644 --- a/terraso_backend/apps/project_management/permission_rules.py +++ b/terraso_backend/apps/project_management/permission_rules.py @@ -14,7 +14,7 @@ # along with this program. If not, see https://www.gnu.org/licenses/. import rules -from .collaboration_roles import ROLE_MANAGER +from apps.project_management.collaboration_roles import ProjectRole @rules.predicate @@ -60,7 +60,7 @@ def allowed_to_add_member_to_project(user, context): requester_membership = context["requester_membership"] return ( requester_membership.membership_list == project.membership_list - and requester_membership.user_role == ROLE_MANAGER + and requester_membership.user_role == ProjectRole.MANAGER.value ) @@ -73,7 +73,8 @@ def allowed_to_delete_user_from_project(user, context): requester_membership = context["requester_membership"] target_membership = context["target_membership"] return project.membership_list == requester_membership.membership_list and ( - user == target_membership.user or requester_membership.user_role == ROLE_MANAGER + user == target_membership.user + or requester_membership.user_role == ProjectRole.MANAGER.value ) @@ -89,7 +90,7 @@ def allowed_to_change_user_project_role(user, context): project.membership_list == requester_membership.membership_list == target_membership.membership_list - and requester_membership.user_role == ROLE_MANAGER + and requester_membership.user_role == ProjectRole.MANAGER.value ) diff --git a/terraso_backend/tests/conftest.py b/terraso_backend/tests/conftest.py index 9b5cca3b6..158f8af96 100644 --- a/terraso_backend/tests/conftest.py +++ b/terraso_backend/tests/conftest.py @@ -26,6 +26,7 @@ from apps.collaboration.models import Membership from apps.core.gis.utils import DEFAULT_CRS from apps.core.models import User +from apps.project_management.collaboration_roles import ProjectRole from apps.project_management.models import Project, Site pytestmark = pytest.mark.django_db @@ -135,7 +136,7 @@ def project_user(project: Project) -> User: @pytest.fixture def project_user_w_role(request, project: Project): user = mixer.blend(User) - project.add_user_with_role(user, request.param) + project.add_user_with_role(user, ProjectRole(request.param)) return user diff --git a/terraso_backend/tests/graphql/mutations/test_projects.py b/terraso_backend/tests/graphql/mutations/test_projects.py index 2cabf443b..1f2cdaa78 100644 --- a/terraso_backend/tests/graphql/mutations/test_projects.py +++ b/terraso_backend/tests/graphql/mutations/test_projects.py @@ -373,7 +373,7 @@ def test_delete_user_from_project_delete_self(project, project_user, client): def test_delete_user_from_project_not_manager(project, project_user, client): other_user = mixer.blend(User) - project.add_user_with_role(other_user, "contributor") + project.add_contributor(other_user) client.force_login(project_user) input_data = {"projectId": str(project.id), "userId": str(other_user.id)} response = graphql_query(DELETE_USER_GRAPHQL, input_data=input_data, client=client) diff --git a/terraso_backend/tests/graphql/mutations/test_sites.py b/terraso_backend/tests/graphql/mutations/test_sites.py index 881b48979..329cde276 100644 --- a/terraso_backend/tests/graphql/mutations/test_sites.py +++ b/terraso_backend/tests/graphql/mutations/test_sites.py @@ -24,6 +24,9 @@ from apps.audit_logs.models import Log from apps.core.models import User from apps.project_management.models import Project, Site +from backend.terraso_backend.apps.project_management.collaboration_roles import ( + ProjectRole, +) pytestmark = pytest.mark.django_db @@ -291,7 +294,7 @@ def linked_site(request, project_manager): if request.param != "linked": project = mixer.blend(Project) site.add_to_project(project) - project.add_user_with_role(project_manager, request.param) + project.add_user_with_role(project_manager, ProjectRole(request.param)) return site @@ -339,7 +342,7 @@ def test_site_transfer_success(linked_site, client, project, project_manager): def test_site_transfer_unlinked_site_user_contributor_success(client, user, site, project): - project.add_user_with_role(user, "contributor") + project.add_contributor(user) input_data = {"siteIds": [str(site.id)], "projectId": str(project.id)} client.force_login(user) payload = graphql_query(SITE_TRANSFER_MUTATION, client=client, input_data=input_data).json() @@ -351,7 +354,7 @@ def test_site_transfer_unlinked_site_user_contributor_success(client, user, site def test_site_transfer_unlinked_site_user_viewer_failure(client, user, site, project): - project.add_user_with_role(user, "viewer") + project.add_viewer(user) input_data = {"siteIds": [str(site.id)], "projectId": str(project.id)} client.force_login(user) payload = graphql_query(SITE_TRANSFER_MUTATION, client=client, input_data=input_data).json() @@ -364,8 +367,8 @@ def test_site_transfer_unlinked_site_user_viewer_failure(client, user, site, pro def transfer_site(request, user, site, project): role_a, role_b = request.param project_a = mixer.blend(Project) - project_a.add_user_with_role(user, role_a) - project.add_user_with_role(user, role_b) + project_a.add_user_with_role(user, ProjectRole(role_a)) + project.add_user_with_role(user, ProjectRole(role_b)) site.add_to_project(project_a) return site