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=(
+ '"
+ ),
+ )
+
+ 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,
+ )