ethyca-fides 2.64.6b0__py2.py3-none-any.whl → 2.64.6b2__py2.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 ethyca-fides might be problematic. Click here for more details.

Files changed (120) hide show
  1. {ethyca_fides-2.64.6b0.dist-info → ethyca_fides-2.64.6b2.dist-info}/METADATA +1 -1
  2. {ethyca_fides-2.64.6b0.dist-info → ethyca_fides-2.64.6b2.dist-info}/RECORD +118 -113
  3. fides/_version.py +3 -3
  4. fides/api/email_templates/get_email_template.py +4 -1
  5. fides/api/email_templates/template_names.py +1 -0
  6. fides/api/email_templates/templates/external_user_welcome.html +23 -0
  7. fides/api/graph/traversal.py +18 -0
  8. fides/api/models/fides_user_respondent_email_verification.py +3 -3
  9. fides/api/schemas/messaging/messaging.py +14 -0
  10. fides/api/service/connectors/__init__.py +4 -0
  11. fides/api/service/connectors/manual_task_connector.py +96 -0
  12. fides/api/service/messaging/message_dispatch_service.py +33 -1
  13. fides/api/service/privacy_request/dsr_package/templates/collection_index.html +9 -1
  14. fides/api/service/privacy_request/dsr_package/templates/main.css +6 -2
  15. fides/api/service/privacy_request/request_runner_service.py +7 -0
  16. fides/api/task/create_request_tasks.py +16 -0
  17. fides/api/task/execute_request_tasks.py +10 -1
  18. fides/api/task/filter_results.py +6 -0
  19. fides/api/task/graph_task.py +1 -0
  20. fides/api/task/manual/__init__.py +0 -0
  21. fides/api/task/manual/manual_task_graph_task.py +300 -0
  22. fides/api/task/manual/manual_task_utils.py +322 -0
  23. fides/api/task/task_resources.py +3 -0
  24. fides/ui-build/static/admin/404.html +1 -1
  25. fides/ui-build/static/admin/_next/static/chunks/pages/{_app-1650bbc3cb8c2299.js → _app-7430e1499432b029.js} +1 -1
  26. fides/ui-build/static/admin/_next/static/chunks/pages/privacy-requests/[id]-00fb442c4adb7371.js +1 -0
  27. fides/ui-build/static/admin/_next/static/chunks/pages/{privacy-requests-7d625b170911a072.js → privacy-requests-c8b02ae92dd7e45b.js} +1 -1
  28. fides/ui-build/static/admin/_next/static/css/399d4757862a3982.css +1 -0
  29. fides/ui-build/static/admin/_next/static/{7-nocO64klVotMrKmugnq → onw4yQbMe2hBVwh4fBpNY}/_buildManifest.js +1 -1
  30. fides/ui-build/static/admin/add-systems/manual.html +1 -1
  31. fides/ui-build/static/admin/add-systems/multiple.html +1 -1
  32. fides/ui-build/static/admin/add-systems.html +1 -1
  33. fides/ui-build/static/admin/consent/configure/add-vendors.html +1 -1
  34. fides/ui-build/static/admin/consent/configure.html +1 -1
  35. fides/ui-build/static/admin/consent/privacy-experience/[id].html +1 -1
  36. fides/ui-build/static/admin/consent/privacy-experience/new.html +1 -1
  37. fides/ui-build/static/admin/consent/privacy-experience.html +1 -1
  38. fides/ui-build/static/admin/consent/privacy-notices/[id].html +1 -1
  39. fides/ui-build/static/admin/consent/privacy-notices/new.html +1 -1
  40. fides/ui-build/static/admin/consent/privacy-notices.html +1 -1
  41. fides/ui-build/static/admin/consent/properties.html +1 -1
  42. fides/ui-build/static/admin/consent/reporting.html +1 -1
  43. fides/ui-build/static/admin/consent.html +1 -1
  44. fides/ui-build/static/admin/data-catalog/[systemId]/projects/[projectUrn]/[resourceUrn].html +1 -1
  45. fides/ui-build/static/admin/data-catalog/[systemId]/projects/[projectUrn].html +1 -1
  46. fides/ui-build/static/admin/data-catalog/[systemId]/projects.html +1 -1
  47. fides/ui-build/static/admin/data-catalog/[systemId]/resources/[resourceUrn].html +1 -1
  48. fides/ui-build/static/admin/data-catalog/[systemId]/resources.html +1 -1
  49. fides/ui-build/static/admin/data-catalog.html +1 -1
  50. fides/ui-build/static/admin/data-discovery/action-center/[monitorId]/[systemId].html +1 -1
  51. fides/ui-build/static/admin/data-discovery/action-center/[monitorId].html +1 -1
  52. fides/ui-build/static/admin/data-discovery/action-center.html +1 -1
  53. fides/ui-build/static/admin/data-discovery/activity.html +1 -1
  54. fides/ui-build/static/admin/data-discovery/detection/[resourceUrn].html +1 -1
  55. fides/ui-build/static/admin/data-discovery/detection.html +1 -1
  56. fides/ui-build/static/admin/data-discovery/discovery/[resourceUrn].html +1 -1
  57. fides/ui-build/static/admin/data-discovery/discovery.html +1 -1
  58. fides/ui-build/static/admin/datamap.html +1 -1
  59. fides/ui-build/static/admin/dataset/[datasetId]/[collectionName]/[...subfieldNames].html +1 -1
  60. fides/ui-build/static/admin/dataset/[datasetId]/[collectionName].html +1 -1
  61. fides/ui-build/static/admin/dataset/[datasetId].html +1 -1
  62. fides/ui-build/static/admin/dataset/new.html +1 -1
  63. fides/ui-build/static/admin/dataset.html +1 -1
  64. fides/ui-build/static/admin/datastore-connection/[id].html +1 -1
  65. fides/ui-build/static/admin/datastore-connection/new.html +1 -1
  66. fides/ui-build/static/admin/datastore-connection.html +1 -1
  67. fides/ui-build/static/admin/index.html +1 -1
  68. fides/ui-build/static/admin/integrations/[id].html +1 -1
  69. fides/ui-build/static/admin/integrations.html +1 -1
  70. fides/ui-build/static/admin/lib/fides-headless.js +1 -1
  71. fides/ui-build/static/admin/lib/fides-preview.js +1 -1
  72. fides/ui-build/static/admin/lib/fides-tcf.js +1 -1
  73. fides/ui-build/static/admin/lib/fides.js +2 -2
  74. fides/ui-build/static/admin/login/[provider].html +1 -1
  75. fides/ui-build/static/admin/login.html +1 -1
  76. fides/ui-build/static/admin/messaging/[id].html +1 -1
  77. fides/ui-build/static/admin/messaging/add-template.html +1 -1
  78. fides/ui-build/static/admin/messaging.html +1 -1
  79. fides/ui-build/static/admin/poc/ant-components.html +1 -1
  80. fides/ui-build/static/admin/poc/form-experiments/AntForm.html +1 -1
  81. fides/ui-build/static/admin/poc/form-experiments/FormikAntFormItem.html +1 -1
  82. fides/ui-build/static/admin/poc/form-experiments/FormikControlled.html +1 -1
  83. fides/ui-build/static/admin/poc/form-experiments/FormikField.html +1 -1
  84. fides/ui-build/static/admin/poc/form-experiments/FormikSpreadField.html +1 -1
  85. fides/ui-build/static/admin/poc/forms.html +1 -1
  86. fides/ui-build/static/admin/poc/table-migration.html +1 -1
  87. fides/ui-build/static/admin/privacy-requests/[id].html +1 -1
  88. fides/ui-build/static/admin/privacy-requests/configure/messaging.html +1 -1
  89. fides/ui-build/static/admin/privacy-requests/configure/storage.html +1 -1
  90. fides/ui-build/static/admin/privacy-requests/configure.html +1 -1
  91. fides/ui-build/static/admin/privacy-requests.html +1 -1
  92. fides/ui-build/static/admin/properties/[id].html +1 -1
  93. fides/ui-build/static/admin/properties/add-property.html +1 -1
  94. fides/ui-build/static/admin/properties.html +1 -1
  95. fides/ui-build/static/admin/reporting/datamap.html +1 -1
  96. fides/ui-build/static/admin/settings/about/alpha.html +1 -1
  97. fides/ui-build/static/admin/settings/about.html +1 -1
  98. fides/ui-build/static/admin/settings/consent/[configuration_id]/[purpose_id].html +1 -1
  99. fides/ui-build/static/admin/settings/consent.html +1 -1
  100. fides/ui-build/static/admin/settings/custom-fields.html +1 -1
  101. fides/ui-build/static/admin/settings/domain-records.html +1 -1
  102. fides/ui-build/static/admin/settings/domains.html +1 -1
  103. fides/ui-build/static/admin/settings/email-templates.html +1 -1
  104. fides/ui-build/static/admin/settings/locations.html +1 -1
  105. fides/ui-build/static/admin/settings/organization.html +1 -1
  106. fides/ui-build/static/admin/settings/regulations.html +1 -1
  107. fides/ui-build/static/admin/systems/configure/[id]/test-datasets.html +1 -1
  108. fides/ui-build/static/admin/systems/configure/[id].html +1 -1
  109. fides/ui-build/static/admin/systems.html +1 -1
  110. fides/ui-build/static/admin/taxonomy.html +1 -1
  111. fides/ui-build/static/admin/user-management/new.html +1 -1
  112. fides/ui-build/static/admin/user-management/profile/[id].html +1 -1
  113. fides/ui-build/static/admin/user-management.html +1 -1
  114. fides/ui-build/static/admin/_next/static/chunks/pages/privacy-requests/[id]-ee8d27e2f17563a0.js +0 -1
  115. fides/ui-build/static/admin/_next/static/css/5ded47c57dae5baf.css +0 -1
  116. {ethyca_fides-2.64.6b0.dist-info → ethyca_fides-2.64.6b2.dist-info}/WHEEL +0 -0
  117. {ethyca_fides-2.64.6b0.dist-info → ethyca_fides-2.64.6b2.dist-info}/entry_points.txt +0 -0
  118. {ethyca_fides-2.64.6b0.dist-info → ethyca_fides-2.64.6b2.dist-info}/licenses/LICENSE +0 -0
  119. {ethyca_fides-2.64.6b0.dist-info → ethyca_fides-2.64.6b2.dist-info}/top_level.txt +0 -0
  120. /fides/ui-build/static/admin/_next/static/{7-nocO64klVotMrKmugnq → onw4yQbMe2hBVwh4fBpNY}/_ssgManifest.js +0 -0
