Skip to content

Commit a3d6b1f

Browse files
authored
[feature] Set location estimated flag to False on manual update #1036
Closes #1036 Signed-off-by: DragnEmperor <[email protected]>
1 parent bc837a1 commit a3d6b1f

File tree

8 files changed

+116
-37
lines changed

8 files changed

+116
-37
lines changed

docs/user/estimated-location.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,7 @@ includes indicators for the estimated status.
6666
<ip_address>)**.
6767
- A warning on top of the page.
6868
- **Is Estimated** field.
69+
70+
Changes to the ``coordinates`` and ``geometry`` of the estimated location
71+
will set the ``is_estimated`` field to ``False`` and remove the
72+
"(Estimated Location)" suffix with IP from the location name.

openwisp_controller/config/whois/service.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ def get_org_config_settings(org_id):
7171
)
7272
return org_settings
7373

74+
@staticmethod
75+
def check_estimate_location_configured(org_id):
76+
if not org_id:
77+
return False
78+
if not app_settings.WHOIS_CONFIGURED:
79+
return False
80+
org_settings = WHOISService.get_org_config_settings(org_id=org_id)
81+
return getattr(org_settings, "estimated_location_enabled")
82+
7483
@property
7584
def is_whois_enabled(self):
7685
"""
@@ -85,11 +94,7 @@ def is_estimated_location_enabled(self):
8594
Check if the Estimated location feature is enabled.
8695
"""
8796
org_settings = self.get_org_config_settings(org_id=self.device.organization.pk)
88-
return getattr(
89-
org_settings,
90-
"estimated_location_enabled",
91-
app_settings.ESTIMATED_LOCATION_ENABLED,
92-
)
97+
return getattr(org_settings, "estimated_location_enabled")
9398

