diff --git a/docs/projects.rst b/docs/projects.rst index ba62d694e2..6a2db91ab3 100644 --- a/docs/projects.rst +++ b/docs/projects.rst @@ -46,22 +46,73 @@ Response ^^^^^^^^^ :: - [ - { - "url": "https://api.ona.io/api/v1/projects/1", - "owner": "https://api.ona.io/api/v1/users/ona", - "name": "project 1", - "date_created": "2013-07-24T13:37:39Z", - "date_modified": "2013-07-24T13:37:39Z" - }, - { - "url": "https://api.ona.io/api/v1/projects/4", - "owner": "https://api.ona.io/api/v1/users/ona", - "name": "project 2", - "date_created": "2013-07-24T13:59:10Z", - "date_modified": "2013-07-24T13:59:10Z" - }, ... - ] + [ + { + "url": "https://api.ona.io/api/v1/projects/21001", + "projectid": 21001, + "owner": "https://api.ona.io/api/v1/users/jdoe", + "created_by": "https://api.ona.io/api/v1/users/jdoe", + "metadata": { + "name": "Health Survey", + "category": "health" + }, + "starred": true, + "current_user_role": "owner", + "forms": [ + { + "name": "Community Health Check", + "formid": 30045, + "id_string": "Community_Health_Check", + "is_merged_dataset": false, + "encrypted": true, + "contributes_entities_to": null, + "consumes_entities_from": [] + } + ], + "public": false, + "tags": ["survey", "health"], + "num_datasets": 2, + "last_submission_date": "2025-09-10T10:15:22.123456Z", + "teams": [], + "name": "Health Survey", + "date_created": "2025-09-05T09:00:00.000000Z", + "date_modified": "2025-09-08T14:22:11.000000Z", + "deleted_at": null + }, + { + "url": "https://api.ona.io/api/v1/projects/21002", + "projectid": 21002, + "owner": "https://api.ona.io/api/v1/users/asanchez", + "created_by": "https://api.ona.io/api/v1/users/asanchez", + "metadata": { + "name": "Wildlife Tracking", + "category": "conservation" + }, + "starred": false, + "current_user_role": "editor", + "forms": [ + { + "name": "Animal Sighting Log", + "formid": 30078, + "id_string": "Animal_Sighting_Log", + "is_merged_dataset": false, + "encrypted": false, + "contributes_entities_to": null, + "consumes_entities_from": [] + } + ], + "public": true, + "tags": ["wildlife", "tracking"], + "num_datasets": 1, + "last_submission_date": "2025-09-20T17:45:30.654321Z", + "teams": [], + "name": "Wildlife Tracking", + "date_created": "2025-09-15T12:30:00.000000Z", + "date_modified": "2025-09-21T09:40:55.000000Z", + "deleted_at": null + } + ] + Get a paginated list of Projects --------------------------------- @@ -84,22 +135,72 @@ Response ^^^^^^^^^ :: - [ - { - "url": "https://api.ona.io/api/v1/projects/1", - "owner": "https://api.ona.io/api/v1/users/ona", - "name": "project 1", - "date_created": "2013-07-24T13:37:39Z", - "date_modified": "2013-07-24T13:37:39Z" - }, - { - "url": "https://api.ona.io/api/v1/projects/4", - "owner": "https://api.ona.io/api/v1/users/ona", - "name": "project 2", - "date_created": "2013-07-24T13:59:10Z", - "date_modified": "2013-07-24T13:59:10Z" - }, ... - ] + [ + { + "url": "https://api.ona.io/api/v1/projects/21001", + "projectid": 21001, + "owner": "https://api.ona.io/api/v1/users/jdoe", + "created_by": "https://api.ona.io/api/v1/users/jdoe", + "metadata": { + "name": "Health Survey", + "category": "health" + }, + "starred": true, + "current_user_role": "owner", + "forms": [ + { + "name": "Community Health Check", + "formid": 30045, + "id_string": "Community_Health_Check", + "is_merged_dataset": false, + "encrypted": true, + "contributes_entities_to": null, + "consumes_entities_from": [] + } + ], + "public": false, + "tags": ["survey", "health"], + "num_datasets": 2, + "last_submission_date": "2025-09-10T10:15:22.123456Z", + "teams": [], + "name": "Health Survey", + "date_created": "2025-09-05T09:00:00.000000Z", + "date_modified": "2025-09-08T14:22:11.000000Z", + "deleted_at": null + }, + { + "url": "https://api.ona.io/api/v1/projects/21002", + "projectid": 21002, + "owner": "https://api.ona.io/api/v1/users/asanchez", + "created_by": "https://api.ona.io/api/v1/users/asanchez", + "metadata": { + "name": "Wildlife Tracking", + "category": "conservation" + }, + "starred": false, + "current_user_role": "editor", + "forms": [ + { + "name": "Animal Sighting Log", + "formid": 30078, + "id_string": "Animal_Sighting_Log", + "is_merged_dataset": false, + "encrypted": false, + "contributes_entities_to": null, + "consumes_entities_from": [] + } + ], + "public": true, + "tags": ["wildlife", "tracking"], + "num_datasets": 1, + "last_submission_date": "2025-09-20T17:45:30.654321Z", + "teams": [], + "name": "Wildlife Tracking", + "date_created": "2025-09-15T12:30:00.000000Z", + "date_modified": "2025-09-21T09:40:55.000000Z", + "deleted_at": null + } + ] List of Projects filter by owner/organization ---------------------------------------------- @@ -137,83 +238,87 @@ Response :: { - "url":"https://api.ona.io/api/v1/projects/1", - "projectid":1, - "owner":"https://api.ona.io/api/v1/users/ona", - "created_by":"https://api.ona.io/api/v1/users/ona", - "metadata":{ - "name":"Entities", - "category":"agriculture" + "url": "https://api.ona.io/api/v1/projects/1", + "projectid": 1, + "owner": "https://api.ona.io/api/v1/users/jdoe", + "created_by": "https://api.ona.io/api/v1/users/jdoe", + "metadata": { + "name": "Urban Water Access", + "category": "infrastructure" }, - "starred":false, - "users":[ + "starred": true, + "users": [ + { + "is_org": false, + "metadata": { + "is_email_verified": true, + "last_password_edit": "2024-10-22T13:12:55.987654+00:00" + }, + "first_name": "Jane", + "last_name": "Doe", + "user": "jdoe", + "role": "owner" + }, { - "is_org":false, - "metadata":{ - "is_email_verified":false + "is_org": false, + "metadata": { + "is_email_verified": false, + "last_password_edit": "2024-08-15T10:30:45.000000+00:00" }, - "first_name":"Ona", - "last_name":"", - "user":"ona", - "role":"owner" + "first_name": "Alex", + "last_name": "Sanchez", + "user": "asanchez", + "role": "editor" } ], - "forms":[ + "forms": [ { - "name":"Trees registration", - "formid":1, - "id_string":"trees_registration", - "num_of_submissions":7, - "downloadable":true, - "encrypted":false, - "published_by_formbuilder":null, - "last_submission_time":"2024-06-18T14:34:57.987361Z", - "date_created":"2024-05-28T12:08:07.993820Z", - "url":"https://api.ona.io/api/v1/forms/1", - "last_updated_at":"2024-06-21T08:13:06.436449Z", - "is_merged_dataset":false, - "contributes_entities_to":{ - "id":100, - "name":"trees", - "is_active":true - }, - "consumes_entities_from":[] + "name": "Water Point Survey", + "formid": 31012, + "id_string": "Water_Point_Survey", + "num_of_submissions": 145, + "downloadable": true, + "encrypted": true, + "published_by_formbuilder": null, + "last_submission_time": "2025-09-18T15:20:45.321000Z", + "date_created": "2025-09-10T07:15:00.000000Z", + "url": "https://api.ona.io/api/v1/forms/31012", + "last_updated_at": "2025-09-18T15:22:30.000000Z", + "is_merged_dataset": false, + "contributes_entities_to": null, + "consumes_entities_from": [] }, { - "name":"Trees follow-up", - "formid":18421, - "id_string":"trees_follow_up", - "num_of_submissions":0, - "downloadable":true, - "encrypted":false, - "published_by_formbuilder":null, - "last_submission_time":null, - "date_created":"2024-05-28T12:08:39.909235Z", - "url":"https://api.ona.io/api/v1/forms/2", - "last_updated_at":"2024-06-21T08:13:58.963836Z", - "is_merged_dataset":false, - "contributes_entities_to":null, - "consumes_entities_from":[ - { - "id":100, - "name":"trees", - "is_active":true - } - ] + "name": "Household Feedback", + "formid": 31013, + "id_string": "Household_Feedback", + "num_of_submissions": 67, + "downloadable": true, + "encrypted": false, + "published_by_formbuilder": "asanchez", + "last_submission_time": "2025-09-20T11:05:12.000000Z", + "date_created": "2025-09-12T09:30:00.000000Z", + "url": "https://api.ona.io/api/v1/forms/31013", + "last_updated_at": "2025-09-21T08:00:00.000000Z", + "is_merged_dataset": false, + "contributes_entities_to": null, + "consumes_entities_from": [] } ], - "public":false, - "tags":[], - "num_datasets":2, - "last_submission_date":"2024-06-18T14:50:32.755792Z", - "teams":[], - "data_views":[], - "name":"Entities", - "date_created":"2023-11-07T07:02:09.655836Z", - "date_modified":"2024-06-21T08:15:12.634454Z", - "deleted_at":null + "public": true, + "tags": ["infrastructure", "survey", "urban"], + "num_datasets": 2, + "last_submission_date": "2025-09-20T11:05:12.000000Z", + "teams": [], + "data_views": [], + "name": "Urban Water Access", + "date_created": "2025-09-10T07:10:00.000000Z", + "date_modified": "2025-09-21T08:05:00.000000Z", + "deleted_at": null, + "current_user_role": "editor" } + Update Project Information ------------------------------ .. raw:: html @@ -231,18 +336,87 @@ Response ^^^^^^^^^ :: - { - "url": "https://api.ona.io/api/v1/projects/1", - "owner": "https://api.ona.io/api/v1/users/ona", - "name": "project 1", - "metadata": { - "description": "Lorem ipsum", - "location": "Nakuru, Kenya", - "category": "water" - }, - "date_created": "2013-07-24T13:37:39Z", - "date_modified": "2013-07-24T13:37:39Z" - } + { + "url": "https://api.ona.io/api/v1/projects/1", + "projectid": 1, + "owner": "https://api.ona.io/api/v1/users/jdoe", + "created_by": "https://api.ona.io/api/v1/users/jdoe", + "metadata": { + "description": "Lorem ipsum", + "location": "Nakuru, Kenya", + "category": "water" + }, + "starred": true, + "users": [ + { + "is_org": false, + "metadata": { + "is_email_verified": true, + "last_password_edit": "2024-10-22T13:12:55.987654+00:00" + }, + "first_name": "Jane", + "last_name": "Doe", + "user": "jdoe", + "role": "owner" + }, + { + "is_org": false, + "metadata": { + "is_email_verified": false, + "last_password_edit": "2024-08-15T10:30:45.000000+00:00" + }, + "first_name": "Alex", + "last_name": "Sanchez", + "user": "asanchez", + "role": "editor" + } + ], + "forms": [ + { + "name": "Water Point Survey", + "formid": 31012, + "id_string": "Water_Point_Survey", + "num_of_submissions": 145, + "downloadable": true, + "encrypted": true, + "published_by_formbuilder": null, + "last_submission_time": "2025-09-18T15:20:45.321000Z", + "date_created": "2025-09-10T07:15:00.000000Z", + "url": "https://api.ona.io/api/v1/forms/31012", + "last_updated_at": "2025-09-18T15:22:30.000000Z", + "is_merged_dataset": false, + "contributes_entities_to": null, + "consumes_entities_from": [] + }, + { + "name": "Household Feedback", + "formid": 31013, + "id_string": "Household_Feedback", + "num_of_submissions": 67, + "downloadable": true, + "encrypted": false, + "published_by_formbuilder": "asanchez", + "last_submission_time": "2025-09-20T11:05:12.000000Z", + "date_created": "2025-09-12T09:30:00.000000Z", + "url": "https://api.ona.io/api/v1/forms/31013", + "last_updated_at": "2025-09-21T08:00:00.000000Z", + "is_merged_dataset": false, + "contributes_entities_to": null, + "consumes_entities_from": [] + } + ], + "public": true, + "tags": ["infrastructure", "survey", "urban"], + "num_datasets": 2, + "last_submission_date": "2025-09-20T11:05:12.000000Z", + "teams": [], + "data_views": [], + "name": "Urban Water Access", + "date_created": "2025-09-10T07:10:00.000000Z", + "date_modified": "2025-09-21T08:05:00.000000Z", + "deleted_at": null, + "current_user_role": "editor" + } Available Permission Roles -------------------------- diff --git a/onadata/apps/api/tests/viewsets/test_project_viewset.py b/onadata/apps/api/tests/viewsets/test_project_viewset.py index 30f62331ed..52289a8ef2 100644 --- a/onadata/apps/api/tests/viewsets/test_project_viewset.py +++ b/onadata/apps/api/tests/viewsets/test_project_viewset.py @@ -61,6 +61,7 @@ from onadata.libs.serializers.metadata_serializer import MetaDataSerializer from onadata.libs.serializers.project_serializer import ( BaseProjectSerializer, + ProjectPrivateSerializer, ProjectSerializer, ) from onadata.libs.utils.cache_tools import PROJ_OWNER_CACHE, safe_key @@ -178,17 +179,8 @@ def test_projects_list(self): ), ("starred", False), ( - "users", - [ - { - "is_org": False, - "metadata": {}, - "first_name": "Bob", - "last_name": "erama", - "user": "bob", - "role": "owner", - } - ], + "current_user_role", + "owner", ), ( "forms", @@ -306,47 +298,6 @@ def test_project_list_returns_projects_for_active_users_only(self): break self.assertTrue(shared_project_in_response) - # pylint: disable=invalid-name - def test_project_list_returns_users_own_project_is_shared_to(self): - """ - Ensure that the project list responses for project owners - contains all the users the project has been shared too - """ - self._project_create() - alice_data = {"username": "alice", "email": "alice@localhost.com"} - alice_profile = self._create_user_profile(alice_data) - - share_project = ShareProject(self.project, "alice", "manager") - share_project.save() - - # Ensure alice is in the list of users - # When an owner requests for the project data - req = self.factory.get("/", **self.extra) - resp = self.view(req) - - self.assertEqual(resp.status_code, 200) - self.assertEqual(len(resp.data[0]["users"]), 2) - shared_users = [user["user"] for user in resp.data[0]["users"]] - self.assertIn(alice_profile.user.username, shared_users) - - # Ensure project managers can view all users the project was shared to - davis_data = {"username": "davis", "email": "davis@localhost.com"} - davis_profile = self._create_user_profile(davis_data) - dave_extras = {"HTTP_AUTHORIZATION": f"Token {davis_profile.user.auth_token}"} - share_project = ShareProject( - self.project, davis_profile.user.username, "manager" - ) - share_project.save() - - req = self.factory.get("/", **dave_extras) - resp = self.view(req) - - self.assertEqual(resp.status_code, 200) - self.assertEqual(len(resp.data[0]["users"]), 3) - shared_users = [user["user"] for user in resp.data[0]["users"]] - self.assertIn(alice_profile.user.username, shared_users) - self.assertIn(self.user.username, shared_users) - def test_projects_get(self): self._project_create() view = ProjectViewSet.as_view({"get": "retrieve"}) @@ -359,11 +310,14 @@ def test_projects_get(self): self.assertEqual(response.status_code, 200) # test serialized data - serializer = ProjectSerializer(self.project, context={"request": request}) - self.assertEqual(response.data, serializer.data) + public_data = ProjectSerializer(self.project, context={"request": request}).data + private_data = ProjectPrivateSerializer( + self.project, context={"request": request} + ).data + self.assertEqual(response.data, {**public_data, **private_data}) self.assertIsNotNone(self.project_data) - self.assertEqual(response.data, self.project_data) + self.assertEqual(response.data, {**self.project_data, **private_data}) res_user_props = list(response.data["users"][0]) res_user_props.sort() self.assertEqual(res_user_props, user_props) @@ -1728,7 +1682,10 @@ def test_cache_updated_on_project_update(self): self.assertEqual(response.status_code, 200) self.assertEqual(False, response.data.get("public")) cached_project = cache.get(f"{PROJ_OWNER_CACHE}{self.project.pk}") - self.assertEqual(cached_project, response.data) + # Response without user specific fields + res_wo_private = {**response.data} + res_wo_private.pop("current_user_role") + self.assertEqual(cached_project, res_wo_private) projectid = self.project.pk data = {"public": True} @@ -1744,7 +1701,10 @@ def test_cache_updated_on_project_update(self): self.assertEqual(response.status_code, 200) self.assertEqual(True, response.data.get("public")) cached_project = cache.get(f"{PROJ_OWNER_CACHE}{self.project.pk}") - self.assertEqual(cached_project, response.data) + # Response without user specific fields + res_wo_private = {**response.data} + res_wo_private.pop("current_user_role") + self.assertEqual(cached_project, res_wo_private) def test_project_put_updates(self): self._project_create() @@ -2846,19 +2806,8 @@ def test_project_list_by_owner(self): request = self.factory.get("/", data=data, **self.extra) response = view(request) - users = response.data[0]["users"] self.assertEqual(response.status_code, 200) - self.assertIn( - { - "first_name": "Bob", - "last_name": "erama", - "is_org": False, - "role": "readonly", - "user": "alice", - "metadata": {}, - }, - users, - ) + self.assertEqual(response.data[0]["current_user_role"], "owner") def test_projects_soft_delete(self): self._project_create() diff --git a/onadata/apps/api/viewsets/project_viewset.py b/onadata/apps/api/viewsets/project_viewset.py index e877ed43c1..bde7a09a1d 100644 --- a/onadata/apps/api/viewsets/project_viewset.py +++ b/onadata/apps/api/viewsets/project_viewset.py @@ -35,6 +35,7 @@ ) from onadata.libs.serializers.project_serializer import ( BaseProjectSerializer, + ProjectPrivateSerializer, ProjectSerializer, ) from onadata.libs.serializers.share_project_serializer import ( @@ -124,14 +125,18 @@ def update(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs): """Retrieve single project""" - project_id = kwargs.get("pk") - project = safe_cache_get(f"{PROJ_OWNER_CACHE}{project_id}") - if project: - return Response(project) - # pylint: disable=attribute-defined-outside-init - self.object = self.get_object() - serializer = ProjectSerializer(self.object, context={"request": request}) - return Response(serializer.data) + project = self.get_object() + public_data = safe_cache_get(f"{PROJ_OWNER_CACHE}{project.pk}") + + if not public_data: + public_data = ProjectSerializer(project, context={"request": request}).data + + # Inject user specific fields + private_data = ProjectPrivateSerializer( + project, context={"request": request} + ).data + + return Response({**public_data, **private_data}) def list(self, request, *args, **kwargs): """Returns a list of projects""" diff --git a/onadata/libs/serializers/project_serializer.py b/onadata/libs/serializers/project_serializer.py index 129a60350d..0439b9ca37 100644 --- a/onadata/libs/serializers/project_serializer.py +++ b/onadata/libs/serializers/project_serializer.py @@ -9,6 +9,7 @@ from django.db.utils import IntegrityError from django.utils.translation import gettext as _ +from guardian.shortcuts import get_perms from rest_framework import serializers from six import itervalues @@ -324,7 +325,7 @@ class BaseProjectSerializer(serializers.HyperlinkedModelSerializer): ) metadata = JsonField(required=False) starred = serializers.SerializerMethodField() - users = serializers.SerializerMethodField() + current_user_role = serializers.SerializerMethodField() forms = serializers.SerializerMethodField() public = serializers.BooleanField(source="shared") tags = TagListSerializer(read_only=True) @@ -341,7 +342,7 @@ class Meta: "created_by", "metadata", "starred", - "users", + "current_user_role", "forms", "public", "tags", @@ -360,15 +361,16 @@ def get_starred(self, obj): """ return is_starred(obj, self.context["request"]) - def get_users(self, obj): + def get_current_user_role(self, obj): """ - Return a list of users and organizations that have access to the - project. + Return the role of the request user in the project. """ - owner_query_param_in_request = ( - "request" in self.context and "owner" in self.context["request"].GET - ) - return get_users(obj, self.context, owner_query_param_in_request) + if self.context["request"].user.is_anonymous: + return None + + perms = get_perms(self.context["request"].user, obj) + + return get_role(perms, obj) @check_obj def get_forms(self, obj): @@ -667,3 +669,23 @@ def get_data_views(self, obj): safe_cache_set(project_dataview_cache_key, data_views) return data_views + + +class ProjectPrivateSerializer(serializers.ModelSerializer): + """User specific fields for a Project""" + + current_user_role = serializers.SerializerMethodField() + + def get_current_user_role(self, obj): + """ + Return the role of the request user in the project. + """ + if self.context["request"].user.is_anonymous: + return None + + perms = get_perms(self.context["request"].user, obj) + return get_role(perms, obj) + + class Meta: + model = Project + fields = ("current_user_role",) diff --git a/onadata/libs/tests/serializers/test_project_serializer.py b/onadata/libs/tests/serializers/test_project_serializer.py index 7a0b86774b..d65ee42288 100644 --- a/onadata/libs/tests/serializers/test_project_serializer.py +++ b/onadata/libs/tests/serializers/test_project_serializer.py @@ -2,8 +2,10 @@ """ Test onadata.libs.serializers.project_serializer """ + from unittest.mock import MagicMock +from django.contrib.auth.models import AnonymousUser from django.core.cache import cache from rest_framework import serializers @@ -43,38 +45,16 @@ def setUp(self): # Create the project self._project_create(data) - def test_get_users(self): - """""" - # Is none when request to get users lacks a project - users = self.serializer.get_users(None) - self.assertEqual(users, None) - - # Has members and NOT collaborators when NOT passed 'owner' + def test_get_current_user_role(self): request = self.factory.get("/", **self.extra) request.user = self.user - self.serializer.context["request"] = request - users = self.serializer.get_users(self.project) - self.assertEqual( - sorted(users, key=lambda x: x["first_name"]), - [ - { - "first_name": "Bob", - "last_name": "erama", - "is_org": False, - "role": "owner", - "user": "bob", - "metadata": {}, - }, - { - "first_name": "Dennis", - "last_name": "", - "is_org": True, - "role": "owner", - "user": "denoinc", - "metadata": {}, - }, - ], - ) + serializer = BaseProjectSerializer(self.project, context={"request": request}) + self.assertEqual(serializer.data["current_user_role"], "owner") + + # Is none if user is anonymous + request.user = AnonymousUser() + serializer = BaseProjectSerializer(self.project, context={"request": request}) + self.assertIsNone(serializer.data["current_user_role"]) class TestProjectSerializer(TestAbstractViewSet):