Skip to content

Commit 2c1dda6

Browse files
committed
[feature] Estimated Location: Admin and API filters #1028
Closes #1028 Signed-off-by: DragnEmperor <[email protected]>
1 parent 01c318a commit 2c1dda6

File tree

11 files changed

+419
-14
lines changed

11 files changed

+419
-14
lines changed

docs/user/estimated-location.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,11 @@ includes indicators for the estimated status.
7070
Changes to the ``coordinates`` and ``geometry`` of the estimated location
7171
will set the ``is_estimated`` field to ``False`` and remove the
7272
"(Estimated Location)" suffix with IP from the location name.
73+
74+
In REST API, the field will be visible in the :ref:`Device Location
75+
<device_location_estimated>`, :ref:`Location list
76+
<location_list_estimated>`, :ref:`Location Detail
77+
<location_detail_estimated>` and :ref:`Location list (GeoJson)
78+
<location_geojson_estimated>` if the feature is **enabled**. The field can
79+
also be used for filtering in the location list (including geojson)
80+
endpoints and in the :ref:`Device List <device_list_estimated_filters>`.

docs/user/rest-api.rst

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ If :doc:`WHOIS Lookup feature <whois>` is enabled, each device in the list
7676
response will also include a ``whois_info`` field with related brief WHOIS
7777
information.
7878

79+
.. _device_list_estimated_filters:
80+
81+
**Estimated Location Filters**
82+
83+
if :doc:`Estimated Location feature <estimated-location>` is enabled,
84+
devices can be filtered based on the estimated nature of their location
85+
using the ``geo_is_estimated``.
86+
7987
**Available filters**
8088

8189
You can filter a list of devices based on their configuration status using
@@ -544,13 +552,26 @@ of certificate's organization as show in the example below:
544552
545553
GET /api/v1/controller/cert/{common_name}/group/?org={org1_slug},{org2_slug}
546554
555+
.. |est_loc| replace:: Estimated Location feature
556+
557+
.. _est_loc: estimated-location.html
558+
559+
.. |estimated_details| replace:: If |est_loc|_ is enabled, the location
560+
response will also include ``is_estimated`` status field.
561+
547562
Get Device Location
548563
~~~~~~~~~~~~~~~~~~~
549564

550565
.. code-block:: text
551566
552567
GET /api/v1/controller/device/{id}/location/
553568
569+
.. _device_location_estimated:
570+
571+
**Estimated Status**
572+
573+
|estimated_details|
574+
554575
.. _create_device_location:
555576

556577
Create Device Location
@@ -787,6 +808,14 @@ List Locations
787808
788809
GET /api/v1/controller/location/
789810
811+
.. _location_list_estimated:
812+
813+
**Estimated Status**
814+
815+
|estimated_details|
816+
817+
Locations can also be filtered using the ``is_estimated``.
818+
790819
**Available filters**
791820

792821
You can filter using ``organization_id`` or ``organization_slug`` to get
@@ -868,6 +897,12 @@ Get Location Details
868897
869898
GET /api/v1/controller/location/{pk}/
870899
900+
.. _location_detail_estimated:
901+
902+
**Estimated Status**
903+
904+
|estimated_details|
905+
871906
Change Location Details
872907
~~~~~~~~~~~~~~~~~~~~~~~
873908

@@ -910,6 +945,14 @@ List Locations with Devices Deployed (in GeoJSON Format)
910945
911946
GET /api/v1/controller/location/geojson/
912947
948+
.. _location_geojson_estimated:
949+
950+
**Estimated Status**
951+
952+
|estimated_details|
953+
954+
Locations can also be filtered using the ``is_estimated``.
955+
913956
**Available filters**
914957

915958
You can filter using ``organization_id`` or ``organization_slug`` to get

docs/user/whois.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ associated with the device's public IP address and includes:
2828
- CIDR block assigned to the ASN
2929
- Physical address registered to the ASN
3030
- Timezone of the ASN's registered location
31+
- Coordinates (Latitude and Longitude)
3132

3233
Trigger Conditions
3334
------------------

