ethyca-fides 2.64.1b0__py2.py3-none-any.whl → 2.64.1b2__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.
Files changed (106) hide show
  1. {ethyca_fides-2.64.1b0.dist-info → ethyca_fides-2.64.1b2.dist-info}/METADATA +1 -1
  2. {ethyca_fides-2.64.1b0.dist-info → ethyca_fides-2.64.1b2.dist-info}/RECORD +106 -102
  3. fides/_version.py +3 -3
  4. fides/api/alembic/migrations/versions/6a76a1fa4f3f_add_manual_task_instance_table.py +256 -0
  5. fides/api/alembic/migrations/versions/aadfe83c5644_add_manual_task_to_connectiontype_enum.py +46 -0
  6. fides/api/db/base.py +4 -0
  7. fides/api/models/connectionconfig.py +11 -0
  8. fides/api/models/manual_tasks/__init__.py +7 -1
  9. fides/api/models/manual_tasks/manual_task.py +19 -3
  10. fides/api/models/manual_tasks/manual_task_config.py +39 -6
  11. fides/api/models/manual_tasks/manual_task_instance.py +187 -0
  12. fides/api/models/manual_tasks/manual_task_log.py +20 -7
  13. fides/api/schemas/manual_tasks/manual_task_schemas.py +42 -0
  14. fides/api/schemas/manual_tasks/manual_task_status.py +107 -46
  15. fides/api/service/connectors/postgres_connector.py +2 -2
  16. fides/common/api/v1/urn_registry.py +4 -0
  17. fides/service/manual_tasks/manual_task_config_service.py +17 -5
  18. fides/service/manual_tasks/manual_task_instance_service.py +285 -0
  19. fides/service/manual_tasks/manual_task_service.py +66 -10
  20. fides/ui-build/static/admin/404.html +1 -1
  21. fides/ui-build/static/admin/add-systems/manual.html +1 -1
  22. fides/ui-build/static/admin/add-systems/multiple.html +1 -1
  23. fides/ui-build/static/admin/add-systems.html +1 -1
  24. fides/ui-build/static/admin/consent/configure/add-vendors.html +1 -1
  25. fides/ui-build/static/admin/consent/configure.html +1 -1
  26. fides/ui-build/static/admin/consent/privacy-experience/[id].html +1 -1
  27. fides/ui-build/static/admin/consent/privacy-experience/new.html +1 -1
  28. fides/ui-build/static/admin/consent/privacy-experience.html +1 -1
  29. fides/ui-build/static/admin/consent/privacy-notices/[id].html +1 -1
  30. fides/ui-build/static/admin/consent/privacy-notices/new.html +1 -1
  31. fides/ui-build/static/admin/consent/privacy-notices.html +1 -1
  32. fides/ui-build/static/admin/consent/properties.html +1 -1
  33. fides/ui-build/static/admin/consent/reporting.html +1 -1
  34. fides/ui-build/static/admin/consent.html +1 -1
  35. fides/ui-build/static/admin/data-catalog/[systemId]/projects/[projectUrn]/[resourceUrn].html +1 -1
  36. fides/ui-build/static/admin/data-catalog/[systemId]/projects/[projectUrn].html +1 -1
  37. fides/ui-build/static/admin/data-catalog/[systemId]/projects.html +1 -1
  38. fides/ui-build/static/admin/data-catalog/[systemId]/resources/[resourceUrn].html +1 -1
  39. fides/ui-build/static/admin/data-catalog/[systemId]/resources.html +1 -1
  40. fides/ui-build/static/admin/data-catalog.html +1 -1
  41. fides/ui-build/static/admin/data-discovery/action-center/[monitorId]/[systemId].html +1 -1
  42. fides/ui-build/static/admin/data-discovery/action-center/[monitorId].html +1 -1
  43. fides/ui-build/static/admin/data-discovery/action-center.html +1 -1
  44. fides/ui-build/static/admin/data-discovery/activity.html +1 -1
  45. fides/ui-build/static/admin/data-discovery/detection/[resourceUrn].html +1 -1
  46. fides/ui-build/static/admin/data-discovery/detection.html +1 -1
  47. fides/ui-build/static/admin/data-discovery/discovery/[resourceUrn].html +1 -1
  48. fides/ui-build/static/admin/data-discovery/discovery.html +1 -1
  49. fides/ui-build/static/admin/datamap.html +1 -1
  50. fides/ui-build/static/admin/dataset/[datasetId]/[collectionName]/[...subfieldNames].html +1 -1
  51. fides/ui-build/static/admin/dataset/[datasetId]/[collectionName].html +1 -1
  52. fides/ui-build/static/admin/dataset/[datasetId].html +1 -1
  53. fides/ui-build/static/admin/dataset/new.html +1 -1
  54. fides/ui-build/static/admin/dataset.html +1 -1
  55. fides/ui-build/static/admin/datastore-connection/[id].html +1 -1
  56. fides/ui-build/static/admin/datastore-connection/new.html +1 -1
  57. fides/ui-build/static/admin/datastore-connection.html +1 -1
  58. fides/ui-build/static/admin/index.html +1 -1
  59. fides/ui-build/static/admin/integrations/[id].html +1 -1
  60. fides/ui-build/static/admin/integrations.html +1 -1
  61. fides/ui-build/static/admin/login/[provider].html +1 -1
  62. fides/ui-build/static/admin/login.html +1 -1
  63. fides/ui-build/static/admin/messaging/[id].html +1 -1
  64. fides/ui-build/static/admin/messaging/add-template.html +1 -1
  65. fides/ui-build/static/admin/messaging.html +1 -1
  66. fides/ui-build/static/admin/poc/ant-components.html +1 -1
  67. fides/ui-build/static/admin/poc/form-experiments/AntForm.html +1 -1
  68. fides/ui-build/static/admin/poc/form-experiments/FormikAntFormItem.html +1 -1
  69. fides/ui-build/static/admin/poc/form-experiments/FormikControlled.html +1 -1
  70. fides/ui-build/static/admin/poc/form-experiments/FormikField.html +1 -1
  71. fides/ui-build/static/admin/poc/form-experiments/FormikSpreadField.html +1 -1
  72. fides/ui-build/static/admin/poc/forms.html +1 -1
  73. fides/ui-build/static/admin/poc/table-migration.html +1 -1
  74. fides/ui-build/static/admin/privacy-requests/[id].html +1 -1
  75. fides/ui-build/static/admin/privacy-requests/configure/messaging.html +1 -1
  76. fides/ui-build/static/admin/privacy-requests/configure/storage.html +1 -1
  77. fides/ui-build/static/admin/privacy-requests/configure.html +1 -1
  78. fides/ui-build/static/admin/privacy-requests.html +1 -1
  79. fides/ui-build/static/admin/properties/[id].html +1 -1
  80. fides/ui-build/static/admin/properties/add-property.html +1 -1
  81. fides/ui-build/static/admin/properties.html +1 -1
  82. fides/ui-build/static/admin/reporting/datamap.html +1 -1
  83. fides/ui-build/static/admin/settings/about/alpha.html +1 -1
  84. fides/ui-build/static/admin/settings/about.html +1 -1
  85. fides/ui-build/static/admin/settings/consent/[configuration_id]/[purpose_id].html +1 -1
  86. fides/ui-build/static/admin/settings/consent.html +1 -1
  87. fides/ui-build/static/admin/settings/custom-fields.html +1 -1
  88. fides/ui-build/static/admin/settings/domain-records.html +1 -1
  89. fides/ui-build/static/admin/settings/domains.html +1 -1
  90. fides/ui-build/static/admin/settings/email-templates.html +1 -1
  91. fides/ui-build/static/admin/settings/locations.html +1 -1
  92. fides/ui-build/static/admin/settings/organization.html +1 -1
  93. fides/ui-build/static/admin/settings/regulations.html +1 -1
  94. fides/ui-build/static/admin/systems/configure/[id]/test-datasets.html +1 -1
  95. fides/ui-build/static/admin/systems/configure/[id].html +1 -1
  96. fides/ui-build/static/admin/systems.html +1 -1
  97. fides/ui-build/static/admin/taxonomy.html +1 -1
  98. fides/ui-build/static/admin/user-management/new.html +1 -1
  99. fides/ui-build/static/admin/user-management/profile/[id].html +1 -1
  100. fides/ui-build/static/admin/user-management.html +1 -1
  101. {ethyca_fides-2.64.1b0.dist-info → ethyca_fides-2.64.1b2.dist-info}/WHEEL +0 -0
  102. {ethyca_fides-2.64.1b0.dist-info → ethyca_fides-2.64.1b2.dist-info}/entry_points.txt +0 -0
  103. {ethyca_fides-2.64.1b0.dist-info → ethyca_fides-2.64.1b2.dist-info}/licenses/LICENSE +0 -0
  104. {ethyca_fides-2.64.1b0.dist-info → ethyca_fides-2.64.1b2.dist-info}/top_level.txt +0 -0
  105. /fides/ui-build/static/admin/_next/static/{nRQ3pmK_d3F5PJE39rP2h → xYqpgK9yFhQK_wL_F_kAF}/_buildManifest.js +0 -0
  106. /fides/ui-build/static/admin/_next/static/{nRQ3pmK_d3F5PJE39rP2h → xYqpgK9yFhQK_wL_F_kAF}/_ssgManifest.js +0 -0
