Skip to content

[fix] Fixes to whois/estimated locations for bugs found during testing#1197

Merged
nemesifier merged 13 commits intogsoc25-whoisfrom
gsoc25-whois-modified
Jan 29, 2026
Merged

[fix] Fixes to whois/estimated locations for bugs found during testing#1197
nemesifier merged 13 commits intogsoc25-whoisfrom
gsoc25-whois-modified

Conversation

@nemesifier
Copy link
Member

Related to #1196, contains other changes suggested by different AI models, @pandafy @DragnEmperor feel free to pick what you think works.

@nemesifier nemesifier self-assigned this Jan 15, 2026
@coderabbitai
Copy link

coderabbitai bot commented Jan 15, 2026

📝 Walkthrough

Walkthrough

The changes adjust WHOIS and estimated-location behavior: Device initialization now avoids adding duplicate "last_ip" entries to _changed_checked_fields; WHOISService.is_older enforces timezone-aware datetimes; WHOIS processing handles missing location/timezone and maps specific GeoIP2 exceptions to user-facing messages; WHOIS task execution uses Django cache to track per-task and per-device failure/success states to limit duplicate notifications; estimated-location creation avoids recreating identical locations when WHOIS data is unchanged. Tests updated/added accordingly.

Sequence Diagram(s)

sequenceDiagram
    participant Task as fetch_whois_details Task
    participant Cache as Django Cache
    participant GeoIP as GeoIP2 Service
    participant WHOIS as WHOISService
    participant DB as Device / Location DB
    participant Notify as Notification system

    Task->>Cache: check per-task/per-device last_operation
    alt cache indicates already-notified permanent failure
        Task-->>Notify: (skip duplicate notification)
    end
    Task->>GeoIP: lookup IP
    GeoIP-->>Task: result or exception
    alt exception
        Task->>Cache: set failure key (per-task or per-device)
        Task->>Notify: send notification (only if not cached)
    else success
        Task->>WHOIS: process_whois_details(result)
        WHOIS-->>WHOIS: derive coordinates/timezone (allow None)
        WHOIS->>DB: create/update WHOISInfo
        WHOIS->>DB: attach or reuse existing Location (no-op if unchanged)
        WHOIS->>Cache: set last_operation = "success" (task & device)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 1 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Description check ⚠️ Warning The description is minimal and lacks required template sections like detailed change description, testing confirmation, and documentation updates. Complete the PR description using the template: add detailed change description, confirm manual testing and new test cases, reference issue #1196, and note any documentation updates.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main changes in the PR, which involve bug fixes related to WHOIS and estimated location handling based on issues found during testing.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@nemesifier
Copy link
Member Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Jan 15, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

)
self._changed_checked_fields = self._changed_checked_fields + ["last_ip"]
super().__init__(*args, **kwargs)
self._set_initial_values_for_changed_checked_fields()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I see this code now, I think it is incorrect to add last_ip in the _changed_checked_fields here.

_changed_checked_fields is a class variable, so we should only update it in the class definition.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is done to allow test in isolation, as it is evaluated at import time. We encountered the same problem elsewhere as well:

# overriding media property to allow testing in isolation

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay! If it creates a problem in the future, we will update the tests and mock this private attribute for the tests to simulate that the feature is turned on.

Signed-off-by: DragnEmperor <dragnemperor@gmail.com>
Copy link
Member

@pandafy pandafy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for following up @DragnEmperor! Overall the PR looks good.

I want to understand how general last status is different from device level status. What kind of decisions are we making based on the different last operation.

Comment on lines -182 to -183
device = self._create_device()
self._create_config(device=device)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this code moved to the last test case?

@override_settings(CELERY_TASK_EAGER_PROPAGATES=True)
@mock.patch.object(app_settings, "WHOIS_CONFIGURED", True)
@mock.patch("openwisp_controller.config.whois.tasks.fetch_whois_details.retry")
def test_whois_task_retry_mechanism(self, mock_retry):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is good!

