acryl-datahub-cloud 0.3.12rc1__py3-none-any.whl → 0.3.12rc4__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.

Potentially problematic release.


This version of acryl-datahub-cloud might be problematic. Click here for more details.

Files changed (74) hide show
  1. acryl_datahub_cloud/_codegen_config.json +1 -1
  2. acryl_datahub_cloud/datahub_forms_notifications/forms_notifications_source.py +559 -0
  3. acryl_datahub_cloud/datahub_forms_notifications/get_search_results_total.gql +14 -0
  4. acryl_datahub_cloud/datahub_forms_notifications/query.py +17 -0
  5. acryl_datahub_cloud/datahub_forms_notifications/scroll_forms_for_notification.gql +29 -0
  6. acryl_datahub_cloud/datahub_forms_notifications/send_form_notification_request.gql +5 -0
  7. acryl_datahub_cloud/datahub_usage_reporting/query_builder.py +48 -8
  8. acryl_datahub_cloud/datahub_usage_reporting/usage_feature_reporter.py +49 -40
  9. acryl_datahub_cloud/metadata/_urns/urn_defs.py +1842 -1786
  10. acryl_datahub_cloud/metadata/com/linkedin/pegasus2avro/application/__init__.py +19 -0
  11. acryl_datahub_cloud/metadata/com/linkedin/pegasus2avro/form/__init__.py +4 -0
  12. acryl_datahub_cloud/metadata/com/linkedin/pegasus2avro/notification/__init__.py +19 -0
  13. acryl_datahub_cloud/metadata/com/linkedin/pegasus2avro/settings/global/__init__.py +2 -0
  14. acryl_datahub_cloud/metadata/schema.avsc +24861 -24050
  15. acryl_datahub_cloud/metadata/schema_classes.py +1031 -631
  16. acryl_datahub_cloud/metadata/schemas/ApplicationKey.avsc +31 -0
  17. acryl_datahub_cloud/metadata/schemas/ApplicationProperties.avsc +72 -0
  18. acryl_datahub_cloud/metadata/schemas/Applications.avsc +38 -0
  19. acryl_datahub_cloud/metadata/schemas/AssertionAnalyticsRunEvent.avsc +40 -7
  20. acryl_datahub_cloud/metadata/schemas/AssertionInfo.avsc +27 -6
  21. acryl_datahub_cloud/metadata/schemas/AssertionRunEvent.avsc +31 -7
  22. acryl_datahub_cloud/metadata/schemas/AssertionsSummary.avsc +14 -0
  23. acryl_datahub_cloud/metadata/schemas/ChartKey.avsc +1 -0
  24. acryl_datahub_cloud/metadata/schemas/ConstraintInfo.avsc +12 -1
  25. acryl_datahub_cloud/metadata/schemas/ContainerKey.avsc +1 -0
  26. acryl_datahub_cloud/metadata/schemas/CorpGroupKey.avsc +2 -1
  27. acryl_datahub_cloud/metadata/schemas/CorpUserKey.avsc +2 -1
  28. acryl_datahub_cloud/metadata/schemas/DashboardKey.avsc +1 -0
  29. acryl_datahub_cloud/metadata/schemas/DataFlowKey.avsc +1 -0
  30. acryl_datahub_cloud/metadata/schemas/DataHubPolicyInfo.avsc +12 -1
  31. acryl_datahub_cloud/metadata/schemas/DataJobKey.avsc +1 -0
  32. acryl_datahub_cloud/metadata/schemas/DataProductKey.avsc +1 -0
  33. acryl_datahub_cloud/metadata/schemas/DataProductProperties.avsc +1 -1
  34. acryl_datahub_cloud/metadata/schemas/DatasetKey.avsc +1 -0
  35. acryl_datahub_cloud/metadata/schemas/FormAssignmentStatus.avsc +36 -0
  36. acryl_datahub_cloud/metadata/schemas/FormInfo.avsc +6 -0
  37. acryl_datahub_cloud/metadata/schemas/FormKey.avsc +2 -1
  38. acryl_datahub_cloud/metadata/schemas/FormNotifications.avsc +69 -0
  39. acryl_datahub_cloud/metadata/schemas/FormSettings.avsc +3 -0
  40. acryl_datahub_cloud/metadata/schemas/GlobalSettingsInfo.avsc +22 -0
  41. acryl_datahub_cloud/metadata/schemas/GlossaryTermKey.avsc +1 -0
  42. acryl_datahub_cloud/metadata/schemas/MLFeatureKey.avsc +1 -0
  43. acryl_datahub_cloud/metadata/schemas/MLFeatureTableKey.avsc +1 -0
  44. acryl_datahub_cloud/metadata/schemas/MLModelGroupKey.avsc +1 -0
  45. acryl_datahub_cloud/metadata/schemas/MLModelKey.avsc +1 -0
  46. acryl_datahub_cloud/metadata/schemas/MLPrimaryKeyKey.avsc +1 -0
  47. acryl_datahub_cloud/metadata/schemas/MetadataChangeEvent.avsc +12 -1
  48. acryl_datahub_cloud/metadata/schemas/MonitorInfo.avsc +27 -6
  49. acryl_datahub_cloud/metadata/schemas/NotebookKey.avsc +1 -0
  50. acryl_datahub_cloud/metadata/schemas/NotificationRequest.avsc +1 -0
  51. acryl_datahub_cloud/notifications/__init__.py +0 -0
  52. acryl_datahub_cloud/notifications/notification_recipient_builder.py +399 -0
  53. acryl_datahub_cloud/sdk/__init__.py +29 -0
  54. acryl_datahub_cloud/{_sdk_extras → sdk}/assertion.py +501 -193
  55. acryl_datahub_cloud/sdk/assertion_input/__init__.py +0 -0
  56. acryl_datahub_cloud/{_sdk_extras → sdk/assertion_input}/assertion_input.py +733 -189
  57. acryl_datahub_cloud/sdk/assertion_input/freshness_assertion_input.py +261 -0
  58. acryl_datahub_cloud/sdk/assertion_input/smart_column_metric_assertion_input.py +947 -0
  59. acryl_datahub_cloud/sdk/assertions_client.py +1639 -0
  60. acryl_datahub_cloud/sdk/entities/__init__.py +0 -0
  61. acryl_datahub_cloud/{_sdk_extras → sdk}/entities/assertion.py +5 -2
  62. acryl_datahub_cloud/{_sdk_extras → sdk}/subscription_client.py +146 -33
  63. {acryl_datahub_cloud-0.3.12rc1.dist-info → acryl_datahub_cloud-0.3.12rc4.dist-info}/METADATA +48 -43
  64. {acryl_datahub_cloud-0.3.12rc1.dist-info → acryl_datahub_cloud-0.3.12rc4.dist-info}/RECORD +72 -54
  65. {acryl_datahub_cloud-0.3.12rc1.dist-info → acryl_datahub_cloud-0.3.12rc4.dist-info}/entry_points.txt +1 -0
  66. acryl_datahub_cloud/_sdk_extras/__init__.py +0 -19
  67. acryl_datahub_cloud/_sdk_extras/assertions_client.py +0 -717
  68. /acryl_datahub_cloud/{_sdk_extras/entities → datahub_forms_notifications}/__init__.py +0 -0
  69. /acryl_datahub_cloud/{_sdk_extras → sdk}/entities/monitor.py +0 -0
  70. /acryl_datahub_cloud/{_sdk_extras → sdk}/entities/subscription.py +0 -0
  71. /acryl_datahub_cloud/{_sdk_extras → sdk}/errors.py +0 -0
  72. /acryl_datahub_cloud/{_sdk_extras → sdk}/resolver_client.py +0 -0
  73. {acryl_datahub_cloud-0.3.12rc1.dist-info → acryl_datahub_cloud-0.3.12rc4.dist-info}/WHEEL +0 -0
  74. {acryl_datahub_cloud-0.3.12rc1.dist-info → acryl_datahub_cloud-0.3.12rc4.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acryl-datahub-cloud",
3
- "version": "0.3.12rc1",
3
+ "version": "0.3.12rc4",
4
4
  "install_requires": [
5
5
  "avro-gen3==0.7.16",
6
6
  "acryl-datahub"
@@ -0,0 +1,559 @@
1
+ import json
2
+ import logging
3
+ import time
4
+ from dataclasses import dataclass
5
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
6
+
7
+ from pydantic import BaseModel
8
+ from tenacity import (
9
+ retry,
10
+ retry_if_exception_type,
11
+ stop_after_attempt,
12
+ wait_exponential,
13
+ )
14
+
15
+ from acryl_datahub_cloud.datahub_forms_notifications.query import (
16
+ GRAPHQL_GET_FEATURE_FLAG,
17
+ GRAPHQL_GET_SEARCH_RESULTS_TOTAL,
18
+ GRAPHQL_SCROLL_FORMS_FOR_NOTIFICATIONS,
19
+ GRAPHQL_SEND_FORM_NOTIFICATION_REQUEST,
20
+ )
21
+ from acryl_datahub_cloud.notifications.notification_recipient_builder import (
22
+ NotificationRecipientBuilder,
23
+ )
24
+ from datahub.emitter.mcp import MetadataChangeProposalWrapper
25
+ from datahub.ingestion.api.common import PipelineContext
26
+ from datahub.ingestion.api.decorators import (
27
+ SupportStatus,
28
+ config_class,
29
+ platform_name,
30
+ support_status,
31
+ )
32
+ from datahub.ingestion.api.source import Source, SourceReport
33
+ from datahub.ingestion.api.workunit import MetadataWorkUnit
34
+ from datahub.ingestion.graph.client import DataHubGraph
35
+ from datahub.ingestion.graph.filters import RawSearchFilter
36
+ from datahub.metadata.schema_classes import (
37
+ FormInfoClass,
38
+ FormNotificationDetailsClass,
39
+ FormNotificationEntryClass,
40
+ FormNotificationsClass,
41
+ FormSettingsClass,
42
+ FormStateClass,
43
+ FormTypeClass,
44
+ )
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ USER_URN_PREFIX = "urn:li:corpuser"
49
+ GROUP_URN_PREFIX = "urn:li:corpGroup"
50
+
51
+
52
+ class DataHubFormsNotificationsSourceConfig(BaseModel):
53
+ form_urns: Optional[List[str]] = None
54
+
55
+
56
+ class DataHubDatasetSearchRow(BaseModel):
57
+ urn: str
58
+ owners: List[str] = []
59
+
60
+
61
+ @dataclass
62
+ class DataHubFormsNotificationsSourceReport(SourceReport):
63
+ notifications_sent: int = (
64
+ 0 # the number of recipients we sent notifications out for
65
+ )
66
+ forms_count: int = (
67
+ 0 # the number of forms that we sent at least one nitification for
68
+ )
69
+
70
+
71
+ @platform_name(id="datahub", platform_name="DataHub")
72
+ @config_class(DataHubFormsNotificationsSourceConfig)
73
+ @support_status(SupportStatus.INCUBATING)
74
+ class DataHubFormsNotificationsSource(Source):
75
+ """Forms Notification Source that notifies recipients for compliance forms tasks"""
76
+
77
+ def __init__(
78
+ self, config: DataHubFormsNotificationsSourceConfig, ctx: PipelineContext
79
+ ):
80
+ super().__init__(ctx)
81
+ self.config: DataHubFormsNotificationsSourceConfig = config
82
+ self.report = DataHubFormsNotificationsSourceReport()
83
+ self.graph: DataHubGraph = ctx.require_graph(
84
+ "Loading default graph coordinates."
85
+ )
86
+ self.group_to_users_map: Dict[str, List[str]] = {}
87
+ self.recipient_builder: NotificationRecipientBuilder = (
88
+ NotificationRecipientBuilder(self.graph)
89
+ )
90
+ self.user_to_form_notifications: Dict[str, FormNotificationsClass] = {}
91
+
92
+ def get_workunits(self) -> Iterable[MetadataWorkUnit]:
93
+ # end early if the feature flag is not enabled
94
+ if not self.is_feature_flag_enabled():
95
+ return []
96
+
97
+ self.notify_form_assignees()
98
+
99
+ # This source doesn't produce any work units
100
+ return []
101
+
102
+ def is_feature_flag_enabled(self) -> bool:
103
+ response = self.execute_graphql_with_retry(
104
+ GRAPHQL_GET_FEATURE_FLAG, variables={}
105
+ )
106
+
107
+ result = response.get("appConfig", {})
108
+ featureFlags = result.get("featureFlags", {})
109
+ is_enabled = featureFlags.get("formsNotificationsEnabled", False)
110
+
111
+ if not is_enabled:
112
+ logger.error(
113
+ "Tried running datahub-forms-notifications with formsNotificationsEnabled disabled"
114
+ )
115
+
116
+ return is_enabled
117
+
118
+ def notify_form_assignees(self) -> None:
119
+ for urn, form in self.get_forms():
120
+ if not self.is_form_complete(urn, form.type):
121
+ assignees = self.get_form_assignees(urn, form)
122
+ self.process_notify_on_publish(assignees, form.name, urn)
123
+
124
+ def process_notify_on_publish(
125
+ self, form_assignees: List[str], form_name: str, form_urn: str
126
+ ) -> None:
127
+ """
128
+ Take in form assignees, find the ones who haven't been notified on publish, and build a notification for them.
129
+ """
130
+ filtered_assignees = self.filter_assignees_to_notify(form_assignees, form_urn)
131
+ recipients = []
132
+ if self.recipient_builder is not None:
133
+ recipients = self.recipient_builder.build_actor_recipients(
134
+ filtered_assignees, "COMPLIANCE_FORM_PUBLISH", True
135
+ )
136
+ recipient_count = len(recipients)
137
+
138
+ if recipient_count > 0:
139
+ self.report.notifications_sent += recipient_count
140
+ self.report.forms_count += 1
141
+
142
+ response = self.execute_graphql_with_retry(
143
+ GRAPHQL_SEND_FORM_NOTIFICATION_REQUEST,
144
+ variables={
145
+ "input": {
146
+ "type": "BROADCAST_COMPLIANCE_FORM_PUBLISH",
147
+ "parameters": [{"key": "formName", "value": form_name}],
148
+ "recipients": self.recipient_builder.convert_recipients_to_json_objects(
149
+ recipients
150
+ ),
151
+ }
152
+ },
153
+ )
154
+
155
+ if not response.get("sendFormNotificationRequest", False):
156
+ logger.error(
157
+ f"Issue sending the notification request for this job. Response: {response}"
158
+ )
159
+ else:
160
+ unique_actor_urns = set(
161
+ [
162
+ recipient.get("actor")
163
+ for recipient in recipients
164
+ if recipient.get("actor") is not None
165
+ ]
166
+ )
167
+ for actor_urn in unique_actor_urns:
168
+ self.update_form_notifications(actor_urn, form_urn)
169
+
170
+ def update_form_notifications(self, user_urn: str, form_urn: str) -> None:
171
+ """
172
+ After sending a notification, update the user's formNotifications aspect
173
+ to track that we sent the notification that we did
174
+ """
175
+ # get or create default formNotifications aspect
176
+ form_notifications = self.user_to_form_notifications.get(user_urn)
177
+ if form_notifications is None:
178
+ form_notifications = FormNotificationsClass(notificationDetails=[])
179
+
180
+ # get the notification details for our specific form or create default
181
+ details_for_form = self.get_notification_details_for_form(
182
+ user_urn, form_urn, form_notifications
183
+ )
184
+ if details_for_form is None:
185
+ details_for_form = FormNotificationDetailsClass(
186
+ formUrn=form_urn, notificationLog=[]
187
+ )
188
+
189
+ # add new notification log entry for this occasion
190
+ new_notification_log_entry = FormNotificationEntryClass(
191
+ time=int(time.time() * 1000),
192
+ notificationType="BROADCAST_COMPLIANCE_FORM_PUBLISH",
193
+ )
194
+ details_for_form.notificationLog.append(new_notification_log_entry)
195
+
196
+ # filter out details for given form so we can add updated one
197
+ final_notification_details = [
198
+ details
199
+ for details in form_notifications.notificationDetails
200
+ if details.formUrn != form_urn
201
+ ]
202
+ final_notification_details.append(details_for_form)
203
+
204
+ # update the aspect with the final notification details
205
+ form_notifications.notificationDetails = final_notification_details
206
+
207
+ self.user_to_form_notifications[user_urn] = form_notifications
208
+
209
+ self.graph.emit(
210
+ MetadataChangeProposalWrapper(
211
+ entityUrn=user_urn,
212
+ aspect=form_notifications,
213
+ )
214
+ )
215
+
216
+ @retry(
217
+ retry=retry_if_exception_type((Exception, ConnectionError)),
218
+ stop=stop_after_attempt(3),
219
+ wait=wait_exponential(multiplier=1, min=4, max=10),
220
+ reraise=True,
221
+ )
222
+ def execute_graphql_with_retry(
223
+ self, query: str, variables: Dict[str, Any]
224
+ ) -> Dict[str, Any]:
225
+ """Execute GraphQL query with retry logic"""
226
+ if self.graph is None:
227
+ raise ValueError("Graph client not initialized")
228
+ response = self.graph.execute_graphql(query, variables=variables)
229
+ error = response.get("error")
230
+ if error:
231
+ raise Exception(f"GraphQL error: {error}")
232
+ return response
233
+
234
+ def get_forms(self) -> List[Tuple[str, FormInfoClass]]:
235
+ """
236
+ Get forms and their formInfo aspect either from the forms provided in the config
237
+ or search for forms that are published and notifyAssigneesOnPublish = True.
238
+ This method will only return forms that are published and have notifications enabled.
239
+ """
240
+ form_urns = []
241
+
242
+ if self.config.form_urns is not None:
243
+ form_urns = self.config.form_urns
244
+ else:
245
+ form_urns = self.search_for_forms()
246
+
247
+ form_urns_with_notifications_enabled = (
248
+ self.get_form_urns_with_notifications_enabled(form_urns)
249
+ )
250
+
251
+ return self.get_form_infos(form_urns_with_notifications_enabled)
252
+
253
+ def get_form_urns_with_notifications_enabled(
254
+ self, form_urns: List[str]
255
+ ) -> List[str]:
256
+ """
257
+ Get formSettings aspects and check if notifications are enabled for a given form urn.
258
+ If notifications are enabled, add to filtered list and return.
259
+ """
260
+ filtered_form_urns: List[str] = []
261
+
262
+ if len(form_urns) > 0:
263
+ entities = self.graph.get_entities("form", form_urns, ["formSettings"])
264
+ for urn, entity in entities.items():
265
+ form_tuple = entity.get(FormSettingsClass.ASPECT_NAME, (None, None))
266
+ if form_tuple and form_tuple[0]:
267
+ if not isinstance(form_tuple[0], FormSettingsClass):
268
+ logger.error(
269
+ f"{form_tuple[0]} is not of type FormInfo for urn: {urn}"
270
+ )
271
+ else:
272
+ form_settings = form_tuple[0]
273
+ if form_settings.notificationSettings.notifyAssigneesOnPublish:
274
+ filtered_form_urns.append(urn)
275
+
276
+ return filtered_form_urns
277
+
278
+ def get_form_infos(self, form_urns: List[str]) -> List[Tuple[str, FormInfoClass]]:
279
+ """
280
+ Get formInfo aspects for a list of form urns and return the formInfos of forms
281
+ that are published. If a form is not published, we don't want to notify.
282
+ """
283
+ form_infos: List[Tuple[str, FormInfoClass]] = []
284
+
285
+ if len(form_urns) > 0:
286
+ entities = self.graph.get_entities("form", form_urns, ["formInfo"])
287
+ for urn, entity in entities.items():
288
+ form_tuple = entity.get(FormInfoClass.ASPECT_NAME, (None, None))
289
+ if form_tuple and form_tuple[0]:
290
+ if not isinstance(form_tuple[0], FormInfoClass):
291
+ logger.error(
292
+ f"{form_tuple[0]} is not of type FormInfo for urn: {urn}"
293
+ )
294
+ else:
295
+ form_info = form_tuple[0]
296
+ if form_info.status.state == FormStateClass.PUBLISHED:
297
+ form_infos.append((urn, form_tuple[0]))
298
+
299
+ return form_infos
300
+
301
+ def search_for_forms(self) -> List[str]:
302
+ scroll_id: Optional[str] = None
303
+ form_urns: List[str] = []
304
+
305
+ try:
306
+ while True:
307
+ next_scroll_id, results = self.scroll_forms_to_notify_for(scroll_id)
308
+
309
+ for result in results:
310
+ form_urn = result.get("entity", {}).get("urn", None)
311
+ if form_urn is None:
312
+ self.report.report_warning(
313
+ message="Failed to resolve entity urn for form! Skipping...",
314
+ context=f"Response: {str(result)}",
315
+ )
316
+ else:
317
+ form_urns.append(form_urn)
318
+
319
+ if next_scroll_id is None:
320
+ break
321
+ else:
322
+ scroll_id = next_scroll_id
323
+
324
+ time.sleep(1)
325
+
326
+ except Exception as e:
327
+ self.report.report_failure(
328
+ title="Failed to search for forms to send notifications for",
329
+ message="Error occurred while searching for forms to send notifications for",
330
+ context=f"message = {str(e)}",
331
+ exc=e,
332
+ )
333
+ return form_urns
334
+
335
+ return form_urns
336
+
337
+ def scroll_forms_to_notify_for(
338
+ self, scroll_id: Optional[str]
339
+ ) -> Tuple[Optional[str], List[Dict[str, Any]]]:
340
+ """Scroll through shared entities with retry logic"""
341
+ response = self.execute_graphql_with_retry(
342
+ GRAPHQL_SCROLL_FORMS_FOR_NOTIFICATIONS,
343
+ variables={
344
+ "scrollId": scroll_id,
345
+ "count": 500,
346
+ },
347
+ )
348
+
349
+ result = response.get("scrollAcrossEntities", {})
350
+ return result.get("nextScrollId"), result.get("searchResults", [])
351
+
352
+ def get_form_assignees(self, form_urn: str, form: FormInfoClass) -> List[str]:
353
+ """
354
+ Form assignees are provided explicitly on the form and the owners of assets with this form
355
+ if it's an ownership form.
356
+ For form notifications, we want to get users from a user group and send notifications to
357
+ those users specifically
358
+ """
359
+ user_urns = form.actors.users if form.actors.users is not None else []
360
+ group_urns = form.actors.groups if form.actors.groups is not None else []
361
+
362
+ if form.actors.owners:
363
+ (user_owners, group_owners) = self.get_owners_of_assets_for_form(
364
+ form_urn, form
365
+ )
366
+ user_urns.extend(user_owners)
367
+ group_urns.extend(group_owners)
368
+
369
+ for group_urn in group_urns:
370
+ user_urns.extend(self._get_users_in_group(group_urn))
371
+
372
+ return list(set(user_urns))
373
+
374
+ def get_owners_of_assets_for_form(
375
+ self, form_urn: str, form: FormInfoClass
376
+ ) -> Tuple[List[str], List[str]]:
377
+ """
378
+ Filter to get assets that are not complete for this form and using the extra_source_fields parameter
379
+ we pull owners from the asset's elastic row. self.graph.get_results_by_filter will paginate over assets
380
+ """
381
+ user_urns = []
382
+ group_urns = []
383
+
384
+ extra_fields = [f for f in DataHubDatasetSearchRow.__fields__]
385
+ results = self.graph.get_results_by_filter(
386
+ extra_or_filters=self._get_incomplete_assets_for_form(form_urn, form.type),
387
+ extra_source_fields=extra_fields,
388
+ skip_cache=True,
389
+ )
390
+ for result in results:
391
+ extra_properties = result["extraProperties"]
392
+ extra_properties_map = {
393
+ x["name"]: json.loads(x["value"]) for x in extra_properties
394
+ }
395
+ search_row = DataHubDatasetSearchRow(**extra_properties_map)
396
+ for owner in search_row.owners:
397
+ if owner.startswith(USER_URN_PREFIX):
398
+ user_urns.append(owner)
399
+ elif owner.startswith(GROUP_URN_PREFIX):
400
+ group_urns.append(owner)
401
+ else:
402
+ logger.warning(
403
+ f"Found unexpected owner {owner} for asset {search_row.urn}"
404
+ )
405
+
406
+ return (user_urns, group_urns)
407
+
408
+ def filter_assignees_to_notify(
409
+ self, user_urns: List[str], form_urn: str
410
+ ) -> List[str]:
411
+ """
412
+ Filter out any users who have already received the publish notification type in the past
413
+ """
414
+ filtered_users = []
415
+
416
+ self.populate_user_to_form_notifications(user_urns)
417
+
418
+ for user in user_urns:
419
+ form_notifications = self.user_to_form_notifications.get(user)
420
+ if form_notifications is None or not self.has_user_been_sent_notification(
421
+ user, form_urn, form_notifications, "BROADCAST_COMPLIANCE_FORM_PUBLISH"
422
+ ):
423
+ filtered_users.append(user)
424
+
425
+ return filtered_users
426
+
427
+ def has_user_been_sent_notification(
428
+ self,
429
+ user_urn: str,
430
+ form_urn: str,
431
+ form_notifications: FormNotificationsClass,
432
+ notification_type: str,
433
+ ) -> bool:
434
+ notification_details = self.get_notification_details_for_form(
435
+ user_urn, form_urn, form_notifications
436
+ )
437
+ if notification_details is None:
438
+ return False
439
+
440
+ notification_types_sent_for_form = [
441
+ entry.notificationType for entry in notification_details.notificationLog
442
+ ]
443
+
444
+ return notification_type in notification_types_sent_for_form
445
+
446
+ def get_notification_details_for_form(
447
+ self, user_urn: str, form_urn: str, form_notifications: FormNotificationsClass
448
+ ) -> FormNotificationDetailsClass | None:
449
+ notification_details_for_form = [
450
+ detail
451
+ for detail in form_notifications.notificationDetails
452
+ if detail.formUrn == form_urn
453
+ ]
454
+
455
+ notification_details = None
456
+ if len(notification_details_for_form) > 1:
457
+ logger.warning(
458
+ f"Found more than one notificationDetails for a given form for user {user_urn} in {form_notifications}"
459
+ )
460
+ # grab first one
461
+ notification_details = notification_details_for_form[0]
462
+ elif len(notification_details_for_form) == 1:
463
+ notification_details = notification_details_for_form[0]
464
+
465
+ return notification_details
466
+
467
+ def populate_user_to_form_notifications(self, user_urns: List[str]) -> None:
468
+ new_users = [
469
+ urn for urn in user_urns if urn not in self.user_to_form_notifications
470
+ ]
471
+
472
+ if len(new_users) == 0:
473
+ return
474
+
475
+ entities = self.graph.get_entities("corpuser", new_users, ["formNotifications"])
476
+ for urn, entity in entities.items():
477
+ user_tuple = entity.get(FormNotificationsClass.ASPECT_NAME, (None, None))
478
+ if user_tuple and user_tuple[0]:
479
+ if not isinstance(user_tuple[0], FormNotificationsClass):
480
+ logger.error(
481
+ f"{user_tuple[0]} is not of type FormNotifications for urn: {urn}"
482
+ )
483
+ else:
484
+ self.user_to_form_notifications[urn] = user_tuple[0]
485
+
486
+ def _get_users_in_group(self, group_urn: str) -> List[str]:
487
+ """
488
+ Using a relationship query, get users inside of a group. Store these users in memory if we've
489
+ already fetched the users for this group.
490
+ """
491
+ if (users_in_group := self.group_to_users_map.get(group_urn)) is not None:
492
+ return users_in_group
493
+
494
+ group_member_urns = []
495
+ members = self.graph.get_related_entities(
496
+ group_urn,
497
+ ["IsMemberOfGroup", "IsMemberOfNativeGroup"],
498
+ self.graph.RelationshipDirection.INCOMING,
499
+ )
500
+ member_urns = [member.urn for member in members]
501
+ for member_urn in member_urns:
502
+ if member_urn.startswith(USER_URN_PREFIX):
503
+ group_member_urns.append(member_urn)
504
+ else:
505
+ logger.warning(
506
+ f"Unexpected group member {member_urn} found in group {group_urn}"
507
+ )
508
+ self.group_to_users_map[group_urn] = group_member_urns
509
+
510
+ return group_member_urns
511
+
512
+ def _get_verification_form_filter(self, form_urn: str) -> RawSearchFilter:
513
+ return [
514
+ {"and": [{"field": "incompleteForms", "values": [form_urn]}]},
515
+ {
516
+ "and": [
517
+ {"field": "completedForms", "values": [form_urn]},
518
+ {"field": "verifiedForms", "values": [form_urn], "negated": True},
519
+ ]
520
+ },
521
+ ]
522
+
523
+ def _get_completion_form_filter(self, form_urn: str) -> RawSearchFilter:
524
+ return [{"and": [{"field": "incompleteForms", "values": [form_urn]}]}]
525
+
526
+ def _get_incomplete_assets_for_form(
527
+ self, form_urn: str, form_type: str | FormTypeClass
528
+ ) -> RawSearchFilter:
529
+ return (
530
+ self._get_completion_form_filter(form_urn)
531
+ if form_type == FormTypeClass.COMPLETION
532
+ else self._get_verification_form_filter(form_urn)
533
+ )
534
+
535
+ def is_form_complete(self, form_urn: str, form_type: str | FormTypeClass) -> int:
536
+ """
537
+ Returns whether this form is complete - meaning no assets have any work left to do for it.
538
+ This takes into account the type of form to know if it's fully complete.
539
+ """
540
+ response = self.execute_graphql_with_retry(
541
+ GRAPHQL_GET_SEARCH_RESULTS_TOTAL,
542
+ variables={
543
+ "count": 0,
544
+ "orFilters": self._get_incomplete_assets_for_form(form_urn, form_type),
545
+ },
546
+ )
547
+
548
+ result = response.get("searchAcrossEntities", {})
549
+ total = result.get("total", -1)
550
+ if total < 0:
551
+ logger.warning(
552
+ f"Error evaluating if form with urn {form_urn} is complete. Skipping."
553
+ )
554
+ return True
555
+ else:
556
+ return total == 0
557
+
558
+ def get_report(self) -> SourceReport:
559
+ return self.report
@@ -0,0 +1,14 @@
1
+ query getSearchResultsTotal($orFilters: [AndFilterInput!]!, $count: Int!) {
2
+ searchAcrossEntities(
3
+ input: {
4
+ types: []
5
+ query: "*"
6
+ count: $count
7
+ searchFlags: { skipCache: true }
8
+ orFilters: $orFilters
9
+ }
10
+ ) {
11
+ count
12
+ total
13
+ }
14
+ }
@@ -0,0 +1,17 @@
1
+ import pathlib
2
+
3
+ GRAPHQL_SCROLL_FORMS_FOR_NOTIFICATIONS = (
4
+ pathlib.Path(__file__).parent / "scroll_forms_for_notification.gql"
5
+ ).read_text()
6
+
7
+ GRAPHQL_GET_SEARCH_RESULTS_TOTAL = (
8
+ pathlib.Path(__file__).parent / "get_search_results_total.gql"
9
+ ).read_text()
10
+
11
+ GRAPHQL_SEND_FORM_NOTIFICATION_REQUEST = (
12
+ pathlib.Path(__file__).parent / "send_form_notification_request.gql"
13
+ ).read_text()
14
+
15
+ GRAPHQL_GET_FEATURE_FLAG = (
16
+ pathlib.Path(__file__).parent / "get_feature_flag.gql"
17
+ ).read_text()
@@ -0,0 +1,29 @@
1
+ query scrollFormsToNotifyFor($scrollId: String, $count: Int!) {
2
+ scrollAcrossEntities(
3
+ input: {
4
+ types: [FORM]
5
+ query: "*"
6
+ scrollId: $scrollId
7
+ count: $count
8
+ searchFlags: { skipCache: true }
9
+ orFilters: [
10
+ {
11
+ and: [
12
+ { field: "formStatus", values: ["PUBLISHED"] }
13
+ { field: "notifyAssigneesOnPublish", values: ["true"] }
14
+ ]
15
+ }
16
+ ]
17
+ }
18
+ ) {
19
+ nextScrollId
20
+ count
21
+ total
22
+ searchResults {
23
+ entity {
24
+ urn
25
+ type
26
+ }
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,5 @@
1
+ mutation sendFormNotificationRequest(
2
+ $input: SendFormNotificationRequestInput!
3
+ ) {
4
+ sendFormNotificationRequest(input: $input)
5
+ }