Skip to content

Commit 07579ca

Browse files
committed
[changes] Added PointField, functions refactoring
Signed-off-by: DragnEmperor <[email protected]>
1 parent 706848e commit 07579ca

File tree

11 files changed

+240
-134
lines changed

11 files changed

+240
-134
lines changed

openwisp_controller/config/base/whois.py

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from ipaddress import ip_address, ip_network
22

3+
from django.contrib.gis.db.models import PointField
34
from django.core.cache import cache
45
from django.core.exceptions import ValidationError
5-
from django.core.validators import MaxValueValidator, MinValueValidator
66
from django.db import models, transaction
77
from django.utils.translation import gettext_lazy as _
88
from jsonfield import JSONField
@@ -52,17 +52,23 @@ class AbstractWHOISInfo(TimeStampedEditableModel):
5252
blank=True,
5353
help_text=_("CIDR"),
5454
)
55-
latitude = models.FloatField(
55+
# latitude = models.FloatField(
56+
# null=True,
57+
# blank=True,
58+
# help_text=_("Latitude"),
59+
# validators=[MinValueValidator(-90.0), MaxValueValidator(90.0)],
60+
# )
61+
# longitude = models.FloatField(
62+
# null=True,
63+
# blank=True,
64+
# help_text=_("Longitude"),
65+
# validators=[MinValueValidator(-180.0), MaxValueValidator(180.0)],
66+
# )
67+
coordinates = PointField(
5668
null=True,
5769
blank=True,
58-
help_text=_("Latitude"),
59-
validators=[MinValueValidator(-90.0), MaxValueValidator(90.0)],
60-
)
61-
longitude = models.FloatField(
62-
null=True,
63-
blank=True,
64-
help_text=_("Longitude"),
65-
validators=[MinValueValidator(-180.0), MaxValueValidator(180.0)],
70+
help_text=_("Coordinates"),
71+
srid=4326,
6672
)
6773

6874
class Meta:
@@ -87,6 +93,20 @@ def clean(self):
8793
raise ValidationError(
8894
{"cidr": _("Invalid CIDR format: %(error)s") % {"error": str(e)}}
8995
)
96+
97+
if self.coordinates:
98+
if not (-90 <= self.coordinates.y <= 90):
99+
raise ValidationError(
100+
{"coordinates": _("Latitude must be between -90 and 90 degrees.")}
101+
)
102+
if not (-180 <= self.coordinates.x <= 180):
103+
raise ValidationError(
104+
{
105+
"coordinates": _(
106+
"Longitude must be between -180 and 180 degrees."
107+
)
108+
}
109+
)
90110
return super().clean()
91111

92112
@staticmethod
@@ -126,3 +146,28 @@ def formatted_address(self):
126146
],
127147
)
128148
)
149+
150+
@property
151+
def _location_name(self):
152+
"""
153+
Used to get location name based on the address and IP.
154+
"""
155+
address = self.formatted_address
156+
if address:
157+
parts = [part.strip() for part in address.split(",")[:2] if part.strip()]
158+
location = ", ".join(parts)
159+
return f"{location} (Estimated Location: {self.ip_address})"
160+
return f"Estimated Location: {self.ip_address}"
161+
162+
def _get_defaults_for_estimated_location(self):
163+
"""
164+
Used to get default values for creating or updating
165+
an estimated location based on the WHOIS information.
166+
"""
167+
return {
168+
"name": self._location_name,
169+
"type": "outdoor",
170+
"is_mobile": False,
171+
"geometry": self.coordinates,
172+
"address": self.formatted_address,
173+
}
Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Generated by Django 5.2.1 on 2025-07-10 18:09
22

3-
import django.core.validators
4-
from django.db import migrations, models
3+
import django.contrib.gis.db.models.fields
4+
from django.db import migrations
55

66
import openwisp_utils.fields
77

@@ -27,28 +27,9 @@ class Migration(migrations.Migration):
2727
),
2828
migrations.AddField(
2929
model_name="whoisinfo",
30-
name="latitude",
31-
field=models.FloatField(
32-
blank=True,
33-
help_text="Latitude",
34-
null=True,
35-
validators=[
36-
django.core.validators.MinValueValidator(-90.0),
37-
django.core.validators.MaxValueValidator(90.0),
38-
],
39-
),
40-
),
41-
migrations.AddField(
42-
model_name="whoisinfo",
43-
name="longitude",
44-
field=models.FloatField(
45-
blank=True,
46-
help_text="Longitude",
47-
null=True,
48-
validators=[
49-
django.core.validators.MinValueValidator(-180.0),
50-
django.core.validators.MaxValueValidator(180.0),
51-
],
30+
name="coordinates",
31+
field=django.contrib.gis.db.models.fields.PointField(
32+
blank=True, help_text="Coordinates", null=True, srid=4326
5233
),
5334
),
5435
]

openwisp_controller/config/whois/service.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from ipaddress import ip_address
1+
from ipaddress import ip_address as ip_addr
22

