Coverage for backend/django/core/auxiliary/services/pinch_import.py: 80%
158 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
1import traceback
3from django.db import transaction
4from django.utils import timezone
5from rest_framework.exceptions import NotFound, ValidationError
7from PinchAnalysis.models.InputModels import PinchUtility
8from PinchAnalysis.models.StreamDataProject import StreamDataProject
9from common.models.notifications.payloads import NotificationServiceMessageType
10from common.models.pinch_import import PinchUtilityCsvImportRequestPayload
11from common.services import messaging
12from core.auxiliary.enums import pinchEnums
13from core.auxiliary.enums.generalEnums import TaskStatus
14from core.auxiliary.managers.TaskManager import handle_task_running_event
15from core.auxiliary.models.Task import Task, TaskType
16from core.auxiliary.models.UploadSession import (
17 UploadSession,
18 UploadSessionPurpose,
19 UploadSessionStatus,
20)
21from core.auxiliary.serializers import TaskSerializer
22from core.auxiliary.services.csv_inspect import CsvInspectionError, parse_float_cell, stream_csv_rows
23from core.auxiliary.services.object_storage import s3 as s3_storage
24from core.auxiliary.services.uploads import inspect_upload_session
27ROW_BATCH_SIZE = 1000
28PROGRESS_UPDATE_INTERVAL = 1000
29REQUIRED_COLUMNS = ("name", "t_supply", "t_target")
30OPTIONAL_COLUMNS = ("heat_flow", "dt_cont", "htc", "price", "type")
31SUPPORTED_COLUMNS = REQUIRED_COLUMNS + OPTIONAL_COLUMNS
32VALID_UTILITY_TYPES = {choice for choice, _ in pinchEnums.StreamType.choices if choice}
35def _send_task_update(task: Task, message_type: NotificationServiceMessageType):
36 messaging.send_flowsheet_notification_message(
37 task.flowsheet_id,
38 TaskSerializer(task).data,
39 message_type,
40 )
43def _get_pinch_project(project_id: int) -> StreamDataProject:
44 try:
45 return StreamDataProject.objects.select_related(
46 "flowsheet_state__flowsheet", "Inputs"
47 ).get(id=project_id)
48 except StreamDataProject.DoesNotExist as exc:
49 raise NotFound({"project_id": "Pinch project not found."}) from exc
52def _get_completed_pinch_upload(upload_session_id: int, user_id: int) -> UploadSession:
53 try:
54 upload_session = UploadSession.objects.get(id=upload_session_id)
55 except UploadSession.DoesNotExist as exc:
56 raise ValidationError({"upload_session_id": "Upload session not found."}) from exc
58 if upload_session.created_by_id != user_id: 58 ↛ 59line 58 didn't jump to line 59 because the condition on line 58 was never true
59 raise ValidationError({"upload_session_id": "You do not own this upload session."})
60 if upload_session.status != UploadSessionStatus.COMPLETED: 60 ↛ 61line 60 didn't jump to line 61 because the condition on line 60 was never true
61 raise ValidationError({"upload_session_id": "The upload session must be completed before import."})
62 if upload_session.expires_at is not None and upload_session.expires_at <= timezone.now():
63 upload_session.status = UploadSessionStatus.EXPIRED
64 upload_session.save(update_fields=["status"])
65 raise ValidationError({"upload_session_id": "The upload session expired before import."})
66 if upload_session.purpose != UploadSessionPurpose.PINCH_UTILITY_CSV: 66 ↛ 67line 66 didn't jump to line 67 because the condition on line 66 was never true
67 raise ValidationError({"upload_session_id": "The upload session purpose must be pinch_utility_csv."})
68 return upload_session
71def _raise_if_completed_upload_expired(upload_session: UploadSession) -> None:
72 """Prevent delayed imports from consuming objects past their application TTL."""
73 if upload_session.expires_at is not None and upload_session.expires_at <= timezone.now():
74 upload_session.status = UploadSessionStatus.EXPIRED
75 upload_session.save(update_fields=["status"])
76 raise CsvInspectionError("The upload session expired before Pinch utility import could run.")
79def enqueue_pinch_utility_import(*, user, project_id: int, upload_session_id: int) -> Task:
80 """Queue a Pinch utility CSV import unless the same upload already has an active task."""
81 project = _get_pinch_project(project_id)
82 upload_session = _get_completed_pinch_upload(upload_session_id, user.id)
84 stable_flowsheet_id = project.flowsheet_state.flowsheet_id
85 if upload_session.flowsheet_state_id != project.flowsheet_state_id:
86 raise ValidationError({"upload_session_id": "The upload session belongs to a different flowsheet."})
88 existing_task = (
89 Task.objects.filter(
90 task_type=TaskType.CSV_IMPORT_PINCH_UTILITIES,
91 flowsheet_id=stable_flowsheet_id,
92 debug__project_id=project_id,
93 debug__upload_session_id=upload_session_id,
94 )
95 .exclude(status__in=[TaskStatus.Failed, TaskStatus.Cancelled])
96 .order_by("-start_time")
97 .first()
98 )
99 if existing_task is not None:
100 return existing_task
102 task = Task.create(
103 user,
104 stable_flowsheet_id,
105 task_type=TaskType.CSV_IMPORT_PINCH_UTILITIES,
106 status=TaskStatus.Pending,
107 expected_flowsheet_state_id=project.flowsheet_state_id,
108 save=True,
109 )
110 task.debug = {
111 "project_id": project_id,
112 "upload_session_id": upload_session_id,
113 "utilities_imported": 0,
114 }
115 task.save(update_fields=["debug"])
117 messaging.send_pinch_utility_csv_import_message(
118 PinchUtilityCsvImportRequestPayload(
119 task_id=task.id,
120 flowsheet_id=stable_flowsheet_id,
121 project_id=project_id,
122 upload_session_id=upload_session_id,
123 bucket=upload_session.bucket,
124 object_key=upload_session.object_key,
125 csv_delimiter=upload_session.csv_delimiter or None,
126 requested_by_user_id=user.id,
127 )
128 )
129 return task
132def _row_has_any_supported_value(row: dict[str, str | None]) -> bool:
133 return any((row.get(column) or "").strip() for column in SUPPORTED_COLUMNS)
136def _parse_float(
137 row_number: int,
138 row: dict[str, str],
139 column_name: str,
140 *,
141 required: bool,
142) -> float | None:
143 parsed = parse_float_cell(row_number, row, column_name, required=required)
144 if parsed is None and required: 144 ↛ 145line 144 didn't jump to line 145 because the condition on line 144 was never true
145 raise CsvInspectionError(f"Row {row_number}, column '{column_name}' is required.")
146 return parsed
149def _parse_type(row_number: int, row: dict[str, str]) -> str:
150 raw_value = (row.get("type") or "").strip()
151 if not raw_value:
152 return pinchEnums.StreamType.Both
153 if raw_value not in VALID_UTILITY_TYPES: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 raise CsvInspectionError(
155 f"Row {row_number}, column 'type' must be one of: {', '.join(sorted(VALID_UTILITY_TYPES))}."
156 )
157 return raw_value
160def _build_utility(row_number: int, row: dict[str, str], project: StreamDataProject) -> PinchUtility | None:
161 if not _row_has_any_supported_value(row): 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true
162 return None
164 name = (row.get("name") or "").strip()
165 if not name: 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true
166 raise CsvInspectionError(f"Row {row_number}, column 'name' is required.")
168 return PinchUtility(
169 flowsheet_state=project.flowsheet_state,
170 input_owner=project.Inputs,
171 name=name,
172 t_supply=_parse_float(row_number, row, "t_supply", required=True),
173 t_target=_parse_float(row_number, row, "t_target", required=True),
174 heat_flow=_parse_float(row_number, row, "heat_flow", required=False),
175 dt_cont=_parse_float(row_number, row, "dt_cont", required=False),
176 htc=_parse_float(row_number, row, "htc", required=False),
177 price=_parse_float(row_number, row, "price", required=False),
178 type=_parse_type(row_number, row),
179 )
182def _flush_utilities(
183 *,
184 task: Task,
185 utility_buffer: list[PinchUtility],
186 imported_count: int,
187) -> int:
188 if not utility_buffer: 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true
189 return imported_count
191 PinchUtility.objects.bulk_create(utility_buffer)
192 imported_count += len(utility_buffer)
193 if imported_count % PROGRESS_UPDATE_INTERVAL == 0: 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true
194 task.debug = {**(task.debug or {}), "utilities_imported": imported_count}
195 task.save(update_fields=["debug"])
196 _send_task_update(task, NotificationServiceMessageType.TASK_UPDATED)
198 utility_buffer.clear()
199 return imported_count
202def process_pinch_utility_import(payload: PinchUtilityCsvImportRequestPayload):
203 """Import Pinch utility rows from object storage into the target project.
205 The payload must reference an existing pending task, a completed upload session,
206 and a Pinch project owned by the same flowsheet. The CSV is streamed from object
207 storage, validated row-by-row, and inserted in batches while task progress
208 notifications are emitted. Invalid CSV structure or row values raise
209 `CsvInspectionError` or `ValueError`, which are captured on the task as a failure.
210 """
211 task = Task.objects.get(id=payload.task_id)
212 if task.status in {TaskStatus.Completed, TaskStatus.Cancelled, TaskStatus.Running, TaskStatus.Failed}: 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true
213 return
215 handle_task_running_event(task.id)
216 try:
217 project = _get_pinch_project(payload.project_id)
218 try:
219 upload_session = UploadSession.objects.get(id=payload.upload_session_id)
220 except UploadSession.DoesNotExist as exc:
221 raise CsvInspectionError("The upload session was deleted before the import could run.") from exc
223 if upload_session.status != UploadSessionStatus.COMPLETED: 223 ↛ 224line 223 didn't jump to line 224 because the condition on line 223 was never true
224 raise CsvInspectionError("The upload session must be completed before Pinch utility import can run.")
225 _raise_if_completed_upload_expired(upload_session)
226 if upload_session.purpose != UploadSessionPurpose.PINCH_UTILITY_CSV: 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true
227 raise CsvInspectionError("The upload session purpose must be pinch_utility_csv for Pinch import.")
228 if upload_session.flowsheet_state_id != project.flowsheet_state_id: 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true
229 raise CsvInspectionError("The upload session belongs to a different flowsheet.")
231 delimiter = payload.csv_delimiter or upload_session.csv_delimiter
232 if not delimiter:
233 delimiter = inspect_upload_session(upload_session).delimiter
235 with transaction.atomic():
236 PinchUtility.objects.filter(input_owner=project.Inputs).delete()
238 body = s3_storage.stream_object(payload.bucket, payload.object_key)
239 _, reader = stream_csv_rows(body, delimiter)
241 utility_buffer: list[PinchUtility] = []
242 imported_count = 0
244 for row_index, row in enumerate(reader):
245 row_number = row_index + 2
246 utility = _build_utility(row_number, row, project)
247 if utility is None: 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true
248 continue
249 utility_buffer.append(utility)
251 if len(utility_buffer) >= ROW_BATCH_SIZE: 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true
252 imported_count = _flush_utilities(
253 task=task,
254 utility_buffer=utility_buffer,
255 imported_count=imported_count,
256 )
258 imported_count = _flush_utilities(
259 task=task,
260 utility_buffer=utility_buffer,
261 imported_count=imported_count,
262 )
264 if imported_count == 0: 264 ↛ 265line 264 didn't jump to line 265 because the condition on line 264 was never true
265 raise CsvInspectionError("The uploaded CSV must include at least one utility row.")
267 task.status = TaskStatus.Completed
268 task.completed_time = timezone.now()
269 task.debug = {**(task.debug or {}), "utilities_imported": imported_count}
270 task.error = None
271 task.save(update_fields=["status", "completed_time", "debug", "error"])
272 except Exception as exc:
273 task.status = TaskStatus.Failed
274 task.completed_time = timezone.now()
275 task.error = {
276 "message": str(exc),
277 "cause": "pinch_utility_csv_import",
278 "traceback": traceback.format_exc(),
279 }
280 task.save(update_fields=["status", "completed_time", "error"])
281 _send_task_update(task, NotificationServiceMessageType.TASK_COMPLETED)
282 return
284 _send_task_update(task, NotificationServiceMessageType.TASK_COMPLETED)
287def mark_pinch_utility_import_delivery_failure(payload: PinchUtilityCsvImportRequestPayload):
288 """Fail a queued Pinch utility CSV import when Dapr cannot deliver the callback."""
289 task = Task.objects.get(id=payload.task_id)
290 if task.status in {TaskStatus.Completed, TaskStatus.Cancelled, TaskStatus.Failed}: 290 ↛ 291line 290 didn't jump to line 291 because the condition on line 290 was never true
291 return
293 task.status = TaskStatus.Failed
294 task.completed_time = timezone.now()
295 task.error = {
296 "message": (
297 "The utility CSV import could not be started. "
298 "Retry the import to queue a new task."
299 ),
300 "cause": "pinch_utility_csv_import_delivery_failed",
301 }
302 task.save(update_fields=["status", "completed_time", "error"])
303 _send_task_update(task, NotificationServiceMessageType.TASK_COMPLETED)