Repository: learningequality/kolibri

Issue:
Add custom user-agent to `NetworkClient` and external API calls
<!--
Instructions:
 * Fill out the sections below, replace …'s with information about your issue
 * Use the 'preview' function above this text box to verify formatting before submitting
-->

## Observed behavior
<!--
Description of the behavior that was observed, including screenshots or other references when applicable
-->
The user agent for API calls made with python `requests` do not clearly identify the device as Kolibri

## Errors and logs
<!--
Relevant logs from:
 * the command line
 * ~/.kolibri/logs/kolibri.txt
 * the browser console

Please wrap errors in triple backticks for clean formatting like this:
```
01:10 info: something happened
01:12 error: something bad happened
```
-->
Perhaps connected with errors like the following although these are not the primary reason to customize the user-agent:
```
http.client.RemoteDisconnected: Remote end closed connection without response
```

## Expected behavior
<!--
Description of what behavior was expected but did not occur
-->
It's recommended that we clearly identify the application making API calls, to differentiate it from potential scripting. According to MDN, it should have the format:
```
User-Agent: <product> / <product-version> <comment>
```
So for Kolibri 0.16.0:
```
User-Agent: kolibri/0.16.0 python-requests/2.28.2
```

## User-facing consequences
<!--
Implications and real-world consequences for learners, coaches, admins, and other users of the application
-->
Web application firewalls may take more aggressive action against clients making many requests if it appears to be purely something scripted.


## Context
<!--
Tell us about your environment, including:
 * Kolibri version
 * Operating system
 * Browser
-->
Kolibri 0.15+



Discussion:

Resolving patch:
diff --git a/kolibri/core/discovery/utils/network/client.py b/kolibri/core/discovery/utils/network/client.py
--- a/kolibri/core/discovery/utils/network/client.py
+++ b/kolibri/core/discovery/utils/network/client.py
@@ -4,6 +4,7 @@
 from six import raise_from
 from six.moves.urllib.parse import urlparse
 
+import kolibri
 from . import errors
 from .urls import get_normalized_url_variations
 from .urls import HTTP_PORTS
@@ -47,6 +48,11 @@ def __init__(self, base_url, timeout=None):
         self.session = None
         self.device_info = None
         self.remote_ip = None
+        self.headers.update(
+            {
+                "User-Agent": get_user_agent(),
+            }
+        )
 
     @classmethod
     def build_for_address(cls, address, timeout=None):
@@ -241,3 +247,9 @@ def connect(self, raise_if_unavailable=True):  # noqa: C901
             return False
 
         return True
+
+
+def get_user_agent():
+    return "Kolibri/{0} python-requests/{1}".format(
+        kolibri.__version__, requests.__version__
+    )


Test patch:
diff --git a/kolibri/core/discovery/test/test_network_utils.py b/kolibri/core/discovery/test/test_network_utils.py
--- a/kolibri/core/discovery/test/test_network_utils.py
+++ b/kolibri/core/discovery/test/test_network_utils.py
@@ -2,6 +2,7 @@
 import requests
 from django.test import TestCase
 
+import kolibri
 from ..models import ConnectionStatus
 from ..models import NetworkLocation
 from ..utils.network import errors
@@ -301,6 +302,16 @@ def test_request__response_failure(self):
                     with NetworkClient("http://sadurl.qqq") as nc:
                         nc.connect()
 
+    @mock.patch.object(requests.Session, "get", mock_response(200))
+    def test_request__user_agent(self):
+        client = NetworkClient("https://example.com")
+        response = client.get("/")
+
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(
+            "Kolibri/{0}".format(kolibri.__version__), client.headers["User-Agent"]
+        )
+
     @mock.patch.object(
         requests.Session, "request", mock_happy_request("https://url.qqq/")
     )