rapidpro-api 0.1.0__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.
- rapidpro_api/__init__.py +0 -0
- rapidpro_api/contact_processors.py +343 -0
- rapidpro_api/contact_processors_pl.py +106 -0
- rapidpro_api/datasets/BaseDataset.py +551 -0
- rapidpro_api/datasets/ContactsDataset.py +31 -0
- rapidpro_api/datasets/DeltaLakeDataset.py +270 -0
- rapidpro_api/datasets/FieldsDataset.py +31 -0
- rapidpro_api/datasets/FlowsDataset.py +31 -0
- rapidpro_api/datasets/GroupsDataset.py +31 -0
- rapidpro_api/datasets/MessagesDataset.py +31 -0
- rapidpro_api/datasets/RawDataset.py +649 -0
- rapidpro_api/datasets/RunsDataset.py +32 -0
- rapidpro_api/datasets/__init__.py +8 -0
- rapidpro_api/field_processors.py +34 -0
- rapidpro_api/flow_processors.py +140 -0
- rapidpro_api/group_processors.py +101 -0
- rapidpro_api/message_processors.py +110 -0
- rapidpro_api/run_processors_pl.py +87 -0
- rapidpro_api/stats/__init__.py +5 -0
- rapidpro_api/stats/download_stats.py +112 -0
- rapidpro_api/stats/download_stats_list.py +115 -0
- rapidpro_api/stats/process_stats.py +318 -0
- rapidpro_api/stats/process_stats_list.py +266 -0
- rapidpro_api/stats/stats.py +643 -0
- rapidpro_api/telegram.py +37 -0
- rapidpro_api/time_utils.py +224 -0
- rapidpro_api/ureport_processors_pl.py +331 -0
- rapidpro_api/utils.py +72 -0
- rapidpro_api/validators.py +123 -0
- rapidpro_api/version.py +8 -0
- rapidpro_api/workspaces.py +278 -0
- rapidpro_api-0.1.0.dist-info/METADATA +95 -0
- rapidpro_api-0.1.0.dist-info/RECORD +65 -0
- rapidpro_api-0.1.0.dist-info/WHEEL +5 -0
- rapidpro_api-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/datasets/__init__.py +0 -0
- tests/datasets/test_base_dataset.py +528 -0
- tests/datasets/test_contacts_dataset.py +27 -0
- tests/datasets/test_fields_dataset.py +27 -0
- tests/datasets/test_flows_dataset.py +27 -0
- tests/datasets/test_groups_dataset.py +27 -0
- tests/datasets/test_messages_dataset.py +27 -0
- tests/datasets/test_raw_dataset.py +400 -0
- tests/datasets/test_runs_dataset.py +27 -0
- tests/stats/__init__.py +0 -0
- tests/stats/test_download_stats.py +186 -0
- tests/stats/test_download_stats_list.py +366 -0
- tests/stats/test_process_stats.py +0 -0
- tests/stats/test_process_stats_list.py +531 -0
- tests/stats/test_stats.py +711 -0
- tests/test_contact_processors.py +199 -0
- tests/test_contact_processors_pl.py +188 -0
- tests/test_field_processors.py +101 -0
- tests/test_flow_processor.py +90 -0
- tests/test_group_processors.py +190 -0
- tests/test_message_processors.py +129 -0
- tests/test_run_processors_pl.py +248 -0
- tests/test_telegram.py +61 -0
- tests/test_time_utils.py +318 -0
- tests/test_ureport_processors_pl.py +198 -0
- tests/test_utils.py +182 -0
- tests/test_validators.py +127 -0
- tests/test_version.py +20 -0
- tests/test_workspaces.py +576 -0
rapidpro_api/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
|
|
2
|
+
import logging
|
|
3
|
+
import copy
|
|
4
|
+
from .group_processors import is_in_groups
|
|
5
|
+
from .utils import anonymize_uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Union
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_contact_urn_type(contact_urn: str):
|
|
12
|
+
"""Contact URNs come from the contacts API as a list of strings such as [facebook:***, twitter:***] etc.
|
|
13
|
+
This function receives a STRING and gets rid of the `:****` and returns a string with the URN type only.
|
|
14
|
+
Args:
|
|
15
|
+
contact_urn (str): A string with the URN type and value, such as facebook:********.
|
|
16
|
+
Returns:
|
|
17
|
+
str: A string with the URN type only. If a URN does not have : or the contact_urn is not a string raises ValueError exception.
|
|
18
|
+
Example:
|
|
19
|
+
contact_urn = "facebook:********"
|
|
20
|
+
contact_urn_type = get_contact_urn_type(contact_urn)
|
|
21
|
+
# contact_urn_type will be "facebook"
|
|
22
|
+
See also:
|
|
23
|
+
get_contact_urn_types(contact_urns)
|
|
24
|
+
"""
|
|
25
|
+
# Check if is not a string
|
|
26
|
+
if not isinstance(contact_urn, str):
|
|
27
|
+
logging.error("Contact URN is not a string")
|
|
28
|
+
raise ValueError("contact_urn is not a string")
|
|
29
|
+
|
|
30
|
+
if ":" in contact_urn:
|
|
31
|
+
return contact_urn.split(":")[0]
|
|
32
|
+
else:
|
|
33
|
+
logging.error("Contact URN '%s' does not have a :", contact_urn)
|
|
34
|
+
raise ValueError("contact_urn is not a valid URN. Expected format is urn_type:urn_value. Example: facebook:********")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_contact_urn_types(contact_urns):
|
|
38
|
+
"""Contact URNs come from the contacts API as a list of strings such as [facebook:***, twitter:***] etc.
|
|
39
|
+
This function gets rid of the :**** and returns a list of strings with the URN type only.
|
|
40
|
+
Args:
|
|
41
|
+
contact_urns (list): A list of strings with the URN type and value, such as [facebook:***, twitter:***].
|
|
42
|
+
Returns:
|
|
43
|
+
list: A list of strings with the URN type only. If a URN does not have : it returns None for that URN. If the contact_urns is not a list raises ValueError exception.
|
|
44
|
+
Example:
|
|
45
|
+
contact_urns = ["facebook:********", "twitter:**********"]
|
|
46
|
+
contact_urn_types = contact_urn_types(contact_urns)
|
|
47
|
+
# ["facebook", "twitter"]
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
# Validate the contact_urns is a list
|
|
51
|
+
if not isinstance(contact_urns, list):
|
|
52
|
+
logging.warning("Contact URNs is not a list, returning empty list")
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
# Validate the contact_urns is not empty
|
|
56
|
+
if not contact_urns:
|
|
57
|
+
logging.debug("Contact URNs is empty, returning empty list")
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
return [get_contact_urn_type(urn) for urn in contact_urns]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def filter_contact_fields(all_fields, fields_to_filter):
|
|
64
|
+
"""Filter the fields of a contact to only include the fields in the fields_to_filter list.
|
|
65
|
+
Args:
|
|
66
|
+
all_fields (dict): A dictionary with all the fields of the contact.
|
|
67
|
+
fields_to_filter (list): A list of fields to filter.
|
|
68
|
+
Returns:
|
|
69
|
+
dict: A dictionary with only the fields in the fields_to_filter list.
|
|
70
|
+
If the field is not in the all_fields dictionary, it will be added with a value of None.
|
|
71
|
+
If the fields_to_filter is not a list, it will return an empty dictionary.
|
|
72
|
+
If the all_fields is not a dictionary, it will return an empty dictionary and log a warning.
|
|
73
|
+
Example:
|
|
74
|
+
all_fields = {"field1": "value1", "field2": "value2", "field3": "value3"}
|
|
75
|
+
fields_to_filter = ["field1", "field3"]
|
|
76
|
+
filtered_fields = filter_contact_fields(all_fields, fields_to_filter)
|
|
77
|
+
# filtered_fields will be {"field1": "value1", "field3": "value3"}
|
|
78
|
+
"""
|
|
79
|
+
if not isinstance(fields_to_filter, list):
|
|
80
|
+
return {}
|
|
81
|
+
|
|
82
|
+
if not isinstance(all_fields, dict):
|
|
83
|
+
logging.warning("All fields is not a dictionary")
|
|
84
|
+
return {}
|
|
85
|
+
|
|
86
|
+
filtered_fields = {key: all_fields.get(key, None) for key in fields_to_filter}
|
|
87
|
+
return filtered_fields
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_age_group(born: int):
|
|
92
|
+
"""
|
|
93
|
+
Determines the age group for a given age.
|
|
94
|
+
Args:
|
|
95
|
+
age (str): The age as a string. It should represent a non-negative integer.
|
|
96
|
+
Returns:
|
|
97
|
+
str: The age group as a string. Possible values are:
|
|
98
|
+
- "None" if the input is invalid, empty, or represents a negative age.
|
|
99
|
+
- "0-14" for ages between 0 and 14 (inclusive).
|
|
100
|
+
- "15-19" for ages between 15 and 19 (inclusive).
|
|
101
|
+
- "20-24" for ages between 20 and 24 (inclusive).
|
|
102
|
+
- "25-30" for ages between 25 and 30 (inclusive).
|
|
103
|
+
- "31-34" for ages between 31 and 34 (inclusive).
|
|
104
|
+
- "35+" for ages 35 and above.
|
|
105
|
+
"""
|
|
106
|
+
if not born:
|
|
107
|
+
return "None"
|
|
108
|
+
if not isinstance(born, int):
|
|
109
|
+
#logging.warning("Born year '%s' is not an integer", born)
|
|
110
|
+
return "None"
|
|
111
|
+
if born < 0:
|
|
112
|
+
#logging.warning("Born year '%s' is negative", born)
|
|
113
|
+
return "None"
|
|
114
|
+
# Calculate the age based on the current year
|
|
115
|
+
try:
|
|
116
|
+
age = datetime.now().year - born
|
|
117
|
+
except ValueError:
|
|
118
|
+
logging.warning("Born year '%s' is not a valid integer", born)
|
|
119
|
+
return "None"
|
|
120
|
+
if age < 0:
|
|
121
|
+
return "None"
|
|
122
|
+
elif 0 <= age <= 14:
|
|
123
|
+
return "0-14"
|
|
124
|
+
elif 15 <= age <= 19:
|
|
125
|
+
return "15-19"
|
|
126
|
+
elif 20 <= age <= 24:
|
|
127
|
+
return "20-24"
|
|
128
|
+
elif 25 <= age <= 30:
|
|
129
|
+
return "25-30"
|
|
130
|
+
elif 31 <= age <= 34:
|
|
131
|
+
return "31-34"
|
|
132
|
+
else:
|
|
133
|
+
return "35+"
|
|
134
|
+
def parse_born(born: str) -> Union[int, None]:
|
|
135
|
+
"""
|
|
136
|
+
Parse and normalize born values from string input to integer.
|
|
137
|
+
This function takes a born string and attempts to convert it to a positive integer.
|
|
138
|
+
Args:
|
|
139
|
+
born (str): The born string to parse.
|
|
140
|
+
Returns:
|
|
141
|
+
int | -1 | None: The parsed born value as a positive integer, or None if the input is empty, -1, not a valid number, or not positive.
|
|
142
|
+
Example:
|
|
143
|
+
parse_born("1990")
|
|
144
|
+
# => 1990
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
if not born:
|
|
148
|
+
return None
|
|
149
|
+
try:
|
|
150
|
+
parsed_born = int(float(born))
|
|
151
|
+
if parsed_born <= 1925 or parsed_born > datetime.now().year:
|
|
152
|
+
return -1
|
|
153
|
+
return parsed_born
|
|
154
|
+
except ValueError:
|
|
155
|
+
logging.warning("parse_born: Born value '%s' is not a valid number", born)
|
|
156
|
+
return -1
|
|
157
|
+
except OverflowError:
|
|
158
|
+
logging.warning("parse_born: Born value '%s' is too large", born)
|
|
159
|
+
return -1
|
|
160
|
+
|
|
161
|
+
def parse_born_as_str(born: str):
|
|
162
|
+
"""
|
|
163
|
+
Parse and normalize born values from string input to string.
|
|
164
|
+
This function takes a born string and attempts to convert it to a positive integer and then to string.
|
|
165
|
+
Args:
|
|
166
|
+
born (str): The born string to parse.
|
|
167
|
+
Returns:
|
|
168
|
+
str: The parsed born value as a positive integer in string format, or "None" if the input is empty, None, not a valid number, or not positive.
|
|
169
|
+
Example:
|
|
170
|
+
parse_born_as_str("1990")
|
|
171
|
+
# => "1990"
|
|
172
|
+
"""
|
|
173
|
+
parsed_born = parse_born(born)
|
|
174
|
+
return str(parsed_born) if parsed_born is not None else "None"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def parse_gender(gender: str, male_list: list[str]=None, female_list: list[str]=None, extend_default_list:bool=True) -> str:
|
|
178
|
+
"""
|
|
179
|
+
Parse and normalize gender values from string input .
|
|
180
|
+
This function takes a gender string and normalizes it to standard values:
|
|
181
|
+
'Female', 'Male', 'Other', or 'None', based on common words in different languages (en, es, fr).
|
|
182
|
+
Args:
|
|
183
|
+
gender (str): The gender string to parse.
|
|
184
|
+
male_list (list, optional): A list of male terms in various languages. Defaults to a predefined list.
|
|
185
|
+
female_list (list, optional): A list of female terms in various languages. Defaults to a predefined list.
|
|
186
|
+
extend_default_list (bool, optional): If True, the provided lists will be appended to the default lists. If False, the provided lists will replace the default lists. Defaults to True.
|
|
187
|
+
Returns:
|
|
188
|
+
str: Normalized gender value:
|
|
189
|
+
- 'Female' if the input matches female terms in various languages
|
|
190
|
+
- 'Male' if the input matches male terms in various languages
|
|
191
|
+
- 'Other' if the input doesn't match known gender terms
|
|
192
|
+
- 'None' if the input is empty or None
|
|
193
|
+
Note:
|
|
194
|
+
The function recognizes gender terms [boy, male, girl, female] in English, Spanish, French, Arabic, Hindi, and Portuguese.
|
|
195
|
+
The function is case-insensitive and will return 'None' for empty or None inputs.
|
|
196
|
+
Example:
|
|
197
|
+
parse_gender("hombre")
|
|
198
|
+
# => "Male"
|
|
199
|
+
"""
|
|
200
|
+
if not gender:
|
|
201
|
+
return "None"
|
|
202
|
+
|
|
203
|
+
the_female_list=["female", "girl", "mujer", "f", "niña", "femme", "fille", "امرأة", "فتاة", "महिला", "लड़की", "mulher", "menina", "gore"]
|
|
204
|
+
the_male_list=["male","boy", "hombre", "m", "niño", "homme", "garçon", "رجل", "ولد", "पुरुष", "लड़का", "homem", "menino", "gabo"]
|
|
205
|
+
|
|
206
|
+
if extend_default_list:
|
|
207
|
+
if male_list:
|
|
208
|
+
the_male_list.extend(male_list)
|
|
209
|
+
if female_list:
|
|
210
|
+
the_female_list.extend(female_list)
|
|
211
|
+
else:
|
|
212
|
+
if male_list:
|
|
213
|
+
the_male_list = male_list
|
|
214
|
+
if female_list:
|
|
215
|
+
the_female_list = female_list
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
#logging.debug("gender", gender.lower)
|
|
219
|
+
|
|
220
|
+
if gender.lower() in the_female_list:
|
|
221
|
+
return "Female"
|
|
222
|
+
elif gender.lower() in the_male_list:
|
|
223
|
+
return "Male"
|
|
224
|
+
else:
|
|
225
|
+
return "Invalid"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def process_contact(contact, fields: list = None, groups: list = None, metadata: dict = None):
|
|
229
|
+
"""
|
|
230
|
+
Process a contact from RapidPro API and return a dictionary with the relevant information.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
contact (dict): A contact item returned from the RapidPro API.
|
|
234
|
+
fields (list): A list of fields that will be extracted from the fields object of the contact as attributes of the processed contact.
|
|
235
|
+
groups (list): A list of groups that will be extracted from the groups object of the contact as attributes of the processed contact. Assumes validated group names.
|
|
236
|
+
metadata (dict): A dictionary of custom columns to include in the output. Metadata you can add to the contact.
|
|
237
|
+
Returns:
|
|
238
|
+
dict: A dictionary with the relevant information from the contact.
|
|
239
|
+
|
|
240
|
+
Example:
|
|
241
|
+
processed_contact = process_api_contact(contact,
|
|
242
|
+
fields=["field1", "field2"],
|
|
243
|
+
groups=["group1", "group2"],
|
|
244
|
+
metadata={"metadata_col1": "value1"})
|
|
245
|
+
|
|
246
|
+
# processed_contact will be a dictionary with the relevant information from the contact.
|
|
247
|
+
print(processed_contact)
|
|
248
|
+
# {
|
|
249
|
+
# "name": "John Doe",
|
|
250
|
+
# "uuid": "12345678-1234-1234-1234-123456789012",
|
|
251
|
+
# "status": "active",
|
|
252
|
+
# "created_on": "2023-01-01T00:00:00Z",
|
|
253
|
+
# "created_on_year": "2023",
|
|
254
|
+
# "created_on_month": "2023-01",
|
|
255
|
+
# "created_on_day": "2023-01-01",
|
|
256
|
+
# "last_seen_on": "2023-01-01T00:00:00Z",
|
|
257
|
+
# "last_seen_on_year": "2023",
|
|
258
|
+
# "last_seen_on_month": "2023-01",
|
|
259
|
+
# "last_seen_on_day": "2023-01-01",
|
|
260
|
+
# "modified_on": "2023-01-01T00:00:00Z",
|
|
261
|
+
# "metadata_col1": "value1",
|
|
262
|
+
# "field1": "value1",
|
|
263
|
+
# "field2": "value2",
|
|
264
|
+
# "group1": True,
|
|
265
|
+
# "group2": False,
|
|
266
|
+
# }
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
# Validate the contact is a dictionary
|
|
272
|
+
if not isinstance(contact, dict):
|
|
273
|
+
logging.error("Contact is not a dictionary")
|
|
274
|
+
raise ValueError("Contact is not a dictionary")
|
|
275
|
+
|
|
276
|
+
# Validate the contact has a uuid
|
|
277
|
+
if not contact.get("uuid"):
|
|
278
|
+
logging.warning("Contact does not have a uuid")
|
|
279
|
+
|
|
280
|
+
#logging.debug("Processing contact %s", anonymize_uuid(contact.get("uuid", "")))
|
|
281
|
+
|
|
282
|
+
processed_contact = copy.deepcopy(contact)
|
|
283
|
+
|
|
284
|
+
# TODO - More DRY
|
|
285
|
+
try:
|
|
286
|
+
created_on = datetime.fromisoformat(contact["created_on"])
|
|
287
|
+
processed_contact["created_on_year"] = created_on.strftime("%Y")
|
|
288
|
+
# Extract the yyyy-mm and yyyy-mm-dd field from the created_on field
|
|
289
|
+
processed_contact["created_on_month"] = created_on.strftime("%Y-%m")
|
|
290
|
+
processed_contact["created_on_day"] = created_on.strftime("%Y-%m-%d")
|
|
291
|
+
except AttributeError:
|
|
292
|
+
logging.warning("Contact does not have a created_on field")
|
|
293
|
+
except (ValueError, TypeError):
|
|
294
|
+
logging.warning("Contact does not have a valid created_on field")
|
|
295
|
+
except Exception as e:
|
|
296
|
+
logging.warning("Contact does not have a valid created_on field: %s", e)
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
# last_seen_on can be null. In that case we use modified on
|
|
300
|
+
# also we set last_seen_on == modified_on
|
|
301
|
+
if contact.get("last_seen_on", None) is None:
|
|
302
|
+
last_seen_on = datetime.fromisoformat(contact.get("modified_on", None))
|
|
303
|
+
processed_contact['last_seen_on'] = contact.get("modified_on", None)
|
|
304
|
+
else:
|
|
305
|
+
last_seen_on = datetime.fromisoformat(contact["last_seen_on"])
|
|
306
|
+
|
|
307
|
+
processed_contact["last_seen_on_year"] = last_seen_on.strftime("%Y")
|
|
308
|
+
# Extract the yyyy-mm and yyyy-mm-dd field from the last_seen_on field
|
|
309
|
+
processed_contact["last_seen_on_month"] = last_seen_on.strftime("%Y-%m")
|
|
310
|
+
processed_contact["last_seen_on_day"] = last_seen_on.strftime("%Y-%m-%d")
|
|
311
|
+
except AttributeError:
|
|
312
|
+
logging.warning("Contact does not have a last_seen_on field")
|
|
313
|
+
except (ValueError, TypeError):
|
|
314
|
+
logging.warning("Contact does not have a valid last_seen_on field - %s", contact.get("last_seen_on", ""))
|
|
315
|
+
except Exception as e:
|
|
316
|
+
logging.warning("Contact does not have a valid last_seen_on field: %s", e)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# Add metadata_cols, key value pairs that will be added to the processed_contact
|
|
320
|
+
if metadata:
|
|
321
|
+
if not isinstance(metadata, dict):
|
|
322
|
+
logging.error("Metadata columns is not a dictionary")
|
|
323
|
+
raise ValueError("Metadata columns is not a dictionary")
|
|
324
|
+
processed_contact.update(metadata)
|
|
325
|
+
|
|
326
|
+
# Add the groups to the root object
|
|
327
|
+
processed_contact.update(is_in_groups(contact.get("groups", []), groups))
|
|
328
|
+
|
|
329
|
+
# Add the fields to the root object
|
|
330
|
+
processed_contact.update(filter_contact_fields(contact.get("fields", {}), fields))
|
|
331
|
+
|
|
332
|
+
# Add the urn type to the root object
|
|
333
|
+
try:
|
|
334
|
+
processed_contact["urn_type"] = get_contact_urn_types(contact.get("urns",[]))[0]
|
|
335
|
+
except IndexError:
|
|
336
|
+
processed_contact["urn_type"] = None
|
|
337
|
+
logging.debug("Contact %s has no URN. Set as None", contact.get("uuid", "<UUID not found>"))
|
|
338
|
+
except ValueError:
|
|
339
|
+
processed_contact["contact_urn_type"] = None
|
|
340
|
+
logging.debug("Contact %s has no URN. Set as None", contact.get("uuid", "<UUID not found>"))
|
|
341
|
+
|
|
342
|
+
return processed_contact
|
|
343
|
+
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import polars as pl
|
|
2
|
+
import logging
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
def process_contacts_pl(contacts: list, fields: list = None, groups: list = None, metadata: dict = None) -> pl.DataFrame:
|
|
6
|
+
"""
|
|
7
|
+
Process contacts from RapidPro API and return a DataFrame with the relevant information.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
contacts_df (list): A list of contacts as returned from the RapidPro API.
|
|
11
|
+
fields (list): A list of fields to extract from the fields object of each contact.
|
|
12
|
+
groups (list): A list of groups to check membership for each contact.
|
|
13
|
+
metadata (dict): A dictionary of custom columns to include in the output.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
pl.DataFrame: A DataFrame with the processed contacts information.
|
|
17
|
+
"""
|
|
18
|
+
if not isinstance(contacts, list):
|
|
19
|
+
logging.error("Expected contacts to be a list, got %s", type(contacts))
|
|
20
|
+
raise ValueError("contacts must be a list")
|
|
21
|
+
|
|
22
|
+
# Create a copy to avoid modifying the original
|
|
23
|
+
processed_df = pl.DataFrame(contacts, infer_schema_length=None)
|
|
24
|
+
|
|
25
|
+
if processed_df.is_empty():
|
|
26
|
+
logging.warning("Contacts DataFrame is empty")
|
|
27
|
+
return pl.DataFrame()
|
|
28
|
+
|
|
29
|
+
# Ensure required columns are present
|
|
30
|
+
|
|
31
|
+
# Process created_on date components
|
|
32
|
+
if "created_on" in processed_df.columns:
|
|
33
|
+
processed_df = processed_df.with_columns([
|
|
34
|
+
pl.col("created_on").str.to_datetime().dt.year().cast(pl.Utf8).alias("created_on_year"),
|
|
35
|
+
pl.col("created_on").str.to_datetime().dt.strftime("%Y-%m").alias("created_on_month"),
|
|
36
|
+
pl.col("created_on").str.to_datetime().dt.strftime("%Y-%m-%d").alias("created_on_day")
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
# Handle last_seen_on (use modified_on if last_seen_on is null)
|
|
40
|
+
if "last_seen_on" in processed_df.columns:
|
|
41
|
+
if "modified_on" in processed_df.columns:
|
|
42
|
+
processed_df = processed_df.with_columns([
|
|
43
|
+
pl.when(pl.col("last_seen_on").is_null())
|
|
44
|
+
.then(pl.col("modified_on"))
|
|
45
|
+
.otherwise(pl.col("last_seen_on"))
|
|
46
|
+
.alias("last_seen_on")
|
|
47
|
+
])
|
|
48
|
+
# Process last_seen_on date components
|
|
49
|
+
processed_df = processed_df.with_columns([
|
|
50
|
+
pl.col("last_seen_on").str.to_datetime().dt.year().cast(pl.Utf8).alias("last_seen_on_year"),
|
|
51
|
+
pl.col("last_seen_on").str.to_datetime().dt.strftime("%Y-%m").alias("last_seen_on_month"),
|
|
52
|
+
pl.col("last_seen_on").str.to_datetime().dt.strftime("%Y-%m-%d").alias("last_seen_on_day")
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
# Add metadata columns
|
|
56
|
+
if metadata and isinstance(metadata, dict):
|
|
57
|
+
for key, value in metadata.items():
|
|
58
|
+
processed_df = processed_df.with_columns(pl.lit(value).alias(key))
|
|
59
|
+
|
|
60
|
+
# Ensure required columns are present
|
|
61
|
+
required_columns = ["fields", "groups", "urns"]
|
|
62
|
+
for col in required_columns:
|
|
63
|
+
if col not in processed_df.columns:
|
|
64
|
+
logging.warning(f"Column '{col}' not found in contacts DataFrame, adding as empty column")
|
|
65
|
+
processed_df = processed_df.with_columns(pl.lit(None).alias(col))
|
|
66
|
+
# Ensure fields, groups, and urns are present and initialized
|
|
67
|
+
processed_df = processed_df.with_columns([
|
|
68
|
+
pl.when(pl.col("fields").is_null()).then(pl.lit({})).otherwise(pl.col("fields")).alias("fields"),
|
|
69
|
+
pl.when(pl.col("groups").is_null()).then(pl.lit([])).otherwise(pl.col("groups")).alias("groups"),
|
|
70
|
+
pl.when(pl.col("urns").is_null()).then(pl.lit([])).otherwise(pl.col("urns")).alias("urns")
|
|
71
|
+
])
|
|
72
|
+
# Ensure the columns are of the correct type
|
|
73
|
+
|
|
74
|
+
# Extract fields from the nested "fields" column
|
|
75
|
+
if fields and "fields" in processed_df.columns:
|
|
76
|
+
for field_name in fields:
|
|
77
|
+
processed_df = processed_df.with_columns([
|
|
78
|
+
pl.col("fields").map_elements(lambda x: x.get(field_name, None) if isinstance(x, dict) else None,
|
|
79
|
+
return_dtype=pl.Utf8)
|
|
80
|
+
.alias(field_name)
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
# Extract group membership
|
|
84
|
+
if groups and "groups" in processed_df.columns:
|
|
85
|
+
for group_name in groups:
|
|
86
|
+
processed_df = processed_df.with_columns([
|
|
87
|
+
pl.col("groups").map_elements(
|
|
88
|
+
lambda groups_list: bool(any(
|
|
89
|
+
isinstance(g, dict) and g.get("name") == group_name
|
|
90
|
+
for g in (groups_list if not None else [])
|
|
91
|
+
)),
|
|
92
|
+
return_dtype=pl.Boolean
|
|
93
|
+
).alias(group_name)
|
|
94
|
+
])
|
|
95
|
+
|
|
96
|
+
# Extract URN type
|
|
97
|
+
if "urns" in processed_df.columns:
|
|
98
|
+
processed_df = processed_df.with_columns([
|
|
99
|
+
pl.col("urns").map_elements(
|
|
100
|
+
lambda urns: urns[0].split(":")[0] if len(urns) > 0
|
|
101
|
+
and isinstance(urns[0], str) and ":" in urns[0] else None,
|
|
102
|
+
return_dtype=pl.Utf8
|
|
103
|
+
).alias("urn_type")
|
|
104
|
+
])
|
|
105
|
+
|
|
106
|
+
return processed_df
|