ingestr 0.13.2__py3-none-any.whl → 0.14.104__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ingestr/conftest.py +72 -0
- ingestr/main.py +134 -87
- ingestr/src/adjust/__init__.py +4 -4
- ingestr/src/adjust/adjust_helpers.py +7 -3
- ingestr/src/airtable/__init__.py +3 -2
- ingestr/src/allium/__init__.py +128 -0
- ingestr/src/anthropic/__init__.py +277 -0
- ingestr/src/anthropic/helpers.py +525 -0
- ingestr/src/applovin/__init__.py +262 -0
- ingestr/src/applovin_max/__init__.py +117 -0
- ingestr/src/appsflyer/__init__.py +325 -0
- ingestr/src/appsflyer/client.py +49 -45
- ingestr/src/appstore/__init__.py +1 -0
- ingestr/src/arrow/__init__.py +9 -1
- ingestr/src/asana_source/__init__.py +1 -1
- ingestr/src/attio/__init__.py +102 -0
- ingestr/src/attio/helpers.py +65 -0
- ingestr/src/blob.py +38 -11
- ingestr/src/buildinfo.py +1 -0
- ingestr/src/chess/__init__.py +1 -1
- ingestr/src/clickup/__init__.py +85 -0
- ingestr/src/clickup/helpers.py +47 -0
- ingestr/src/collector/spinner.py +43 -0
- ingestr/src/couchbase_source/__init__.py +118 -0
- ingestr/src/couchbase_source/helpers.py +135 -0
- ingestr/src/cursor/__init__.py +83 -0
- ingestr/src/cursor/helpers.py +188 -0
- ingestr/src/destinations.py +520 -33
- ingestr/src/docebo/__init__.py +589 -0
- ingestr/src/docebo/client.py +435 -0
- ingestr/src/docebo/helpers.py +97 -0
- ingestr/src/elasticsearch/__init__.py +80 -0
- ingestr/src/elasticsearch/helpers.py +138 -0
- ingestr/src/errors.py +8 -0
- ingestr/src/facebook_ads/__init__.py +47 -28
- ingestr/src/facebook_ads/helpers.py +59 -37
- ingestr/src/facebook_ads/settings.py +2 -0
- ingestr/src/facebook_ads/utils.py +39 -0
- ingestr/src/factory.py +116 -2
- ingestr/src/filesystem/__init__.py +8 -3
- ingestr/src/filters.py +46 -3
- ingestr/src/fluxx/__init__.py +9906 -0
- ingestr/src/fluxx/helpers.py +209 -0
- ingestr/src/frankfurter/__init__.py +157 -0
- ingestr/src/frankfurter/helpers.py +48 -0
- ingestr/src/freshdesk/__init__.py +89 -0
- ingestr/src/freshdesk/freshdesk_client.py +137 -0
- ingestr/src/freshdesk/settings.py +9 -0
- ingestr/src/fundraiseup/__init__.py +95 -0
- ingestr/src/fundraiseup/client.py +81 -0
- ingestr/src/github/__init__.py +41 -6
- ingestr/src/github/helpers.py +5 -5
- ingestr/src/google_analytics/__init__.py +22 -4
- ingestr/src/google_analytics/helpers.py +124 -6
- ingestr/src/google_sheets/__init__.py +4 -4
- ingestr/src/google_sheets/helpers/data_processing.py +2 -2
- ingestr/src/hostaway/__init__.py +302 -0
- ingestr/src/hostaway/client.py +288 -0
- ingestr/src/http/__init__.py +35 -0
- ingestr/src/http/readers.py +114 -0
- ingestr/src/http_client.py +24 -0
- ingestr/src/hubspot/__init__.py +66 -23
- ingestr/src/hubspot/helpers.py +52 -22
- ingestr/src/hubspot/settings.py +14 -7
- ingestr/src/influxdb/__init__.py +46 -0
- ingestr/src/influxdb/client.py +34 -0
- ingestr/src/intercom/__init__.py +142 -0
- ingestr/src/intercom/helpers.py +674 -0
- ingestr/src/intercom/settings.py +279 -0
- ingestr/src/isoc_pulse/__init__.py +159 -0
- ingestr/src/jira_source/__init__.py +340 -0
- ingestr/src/jira_source/helpers.py +439 -0
- ingestr/src/jira_source/settings.py +170 -0
- ingestr/src/kafka/__init__.py +4 -1
- ingestr/src/kinesis/__init__.py +139 -0
- ingestr/src/kinesis/helpers.py +82 -0
- ingestr/src/klaviyo/{_init_.py → __init__.py} +5 -6
- ingestr/src/linear/__init__.py +634 -0
- ingestr/src/linear/helpers.py +111 -0
- ingestr/src/linkedin_ads/helpers.py +0 -1
- ingestr/src/loader.py +69 -0
- ingestr/src/mailchimp/__init__.py +126 -0
- ingestr/src/mailchimp/helpers.py +226 -0
- ingestr/src/mailchimp/settings.py +164 -0
- ingestr/src/masking.py +344 -0
- ingestr/src/mixpanel/__init__.py +62 -0
- ingestr/src/mixpanel/client.py +99 -0
- ingestr/src/monday/__init__.py +246 -0
- ingestr/src/monday/helpers.py +392 -0
- ingestr/src/monday/settings.py +328 -0
- ingestr/src/mongodb/__init__.py +72 -8
- ingestr/src/mongodb/helpers.py +915 -38
- ingestr/src/partition.py +32 -0
- ingestr/src/personio/__init__.py +331 -0
- ingestr/src/personio/helpers.py +86 -0
- ingestr/src/phantombuster/__init__.py +65 -0
- ingestr/src/phantombuster/client.py +87 -0
- ingestr/src/pinterest/__init__.py +82 -0
- ingestr/src/pipedrive/__init__.py +198 -0
- ingestr/src/pipedrive/helpers/__init__.py +23 -0
- ingestr/src/pipedrive/helpers/custom_fields_munger.py +102 -0
- ingestr/src/pipedrive/helpers/pages.py +115 -0
- ingestr/src/pipedrive/settings.py +27 -0
- ingestr/src/pipedrive/typing.py +3 -0
- ingestr/src/plusvibeai/__init__.py +335 -0
- ingestr/src/plusvibeai/helpers.py +544 -0
- ingestr/src/plusvibeai/settings.py +252 -0
- ingestr/src/quickbooks/__init__.py +117 -0
- ingestr/src/resource.py +40 -0
- ingestr/src/revenuecat/__init__.py +83 -0
- ingestr/src/revenuecat/helpers.py +237 -0
- ingestr/src/salesforce/__init__.py +156 -0
- ingestr/src/salesforce/helpers.py +64 -0
- ingestr/src/shopify/__init__.py +1 -17
- ingestr/src/smartsheets/__init__.py +82 -0
- ingestr/src/snapchat_ads/__init__.py +489 -0
- ingestr/src/snapchat_ads/client.py +72 -0
- ingestr/src/snapchat_ads/helpers.py +535 -0
- ingestr/src/socrata_source/__init__.py +83 -0
- ingestr/src/socrata_source/helpers.py +85 -0
- ingestr/src/socrata_source/settings.py +8 -0
- ingestr/src/solidgate/__init__.py +219 -0
- ingestr/src/solidgate/helpers.py +154 -0
- ingestr/src/sources.py +3132 -212
- ingestr/src/stripe_analytics/__init__.py +49 -21
- ingestr/src/stripe_analytics/helpers.py +286 -1
- ingestr/src/stripe_analytics/settings.py +62 -10
- ingestr/src/telemetry/event.py +10 -9
- ingestr/src/tiktok_ads/__init__.py +12 -6
- ingestr/src/tiktok_ads/tiktok_helpers.py +0 -1
- ingestr/src/trustpilot/__init__.py +48 -0
- ingestr/src/trustpilot/client.py +48 -0
- ingestr/src/version.py +6 -1
- ingestr/src/wise/__init__.py +68 -0
- ingestr/src/wise/client.py +63 -0
- ingestr/src/zoom/__init__.py +99 -0
- ingestr/src/zoom/helpers.py +102 -0
- ingestr/tests/unit/test_smartsheets.py +133 -0
- ingestr-0.14.104.dist-info/METADATA +563 -0
- ingestr-0.14.104.dist-info/RECORD +203 -0
- ingestr/src/appsflyer/_init_.py +0 -24
- ingestr-0.13.2.dist-info/METADATA +0 -302
- ingestr-0.13.2.dist-info/RECORD +0 -107
- {ingestr-0.13.2.dist-info → ingestr-0.14.104.dist-info}/WHEEL +0 -0
- {ingestr-0.13.2.dist-info → ingestr-0.14.104.dist-info}/entry_points.txt +0 -0
- {ingestr-0.13.2.dist-info → ingestr-0.14.104.dist-info}/licenses/LICENSE.md +0 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Docebo API Client for handling authentication and paginated requests."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Iterator, Optional
|
|
4
|
+
|
|
5
|
+
from ingestr.src.docebo.helpers import normalize_docebo_dates
|
|
6
|
+
from ingestr.src.http_client import create_client
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DoceboClient:
|
|
10
|
+
"""Client for interacting with Docebo LMS API."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
base_url: str,
|
|
15
|
+
client_id: str,
|
|
16
|
+
client_secret: str,
|
|
17
|
+
username: Optional[str] = None,
|
|
18
|
+
password: Optional[str] = None,
|
|
19
|
+
):
|
|
20
|
+
"""
|
|
21
|
+
Initialize Docebo API client.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
base_url: The base URL of your Docebo instance
|
|
25
|
+
client_id: OAuth2 client ID
|
|
26
|
+
client_secret: OAuth2 client secret
|
|
27
|
+
username: Optional username for password grant type
|
|
28
|
+
password: Optional password for password grant type
|
|
29
|
+
"""
|
|
30
|
+
self.base_url = base_url.rstrip("/")
|
|
31
|
+
self.client_id = client_id
|
|
32
|
+
self.client_secret = client_secret
|
|
33
|
+
self.username = username
|
|
34
|
+
self.password = password
|
|
35
|
+
self._access_token = None
|
|
36
|
+
# Use shared HTTP client with retry logic
|
|
37
|
+
self.client = create_client(retry_status_codes=[429, 500, 502, 503, 504])
|
|
38
|
+
|
|
39
|
+
def get_access_token(self) -> str:
|
|
40
|
+
"""
|
|
41
|
+
Get or refresh OAuth2 access token.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Access token string
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
Exception: If authentication fails
|
|
48
|
+
"""
|
|
49
|
+
if self._access_token:
|
|
50
|
+
return self._access_token
|
|
51
|
+
|
|
52
|
+
auth_endpoint = f"{self.base_url}/oauth2/token"
|
|
53
|
+
|
|
54
|
+
# Use client_credentials grant type if no username/password provided
|
|
55
|
+
if not self.username or not self.password:
|
|
56
|
+
data = {
|
|
57
|
+
"client_id": self.client_id,
|
|
58
|
+
"client_secret": self.client_secret,
|
|
59
|
+
"grant_type": "client_credentials",
|
|
60
|
+
"scope": "api",
|
|
61
|
+
}
|
|
62
|
+
else:
|
|
63
|
+
data = {
|
|
64
|
+
"client_id": self.client_id,
|
|
65
|
+
"client_secret": self.client_secret,
|
|
66
|
+
"username": self.username,
|
|
67
|
+
"password": self.password,
|
|
68
|
+
"grant_type": "password",
|
|
69
|
+
"scope": "api",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
response = self.client.post(url=auth_endpoint, data=data)
|
|
73
|
+
response.raise_for_status()
|
|
74
|
+
token_data = response.json()
|
|
75
|
+
self._access_token = token_data.get("access_token")
|
|
76
|
+
if not self._access_token:
|
|
77
|
+
raise Exception("Failed to obtain access token from Docebo")
|
|
78
|
+
|
|
79
|
+
return self._access_token
|
|
80
|
+
|
|
81
|
+
def get_paginated_data(
|
|
82
|
+
self,
|
|
83
|
+
endpoint: str,
|
|
84
|
+
page_size: int = 200,
|
|
85
|
+
params: Optional[Dict[str, Any]] = None,
|
|
86
|
+
) -> Iterator[list[Dict[str, Any]]]:
|
|
87
|
+
"""
|
|
88
|
+
Fetch paginated data from a Docebo API endpoint.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
endpoint: API endpoint path (e.g., "manage/v1/user")
|
|
92
|
+
page_size: Number of items per page
|
|
93
|
+
params: Additional query parameters
|
|
94
|
+
|
|
95
|
+
Yields:
|
|
96
|
+
Batches of items from the API
|
|
97
|
+
"""
|
|
98
|
+
url = f"{self.base_url}/{endpoint}"
|
|
99
|
+
headers = {"authorization": f"Bearer {self.get_access_token()}"}
|
|
100
|
+
|
|
101
|
+
page = 1
|
|
102
|
+
has_more_data = True
|
|
103
|
+
|
|
104
|
+
while has_more_data:
|
|
105
|
+
request_params = {"page": page, "page_size": page_size}
|
|
106
|
+
if params:
|
|
107
|
+
request_params.update(params)
|
|
108
|
+
|
|
109
|
+
response = self.client.get(url=url, headers=headers, params=request_params)
|
|
110
|
+
response.raise_for_status()
|
|
111
|
+
data = response.json()
|
|
112
|
+
|
|
113
|
+
# Handle paginated response structure
|
|
114
|
+
if "data" in data:
|
|
115
|
+
# Most Docebo endpoints return data in this structure
|
|
116
|
+
if "items" in data["data"]:
|
|
117
|
+
items = data["data"]["items"]
|
|
118
|
+
if items:
|
|
119
|
+
# Normalize dates for each item before yielding
|
|
120
|
+
normalized_items = [
|
|
121
|
+
normalize_docebo_dates(item) for item in items
|
|
122
|
+
]
|
|
123
|
+
yield normalized_items
|
|
124
|
+
|
|
125
|
+
# Check for more pages
|
|
126
|
+
has_more_data = data["data"].get("has_more_data", False)
|
|
127
|
+
if has_more_data and "total_page_count" in data["data"]:
|
|
128
|
+
total_pages = data["data"]["total_page_count"]
|
|
129
|
+
if page >= total_pages:
|
|
130
|
+
has_more_data = False
|
|
131
|
+
# Some endpoints might return data directly as a list
|
|
132
|
+
elif isinstance(data["data"], list):
|
|
133
|
+
items = data["data"]
|
|
134
|
+
if items:
|
|
135
|
+
# Normalize dates for each item before yielding
|
|
136
|
+
normalized_items = [
|
|
137
|
+
normalize_docebo_dates(item) for item in items
|
|
138
|
+
]
|
|
139
|
+
yield normalized_items
|
|
140
|
+
# For direct list responses, check if we got a full page
|
|
141
|
+
has_more_data = len(items) == page_size
|
|
142
|
+
else:
|
|
143
|
+
has_more_data = False
|
|
144
|
+
# Some endpoints might return items directly
|
|
145
|
+
elif isinstance(data, list):
|
|
146
|
+
if data:
|
|
147
|
+
# Normalize dates for each item before yielding
|
|
148
|
+
normalized_items = [normalize_docebo_dates(item) for item in data]
|
|
149
|
+
yield normalized_items
|
|
150
|
+
has_more_data = len(data) == page_size
|
|
151
|
+
else:
|
|
152
|
+
has_more_data = False
|
|
153
|
+
|
|
154
|
+
page += 1
|
|
155
|
+
|
|
156
|
+
def fetch_users(self) -> Iterator[list[Dict[str, Any]]]:
|
|
157
|
+
"""
|
|
158
|
+
Fetch all users from Docebo.
|
|
159
|
+
|
|
160
|
+
Yields:
|
|
161
|
+
Batches of user data
|
|
162
|
+
"""
|
|
163
|
+
yield from self.get_paginated_data("manage/v1/user")
|
|
164
|
+
|
|
165
|
+
def fetch_courses(self, page_size: int = 200) -> Iterator[list[Dict[str, Any]]]:
|
|
166
|
+
"""
|
|
167
|
+
Fetch all courses from Docebo.
|
|
168
|
+
|
|
169
|
+
Yields:
|
|
170
|
+
Batches of course data
|
|
171
|
+
"""
|
|
172
|
+
yield from self.get_paginated_data("learn/v1/courses", page_size=page_size)
|
|
173
|
+
|
|
174
|
+
# Phase 1: Core User and Organization Resources
|
|
175
|
+
def fetch_user_fields(self) -> Iterator[list[Dict[str, Any]]]:
|
|
176
|
+
"""
|
|
177
|
+
Fetch all user fields from Docebo.
|
|
178
|
+
|
|
179
|
+
Yields:
|
|
180
|
+
Batches of user field definitions
|
|
181
|
+
"""
|
|
182
|
+
yield from self.get_paginated_data("manage/v1/user_fields")
|
|
183
|
+
|
|
184
|
+
def fetch_branches(self) -> Iterator[list[Dict[str, Any]]]:
|
|
185
|
+
"""
|
|
186
|
+
Fetch all branches/organizational units from Docebo.
|
|
187
|
+
|
|
188
|
+
Yields:
|
|
189
|
+
Batches of branch/org chart data
|
|
190
|
+
"""
|
|
191
|
+
yield from self.get_paginated_data("manage/v1/orgchart")
|
|
192
|
+
|
|
193
|
+
# Phase 2: Group Management
|
|
194
|
+
def fetch_groups(self) -> Iterator[list[Dict[str, Any]]]:
|
|
195
|
+
"""
|
|
196
|
+
Fetch all groups/audiences from Docebo.
|
|
197
|
+
|
|
198
|
+
Yields:
|
|
199
|
+
Batches of group data
|
|
200
|
+
"""
|
|
201
|
+
yield from self.get_paginated_data("audiences/v1/audience")
|
|
202
|
+
|
|
203
|
+
def fetch_all_group_members(self) -> Iterator[list[Dict[str, Any]]]:
|
|
204
|
+
"""
|
|
205
|
+
Fetch all group members for all groups.
|
|
206
|
+
|
|
207
|
+
Yields:
|
|
208
|
+
Batches of group member data with group_id included
|
|
209
|
+
"""
|
|
210
|
+
# First fetch all groups
|
|
211
|
+
all_groups: list[Dict[str, Any]] = []
|
|
212
|
+
for group_batch in self.fetch_groups():
|
|
213
|
+
all_groups.extend(group_batch)
|
|
214
|
+
|
|
215
|
+
# Then fetch members for each group
|
|
216
|
+
for group in all_groups:
|
|
217
|
+
group_id = (
|
|
218
|
+
group.get("group_id") or group.get("audience_id") or group.get("id")
|
|
219
|
+
)
|
|
220
|
+
if group_id:
|
|
221
|
+
try:
|
|
222
|
+
for member_batch in self.get_paginated_data(
|
|
223
|
+
f"manage/v1/group/{group_id}/members"
|
|
224
|
+
):
|
|
225
|
+
# Add group_id to each member record
|
|
226
|
+
for member in member_batch:
|
|
227
|
+
member["group_id"] = group_id
|
|
228
|
+
yield member_batch
|
|
229
|
+
except Exception as e:
|
|
230
|
+
print(f"Error fetching members for group {group_id}: {e}")
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
# Phase 3: Advanced Course Resources
|
|
234
|
+
def fetch_course_fields(self) -> Iterator[list[Dict[str, Any]]]:
|
|
235
|
+
"""
|
|
236
|
+
Fetch all course field definitions from Docebo.
|
|
237
|
+
|
|
238
|
+
Yields:
|
|
239
|
+
Batches of course field data
|
|
240
|
+
"""
|
|
241
|
+
yield from self.get_paginated_data("learn/v1/courses/field")
|
|
242
|
+
|
|
243
|
+
def fetch_all_course_learning_objects(self) -> Iterator[list[Dict[str, Any]]]:
|
|
244
|
+
"""
|
|
245
|
+
Fetch learning objects for all courses.
|
|
246
|
+
|
|
247
|
+
Yields:
|
|
248
|
+
Batches of learning object data
|
|
249
|
+
"""
|
|
250
|
+
# First fetch all courses
|
|
251
|
+
all_courses: list[Dict[str, Any]] = []
|
|
252
|
+
for course_batch in self.fetch_courses():
|
|
253
|
+
all_courses.extend(course_batch)
|
|
254
|
+
|
|
255
|
+
# Then fetch learning objects for each course
|
|
256
|
+
for course in all_courses:
|
|
257
|
+
course_id = course.get("id_course") or course.get("course_id")
|
|
258
|
+
if course_id:
|
|
259
|
+
try:
|
|
260
|
+
endpoint = f"learn/v1/courses/{course_id}/los"
|
|
261
|
+
for lo_batch in self.get_paginated_data(endpoint):
|
|
262
|
+
# Add course_id to each learning object
|
|
263
|
+
for lo in lo_batch:
|
|
264
|
+
if "course_id" not in lo:
|
|
265
|
+
lo["course_id"] = course_id
|
|
266
|
+
yield lo_batch
|
|
267
|
+
except Exception as e:
|
|
268
|
+
print(
|
|
269
|
+
f"Error fetching learning objects for course {course_id}: {e}"
|
|
270
|
+
)
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
# Phase 4: Learning Plans
|
|
274
|
+
def fetch_learning_plans(self) -> Iterator[list[Dict[str, Any]]]:
|
|
275
|
+
"""
|
|
276
|
+
Fetch all learning plans from Docebo.
|
|
277
|
+
|
|
278
|
+
Yields:
|
|
279
|
+
Batches of learning plan data
|
|
280
|
+
"""
|
|
281
|
+
yield from self.get_paginated_data("learningplan/v1/learningplans")
|
|
282
|
+
|
|
283
|
+
def fetch_learning_plan_enrollments(self) -> Iterator[list[Dict[str, Any]]]:
|
|
284
|
+
"""
|
|
285
|
+
Fetch all learning plan enrollments.
|
|
286
|
+
|
|
287
|
+
Yields:
|
|
288
|
+
Batches of learning plan enrollment data
|
|
289
|
+
"""
|
|
290
|
+
yield from self.get_paginated_data(
|
|
291
|
+
"learningplan/v1/learningplans/enrollments",
|
|
292
|
+
params={"extra_fields[]": "enrollment_status"},
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def fetch_all_learning_plan_course_enrollments(
|
|
296
|
+
self,
|
|
297
|
+
) -> Iterator[list[Dict[str, Any]]]:
|
|
298
|
+
"""
|
|
299
|
+
Fetch course enrollments for all learning plans.
|
|
300
|
+
|
|
301
|
+
Yields:
|
|
302
|
+
Batches of learning plan course enrollment data
|
|
303
|
+
"""
|
|
304
|
+
# First fetch all learning plans
|
|
305
|
+
all_plans: list[Dict[str, Any]] = []
|
|
306
|
+
for plan_batch in self.fetch_learning_plans():
|
|
307
|
+
all_plans.extend(plan_batch)
|
|
308
|
+
|
|
309
|
+
# Then fetch course enrollments for each learning plan
|
|
310
|
+
for plan in all_plans:
|
|
311
|
+
plan_id = (
|
|
312
|
+
plan.get("id_path") or plan.get("learning_plan_id") or plan.get("id")
|
|
313
|
+
)
|
|
314
|
+
if plan_id:
|
|
315
|
+
try:
|
|
316
|
+
endpoint = (
|
|
317
|
+
f"learningplan/v1/learningplans/{plan_id}/courses/enrollments"
|
|
318
|
+
)
|
|
319
|
+
for enrollment_batch in self.get_paginated_data(
|
|
320
|
+
endpoint, params={"enrollment_level[]": "student"}
|
|
321
|
+
):
|
|
322
|
+
# Add learning_plan_id to each enrollment
|
|
323
|
+
for enrollment in enrollment_batch:
|
|
324
|
+
enrollment["learning_plan_id"] = plan_id
|
|
325
|
+
yield enrollment_batch
|
|
326
|
+
except Exception as e:
|
|
327
|
+
print(
|
|
328
|
+
f"Error fetching course enrollments for learning plan {plan_id}: {e}"
|
|
329
|
+
)
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
# Phase 5: Enrollments and Surveys
|
|
333
|
+
def fetch_all_course_enrollments(self) -> Iterator[list[Dict[str, Any]]]:
|
|
334
|
+
"""
|
|
335
|
+
Fetch enrollments for all courses.
|
|
336
|
+
|
|
337
|
+
Yields:
|
|
338
|
+
Batches of course enrollment data
|
|
339
|
+
"""
|
|
340
|
+
# First fetch all courses
|
|
341
|
+
all_courses: list[Dict[str, Any]] = []
|
|
342
|
+
for course_batch in self.fetch_courses():
|
|
343
|
+
all_courses.extend(course_batch)
|
|
344
|
+
|
|
345
|
+
# Then fetch enrollments for each course
|
|
346
|
+
for course in all_courses:
|
|
347
|
+
course_id = course.get("id_course") or course.get("course_id")
|
|
348
|
+
if course_id:
|
|
349
|
+
try:
|
|
350
|
+
endpoint = f"course/v1/courses/{course_id}/enrollments"
|
|
351
|
+
for enrollment_batch in self.get_paginated_data(
|
|
352
|
+
endpoint, params={"level[]": "3"}
|
|
353
|
+
):
|
|
354
|
+
# Add course_id to each enrollment
|
|
355
|
+
for enrollment in enrollment_batch:
|
|
356
|
+
enrollment["course_id"] = course_id
|
|
357
|
+
yield enrollment_batch
|
|
358
|
+
except Exception as e:
|
|
359
|
+
print(f"Error fetching enrollments for course {course_id}: {e}")
|
|
360
|
+
continue
|
|
361
|
+
|
|
362
|
+
# Additional Resources
|
|
363
|
+
def fetch_sessions(self) -> Iterator[list[Dict[str, Any]]]:
|
|
364
|
+
"""
|
|
365
|
+
Fetch all ILT/classroom sessions for all courses.
|
|
366
|
+
|
|
367
|
+
Yields:
|
|
368
|
+
Batches of session data
|
|
369
|
+
"""
|
|
370
|
+
# First fetch all courses
|
|
371
|
+
all_courses: list[Dict[str, Any]] = []
|
|
372
|
+
for course_batch in self.fetch_courses():
|
|
373
|
+
all_courses.extend(course_batch)
|
|
374
|
+
|
|
375
|
+
# Then fetch sessions for each course
|
|
376
|
+
for course in all_courses:
|
|
377
|
+
course_id = course.get("id_course") or course.get("course_id")
|
|
378
|
+
if course_id:
|
|
379
|
+
try:
|
|
380
|
+
endpoint = f"learn/v1/courses/{course_id}/sessions"
|
|
381
|
+
for session_batch in self.get_paginated_data(endpoint):
|
|
382
|
+
# Add course_id to each session
|
|
383
|
+
for session in session_batch:
|
|
384
|
+
session["course_id"] = course_id
|
|
385
|
+
yield session_batch
|
|
386
|
+
except Exception:
|
|
387
|
+
# Many courses may not have sessions, so just continue
|
|
388
|
+
continue
|
|
389
|
+
|
|
390
|
+
def fetch_categories(self) -> Iterator[list[Dict[str, Any]]]:
|
|
391
|
+
"""
|
|
392
|
+
Fetch all course categories.
|
|
393
|
+
|
|
394
|
+
Yields:
|
|
395
|
+
Batches of category data
|
|
396
|
+
"""
|
|
397
|
+
yield from self.get_paginated_data("learn/v1/categories")
|
|
398
|
+
|
|
399
|
+
def fetch_certifications(self) -> Iterator[list[Dict[str, Any]]]:
|
|
400
|
+
"""
|
|
401
|
+
Fetch all certifications.
|
|
402
|
+
|
|
403
|
+
Yields:
|
|
404
|
+
Batches of certification data
|
|
405
|
+
"""
|
|
406
|
+
yield from self.get_paginated_data("learn/v1/certification")
|
|
407
|
+
|
|
408
|
+
def fetch_external_training(self) -> Iterator[list[Dict[str, Any]]]:
|
|
409
|
+
"""
|
|
410
|
+
Fetch all external training records.
|
|
411
|
+
|
|
412
|
+
Yields:
|
|
413
|
+
Batches of external training data
|
|
414
|
+
"""
|
|
415
|
+
yield from self.get_paginated_data("learn/v1/external_training")
|
|
416
|
+
|
|
417
|
+
def fetch_survey_answers_for_poll(
|
|
418
|
+
self, poll_id: int, course_id: int
|
|
419
|
+
) -> Dict[str, Any]:
|
|
420
|
+
"""
|
|
421
|
+
Fetch survey answers for a specific poll.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
poll_id: The poll/survey ID
|
|
425
|
+
course_id: The course ID containing the poll
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
Survey answer data or empty dict if no answers
|
|
429
|
+
"""
|
|
430
|
+
url = f"{self.base_url}/learn/v1/survey/{poll_id}/answer"
|
|
431
|
+
headers = {"authorization": f"Bearer {self.get_access_token()}"}
|
|
432
|
+
params = {"id_course": course_id}
|
|
433
|
+
|
|
434
|
+
response = self.client.get(url, headers=headers, params=params)
|
|
435
|
+
return normalize_docebo_dates(response.json().get("data", {}))
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Helper functions for Docebo API data processing."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Dict, Union
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def normalize_date_field(date_value: Any) -> Union[datetime, str, None]:
|
|
8
|
+
"""
|
|
9
|
+
Normalize a single date field that may contain invalid dates.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
date_value: The date value to normalize (string, datetime, or None)
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
Normalized datetime object or None for invalid/empty dates
|
|
16
|
+
"""
|
|
17
|
+
# Unix epoch datetime (1970-01-01 00:00:00 UTC)
|
|
18
|
+
epoch_datetime = datetime(1970, 1, 1)
|
|
19
|
+
|
|
20
|
+
# Handle string dates
|
|
21
|
+
if isinstance(date_value, str):
|
|
22
|
+
# Handle '0000-00-00' or '0000-00-00 00:00:00'
|
|
23
|
+
if date_value.startswith("0000-00-00"):
|
|
24
|
+
return epoch_datetime
|
|
25
|
+
# Handle other invalid date formats
|
|
26
|
+
elif date_value in ["", "0", "null", "NULL"]:
|
|
27
|
+
return None
|
|
28
|
+
# Try to parse valid date strings
|
|
29
|
+
else:
|
|
30
|
+
try:
|
|
31
|
+
# Try common date formats
|
|
32
|
+
for fmt in [
|
|
33
|
+
"%Y-%m-%d %H:%M:%S",
|
|
34
|
+
"%Y-%m-%d",
|
|
35
|
+
"%Y/%m/%d %H:%M:%S",
|
|
36
|
+
"%Y/%m/%d",
|
|
37
|
+
]:
|
|
38
|
+
try:
|
|
39
|
+
return datetime.strptime(date_value, fmt)
|
|
40
|
+
except ValueError:
|
|
41
|
+
continue
|
|
42
|
+
# If no format matches, return the original string
|
|
43
|
+
return date_value
|
|
44
|
+
except Exception:
|
|
45
|
+
return date_value
|
|
46
|
+
# Handle datetime objects - pass through
|
|
47
|
+
elif isinstance(date_value, datetime):
|
|
48
|
+
return date_value
|
|
49
|
+
# Handle cases where the field might be None or empty
|
|
50
|
+
elif not date_value:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
# Return the original value for other types
|
|
54
|
+
return date_value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def normalize_docebo_dates(item: Dict[str, Any]) -> Dict[str, Any]:
|
|
58
|
+
"""
|
|
59
|
+
Normalize Docebo date fields that contain '0000-00-00' to Unix epoch (1970-01-01).
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
item: Dictionary containing data from Docebo API
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Dictionary with normalized date fields
|
|
66
|
+
"""
|
|
67
|
+
# Date fields that might contain '0000-00-00'
|
|
68
|
+
# Add more fields as needed for different resources
|
|
69
|
+
date_fields = [
|
|
70
|
+
"last_access_date",
|
|
71
|
+
"last_update",
|
|
72
|
+
"creation_date",
|
|
73
|
+
"date_begin", # Course field
|
|
74
|
+
"date_end", # Course field
|
|
75
|
+
"date_publish", # Course field
|
|
76
|
+
"date_unpublish", # Course field
|
|
77
|
+
"enrollment_date", # Enrollment field
|
|
78
|
+
"completion_date", # Enrollment field
|
|
79
|
+
"date_assigned", # Assignment field
|
|
80
|
+
"date_completed", # Completion field
|
|
81
|
+
"survey_date", # Survey field
|
|
82
|
+
"start_date", # Course/Plan field
|
|
83
|
+
"end_date", # Course/Plan field
|
|
84
|
+
"date_created", # Generic creation date
|
|
85
|
+
"created_on", # Learning plan field
|
|
86
|
+
"updated_on", # Learning plan field
|
|
87
|
+
"date_modified", # Generic modification date
|
|
88
|
+
"expire_date", # Expiration date
|
|
89
|
+
"date_last_updated", # Update date
|
|
90
|
+
"date", # Generic date field (used in survey answers)
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
for field in date_fields:
|
|
94
|
+
if field in item:
|
|
95
|
+
item[field] = normalize_date_field(item[field])
|
|
96
|
+
|
|
97
|
+
return item
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from datetime import date, datetime
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
import dlt
|
|
5
|
+
import pendulum
|
|
6
|
+
from dlt.common.time import ensure_pendulum_datetime
|
|
7
|
+
from pendulum import parse
|
|
8
|
+
|
|
9
|
+
from elasticsearch import Elasticsearch
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dlt.source
|
|
13
|
+
def elasticsearch_source(
|
|
14
|
+
connection_url: str,
|
|
15
|
+
index: str,
|
|
16
|
+
verify_certs: bool,
|
|
17
|
+
incremental: Optional[dlt.sources.incremental] = None,
|
|
18
|
+
):
|
|
19
|
+
client = Elasticsearch(connection_url, verify_certs=verify_certs)
|
|
20
|
+
|
|
21
|
+
@dlt.resource(
|
|
22
|
+
name=index, primary_key="id", write_disposition="merge", incremental=incremental
|
|
23
|
+
)
|
|
24
|
+
def get_documents(incremental=incremental):
|
|
25
|
+
body = {"query": {"match_all": {}}}
|
|
26
|
+
|
|
27
|
+
if incremental:
|
|
28
|
+
start_value = incremental.last_value
|
|
29
|
+
range_filter = {"gte": start_value}
|
|
30
|
+
if incremental.end_value is not None:
|
|
31
|
+
range_filter["lt"] = incremental.end_value
|
|
32
|
+
body = {"query": {"range": {incremental.cursor_path: range_filter}}}
|
|
33
|
+
|
|
34
|
+
page = client.search(index=index, scroll="5m", size=5, body=body)
|
|
35
|
+
|
|
36
|
+
sid = page["_scroll_id"]
|
|
37
|
+
hits = page["hits"]["hits"]
|
|
38
|
+
|
|
39
|
+
if not hits:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
# fetching first page (via .search)
|
|
43
|
+
for doc in hits:
|
|
44
|
+
doc_data = {"id": doc["_id"], **doc["_source"]}
|
|
45
|
+
if incremental:
|
|
46
|
+
doc_data[incremental.cursor_path] = convert_elasticsearch_objs(
|
|
47
|
+
doc_data[incremental.cursor_path]
|
|
48
|
+
)
|
|
49
|
+
yield doc_data
|
|
50
|
+
|
|
51
|
+
while True:
|
|
52
|
+
# fetching page 2 and other pages (via .scroll)
|
|
53
|
+
page = client.scroll(scroll_id=sid, scroll="5m")
|
|
54
|
+
sid = page["_scroll_id"]
|
|
55
|
+
hits = page["hits"]["hits"]
|
|
56
|
+
if not hits:
|
|
57
|
+
break
|
|
58
|
+
for doc in hits:
|
|
59
|
+
doc_data = {"id": doc["_id"], **doc["_source"]}
|
|
60
|
+
if incremental:
|
|
61
|
+
doc_data[incremental.cursor_path] = convert_elasticsearch_objs(
|
|
62
|
+
doc_data[incremental.cursor_path]
|
|
63
|
+
)
|
|
64
|
+
yield doc_data
|
|
65
|
+
|
|
66
|
+
client.clear_scroll(scroll_id=sid)
|
|
67
|
+
|
|
68
|
+
return get_documents
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def convert_elasticsearch_objs(value: Any) -> Any:
|
|
72
|
+
if isinstance(value, str):
|
|
73
|
+
parsed_date = parse(value, strict=False)
|
|
74
|
+
if parsed_date is not None:
|
|
75
|
+
if isinstance(
|
|
76
|
+
parsed_date,
|
|
77
|
+
(pendulum.DateTime, pendulum.Date, datetime, date, str, float, int),
|
|
78
|
+
):
|
|
79
|
+
return ensure_pendulum_datetime(parsed_date)
|
|
80
|
+
return value
|