From e0f0896f94ef91d51799db16deabd67fde74723b Mon Sep 17 00:00:00 2001 From: Jose Buitron Date: Mon, 19 Feb 2024 09:46:24 -0500 Subject: [PATCH] feat: Shareable resource (#1104) --- terraso_backend/apps/core/admin.py | 6 ++ .../0049_sharedresource_share_fields.py | 66 ++++++++++++ .../0050_sharedresource_remove_none.py | 40 +++++++ ...1_sharedresource_renamed_target_members.py | 29 +++++ .../apps/core/models/shared_resources.py | 55 ++++++++++ terraso_backend/apps/core/permission_rules.py | 17 +++ .../apps/graphql/schema/__init__.py | 4 + .../apps/graphql/schema/schema.graphql | 39 +++++-- .../apps/graphql/schema/shared_resources.py | 93 +++++++++++++++- .../apps/shared_data/permission_rules.py | 14 ++- terraso_backend/apps/shared_data/urls.py | 9 +- terraso_backend/apps/shared_data/views.py | 36 ++++++- terraso_backend/tests/conftest.py | 5 + .../tests/core/gis/test_parsers.py | 1 - terraso_backend/tests/graphql/conftest.py | 2 + .../test_shared_resource_mutations.py | 76 +++++++++++++ .../tests/graphql/test_shared_data.py | 29 +++-- .../tests/graphql/test_shared_resource.py | 102 ++++++++++++++++++ terraso_backend/tests/shared_data/conftest.py | 56 +++++++++- .../tests/shared_data/test_views.py | 48 +++++++++ 20 files changed, 704 insertions(+), 23 deletions(-) create mode 100644 terraso_backend/apps/core/migrations/0049_sharedresource_share_fields.py create mode 100644 terraso_backend/apps/core/migrations/0050_sharedresource_remove_none.py create mode 100644 terraso_backend/apps/core/migrations/0051_sharedresource_renamed_target_members.py create mode 100644 terraso_backend/tests/graphql/mutations/test_shared_resource_mutations.py create mode 100644 terraso_backend/tests/graphql/test_shared_resource.py diff --git a/terraso_backend/apps/core/admin.py b/terraso_backend/apps/core/admin.py index 6eac68fd5..a3e0e3979 100644 --- a/terraso_backend/apps/core/admin.py +++ b/terraso_backend/apps/core/admin.py @@ -20,6 +20,7 @@ Landscape, LandscapeDevelopmentStrategy, LandscapeGroup, + SharedResource, TaxonomyTerm, User, UserPreference, @@ -69,3 +70,8 @@ class TaxonomyTermAdmin(admin.ModelAdmin): @admin.register(LandscapeDevelopmentStrategy) class LandscapeDevelopmentStrategyAdmin(admin.ModelAdmin): list_display = ("id", "landscape") + + +@admin.register(SharedResource) +class SharedResourceAdmin(admin.ModelAdmin): + list_display = ("id", "share_uuid", "share_access") diff --git a/terraso_backend/apps/core/migrations/0049_sharedresource_share_fields.py b/terraso_backend/apps/core/migrations/0049_sharedresource_share_fields.py new file mode 100644 index 000000000..6b32e5612 --- /dev/null +++ b/terraso_backend/apps/core/migrations/0049_sharedresource_share_fields.py @@ -0,0 +1,66 @@ +# 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 on 2024-01-08 15:59 + +import uuid + +from django.conf import settings +from django.db import migrations, models + + +def fill_shared_uuid(apps, schema_editor): + SharedResource = apps.get_model("core", "SharedResource") + shared_resources = SharedResource.objects.all() + for shared_resource in shared_resources: + shared_resource.share_uuid = uuid.uuid4() + SharedResource.objects.bulk_update(shared_resources, ["share_uuid"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0048_group_membership_list"), + ] + + operations = [ + migrations.AddField( + model_name="sharedresource", + name="share_access", + field=models.CharField( + choices=[ + ("no", "No share access"), + ("all", "Anyone with the link"), + ("target_members", "Only tagert members"), + ], + default="no", + max_length=32, + ), + ), + migrations.AddField( + model_name="sharedresource", + name="share_uuid", + field=models.UUIDField(default=uuid.uuid4), + preserve_default=False, + ), + migrations.RunPython(fill_shared_uuid), + migrations.AddConstraint( + model_name="sharedresource", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("share_uuid",), + name="unique_share_uuid", + ), + ), + ] diff --git a/terraso_backend/apps/core/migrations/0050_sharedresource_remove_none.py b/terraso_backend/apps/core/migrations/0050_sharedresource_remove_none.py new file mode 100644 index 000000000..b66432cc5 --- /dev/null +++ b/terraso_backend/apps/core/migrations/0050_sharedresource_remove_none.py @@ -0,0 +1,40 @@ +# 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/. + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0049_sharedresource_share_fields"), + ] + + operations = [ + migrations.RunSQL( + sql="UPDATE core_sharedresource SET share_access ='target_members' WHERE share_access='no';" + ), + migrations.AlterField( + model_name="sharedresource", + name="share_access", + field=models.CharField( + choices=[ + ("all", "Anyone with the link"), + ("target_members", "Only target members"), + ], + default="target_members", + max_length=32, + ), + ), + ] diff --git a/terraso_backend/apps/core/migrations/0051_sharedresource_renamed_target_members.py b/terraso_backend/apps/core/migrations/0051_sharedresource_renamed_target_members.py new file mode 100644 index 000000000..4cb604f06 --- /dev/null +++ b/terraso_backend/apps/core/migrations/0051_sharedresource_renamed_target_members.py @@ -0,0 +1,29 @@ +# 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/. + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0050_sharedresource_remove_none"), + ] + + operations = [ + migrations.RunSQL( + sql="UPDATE core_sharedresource SET share_access ='members' WHERE share_access='target_members';" + ), + ] diff --git a/terraso_backend/apps/core/models/shared_resources.py b/terraso_backend/apps/core/models/shared_resources.py index 211117fde..41de5f445 100644 --- a/terraso_backend/apps/core/models/shared_resources.py +++ b/terraso_backend/apps/core/models/shared_resources.py @@ -12,9 +12,13 @@ # # 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/. +import uuid + +from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models +from django.utils.translation import gettext_lazy as _ from apps.core.models import BaseModel @@ -26,6 +30,15 @@ class SharedResource(BaseModel): Target represents the resource that is receiving the shared resource (Example: Landscape). """ + SHARE_ACCESS_ALL = "all" + SHARE_ACCESS_MEMBERS = "members" + DEFAULT_SHARE_ACCESS = SHARE_ACCESS_MEMBERS + + SHARE_ACCESS_TYPES = ( + (SHARE_ACCESS_ALL, _("Anyone with the link")), + (SHARE_ACCESS_MEMBERS, _("Only members")), + ) + source = GenericForeignKey("source_content_type", "source_object_id") source_content_type = models.ForeignKey( ContentType, on_delete=models.CASCADE, related_name="source_content_type" @@ -37,3 +50,45 @@ class SharedResource(BaseModel): ContentType, on_delete=models.CASCADE, related_name="target_content_type" ) target_object_id = models.UUIDField() + share_uuid = models.UUIDField(default=uuid.uuid4) + share_access = models.CharField( + max_length=32, + choices=SHARE_ACCESS_TYPES, + default=DEFAULT_SHARE_ACCESS, + ) + + class Meta: + constraints = ( + models.UniqueConstraint( + fields=("share_uuid",), + condition=models.Q(deleted_at__isnull=True), + name="unique_share_uuid", + ), + ) + + def get_download_url(self): + return f"{settings.API_ENDPOINT}/shared-data/download/{self.share_uuid}" + + def get_share_url(self): + from apps.core.models import Group, Landscape + + target = self.target + entity = ( + "groups" + if isinstance(target, Group) + else "landscapes" if isinstance(target, Landscape) else None + ) + if not entity: + return None + slug = target.slug + share_uuid = self.share_uuid + return f"{settings.WEB_CLIENT_URL}/{entity}/{slug}/download/{share_uuid}" + + @classmethod + def get_share_access_from_text(cls, share_access): + if not share_access: + return cls.SHARE_ACCESS_MEMBERS + lowered = share_access.lower() + if lowered == cls.SHARE_ACCESS_ALL: + return cls.SHARE_ACCESS_ALL + return cls.SHARE_ACCESS_MEMBERS diff --git a/terraso_backend/apps/core/permission_rules.py b/terraso_backend/apps/core/permission_rules.py index c9636df4e..118283e0c 100644 --- a/terraso_backend/apps/core/permission_rules.py +++ b/terraso_backend/apps/core/permission_rules.py @@ -214,6 +214,22 @@ def allowed_to_delete_group_membership(user, obj): return validate_delete_membership(user, group, membership) +@rules.predicate +def allowed_to_change_shared_resource(user, shared_resource): + from apps.shared_data.permission_rules import is_target_manager + + target = shared_resource.target + source = shared_resource.source + + if source.created_by == user: + return True + + if is_target_manager(user, target): + return True + + return False + + rules.add_rule("allowed_group_managers_count", allowed_group_managers_count) rules.add_rule("allowed_to_update_preferences", allowed_to_update_preferences) rules.add_rule("allowed_to_change_landscape", allowed_to_change_landscape) @@ -222,3 +238,4 @@ def allowed_to_delete_group_membership(user, obj): rules.add_rule("allowed_landscape_managers_count", allowed_landscape_managers_count) rules.add_rule("allowed_to_change_group_membership", allowed_to_change_group_membership) rules.add_rule("allowed_to_delete_group_membership", allowed_to_delete_group_membership) +rules.add_rule("allowed_to_change_shared_resource", allowed_to_change_shared_resource) diff --git a/terraso_backend/apps/graphql/schema/__init__.py b/terraso_backend/apps/graphql/schema/__init__.py index 526c08e85..7d092596e 100644 --- a/terraso_backend/apps/graphql/schema/__init__.py +++ b/terraso_backend/apps/graphql/schema/__init__.py @@ -80,6 +80,7 @@ LandscapeMembershipDeleteMutation, LandscapeMembershipSaveMutation, ) +from .shared_resources import SharedResourceRelayNode, SharedResourceUpdateMutation from .sites import ( SiteAddMutation, SiteDeleteMutation, @@ -139,6 +140,8 @@ class Query(graphene.ObjectType): site = TerrasoRelayNode.Field(SiteNode) sites = DjangoFilterConnectionField(SiteNode, required=True) audit_logs = DjangoFilterConnectionField(AuditLogNode) + shared_resource = SharedResourceRelayNode.Field() + from .shared_resources import resolve_shared_resource # All mutations should inherit from BaseWriteMutation or BaseDeleteMutation @@ -202,6 +205,7 @@ class Mutations(graphene.ObjectType): delete_landscape_membership = LandscapeMembershipDeleteMutation.Field() save_group_membership = GroupMembershipSaveMutation.Field() delete_group_membership = GroupMembershipDeleteMutation.Field() + update_shared_resource = SharedResourceUpdateMutation.Field() schema = graphene.Schema(query=Query, mutation=Mutations) diff --git a/terraso_backend/apps/graphql/schema/schema.graphql b/terraso_backend/apps/graphql/schema/schema.graphql index bcc577ef3..def8172fe 100644 --- a/terraso_backend/apps/graphql/schema/schema.graphql +++ b/terraso_backend/apps/graphql/schema/schema.graphql @@ -89,6 +89,7 @@ type Query { """Ordering""" orderBy: String ): AuditLogNodeConnection + sharedResource(shareUuid: String!): SharedResourceNode } type GroupNode implements Node { @@ -430,8 +431,27 @@ type SharedResourceNodeEdge { type SharedResourceNode implements Node { id: ID! + shareUuid: UUID! + shareAccess: CoreSharedResourceShareAccessChoices! source: SourceNode target: TargetNode + downloadUrl: String + shareUrl: String +} + +""" +Leverages the internal Python implementation of UUID (uuid.UUID) to provide native UUID objects +in fields, resolvers and input. +""" +scalar UUID + +"""An enumeration.""" +enum CoreSharedResourceShareAccessChoices { + """Anyone with the link""" + ALL + + """Only members""" + MEMBERS } union SourceNode = VisualizationConfigNode | DataEntryNode @@ -507,12 +527,6 @@ type VisualizationConfigNodeEdge { cursor: String! } -""" -Leverages the internal Python implementation of UUID (uuid.UUID) to provide native UUID objects -in fields, resolvers and input. -""" -scalar UUID - union OwnerNode = GroupNode | LandscapeNode union TargetNode = GroupNode | LandscapeNode @@ -1676,6 +1690,7 @@ type Mutations { deleteLandscapeMembership(input: LandscapeMembershipDeleteMutationInput!): LandscapeMembershipDeleteMutationPayload! saveGroupMembership(input: GroupMembershipSaveMutationInput!): GroupMembershipSaveMutationPayload! deleteGroupMembership(input: GroupMembershipDeleteMutationInput!): GroupMembershipDeleteMutationPayload! + updateSharedResource(input: SharedResourceUpdateMutationInput!): SharedResourceUpdateMutationPayload! } type GroupAddMutationPayload { @@ -2477,3 +2492,15 @@ input GroupMembershipDeleteMutationInput { groupSlug: String! clientMutationId: String } + +type SharedResourceUpdateMutationPayload { + errors: GenericScalar + sharedResource: SharedResourceNode + clientMutationId: String +} + +input SharedResourceUpdateMutationInput { + id: ID! + shareAccess: String! + clientMutationId: String +} diff --git a/terraso_backend/apps/graphql/schema/shared_resources.py b/terraso_backend/apps/graphql/schema/shared_resources.py index b13ecd65d..b6469b7d1 100644 --- a/terraso_backend/apps/graphql/schema/shared_resources.py +++ b/terraso_backend/apps/graphql/schema/shared_resources.py @@ -14,16 +14,24 @@ # along with this program. If not, see https://www.gnu.org/licenses/. import graphene +import rules +import structlog +from django.db.models import Q, Subquery from graphene import relay from graphene_django import DjangoObjectType -from apps.core.models import SharedResource +from apps.collaboration.models import Membership as CollaborationMembership +from apps.core.models import Group, Landscape, SharedResource +from apps.graphql.exceptions import GraphQLNotAllowedException, GraphQLNotFoundException from . import GroupNode, LandscapeNode -from .commons import TerrasoConnection +from .commons import BaseWriteMutation, TerrasoConnection +from .constants import MutationTypes from .data_entries import DataEntryNode from .visualization_config import VisualizationConfigNode +logger = structlog.get_logger(__name__) + class SourceNode(graphene.Union): class Meta: @@ -39,10 +47,12 @@ class SharedResourceNode(DjangoObjectType): id = graphene.ID(source="pk", required=True) source = graphene.Field(SourceNode) target = graphene.Field(TargetNode) + download_url = graphene.String() + share_url = graphene.String() class Meta: model = SharedResource - fields = ["id"] + fields = ["id", "share_access", "share_uuid"] interfaces = (relay.Node,) connection_class = TerrasoConnection @@ -51,3 +61,80 @@ def resolve_source(self, info, **kwargs): def resolve_target(self, info, **kwargs): return self.target + + def resolve_download_url(self, info, **kwargs): + return self.get_download_url() + + def resolve_share_url(self, info, **kwargs): + return self.get_share_url() + + +class SharedResourceRelayNode: + @classmethod + def Field(cls): + return graphene.Field(SharedResourceNode, share_uuid=graphene.String(required=True)) + + +def resolve_shared_resource(root, info, share_uuid=None): + if not share_uuid: + return None + + user_pk = getattr(info.context.user, "pk", False) + user_groups_ids = Subquery( + Group.objects.filter( + membership_list__memberships__deleted_at__isnull=True, + membership_list__memberships__user__id=user_pk, + membership_list__memberships__membership_status=CollaborationMembership.APPROVED, + ).values("id") + ) + user_landscape_ids = Subquery( + Landscape.objects.filter( + membership_list__memberships__deleted_at__isnull=True, + membership_list__memberships__user__id=user_pk, + membership_list__memberships__membership_status=CollaborationMembership.APPROVED, + ).values("id") + ) + + share_access_all = Q(share_access=SharedResource.SHARE_ACCESS_ALL) + share_access_members = Q( + Q(share_access=SharedResource.SHARE_ACCESS_MEMBERS) + & Q(Q(target_object_id__in=user_groups_ids) | Q(target_object_id__in=user_landscape_ids)) + ) + + return SharedResource.objects.filter( + Q(share_uuid=share_uuid) & Q(share_access_all | share_access_members) + ).first() + + +class SharedResourceUpdateMutation(BaseWriteMutation): + shared_resource = graphene.Field(SharedResourceNode) + + model_class = SharedResource + + class Input: + id = graphene.ID(required=True) + share_access = graphene.String(required=True) + + @classmethod + def mutate_and_get_payload(cls, root, info, **kwargs): + user = info.context.user + + try: + shared_resource = SharedResource.objects.get(pk=kwargs["id"]) + except SharedResource.DoesNotExist: + logger.error( + "SharedResource not found", + extra={"shared_resource_id": kwargs["id"]}, + ) + raise GraphQLNotFoundException(field="id", model_name=SharedResource.__name__) + + if not rules.test_rule("allowed_to_change_shared_resource", user, shared_resource): + logger.info( + "Attempt to update a SharedResource, but user lacks permission", + extra={"user_id": user.pk, "shared_resource_id": str(shared_resource.pk)}, + ) + raise GraphQLNotAllowedException( + model_name=SharedResource.__name__, operation=MutationTypes.UPDATE + ) + kwargs["share_access"] = SharedResource.get_share_access_from_text(kwargs["share_access"]) + return super().mutate_and_get_payload(root, info, **kwargs) diff --git a/terraso_backend/apps/shared_data/permission_rules.py b/terraso_backend/apps/shared_data/permission_rules.py index 5d86fb04b..0a888c6a0 100644 --- a/terraso_backend/apps/shared_data/permission_rules.py +++ b/terraso_backend/apps/shared_data/permission_rules.py @@ -16,7 +16,7 @@ import rules from apps.core import group_collaboration_roles, landscape_collaboration_roles -from apps.core.models import Group, Landscape +from apps.core.models import Group, Landscape, SharedResource def is_target_manager(user, target): @@ -103,4 +103,16 @@ def allowed_to_delete_visualization_config(user, visualization_config): return is_user_allowed_to_change_data_entry(visualization_config.data_entry, user) +@rules.predicate +def allowed_to_download_data_entry_file(user, shared_resource): + target = shared_resource.target + + if shared_resource.share_access == SharedResource.SHARE_ACCESS_ALL: + return True + + if shared_resource.share_access == SharedResource.SHARE_ACCESS_MEMBERS: + return is_target_member(user, target) + + rules.add_rule("allowed_to_add_data_entry", allowed_to_add_data_entry) +rules.add_rule("allowed_to_download_data_entry_file", allowed_to_download_data_entry_file) diff --git a/terraso_backend/apps/shared_data/urls.py b/terraso_backend/apps/shared_data/urls.py index 6f9b53053..ed411ab10 100644 --- a/terraso_backend/apps/shared_data/urls.py +++ b/terraso_backend/apps/shared_data/urls.py @@ -16,10 +16,17 @@ from django.urls import path from django.views.decorators.csrf import csrf_exempt -from .views import DataEntryFileUploadView +from apps.auth.middleware import auth_optional + +from .views import DataEntryFileDownloadView, DataEntryFileUploadView app_name = "apps.shared_data" urlpatterns = [ path("upload/", csrf_exempt(DataEntryFileUploadView.as_view()), name="upload"), + path( + "download/", + csrf_exempt(auth_optional(DataEntryFileDownloadView.as_view())), + name="download", + ), ] diff --git a/terraso_backend/apps/shared_data/views.py b/terraso_backend/apps/shared_data/views.py index 035ef02c9..61105ea62 100644 --- a/terraso_backend/apps/shared_data/views.py +++ b/terraso_backend/apps/shared_data/views.py @@ -17,15 +17,18 @@ from dataclasses import asdict from pathlib import Path +import rules import structlog from config.settings import DATA_ENTRY_ACCEPTED_EXTENSIONS, MEDIA_UPLOAD_MAX_FILE_SIZE from django.contrib.contenttypes.models import ContentType from django.db import transaction -from django.http import JsonResponse +from django.http import HttpResponse, HttpResponseRedirect, JsonResponse +from django.views import View from django.views.generic.edit import FormView from apps.auth.mixins import AuthenticationRequiredMixin from apps.core.exceptions import ErrorContext, ErrorMessage +from apps.core.models import SharedResource from apps.storage.file_utils import has_multiple_files, is_file_upload_oversized from .forms import DataEntryForm @@ -38,6 +41,37 @@ mimetypes.init() +class DataEntryFileDownloadView(View): + def get(self, request, shared_resource_uuid, *args, **kwargs): + shared_resource = SharedResource.objects.filter(share_uuid=shared_resource_uuid).first() + + if shared_resource is None: + return HttpResponse("Not Found", status=404) + + needs_authentication = ( + shared_resource.share_access != SharedResource.SHARE_ACCESS_ALL + and not request.user.is_authenticated + ) + if needs_authentication: + return HttpResponse("Not Found", status=404) + + source = shared_resource.source + + if not isinstance(source, DataEntry) or source.entry_type != DataEntry.ENTRY_TYPE_FILE: + # Only support download for data entries files + return HttpResponse("Not Found", status=404) + + if not rules.test_rule( + "allowed_to_download_data_entry_file", request.user, shared_resource + ): + return HttpResponse("Not Found", status=404) + + signed_url = source.signed_url + + # Redirect to the presigned URL + return HttpResponseRedirect(signed_url) + + class DataEntryFileUploadView(AuthenticationRequiredMixin, FormView): @transaction.atomic def post(self, request, **kwargs): diff --git a/terraso_backend/tests/conftest.py b/terraso_backend/tests/conftest.py index 1bc47b9d4..9b5cca3b6 100644 --- a/terraso_backend/tests/conftest.py +++ b/terraso_backend/tests/conftest.py @@ -46,6 +46,11 @@ def logged_client(access_token): return Client(HTTP_AUTHORIZATION=f"Bearer {access_token}") +@pytest.fixture +def not_logged_in_client(access_token): + return Client() + + @pytest.fixture def unit_polygon(): """A polygon whose geographical area is roughly 1 km squared.""" diff --git a/terraso_backend/tests/core/gis/test_parsers.py b/terraso_backend/tests/core/gis/test_parsers.py index 279a90902..e7f7a411f 100644 --- a/terraso_backend/tests/core/gis/test_parsers.py +++ b/terraso_backend/tests/core/gis/test_parsers.py @@ -202,7 +202,6 @@ def test_parse_shapefile(file_path_expected): with open(resources.files("tests").joinpath(expected_file_path), "rb") as file: expected_json = json.load(file) - print(f"shapefile_json: {shapefile_json}") assert json.dumps(shapefile_json) == json.dumps(expected_json) diff --git a/terraso_backend/tests/graphql/conftest.py b/terraso_backend/tests/graphql/conftest.py index 9ff927f81..8884b2a9d 100644 --- a/terraso_backend/tests/graphql/conftest.py +++ b/terraso_backend/tests/graphql/conftest.py @@ -13,6 +13,7 @@ # 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/. +import uuid from datetime import timedelta import pytest @@ -321,6 +322,7 @@ def group_data_entries(users, groups): resources = mixer.cycle(5).blend( SharedResource, target=creator_group, + share_uuid=lambda: uuid.uuid4(), source=lambda: mixer.blend(DataEntry, created_by=creator, size=100, resource_type="csv"), ) return [resource.source for resource in resources] diff --git a/terraso_backend/tests/graphql/mutations/test_shared_resource_mutations.py b/terraso_backend/tests/graphql/mutations/test_shared_resource_mutations.py new file mode 100644 index 000000000..71be149ab --- /dev/null +++ b/terraso_backend/tests/graphql/mutations/test_shared_resource_mutations.py @@ -0,0 +1,76 @@ +# 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/. + +import pytest + +pytestmark = pytest.mark.django_db + + +def test_shared_resource_update_by_source_works(client_query, data_entries): + data_entry = data_entries[0] + shared_resource = data_entry.shared_resources.all()[0] + + new_data = { + "id": str(shared_resource.id), + "shareAccess": "ALL", + } + response = client_query( + """ + mutation updateSharedResource($input: SharedResourceUpdateMutationInput!) { + updateSharedResource(input: $input) { + sharedResource { + id + shareAccess + } + } + } + """, + variables={"input": new_data}, + ) + json_result = response.json() + result = json_result["data"]["updateSharedResource"]["sharedResource"] + + assert result == new_data + + +def test_shared_resource_update_by_non_creator_or_manager_fails_due_permission_check( + client_query, data_entries, users +): + data_entry = data_entries[0] + shared_resource = data_entry.shared_resources.all()[0] + + # Let's force old data creator be different from client query user + data_entry.created_by = users[2] + data_entry.save() + + new_data = { + "id": str(shared_resource.id), + "shareAccess": "ALL", + } + + response = client_query( + """ + mutation updateSharedResource($input: SharedResourceUpdateMutationInput!) { + updateSharedResource(input: $input) { + errors + } + } + """, + variables={"input": new_data}, + ) + response = response.json() + + assert "errors" in response["data"]["updateSharedResource"] + assert "update_not_allowed" in response["data"]["updateSharedResource"]["errors"][0]["message"] diff --git a/terraso_backend/tests/graphql/test_shared_data.py b/terraso_backend/tests/graphql/test_shared_data.py index 25aed428f..5add53eb1 100644 --- a/terraso_backend/tests/graphql/test_shared_data.py +++ b/terraso_backend/tests/graphql/test_shared_data.py @@ -146,9 +146,6 @@ def test_data_entries_filter_by_closed_group_slug_filters_successfuly( users[0].email, group_collaboration_roles.ROLE_MEMBER, CollaborationMembership.APPROVED ) - shared_resources = data_entry_a.shared_resources.all() - print(shared_resources) - response = client_query( """ {dataEntries(sharedResources_Target_Slug: "%s", sharedResources_TargetContentType: "%s") { @@ -344,10 +341,6 @@ def test_data_entries_empty_from_closed_group_query(client_query, groups_closed, data_entry_a.shared_resources.create(target=group) data_entry_b.shared_resources.create(target=group) - memberships = group.membership_list.memberships.all() - - print(memberships) - response = client_query( """ {groups(slug: "%s") { @@ -404,6 +397,9 @@ def test_data_entries_from_parent_query_by_resource_field( name } } + shareUrl + downloadUrl + shareAccess } } } @@ -417,10 +413,25 @@ def test_data_entries_from_parent_query_by_resource_field( json_response = response.json() resources = json_response["data"][parent]["edges"][0]["node"]["sharedResources"]["edges"] - entries_result = [resource["node"]["source"]["name"] for resource in resources] + entries_result = [ + { + "name": resource["node"]["source"]["name"], + "share_url": resource["node"]["shareUrl"], + "download_url": resource["node"]["downloadUrl"], + "share_access": resource["node"]["shareAccess"], + } + for resource in resources + ] for data_entry in data_entries: - assert data_entry.name in entries_result + shared_resource = data_entry.shared_resources.all()[0] + expected = { + "name": data_entry.name, + "download_url": shared_resource.get_download_url(), + "share_url": shared_resource.get_share_url(), + "share_access": shared_resource.share_access.upper(), + } + assert expected in entries_result @pytest.mark.parametrize( diff --git a/terraso_backend/tests/graphql/test_shared_resource.py b/terraso_backend/tests/graphql/test_shared_resource.py new file mode 100644 index 000000000..41c9b3d74 --- /dev/null +++ b/terraso_backend/tests/graphql/test_shared_resource.py @@ -0,0 +1,102 @@ +# 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/. + + +import pytest + +from apps.collaboration.models import Membership as CollaborationMembership +from apps.core import group_collaboration_roles +from apps.core.models import SharedResource + +pytestmark = pytest.mark.django_db + + +def test_shared_resource_access_all(client_query, data_entries): + data_entry = data_entries[0] + shared_resource = data_entry.shared_resources.all()[0] + + shared_resource.target.membership_list.memberships.all().delete() + + shared_resource.share_access = SharedResource.SHARE_ACCESS_ALL + shared_resource.save() + + response = client_query( + """ + {sharedResource(shareUuid: "%s") { + shareAccess + }} + """ + % shared_resource.share_uuid + ) + + json_response = response.json() + + result = json_response["data"]["sharedResource"] + + assert shared_resource.share_access == result["shareAccess"].lower() + + +def test_shared_resource_access_members(client_query, data_entries, users): + data_entry = data_entries[0] + shared_resource = data_entry.shared_resources.all()[0] + + shared_resource.target.membership_list.memberships.all().delete() + + shared_resource.target.membership_list.save_membership( + users[0].email, group_collaboration_roles.ROLE_MEMBER, CollaborationMembership.APPROVED + ) + + shared_resource.share_access = SharedResource.SHARE_ACCESS_MEMBERS + shared_resource.save() + + response = client_query( + """ + {sharedResource(shareUuid: "%s") { + shareAccess + }} + """ + % shared_resource.share_uuid + ) + + json_response = response.json() + + result = json_response["data"]["sharedResource"] + + assert shared_resource.share_access == result["shareAccess"].lower() + + +def test_shared_resource_access_members_fail(client_query, data_entries, users): + data_entry = data_entries[0] + shared_resource = data_entry.shared_resources.all()[0] + + shared_resource.target.membership_list.memberships.all().delete() + + shared_resource.share_access = SharedResource.SHARE_ACCESS_MEMBERS + shared_resource.save() + + response = client_query( + """ + {sharedResource(shareUuid: "%s") { + shareAccess + }} + """ + % shared_resource.share_uuid + ) + + json_response = response.json() + + result = json_response["data"]["sharedResource"] + + assert result is None diff --git a/terraso_backend/tests/shared_data/conftest.py b/terraso_backend/tests/shared_data/conftest.py index 4b01a4f6b..e09b9b219 100644 --- a/terraso_backend/tests/shared_data/conftest.py +++ b/terraso_backend/tests/shared_data/conftest.py @@ -13,11 +13,15 @@ # 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/. +import uuid + import pytest from django.conf import settings from mixer.backend.django import mixer -from apps.core.models import Group, Landscape, User +from apps.collaboration.models import Membership as CollaborationMembership +from apps.core import group_collaboration_roles +from apps.core.models import Group, Landscape, SharedResource, User from apps.shared_data.models import DataEntry, VisualizationConfig @@ -117,3 +121,53 @@ def visualization_config_gpx(user): ), created_by=user, ) + + +@pytest.fixture +def shared_resource_data_entry_shared_all(users): + creator = users[0] + return mixer.blend( + SharedResource, + share_access=SharedResource.SHARE_ACCESS_ALL, + share_uuid=uuid.uuid4(), + target=mixer.blend(Group), + source=mixer.blend( + DataEntry, slug=None, created_by=creator, size=100, entry_type=DataEntry.ENTRY_TYPE_FILE + ), + ) + + +@pytest.fixture +def shared_resource_data_entry_shared_members(users): + creator = users[0] + creator_group = mixer.blend(Group) + creator_group.membership_list.save_membership( + creator.email, group_collaboration_roles.ROLE_MEMBER, CollaborationMembership.APPROVED + ) + return mixer.blend( + SharedResource, + share_access=SharedResource.SHARE_ACCESS_MEMBERS, + share_uuid=uuid.uuid4(), + target=creator_group, + source=mixer.blend( + DataEntry, slug=None, created_by=creator, size=100, entry_type=DataEntry.ENTRY_TYPE_FILE + ), + ) + + +@pytest.fixture +def shared_resource_data_entry_shared_members_user_1(users): + creator = users[1] + creator_group = mixer.blend(Group) + creator_group.membership_list.save_membership( + creator.email, group_collaboration_roles.ROLE_MEMBER, CollaborationMembership.APPROVED + ) + return mixer.blend( + SharedResource, + share_access=SharedResource.SHARE_ACCESS_MEMBERS, + share_uuid=uuid.uuid4(), + target=creator_group, + source=mixer.blend( + DataEntry, slug=None, created_by=creator, size=100, entry_type=DataEntry.ENTRY_TYPE_FILE + ), + ) diff --git a/terraso_backend/tests/shared_data/test_views.py b/terraso_backend/tests/shared_data/test_views.py index 6f32cb6bc..26e35b7d0 100644 --- a/terraso_backend/tests/shared_data/test_views.py +++ b/terraso_backend/tests/shared_data/test_views.py @@ -165,3 +165,51 @@ def test_create_data_entry_file_invalid_type(logged_client, upload_url, data_ent response_data = response.json() assert "errors" in response_data + + +@mock.patch("apps.shared_data.models.data_entries.data_entry_file_storage.url") +def test_download_data_entry_file_shared_all( + get_url_mock, not_logged_in_client, shared_resource_data_entry_shared_all +): + redirect_url = "https://example.org/s3_file.json" + get_url_mock.return_value = redirect_url + url = reverse( + "shared_data:download", + kwargs={"shared_resource_uuid": shared_resource_data_entry_shared_all.share_uuid}, + ) + response = not_logged_in_client.get(url) + + assert response.status_code == 302 + assert response.url == redirect_url + + +@mock.patch("apps.shared_data.models.data_entries.data_entry_file_storage.url") +def test_download_data_entry_file_shared_members( + get_url_mock, logged_client, shared_resource_data_entry_shared_members +): + redirect_url = "https://example.org/s3_file.json" + get_url_mock.return_value = redirect_url + url = reverse( + "shared_data:download", + kwargs={"shared_resource_uuid": shared_resource_data_entry_shared_members.share_uuid}, + ) + response = logged_client.get(url) + + assert response.status_code == 302 + assert response.url == redirect_url + + +@mock.patch("apps.shared_data.models.data_entries.data_entry_file_storage.url") +def test_download_data_entry_file_shared_members_fail( + get_url_mock, logged_client, shared_resource_data_entry_shared_members_user_1 +): + redirect_url = "https://example.org/s3_file.json" + get_url_mock.return_value = redirect_url + share_uuid = shared_resource_data_entry_shared_members_user_1.share_uuid + url = reverse( + "shared_data:download", + kwargs={"shared_resource_uuid": share_uuid}, + ) + response = logged_client.get(url) + + assert response.status_code == 404