Coverage for rest_api/data_entry/sample_entry_job.py: 13%
412 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-10 03:22 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-10 03:22 +0000
1from datetime import datetime
2import pathlib
3import pickle
4import shutil
5import traceback
6import zipfile
8from celery import group
9from celery import shared_task
10from django.core.cache import cache
11from django.core.exceptions import FieldDoesNotExist
12from django.db import DataError
13from django.db import transaction
14from django.db.models import Q
15from django.utils import timezone
16from line_profiler import LineProfiler
17import pandas as pd
19from rest_api import models
20from rest_api.data_entry.annotation_import import AnnotationImport
21from rest_api.data_entry.sample_import import SonarImport
22from rest_api.models import Alignment
23from rest_api.models import AminoAcidMutation
24from rest_api.models import AnnotationType
25from rest_api.models import FileProcessing
26from rest_api.models import ImportLog
27from rest_api.models import NucleotideMutation
28from rest_api.models import ProcessingJob
29from rest_api.models import Sample
30from rest_api.models import Sequence
31from rest_api.serializers import Sample2PropertyBulkCreateOrUpdateSerializer
32from rest_api.utils import parse_date
33from rest_api.utils import PropertyColumnMapping
34from sonar_backend.settings import KEEP_IMPORTED_DATA_FILES
35from sonar_backend.settings import LOGGER
36from sonar_backend.settings import PROFILE_IMPORT
37from sonar_backend.settings import PROPERTY_BATCH_SIZE
38from sonar_backend.settings import REDIS_URL
39from sonar_backend.settings import SAMPLE_BATCH_SIZE
40from sonar_backend.settings import SONAR_DATA_ARCHIVE
41from sonar_backend.settings import SONAR_DATA_ENTRY_FOLDER
42from sonar_backend.settings import SONAR_DATA_PROCESSING_FOLDER
44property_cache = {}
47def check_for_new_data():
48 """Checks for new processing jobs in the database queue and processes them sequentially.
49 Steps:
50 - Fetches jobs with `QUEUED` status from the 'ProcessingJob' table, ordered by 'entry_time'.
51 - For each job:
52 - Retrieves associated files from 'FileProcessing'.
53 - If an error occurs (files not found), updates job status to 'FAILED'.
54 - Moves valid files to the processing directory.
55 - Calls 'import_archive' to handle import logic.
56 - Recursively calls itself to process subsequent jobs.
58 """
59 processing_dir = pathlib.Path(SONAR_DATA_PROCESSING_FOLDER)
60 if REDIS_URL is None:
61 print("--------------- WARNING -----------------")
62 print("REDIS_URL not set, running without celery.")
63 print("This will take a long time.")
64 print("--------------- ------- -----------------")
65 # only one worker (Master) can take a job at a time
66 with transaction.atomic():
67 # Fetch and lock a single job with QUEUED status NOTE: Order of entrytime is matter!!
68 job = (
69 ProcessingJob.objects.select_for_update()
70 .filter(status=ProcessingJob.ImportType.QUEUED)
71 .order_by("entry_time")
72 .first()
73 )
74 if job is None:
75 return
76 # Update job status to IN_PROGRESS
77 job.status = ProcessingJob.ImportType.IN_PROGRESS
78 job.save()
80 LOGGER.info(f"## New processing job: {job.job_name} ---")
81 files = FileProcessing.objects.filter(processing_job=job)
82 if not files.exists():
83 LOGGER.warning(
84 f"No associated files found, in {job.job_name}, marking as FAILED, continue the proces.."
85 )
86 job.status = ProcessingJob.ImportType.FAILED
87 job.save()
88 return
90 for file in files:
91 file_path = pathlib.Path(SONAR_DATA_ENTRY_FOLDER).joinpath(file.file_name)
92 if not file_path.exists():
93 LOGGER.error(f"File {file_path} does not exist, marking job as FAILED!")
94 job.status = ProcessingJob.ImportType.FAILED
95 job.save()
96 return
98 # move files to SONAR_DATA_PROCESSING_FOLDER
99 if "_prop" in str(file_path):
100 # Handle corresponding .pkl file for property imports
101 pkl_file = file_path.with_suffix(".pkl")
102 new_zip_path = file_path.rename(processing_dir.joinpath(file_path.name))
103 new_pkl_path = pkl_file.rename(processing_dir.joinpath(pkl_file.name))
104 import_archive(
105 new_zip_path, pkl_path=new_pkl_path
106 ) # Pass the .pkl path to import_archive
107 else:
108 # process as normal sample or annotation import
109 new_zip_path = file_path.rename(processing_dir.joinpath(file_path.name))
110 import_archive(new_zip_path)
112 # Recursively check for new data after processing current batch
113 check_for_new_data()
114 print("---- No new jobs found. ----")
117def import_archive(process_file_path: pathlib.Path, pkl_path: pathlib.Path = None):
118 """Processes an archive file by extracting and importing its contents.
119 Steps:
120 - Determines the corresponding `ProcessingJob` based on the file.
121 - Updates the job status to `IN_PROGRESS`.
122 - Extracts the archive into a temporary directory.
123 - Differentiates between property imports (requires `.pkl` mapping) and sample/annotation imports.
124 - Uses Celery (if available) to process batches; otherwise, processes sequentially.
125 - On success, moves files to `completed` directory or deletes them.
126 - On failure, moves files to `error` directory and logs the error.
127 - Updates the job status based on the completion of the processed file.
129 NOTE: [optional] if it just starts, we update the ProcessingJob (to IP)
130 """
131 temp_dir = (
132 pathlib.Path(SONAR_DATA_ARCHIVE)
133 .joinpath("temp")
134 .joinpath(process_file_path.stem)
135 )
136 try:
137 filename_ID = process_file_path.name
138 # get JOB ID based on the given files
139 proJob_obj = ProcessingJob.objects.filter(files__file_name=filename_ID).first()
140 if not proJob_obj:
141 LOGGER.warning(
142 "The given import files is not related to any jobID (skip this batch)"
143 )
144 return
145 job_ID = proJob_obj.job_name
146 LOGGER.info(f"Process job: {job_ID}")
147 print(f"Unzip to {temp_dir}")
148 # ProcessingJob.objects.filter(job_name=job_ID).update(
149 # status=ProcessingJob.ImportType.IN_PROGRESS
150 # )
151 # unzip the zip file to SONAR_DATA_ARCHIVE
152 with zipfile.ZipFile(process_file_path, "r") as zip_ref:
153 zip_ref.extractall(temp_dir)
155 # Files are distributed and processed
156 print(f"Running data entry for {temp_dir}")
157 if pkl_path and pkl_path.exists():
158 # property import
159 import_type = ImportLog.ImportType.PROPERTY
160 print(f"Property import detected")
161 batch_size = PROPERTY_BATCH_SIZE
162 print("Batch size:", batch_size)
163 property_files_tsv = list(temp_dir.glob("**/*.tsv"))
164 property_files_csv = list(temp_dir.glob("**/*.csv"))
165 property_files = (
166 property_files_tsv + property_files_csv
167 ) # Combine both types
169 if property_files:
170 # Load the column mapping from the .pkl file
171 with open(pkl_path, "rb") as pkl_file:
172 column_mapping = pickle.load(
173 pkl_file
174 ) # Load the .pkl file into column_mapping
176 use_celery = bool(REDIS_URL) # Use Celery if Redis is configured
177 for property_file in property_files:
178 sep = "," if property_file.suffix == ".csv" else "\t"
179 import_property(
180 str(property_file),
181 sep,
182 use_celery,
183 column_mapping,
184 batch_size=batch_size,
185 ) # Pass column_mapping
187 else:
188 batch_size = SAMPLE_BATCH_SIZE
189 print("Batch size:", batch_size)
190 # var and vcf
191 sample_files = list(temp_dir.joinpath("samples").glob("**/*.sample"))
192 anno_files = list(temp_dir.joinpath("anno").glob("**/*.vcf.*"))
193 print(f"Sample: {len(sample_files)} files found")
194 print(f"Annotation (vcfs): {len(anno_files)} files found")
195 if len(sample_files) > 0:
196 import_type = ImportLog.ImportType.SAMPLE
197 elif len(anno_files) > 0:
198 import_type = ImportLog.ImportType.ANNOTATION
199 else:
200 import_type = ImportLog.ImportType.SAMPLE_ANNOTATION_ARCHIVE
202 timer = datetime.now()
204 number_of_batches = (
205 (len(sample_files) + batch_size - 1) // batch_size
206 if sample_files
207 else (len(anno_files) + batch_size - 1) // batch_size
208 )
210 print(f"Total number of batches: {number_of_batches}")
211 sample_files = [str(file) for file in sample_files]
212 if batch_size:
213 replicon_cache = {}
214 gene_cache_by_accession = {}
215 gene_cache_by_var_pos = {}
216 if REDIS_URL:
217 print("setting up sample import celery jobs..")
218 sample_jobs = []
219 for i in range(0, len(sample_files), batch_size):
220 batch = sample_files[i : i + batch_size]
221 sample_jobs.append(
222 process_batch.s(
223 batch,
224 replicon_cache,
225 gene_cache_by_accession,
226 gene_cache_by_var_pos,
227 str(temp_dir),
228 )
229 )
230 results = group(sample_jobs).apply_async().get()
231 for result in results:
232 if not result[0]:
233 raise Exception(
234 f"Sample Import Error: {result[1]} - {result[2]}"
235 )
236 results = (
237 group([process_annotation.s(str(file)) for file in anno_files])
238 .apply_async()
239 .get()
240 )
241 for result in results:
242 if not result[0]:
243 raise Exception(
244 f"Annotation Import Error: {result[1]} - {result[2]}"
245 )
246 else:
247 replicon_cache = {}
248 gene_cache_by_accession = {}
249 # Samples
250 for i in range(0, len(sample_files), batch_size):
251 # print(
252 # f"processing batch {(i//batch_size) + 1} of {number_of_batches}"
253 # )
254 # batchtimer = datetime.now()
255 batch = sample_files[i : i + batch_size]
256 process_batch_single_thread(
257 batch,
258 replicon_cache,
259 gene_cache_by_accession,
260 gene_cache_by_var_pos,
261 str(temp_dir),
262 )
263 # annotation
264 for file in anno_files:
265 process_annotation(str(file))
267 # print(
268 # f"batch {(i//batch_size) + 1} done in {datetime.now() - batchtimer}"
269 # )
271 LOGGER.info(f"import done in {datetime.now() - timer}")
272 except Exception as e:
273 # TODO: [optional] if fail, we update the ProcessingJob right now.
274 LOGGER.error(f"Error : {e}")
275 error_dir = pathlib.Path(SONAR_DATA_ARCHIVE).joinpath("error")
276 error_dir.mkdir(parents=True, exist_ok=True)
277 process_file_path.rename(error_dir.joinpath(process_file_path.name))
278 if pkl_path and pkl_path.exists():
279 pkl_path.rename(error_dir.joinpath(pkl_path.name))
280 ImportLog.objects.create(
281 type=import_type,
282 file=FileProcessing.objects.get(file_name=filename_ID),
283 success=False,
284 exception_text=e,
285 stack_trace=traceback.format_exc(),
286 )
287 LOGGER.error(f"--- Exception: move to {error_dir} ---")
289 else: # no exception occurs
290 if KEEP_IMPORTED_DATA_FILES:
291 completed_dir = pathlib.Path(SONAR_DATA_ARCHIVE).joinpath("completed")
292 completed_dir.mkdir(parents=True, exist_ok=True)
293 process_file_path.rename(completed_dir.joinpath(process_file_path.name))
294 if pkl_path and pkl_path.exists():
295 pkl_path.rename(completed_dir.joinpath(pkl_path.name))
296 LOGGER.info(f"--- Finish: move to {completed_dir} ---")
297 else:
298 # Delete files after import rather than keeping them in the
299 # archive/completed/ dir
300 process_file_path.unlink()
301 if pkl_path and pkl_path.exists():
302 pkl_path.unlink()
303 ImportLog.objects.create(
304 type=import_type,
305 file=FileProcessing.objects.get(file_name=filename_ID),
306 success=True,
307 )
308 finally:
309 # TODO: need to rethink about how to finalize the job status
310 # The _file_count = total_all_file - total_file is not a great idea
311 shutil.rmtree(temp_dir)
312 # --- update job status
314 # list all related files
315 all_file_obj = FileProcessing.objects.filter(processing_job__job_name=job_ID)
316 total_all_file = all_file_obj.count()
317 all_file_names_list = all_file_obj.values_list("file_name", flat=True)
318 # Import search only success and count
319 _file_obj = ImportLog.objects.filter(file__in=all_file_names_list)
320 total_file = _file_obj.count()
321 # Calculate the difference between total files and successful imports
322 _file_count = total_all_file - total_file
323 # Update ProcessingJob status based on file processing status
324 if _file_count > 0:
325 # "In Progress"
326 ProcessingJob.objects.filter(job_name=job_ID).update(
327 status=ProcessingJob.ImportType.IN_PROGRESS
328 )
329 # print("In progress: %s", _file_count)
330 else:
331 # If all files are successfully processed, check if any errors occurred during import
332 error_files = ImportLog.objects.filter(file__in=all_file_obj, success=False)
333 if error_files.exists():
334 # "Failed"
335 ProcessingJob.objects.filter(job_name=job_ID).update(
336 status=ProcessingJob.ImportType.FAILED
337 )
338 # print(f"### Job ID: {job_ID} \nRun status: Failed")
339 LOGGER.error(f"### Job ID: {job_ID} \nRun status: Failed")
340 else:
341 # "Completed"
342 ProcessingJob.objects.filter(job_name=job_ID).update(
343 status=ProcessingJob.ImportType.COMPLETED
344 )
345 LOGGER.info(f"### Job ID: {job_ID} \nRun status: Completed")
348@shared_task
349def process_batch(
350 batch: list[str],
351 replicon_cache,
352 gene_cache_by_accession,
353 gene_cache_by_var_pos,
354 temp_dir,
355):
356 parameters = locals().copy()
357 if PROFILE_IMPORT:
358 lp = LineProfiler()
359 # Add a few of the slowest functions based on profiling the
360 # process_batch_run() function
361 lp.add_function(SonarImport.get_mutation_objs_cds_and_parent_relations)
362 lp.add_function(SonarImport.get_mutation_objs_nt)
363 lp.add_function(process_batch_run)
364 lp.add_function(process_annotation)
365 process_batch_profiled = lp(process_batch_run)
366 retval = process_batch_profiled(**parameters)
367 lp.print_stats()
368 return retval
369 else:
370 return process_batch_run(**parameters)
373def process_batch_run(
374 batch: list[str],
375 replicon_cache,
376 gene_cache_by_accession,
377 gene_cache_by_var_pos,
378 temp_dir,
379):
380 try:
381 sonar_import_objs = [
382 SonarImport(pathlib.Path(file), import_folder=temp_dir) for file in batch
383 ]
384 sequences = [
385 sample_import_obj.get_sequence_obj()
386 for sample_import_obj in sonar_import_objs
387 ]
389 # Use bulk upsert
390 # performs INSERT ... ON CONFLICT DO UPDATE
391 with cache.lock("sequence"):
392 Sequence.objects.bulk_create(
393 sequences,
394 update_conflicts=True,
395 unique_fields=["name"],
396 update_fields=["seqhash", "length", "last_update_date"],
397 )
398 alignments: list[Alignment] = []
399 for sample_import_obj in sonar_import_objs:
400 sample_import_obj.update_replicon_obj(replicon_cache)
401 sample_import_obj.create_alignment(alignments)
403 with cache.lock("alignment"):
404 Alignment.objects.bulk_create(
405 alignments,
406 update_conflicts=True,
407 unique_fields=["sequence", "replicon"],
408 update_fields=["sequence", "replicon"],
409 )
411 nt_mutation_set: list[NucleotideMutation] = []
412 cds_mutation_set: list[AminoAcidMutation] = []
413 mutation_parent_relations = []
414 nt_mutation_alignment_relations: list[NucleotideMutation.alignments.through] = (
415 []
416 )
417 aa_mutation_alignment_relations: list[AminoAcidMutation.alignments.through] = []
418 for sample_import_obj in sonar_import_objs:
419 id_to_mutation_mapping = sample_import_obj.get_mutation_objs_nt(
420 nt_mutation_set,
421 replicon_cache,
422 gene_cache_by_var_pos,
423 nt_mutation_alignment_relations,
424 )
425 parent_relations = (
426 sample_import_obj.get_mutation_objs_cds_and_parent_relations(
427 cds_mutation_set,
428 gene_cache_by_accession,
429 id_to_mutation_mapping,
430 aa_mutation_alignment_relations,
431 )
432 )
433 mutation_parent_relations.extend(parent_relations)
434 with cache.lock("mutation"):
435 NucleotideMutation.objects.bulk_create(
436 nt_mutation_set,
437 update_conflicts=True,
438 unique_fields=["ref", "alt", "start", "end", "replicon"],
439 update_fields=[
440 "is_frameshift",
441 ],
442 )
444 AminoAcidMutation.objects.bulk_create(
445 cds_mutation_set,
446 update_conflicts=True,
447 unique_fields=["ref", "alt", "start", "end", "cds"],
448 update_fields=["ref", "alt", "start", "end", "cds"],
449 )
450 AminoAcidMutation.parent.through.objects.bulk_create(
451 mutation_parent_relations,
452 ignore_conflicts=True,
453 )
454 NucleotideMutation.alignments.through.objects.bulk_create(
455 [
456 NucleotideMutation.alignments.through(
457 nucleotidemutation_id=rel.nucleotidemutation.id,
458 alignment_id=rel.alignment.id,
459 )
460 for rel in nt_mutation_alignment_relations
461 ],
462 ignore_conflicts=True,
463 )
464 AminoAcidMutation.alignments.through.objects.bulk_create(
465 [
466 AminoAcidMutation.alignments.through(
467 aminoacidmutation_id=rel.aminoacidmutation.id,
468 alignment_id=rel.alignment.id,
469 )
470 for rel in aa_mutation_alignment_relations
471 ],
472 ignore_conflicts=True,
473 )
475 return (True, None, None)
477 except Exception as e:
478 # Handle the DataError exception here
479 LOGGER.error(f"DataError: {e}")
480 # Perform additional error handling or logging as needed
481 LOGGER.error("Error happens on this batch")
482 return (False, str(e), traceback.format_exc())
485@shared_task
486def process_annotation(file_name):
487 try:
488 annotation_import = AnnotationImport(file_name)
489 if REDIS_URL:
490 with cache.lock("annotation"):
491 AnnotationType.objects.bulk_create(
492 annotation_import.get_annotation_objs(),
493 ignore_conflicts=True,
494 )
495 with cache.lock("annotation2mutation"):
496 AnnotationType.mutations.through.objects.bulk_create(
497 annotation_import.get_annotation2mutation_objs(),
498 ignore_conflicts=True,
499 )
500 else:
501 AnnotationType.objects.bulk_create(
502 annotation_import.get_annotation_objs(),
503 ignore_conflicts=True,
504 )
505 AnnotationType.mutations.through.objects.bulk_create(
506 annotation_import.get_annotation2mutation_objs(), ignore_conflicts=True
507 )
508 except Exception as e:
509 LOGGER.error(f"Error in process_annotation: {e}")
510 return (False, str(e), traceback.format_exc())
511 return (True, None, None)
514def process_batch_single_thread(
515 batch, replicon_cache, gene_cache_by_accession, gene_cache_by_var_pos, temp_dir
516):
517 try:
518 sample_import_objs = [
519 SonarImport(file, import_folder=temp_dir) for file in batch
520 ]
521 with transaction.atomic():
522 sequences = [
523 sample_import_obj.get_sequence_obj()
524 for sample_import_obj in sample_import_objs
525 ]
526 Sequence.objects.bulk_create(
527 sequences,
528 update_conflicts=True,
529 unique_fields=["name"], # Use name as the unique identifier
530 update_fields=[
531 "seqhash",
532 "length",
533 "last_update_date",
534 ], # Update these fields
535 )
536 [x.update_replicon_obj(replicon_cache) for x in sample_import_objs]
537 alignments: list[Alignment] = []
538 for sample_import_obj in sample_import_objs:
539 sample_import_obj.create_alignment(alignments)
540 Alignment.objects.bulk_create(
541 alignments,
542 update_conflicts=True,
543 unique_fields=["sequence", "replicon"],
544 update_fields=["sequence", "replicon"],
545 )
546 nt_mutation_set: list[NucleotideMutation] = []
547 cds_mutation_set: list[AminoAcidMutation] = []
548 mutation_parent_relations = []
549 nt_mutation_alignment_relations: list[
550 NucleotideMutation.alignments.through
551 ] = []
552 aa_mutation_alignment_relations: list[
553 AminoAcidMutation.alignments.through
554 ] = []
556 for sample_import_obj in sample_import_objs:
557 id_to_mutation_mapping = sample_import_obj.get_mutation_objs_nt(
558 nt_mutation_set,
559 replicon_cache,
560 gene_cache_by_var_pos,
561 nt_mutation_alignment_relations,
562 )
563 parent_relations = (
564 sample_import_obj.get_mutation_objs_cds_and_parent_relations(
565 cds_mutation_set,
566 gene_cache_by_accession,
567 id_to_mutation_mapping,
568 aa_mutation_alignment_relations,
569 )
570 )
571 mutation_parent_relations.extend(parent_relations)
573 NucleotideMutation.objects.bulk_create(
574 nt_mutation_set,
575 update_conflicts=True,
576 unique_fields=["ref", "alt", "start", "end", "replicon"],
577 update_fields=["ref", "alt", "start", "end", "replicon"],
578 )
579 AminoAcidMutation.objects.bulk_create(
580 cds_mutation_set,
581 update_conflicts=True,
582 unique_fields=["ref", "alt", "start", "end", "cds"],
583 update_fields=["ref", "alt", "start", "end", "cds"],
584 )
585 AminoAcidMutation.parent.through.objects.bulk_create(
586 mutation_parent_relations,
587 ignore_conflicts=True,
588 )
589 NucleotideMutation.alignments.through.objects.bulk_create(
590 [
591 NucleotideMutation.alignments.through(
592 nucleotidemutation_id=rel.nucleotidemutation.id,
593 alignment_id=rel.alignment.id,
594 )
595 for rel in nt_mutation_alignment_relations
596 ],
597 ignore_conflicts=True,
598 )
599 AminoAcidMutation.alignments.through.objects.bulk_create(
600 [
601 AminoAcidMutation.alignments.through(
602 aminoacidmutation_id=rel.aminoacidmutation.id,
603 alignment_id=rel.alignment.id,
604 )
605 for rel in aa_mutation_alignment_relations
606 ],
607 ignore_conflicts=True,
608 )
610 # annotations = []
611 # for sample_import_obj in sample_import_objs:
612 # annotations.extend(sample_import_obj.get_annotation_objs())
613 # AnnotationType.objects.bulk_create(
614 # annotations,
615 # ignore_conflicts=True,
616 # )
617 # annotation2mutations = []
618 # for sample_import_obj in sample_import_objs:
619 # annotation2mutations.extend(
620 # sample_import_obj.get_annotation2mutation_objs()
621 # )
622 # Mutation2Annotation.objects.bulk_create(
623 # annotation2mutations, ignore_conflicts=True
624 # )
626 # Filter sequences without associated samples
627 # clean_unused_sequences()
629 except DataError as data_error:
630 # Handle the DataError exception here
631 LOGGER.error(f"DataError: {data_error}")
632 # Perform additional error handling or logging as needed
633 LOGGER.error("Error happens on this batch")
634 for sample_import_obj in sample_import_objs:
635 LOGGER.error(f"{sample_import_obj.sample_file_path}")
636 raise
637 except Exception as e:
638 # Handle other exceptions if necessary
639 LOGGER.critical(f"An unexpected error occurred: {e}")
640 raise
643def import_property(
644 property_file, sep, use_celery=False, column_mapping=None, batch_size=1000
645):
646 try:
647 # Load the CSV file in batches
648 properties_df = pd.read_csv(
649 property_file,
650 sep=sep,
651 dtype="string",
652 keep_default_na=False,
653 ).replace("", pd.NA)
654 timer = datetime.now()
656 # Use Celery if parallel processing is enabled
657 # Convert column_mapping object (PropertyColumnMapping class) to a JSON-serializable format
658 # because celery takes JSON-serializable data (like dictionaries or lists) and passes to the Celery tasks
659 if column_mapping is not None:
660 serializable_column_mapping = {
661 k: {
662 "db_property_name": v.db_property_name,
663 "data_type": v.data_type,
664 "default": v.default,
665 }
666 for k, v in column_mapping["column_mapping"].items()
667 }
668 sample_name_column = column_mapping["sample_id_column"]
669 related_sequences_column = column_mapping["sequences_id_column"]
670 else:
671 serializable_column_mapping = None
672 sample_name_column = "ID"
673 related_sequences_column = "ID"
675 # Data preprocessing
677 for column_name, col_info in serializable_column_mapping.items():
678 if column_name not in properties_df.columns:
679 print(
680 f"Skipping column '{column_name}' as it is not in the property file."
681 )
682 continue
683 # Apply default values for missing or empty fields
684 default_value = col_info["default"]
685 print(f"{column_name} :pairs with: {col_info}")
687 if default_value is not None:
688 properties_df[column_name] = properties_df[column_name].fillna(
689 default_value
690 ) # Replace NaN values
692 # # Explicitly convert the column to string to avoid .0 issues
693 # if (
694 # col_info["data_type"] == "value_varchar"
695 # and column_name in properties_df.columns
696 # ):
697 # properties_df[column_name] = properties_df[column_name].astype(str)
699 # Format columns with 'value_date' type
700 if (
701 col_info["data_type"] == "value_date"
702 and column_name in properties_df.columns
703 ):
704 properties_df[column_name] = properties_df[column_name].apply(
705 parse_date
706 )
707 if use_celery:
708 print("Setting up property import celery jobs...")
710 property_jobs = []
711 for i in range(0, len(properties_df), batch_size):
712 batch = properties_df.iloc[i : i + batch_size]
713 if isinstance(batch, pd.Series):
714 batch = (
715 batch.to_frame().T
716 ) # Ensure it's a DataFrame if it's a Series
717 batch_as_dict = batch.to_dict(orient="records")
718 property_jobs.append(
719 process_property_batch.s(
720 batch_as_dict,
721 sample_name_column,
722 serializable_column_mapping,
723 related_sequences_column,
724 ) # Pass column_mapping
725 )
727 # Run the Celery group of tasks and collect results
728 results = group(property_jobs).apply_async().get()
730 for result in results:
731 if not result[0]:
732 raise Exception(f"Property Import Error: {result[1]}")
734 else:
735 print("Processing properties in single-threaded mode...")
737 # Process the CSV file in a single-threaded manner
738 if isinstance(properties_df, pd.Series):
739 properties_df = properties_df.to_frame().T
740 batch_as_dict = properties_df.to_dict(orient="records")
741 # column_mapping does not need to be JSON-serializable format, because we use only single thread
742 _process_property_file(
743 batch_as_dict,
744 sample_name_column,
745 column_mapping["column_mapping"],
746 related_sequences_column,
747 )
749 print(f"Property import usage time: {datetime.now() - timer}")
751 except Exception as e:
752 print("Error in import_property func.:", e)
753 print(f"Error in import_property line#: {e.__traceback__.tb_lineno}")
754 raise # Re-raise the exception to propagate it up the stack?
757@shared_task
758def process_property_batch(
759 batch_as_dict,
760 sample_name_column,
761 serialized_column_mapping,
762 related_sequences_column,
763):
764 # Reconstruct column_mapping from the serialized format
765 if serialized_column_mapping is not None:
766 column_mapping = {
767 k: PropertyColumnMapping(
768 db_property_name=v["db_property_name"],
769 data_type=v["data_type"],
770 default=v["default"],
771 )
772 for k, v in serialized_column_mapping.items()
773 }
774 else:
775 column_mapping = None
777 try:
778 _process_property_file(
779 batch_as_dict, sample_name_column, column_mapping, related_sequences_column
780 )
781 except Exception as e:
782 print("Error in process_property_batch func.:", e)
783 print(f"Error in process_property_batch line#: {e.__traceback__.tb_lineno}")
784 return False, f"Failed or interrupted batch job: {e}"
785 return True, "Batch processed successfully"
788def _process_property_file(
789 batch_as_dict, sample_name_column, column_mapping, related_sequences_column
790):
791 """
792 Logic for processing a batch of the property file
793 """
794 properties_df = pd.DataFrame.from_dict(batch_as_dict, dtype=object)
795 properties_df.drop_duplicates(
796 subset=[sample_name_column], keep="last", inplace=True
797 )
798 if sample_name_column == related_sequences_column:
799 properties_df[f"{sample_name_column}_name"] = properties_df[sample_name_column]
800 sample_name_column = f"{sample_name_column}_name"
801 properties_df[related_sequences_column] = properties_df[
802 related_sequences_column
803 ].apply(lambda x: x.split() if x else [])
804 sample_property_names = []
805 custom_property_names = []
807 # Separate sample properties and custom properties
808 for property_name in properties_df.columns:
809 if property_name in column_mapping.keys():
810 db_property_name = column_mapping[property_name].db_property_name
811 try:
812 models.Sample._meta.get_field(db_property_name)
813 sample_property_names.append(db_property_name)
814 except FieldDoesNotExist:
815 custom_property_names.append(property_name)
816 sample_name_set = set(properties_df[sample_name_column])
817 properties_df.convert_dtypes()
818 properties_df.set_index(sample_name_column, inplace=True)
820 # Update existing samples
821 # Fetch existing samples from the database (empty before first property import)
822 samples = models.Sample.objects.filter(name__in=sample_name_set).iterator()
823 sample_updates = []
824 property_updates = []
825 existing_sample_names = set()
826 try:
827 for sample in samples:
828 existing_sample_names.add(sample.name)
829 row = properties_df.loc[sample.name]
830 sample.last_update_date = timezone.now()
831 for name, value in row.items():
832 if name in column_mapping.keys():
833 db_name = column_mapping[name].db_property_name
834 if db_name in sample_property_names:
835 setattr(sample, db_name, value)
836 sequence_ids = row[related_sequences_column]
837 sequences = models.Sequence.objects.filter(name__in=sequence_ids)
838 sample.sequences.set(sequences) # Update the ManyToMany relationship
839 sample_updates.append(sample)
841 # Update custom properties
842 property_updates += _create_property_updates(
843 sample,
844 {
845 column_mapping[name].db_property_name: {
846 "value": (
847 value
848 if value
849 else column_mapping[name].default
850 # Fallback value if default is also None (NULL in database)
851 ),
852 "datatype": column_mapping[name].data_type,
853 }
854 for name, value in row.items()
855 if name in custom_property_names
856 },
857 use_property_cache=True,
858 property_cache=property_cache, # global variable
859 )
861 # Create new samples for names not in the database
862 new_sample_name_set = sample_name_set - existing_sample_names
863 for sample_name in new_sample_name_set:
864 row = properties_df.loc[sample_name]
865 new_sample = models.Sample(
866 name=sample_name,
867 last_update_date=timezone.now(),
868 )
869 new_sample.save() # Save to generate an ID for the new sample
870 for name, value in row.items():
871 if name in column_mapping.keys():
872 db_name = column_mapping[name].db_property_name
873 if db_name in sample_property_names:
874 setattr(new_sample, db_name, value)
876 sequence_ids = row[related_sequences_column]
877 sequences_qs = models.Sequence.objects.filter(name__in=sequence_ids)
878 new_sample.sequences.set(sequences_qs)
879 sample_updates.append(new_sample)
880 # Create custom properties for the new sample
881 property_updates += _create_property_updates(
882 new_sample,
883 {
884 column_mapping[name].db_property_name: {
885 "value": (value if value else column_mapping[name].default),
886 "datatype": column_mapping[name].data_type,
887 }
888 for name, value in row.items()
889 if name in custom_property_names
890 },
891 use_property_cache=True,
892 property_cache=property_cache, # global variable
893 )
894 except Exception as e:
895 print(f"Error :{e}")
896 print(f"Error processing sample data: {row.to_dict()}")
897 print(f"error in _process_property_file line#: {e.__traceback__.tb_lineno}")
898 raise # Or raise the exception
900 with transaction.atomic():
901 # Bulk update samples
902 # when there is no update/add to based prop.
903 if sample_property_names:
904 models.Sample.objects.bulk_update(
905 sample_updates, sample_property_names + ["last_update_date"]
906 )
907 # update custom prop. for Sample
908 serializer = Sample2PropertyBulkCreateOrUpdateSerializer(
909 data=property_updates, many=True
910 )
911 serializer.is_valid(raise_exception=True)
912 models.Sample2Property.objects.bulk_create(
913 [models.Sample2Property(**data) for data in serializer.validated_data],
914 update_conflicts=True,
915 update_fields=[
916 "value_integer",
917 "value_float",
918 "value_text",
919 "value_varchar",
920 "value_blob",
921 "value_date",
922 "value_zip",
923 ],
924 unique_fields=["sample", "property"],
925 )
928def _create_property_updates(
929 sample, properties: dict, use_property_cache=False, property_cache=None
930) -> list[dict]:
931 property_objects = []
932 if use_property_cache:
933 if property_cache is None:
934 property_cache = {}
935 # Pre-build or refresh cache for all properties
936 for name, value in properties.items():
937 # if name not in property_cache:
938 property_cache[name] = models.Property.objects.get_or_create(
939 name=name, datatype=value["datatype"]
940 )[0].id
942 for name, value in properties.items():
943 property = {"sample": sample.id, value["datatype"]: value["value"]}
944 if use_property_cache:
945 # Use the prebuilt property_cache
946 property["property"] = property_cache[name]
947 else:
948 # Fetch property directly
949 property["property"] = models.Property.objects.get_or_create(
950 name=name, datatype=value["datatype"]
951 )[0].id
953 property_objects.append(property)
954 return property_objects