ethyca-fides 2.67.2b2__py2.py3-none-any.whl → 2.67.2rc0__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 (107) hide show
  1. {ethyca_fides-2.67.2b2.dist-info → ethyca_fides-2.67.2rc0.dist-info}/METADATA +2 -2
  2. {ethyca_fides-2.67.2b2.dist-info → ethyca_fides-2.67.2rc0.dist-info}/RECORD +107 -107
  3. fides/_version.py +3 -3
  4. fides/api/db/crud.py +41 -24
  5. fides/api/graph/traversal.py +1 -1
  6. fides/api/main.py +1 -2
  7. fides/api/models/detection_discovery/core.py +20 -33
  8. fides/api/models/manual_task/manual_task.py +6 -3
  9. fides/api/models/privacy_request/privacy_request.py +0 -78
  10. fides/api/service/privacy_request/dsr_package/dsr_report_builder.py +19 -3
  11. fides/api/task/create_request_tasks.py +3 -5
  12. fides/api/task/filter_results.py +1 -1
  13. fides/api/task/manual/manual_task_address.py +2 -4
  14. fides/api/task/manual/manual_task_graph_task.py +83 -87
  15. fides/api/task/manual/manual_task_utils.py +201 -84
  16. fides/api/tasks/storage.py +0 -3
  17. fides/api/util/aws_util.py +13 -1
  18. fides/api/util/storage_util.py +0 -19
  19. fides/ui-build/static/admin/404.html +1 -1
  20. fides/ui-build/static/admin/_next/static/{gqCWjlCr8J6bJ_5t4lTGZ → Y7C7V1jdXK7rWXDIdtD13}/_buildManifest.js +1 -1
  21. fides/ui-build/static/admin/_next/static/chunks/pages/integrations/{[id]-330475705adbd36f.js → [id]-e0a755c69081fffa.js} +1 -1
  22. fides/ui-build/static/admin/add-systems/manual.html +1 -1
  23. fides/ui-build/static/admin/add-systems/multiple.html +1 -1
  24. fides/ui-build/static/admin/add-systems.html +1 -1
  25. fides/ui-build/static/admin/consent/configure/add-vendors.html +1 -1
  26. fides/ui-build/static/admin/consent/configure.html +1 -1
  27. fides/ui-build/static/admin/consent/privacy-experience/[id].html +1 -1
  28. fides/ui-build/static/admin/consent/privacy-experience/new.html +1 -1
  29. fides/ui-build/static/admin/consent/privacy-experience.html +1 -1
  30. fides/ui-build/static/admin/consent/privacy-notices/[id].html +1 -1
  31. fides/ui-build/static/admin/consent/privacy-notices/new.html +1 -1
  32. fides/ui-build/static/admin/consent/privacy-notices.html +1 -1
  33. fides/ui-build/static/admin/consent/properties.html +1 -1
  34. fides/ui-build/static/admin/consent/reporting.html +1 -1
  35. fides/ui-build/static/admin/consent.html +1 -1
  36. fides/ui-build/static/admin/data-catalog/[systemId]/projects/[projectUrn]/[resourceUrn].html +1 -1
  37. fides/ui-build/static/admin/data-catalog/[systemId]/projects/[projectUrn].html +1 -1
  38. fides/ui-build/static/admin/data-catalog/[systemId]/projects.html +1 -1
  39. fides/ui-build/static/admin/data-catalog/[systemId]/resources/[resourceUrn].html +1 -1
  40. fides/ui-build/static/admin/data-catalog/[systemId]/resources.html +1 -1
  41. fides/ui-build/static/admin/data-catalog.html +1 -1
  42. fides/ui-build/static/admin/data-discovery/action-center/[monitorId]/[systemId].html +1 -1
  43. fides/ui-build/static/admin/data-discovery/action-center/[monitorId].html +1 -1
  44. fides/ui-build/static/admin/data-discovery/action-center.html +1 -1
  45. fides/ui-build/static/admin/data-discovery/activity.html +1 -1
  46. fides/ui-build/static/admin/data-discovery/detection/[resourceUrn].html +1 -1
  47. fides/ui-build/static/admin/data-discovery/detection.html +1 -1
  48. fides/ui-build/static/admin/data-discovery/discovery/[resourceUrn].html +1 -1
  49. fides/ui-build/static/admin/data-discovery/discovery.html +1 -1
  50. fides/ui-build/static/admin/datamap.html +1 -1
  51. fides/ui-build/static/admin/dataset/[datasetId]/[collectionName]/[...subfieldNames].html +1 -1
  52. fides/ui-build/static/admin/dataset/[datasetId]/[collectionName].html +1 -1
  53. fides/ui-build/static/admin/dataset/[datasetId].html +1 -1
  54. fides/ui-build/static/admin/dataset/new.html +1 -1
  55. fides/ui-build/static/admin/dataset.html +1 -1
  56. fides/ui-build/static/admin/datastore-connection/[id].html +1 -1
  57. fides/ui-build/static/admin/datastore-connection/new.html +1 -1
  58. fides/ui-build/static/admin/datastore-connection.html +1 -1
  59. fides/ui-build/static/admin/index.html +1 -1
  60. fides/ui-build/static/admin/integrations/[id].html +1 -1
  61. fides/ui-build/static/admin/integrations.html +1 -1
  62. fides/ui-build/static/admin/lib/fides-preview.js +1 -1
  63. fides/ui-build/static/admin/login/[provider].html +1 -1
  64. fides/ui-build/static/admin/login.html +1 -1
  65. fides/ui-build/static/admin/messaging/[id].html +1 -1
  66. fides/ui-build/static/admin/messaging/add-template.html +1 -1
  67. fides/ui-build/static/admin/messaging.html +1 -1
  68. fides/ui-build/static/admin/poc/ant-components.html +1 -1
  69. fides/ui-build/static/admin/poc/form-experiments/AntForm.html +1 -1
  70. fides/ui-build/static/admin/poc/form-experiments/FormikAntFormItem.html +1 -1
  71. fides/ui-build/static/admin/poc/form-experiments/FormikControlled.html +1 -1
  72. fides/ui-build/static/admin/poc/form-experiments/FormikField.html +1 -1
  73. fides/ui-build/static/admin/poc/form-experiments/FormikSpreadField.html +1 -1
  74. fides/ui-build/static/admin/poc/forms.html +1 -1
  75. fides/ui-build/static/admin/poc/table-migration.html +1 -1
  76. fides/ui-build/static/admin/privacy-requests/[id].html +1 -1
  77. fides/ui-build/static/admin/privacy-requests/configure/messaging.html +1 -1
  78. fides/ui-build/static/admin/privacy-requests/configure/storage.html +1 -1
  79. fides/ui-build/static/admin/privacy-requests/configure.html +1 -1
  80. fides/ui-build/static/admin/privacy-requests.html +1 -1
  81. fides/ui-build/static/admin/properties/[id].html +1 -1
  82. fides/ui-build/static/admin/properties/add-property.html +1 -1
  83. fides/ui-build/static/admin/properties.html +1 -1
  84. fides/ui-build/static/admin/reporting/datamap.html +1 -1
  85. fides/ui-build/static/admin/settings/about/alpha.html +1 -1
  86. fides/ui-build/static/admin/settings/about.html +1 -1
  87. fides/ui-build/static/admin/settings/consent/[configuration_id]/[purpose_id].html +1 -1
  88. fides/ui-build/static/admin/settings/consent.html +1 -1
  89. fides/ui-build/static/admin/settings/custom-fields.html +1 -1
  90. fides/ui-build/static/admin/settings/domain-records.html +1 -1
  91. fides/ui-build/static/admin/settings/domains.html +1 -1
  92. fides/ui-build/static/admin/settings/email-templates.html +1 -1
  93. fides/ui-build/static/admin/settings/locations.html +1 -1
  94. fides/ui-build/static/admin/settings/organization.html +1 -1
  95. fides/ui-build/static/admin/settings/regulations.html +1 -1
  96. fides/ui-build/static/admin/systems/configure/[id]/test-datasets.html +1 -1
  97. fides/ui-build/static/admin/systems/configure/[id].html +1 -1
  98. fides/ui-build/static/admin/systems.html +1 -1
  99. fides/ui-build/static/admin/taxonomy.html +1 -1
  100. fides/ui-build/static/admin/user-management/new.html +1 -1
  101. fides/ui-build/static/admin/user-management/profile/[id].html +1 -1
  102. fides/ui-build/static/admin/user-management.html +1 -1
  103. {ethyca_fides-2.67.2b2.dist-info → ethyca_fides-2.67.2rc0.dist-info}/WHEEL +0 -0
  104. {ethyca_fides-2.67.2b2.dist-info → ethyca_fides-2.67.2rc0.dist-info}/entry_points.txt +0 -0
  105. {ethyca_fides-2.67.2b2.dist-info → ethyca_fides-2.67.2rc0.dist-info}/licenses/LICENSE +0 -0
  106. {ethyca_fides-2.67.2b2.dist-info → ethyca_fides-2.67.2rc0.dist-info}/top_level.txt +0 -0
  107. /fides/ui-build/static/admin/_next/static/{gqCWjlCr8J6bJ_5t4lTGZ → Y7C7V1jdXK7rWXDIdtD13}/_ssgManifest.js +0 -0
