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
This commit is contained in:
Víctor Fernández Poyatos
2024-11-07 10:24:00 +01:00
committed by GitHub
parent 6f1aa6a1b1
commit 0271fe5ca0
5 changed files with 196 additions and 21 deletions
+27
View File
@@ -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)
+15
View File
@@ -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"],
}
+128 -2
View File
@@ -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:
+11 -2
View File
@@ -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(
+15 -17
View File
@@ -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")