@@ -1,6 +1,6 @@
1
1
  from datetime import datetime, timezone
2
2
  from enum import Enum as EnumType
3
- from typing import Optional
3
+ from typing import Optional, Protocol
4
4
 
5
5
  from sqlalchemy.orm import Session
6
6
 
@@ -23,61 +23,133 @@ class StatusType(str, EnumType):
23
23
 
24
24
  @classmethod
25
25
  def get_valid_transitions(cls, current_status: "StatusType") -> list["StatusType"]:
26
- """Get valid transitions from the current status.
26
+ """Get valid transitions from the current status."""
27
+ transitions = {
28
+ cls.pending: [cls.in_progress, cls.failed, cls.completed],
29
+ cls.in_progress: [cls.completed, cls.failed],
30
+ cls.completed: [],
31
+ cls.failed: [cls.pending, cls.in_progress],
32
+ }
33
+ return transitions.get(current_status, [])
34
+
35
+
36
+ class StatusTransitionProtocol(Protocol):
37
+ """Protocol for objects that support status transitions.
38
+
39
+ This protocol defines the interface that any object supporting status transitions
40
+ must implement. It includes both the required attributes and methods.
41
+
42
+ Example:
43
+ ```python
44
+ # Any class that implements this protocol can be used interchangeably
45
+ def process_status_update(obj: StatusTransitionProtocol, db: Session) -> None:
46
+ if obj.is_pending:
47
+ obj.start_progress(db)
48
+ elif obj.is_in_progress:
49
+ obj.mark_completed(db, user_id="user123")
50
+
51
+ # This works with ManualTaskInstance or any other class implementing the protocol
52
+ instance = ManualTaskInstance(...)
53
+ process_status_update(instance, db)
54
+ ```
55
+ """
27
56
 
28
- Args:
29
- current_status: The current status
57
+ # Required attributes - using runtime types that work with SQLAlchemy
58
+ status: StatusType
59
+ completed_at: Optional[datetime] # Can be None when resetting to pending
60
+ completed_by_id: Optional[str] # Can be None when resetting to pending
30
61
 
31
- Returns:
32
- list[StatusType]: List of valid transitions
33
- """
34
- if current_status == cls.pending:
35
- return [cls.in_progress, cls.failed, cls.completed]
36
- if current_status == cls.in_progress:
37
- return [cls.completed, cls.failed]
38
- if current_status == cls.completed:
39
- return []
40
- if current_status == cls.failed:
41
- return [cls.pending, cls.in_progress]
42
- return []
62
+ # Required methods
63
+ # pylint does not understand the Protocol abstract syntax and will complain about the ellipsis
64
+ def update_status(
65
+ self, db: Session, new_status: StatusType, user_id: Optional[str] = None
66
+ ) -> None:
67
+ """Update the status with validation and completion handling."""
68
+ ... # pylint: disable=unnecessary-ellipsis
69
+
70
+ def mark_completed(self, db: Session, user_id: str) -> None:
71
+ """Mark as completed."""
72
+ ... # pylint: disable=unnecessary-ellipsis
73
+
74
+ def mark_failed(self, db: Session) -> None:
75
+ """Mark as failed."""
76
+ ... # pylint: disable=unnecessary-ellipsis
77
+
78
+ def start_progress(self, db: Session) -> None:
79
+ """Mark as in progress."""
80
+ ... # pylint: disable=unnecessary-ellipsis
81
+
82
+ def reset_to_pending(self, db: Session) -> None:
83
+ """Reset to pending status."""
84
+ ... # pylint: disable=unnecessary-ellipsis
85
+
86
+ @property
87
+ def is_completed(self) -> bool:
88
+ """Check if completed."""
89
+ ... # pylint: disable=unnecessary-ellipsis
90
+
91
+ @property
92
+ def is_failed(self) -> bool:
93
+ """Check if failed."""
94
+ ... # pylint: disable=unnecessary-ellipsis
95
+
96
+ @property
97
+ def is_in_progress(self) -> bool:
98
+ """Check if in progress."""
99
+ ... # pylint: disable=unnecessary-ellipsis
100
+
101
+ @property
102
+ def is_pending(self) -> bool:
103
+ """Check if pending."""
104
+ ... # pylint: disable=unnecessary-ellipsis
105
+
106
+
107
+ def validate_status_transition_object(obj: StatusTransitionProtocol) -> bool:
108
+ """Validate that an object properly implements the StatusTransitionProtocol.
109
+
110
+ This function demonstrates how the Protocol can be used for runtime validation
111
+ and type checking.
112
+ """
113
+ required_attrs = ["status", "completed_at", "completed_by_id"]
114
+ required_methods = [
115
+ "update_status",
116
+ "mark_completed",
117
+ "mark_failed",
118
+ "start_progress",
119
+ "reset_to_pending",
120
+ ]
121
+ required_properties = ["is_completed", "is_failed", "is_in_progress", "is_pending"]
122
+
123
+ # Check all required elements
124
+ all_required = required_attrs + required_methods + required_properties
125
+ return all(hasattr(obj, attr) for attr in all_required) and all(
126
+ callable(getattr(obj, method)) for method in required_methods
127
+ )
43
128
 