@@ -0,0 +1,96 @@
1
+ """
2
+ Manual Task Connector - A minimal connector for manual task operations.
3
+
4
+ Since manual tasks don't actually connect to external systems, this connector
5
+ provides no-op implementations of the BaseConnector interface.
6
+ """
7
+
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from fides.api.graph.execution import ExecutionNode
11
+ from fides.api.models.connectionconfig import ConnectionTestStatus
12
+ from fides.api.models.policy import Policy
13
+ from fides.api.models.privacy_request import PrivacyRequest, RequestTask
14
+ from fides.api.service.connectors.base_connector import BaseConnector
15
+ from fides.api.service.connectors.query_configs.query_config import QueryConfig
16
+ from fides.api.util.collection_util import Row
17
+
18
+
19
+ class ManualTaskQueryConfig(QueryConfig):
20
+ """Minimal query config for manual tasks - not actually used"""
21
+
22
+ def generate_query(
23
+ self, input_data: Dict[str, List[Any]], policy: Optional[Policy]
24
+ ) -> str:
25
+ return "Manual task: no query needed"
26
+
27
+ def dry_run_query(self) -> str:
28
+ return "Manual task: no query needed"
29
+
30
+ def query_to_str(self, t: Any, input_data: Dict[str, List[Any]]) -> str:
31
+ """Convert query to string - not used for manual tasks"""
32
+ return "Manual task: no query needed"
33
+
34
+ def generate_update_stmt(
35
+ self, row: Row, policy: Policy, request: PrivacyRequest
36
+ ) -> Any:
37
+ """Generate update statement - not used for manual tasks"""
38
+ return None
39
+
40
+
41
+ class ManualTaskConnector(BaseConnector):
42
+ """
43
+ Minimal connector for manual tasks.
44
+
45
+ This connector provides no-op implementations since manual tasks don't
46
+ actually connect to external systems. The actual manual task logic
47
+ is handled by ManualTaskGraphTask.access_request()
48
+ """
49
+
50
+ def query_config(self, node: ExecutionNode) -> QueryConfig[Any]:
51
+ """Return a minimal query config - not actually used for manual tasks"""
52
+ return ManualTaskQueryConfig(node)
53
+
54
+ def test_connection(self) -> Optional[ConnectionTestStatus]:
55
+ """Manual tasks don't have connections to test"""
56
+ return ConnectionTestStatus.succeeded
57
+
58
+ def create_client(self) -> None:
59
+ """Manual tasks don't need database clients"""
60
+ return None
61
+
62
+ def retrieve_data(
63
+ self,
64
+ node: ExecutionNode,
65
+ policy: Policy,
66
+ privacy_request: PrivacyRequest,
67
+ request_task: RequestTask,
68
+ input_data: Dict[str, List[Any]],
69
+ ) -> List[Row]:
70
+ """
71
+ This method is not used for manual tasks.
72
+ Manual task data retrieval is handled by ManualTaskGraphTask.access_request()
73
+ """
74
+ return []
75
+
76
+ def mask_data(
77
+ self,
78
+ node: ExecutionNode,
79
+ policy: Policy,
80
+ privacy_request: PrivacyRequest,
81
+ request_task: RequestTask,
82
+ rows: List[Row],
83
+ ) -> int:
84
+ """
85
+ Manual tasks don't support erasure operations.
86
+ Manual tasks are for data collection, not data modification.
87
+ """
88
+ return 0
89
+
90
+ def close(self) -> None:
91
+ """No resources to close for manual tasks"""
92
+
93
+ @property
94
+ def requires_primary_keys(self) -> bool:
95
+ """Manual tasks don't require primary keys since they don't modify data"""
96
+ return False
@@ -26,6 +26,7 @@ from fides.api.schemas.messaging.messaging import (
26
26
  EmailForActionType,
27
27
  ErasureRequestBodyParams,
28
28
  ErrorNotificationBodyParams,
29
+ ExternalUserWelcomeBodyParams,
29
30
  FidesopsMessage,
30
31
  MessagingActionType,
31
32
  MessagingMethod,
@@ -176,6 +177,7 @@ def dispatch_message(
176
177
  ErasureRequestBodyParams,
177
178
  UserInviteBodyParams,
178
179
  ErrorNotificationBodyParams,
180
+ ExternalUserWelcomeBodyParams,
179
181
  ]
180
182
  ] = None,
