tricc-oo 1.5.26__py3-none-any.whl → 1.6.14__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.
@@ -21,6 +21,7 @@ from tricc_oo.models.tricc import (
21
21
  TriccNodeSelectOption,
22
22
  TriccNodeInputModel,
23
23
  TriccNodeBaseModel,
24
+ TriccNodeSelect,
24
25
  TriccNodeDisplayModel,
25
26
  )
26
27
 
@@ -69,6 +70,9 @@ class OpenMRSStrategy(BaseOutPutStrategy):
69
70
  self.current_segment = None
70
71
  self.current_activity = None
71
72
  self.concept_map = {}
73
+ self.calculated_fields = [] # Store calculated fields to add to first section of each page
74
+ self.calculated_fields_added = set() # Track which pages have had calculated fields added
75
+ self.inject_version()
72
76
 
73
77
  def get_export_name(self, r):
74
78
  if isinstance(r, TriccNodeSelectOption):
@@ -146,6 +150,9 @@ class OpenMRSStrategy(BaseOutPutStrategy):
146
150
  logger.info("generate the export format")
147
151
  self.process_export(self.project.start_pages, pages=self.project.pages)
148
152
 
153
+ logger.info("create calculation page")
154
+ self.create_calculation_page()
155
+
149
156
  logger.info("print the export")
150
157
  self.export(self.project.start_pages, version=version)
151
158
 
@@ -160,7 +167,7 @@ class OpenMRSStrategy(BaseOutPutStrategy):
160
167
  'select_multiple': 'multiCheckbox',
161
168
  'select_yesno': 'select',
162
169
  'not_available': 'checkbox',
163
- 'note': 'text'
170
+ 'note': 'markdown'
164
171
  }
165
172
 
166
173
  # if issubclass(node.__class__, TriccNodeSelectYesNo):
@@ -250,7 +257,9 @@ class OpenMRSStrategy(BaseOutPutStrategy):
250
257
  }
251
258
  }
252
259
  }
253
- return question
260
+ # Collect calculated fields to add to first section of each page
261
+ self.calculated_fields.append(question)
262
+ return None # Don't return the question, it will be added to first section
254
263
  return None
255
264
 
256
265
  def generate_calculate(self, node, processed_nodes, **kwargs):
@@ -400,6 +409,8 @@ class OpenMRSStrategy(BaseOutPutStrategy):
400
409
  return f"'{option}'"
401
410
  elif issubclass(r.__class__, TriccNodeInputModel):
402
411
  return self.get_export_name(r)
412
+ elif issubclass(r.__class__, TriccNodeSelect):
413
+ return "(" + self.get_export_name(r) + " ?? [])"
403
414
  elif issubclass(r.__class__, TriccNodeBaseModel):
404
415
  return self.get_export_name(r)
405
416
  else:
@@ -439,7 +450,7 @@ class OpenMRSStrategy(BaseOutPutStrategy):
439
450
  if len(ref_expressions) == 1:
440
451
  return ref_expressions[0]
441
452
  if len(ref_expressions) > 1:
442
- return " || ".join(ref_expressions)
453
+ return "(" + " || ".join(ref_expressions) + ")"
443
454
  else:
444
455
  return "true"
445
456
 
@@ -447,7 +458,7 @@ class OpenMRSStrategy(BaseOutPutStrategy):
447
458
  return f"!({ref_expressions[0]})"
448
459
 
449
460
  def tricc_operation_plus(self, ref_expressions):
450
- return " + ".join(ref_expressions)
461
+ return "(" + " + ".join(ref_expressions) + ")"
451
462
 
452
463
  def tricc_operation_minus(self, ref_expressions):
453
464
  if len(ref_expressions) > 1:
@@ -472,7 +483,7 @@ class OpenMRSStrategy(BaseOutPutStrategy):
472
483
  return f"({ref_expressions[0]}.includes({ref_expressions[1]}))"
473
484
 
474
485
  def tricc_operation_count(self, ref_expressions):