@@ -383,13 +383,15 @@ class ManualTaskInstance(Base):
383
383
 
384
384
  @property
385
385
  def incomplete_fields(self) -> list["ManualTaskConfigField"]:
386
- """Get all fields that have no submission.
386
+ """Get all fields that haven't been completed yet.
387
+ A field is considered incomplete if:
388
+ 1. It's required and has no submission
387
389
  Returns:
388
390
  list[ManualTaskConfigField]: List of incomplete fields
389
391
  """
390
392
  return [
391
393
  field
392
- for field in self.config.field_definitions
394
+ for field in self.required_fields
393
395
  if not self.get_submission_for_field(field.id)
394
396
  ]
395
397
 
@@ -399,7 +401,8 @@ class ManualTaskInstance(Base):
399
401
  return [
400
402
  field
401
403
  for field in self.config.field_definitions
402
- if self.get_submission_for_field(field.id)
404
+ if field.field_metadata.get("required", False)
405
+ and self.get_submission_for_field(field.id)
403
406
  ]
404
407
 
405
408
  def get_submission_for_field(
@@ -47,16 +47,8 @@ from fides.api.models.attachment import (
47
47
  from fides.api.models.audit_log import AuditLog
48
48
  from fides.api.models.client import ClientDetail
49
49
  from fides.api.models.comment import Comment, CommentReference, CommentReferenceType
50
- from fides.api.models.connectionconfig import ConnectionConfig
51
50
  from fides.api.models.fides_user import FidesUser
52
51
  from fides.api.models.field_types import EncryptedLargeDataDescriptor
53
- from fides.api.models.manual_task import (
54
- ManualTask,
55
- ManualTaskConfig,
56
- ManualTaskConfigurationType,
57
- ManualTaskEntityType,
58
- ManualTaskInstance,
59
- )
60
52
  from fides.api.models.manual_webhook import AccessManualWebhook
61
53
  from fides.api.models.masking_secret import MaskingSecret
62
54
  from fides.api.models.policy import (
@@ -208,14 +200,6 @@ class PrivacyRequest(
208
200
  viewonly=True,
209
201
  uselist=True,
210
202
  )
211
- manual_task_instances = relationship(
212
- "ManualTaskInstance",
213
- lazy="select",
214
- passive_deletes="all",
215
- primaryjoin="and_(ManualTaskInstance.entity_id==foreign(PrivacyRequest.id), "
216
- "ManualTaskInstance.entity_type=='privacy_request')",
217
- uselist=True,
218
- )
219
203
  property_id = Column(String, nullable=True)
220
204
 
221
205
  cancel_reason = Column(String(200))
@@ -1191,68 +1175,6 @@ class PrivacyRequest(
1191
1175
  db, manual_webhook_id, "erasure_manual_webhook"
1192
1176
  )
1193
1177
 
1194
- def create_manual_task_instances(
1195
- self, db: Session, connection_configs_with_manual_tasks: list[ConnectionConfig]
1196
- ) -> list[ManualTaskInstance]:
1197
- """Create ManualTaskInstance entries for all active manual tasks relevant to a privacy request."""
1198
- # Early return if no relevant policy rules
1199
- policy_rules = {
1200
- ActionType.access: bool(
1201
- self.policy.get_rules_for_action(action_type=ActionType.access)
1202
- ),
1203
- ActionType.erasure: bool(
1204
- self.policy.get_rules_for_action(action_type=ActionType.erasure)
1205
- ),
1206
- }
1207
-
1208
- if not any(policy_rules.values()):
1209
- return []
1210
-
1211
- # Build configuration types using list comprehension
1212
- config_types = [
1213
- (
1214
- ManualTaskConfigurationType.access_privacy_request
1215
- if action_type == ActionType.access
1216
- else ManualTaskConfigurationType.erasure_privacy_request
1217
- )
1218
- for action_type, has_rules in policy_rules.items()
1219
- if has_rules
1220
- ]
1221
-
1222
- # Get all relevant manual tasks and configs in one query
1223
- connection_config_ids = [cc.id for cc in connection_configs_with_manual_tasks]
1224
- manual_tasks_with_configs = (
1225
- db.query(ManualTask, ManualTaskConfig)
1226
- .join(ManualTaskConfig, ManualTask.id == ManualTaskConfig.task_id)
1227
- .filter(
1228
- ManualTask.parent_entity_id.in_(connection_config_ids),
1229
- ManualTask.parent_entity_type == "connection_config",
1230
- ManualTaskConfig.is_current.is_(True),
1231
- ManualTaskConfig.config_type.in_(config_types),
1232
- )
1233
- .all()
1234
- )
1235
-
1236
- # Get existing config IDs to avoid duplicates
1237
- existing_config_ids = {
1238
- instance.config_id for instance in self.manual_task_instances
1239
- }
1240
-
1241
- # Create instances using list comprehension and filter out existing ones
1242
- return [
1243
- ManualTaskInstance.create(
1244
- db=db,
1245
- data={
1246
- "entity_id": self.id,
1247
- "entity_type": ManualTaskEntityType.privacy_request,
1248
- "task_id": manual_task.id,
1249
- "config_id": config.id,
1250
- },
1251
- )
1252
- for manual_task, config in manual_tasks_with_configs
1253
- if config.id not in existing_config_ids
1254
- ]
1255
-
1256
1178
  def get_existing_request_task(
1257
1179
  self,
1258
1180
  db: Session,
@@ -13,7 +13,7 @@ from loguru import logger
13
13
 
14
14
  from fides.api.models.privacy_request import PrivacyRequest
15
15
  from fides.api.schemas.policy import ActionType
16
- from fides.api.util.storage_util import StorageJSONEncoder, format_size
16
+ from fides.api.util.storage_util import StorageJSONEncoder
17
17
 
18
18
  DSR_DIRECTORY = Path(__file__).parent.resolve()
19
19
 
@@ -204,7 +204,7 @@ class DsrReportBuilder:
204
204
 
205
205
  file_size = attachment.get("file_size")
206
206
  if isinstance(file_size, (int, float)):
207
- file_size = format_size(float(file_size))
207
+ file_size = self._format_size(float(file_size))
208
208
  else:
209
209
  file_size = "Unknown"
210
210
 
@@ -321,6 +321,22 @@ class DsrReportBuilder:
321
321
 
322
322
  return datasets
323
323
 
324
+ def _format_size(self, size_bytes: float) -> str:
325
+ """
326
+ Format size in bytes to human readable format.
327
+
328
+ Args:
329
+ size_bytes: Size in bytes
330
+
331
+ Returns:
332
+ Formatted string with appropriate unit (B, KB, MB, GB)
333
+ """
334
+ for unit in ["B", "KB", "MB", "GB"]:
335
+ if size_bytes < 1024.0:
336
+ return f"{size_bytes:.1f} {unit}"
337
+ size_bytes /= 1024.0
338
+ return f"{size_bytes:.1f} TB"
339
+
324
340
  def generate(self) -> BytesIO:
325
341
  """