181
183
  subject_override: Optional[str] = None,
@@ -351,7 +353,7 @@ def _render(template_str: str, variables: Optional[Dict] = None) -> str:
351
353
  return template_str
352
354
 
353
355
 
354
- def _build_email( # pylint: disable=too-many-return-statements
356
+ def _build_email( # pylint: disable=too-many-return-statements, too-many-branches
355
357
  config_proxy: ConfigProxy,
356
358
  action_type: MessagingActionType,
357
359
  body_params: Any,
@@ -463,6 +465,36 @@ def _build_email( # pylint: disable=too-many-return-statements
463
465
  }
464
466
  ),
465
467
  )
468
+ if action_type == MessagingActionType.EXTERNAL_USER_WELCOME:
469
+ base_template = get_email_template(action_type)
470
+ # Generate display name for personalization
471
+ display_name = body_params.username
472
+ if body_params.first_name:
473
+ display_name = body_params.first_name
474
+ if body_params.last_name:
475
+ display_name = f"{body_params.first_name} {body_params.last_name}"
476
+
477
+ portal_link = (
478
+ f"{body_params.privacy_center_url}?access_token={body_params.access_token}"
479
+ )
480
+
481
+ variables = {
482
+ "username": body_params.username,
483
+ "display_name": display_name,
484
+ "first_name": body_params.first_name,
485
+ "last_name": body_params.last_name,
486
+ "org_name": body_params.org_name,
487
+ "portal_link": portal_link,
488
+ "privacy_center_url": body_params.privacy_center_url,
489
+ "access_token": body_params.access_token,
490
+ }
491
+
492
+ return EmailForActionType(
493
+ subject="Welcome to our Privacy Center",
494
+ body=base_template.render(variables),
495
+ template_variables=variables,
496
+ )
497
+
466
498
  logger.error("Message action type {} is not implemented", action_type)
