nci-cidc-api-modules 1.2.34__py3-none-any.whl → 1.2.53__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 (151) hide show
  1. boot.py +14 -0
  2. cidc_api/__init__.py +1 -0
  3. cidc_api/config/db.py +21 -1
  4. cidc_api/config/settings.py +5 -10
  5. cidc_api/models/__init__.py +0 -2
  6. cidc_api/models/data.py +15 -6
  7. cidc_api/models/db/stage1/__init__.py +56 -0
  8. cidc_api/models/db/stage1/additional_treatment_orm.py +22 -0
  9. cidc_api/models/db/stage1/adverse_event_orm.py +46 -0
  10. cidc_api/models/db/stage1/base_orm.py +7 -0
  11. cidc_api/models/db/stage1/baseline_clinical_assessment_orm.py +22 -0
  12. cidc_api/models/db/stage1/comorbidity_orm.py +23 -0
  13. cidc_api/models/db/stage1/consent_group_orm.py +32 -0
  14. cidc_api/models/db/stage1/demographic_orm.py +47 -0
  15. cidc_api/models/db/stage1/disease_orm.py +52 -0
  16. cidc_api/models/db/stage1/exposure_orm.py +22 -0
  17. cidc_api/models/db/stage1/gvhd_diagnosis_acute_orm.py +34 -0
  18. cidc_api/models/db/stage1/gvhd_diagnosis_chronic_orm.py +36 -0
  19. cidc_api/models/db/stage1/gvhd_organ_acute_orm.py +21 -0
  20. cidc_api/models/db/stage1/gvhd_organ_chronic_orm.py +21 -0
  21. cidc_api/models/db/stage1/medical_history_orm.py +30 -0
  22. cidc_api/models/db/stage1/other_malignancy_orm.py +29 -0
  23. cidc_api/models/db/stage1/participant_orm.py +77 -0
  24. cidc_api/models/db/stage1/prior_treatment_orm.py +29 -0
  25. cidc_api/models/db/stage1/radiotherapy_dose_orm.py +39 -0
  26. cidc_api/models/db/stage1/response_by_system_orm.py +30 -0
  27. cidc_api/models/db/stage1/response_orm.py +28 -0
  28. cidc_api/models/db/stage1/specimen_orm.py +46 -0
  29. cidc_api/models/db/stage1/stem_cell_transplant_orm.py +25 -0
  30. cidc_api/models/db/stage1/surgery_orm.py +27 -0
  31. cidc_api/models/db/stage1/therapy_agent_dose_orm.py +31 -0
  32. cidc_api/models/db/stage1/treatment_orm.py +38 -0
  33. cidc_api/models/db/stage1/trial_orm.py +35 -0
  34. cidc_api/models/db/stage2/additional_treatment_orm.py +6 -7
  35. cidc_api/models/db/stage2/administrative_person_orm.py +4 -4
  36. cidc_api/models/db/stage2/administrative_role_assignment_orm.py +4 -4
  37. cidc_api/models/db/stage2/adverse_event_orm.py +11 -13
  38. cidc_api/models/db/stage2/arm_orm.py +3 -3
  39. cidc_api/models/db/stage2/base_orm.py +7 -0
  40. cidc_api/models/db/stage2/baseline_clinical_assessment_orm.py +5 -7
  41. cidc_api/models/db/stage2/cohort_orm.py +3 -3
  42. cidc_api/models/db/stage2/comorbidity_orm.py +6 -8
  43. cidc_api/models/db/stage2/consent_group_orm.py +4 -4
  44. cidc_api/models/db/stage2/contact_orm.py +16 -20
  45. cidc_api/models/db/stage2/demographic_orm.py +3 -3
  46. cidc_api/models/db/stage2/disease_orm.py +4 -4
  47. cidc_api/models/db/stage2/exposure_orm.py +3 -3
  48. cidc_api/models/db/stage2/file_orm.py +6 -9
  49. cidc_api/models/db/stage2/gvhd_diagnosis_acute_orm.py +4 -4
  50. cidc_api/models/db/stage2/gvhd_diagnosis_chronic_orm.py +4 -6
  51. cidc_api/models/db/stage2/gvhd_organ_acute_orm.py +3 -3
  52. cidc_api/models/db/stage2/gvhd_organ_chronic_orm.py +3 -3
  53. cidc_api/models/db/stage2/institution_orm.py +7 -7
  54. cidc_api/models/db/stage2/medical_history_orm.py +9 -9
  55. cidc_api/models/db/stage2/other_clinical_endpoint_orm.py +8 -12
  56. cidc_api/models/db/stage2/other_malignancy_orm.py +8 -10
  57. cidc_api/models/db/stage2/participant_orm.py +23 -24
  58. cidc_api/models/db/stage2/prior_treatment_orm.py +12 -13
  59. cidc_api/models/db/stage2/publication_orm.py +9 -11
  60. cidc_api/models/db/stage2/radiotherapy_dose_orm.py +8 -9
  61. cidc_api/models/db/stage2/response_by_system_orm.py +3 -3
  62. cidc_api/models/db/stage2/response_orm.py +3 -3
  63. cidc_api/models/db/stage2/shipment_orm.py +17 -17
  64. cidc_api/models/db/stage2/shipment_specimen_orm.py +4 -4
  65. cidc_api/models/db/stage2/specimen_orm.py +7 -6
  66. cidc_api/models/db/stage2/stem_cell_transplant_orm.py +6 -7
  67. cidc_api/models/db/stage2/surgery_orm.py +6 -7
  68. cidc_api/models/db/stage2/therapy_agent_dose_orm.py +7 -8
  69. cidc_api/models/db/stage2/treatment_orm.py +15 -15
  70. cidc_api/models/db/stage2/trial_orm.py +15 -17
  71. cidc_api/models/errors.py +7 -0
  72. cidc_api/models/files/facets.py +4 -0
  73. cidc_api/models/models.py +167 -11
  74. cidc_api/models/pydantic/base.py +109 -0
  75. cidc_api/models/pydantic/stage1/__init__.py +56 -0
  76. cidc_api/models/pydantic/stage1/additional_treatment.py +23 -0
  77. cidc_api/models/pydantic/stage1/adverse_event.py +127 -0
  78. cidc_api/models/pydantic/stage1/baseline_clinical_assessment.py +23 -0
  79. cidc_api/models/pydantic/stage1/comorbidity.py +43 -0
  80. cidc_api/models/pydantic/stage1/consent_group.py +30 -0
  81. cidc_api/models/pydantic/stage1/demographic.py +140 -0
  82. cidc_api/models/pydantic/stage1/disease.py +200 -0
  83. cidc_api/models/pydantic/stage1/exposure.py +38 -0
  84. cidc_api/models/pydantic/stage1/gvhd_diagnosis_acute.py +33 -0
  85. cidc_api/models/pydantic/stage1/gvhd_diagnosis_chronic.py +32 -0
  86. cidc_api/models/pydantic/stage1/gvhd_organ_acute.py +22 -0
  87. cidc_api/models/pydantic/stage1/gvhd_organ_chronic.py +23 -0
  88. cidc_api/models/pydantic/stage1/medical_history.py +43 -0
  89. cidc_api/models/pydantic/stage1/other_malignancy.py +55 -0
  90. cidc_api/models/pydantic/stage1/participant.py +63 -0
  91. cidc_api/models/pydantic/stage1/prior_treatment.py +45 -0
  92. cidc_api/models/pydantic/stage1/radiotherapy_dose.py +92 -0
  93. cidc_api/models/pydantic/stage1/response.py +84 -0
  94. cidc_api/models/pydantic/stage1/response_by_system.py +220 -0
  95. cidc_api/models/pydantic/stage1/specimen.py +31 -0
  96. cidc_api/models/pydantic/stage1/stem_cell_transplant.py +35 -0
  97. cidc_api/models/pydantic/stage1/surgery.py +57 -0
  98. cidc_api/models/pydantic/stage1/therapy_agent_dose.py +80 -0
  99. cidc_api/models/pydantic/stage1/treatment.py +64 -0
  100. cidc_api/models/pydantic/stage1/trial.py +45 -0
  101. cidc_api/models/pydantic/stage2/additional_treatment.py +2 -4
  102. cidc_api/models/pydantic/stage2/administrative_person.py +1 -1
  103. cidc_api/models/pydantic/stage2/administrative_role_assignment.py +2 -2
  104. cidc_api/models/pydantic/stage2/adverse_event.py +1 -1
  105. cidc_api/models/pydantic/stage2/arm.py +2 -2
  106. cidc_api/models/pydantic/stage2/baseline_clinical_assessment.py +1 -1
  107. cidc_api/models/pydantic/stage2/cohort.py +1 -1
  108. cidc_api/models/pydantic/stage2/comorbidity.py +1 -1
  109. cidc_api/models/pydantic/stage2/consent_group.py +2 -2
  110. cidc_api/models/pydantic/stage2/contact.py +1 -1
  111. cidc_api/models/pydantic/stage2/demographic.py +1 -1
  112. cidc_api/models/pydantic/stage2/disease.py +1 -1
  113. cidc_api/models/pydantic/stage2/exposure.py +1 -1
  114. cidc_api/models/pydantic/stage2/file.py +2 -2
  115. cidc_api/models/pydantic/stage2/gvhd_diagnosis_acute.py +1 -1
  116. cidc_api/models/pydantic/stage2/gvhd_diagnosis_chronic.py +1 -1
  117. cidc_api/models/pydantic/stage2/gvhd_organ_acute.py +1 -1
  118. cidc_api/models/pydantic/stage2/gvhd_organ_chronic.py +1 -1
  119. cidc_api/models/pydantic/stage2/institution.py +1 -1
  120. cidc_api/models/pydantic/stage2/medical_history.py +1 -1
  121. cidc_api/models/pydantic/stage2/other_clinical_endpoint.py +1 -1
  122. cidc_api/models/pydantic/stage2/other_malignancy.py +1 -1
  123. cidc_api/models/pydantic/stage2/participant.py +6 -3
  124. cidc_api/models/pydantic/stage2/prior_treatment.py +6 -15
  125. cidc_api/models/pydantic/stage2/publication.py +2 -2
  126. cidc_api/models/pydantic/stage2/radiotherapy_dose.py +1 -1
  127. cidc_api/models/pydantic/stage2/response.py +2 -2
  128. cidc_api/models/pydantic/stage2/response_by_system.py +1 -1
  129. cidc_api/models/pydantic/stage2/shipment.py +2 -2
  130. cidc_api/models/pydantic/stage2/shipment_specimen.py +1 -1
  131. cidc_api/models/pydantic/stage2/specimen.py +6 -3
  132. cidc_api/models/pydantic/stage2/stem_cell_transplant.py +2 -2
  133. cidc_api/models/pydantic/stage2/surgery.py +1 -1
  134. cidc_api/models/pydantic/stage2/therapy_agent_dose.py +1 -1
  135. cidc_api/models/pydantic/stage2/treatment.py +1 -1
  136. cidc_api/models/pydantic/stage2/trial.py +8 -10
  137. cidc_api/models/types.py +30 -16
  138. cidc_api/shared/assay_handling.py +68 -0
  139. cidc_api/shared/auth.py +5 -5
  140. cidc_api/shared/file_handling.py +18 -4
  141. cidc_api/shared/gcloud_client.py +96 -16
  142. cidc_api/shared/utils.py +18 -9
  143. cidc_api/telemetry.py +101 -0
  144. {nci_cidc_api_modules-1.2.34.dist-info → nci_cidc_api_modules-1.2.53.dist-info}/METADATA +25 -15
  145. nci_cidc_api_modules-1.2.53.dist-info/RECORD +167 -0
  146. {nci_cidc_api_modules-1.2.34.dist-info → nci_cidc_api_modules-1.2.53.dist-info}/WHEEL +1 -1
  147. {nci_cidc_api_modules-1.2.34.dist-info → nci_cidc_api_modules-1.2.53.dist-info}/top_level.txt +1 -0
  148. cidc_api/models/db/base_orm.py +0 -25
  149. cidc_api/models/pydantic/stage2/base.py +0 -48
  150. nci_cidc_api_modules-1.2.34.dist-info/RECORD +0 -109
  151. {nci_cidc_api_modules-1.2.34.dist-info → nci_cidc_api_modules-1.2.53.dist-info}/licenses/LICENSE +0 -0
