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', }); });