33
from django.core.cache import cache
44
from django.db import transaction
@@ -30,7 +30,7 @@ def is_valid_public_ip_address(ip):
3030
Check if given IP address is a valid public IP address.
3131
"""
3232
try:
33-
return ip and ip_address(ip).is_global
33+
return ip and ip_addr(ip).is_global
3434
except ValueError:
3535
return False
3636

openwisp_controller/config/whois/tasks.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import requests
44
from celery import shared_task
5+
from django.contrib.gis.geos import Point
56
from django.utils.translation import gettext as _
67
from geoip2 import errors
78
from geoip2 import webservice as geoip2_webservice
@@ -109,16 +110,15 @@ def fetch_whois_details(self, device_pk, initial_ip_address, new_ip_address):
109110
"continent": data.continent.name or "",
110111
"postal": str(data.postal.code or ""),
111112
}
112-
113+
coordinates = Point(data.location.longitude, data.location.latitude, srid=4326)
113114
whois_obj = WHOISInfo(
114115
isp=data.traits.autonomous_system_organization,
115116
asn=data.traits.autonomous_system_number,
116117
timezone=data.location.time_zone,
117118
address=address,
118119
cidr=data.traits.network,
119120
ip_address=new_ip_address,
120-
longitude=data.location.longitude,
121-
latitude=data.location.latitude,
121+
coordinates=coordinates,
122122
)
123123
whois_obj.full_clean()
124124
whois_obj.save()

openwisp_controller/config/whois/test_whois.py

Lines changed: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from unittest import mock
33

44
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
5+
from django.contrib.gis.geos import Point
56
from django.core.exceptions import ImproperlyConfigured, ValidationError
67
from django.db.models.signals import post_delete, post_save
78
from django.test import TestCase, TransactionTestCase, override_settings, tag
@@ -15,8 +16,7 @@
1516
from ...tests.utils import TestAdminMixin
1617
from .. import settings as app_settings
1718
from .handlers import connect_whois_handlers
18-
from .tests_utils import CreateWHOISMixin
19-
from utils import WHOISTransactionMixin
19+
from .tests_utils import CreateWHOISMixin, WHOISTransactionMixin
2020

2121
Device = load_model("config", "Device")
2222
WHOISInfo = load_model("config", "WHOISInfo")
@@ -302,32 +302,20 @@ def test_whois_model_fields_validation(self):
302302
with self.assertRaises(ValidationError):
303303
self._create_whois_info(asn="InvalidASN")
304304

305-
# Common validation checks for latitude and longitude
306-
latitudes = [
307-
(100.0, "Ensure this value is less than or equal to 90.0."),
308-
(-100.0, "Ensure this value is greater than or equal to -90.0."),
305+
# Common validation checks for longitude and latitude
306+
coordinates_cases = [
307+
(150.0, 100.0, "Latitude must be between -90 and 90 degrees."),
308+
(150.0, -100.0, "Latitude must be between -90 and 90 degrees."),
309+
(200.0, 80.0, "Longitude must be between -180 and 180 degrees."),
310+
(-200.0, -80.0, "Longitude must be between -180 and 180 degrees."),
309311
]
310-
for value, expected_msg in latitudes:
312+
for longitude, latitude, expected_msg in coordinates_cases:
311313
with self.assertRaises(ValidationError) as context_manager:
312-
self._create_whois_info(latitude=value)
314+
point = Point(longitude, latitude, srid=4326)
315+
self._create_whois_info(coordinates=point)
313316
try:
314317
self.assertEqual(
315-
context_manager.exception.message_dict["latitude"][0],
316-
expected_msg,
317-
)
318-
except AssertionError:
319-
self.fail("ValidationError message not equal to expected message.")
320-
321-
longitudes = [
322-
(200.0, "Ensure this value is less than or equal to 180.0."),
323-
(-200.0, "Ensure this value is greater than or equal to -180.0."),
324-
]
325-
for value, expected_msg in longitudes:
326-
with self.assertRaises(ValidationError) as context_manager:
327-
self._create_whois_info(longitude=value)
328-
try:
329-
self.assertEqual(
330-
context_manager.exception.message_dict["longitude"][0],
318+
context_manager.exception.message_dict["coordinates"][0],
331319
expected_msg,
332320
)
333321
except AssertionError:
@@ -467,8 +455,8 @@ def _verify_whois_details(instance, ip_address):
467455
instance.formatted_address,
468456
"Mountain View, United States, North America, 94043",
469457
)
470-
self.assertEqual(instance.latitude, 50)
471-
self.assertEqual(instance.longitude, 150)
458+
self.assertEqual(instance.coordinates.x, 150.0)
459+
self.assertEqual(instance.coordinates.y, 50.0)
472460

473461
# mocking the response from the geoip2 client
474462
mock_client.return_value.city.return_value = self._mocked_client_response()

openwisp_controller/config/whois/tests_utils.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
from unittest import mock
2+
3+
from django.contrib.gis.geos import Point
4+
from django.urls import reverse
15
from swapper import load_model
26

37
from ..tests.utils import CreateConfigMixin
@@ -21,6 +25,7 @@ def _create_whois_info(self, **kwargs):
2125
isp="Google LLC",
2226
timezone="America/Los_Angeles",
2327
cidr="172.217.22.0/24",
28+
coordinates=Point(150, 50, srid=4326),
2429
)
2530

2631
options.update(kwargs)
@@ -34,3 +39,104 @@ def setUp(self):
3439
OrganizationConfigSettings.objects.create(
3540
organization=self._get_org(), whois_enabled=True
3641
)
42+
43+
44+
class WHOISTransactionMixin:
45+
@staticmethod
46+
def _mocked_client_response():
47+
mock_response = mock.MagicMock()
48+
mock_response.city.name = "Mountain View"
49+
mock_response.country.name = "United States"
50+
mock_response.continent.name = "North America"
51+
mock_response.postal.code = "94043"
52+
mock_response.traits.autonomous_system_organization = "Google LLC"
53+
mock_response.traits.autonomous_system_number = 15169
54+
mock_response.traits.network = "172.217.22.0/24"
55+
mock_response.location.time_zone = "America/Los_Angeles"
56+
mock_response.location.latitude = 50
57+
mock_response.location.longitude = 150
58+
return mock_response
59+
60+
def _task_called(self, mocked_task, task_name="WHOIS lookup"):
61+
org = self._get_org()
62+
63+
with self.subTest(f"{task_name} task called when last_ip is public"):
64+
with mock.patch("django.core.cache.cache.set") as mocked_set:
65+
device = self._create_device(last_ip="172.217.22.14")
66+
mocked_task.assert_called()
67+
mocked_set.assert_called_once()
68+
mocked_task.reset_mock()
69+
70+
with self.subTest(
71+
f"{task_name} task called when last_ip is changed and is public"
72+
):
73+
with mock.patch("django.core.cache.cache.get") as mocked_get:
74+
device.last_ip = "172.217.22.10"
75+
device.save()
76+
mocked_task.assert_called()
77+
# The cache `get` is called twice, once for `whois_enabled` and
78+
# once for `estimated_location_enabled`
79+
mocked_get.assert_called()
80+
mocked_task.reset_mock()
81+
82+
with self.subTest(f"{task_name} task not called when last_ip is private"):
83+
device.last_ip = "10.0.0.1"
84+
device.save()
85+
mocked_task.assert_not_called()
86+
mocked_task.reset_mock()
87+
88+
with self.subTest(f"{task_name} task not called when WHOIS is disabled"):
89+
Device.objects.all().delete()
90+
org.config_settings.whois_enabled = False
91+
# Invalidates old org config settings cache
92+
org.config_settings.save(update_fields=["whois_enabled"])
93+
device = self._create_device(last_ip="172.217.22.14")
94+
mocked_task.assert_not_called()
95+
mocked_task.reset_mock()
96+
97+
with self.subTest(
98+
f"{task_name} task called via DeviceChecksumView when WHOIS is enabled"
99+
):
100+
org.config_settings.whois_enabled = True
101+
# Invalidates old org config settings cache
102+
org.config_settings.save(update_fields=["whois_enabled"])
103+
# config is required for checksum view to work
104+
self._create_config(device=device)
105+
# setting remote address field to a public IP to trigger WHOIS task
106+
# since the view uses this header for tracking the device's IP
107+
response = self.client.get(
108+
reverse("controller:device_checksum", args=[device.pk]),
109+
{"key": device.key},
110+
REMOTE_ADDR="172.217.22.10",
111+
)
112+
self.assertEqual(response.status_code, 200)
113+
mocked_task.assert_called()
114+
mocked_task.reset_mock()
115+
116+
with self.subTest(
117+
f"{task_name} task called via DeviceChecksumView for no WHOIS record"
118+
):
119+
WHOISInfo.objects.all().delete()
120+
response = self.client.get(
121+
reverse("controller:device_checksum", args=[device.pk]),
122+
{"key": device.key},
123+
REMOTE_ADDR=device.last_ip,
124+
)
125+
self.assertEqual(response.status_code, 200)
126+
mocked_task.assert_called()
127+
mocked_task.reset_mock()
128+
129+
with self.subTest(
130+
f"{task_name} task not called via DeviceChecksumView when WHOIS is disabled"
131+
):
132+
WHOISInfo.objects.all().delete()
133+
org.config_settings.whois_enabled = False
134+
org.config_settings.save(update_fields=["whois_enabled"])
135+
response = self.client.get(
136+
reverse("controller:device_checksum", args=[device.pk]),
137+
{"key": device.key},
138+
REMOTE_ADDR=device.last_ip,
139+
)
140+
self.assertEqual(response.status_code, 200)
141+
mocked_task.assert_not_called()
142+
mocked_task.reset_mock()

openwisp_controller/config/whois/utils.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
from unittest import mock
2-
3-
from django.urls import reverse
41
from django.utils.translation import gettext as _
52
from openwisp_notifications.signals import notify
63
from swapper import load_model
@@ -33,6 +30,8 @@
3330

3431

3532
def send_whois_task_notification(device_pk, notify_type):
33+
Device = load_model("config", "Device")
34+
3635
device = Device.objects.get(pk=device_pk)
3736
notify_details = MESSAGE_MAP[notify_type]
3837
notify.send(

0 commit comments

Comments
 (0)