Skip to content

Commit c2e858e

Browse files
authored
Merge pull request #167 from Rafalkufel/143-student-form-validation
143-Refactor validation flow + child tabl validation
2 parents 03e223f + 22c843f commit c2e858e

File tree

26 files changed

+523
-464
lines changed

26 files changed

+523
-464
lines changed

alinka/constants/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from enum import Enum, StrEnum
22

33
RPSO_SUPPORT_CENTER_TYPE_ID = 48
4+
INVALID_FORM_MESSAGE = "Popraw błędy w formularzu."
45

56

67
class DocumentsTypes(Enum):

alinka/rspo_client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def wrapper(*args, **kwargs):
3535
formatted_path = path.format(**kwargs)
3636
url = os.path.join(settings.RSPO_DOMAIN, formatted_path)
3737
response = request(method=method, url=url, params=params, json=body)
38+
response.raise_for_status()
3839
return type_adapter.validate_python(response.json())
3940

4041
return wrapper

alinka/widget/components.py

Lines changed: 136 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from PySide6.QtGui import QValidator
1+
from PySide6.QtCore import Signal
22
from PySide6.QtWidgets import (
33
QCheckBox,
44
QComboBox,
@@ -11,107 +11,58 @@
1111
QWidget,
1212
)
1313

14-
from alinka.widget.validators import RequiredValidator
14+
from alinka import rspo_client
1515

1616

