From ac853299d3df44682c20dd440177e5e14e1b1b2c Mon Sep 17 00:00:00 2001 From: Quentin BEY Date: Wed, 6 Nov 2024 17:22:01 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20user=20abilities=20f?= =?UTF-8?q?or=20front?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This allows, on a per user basis, the display of features. The main goal here is to allow Team admin or owner to see the management views. We also added the same for the two other features (mailboxes and contacts) This will be improved later if needed :) --- CHANGELOG.md | 4 + src/backend/core/api/serializers.py | 33 +++++++++ src/backend/core/api/viewsets.py | 5 +- src/backend/core/models.py | 57 ++++++++++++++ src/backend/core/tests/test_api_users.py | 74 +++++++++++++++++++ src/backend/people/settings.py | 27 +++++-- .../e2e/__tests__/app-desk/config.spec.ts | 8 +- 7 files changed, 198 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8164d7..9a81a1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Added + +- ✨(teams) allow team management for team admins/owners #509 + ## [1.5.0] - 2024-11-14 ### Removed diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index f9d776c..4e73651 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -71,6 +71,39 @@ class UserSerializer(DynamicFieldsModelSerializer): read_only_fields = ["id", "name", "email", "is_device", "is_staff"] +class UserMeSerializer(UserSerializer): + """ + Serialize the current user. + + Same as the `UserSerializer` but with abilities. + """ + + abilities = serializers.SerializerMethodField() + + class Meta: + model = models.User + fields = [ + "email", + "id", + "is_device", + "is_staff", + "language", + "name", + "timezone", + # added fields + "abilities", + ] + read_only_fields = ["id", "name", "email", "is_device", "is_staff"] + + def get_abilities(self, user: models.User) -> dict: + """Return abilities of the logged-in user on the instance.""" + if user != self.context["request"].user: # Should not happen + raise RuntimeError( + "UserMeSerializer.get_abilities: user is not the same as the request user", + ) + return user.get_abilities() + + class TeamAccessSerializer(serializers.ModelSerializer): """Serialize team accesses.""" diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 6c8973a..137da43 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -188,6 +188,7 @@ class UserViewSet( permission_classes = [permissions.IsSelf] queryset = models.User.objects.all().order_by("-created_at") serializer_class = serializers.UserSerializer + get_me_serializer_class = serializers.UserMeSerializer throttle_classes = [BurstRateThrottle, SustainedRateThrottle] pagination_class = Pagination @@ -225,9 +226,7 @@ class UserViewSet( Return information on currently logged user """ user = request.user - return response.Response( - self.serializer_class(user, context={"request": request}).data - ) + return response.Response(self.get_serializer(user).data) class TeamViewSet( diff --git a/src/backend/core/models.py b/src/backend/core/models.py index d4a7e5a..3b94c13 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -440,6 +440,63 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): raise ValueError("You must first set an email for the user.") mail.send_mail(subject, message, from_email, [self.email], **kwargs) + def get_abilities(self): + """ + Return the user permissions at the application level (ie feature availability). + + Note: this is for display purposes only for now. + + - Contacts view and creation is globally available (or not) for now. + - Teams view is available if the user is owner or admin of at least + one Team for now. It allows to restrict users knowing the existence of + this feature. + - Teams creation is globally available (or not) for now. + - Mailboxes view is available if the user has access to at least one + MailDomain for now. It allows to restrict users knowing the existence of + this feature. + - Mailboxes creation is globally available (or not) for now. + """ + user_info = ( + self._meta.model.objects.filter(pk=self.pk) + .annotate( + teams_can_view=models.Exists( + self.accesses.model.objects.filter( # pylint: disable=no-member + user=models.OuterRef("pk"), + role__in=[RoleChoices.OWNER, RoleChoices.ADMIN], + ) + ), + mailboxes_can_view=models.Exists( + self.mail_domain_accesses.model.objects.filter( # pylint: disable=no-member + user=models.OuterRef("pk") + ) + ), + ) + .only("id") + .get() + ) + + teams_can_view = user_info.teams_can_view + mailboxes_can_view = user_info.mailboxes_can_view + + return { + "contacts": { + "can_view": settings.FEATURES["CONTACTS_DISPLAY"], + "can_create": ( + settings.FEATURES["CONTACTS_DISPLAY"] + and settings.FEATURES["CONTACTS_CREATE"] + ), + }, + "teams": { + "can_view": teams_can_view and settings.FEATURES["TEAMS"], + "can_create": teams_can_view and settings.FEATURES["TEAMS_CREATE"], + }, + "mailboxes": { + "can_view": mailboxes_can_view, + "can_create": mailboxes_can_view + and settings.FEATURES["MAILBOXES_CREATE"], + }, + } + class OrganizationAccess(BaseModel): """ diff --git a/src/backend/core/tests/test_api_users.py b/src/backend/core/tests/test_api_users.py index ee16969..ae858ee 100644 --- a/src/backend/core/tests/test_api_users.py +++ b/src/backend/core/tests/test_api_users.py @@ -16,6 +16,9 @@ from rest_framework.test import APIClient from core import factories, models from core.api import serializers from core.api.viewsets import Pagination +from core.factories import TeamAccessFactory + +from mailbox_manager.factories import MailDomainAccessFactory pytestmark = pytest.mark.django_db @@ -458,6 +461,77 @@ def test_api_users_retrieve_me_authenticated(): "timezone": str(user.timezone), "is_device": False, "is_staff": False, + "abilities": { + "contacts": {"can_create": True, "can_view": True}, + "mailboxes": {"can_create": False, "can_view": False}, + "teams": {"can_create": False, "can_view": False}, + }, + } + + +def test_api_users_retrieve_me_authenticated_abilities(): + """ + Authenticated users should be able to retrieve their own user via the "/users/me" path + with the proper abilities. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + # Define profile contact + contact = factories.ContactFactory(owner=user) + user.profile_contact = contact + user.save() + + factories.UserFactory.create_batch(2) + + # Test the mailboxes abilities + mail_domain_access = MailDomainAccessFactory(user=user) + + response = client.get("/api/v1.0/users/me/") + + assert response.status_code == HTTP_200_OK + assert response.json()["abilities"] == { + "contacts": {"can_create": True, "can_view": True}, + "mailboxes": {"can_create": True, "can_view": True}, + "teams": {"can_create": False, "can_view": False}, + } + + # Test the teams abilities - user is not an admin/owner + team_access = TeamAccessFactory(user=user, role=models.RoleChoices.MEMBER) + response = client.get("/api/v1.0/users/me/") + + assert response.status_code == HTTP_200_OK + assert response.json()["abilities"] == { + "contacts": {"can_create": True, "can_view": True}, + "mailboxes": {"can_create": True, "can_view": True}, + "teams": {"can_create": False, "can_view": False}, + } + + # Test the teams abilities - user is an admin/owner + team_access.role = models.RoleChoices.ADMIN + team_access.save() + + response = client.get("/api/v1.0/users/me/") + + assert response.status_code == HTTP_200_OK + assert response.json()["abilities"] == { + "contacts": {"can_create": True, "can_view": True}, + "mailboxes": {"can_create": True, "can_view": True}, + "teams": {"can_create": True, "can_view": True}, + } + + # Test the mailboxes abilities - user has no mail domain access anymore + mail_domain_access.delete() + + response = client.get("/api/v1.0/users/me/") + + assert response.status_code == HTTP_200_OK + assert response.json()["abilities"] == { + "contacts": {"can_create": True, "can_view": True}, + "mailboxes": {"can_create": False, "can_view": False}, + "teams": {"can_create": True, "can_view": True}, } diff --git a/src/backend/people/settings.py b/src/backend/people/settings.py index be19a29..9e00e70 100755 --- a/src/backend/people/settings.py +++ b/src/backend/people/settings.py @@ -451,18 +451,33 @@ class Base(Configuration): ) ) - FEATURES = { - "TEAMS": values.BooleanValue( - default=True, environ_name="FEATURE_TEAMS", environ_prefix=None - ), - } - # pylint: disable=invalid-name @property def ENVIRONMENT(self): """Environment in which the application is launched.""" return self.__class__.__name__.lower() + # pylint: disable=invalid-name + @property + def FEATURES(self): + """Feature flags for the application.""" + FEATURE_FLAGS: set = { + "CONTACTS_CREATE", # Used in the users/me/ endpoint + "CONTACTS_DISPLAY", # Used in the users/me/ endpoint + "MAILBOXES_CREATE", # Used in the users/me/ endpoint + "TEAMS", + "TEAMS_CREATE", # Used in the users/me/ endpoint + } + + return { + feature: values.BooleanValue( + default=True, + environ_name=f"FEATURE_{feature}", + environ_prefix=None, + ) + for feature in FEATURE_FLAGS + } + # pylint: disable=invalid-name @property def RELEASE(self): diff --git a/src/frontend/apps/e2e/__tests__/app-desk/config.spec.ts b/src/frontend/apps/e2e/__tests__/app-desk/config.spec.ts index 7d8beb3..fbe68f5 100644 --- a/src/frontend/apps/e2e/__tests__/app-desk/config.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-desk/config.spec.ts @@ -22,7 +22,13 @@ test.describe('Config', () => { ['en-us', 'English'], ['fr-fr', 'French'], ], - FEATURES: { TEAMS: true }, + FEATURES: { + CONTACTS_CREATE: true, + CONTACTS_DISPLAY: true, + MAILBOXES_CREATE: true, + TEAMS_CREATE: true, + TEAMS: true, + }, RELEASE: 'NA', }); });