Skip to content

Commit adf67ee

Browse files
authored
Merge pull request #4507 from unicef/feature/RDI-Access-change-for-retrieving-data-from-Kobo-NEW
RDI Access change for retrieving data from Kobo Reinstated
2 parents 29623b9 + 8ea785b commit adf67ee

19 files changed

+26660
-124
lines changed

development_tools/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ PGUSER=postgres
1111
POSTGRES_HOST_AUTH_METHOD=trust
1212
DATABASE_URL=postgis://postgres:postgres@db:5432/postgres
1313
REP_DATABASE_URL=postgis://postgres:postgres@db:5432/postgres
14+
POSTGRES_SSL_MODE=off
1415
EMAIL_HOST=TBD
1516
EMAIL_HOST_USER=TBD
1617
EMAIL_HOST_PASSWORD=TBD
@@ -39,6 +40,7 @@ USE_DUMMY_EXCHANGE_RATES=no
3940
OPENAPI_URL=127.0.0.1:8080/api/rest/
4041

4142
KOBO_URL=https://kobo.humanitarianresponse.info
43+
KOBO_PROJECT_VIEWS_ID=
4244
#MAILJET_API_KEY=
4345
#MAILJET_SECRET_KEY=
4446
#CATCH_ALL_EMAIL=

src/hct_mis_api/apps/core/kobo/api.py

Lines changed: 74 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -2,130 +2,100 @@
22
import time
33
import typing
44
from io import BytesIO
5-
from typing import Any, Dict, List, Optional, Tuple
5+
from typing import Dict, List, Optional, Tuple
66
from urllib.parse import urlparse
77

88
from django.conf import settings
99

1010
import requests
11+
from requests import Response
1112
from requests.adapters import HTTPAdapter
1213
from requests.exceptions import RetryError
1314
from requests.packages.urllib3.util.retry import Retry
1415

15-
from hct_mis_api.apps.core.kobo.common import filter_by_owner
16-
from hct_mis_api.apps.core.models import BusinessArea, XLSXKoboTemplate
16+
from hct_mis_api.apps.core.models import XLSXKoboTemplate
1717
from hct_mis_api.apps.utils.exceptions import log_and_raise
1818

1919
logger = logging.getLogger(__name__)
2020

2121

22-
class TokenNotProvided(Exception):
23-
pass
24-
25-
26-
class TokenInvalid(Exception):
22+
class CountryCodeNotProvided(Exception):
2723
pass
2824

2925

3026
class KoboRequestsSession(requests.Session):
31-
AUTH_DOMAINS = [urlparse(settings.KOBO_URL).hostname]
27+
AUTH_DOMAINS = [urlparse(settings.KOBO_URL).hostname, urlparse(settings.KOBO_URL).hostname]
3228

3329
def should_strip_auth(self, old_url: str, new_url: str) -> bool:
3430
new_parsed = urlparse(new_url)
35-
if new_parsed.hostname in KoboRequestsSession.AUTH_DOMAINS:
31+
if new_parsed.hostname in KoboRequestsSession.AUTH_DOMAINS: # pragma: no cover
3632
return False
37-
return super().should_strip_auth(old_url, new_url) # type: ignore # FIXME: Call to untyped function "should_strip_auth" in typed context
33+
return super().should_strip_auth(
34+
old_url, new_url
35+
) # type: ignore # FIXME: Call to untyped function "should_strip_auth" in typed context
3836

3937

4038
class KoboAPI:
41-
def __init__(self, business_area_slug: Optional[str] = None):
42-
if business_area_slug is not None:
43-
self.business_area = BusinessArea.objects.get(slug=business_area_slug)
44-
self.KPI_URL = self.business_area.kobo_url or settings.KOBO_URL
45-
else:
46-
self.business_area = None
47-
self.KPI_URL = settings.KOBO_URL
39+
LIMIT = 30_000
40+
FORMAT = "json"
4841

49-
self._get_token()
42+
def __init__(
43+
self, kpi_url: Optional[str] = None, token: Optional[str] = None, project_views_id: Optional[str] = None
44+
) -> None:
45+
self._kpi_url = kpi_url or settings.KOBO_URL
46+
self._token = token or settings.KOBO_MASTER_API_TOKEN
47+
self._project_views_id = project_views_id or settings.KOBO_PROJECT_VIEWS_ID
48+
49+
self._client = KoboRequestsSession()
50+
self._set_token()
51+
52+
def _set_token(self) -> None:
53+
retries = Retry(total=5, backoff_factor=1, status_forcelist=[502, 503, 504], allowed_methods=False)
54+
self._client.mount(self._kpi_url, HTTPAdapter(max_retries=retries))
55+
self._client.headers.update({"Authorization": f"token {self._token}"})
5056

