From 4bc0e9f01e9c6565eff1ba7e6713df52260f12fd Mon Sep 17 00:00:00 2001 From: shrouxm Date: Thu, 14 Sep 2023 16:47:53 -0700 Subject: [PATCH] feat: track whether sites and projects have been seen by users --- .../apps/graphql/schema/__init__.py | 11 ++++- .../apps/graphql/schema/projects.py | 30 +++++++++++- .../apps/graphql/schema/schema.graphql | 26 +++++++++++ terraso_backend/apps/graphql/schema/sites.py | 30 +++++++++++- .../0015_project_seen_by_site_seen_by.py | 24 ++++++++++ .../project_management/models/projects.py | 5 ++ .../apps/project_management/models/sites.py | 5 ++ .../tests/graphql/mutations/test_projects.py | 46 +++++++++++++++++-- .../tests/graphql/mutations/test_sites.py | 35 ++++++++++++++ 9 files changed, 204 insertions(+), 8 deletions(-) create mode 100644 terraso_backend/apps/project_management/migrations/0015_project_seen_by_site_seen_by.py diff --git a/terraso_backend/apps/graphql/schema/__init__.py b/terraso_backend/apps/graphql/schema/__init__.py index 9a6a6fde4..68a0622c2 100644 --- a/terraso_backend/apps/graphql/schema/__init__.py +++ b/terraso_backend/apps/graphql/schema/__init__.py @@ -61,10 +61,17 @@ ProjectAddMutation, ProjectArchiveMutation, ProjectDeleteMutation, + ProjectMarkSeenMutation, ProjectNode, ProjectUpdateMutation, ) -from .sites import SiteAddMutation, SiteDeleteMutation, SiteNode, SiteUpdateMutation +from .sites import ( + SiteAddMutation, + SiteDeleteMutation, + SiteMarkSeenMutation, + SiteNode, + SiteUpdateMutation, +) from .story_maps import ( StoryMapDeleteMutation, StoryMapMembershipApproveMutation, @@ -157,10 +164,12 @@ class Mutations(graphene.ObjectType): add_site = SiteAddMutation.Field() update_site = SiteUpdateMutation.Field() delete_site = SiteDeleteMutation.Field() + mark_site_seen = SiteMarkSeenMutation.Field() add_project = ProjectAddMutation.Field() update_project = ProjectUpdateMutation.Field() archive_project = ProjectArchiveMutation.Field() delete_project = ProjectDeleteMutation.Field() + mark_project_seen = ProjectMarkSeenMutation.Field() update_soil_data = SoilDataUpdateMutation.Field() update_depth_dependent_soil_data = DepthDependentSoilDataUpdateMutation.Field() diff --git a/terraso_backend/apps/graphql/schema/projects.py b/terraso_backend/apps/graphql/schema/projects.py index 05d697fa4..00547249b 100644 --- a/terraso_backend/apps/graphql/schema/projects.py +++ b/terraso_backend/apps/graphql/schema/projects.py @@ -26,7 +26,13 @@ from apps.project_management.models import Project from apps.project_management.models.sites import Site -from .commons import BaseDeleteMutation, BaseWriteMutation, TerrasoConnection +from .commons import ( + BaseAuthenticatedMutation, + BaseDeleteMutation, + BaseMutation, + BaseWriteMutation, + TerrasoConnection, +) class ProjectFilterSet(FilterSet): @@ -39,6 +45,7 @@ class Meta: class ProjectNode(DjangoObjectType): id = graphene.ID(source="pk", required=True) + seen = graphene.Boolean(required=True) class Meta: model = Project @@ -49,6 +56,12 @@ class Meta: interfaces = (relay.Node,) connection_class = TerrasoConnection + def resolve_seen(self, info): + user = info.context.user + if user.is_anonymous: + return True + return self.seen_by.filter(id=user.id).exists() + class ProjectPrivacy(graphene.Enum): PRIVATE = Project.PRIVATE @@ -74,6 +87,7 @@ def mutate_and_get_payload(cls, root, info, **kwargs): kwargs["privacy"] = kwargs["privacy"].value result = super().mutate_and_get_payload(root, info, **kwargs) result.project.add_manager(user) + result.project.mark_seen_by(user) client_time = kwargs.get("client_time", None) if not client_time: @@ -90,6 +104,20 @@ def mutate_and_get_payload(cls, root, info, **kwargs): return result +class ProjectMarkSeenMutation(BaseAuthenticatedMutation): + project = graphene.Field(ProjectNode, required=True) + + class Input: + id = graphene.ID(required=True) + + @classmethod + def mutate_and_get_payload(cls, root, info, id): + user = info.context.user + project = BaseMutation.get_or_throw(Project, "id", id) + project.mark_seen_by(user) + return ProjectMarkSeenMutation(project=project) + + class ProjectDeleteMutation(BaseDeleteMutation): project = graphene.Field(ProjectNode, required=True) diff --git a/terraso_backend/apps/graphql/schema/schema.graphql b/terraso_backend/apps/graphql/schema/schema.graphql index e05ba86cc..44a7f39dc 100644 --- a/terraso_backend/apps/graphql/schema/schema.graphql +++ b/terraso_backend/apps/graphql/schema/schema.graphql @@ -655,6 +655,7 @@ type ProjectNode implements Node { orderBy: String ): SiteNodeConnection! id: ID! + seen: Boolean! } """An enumeration.""" @@ -695,6 +696,7 @@ type SiteNode implements Node { archived: Boolean! soilData: SoilDataNode id: ID! + seen: Boolean! } """An enumeration.""" @@ -1290,10 +1292,12 @@ type Mutations { addSite(input: SiteAddMutationInput!): SiteAddMutationPayload! updateSite(input: SiteUpdateMutationInput!): SiteUpdateMutationPayload! deleteSite(input: SiteDeleteMutationInput!): SiteDeleteMutationPayload! + markSiteSeen(input: SiteMarkSeenMutationInput!): SiteMarkSeenMutationPayload! addProject(input: ProjectAddMutationInput!): ProjectAddMutationPayload! updateProject(input: ProjectUpdateMutationInput!): ProjectUpdateMutationPayload! archiveProject(input: ProjectArchiveMutationInput!): ProjectArchiveMutationPayload! deleteProject(input: ProjectDeleteMutationInput!): ProjectDeleteMutationPayload! + markProjectSeen(input: ProjectMarkSeenMutationInput!): ProjectMarkSeenMutationPayload! updateSoilData(input: SoilDataUpdateMutationInput!): SoilDataUpdateMutationPayload! updateDepthDependentSoilData(input: DepthDependentSoilDataUpdateMutationInput!): DepthDependentSoilDataUpdateMutationPayload! } @@ -1737,6 +1741,17 @@ input SiteDeleteMutationInput { clientMutationId: String } +type SiteMarkSeenMutationPayload { + errors: GenericScalar + site: SiteNode! + clientMutationId: String +} + +input SiteMarkSeenMutationInput { + id: ID! + clientMutationId: String +} + type ProjectAddMutationPayload { errors: GenericScalar project: ProjectNode! @@ -1793,6 +1808,17 @@ input ProjectDeleteMutationInput { clientMutationId: String } +type ProjectMarkSeenMutationPayload { + errors: GenericScalar + project: ProjectNode! + clientMutationId: String +} + +input ProjectMarkSeenMutationInput { + id: ID! + clientMutationId: String +} + type SoilDataUpdateMutationPayload { errors: GenericScalar soilData: SoilDataNode diff --git a/terraso_backend/apps/graphql/schema/sites.py b/terraso_backend/apps/graphql/schema/sites.py index bd4a31434..54d8a2987 100644 --- a/terraso_backend/apps/graphql/schema/sites.py +++ b/terraso_backend/apps/graphql/schema/sites.py @@ -24,7 +24,13 @@ from apps.audit_logs import api as audit_log_api from apps.project_management.models import Project, Site, sites -from .commons import BaseDeleteMutation, BaseWriteMutation, TerrasoConnection +from .commons import ( + BaseAuthenticatedMutation, + BaseDeleteMutation, + BaseMutation, + BaseWriteMutation, + TerrasoConnection, +) from .constants import MutationTypes @@ -47,6 +53,7 @@ class Meta: class SiteNode(DjangoObjectType): id = graphene.ID(source="pk", required=True) + seen = graphene.Boolean(required=True) class Meta: model = Site @@ -78,6 +85,12 @@ def get_queryset(cls, queryset, info): def privacy_enum(cls): return cls._meta.fields["privacy"].type.of_type() + def resolve_seen(self, info): + user = info.context.user + if user.is_anonymous: + return True + return self.seen_by.filter(id=user.id).exists() + class SiteAddMutation(BaseWriteMutation): site = graphene.Field(SiteNode, required=True) @@ -117,6 +130,7 @@ def mutate_and_get_payload(cls, root, info, **kwargs): return result site = result.site + site.mark_seen_by(user) metadata = { "latitude": kwargs["latitude"], "longitude": kwargs["longitude"], @@ -134,6 +148,20 @@ def mutate_and_get_payload(cls, root, info, **kwargs): return result +class SiteMarkSeenMutation(BaseAuthenticatedMutation): + site = graphene.Field(SiteNode, required=True) + + class Input: + id = graphene.ID(required=True) + + @classmethod + def mutate_and_get_payload(cls, root, info, id): + user = info.context.user + site = BaseMutation.get_or_throw(Site, "id", id) + site.mark_seen_by(user) + return SiteMarkSeenMutation(site=site) + + class SiteUpdateMutation(BaseWriteMutation): site = graphene.Field(SiteNode) diff --git a/terraso_backend/apps/project_management/migrations/0015_project_seen_by_site_seen_by.py b/terraso_backend/apps/project_management/migrations/0015_project_seen_by_site_seen_by.py new file mode 100644 index 000000000..e1c3a1bb2 --- /dev/null +++ b/terraso_backend/apps/project_management/migrations/0015_project_seen_by_site_seen_by.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.5 on 2023-09-14 23:47 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("project_management", "0014_site_privacy_alter_project_description"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="seen_by", + field=models.ManyToManyField(related_name="+", to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name="site", + name="seen_by", + field=models.ManyToManyField(related_name="+", to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/terraso_backend/apps/project_management/models/projects.py b/terraso_backend/apps/project_management/models/projects.py index ece13eac7..2cc0f1916 100644 --- a/terraso_backend/apps/project_management/models/projects.py +++ b/terraso_backend/apps/project_management/models/projects.py @@ -55,6 +55,8 @@ class Meta(BaseModel.Meta): max_length=32, choices=PRIVACY_STATUS, default=DEFAULT_PRIVACY_STATUS ) + seen_by = models.ManyToManyField(User, related_name="+") + @staticmethod def default_settings(): settings = ProjectSettings() @@ -105,5 +107,8 @@ def add_manager(self, user: User): def add_member(self, user: User): return self.group.add_member(user) + def mark_seen_by(self, user: User): + self.seen_by.add(user) + def __str__(self): return self.name diff --git a/terraso_backend/apps/project_management/models/sites.py b/terraso_backend/apps/project_management/models/sites.py index 014a50e1c..367d8d45b 100644 --- a/terraso_backend/apps/project_management/models/sites.py +++ b/terraso_backend/apps/project_management/models/sites.py @@ -53,6 +53,8 @@ class Meta(BaseModel.Meta): verbose_name="owner to which the site belongs", ) + seen_by = models.ManyToManyField(User, related_name="+") + PRIVATE = "private" PUBLIC = "public" DEFAULT_PRIVACY_STATUS = PRIVATE @@ -97,6 +99,9 @@ def owned_by(self, obj: Union[Project, User]): def human_readable(self) -> str: return self.name + def mark_seen_by(self, user: User): + self.seen_by.add(user) + def filter_only_sites_user_owner_or_member(user: User, queryset): return queryset.filter( diff --git a/terraso_backend/tests/graphql/mutations/test_projects.py b/terraso_backend/tests/graphql/mutations/test_projects.py index 1228da2c3..a32589d14 100644 --- a/terraso_backend/tests/graphql/mutations/test_projects.py +++ b/terraso_backend/tests/graphql/mutations/test_projects.py @@ -1,6 +1,7 @@ import json import pytest +import structlog from graphene_django.utils.testing import graphql_query from mixer.backend.django import mixer @@ -12,19 +13,24 @@ pytestmark = pytest.mark.django_db +logger = structlog.get_logger(__name__) -def test_create_project(client, user): - client.force_login(user) - response = graphql_query( - """ +CREATE_PROJECT_QUERY = """ mutation createProject($input: ProjectAddMutationInput!) { addProject(input: $input) { project { id + seen } } } - """, +""" + + +def test_create_project(client, user): + client.force_login(user) + response = graphql_query( + CREATE_PROJECT_QUERY, variables={ "input": {"name": "testProject", "privacy": "PRIVATE", "description": "A test project"} }, @@ -194,3 +200,33 @@ def test_update_project_user_not_manager(project, client): error_result = response.json()["data"]["updateProject"]["errors"][0]["message"] json_error = json.loads(error_result) assert json_error[0]["code"] == "change_not_allowed" + + +def test_mark_project_seen(client, user): + client.force_login(user) + response = graphql_query( + CREATE_PROJECT_QUERY, + variables={"input": {"name": "project", "privacy": "PUBLIC"}}, + client=client, + ) + project = response.json()["data"]["addProject"]["project"] + assert project["seen"] is True + + client.force_login(mixer.blend(User)) + response = graphql_query( + "query project($id: ID!){ project(id: $id) { seen } }", + variables={"id": project["id"]}, + client=client, + ) + assert response.json()["data"]["project"]["seen"] is False + + response = graphql_query( + """ + mutation($input: ProjectMarkSeenMutationInput!){ + markProjectSeen(input: $input) { project { seen } } + } + """, + variables={"input": {"id": project["id"]}}, + client=client, + ) + assert response.json()["data"]["markProjectSeen"]["project"]["seen"] is True diff --git a/terraso_backend/tests/graphql/mutations/test_sites.py b/terraso_backend/tests/graphql/mutations/test_sites.py index 08b1a291b..3ad85b261 100644 --- a/terraso_backend/tests/graphql/mutations/test_sites.py +++ b/terraso_backend/tests/graphql/mutations/test_sites.py @@ -15,6 +15,7 @@ import json import pytest +import structlog from graphene_django.utils.testing import graphql_query from mixer.backend.django import mixer @@ -26,11 +27,14 @@ pytestmark = pytest.mark.django_db +logger = structlog.get_logger(__name__) + CREATE_SITE_QUERY = """ mutation createSite($input: SiteAddMutationInput!) { addSite(input: $input) { site { id + seen } } } @@ -248,3 +252,34 @@ def test_delete_site_not_allowed(client, site): assert json.loads(error_msg)[0]["code"] == "delete_not_allowed" assert len(Site.objects.filter(id=site.id)) == 1 + + +def test_mark_site_seen(client, user): + client.force_login(user) + response = graphql_query( + CREATE_SITE_QUERY, + variables={"input": {"name": "site", "latitude": 0, "longitude": 0}}, + client=client, + ) + site = response.json()["data"]["addSite"]["site"] + assert site["seen"] is True + + client.force_login(mixer.blend(User)) + response = graphql_query( + "query site($id: ID!){ site(id: $id) { seen } }", + variables={"id": site["id"]}, + client=client, + ) + assert response.json()["data"]["site"]["seen"] is False + + response = graphql_query( + """ + mutation($input: SiteMarkSeenMutationInput!){ + markSiteSeen(input: $input) { site { seen } } + } + """, + variables={"input": {"id": site["id"]}}, + client=client, + ) + logger.info(response.json()) + assert response.json()["data"]["markSiteSeen"]["site"]["seen"] is True