Coverage for postrfp/model/questionnaire/qelements.py: 97%

141 statements  

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

1import re 

2import enum 

3from typing import Optional, TYPE_CHECKING 

4 

5from sqlalchemy import JSON, Boolean, ForeignKey, Index, Integer, Unicode, inspect, text 

6from sqlalchemy.orm import DynamicMapped, Mapped, mapped_column, relationship 

7from sqlalchemy.types import CHAR, INTEGER, TEXT 

8 

9 

10from ..exc import ValidationFailure 

11from postrfp.model.meta import Base, AttachmentMixin 

12 

13 

14if TYPE_CHECKING: 

15 from postrfp.model.questionnaire.answering import Answer 

16 from postrfp.model.questionnaire.nodes import QuestionDefinition 

17 

18 

19class ElementCode(enum.StrEnum): 

20 text_input = "TX" 

21 label = "LB" 

22 radio_choices = "CR" 

23 combo_choices = "CC" 

24 checkbox = "CB" 

25 question_attachment = "QA" 

26 answerable_attachment = "AT" 

27 

28 

29el_keys = [ 

30 "col", 

31 "colspan", 

32 "row", 

33 "rowspan", 

34 "el_type", 

35 "label", 

36 "mandatory", 

37 "height", 

38 "width", 

39 "multitopic", 

40 "regexp", 

41 "choices", 

42] 

43 

44 

45class QElement(Base): 

46 __tablename__ = "question_elements" 

47 __table_args__ = ( # type: ignore 

48 Index( 

49 "question_elements_qId_row_col", 

50 "question_id", 

51 "row", 

52 "col", 

53 ), 

54 Index("ft_label", "label", mariadb_prefix="FULLTEXT"), 

55 ) 

56 

57 __mapper_args__ = {"polymorphic_on": "el_type", "polymorphic_identity": ""} 

58 

59 is_answerable = True 

60 

61 contains_choices = False 

62 

63 public_attrs = ("id,el_type,colspan,rowspan,label,mandatory,regexp,col,row").split( 

64 "," 

65 ) 

66 

67 answerable_types = {"TX", "CR", "CC", "CB", "AT"} 

68 

69 question_id: Mapped[int] = mapped_column( 

70 ForeignKey("questions.id", ondelete="CASCADE"), 

71 nullable=False, 

72 index=True, 

73 server_default=text("'0'"), 

74 ) 

75 row: Mapped[int] = mapped_column( 

76 INTEGER, nullable=False, default=1, server_default=text("'1'") 

77 ) 

78 col: Mapped[int] = mapped_column( 

79 INTEGER, nullable=False, default=1, server_default=text("'1'") 

80 ) 

81 el_type: Mapped[str] = mapped_column( 

82 "type", CHAR(length=2), nullable=False, server_default=text("''") 

83 ) 

84 colspan: Mapped[int] = mapped_column( 

85 Integer, default=1, nullable=True, server_default=text("'1'") 

86 ) 

87 rowspan: Mapped[int] = mapped_column( 

88 Integer, default=1, nullable=True, server_default=text("'1'") 

89 ) 

90 

91 label: Mapped[Optional[str]] = mapped_column(TEXT(), nullable=True) 

92 

93 mandatory: Mapped[Optional[bool]] = mapped_column( 

94 Boolean, default=False, nullable=True 

95 ) 

96 height: Mapped[Optional[int]] = mapped_column(Integer, default=1) 

97 width: Mapped[Optional[int]] = mapped_column(Integer, default=1) 

98 

99 multitopic: Mapped[Optional[bool]] = mapped_column( 

100 Boolean, default=False, nullable=True 

101 ) 

102 regexp: Mapped[Optional[str]] = mapped_column(Unicode(255)) 

103 choices: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) 

104 

105 question_def: Mapped["QuestionDefinition"] = relationship( 

106 "QuestionDefinition", back_populates="elements" 

107 ) 

108 answers: Mapped[list["Answer"]] = relationship("Answer", back_populates="element") 

109 _answers: DynamicMapped["Answer"] = relationship( 

110 "Answer", lazy="dynamic", viewonly=True 

111 ) 

112 

113 def __eq__(self, o: object) -> bool: 

114 if isinstance(o, QElement): 

115 equal: bool = True 

116 for k in el_keys: 

117 if ( 

118 hasattr(self, k) 

119 and hasattr(o, k) 

120 and getattr(o, k) == getattr(self, k) 

121 ): 

122 continue 

123 else: 

124 equal = False 

125 break 

126 return equal 

127 return False 

128 

129 def __hash__(self) -> int: 

130 return super().__hash__() 

131 

132 def get_answer(self, issue): 

133 """Get the answer for this element and `issue` or None if not yet answered""" 

134 return self._answers.filter_by(issue=issue).one() 

135 

136 def get_question_instance(self, project_id): 

137 return self.question_def.instances.filter_by(project_id=project_id).one() 

138 

139 @staticmethod 

140 def split_choice_string(choice_string): 

141 """ 

142 Split a string representation of a multiple choice option, 

143 e.g. 'Yes <8>' into a tuple - ('Yes', 8) 

144 """ 

145 regex_match = re.match(r"([^<]+)<(\d+)>", choice_string) 

146 if regex_match: 

147 label, autoscore = regex_match.groups() 

148 return (label.strip(), autoscore.strip()) 

149 else: 

150 return (choice_string.strip(), None) 

151 

152 @classmethod 

153 def build(cls, type_name, **kwargs): 

