From f640d5c26b0bb35805886106c19304ae2679266d Mon Sep 17 00:00:00 2001 From: Jose Buitron Date: Thu, 12 Oct 2023 12:09:52 -0500 Subject: [PATCH 01/11] fix: Link shared data directly to landscapes --- .../apps/graphql/schema/data_entries.py | 32 +++++++++-- .../apps/graphql/schema/landscapes.py | 1 + .../apps/graphql/schema/schema.graphql | 43 ++++++++------- terraso_backend/apps/shared_data/forms.py | 16 +++++- .../apps/shared_data/models/data_entries.py | 14 ++++- .../models/visualization_config.py | 9 +++- .../apps/shared_data/permission_rules.py | 2 +- .../visualization_tileset_tasks.py | 12 ++++- terraso_backend/tests/graphql/conftest.py | 16 +++++- .../mutations/test_shared_data_mutations.py | 54 ++++++++++++++----- .../tests/graphql/test_shared_data.py | 44 ++++++++++++++- terraso_backend/tests/shared_data/conftest.py | 7 ++- .../tests/shared_data/test_views.py | 22 +++++++- 13 files changed, 220 insertions(+), 52 deletions(-) diff --git a/terraso_backend/apps/graphql/schema/data_entries.py b/terraso_backend/apps/graphql/schema/data_entries.py index 8304c7d04..4ba41e5e7 100644 --- a/terraso_backend/apps/graphql/schema/data_entries.py +++ b/terraso_backend/apps/graphql/schema/data_entries.py @@ -15,10 +15,11 @@ import graphene import structlog +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 @@ -52,6 +53,7 @@ class Meta: "created_by", "created_at", "groups", + "landscapes", "visualizations", ) interfaces = (relay.Node,) @@ -63,7 +65,14 @@ 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__is_default_landscape_group=True, + ).values_list("id", flat=True) + + return queryset.filter( + Q(groups__in=user_groups_ids) | Q(landscapes__id__in=user_landscape_ids) + ) def resolve_url(self, info): if self.entry_type == DataEntry.ENTRY_TYPE_FILE: @@ -77,7 +86,8 @@ class DataEntryAddMutation(BaseWriteMutation): model_class = DataEntry class Input: - group_slug = graphene.String(required=True) + group_slug = graphene.String() + landscape_slug = graphene.String() name = graphene.String(required=True) url = graphene.String(required=True) entry_type = graphene.String(required=True) @@ -88,10 +98,22 @@ class Input: def mutate_and_get_payload(cls, root, info, **kwargs): user = info.context.user - group_slug = kwargs.pop("group_slug") + group_slug = kwargs.pop("group_slug") if "group_slug" in kwargs else None + landscape_slug = kwargs.pop("landscape_slug") if "landscape_slug" in kwargs else None + + if not group_slug and not landscape_slug: + logger.error("Neither group_slug nor landscape_slug provided when adding dataEntry") + raise GraphQLNotFoundException( + field="group_slug or landscape_slug", + model_name=Group.__name__, + ) try: - group = Group.objects.get(slug=group_slug) + group = ( + Group.objects.get(slug=group_slug) + if group_slug + else Landscape.objects.get(slug=landscape_slug).get_default_group() + ) except Group.DoesNotExist: logger.error( "Group not found when adding dataEntry", diff --git a/terraso_backend/apps/graphql/schema/landscapes.py b/terraso_backend/apps/graphql/schema/landscapes.py index 5aed5e574..a828a0973 100644 --- a/terraso_backend/apps/graphql/schema/landscapes.py +++ b/terraso_backend/apps/graphql/schema/landscapes.py @@ -71,6 +71,7 @@ class Meta: "profile_image", "profile_image_description", "center_coordinates", + "data_entries", ) interfaces = (relay.Node,) diff --git a/terraso_backend/apps/graphql/schema/schema.graphql b/terraso_backend/apps/graphql/schema/schema.graphql index 62716da6a..437fea381 100644 --- a/terraso_backend/apps/graphql/schema/schema.graphql +++ b/terraso_backend/apps/graphql/schema/schema.graphql @@ -294,6 +294,7 @@ type LandscapeNode implements Node { centerCoordinates: Point 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! + 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! id: ID! areaTypes: [String] defaultGroup: GroupNode @@ -433,6 +434,7 @@ 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! + landscapes(offset: Int, before: String, after: String, first: Int, last: Int, name_Icontains: String, description_Icontains: String, slug: String, slug_Icontains: String, website_Icontains: String, location_Icontains: String): LandscapeNodeConnection! 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! id: ID! @@ -479,6 +481,24 @@ type GroupNodeEdge { cursor: String! } +type LandscapeNodeConnection { + """Pagination data for this connection.""" + pageInfo: PageInfo! + + """Contains the nodes in this connection.""" + edges: [LandscapeNodeEdge!]! + totalCount: Int! +} + +"""A Relay edge containing a `LandscapeNode` and its cursor.""" +type LandscapeNodeEdge { + """The item at the end of the edge""" + node: LandscapeNode! + + """A cursor for use in pagination""" + cursor: String! +} + type VisualizationConfigNodeConnection { """Pagination data for this connection.""" pageInfo: PageInfo! @@ -506,25 +526,7 @@ type VisualizationConfigNode implements Node { createdBy: UserNode mapboxTilesetId: String dataEntry: DataEntryNode! - group: GroupNode! -} - -type LandscapeNodeConnection { - """Pagination data for this connection.""" - pageInfo: PageInfo! - - """Contains the nodes in this connection.""" - edges: [LandscapeNodeEdge!]! - totalCount: Int! -} - -"""A Relay edge containing a `LandscapeNode` and its cursor.""" -type LandscapeNodeEdge { - """The item at the end of the edge""" - node: LandscapeNode! - - """A cursor for use in pagination""" - cursor: String! + group: GroupNode } type UserNodeConnection { @@ -1880,7 +1882,8 @@ type DataEntryAddMutationPayload { } input DataEntryAddMutationInput { - groupSlug: String! + groupSlug: String + landscapeSlug: String name: String! url: String! entryType: String! diff --git a/terraso_backend/apps/shared_data/forms.py b/terraso_backend/apps/shared_data/forms.py index d61344599..1ecf9edc6 100644 --- a/terraso_backend/apps/shared_data/forms.py +++ b/terraso_backend/apps/shared_data/forms.py @@ -23,7 +23,7 @@ from django.core.exceptions import ValidationError from apps.core.gis.parsers import is_shape_file_zip -from apps.core.models import Group +from apps.core.models import Group, Landscape from .models import DataEntry from .services import data_entry_upload_service @@ -38,7 +38,10 @@ class DataEntryForm(forms.ModelForm): 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() + required=False, to_field_name="slug", queryset=Group.objects.all() + ) + landscapes = forms.ModelMultipleChoiceField( + required=False, to_field_name="slug", queryset=Landscape.objects.all() ) class Meta: @@ -52,6 +55,7 @@ class Meta: "size", "url", "groups", + "landscapes", "created_by", ) @@ -88,6 +92,14 @@ def clean_data_file(self): def clean(self): data = self.cleaned_data data_file = data.get("data_file") + groups = data.get("groups") + landscapes = data.get("landscapes") + + # Check if either groups or landscapes are provided + if not groups and not landscapes: + raise ValidationError( + "Either groups or landscapes are required", code="invalid_groups_landscapes" + ) if data_file: file_extension = pathlib.Path(data_file.name).suffix diff --git a/terraso_backend/apps/shared_data/models/data_entries.py b/terraso_backend/apps/shared_data/models/data_entries.py index ccc742913..689fa0604 100644 --- a/terraso_backend/apps/shared_data/models/data_entries.py +++ b/terraso_backend/apps/shared_data/models/data_entries.py @@ -18,7 +18,7 @@ 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, Landscape, User from apps.shared_data import permission_rules as perm_rules from apps.shared_data.services import DataEntryFileStorage @@ -73,6 +73,7 @@ class DataEntry(BaseModel): size = models.PositiveBigIntegerField(null=True, blank=True) groups = models.ManyToManyField(Group, related_name="data_entries") + landscapes = models.ManyToManyField(Landscape, 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) @@ -98,6 +99,16 @@ def signed_url(self): storage = DataEntryFileStorage(custom_domain=None) return storage.url(self.s3_object_name) + def is_user_allowed_to_view(self, user): + groups_ids = [group.id for group in self.groups.all()] + landscape_default_groups_ids = [ + landscape.get_default_group().id for landscape in self.landscapes.all() + ] + user_groups_ids = user.memberships.approved_only().values_list("group", flat=True) + return any( + [group_id in user_groups_ids for group_id in groups_ids + landscape_default_groups_ids] + ) + def delete_file_on_storage(self): if not self.deleted_at: raise RuntimeError( @@ -123,6 +134,7 @@ def to_dict(self): size=self.size, created_by=str(self.created_by.id), groups=[str(group.id) for group in self.groups.all()], + landscapes=[str(landscape.id) for landscape in self.landscapes.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..d12bb8ff3 100644 --- a/terraso_backend/apps/shared_data/models/visualization_config.py +++ b/terraso_backend/apps/shared_data/models/visualization_config.py @@ -16,7 +16,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ -from apps.core.models import BaseModel, Group, SlugModel, User +from apps.core.models import BaseModel, Group, Landscape, SlugModel, User from apps.core.models.commons import validate_name from apps.shared_data import permission_rules as perm_rules @@ -48,7 +48,12 @@ 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 = models.ForeignKey( + Group, on_delete=models.CASCADE, related_name="visualizations", null=True, blank=True + ) + landscape = models.ForeignKey( + Landscape, on_delete=models.CASCADE, related_name="visualizations", null=True, blank=True + ) field_to_slug = "title" diff --git a/terraso_backend/apps/shared_data/permission_rules.py b/terraso_backend/apps/shared_data/permission_rules.py index fc2e988fa..f1becd5e1 100644 --- a/terraso_backend/apps/shared_data/permission_rules.py +++ b/terraso_backend/apps/shared_data/permission_rules.py @@ -50,7 +50,7 @@ def allowed_to_view_visualization_config(user, visualization_config): @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 data_entry.is_user_allowed_to_view(user) @rules.predicate diff --git a/terraso_backend/apps/shared_data/visualization_tileset_tasks.py b/terraso_backend/apps/shared_data/visualization_tileset_tasks.py index 2f2bb0021..664869805 100644 --- a/terraso_backend/apps/shared_data/visualization_tileset_tasks.py +++ b/terraso_backend/apps/shared_data/visualization_tileset_tasks.py @@ -58,11 +58,19 @@ def remove_mapbox_tileset(tileset_id): ) +def get_owner_name(visualization): + if visualization.group: + return visualization.group.name + if visualization.landscape: + return visualization.landscape.name + return "" + + 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 +151,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..84104d469 100644 --- a/terraso_backend/tests/graphql/conftest.py +++ b/terraso_backend/tests/graphql/conftest.py @@ -280,13 +280,27 @@ def data_entry_other_user(users, groups): @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) +@pytest.fixture +def landscape_data_entries(users, landscapes, landscape_groups): + creator = users[0] + creator_landscape = landscapes[0] + return mixer.cycle(5).blend( + DataEntry, created_by=creator, size=100, landscapes=creator_landscape + ) + + +@pytest.fixture +def data_entries(group_data_entries, landscape_data_entries): + return group_data_entries + landscape_data_entries + + @pytest.fixture def visualization_config_current_user(users, data_entry_current_user_file, groups): creator = users[0] 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..03e7fa9a5 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,24 @@ pytestmark = pytest.mark.django_db -def test_add_data_entry(client_query, managed_groups): - group = managed_groups[0] - data = { +@pytest.fixture +def input_by_owner(request, managed_groups, managed_landscapes): + owner = request.param + base_input = { "name": "Name", "description": "Description", "url": "https://example.com", "entryType": "link", "resourceType": "link", - "groupSlug": group.slug, } + if owner == "group": + return {**base_input, "groupSlug": managed_groups[0].slug} + if owner == "landscape": + return {**base_input, "landscapeSlug": managed_landscapes[0].slug} + + +@pytest.mark.parametrize("input_by_owner", ["group", "landscape"], indirect=True) +def test_add_data_entry(client_query, input_by_owner): response = client_query( """ mutation addDataEntry($input: DataEntryAddMutationInput!) { @@ -44,12 +52,12 @@ def test_add_data_entry(client_query, managed_groups): } } """, - variables={"input": data}, + variables={"input": input_by_owner}, ) 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_owner["name"] + assert result["dataEntry"]["url"] == input_by_owner["url"] def test_data_entry_update_by_creator_works(client_query, data_entries): @@ -192,15 +200,33 @@ def test_data_entry_delete_by_manager_works(client_query, data_entries, users, g 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].groups.first()) + if owner == "group" + else ( + landscape_data_entries[0], + landscape_data_entries[0].landscapes.first().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/test_shared_data.py b/terraso_backend/tests/graphql/test_shared_data.py index d9368fb98..d12acb24c 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: @@ -206,3 +208,43 @@ 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_owner(request, group_data_entries, landscape_data_entries): + owner = request.param + if owner == "groups": + return (owner, group_data_entries) + if owner == "landscapes": + return (owner, landscape_data_entries) + + +@pytest.mark.parametrize("data_entries_by_owner", ["groups", "landscapes"], indirect=True) +def test_data_entries_from_owner_query(client_query, data_entries_by_owner): + (owner, data_entries) = data_entries_by_owner + response = client_query( + """ + {%s { + edges { + node { + dataEntries { + edges { + node { + name + } + } + } + } + } + }} + """ + % owner + ) + + json_response = response.json() + + edges = json_response["data"][owner]["edges"][0]["node"]["dataEntries"]["edges"] + entries_result = [edge["node"]["name"] for edge in edges] + + for data_entry in data_entries: + assert data_entry.name in entries_result 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_views.py b/terraso_backend/tests/shared_data/test_views.py index 185dff144..5d2c8720f 100644 --- a/terraso_backend/tests/shared_data/test_views.py +++ b/terraso_backend/tests/shared_data/test_views.py @@ -30,19 +30,27 @@ 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", ), + **( + {"groups": [group.slug]} + if type == "group" + else {"landscapes": [landscape.slug]} + if type == "landscape" + else {} + ), ) +@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,6 +70,7 @@ def test_create_oversized_data_entry(mock_get_size, logged_client, upload_url, d assert "errors" in response_data +@pytest.mark.parametrize("data_entry_payload", ["group", "landscape"], indirect=True) def test_create_data_entry_successfully(logged_client, upload_url, data_entry_payload): with patch( "apps.shared_data.forms.data_entry_upload_service.upload_file" @@ -79,8 +88,15 @@ def test_create_data_entry_successfully(logged_client, upload_url, data_entry_pa assert "id" in response_data assert "url" in response_data assert response_data["size"] + if "landscape" in data_entry_payload: + assert "landscapes" in response_data + assert "groups" not in response_data + if "group" in data_entry_payload: + assert "groups" in response_data + assert "landscapes" not in response_data +@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 +123,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 +148,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( From 81dda6cd9346445009a3ca0f47db692a8cfa70a9 Mon Sep 17 00:00:00 2001 From: Jose Buitron Date: Fri, 13 Oct 2023 17:09:04 -0500 Subject: [PATCH 02/11] refactor: Added generic share resources entity --- .../apps/collaboration/graphql/memberships.py | 3 - terraso_backend/apps/core/models/__init__.py | 2 + terraso_backend/apps/core/models/groups.py | 6 + .../apps/core/models/landscapes.py | 6 + .../apps/core/models/shared_resources.py | 33 +++++ .../apps/graphql/schema/data_entries.py | 94 +++++++++---- terraso_backend/apps/graphql/schema/groups.py | 4 + .../apps/graphql/schema/landscapes.py | 5 +- .../apps/graphql/schema/schema.graphql | 124 ++++++++++-------- .../apps/graphql/schema/shared_resources.py | 58 ++++++++ .../graphql/schema/visualization_config.py | 84 +++++++++--- .../apps/shared_data/models/data_entries.py | 23 ++-- .../models/visualization_config.py | 12 +- .../apps/shared_data/permission_rules.py | 69 +++++++--- .../visualization_tileset_tasks.py | 6 +- terraso_backend/tests/graphql/conftest.py | 56 ++++---- .../mutations/test_shared_data_mutations.py | 35 ++--- .../test_visualization_config_mutations.py | 23 ++-- .../tests/graphql/test_shared_data.py | 48 +++---- .../graphql/test_visualization_config.py | 33 ++--- .../tests/shared_data/test_models.py | 14 +- 21 files changed, 502 insertions(+), 236 deletions(-) create mode 100644 terraso_backend/apps/core/models/shared_resources.py create mode 100644 terraso_backend/apps/graphql/schema/shared_resources.py 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/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..7d651fe49 --- /dev/null +++ b/terraso_backend/apps/core/models/shared_resources.py @@ -0,0 +1,33 @@ +# 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): + 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 4ba41e5e7..53044ec36 100644 --- a/terraso_backend/apps/graphql/schema/data_entries.py +++ b/terraso_backend/apps/graphql/schema/data_entries.py @@ -13,8 +13,12 @@ # 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 @@ -29,20 +33,45 @@ logger = structlog.get_logger(__name__) -class DataEntryNode(DjangoObjectType): - id = graphene.ID(source="pk", required=True) +class DataEntryFilterSet(django_filters.FilterSet): + shared_targets__target__slug = django_filters.CharFilter( + method="filter_shared_targets_target_slug" + ) + shared_targets__target_content_type = django_filters.CharFilter( + method="filter_shared_targets_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_targets__target_object_id": ["exact"], } + + def filter_shared_targets_target_slug(self, queryset, name, value): + return queryset.filter( + Q(shared_targets__target_object_id__in=Group.objects.filter(slug=value)) + | Q(shared_targets__target_object_id__in=Landscape.objects.filter(slug=value)) + ) + + def filter_shared_targets_target_content_type(self, queryset, name, value): + return queryset.filter( + shared_targets__target_content_type=ContentType.objects.get( + app_label="core", model=value + ) + ).distinct() + + +class DataEntryNode(DjangoObjectType): + id = graphene.ID(source="pk", required=True) + shared_targets = graphene.List("apps.graphql.schema.shared_resources.SharedResourceNode") + + class Meta: + model = DataEntry fields = ( "name", "description", @@ -55,8 +84,10 @@ class Meta: "groups", "landscapes", "visualizations", + "shared_targets", ) interfaces = (relay.Node,) + filterset_class = DataEntryFilterSet connection_class = TerrasoConnection @classmethod @@ -71,7 +102,14 @@ def get_queryset(cls, queryset, info): ).values_list("id", flat=True) return queryset.filter( - Q(groups__in=user_groups_ids) | Q(landscapes__id__in=user_landscape_ids) + Q( + shared_targets__target_content_type=ContentType.objects.get_for_model(Group), + shared_targets__target_object_id__in=user_groups_ids, + ) + | Q( + shared_targets__target_content_type=ContentType.objects.get_for_model(Landscape), + shared_targets__target_object_id__in=user_landscape_ids, + ) ) def resolve_url(self, info): @@ -79,6 +117,9 @@ def resolve_url(self, info): return self.signed_url return self.url + def resolve_shared_targets(self, info, **kwargs): + return self.shared_targets.all() + class DataEntryAddMutation(BaseWriteMutation): data_entry = graphene.Field(DataEntryNode) @@ -86,8 +127,8 @@ class DataEntryAddMutation(BaseWriteMutation): model_class = DataEntry class Input: - group_slug = graphene.String() - landscape_slug = graphene.String() + 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) @@ -95,36 +136,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") if "group_slug" in kwargs else None - landscape_slug = kwargs.pop("landscape_slug") if "landscape_slug" in kwargs else None + target_type = kwargs.pop("target_type") + target_slug = kwargs.pop("target_slug") - if not group_slug and not landscape_slug: - logger.error("Neither group_slug nor landscape_slug provided when adding dataEntry") + if target_type not in ["group", "landscape"]: + logger.error("Invalid target_type provided when adding dataEntry") raise GraphQLNotFoundException( - field="group_slug or landscape_slug", + 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) - if group_slug - else Landscape.objects.get(slug=landscape_slug).get_default_group() - ) - except Group.DoesNotExist: + target = model_class.objects.get(slug=target_slug) + except Exception: 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 @@ -138,8 +179,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_targets.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..5a0a21b8e 100644 --- a/terraso_backend/apps/graphql/schema/groups.py +++ b/terraso_backend/apps/graphql/schema/groups.py @@ -63,6 +63,7 @@ class GroupNode(DjangoObjectType): id = graphene.ID(source="pk", required=True) account_membership = graphene.Field("apps.graphql.schema.memberships.MembershipNode") memberships_count = graphene.Int() + shared_resources = graphene.List("apps.graphql.schema.shared_resources.SharedResourceNode") class Meta: model = Group @@ -108,6 +109,9 @@ def resolve_memberships_count(self, info): return self.memberships.approved_only().count() + def resolve_shared_resources(self, info, **kwargs): + return self.shared_resources.all() + class GroupAddMutation(BaseWriteMutation): group = graphene.Field(GroupNode) diff --git a/terraso_backend/apps/graphql/schema/landscapes.py b/terraso_backend/apps/graphql/schema/landscapes.py index a828a0973..7f6a5d149 100644 --- a/terraso_backend/apps/graphql/schema/landscapes.py +++ b/terraso_backend/apps/graphql/schema/landscapes.py @@ -43,6 +43,7 @@ class LandscapeNode(DjangoObjectType): area_types = graphene.List(graphene.String) default_group = graphene.Field("apps.graphql.schema.groups.GroupNode") center_coordinates = graphene.Field(Point) + shared_resources = graphene.List("apps.graphql.schema.shared_resources.SharedResourceNode") class Meta: model = Landscape @@ -71,7 +72,6 @@ class Meta: "profile_image", "profile_image_description", "center_coordinates", - "data_entries", ) interfaces = (relay.Node,) @@ -139,6 +139,9 @@ def resolve_default_group(self, info): return None return self.get_default_group() + def resolve_shared_resources(self, info, **kwargs): + return self.shared_resources.all() + class LandscapeDevelopmentStrategyNode(DjangoObjectType): id = graphene.ID(source="pk", required=True) diff --git a/terraso_backend/apps/graphql/schema/schema.graphql b/terraso_backend/apps/graphql/schema/schema.graphql index 437fea381..25e61703e 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], sharedTargets_TargetObjectId: UUID, sharedTargets_Target_Slug: String, sharedTargets_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_SharedTargets_TargetObjectId: UUID, dataEntry_SharedTargets_Target_Slug: String, dataEntry_SharedTargets_TargetContentType: String): VisualizationConfigNodeConnection taxonomyTerm( """The ID of the object""" id: ID! @@ -108,10 +108,11 @@ 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], sharedTargets_TargetObjectId: UUID, sharedTargets_Target_Slug: String, sharedTargets_TargetContentType: String): DataEntryNodeConnection! id: ID! accountMembership: MembershipNode membershipsCount: Int + sharedResources: [SharedResourceNode] } """An object with an ID""" @@ -294,10 +295,10 @@ type LandscapeNode implements Node { centerCoordinates: Point 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! - 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! id: ID! areaTypes: [String] defaultGroup: GroupNode + sharedResources: [SharedResourceNode] areaScalarHa: Float } @@ -405,24 +406,33 @@ type LandscapeDevelopmentStrategyNode implements Node { id: ID! } -type DataEntryNodeConnection { - """Pagination data for this connection.""" - pageInfo: PageInfo! - - """Contains the nodes in this connection.""" - edges: [DataEntryNodeEdge!]! - totalCount: Int! +type SharedResourceNode implements Node { + id: ID! + source: SourceNode + target: TargetNode } -"""A Relay edge containing a `DataEntryNode` and its cursor.""" -type DataEntryNodeEdge { - """The item at the end of the edge""" - node: DataEntryNode! +union SourceNode = VisualizationConfigNode | 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! + 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! @@ -434,19 +444,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! - landscapes(offset: Int, before: String, after: String, first: Int, last: Int, name_Icontains: String, description_Icontains: String, slug: String, slug_Icontains: String, website_Icontains: String, location_Icontains: String): LandscapeNodeConnection! 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_SharedTargets_TargetObjectId: UUID, dataEntry_SharedTargets_Target_Slug: String, dataEntry_SharedTargets_TargetContentType: String): VisualizationConfigNodeConnection! id: ID! + sharedTargets: [SharedResourceNode] } -""" -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""" @@ -481,52 +484,68 @@ type GroupNodeEdge { cursor: String! } -type LandscapeNodeConnection { +type VisualizationConfigNodeConnection { """Pagination data for this connection.""" pageInfo: PageInfo! """Contains the nodes in this connection.""" - edges: [LandscapeNodeEdge!]! + edges: [VisualizationConfigNodeEdge!]! totalCount: Int! } -"""A Relay edge containing a `LandscapeNode` and its cursor.""" -type LandscapeNodeEdge { +"""A Relay edge containing a `VisualizationConfigNode` and its cursor.""" +type VisualizationConfigNodeEdge { """The item at the end of the edge""" - node: LandscapeNode! + 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 LandscapeNodeConnection { + """Pagination data for this connection.""" + pageInfo: PageInfo! + + """Contains the nodes in this connection.""" + edges: [LandscapeNodeEdge!]! + totalCount: Int! +} + +"""A Relay edge containing a `LandscapeNode` and its cursor.""" +type LandscapeNodeEdge { + """The item at the end of the edge""" + node: LandscapeNode! + + """A cursor for use in pagination""" + cursor: String! } type UserNodeConnection { @@ -1551,12 +1570,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: @@ -1882,8 +1895,8 @@ type DataEntryAddMutationPayload { } input DataEntryAddMutationInput { - groupSlug: String - landscapeSlug: String + targetType: String! + targetSlug: String! name: String! url: String! entryType: String! @@ -1926,7 +1939,8 @@ input VisualizationConfigAddMutationInput { title: String! configuration: JSONString dataEntryId: ID! - groupId: ID! + targetId: ID! + targetType: 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..b131f3c3f --- /dev/null +++ b/terraso_backend/apps/graphql/schema/shared_resources.py @@ -0,0 +1,58 @@ +# 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 + + +class SharedResourcesMixin: + shared_resources = graphene.List(SharedResourceNode) + + def resolve_shared_resources(self, info, **kwargs): + return self.shared_resources.all() diff --git a/terraso_backend/apps/graphql/schema/visualization_config.py b/terraso_backend/apps/graphql/schema/visualization_config.py index 9eda06c6b..9552b8aff 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_targets__target__slug = django_filters.CharFilter( + method="filter_data_entry_shared_targets_target_slug" + ) + data_entry__shared_targets__target_content_type = django_filters.CharFilter( + method="filter_data_entry_shared_targets_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_targets__target_object_id": ["exact"], } + + def filter_data_entry_shared_targets_target_slug(self, queryset, name, value): + return queryset.filter( + Q(data_entry__shared_targets__target_object_id__in=Group.objects.filter(slug=value)) + | Q( + data_entry__shared_targets__target_object_id__in=Landscape.objects.filter( + slug=value + ) + ) + ) + + def filter_data_entry_shared_targets_target_content_type(self, queryset, name, value): + return queryset.filter( + data_entry__shared_targets__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,24 @@ 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__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_targets__target_object_id__in=all_ids) def resolve_mapbox_tileset_id(self, info): if self.mapbox_tileset_id is None: @@ -89,24 +134,30 @@ class Input: title = graphene.String(required=True) configuration = graphene.JSONString() data_entry_id = graphene.ID(required=True) - group_id = graphene.ID(required=True) + targetId = graphene.ID(required=True) + targetType = 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["targetType"]) + model_class = content_type.model_class() try: - group_entry = Group.objects.get(id=kwargs["group_id"]) + target = model_class.objects.get(id=kwargs["targetId"]) 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 +175,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"] = target + result = super().mutate_and_get_payload(root, info, **kwargs) # Create mapbox tileset diff --git a/terraso_backend/apps/shared_data/models/data_entries.py b/terraso_backend/apps/shared_data/models/data_entries.py index 689fa0604..64fd4d2ad 100644 --- a/terraso_backend/apps/shared_data/models/data_entries.py +++ b/terraso_backend/apps/shared_data/models/data_entries.py @@ -13,12 +13,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/. +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, Landscape, 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 @@ -73,10 +74,13 @@ class DataEntry(BaseModel): size = models.PositiveBigIntegerField(null=True, blank=True) groups = models.ManyToManyField(Group, related_name="data_entries") - landscapes = models.ManyToManyField(Landscape, 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_targets = 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 = { @@ -99,16 +103,6 @@ def signed_url(self): storage = DataEntryFileStorage(custom_domain=None) return storage.url(self.s3_object_name) - def is_user_allowed_to_view(self, user): - groups_ids = [group.id for group in self.groups.all()] - landscape_default_groups_ids = [ - landscape.get_default_group().id for landscape in self.landscapes.all() - ] - user_groups_ids = user.memberships.approved_only().values_list("group", flat=True) - return any( - [group_id in user_groups_ids for group_id in groups_ids + landscape_default_groups_ids] - ) - def delete_file_on_storage(self): if not self.deleted_at: raise RuntimeError( @@ -133,8 +127,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()], - landscapes=[str(landscape.id) for landscape in self.landscapes.all()], + shared_targets=[ + str(shared_target.target.id) for shared_target in self.shared_targets.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 d12bb8ff3..42f7c95b4 100644 --- a/terraso_backend/apps/shared_data/models/visualization_config.py +++ b/terraso_backend/apps/shared_data/models/visualization_config.py @@ -13,10 +13,12 @@ # 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 _ -from apps.core.models import BaseModel, Group, Landscape, SlugModel, User +from apps.core.models import BaseModel, Group, SlugModel, User from apps.core.models.commons import validate_name from apps.shared_data import permission_rules as perm_rules @@ -51,16 +53,18 @@ class VisualizationConfig(SlugModel): group = models.ForeignKey( Group, on_delete=models.CASCADE, related_name="visualizations", null=True, blank=True ) - landscape = models.ForeignKey( - Landscape, 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", ), diff --git a/terraso_backend/apps/shared_data/permission_rules.py b/terraso_backend/apps/shared_data/permission_rules.py index f1becd5e1..d92b8cc11 100644 --- a/terraso_backend/apps/shared_data/permission_rules.py +++ b/terraso_backend/apps/shared_data/permission_rules.py @@ -15,6 +15,38 @@ 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_targets = data_entry.shared_targets.all() + for shared_target in shared_targets: + if is_target_member(user, shared_target.target): + return True + + +def is_user_allowed_to_change_data_entry(data_entry, user): + shared_targets = data_entry.shared_targets.all() + for shared_target in shared_targets: + if is_target_manager(user, shared_target.target): + return True + @rules.predicate def allowed_to_change_data_entry(user, data_entry): @@ -23,34 +55,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_targets = data_entry.shared_targets.all() + for shared_target in shared_targets: + if is_target_manager(user, shared_target.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 data_entry.is_user_allowed_to_view(user) + return is_user_allowed_to_view_data_entry(data_entry, user) @rules.predicate @@ -60,9 +91,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/visualization_tileset_tasks.py b/terraso_backend/apps/shared_data/visualization_tileset_tasks.py index 664869805..a3d0b55a4 100644 --- a/terraso_backend/apps/shared_data/visualization_tileset_tasks.py +++ b/terraso_backend/apps/shared_data/visualization_tileset_tasks.py @@ -59,11 +59,7 @@ def remove_mapbox_tileset(tileset_id): def get_owner_name(visualization): - if visualization.group: - return visualization.group.name - if visualization.landscape: - return visualization.landscape.name - return "" + return visualization.owner.name if visualization.owner else "Unknown" def create_mapbox_tileset(visualization_id): diff --git a/terraso_backend/tests/graphql/conftest.py b/terraso_backend/tests/graphql/conftest.py index 84104d469..b557726b5 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,12 @@ 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, created_by=creator, entry_type=DataEntry.ENTRY_TYPE_FILE), ) + return resource.source @pytest.fixture @@ -262,13 +261,12 @@ 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, created_by=creator, entry_type=DataEntry.ENTRY_TYPE_LINK), ) + return resource.source @pytest.fixture @@ -276,7 +274,12 @@ 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, created_by=creator, size=100), + ) + return resource.source @pytest.fixture @@ -284,16 +287,24 @@ 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), + ) + return [resource.source for resource in resources] @pytest.fixture def landscape_data_entries(users, landscapes, landscape_groups): creator = users[0] creator_landscape = landscapes[0] - return mixer.cycle(5).blend( - DataEntry, created_by=creator, size=100, landscapes=creator_landscape + resources = mixer.cycle(5).blend( + SharedResource, + target=creator_landscape, + source=lambda: mixer.blend(DataEntry, created_by=creator, size=100), ) + return [resource.source for resource in resources] @pytest.fixture @@ -324,15 +335,16 @@ def visualization_configs(users, groups): creator = users[0] creator_group = groups[1] creator_group.members.add(creator) - visualizations = mixer.cycle(5).blend( + return mixer.cycle(5).blend( 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), + ).source, + owner=creator_group, ) - return visualizations @pytest.fixture 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 03e7fa9a5..55f4f8948 100644 --- a/terraso_backend/tests/graphql/mutations/test_shared_data_mutations.py +++ b/terraso_backend/tests/graphql/mutations/test_shared_data_mutations.py @@ -22,23 +22,21 @@ @pytest.fixture -def input_by_owner(request, managed_groups, managed_landscapes): - owner = request.param - base_input = { +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", + "targetType": "group" if parent == "group" else "landscape", + "targetSlug": managed_groups[0].slug if parent == "group" else managed_landscapes[0].slug, } - if owner == "group": - return {**base_input, "groupSlug": managed_groups[0].slug} - if owner == "landscape": - return {**base_input, "landscapeSlug": managed_landscapes[0].slug} -@pytest.mark.parametrize("input_by_owner", ["group", "landscape"], indirect=True) -def test_add_data_entry(client_query, input_by_owner): +@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!) { @@ -52,12 +50,12 @@ def test_add_data_entry(client_query, input_by_owner): } } """, - variables={"input": input_by_owner}, + variables={"input": input_by_parent}, ) result = response.json()["data"]["addDataEntry"] assert result["errors"] is None - assert result["dataEntry"]["name"] == input_by_owner["name"] - assert result["dataEntry"]["url"] == input_by_owner["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): @@ -138,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"]) @@ -178,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( """ @@ -187,6 +186,7 @@ def test_data_entry_delete_by_manager_works(client_query, data_entries, users, g dataEntry { name } + errors } } @@ -194,7 +194,8 @@ 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"]) @@ -205,11 +206,11 @@ def data_entry_by_not_manager_by_owner(request, users, landscape_data_entries, g owner = request.param (data_entry, group) = ( - (group_data_entries[0], group_data_entries[0].groups.first()) + (group_data_entries[0], group_data_entries[0].shared_targets.first().target) if owner == "group" else ( landscape_data_entries[0], - landscape_data_entries[0].landscapes.first().get_default_group(), + landscape_data_entries[0].shared_targets.first().target.get_default_group(), ) ) 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..c672702b1 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, + "targetId": group_id, + "targetType": "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), + "targetId": str(visualization_configs[0].owner.id), + "targetType": "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 d12acb24c..e8e71fd9e 100644 --- a/terraso_backend/tests/graphql/test_shared_data.py +++ b/terraso_backend/tests/graphql/test_shared_data.py @@ -80,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_targets.create(target=groups[-1]) + data_entry_b.shared_targets.create(target=groups[-1]) group_filter = groups[-1] response = client_query( """ - {dataEntries(groups_Slug_Icontains: "%s") { + {dataEntries(sharedTargets_Target_Slug: "%s", sharedTargets_TargetContentType: "%s") { edges { node { id @@ -95,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 @@ -110,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_targets.create(target=groups[-1]) + data_entry_b.shared_targets.create(target=groups[-1]) group_filter = groups[-1] response = client_query( """ - {dataEntries(groups_Id: "%s") { + {dataEntries(sharedTargets_TargetObjectId: "%s") { edges { node { id @@ -211,25 +213,25 @@ def test_data_entries_anonymous_user(client_query_no_token, data_entries): @pytest.fixture -def data_entries_by_owner(request, group_data_entries, landscape_data_entries): - owner = request.param - if owner == "groups": - return (owner, group_data_entries) - if owner == "landscapes": - return (owner, landscape_data_entries) +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_owner", ["groups", "landscapes"], indirect=True) -def test_data_entries_from_owner_query(client_query, data_entries_by_owner): - (owner, data_entries) = data_entries_by_owner +@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 { - dataEntries { - edges { - node { + sharedResources { + source { + ... on DataEntryNode { name } } @@ -238,13 +240,13 @@ def test_data_entries_from_owner_query(client_query, data_entries_by_owner): } }} """ - % owner + % parent ) json_response = response.json() - edges = json_response["data"][owner]["edges"][0]["node"]["dataEntries"]["edges"] - entries_result = [edge["node"]["name"] for edge in edges] + resources = json_response["data"][parent]["edges"][0]["node"]["sharedResources"] + entries_result = [resource["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..a4ec878dd 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,21 +80,24 @@ 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_targets.create(target=groups[-1]) + visualization_config_b.data_entry.shared_targets.create(target=groups[-1]) group_filter = groups[-1] response = client_query( """ - {visualizationConfigs(dataEntry_Groups_Slug_Icontains: "%s") { + {visualizationConfigs( + dataEntry_SharedTargets_Target_Slug: "%s", + dataEntry_SharedTargets_TargetContentType: "%s" + ) { edges { node { id dataEntry { - groups { - edges { - node { + sharedTargets { + target { + ... on GroupNode { slug } } @@ -104,13 +107,13 @@ 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"]["sharedTargets"][1]["target"]["slug"] == group_filter.slug assert len(visualization_configs_result) == 2 assert str(visualization_config_a.id) in visualization_configs_result @@ -123,14 +126,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_targets.create(target=groups[-1]) + visualization_config_b.data_entry.shared_targets.create(target=groups[-1]) group_filter = groups[-1] response = client_query( """ - {visualizationConfigs(dataEntry_Groups_Id: "%s") { + {visualizationConfigs(dataEntry_SharedTargets_TargetObjectId: "%s") { edges { node { id diff --git a/terraso_backend/tests/shared_data/test_models.py b/terraso_backend/tests/shared_data/test_models.py index e90afd035..a65a71ef0 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_targets.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_targets.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_targets.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_targets.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_targets.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_targets.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_targets.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) From 84a4b570d89c88e6a0eaaba1dd5ca621fcdc7a03 Mon Sep 17 00:00:00 2001 From: Jose Buitron Date: Mon, 16 Oct 2023 10:42:00 -0500 Subject: [PATCH 03/11] refactor: Added generic share resources entity --- ...ltgroup_alter_group_deleted_at_and_more.py | 106 ++++++++++++++++++ .../apps/graphql/schema/data_entries.py | 39 +++---- terraso_backend/apps/graphql/schema/groups.py | 7 +- .../apps/graphql/schema/landscapes.py | 7 +- .../apps/graphql/schema/schema.graphql | 14 +-- .../apps/graphql/schema/shared_resources.py | 7 -- .../graphql/schema/shared_resources_mixin.py | 23 ++++ .../graphql/schema/visualization_config.py | 22 ++-- ...ig_unique_active_slug_by_group_and_more.py | 80 +++++++++++++ .../apps/shared_data/models/data_entries.py | 7 +- .../models/visualization_config.py | 1 + .../apps/shared_data/permission_rules.py | 18 +-- .../mutations/test_shared_data_mutations.py | 4 +- .../tests/graphql/test_shared_data.py | 12 +- .../graphql/test_visualization_config.py | 20 ++-- .../tests/shared_data/test_models.py | 14 +-- 16 files changed, 289 insertions(+), 92 deletions(-) create mode 100644 terraso_backend/apps/core/migrations/0046_landscapedefaultgroup_alter_group_deleted_at_and_more.py create mode 100644 terraso_backend/apps/graphql/schema/shared_resources_mixin.py create mode 100644 terraso_backend/apps/shared_data/migrations/0011_remove_visualizationconfig_shared_data_visualizationconfig_unique_active_slug_by_group_and_more.py diff --git a/terraso_backend/apps/core/migrations/0046_landscapedefaultgroup_alter_group_deleted_at_and_more.py b/terraso_backend/apps/core/migrations/0046_landscapedefaultgroup_alter_group_deleted_at_and_more.py new file mode 100644 index 000000000..ae9b0e1b4 --- /dev/null +++ b/terraso_backend/apps/core/migrations/0046_landscapedefaultgroup_alter_group_deleted_at_and_more.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/graphql/schema/data_entries.py b/terraso_backend/apps/graphql/schema/data_entries.py index 53044ec36..3df53e03d 100644 --- a/terraso_backend/apps/graphql/schema/data_entries.py +++ b/terraso_backend/apps/graphql/schema/data_entries.py @@ -29,16 +29,17 @@ from .commons import BaseDeleteMutation, BaseWriteMutation, TerrasoConnection from .constants import MutationTypes +from .shared_resources_mixin import SharedResourcesMixin logger = structlog.get_logger(__name__) class DataEntryFilterSet(django_filters.FilterSet): - shared_targets__target__slug = django_filters.CharFilter( - method="filter_shared_targets_target_slug" + shared_resources__target__slug = django_filters.CharFilter( + method="filter_shared_resources_target_slug" ) - shared_targets__target_content_type = django_filters.CharFilter( - method="filter_shared_targets_target_content_type", + shared_resources__target_content_type = django_filters.CharFilter( + method="filter_shared_resources_target_content_type", ) class Meta: @@ -49,26 +50,25 @@ class Meta: "url": ["icontains"], "entry_type": ["in"], "resource_type": ["in"], - "shared_targets__target_object_id": ["exact"], + "shared_resources__target_object_id": ["exact"], } - def filter_shared_targets_target_slug(self, queryset, name, value): + def filter_shared_resources_target_slug(self, queryset, name, value): return queryset.filter( - Q(shared_targets__target_object_id__in=Group.objects.filter(slug=value)) - | Q(shared_targets__target_object_id__in=Landscape.objects.filter(slug=value)) + 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_targets_target_content_type(self, queryset, name, value): + def filter_shared_resources_target_content_type(self, queryset, name, value): return queryset.filter( - shared_targets__target_content_type=ContentType.objects.get( + shared_resources__target_content_type=ContentType.objects.get( app_label="core", model=value ) ).distinct() -class DataEntryNode(DjangoObjectType): +class DataEntryNode(DjangoObjectType, SharedResourcesMixin): id = graphene.ID(source="pk", required=True) - shared_targets = graphene.List("apps.graphql.schema.shared_resources.SharedResourceNode") class Meta: model = DataEntry @@ -84,7 +84,7 @@ class Meta: "groups", "landscapes", "visualizations", - "shared_targets", + "shared_resources", ) interfaces = (relay.Node,) filterset_class = DataEntryFilterSet @@ -103,12 +103,12 @@ def get_queryset(cls, queryset, info): return queryset.filter( Q( - shared_targets__target_content_type=ContentType.objects.get_for_model(Group), - shared_targets__target_object_id__in=user_groups_ids, + shared_resources__target_content_type=ContentType.objects.get_for_model(Group), + shared_resources__target_object_id__in=user_groups_ids, ) | Q( - shared_targets__target_content_type=ContentType.objects.get_for_model(Landscape), - shared_targets__target_object_id__in=user_landscape_ids, + shared_resources__target_content_type=ContentType.objects.get_for_model(Landscape), + shared_resources__target_object_id__in=user_landscape_ids, ) ) @@ -117,9 +117,6 @@ def resolve_url(self, info): return self.signed_url return self.url - def resolve_shared_targets(self, info, **kwargs): - return self.shared_targets.all() - class DataEntryAddMutation(BaseWriteMutation): data_entry = graphene.Field(DataEntryNode) @@ -179,7 +176,7 @@ def mutate_and_get_payload(cls, root, info, **kwargs): result = super().mutate_and_get_payload(root, info, **kwargs) - result.data_entry.shared_targets.create( + 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 5a0a21b8e..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,11 +60,10 @@ 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() - shared_resources = graphene.List("apps.graphql.schema.shared_resources.SharedResourceNode") class Meta: model = Group @@ -109,9 +109,6 @@ def resolve_memberships_count(self, info): return self.memberships.approved_only().count() - def resolve_shared_resources(self, info, **kwargs): - return self.shared_resources.all() - class GroupAddMutation(BaseWriteMutation): group = graphene.Field(GroupNode) diff --git a/terraso_backend/apps/graphql/schema/landscapes.py b/terraso_backend/apps/graphql/schema/landscapes.py index 7f6a5d149..260553e9a 100644 --- a/terraso_backend/apps/graphql/schema/landscapes.py +++ b/terraso_backend/apps/graphql/schema/landscapes.py @@ -34,16 +34,16 @@ 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") center_coordinates = graphene.Field(Point) - shared_resources = graphene.List("apps.graphql.schema.shared_resources.SharedResourceNode") class Meta: model = Landscape @@ -139,9 +139,6 @@ def resolve_default_group(self, info): return None return self.get_default_group() - def resolve_shared_resources(self, info, **kwargs): - return self.shared_resources.all() - class LandscapeDevelopmentStrategyNode(DjangoObjectType): id = graphene.ID(source="pk", required=True) diff --git a/terraso_backend/apps/graphql/schema/schema.graphql b/terraso_backend/apps/graphql/schema/schema.graphql index 25e61703e..1720e0cd5 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], sharedTargets_TargetObjectId: UUID, sharedTargets_Target_Slug: String, sharedTargets_TargetContentType: String): 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_SharedTargets_TargetObjectId: UUID, dataEntry_SharedTargets_Target_Slug: String, dataEntry_SharedTargets_TargetContentType: String): 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,11 +108,11 @@ 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], sharedTargets_TargetObjectId: UUID, sharedTargets_Target_Slug: String, sharedTargets_TargetContentType: String): 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: [SharedResourceNode] accountMembership: MembershipNode membershipsCount: Int - sharedResources: [SharedResourceNode] } """An object with an ID""" @@ -296,9 +296,9 @@ 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: [SharedResourceNode] areaTypes: [String] defaultGroup: GroupNode - sharedResources: [SharedResourceNode] areaScalarHa: Float } @@ -445,9 +445,9 @@ 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_SharedTargets_TargetObjectId: UUID, dataEntry_SharedTargets_Target_Slug: String, dataEntry_SharedTargets_TargetContentType: String): 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! - sharedTargets: [SharedResourceNode] + sharedResources: [SharedResourceNode] } """An enumeration.""" diff --git a/terraso_backend/apps/graphql/schema/shared_resources.py b/terraso_backend/apps/graphql/schema/shared_resources.py index b131f3c3f..844b41273 100644 --- a/terraso_backend/apps/graphql/schema/shared_resources.py +++ b/terraso_backend/apps/graphql/schema/shared_resources.py @@ -49,10 +49,3 @@ def resolve_source(self, info, **kwargs): def resolve_target(self, info, **kwargs): return self.target - - -class SharedResourcesMixin: - shared_resources = graphene.List(SharedResourceNode) - - def resolve_shared_resources(self, info, **kwargs): - return self.shared_resources.all() 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..0706854d1 --- /dev/null +++ b/terraso_backend/apps/graphql/schema/shared_resources_mixin.py @@ -0,0 +1,23 @@ +# 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 + + +class SharedResourcesMixin: + shared_resources = graphene.List("apps.graphql.schema.shared_resources.SharedResourceNode") + + def resolve_shared_resources(self, info, **kwargs): + return self.shared_resources.all() diff --git a/terraso_backend/apps/graphql/schema/visualization_config.py b/terraso_backend/apps/graphql/schema/visualization_config.py index 9552b8aff..3b48f3033 100644 --- a/terraso_backend/apps/graphql/schema/visualization_config.py +++ b/terraso_backend/apps/graphql/schema/visualization_config.py @@ -41,33 +41,33 @@ class VisualizationConfigFilterSet(django_filters.FilterSet): - data_entry__shared_targets__target__slug = django_filters.CharFilter( - method="filter_data_entry_shared_targets_target_slug" + data_entry__shared_resources__target__slug = django_filters.CharFilter( + method="filter_data_entry_shared_resources_target_slug" ) - data_entry__shared_targets__target_content_type = django_filters.CharFilter( - method="filter_data_entry_shared_targets_target_content_type", + data_entry__shared_resources__target_content_type = django_filters.CharFilter( + method="filter_data_entry_shared_resources_target_content_type", ) class Meta: model = VisualizationConfig fields = { "slug": ["exact", "icontains"], - "data_entry__shared_targets__target_object_id": ["exact"], + "data_entry__shared_resources__target_object_id": ["exact"], } - def filter_data_entry_shared_targets_target_slug(self, queryset, name, value): + def filter_data_entry_shared_resources_target_slug(self, queryset, name, value): return queryset.filter( - Q(data_entry__shared_targets__target_object_id__in=Group.objects.filter(slug=value)) + Q(data_entry__shared_resources__target_object_id__in=Group.objects.filter(slug=value)) | Q( - data_entry__shared_targets__target_object_id__in=Landscape.objects.filter( + data_entry__shared_resources__target_object_id__in=Landscape.objects.filter( slug=value ) ) ) - def filter_data_entry_shared_targets_target_content_type(self, queryset, name, value): + def filter_data_entry_shared_resources_target_content_type(self, queryset, name, value): return queryset.filter( - data_entry__shared_targets__target_content_type=ContentType.objects.get( + data_entry__shared_resources__target_content_type=ContentType.objects.get( app_label="core", model=value ) ).distinct() @@ -109,7 +109,7 @@ def get_queryset(cls, queryset, info): 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_targets__target_object_id__in=all_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: diff --git a/terraso_backend/apps/shared_data/migrations/0011_remove_visualizationconfig_shared_data_visualizationconfig_unique_active_slug_by_group_and_more.py b/terraso_backend/apps/shared_data/migrations/0011_remove_visualizationconfig_shared_data_visualizationconfig_unique_active_slug_by_group_and_more.py new file mode 100644 index 000000000..a83ffcc1e --- /dev/null +++ b/terraso_backend/apps/shared_data/migrations/0011_remove_visualizationconfig_shared_data_visualizationconfig_unique_active_slug_by_group_and_more.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_landscapedefaultgroup_alter_group_deleted_at_and_more"), + ("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/models/data_entries.py b/terraso_backend/apps/shared_data/models/data_entries.py index 64fd4d2ad..62228d397 100644 --- a/terraso_backend/apps/shared_data/models/data_entries.py +++ b/terraso_backend/apps/shared_data/models/data_entries.py @@ -73,11 +73,12 @@ 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_targets = GenericRelation( + shared_resources = GenericRelation( SharedResource, content_type_field="source_content_type", object_id_field="source_object_id" ) @@ -127,8 +128,8 @@ def to_dict(self): resource_type=self.resource_type, size=self.size, created_by=str(self.created_by.id), - shared_targets=[ - str(shared_target.target.id) for shared_target in self.shared_targets.all() + shared_resources=[ + str(shared_resource.target.id) for shared_resource in self.shared_resources.all() ], ) diff --git a/terraso_backend/apps/shared_data/models/visualization_config.py b/terraso_backend/apps/shared_data/models/visualization_config.py index 42f7c95b4..3196f5914 100644 --- a/terraso_backend/apps/shared_data/models/visualization_config.py +++ b/terraso_backend/apps/shared_data/models/visualization_config.py @@ -50,6 +50,7 @@ class VisualizationConfig(SlugModel): data_entry = models.ForeignKey( DataEntry, 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 ) diff --git a/terraso_backend/apps/shared_data/permission_rules.py b/terraso_backend/apps/shared_data/permission_rules.py index d92b8cc11..3f75c29f2 100644 --- a/terraso_backend/apps/shared_data/permission_rules.py +++ b/terraso_backend/apps/shared_data/permission_rules.py @@ -35,16 +35,16 @@ def is_target_member(user, target): def is_user_allowed_to_view_data_entry(data_entry, user): - shared_targets = data_entry.shared_targets.all() - for shared_target in shared_targets: - if is_target_member(user, shared_target.target): + shared_resources = data_entry.shared_resources.all() + for shared_resource in shared_resources: + if is_target_member(user, shared_resource.target): return True def is_user_allowed_to_change_data_entry(data_entry, user): - shared_targets = data_entry.shared_targets.all() - for shared_target in shared_targets: - if is_target_manager(user, shared_target.target): + shared_resources = data_entry.shared_resources.all() + for shared_resource in shared_resources: + if is_target_manager(user, shared_resource.target): return True @@ -57,9 +57,9 @@ def allowed_to_change_data_entry(user, data_entry): def allowed_to_delete_data_entry(user, data_entry): if data_entry.created_by == user: return True - shared_targets = data_entry.shared_targets.all() - for shared_target in shared_targets: - if is_target_manager(user, shared_target.target): + 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 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 55f4f8948..2716b718b 100644 --- a/terraso_backend/tests/graphql/mutations/test_shared_data_mutations.py +++ b/terraso_backend/tests/graphql/mutations/test_shared_data_mutations.py @@ -206,11 +206,11 @@ def data_entry_by_not_manager_by_owner(request, users, landscape_data_entries, g owner = request.param (data_entry, group) = ( - (group_data_entries[0], group_data_entries[0].shared_targets.first().target) + (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_targets.first().target.get_default_group(), + landscape_data_entries[0].shared_resources.first().target.get_default_group(), ) ) diff --git a/terraso_backend/tests/graphql/test_shared_data.py b/terraso_backend/tests/graphql/test_shared_data.py index e8e71fd9e..37ebe4dae 100644 --- a/terraso_backend/tests/graphql/test_shared_data.py +++ b/terraso_backend/tests/graphql/test_shared_data.py @@ -80,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.shared_targets.create(target=groups[-1]) - data_entry_b.shared_targets.create(target=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(sharedTargets_Target_Slug: "%s", sharedTargets_TargetContentType: "%s") { + {dataEntries(sharedResources_Target_Slug: "%s", sharedResources_TargetContentType: "%s") { edges { node { id @@ -112,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.shared_targets.create(target=groups[-1]) - data_entry_b.shared_targets.create(target=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(sharedTargets_TargetObjectId: "%s") { + {dataEntries(sharedResources_TargetObjectId: "%s") { edges { node { id diff --git a/terraso_backend/tests/graphql/test_visualization_config.py b/terraso_backend/tests/graphql/test_visualization_config.py index a4ec878dd..388b22611 100644 --- a/terraso_backend/tests/graphql/test_visualization_config.py +++ b/terraso_backend/tests/graphql/test_visualization_config.py @@ -80,22 +80,22 @@ 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.shared_targets.create(target=groups[-1]) - visualization_config_b.data_entry.shared_targets.create(target=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_SharedTargets_Target_Slug: "%s", - dataEntry_SharedTargets_TargetContentType: "%s" + dataEntry_SharedResources_Target_Slug: "%s", + dataEntry_SharedResources_TargetContentType: "%s" ) { edges { node { id dataEntry { - sharedTargets { + sharedResources { target { ... on GroupNode { slug @@ -113,7 +113,9 @@ def test_visualization_configs_filter_by_group_slug_filters_successfuly( edges = json_response["data"]["visualizationConfigs"]["edges"] visualization_configs_result = [edge["node"]["id"] for edge in edges] - assert edges[0]["node"]["dataEntry"]["sharedTargets"][1]["target"]["slug"] == group_filter.slug + assert ( + edges[0]["node"]["dataEntry"]["sharedResources"][1]["target"]["slug"] == group_filter.slug + ) assert len(visualization_configs_result) == 2 assert str(visualization_config_a.id) in visualization_configs_result @@ -126,14 +128,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.shared_targets.create(target=groups[-1]) - visualization_config_b.data_entry.shared_targets.create(target=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_SharedTargets_TargetObjectId: "%s") { + {visualizationConfigs(dataEntry_SharedResources_TargetObjectId: "%s") { edges { node { id diff --git a/terraso_backend/tests/shared_data/test_models.py b/terraso_backend/tests/shared_data/test_models.py index a65a71ef0..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.shared_targets.create(target=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.shared_targets.create(target=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.shared_targets.create(target=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.shared_targets.create(target=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.shared_targets.create(target=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.shared_targets.create(target=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.shared_targets.create(target=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) From 0529c74d1e72a9254a18dd867e78868e593154c6 Mon Sep 17 00:00:00 2001 From: Jose Buitron Date: Mon, 16 Oct 2023 13:39:36 -0500 Subject: [PATCH 04/11] fix: Use connection for shared resources GQL relationship --- .../apps/graphql/schema/shared_resources_mixin.py | 10 +++++++--- terraso_backend/tests/graphql/test_shared_data.py | 14 +++++++++----- .../tests/graphql/test_visualization_config.py | 13 +++++++++---- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/terraso_backend/apps/graphql/schema/shared_resources_mixin.py b/terraso_backend/apps/graphql/schema/shared_resources_mixin.py index 0706854d1..194bd1d70 100644 --- a/terraso_backend/apps/graphql/schema/shared_resources_mixin.py +++ b/terraso_backend/apps/graphql/schema/shared_resources_mixin.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 graphene +from graphene_django.filter import DjangoFilterConnectionField + + class SharedResourcesMixin: - shared_resources = graphene.List("apps.graphql.schema.shared_resources.SharedResourceNode") + shared_resources = DjangoFilterConnectionField( + "apps.graphql.schema.shared_resources.SharedResourceNode", + ) def resolve_shared_resources(self, info, **kwargs): - return self.shared_resources.all() + return self.shared_resources diff --git a/terraso_backend/tests/graphql/test_shared_data.py b/terraso_backend/tests/graphql/test_shared_data.py index 37ebe4dae..833d83779 100644 --- a/terraso_backend/tests/graphql/test_shared_data.py +++ b/terraso_backend/tests/graphql/test_shared_data.py @@ -230,9 +230,13 @@ def test_data_entries_from_parent_query(client_query, data_entries_by_parent): edges { node { sharedResources { - source { - ... on DataEntryNode { - name + edges { + node { + source { + ... on DataEntryNode { + name + } + } } } } @@ -245,8 +249,8 @@ def test_data_entries_from_parent_query(client_query, data_entries_by_parent): json_response = response.json() - resources = json_response["data"][parent]["edges"][0]["node"]["sharedResources"] - entries_result = [resource["source"]["name"] for resource in resources] + 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 388b22611..8612223e3 100644 --- a/terraso_backend/tests/graphql/test_visualization_config.py +++ b/terraso_backend/tests/graphql/test_visualization_config.py @@ -96,9 +96,13 @@ def test_visualization_configs_filter_by_group_slug_filters_successfuly( id dataEntry { sharedResources { - target { - ... on GroupNode { - slug + edges { + node { + target { + ... on GroupNode { + slug + } + } } } } @@ -114,7 +118,8 @@ def test_visualization_configs_filter_by_group_slug_filters_successfuly( visualization_configs_result = [edge["node"]["id"] for edge in edges] assert ( - edges[0]["node"]["dataEntry"]["sharedResources"][1]["target"]["slug"] == group_filter.slug + edges[0]["node"]["dataEntry"]["sharedResources"]["edges"][1]["node"]["target"]["slug"] + == group_filter.slug ) assert len(visualization_configs_result) == 2 From 1941386700cbf5c30edad4928e3ff32a9a522882 Mon Sep 17 00:00:00 2001 From: Jose Buitron Date: Tue, 17 Oct 2023 10:03:12 -0500 Subject: [PATCH 05/11] fix: Added data entry resource filter --- .../apps/graphql/schema/schema.graphql | 24 +++++++++++-- .../graphql/schema/shared_resources_mixin.py | 27 ++++++++++++++ terraso_backend/tests/graphql/conftest.py | 6 ++-- .../tests/graphql/test_shared_data.py | 35 +++++++++++++++++++ 4 files changed, 87 insertions(+), 5 deletions(-) diff --git a/terraso_backend/apps/graphql/schema/schema.graphql b/terraso_backend/apps/graphql/schema/schema.graphql index 1720e0cd5..96062f1d0 100644 --- a/terraso_backend/apps/graphql/schema/schema.graphql +++ b/terraso_backend/apps/graphql/schema/schema.graphql @@ -110,7 +110,7 @@ type GroupNode implements Node { 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], sharedResources_TargetObjectId: UUID, sharedResources_Target_Slug: String, sharedResources_TargetContentType: String): DataEntryNodeConnection! id: ID! - sharedResources: [SharedResourceNode] + sharedResources(offset: Int, before: String, after: String, first: Int, last: Int, source_DataEntry_ResourceType_In: [String]): SharedResourceNodeConnection accountMembership: MembershipNode membershipsCount: Int } @@ -296,7 +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: [SharedResourceNode] + sharedResources(offset: Int, before: String, after: String, first: Int, last: Int, source_DataEntry_ResourceType_In: [String]): SharedResourceNodeConnection areaTypes: [String] defaultGroup: GroupNode areaScalarHa: Float @@ -406,6 +406,24 @@ type LandscapeDevelopmentStrategyNode implements Node { id: ID! } +type SharedResourceNodeConnection { + """Pagination data for this connection.""" + pageInfo: PageInfo! + + """Contains the nodes in this connection.""" + edges: [SharedResourceNodeEdge!]! + totalCount: Int! +} + +"""A Relay edge containing a `SharedResourceNode` and its cursor.""" +type SharedResourceNodeEdge { + """The item at the end of the edge""" + node: SharedResourceNode! + + """A cursor for use in pagination""" + cursor: String! +} + type SharedResourceNode implements Node { id: ID! source: SourceNode @@ -447,7 +465,7 @@ type DataEntryNode implements Node { createdBy: UserNode 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: [SharedResourceNode] + sharedResources(offset: Int, before: String, after: String, first: Int, last: Int, source_DataEntry_ResourceType_In: [String]): SharedResourceNodeConnection } """An enumeration.""" diff --git a/terraso_backend/apps/graphql/schema/shared_resources_mixin.py b/terraso_backend/apps/graphql/schema/shared_resources_mixin.py index 194bd1d70..b989b5bed 100644 --- a/terraso_backend/apps/graphql/schema/shared_resources_mixin.py +++ b/terraso_backend/apps/graphql/schema/shared_resources_mixin.py @@ -13,14 +13,41 @@ # 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): diff --git a/terraso_backend/tests/graphql/conftest.py b/terraso_backend/tests/graphql/conftest.py index b557726b5..3811688c1 100644 --- a/terraso_backend/tests/graphql/conftest.py +++ b/terraso_backend/tests/graphql/conftest.py @@ -290,7 +290,7 @@ def group_data_entries(users, groups): resources = mixer.cycle(5).blend( SharedResource, target=creator_group, - source=lambda: mixer.blend(DataEntry, created_by=creator, size=100), + source=lambda: mixer.blend(DataEntry, created_by=creator, size=100, resource_type="csv"), ) return [resource.source for resource in resources] @@ -302,7 +302,9 @@ def landscape_data_entries(users, landscapes, landscape_groups): resources = mixer.cycle(5).blend( SharedResource, target=creator_landscape, - source=lambda: mixer.blend(DataEntry, created_by=creator, size=100), + 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] diff --git a/terraso_backend/tests/graphql/test_shared_data.py b/terraso_backend/tests/graphql/test_shared_data.py index 833d83779..75e5b42e8 100644 --- a/terraso_backend/tests/graphql/test_shared_data.py +++ b/terraso_backend/tests/graphql/test_shared_data.py @@ -254,3 +254,38 @@ def test_data_entries_from_parent_query(client_query, data_entries_by_parent): 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 From 792a03e69913aefba0cec40a571e7176ccd51361 Mon Sep 17 00:00:00 2001 From: Jose Buitron Date: Tue, 17 Oct 2023 12:37:52 -0500 Subject: [PATCH 06/11] fix: Renamed to owner id in visualization mutation --- terraso_backend/apps/graphql/schema/schema.graphql | 4 ++-- .../apps/graphql/schema/visualization_config.py | 10 +++++----- .../mutations/test_visualization_config_mutations.py | 8 ++++---- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/terraso_backend/apps/graphql/schema/schema.graphql b/terraso_backend/apps/graphql/schema/schema.graphql index 96062f1d0..09d779b6f 100644 --- a/terraso_backend/apps/graphql/schema/schema.graphql +++ b/terraso_backend/apps/graphql/schema/schema.graphql @@ -1957,8 +1957,8 @@ input VisualizationConfigAddMutationInput { title: String! configuration: JSONString dataEntryId: ID! - targetId: ID! - targetType: String! + ownerId: ID! + ownerType: String! clientMutationId: String } diff --git a/terraso_backend/apps/graphql/schema/visualization_config.py b/terraso_backend/apps/graphql/schema/visualization_config.py index 3b48f3033..feb4741bd 100644 --- a/terraso_backend/apps/graphql/schema/visualization_config.py +++ b/terraso_backend/apps/graphql/schema/visualization_config.py @@ -134,18 +134,18 @@ class Input: title = graphene.String(required=True) configuration = graphene.JSONString() data_entry_id = graphene.ID(required=True) - targetId = graphene.ID(required=True) - targetType = graphene.String(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["targetType"]) + content_type = ContentType.objects.get(app_label="core", model=kwargs["ownerType"]) model_class = content_type.model_class() try: - target = model_class.objects.get(id=kwargs["targetId"]) + owner = model_class.objects.get(id=kwargs["ownerId"]) except Group.DoesNotExist: logger.error( "Target not found when adding a VisualizationConfig", @@ -179,7 +179,7 @@ def mutate_and_get_payload(cls, root, info, **kwargs): if not cls.is_update(kwargs): kwargs["created_by"] = user - kwargs["owner"] = target + kwargs["owner"] = owner result = super().mutate_and_get_payload(root, info, **kwargs) 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 c672702b1..bf91219c6 100644 --- a/terraso_backend/tests/graphql/mutations/test_visualization_config_mutations.py +++ b/terraso_backend/tests/graphql/mutations/test_visualization_config_mutations.py @@ -30,8 +30,8 @@ def test_visualization_config_add(mock_create_tileset, client_query, groups, dat new_data = { "title": "Test title", "configuration": '{"key": "value"}', - "targetId": group_id, - "targetType": "group", + "ownerId": group_id, + "ownerType": "group", "dataEntryId": data_entry_id, } @@ -76,8 +76,8 @@ def test_visualization_config_add_fails_due_uniqueness_check( new_data = { "title": visualization_configs[0].title, "configuration": '{"key": "value"}', - "targetId": str(visualization_configs[0].owner.id), - "targetType": "group", + "ownerId": str(visualization_configs[0].owner.id), + "ownerType": "group", "dataEntryId": str(data_entries[0].id), } From a0823b7b8e42d847b94cc90eb27e885e834401ae Mon Sep 17 00:00:00 2001 From: Jose Buitron Date: Tue, 17 Oct 2023 12:53:22 -0500 Subject: [PATCH 07/11] fix: Removed groups and landscapes from data entry node --- .../apps/graphql/schema/data_entries.py | 2 - .../apps/graphql/schema/schema.graphql | 37 +++++++++---------- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/terraso_backend/apps/graphql/schema/data_entries.py b/terraso_backend/apps/graphql/schema/data_entries.py index 3df53e03d..91bd3d57c 100644 --- a/terraso_backend/apps/graphql/schema/data_entries.py +++ b/terraso_backend/apps/graphql/schema/data_entries.py @@ -81,8 +81,6 @@ class Meta: "size", "created_by", "created_at", - "groups", - "landscapes", "visualizations", "shared_resources", ) diff --git a/terraso_backend/apps/graphql/schema/schema.graphql b/terraso_backend/apps/graphql/schema/schema.graphql index 09d779b6f..61641e3dc 100644 --- a/terraso_backend/apps/graphql/schema/schema.graphql +++ b/terraso_backend/apps/graphql/schema/schema.graphql @@ -461,7 +461,6 @@ 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_SharedResources_TargetObjectId: UUID, dataEntry_SharedResources_Target_Slug: String, dataEntry_SharedResources_TargetContentType: String): VisualizationConfigNodeConnection! id: ID! @@ -484,24 +483,6 @@ compatible type. """ scalar BigInt -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 VisualizationConfigNodeConnection { """Pagination data for this connection.""" pageInfo: PageInfo! @@ -548,6 +529,24 @@ type DataEntryNodeEdge { cursor: String! } +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 { """Pagination data for this connection.""" pageInfo: PageInfo! From 61a3afa2583bfdf017d25d67ce53e25ea8fee5cd Mon Sep 17 00:00:00 2001 From: Jose Buitron Date: Tue, 17 Oct 2023 14:09:06 -0500 Subject: [PATCH 08/11] fix: Fixed null visualization group constraint --- ...at_and_more.py => 0046_shared_resource.py} | 0 ...e.py => 0011_visualizationconfig_owner.py} | 2 +- ...ve_visualizationconfig_group_constraint.py | 44 +++++++++++++++++++ .../0013_visualizationconfig_group_null.py | 43 ++++++++++++++++++ .../models/visualization_config.py | 2 +- 5 files changed, 89 insertions(+), 2 deletions(-) rename terraso_backend/apps/core/migrations/{0046_landscapedefaultgroup_alter_group_deleted_at_and_more.py => 0046_shared_resource.py} (100%) rename terraso_backend/apps/shared_data/migrations/{0011_remove_visualizationconfig_shared_data_visualizationconfig_unique_active_slug_by_group_and_more.py => 0011_visualizationconfig_owner.py} (97%) create mode 100644 terraso_backend/apps/shared_data/migrations/0012_remove_visualizationconfig_group_constraint.py create mode 100644 terraso_backend/apps/shared_data/migrations/0013_visualizationconfig_group_null.py diff --git a/terraso_backend/apps/core/migrations/0046_landscapedefaultgroup_alter_group_deleted_at_and_more.py b/terraso_backend/apps/core/migrations/0046_shared_resource.py similarity index 100% rename from terraso_backend/apps/core/migrations/0046_landscapedefaultgroup_alter_group_deleted_at_and_more.py rename to terraso_backend/apps/core/migrations/0046_shared_resource.py diff --git a/terraso_backend/apps/shared_data/migrations/0011_remove_visualizationconfig_shared_data_visualizationconfig_unique_active_slug_by_group_and_more.py b/terraso_backend/apps/shared_data/migrations/0011_visualizationconfig_owner.py similarity index 97% rename from terraso_backend/apps/shared_data/migrations/0011_remove_visualizationconfig_shared_data_visualizationconfig_unique_active_slug_by_group_and_more.py rename to terraso_backend/apps/shared_data/migrations/0011_visualizationconfig_owner.py index a83ffcc1e..73d6b57fe 100644 --- a/terraso_backend/apps/shared_data/migrations/0011_remove_visualizationconfig_shared_data_visualizationconfig_unique_active_slug_by_group_and_more.py +++ b/terraso_backend/apps/shared_data/migrations/0011_visualizationconfig_owner.py @@ -44,7 +44,7 @@ class Migration(migrations.Migration): dependencies = [ ("contenttypes", "0002_remove_content_type_name"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("core", "0046_landscapedefaultgroup_alter_group_deleted_at_and_more"), + ("core", "0046_shared_resource"), ("shared_data", "0010_visualizationconfig_mapbox_tileset_id_status"), ] 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/visualization_config.py b/terraso_backend/apps/shared_data/models/visualization_config.py index 3196f5914..972bb6013 100644 --- a/terraso_backend/apps/shared_data/models/visualization_config.py +++ b/terraso_backend/apps/shared_data/models/visualization_config.py @@ -67,7 +67,7 @@ class Meta(BaseModel.Meta): models.UniqueConstraint( 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" From f191ba76078a87afbb1cebdf3c7aa4925e6ec60b Mon Sep 17 00:00:00 2001 From: Jose Buitron Date: Tue, 17 Oct 2023 16:20:24 -0500 Subject: [PATCH 09/11] fix: Handle target for data entry upload --- .../apps/core/models/shared_resources.py | 6 ++ .../apps/graphql/schema/data_entries.py | 1 + .../graphql/schema/visualization_config.py | 1 + terraso_backend/apps/shared_data/forms.py | 17 ------ terraso_backend/apps/shared_data/views.py | 60 +++++++++++++++++++ .../tests/shared_data/test_views.py | 31 ++++------ 6 files changed, 81 insertions(+), 35 deletions(-) diff --git a/terraso_backend/apps/core/models/shared_resources.py b/terraso_backend/apps/core/models/shared_resources.py index 7d651fe49..45e51d744 100644 --- a/terraso_backend/apps/core/models/shared_resources.py +++ b/terraso_backend/apps/core/models/shared_resources.py @@ -20,6 +20,12 @@ 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: Lanscape). + """ + source = GenericForeignKey("source_content_type", "source_object_id") source_content_type = models.ForeignKey( ContentType, on_delete=models.CASCADE, related_name="source_content_type" diff --git a/terraso_backend/apps/graphql/schema/data_entries.py b/terraso_backend/apps/graphql/schema/data_entries.py index 91bd3d57c..0a9991740 100644 --- a/terraso_backend/apps/graphql/schema/data_entries.py +++ b/terraso_backend/apps/graphql/schema/data_entries.py @@ -96,6 +96,7 @@ def get_queryset(cls, queryset, info): ).values_list("group", flat=True) 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) diff --git a/terraso_backend/apps/graphql/schema/visualization_config.py b/terraso_backend/apps/graphql/schema/visualization_config.py index feb4741bd..bfa82a8d8 100644 --- a/terraso_backend/apps/graphql/schema/visualization_config.py +++ b/terraso_backend/apps/graphql/schema/visualization_config.py @@ -106,6 +106,7 @@ def get_queryset(cls, queryset, info): ).values_list("group", flat=True) 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) diff --git a/terraso_backend/apps/shared_data/forms.py b/terraso_backend/apps/shared_data/forms.py index 1ecf9edc6..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, Landscape from .models import DataEntry from .services import data_entry_upload_service @@ -37,12 +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=False, to_field_name="slug", queryset=Group.objects.all() - ) - landscapes = forms.ModelMultipleChoiceField( - required=False, to_field_name="slug", queryset=Landscape.objects.all() - ) class Meta: model = DataEntry @@ -54,8 +47,6 @@ class Meta: "resource_type", "size", "url", - "groups", - "landscapes", "created_by", ) @@ -92,14 +83,6 @@ def clean_data_file(self): def clean(self): data = self.cleaned_data data_file = data.get("data_file") - groups = data.get("groups") - landscapes = data.get("landscapes") - - # Check if either groups or landscapes are provided - if not groups and not landscapes: - raise ValidationError( - "Either groups or landscapes are required", code="invalid_groups_landscapes" - ) if data_file: file_extension = pathlib.Path(data_file.name).suffix diff --git a/terraso_backend/apps/shared_data/views.py b/terraso_backend/apps/shared_data/views.py index d2b0a945e..ef98af751 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 @@ -36,10 +38,64 @@ 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 ["group", "landscape"]: + logger.error("Invalid target_type provided when adding dataEntry") + return JsonResponse( + { + "errors": [ + { + "message": [ + asdict( + ErrorMessage( + code="Invalid target_type provided when adding dataEntry", + context=ErrorContext( + model="DataEntry", field="target_type" + ), + ) + ) + ] + } + ] + }, + status=400, + ) + + 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 JsonResponse( + { + "errors": [ + { + "message": [ + asdict( + ErrorMessage( + code="Target not found when adding dataEntry", + context=ErrorContext( + model="DataEntry", field="target_type" + ), + ) + ) + ] + } + ] + }, + status=400, + ) if has_multiple_files(request.FILES.getlist("data_file")): error_message = ErrorMessage( @@ -70,6 +126,10 @@ def post(self, request, **kwargs): data_entry = entry_form.save() + data_entry.shared_resources.create( + target=target, + ) + return JsonResponse(data_entry.to_dict(), status=201) diff --git a/terraso_backend/tests/shared_data/test_views.py b/terraso_backend/tests/shared_data/test_views.py index 5d2c8720f..779baf03c 100644 --- a/terraso_backend/tests/shared_data/test_views.py +++ b/terraso_backend/tests/shared_data/test_views.py @@ -40,13 +40,8 @@ def data_entry_payload(request, group, landscape): content=json.dumps({"key": "value", "keyN": "valueN"}).encode(), content_type="application/json", ), - **( - {"groups": [group.slug]} - if type == "group" - else {"landscapes": [landscape.slug]} - if type == "landscape" - else {} - ), + target_type=type, + target_slug=group.slug if type == "group" else landscape.slug, ) @@ -71,29 +66,29 @@ def test_create_oversized_data_entry(mock_get_size, logged_client, upload_url, d @pytest.mark.parametrize("data_entry_payload", ["group", "landscape"], indirect=True) -def test_create_data_entry_successfully(logged_client, upload_url, data_entry_payload): +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"] - if "landscape" in data_entry_payload: - assert "landscapes" in response_data - assert "groups" not in response_data - if "group" in data_entry_payload: - assert "groups" in response_data - assert "landscapes" not in response_data + 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) From 7d81af7ea8814186efae1508e3b87b014788bb34 Mon Sep 17 00:00:00 2001 From: Jose Buitron Date: Wed, 18 Oct 2023 10:05:06 -0500 Subject: [PATCH 10/11] fix: Small fixes --- .../apps/core/models/shared_resources.py | 2 +- .../apps/shared_data/permission_rules.py | 2 + terraso_backend/apps/shared_data/views.py | 66 +++++++------------ terraso_backend/tests/graphql/conftest.py | 15 +++-- 4 files changed, 37 insertions(+), 48 deletions(-) diff --git a/terraso_backend/apps/core/models/shared_resources.py b/terraso_backend/apps/core/models/shared_resources.py index 45e51d744..211117fde 100644 --- a/terraso_backend/apps/core/models/shared_resources.py +++ b/terraso_backend/apps/core/models/shared_resources.py @@ -23,7 +23,7 @@ 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: Lanscape). + Target represents the resource that is receiving the shared resource (Example: Landscape). """ source = GenericForeignKey("source_content_type", "source_object_id") diff --git a/terraso_backend/apps/shared_data/permission_rules.py b/terraso_backend/apps/shared_data/permission_rules.py index 3f75c29f2..cec25cacd 100644 --- a/terraso_backend/apps/shared_data/permission_rules.py +++ b/terraso_backend/apps/shared_data/permission_rules.py @@ -39,6 +39,7 @@ def is_user_allowed_to_view_data_entry(data_entry, user): 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): @@ -46,6 +47,7 @@ def is_user_allowed_to_change_data_entry(data_entry, user): for shared_resource in shared_resources: if is_target_manager(user, shared_resource.target): return True + return False @rules.predicate diff --git a/terraso_backend/apps/shared_data/views.py b/terraso_backend/apps/shared_data/views.py index ef98af751..7b3fc47a8 100644 --- a/terraso_backend/apps/shared_data/views.py +++ b/terraso_backend/apps/shared_data/views.py @@ -47,24 +47,13 @@ def post(self, request, **kwargs): target_slug = form_data.pop("target_slug")[0] if target_type not in ["group", "landscape"]: logger.error("Invalid target_type provided when adding dataEntry") - return JsonResponse( - { - "errors": [ - { - "message": [ - asdict( - ErrorMessage( - code="Invalid target_type provided when adding dataEntry", - context=ErrorContext( - model="DataEntry", field="target_type" - ), - ) - ) - ] - } - ] - }, - status=400, + 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) @@ -77,24 +66,13 @@ def post(self, request, **kwargs): "Target not found when adding dataEntry", extra={"target_type": target_type, "target_slug": target_slug}, ) - return JsonResponse( - { - "errors": [ - { - "message": [ - asdict( - ErrorMessage( - code="Target not found when adding dataEntry", - context=ErrorContext( - model="DataEntry", field="target_type" - ), - ) - ) - ] - } - ] - }, - status=400, + 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")): @@ -102,27 +80,25 @@ def post(self, request, **kwargs): 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() @@ -154,3 +130,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/tests/graphql/conftest.py b/terraso_backend/tests/graphql/conftest.py index 3811688c1..e6db1caf2 100644 --- a/terraso_backend/tests/graphql/conftest.py +++ b/terraso_backend/tests/graphql/conftest.py @@ -251,7 +251,9 @@ def data_entry_current_user_file(users, groups): resource = mixer.blend( SharedResource, target=creator_group, - source=mixer.blend(DataEntry, created_by=creator, entry_type=DataEntry.ENTRY_TYPE_FILE), + source=mixer.blend( + DataEntry, slug=None, created_by=creator, size=100, entry_type=DataEntry.ENTRY_TYPE_FILE + ), ) return resource.source @@ -264,7 +266,9 @@ def data_entry_current_user_link(users, groups): resource = mixer.blend( SharedResource, target=creator_group, - source=mixer.blend(DataEntry, created_by=creator, entry_type=DataEntry.ENTRY_TYPE_LINK), + source=mixer.blend( + DataEntry, slug=None, created_by=creator, entry_type=DataEntry.ENTRY_TYPE_LINK + ), ) return resource.source @@ -277,7 +281,7 @@ def data_entry_other_user(users, groups): resource = mixer.blend( SharedResource, target=creator_group, - source=mixer.blend(DataEntry, created_by=creator, size=100), + source=mixer.blend(DataEntry, slug=None, created_by=creator, size=100), ) return resource.source @@ -337,16 +341,17 @@ def visualization_configs(users, groups): creator = users[0] creator_group = groups[1] creator_group.members.add(creator) - return mixer.cycle(5).blend( + visualizations = mixer.cycle(5).blend( VisualizationConfig, created_by=creator, data_entry=lambda: mixer.blend( SharedResource, target=creator_group, - source=lambda: mixer.blend(DataEntry, created_by=creator), + source=lambda: mixer.blend(DataEntry, created_by=creator, size=100), ).source, owner=creator_group, ) + return visualizations @pytest.fixture From ed3430adbf10347f8ae687e5ebe4f8464f5b32eb Mon Sep 17 00:00:00 2001 From: Jose Buitron Date: Mon, 23 Oct 2023 11:15:07 -0500 Subject: [PATCH 11/11] fix: Added constant for valid target types for data entries, added more specific exception --- terraso_backend/apps/graphql/schema/data_entries.py | 5 +++-- terraso_backend/apps/shared_data/models/data_entries.py | 2 ++ terraso_backend/apps/shared_data/views.py | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/terraso_backend/apps/graphql/schema/data_entries.py b/terraso_backend/apps/graphql/schema/data_entries.py index 0a9991740..5ba5689d2 100644 --- a/terraso_backend/apps/graphql/schema/data_entries.py +++ b/terraso_backend/apps/graphql/schema/data_entries.py @@ -26,6 +26,7 @@ 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 @@ -139,7 +140,7 @@ def mutate_and_get_payload(cls, root, info, **kwargs): target_type = kwargs.pop("target_type") target_slug = kwargs.pop("target_slug") - if target_type not in ["group", "landscape"]: + if target_type not in VALID_TARGET_TYPES: logger.error("Invalid target_type provided when adding dataEntry") raise GraphQLNotFoundException( field="target_type", @@ -151,7 +152,7 @@ def mutate_and_get_payload(cls, root, info, **kwargs): try: target = model_class.objects.get(slug=target_slug) - except Exception: + except model_class.DoesNotExist: logger.error( "Target not found when adding dataEntry", extra={"target_type": target_type, "target_slug": target_slug}, diff --git a/terraso_backend/apps/shared_data/models/data_entries.py b/terraso_backend/apps/shared_data/models/data_entries.py index 62228d397..77c0b1a7b 100644 --- a/terraso_backend/apps/shared_data/models/data_entries.py +++ b/terraso_backend/apps/shared_data/models/data_entries.py @@ -23,6 +23,8 @@ 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): """ diff --git a/terraso_backend/apps/shared_data/views.py b/terraso_backend/apps/shared_data/views.py index 7b3fc47a8..035ef02c9 100644 --- a/terraso_backend/apps/shared_data/views.py +++ b/terraso_backend/apps/shared_data/views.py @@ -30,6 +30,7 @@ from .forms import DataEntryForm from .models import DataEntry +from .models.data_entries import VALID_TARGET_TYPES logger = structlog.get_logger(__name__) @@ -45,7 +46,7 @@ def post(self, request, **kwargs): 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 ["group", "landscape"]: + if target_type not in VALID_TARGET_TYPES: logger.error("Invalid target_type provided when adding dataEntry") return get_json_response_error( [