diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4135e0e..5f5f99a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} - + # Caches downloaded .deb packages to speed up future installations - name: Cache APT packages uses: actions/cache@v4 @@ -51,7 +51,7 @@ jobs: restore-keys: | apt-${{ runner.os }}- - # Disables man-db auto-update to prevent delays during package installation + # Disables man-db auto-update to prevent delays during package installation - name: Disable man page auto-update run: | echo 'set man-db/auto-update false' | sudo debconf-communicate >/dev/null @@ -71,6 +71,7 @@ jobs: pip install -U pip wheel setuptools pip install -U -r requirements-test.txt pip install -U -e . + pip install -UI "openwisp-users @ https://github.com/openwisp/openwisp-users/tarball/issues/238-view-shared-objects" "cryptography~=43.0.3" pip install ${{ matrix.django-version }} sudo npm install -g prettier diff --git a/openwisp_ipam/api/views.py b/openwisp_ipam/api/views.py index 51d89e9..f0db686 100644 --- a/openwisp_ipam/api/views.py +++ b/openwisp_ipam/api/views.py @@ -179,6 +179,8 @@ class IpAddressListCreateView(IpAddressOrgMixin, ProtectedAPIMixin, ListCreateAP subnet_model = Subnet serializer_class = IpAddressSerializer pagination_class = ListViewPagination + organization_field = "subnet__organization" + organization_lookup = "organization__in" def get_queryset(self): subnet = get_object_or_404(self.subnet_model, pk=self.kwargs["subnet_id"]) diff --git a/openwisp_ipam/static/openwisp-ipam/css/admin.css b/openwisp_ipam/static/openwisp-ipam/css/admin.css index ecde0f1..d75b4e4 100644 --- a/openwisp_ipam/static/openwisp-ipam/css/admin.css +++ b/openwisp_ipam/static/openwisp-ipam/css/admin.css @@ -41,6 +41,9 @@ section.subnet-visual { background: rgba(149, 10, 10, 1); border: 1px solid rgba(0, 0, 0, 0.4); } +.subnet-visual a.disabled { + cursor: not-allowed; +} .subnet-visual .page { display: inline; diff --git a/openwisp_ipam/static/openwisp-ipam/js/subnet.js b/openwisp_ipam/static/openwisp-ipam/js/subnet.js index 4d5bcb1..b0b9061 100644 --- a/openwisp_ipam/static/openwisp-ipam/js/subnet.js +++ b/openwisp_ipam/static/openwisp-ipam/js/subnet.js @@ -71,20 +71,21 @@ function initHostsInfiniteScroll( "" ); } - return ( - '' + - addr.address + - "" - ); + var anchorAttributes = 'class="disabled"'; + if (hasSubnetChangePermission === "true") { + anchorAttributes = + 'href=\"' + + address_add_url + + "?_to_field=id&_popup=1&ip_address=" + + addr.address + + "&subnet=" + + current_subnet + + '"onclick="return showAddAnotherPopup(this);" ' + + 'id="addr_' + + id + + '"'; + } + return "" + addr.address + ""; } function pageContainer(page) { var div = $('
'); diff --git a/openwisp_ipam/templates/admin/openwisp-ipam/subnet/change_form.html b/openwisp_ipam/templates/admin/openwisp-ipam/subnet/change_form.html index 6e09ed5..959d52d 100644 --- a/openwisp_ipam/templates/admin/openwisp-ipam/subnet/change_form.html +++ b/openwisp_ipam/templates/admin/openwisp-ipam/subnet/change_form.html @@ -49,6 +49,7 @@

{% trans 'Subnet Visual Display' %}

