diff --git a/terraso_backend/apps/collaboration/graphql/memberships.py b/terraso_backend/apps/collaboration/graphql/memberships.py index 635a45c56..98011abe9 100644 --- a/terraso_backend/apps/collaboration/graphql/memberships.py +++ b/terraso_backend/apps/collaboration/graphql/memberships.py @@ -21,9 +21,6 @@ from ..models import Membership, MembershipList -# from apps.graphql.schema.commons import TerrasoConnection - - logger = structlog.get_logger(__name__) diff --git a/terraso_backend/apps/core/migrations/0046_shared_resource.py b/terraso_backend/apps/core/migrations/0046_shared_resource.py new file mode 100644 index 000000000..ae9b0e1b4 --- /dev/null +++ b/terraso_backend/apps/core/migrations/0046_shared_resource.py @@ -0,0 +1,106 @@ +# 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/. + + +# Generated by Django 4.2.6 on 2023-10-13 22:10 + +import uuid + +import django.db.models.deletion +import rules.contrib.models +from django.conf import settings +from django.db import migrations, models + +import apps.core.models.commons + + +def data_entries_to_shared_resources(apps, schema_editor): + ContentType = apps.get_model("contenttypes", "ContentType") + LandscapeGroup = apps.get_model("core", "LandscapeGroup") + SharedResource = apps.get_model("core", "SharedResource") + DataEntry = apps.get_model("shared_data", "DataEntry") + data_entries = DataEntry.objects.all() + for data_entry in data_entries: + groups = data_entry.groups.all() + for group in groups: + landscape_group = LandscapeGroup.objects.filter( + group=group, is_default_landscape_group=True + ).first() + if landscape_group is None: + SharedResource.objects.create( + source_content_type=ContentType.objects.get_for_model(data_entry), + source_object_id=data_entry.id, + target_content_type=ContentType.objects.get_for_model(group), + target_object_id=group.id, + ) + else: + SharedResource.objects.create( + source_content_type=ContentType.objects.get_for_model(data_entry), + source_object_id=data_entry.id, + target_content_type=ContentType.objects.get_for_model( + landscape_group.landscape + ), + target_object_id=landscape_group.landscape.id, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("core", "0045_taxonomyterms_ecosystems_renamed"), + ] + + operations = [ + migrations.CreateModel( + name="SharedResource", + fields=[ + ("deleted_at", models.DateTimeField(db_index=True, editable=False, null=True)), + ("deleted_by_cascade", models.BooleanField(default=False, editable=False)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("source_object_id", models.UUIDField()), + ("target_object_id", models.UUIDField()), + ( + "source_content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="source_content_type", + to="contenttypes.contenttype", + ), + ), + ( + "target_content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="target_content_type", + to="contenttypes.contenttype", + ), + ), + ], + options={ + "ordering": ["created_at"], + "get_latest_by": "-created_at", + "abstract": False, + }, + bases=(rules.contrib.models.RulesModelMixin, models.Model), + ), + migrations.RunPython(data_entries_to_shared_resources), + ] diff --git a/terraso_backend/apps/core/models/__init__.py b/terraso_backend/apps/core/models/__init__.py index daefa027c..f0bb0c63f 100644 --- a/terraso_backend/apps/core/models/__init__.py +++ b/terraso_backend/apps/core/models/__init__.py @@ -17,6 +17,7 @@ from .commons import BaseModel, SlugModel from .groups import Group, GroupAssociation, Membership from .landscapes import Landscape, LandscapeDevelopmentStrategy, LandscapeGroup +from .shared_resources import SharedResource from .taxonomy_terms import TaxonomyTerm from .users import User, UserPreference @@ -33,4 +34,5 @@ "User", "UserPreference", "TaxonomyTerm", + "SharedResource", ] diff --git a/terraso_backend/apps/core/models/groups.py b/terraso_backend/apps/core/models/groups.py index 328050e2c..af8e344ca 100644 --- a/terraso_backend/apps/core/models/groups.py +++ b/terraso_backend/apps/core/models/groups.py @@ -14,6 +14,7 @@ # along with this program. If not, see https://www.gnu.org/licenses/. from typing import Literal, Union +from django.contrib.contenttypes.fields import GenericRelation from django.db import models, transaction from django.utils.translation import gettext_lazy as _ from safedelete.models import SafeDeleteManager @@ -21,6 +22,7 @@ from apps.core import permission_rules as perm_rules from .commons import BaseModel, SlugModel, validate_name +from .shared_resources import SharedResource from .users import User @@ -94,6 +96,10 @@ class Group(SlugModel): default=DEFAULT_MEMERBSHIP_TYPE, ) + shared_resources = GenericRelation( + SharedResource, content_type_field="target_content_type", object_id_field="target_object_id" + ) + field_to_slug = "name" class Meta(SlugModel.Meta): diff --git a/terraso_backend/apps/core/models/landscapes.py b/terraso_backend/apps/core/models/landscapes.py index 89a7856e6..1cdae393c 100644 --- a/terraso_backend/apps/core/models/landscapes.py +++ b/terraso_backend/apps/core/models/landscapes.py @@ -15,6 +15,7 @@ import structlog from dirtyfields import DirtyFieldsMixin +from django.contrib.contenttypes.fields import GenericRelation from django.db import models, transaction from apps.core import permission_rules as perm_rules @@ -26,6 +27,7 @@ from .commons import BaseModel, SlugModel, validate_name from .groups import Group +from .shared_resources import SharedResource from .users import User logger = structlog.get_logger(__name__) @@ -85,6 +87,10 @@ class Landscape(SlugModel, DirtyFieldsMixin): profile_image_description = models.TextField(blank=True, default="") center_coordinates = models.JSONField(blank=True, null=True) + shared_resources = GenericRelation( + SharedResource, content_type_field="target_content_type", object_id_field="target_object_id" + ) + field_to_slug = "name" class Meta(SlugModel.Meta): diff --git a/terraso_backend/apps/core/models/shared_resources.py b/terraso_backend/apps/core/models/shared_resources.py new file mode 100644 index 000000000..211117fde --- /dev/null +++ b/terraso_backend/apps/core/models/shared_resources.py @@ -0,0 +1,39 @@ +# 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/. +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models + +from apps.core.models import BaseModel + + +class SharedResource(BaseModel): + """ + This model represents a shared resource. + Source represents the resource that is being shared (Example: DataEntry). + Target represents the resource that is receiving the shared resource (Example: Landscape). + """ + + source = GenericForeignKey("source_content_type", "source_object_id") + source_content_type = models.ForeignKey( + ContentType, on_delete=models.CASCADE, related_name="source_content_type" + ) + source_object_id = models.UUIDField() + + target = GenericForeignKey("target_content_type", "target_object_id") + target_content_type = models.ForeignKey( + ContentType, on_delete=models.CASCADE, related_name="target_content_type" + ) + target_object_id = models.UUIDField() diff --git a/terraso_backend/apps/graphql/schema/data_entries.py b/terraso_backend/apps/graphql/schema/data_entries.py index 8304c7d04..5ba5689d2 100644 --- a/terraso_backend/apps/graphql/schema/data_entries.py +++ b/terraso_backend/apps/graphql/schema/data_entries.py @@ -13,35 +13,66 @@ # 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 django_filters import graphene +import rules import structlog +from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from django.db.models import Q from graphene import relay from graphene_django import DjangoObjectType -from apps.core.models import Group, Membership +from apps.core.models import Group, Landscape, Membership from apps.graphql.exceptions import GraphQLNotAllowedException, GraphQLNotFoundException from apps.shared_data.models import DataEntry +from apps.shared_data.models.data_entries import VALID_TARGET_TYPES from .commons import BaseDeleteMutation, BaseWriteMutation, TerrasoConnection from .constants import MutationTypes +from .shared_resources_mixin import SharedResourcesMixin logger = structlog.get_logger(__name__) -class DataEntryNode(DjangoObjectType): - id = graphene.ID(source="pk", required=True) +class DataEntryFilterSet(django_filters.FilterSet): + shared_resources__target__slug = django_filters.CharFilter( + method="filter_shared_resources_target_slug" + ) + shared_resources__target_content_type = django_filters.CharFilter( + method="filter_shared_resources_target_content_type", + ) class Meta: model = DataEntry - filter_fields = { + fields = { "name": ["icontains"], "description": ["icontains"], "url": ["icontains"], "entry_type": ["in"], "resource_type": ["in"], - "groups__slug": ["exact", "icontains"], - "groups__id": ["exact"], + "shared_resources__target_object_id": ["exact"], } + + def filter_shared_resources_target_slug(self, queryset, name, value): + return queryset.filter( + Q(shared_resources__target_object_id__in=Group.objects.filter(slug=value)) + | Q(shared_resources__target_object_id__in=Landscape.objects.filter(slug=value)) + ) + + def filter_shared_resources_target_content_type(self, queryset, name, value): + return queryset.filter( + shared_resources__target_content_type=ContentType.objects.get( + app_label="core", model=value + ) + ).distinct() + + +class DataEntryNode(DjangoObjectType, SharedResourcesMixin): + id = graphene.ID(source="pk", required=True) + + class Meta: + model = DataEntry fields = ( "name", "description", @@ -51,10 +82,11 @@ class Meta: "size", "created_by", "created_at", - "groups", "visualizations", + "shared_resources", ) interfaces = (relay.Node,) + filterset_class = DataEntryFilterSet connection_class = TerrasoConnection @classmethod @@ -63,7 +95,22 @@ def get_queryset(cls, queryset, info): user_groups_ids = Membership.objects.filter( user__id=user_pk, membership_status=Membership.APPROVED ).values_list("group", flat=True) - return queryset.filter(groups__in=user_groups_ids) + user_landscape_ids = Landscape.objects.filter( + associated_groups__group__memberships__user__id=user_pk, + associated_groups__group__memberships__membership_status=Membership.APPROVED, + associated_groups__is_default_landscape_group=True, + ).values_list("id", flat=True) + + return queryset.filter( + Q( + shared_resources__target_content_type=ContentType.objects.get_for_model(Group), + shared_resources__target_object_id__in=user_groups_ids, + ) + | Q( + shared_resources__target_content_type=ContentType.objects.get_for_model(Landscape), + shared_resources__target_object_id__in=user_landscape_ids, + ) + ) def resolve_url(self, info): if self.entry_type == DataEntry.ENTRY_TYPE_FILE: @@ -77,7 +124,8 @@ class DataEntryAddMutation(BaseWriteMutation): model_class = DataEntry class Input: - group_slug = graphene.String(required=True) + target_type = graphene.String(required=True) + target_slug = graphene.String(required=True) name = graphene.String(required=True) url = graphene.String(required=True) entry_type = graphene.String(required=True) @@ -85,24 +133,36 @@ class Input: description = graphene.String() @classmethod + @transaction.atomic def mutate_and_get_payload(cls, root, info, **kwargs): user = info.context.user - group_slug = kwargs.pop("group_slug") + target_type = kwargs.pop("target_type") + target_slug = kwargs.pop("target_slug") + + if target_type not in VALID_TARGET_TYPES: + logger.error("Invalid target_type provided when adding dataEntry") + raise GraphQLNotFoundException( + field="target_type", + model_name=Group.__name__, + ) + + content_type = ContentType.objects.get(app_label="core", model=target_type) + model_class = content_type.model_class() try: - group = Group.objects.get(slug=group_slug) - except Group.DoesNotExist: + target = model_class.objects.get(slug=target_slug) + except model_class.DoesNotExist: logger.error( - "Group not found when adding dataEntry", - extra={"group_slug": group_slug}, + "Target not found when adding dataEntry", + extra={"target_type": target_type, "target_slug": target_slug}, ) - raise GraphQLNotFoundException(field="group", model_name=Group.__name__) + raise GraphQLNotFoundException(field="target") - if not user.has_perm(DataEntry.get_perm("add"), obj=group.pk): + if not rules.test_rule("allowed_to_add_data_entry", user, target): logger.info( "Attempt to add a DataEntry, but user lacks permission", - extra={"user_id": user.pk, "group_id": str(group.pk)}, + extra={"user_id": user.pk, "target_id": str(target.pk)}, ) raise GraphQLNotAllowedException( model_name=DataEntry.__name__, operation=MutationTypes.CREATE @@ -116,8 +176,9 @@ def mutate_and_get_payload(cls, root, info, **kwargs): result = super().mutate_and_get_payload(root, info, **kwargs) - result.data_entry.groups.set([group]) - + result.data_entry.shared_resources.create( + target=target, + ) return cls(data_entry=result.data_entry) diff --git a/terraso_backend/apps/graphql/schema/groups.py b/terraso_backend/apps/graphql/schema/groups.py index bcbb5d748..3eadc699e 100644 --- a/terraso_backend/apps/graphql/schema/groups.py +++ b/terraso_backend/apps/graphql/schema/groups.py @@ -24,6 +24,7 @@ from .commons import BaseDeleteMutation, BaseWriteMutation, TerrasoConnection from .constants import MutationTypes +from .shared_resources_mixin import SharedResourcesMixin logger = structlog.get_logger(__name__) @@ -59,7 +60,7 @@ def filter_associated_landscapes(self, queryset, name, value): return queryset.filter(**filters).order_by("slug").distinct("slug") -class GroupNode(DjangoObjectType): +class GroupNode(DjangoObjectType, SharedResourcesMixin): id = graphene.ID(source="pk", required=True) account_membership = graphene.Field("apps.graphql.schema.memberships.MembershipNode") memberships_count = graphene.Int() diff --git a/terraso_backend/apps/graphql/schema/landscapes.py b/terraso_backend/apps/graphql/schema/landscapes.py index 5aed5e574..260553e9a 100644 --- a/terraso_backend/apps/graphql/schema/landscapes.py +++ b/terraso_backend/apps/graphql/schema/landscapes.py @@ -34,11 +34,12 @@ from .commons import BaseDeleteMutation, BaseWriteMutation, TerrasoConnection from .constants import MutationTypes from .gis import Point +from .shared_resources_mixin import SharedResourcesMixin logger = structlog.get_logger(__name__) -class LandscapeNode(DjangoObjectType): +class LandscapeNode(DjangoObjectType, SharedResourcesMixin): id = graphene.ID(source="pk", required=True) area_types = graphene.List(graphene.String) default_group = graphene.Field("apps.graphql.schema.groups.GroupNode") diff --git a/terraso_backend/apps/graphql/schema/schema.graphql b/terraso_backend/apps/graphql/schema/schema.graphql index 62716da6a..61641e3dc 100644 --- a/terraso_backend/apps/graphql/schema/schema.graphql +++ b/terraso_backend/apps/graphql/schema/schema.graphql @@ -38,12 +38,12 @@ type Query { """The ID of the object""" id: ID! ): DataEntryNode! - dataEntries(offset: Int, before: String, after: String, first: Int, last: Int, name_Icontains: String, description_Icontains: String, url_Icontains: String, entryType_In: [SharedDataDataEntryEntryTypeChoices], resourceType_In: [String], groups_Slug: String, groups_Slug_Icontains: String, groups_Id: ID): DataEntryNodeConnection + dataEntries(offset: Int, before: String, after: String, first: Int, last: Int, name_Icontains: String, description_Icontains: String, url_Icontains: String, entryType_In: [SharedDataDataEntryEntryTypeChoices], resourceType_In: [String], sharedResources_TargetObjectId: UUID, sharedResources_Target_Slug: String, sharedResources_TargetContentType: String): DataEntryNodeConnection visualizationConfig( """The ID of the object""" id: ID! ): VisualizationConfigNode! - visualizationConfigs(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, slug_Icontains: String, dataEntry_Groups_Slug: String, dataEntry_Groups_Slug_Icontains: String, dataEntry_Groups_Id: ID): VisualizationConfigNodeConnection + visualizationConfigs(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, slug_Icontains: String, dataEntry_SharedResources_TargetObjectId: UUID, dataEntry_SharedResources_Target_Slug: String, dataEntry_SharedResources_TargetContentType: String): VisualizationConfigNodeConnection taxonomyTerm( """The ID of the object""" id: ID! @@ -108,8 +108,9 @@ type GroupNode implements Node { associationsAsChild(offset: Int, before: String, after: String, first: Int, last: Int, parentGroup: ID, childGroup: ID, parentGroup_Slug_Icontains: String, childGroup_Slug_Icontains: String): GroupAssociationNodeConnection! memberships(offset: Int, before: String, after: String, first: Int, last: Int, group: ID, group_In: [ID], group_Slug_Icontains: String, group_Slug_In: [String], user: ID, user_In: [ID], userRole: CoreMembershipUserRoleChoices, user_Email_Icontains: String, user_Email_In: [String], membershipStatus: CoreMembershipMembershipStatusChoices): MembershipNodeConnection! associatedLandscapes(offset: Int, before: String, after: String, first: Int, last: Int, landscape: ID, landscape_Slug_Icontains: String, group: ID, group_Slug_Icontains: String, isDefaultLandscapeGroup: Boolean, isPartnership: Boolean): LandscapeGroupNodeConnection! - dataEntries(offset: Int, before: String, after: String, first: Int, last: Int, name_Icontains: String, description_Icontains: String, url_Icontains: String, entryType_In: [SharedDataDataEntryEntryTypeChoices], resourceType_In: [String], groups_Slug: String, groups_Slug_Icontains: String, groups_Id: ID): DataEntryNodeConnection! + dataEntries(offset: Int, before: String, after: String, first: Int, last: Int, name_Icontains: String, description_Icontains: String, url_Icontains: String, entryType_In: [SharedDataDataEntryEntryTypeChoices], resourceType_In: [String], sharedResources_TargetObjectId: UUID, sharedResources_Target_Slug: String, sharedResources_TargetContentType: String): DataEntryNodeConnection! id: ID! + sharedResources(offset: Int, before: String, after: String, first: Int, last: Int, source_DataEntry_ResourceType_In: [String]): SharedResourceNodeConnection accountMembership: MembershipNode membershipsCount: Int } @@ -295,6 +296,7 @@ type LandscapeNode implements Node { associatedDevelopmentStrategy(offset: Int, before: String, after: String, first: Int, last: Int): LandscapeDevelopmentStrategyNodeConnection! associatedGroups(offset: Int, before: String, after: String, first: Int, last: Int, landscape: ID, landscape_Slug_Icontains: String, group: ID, group_Slug_Icontains: String, isDefaultLandscapeGroup: Boolean, isPartnership: Boolean): LandscapeGroupNodeConnection! id: ID! + sharedResources(offset: Int, before: String, after: String, first: Int, last: Int, source_DataEntry_ResourceType_In: [String]): SharedResourceNodeConnection areaTypes: [String] defaultGroup: GroupNode areaScalarHa: Float @@ -404,24 +406,51 @@ type LandscapeDevelopmentStrategyNode implements Node { id: ID! } -type DataEntryNodeConnection { +type SharedResourceNodeConnection { """Pagination data for this connection.""" pageInfo: PageInfo! """Contains the nodes in this connection.""" - edges: [DataEntryNodeEdge!]! + edges: [SharedResourceNodeEdge!]! totalCount: Int! } -"""A Relay edge containing a `DataEntryNode` and its cursor.""" -type DataEntryNodeEdge { +"""A Relay edge containing a `SharedResourceNode` and its cursor.""" +type SharedResourceNodeEdge { """The item at the end of the edge""" - node: DataEntryNode! + node: SharedResourceNode! """A cursor for use in pagination""" cursor: String! } +type SharedResourceNode implements Node { + id: ID! + source: SourceNode + target: TargetNode +} + +union SourceNode = VisualizationConfigNode | DataEntryNode + +type VisualizationConfigNode implements Node { + id: ID! + createdAt: DateTime! + slug: String! + title: String! + configuration: JSONString + createdBy: UserNode + mapboxTilesetId: String + dataEntry: DataEntryNode! + owner: OwnerNode +} + +""" +The `DateTime` scalar type represents a DateTime +value as specified by +[iso8601](https://en.wikipedia.org/wiki/ISO_8601). +""" +scalar DateTime + type DataEntryNode implements Node { createdAt: DateTime! name: String! @@ -432,19 +461,12 @@ type DataEntryNode implements Node { """""" size: BigInt - groups(offset: Int, before: String, after: String, first: Int, last: Int, name: String, name_Icontains: String, name_Istartswith: String, slug: String, slug_Icontains: String, description_Icontains: String, memberships_Email: String, associatedLandscapes_IsDefaultLandscapeGroup: Boolean, associatedLandscapes_Isnull: Boolean, associatedLandscapes_IsPartnership: Boolean): GroupNodeConnection! createdBy: UserNode - visualizations(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, slug_Icontains: String, dataEntry_Groups_Slug: String, dataEntry_Groups_Slug_Icontains: String, dataEntry_Groups_Id: ID): VisualizationConfigNodeConnection! + visualizations(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, slug_Icontains: String, dataEntry_SharedResources_TargetObjectId: UUID, dataEntry_SharedResources_Target_Slug: String, dataEntry_SharedResources_TargetContentType: String): VisualizationConfigNodeConnection! id: ID! + sharedResources(offset: Int, before: String, after: String, first: Int, last: Int, source_DataEntry_ResourceType_In: [String]): SharedResourceNodeConnection } -""" -The `DateTime` scalar type represents a DateTime -value as specified by -[iso8601](https://en.wikipedia.org/wiki/ISO_8601). -""" -scalar DateTime - """An enumeration.""" enum SharedDataDataEntryEntryTypeChoices { """File""" @@ -461,52 +483,68 @@ compatible type. """ scalar BigInt -type GroupNodeConnection { +type VisualizationConfigNodeConnection { """Pagination data for this connection.""" pageInfo: PageInfo! """Contains the nodes in this connection.""" - edges: [GroupNodeEdge!]! + edges: [VisualizationConfigNodeEdge!]! totalCount: Int! } -"""A Relay edge containing a `GroupNode` and its cursor.""" -type GroupNodeEdge { +"""A Relay edge containing a `VisualizationConfigNode` and its cursor.""" +type VisualizationConfigNodeEdge { """The item at the end of the edge""" - node: GroupNode! + node: VisualizationConfigNode! """A cursor for use in pagination""" cursor: String! } -type VisualizationConfigNodeConnection { +""" +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 + +type DataEntryNodeConnection { """Pagination data for this connection.""" pageInfo: PageInfo! """Contains the nodes in this connection.""" - edges: [VisualizationConfigNodeEdge!]! + edges: [DataEntryNodeEdge!]! totalCount: Int! } -"""A Relay edge containing a `VisualizationConfigNode` and its cursor.""" -type VisualizationConfigNodeEdge { +"""A Relay edge containing a `DataEntryNode` and its cursor.""" +type DataEntryNodeEdge { """The item at the end of the edge""" - node: VisualizationConfigNode! + node: DataEntryNode! """A cursor for use in pagination""" cursor: String! } -type VisualizationConfigNode implements Node { - id: ID! - createdAt: DateTime! - slug: String! - title: String! - configuration: JSONString - createdBy: UserNode - mapboxTilesetId: String - dataEntry: DataEntryNode! - group: GroupNode! +type GroupNodeConnection { + """Pagination data for this connection.""" + pageInfo: PageInfo! + + """Contains the nodes in this connection.""" + edges: [GroupNodeEdge!]! + totalCount: Int! +} + +"""A Relay edge containing a `GroupNode` and its cursor.""" +type GroupNodeEdge { + """The item at the end of the edge""" + node: GroupNode! + + """A cursor for use in pagination""" + cursor: String! } type LandscapeNodeConnection { @@ -1549,12 +1587,6 @@ enum AuditLogsLogEventChoices { DELETE } -""" -Leverages the internal Python implementation of UUID (uuid.UUID) to provide native UUID objects -in fields, resolvers and input. -""" -scalar UUID - """ The `GenericScalar` scalar type represents a generic GraphQL scalar value that could be: @@ -1880,7 +1912,8 @@ type DataEntryAddMutationPayload { } input DataEntryAddMutationInput { - groupSlug: String! + targetType: String! + targetSlug: String! name: String! url: String! entryType: String! @@ -1923,7 +1956,8 @@ input VisualizationConfigAddMutationInput { title: String! configuration: JSONString dataEntryId: ID! - groupId: ID! + ownerId: ID! + ownerType: String! clientMutationId: String } diff --git a/terraso_backend/apps/graphql/schema/shared_resources.py b/terraso_backend/apps/graphql/schema/shared_resources.py new file mode 100644 index 000000000..844b41273 --- /dev/null +++ b/terraso_backend/apps/graphql/schema/shared_resources.py @@ -0,0 +1,51 @@ +# 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/. + +import graphene +from graphene import relay +from graphene_django import DjangoObjectType + +from apps.core.models import SharedResource + +from . import DataEntryNode, GroupNode, LandscapeNode, VisualizationConfigNode +from .commons import TerrasoConnection + + +class SourceNode(graphene.Union): + class Meta: + types = (VisualizationConfigNode, DataEntryNode) + + +class TargetNode(graphene.Union): + class Meta: + types = (GroupNode, LandscapeNode) + + +class SharedResourceNode(DjangoObjectType): + id = graphene.ID(source="pk", required=True) + source = graphene.Field(SourceNode) + target = graphene.Field(TargetNode) + + class Meta: + model = SharedResource + fields = ["id"] + interfaces = (relay.Node,) + connection_class = TerrasoConnection + + def resolve_source(self, info, **kwargs): + return self.source + + def resolve_target(self, info, **kwargs): + return self.target diff --git a/terraso_backend/apps/graphql/schema/shared_resources_mixin.py b/terraso_backend/apps/graphql/schema/shared_resources_mixin.py new file mode 100644 index 000000000..b989b5bed --- /dev/null +++ b/terraso_backend/apps/graphql/schema/shared_resources_mixin.py @@ -0,0 +1,54 @@ +# 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/. + +import django_filters +from graphene_django.filter import DjangoFilterConnectionField + +from apps.core.models import SharedResource +from apps.shared_data.models import DataEntry + + +class MultipleChoiceField(django_filters.fields.MultipleChoiceField): + def validate(self, value): + pass + + +class MultipleInputFilter(django_filters.MultipleChoiceFilter): + field_class = MultipleChoiceField + + +class SharedResourceFilterSet(django_filters.FilterSet): + source__data_entry__resource_type__in = MultipleInputFilter( + method="filter_source_data_entry", + ) + + class Meta: + model = SharedResource + fields = {} + + def filter_source_data_entry(self, queryset, name, value): + data_entry_filter = name.replace("source__data_entry__", "") + filters = {data_entry_filter: value} + return queryset.filter(source_object_id__in=DataEntry.objects.filter(**filters)) + + +class SharedResourcesMixin: + shared_resources = DjangoFilterConnectionField( + "apps.graphql.schema.shared_resources.SharedResourceNode", + filterset_class=SharedResourceFilterSet, + ) + + def resolve_shared_resources(self, info, **kwargs): + return self.shared_resources diff --git a/terraso_backend/apps/graphql/schema/visualization_config.py b/terraso_backend/apps/graphql/schema/visualization_config.py index 9eda06c6b..bfa82a8d8 100644 --- a/terraso_backend/apps/graphql/schema/visualization_config.py +++ b/terraso_backend/apps/graphql/schema/visualization_config.py @@ -13,13 +13,17 @@ # 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 django_filters import graphene import structlog +from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from django.db.models import Q from graphene import relay from graphene_django import DjangoObjectType from apps.core.gis.mapbox import get_publish_status -from apps.core.models import Group, Membership +from apps.core.models import Group, Landscape, Membership from apps.graphql.exceptions import GraphQLNotAllowedException from apps.shared_data.models.data_entries import DataEntry from apps.shared_data.models.visualization_config import VisualizationConfig @@ -29,22 +33,57 @@ ) from ..exceptions import GraphQLNotFoundException +from . import GroupNode, LandscapeNode from .commons import BaseDeleteMutation, BaseWriteMutation, TerrasoConnection from .constants import MutationTypes logger = structlog.get_logger(__name__) -class VisualizationConfigNode(DjangoObjectType): - id = graphene.ID(source="pk", required=True) +class VisualizationConfigFilterSet(django_filters.FilterSet): + data_entry__shared_resources__target__slug = django_filters.CharFilter( + method="filter_data_entry_shared_resources_target_slug" + ) + data_entry__shared_resources__target_content_type = django_filters.CharFilter( + method="filter_data_entry_shared_resources_target_content_type", + ) class Meta: model = VisualizationConfig - filter_fields = { + fields = { "slug": ["exact", "icontains"], - "data_entry__groups__slug": ["exact", "icontains"], - "data_entry__groups__id": ["exact"], + "data_entry__shared_resources__target_object_id": ["exact"], } + + def filter_data_entry_shared_resources_target_slug(self, queryset, name, value): + return queryset.filter( + Q(data_entry__shared_resources__target_object_id__in=Group.objects.filter(slug=value)) + | Q( + data_entry__shared_resources__target_object_id__in=Landscape.objects.filter( + slug=value + ) + ) + ) + + def filter_data_entry_shared_resources_target_content_type(self, queryset, name, value): + return queryset.filter( + data_entry__shared_resources__target_content_type=ContentType.objects.get( + app_label="core", model=value + ) + ).distinct() + + +class OwnerNode(graphene.Union): + class Meta: + types = (GroupNode, LandscapeNode) + + +class VisualizationConfigNode(DjangoObjectType): + id = graphene.ID(source="pk", required=True) + owner = graphene.Field(OwnerNode) + + class Meta: + model = VisualizationConfig fields = ( "id", "slug", @@ -53,18 +92,25 @@ class Meta: "created_by", "created_at", "data_entry", - "group", "mapbox_tileset_id", ) interfaces = (relay.Node,) + filterset_class = VisualizationConfigFilterSet connection_class = TerrasoConnection @classmethod def get_queryset(cls, queryset, info): + user_pk = getattr(info.context.user, "pk", False) user_groups_ids = Membership.objects.filter( - user=info.context.user, membership_status=Membership.APPROVED + user__id=user_pk, membership_status=Membership.APPROVED ).values_list("group", flat=True) - return queryset.filter(data_entry__groups__in=user_groups_ids) + user_landscape_ids = Landscape.objects.filter( + associated_groups__group__memberships__user__id=user_pk, + associated_groups__group__memberships__membership_status=Membership.APPROVED, + associated_groups__is_default_landscape_group=True, + ).values_list("id", flat=True) + all_ids = list(user_groups_ids) + list(user_landscape_ids) + return queryset.filter(data_entry__shared_resources__target_object_id__in=all_ids) def resolve_mapbox_tileset_id(self, info): if self.mapbox_tileset_id is None: @@ -89,24 +135,30 @@ class Input: title = graphene.String(required=True) configuration = graphene.JSONString() data_entry_id = graphene.ID(required=True) - group_id = graphene.ID(required=True) + ownerId = graphene.ID(required=True) + ownerType = graphene.String(required=True) @classmethod + @transaction.atomic def mutate_and_get_payload(cls, root, info, **kwargs): user = info.context.user + content_type = ContentType.objects.get(app_label="core", model=kwargs["ownerType"]) + model_class = content_type.model_class() try: - group_entry = Group.objects.get(id=kwargs["group_id"]) + owner = model_class.objects.get(id=kwargs["ownerId"]) except Group.DoesNotExist: logger.error( - "Group not found when adding a VisualizationConfig", - extra={"group_id": kwargs["group_id"]}, + "Target not found when adding a VisualizationConfig", + extra={ + "targetId": kwargs["targetId"], + "targetType": kwargs["targetType"], + }, ) - raise GraphQLNotFoundException(field="group", model_name=Group.__name__) + raise GraphQLNotFoundException(field="target") try: data_entry = DataEntry.objects.get(id=kwargs["data_entry_id"]) - except DataEntry.DoesNotExist: logger.error( "DataEntry not found when adding a VisualizationConfig", @@ -124,11 +176,12 @@ def mutate_and_get_payload(cls, root, info, **kwargs): ) kwargs["data_entry"] = data_entry - kwargs["group"] = group_entry if not cls.is_update(kwargs): kwargs["created_by"] = user + kwargs["owner"] = owner + result = super().mutate_and_get_payload(root, info, **kwargs) # Create mapbox tileset diff --git a/terraso_backend/apps/shared_data/forms.py b/terraso_backend/apps/shared_data/forms.py index d61344599..78f02a856 100644 --- a/terraso_backend/apps/shared_data/forms.py +++ b/terraso_backend/apps/shared_data/forms.py @@ -23,7 +23,6 @@ from django.core.exceptions import ValidationError from apps.core.gis.parsers import is_shape_file_zip -from apps.core.models import Group from .models import DataEntry from .services import data_entry_upload_service @@ -37,9 +36,6 @@ class DataEntryForm(forms.ModelForm): url = forms.URLField(required=False) resource_type = forms.CharField(max_length=255, required=False) size = forms.IntegerField(required=False) - groups = forms.ModelMultipleChoiceField( - required=True, to_field_name="slug", queryset=Group.objects.all() - ) class Meta: model = DataEntry @@ -51,7 +47,6 @@ class Meta: "resource_type", "size", "url", - "groups", "created_by", ) diff --git a/terraso_backend/apps/shared_data/migrations/0011_visualizationconfig_owner.py b/terraso_backend/apps/shared_data/migrations/0011_visualizationconfig_owner.py new file mode 100644 index 000000000..73d6b57fe --- /dev/null +++ b/terraso_backend/apps/shared_data/migrations/0011_visualizationconfig_owner.py @@ -0,0 +1,80 @@ +# 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/. + + +# Generated by Django 4.2.6 on 2023-10-13 22:10 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def group_to_owner(apps, schema_editor): + ContentType = apps.get_model("contenttypes", "ContentType") + VisualizationConfig = apps.get_model("shared_data", "VisualizationConfig") + LandscapeGroup = apps.get_model("core", "LandscapeGroup") + configs = VisualizationConfig.objects.all() + for config in configs: + group = config.group + landscape_group = LandscapeGroup.objects.filter( + group=group, is_default_landscape_group=True + ).first() + if landscape_group is None: + config.owner_content_type = ContentType.objects.get_for_model(config.group) + config.owner_object_id = config.group.id + else: + config.owner_content_type = ContentType.objects.get_for_model(landscape_group.landscape) + config.owner_object_id = landscape_group.landscape.id + config.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("core", "0046_shared_resource"), + ("shared_data", "0010_visualizationconfig_mapbox_tileset_id_status"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="visualizationconfig", + name="shared_data_visualizationconfig_unique_active_slug_by_group", + ), + migrations.AddField( + model_name="visualizationconfig", + name="owner_content_type", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="owner_content_type", + to="contenttypes.contenttype", + null=True, + ), + ), + migrations.AddField( + model_name="visualizationconfig", + name="owner_object_id", + field=models.UUIDField(null=True), + ), + migrations.RunPython(group_to_owner), + migrations.AddConstraint( + model_name="visualizationconfig", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("owner_object_id", "slug"), + name="shared_data_visualizationconfig_unique_active_slug_by_group", + ), + ), + ] diff --git a/terraso_backend/apps/shared_data/migrations/0012_remove_visualizationconfig_group_constraint.py b/terraso_backend/apps/shared_data/migrations/0012_remove_visualizationconfig_group_constraint.py new file mode 100644 index 000000000..d544863c5 --- /dev/null +++ b/terraso_backend/apps/shared_data/migrations/0012_remove_visualizationconfig_group_constraint.py @@ -0,0 +1,44 @@ +# 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/. + +# Generated by Django 4.2.6 on 2023-10-17 18:24 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("core", "0046_shared_resource"), + ("shared_data", "0011_visualizationconfig_owner"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="visualizationconfig", + name="shared_data_visualizationconfig_unique_active_slug_by_group", + ), + migrations.AddConstraint( + model_name="visualizationconfig", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("owner_object_id", "slug"), + name="shared_data_visualizationconfig_unique_active_slug_by_owner", + ), + ), + ] diff --git a/terraso_backend/apps/shared_data/migrations/0013_visualizationconfig_group_null.py b/terraso_backend/apps/shared_data/migrations/0013_visualizationconfig_group_null.py new file mode 100644 index 000000000..c6df819aa --- /dev/null +++ b/terraso_backend/apps/shared_data/migrations/0013_visualizationconfig_group_null.py @@ -0,0 +1,43 @@ +# 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/. + +# Generated by Django 4.2.6 on 2023-10-17 18:47 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("core", "0046_shared_resource"), + ("contenttypes", "0002_remove_content_type_name"), + ("shared_data", "0012_remove_visualizationconfig_group_constraint"), + ] + + operations = [ + migrations.AlterField( + model_name="visualizationconfig", + name="group", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="visualizations", + to="core.group", + ), + ), + ] diff --git a/terraso_backend/apps/shared_data/models/data_entries.py b/terraso_backend/apps/shared_data/models/data_entries.py index ccc742913..77c0b1a7b 100644 --- a/terraso_backend/apps/shared_data/models/data_entries.py +++ b/terraso_backend/apps/shared_data/models/data_entries.py @@ -13,15 +13,18 @@ # 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.contrib.contenttypes.fields import GenericRelation from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ from safedelete.models import SOFT_DELETE -from apps.core.models import BaseModel, Group, User +from apps.core.models import BaseModel, Group, SharedResource, User from apps.shared_data import permission_rules as perm_rules from apps.shared_data.services import DataEntryFileStorage +VALID_TARGET_TYPES = ["group", "landscape"] + class DataEntry(BaseModel): """ @@ -72,10 +75,15 @@ class DataEntry(BaseModel): url = models.URLField() size = models.PositiveBigIntegerField(null=True, blank=True) + # groups deprecated, use shared_resources instead, groups will be removed in the future groups = models.ManyToManyField(Group, related_name="data_entries") created_by = models.ForeignKey(User, null=True, on_delete=models.DO_NOTHING) file_removed_at = models.DateTimeField(blank=True, null=True) + shared_resources = GenericRelation( + SharedResource, content_type_field="source_content_type", object_id_field="source_object_id" + ) + class Meta(BaseModel.Meta): verbose_name_plural = "Data Entries" rules_permissions = { @@ -122,7 +130,9 @@ def to_dict(self): resource_type=self.resource_type, size=self.size, created_by=str(self.created_by.id), - groups=[str(group.id) for group in self.groups.all()], + shared_resources=[ + str(shared_resource.target.id) for shared_resource in self.shared_resources.all() + ], ) def __str__(self): diff --git a/terraso_backend/apps/shared_data/models/visualization_config.py b/terraso_backend/apps/shared_data/models/visualization_config.py index 85ccd4961..972bb6013 100644 --- a/terraso_backend/apps/shared_data/models/visualization_config.py +++ b/terraso_backend/apps/shared_data/models/visualization_config.py @@ -13,6 +13,8 @@ # 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.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 _ @@ -48,16 +50,24 @@ class VisualizationConfig(SlugModel): data_entry = models.ForeignKey( DataEntry, on_delete=models.CASCADE, related_name="visualizations" ) - group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name="visualizations") + # group deprecated, use owner instead, group will be removed in the future + group = models.ForeignKey( + Group, on_delete=models.CASCADE, related_name="visualizations", null=True, blank=True + ) + owner = GenericForeignKey("owner_content_type", "owner_object_id") + owner_content_type = models.ForeignKey( + ContentType, on_delete=models.CASCADE, related_name="owner_content_type" + ) + owner_object_id = models.UUIDField() field_to_slug = "title" class Meta(BaseModel.Meta): constraints = ( models.UniqueConstraint( - fields=("group_id", "slug"), + fields=("owner_object_id", "slug"), condition=models.Q(deleted_at__isnull=True), - name="shared_data_visualizationconfig_unique_active_slug_by_group", + name="shared_data_visualizationconfig_unique_active_slug_by_owner", ), ) verbose_name_plural = "Visualization Configs" diff --git a/terraso_backend/apps/shared_data/permission_rules.py b/terraso_backend/apps/shared_data/permission_rules.py index fc2e988fa..cec25cacd 100644 --- a/terraso_backend/apps/shared_data/permission_rules.py +++ b/terraso_backend/apps/shared_data/permission_rules.py @@ -15,6 +15,40 @@ import rules +from apps.core.models import Group, Landscape + + +def is_target_manager(user, target): + if isinstance(target, Group): + return user.memberships.managers_only().filter(group=target).exists() + if isinstance(target, Landscape): + return user.memberships.managers_only().filter(group=target.get_default_group()).exists() + return False + + +def is_target_member(user, target): + if isinstance(target, Group): + return user.memberships.approved_only().filter(group=target).exists() + if isinstance(target, Landscape): + return user.memberships.approved_only().filter(group=target.get_default_group()).exists() + return False + + +def is_user_allowed_to_view_data_entry(data_entry, user): + shared_resources = data_entry.shared_resources.all() + for shared_resource in shared_resources: + if is_target_member(user, shared_resource.target): + return True + return False + + +def is_user_allowed_to_change_data_entry(data_entry, user): + shared_resources = data_entry.shared_resources.all() + for shared_resource in shared_resources: + if is_target_manager(user, shared_resource.target): + return True + return False + @rules.predicate def allowed_to_change_data_entry(user, data_entry): @@ -23,34 +57,33 @@ def allowed_to_change_data_entry(user, data_entry): @rules.predicate def allowed_to_delete_data_entry(user, data_entry): - return ( - data_entry.created_by == user - or user.memberships.managers_only().filter(group__in=data_entry.groups.all()).exists() - ) + if data_entry.created_by == user: + return True + shared_resources = data_entry.shared_resources.all() + for shared_resource in shared_resources: + if is_target_manager(user, shared_resource.target): + return True + return False @rules.predicate -def allowed_to_add_data_entry(user, group): - return user.memberships.approved_only().filter(group=group).exists() +def allowed_to_add_data_entry(user, target): + return is_target_manager(user, target) @rules.predicate def allowed_to_view_data_entry(user, data_entry): - return user.memberships.approved_only().filter(group__in=data_entry.groups.all()).exists() + return is_user_allowed_to_view_data_entry(data_entry, user) @rules.predicate def allowed_to_view_visualization_config(user, visualization_config): - return ( - user.memberships.approved_only() - .filter(group__in=visualization_config.data_entry.groups.all()) - .exists() - ) + return is_user_allowed_to_view_data_entry(visualization_config.data_entry, user) @rules.predicate def allowed_to_add_visualization_config(user, data_entry): - return user.memberships.approved_only().filter(group__in=data_entry.groups.all()).exists() + return is_user_allowed_to_view_data_entry(data_entry, user) @rules.predicate @@ -60,9 +93,9 @@ def allowed_to_change_visualization_config(user, visualization_config): @rules.predicate def allowed_to_delete_visualization_config(user, visualization_config): - return ( - visualization_config.created_by == user - or user.memberships.managers_only() - .filter(group__in=visualization_config.data_entry.groups.all()) - .exists() - ) + if visualization_config.created_by == user: + return True + return is_user_allowed_to_change_data_entry(visualization_config.data_entry, user) + + +rules.add_rule("allowed_to_add_data_entry", allowed_to_add_data_entry) diff --git a/terraso_backend/apps/shared_data/views.py b/terraso_backend/apps/shared_data/views.py index d2b0a945e..035ef02c9 100644 --- a/terraso_backend/apps/shared_data/views.py +++ b/terraso_backend/apps/shared_data/views.py @@ -19,6 +19,8 @@ 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.views.generic.edit import FormView @@ -28,6 +30,7 @@ from .forms import DataEntryForm from .models import DataEntry +from .models.data_entries import VALID_TARGET_TYPES logger = structlog.get_logger(__name__) @@ -36,40 +39,74 @@ class DataEntryFileUploadView(AuthenticationRequiredMixin, FormView): + @transaction.atomic def post(self, request, **kwargs): form_data = request.POST.copy() form_data["created_by"] = str(request.user.id) form_data["entry_type"] = DataEntry.ENTRY_TYPE_FILE + target_type = form_data.pop("target_type")[0] + target_slug = form_data.pop("target_slug")[0] + if target_type not in VALID_TARGET_TYPES: + logger.error("Invalid target_type provided when adding dataEntry") + return get_json_response_error( + [ + ErrorMessage( + code="Invalid target_type provided when adding dataEntry", + context=ErrorContext(model="DataEntry", field="target_type"), + ) + ] + ) + + content_type = ContentType.objects.get(app_label="core", model=target_type) + model_class = content_type.model_class() + + try: + target = model_class.objects.get(slug=target_slug) + except Exception: + logger.error( + "Target not found when adding dataEntry", + extra={"target_type": target_type, "target_slug": target_slug}, + ) + return get_json_response_error( + [ + ErrorMessage( + code="Target not found when adding dataEntry", + context=ErrorContext(model="DataEntry", field="target_type"), + ) + ] + ) if has_multiple_files(request.FILES.getlist("data_file")): error_message = ErrorMessage( code="Uploaded more than one file", context=ErrorContext(model="DataEntry", field="data_file"), ) - return JsonResponse({"errors": [{"message": [asdict(error_message)]}]}, status=400) + return get_json_response_error([error_message]) if is_file_upload_oversized(request.FILES.getlist("data_file"), MEDIA_UPLOAD_MAX_FILE_SIZE): error_message = ErrorMessage( code="File size exceeds 10 MB", context=ErrorContext(model="DataEntry", field="data_file"), ) - return JsonResponse({"errors": [{"message": [asdict(error_message)]}]}, status=400) + return get_json_response_error([error_message]) if not is_valid_shared_data_type(request.FILES.getlist("data_file")): error_message = ErrorMessage( code="invalid_media_type", context=ErrorContext(model="Shared Data", field="context_type"), ) - return JsonResponse({"errors": [{"message": [asdict(error_message)]}]}, status=400) + return get_json_response_error([error_message]) entry_form = DataEntryForm(data=form_data, files=request.FILES) if not entry_form.is_valid(): error_messages = get_error_messages(entry_form.errors.as_data()) - return JsonResponse( - {"errors": [{"message": [asdict(e) for e in error_messages]}]}, status=400 - ) + return get_json_response_error(error_messages) data_entry = entry_form.save() + data_entry.shared_resources.create( + target=target, + ) + return JsonResponse(data_entry.to_dict(), status=201) @@ -94,3 +131,9 @@ def get_error_messages(validation_errors): ) return error_messages + + +def get_json_response_error(error_messages, status=400): + return JsonResponse( + {"errors": [{"message": [asdict(e) for e in error_messages]}]}, status=status + ) diff --git a/terraso_backend/apps/shared_data/visualization_tileset_tasks.py b/terraso_backend/apps/shared_data/visualization_tileset_tasks.py index 2f2bb0021..a3d0b55a4 100644 --- a/terraso_backend/apps/shared_data/visualization_tileset_tasks.py +++ b/terraso_backend/apps/shared_data/visualization_tileset_tasks.py @@ -58,11 +58,15 @@ def remove_mapbox_tileset(tileset_id): ) +def get_owner_name(visualization): + return visualization.owner.name if visualization.owner else "Unknown" + + def create_mapbox_tileset(visualization_id): logger.info("Creating mapbox tileset", visualization_id=visualization_id) visualization = VisualizationConfig.objects.get(pk=visualization_id) data_entry = visualization.data_entry - group_entry = visualization.group + owner_name = get_owner_name(visualization) # You cannot update a Mapbox tileset. We have to delete it and create a new one. remove_mapbox_tileset(visualization.mapbox_tileset_id) @@ -143,7 +147,7 @@ def create_mapbox_tileset(visualization_id): # Adding the environment to the title allows us to distinguish between environments # in the Mapbox studio UI. title = f"{settings.ENV} - {visualization.title}"[:64] - description = f"{settings.ENV} - {group_entry.name} - {visualization.title}" + description = f"{settings.ENV} - {owner_name} - {visualization.title}" id = str(visualization.id).replace("-", "") tileset_id = create_tileset(id, geojson, title, description) diff --git a/terraso_backend/tests/graphql/conftest.py b/terraso_backend/tests/graphql/conftest.py index 3cdbad7ee..e6db1caf2 100644 --- a/terraso_backend/tests/graphql/conftest.py +++ b/terraso_backend/tests/graphql/conftest.py @@ -30,6 +30,7 @@ Landscape, LandscapeGroup, Membership, + SharedResource, TaxonomyTerm, User, UserPreference, @@ -247,14 +248,14 @@ def data_entry_current_user_file(users, groups): creator = users[0] creator_group = groups[0] creator_group.members.add(creator) - return mixer.blend( - DataEntry, - slug=None, - created_by=creator, - size=100, - groups=creator_group, - entry_type=DataEntry.ENTRY_TYPE_FILE, + resource = mixer.blend( + SharedResource, + target=creator_group, + source=mixer.blend( + DataEntry, slug=None, created_by=creator, size=100, entry_type=DataEntry.ENTRY_TYPE_FILE + ), ) + return resource.source @pytest.fixture @@ -262,13 +263,14 @@ def data_entry_current_user_link(users, groups): creator = users[0] creator_group = groups[0] creator_group.members.add(creator) - return mixer.blend( - DataEntry, - slug=None, - created_by=creator, - groups=creator_group, - entry_type=DataEntry.ENTRY_TYPE_LINK, + resource = mixer.blend( + SharedResource, + target=creator_group, + source=mixer.blend( + DataEntry, slug=None, created_by=creator, entry_type=DataEntry.ENTRY_TYPE_LINK + ), ) + return resource.source @pytest.fixture @@ -276,15 +278,44 @@ def data_entry_other_user(users, groups): creator = users[1] creator_group = groups[1] creator_group.members.add(creator) - return mixer.blend(DataEntry, slug=None, created_by=creator, size=100, groups=creator_group) + resource = mixer.blend( + SharedResource, + target=creator_group, + source=mixer.blend(DataEntry, slug=None, created_by=creator, size=100), + ) + return resource.source @pytest.fixture -def data_entries(users, groups): +def group_data_entries(users, groups): creator = users[0] creator_group = groups[0] creator_group.members.add(creator) - return mixer.cycle(5).blend(DataEntry, created_by=creator, size=100, groups=creator_group) + resources = mixer.cycle(5).blend( + SharedResource, + target=creator_group, + source=lambda: mixer.blend(DataEntry, created_by=creator, size=100, resource_type="csv"), + ) + return [resource.source for resource in resources] + + +@pytest.fixture +def landscape_data_entries(users, landscapes, landscape_groups): + creator = users[0] + creator_landscape = landscapes[0] + resources = mixer.cycle(5).blend( + SharedResource, + target=creator_landscape, + source=lambda: mixer.blend( + DataEntry, created_by=creator, size=100, resource_type=(type for type in ("xls", "csv")) + ), + ) + return [resource.source for resource in resources] + + +@pytest.fixture +def data_entries(group_data_entries, landscape_data_entries): + return group_data_entries + landscape_data_entries @pytest.fixture @@ -314,9 +345,11 @@ def visualization_configs(users, groups): VisualizationConfig, created_by=creator, data_entry=lambda: mixer.blend( - DataEntry, created_by=creator, size=100, groups=creator_group - ), - group=groups[0], + SharedResource, + target=creator_group, + source=lambda: mixer.blend(DataEntry, created_by=creator, size=100), + ).source, + owner=creator_group, ) return visualizations diff --git a/terraso_backend/tests/graphql/mutations/test_shared_data_mutations.py b/terraso_backend/tests/graphql/mutations/test_shared_data_mutations.py index afada61f9..2716b718b 100644 --- a/terraso_backend/tests/graphql/mutations/test_shared_data_mutations.py +++ b/terraso_backend/tests/graphql/mutations/test_shared_data_mutations.py @@ -21,16 +21,22 @@ pytestmark = pytest.mark.django_db -def test_add_data_entry(client_query, managed_groups): - group = managed_groups[0] - data = { +@pytest.fixture +def input_by_parent(request, managed_groups, managed_landscapes): + parent = request.param + return { "name": "Name", "description": "Description", "url": "https://example.com", "entryType": "link", "resourceType": "link", - "groupSlug": group.slug, + "targetType": "group" if parent == "group" else "landscape", + "targetSlug": managed_groups[0].slug if parent == "group" else managed_landscapes[0].slug, } + + +@pytest.mark.parametrize("input_by_parent", ["group", "landscape"], indirect=True) +def test_add_data_entry(client_query, input_by_parent): response = client_query( """ mutation addDataEntry($input: DataEntryAddMutationInput!) { @@ -44,12 +50,12 @@ def test_add_data_entry(client_query, managed_groups): } } """, - variables={"input": data}, + variables={"input": input_by_parent}, ) result = response.json()["data"]["addDataEntry"] assert result["errors"] is None - assert result["dataEntry"]["name"] == data["name"] - assert result["dataEntry"]["url"] == data["url"] + assert result["dataEntry"]["name"] == input_by_parent["name"] + assert result["dataEntry"]["url"] == input_by_parent["url"] def test_data_entry_update_by_creator_works(client_query, data_entries): @@ -130,7 +136,8 @@ def test_data_entry_delete_by_creator_works(client_query, data_entries): variables={"input": {"id": str(old_data_entry.id)}}, ) - data_entry_result = response.json()["data"]["deleteDataEntry"]["dataEntry"] + json_response = response.json() + data_entry_result = json_response["data"]["deleteDataEntry"]["dataEntry"] assert data_entry_result["name"] == old_data_entry.name assert not DataEntry.objects.filter(name=data_entry_result["name"]) @@ -170,7 +177,7 @@ def test_data_entry_delete_by_manager_works(client_query, data_entries, users, g old_data_entry = data_entries[0] old_data_entry.created_by = users[2] old_data_entry.save() - old_data_entry.groups.first().add_manager(users[0]) + groups[0].add_manager(users[0]) response = client_query( """ @@ -179,6 +186,7 @@ def test_data_entry_delete_by_manager_works(client_query, data_entries, users, g dataEntry { name } + errors } } @@ -186,21 +194,40 @@ def test_data_entry_delete_by_manager_works(client_query, data_entries, users, g variables={"input": {"id": str(old_data_entry.id)}}, ) - data_entry_result = response.json()["data"]["deleteDataEntry"]["dataEntry"] + json_response = response.json() + data_entry_result = json_response["data"]["deleteDataEntry"]["dataEntry"] assert data_entry_result["name"] == old_data_entry.name assert not DataEntry.objects.filter(name=data_entry_result["name"]) -def test_data_entry_delete_by_manager_fails_due_to_membership_approval_status( - client_query, data_entries, users -): - old_data_entry = data_entries[0] - old_data_entry.created_by = users[2] - old_data_entry.save() - group = old_data_entry.groups.first() +@pytest.fixture +def data_entry_by_not_manager_by_owner(request, users, landscape_data_entries, group_data_entries): + owner = request.param + + (data_entry, group) = ( + (group_data_entries[0], group_data_entries[0].shared_resources.first().target) + if owner == "group" + else ( + landscape_data_entries[0], + landscape_data_entries[0].shared_resources.first().target.get_default_group(), + ) + ) + + data_entry.created_by = users[2] + data_entry.save() group.add_manager(users[0]) users[0].memberships.filter(group=group).update(membership_status=Membership.PENDING) + return data_entry + + +@pytest.mark.parametrize( + "data_entry_by_not_manager_by_owner", ["group", "landscape"], indirect=True +) +def test_data_entry_delete_by_manager_fails_due_to_membership_approval_status( + client_query, data_entry_by_not_manager_by_owner +): + old_data_entry = data_entry_by_not_manager_by_owner response = client_query( """ diff --git a/terraso_backend/tests/graphql/mutations/test_visualization_config_mutations.py b/terraso_backend/tests/graphql/mutations/test_visualization_config_mutations.py index 949cfd068..bf91219c6 100644 --- a/terraso_backend/tests/graphql/mutations/test_visualization_config_mutations.py +++ b/terraso_backend/tests/graphql/mutations/test_visualization_config_mutations.py @@ -24,15 +24,14 @@ @mock.patch("apps.graphql.schema.visualization_config.start_create_mapbox_tileset_task") -def test_visualization_config_add( - mock_create_tileset, client_query, visualization_configs, data_entries -): - group_id = str(visualization_configs[0].group.id) +def test_visualization_config_add(mock_create_tileset, client_query, groups, data_entries): + group_id = str(groups[0].id) data_entry_id = str(data_entries[0].id) new_data = { "title": "Test title", "configuration": '{"key": "value"}', - "groupId": group_id, + "ownerId": group_id, + "ownerType": "group", "dataEntryId": data_entry_id, } @@ -44,22 +43,27 @@ def test_visualization_config_add( slug title configuration - group { id } dataEntry { id } + owner { + ... on GroupNode { id } + } } + errors } } """, variables={"input": new_data}, ) - result = response.json()["data"]["addVisualizationConfig"]["visualizationConfig"] + json_response = response.json() + + result = json_response["data"]["addVisualizationConfig"]["visualizationConfig"] assert result == { "slug": "test-title", "title": "Test title", "configuration": '{"key": "value"}', - "group": {"id": group_id}, + "owner": {"id": group_id}, "dataEntry": {"id": data_entry_id}, } mock_create_tileset.assert_called_once() @@ -72,7 +76,8 @@ def test_visualization_config_add_fails_due_uniqueness_check( new_data = { "title": visualization_configs[0].title, "configuration": '{"key": "value"}', - "groupId": str(visualization_configs[0].group.id), + "ownerId": str(visualization_configs[0].owner.id), + "ownerType": "group", "dataEntryId": str(data_entries[0].id), } diff --git a/terraso_backend/tests/graphql/test_shared_data.py b/terraso_backend/tests/graphql/test_shared_data.py index d9368fb98..75e5b42e8 100644 --- a/terraso_backend/tests/graphql/test_shared_data.py +++ b/terraso_backend/tests/graphql/test_shared_data.py @@ -31,7 +31,9 @@ def test_data_entries_query(client_query, data_entries): """ ) - edges = response.json()["data"]["dataEntries"]["edges"] + json_response = response.json() + + edges = json_response["data"]["dataEntries"]["edges"] entries_result = [edge["node"]["name"] for edge in edges] for data_entry in data_entries: @@ -78,14 +80,14 @@ def test_data_entries_filter_by_group_slug_filters_successfuly(client_query, dat data_entry_a = data_entries[0] data_entry_b = data_entries[1] - data_entry_a.groups.add(groups[-1]) - data_entry_b.groups.add(groups[-1]) + data_entry_a.shared_resources.create(target=groups[-1]) + data_entry_b.shared_resources.create(target=groups[-1]) group_filter = groups[-1] response = client_query( """ - {dataEntries(groups_Slug_Icontains: "%s") { + {dataEntries(sharedResources_Target_Slug: "%s", sharedResources_TargetContentType: "%s") { edges { node { id @@ -93,10 +95,12 @@ def test_data_entries_filter_by_group_slug_filters_successfuly(client_query, dat } }} """ - % group_filter.slug + % (group_filter.slug, "group") ) - edges = response.json()["data"]["dataEntries"]["edges"] + json_response = response.json() + + edges = json_response["data"]["dataEntries"]["edges"] data_entries_result = [edge["node"]["id"] for edge in edges] assert len(data_entries_result) == 2 @@ -108,14 +112,14 @@ def test_data_entries_filter_by_group_id_filters_successfuly(client_query, data_ data_entry_a = data_entries[0] data_entry_b = data_entries[1] - data_entry_a.groups.add(groups[-1]) - data_entry_b.groups.add(groups[-1]) + data_entry_a.shared_resources.create(target=groups[-1]) + data_entry_b.shared_resources.create(target=groups[-1]) group_filter = groups[-1] response = client_query( """ - {dataEntries(groups_Id: "%s") { + {dataEntries(sharedResources_TargetObjectId: "%s") { edges { node { id @@ -206,3 +210,82 @@ def test_data_entries_anonymous_user(client_query_no_token, data_entries): entries_result = [edge["node"]["name"] for edge in edges] assert len(entries_result) == 0 + + +@pytest.fixture +def data_entries_by_parent(request, group_data_entries, landscape_data_entries): + parent = request.param + if parent == "groups": + return (parent, group_data_entries) + if parent == "landscapes": + return (parent, landscape_data_entries) + + +@pytest.mark.parametrize("data_entries_by_parent", ["groups", "landscapes"], indirect=True) +def test_data_entries_from_parent_query(client_query, data_entries_by_parent): + (parent, data_entries) = data_entries_by_parent + response = client_query( + """ + {%s { + edges { + node { + sharedResources { + edges { + node { + source { + ... on DataEntryNode { + name + } + } + } + } + } + } + } + }} + """ + % parent + ) + + json_response = response.json() + + resources = json_response["data"][parent]["edges"][0]["node"]["sharedResources"]["edges"] + entries_result = [resource["node"]["source"]["name"] for resource in resources] + + for data_entry in data_entries: + assert data_entry.name in entries_result + + +@pytest.mark.parametrize("data_entries_by_parent", ["groups", "landscapes"], indirect=True) +def test_data_entries_from_parent_query_by_resource_field(client_query, data_entries_by_parent): + (parent, data_entries) = data_entries_by_parent + response = client_query( + """ + {%s { + edges { + node { + sharedResources(source_DataEntry_ResourceType_In: ["csv", "xls"]) { + edges { + node { + source { + ... on DataEntryNode { + name + } + } + } + } + } + } + } + }} + """ + % parent + ) + + json_response = response.json() + + resources = json_response["data"][parent]["edges"][0]["node"]["sharedResources"]["edges"] + entries_result = [resource["node"]["source"]["name"] for resource in resources] + + for data_entry in data_entries: + assert data_entry.name in entries_result diff --git a/terraso_backend/tests/graphql/test_visualization_config.py b/terraso_backend/tests/graphql/test_visualization_config.py index 0807c9ab8..8612223e3 100644 --- a/terraso_backend/tests/graphql/test_visualization_config.py +++ b/terraso_backend/tests/graphql/test_visualization_config.py @@ -30,8 +30,8 @@ def test_visualization_configs_query(client_query, visualization_configs): }} """ ) - - edges = response.json()["data"]["visualizationConfigs"]["edges"] + json_response = response.json() + edges = json_response["data"]["visualizationConfigs"]["edges"] entries_result = [edge["node"]["configuration"] for edge in edges] for visualization_config in visualization_configs: @@ -80,22 +80,29 @@ def test_visualization_configs_filter_by_group_slug_filters_successfuly( visualization_config_a = visualization_configs[0] visualization_config_b = visualization_configs[1] - visualization_config_a.data_entry.groups.add(groups[-1]) - visualization_config_b.data_entry.groups.add(groups[-1]) + visualization_config_a.data_entry.shared_resources.create(target=groups[-1]) + visualization_config_b.data_entry.shared_resources.create(target=groups[-1]) group_filter = groups[-1] response = client_query( """ - {visualizationConfigs(dataEntry_Groups_Slug_Icontains: "%s") { + {visualizationConfigs( + dataEntry_SharedResources_Target_Slug: "%s", + dataEntry_SharedResources_TargetContentType: "%s" + ) { edges { node { id dataEntry { - groups { + sharedResources { edges { node { - slug + target { + ... on GroupNode { + slug + } + } } } } @@ -104,13 +111,16 @@ def test_visualization_configs_filter_by_group_slug_filters_successfuly( } }} """ - % group_filter.slug + % (group_filter.slug, "group") ) - - edges = response.json()["data"]["visualizationConfigs"]["edges"] + json_response = response.json() + edges = json_response["data"]["visualizationConfigs"]["edges"] visualization_configs_result = [edge["node"]["id"] for edge in edges] - assert edges[0]["node"]["dataEntry"]["groups"]["edges"][1]["node"]["slug"] == group_filter.slug + assert ( + edges[0]["node"]["dataEntry"]["sharedResources"]["edges"][1]["node"]["target"]["slug"] + == group_filter.slug + ) assert len(visualization_configs_result) == 2 assert str(visualization_config_a.id) in visualization_configs_result @@ -123,14 +133,14 @@ def test_visualization_configs_filter_by_group_id_filters_successfuly( visualization_config_a = visualization_configs[0] visualization_config_b = visualization_configs[1] - visualization_config_a.data_entry.groups.add(groups[-1]) - visualization_config_b.data_entry.groups.add(groups[-1]) + visualization_config_a.data_entry.shared_resources.create(target=groups[-1]) + visualization_config_b.data_entry.shared_resources.create(target=groups[-1]) group_filter = groups[-1] response = client_query( """ - {visualizationConfigs(dataEntry_Groups_Id: "%s") { + {visualizationConfigs(dataEntry_SharedResources_TargetObjectId: "%s") { edges { node { id diff --git a/terraso_backend/tests/shared_data/conftest.py b/terraso_backend/tests/shared_data/conftest.py index addc93133..407daf294 100644 --- a/terraso_backend/tests/shared_data/conftest.py +++ b/terraso_backend/tests/shared_data/conftest.py @@ -17,7 +17,7 @@ from django.conf import settings from mixer.backend.django import mixer -from apps.core.models import Group, User +from apps.core.models import Group, Landscape, User from apps.shared_data.models import DataEntry, VisualizationConfig @@ -36,6 +36,11 @@ def group(): return mixer.blend(Group) +@pytest.fixture +def landscape(): + return mixer.blend(Landscape) + + @pytest.fixture def data_entry_filename(): return "test_data.csv" diff --git a/terraso_backend/tests/shared_data/test_models.py b/terraso_backend/tests/shared_data/test_models.py index e90afd035..eb04d02cc 100644 --- a/terraso_backend/tests/shared_data/test_models.py +++ b/terraso_backend/tests/shared_data/test_models.py @@ -52,7 +52,7 @@ def test_data_entry_can_be_deleted_by_its_creator(user, data_entry): def test_data_entry_can_be_deleted_by_group_manager(user_b, group, data_entry): group.add_manager(user_b) - data_entry.groups.add(group) + data_entry.shared_resources.create(target=group) assert user_b.has_perm(DataEntry.get_perm("delete"), obj=data_entry) @@ -63,14 +63,14 @@ def test_data_entry_cannot_be_deleted_by_non_creator_or_manager(user, user_b, da def test_data_entry_can_be_viewed_by_group_members(user, user_b, group, data_entry): group.members.add(user, user_b) - data_entry.groups.add(group) + data_entry.shared_resources.create(target=group) assert user_b.has_perm(DataEntry.get_perm("view"), obj=data_entry) def test_data_entry_cannot_be_viewed_by_non_group_members(user, user_b, group, data_entry): group.members.add(user) - data_entry.groups.add(group) + data_entry.shared_resources.create(target=group) assert not user_b.has_perm(DataEntry.get_perm("view"), obj=data_entry) @@ -87,7 +87,7 @@ def test_visualization_config_cannot_be_updated_by_group_manager( user_b, group, visualization_config ): group.add_manager(user_b) - visualization_config.data_entry.groups.add(group) + visualization_config.data_entry.shared_resources.create(target=group) assert not user_b.has_perm(VisualizationConfig.get_perm("change"), obj=visualization_config) @@ -102,7 +102,7 @@ def test_visualization_config_cannot_be_deleted_by_non_creator(user, visualizati def test_visualization_config_can_be_deleted_by_group_manager(user_b, group, visualization_config): group.add_manager(user_b) - visualization_config.data_entry.groups.add(group) + visualization_config.data_entry.shared_resources.create(target=group) assert user_b.has_perm(VisualizationConfig.get_perm("delete"), obj=visualization_config) @@ -111,7 +111,7 @@ def test_visualization_config_can_be_viewed_by_group_members( user, user_b, group, visualization_config ): group.members.add(user, user_b) - visualization_config.data_entry.groups.add(group) + visualization_config.data_entry.shared_resources.create(target=group) assert user_b.has_perm(VisualizationConfig.get_perm("view"), obj=visualization_config) assert user.has_perm(VisualizationConfig.get_perm("view"), obj=visualization_config) @@ -121,7 +121,7 @@ def test_visualization_config_cannot_be_viewed_by_non_group_members( user, user_b, group, visualization_config ): group.members.add(user) - visualization_config.data_entry.groups.add(group) + visualization_config.data_entry.shared_resources.create(target=group) assert not user_b.has_perm(VisualizationConfig.get_perm("view"), obj=visualization_config) assert user.has_perm(VisualizationConfig.get_perm("view"), obj=visualization_config) diff --git a/terraso_backend/tests/shared_data/test_views.py b/terraso_backend/tests/shared_data/test_views.py index 185dff144..779baf03c 100644 --- a/terraso_backend/tests/shared_data/test_views.py +++ b/terraso_backend/tests/shared_data/test_views.py @@ -30,19 +30,22 @@ def upload_url(): @pytest.fixture -def data_entry_payload(group): +def data_entry_payload(request, group, landscape): + type = request.param return dict( name="Testing Data File", description="This is the description of the testing data file", - groups=[group.slug], data_file=SimpleUploadedFile( name="data_file.json", content=json.dumps({"key": "value", "keyN": "valueN"}).encode(), content_type="application/json", ), + target_type=type, + target_slug=group.slug if type == "group" else landscape.slug, ) +@pytest.mark.parametrize("data_entry_payload", ["group", "landscape"], indirect=True) @mock.patch("apps.storage.file_utils.get_file_size") def test_create_oversized_data_entry(mock_get_size, logged_client, upload_url, data_entry_payload): mock_get_size.return_value = 10000001 @@ -62,25 +65,33 @@ def test_create_oversized_data_entry(mock_get_size, logged_client, upload_url, d assert "errors" in response_data -def test_create_data_entry_successfully(logged_client, upload_url, data_entry_payload): +@pytest.mark.parametrize("data_entry_payload", ["group", "landscape"], indirect=True) +def test_create_data_entry_successfully( + logged_client, upload_url, data_entry_payload, landscape, group +): with patch( "apps.shared_data.forms.data_entry_upload_service.upload_file" ) as mocked_upload_service: mocked_upload_service.return_value = "https://example.org/uploaded_file.json" response = logged_client.post(upload_url, data_entry_payload) + response_data = response.json() + print(response_data) + assert response.status_code == 201 mocked_upload_service.assert_called_once() - assert response.status_code == 201 - - response_data = response.json() - assert "id" in response_data assert "url" in response_data assert response_data["size"] + assert len(response_data["shared_resources"]) == 1 + if "landscape" == data_entry_payload["target_type"]: + assert str(landscape.id) in response_data["shared_resources"] + if "group" == data_entry_payload["target_type"]: + assert str(group.id) in response_data["shared_resources"] +@pytest.mark.parametrize("data_entry_payload", ["group", "landscape"], indirect=True) def test_create_data_entry_file_type_different_from_extension( logged_client, upload_url, data_entry_payload ): @@ -107,6 +118,7 @@ def test_create_data_entry_file_type_different_from_extension( assert "errors" in response_data +@pytest.mark.parametrize("data_entry_payload", ["group", "landscape"], indirect=True) def test_create_data_entry_file_type_csv(logged_client, upload_url, data_entry_payload): data_entry_payload["data_file"] = ( SimpleUploadedFile( @@ -131,6 +143,7 @@ def test_create_data_entry_file_type_csv(logged_client, upload_url, data_entry_p assert response_data["size"] +@pytest.mark.parametrize("data_entry_payload", ["group", "landscape"], indirect=True) def test_create_data_entry_file_invalid_type(logged_client, upload_url, data_entry_payload): data_entry_payload["data_file"] = ( SimpleUploadedFile(