1717
class ValidationMixin:
18-
"""Mixin providing reusable validation and highlighting functionality.
19-
20-
It can be used by any component that needs field validation.
18+
"""
19+
Mixin class to add validation functionality to components/containers
20+
Including this mixing ensures that all components/containers have a consistent
21+
interface for validation-related methods and properties.
22+
Method should be overridden in subclasses as needed.
2123
"""
2224

23-
def highlight_field(self, field_component: any, highlight: bool) -> None:
24-
"""Trigger highlighting of a field component with consistent styling.
25-
26-
Args:
27-
field_component: The component to highlight (LabeledInputComponent or LabeledComboBoxComponent)
28-
highlight: Whether to highlight (True) or clear highlighting (False)
29-
"""
30-
if not hasattr(field_component, "line_edit") and not hasattr(field_component, "combobox"):
31-
return
32-
33-
widget = getattr(field_component, "line_edit", None) or getattr(field_component, "combobox", None)
34-
35-
if highlight:
36-
widget.setStyleSheet("border: 2px solid #ff4444; background-color: #fff5f5; color: black;")
37-
else:
38-
widget.setStyleSheet("")
39-
40-
def validate_required_fields(self, required_fields: list) -> bool:
41-
"""Validate a list of required fields and highlight missing ones.
42-
43-
Args:
44-
required_fields: List of field components to validate
45-
46-
Returns:
47-
bool: True if all fields are valid, False otherwise
48-
"""
49-
is_valid = True
50-
51-
for field in required_fields:
52-
if not field.is_valid:
53-
self.highlight_field(field, True)
54-
is_valid = False
55-
else:
56-
self.highlight_field(field, False)
57-
58-
return is_valid
59-
60-
def clear_highlights(self, fields: list) -> None:
61-
"""Clear highlighting from a list of fields.
62-
63-
Args:
64-
fields: List of field components to clear highlighting from
65-
"""
66-
for field in fields:
67-
self.highlight_field(field, False)
68-
69-
def connect_field_clear_handlers(self, fields: list) -> None:
70-
"""Connect text change handlers to clear highlights when users start typing.
71-
72-
Args:
73-
fields: List of field components to connect handlers to
74-
"""
75-
for field in fields:
76-
if hasattr(field, "line_edit"):
77-
# For input fields, clear highlight when text changes
78-
field.line_edit.textChanged.connect(lambda checked, f=field: self.highlight_field(f, False))
79-
elif hasattr(field, "combobox"):
80-
# For combobox fields, clear highlight when selection changes
81-
field.combobox.currentTextChanged.connect(lambda text, f=field: self.highlight_field(f, False))
82-
# Also connect to currentIndexChanged for more reliable triggering
83-
field.combobox.currentIndexChanged.connect(lambda index, f=field: self.highlight_field(f, False))
84-
85-
86-
class BaseComponent(QFrame):
8725
@property
8826
def is_valid(self) -> bool:
89-
"""
90-
Method to check if the component is valid.
91-
If this method is not overridden in the subclass, component is always valid.
92-
"""
27+
"""Method to check if the component/container is valid."""
9328
return True
9429

9530
@property
9631
def error_message(self) -> str | None:
9732
"""
9833
Method to get the error message if the component is not valid.
99-
If this method is not overridden in the subclass, it returns None.
10034
Return type should be string - if there's error message or None if there's no error.
10135
"""
10236
return None
10337

38+
def validate(self) -> bool:
39+
"""
40+
Trigger validation of this component/containers and subclasses.
41+
This method should also update the visual state of the component/container
42+
to reflect whether it's valid or not (e.g., highlighting fields in red)
43+
by calling self.display_validation_result(validation_result)
44+
"""
45+
is_valid = self.is_valid
46+
self.display_validation_result(is_valid)
47+
return is_valid
10448

105-
class LabeledInputComponent(BaseComponent):
49+
def clear_validation_state(self) -> None:
50+
"""Reset component and it's parent validation state"""
51+
pass
52+
53+
def display_validation_result(self, validation_result: bool) -> None:
54+
"""Display validation result by updating component's appearance"""
55+
pass
56+
57+
58+
class LabeledInputComponent(ValidationMixin, QFrame):
10659
def __init__(
10760
self,
10861
text: str,
10962
parent: QWidget,
11063
min_length: int | None = None,
11164
required: bool = False,
112-
validator: QValidator | None = None,
11365
):
114-
self.used_validator = None
11566
self.label = text
11667
self.is_required = required
11768
super().__init__(parent)
@@ -123,13 +74,7 @@ def __init__(
12374
if min_length:
12475
self.line_edit.setMinimumWidth(min_length)
12576

126-
if validator:
127-
self.used_validator = validator
128-
elif required:
129-
self.used_validator = RequiredValidator()
130-
if self.used_validator:
131-
self.line_edit.setValidator(self.used_validator)
132-
self.line_edit.editingFinished.connect(self.validate)
77+
self.line_edit.textChanged.connect(self.clear_validation_state)
13378

13479
layout.addWidget(label)
13580
layout.addWidget(self.line_edit)
@@ -145,32 +90,22 @@ def text(self, text):
14590
def clear(self) -> None:
14691
self.line_edit.clear()
14792

93+
def display_validation_result(self, validation_result: bool) -> None:
94+
if validation_result:
95+
self.toggle_highlight("lightgreen")
96+
else:
97+
self.toggle_highlight("mistyrose")
98+
14899
def toggle_highlight(self, color: str | None) -> None:
149100
if color:
150101
self.line_edit.setStyleSheet(f"background-color: {color};")
151102
else:
152103
self.line_edit.setStyleSheet("")
153104

154-
def validate(self) -> None:
155-
if self.used_validator:
156-
validation_state, _, _ = self.used_validator.validate(self.text, 0)
157-
if validation_state != QValidator.Acceptable:
158-
self.toggle_highlight("mistyrose")
159-
else:
160-
self.toggle_highlight(None)
161-
162105
@property
163106
def is_valid(self) -> bool:
164-
# For required fields without a specific validator, check if they have content
165-
if self.is_required and not self.used_validator:
107+
if self.is_required:
166108
return bool(self.text.strip())
167-
168-
# For fields with validators, check the validation state
169-
if self.used_validator:
170-
validation_state, _, _ = self.used_validator.validate(self.text, 0)
171-
return validation_state == QValidator.Acceptable
172-
173-
# For non-required fields without validators, always valid
174109
return True
175110

176111
@property
@@ -180,17 +115,26 @@ def error_message(self) -> str | None:
180115
if self.used_validator and not self.is_valid:
181116
return self.used_validator.default_error_message
182117

118+
def clear_validation_state(self) -> None:
119+
"""Reset component and it's parent validation state"""
120+
self.parent().clear_validation_state()
121+
self.toggle_highlight(None)
183122

184-
class LabeledComboBoxComponent(BaseComponent):
123+
124+
class LabeledComboBoxComponent(ValidationMixin, QFrame):
185125
def __init__(
186126
self,
187127
text: str,
188128
parent: QWidget,
189129
min_length: int | None = None,
190130
required: bool = False,
131+
static: bool = False,
191132
):
192133
self.label = text
193134
self.is_required = required
135+
# If static, options won't change dynamically, e.g. school types
136+
# clear means in this case remove selection only
137+
self.is_static = static
194138
super().__init__(parent)
195139
layout = QVBoxLayout(self)
196140
layout.setContentsMargins(0, 0, 0, 0)
@@ -200,6 +144,7 @@ def __init__(
200144
if min_length:
201145
self.combobox.setMinimumWidth(min_length)
202146

147+
self.combobox.currentIndexChanged.connect(self.clear_validation_state)
203148
layout.addWidget(label)
204149
layout.addWidget(self.combobox)
205150

@@ -213,12 +158,37 @@ def text(self, text):
213158
if index >= 0:
214159
self.combobox.setCurrentIndex(index)
215160

216-
def clear(self) -> None:
161+
def remove_selection(self) -> None:
162+
self.combobox.setCurrentIndex(-1)
163+
164+
def clear_options(self) -> None:
217165
self.combobox.clear()
218166

167+
def clear(self) -> None:
168+
self.remove_selection()
169+
if not self.is_static:
170+
self.clear_options()
171+
219172
def addItems(self, items: list[str]) -> None:
220173
self.combobox.addItems(items)
221174

175+
def display_validation_result(self, validation_result: bool) -> None:
176+
if validation_result:
177+
self.toggle_highlight("lightgreen")
178+
else:
179+
self.toggle_highlight("mistyrose")
180+
181+
def toggle_highlight(self, color: str | None) -> None:
182+
if color:
183+
self.combobox.setStyleSheet(f"background-color: {color};")
184+
else:
185+
self.combobox.setStyleSheet(None)
186+
187+
def clear_validation_state(self) -> None:
188+
"""Reset component and it's parent validation state"""
189+
self.parent().clear_validation_state()
190+
self.toggle_highlight(None)
191+
222192
@property
223193
def is_valid(self) -> bool:
224194
if not self.is_required:
@@ -232,7 +202,7 @@ def error_message(self) -> str | None:
232202
return None
233203

234204

235-
class LabeledCheckboxComponent(BaseComponent):
205+
class LabeledCheckboxComponent(ValidationMixin, QFrame):
236206
def __init__(self, text, parent, label_position: str = "above"):
237207
super().__init__(parent)
238208
label = QLabel(text=text, parent=self)
@@ -246,8 +216,11 @@ def __init__(self, text, parent, label_position: str = "above"):
246216
else:
247217
layout.addWidget(self.checkbox)
248218

219+
def clear(self) -> None:
220+
self.checkbox.setChecked(False)
221+
249222

250-
class LabeledDateComponent(BaseComponent):
223+
class LabeledDateComponent(ValidationMixin, QFrame):
251224
def __init__(self, text, parent):
252225
super().__init__(parent)
253226
layout = QVBoxLayout(self)
@@ -258,3 +231,61 @@ def __init__(self, text, parent):
258231
self.date_input.dateTime()
259232
layout.addWidget(label)
260233
layout.addWidget(self.date_input)
234+
235+
def clear(self) -> None:
236+
self.date_input.clear()
237+
238+
239+
class SelectProvinceDistrictGroup(ValidationMixin, QFrame):
240+
"""Generic component to select provinece and district from RPSO"""
241+
242+
selection_changed = Signal()
243+
244+
def __init__(self, parent: QWidget):
245+
super().__init__(parent)
246+
self.select_school_group = parent
247+
248+
location_frame_layout = QHBoxLayout(self)
249+
location_frame_layout.setContentsMargins(0, 0, 0, 0)
250+
self.province_combobox = LabeledComboBoxComponent("Województwo", self, required=True)
251+
self.province_combobox.combobox.setPlaceholderText("Wybierz z listy...")
252+
self.province_combobox.combobox.currentTextChanged.connect(self.on_province_changed)
253+
254+
provinces = rspo_client.list_provinces()
255+
for province in provinces:
256+
self.province_combobox.combobox.addItem(province.name, province.id)
257+
258+
self.district_combobox = LabeledComboBoxComponent("Powiat", self, required=True)
259+
self.district_combobox.combobox.setPlaceholderText("Wybierz z listy...")
260+
self.district_combobox.combobox.currentTextChanged.connect(self.on_district_changed)
261+
location_frame_layout.addWidget(self.province_combobox)
262+
location_frame_layout.addWidget(self.district_combobox)
263+
264+
@property
265+
def province_id(self) -> int | None:
266+
return self.province_combobox.combobox.currentData()
267+
268+
@property
269+
def district_id(self) -> int | None:
270+
return self.district_combobox.combobox.currentData()
271+
272+
def populate_districts_combobox(self) -> None:
273+
self.district_combobox.combobox.clear()
274+
if not self.province_id:
275+
return
276+
districts = rspo_client.list_districts(province_id=self.province_id)
277+
for district in districts:
278+
self.district_combobox.combobox.addItem(district.name, district.id)
279+
280+
def on_province_changed(self):
281+
"""
282+
In case of province change we should clear all other selections and
283+
repopulate districts combobox
284+
"""
285+
self.selection_changed.emit()
286+
self.district_combobox.combobox.clear()
287+
self.populate_districts_combobox()
288+
289+
def on_district_changed(self):
290+
"""In case of district change we should only trigger selection changed signal"""
291+
self.selection_changed.emit()

alinka/widget/containers/main_body/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from PySide6.QtWidgets import QFrame, QVBoxLayout, QWidget
22

3+
from alinka.widget.components import ValidationMixin
4+
35
from .content import ContentContainer
46
from .footer import FooterContainer
57
from .header import HeaderContainer
68

79

8-
class MainBody(QFrame):
10+
class MainBody(ValidationMixin, QFrame):
911
def __init__(self, parent: QWidget):
1012
super().__init__(parent)
1113
self.central_widget = parent

0 commit comments

Comments
 (0)