var current_subnet = '{{ original.pk }}'; var ipAddUrl = '{% url ipaddress_add_url %}' var ipChangeUrl = '{% url ipaddress_change_url "1234" %}' + var hasSubnetChangePermission = {{ has_change_permission|yesno:"true,false" }}; django.jQuery(document).ready(function () { initHostsInfiniteScroll(django.jQuery, current_subnet, ipAddUrl, ipChangeUrl, {{ ip_uuid| safe}}) }); diff --git a/openwisp_ipam/tests/test_admin.py b/openwisp_ipam/tests/test_admin.py index 0ce00e6..ce63803 100644 --- a/openwisp_ipam/tests/test_admin.py +++ b/openwisp_ipam/tests/test_admin.py @@ -1,10 +1,12 @@ import json +import django from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from django.urls import reverse +from openwisp_users.tests.utils import TestMultitenantAdminMixin from swapper import load_model from . import CreateModelsMixin, PostDataMixin @@ -14,7 +16,7 @@ IpAddress = load_model("openwisp_ipam", "IpAddress") -class TestAdmin(CreateModelsMixin, PostDataMixin, TestCase): +class TestAdmin(TestMultitenantAdminMixin, CreateModelsMixin, PostDataMixin, TestCase): app_label = "openwisp_ipam" def setUp(self): @@ -438,3 +440,76 @@ def assert_response(response): reverse("admin:ipam_export_subnet", args=[subnet.id]), follow=True ) assert_response(response) + + def test_superuser_create_shared_subnet(self): + admin = self._get_admin() + self.client.force_login(admin) + response = self.client.post( + reverse(f"admin:{self.app_label}_subnet_add"), + data={ + "name": "test-subnet", + "subnet": "10.0.0.0/24", + "organization": "", + }, + follow=True, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(Subnet.objects.count(), 1) + + def test_org_admin_view_shared_subnet(self): + subnet = self._create_subnet(organization=None, subnet="10.8.0.0/24") + self._test_org_admin_view_shareable_object( + reverse(f"admin:{self.app_label}_subnet_change", args=(subnet.id,)), + ) + + def test_org_admin_create_shared_subnet(self): + self._test_org_admin_create_shareable_object( + reverse(f"admin:{self.app_label}_subnet_add"), + payload={ + "name": "test-subnet", + "subnet": "10.0.0.0/24", + "organization": "", + }, + model=Subnet, + ) + + def test_superuser_create_shared_ip(self): + admin = self._get_admin() + self.client.force_login(admin) + shared_subnet = self._create_subnet(subnet="10.0.0.0/24", organization=None) + response = self.client.post( + reverse(f"admin:{self.app_label}_ipaddress_add"), + data={ + "subnet": str(shared_subnet.id), + }, + follow=True, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(Subnet.objects.count(), 1) + + def test_org_admin_view_shared_ip(self): + shared_subnet = self._create_subnet(subnet="10.0.0.0/24", organization=None) + ip = shared_subnet.request_ip() + self._test_org_admin_view_shareable_object( + reverse(f"admin:{self.app_label}_ipaddress_change", args=(ip.id,)), + expected_element=( + '
\n\n\n
\n\n' + '
\n\n' + "\n\n" + '
10.0.0.1
\n\n\n' + "
\n\n
\n\n\n
" + ), + ) + + def test_org_admin_create_shared_ip(self): + shared_subnet = self._create_subnet(subnet="10.0.0.0/24", organization=None) + self._test_org_admin_create_shareable_object( + reverse(f"admin:{self.app_label}_ipaddress_add"), + payload={ + "subnet": str(shared_subnet.id), + }, + model=IpAddress, + error_message=( + '' + ).format(' id="id_ip_address_error"' if django.VERSION >= (5, 2) else ""), + ) diff --git a/openwisp_ipam/tests/test_api.py b/openwisp_ipam/tests/test_api.py index 74bffd8..d56da7f 100644 --- a/openwisp_ipam/tests/test_api.py +++ b/openwisp_ipam/tests/test_api.py @@ -2,9 +2,8 @@ from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile -from django.test import TestCase from django.urls import reverse -from openwisp_users.tests.utils import TestMultitenantAdminMixin +from openwisp_users.tests.test_api import APITestCase from swapper import load_model from . import CreateModelsMixin, PostDataMixin @@ -14,7 +13,7 @@ IpAddress = load_model("openwisp_ipam", "IpAddress") -class TestApi(TestMultitenantAdminMixin, CreateModelsMixin, PostDataMixin, TestCase): +class TestApi(CreateModelsMixin, PostDataMixin, APITestCase): def setUp(self): super().setUp() self._login() @@ -351,3 +350,83 @@ def test_subnet_single_hosts_first_address(self): host_address_32 = response.data["results"][0]["address"] self.assertEqual(host_address_128, "2001:db00::") self.assertEqual(host_address_32, "192.168.0.0") + + def test_superuser_access_shared_subnet(self): + self._logout() + self._test_superuser_access_shared_object( + token=None, + listview_name="ipam:subnet_list_create", + detailview_name="ipam:subnet", + create_payload={ + "name": "test-subnet", + "subnet": "10.0.0.0/24", + "description": "Test Subnet", + "organization": None, + }, + update_payload={ + "name": "updated-subnet", + "subnet": "10.0.0.0/24", + }, + expected_count=1, + ) + + def test_org_manager_access_shared_subnet(self): + self._logout() + shared_subnet = self._create_subnet(organization=None, subnet="10.0.0.0/24") + self._test_org_user_access_shared_object( + listview_path=reverse("ipam:subnet_list_create"), + detailview_path=reverse("ipam:subnet", args=[shared_subnet.pk]), + create_payload={ + "name": "test-subnet", + "subnet": "10.0.0.0/24", + "description": "Test Subnet", + "organization": None, + }, + update_payload={ + "name": "updated-subnet", + "subnet": "10.0.0.0/24", + }, + expected_count=1, + ) + + def test_superuser_access_shared_ip(self): + self._logout() + subnet = self._create_subnet(subnet="10.0.0.0/24", organization=None) + self._test_superuser_access_shared_object( + token=None, + listview_path=reverse("ipam:list_create_ip_address", args=[subnet.id]), + detailview_name="ipam:ip_address", + create_payload={ + "ip_address": "10.0.0.1", + "subnet": str(subnet.id), + "description": "Test IP", + }, + update_payload={ + "description": "updated-ip", + "ip_address": "10.0.0.1", + "subnet": str(subnet.id), + }, + expected_count=1, + ) + + def test_org_manager_access_shared_ip(self): + self._logout() + shared_subnet = self._create_subnet(subnet="10.0.0.0/24", organization=None) + shared_ip = shared_subnet.request_ip() + self._test_org_user_access_shared_object( + listview_path=reverse( + "ipam:list_create_ip_address", args=[shared_subnet.id] + ), + detailview_path=reverse("ipam:ip_address", args=[shared_ip.id]), + create_payload={ + "ip_address": "10.0.0.2", + "subnet": str(shared_subnet.id), + "description": "Test IP", + }, + update_payload={ + "description": "updated-ip", + "ip_address": "10.0.0.1", + "subnet": str(shared_subnet.id), + }, + expected_count=1, + )