Coverage for postrfp/buyer/api/io/xml/importer.py: 100%

75 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-22 21:34 +0000

1from lxml import etree 

2from sqlalchemy.orm import Session 

3import logging 

4 

5from postrfp.model import Section 

6from postrfp.buyer.api.io.qbuilder import QuestionnaireBuilder, parse_bool_attr 

7 

8logger = logging.getLogger(__name__) 

9 

10 

11class XmlQuestionnaireImporter: 

12 def __init__(self, session: Session, root: Section): 

13 self.root_section = root 

14 self.session = session 

15 self.qb = QuestionnaireBuilder(session, root) 

16 self.current_td: dict[str, int] | None = ( 

17 None # Minimal state for table cell context only 

18 ) 

19 self.current_question: etree._Element | None = None 

20 self.current_description: str | None = None # Store description text 

21 

22 def dispatch(self, event: str, element: etree._Element) -> None: 

23 handler_name = f"{event}_{element.tag}" 

24 handler = getattr(self, handler_name, None) 

25 if handler is None: 

26 return 

27 

28 handler(element) 

29 

30 def parse(self, filename: str) -> Section: 

31 """Parse an XML file and build the questionnaire structure.""" 

32 self.current_question = None 

33 self.current_td = None 

34 

35 with self.qb: # Let QuestionnaireBuilder manage the overall context 

36 for event, element in etree.iterparse(filename, events=("start", "end")): 

37 self.dispatch(event, element) 

38 

39 return self.root_section 

40 

41 # Section handlers 

42 

43 def start_section(self, section_element: etree._Element) -> None: 

44 title = section_element.get("title", "Untitled Section") 

45 # Start section with empty description, we'll update it later 

46 self.qb.start_section(title, None) 

47 self.current_description = None 

48 

49 def end_section(self, section_element: etree._Element) -> None: 

50 # Pass the description to end_section 

51 self.qb.end_section(description=self.current_description) 

52 self.current_description = None 

53 section_element.clear() 

54 

55 # Add handler for description element 

56 def start_description(self, element: etree._Element) -> None: 

57 # Store the description text for the next end_section call 

58 self.current_description = element.text 

59 

60 # Question handlers 

61 def start_question(self, element: etree._Element) -> None: 

62 """Start a new question context.""" 

63 title = element.get("title", "") 

64 self.qb.start_question(title) 

65 self.current_question = element 

66 

67 def end_question(self, element: etree._Element) -> None: 

68 """End the current question context.""" 

69 self.qb.end_question() 

70 self.current_question = None 

71 

72 def start_td(self, element: etree._Element) -> None: 

73 """Process a table cell.""" 

74 

75 # Set current td with colspan/rowspan attributes 

76 self.current_td = { 

77 "colspan": int(element.get("colspan", 1)), 

78 "rowspan": int(element.get("rowspan", 1)), 

79 } 

80 

81 def end_td(self, element: etree._Element) -> None: 

82 """End the current table cell.""" 

83 self.current_td = None 

84 

85 # Element handlers within table cells 

86 def start_text(self, text_element: etree._Element) -> None: 

87 assert self.current_td is not None, ( 

88 "start_text called outside of table cell context" 

89 ) 

90 self.qb.add_text_input( 

91 label=text_element.get("label", ""), 

92 colspan=self.current_td["colspan"], 

93 rowspan=self.current_td["rowspan"], 

94 mandatory=parse_bool_attr(text_element.get("mandatory")), 

95 width=int(text_element.get("width", 40)), 

96 height=int(text_element.get("height", 1)), 

97 ) 

98 

99 def start_checkbox(self, checkbox_element: etree._Element) -> None: 

100 assert self.current_td is not None, ( 

101 "start_checkbox called outside of table cell context" 

102 ) 

103 self.qb.add_checkbox( 

104 label=checkbox_element.get("label", ""), 

105 colspan=self.current_td["colspan"], 

106 rowspan=self.current_td["rowspan"], 

107 ) 

108 

109 def start_label(self, label_element: etree._Element) -> None: 

110 assert self.current_td is not None, ( 

111 "start_label called outside of table cell context" 

112 ) 

113 self.qb.add_label( 

114 label=label_element.text or "", 

115 colspan=self.current_td["colspan"], 

116 rowspan=self.current_td["rowspan"], 

117 ) 

118 

119 # Attachment handlers 

120 def start_attachment(self, attachment_element: etree._Element) -> None: 

121 assert self.current_td is not None, ( 

122 "start_attachment called outside of table cell context" 

123 ) 

124 self.qb.add_attachment( 

125 label=attachment_element.get("label", ""), 

126 colspan=self.current_td["colspan"], 

127 rowspan=self.current_td["rowspan"], 

128 mandatory=parse_bool_attr(attachment_element.get("mandatory")), 

129 ) 

130 

131 def start_questionattachment(self, qa_element: etree._Element) -> None: 

132 assert self.current_td is not None, ( 

133 "start_questionattachment called outside of table cell context" 

134 ) 

135 # Convert to a simple label instead 

136 filename = qa_element.get("filename", "") 

137 label = qa_element.get("label", "") or f"Attachment: {filename}" 

138 

139 self.qb.add_label( 

140 label=label, 

141 colspan=self.current_td["colspan"], 

142 rowspan=self.current_td["rowspan"], 

143 ) 

144 

145 # Choice handlers 

146 def start_choiceset(self, choiceset_element: etree._Element) -> None: 

147 assert self.current_td is not None, ( 

148 "start_choiceset called outside of table cell context" 

149 ) 

150 is_combo = choiceset_element.get("type", "combobox") == "combobox" 

151 

152 # Use the new context management in QBuilder 

153 self.qb.start_choice_set( 

154 label=choiceset_element.get("label", ""), 

155 is_combo=is_combo, 

156 colspan=self.current_td["colspan"], 

157 rowspan=self.current_td["rowspan"], 

158 mandatory=parse_bool_attr(choiceset_element.get("mandatory")), 

159 ) 

160 

161 def end_choiceset(self, choiceset_element: etree._Element) -> None: 

162 # Let QBuilder handle the choice set completion 

163 self.qb.end_choice_set() 

164 

165 def start_choice(self, choice_element: etree._Element) -> None: 

166 value = choice_element.get("value", "") 

167 label = choice_element.get("label", value) 

168 

169 # Add the choice to the current choice set in QBuilder 

170 self.qb.add_choice(value, label)