From 0271fe5ca04b7547d8ed06bae1e9950cf8182fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Fern=C3=A1ndez=20Poyatos?= Date: Thu, 7 Nov 2024 10:24:00 +0100 Subject: [PATCH] feat(User): Add GET /users (#76) * feat(Membership): implement include parameter for users in memberships * feat(User): refactor userviewset * chore(Schema): update API schema * feat(User): add filters --- src/backend/api/base_views.py | 27 ++++++ src/backend/api/filters.py | 15 ++++ src/backend/api/specs/v1.yaml | 130 +++++++++++++++++++++++++++- src/backend/api/tests/test_views.py | 13 ++- src/backend/api/v1/views.py | 32 ++++--- 5 files changed, 196 insertions(+), 21 deletions(-) diff --git a/src/backend/api/base_views.py b/src/backend/api/base_views.py index 36db423cc1..05cb174680 100644 --- a/src/backend/api/base_views.py +++ b/src/backend/api/base_views.py @@ -74,3 +74,30 @@ class BaseTenantViewset(BaseViewSet): with connection.cursor() as cursor: cursor.execute(f"SELECT set_config('api.user_id', '{user_id}', TRUE);") return super().initial(request, *args, **kwargs) + + +class BaseUserViewset(BaseViewSet): + def dispatch(self, request, *args, **kwargs): + with transaction.atomic(): + return super().dispatch(request, *args, **kwargs) + + def initial(self, request, *args, **kwargs): + # TODO refactor after improving RLS on users + if request.stream is not None and request.stream.method == "POST": + return super().initial(request, *args, **kwargs) + if request.auth is None: + raise NotAuthenticated + + tenant_id = request.auth.get("tenant_id") + if tenant_id is None: + raise NotAuthenticated("Tenant ID is not present in token") + + try: + uuid.UUID(tenant_id) + except ValueError: + raise ValidationError("Tenant ID must be a valid UUID") + + with connection.cursor() as cursor: + cursor.execute(f"SELECT set_config('api.tenant_id', '{tenant_id}', TRUE);") + self.request.tenant_id = tenant_id + return super().initial(request, *args, **kwargs) diff --git a/src/backend/api/filters.py b/src/backend/api/filters.py index 763ac5836a..176469011e 100644 --- a/src/backend/api/filters.py +++ b/src/backend/api/filters.py @@ -22,6 +22,7 @@ from api.db_utils import ( InvitationStateEnumField, ) from api.models import ( + User, Membership, Provider, Resource, @@ -434,3 +435,17 @@ class InvitationFilter(FilterSet): "filter_class": CharFilter, } } + + +class UserFilter(FilterSet): + date_joined = DateFilter(field_name="date_joined", lookup_expr="date") + + class Meta: + model = User + fields = { + "name": ["exact", "icontains"], + "email": ["exact", "icontains"], + "company_name": ["exact", "icontains"], + "date_joined": ["date", "gte", "lte"], + "is_active": ["exact"], + } diff --git a/src/backend/api/specs/v1.yaml b/src/backend/api/specs/v1.yaml index 46dcd8abbc..341bc2ba77 100644 --- a/src/backend/api/specs/v1.yaml +++ b/src/backend/api/specs/v1.yaml @@ -2622,6 +2622,124 @@ paths: $ref: '#/components/schemas/TokenRefreshResponse' description: '' /api/v1/users: + get: + operationId: users_list + description: Retrieve a list of all users with options for filtering by various + criteria. + summary: List all users + parameters: + - in: query + name: fields[User] + schema: + type: array + items: + type: string + enum: + - name + - email + - company_name + - date_joined + - memberships + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[company_name] + schema: + type: string + - in: query + name: filter[company_name__icontains] + schema: + type: string + - in: query + name: filter[date_joined] + schema: + type: string + format: date + - in: query + name: filter[date_joined__date] + schema: + type: string + format: date + - in: query + name: filter[date_joined__gte] + schema: + type: string + format: date-time + - in: query + name: filter[date_joined__lte] + schema: + type: string + format: date-time + - in: query + name: filter[email] + schema: + type: string + - in: query + name: filter[email__icontains] + schema: + type: string + - in: query + name: filter[is_active] + schema: + type: boolean + - in: query + name: filter[name] + schema: + type: string + - in: query + name: filter[name__icontains] + schema: + type: string + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - name: page[number] + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page[size] + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - name + - -name + - email + - -email + - company_name + - -company_name + - date_joined + - -date_joined + - is_active + - -is_active + explode: false + tags: + - User + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedUserList' + description: '' post: operationId: users_create description: Create a new user account by providing the necessary registration @@ -2661,8 +2779,7 @@ paths: /api/v1/users/{id}: get: operationId: users_retrieve - description: Fetch detailed information about an authenticated user. It only - allows using your own user ID. + description: Fetch detailed information about an authenticated user. summary: Retrieve a user's information parameters: - in: query @@ -3586,6 +3703,15 @@ components: $ref: '#/components/schemas/Tenant' required: - data + PaginatedUserList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/User' + required: + - data PatchedInvitationUpdateRequest: type: object properties: diff --git a/src/backend/api/tests/test_views.py b/src/backend/api/tests/test_views.py index 87b20d5bf6..4492c32ed7 100644 --- a/src/backend/api/tests/test_views.py +++ b/src/backend/api/tests/test_views.py @@ -21,9 +21,18 @@ TODAY = str(datetime.today().date()) @pytest.mark.django_db class TestUserViewSet: - def test_users_list_not_allowed(self, authenticated_client): + def test_users_list(self, authenticated_client, create_test_user): + user = create_test_user + user.refresh_from_db() response = authenticated_client.get(reverse("user-list")) - assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + assert response.json()["data"][0]["attributes"]["email"] == user.email + assert response.json()["data"][0]["attributes"]["name"] == user.name + assert ( + response.json()["data"][0]["attributes"]["company_name"] + == user.company_name + ) def test_users_retrieve(self, authenticated_client, create_test_user): response = authenticated_client.get( diff --git a/src/backend/api/v1/views.py b/src/backend/api/v1/views.py index 3fbb0c2796..5435b416cd 100644 --- a/src/backend/api/v1/views.py +++ b/src/backend/api/v1/views.py @@ -29,7 +29,7 @@ from rest_framework_json_api.views import Response from rest_framework_simplejwt.exceptions import InvalidToken from rest_framework_simplejwt.exceptions import TokenError -from api.base_views import BaseTenantViewset, BaseRLSViewSet, BaseViewSet +from api.base_views import BaseTenantViewset, BaseRLSViewSet, BaseUserViewset from api.db_router import MainRouter from api.filters import ( ProviderFilter, @@ -41,6 +41,7 @@ from api.filters import ( FindingFilter, ProviderSecretFilter, InvitationFilter, + UserFilter, ) from api.models import ( User, @@ -196,9 +197,13 @@ class SchemaView(SpectacularAPIView): @extend_schema_view( + list=extend_schema( + summary="List all users", + description="Retrieve a list of all users with options for filtering by various criteria.", + ), retrieve=extend_schema( summary="Retrieve a user's information", - description="Fetch detailed information about an authenticated user. It only allows using your own user ID.", + description="Fetch detailed information about an authenticated user.", ), create=extend_schema( summary="Register a new user", @@ -218,14 +223,18 @@ class SchemaView(SpectacularAPIView): ), ) @method_decorator(CACHE_DECORATOR, name="list") -class UserViewSet(BaseViewSet): +class UserViewSet(BaseUserViewset): serializer_class = UserSerializer http_method_names = ["get", "post", "patch", "delete"] - ordering = ["id"] - ordering_fields = [] + filterset_class = UserFilter + ordering = ["-date_joined"] + ordering_fields = ["name", "email", "company_name", "date_joined", "is_active"] def get_queryset(self): - return User.objects.filter(id=self.request.user.id) + # If called during schema generation, return an empty queryset + if getattr(self, "swagger_fake_view", False): + return User.objects.none() + return User.objects.filter(membership__tenant__id=self.request.tenant_id) def get_permissions(self): if self.action == "create": @@ -242,15 +251,6 @@ class UserViewSet(BaseViewSet): else: return UserSerializer - @extend_schema(exclude=True) - def list(self, request, *args, **kwargs): - raise MethodNotAllowed(method="GET") - - def retrieve(self, request, *args, **kwargs): - if kwargs["pk"] != str(request.user.id): - raise NotFound(detail="User was not found.") - return super().retrieve(request, *args, **kwargs) - @action(detail=False, methods=["get"], url_name="me") def me(self, request): user = self.get_queryset().first() @@ -445,8 +445,6 @@ class TenantMembersViewSet(BaseTenantViewset): raise NotFound("Membership does not exist.") return membership - # TODO: Add invite functionality - @extend_schema(exclude=True) def retrieve(self, request, *args, **kwargs): raise MethodNotAllowed(method="GET")