Comment on lines 824 to 834
with self.subTest("Test Status Cached per device for other exceptions"):
cache.clear()
device = trigger_error_and_assert_cached(Exception, True, 1)
with self.subTest(f"Cache reused by {Exception.__name__}"), mock.patch(
self._WHOIS_GEOIP_CLIENT, side_effect=Exception("test")
):
device.last_ip = "172.217.22.15"
device.save()
cache_key = f"{self._WHOIS_TASK_NAME}_{device.pk}_last_operation"
self.assertEqual(cache.get(cache_key), "error")
self.assertEqual(notification_qs.count(), 0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand this problem as following:

  1. We clear the cache status to obtain a clean state
  2. First exception will set the cache and send the notification
  3. Second exception will check that the last operation failed and will not send the notification

In the end, we should have one notification, right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's say a new system was configured which does not have any WHOIS record. Then, the user pasted the wrong credentials.

In this case, the first task executed will run into an error and it should send a notification to the user.

If we don't do this, then this will remain unnoticed. And, all future errors will be silenced because of the cache key.

Please let me know if my assumptions are wrong.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand this problem as following:

  1. We clear the cache status to obtain a clean state
  2. First exception will set the cache and send the notification
  3. Second exception will check that the last operation failed and will not send the notification

In the end, we should have one notification, right?

yes the notification will be 1 and 0 for any subsequent errors

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's say a new system was configured which does not have any WHOIS record. Then, the user pasted the wrong credentials.

In this case, the first task executed will run into an error and it should send a notification to the user.

If we don't do this, then this will remain unnoticed. And, all future errors will be silenced because of the cache key.

Please let me know if my assumptions are wrong.

Yes this is the correct flow. The current implementation sends only one notification if error is of non_recoverable type i.e. one of the permanent errors (OutOfQueries, Authentication, Permission and AddressNotFound).
If we are talking about a scenario where two errors of different types (lets say Authentication and Permission) are raised simultaneously then yes the latter one will go unnoticed.
We can store the exception type in the cache and restrict notifications if same exception is raised again.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes this is the correct flow. The current implementation sends only one notification if error is of non_recoverable type i.e. one of the permanent errors (OutOfQueries, Authentication, Permission and AddressNotFound).

This is correct.

We can store the exception type in the cache and restrict notifications if same exception is raised again.

We don't need to implement this. The current flow is sufficient.

Comment on lines 43 to 50
else:
task_key = f"{self.name}_{device_pk}_last_operation"
last_operation = cache.get(task_key)
if last_operation != "error":
cache.set(task_key, "error", None)
send_whois_task_notification(
device=device_pk, notify_type="whois_device_error"
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to this logic, if I have 100 devices, and the all 100 of them encounters HTTPError, then I will get 100 notifications. I don't think this is ideal.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following is an evaluation by GitHub Copilot using Claude Sonnet 4.5:

TLDR

HTTPError currently sends a notification per device, causing spam during service outages
(100 devices = 100 notifications). Recommendation: Use global caching like permanent errors
to send one notification regardless of device count. This prevents spam while appropriately
treating HTTPError as a service-wide issue rather than device-specific failures.

HTTPError Notification Strategy Evaluation

Current Implementation

  • HTTPError uses device-specific caching
  • Each device that fails after retries sends a separate notification
  • Problem: During GeoIP2 service outages, 100 devices = 100 notifications

Option A: Global Cache (Like Permanent Errors)

Pros:

  • Simple to implement (consistent with existing permanent error handling)
  • Prevents notification spam during service outages
  • Clear signal that GeoIP2 service is having issues
  • One notification regardless of device count

Cons:

  • Lose visibility into which specific devices failed
  • Can't distinguish between service-wide outage vs specific device issues
  • No per-device recovery tracking

Best for:

Small/medium deployments, rare service outages


Option B: Hybrid with Time-Based Throttling

Approach:

  • First HTTPError within 1 hour window sends notification with affected device count
  • Subsequent HTTPErrors within same window are tracked but don't send notifications
  • After 1 hour, if still failing, send new summary notification
  • Maintain device-level cache for recovery tracking

Pros:

  • Prevents notification spam (maximum 1 notification per hour)
  • Preserves device-level tracking for troubleshooting
  • Notifications include helpful context like "15 devices affected"
  • Enables future recovery notifications per device
  • Distinguishes between isolated failures vs service-wide outages

Cons:

  • More complex implementation
  • Requires additional cache key management
  • Slightly more difficult to understand and maintain
  • Delayed visibility (up to 1 hour) if issues start gradually

Best for:

Large deployments with many devices


Option C: Keep Current Behavior + Document

Pros:

  • No code changes needed
  • Preserves full device-level visibility
  • Simple to understand

Cons:

  • Notification spam during outages
  • Poor admin experience at scale
  • Could lead to notification fatigue and missed alerts

Best for:

Very small deployments (fewer than 10 devices)


Recommendation: Option A (Global Cache)

Why Option A:

  1. HTTPError after exhausting retries typically indicates service-wide issues such as API downtime or network problems, not device-specific issues
  2. Consistent with how other service-wide errors (OutOfQueriesError, AuthenticationError) are already handled
  3. Simple, maintainable solution that can be implemented quickly
  4. Significantly improves admin experience without over-engineering

Implementation Strategy:

  • Treat HTTPError the same as permanent errors in the on_failure method
  • Use global cache key instead of device-specific key
  • Send single notification for service-wide HTTPError failures
  • Can still log individual device failures for troubleshooting via application logs

Future Enhancement Path:

  • If device-level tracking becomes a requirement, can upgrade to Option B
  • Add admin dashboard showing recent failed devices by querying application logs
  • Monitor whether HTTPError patterns suggest need for more granular approach

Why Not Option B:

  • Adds significant complexity without clear immediate need
  • Start simple and enhance later if production usage reveals necessity
  • Overhead of time-based throttling may not be justified initially

Why Not Option C:

  • Notification spam is a real UX problem that will negatively affect production deployments
  • Admin fatigue leads to missed important alerts
  • Does not scale beyond small installations

Copy link
Member

@DragnEmperor DragnEmperor Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to this logic, if I have 100 devices, and the all 100 of them encounters HTTPError, then I will get 100 notifications. I don't think this is ideal.

Yes, I was a bit confused if we should have global level cache or device level so I implemented both (global for permanent/non-recoverable and others for device level).
Do we need device level errors to send notification?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's handle this problem with a global cache.

Comment on lines 80 to 85
general_status_key = f"{self.name}_last_operation"
device_status_key = f"{self.name}_{device_pk}_last_operation"
if cache.get(general_status_key) != "success":
cache.set(general_status_key, "success", None)
if cache.get(device_status_key) != "success":
cache.set(device_status_key, "success", None)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be moved to OpenwispCeleryTask.on_success ?

Copy link
Member

@pandafy pandafy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While reviewing this PR, I also noticed the following issue

Bug: Estimated Location Recreated Despite Identical WHOIS Data

Steps to Reproduce:

  1. Setup: Two devices with same public IP (e.g., 160.202.37.XXX)

    • Device A has estimated location L1
    • Device B has estimated location L1 (same shared location)
  2. Trigger: Clear last_ip field for both devices (set to empty string)

  3. Action: Both devices fetch their checksum sequentially

    • Both devices get the same IP: 160.202.37.XXX
    • WHOIS lookup returns SAME coordinates as location L1
  4. Expected Result:

    • Both devices keep using location L1
    • No new location created (data unchanged)
    • No notifications sent
  5. Actual Result:

    • Device A: Creates NEW location L2 with IDENTICAL coordinates
    • Device B: Reuses L2
    • Location L1 is DELETED
    • Two notifications sent ("created" and "updated")

Root Cause:

Code condition checks if (other_devices_exist AND location_is_estimated) but does NOT check if WHOIS data actually changed before creating new location.

Impact:

  • Unnecessary location churn on every IP refresh
  • Old locations deleted (data loss)
  • Database bloat
  • Misleading notifications

I have implemented a test for this, but I don't understand where to put this fix.

diff --git a/openwisp_controller/geo/estimated_location/tests/tests.py b/openwisp_controller/geo/estimated_location/tests/tests.py
index 14222f23..8eb07abf 100644
--- a/openwisp_controller/geo/estimated_location/tests/tests.py
+++ b/openwisp_controller/geo/estimated_location/tests/tests.py
@@ -560,6 +560,58 @@ class TestEstimatedLocationTransaction(
             )
         mock_info.reset_mock()
 
+    @mock.patch.object(config_app_settings, "WHOIS_CONFIGURED", True)
+    @mock.patch(_ESTIMATED_LOCATION_INFO_LOGGER)
+    @mock.patch(_WHOIS_GEOIP_CLIENT)
+    def test_unchanged_whois_data_no_location_recreation(
+        self,
+        mock_client,
+        mock_info,
+    ):
+        """
+        Two devices share same IP/location. Clear last_ip, then both fetch
+        checksum with same IP. WHOIS returns identical coords. Expected: keep
+        existing location. Bug: creates new location, deletes old one.
+        """
+        connect_whois_handlers()
+        mocked_response = self._mocked_client_response()
+        mock_client.return_value.city.return_value = mocked_response
+        shared_ip = "20.49.19.19"
+        device1 = self._create_device(
+            name="device-a",
+            mac_address="00:11:22:33:44:55",
+            last_ip=shared_ip,
+        )
+        device2 = self._create_device(
+            name="device-b",
+            mac_address="00:11:22:33:44:66",
+            last_ip=shared_ip,
+        )
+        original_location = device1.devicelocation.location
+        self.assertEqual(original_location.pk, device2.devicelocation.location.pk)
+        location_count = Location.objects.count()
+        notification_count = notification_qs.count()
+
+        # Clear the last ip for both devices, so setting them again
+        # will trigger the WHOIS lookup flow.
+        for device in (device1, device2):
+            device.last_ip = ""
+            device.save(update_fields=["last_ip"])
+            device.refresh_from_db()
+
+        # We set the same shared IP again. This simulates device fetching checksum.
+        for device in (device1, device2):
+            device.last_ip = shared_ip
+            device.save(update_fields=["last_ip"])
+            device.refresh_from_db()
+
+        # The location object should remain unchanged since the WHOIS data is the same.
+        self.assertEqual(original_location.pk, device1.devicelocation.location.pk)
+        self.assertEqual(device1.devicelocation.location.pk, device2.devicelocation.location.pk)
+        self.assertEqual(Location.objects.count(), location_count)
+        self.assertTrue(Location.objects.filter(pk=original_location.pk).exists())
+        self.assertEqual(notification_qs.count(), notification_count)
+
     @mock.patch.object(config_app_settings, "WHOIS_CONFIGURED", True)
     @mock.patch(_ESTIMATED_LOCATION_INFO_LOGGER)
     @mock.patch(_ESTIMATED_LOCATION_ERROR_LOGGER)

@pandafy
Copy link
Member

pandafy commented Jan 26, 2026

Bug: Estimated Location Recreated Despite Identical WHOIS Data

I fixed this issue in 37448f2

Copy link
Member

@pandafy pandafy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DragnEmperor can you help me verify this scenario?

Steps to Replicate

  1. Create a device

  2. Configure features (initial state)

    • Enable the WHOIS feature for the device.
    • Ensure the Estimated Location feature is disabled.
  3. Adjust WHOIS triggering behavior

    • Edit WHOIsService.is_older such that it always return True
    • This will allow to test an expired WHOIS record.
    • Alternatively, you can update the WHOIS record from the Django shell
  4. Trigger WHOIS

    • Trigger the WHOIS lookup for the device.
    • Confirm that the WHOIS record is created as expected.
    • Ideally, we should do it by fetching checksum of the device
  5. Enable Estimated Location

    • Enable the Estimated Location feature for the same device.
  6. Trigger WHOIS again

    • Trigger the WHOIS lookup once more for the device.
  7. Verify result

    • Check whether an Estimated Location entry is created for the device after the WHOIS lookup.

and current_location.geometry == location_defaults.get("geometry")
and current_location.name == location_defaults.get("name")
):
logger.info(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
logger.info(
logger.debug(

Otherwise we'll flood the logs with no benefit.

@nemesifier
Copy link
Member Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Jan 26, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Comment on lines 35 to 50
if exc.__class__ in EXCEPTION_MESSAGES:
task_key = f"{self.name}_last_operation"
last_operation = cache.get(task_key)
if last_operation != "non_recoverable":
cache.set(task_key, "non_recoverable", None)
send_whois_task_notification(
device=device_pk, notify_type="whois_device_error"
)
else:
task_key = f"{self.name}_{device_pk}_last_operation"
last_operation = cache.get(task_key)
if last_operation != "error":
cache.set(task_key, "error", None)
send_whois_task_notification(
device=device_pk, notify_type="whois_device_error"
)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stktyagi I think there's some overlap here with the work you have done in #1147, do you think we could make your code work here too? If so, we can create a follow up issue to be done later on.

Copy link
Member

@stktyagi stktyagi Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I think we can do that. A follow up issue would be good.
Also you can le tme know about any changes in #1147 if there are any whenever you are free to, so that we can move with completeness towards the follow up later.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@openwisp_controller/geo/estimated_location/tests/tests.py`:
- Around line 563-612: The test function
test_unchanged_whois_data_no_location_recreation has an unused mocked logger
parameter mock_info introduced by `@mock.patch`(_ESTIMATED_LOCATION_INFO_LOGGER);
remove the unused mock to satisfy static analysis by deleting the
`@mock.patch`(_ESTIMATED_LOCATION_INFO_LOGGER) decorator and removing mock_info
from the test signature, ensuring the remaining `@mock.patch`(_WHOIS_GEOIP_CLIENT)
and its parameter (mock_client) remain in the correct order.
🧹 Nitpick comments (2)
openwisp_controller/config/whois/tasks.py (1)

27-52: Well-designed notification deduplication logic.

The cache-based approach effectively prevents notification spam by distinguishing between:

  1. Per-task permanent failures (e.g., OutOfQueriesError, AuthenticationError) - one notification until manually cleared
  2. Per-device transient failures - one notification per device until success

One concern: using cache.set(..., None) sets an indefinite timeout. While this may be intentional for permanent errors, it means the "error" state for per-device failures will persist forever unless explicitly cleared or the cache is flushed. Consider if a TTL would be more appropriate for device-level errors to allow retry notifications after some period.

openwisp_controller/config/whois/tests/tests.py (1)

421-433: Avoid brittle side_effect list for cache.get.
Using a single-item side_effect makes the test fail with StopIteration if the implementation calls cache.get more than once. Prefer return_value=None and, if desired, assert call count explicitly.

♻️ Proposed tweak
-            with mock.patch(
-                "django.core.cache.cache.get", side_effect=[None]
-            ) as mocked_get, mock.patch("django.core.cache.cache.set") as mocked_set:
+            with mock.patch(
+                "django.core.cache.cache.get", return_value=None
+            ) as mocked_get, mock.patch("django.core.cache.cache.set") as mocked_set:

Also applies to: 438-447

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1eb7942 and b7dcac0.

📒 Files selected for processing (8)
  • openwisp_controller/config/base/device.py
  • openwisp_controller/config/whois/service.py
  • openwisp_controller/config/whois/tasks.py
  • openwisp_controller/config/whois/tests/tests.py
  • openwisp_controller/config/whois/tests/utils.py
  • openwisp_controller/config/whois/utils.py
  • openwisp_controller/geo/estimated_location/tasks.py
  • openwisp_controller/geo/estimated_location/tests/tests.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • openwisp_controller/config/base/device.py
🧰 Additional context used
🧠 Learnings (5)
📓 Common learnings
Learnt from: nemesifier
Repo: openwisp/openwisp-controller PR: 1175
File: tests/openwisp2/sample_config/migrations/0008_whoisinfo_organizationconfigsettings_whois_enabled.py:18-67
Timestamp: 2026-01-12T22:27:48.342Z
Learning: In tests/openwisp2/sample_config/models.py and corresponding test migrations, the WHOISInfo model intentionally includes an additional "details" field not present in the base AbstractWHOISInfo model. This is a testing pattern to verify that swappable models (CONFIG_WHOISINFO_MODEL) can be extended with custom fields without errors.
Learnt from: DragnEmperor
Repo: openwisp/openwisp-controller PR: 1175
File: openwisp_controller/geo/base/models.py:63-93
Timestamp: 2026-01-15T14:06:53.460Z
Learning: In openwisp_controller/geo/base/models.py, the BaseLocation.save() method protects the is_estimated field from manual changes when the estimated location feature is disabled by reverting it to its initial database value using the elif branch. This is intentional behavior to ensure the field can only be modified when the feature is properly configured.
📚 Learning: 2026-01-15T15:05:49.557Z
Learnt from: DragnEmperor
Repo: openwisp/openwisp-controller PR: 1175
File: openwisp_controller/config/management/commands/clear_last_ip.py:38-42
Timestamp: 2026-01-15T15:05:49.557Z
Learning: In Django projects, when using select_related() to traverse relations (for example, select_related("organization__config_settings")), the traversed relation must not be deferred. If you also use .only() in the same query, include the relation name or FK field (e.g., "organization" or "organization_id") in the .only() list to avoid the error "Field X cannot be both deferred and traversed using select_related at the same time." Apply this guideline to Django code in openwisp_controller/config/management/commands/clear_last_ip.py and similar modules by ensuring any select_related with an accompanying only() includes the related field names to prevent deferred/traversed conflicts.

Applied to files:

  • openwisp_controller/config/whois/service.py
  • openwisp_controller/config/whois/tests/tests.py
  • openwisp_controller/geo/estimated_location/tasks.py
  • openwisp_controller/config/whois/tests/utils.py
  • openwisp_controller/config/whois/utils.py
  • openwisp_controller/geo/estimated_location/tests/tests.py
  • openwisp_controller/config/whois/tasks.py
📚 Learning: 2026-01-15T15:07:17.354Z
Learnt from: DragnEmperor
Repo: openwisp/openwisp-controller PR: 1175
File: openwisp_controller/geo/estimated_location/tests/tests.py:172-175
Timestamp: 2026-01-15T15:07:17.354Z
Learning: In this repository, flake8 enforces E501 (line too long) via setup.cfg (max-line-length = 88) while ruff ignores E501 via ruff.toml. Therefore, use '# noqa: E501' on lines that intentionally exceed 88 characters to satisfy flake8 without affecting ruff checks. This applies to Python files across the project (any .py) and is relevant for tests as well. Use sparingly and only where breaking lines is not feasible without hurting readability or functionality.

Applied to files:

  • openwisp_controller/config/whois/service.py
  • openwisp_controller/config/whois/tests/tests.py
  • openwisp_controller/geo/estimated_location/tasks.py
  • openwisp_controller/config/whois/tests/utils.py
  • openwisp_controller/config/whois/utils.py
  • openwisp_controller/geo/estimated_location/tests/tests.py
  • openwisp_controller/config/whois/tasks.py
📚 Learning: 2026-01-12T22:27:48.342Z
Learnt from: nemesifier
Repo: openwisp/openwisp-controller PR: 1175
File: tests/openwisp2/sample_config/migrations/0008_whoisinfo_organizationconfigsettings_whois_enabled.py:18-67
Timestamp: 2026-01-12T22:27:48.342Z
Learning: In tests/openwisp2/sample_config/models.py and corresponding test migrations, the WHOISInfo model intentionally includes an additional "details" field not present in the base AbstractWHOISInfo model. This is a testing pattern to verify that swappable models (CONFIG_WHOISINFO_MODEL) can be extended with custom fields without errors.

Applied to files:

  • openwisp_controller/config/whois/tests/tests.py
  • openwisp_controller/config/whois/tests/utils.py
  • openwisp_controller/geo/estimated_location/tests/tests.py
📚 Learning: 2026-01-15T14:06:53.460Z
Learnt from: DragnEmperor
Repo: openwisp/openwisp-controller PR: 1175
File: openwisp_controller/geo/base/models.py:63-93
Timestamp: 2026-01-15T14:06:53.460Z
Learning: In openwisp_controller/geo/base/models.py, the BaseLocation.save() method protects the is_estimated field from manual changes when the estimated location feature is disabled by reverting it to its initial database value using the elif branch. This is intentional behavior to ensure the field can only be modified when the feature is properly configured.

Applied to files:

  • openwisp_controller/geo/estimated_location/tasks.py
  • openwisp_controller/geo/estimated_location/tests/tests.py
🧬 Code graph analysis (6)
openwisp_controller/config/whois/service.py (1)
openwisp_controller/config/whois/utils.py (1)
  • send_whois_task_notification (62-73)
openwisp_controller/config/whois/tests/tests.py (1)
openwisp_controller/config/whois/service.py (2)
  • is_older (69-81)
  • process_whois_details (145-197)
openwisp_controller/geo/estimated_location/tasks.py (2)
openwisp_controller/config/base/config.py (1)
  • name (132-139)
openwisp_controller/geo/base/models.py (1)
  • device (152-153)
openwisp_controller/config/whois/tests/utils.py (1)
openwisp_controller/geo/base/models.py (1)
  • device (152-153)
openwisp_controller/geo/estimated_location/tests/tests.py (5)
openwisp_controller/config/base/device.py (1)
  • save (279-299)
openwisp_controller/geo/base/models.py (2)
  • save (68-106)
  • device (152-153)
openwisp_controller/config/base/multitenancy.py (1)
  • save (90-101)
openwisp_controller/config/whois/handlers.py (1)
  • connect_whois_handlers (7-29)
openwisp_controller/config/whois/tests/utils.py (1)
  • _mocked_client_response (46-58)
openwisp_controller/config/whois/tasks.py (1)
openwisp_controller/config/whois/utils.py (1)
  • send_whois_task_notification (62-73)
🪛 Ruff (0.14.14)
openwisp_controller/config/whois/service.py

75-78: Avoid specifying long messages outside the exception class

(TRY003)

openwisp_controller/geo/estimated_location/tests/tests.py

569-569: Unused method argument: mock_info

(ARG002)

🔇 Additional comments (17)
openwisp_controller/config/whois/utils.py (1)

44-59: LGTM! Well-structured exception messages mapping.

The EXCEPTION_MESSAGES dictionary cleanly centralizes GeoIP2 error messages with proper i18n support. The AddressNotFoundError message correctly includes a placeholder for dynamic IP address formatting, while other messages are appropriately static.

openwisp_controller/config/whois/tests/utils.py (1)

60-70: LGTM! Simplified test setup.

The direct device creation with public IP and subsequent mock reset streamlines the test flow. The cache interaction tests have been appropriately moved to the main test file where they can be tested with more granular assertions.

openwisp_controller/geo/estimated_location/tasks.py (2)

31-34: LGTM! Correct guard for existing location attachment.

The updated condition properly checks that existing_device_location exists AND its location differs from the current device_location.location before proceeding with the attachment logic. This prevents unnecessary operations when locations are already identical.


67-79: Good fix for preventing unnecessary location recreation.

This guard correctly addresses the reported bug where shared locations were being recreated despite identical WHOIS data. The check compares geometry and name which are the key identifying attributes derived from WHOIS coordinates and address data. The comparison logic handles all cases correctly, including when geometry is None (in cases where WHOIS coordinates are unavailable), since the equality comparisons work as expected.

openwisp_controller/config/whois/tasks.py (1)

80-85: Cache success status updates look correct.

Setting both the general and device-specific cache keys to "success" after a successful fetch allows future failures to trigger notifications again. This complements the on_failure logic well.

However, there's a subtle issue: on Lines 82-83 and 84-85, you're checking if the value isn't already "success" before setting it. But after a failure, the value would be "non_recoverable" or "error", not "success". The condition != "success" will be True in those cases, which is correct. But if you want to explicitly clear the error state and allow future error notifications, this approach works.

openwisp_controller/geo/estimated_location/tests/tests.py (2)

155-155: Good addition of task name constant.

Centralizing _WHOIS_TASK_NAME as a class attribute improves maintainability and ensures consistent cache key construction in tests.


189-212: Comprehensive cache interaction assertions.

The test properly validates that:

  1. Cache is queried for existing state
  2. Organization config is cached with correct timeout
  3. Task-level and device-level success statuses are set

The use of assert_has_calls allows verifying the expected calls without being overly strict about call order.

openwisp_controller/config/whois/service.py (2)

68-81: Good defensive programming for timezone awareness.

The explicit check for timezone-aware datetimes using timezone.is_aware(dt) prevents subtle bugs that could occur with naive datetime comparisons. This is especially important when the modified field comes from the database and could potentially be naive in misconfigured environments.

The static analysis hint (TRY003) about long exception messages is a style preference. The inline message here is acceptable since it provides clear guidance for fixing the issue.


178-197: Robust handling of missing location data.

The changes properly handle edge cases where WHOIS response lacks location information:

  1. Default timezone to empty string (matching non-nullable CharField requirement)
  2. coordinates set to None when lat/long are missing
  3. Clean conditional with walrus operator for location check

The comment on Lines 178-180 clearly documents the rationale for the empty string default, which is helpful for future maintainers.

openwisp_controller/config/whois/tests/tests.py (8)

8-8: Imports and model loading look fine.

Also applies to: 25-28


66-66: Comment-only change.


143-147: Good coverage for naive datetime guard.


391-391: Nice: shared task name constant avoids duplication.


397-408: Missing-coordinates scenario is well covered.


739-778: Notification assertions aligned with new caching behavior.


779-849: Cache status coverage is solid (per-task/per-device/success).


851-874: Retry mechanism test additions look good.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines 563 to 612
@mock.patch.object(config_app_settings, "WHOIS_CONFIGURED", True)
@mock.patch(_ESTIMATED_LOCATION_INFO_LOGGER)
@mock.patch(_WHOIS_GEOIP_CLIENT)
def test_unchanged_whois_data_no_location_recreation(
self,
mock_client,
mock_info,
):
"""Ensure identical WHOIS results do not recreate a shared Location when
devices reuse the same IP."""
connect_whois_handlers()
mocked_response = self._mocked_client_response()
mock_client.return_value.city.return_value = mocked_response
shared_ip = "20.49.19.19"
device1 = self._create_device(
name="device-a",
mac_address="00:11:22:33:44:55",
last_ip=shared_ip,
)
device2 = self._create_device(
name="device-b",
mac_address="00:11:22:33:44:66",
last_ip=shared_ip,
)
original_location = device1.devicelocation.location
self.assertEqual(original_location.pk, device2.devicelocation.location.pk)
location_count = Location.objects.count()
notification_count = notification_qs.count()

# Clear the last ip for both devices, so setting them again
# will trigger the WHOIS lookup flow.
for device in (device1, device2):
device.last_ip = ""
device.save(update_fields=["last_ip"])
device.refresh_from_db()

# We set the same shared IP again. This simulates device fetching checksum.
for device in (device1, device2):
device.last_ip = shared_ip
device.save(update_fields=["last_ip"])
device.refresh_from_db()

# The location object should remain unchanged since the WHOIS data is the same.
self.assertEqual(original_location.pk, device1.devicelocation.location.pk)
self.assertEqual(
device1.devicelocation.location.pk, device2.devicelocation.location.pk
)
self.assertEqual(Location.objects.count(), location_count)
self.assertTrue(Location.objects.filter(pk=original_location.pk).exists())
self.assertEqual(notification_qs.count(), notification_count)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Good regression test for the location recreation bug.

This test validates the fix reported by pandafy: identical WHOIS data should not trigger location recreation. The test:

  1. Creates two devices sharing the same IP and location
  2. Clears and re-sets last_ip to trigger WHOIS flow
  3. Verifies the original location object is preserved

However, static analysis correctly flags mock_info as unused. If the logger mock isn't being asserted against, consider removing it to clean up the test signature.

Proposed fix: Remove unused mock_info parameter
 `@mock.patch.object`(config_app_settings, "WHOIS_CONFIGURED", True)
-    `@mock.patch`(_ESTIMATED_LOCATION_INFO_LOGGER)
     `@mock.patch`(_WHOIS_GEOIP_CLIENT)
     def test_unchanged_whois_data_no_location_recreation(
         self,
         mock_client,
-        mock_info,
     ):
🧰 Tools
🪛 Ruff (0.14.14)

569-569: Unused method argument: mock_info

(ARG002)

🤖 Prompt for AI Agents
In `@openwisp_controller/geo/estimated_location/tests/tests.py` around lines 563 -
612, The test function test_unchanged_whois_data_no_location_recreation has an
unused mocked logger parameter mock_info introduced by
`@mock.patch`(_ESTIMATED_LOCATION_INFO_LOGGER); remove the unused mock to satisfy
static analysis by deleting the `@mock.patch`(_ESTIMATED_LOCATION_INFO_LOGGER)
decorator and removing mock_info from the test signature, ensuring the remaining
`@mock.patch`(_WHOIS_GEOIP_CLIENT) and its parameter (mock_client) remain in the
correct order.

@pandafy
Copy link
Member

pandafy commented Jan 27, 2026

I have tested this scenario #1197 (review), and my suspicion was correct. When the WHOIS record is stale and the device does not already have an estimated location, the logic does not create a Estimated location for the device.

EDIT: The potential fix for this scenario will re-create the DeviceLocation object if user have deleted it manually whenever a WHOIS Lookup is performed. I don't think this is ideal.

with self.subTest(f"Cache populated by {first_error.__name__}"):
cache.clear()
trigger_error_and_assert_cached(first_error, 1)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

for subsequent_error in permanent_errors:
if subsequent_error is first_error:
continue

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

self.assertEqual(notification_qs.count(), notification_count)
notification_qs.delete()
return device

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

errors.AuthenticationError,
errors.PermissionRequiredError,
]

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

assert isinstance(mock_retry.call_args.kwargs["exc"], exception)
else:
self.assertEqual(mock_retry.call_count, 0)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

location.longitude, location.latitude, srid=4326
)
timezone = location.time_zone or ""

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

self.assertEqual(original_location.pk, device2.devicelocation.location.pk)
location_count = Location.objects.count()
notification_count = notification_qs.count()

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

device.last_ip = ""
device.save(update_fields=["last_ip"])
device.refresh_from_db()

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

device.last_ip = shared_ip
device.save(update_fields=["last_ip"])
device.refresh_from_db()

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

**whois_obj._get_defaults_for_estimated_location(),
"organization_id": device.organization_id,
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

@nemesifier nemesifier changed the title [fix] Fixes to whois/estimated locations suggested by different AI models [fix] Fixes to whois/estimated locations for bugs found during testing Jan 29, 2026
@nemesifier nemesifier merged commit 20c3b6c into gsoc25-whois Jan 29, 2026
14 checks passed
@nemesifier nemesifier deleted the gsoc25-whois-modified branch January 29, 2026 14:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

4 participants