154 """Create a QElement subclass instance of the given type_name""" 

155 mapper = inspect(cls).polymorphic_map[type_name] 

156 el = mapper.class_(**kwargs) 

157 return el 

158 

159 @property 

160 def cell_area(self): 

161 return self.colspan * self.rowspan 

162 

163 @property 

164 def summary(self): 

165 """For describing this element in audit event change log""" 

166 return self.label 

167 

168 def validate(self, answer): 

169 """Validate the given answer for this element. 

170 

171 Raise ValidationFailure if the answer is not valid. 

172 """ 

173 pass 

174 

175 

176class Label(QElement): 

177 __mapper_args__ = {"polymorphic_identity": "LB"} 

178 is_answerable = False 

179 public_attrs = "id,el_type,label,colspan,rowspan".split(",") 

180 

181 

182class Checkbox(QElement): 

183 __mapper_args__ = {"polymorphic_identity": "CB"} 

184 public_attrs = "id,el_type,label,colspan,rowspan".split(",") 

185 

186 def validate(self, answer): 

187 if answer not in ("true", "false"): 

188 m = f"Answer for checkbox element {self.id} must be 'true' or 'false'" 

189 raise ValidationFailure(m) 

190 

191 

192class TextInput(QElement): 

193 __mapper_args__ = {"polymorphic_identity": "TX"} 

194 

195 def as_dict(self): 

196 return { 

197 "id": self.id, 

198 "el_type": self.el_type, 

199 "colspan": self.colspan, 

200 "rowspan": self.rowspan, 

201 "height": self.height, 

202 "width": self.width, 

203 "mandatory": self.mandatory, 

204 "regexp": self.regexp, 

205 } 

206 

207 def validate(self, answer): 

208 if self.regexp and not re.match(self.regexp, answer): 

209 tmpl = "Answer {:.10} does not match regular expression {} for element {}" 

210 raise ValidationFailure(tmpl.format(self.regexp, answer, self.id)) 

211 

212 @property 

213 def summary(self): 

214 return f"[{self.width} X {self.height}]" 

215 

216 

217class MultipleChoice(object): 

218 """ 

219 The choices property on QElement is used by RadioChoices (CR) and SelectChoices(CC) 

220 subclasses. The property is a JSON field containing an array of label:autoscore objects: 

221 [ 

222 {label: 'Yes', autoscore: 10}, 

223 {label: 'No', autoscore: 0} 

224 ] 

225 

226 """ 

227 

228 contains_choices = True 

229 # `choices` attribute is set by QuestionDefinition.elements() 

230 

231 def as_dict(self, vendor_view=False): 

232 if vendor_view: 

233 choices = [{"label": c["label"]} for c in self.choices] 

234 else: 

235 choices = self.choices 

236 return { 

237 "id": self.id, 

238 "el_type": self.el_type, 

239 "colspan": self.colspan, 

240 "rowspan": self.rowspan, 

241 "mandatory": self.mandatory, 

242 "choices": choices, 

243 } 

244 

245 def validate(self, answer): 

246 choice_vals = {c["label"] for c in self.choices} 

247 if answer not in choice_vals: 

248 tmpl = ( 

249 "Answer '{:.10}' is not one of the accepted values: [{}] for element {}" 

250 ) 

251 raise ValidationFailure(tmpl.format(answer, ",".join(choice_vals), self.id)) 

252 return True 

253 

254 @property 

255 def summary(self): 

256 if self.choices: 

257 return "\n".join( 

258 f"{c['label']}: {c.get('autoscore', None)}" for c in self.choices 

259 ) 

260 return "" 

261 

262 

263class SelectChoices(MultipleChoice, QElement): 

264 __mapper_args__ = {"polymorphic_identity": "CC"} # 'ChoiceCombo' 

265 

266 

267class RadioChoices(MultipleChoice, QElement): 

268 __mapper_args__ = {"polymorphic_identity": "CR"} 

269 

270 

271class QuestionAttachment(QElement): 

272 """An attachment added by the Buyer to the Question""" 

273 

274 __mapper_args__ = {"polymorphic_identity": "QA"} 

275 is_answerable = False 

276 

277 public_attrs = "id,el_type,label,colspan,rowspan".split(",") 

278 

279 attachment: Mapped[Optional["QAttachment"]] = relationship( 

280 "QAttachment", uselist=False 

281 ) 

282 

283 

284class SupportingAttachment(QElement): 

285 """ 

286 A File Input form element enabling a Respondent to upload an attachment 

287 as part of an answer to to a Question 

288 """ 

289 

290 __mapper_args__ = {"polymorphic_identity": "AT"} 

291 public_attrs = "id,el_type,colspan,rowspan,mandatory".split(",") 

292 

293 def validate(self, answer): 

294 if not isinstance(answer, str): 

295 raise ValidationFailure( 

296 "SupportingAttachments answer should be the filename and size" 

297 ) 

298 

299 @property 

300 def summary(self): 

301 return "File upload field" 

302 

303 

304class ExternalMedia(QElement): 

305 """A link to an external resource, e.g. video""" 

306 

307 __mapper_args__ = {"polymorphic_identity": "MD"} 

308 

309 

310class QAttachment(AttachmentMixin, Base): 

311 __tablename__ = "question_attachments" 

312 

313 public_attrs = ("id,size,filename,url").split(",") 

314 element_id = mapped_column(Integer, ForeignKey("question_elements.id")) 

315 

316 element = relationship(QElement, viewonly=True)