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