(backend) add user abilities for front

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 :)
This commit is contained in:
Quentin BEY
2024-11-06 17:22:01 +01:00
committed by BEY Quentin
parent 4d3097e322
commit ac853299d3
7 changed files with 198 additions and 10 deletions

View File

@@ -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

View File

@@ -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."""

View File

@@ -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(

View File

@@ -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):
"""

View File

@@ -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},
}

View File

@@ -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):

View File

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