51-
def _handle_paginated_results(self, url: str) -> List[Dict]:
57+
def _get_paginated_request(self, url: str) -> List[Dict]:
5258
next_url = url
5359
results: List = []
5460

55-
# if there will be more than 30000 results,
56-
# we need to make additional queries
5761
while next_url:
58-
data = self._handle_request(next_url)
62+
response = self._get_request(next_url)
63+
data = response.json()
5964
next_url = data["next"]
6065
results.extend(data["results"])
6166
return results
6267

63-
def _get_url(
64-
self,
65-
endpoint: str,
66-
append_api: bool = True,
67-
add_limit: bool = True,
68-
additional_query_params: Optional[Any] = None,
69-
) -> str:
70-
endpoint.strip("/")
71-
if endpoint != "token" and append_api is True:
72-
endpoint = f"api/v2/{endpoint}"
73-
# According to the Kobo API documentation,
74-
# the maximum limit per page is 30000
75-
query_params = f"format=json{'&limit=30000' if add_limit else ''}"
76-
if additional_query_params is not None:
77-
query_params += f"&{additional_query_params}"
78-
return f"{self.KPI_URL}/{endpoint}?{query_params}"
79-
80-
def _get_token(self) -> None:
81-
self._client = KoboRequestsSession()
82-
retries = Retry(total=5, backoff_factor=1, status_forcelist=[502, 503, 504], allowed_methods=False)
83-
self._client.mount(self.KPI_URL, HTTPAdapter(max_retries=retries))
84-
85-
if self.business_area is None:
86-
token = settings.KOBO_MASTER_API_TOKEN
87-
else:
88-
token = self.business_area.kobo_token
89-
90-
if not token:
91-
msg = f"KOBO Token is not set for business area {self.business_area}"
92-
logger.warning(msg)
93-
raise TokenNotProvided(msg)
94-
95-
self._client.headers.update({"Authorization": f"token {token}"})
96-
97-
def _handle_request(self, url: str) -> Dict:
68+
def _get_request(self, url: str) -> Response:
9869
response = self._client.get(url=url)
9970
try:
10071
response.raise_for_status()
101-
except requests.exceptions.HTTPError as e:
102-
logger.warning(e)
72+
except requests.exceptions.HTTPError as e: # pragma: no cover
73+
logger.exception(e)
10374
raise
104-
return response.json()
75+
return response
10576

10677
def _post_request(
10778
self, url: str, data: Optional[Dict] = None, files: Optional[typing.IO] = None
108-
) -> requests.Response:
79+
) -> Response: # pragma: no cover
10980
return self._client.post(url=url, data=data, files=files)
11081