9499
def _need_whois_lookup(self, new_ip):
95100
"""

openwisp_controller/config/whois/test_whois.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,6 @@ def test_whois_details_device_api(self):
217217
with self.subTest(
218218
"Device Detail API has whois_info when WHOIS_CONFIGURED is True"
219219
):
220-
221220
response = self.client.get(
222221
reverse("config_api:device_detail", args=[device.pk])
223222
)

openwisp_controller/geo/admin.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@
1212
)
1313
from swapper import load_model
1414

15+
from openwisp_controller.config.whois.service import WHOISService
1516
from openwisp_users.multitenancy import MultitenantOrgFilter
1617

1718
from ..admin import MultitenantAdminMixin
1819
from ..config.admin import DeactivatedDeviceReadOnlyMixin, DeviceAdminExportable
19-
from .estimated_location.utils import check_estimate_location_configured
2020
from .exportable import GeoDeviceResource
2121

2222
DeviceLocation = load_model("geo", "DeviceLocation")
@@ -103,24 +103,24 @@ class LocationAdmin(MultitenantAdminMixin, AbstractLocationAdmin):
103103

104104
def get_fields(self, request, obj=None):
105105
fields = super().get_fields(request, obj)
106-
if obj and not check_estimate_location_configured(obj.organization_id):
106+
org_id = obj.organization_id if obj else None
107+
if not WHOISService.check_estimate_location_configured(org_id):
107108
if "is_estimated" in fields:
108109
fields.remove("is_estimated")
109110
return fields
110111

111112
def get_readonly_fields(self, request, obj=None):
112113
fields = super().get_readonly_fields(request, obj)
113-
if obj and check_estimate_location_configured(obj.organization_id):
114+
org_id = obj.organization_id if obj else None
115+
if obj and WHOISService.check_estimate_location_configured(org_id):
114116
fields = fields + ("is_estimated",)
115117
return fields
116118

117119
def change_view(self, request, object_id, form_url="", extra_context=None):
118120
obj = self.get_object(request, object_id)
119-
extra_context = {
120-
"estimated_configured": check_estimate_location_configured(
121-
obj.organization_id
122-
)
123-
}
121+
org_id = obj.organization_id if obj else None
122+
estimated_configured = WHOISService.check_estimate_location_configured(org_id)
123+
extra_context = {"estimated_configured": estimated_configured}
124124
return super().change_view(request, object_id, form_url, extra_context)
125125

126126

openwisp_controller/geo/base/models.py

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import re
2+
13
from django.contrib.gis.db import models
24
from django.core.exceptions import ValidationError
5+
from django.utils.translation import gettext
36
from django.utils.translation import gettext_lazy as _
47
from django_loci.base.models import (
58
AbstractFloorPlan,
@@ -8,12 +11,13 @@
811
)
912
from swapper import get_model_name
1013

14+
from openwisp_controller.config.whois.service import WHOISService
1115
from openwisp_users.mixins import OrgMixin, ValidateOrgMixin
1216

13-
from ..estimated_location.utils import check_estimate_location_configured
14-
1517

1618
class BaseLocation(OrgMixin, AbstractLocation):
19+
_changed_checked_fields = ["is_estimated", "address", "geometry"]
20+
1721
is_estimated = models.BooleanField(
1822
default=False,
1923
help_text=_("Whether the location's coordinates are estimated."),
@@ -24,15 +28,25 @@ class Meta(AbstractLocation.Meta):
2428

2529
def __init__(self, *args, **kwargs):
2630
super().__init__(*args, **kwargs)
27-
self._initial_is_estimated = self.is_estimated
31+
self._set_initial_values_for_changed_checked_fields()
32+
33+
def _set_initial_values_for_changed_checked_fields(self):
34+
deferred_fields = self.get_deferred_fields()
35+
for field in self._changed_checked_fields:
36+
if field in deferred_fields:
37+
setattr(self, f"_initial_{field}", models.DEFERRED)
38+
else:
39+
setattr(self, f"_initial_{field}", getattr(self, field))
2840

2941
def clean(self):
3042
# Raise validation error if `is_estimated` is True but estimated feature is
3143
# disabled.
3244
if (
3345
(self._state.adding or self._initial_is_estimated != self.is_estimated)
3446
and self.is_estimated
35-
and not check_estimate_location_configured(self.organization_id)
47+
and not WHOISService.check_estimate_location_configured(
48+
self.organization_id
49+
)
3650
):
3751
raise ValidationError(
3852
{
@@ -43,6 +57,35 @@ def clean(self):
4357
)
4458
return super().clean()
4559

60+
def save(self, *args, _set_estimated=False, **kwargs):
61+
"""
62+
Save the location object with special handling for estimated locations.
63+
64+
Parameters:
65+
_set_estimated: Boolean flag to indicate if this save is being performed
66+
by the estimated location system. When False (default),
67+
manual edits will clear the estimated status.
68+
*args, **kwargs: Arguments passed to the parent save method.
69+
70+
Returns:
71+
The result of the parent save method.
72+
"""
73+
if WHOISService.check_estimate_location_configured(self.organization_id):
74+
if not _set_estimated and (
75+
self._initial_address != self.address
76+
or self._initial_geometry != self.geometry
77+
):
78+
self.is_estimated = False
79+
estimated_string = gettext("Estimated Location")
80+
if self.name and estimated_string in self.name:
81+
# remove string starting with "(Estimated Location"
82+
self.name = re.sub(
83+
rf"\s\({estimated_string}.*", "", self.name, flags=re.IGNORECASE
84+
)
85+
else:
86+
self.is_estimated = self._initial_is_estimated
87+
return super().save(*args, **kwargs)
88+
4689

4790
class BaseFloorPlan(OrgMixin, AbstractFloorPlan):
4891
location = models.ForeignKey(get_model_name("geo", "Location"), models.CASCADE)

openwisp_controller/geo/estimated_location/tasks.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def _create_estimated_location(device_location, location_defaults):
3636
with transaction.atomic():
3737
location = Location(**location_defaults, is_estimated=True)
3838
location.full_clean()
39-
location.save()
39+
location.save(_set_estimated=True)
4040
device_location.location = location
4141
device_location.full_clean()
4242
device_location.save()
@@ -72,7 +72,9 @@ def _update_or_create_estimated_location(
7272
setattr(current_location, attr, value)
7373
update_fields.append(attr)
7474
if update_fields:
75-
current_location.save(update_fields=update_fields)
75+
current_location.save(
76+
update_fields=update_fields, _set_estimated=True
77+
)
7678
logger.info(
7779
f"Estimated location saved successfully for {device_pk}"
7880
f" for IP: {ip_address}"

openwisp_controller/geo/estimated_location/test_estimated_location.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ def _verify_location_details(device, mocked_response):
294294
device.last_ip = "172.217.22.11"
295295
device.devicelocation.location.is_estimated = False
296296
mock_client.return_value.city.return_value = self._mocked_client_response()
297-
device.devicelocation.location.save()
297+
device.devicelocation.location.save(_set_estimated=True)
298298
device.save()
299299
device.refresh_from_db()
300300

@@ -431,6 +431,7 @@ def _verify_notification(device, messages, notify_level="info"):
431431
for message in messages:
432432
self.assertIn(message, notification.message)
433433
self.assertIn(device.last_ip, notification.rendered_description)
434+
self.assertIn("#devicelocation-group", notification.target_url)
434435

435436
with self.subTest("Test Notification for location create"):
436437
mocked_response = self._mocked_client_response()
@@ -468,3 +469,44 @@ def _verify_notification(device, messages, notify_level="info"):
468469
)
469470
messages = ["Unable to create estimated location for device"]
470471
_verify_notification(device3, messages, "error")
472+
473+
@mock.patch.object(config_app_settings, "WHOIS_CONFIGURED", True)
474+
@mock.patch(_WHOIS_GEOIP_CLIENT)
475+
def test_estimate_location_status_remove(self, mock_client):
476+
mocked_response = self._mocked_client_response()
477+
mock_client.return_value.city.return_value = mocked_response
478+
device = self._create_device(last_ip="172.217.22.10")
479+
location = device.devicelocation.location
480+
self.assertTrue(location.is_estimated)
481+
org = self._get_org()
482+
483+
with self.subTest(
484+
"Test Estimated Status unchanged if Estimated feature is disabled"
485+
):
486+
org.config_settings.estimated_location_enabled = False
487+
org.config_settings.save()
488+
location.geometry = GEOSGeometry("POINT(12.512124 41.898903)", srid=4326)
489+
location.save()
490+
self.assertTrue(location.is_estimated)
491+
self.assertIn(f"(Estimated Location: {device.last_ip})", location.name)
492+
493+
with self.subTest(
494+
"Test Estimated Status unchanged if Estimated feature is enabled"
495+
" and desired fields not changed"
496+
):
497+
org.config_settings.estimated_location_enabled = True
498+
org.config_settings.save()
499+
location._set_initial_values_for_changed_checked_fields()
500+
location.type = "outdoor"
501+
location.is_mobile = True
502+
location.save()
503+
self.assertTrue(location.is_estimated)
504+
505+
with self.subTest(
506+
"Test Estimated Status changed if Estimated feature is enabled"
507+
" and desired fields changed"
508+
):
509+
location.geometry = GEOSGeometry("POINT(15.512124 45.898903)", srid=4326)
510+
location.save()
511+
self.assertFalse(location.is_estimated)
512+
self.assertNotIn(f"(Estimated Location: {device.last_ip})", location.name)

openwisp_controller/geo/estimated_location/utils.py

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,5 @@
11
from openwisp_notifications.utils import _get_object_link
22

3-
from openwisp_controller.config import settings as config_app_settings
4-
from openwisp_controller.config.whois.service import WHOISService
5-
6-
7-
def check_estimate_location_configured(org_id):
8-
if not org_id:
9-
return False
10-
if not config_app_settings.WHOIS_CONFIGURED:
11-
return False
12-
org_settings = WHOISService.get_org_config_settings(org_id=org_id)
13-
return getattr(
14-
org_settings,
15-
"estimated_location_enabled",
16-
config_app_settings.ESTIMATED_LOCATION_ENABLED,
17-
)
18-
193

204
def get_device_location_notification_target_url(obj, field, absolute_url=True):
215
url = _get_object_link(obj._related_object(field), absolute_url)

0 commit comments

Comments
 (0)