diff --git a/docs/user/rest-api.rst b/docs/user/rest-api.rst index 30c6b677..e1301dc0 100644 --- a/docs/user/rest-api.rst +++ b/docs/user/rest-api.rst @@ -869,12 +869,49 @@ This API endpoint allows to use the features described in /api/v1/radius/batch/ +GET +^^^ + +Returns a list of batch user creation operations for the organizations +managed by the requesting user. Results are paginated and can be filtered. + +.. code-block:: text + + /api/v1/radius/batch?search= + /api/v1/radius/batch?organization= + /api/v1/radius/batch?strategy=prefix + +Filters +""""""" + +================= =============================== +Filter Parameter Description +================= =============================== +search Search batches by name +organization Filter by organization id +organization_slug Filter by organization slug +strategy Filter by strategy (prefix/csv) +================= =============================== + +Pagination +"""""""""" + +Pagination is provided using page number pagination, the default page size +is 20, which can be overridden using the ``page_size`` parameter, up to +:ref:`OPENWISP_API_MAX_PAGE_SIZE ` (100 by +default). + .. note:: - This API endpoint allows to use the features described in - :doc:`importing_users` and :doc:`generating_users`. + The list response does not include ``user_credentials`` to avoid + repeatedly exposing plaintext credentials. Use the batch creation + response or the protected PDF download endpoint for credentials. -Responds only to **POST**, used to save a ``RadiusBatch`` instance. +POST +^^^^ + +Creates a batch of users using a csv file or generates users with a given +prefix. It is possible to generate the users of the ``RadiusBatch`` with two different strategies: csv or prefix. @@ -919,6 +956,43 @@ group by organization before sending its UUID in the ``group`` parameter. The ``group`` and ``notes`` parameters are optional. When ``group`` is omitted, users retain the standard default-group behavior. +.. note:: + + The synchronous ``201 Created`` response for prefix-generated batches + includes ``user_credentials``. The asynchronous ``202 Accepted`` + response does not include credentials; use the ``pdf_link`` after + completion. + +Batch Detail +++++++++++++ + +.. code-block:: text + + /api/v1/radius/batch// + +GET +^^^ + +Returns a single batch user creation operation by its UUID. The response +does not include ``user_credentials``. + +For completed prefix batches, the response includes a ``pdf_link`` field +pointing to the protected PDF download endpoint. For CSV batches with an +uploaded file, the response includes a ``csv_link`` field pointing to the +protected CSV download endpoint. + +DELETE +^^^^^^ + +Deletes a batch user creation operation and its associated users. Returns +``204 No Content`` on success. + +.. note:: + + Deletion is rejected while the batch ``status`` is ``processing``. The + API returns a ``409 Conflict`` response with a clear error message in + this case. Pending, completed, and failed batches can be deleted. + Batch CSV Download ++++++++++++++++++ diff --git a/openwisp_radius/admin.py b/openwisp_radius/admin.py index 1f3110fe..cef3db20 100644 --- a/openwisp_radius/admin.py +++ b/openwisp_radius/admin.py @@ -506,11 +506,28 @@ def get_actions(self, request): @admin.action(description=_("Delete selected batches"), permissions=["delete"]) def delete_selected_batches(self, request, queryset): + skipped = 0 + deleted = 0 for obj in queryset: + if obj.status == RadiusBatch.PROCESSING: + skipped += 1 + continue obj.delete() - self.message_user( - request, "Successfully deleted selected batches.", level=messages.SUCCESS - ) + deleted += 1 + if skipped: + self.message_user( + request, + _( + "Skipped {count} batch(es) that are currently being processed." + ).format(count=skipped), + level=messages.WARNING, + ) + if deleted: + self.message_user( + request, + _("Successfully deleted {count} batch(es).").format(count=deleted), + level=messages.SUCCESS, + ) def get_readonly_fields(self, request, obj=None): readonly_fields = super(RadiusBatchAdmin, self).get_readonly_fields( diff --git a/openwisp_radius/api/serializers.py b/openwisp_radius/api/serializers.py index 38b9808d..92d9ff26 100644 --- a/openwisp_radius/api/serializers.py +++ b/openwisp_radius/api/serializers.py @@ -562,6 +562,66 @@ class Meta: read_only_fields = ("status", "user_credentials", "created", "modified") +class BatchUserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ( + "id", + "username", + "email", + "first_name", + "last_name", + ) + read_only_fields = fields + + +class RadiusBatchReadSerializer(serializers.ModelSerializer): + organization = serializers.PrimaryKeyRelatedField(read_only=True) + users = BatchUserSerializer(many=True, read_only=True) + pdf_link = serializers.SerializerMethodField(required=False, read_only=True) + csv_link = serializers.SerializerMethodField(required=False, read_only=True) + status = serializers.CharField(read_only=True) + + def get_pdf_link(self, obj): + if obj.strategy == "prefix" and obj.status == RadiusBatch.COMPLETED: + request = self.context.get("request") + return request.build_absolute_uri( + reverse( + "radius:download_rad_batch_pdf", + args=[obj.organization.slug, obj.pk], + ) + ) + return None + + def get_csv_link(self, obj): + if obj.csvfile: + request = self.context.get("request") + csv_url = reverse( + "radius:radius_organization_batch_csv_read", + args=[obj.organization.slug, obj.pk], + ) + return request.build_absolute_uri(csv_url) + return None + + class Meta: + model = RadiusBatch + fields = ( + "id", + "organization", + "name", + "strategy", + "status", + "expiration_date", + "prefix", + "users", + "pdf_link", + "csv_link", + "created", + "modified", + ) + read_only_fields = fields + + class RegisterSerializer( ErrorDictMixin, AllowedMobilePrefixMixin, diff --git a/openwisp_radius/api/urls.py b/openwisp_radius/api/urls.py index 873c5b1f..286354f4 100644 --- a/openwisp_radius/api/urls.py +++ b/openwisp_radius/api/urls.py @@ -88,6 +88,11 @@ def get_view(name): name="update_registered_user_registration_method", ), path("radius/batch/", get_view("batch"), name="batch"), + path( + "radius/batch//", + get_view("batch_detail"), + name="radius_batch_detail", + ), path( "radius/organization//batch//pdf/", get_view("download_rad_batch_pdf"), diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index 5c8b3914..bd60441d 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -23,7 +23,7 @@ from rest_framework.authentication import SessionAuthentication from rest_framework.authtoken.models import Token as UserToken from rest_framework.authtoken.views import ObtainAuthToken as BaseObtainAuthToken -from rest_framework.exceptions import NotFound, PermissionDenied +from rest_framework.exceptions import APIException, NotFound, PermissionDenied from rest_framework.filters import SearchFilter from rest_framework.generics import ( CreateAPIView, @@ -31,6 +31,7 @@ ListAPIView, ListCreateAPIView, RetrieveAPIView, + RetrieveDestroyAPIView, RetrieveUpdateDestroyAPIView, get_object_or_404, ) @@ -78,6 +79,7 @@ AuthTokenSerializer, ChangePhoneNumberSerializer, RadiusAccountingSerializer, + RadiusBatchReadSerializer, RadiusBatchSerializer, RadiusGroupSerializer, RadiusUserGroupSerializer, @@ -120,20 +122,53 @@ class ThrottledAPIMixin(object): throttle_scope = "others" -class BatchView(ThrottledAPIMixin, CreateAPIView): - authentication_classes = (BearerAuthentication, SessionAuthentication) - permission_classes = (IsAdminUser, DjangoModelPermissions) - queryset = RadiusBatch.objects.all() - serializer_class = RadiusBatchSerializer +class RadiusBatchFilter(OrganizationManagedFilter, filters.FilterSet): + class Meta(OrganizationManagedFilter.Meta): + model = RadiusBatch + fields = [*OrganizationManagedFilter.Meta.fields, "strategy"] - def post(self, request, *args, **kwargs): - """ + +@method_decorator( + name="get", + decorator=swagger_auto_schema( + operation_description=""" + Returns a list of batch user creation operations for the + organizations managed by the user. + """, + ), +) +@method_decorator( + name="post", + decorator=swagger_auto_schema( + operation_description=""" **Requires the user auth token (Bearer Token).** Allows organization administrators to create a batch of users using a csv file or generate users with a given prefix. - """ - serializer = self.get_serializer(data=request.data) + """, + request_body=RadiusBatchSerializer, + responses={201: RadiusBatchSerializer, 202: RadiusBatchSerializer}, + ), +) +class BatchView(ThrottledAPIMixin, FilterByOrganizationManaged, ListCreateAPIView): + authentication_classes = (BearerAuthentication, SessionAuthentication) + permission_classes = (IsAdminUser, DjangoModelPermissions) + queryset = ( + RadiusBatch.objects.select_related("organization") + .prefetch_related("users") + .order_by("-created") + ) + serializer_class = RadiusBatchReadSerializer + filterset_class = RadiusBatchFilter + filter_backends = [DjangoFilterBackend, SearchFilter] + search_fields = ["name"] + pagination_class = OpenWispPagination + pagination_page_size = 20 + + def post(self, request, *args, **kwargs): + serializer = RadiusBatchSerializer( + data=request.data, context={"request": request} + ) if serializer.is_valid(): valid_data = serializer.validated_data.copy() num_of_users = valid_data.get("number_of_users", 0) @@ -142,7 +177,9 @@ def post(self, request, *args, **kwargs): batch = serializer.save(organization=organization) is_async = batch.schedule_processing(number_of_users=num_of_users) batch.refresh_from_db() - response_serializer = self.get_serializer(batch) + response_serializer = RadiusBatchSerializer( + batch, context={"request": request} + ) status_code = ( status.HTTP_202_ACCEPTED if is_async else status.HTTP_201_CREATED ) @@ -196,6 +233,54 @@ def get(self, request, *args, **kwargs): download_rad_batch_pdf = DownloadRadiusBatchPdfView.as_view() +class Conflict(APIException): + status_code = status.HTTP_409_CONFLICT + default_detail = _("Conflict.") + default_code = "conflict" + + +@method_decorator( + name="get", + decorator=swagger_auto_schema( + operation_description=""" + Returns a batch user creation operation by its UUID. + """, + ), +) +@method_decorator( + name="delete", + decorator=swagger_auto_schema( + operation_description=""" + Deletes a batch user creation operation and its associated users. + Cannot delete a batch while it is being processed. + """, + responses={204: "No Content", 409: "Conflict"}, + ), +) +class BatchDetailView( + ProtectedAPIMixin, FilterByOrganizationManaged, RetrieveDestroyAPIView +): + authentication_classes = (BearerAuthentication, SessionAuthentication) + permission_classes = (IsAdminUser, DjangoModelPermissions) + queryset = RadiusBatch.objects.select_related("organization").prefetch_related( + "users" + ) + serializer_class = RadiusBatchReadSerializer + + def perform_destroy(self, instance): + if instance.status == RadiusBatch.PROCESSING: + raise Conflict( + _( + "The radius batch object is currently being processed" + " and cannot be deleted." + ) + ) + instance.delete() + + +batch_detail = BatchDetailView.as_view() + + class UserDetailsUpdaterMixin(object): def update_user_details(self, user): language = get_language_from_request(self.request) diff --git a/openwisp_radius/tests/test_admin.py b/openwisp_radius/tests/test_admin.py index 1c229d6f..cf5f3ca3 100644 --- a/openwisp_radius/tests/test_admin.py +++ b/openwisp_radius/tests/test_admin.py @@ -402,11 +402,40 @@ def test_delete_selected_batches_action_perms(self): action="delete_selected_batches", user=user, obj=batch, - message="Successfully deleted selected batches.", + message="Successfully deleted 1 batch(es).", required_perms=["delete"], extra_payload={"_selected_action": [batch.id]}, ) + def test_delete_selected_batches_skips_processing(self): + org = self._get_org() + self._get_admin() + deletable = self._create_radius_batch( + organization=org, + name="deletable", + strategy="prefix", + prefix="test-del", + ) + processing = self._create_radius_batch( + organization=org, + name="processing", + strategy="prefix", + prefix="test-proc", + ) + processing.status = RadiusBatch.PROCESSING + processing.save(update_fields=["status"]) + changelist_path = reverse(f"admin:{self.app_label}_radiusbatch_changelist") + data = { + "action": "delete_selected_batches", + "_selected_action": [deletable.pk, processing.pk], + } + response = self.client.post(changelist_path, data, follow=True) + self.assertEqual(response.status_code, 200) + self.assertFalse(RadiusBatch.objects.filter(pk=deletable.pk).exists()) + self.assertTrue(RadiusBatch.objects.filter(pk=processing.pk).exists()) + self.assertContains(response, "Skipped 1 batch") + self.assertContains(response, "Successfully deleted 1 batch(es).") + def test_radius_batch_csv_help_text(self): add_url = reverse(f"admin:{self.app_label}_radiusbatch_add") response = self.client.get(add_url) diff --git a/openwisp_radius/tests/test_api/test_batch.py b/openwisp_radius/tests/test_api/test_batch.py new file mode 100644 index 00000000..d17c2d0e --- /dev/null +++ b/openwisp_radius/tests/test_api/test_batch.py @@ -0,0 +1,430 @@ +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse +from rest_framework import status + +from ...utils import load_model +from ..mixins import ApiTokenMixin, BaseTestCase + +User = get_user_model() +RadiusBatch = load_model("RadiusBatch") + + +class TestBatch(ApiTokenMixin, BaseTestCase): + def _get_auth_header(self, username="tester", password="tester"): + login_payload = {"username": username, "password": password} + login_url = reverse("radius:user_auth_token", args=[self.default_org.slug]) + response = self.client.post(login_url, data=login_payload) + return f"Bearer {response.json()['key']}" + + def test_batch_list_200(self): + self._create_radius_batch( + name="batch-a", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + ) + self._create_radius_batch( + name="batch-b", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + ) + self._superuser_login() + with self.assertNumQueries(5): + response = self.client.get(reverse("radius:batch")) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["count"], 2) + + def test_batch_list_permissions(self): + self._create_radius_batch( + name="test-prefix-batch", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + ) + with self.subTest("w/o login"): + response = self.client.get(reverse("radius:batch")) + self.assertEqual(response.status_code, 401) + + with self.subTest("superuser"): + self._superuser_login() + response = self.client.get(reverse("radius:batch")) + self.assertEqual(response.status_code, 200) + + with self.subTest("staff w/ managed org"): + staff = self._create_operator( + organizations=[self.default_org], + username="liststaff", + email="liststaff@test.com", + ) + header = self._get_auth_header(staff.username, "tester") + response = self.client.get( + reverse("radius:batch"), HTTP_AUTHORIZATION=header + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["count"], 1) + + with self.subTest("non-staff user"): + regular = User.objects.create_user( + username="regular", email="regular@test.com", password="tester" + ) + header = self._get_auth_header(regular.username, "tester") + response = self.client.get( + reverse("radius:batch"), HTTP_AUTHORIZATION=header + ) + self.assertEqual(response.status_code, 403) + + def test_batch_list_filter_strategy(self): + self._create_radius_batch( + name="prefix-batch", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + ) + csv_content = b"user,cleartext$abcd,email@gmail.com,firstname,lastname" + csv_file = SimpleUploadedFile("filter_test.csv", csv_content) + self._create_radius_batch( + name="csv-batch", + strategy="csv", + csvfile=csv_file, + status=RadiusBatch.COMPLETED, + ) + self._superuser_login() + url = reverse("radius:batch") + with self.subTest("filter prefix"): + response = self.client.get(url, {"strategy": "prefix"}) + names = [b["name"] for b in response.json()["results"]] + self.assertIn("prefix-batch", names) + self.assertNotIn("csv-batch", names) + + with self.subTest("filter csv"): + response = self.client.get(url, {"strategy": "csv"}) + names = [b["name"] for b in response.json()["results"]] + self.assertIn("csv-batch", names) + self.assertNotIn("prefix-batch", names) + + def test_batch_list_filter_organization(self): + org2 = self._create_org(**{"name": "other", "slug": "other"}) + self._create_radius_batch( + name="org1-batch", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + ) + self._create_radius_batch( + name="org2-batch", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + organization=org2, + ) + operator = self._create_operator( + organizations=[self.default_org], + username="orgfilterstaff", + email="orgfilterstaff@test.com", + ) + header = self._get_auth_header(operator.username, "tester") + response = self.client.get(reverse("radius:batch"), HTTP_AUTHORIZATION=header) + names = [b["name"] for b in response.json()["results"]] + self.assertIn("org1-batch", names) + self.assertNotIn("org2-batch", names) + + def test_batch_list_search_name(self): + self._create_radius_batch( + name="alpha-batch", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + ) + self._create_radius_batch( + name="beta-batch", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + ) + self._superuser_login() + response = self.client.get( + reverse("radius:batch"), + {"search": "alpha"}, + ) + names = [b["name"] for b in response.json()["results"]] + self.assertIn("alpha-batch", names) + self.assertNotIn("beta-batch", names) + + def test_batch_list_no_user_credentials(self): + self._create_radius_batch( + name="test-prefix-batch", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + ) + self._superuser_login() + response = self.client.get(reverse("radius:batch")) + batch_data = response.json()["results"][0] + self.assertNotIn("user_credentials", batch_data) + + def test_batch_list_exposes_download_links(self): + batch = self._create_radius_batch( + name="test-prefix-batch", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + ) + self._superuser_login() + response = self.client.get(reverse("radius:batch")) + batch_data = response.json()["results"][0] + self.assertIsNotNone(batch_data["pdf_link"]) + self.assertIn(str(batch.pk), batch_data["pdf_link"]) + self.assertIsNone(batch_data["csv_link"]) + + def test_batch_csv_link_in_list_and_detail(self): + csv_content = b"user,cleartext$abcd,email@gmail.com,firstname,lastname" + csv_file = SimpleUploadedFile("test_csv_link.csv", csv_content) + batch = self._create_radius_batch( + name="csv-link-test", + strategy="csv", + csvfile=csv_file, + status=RadiusBatch.COMPLETED, + ) + self._superuser_login() + with self.subTest("list"): + resp = self.client.get(reverse("radius:batch")) + batch_data = resp.json()["results"][0] + self.assertIsNotNone(batch_data["csv_link"]) + self.assertIn(str(batch.pk), batch_data["csv_link"]) + self.assertIsNone(batch_data["pdf_link"]) + + with self.subTest("detail"): + resp = self.client.get( + reverse("radius:radius_batch_detail", args=[batch.pk]), + ) + data = resp.json() + self.assertIsNotNone(data["csv_link"]) + self.assertIn(str(batch.pk), data["csv_link"]) + + def test_batch_detail_200(self): + batch = self._create_radius_batch( + name="test-prefix-batch", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + ) + self._superuser_login() + url = reverse("radius:radius_batch_detail", args=[batch.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data["id"], str(batch.pk)) + self.assertEqual(data["name"], "test-prefix-batch") + self.assertEqual(data["strategy"], "prefix") + self.assertEqual(data["status"], RadiusBatch.COMPLETED) + self.assertNotIn("user_credentials", data) + self.assertIsNotNone(data["pdf_link"]) + + def test_batch_detail_permissions(self): + batch = self._create_radius_batch( + name="test-prefix-batch", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + ) + with self.subTest("w/o login"): + response = self.client.get( + reverse("radius:radius_batch_detail", args=[batch.pk]) + ) + self.assertEqual(response.status_code, 401) + + with self.subTest("superuser"): + self._superuser_login() + response = self.client.get( + reverse("radius:radius_batch_detail", args=[batch.pk]), + ) + self.assertEqual(response.status_code, 200) + + with self.subTest("staff w/ managed org"): + staff = self._create_operator( + organizations=[self.default_org], + username="detailstaff", + email="detailstaff@test.com", + ) + header = self._get_auth_header(staff.username, "tester") + response = self.client.get( + reverse("radius:radius_batch_detail", args=[batch.pk]), + HTTP_AUTHORIZATION=header, + ) + self.assertEqual(response.status_code, 200) + + with self.subTest("staff w/o managed org"): + org2 = self._create_org(**{"name": "other", "slug": "other"}) + no_org_staff = self._create_operator( + organizations=[org2], + username="noorgstaff", + email="noorgstaff@test.com", + ) + header = self._get_auth_header(no_org_staff.username, "tester") + response = self.client.get( + reverse("radius:radius_batch_detail", args=[batch.pk]), + HTTP_AUTHORIZATION=header, + ) + self.assertEqual(response.status_code, 404) + + def test_batch_detail_cross_org_404(self): + org2 = self._create_org(**{"name": "other", "slug": "other"}) + batch = self._create_radius_batch( + name="test-prefix-batch", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + organization=org2, + ) + staff = self._create_operator( + organizations=[self.default_org], + username="crossorgstaff", + email="crossorgstaff@test.com", + ) + header = self._get_auth_header(staff.username, "tester") + url = reverse("radius:radius_batch_detail", args=[batch.pk]) + response = self.client.get(url, HTTP_AUTHORIZATION=header) + self.assertEqual(response.status_code, 404) + + def test_batch_detail_404(self): + self._superuser_login() + url = reverse( + "radius:radius_batch_detail", + args=["00000000-0000-0000-0000-000000000000"], + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_batch_delete_204(self): + batch = self._create_radius_batch( + name="test-prefix-batch", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + ) + batch_id = batch.pk + self._superuser_login() + url = reverse("radius:radius_batch_detail", args=[batch_id]) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(RadiusBatch.objects.filter(pk=batch_id).exists()) + + def test_batch_delete_permissions(self): + operator = self._create_operator( + organizations=[self.default_org], + username="deletestaff", + email="deletestaff@test.com", + ) + administrator = self._create_administrator( + organizations=[self.default_org], + username="deletadmin", + email="deletadmin@test.com", + ) + with self.subTest("w/o login"): + batch = self._create_radius_batch( + name="batch-noauth", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + ) + response = self.client.delete( + reverse("radius:radius_batch_detail", args=[batch.pk]) + ) + self.assertEqual(response.status_code, 401) + + with self.subTest("superuser"): + batch = self._create_radius_batch( + name="batch-super", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + ) + self._superuser_login() + response = self.client.delete( + reverse("radius:radius_batch_detail", args=[batch.pk]), + ) + self.assertEqual(response.status_code, 204) + + with self.subTest("operator w/o delete permission"): + batch = self._create_radius_batch( + name="batch-noperm", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + ) + header = self._get_auth_header(operator.username, "tester") + response = self.client.delete( + reverse("radius:radius_batch_detail", args=[batch.pk]), + HTTP_AUTHORIZATION=header, + ) + self.assertEqual(response.status_code, 403) + + with self.subTest("administrator w/ delete permission"): + batch = self._create_radius_batch( + name="batch-withperm", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + ) + header = self._get_auth_header(administrator.username, "tester") + response = self.client.delete( + reverse("radius:radius_batch_detail", args=[batch.pk]), + HTTP_AUTHORIZATION=header, + ) + self.assertEqual(response.status_code, 204) + + def test_batch_delete_processing_409(self): + batch = self._create_radius_batch( + name="test-prefix-batch", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + ) + batch.status = RadiusBatch.PROCESSING + batch.save(update_fields=["status"]) + self._superuser_login() + url = reverse("radius:radius_batch_detail", args=[batch.pk]) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + self.assertIn( + "currently being processed and cannot be deleted", + response.json()["detail"], + ) + self.assertTrue(RadiusBatch.objects.filter(pk=batch.pk).exists()) + + def test_batch_delete_cross_org_404(self): + org2 = self._create_org(**{"name": "other", "slug": "other"}) + batch = self._create_radius_batch( + name="test-prefix-batch", + strategy="prefix", + prefix="test", + status=RadiusBatch.COMPLETED, + organization=org2, + ) + administrator = self._create_administrator( + organizations=[self.default_org], + username="delcrossorg", + email="delcrossorg@test.com", + ) + header = self._get_auth_header(administrator.username, "tester") + url = reverse("radius:radius_batch_detail", args=[batch.pk]) + response = self.client.delete(url, HTTP_AUTHORIZATION=header) + self.assertEqual(response.status_code, 404) + self.assertTrue(RadiusBatch.objects.filter(pk=batch.pk).exists()) + + def test_batch_delete_pending_and_failed_allowed(self): + for batch_status in [RadiusBatch.PENDING, RadiusBatch.FAILED]: + with self.subTest(status=batch_status): + batch = self._create_radius_batch( + name=f"batch-{batch_status}", + strategy="prefix", + prefix="test", + status=batch_status, + ) + self._superuser_login() + url = reverse("radius:radius_batch_detail", args=[batch.pk]) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(RadiusBatch.objects.filter(pk=batch.pk).exists()) diff --git a/tests/openwisp2/sample_radius/api/views.py b/tests/openwisp2/sample_radius/api/views.py index d5b468ef..9f0a9367 100644 --- a/tests/openwisp2/sample_radius/api/views.py +++ b/tests/openwisp2/sample_radius/api/views.py @@ -1,6 +1,7 @@ from openwisp_radius.api.freeradius_views import AccountingView as BaseAccountingView from openwisp_radius.api.freeradius_views import AuthorizeView as BaseAuthorizeView from openwisp_radius.api.freeradius_views import PostAuthView as BasePostAuthView +from openwisp_radius.api.views import BatchDetailView as BaseBatchDetailView from openwisp_radius.api.views import BatchView as BaseBatchView from openwisp_radius.api.views import ChangePhoneNumberView as BaseChangePhoneNumberView from openwisp_radius.api.views import CreatePhoneTokenView as BaseCreatePhoneTokenView @@ -51,6 +52,10 @@ class BatchView(BaseBatchView): pass +class BatchDetailView(BaseBatchDetailView): + pass + + class RegisterView(BaseRegisterView): pass @@ -115,6 +120,7 @@ class UpdateRegisteredUserMethodView(BaseUpdateRegisteredUserMethodView): postauth = PostAuthView.as_view() accounting = AccountingView.as_view() batch = BatchView.as_view() +batch_detail = BatchDetailView.as_view() register = RegisterView.as_view() obtain_auth_token = ObtainAuthTokenView.as_view() validate_auth_token = ValidateAuthTokenView.as_view() diff --git a/tests/openwisp2/sample_radius/tests.py b/tests/openwisp2/sample_radius/tests.py index 0d01f8ac..256c3a0e 100644 --- a/tests/openwisp2/sample_radius/tests.py +++ b/tests/openwisp2/sample_radius/tests.py @@ -1,6 +1,7 @@ from openwisp_radius.tests import test_migrations as base_migration_tests from openwisp_radius.tests.test_admin import TestAdmin as BaseTestAdmin from openwisp_radius.tests.test_api.test_api import TestApi as BaseTestApi +from openwisp_radius.tests.test_api.test_batch import TestBatch as BaseTestBatch from openwisp_radius.tests.test_api.test_freeradius_api import ( TestApiReject as BaseTestApiReject, ) @@ -80,6 +81,10 @@ class TestApi(BaseTestApi): pass +class TestBatch(BaseTestBatch): + pass + + class TestFreeradiusApi(BaseTestFreeradiusApi): pass @@ -207,6 +212,7 @@ class TestPhoneTokenOrganizationPopulateResolution( del BaseTestAdmin del BaseTestApi +del BaseTestBatch del BaseTestFreeradiusApi del BaseTestApiReject del BaseTestAutoGroupname