openwisp_controller/config/whois/service.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,15 @@ def _get_whois_info_from_db(ip_address):
4646
@staticmethod
4747
def get_org_config_settings(org_id):
4848
"""
49-
Caches the OrganizationConfigSettings as these settings
50-
are not expected to change frequently. The timeout for the cache
51-
is set to the same as the checksum cache timeout for consistency
52-
with DeviceChecksumView.
49+
Retrieve and cache organization-specific configuration settings.
50+
51+
Returns a "read-only" OrganizationConfigSettings instance for the
52+
given organization.
53+
If no settings exist for the organization, returns an empty instance to allow
54+
fallback to global defaults.
55+
56+
OrganizationConfigSettings are cached for performance, using the same timeout
57+
as DeviceChecksumView for consistency.
5358
"""
5459
OrganizationConfigSettings = load_model("config", "OrganizationConfigSettings")
5560
Config = load_model("config", "Config")
@@ -63,7 +68,7 @@ def get_org_config_settings(org_id):
6368
)
6469
except OrganizationConfigSettings.DoesNotExist:
6570
# If organization settings do not exist, fall back to global setting
66-
return None
71+
org_settings = OrganizationConfigSettings()
6772
cache.set(
6873
cache_key,
6974
org_settings,
@@ -78,23 +83,23 @@ def check_estimate_location_configured(org_id):
7883
if not app_settings.WHOIS_CONFIGURED:
7984
return False
8085
org_settings = WHOISService.get_org_config_settings(org_id=org_id)
81-
return getattr(org_settings, "estimated_location_enabled")
86+
return org_settings.estimated_location_enabled
8287

8388
@property
8489
def is_whois_enabled(self):
8590
"""
8691
Check if the WHOIS lookup feature is enabled.
8792
"""
8893
org_settings = self.get_org_config_settings(org_id=self.device.organization.pk)
89-
return getattr(org_settings, "whois_enabled", app_settings.WHOIS_ENABLED)
94+
return org_settings.whois_enabled
9095

9196
@property
9297
def is_estimated_location_enabled(self):
9398
"""
9499
Check if the Estimated location feature is enabled.
95100
"""
96101
org_settings = self.get_org_config_settings(org_id=self.device.organization.pk)
97-
return getattr(org_settings, "estimated_location_enabled")
102+
return org_settings.estimated_location_enabled
98103

99104
def _need_whois_lookup(self, new_ip):
100105
"""

openwisp_controller/config/whois/test_whois.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,16 @@ def assert_logging_on_exception(
631631
class TestWHOISSelenium(CreateWHOISMixin, SeleniumTestMixin, StaticLiveServerTestCase):
632632
@mock.patch.object(app_settings, "WHOIS_CONFIGURED", True)
633633
def test_whois_device_admin(self):
634+
def _assert_no_js_errors():
635+
browser_logs = []
636+
for log in self.get_browser_logs():
637+
if self.browser == "chrome" and log["source"] != "console-api":
638+
continue
639+
elif log["message"] in ["wrong event specified: touchleave"]:
640+
continue
641+
browser_logs.append(log)
642+
self.assertEqual(browser_logs, [])
643+
634644
whois_obj = self._create_whois_info()
635645
device = self._create_device(last_ip=whois_obj.ip_address)
636646
self.login()
@@ -655,6 +665,7 @@ def test_whois_device_admin(self):
655665
self.assertIn(whois_obj.timezone, additional_text[1].text)
656666
self.assertIn(whois_obj.formatted_address, additional_text[2].text)
657667
self.assertIn(whois_obj.cidr, additional_text[3].text)
668+
_assert_no_js_errors()
658669

659670
with mock.patch.object(app_settings, "WHOIS_CONFIGURED", False):
660671
with self.subTest(
@@ -664,6 +675,7 @@ def test_whois_device_admin(self):
664675
self.open(reverse("admin:config_device_change", args=[device.pk]))
665676
self.wait_for_invisibility(By.CSS_SELECTOR, "table.whois-table")
666677
self.wait_for_invisibility(By.CSS_SELECTOR, "details.whois")
678+
_assert_no_js_errors()
667679

668680
with self.subTest(
669681
"WHOIS details not visible in device admin when WHOIS is disabled"
@@ -674,6 +686,7 @@ def test_whois_device_admin(self):
674686
self.open(reverse("admin:config_device_change", args=[device.pk]))
675687
self.wait_for_invisibility(By.CSS_SELECTOR, "table.whois-table")
676688
self.wait_for_invisibility(By.CSS_SELECTOR, "details.whois")
689+
_assert_no_js_errors()
677690

678691
with self.subTest(
679692
"WHOIS details not visible in device admin when WHOIS Info does not exist"
@@ -685,3 +698,4 @@ def test_whois_device_admin(self):
685698
self.open(reverse("admin:config_device_change", args=[device.pk]))
686699
self.wait_for_invisibility(By.CSS_SELECTOR, "table.whois-table")
687700
self.wait_for_invisibility(By.CSS_SELECTOR, "details.whois")
701+
_assert_no_js_errors()

openwisp_controller/geo/admin.py

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

15+
from openwisp_controller.config import settings as config_app_settings
1516
from openwisp_controller.config.whois.service import WHOISService
1617
from openwisp_users.multitenancy import MultitenantOrgFilter
1718

@@ -146,16 +147,39 @@ class DeviceLocationFilter(admin.SimpleListFilter):
146147
title = _("has geographic position set?")
147148
parameter_name = "with_geo"
148149

150+
def __init__(self, request, params, model, model_admin):
151+
super().__init__(request, params, model, model_admin)
152+
if config_app_settings.WHOIS_CONFIGURED:
153+
self.title = _("geographic position")
154+
149155
def lookups(self, request, model_admin):
156+
if config_app_settings.WHOIS_CONFIGURED:
157+
return (
158+
("outdoor", _("Outdoor")),
159+
("indoor", _("Indoor")),
160+
("estimated", _("Estimated")),
161+
("false", _("No Location")),
162+
)
150163
return (
151164
("true", _("Yes")),
152165
("false", _("No")),
153166
)
154167

155168
def queryset(self, request, queryset):
156-
if self.value():
157-
return queryset.filter(devicelocation__isnull=self.value() == "false")
158-
return queryset
169+
value = self.value()
170+
if not value:
171+
return queryset
172+
if config_app_settings.WHOIS_CONFIGURED:
173+
if value == "estimated":
174+
return queryset.filter(devicelocation__location__is_estimated=True)
175+
elif value in ("indoor", "outdoor"):
176+
# estimated locations are outdoor by default
177+
# so we need to exclude them from the result
178+
return queryset.filter(
179+
devicelocation__location__type=value,
180+
devicelocation__location__is_estimated=False,
181+
)
182+
return queryset.filter(devicelocation__isnull=self.value() == "false")
159183

160184

161185
# Prepend DeviceLocationInline to config.DeviceAdminExportable

openwisp_controller/geo/api/filters.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.utils.translation import gettext_lazy as _
22
from django_filters import rest_framework as filters
33

4+
from openwisp_controller.config import settings as config_app_settings
45
from openwisp_controller.config.api.filters import (
56
DeviceListFilter as BaseDeviceListFilter,
67
)
@@ -21,6 +22,20 @@ def filter_devicelocation(self, queryset, name, value):
2122
# Returns list of device that have devicelocation objects
2223
return queryset.exclude(devicelocation__isnull=value)
2324

25+
def filter_is_estimated(self, queryset, name, value):
26+
return queryset.filter(devicelocation__location__is_estimated=value)
27+
28+
def __init__(self, *args, **kwargs):
29+
super().__init__(*args, **kwargs)
30+
if config_app_settings.WHOIS_CONFIGURED:
31+
self.filters["geo_is_estimated"] = filters.BooleanFilter(
32+
field_name="devicelocation__location__is_estimated",
33+
method=self.filter_is_estimated,
34+
)
35+
self.filters["geo_is_estimated"].label = _(
36+
"Is geographic location estimated?"
37+
)
38+
2439
class Meta:
2540
model = BaseDeviceListFilter.Meta.model
2641
fields = BaseDeviceListFilter.Meta.fields[:]

openwisp_controller/geo/api/serializers.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
from openwisp_utils.api.serializers import ValidatedModelSerializer
1212

1313
from ...serializers import BaseSerializer
14+
from ..estimated_location.mixins import (
15+
EstimatedLocationGeoJsonSerializer,
16+
EstimatedLocationMixin,
17+
)
1418

1519
Device = load_model("config", "Device")
1620
Location = load_model("geo", "Location")
@@ -31,7 +35,9 @@ class Meta:
3135
fields = "__all__"
3236

3337

34-
class GeoJsonLocationSerializer(gis_serializers.GeoFeatureModelSerializer):
38+
class GeoJsonLocationSerializer(
39+
EstimatedLocationGeoJsonSerializer, gis_serializers.GeoFeatureModelSerializer
40+
):
3541
device_count = IntegerField()
3642

3743
class Meta:
@@ -126,7 +132,7 @@ class Meta:
126132
read_only_fields = ("name",)
127133

128134

129-
class LocationSerializer(BaseSerializer):
135+
class LocationSerializer(EstimatedLocationMixin, BaseSerializer):
130136
floorplan = FloorPlanLocationSerializer(required=False, allow_null=True)
131137

132138
class Meta:
@@ -225,7 +231,9 @@ def update(self, instance, validated_data):
225231
return super().update(instance, validated_data)
226232

227233

228-
class NestedtLocationSerializer(gis_serializers.GeoFeatureModelSerializer):
234+
class NestedtLocationSerializer(
235+
EstimatedLocationGeoJsonSerializer, gis_serializers.GeoFeatureModelSerializer
236+
):
229237
class Meta:
230238
model = Location
231239
geo_field = "geometry"

openwisp_controller/geo/api/views.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from rest_framework_gis.pagination import GeoJsonPagination
1111
from swapper import load_model
1212

13+
from openwisp_controller.config import settings as config_app_settings
1314
from openwisp_controller.config.api.views import DeviceListCreateView
1415
from openwisp_users.api.filters import OrganizationManagedFilter
1516
from openwisp_users.api.mixins import FilterByOrganizationManaged, FilterByParentManaged
@@ -45,6 +46,13 @@ class Meta(OrganizationManagedFilter.Meta):
4546
model = Location
4647
fields = OrganizationManagedFilter.Meta.fields + ["is_mobile", "type"]
4748

49+
def __init__(self, data=None, queryset=None, *, request=None, prefix=None):
50+
super().__init__(data, queryset, request=request, prefix=prefix)
51+
if config_app_settings.WHOIS_CONFIGURED:
52+
self.filters["is_estimated"] = filters.BooleanFilter(
53+
field_name="is_estimated"
54+
)
55+
4856

4957
class FloorPlanOrganizationFilter(OrganizationManagedFilter):
5058
class Meta(OrganizationManagedFilter.Meta):
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from openwisp_controller.config.whois.service import WHOISService
2+
3+
4+
class EstimatedLocationMixin:
5+
"""
6+
Serializer mixin to add estimated location field to the serialized data
7+
if the estimated location feature is configured and enabled for the organization.
8+
"""
9+
10+
def to_representation(self, obj):
11+
data = super().to_representation(obj)
12+
if WHOISService.check_estimate_location_configured(obj.organization_id):
13+
data["is_estimated"] = obj.is_estimated
14+
else:
15+
data.pop("is_estimated", None)
16+
return data
17+
18+
19+
class EstimatedLocationGeoJsonSerializer(EstimatedLocationMixin):
20+
"""
21+
Extension of EstimatedLocationMixin for GeoJSON serialization.
22+
"""
23+
24+
def to_representation(self, obj):
25+
data = super(EstimatedLocationMixin, self).to_representation(obj)
26+
if WHOISService.check_estimate_location_configured(obj.organization_id):
27+
data["properties"]["is_estimated"] = obj.is_estimated
28+
else:
29+
data["properties"].pop("is_estimated", None)
30+
return data

0 commit comments

Comments
 (0)