cidc_api/models/models.py CHANGED
@@ -28,6 +28,7 @@ __all__ = [
28
28
  "JobFileCategories",
29
29
  "CategoryDataElements",
30
30
  "ValidationConfigs",
31
+ "MASTER_APPENDIX_A",
31
32
  "TRIAL_APPENDIX_A",
32
33
  "TRIAL_APPENDIX_A_CELL_THAT_ENDS_THE_HEADER",
33
34
  "REQUEST_LETTER",
@@ -35,7 +36,9 @@ __all__ = [
35
36
  "ADMIN_FILE_CATEGORIES",
36
37
  "FINAL_JOB_STATUS",
37
38
  "INGESTION_JOB_STATUSES",
38
- "INGESTION_JOB_COLORS",
39
+ "INGESTION_PHASE_STATUSES",
40
+ "ASSAY_JOB_COLORS",
41
+ "CLINICAL_JOB_COLORS",
39
42
  ]
40
43
 
41
44
  import hashlib
@@ -1046,11 +1049,11 @@ class Permissions(CommonColumns):
1046
1049
  user_email_list.append(user.email)
1047
1050
  grant_lister_access(user.email)
1048
1051
 
1049
- if upload.upload_type in prism.SUPPORTED_SHIPPING_MANIFESTS:
1052
+ if upload.upload_type in prism.SUPPORTED_MANIFESTS:
1050
1053
  # Passed with empty user email list because they will be queried for in CFn
1051
1054
  grant_download_access([], upload.trial_id, "participants info")
1052
1055
  grant_download_access([], upload.trial_id, "samples info")
1053
- elif upload.upload_type not in prism.SUPPORTED_WEIRD_MANIFESTS:
1056
+ else:
1054
1057
  grant_download_access(user_email_list, upload.trial_id, upload.upload_type)
1055
1058
 
1056
1059
  @staticmethod
@@ -3261,6 +3264,7 @@ def upload_manifest_json(
3261
3264
  return manifest_upload.id
3262
3265
 
3263
3266
 
3267
+ MASTER_APPENDIX_A = "master_appendix_a"
3264
3268
  TRIAL_APPENDIX_A = "trial_appendix_a"
3265
3269
  REQUEST_LETTER = "request_letter"
3266
3270
  DETAILED_VALIDATION = "detailed_validation"
@@ -3360,6 +3364,16 @@ class PreprocessedFiles(CommonColumns):
3360
3364
  query = cls.add_job_filter(query, job_id)
3361
3365
  return query.all()
3362
3366
 
3367
+ @classmethod
3368
+ @with_default_session
3369
+ def get_latest_current_file(
3370
+ cls, file_category: str, job_id: int = None, session: Session = None
3371
+ ) -> Optional["PreprocessedFiles"]:
3372
+ """Return the latest 'current' file for the given category and job_id. Returns None if no file exists."""
3373
+ query = session.query(cls).filter_by(file_category=file_category, status="current")
3374
+ query = cls.add_job_filter(query, job_id)
3375
+ return query.order_by(cls.version.desc()).first()
3376
+
3363
3377
  @classmethod
3364
3378
  @with_default_session
3365
3379
  def get_file_by_category_and_version(
@@ -3435,8 +3449,10 @@ INGESTION_JOB_STATUSES = [
3435
3449
  "PUBLISHED",
3436
3450
  ]
3437
3451
 
3452
+ INGESTION_PHASE_STATUSES = ["NOT STARTED", "SUCCESS", "FAILED"]
3453
+
3438
3454
  # Business decision to pass hex codes from the backend though that should be done by the front end...
3439
- INGESTION_JOB_COLORS = {
3455
+ CLINICAL_JOB_COLORS = {
3440
3456
  "DRAFT": "",
3441
3457
  "INITIAL SUBMISSION": "#ACCAD7",
3442
3458
  "VALIDATION REVIEW": "#DABE90",
@@ -3444,6 +3460,13 @@ INGESTION_JOB_COLORS = {
3444
3460
  "INGESTION": "#8FCEC7",
3445
3461
  "PUBLISHED": "#90D9E6",
3446
3462
  }
3463
+ ASSAY_JOB_COLORS = {
3464
+ "INITIAL SUBMISSION": "#43807E",
3465
+ "VALIDATION REVIEW": "#906F3F",
3466
+ "REVISION SUBMISSION": "#95358A",
3467
+ "INGESTION": "#542C88",
3468
+ "PUBLISHED": "#1C81A0",
3469
+ }
3447
3470
  # TODO If have "CANCELLED" concept or other final status, add here
3448
3471
  FINAL_JOB_STATUS = ["PUBLISHED"]
3449
3472
  TRIAL_APPENDIX_A_CELL_THAT_ENDS_THE_HEADER = "Data Category"
@@ -3465,11 +3488,54 @@ class IngestionJobs(CommonColumns):
3465
3488
  pending = Column(Boolean, nullable=False, default=False)
3466
3489
  start_date = Column(DateTime, nullable=True)
3467
3490
  error_status = Column(String, nullable=True)
3491
+ job_type = Column(String, nullable=False, default="clinical")
3492
+ assay_type = Column(String, nullable=True)
3493
+ batch_id = Column(String, nullable=True)
3494
+ submission_id = Column(String, nullable=True)
3495
+ intake_path = Column(String, nullable=True)
3496
+ uploader_email = Column(String, nullable=True)
3497
+ is_template_downloaded = Column(Boolean, nullable=True)
3498
+ master_aa_version = Column(Integer, nullable=True)
3499
+ ingestion_phase_status = Column(
3500
+ "ingestion_phase_status",
3501
+ Enum(*INGESTION_PHASE_STATUSES, name="ingestion_phase_status"),
3502
+ nullable=False,
3503
+ default=INGESTION_PHASE_STATUSES[0],
3504
+ )
3468
3505
 
3469
3506
  @staticmethod
3470
3507
  @with_default_session
3471
- def create(trial_id: str, status: str, version: int, pending: Boolean = False, session: Session = None):
3472
- new_job = IngestionJobs(trial_id=trial_id, status=status, version=version, pending=pending)
3508
+ def create(
3509
+ trial_id: str,
3510
+ status: str,
3511
+ version: int,
3512
+ error_status: str = None,
3513
+ pending: Boolean = False,
3514
+ job_type: str = "clinical",
3515
+ ingestion_phase_status=INGESTION_PHASE_STATUSES[0],
3516
+ assay_type: str = None,
3517
+ batch_id: str = None,
3518
+ submission_id: str = None,
3519
+ intake_path: str = None,
3520
+ start_date: datetime = None,
3521
+ uploader_email: str = None,
3522
+ session: Session = None,
3523
+ ):
3524
+ new_job = IngestionJobs(
3525
+ trial_id=trial_id,
3526
+ status=status,
3527
+ error_status=error_status,
3528
+ version=version,
3529
+ pending=pending,
3530
+ job_type=job_type,
3531
+ assay_type=assay_type,
3532
+ batch_id=batch_id,
3533
+ submission_id=submission_id,
3534
+ intake_path=intake_path,
3535
+ start_date=start_date,
3536
+ uploader_email=uploader_email,
3537
+ ingestion_phase_status=ingestion_phase_status,
3538
+ )
3473
3539
  new_job.insert(session=session)
3474
3540
  return new_job
3475
3541
 
@@ -3494,29 +3560,43 @@ class IngestionJobs(CommonColumns):
3494
3560
 
3495
3561
  @classmethod
3496
3562
  @with_default_session
3497
- def get_jobs_by_trial(cls, trial_id: str, session: Session = None) -> list["IngestionJobs"]:
3498
- return session.query(cls).filter(cls.trial_id == trial_id).order_by(cls.version.desc()).all()
3563
+ def get_jobs_by_trial(
3564
+ cls, trial_id: str, job_type: str = "clinical", session: Session = None
3565
+ ) -> list["IngestionJobs"]:
3566
+ return (
3567
+ session.query(cls)
3568
+ .filter(cls.trial_id == trial_id, cls.job_type == job_type)
3569
+ .order_by(cls.version.desc())
3570
+ .all()
3571
+ )
3499
3572
 
3500
3573
  @classmethod
3501
3574
  @with_default_session
3502
- def get_open_job_by_trial(cls, trial_id: str, session: Session = None) -> Optional["IngestionJobs"]:
3575
+ def get_open_job_by_trial(
3576
+ cls, trial_id: str, job_type: str = "clinical", session: Session = None
3577
+ ) -> Optional["IngestionJobs"]:
3503
3578
  """Return the open job for a given trial if it exists."""
3504
3579
  return (
3505
3580
  session.query(cls)
3506
3581
  .filter(
3507
3582
  cls.trial_id == trial_id,
3583
+ cls.job_type == job_type,
3508
3584
  cls.status.notin_(FINAL_JOB_STATUS),
3509
3585
  )
3510
3586
  .order_by(cls._created.desc())
3511
3587
  .first()
3512
3588
  )
3513
3589
 
3590
+ @classmethod
3591
+ def get_jobs_for_user(cls, user: Users, job_type: str = None) -> list["IngestionJobs"]:
3592
+ return cls.get_assay_jobs_for_user(user) if job_type == "assay" else cls.get_clinical_jobs_for_user(user)
3593
+
3514
3594
  @classmethod
3515
3595
  @with_default_session
3516
- def get_open_jobs_for_user(cls, user: Users, session: Session = None) -> list["IngestionJobs"]:
3596
+ def get_clinical_jobs_for_user(cls, user: Users, session: Session = None) -> list["IngestionJobs"]:
3517
3597
  if user.role not in [CIDCRole.ADMIN.value, CIDCRole.CLINICAL_TRIAL_USER.value]:
3518
3598
  return []
3519
- job_query = session.query(cls).filter(cls.status.notin_(["DRAFT"]))
3599
+ job_query = session.query(cls).filter(cls.status.notin_(["DRAFT"]), cls.job_type == "clinical")
3520
3600
  if (
3521
3601
  user.role != CIDCRole.ADMIN.value
3522
3602
  and not session.query(Permissions)
@@ -3539,6 +3619,81 @@ class IngestionJobs(CommonColumns):
3539
3619
  job_query = job_query.filter(cls.trial_id.in_(map(lambda x: x.trial_id, authorized_trials)))
3540
3620
  return job_query.order_by(cls._created.desc()).all()
3541
3621
 
3622
+ @classmethod
3623
+ @with_default_session
3624
+ def get_assay_jobs_for_user(cls, user: Users, session: Session = None) -> list["IngestionJobs"]:
3625
+ # TODO allow more than just Admin role and get authorized trials based on permissions
3626
+ if user.role not in [CIDCRole.ADMIN.value]:
3627
+ return []
3628
+ return session.query(cls).filter(cls.job_type == "assay").order_by(cls._created.desc()).all()
3629
+
3630
+ @classmethod
3631
+ @with_default_session
3632
+ def get_unique_assay_job(
3633
+ cls,
3634
+ trial_id: str,
3635
+ assay_type: str,
3636
+ batch_id: str,
3637
+ session: Session = None,
3638
+ ) -> Optional["IngestionJobs"]:
3639
+ """Look for unique assay job with matching trial_id/assay_type/batch_id combination."""
3640
+ return (
3641
+ session.query(cls)
3642
+ .filter(
3643
+ cls.job_type == "assay",
3644
+ cls.trial_id == trial_id,
3645
+ cls.assay_type == assay_type,
3646
+ cls.batch_id == batch_id,
3647
+ )
3648
+ .first()
3649
+ )
3650
+
3651
+ @classmethod
3652
+ @with_default_session
3653
+ def next_assay_submission_id(cls, trial_id: str, assay_type: str, session: Session = None) -> str:
3654
+ """
3655
+ Generate the next CIDC Submission ID for an assay job.
3656
+
3657
+ Format:
3658
+ <trial_id>-<assay_type>-<yyyymmdd> (first submission of the day)
3659
+ <trial_id>-<assay_type>-<yyyymmdd>-<#> (subsequent submissions on same day)
3660
+
3661
+ Uses only the most recent matching submission_id to determine the next suffix.
3662
+ """
3663
+ today_str = datetime.now().strftime("%Y%m%d")
3664
+ base_submission_id = f"{trial_id}-{assay_type}-{today_str}"
3665
+
3666
+ # Get the most recent submission_id matching this prefix
3667
+ latest = (
3668
+ session.query(cls.submission_id)
3669
+ .filter(
3670
+ cls.trial_id == trial_id,
3671
+ cls.assay_type == assay_type,
3672
+ cls.submission_id.like(f"{base_submission_id}%"),
3673
+ )
3674
+ .order_by(cls._created.desc())
3675
+ .first()
3676
+ )
3677
+
3678
+ # No existing submission for this prefix -> start at 1
3679
+ if not latest or not latest[0]:
3680
+ return base_submission_id
3681
+
3682
+ last_id = latest[0]
3683
+ # Case 1: the latest is exactly the prefix (i.e., first submission today)
3684
+ if last_id == base_submission_id:
3685
+ return f"{base_submission_id}-2"
3686
+
3687
+ # Case 2: latest already has a suffix
3688
+ try:
3689
+ _, last_suffix = last_id.rsplit("-", 1)
3690
+ n = int(last_suffix)
3691
+ return f"{base_submission_id}-{n + 1}"
3692
+ except Exception as e:
3693
+ # If malformed, restart numbering for safety
3694
+ logger.error("Unexpected error parsing Submission ID in next_assay_submission_id: %s", e)
3695
+ return f"{base_submission_id}-2"
3696
+
3542
3697
 
3543
3698
  class JobFileCategories(CommonColumns):
3544
3699
  __tablename__ = "job_file_categories"
@@ -3613,6 +3768,7 @@ class CategoryDataElements(CommonColumns):
3613
3768
  name = Column(String, nullable=False)
3614
3769
  is_custom = Column(Boolean, nullable=False, default=False, server_default="false")
3615
3770
  element_type = Column(String, nullable=False)
3771
+ data_type = Column(String, nullable=True)
3616
3772
  cardinality = Column(String, nullable=True)
3617
3773
 
3618
3774
  @classmethod
@@ -0,0 +1,109 @@
1
+ import copy
2
+ from contextlib import contextmanager
3
+ from typing import Self, ClassVar
4
+ from pydantic import BaseModel, ConfigDict, model_validator, ValidationError
5
+ from pydantic_core import InitErrorDetails
6
+
7
+ from cidc_api.models.errors import ValueLocError
8
+ from functools import wraps
9
+
10
+
11
+ class Base(BaseModel):
12
+
13
+ model_config = ConfigDict(
14
+ validate_assignment=True,
15
+ from_attributes=True,
16
+ extra="allow",
17
+ )
18
+ forced_validators: ClassVar = []
19
+
20
+ # Validates the new state and updates the object if valid
21
+ def update(self, **kwargs):
22
+ self.model_validate(self.__dict__ | kwargs)
23
+ self.__dict__.update(kwargs)
24
+
25
+ # CM that delays validation until all fields are applied.
26
+ # If validation fails the original fields are restored and the ValidationError is raised.
27
+ @contextmanager
28
+ def delay_validation(self):
29
+ original_dict = copy.deepcopy(self.__dict__)
30
+ self.model_config["validate_assignment"] = False
31
+ try:
32
+ yield
33
+ finally:
34
+ self.model_config["validate_assignment"] = True
35
+ try:
36
+ self.model_validate(self.__dict__)
37
+ except:
38
+ self.__dict__.update(original_dict)
39
+ raise
40
+
41
+ # CM that delays validation until all fields are applied.
42
+
43
+ @classmethod
44
+ def split_list(cls, val):
45
+ """Listify fields that are multi-valued in input data, e.g. 'lung|kidney'"""
46
+ if type(val) == list:
47
+ return val
48
+ elif type(val) == str:
49
+ if not val:
50
+ return []
51
+ return val.split("|")
52
+ elif val == None:
53
+ return []
54
+ else:
55
+ raise ValueError("Field value must be string or list")
56
+
57
+ @model_validator(mode="wrap")
58
+ @classmethod
59
+ def check_all_forced_validators(cls, data, handler, info) -> Self:
60
+ """This base validator ensures all registered forced_validator(s) get called along with
61
+ normal model field validators no matter what the outcome of either is. Normal field_validator
62
+ and model_validator decorators don't guarantee this. We want to collect as many errors as we can.
63
+
64
+ Collects errors from both attempts and raises them in a combined ValidationError."""
65
+
66
+ validation_errors = []
67
+ # When assigning attributes to an already-hydrated model, data is an instance of the model
68
+ # instead of a dict and handler is an AssignmentValidatorCallable instead of a ValidatorCallable(!!)
69
+ extracted_data = data.model_dump() if not isinstance(data, dict) else data
70
+
71
+ for validator in cls.forced_validators:
72
+ try:
73
+ func_name = validator.__name__
74
+ func = getattr(cls, func_name)
75
+ func(extracted_data, info)
76
+ except (ValueError, ValueLocError) as e:
77
+ validation_errors.append(
78
+ InitErrorDetails(
79
+ type="value_error",
80
+ loc=e.loc,
81
+ input=extracted_data,
82
+ ctx={"error": e},
83
+ )
84
+ )
85
+ try:
86
+ # Instantiate the model to ensure all other normal field validations are called
87
+ retval = handler(data)
88
+ except ValidationError as e:
89
+ validation_errors.extend(e.errors())
90
+ if validation_errors:
91
+ raise ValidationError.from_exception_data(title=cls.__name__, line_errors=validation_errors)
92
+ return retval
93
+
94
+
95
+ def forced_validator(func):
96
+ """A method marked with this decorator is added to the class list of forced_validators"""
97
+ func._is_forced_validator = True # Tag the function object with a custom attribute
98
+ return func
99
+
100
+
101
+ def forced_validators(cls):
102
+ """A class marked with this decorator accumulates its methods marked with force_validator into a list
103
+ for later invocation."""
104
+
105
+ cls.forced_validators = []
106
+ for obj in cls.__dict__.values():
107
+ if getattr(obj, "_is_forced_validator", False):
108
+ cls.forced_validators.append(obj)
109
+ return cls
@@ -0,0 +1,56 @@
1
+ from .additional_treatment import AdditionalTreatment
2
+ from .adverse_event import AdverseEvent
3
+ from .baseline_clinical_assessment import BaselineClinicalAssessment
4
+ from .comorbidity import Comorbidity
5
+ from .consent_group import ConsentGroup
6
+ from .demographic import Demographic
7
+ from .disease import Disease
8
+ from .exposure import Exposure
9
+ from .gvhd_diagnosis_acute import GVHDDiagnosisAcute
10
+ from .gvhd_diagnosis_chronic import GVHDDiagnosisChronic
11
+ from .gvhd_organ_acute import GVHDOrganAcute
12
+ from .gvhd_organ_chronic import GVHDOrganChronic
13
+ from .medical_history import MedicalHistory
14
+ from .other_malignancy import OtherMalignancy
15
+ from .participant import Participant
16
+ from .prior_treatment import PriorTreatment
17
+ from .radiotherapy_dose import RadiotherapyDose
18
+ from .response import Response
19
+ from .response_by_system import ResponseBySystem
20
+ from .specimen import Specimen
21
+ from .stem_cell_transplant import StemCellTransplant
22
+ from .surgery import Surgery
23
+ from .therapy_agent_dose import TherapyAgentDose
24
+ from .treatment import Treatment
25
+ from .trial import Trial
26
+
27
+
28
+ __all__ = [
29
+ "AdditionalTreatment",
30
+ "AdverseEvent",
31
+ "BaselineClinicalAssessment",
32
+ "Comorbidity",
33
+ "ConsentGroup",
34
+ "Demographic",
35
+ "Disease",
36
+ "Exposure",
37
+ "GVHDDiagnosisAcute",
38
+ "GVHDOrganAcute",
39
+ "GVHDDiagnosisChronic",
40
+ "GVHDOrganChronic",
41
+ "MedicalHistory",
42
+ "OtherMalignancy",
43
+ "Participant",
44
+ "PriorTreatment",
45
+ "RadiotherapyDose",
46
+ "Response",
47
+ "ResponseBySystem",
48
+ "Specimen",
49
+ "StemCellTransplant",
50
+ "Surgery",
51
+ "TherapyAgentDose",
52
+ "Treatment",
53
+ "Trial",
54
+ ]
55
+
56
+ all_models = [globals()[cls_name] for cls_name in __all__]
@@ -0,0 +1,23 @@
1
+ from pydantic import NonNegativeInt
2
+ from cidc_api.models.pydantic.base import Base
3
+
4
+
5
+ class AdditionalTreatment(Base):
6
+ __data_category__ = "additional_treatment"
7
+ __cardinality__ = "many"
8
+
9
+ # The unique internal identifier for the AdditionalTreatment record
10
+ additional_treatment_id: int | None = None
11
+
12
+ # The unique internal identifier for the associated Participant record
13
+ participant_id: str | None = None
14
+
15
+ # Number of days from the enrollment date to the first recorded administration or occurrence of the treatment modality.
16
+ additional_treatment_days_to_start: NonNegativeInt | None = None
17
+
18
+ # Number of days from the enrollment date to the last recorded administration or occurrence of the treatment modality.
19
+ additional_treatment_days_to_end: NonNegativeInt | None = None
20
+
21
+ # Description of the prior treatment such as its full generic name if it is a type of therapy agent, radiotherapy procedure
22
+ # name and location, or surgical procedure name and location.
23
+ additional_treatment_description: str
@@ -0,0 +1,127 @@
1
+ from pydantic import NonNegativeInt
2
+
3
+ from cidc_api.models.pydantic.base import Base
4
+ from cidc_api.reference.ctcae import is_ctcae_other_term
5
+ from cidc_api.models.pydantic.base import forced_validator, forced_validators
6
+ from cidc_api.models.errors import ValueLocError
7
+ from cidc_api.models.types import (
8
+ CTCAEEventTerm,
9
+ CTCAEEventCode,
10
+ SeverityGradeSystem,
11
+ SeverityGradeSystemVersion,
12
+ SeverityGrade,
13
+ SystemOrganClass,
14
+ AttributionCause,
15
+ AttributionLikelihood,
16
+ YNU,
17
+ )
18
+
19
+
20
+ @forced_validators
21
+ class AdverseEvent(Base):
22
+ __data_category__ = "adverse_event"
23
+ __cardinality__ = "many"
24
+
25
+ # The unique internal identifier of the adverse event
26
+ adverse_event_id: int | None = None
27
+
28
+ # The unique internal identifier of the associated participant
29
+ participant_id: str | None = None
30
+
31
+ # The unique internal identifier of the attributed treatment, if any
32
+ treatment_id: int | None = None
33
+
34
+ # Text that represents the Common Terminology Criteria for Adverse Events low level term name for an adverse event.
35
+ event_term: CTCAEEventTerm | None = None
36
+
37
+ # A MedDRA code mapped to a CTCAE low level name for an adverse event.
38
+ event_code: CTCAEEventCode | None = None
39
+
40
+ # System used to define and report adverse event severity grade.
41
+ severity_grade_system: SeverityGradeSystem
42
+
43
+ # The version of the adverse event grading system.
44
+ severity_grade_system_version: SeverityGradeSystemVersion
45
+
46
+ # Numerical grade indicating the severity of an adverse event.
47
+ severity_grade: SeverityGrade
48
+
49
+ # A brief description that sufficiently details the event.
50
+ event_other_specify: str | None = None
51
+
52
+ # The highest level of the MedDRA hierarchy, distinguished by anatomical or physiological system, etiology (disease origin) or purpose.
53
+ system_organ_class: SystemOrganClass | None = None
54
+
55
+ # Indicator to identify whether a participant exited the study prematurely due to the adverse event being described.
56
+ discontinuation_due_to_event: bool
57
+
58
+ # Days from enrollment date to date of onset of the adverse event.
59
+ days_to_onset_of_event: NonNegativeInt
60
+
61
+ # Days from enrollment date to date of resolution of the adverse event.
62
+ days_to_resolution_of_event: NonNegativeInt | None = None
63
+
64
+ # Indicates whether the adverse event was a serious adverse event (SAE).
65
+ serious_adverse_event: YNU
66
+
67
+ # Indicates whether the adverse event was a dose-limiting toxicity (DLT).
68
+ dose_limiting_toxicity: YNU
69
+
70
+ # Indicates if the adverse was attributable to the protocol as a whole or to an individual treatment.
71
+ attribution_cause: AttributionCause
72
+
73
+ # The code that indicates whether the adverse event is related to the treatment/intervention.
74
+ attribution_likelihood: AttributionLikelihood
75
+
76
+ # The individual therapy (therapy agent, radiotherapy, surgery, stem cell transplant) in the treatment that is attributed to the adverse event.
77
+ individual_therapy: str | None = None
78
+
79
+ @forced_validator
80
+ @classmethod
81
+ def validate_term_and_code_cr(cls, data, info) -> None:
82
+ event_code = data.get("event_code", None)
83
+ event_term = data.get("event_term", None)
84
+
85
+ if not event_term and not event_code:
86
+ raise ValueLocError(
87
+ "Please provide event_term or event_code or both",
88
+ loc="event_term,event_code",
89
+ )
90
+
91
+ @forced_validator
92
+ @classmethod
93
+ def validate_event_other_specify_cr(cls, data, info) -> None:
94
+ event_other_specify = data.get("event_other_specify", None)
95
+ severity_grade_system = data.get("severity_grade_system", None)
96
+ event_term = data.get("event_term", None)
97
+
98
+ if severity_grade_system == "CTCAE" and is_ctcae_other_term(event_term) and not event_other_specify:
99
+ raise ValueLocError(
100
+ 'If severity_grade_system is "CTCAE" and the event_code or event_term are of type '
101
+ '"Other, specify", please provide event_other_specify',
102
+ loc="event_other_specify",
103
+ )
104
+
105
+ @forced_validator
106
+ @classmethod
107
+ def validate_system_organ_class_cr(cls, data, info) -> None:
108
+ event_other_specify = data.get("event_other_specify", None)
109
+ system_organ_class = data.get("system_organ_class", None)
110
+
111
+ if event_other_specify and not system_organ_class:
112
+ raise ValueLocError(
113
+ "If event_other_specify is provided, please provide system_organ_class.", loc="system_organ_class"
114
+ )
115
+
116
+ @forced_validator
117
+ @classmethod
118
+ def validate_days_to_resolution_of_event_chronology(cls, data, info) -> None:
119
+ days_to_onset_of_event = data.get("days_to_onset_of_event", None)
120
+ days_to_resolution_of_event = data.get("days_to_resolution_of_event", None)
121
+
122
+ if days_to_resolution_of_event is not None and days_to_onset_of_event is not None:
123
+ if int(days_to_resolution_of_event) < int(days_to_onset_of_event):
124
+ raise ValueLocError(
125
+ 'Violate "days_to_onset_of_event" <= "days_to_resolution_of_event"',
126
+ loc="days_to_resolution_of_event",
127
+ )
@@ -0,0 +1,23 @@
1
+ from cidc_api.models.pydantic.base import Base
2
+ from cidc_api.models.types import ECOGScore, KarnofskyScore
3
+
4
+
5
+ class BaselineClinicalAssessment(Base):
6
+ __data_category__ = "baseline_clinical_assessment"
7
+ __cardinality__ = "one"
8
+
9
+ # A unique internal identifier for the baseline clinical assessment
10
+ baseline_clinical_assessment_id: int | None = None
11
+
12
+ # The unique identifier for the associated participant
13
+ participant_id: str | None = None
14
+
15
+ # The numerical score that represents the functional capabilities of a participant at the
16
+ # enrollment date using the Eastern Cooperative Oncology Group Performance Status assessment.
17
+ # CDE: https://cadsr.cancer.gov/onedata/dmdirect/NIH/NCI/CO/CDEDD?filter=CDEDD.ITEM_ID=88%20and%20ver_nr=5.1
18
+ ecog_score: ECOGScore | None = None
19
+
20
+ # Score from the Karnofsky Performance status scale, representing the functional capabilities of a participant
21
+ # at the enrollment date.
22
+ # CDE: https://cadsr.cancer.gov/onedata/dmdirect/NIH/NCI/CO/CDEDD?filter=CDEDD.ITEM_ID=2003853%20and%20ver_nr=4.2
23
+ karnofsky_score: KarnofskyScore | None = None
@@ -0,0 +1,43 @@
1
+ from typing import Any
2
+
3
+ from cidc_api.models.pydantic.base import forced_validator, forced_validators
4
+
5
+ from cidc_api.models.errors import ValueLocError
6
+ from cidc_api.models.pydantic.base import Base
7
+ from cidc_api.models.types import ICD10CMCode, ICD10CMTerm
8
+
9
+
10
+ @forced_validators
11
+ class Comorbidity(Base):
12
+ __data_category__ = "comorbidity"
13
+ __cardinality__ = "many"
14
+
15
+ # The unique internal identifier for the comorbidity record
16
+ comorbidity_id: int | None = None
17
+
18
+ # The unique internal identifier for the associated MedicalHistory record
19
+ medical_history_id: int | None = None
20
+
21
+ # The diagnosis, in humans, as captured in the tenth version of the
22
+ # International Classification of Disease (ICD-10-CM, the disease code subset of ICD-10).
23
+ comorbidity_code: ICD10CMCode | None = None
24
+
25
+ # The words from the tenth version of the International Classification of Disease (ICD-10-CM,
26
+ # the disease subset of ICD-10) used to identify the diagnosis in humans.
27
+ comorbidity_term: ICD10CMTerm | None = None
28
+
29
+ # A descriptive string that names or briefly describes the comorbidity.
30
+ comorbidity_other: str | None = None
31
+
32
+ @forced_validator
33
+ @classmethod
34
+ def validate_code_or_term_or_other_cr(cls, data, info) -> None:
35
+ comorbidity_term = data.get("comorbidity_term", None)
36
+ comorbidity_other = data.get("comorbidity_other", None)
37
+ comorbidity_code = data.get("comorbidity_code", None)
38
+
39
+ if not comorbidity_code and not comorbidity_term and not comorbidity_other:
40
+ raise ValueLocError(
41
+ 'Please provide at least one of "comorbidity_code", "comorbidity_term" or "comorbidity_other".',
42
+ loc="comorbidity_code,comorbidity_term,comorbidity_other",
43
+ )