467
499
  raise MessageDispatchException(
468
500
  f"Message action type {action_type} is not implemented"
@@ -26,7 +26,15 @@
26
26
  <div class="table-row">
27
27
  <div class="table-cell">{{ field }}</div>
28
28
  <div class="table-cell">
29
- {% if field == "attachments" and value is mapping and value|length > 0 %}
29
+ {% set _is_attachment_block = false %}
30
+ {% if value is mapping and value|length > 0 %}
31
+ {% set _first_key = (value.keys() | list)[0] %}
32
+ {% if value[_first_key] is mapping and ('url' in value[_first_key]) %}
33
+ {% set _is_attachment_block = true %}
34
+ {% endif %}
35
+ {% endif %}
36
+
37
+ {% if _is_attachment_block %}
30
38
  <p class="expiration-notice">Note: All download links will expire in 7 days.</p>
31
39
  <div class="table table-hover">
32
40
  <div class="table-row">
@@ -23,6 +23,10 @@ h1 {
23
23
  color: var(--text-color);
24
24
  }
25
25
 
26
+ h2 {
27
+ margin-bottom: 12px;
28
+ }
29
+
26
30
  .container {
27
31
  display: flex;
28
32
  flex-direction: column;
@@ -109,8 +113,8 @@ h1 {
109
113
  width: 100%;
110
114
  border-collapse: separate;
111
115
  border-spacing: 0;
112
- padding-top: 20px;
113
- padding-bottom: 100px;
116
+ padding-top: 0;
117
+ padding-bottom: 14px;
114
118
  font-size: 14px;
115
119
  }
116
120
 
@@ -1,3 +1,4 @@
1
+ # pylint: disable=too-many-lines
1
2
  import time
2
3
  from copy import deepcopy
3
4
  from datetime import datetime, timedelta
@@ -71,6 +72,7 @@ from fides.api.task.graph_task import (
71
72
  filter_by_enabled_actions,
72
73
  get_cached_data_for_erasures,
73
74
  )
75
+ from fides.api.task.manual.manual_task_utils import create_manual_task_artificial_graphs
74
76
  from fides.api.tasks import DatabaseTask, celery_app
75
77
  from fides.api.tasks.scheduled.scheduler import scheduler
76
78
  from fides.api.util.collection_util import Row
@@ -450,6 +452,11 @@ def run_privacy_request(
450
452
  for dataset_config in datasets
451
453
  if not dataset_config.connection_config.disabled
452
454
  ]
455
+
456
+ # Add manual task artificial graphs to dataset graphs
457
+ manual_task_graphs = create_manual_task_artificial_graphs(session)
458
+ dataset_graphs.extend(manual_task_graphs)
459
+
453
460
  dataset_graph = DatasetGraph(*dataset_graphs)
454
461
 
455
462
  # Add success log for dataset configuration
@@ -33,6 +33,10 @@ from fides.api.models.worker_task import ExecutionLogStatus
33
33
  from fides.api.schemas.policy import ActionType
34
34
  from fides.api.task.deprecated_graph_task import format_data_use_map_for_caching
35
35
  from fides.api.task.execute_request_tasks import log_task_queued, queue_request_task
36
+ from fides.api.task.manual.manual_task_utils import (
37
+ ManualTaskAddress,
38
+ create_manual_task_instances_for_privacy_request,
39
+ )
36
40
  from fides.api.util.logger_context_utils import log_context
37
41
 
38
42
 
@@ -85,6 +89,14 @@ def build_access_networkx_digraph(
85
89
  # Connect the end nodes, those that have no downstream dependencies, to the terminator node
86
90
  networkx_graph.add_edge(node, TERMINATOR_ADDRESS)
87
91
 
92
+ manual_nodes = [
93
+ addr
94
+ for addr in traversal_nodes.keys()
95
+ if addr.collection == ManualTaskAddress.MANUAL_DATA_COLLECTION
96
+ ]
97
+ for manual_node in manual_nodes:
98
+ networkx_graph.add_edge(ROOT_COLLECTION_ADDRESS, manual_node)
99
+
88
100
  _add_edge_if_no_nodes(traversal_nodes, networkx_graph)
89
101
  return networkx_graph
90
102
 
@@ -458,6 +470,10 @@ def run_access_request(
458
470
  end_nodes: List[CollectionAddress] = traversal.traverse(
459
471
  traversal_nodes, collect_tasks_fn
460
472
  )
473
+
474
+ # Snapshot manual task field instances for this privacy request
475
+ create_manual_task_instances_for_privacy_request(session, privacy_request)
476
+
461
477
  # Save Access Request Tasks to the database
462
478
  ready_tasks = persist_new_access_request_tasks(
463
479
  session, privacy_request, traversal, traversal_nodes, end_nodes, graph
@@ -29,6 +29,8 @@ from fides.api.task.graph_task import (
29
29
  GraphTask,
30
30
  mark_current_and_downstream_nodes_as_failed,
31
31
  )
32
+ from fides.api.task.manual.manual_task_graph_task import ManualTaskGraphTask
33
+ from fides.api.task.manual.manual_task_utils import ManualTaskAddress
32
34
  from fides.api.task.task_resources import TaskResources
33
35
  from fides.api.tasks import DSR_QUEUE_NAME, DatabaseTask, celery_app
34
36
  from fides.api.util.cache import cache_task_tracking_key
@@ -108,7 +110,14 @@ def create_graph_task(
108
110
  to begin with - this may be unrecoverable and a new Privacy Request should be created.
109
111
  """
110
112
  try:
111
- graph_task: GraphTask = GraphTask(resources)
113
+ collection_address = request_task.request_task_address
114
+
115
+ # Check if this is a manual task address
116
+ graph_task: GraphTask
117
+ if ManualTaskAddress.is_manual_task_address(collection_address):
118
+ graph_task = ManualTaskGraphTask(resources)
119
+ else:
120
+ graph_task = GraphTask(resources)
112
121
 
113
122
  except Exception as exc:
114
123
  logger.debug(
@@ -6,6 +6,7 @@ from loguru import logger
6
6
 
7
7
  from fides.api.graph.config import CollectionAddress, FieldPath
8
8
  from fides.api.graph.graph import DatasetGraph
9
+ from fides.api.task.manual.manual_task_utils import ManualTaskAddress
9
10
  from fides.api.util.collection_util import Row
10
11
 
11
12
 
@@ -37,6 +38,11 @@ def filter_data_categories(
37
38
  if not results:
38
39
  continue
39
40
 
41
+ # Skip manual task data - it doesn't need filtering since it's controlled by field definitions
42
+ if f":{ManualTaskAddress.MANUAL_DATA_COLLECTION}" in node_address:
43
+ filtered_access_results[node_address].extend(results)
44
+ continue
45
+
40
46
  # Results from fides connectors are a special case:
41
47
  # they've already been filtered and stored in a dict keyed by rule key.
42
48
  # So here, we simply find the results corresponding to our current rule
@@ -109,6 +109,7 @@ def retry(
109
109
  method_name,
110
110
  self.execution_node.address,
111
111
  )
112
+ # Log the awaiting processing status and exit without retrying.
112
113
  self.log_awaiting_processing(action_type, ex)
113
114
  # Request Task put in "awaiting_processing" status and exited, awaiting Async Callback
114
115
  return None
File without changes
@@ -0,0 +1,300 @@
1
+ from typing import Any, Dict, List, Optional
2
+
3
+ from loguru import logger
4
+ from sqlalchemy.orm import Session
5
+
6
+ from fides.api.common_exceptions import AwaitingAsyncTaskCallback
7
+ from fides.api.models.attachment import AttachmentType
8
+ from fides.api.models.manual_task import (
9
+ ManualTask,
10
+ ManualTaskConfigurationType,
11
+ ManualTaskEntityType,
12
+ ManualTaskFieldType,
13
+ ManualTaskInstance,
14
+ StatusType,
15
+ )
16
+ from fides.api.models.privacy_request import PrivacyRequest
17
+ from fides.api.schemas.policy import ActionType
18
+ from fides.api.schemas.privacy_request import PrivacyRequestStatus
19
+ from fides.api.task.graph_task import GraphTask, retry
20
+ from fides.api.task.manual.manual_task_utils import (
21
+ ManualTaskAddress,
22
+ get_manual_tasks_for_connection_config,
23
+ )
24
+ from fides.api.util.collection_util import Row
25
+
26
+
27
+ class ManualTaskGraphTask(GraphTask):
28
+ """GraphTask implementation for ManualTask execution"""
29
+
30
+ @retry(action_type=ActionType.access, default_return=[])
31
+ def access_request(self, *inputs: List[Row]) -> List[Row]:
32
+ """
33
+ Execute manual task logic following the standard GraphTask pattern:
34
+ 1. Create ManualTaskInstances if they don't exist
35
+ 2. Check for submissions
36
+ 3. Return data if submitted, raise AwaitingAsyncTaskCallback if not
37
+ """
38
+ db = self.resources.session
39
+ collection_address = self.execution_node.address
40
+
41
+ # Verify this is a manual task address
42
+ if not ManualTaskAddress.is_manual_task_address(collection_address):
43
+ raise ValueError(f"Invalid manual task address: {collection_address}")
44
+
45
+ connection_key = ManualTaskAddress.get_connection_key(collection_address)
46
+
47
+ # Get manual tasks for this connection
48
+ manual_tasks = get_manual_tasks_for_connection_config(db, connection_key)
49
+
50
+ if not manual_tasks:
51
+ return []
52
+
53
+ # Check/create manual task instances for ACCESS configs only
54
+ self._ensure_manual_task_instances(
55
+ db,
56
+ manual_tasks,
57
+ self.resources.request,
58
+ ManualTaskConfigurationType.access_privacy_request,
59
+ )
60
+
61
+ # Check if all manual task instances have submissions for ACCESS configs only
62
+ submitted_data = self._get_submitted_data(
63
+ db,
64
+ manual_tasks,
65
+ self.resources.request,
66
+ ManualTaskConfigurationType.access_privacy_request,
67
+ )
68
+
69
+ if submitted_data is not None:
70
+ result: List[Row] = [submitted_data] if submitted_data else []
71
+ self.request_task.access_data = result
72
+
73
+ # Mark request task as complete and write execution log
74
+ self.log_end(ActionType.access)
75
+ return result
76
+
77
+ # Set privacy request status to requires_input if not already set
78
+ if self.resources.request.status != PrivacyRequestStatus.requires_input:
79
+ self.resources.request.status = PrivacyRequestStatus.requires_input
80
+ self.resources.request.save(db)
81
+
82
+ # This should trigger log_awaiting_processing via the @retry decorator
83
+ raise AwaitingAsyncTaskCallback(
84
+ f"Manual task for {connection_key} requires user input"
85
+ )
86
+
87
+ def _ensure_manual_task_instances(
88
+ self,
89
+ db: Session,
90
+ manual_tasks: List[ManualTask],
91
+ privacy_request: PrivacyRequest,
92
+ allowed_config_type: "ManualTaskConfigurationType",
93
+ ) -> None:
94
+ """Create ManualTaskInstances for configs matching `allowed_config_type` if they don't exist."""
95
+
96
+ for manual_task in manual_tasks:
97
+ # ------------------------------------------------------------------
98
+ # Short-circuit: if instances already exist for this task & entity
99
+ # (no matter what config version they were created for) we should reuse
100
+ # them instead of creating a brand-new one that would result in
101
+ # duplicates when configurations are versioned after the privacy
102
+ # request has started.
103
+ # ------------------------------------------------------------------
104
+ existing_task_instance = (
105
+ db.query(ManualTaskInstance)
106
+ .filter(
107
+ ManualTaskInstance.task_id == manual_task.id,
108
+ ManualTaskInstance.entity_id == privacy_request.id,
109
+ ManualTaskInstance.entity_type
110
+ == ManualTaskEntityType.privacy_request,
111
+ )
112
+ .first()
113
+ )
114
+ if existing_task_instance:
115
+ # An instance already exists for this privacy request – no need
116
+ # to create another one tied to a newer config version.
117
+ continue
118
+
119
+ # Check each active config for instances (now we know none exist yet)
120
+ for config in manual_task.configs:
121
+ if not config.is_current or config.config_type != allowed_config_type:
122
+ # Skip configs that are not current or not relevant for this request type
123
+ continue
124
+
125
+ ManualTaskInstance.create(
126
+ db=db,
127
+ data={
128
+ "task_id": manual_task.id,
129
+ "config_id": config.id,
130
+ "entity_id": privacy_request.id,
131
+ "entity_type": ManualTaskEntityType.privacy_request.value,
132
+ "status": StatusType.pending.value,
133
+ },
134
+ )
135
+
136
+ # pylint: disable=too-many-branches,too-many-nested-blocks
137
+ def _get_submitted_data(
138
+ self,
139
+ db: Session,
140
+ manual_tasks: List[ManualTask],
141
+ privacy_request: PrivacyRequest,
142
+ allowed_config_type: "ManualTaskConfigurationType",
143
+ ) -> Optional[Dict[str, Any]]:
144
+ """
145
+ Check if all manual task instances have submissions for ALL fields and return aggregated data
146
+ Returns None if any field submissions are missing (all fields must be completed or skipped)
147
+ """
148
+ aggregated_data: Dict[str, Any] = {}
149
+
150
+ def _format_size(size_bytes: int) -> str:
151
+ units = ["B", "KB", "MB", "GB", "TB"]
152
+ size = float(size_bytes)
153
+ for unit in units:
154
+ if size < 1024.0:
155
+ return f"{size:.1f} {unit}"
156
+ size /= 1024.0
157
+ return f"{size:.1f} PB"
158
+
159
+ for manual_task in manual_tasks:
160
+
161
+ candidate_instances: list[ManualTaskInstance] = (
162
+ db.query(ManualTaskInstance)
163
+ .filter(
164
+ ManualTaskInstance.task_id == manual_task.id,
165
+ ManualTaskInstance.entity_id == privacy_request.id,
166
+ ManualTaskInstance.entity_type
167
+ == ManualTaskEntityType.privacy_request,
168
+ )
169
+ .all()
170
+ )
171
+
172
+ if not candidate_instances:
173
+ return None # No instance yet for this manual task
174
+
175
+ for inst in candidate_instances:
176
+ # Skip instances tied to other request types
177
+ if not inst.config or inst.config.config_type != allowed_config_type:
178
+ continue
179
+
180
+ all_fields = inst.config.field_definitions or []
181
+
182
+ # Every field must have a submission
183
+ if not all(inst.get_submission_for_field(f.id) for f in all_fields):
184
+ return None # At least one instance still incomplete
185
+
186
+ # Ensure status set
187
+ if inst.status != StatusType.completed:
188
+ inst.status = StatusType.completed
189
+ inst.save(db)
190
+
191
+ # Aggregate submission data from this instance
192
+ for submission in inst.submissions:
193
+ if not submission.field or not submission.field.field_key:
194
+ continue
195
+
196
+ field_key = submission.field.field_key
197
+
198
+ if not isinstance(submission.data, dict):
199
+ continue
200
+
201
+ data_dict: Dict[str, Any] = submission.data
202
+
203
+ field_type = data_dict.get("field_type")
204
+
205
+ if field_type == ManualTaskFieldType.attachment.value:
206
+ attachment_map: Dict[str, Dict[str, Any]] = {}
207
+ for attachment in submission.attachments or []:
208
+ if (
209
+ attachment.attachment_type
210
+ == AttachmentType.include_with_access_package
211
+ ):
212
+ try:
213
+ size, url = attachment.retrieve_attachment()
214
+ attachment_map[attachment.file_name] = {
215
+ "url": str(url) if url else None,
216
+ "size": (
217
+ _format_size(size) if size else "Unknown"
218
+ ),
219
+ }
220
+ except (
221
+ Exception
222
+ ) as exc: # pylint: disable=broad-exception-caught
223
+ logger.warning(
224
+ "Error retrieving attachment {}: {}",
225
+ attachment.file_name,
226
+ str(exc),
227
+ )
228
+
229
+ aggregated_data[field_key] = attachment_map or None
230
+ else:
231
+ aggregated_data[field_key] = data_dict.get("value")
232
+
233
+ return aggregated_data if aggregated_data else None
234
+
235
+ def dry_run_task(self) -> int:
236
+ """Return estimated row count for dry run - manual tasks don't have predictable counts"""
237
+ return 1 # Placeholder - manual tasks generate variable data
238
+
239
+ # NEW METHOD: Provide erasure support for manual tasks
240
+ @retry(action_type=ActionType.erasure, default_return=0)
241
+ def erasure_request(
242
+ self,
243
+ retrieved_data: List[Row],
244
+ *erasure_prereqs: int, # noqa: D401, pylint: disable=unused-argument
245
+ ) -> int:
246
+ """Execute manual-task-driven erasure logic.
247
+
248
+ Mirrors access_request behaviour but returns the number of rows masked (always 0)
249
+ once all required manual task submissions are present. If submissions are
250
+ incomplete the privacy request is paused awaiting user input.
251
+ """
252
+ db = self.resources.session
253
+ collection_address = self.execution_node.address
254
+
255
+ # Validate manual task address
256
+ if not ManualTaskAddress.is_manual_task_address(collection_address):
257
+ raise ValueError(f"Invalid manual task address: {collection_address}")
258
+
259
+ connection_key = ManualTaskAddress.get_connection_key(collection_address)
260
+
261
+ # Fetch relevant manual tasks for this connection
262
+ manual_tasks = get_manual_tasks_for_connection_config(db, connection_key)
263
+ if not manual_tasks:
264
+ # No manual tasks defined – nothing to erase
265
+ self.log_end(ActionType.erasure)
266
+ return 0
267
+
268
+ # Create ManualTaskInstances for ERASURE configs only
269
+ self._ensure_manual_task_instances(
270
+ db,
271
+ manual_tasks,
272
+ self.resources.request,
273
+ ManualTaskConfigurationType.erasure_privacy_request,
274
+ )
275
+
276
+ # Check for full submissions – reuse helper used by access flow, filtering ERASURE configs
277
+ submissions_complete = self._get_submitted_data(
278
+ db,
279
+ manual_tasks,
280
+ self.resources.request,
281
+ ManualTaskConfigurationType.erasure_privacy_request,
282
+ )
283
+
284
+ # If any field submissions are missing, pause processing
285
+ if submissions_complete is None:
286
+ if self.resources.request.status != PrivacyRequestStatus.requires_input:
287
+ self.resources.request.status = PrivacyRequestStatus.requires_input
288
+ self.resources.request.save(db)
289
+ raise AwaitingAsyncTaskCallback(
290
+ f"Manual erasure task for {connection_key} requires user input"
291
+ )
292
+
293
+ # Mark rows_masked = 0 (manual tasks do not mask data directly)
294
+ if self.request_task.id:
295
+ # Storing result for DSR 3.0; SQLAlchemy column typing triggers mypy warning
296
+ self.request_task.rows_masked = 0 # type: ignore[assignment]
297
+
298
+ # Mark successful completion
299
+ self.log_end(ActionType.erasure)
300
+ return 0