Coverage for rfpy/web/serial/qmodels.py: 100%
109 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-24 10:52 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-24 10:52 +0000
1from datetime import datetime
2from enum import Enum
3from typing import List, Union, Literal, Optional
6from pydantic import (
7 field_validator,
8 StringConstraints,
9 ConfigDict,
10 BaseModel,
11 Field,
12 RootModel,
13)
14from typing_extensions import Annotated
17class ElTypes(str, Enum):
18 TX = "TX"
19 LB = "LB"
20 CR = "CR"
21 CC = "CC"
22 CB = "CB"
23 QA = "QA"
24 AT = "AT"
27class Choice(BaseModel):
28 model_config = ConfigDict(extra="forbid")
30 autoscore: Optional[Annotated[int, Field(ge=0, le=1000)]] = None
31 label: Annotated[str, StringConstraints(min_length=1, max_length=1024)]
34nonzero_db_int = Annotated[int, Field(lt=2147483648, gt=0)]
37class QElement(BaseModel):
38 model_config = ConfigDict(from_attributes=True, extra="forbid")
40 id: Optional[nonzero_db_int] = None
41 el_type: Literal["TX", "LB", "CR", "CC", "CB", "QA", "AT"]
42 label: Optional[Annotated[str, StringConstraints(max_length=4086)]] = None
43 colspan: Annotated[int, Field(ge=1, le=10)] = 1
44 rowspan: Annotated[int, Field(ge=1, le=50)] = 1
47class Checkbox(QElement):
48 el_type: Literal["CB"]
51class Label(QElement):
52 el_type: Literal["LB"]
55class TextInput(QElement):
56 el_type: Literal["TX"]
57 height: Annotated[int, Field(gt=0, lt=40)]
58 width: Annotated[int, Field(gt=0, lt=500)]
59 regexp: Optional[str] = None
60 mandatory: bool = False
63class MultiChoice(BaseModel):
64 choices: Annotated[List[Choice], Field(min_length=2, max_length=20)]
67class SelectDropdown(QElement, MultiChoice):
68 el_type: Literal["CC"]
69 mandatory: bool = False
72class RadioChoices(QElement, MultiChoice):
73 el_type: Literal["CR"]
74 mandatory: bool = False
77class QuestionAttachment(QElement):
78 el_type: Literal["QA"]
81class UploadField(QElement):
82 el_type: Literal["AT"]
83 mandatory: bool = False
86el_types = {
87 ElTypes.TX: TextInput,
88 ElTypes.LB: Label,
89 ElTypes.CR: RadioChoices,
90 ElTypes.CC: SelectDropdown,
91 ElTypes.CB: Checkbox,
92 ElTypes.QA: QuestionAttachment,
93 ElTypes.AT: UploadField,
94}
97class ElRow(RootModel):
98 root: List[
99 Union[
100 TextInput,
101 Label,
102 RadioChoices,
103 SelectDropdown,
104 Checkbox,
105 QuestionAttachment,
106 UploadField,
107 ]
108 ] = Field(..., max_length=10, min_length=1)
111class ElGrid(RootModel):
112 root: List[ElRow] = Field(..., max_length=50, min_length=0)
115class QuestionDef(BaseModel):
116 title: Annotated[str, StringConstraints(min_length=2, max_length=1024)]
117 elements: ElGrid
118 model_config = ConfigDict(from_attributes=True)
120 @field_validator("elements", mode="before")
121 @classmethod
122 def nest_elements(cls, els):
123 """
124 Manage element nesting & row/col values (a list of lists)
126 If the data being validated is already nested then assign
127 row/col values according to the position of the element in the
128 list of lists (2D array).
130 If we are validating an ORM model then build up the list of lists
132 Element.row an Element.col attributes are not exposed to the outside
133 world. For them to be set on incoming data it's necessary that the
134 pydantic QElement model config allows extra attributes
135 """
136 if els is None:
137 raise ValueError("At least one row is required")
138 if len(els) == 0:
139 return els
140 if isinstance(els[0], list):
141 # We are validating a JSON dict were elements are
142 # already arranged in rows/columns, i.e. incoming json
143 for row_idx, row in enumerate(els, start=1):
144 for col_idx, el in enumerate(row, start=1):
145 # el['row'] = row_idx
146 # el['col'] = col_idx
147 el_cls = el_types[el["el_type"]]
148 el_model = el_cls(**el)
149 els[row_idx - 1][col_idx - 1] = el_model
150 # validate_rowspans(els)
151 return els
153 # We are validating an ORM model - elements is a list of model objects
154 rows = [[]]
155 current_row = rows[0]
156 current_row_idx = 1
158 if els[0].row != 1:
159 el = els[0]
160 raise ValueError(
161 f"{el.el_type} element {el.id} row is {el.row}, should be 1"
162 )
164 for el in els:
165 mod_cls = el_types[el.el_type]
166 mod = mod_cls.model_validate(el)
167 if el.row == current_row_idx:
168 current_row.append(mod)
169 elif el.row == current_row_idx + 1:
170 current_row = [mod]
171 rows.append(current_row)
172 current_row_idx = el.row
173 else:
174 raise ValueError(f"row {el.row} invalid for el {el.id}")
175 return rows
178class Question(QuestionDef):
179 id: Annotated[int, Field(lt=4294967295)] | None = None
180 number: str
181 section_id: Annotated[int, Field(lt=4294967295)] | None = None
183 @field_validator("number")
184 @classmethod
185 def to_dotted(cls, val):
186 return getattr(val, "dotted", val)
189class RespondentAnswer(BaseModel):
190 model_config = ConfigDict(from_attributes=True)
191 answer_id: int
192 answer: str
193 respondent_id: str
194 issue_id: int
195 project_id: int
196 project_title: str
197 date_published: Optional[datetime]
200class RespondentAnswers(RootModel):
201 root: List[RespondentAnswer]
204class ExcelImportResult(BaseModel):
205 imported_count: int = Field(
206 ..., description="Count of the number of imported questions"
207 )