44
129
 
45
130
  class StatusTransitionMixin:
46
131
  """Mixin for handling status transitions.
47
132
 
48
133
  This mixin provides methods for managing status transitions and completion tracking.
49
- It can be used by any model that needs status management.
134
+ It implements the StatusTransitionProtocol and can be used by any model that needs status management.
50
135
  """
51
136
 
52
- # These should be overridden by the implementing class
137
+ # Type annotations to match the Protocol
53
138
  status: StatusType
54
139
  completed_at: Optional[datetime]
55
140
  completed_by_id: Optional[str]
56
141
 
57
142
  def _get_valid_transitions(self) -> list[StatusType]:
58
- """Get valid transitions from the current status.
59
-
60
- Returns:
61
- list[StatusType]: List of valid transitions
62
- """
143
+ """Get valid transitions from the current status."""
63
144
  return StatusType.get_valid_transitions(self.status)
64
145
 
65
146
  def _validate_status_transition(self, new_status: StatusType) -> None:
66
- """Validate that a status transition is allowed.
67
-
68
- Args:
69
- new_status: The new status to transition to
70
-
71
- Raises:
72
- StatusTransitionNotAllowed: If the transition is not allowed
73
- """
74
- # Don't allow transitions to the same status
147
+ """Validate that a status transition is allowed."""
75
148
  if new_status == self.status:
76
149
  raise StatusTransitionNotAllowed(
77
150
  f"Invalid status transition: already in status {new_status}"
78
151
  )
79
152
 
80
- # Get valid transitions for current status
81
153
  valid_transitions = self._get_valid_transitions()
82
154
  if new_status not in valid_transitions:
83
155
  raise StatusTransitionNotAllowed(
@@ -88,13 +160,7 @@ class StatusTransitionMixin:
88
160
  def update_status(
89
161
  self, db: Session, new_status: StatusType, user_id: Optional[str] = None
90
162
  ) -> None:
91
- """Update the status with validation and completion handling.
92
-
93
- Args:
94
- db: Database session
95
- new_status: New status to set
96
- user_id: Optional user ID who is making the change
97
- """
163
+ """Update the status with validation and completion handling."""
98
164
  self._validate_status_transition(new_status)
99
165
 
100
166
  if new_status == StatusType.completed:
@@ -110,12 +176,7 @@ class StatusTransitionMixin:
110
176
  db.commit()
111
177
 
112
178
  def mark_completed(self, db: Session, user_id: str) -> None:
113
- """Mark as completed.
114
-
115
- Args:
116
- db: Database session
117
- user_id: user ID who completed the task
118
- """
179
+ """Mark as completed."""
119
180
  self.update_status(db, StatusType.completed, user_id)
120
181
 
121
182
  def mark_failed(self, db: Session) -> None:
@@ -37,7 +37,7 @@ class PostgreSQLConnector(SQLConnector):
37
37
  netloc = config.host
38
38
  port = f":{config.port}" if config.port else ""
39
39
  dbname = f"/{config.dbname}" if config.dbname else ""
40
- query = f"?sslmode=${config.ssl_mode}" if config.ssl_mode else ""
40
+ query = f"?sslmode={config.ssl_mode}" if config.ssl_mode else ""
41
41
  return f"postgresql://{user_password}{netloc}{port}{dbname}{query}"
42
42
 
43
43
  def build_ssh_uri(self, local_address: tuple) -> str:
@@ -54,7 +54,7 @@ class PostgreSQLConnector(SQLConnector):
54
54
  netloc = local_host
55
55
  port = f":{local_port}" if local_port else ""
56
56
  dbname = f"/{config.dbname}" if config.dbname else ""
57
- query = f"?sslmode=${config.ssl_mode}" if config.ssl_mode else ""
57
+ query = f"?sslmode={config.ssl_mode}" if config.ssl_mode else ""
58
58
  return f"postgresql://{user_password}{netloc}{port}{dbname}{query}"
59
59
 
60
60
  # Overrides SQLConnector.create_client
@@ -155,6 +155,10 @@ AUTHORIZE = "/connection/{connection_key}/authorize"
155
155
  ACCESS_MANUAL_WEBHOOKS = "/access_manual_webhook"
156
156
  ACCESS_MANUAL_WEBHOOK = CONNECTION_BY_KEY + "/access_manual_webhook"
157
157
 
158
+ # Manual Tasks
159
+ MANUAL_TASKS = "/manual-tasks"
160
+ MANUAL_TASK = CONNECTION_BY_KEY + "/manual-task"
161
+
158
162
  # Collection URLs
159
163
  DATASETS = "/dataset"
160
164
  DATASET_CONFIG = "/datasetconfig"
@@ -15,6 +15,14 @@ from fides.api.schemas.manual_tasks.manual_task_config import (
15
15
  from fides.service.manual_tasks.utils import validate_fields, with_task_logging
16
16
 
17
17
 
18
+ class ManualTaskConfigError(Exception):
19
+ """Exception raised when a manual task config error occurs."""
20
+
21
+ def __init__(self, message: str):
22
+ self.message = message
23
+ super().__init__(self.message)
24
+
25
+
18
26
  class ManualTaskConfigService:
19
27
  def __init__(self, db: Session):
20
28
  self.db = db
@@ -51,8 +59,8 @@ class ManualTaskConfigService:
51
59
  """
52
60
  try:
53
61
  ManualTaskConfigurationType(config_type)
54
- except ValueError:
55
- raise ValueError(f"Invalid config type: {config_type}")
62
+ except ManualTaskConfigError:
63
+ raise ManualTaskConfigError(f"Invalid config type: {config_type}")
56
64
 
57
65
  # Set all existing versions to non-current
58
66
  if is_current:
@@ -96,7 +104,11 @@ class ManualTaskConfigService:
96
104
  modified_keys = set(fields_to_remove or [])
97
105
 
98
106
  if field_updates:
99
- validate_fields(field_updates, is_submission=False)
107
+ try:
108
+ validate_fields(field_updates, is_submission=False)
109
+ except ValueError as e:
110
+ raise ManualTaskConfigError(f"Invalid field updates: {e}") from e
111
+
100
112
  fields_to_create = [
101
113
  {
102
114
  "task_id": config.task_id,
@@ -167,7 +179,7 @@ class ManualTaskConfigService:
167
179
  )
168
180
 
169
181
  if not config:
170
- raise ValueError(
182
+ raise ManualTaskConfigError(
171
183
  f"No current config found for task {task.id} and type {config_type}"
172
184
  )
173
185
  return config
@@ -355,7 +367,7 @@ class ManualTaskConfigService:
355
367
  """
356
368
  config = self.db.query(ManualTaskConfig).filter_by(id=config_id).first()
357
369
  if not config:
358
- raise ValueError(f"Config with ID {config_id} not found")
370
+ raise ManualTaskConfigError(f"Config with ID {config_id} not found")
359
371
 
360
372
  log_data = self._create_log_data(
361
373
  task.id,
@@ -0,0 +1,285 @@
1
+ from typing import Any, Optional
2
+
3
+ from loguru import logger
4
+ from sqlalchemy.orm import Session
5
+
6
+ from fides.api.models.attachment import Attachment
7
+ from fides.api.models.manual_tasks.manual_task_config import ManualTaskConfigField
8
+ from fides.api.models.manual_tasks.manual_task_instance import (
9
+ ManualTaskInstance,
10
+ ManualTaskSubmission,
11
+ )
12
+ from fides.api.schemas.manual_tasks.manual_task_status import (
13
+ StatusTransitionNotAllowed,
14
+ StatusType,
15
+ )
16
+ from fides.service.manual_tasks.utils import validate_fields, with_task_logging
17
+
18
+
19
+ class ManualTaskInstanceError(Exception):
20
+ """Exception raised when a manual task instance error occurs."""
21
+
22
+ def __init__(self, message: str):
23
+ self.message = message
24
+ super().__init__(self.message)
25
+
26
+
27
+ class ManualTaskSubmissionError(Exception):
28
+ """Exception raised when a manual task submission error occurs."""
29
+
30
+ def __init__(self, message: str):
31
+ self.message = message
32
+ super().__init__(self.message)
33
+
34
+
35
+ class ManualTaskInstanceService:
36
+ def __init__(self, db: Session):
37
+ self.db = db
38
+
39
+ def _create_log_data(
40
+ self,
41
+ task_id: str,
42
+ config_id: str,
43
+ instance_id: str,
44
+ details: dict[str, Any],
45
+ ) -> dict[str, Any]:
46
+ """Create standard log data structure."""
47
+ return {
48
+ "task_id": task_id,
49
+ "config_id": config_id,
50
+ "instance_id": instance_id,
51
+ "details": details,
52
+ }
53
+
54
+ def _get_instance(
55
+ self, instance_id: str, allow_completed: bool = False
56
+ ) -> ManualTaskInstance:
57
+ """Get and validate instance."""
58
+ instance = self.db.query(ManualTaskInstance).filter_by(id=instance_id).first()
59
+ if not instance:
60
+ raise ManualTaskInstanceError(f"Instance with ID {instance_id} not found")
61
+
62
+ if not allow_completed and instance.status == StatusType.completed:
63
+ raise StatusTransitionNotAllowed(
64
+ "Instance is already completed, no further changes allowed"
65
+ )
66
+
67
+ return instance
68
+
69
+ def _get_field(
70
+ self, field_id: str, validate_data: Optional[dict[str, Any]] = None
71
+ ) -> ManualTaskConfigField:
72
+ """Get and validate field."""
73
+ field = ManualTaskConfigField.get_by_key_or_id(
74
+ db=self.db, data={"id": field_id}
75
+ )
76
+ if not field:
77
+ raise ManualTaskInstanceError(f"Field with ID {field_id} not found")
78
+
79
+ if validate_data:
80
+ try:
81
+ validate_fields([validate_data], is_submission=True)
82
+ except ValueError as e:
83
+ raise ManualTaskInstanceError(f"Invalid field data: {e}") from e
84
+
85
+ return field
86
+
87
+ def _update_instance_status(
88
+ self,
89
+ instance: ManualTaskInstance,
90
+ new_status: Optional[StatusType] = None,
91
+ user_id: Optional[str] = None,
92
+ silent: bool = False,
93
+ ) -> None:
94
+ """Update instance status with optional error suppression."""
95
+ try:
96
+ if not new_status:
97
+ new_status = (
98
+ StatusType.in_progress
99
+ if instance.submissions
100
+ else StatusType.pending
101
+ )
102
+ instance.update_status(self.db, new_status, user_id)
103
+ except StatusTransitionNotAllowed as e:
104
+ if not silent:
105
+ raise
106
+ logger.info(f"Status not transitioning: {e}")
107
+
108
+ @with_task_logging("Created task instance")
109
+ def create_instance(
110
+ self, task_id: str, config_id: str, entity_id: str, entity_type: str
111
+ ) -> tuple[ManualTaskInstance, dict[str, Any]]:
112
+ """Create a new instance for an entity."""
113
+ instance = ManualTaskInstance.create(
114
+ self.db,
115
+ data={
116
+ "task_id": task_id,
117
+ "config_id": config_id,
118
+ "entity_id": entity_id,
119
+ "entity_type": entity_type,
120
+ },
121
+ )
122
+ return instance, self._create_log_data(
123
+ task_id, config_id, instance.id, {"entity_type": entity_type}
124
+ )
125
+
126
+ def get_submission_for_field(
127
+ self, instance_id: str, field_id: str
128
+ ) -> Optional[ManualTaskSubmission]:
129
+ """Get the submission for a specific field."""
130
+ return self._get_instance(instance_id).get_submission_for_field(field_id)
131
+
132
+ @with_task_logging("Updated task instance status")
133
+ def update_status(
134
+ self,
135
+ instance_id: str,
136
+ new_status: Optional[StatusType] = None,
137
+ user_id: Optional[str] = None,
138
+ ) -> tuple[ManualTaskInstance, dict[str, Any]]:
139
+ """Update instance status with logging."""
140
+ instance = self._get_instance(instance_id)
141
+ previous_status = instance.status
142
+ self._update_instance_status(instance, new_status, user_id)
143
+
144
+ return instance, self._create_log_data(
145
+ instance.task_id,
146
+ instance.config_id,
147
+ instance.id,
148
+ {
149
+ "previous_status": previous_status,
150
+ "new_status": instance.status,
151
+ "user_id": user_id,
152
+ },
153
+ )
154
+
155
+ @with_task_logging("Created task submission")
156
+ def create_submission(
157
+ self,
158
+ instance_id: str,
159
+ field_id: str,
160
+ data: dict[str, Any],
161
+ ) -> tuple[ManualTaskSubmission, dict[str, Any]]:
162
+ """Create a new submission for a field."""
163
+ instance = self._get_instance(instance_id)
164
+ field = self._get_field(field_id, data)
165
+
166
+ if instance.get_submission_for_field(field_id):
167
+ raise ManualTaskInstanceError(
168
+ f"Submission for field {field.field_key} already exists for instance {instance.id}"
169
+ )
170
+
171
+ submission = ManualTaskSubmission.create(
172
+ self.db,
173
+ data={
174
+ "task_id": instance.task_id,
175
+ "config_id": instance.config_id,
176
+ "instance_id": instance.id,
177
+ "field_id": field.id,
178
+ "data": data,
179
+ },
180
+ )
181
+
182
+ # Update instance status to in_progress
183
+ self._update_instance_status(instance, StatusType.in_progress, silent=True)
184
+
185
+ return submission, self._create_log_data(
186
+ instance.task_id,
187
+ instance.config_id,
188
+ instance.id,
189
+ {
190
+ "field_key": field.field_key,
191
+ "field_type": field.field_type,
192
+ },
193
+ )
194
+
195
+ @with_task_logging("Updated task submission")
196
+ def update_submission(
197
+ self,
198
+ instance_id: str,
199
+ submission_id: str,
200
+ data: dict[str, Any],
201
+ ) -> tuple[ManualTaskSubmission, dict[str, Any]]:
202
+ """Update a submission for a field."""
203
+ instance = self._get_instance(instance_id)
204
+ submission = next(
205
+ (s for s in instance.submissions if s.id == submission_id),
206
+ None,
207
+ )
208
+ if not submission or not submission.field_id:
209
+ raise ManualTaskSubmissionError(
210
+ f"Valid submission with ID {submission_id} not found"
211
+ )
212
+
213
+ field = self._get_field(submission.field_id, data)
214
+ submission.update(self.db, data={"data": data})
215
+ self._update_instance_status(instance, silent=True)
216
+
217
+ return submission, self._create_log_data(
218
+ instance.task_id,
219
+ instance.config_id,
220
+ instance.id,
221
+ {
222
+ "field_key": field.field_key,
223
+ "field_type": field.field_type,
224
+ },
225
+ )
226
+
227
+ @with_task_logging("Delete task attachment")
228
+ def delete_attachment_by_id(
229
+ self,
230
+ submission_id: str,
231
+ attachment_id: str,
232
+ ) -> None:
233
+ """Delete an attachment for a field."""
234
+ submission = (
235
+ self.db.query(ManualTaskSubmission).filter_by(id=submission_id).first()
236
+ )
237
+ if not submission:
238
+ raise ManualTaskSubmissionError(
239
+ f"Submission with ID {submission_id} does not exist"
240
+ )
241
+
242
+ self._get_instance(
243
+ submission.instance_id
244
+ ) # Validates instance is not completed
245
+
246
+ attachment = self.db.query(Attachment).filter_by(id=attachment_id).first()
247
+ if not attachment or attachment not in submission.attachments:
248
+ raise ManualTaskSubmissionError(
249
+ f"Attachment {attachment_id} not found in submission {submission_id}"
250
+ )
251
+
252
+ # Delete attachment and optionally submission
253
+ if (
254
+ len(submission.attachments) == 1
255
+ and submission.field.field_type == "attachment"
256
+ ):
257
+ attachment.delete(self.db)
258
+ submission.delete(self.db)
259
+ else:
260
+ attachment.delete(self.db)
261
+
262
+ @with_task_logging("Completed task instance")
263
+ def complete_task_instance(
264
+ self, task_id: str, config_id: str, instance_id: str, user_id: str
265
+ ) -> tuple[ManualTaskInstance, dict[str, Any]]:
266
+ """Complete a task instance."""
267
+ instance = self._get_instance(instance_id)
268
+
269
+ missing_fields = [
270
+ field.field_key
271
+ for field in instance.required_fields
272
+ if not instance.get_submission_for_field(field.id)
273
+ ]
274
+ if missing_fields:
275
+ raise StatusTransitionNotAllowed(
276
+ f"Cannot complete task instance. Missing required fields: {', '.join(missing_fields)}"
277
+ )
278
+
279
+ instance.update_status(self.db, StatusType.completed, user_id)
280
+ instance.completed_by_id = user_id
281
+ instance.save(self.db)
282
+
283
+ return instance, self._create_log_data(
284
+ task_id, config_id, instance_id, {"completed_by": user_id}
285
+ )