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
« 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
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
10from ..exc import ValidationFailure
11from postrfp.model.meta import Base, AttachmentMixin
14if TYPE_CHECKING:
15 from postrfp.model.questionnaire.answering import Answer
16 from postrfp.model.questionnaire.nodes import QuestionDefinition
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"
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]
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 )
57 __mapper_args__ = {"polymorphic_on": "el_type", "polymorphic_identity": ""}
59 is_answerable = True
61 contains_choices = False
63 public_attrs = ("id,el_type,colspan,rowspan,label,mandatory,regexp,col,row").split(
64 ","
65 )
67 answerable_types = {"TX", "CR", "CC", "CB", "AT"}
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 )
91 label: Mapped[Optional[str]] = mapped_column(TEXT(), nullable=True)
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)
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)
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 )
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
129 def __hash__(self) -> int:
130 return super().__hash__()
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()
136 def get_question_instance(self, project_id):
137 return self.question_def.instances.filter_by(project_id=project_id).one()
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)
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
159 @property
160 def cell_area(self):
161 return self.colspan * self.rowspan
163 @property
164 def summary(self):
165 """For describing this element in audit event change log"""
166 return self.label
168 def validate(self, answer):
169 """Validate the given answer for this element.
171 Raise ValidationFailure if the answer is not valid.
172 """
173 pass
176class Label(QElement):
177 __mapper_args__ = {"polymorphic_identity": "LB"}
178 is_answerable = False
179 public_attrs = "id,el_type,label,colspan,rowspan".split(",")
182class Checkbox(QElement):
183 __mapper_args__ = {"polymorphic_identity": "CB"}
184 public_attrs = "id,el_type,label,colspan,rowspan".split(",")
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)
192class TextInput(QElement):
193 __mapper_args__ = {"polymorphic_identity": "TX"}
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 }
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))
212 @property
213 def summary(self):
214 return f"[{self.width} X {self.height}]"
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 ]
226 """
228 contains_choices = True
229 # `choices` attribute is set by QuestionDefinition.elements()
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 }
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
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 ""
263class SelectChoices(MultipleChoice, QElement):
264 __mapper_args__ = {"polymorphic_identity": "CC"} # 'ChoiceCombo'
267class RadioChoices(MultipleChoice, QElement):
268 __mapper_args__ = {"polymorphic_identity": "CR"}
271class QuestionAttachment(QElement):
272 """An attachment added by the Buyer to the Question"""
274 __mapper_args__ = {"polymorphic_identity": "QA"}
275 is_answerable = False
277 public_attrs = "id,el_type,label,colspan,rowspan".split(",")
279 attachment: Mapped[Optional["QAttachment"]] = relationship(
280 "QAttachment", uselist=False
281 )
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 """
290 __mapper_args__ = {"polymorphic_identity": "AT"}
291 public_attrs = "id,el_type,colspan,rowspan,mandatory".split(",")
293 def validate(self, answer):
294 if not isinstance(answer, str):
295 raise ValidationFailure(
296 "SupportingAttachments answer should be the filename and size"
297 )
299 @property
300 def summary(self):
301 return "File upload field"
304class ExternalMedia(QElement):
305 """A link to an external resource, e.g. video"""
307 __mapper_args__ = {"polymorphic_identity": "MD"}
310class QAttachment(AttachmentMixin, Base):
311 __tablename__ = "question_attachments"
313 public_attrs = ("id,size,filename,url").split(",")
314 element_id = mapped_column(Integer, ForeignKey("question_elements.id"))
316 element = relationship(QElement, viewonly=True)