475
- return f"({ref_expressions[0]}.length)"
486
+ return f"{ref_expressions[0]}.length"
476
487
 
477
488
  def tricc_operation_multiplied(self, ref_expressions):
478
489
  return "*".join(ref_expressions)
@@ -638,6 +649,7 @@ class OpenMRSStrategy(BaseOutPutStrategy):
638
649
  "label": section_label,
639
650
  "questions": []
640
651
  })
652
+
641
653
  if group_node.relevance:
642
654
  relevance_str = self.convert_expression_to_string(not_clean(group_node.relevance))
643
655
  if relevance_str and relevance_str != 'false':
@@ -645,3 +657,38 @@ class OpenMRSStrategy(BaseOutPutStrategy):
645
657
  "hideWhenExpression": f"{relevance_str}"
646
658
  }
647
659
  logger.debug(f"Started section: {section_label}")
660
+
661
+ def create_calculation_page(self):
662
+ """Create a dedicated page for all calculated fields"""
663
+ if self.calculated_fields:
664
+ self.clean_sections()
665
+ self.clean_pages()
666
+ page = {
667
+ "label": "Calculations",
668
+ "sections": [
669
+ {
670
+ "label": "Calculations",
671
+ "questions": self.calculated_fields
672
+ }
673
+ ]
674
+ }
675
+ self.form_data["pages"].append(page)
676
+ logger.debug("Created calculation page")
677
+
678
+ def inject_version(self):
679
+ # Add hidden version field using version() function
680
+ question = {
681
+ "id": "version",
682
+ "type": "control",
683
+ "label": "",
684
+ "hide": {
685
+ "hideWhenExpression": "true"
686
+ },
687
+ "questionOptions": {
688
+ "calculate": {
689
+ "calculateExpression": "version()"
690
+ }
691
+ }
692
+ }
693
+ # Collect calculated fields to add to first section of each page
694
+ self.calculated_fields.append(question)
@@ -8,6 +8,7 @@ import logging
8
8
  import os
9
9
  import re
10
10
  import pandas as pd
11
+ from pyxform import create_survey_from_xls
11
12
 
12
13
  from tricc_oo.converters.utils import clean_name