326
342
  Processes the request and DSR data to build zip file containing the DSR report.
@@ -379,7 +395,7 @@ class DsrReportBuilder:
379
395
 
380
396
  # Calculate time taken and file size
381
397
  time_taken = time_module.time() - start_time
382
- file_size = format_size(float(len(self.baos.getvalue())))
398
+ file_size = self._format_size(float(len(self.baos.getvalue())))
383
399
 
384
400
  logger.bind(time_to_generate=time_taken, dsr_package_size=file_size).info(
385
401
  "DSR report generation complete."
@@ -35,7 +35,7 @@ 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
36
  from fides.api.task.manual.manual_task_address import ManualTaskAddress
37
37
  from fides.api.task.manual.manual_task_utils import (
38
- get_connection_configs_with_manual_tasks,
38
+ create_manual_task_instances_for_privacy_request,
39
39
  )
40
40
  from fides.api.util.logger_context_utils import log_context
41
41
 
@@ -92,7 +92,7 @@ def build_access_networkx_digraph(
92
92
  manual_nodes = [
93
93
  addr
94
94
  for addr in traversal_nodes.keys()
95
- if ManualTaskAddress.is_manual_task_address(addr)
95
+ if addr.collection == ManualTaskAddress.MANUAL_DATA_COLLECTION
96
96
  ]
97
97
  for manual_node in manual_nodes:
98
98
  networkx_graph.add_edge(ROOT_COLLECTION_ADDRESS, manual_node)
@@ -472,9 +472,7 @@ def run_access_request(
472
472
  )
473
473
 
474
474
  # Snapshot manual task field instances for this privacy request
475
- privacy_request.create_manual_task_instances(
476
- session, get_connection_configs_with_manual_tasks(session)
477
- )
475
+ create_manual_task_instances_for_privacy_request(session, privacy_request)
478
476
 
479
477
  # Save Access Request Tasks to the database
480
478
  ready_tasks = persist_new_access_request_tasks(
@@ -39,7 +39,7 @@ def filter_data_categories(
39
39
  continue
40
40
 
41
41
  # Skip manual task data - it doesn't need filtering since it's controlled by field definitions
42
- if ManualTaskAddress.is_manual_task_address(node_address):
42
+ if f":{ManualTaskAddress.MANUAL_DATA_COLLECTION}" in node_address:
43
43
  filtered_access_results[node_address].extend(results)
44
44
  continue
45
45
 
@@ -1,5 +1,3 @@
1
- from typing import Union
2
-
3
1
  from fides.api.graph.config import CollectionAddress
4
2
 
5
3
 
@@ -22,7 +20,7 @@ class ManualTaskAddress:
22
20
  return collection_name == ManualTaskAddress.MANUAL_DATA_COLLECTION
23
21
 
24
22
  @staticmethod
25
- def is_manual_task_address(address: Union[str, CollectionAddress]) -> bool:
23
+ def is_manual_task_address(address: CollectionAddress) -> bool:
26
24
  """Check if address represents manual task data"""
27
25
  if isinstance(address, str):
28
26
  # Handle string format "connection_key:collection_name"
@@ -35,7 +33,7 @@ class ManualTaskAddress:
35
33
  return ManualTaskAddress._is_manual_data_collection(address.collection)
36
34
 
37
35
  @staticmethod
38
- def get_connection_key(address: Union[str, CollectionAddress]) -> str:
36
+ def get_connection_key(address: CollectionAddress) -> str:
39
37
  """Extract connection config key from manual task address"""
40
38
  if not ManualTaskAddress.is_manual_task_address(address):
41
39
  raise ValueError(f"Not a manual task address: {address}")
@@ -7,11 +7,11 @@ from fides.api.common_exceptions import AwaitingAsyncTaskCallback
7
7
  from fides.api.models.attachment import AttachmentType
8
8
  from fides.api.models.manual_task import (
9
9
  ManualTask,
10
+ ManualTaskConfig,
10
11
  ManualTaskConfigurationType,
11
12
  ManualTaskEntityType,
12
13
  ManualTaskFieldType,
13
14
  ManualTaskInstance,
14
- ManualTaskSubmission,
15
15
  StatusType,
16
16
  )
17
17
  from fides.api.models.privacy_request import PrivacyRequest
@@ -23,7 +23,6 @@ from fides.api.task.manual.manual_task_utils import (
23
23
  get_manual_task_for_connection_config,
24
24
  )
25
25
  from fides.api.util.collection_util import Row
26
- from fides.api.util.storage_util import format_size
27
26
 
28
27
 
29
28
  class ManualTaskGraphTask(GraphTask):
@@ -123,36 +122,29 @@ class ManualTaskGraphTask(GraphTask):
123
122
  # request has started, while allowing different config types (access vs erasure)
124
123
  # to have separate instances.
125
124
  # ------------------------------------------------------------------
126
- existing_task_instance = next(
127
- (
128
- instance
129
- for instance in privacy_request.manual_task_instances
130
- if instance.task_id == manual_task.id
131
- and instance.config.config_type == allowed_config_type
132
- ),
133
- None,
125
+ existing_task_instance = (
126
+ db.query(ManualTaskInstance)
127
+ .join(ManualTaskInstance.config) # Join to access config information
128
+ .filter(
129
+ ManualTaskInstance.task_id == manual_task.id,
130
+ ManualTaskInstance.entity_id == privacy_request.id,
131
+ ManualTaskInstance.entity_type == ManualTaskEntityType.privacy_request,
132
+ # Only check for instances of the same config type
133
+ ManualTaskConfig.config_type == allowed_config_type,
134
+ )
135
+ .first()
134
136
  )
135
137
  if existing_task_instance:
136
138
  # An instance already exists for this privacy request and config type – no need
137
139
  # to create another one tied to a newer config version.
138
140
  return
139
141
 
140
- # If no existing instances, create a new one for the current config
141
- # There will only be one config of each type per manual task
142
- config = next(
143
- (
144
- config
145
- for config in sorted(
146
- manual_task.configs,
147
- key=lambda c: c.version if hasattr(c, "version") else 0,
148
- reverse=True,
149
- )
150
- if config.is_current and config.config_type == allowed_config_type
151
- ),
152
- None,
153
- )
142
+ # Check each active config for instances (now we know none exist yet for this config type)
143
+ for config in manual_task.configs:
144
+ if not config.is_current or config.config_type != allowed_config_type:
145
+ # Skip configs that are not current or not relevant for this request type
146
+ continue
154
147
 
155
- if config:
156
148
  ManualTaskInstance.create(
157
149
  db=db,
158
150
  data={
@@ -164,6 +156,7 @@ class ManualTaskGraphTask(GraphTask):
164
156
  },
165
157
  )
166
158
 
159
+ # pylint: disable=too-many-branches,too-many-nested-blocks
167
160
  def _get_submitted_data(
168
161
  self,
169
162
  db: Session,
@@ -175,90 +168,93 @@ class ManualTaskGraphTask(GraphTask):
175
168
  Check if all manual task instances have submissions for ALL fields and return aggregated data
176
169
  Returns None if any field submissions are missing (all fields must be completed or skipped)
177
170
  """
178
- candidate_instances: list[ManualTaskInstance] = [
179
- instance
180
- for instance in privacy_request.manual_task_instances
181
- if instance.task_id == manual_task.id
182
- and instance.config.config_type == allowed_config_type
183
- ]
171
+ aggregated_data: dict[str, Any] = {}
172
+
173
+ def _format_size(size_bytes: int) -> str:
174
+ units = ["B", "KB", "MB", "GB", "TB"]
175
+ size = float(size_bytes)
176
+ for unit in units:
177
+ if size < 1024.0:
178
+ return f"{size:.1f} {unit}"
179
+ size /= 1024.0
180
+ return f"{size:.1f} PB"
181
+
182
+ candidate_instances: list[ManualTaskInstance] = (
183
+ db.query(ManualTaskInstance)
184
+ .filter(
185
+ ManualTaskInstance.task_id == manual_task.id,
186
+ ManualTaskInstance.entity_id == privacy_request.id,
187
+ ManualTaskInstance.entity_type == ManualTaskEntityType.privacy_request,
188
+ )
189
+ .all()
190
+ )
184
191
 
185
192
  if not candidate_instances:
186
193
  return None # No instance yet for this manual task
187
194
 
188
- # Check for incomplete fields and update status in single pass
189
195
  for inst in candidate_instances:
190
- if inst.incomplete_fields:
196
+ # Skip instances tied to other request types
197
+ if not inst.config or inst.config.config_type != allowed_config_type:
198
+ continue
199
+
200
+ all_fields = inst.config.field_definitions or []
201
+
202
+ # Every field must have a submission
203
+ if not all(inst.get_submission_for_field(f.id) for f in all_fields):
191
204
  return None # At least one instance still incomplete
192
205
 
193
- # Update status if needed
206
+ # Ensure status set
194
207
  if inst.status != StatusType.completed:
195
208
  inst.status = StatusType.completed
196
209
  inst.save(db)
197
210
 
198
- # Aggregate submission data from all instances
199
- aggregated_data = self._aggregate_submission_data(candidate_instances)
200
- return aggregated_data or None
201
-
202
- def _aggregate_submission_data(
203
- self, instances: list[ManualTaskInstance]
204
- ) -> dict[str, Any]:
205
- """Aggregate submission data from all instances into a single dictionary."""
206
- aggregated_data: dict[str, Any] = {}
207
-
208
- for inst in instances:
209
- # Filter valid submissions and process them
210
- valid_submissions = (
211
- submission
212
- for submission in inst.submissions
213
- if (
214
- submission.field
215
- and submission.field.field_key
216
- and isinstance(submission.data, dict)
217
- )
218
- )
211
+ # Aggregate submission data from this instance
212
+ for submission in inst.submissions:
213
+ if not submission.field or not submission.field.field_key:
214
+ continue
219
215
 
220
- for submission in valid_submissions:
221
216
  field_key = submission.field.field_key
222
- # We already checked isinstance(submission.data, dict) in valid_submissions
223
- data_dict: dict[str, Any] = submission.data # type: ignore[assignment]
224
- field_type = data_dict.get("field_type")
225
217
 
226
- # Process field data based on type
227
- aggregated_data[field_key] = (
228
- self._process_attachment_field(submission)
229
- if field_type == ManualTaskFieldType.attachment.value
230
- else data_dict.get("value")
231
- )
218
+ if not isinstance(submission.data, dict):
219
+ continue
232
220
 
233
- return aggregated_data
221
+ data_dict: dict[str, Any] = submission.data
234
222
 
235
- def _process_attachment_field(
236
- self, submission: ManualTaskSubmission
237
- ) -> Optional[dict[str, dict[str, Any]]]:
238
- """Process attachment field and return attachment map or None."""
239
- attachment_map: dict[str, dict[str, Any]] = {}
223
+ field_type = data_dict.get("field_type")
240
224
 
241
- for attachment in filter(
242
- lambda a: a.attachment_type == AttachmentType.include_with_access_package,
243
- submission.attachments,
244
- ):
245
- try:
246
- size, url = attachment.retrieve_attachment()
247
- attachment_map[attachment.file_name] = {
248
- "url": str(url) if url else None,
249
- "size": (format_size(size) if size else "Unknown"),
250
- }
251
- except Exception as exc: # pylint: disable=broad-exception-caught
252
- logger.warning(
253
- f"Error retrieving attachment {attachment.file_name}: {str(exc)}"
254
- )
255
- return attachment_map or None
225
+ if field_type == ManualTaskFieldType.attachment.value:
226
+ attachment_map: dict[str, dict[str, Any]] = {}
227
+ for attachment in submission.attachments or []:
228
+ if (
229
+ attachment.attachment_type
230
+ == AttachmentType.include_with_access_package
231
+ ):
232
+ try:
233
+ size, url = attachment.retrieve_attachment()
234
+ attachment_map[attachment.file_name] = {
235
+ "url": str(url) if url else None,
236
+ "size": (_format_size(size) if size else "Unknown"),
237
+ }
238
+ except (
239
+ Exception
240
+ ) as exc: # pylint: disable=broad-exception-caught
241
+ logger.warning(
242
+ "Error retrieving attachment {}: {}",
243
+ attachment.file_name,
244
+ str(exc),
245
+ )
246
+
247
+ aggregated_data[field_key] = attachment_map or None
248
+ else:
249
+ aggregated_data[field_key] = data_dict.get("value")
250
+
251
+ return aggregated_data if aggregated_data else None
256
252
 
257
253
  def dry_run_task(self) -> int:
258
254
  """Return estimated row count for dry run - manual tasks don't have predictable counts"""
259
255
  return 1 # Placeholder - manual tasks generate variable data
260
256
 
261
- # Provide erasure support for manual tasks
257
+ # NEW METHOD: Provide erasure support for manual tasks
262
258
  @retry(action_type=ActionType.erasure, default_return=0)
263
259
  def erasure_request(
264
260
  self,