111-
def _patch_request(
112-
self, url: str, data: Optional[Dict] = None, files: Optional[typing.IO] = None
113-
) -> requests.Response:
114-
return self._client.patch(url=url, data=data, files=files)
115-
11682
def create_template_from_file(
117-
self, bytes_io_file: Optional[typing.IO], xlsx_kobo_template_object: XLSXKoboTemplate, template_id: str = ""
118-
) -> Optional[Tuple[Dict, str]]:
119-
data = {
120-
"name": "Untitled",
121-
"asset_type": "template",
122-
"description": "",
123-
"sector": "",
124-
"country": "",
125-
"share-metadata": False,
126-
}
83+
self, bytes_io_file: typing.IO, xlsx_kobo_template_object: XLSXKoboTemplate, template_id: str = ""
84+
) -> Optional[Tuple[Dict, str]]: # pragma: no cover
85+
# TODO: not sure if this actually works
12786
if not template_id:
128-
asset_response = self._post_request(url=self._get_url("assets/", add_limit=False), data=data)
87+
data = {
88+
"name": "Untitled",
89+
"asset_type": "template",
90+
"description": "",
91+
"sector": "",
92+
"country": "",
93+
"share-metadata": False,
94+
}
95+
endpoint = "api/v2/assets"
96+
query_params = f"format={self.FORMAT}"
97+
url = f"{self._kpi_url}/{endpoint}?{query_params}"
98+
asset_response = self._post_request(url=url, data=data)
12999
try:
130100
asset_response.raise_for_status()
131101
except requests.exceptions.HTTPError as e:
@@ -135,12 +105,13 @@ def create_template_from_file(
135105
asset_uid = asset_response_dict.get("uid")
136106
else:
137107
asset_uid = template_id
108+
138109
file_import_data = {
139110
"assetUid": asset_uid,
140-
"destination": self._get_url(f"assets/{asset_uid}/", append_api=False, add_limit=False),
111+
"destination": f"{self._kpi_url}/assets/{asset_uid}?format={self.FORMAT}",
141112
}
142113
file_import_response = self._post_request(
143-
url=self._get_url("imports/", append_api=False, add_limit=False),
114+
url=f"{self._kpi_url}/imports?format={self.FORMAT}",
144115
data=file_import_data,
145116
files={"file": bytes_io_file}, # type: ignore # FIXME
146117
)
@@ -149,7 +120,8 @@ def create_template_from_file(
149120

150121
attempts = 5
151122
while attempts >= 0:
152-
response_dict = self._handle_request(url)
123+
response = self._get_request(url)
124+
response_dict = response.json()
153125
import_status = response_dict.get("status")
154126
if import_status == "processing":
155127
xlsx_kobo_template_object.status = XLSXKoboTemplate.PROCESSING
@@ -162,36 +134,31 @@ def create_template_from_file(
162134
log_and_raise("Fetching import data took too long", error_type=RetryError)
163135
return None
164136

165-
def get_all_projects_data(self) -> List:
166-
if not self.business_area:
167-
logger.warning("Business area is not provided")
168-
raise ValueError("Business area is not provided")
169-
projects_url = self._get_url("assets/")
170-
171-
results = self._handle_paginated_results(projects_url)
172-
return filter_by_owner(results, self.business_area)
137+
def get_all_projects_data(self, country_code: str) -> List:
138+
if not country_code:
139+
raise CountryCodeNotProvided("No country code provided")
140+
endpoint = f"api/v2/project-views/{self._project_views_id}/assets/"
141+
query_params = f"format={self.FORMAT}&limit={self.LIMIT}"
142+
query_params += f"&q=settings__country_codes__icontains:{country_code.upper()}"
143+
url = f"{self._kpi_url}/{endpoint}?{query_params}"
144+
return self._get_paginated_request(url)
173145

174146
def get_single_project_data(self, uid: str) -> Dict:
175-
projects_url = self._get_url(f"assets/{uid}")
176-
177-
return self._handle_request(projects_url)
147+
endpoint = f"api/v2/assets/{uid}/"
148+
query_params = f"format={self.FORMAT}&limit={self.LIMIT}"
149+
url = f"{self._kpi_url}/{endpoint}?{query_params}"
150+
response = self._get_request(url)
151+
return response.json()
178152

179-
def get_project_submissions(self, uid: str, only_active_submissions: bool) -> List:
180-
additional_query_params = None
153+
def get_project_submissions(self, uid: str, only_active_submissions: bool) -> List[Dict]:
154+
endpoint = f"api/v2/assets/{uid}/data/"
155+
query_params = f"format={self.FORMAT}&limit={self.LIMIT}"
181156
if only_active_submissions:
182157
additional_query_params = 'query={"_validation_status.uid":"validation_status_approved"}'
183-
submissions_url = self._get_url(
184-
f"assets/{uid}/data/",
185-
additional_query_params=additional_query_params,
186-
)
187-
188-
return self._handle_paginated_results(submissions_url)
158+
query_params += f"&{additional_query_params}"
159+
url = f"{self._kpi_url}/{endpoint}?{query_params}"
160+
return self._get_paginated_request(url)
189161

190-
def get_attached_file(self, url: str) -> BytesIO:
191-
response = self._client.get(url=url)
192-
try:
193-
response.raise_for_status()
194-
except requests.exceptions.HTTPError as e:
195-
logger.warning(e)
196-
raise
162+
def get_attached_file(self, url: str) -> BytesIO: # pragma: no cover
163+
response = self._get_request(url)
197164
return BytesIO(response.content)

src/hct_mis_api/apps/core/kobo/common.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,6 @@ def count_population(results: list, business_area: BusinessArea) -> tuple[int, i
111111
return total_households_count, total_individuals_count
112112

113113

114-
def filter_by_owner(data: List, business_area: BusinessArea) -> List:
115-
kobo_username = business_area.kobo_username
116-
if data:
117-
return [element for element in data if element["owner__username"] == kobo_username]
118-
return []
119-
120-
121114
def get_submission_metadata(household_data_dict: Dict) -> Dict:
122115
meta_fields_mapping = {
123116
"_uuid": "kobo_submission_uuid",

src/hct_mis_api/apps/core/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ class BusinessArea(NaturalKeyModel, TimeStampedUUIDModel):
9898
is_accountability_applicable = models.BooleanField(default=False)
9999
active = models.BooleanField(default=False)
100100
enable_email_notification = models.BooleanField(default=True, verbose_name="Automatic Email notifications enabled")
101-
101+
# TODO: deprecated to remove in the next release
102102
kobo_username = models.CharField(max_length=255, null=True, blank=True)
103103
kobo_token = models.CharField(max_length=255, null=True, blank=True)
104104
kobo_url = models.URLField(max_length=255, null=True, blank=True)

src/hct_mis_api/apps/core/schema.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from django.core.exceptions import ObjectDoesNotExist
1616
from django.db import models
17-
from django.db.models import Q
17+
from django.db.models import F, Q
1818

1919
import graphene
2020
from constance import config
@@ -32,7 +32,7 @@
3232
TYPE_STRING,
3333
Scope,
3434
)
35-
from hct_mis_api.apps.core.kobo.api import KoboAPI
35+
from hct_mis_api.apps.core.kobo.api import CountryCodeNotProvided, KoboAPI
3636
from hct_mis_api.apps.core.kobo.common import reduce_asset, reduce_assets_list
3737
from hct_mis_api.apps.core.languages import Language, Languages
3838
from hct_mis_api.apps.core.models import (
@@ -313,11 +313,11 @@ def get_fields_attr_generators(
313313

314314
def resolve_asset(business_area_slug: str, uid: str) -> Dict:
315315
try:
316-
assets = KoboAPI(business_area_slug).get_single_project_data(uid)
316+
assets = KoboAPI().get_single_project_data(uid)
317317
except ObjectDoesNotExist as e:
318318
logger.warning(f"Provided business area: {business_area_slug}, does not exist.")
319319
raise GraphQLError("Provided business area does not exist.") from e
320-
except AttributeError as error:
320+
except AttributeError as error: # pragma: no cover
321321
logger.warning(error)
322322
raise GraphQLError(str(error)) from error
323323

@@ -326,13 +326,18 @@ def resolve_asset(business_area_slug: str, uid: str) -> Dict:
326326

327327
def resolve_assets_list(business_area_slug: str, only_deployed: bool = False) -> List:
328328
try:
329-
assets = KoboAPI(business_area_slug).get_all_projects_data()
329+
business_area = BusinessArea.objects.annotate(country_code=F("countries__iso_code3")).get(
330+
slug=business_area_slug
331+
)
332+
assets = KoboAPI().get_all_projects_data(business_area.country_code)
330333
except ObjectDoesNotExist as e:
331334
logger.warning(f"Provided business area: {business_area_slug}, does not exist.")
332335
raise GraphQLError("Provided business area does not exist.") from e
333-
except AttributeError as error:
336+
except AttributeError as error: # pragma: no cover
334337
logger.warning(error)
335338
raise GraphQLError(str(error)) from error
339+
except CountryCodeNotProvided:
340+
raise GraphQLError(f"Business area {business_area_slug} does not have a country code.")
336341

337342
return reduce_assets_list(assets, only_deployed=only_deployed)
338343

src/hct_mis_api/apps/registration_datahub/tasks/pull_kobo_submissions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ class PullKoboSubmissions:
2222
def execute(self, kobo_import_data: KoboImportData, program: Program) -> Dict:
2323
kobo_import_data.status = KoboImportData.STATUS_RUNNING
2424
kobo_import_data.save()
25-
kobo_api = KoboAPI(kobo_import_data.business_area_slug)
25+
business_area = BusinessArea.objects.get(slug=kobo_import_data.business_area_slug)
26+
kobo_api = KoboAPI()
2627
submissions = kobo_api.get_project_submissions(
2728
kobo_import_data.kobo_asset_id, kobo_import_data.only_active_submissions
2829
)
29-
business_area = BusinessArea.objects.get(slug=kobo_import_data.business_area_slug)
3030
validator = KoboProjectImportDataInstanceValidator(program)
3131
skip_validate_pictures = kobo_import_data.pull_pictures is False
3232
validation_errors = validator.validate_everything(submissions, business_area, skip_validate_pictures)

src/hct_mis_api/apps/registration_datahub/tasks/rdi_kobo_create.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def _handle_image_field(self, value: Any, is_flex_field: bool) -> Optional[Union
9898
return None
9999
current_download_url = attachment.get("download_url", "")
100100
download_url = current_download_url.replace("?format=json", "")
101-
api = KoboAPI(self.business_area.slug)
101+
api = KoboAPI()
102102
image_bytes = api.get_attached_file(download_url)
103103
file = File(image_bytes, name=value)
104104
if is_flex_field:

src/hct_mis_api/config/env.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"DEFAULT_EMAIL_DISPLAY": (str, ""),
5555
"KOBO_URL": (str, "https://kobo-hope-trn.unitst.org"),
5656
"KOBO_MASTER_API_TOKEN": (str, "KOBO_TOKEN"),
57+
"KOBO_PROJECT_VIEWS_ID": (str, ""),
5758
"AZURE_CLIENT_ID": (str, ""),
5859
"AZURE_CLIENT_SECRET": (str, ""),
5960
"AZURE_TENANT_KEY": (str, ""),

src/hct_mis_api/config/fragments/kobo.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22

33
KOBO_URL = env("KOBO_URL")
44
KOBO_MASTER_API_TOKEN = env("KOBO_MASTER_API_TOKEN")
5+
KOBO_PROJECT_VIEWS_ID = env("KOBO_PROJECT_VIEWS_ID")

0 commit comments

Comments
 (0)