13
14
  from tricc_oo.models.base import (
@@ -131,7 +132,7 @@ class XLSFormStrategy(BaseOutPutStrategy):
131
132
  return generate_xls_form_export(self, node, **kwargs)
132
133
 
133
134
  def inject_version(self):
134
- # Add hidden version field using ODK's jr:version()
135
+ # Add hidden version field using ODK's version()
135
136
  empty = langs.get_trads("", force_dict=True)
136
137
  self.df_survey.loc[len(self.df_survey)] = [
137
138
  "hidden",
@@ -148,7 +149,7 @@ class XLSFormStrategy(BaseOutPutStrategy):
148
149
  "", # required
149
150
  *list(empty.values()), # required_message
150
151
  "", # read only
151
- "jr:version()", # calculation
152
+ "version()", # calculation
152
153
  "", # trigger
153
154
  "", # repeat_count
154
155
  "", # image
@@ -409,7 +410,7 @@ class XLSFormStrategy(BaseOutPutStrategy):
409
410
  return f"count-selected({self.clean_coalesce(ref_expressions[0])})"
410
411
 
411
412
  def tricc_operation_multiplied(self, ref_expressions):
412
- return "*".join(ref_expressions)
413
+ return "*".join(map(str, ref_expressions))
413
414
 
414
415
  def tricc_operation_divided(self, ref_expressions):
415
416
  return f"{ref_expressions[0]} div {ref_expressions[1]}"
@@ -430,7 +431,7 @@ class XLSFormStrategy(BaseOutPutStrategy):
430
431
  return f"-{ref_expressions[0]}"
431
432
 
432
433
  def tricc_operation_plus(self, ref_expressions):
433
- return " + ".join(ref_expressions)
434
+ return " + ".join(map(str, ref_expressions))
434
435
 
435
436
  def tricc_operation_not(self, ref_expressions):
436
437
  return f"not({ref_expressions[0]})"
@@ -460,9 +461,9 @@ class XLSFormStrategy(BaseOutPutStrategy):
460
461
  return "1"
461
462
 
462
463
  def tricc_operation_native(self, ref_expressions):
463
-
464
+
464
465
  if len(ref_expressions) > 0:
465
- if ref_expressions[0].startswith(("'","`",)):
466
+ if ref_expressions[0].startswith(("'", "`",)):
466
467
  ref_expressions[0] = ref_expressions[0][1:-1]
467
468
  if ref_expressions[0] == "GetChoiceName":
468
469
  return f"""jr:choice-name({
@@ -474,7 +475,7 @@ class XLSFormStrategy(BaseOutPutStrategy):
474
475
  return "0"
475
476
  # return f"jr:choice-name({','.join(ref_expressions[1:])})"
476
477
  else:
477
- return f"{ref_expressions[0]}({','.join(ref_expressions[1:])})"
478
+ return f"{ref_expressions[0]}({','.join(map(str, ref_expressions[1:]))})"
478
479
 
479
480
  def tricc_operation_istrue(self, ref_expressions):
480
481
  if str(BOOLEAN_MAP[str(TRICC_TRUE_VALUE)]).isnumeric():
@@ -495,7 +496,12 @@ class XLSFormStrategy(BaseOutPutStrategy):
495
496
  parts = []
496
497
  for s in ref_expressions[1:]:
497
498
  # for option with numeric value
498
- cleaned_s = s if isinstance(s, str) else "'" + str(s) + "'"
499
+ if isinstance(s, str):
500
+ cleaned_s = s
501
+ elif isinstance(s, TriccNodeSelectOption):
502
+ cleaned_s = s.name
503
+ else:
504
+ cleaned_s = "'" + str(s) + "'"
499
505
  parts.append(f"selected({self.clean_coalesce(ref_expressions[0])}, {cleaned_s})")
500
506
  if len(parts) == 1:
501
507
  return parts[0]
@@ -548,15 +554,15 @@ class XLSFormStrategy(BaseOutPutStrategy):
548
554
  ifs = 0
549
555
  parts = []
550
556
  else_found = False
551
- if not isinstance(ref_expressions[0], list):
557
+ if isinstance(ref_expressions[0], list):
552
558
  return self.tricc_operation_ifs(ref_expressions)
553
- for i in range(int(len(ref_expressions))):
559
+ for i in range(int(len(ref_expressions[1:]))):
554
560
  if isinstance(ref_expressions[i], list):
555
- parts.append(f"if({ref_expressions[i][0]},{ref_expressions[i][1]}")
561
+ parts.append(f"if({ref_expressions[0]}={ref_expressions[i+1][0]},{ref_expressions[i+1][1]}")
556
562
  ifs += 1
557
563
  else:
558
564
  else_found = True
559
- parts.append(ref_expressions[i])
565
+ parts.append(ref_expressions[i+1])
560
566
  # join the if
561
567
  exp = ",".join(map(str, parts))
562
568
  # in case there is no default put ''
@@ -571,15 +577,16 @@ class XLSFormStrategy(BaseOutPutStrategy):
571
577
  ifs = 0
572
578
  parts = []
573
579
  else_found = False
574
- for i in range(int(len(ref_expressions[1:]))):
575
- if isinstance(ref_expressions[i + 1], list):
576
- parts.append(f"if({ref_expressions[0]}={ref_expressions[i+1][0]},{ref_expressions[i+1][1]}")
580
+ for i in range(int(len(ref_expressions))):
581
+ if isinstance(ref_expressions[i], list):
582
+ parts.append(f"if({ref_expressions[i][0]},{ref_expressions[i][1]}")
577
583
  ifs += 1
578
584
  else:
579
585
  else_found = True
580
- parts.append(ref_expressions[i + 1])
586
+ parts.append(ref_expressions[i])
587
+ break
581
588
  # join the if
582
- exp = ",".join(parts)
589
+ exp = ",".join(map(str, parts))
583
590
  # in case there is no default put ''
584
591
  if not else_found:
585
592
  exp += ",''"
@@ -588,11 +595,14 @@ class XLSFormStrategy(BaseOutPutStrategy):
588
595
  exp += ")"
589
596
  return exp
590
597
 
598
+ def get_empty_label(self):
599
+ return "."
600
+
591
601
  def tricc_operation_if(self, ref_expressions):
592
602
  return f"if({ref_expressions[0]},{ref_expressions[1]},{ref_expressions[2]})"
593
603
 
594
604
  def tricc_operation_contains(self, ref_expressions):
595
- return f"contains('{self.clean_coalesce(ref_expressions[0])}', '{self.clean_coalesce(ref_expressions[1])}')"
605
+ return f"contains({self.clean_coalesce(ref_expressions[0])}, {self.clean_coalesce(ref_expressions[1])})"
596
606
 
597
607
  def tricc_operation_exists(self, ref_expressions):
598
608
  parts = []
@@ -720,22 +730,12 @@ class XLSFormStrategy(BaseOutPutStrategy):
720
730
  # @param r reference to be translated
721
731
  if isinstance(r, TriccOperation):
722
732
  return self.get_tricc_operation_expression(r)
733
+ elif isinstance(r, (TriccStatic, str, int, float)):
734
+ return get_export_name(r)
723
735
  elif isinstance(r, TriccReference):
724
736
  logger.warning(f"reference `{r.value}` still used in a calculate")
725
737
  return f"${{{get_export_name(r.value)}}}"
726
- elif isinstance(r, TriccStatic):
727
- return get_export_name(r)
728
- elif isinstance(r, str):
729
- if r == TRICC_TRUE_VALUE:
730
- return BOOLEAN_MAP[str(TRICC_TRUE_VALUE)]
731
- elif r == TRICC_FALSE_VALUE:
732
- return BOOLEAN_MAP[str(TRICC_FALSE_VALUE)]
733
- elif isinstance(r, str):
734
- return f"'{r}'"
735
- else:
736
- return str(r)
737
- elif isinstance(r, (int, float)):
738
- return str(r)
738
+
739
739
  elif isinstance(r, TriccNodeSelectOption):
740
740
  logger.debug(f"select option {r.get_name()} from {r.select.get_name()} was used as a reference")
741
741
  return get_export_name(r)
@@ -747,4 +747,34 @@ class XLSFormStrategy(BaseOutPutStrategy):
747
747
  raise NotImplementedError(f"This type of node {r.__class__} is not supported within an operation")
748
748
 
749
749
  def tricc_operation_concatenate(self, ref_expressions):
750
- return f"concat({','.join(ref_expressions)})"
750
+ return f"concat({','.join(map(str, ref_expressions))})"
751
+
752
+ def validate(self):
753
+ """Validate the generated XLS form using pyxform."""
754
+ try:
755
+ # Determine the XLS file path
756
+ if self.project.start_pages["main"].root.form_id is not None:
757
+ form_id = str(self.project.start_pages["main"].root.form_id)
758
+ xls_path = os.path.join(self.output_path, form_id + ".xlsx")
759
+
760
+ if not os.path.exists(xls_path):
761
+ logger.error(f"XLS file not found: {xls_path}")
762
+ return False
763
+
764
+ # Validate using pyxform
765
+ survey = create_survey_from_xls(xls_path)
766
+ xml_output = survey.to_xml()
767
+
768
+ # Check if XML was generated successfully
769
+ if xml_output and len(xml_output.strip()) > 0:
770
+ logger.info("XLSForm validation successful")
771
+ return True
772
+ else:
773
+ logger.error("XLSForm validation failed: Empty XML output")
774
+ return False
775
+ else:
776
+ logger.error("Form ID not found for validation")
777
+ return False
778
+ except Exception as e:
779
+ logger.error(f"XLSForm validation failed: {str(e)}")
780
+ return False
@@ -2,8 +2,13 @@ import datetime
2
2
  import logging
3
3
  import os
4
4
  import shutil
5
+ import subprocess
6
+ import tempfile
7
+ import zipfile
5
8
  import pandas as pd
6
9
 
10
+ from pyxform.xls2xform import convert
11
+
7
12
  from tricc_oo.models.lang import SingletonLangClass
8
13
  from tricc_oo.models.calculate import TriccNodeEnd
9
14
  from tricc_oo.models.tricc import TriccNodeDisplayModel
@@ -33,6 +38,9 @@ class XLSFormCHTStrategy(XLSFormCDSSStrategy):
33
38
 
34
39
  self.inject_version()
35
40
 
41
+ def get_empty_label(self):
42
+ return "NO_LABEL"
43
+
36
44
  def get_cht_input(self, start_pages, **kwargs):
37
45
  empty = langs.get_trads("", force_dict=True)
38
46
  df_input = pd.DataFrame(columns=SURVEY_MAP.keys())
@@ -613,6 +621,9 @@ class XLSFormCHTStrategy(XLSFormCDSSStrategy):
613
621
  newpath = os.path.join(self.output_path, newfilename)
614
622
  media_path = os.path.join(self.output_path, form_id + "-media")
615
623
 
624
+ # Track all generated XLS files for validation
625
+ generated_files = [newpath]
626
+
616
627
  settings = {
617
628
  "form_title": title,
618
629
  "form_id": form_id,
@@ -657,6 +668,7 @@ class XLSFormCHTStrategy(XLSFormCDSSStrategy):
657
668
  new_form_id = f"{form_id}_{clean_name(e.name)}"
658
669
  newfilename = f"{new_form_id}.xlsx"
659
670
  newpath = os.path.join(self.output_path, newfilename)
671
+ generated_files.append(newpath) # Track additional XLS files
660
672
  settings = {
661
673
  "form_title": title,
662
674
  "form_id": f"{new_form_id}",
@@ -705,6 +717,128 @@ class XLSFormCHTStrategy(XLSFormCDSSStrategy):
705
717
  shutil.move(os.path.join(media_path_tmp, file_name), media_path)
706
718
  shutil.rmtree(media_path_tmp)
707
719
 
720
+ return generated_files
721
+
722
+ def execute(self):
723
+ """Override execute to handle multiple output files from CHT strategy."""
724
+ version = datetime.datetime.now().strftime("%Y%m%d%H%M")
725
+ logger.info(f"build version: {version}")
726
+ if "main" in self.project.start_pages:
727
+ self.process_base(self.project.start_pages, pages=self.project.pages, version=version)
728
+ else:
729
+ logger.critical("Main process required")
730
+
731
+ logger.info("generate the relevance based on edges")
732
+
733
+ # create relevance Expression
734
+
735
+ # create calculate Expression
736
+ self.process_calculate(self.project.start_pages, pages=self.project.pages)
737
+ logger.info("generate the export format")
738
+ # create calculate Expression
739
+ self.process_export(self.project.start_pages, pages=self.project.pages)
740
+
741
+ logger.info("print the export")
742
+
743
+ # Export returns list of generated files for CHT strategy
744
+ generated_files = self.export(self.project.start_pages, version=version)
745
+
746
+ logger.info("validate the output")
747
+ if not self.validate(generated_files):
748
+ logger.error("CHT validation failed - aborting build")
749
+ exit(1)
750
+
751
+ def validate(self, generated_files=None):
752
+ """Validate the generated XLS form(s) using pyxform conversion and ODK Validate JAR."""
753
+ if generated_files is None:
754
+ # Fallback for single file validation
755
+ if self.project.start_pages["main"].root.form_id is not None:
756
+ form_id = str(self.project.start_pages["main"].root.form_id)
757
+ generated_files = [os.path.join(self.output_path, form_id + ".xlsx")]
758
+ else:
759
+ logger.error("Form ID not found for validation")
760
+ return False
761
+
762
+ # Ensure ODK Validate JAR is available
763
+ jar_path = self._ensure_odk_validate_jar()
764
+ if not jar_path:
765
+ logger.error("ODK Validate JAR not available, skipping CHT validation")
766
+ return False
767
+
768
+ all_valid = True
769
+ for xls_file in generated_files:
770
+ if not os.path.exists(xls_file):
771
+ logger.error(f"XLS file not found: {xls_file}")
772
+ all_valid = False
773
+ continue
774
+
775
+ try:
776
+ # Convert XLS to XForm using pyxform (without validation)
777
+ xform_path = xls_file.replace('.xlsx', '.xml')
778
+ convert_result = convert(
779
+ xlsform=xls_file,
780
+ validate=False, # Don't validate during conversion
781
+ pretty_print=True
782
+ )
783
+ xform_content = convert_result.xform
784
+
785
+ # Write XForm to temporary file for validation
786
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.xml', delete=False) as temp_file:
787
+ temp_file.write(xform_content)
788
+ temp_xform_path = temp_file.name
789
+
790
+ try:
791
+ # Run ODK Validate JAR on the XForm
792
+ result = subprocess.run(
793
+ ["java", "-Djava.awt.headless=true", "-jar", jar_path, temp_xform_path],
794
+ capture_output=True,
795
+ text=True,
796
+ cwd=self.output_path
797
+ )
798
+
799
+ if result.returncode == 0 or "Cycle detected" in result.stderr:
800
+ logger.info(f"CHT XLSForm validation successful: {os.path.basename(xls_file)}")
801
+ else:
802
+ logger.error(f"CHT XLSForm validation failed for {os.path.basename(xls_file)}: {result.stderr}")
803
+ all_valid = False
804
+
805
+ finally:
806
+ # Clean up temporary XForm file
807
+ os.unlink(temp_xform_path)
808
+
809
+ except Exception as e:
810
+ logger.error(f"CHT XLSForm validation error for {os.path.basename(xls_file)}: {str(e)}")
811
+ all_valid = False
812
+
813
+ jar_in_zip = "site-packages/pyxform/validators/odk_validate/bin/ODK_Validate.jar"
814
+ zip_ref.extract(jar_in_zip, os.path.dirname(__file__))
815
+
816
+ # Move to final location
817
+ extracted_jar = os.path.join(os.path.dirname(__file__), jar_in_zip)
818
+ shutil.move(extracted_jar, jar_path)
819
+
820
+ logger.info(f"Extracted ODK Validate JAR to {jar_path}")
821
+ return jar_path
822
+
823
+ def _ensure_odk_validate_jar(self):
824
+ """Ensure ODK Validate JAR is available by downloading from GitHub releases."""
825
+ jar_path = os.path.join(os.path.dirname(__file__), "ODK_Validate.jar")
826
+
827
+ # Check if JAR already exists
828
+ if os.path.exists(jar_path):
829
+ return jar_path
830
+
831
+ # Download JAR from GitHub releases
832
+ jar_url = "https://github.com/getodk/validate/releases/download/v1.20.0/ODK-Validate-v1.20.0.jar"
833
+ try:
834
+ import urllib.request
835
+ urllib.request.urlretrieve(jar_url, jar_path)
836
+ logger.info(f"Downloaded ODK Validate JAR to {jar_path}")
837
+ return jar_path
838
+ except Exception as e:
839
+ logger.error(f"Failed to download ODK Validate JAR: {str(e)}")
840
+ return None
841
+
708
842
  def tricc_operation_zscore(self, ref_expressions):
709
843
  y, ll, m, s = self.get_zscore_params(ref_expressions)
710
844
  # return ((Math.pow((y / m), l) - 1) / (s * l));
@@ -729,7 +863,7 @@ class XLSFormCHTStrategy(XLSFormCDSSStrategy):
729
863
  self.clean_coalesce(ref_expressions[2])
730
864
  } ,{
731
865
  self.clean_coalesce(ref_expressions[3])
732
- }, true)"""
866
+ }, true"""
733
867
 
734
868
  def tricc_operation_drug_dosage(self, ref_expressions):
735
869
  # drug name