Skip to content
This repository was archived by the owner on Jun 13, 2025. It is now read-only.

Commit 6b47a93

Browse files
authored
v2 API: add endpoint to activate and deactivate users (#1007)
1 parent 591a7ab commit 6b47a93

File tree

3 files changed

+351
-3
lines changed

3 files changed

+351
-3
lines changed

api/public/v2/owner/serializers.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,11 @@ class Meta:
3131
model = Owner
3232
fields = ("username", "name", "has_active_session", "expiry_date")
3333
read_only_fields = fields
34+
35+
36+
class UserUpdateActivationSerializer(serializers.ModelSerializer):
37+
activated = serializers.BooleanField()
38+
39+
class Meta:
40+
model = Owner
41+
fields = ("activated",)

api/public/v2/owner/views.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,34 @@
33
from django.db.models import Q, QuerySet
44
from drf_spectacular.utils import extend_schema
55
from rest_framework import mixins, viewsets
6-
from rest_framework.exceptions import NotFound
6+
from rest_framework.exceptions import APIException, NotFound
77
from rest_framework.permissions import IsAuthenticated
88
from rest_framework.request import Request
99
from rest_framework.response import Response
1010

11-
from api.public.v2.schema import owner_parameters, service_parameter
11+
from api.public.v2.schema import (
12+
owner_parameters,
13+
service_parameter,
14+
)
1215
from api.shared.owner.mixins import (
1316
OwnerViewSetMixin,
1417
UserSessionViewSetMixin,
1518
UserViewSetMixin,
1619
)
1720
from codecov_auth.models import Owner, Service
1821

19-
from .serializers import OwnerSerializer, UserSerializer, UserSessionSerializer
22+
from .serializers import (
23+
OwnerSerializer,
24+
UserSerializer,
25+
UserSessionSerializer,
26+
UserUpdateActivationSerializer,
27+
)
28+
29+
30+
class NotEnoughSeatsLeft(APIException):
31+
status_code = 400
32+
default_detail = "Cannot activate user -- not enough seats left."
33+
default_code = "no_seats_left"
2034

2135

2236
@extend_schema(parameters=owner_parameters, tags=["Users"])
@@ -53,6 +67,31 @@ def retrieve(self, request: Request, *args: Any, **kwargs: Any) -> Owner:
5367
"""
5468
return super().retrieve(request, *args, **kwargs)
5569

70+
@extend_schema(summary="Update a user", request=UserUpdateActivationSerializer)
71+
def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response:
72+
"""
73+
Updates a user for the specified owner_username or ownerid
74+
75+
Allowed fields
76+
- activated: boolean value to activate or deactivate the user
77+
"""
78+
instance = self.get_object()
79+
serializer = UserUpdateActivationSerializer(
80+
instance,
81+
data=request.data,
82+
)
83+
serializer.is_valid(raise_exception=True)
84+
85+
if serializer.validated_data["activated"]:
86+
if self.owner.can_activate_user(instance):
87+
self.owner.activate_user(instance)
88+
else:
89+
raise NotEnoughSeatsLeft()
90+
else:
91+
self.owner.deactivate_user(instance)
92+
93+
return super().retrieve(request, *args, **kwargs)
94+
5695

5796
@extend_schema(parameters=owner_parameters, tags=["Users"])
5897
class UserSessionViewSet(UserSessionViewSetMixin, mixins.ListModelMixin):

api/public/v2/tests/test_api_owner_viewset.py

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ def _list(self, kwargs):
6363
def _detail(self, kwargs):
6464
return self.client.get(reverse("api-v2-users-detail", kwargs=kwargs))
6565

66+
def _patch(self, kwargs, data):
67+
return self.client.patch(
68+
reverse("api-v2-users-detail", kwargs=kwargs), data=data
69+
)
70+
6671
def setUp(self):
6772
self.org = OwnerFactory(service="github")
6873
self.current_owner = OwnerFactory(service="github", organizations=[self.org.pk])
@@ -179,6 +184,302 @@ def test_retrieve_cannot_get_details_if_not_member_of_org(self):
179184
"email": another_user.email,
180185
}
181186

187+
def test_update_activate_by_username(self):
188+
another_user = OwnerFactory(service="github", organizations=[self.org.pk])
189+
190+
# Activate user
191+
response = self._patch(
192+
kwargs={
193+
"service": self.org.service,
194+
"owner_username": self.org.username,
195+
"user_username_or_ownerid": another_user.username,
196+
},
197+
data={"activated": True},
198+
)
199+
assert response.status_code == status.HTTP_200_OK
200+
assert response.data == {
201+
"service": "github",
202+
"username": another_user.username,
203+
"name": another_user.name,
204+
"activated": True,
205+
"is_admin": False,
206+
"email": another_user.email,
207+
}
208+
209+
# Deactivate user
210+
response = self._patch(
211+
kwargs={
212+
"service": self.org.service,
213+
"owner_username": self.org.username,
214+
"user_username_or_ownerid": another_user.username,
215+
},
216+
data={"activated": False},
217+
)
218+
assert response.status_code == status.HTTP_200_OK
219+
assert response.data == {
220+
"service": "github",
221+
"username": another_user.username,
222+
"name": another_user.name,
223+
"activated": False,
224+
"is_admin": False,
225+
"email": another_user.email,
226+
}
227+
228+
def test_update_activate_by_ownerid(self):
229+
another_user = OwnerFactory(service="github", organizations=[self.org.pk])
230+
231+
# Activate user
232+
response = self._patch(
233+
kwargs={
234+
"service": self.org.service,
235+
"owner_username": self.org.username,
236+
"user_username_or_ownerid": another_user.ownerid,
237+
},
238+
data={"activated": True},
239+
)
240+
assert response.status_code == status.HTTP_200_OK
241+
assert response.data == {
242+
"service": "github",
243+
"username": another_user.username,
244+
"name": another_user.name,
245+
"activated": True,
246+
"is_admin": False,
247+
"email": another_user.email,
248+
}
249+
250+
# Deactivate user
251+
response = self._patch(
252+
kwargs={
253+
"service": self.org.service,
254+
"owner_username": self.org.username,
255+
"user_username_or_ownerid": another_user.ownerid,
256+
},
257+
data={"activated": False},
258+
)
259+
assert response.status_code == status.HTTP_200_OK
260+
assert response.data == {
261+
"service": "github",
262+
"username": another_user.username,
263+
"name": another_user.name,
264+
"activated": False,
265+
"is_admin": False,
266+
"email": another_user.email,
267+
}
268+
269+
def test_update_activate_unauthorized_members_of_other_orgs(self):
270+
another_org = OwnerFactory(service="github")
271+
another_user = OwnerFactory(service="github", organizations=[another_org.pk])
272+
273+
# Activate user - not allowed
274+
response = self._patch(
275+
kwargs={
276+
"service": self.org.service,
277+
"owner_username": self.org.username,
278+
"user_username_or_ownerid": another_user.username,
279+
},
280+
data={"activated": True},
281+
)
282+
assert response.status_code == status.HTTP_404_NOT_FOUND
283+
284+
# Deactivate user - not allowed
285+
response = self._patch(
286+
kwargs={
287+
"service": self.org.service,
288+
"owner_username": self.org.username,
289+
"user_username_or_ownerid": another_user.username,
290+
},
291+
data={"activated": False},
292+
)
293+
assert response.status_code == status.HTTP_404_NOT_FOUND
294+
295+
# Request allowed after user joins the org
296+
another_user.organizations.append(self.org.pk)
297+
another_user.save()
298+
299+
# Activate user
300+
response = self._patch(
301+
kwargs={
302+
"service": self.org.service,
303+
"owner_username": self.org.username,
304+
"user_username_or_ownerid": another_user.username,
305+
},
306+
data={"activated": True},
307+
)
308+
assert response.status_code == status.HTTP_200_OK
309+
assert response.data == {
310+
"service": "github",
311+
"username": another_user.username,
312+
"name": another_user.name,
313+
"activated": True,
314+
"is_admin": False,
315+
"email": another_user.email,
316+
}
317+
318+
# Deactivate user
319+
response = self._patch(
320+
kwargs={
321+
"service": self.org.service,
322+
"owner_username": self.org.username,
323+
"user_username_or_ownerid": another_user.username,
324+
},
325+
data={"activated": False},
326+
)
327+
assert response.status_code == status.HTTP_200_OK
328+
assert response.data == {
329+
"service": "github",
330+
"username": another_user.username,
331+
"name": another_user.name,
332+
"activated": False,
333+
"is_admin": False,
334+
"email": another_user.email,
335+
}
336+
337+
def test_update_activate_unauthorized_not_member_of_org(self):
338+
another_org = OwnerFactory(service="github")
339+
another_user = OwnerFactory(service="github", organizations=[another_org.pk])
340+
341+
# Activate user - not allowed
342+
response = self._patch(
343+
kwargs={
344+
"service": another_org.service,
345+
"owner_username": another_org.username,
346+
"user_username_or_ownerid": another_user.username,
347+
},
348+
data={"activated": True},
349+
)
350+
assert response.status_code == status.HTTP_404_NOT_FOUND
351+
352+
# Deactivate user - not allowed
353+
response = self._patch(
354+
kwargs={
355+
"service": another_org.service,
356+
"owner_username": another_org.username,
357+
"user_username_or_ownerid": another_user.username,
358+
},
359+
data={"activated": False},
360+
)
361+
assert response.status_code == status.HTTP_404_NOT_FOUND
362+
363+
# Request owner now joins the other org and thus is allowed to activate/deactivate
364+
self.current_owner.organizations.append(another_org.pk)
365+
self.current_owner.save()
366+
367+
# Activate user
368+
response = self._patch(
369+
kwargs={
370+
"service": another_org.service,
371+
"owner_username": another_org.username,
372+
"user_username_or_ownerid": another_user.username,
373+
},
374+
data={"activated": True},
375+
)
376+
assert response.status_code == status.HTTP_200_OK
377+
assert response.data == {
378+
"service": "github",
379+
"username": another_user.username,
380+
"name": another_user.name,
381+
"activated": True,
382+
"is_admin": False,
383+
"email": another_user.email,
384+
}
385+
386+
# Deactivate user
387+
response = self._patch(
388+
kwargs={
389+
"service": another_org.service,
390+
"owner_username": another_org.username,
391+
"user_username_or_ownerid": another_user.username,
392+
},
393+
data={"activated": False},
394+
)
395+
assert response.status_code == status.HTTP_200_OK
396+
assert response.data == {
397+
"service": "github",
398+
"username": another_user.username,
399+
"name": another_user.name,
400+
"activated": False,
401+
"is_admin": False,
402+
"email": another_user.email,
403+
}
404+
405+
def test_update_activate_no_seats_left(self):
406+
another_user = OwnerFactory(service="github", organizations=[self.org.pk])
407+
another_user_2 = OwnerFactory(service="github", organizations=[self.org.pk])
408+
409+
# Activate user 1
410+
response = self._patch(
411+
kwargs={
412+
"service": self.org.service,
413+
"owner_username": self.org.username,
414+
"user_username_or_ownerid": another_user.username,
415+
},
416+
data={"activated": True},
417+
)
418+
assert response.status_code == status.HTTP_200_OK
419+
assert response.data == {
420+
"service": "github",
421+
"username": another_user.username,
422+
"name": another_user.name,
423+
"activated": True,
424+
"is_admin": False,
425+
"email": another_user.email,
426+
}
427+
428+
# Activate user 2
429+
response = self._patch(
430+
kwargs={
431+
"service": self.org.service,
432+
"owner_username": self.org.username,
433+
"user_username_or_ownerid": another_user_2.username,
434+
},
435+
data={"activated": True},
436+
)
437+
assert response.status_code == status.HTTP_400_BAD_REQUEST
438+
assert response.data == {
439+
"detail": ErrorDetail(
440+
string="Cannot activate user -- not enough seats left.",
441+
code="no_seats_left",
442+
)
443+
}
444+
445+
# Deactivate user 1 to make room for user 2
446+
response = self._patch(
447+
kwargs={
448+
"service": self.org.service,
449+
"owner_username": self.org.username,
450+
"user_username_or_ownerid": another_user.username,
451+
},
452+
data={"activated": False},
453+
)
454+
assert response.status_code == status.HTTP_200_OK
455+
assert response.data == {
456+
"service": "github",
457+
"username": another_user.username,
458+
"name": another_user.name,
459+
"activated": False,
460+
"is_admin": False,
461+
"email": another_user.email,
462+
}
463+
464+
# Activate user 2 now that there's room
465+
response = self._patch(
466+
kwargs={
467+
"service": self.org.service,
468+
"owner_username": self.org.username,
469+
"user_username_or_ownerid": another_user_2.username,
470+
},
471+
data={"activated": True},
472+
)
473+
assert response.status_code == status.HTTP_200_OK
474+
assert response.data == {
475+
"service": "github",
476+
"username": another_user_2.username,
477+
"name": another_user_2.name,
478+
"activated": True,
479+
"is_admin": False,
480+
"email": another_user_2.email,
481+
}
482+
182483

183484
class UserSessionViewSetTests(APITestCase):
184485
def _list(self, kwargs):

0 commit comments

Comments
 (0)