implement custom datetime and date widgets based on customer requirements

This commit is contained in:
2026-07-03 10:32:39 +02:00
parent 1d353d863e
commit ae48f72500
3 changed files with 461 additions and 6 deletions
+4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
import zoneinfo
from pathlib import Path
from typing import Final
@@ -25,3 +26,6 @@ class Config:
PATH_LOGGING: Path = BASE_PATH / os.getenv("DOPT_PATH_LOGGING", "data/d-opt.log")
LOG_FILENAME: str = "dopt.log"
ALEMBIC_PATH: Path = BASE_PATH / os.getenv("DOPT_ALEMBIC_BASE", "python/alembic")
TIMEZONE_CEST: Final[zoneinfo.ZoneInfo] = zoneinfo.ZoneInfo("Europe/Berlin")
+2
View File
@@ -16,7 +16,9 @@ class FormFieldType(enum.StrEnum):
TEXT = enum.auto()
LONGTEXT = enum.auto()
DATE = enum.auto()
DATE_FLEXIBLE = enum.auto()
DATETIME = enum.auto()
DATETIME_FLEXIBLE = enum.auto()
DROPDOWN = enum.auto()
EXTENDED_DROPDOWN = enum.auto()
DYNAMIC_LIST = enum.auto()
+455 -6
View File
@@ -15,14 +15,17 @@ from pprint import pformat
from typing import TYPE_CHECKING, Any, Final, Protocol, TypeAlias, TypedDict, TypeVar, cast
from typing_extensions import override
from dopt_basics import datetime as dopt_dt
from pydantic import (
ValidationError,
)
from PySide6.QtCore import (
QDate,
QDateTime,
QEvent,
QObject,
Qt,
QTime,
QTimer,
QtMsgType,
Signal,
@@ -33,6 +36,7 @@ from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QButtonGroup,
QCalendarWidget,
QComboBox,
QCompleter,
QDateEdit,
@@ -61,6 +65,7 @@ from PySide6.QtWidgets import (
import wce_crm.constants
from wce_crm.backend import backend
from wce_crm.constants import TIMEZONE_CEST
from wce_crm.data_models import (
COLUMN_SEP,
FlatBaseModel,
@@ -406,6 +411,54 @@ def _build_ui_recursively(
else:
parent_layout.addRow(field.label, widget)
case FormFieldType.DATE_FLEXIBLE:
widget = FlexibleDateInput()
if field.fill_value:
set_date = QDate.fromString(field.fill_value, "dd.MM.yyyy")
if not set_date.isValid():
raise ValueError(
f"Could not parse date field value >{field.fill_value}<"
)
widget.set_date(set_date)
if field.readonly:
widget.setReadOnly(True)
widget_registry[full_key] = {
"widget": widget,
"form_field": field,
}
if field.tooltip:
tooltip_layout = _add_tooltip(widget, field.tooltip)
parent_layout.addRow(field.label, tooltip_layout)
else:
parent_layout.addRow(field.label, widget)
case FormFieldType.DATETIME_FLEXIBLE:
widget = FlexibleDateTimeInput()
if field.fill_value:
set_datetime = QDateTime.fromString(field.fill_value, "dd.MM.yyyy hh:mm")
if not set_datetime.isValid():
raise ValueError(
f"Could not parse date field value >{field.fill_value}<"
)
widget.set_datetime(set_datetime)
if field.readonly:
widget.setReadOnly(True)
widget_registry[full_key] = {
"widget": widget,
"form_field": field,
}
if field.tooltip:
tooltip_layout = _add_tooltip(widget, field.tooltip)
parent_layout.addRow(field.label, tooltip_layout)
else:
parent_layout.addRow(field.label, widget)
case FormFieldType.DROPDOWN:
widget = QComboBox()
widget.addItem(DROPDOWN_DEFAULT, None)
@@ -576,6 +629,24 @@ def reset_form(
widget.setDate(set_date)
else:
widget.setDate(QDate.currentDate())
elif isinstance(widget, FlexibleDateInput):
widget.reset()
if form_field.fill_value:
set_date = QDate.fromString(form_field.fill_value, "dd.MM.yyyy")
if not set_date.isValid():
raise ValueError(
f"Could not parse date field value >{form_field.fill_value}<"
)
widget.set_date(set_date)
elif isinstance(widget, FlexibleDateTimeInput):
widget.reset()
if form_field.fill_value:
set_datetime = QDateTime.fromString(form_field.fill_value, "dd.MM.yyyy hh:mm")
if not set_datetime.isValid():
raise ValueError(
f"Could not parse date field value >{form_field.fill_value}<"
)
widget.set_datetime(set_datetime)
elif isinstance(widget, QComboBox):
if form_field.fill_value:
widget.setCurrentText(form_field.fill_value)
@@ -699,6 +770,10 @@ def get_form_data(
elif isinstance(widget, QDateEdit):
qt_date = widget.date()
value = qt_date.toPython()
elif isinstance(widget, FlexibleDateInput):
value = widget.get_pydate()
elif isinstance(widget, FlexibleDateTimeInput):
value = widget.get_pydatetime()
elif isinstance(widget, QComboBox):
value = widget.currentData()
elif isinstance(
@@ -754,7 +829,18 @@ def set_widget_value(
if not set_date.isValid():
raise ValueError(f"Could not parse date field value >{value}<")
widget.setDate(set_date)
elif isinstance(widget, FlexibleDateInput):
assert isinstance(value, datetime.datetime)
try:
widget.set_pydate(value, raise_error=True)
except ValueError:
raise ValueError(f"Could not parse date field value >{value}<")
elif isinstance(widget, FlexibleDateTimeInput):
assert isinstance(value, datetime.datetime)
try:
widget.set_pydatetime(value, raise_error=True)
except ValueError:
raise ValueError(f"Could not parse datetime field value >{value}<")
elif isinstance(widget, QComboBox):
index = widget.findData(value)
if index >= 0:
@@ -847,6 +933,16 @@ def validate_form_data(
if widget.currentData() is not None:
continue
error_post = True
elif isinstance(widget, FlexibleDateInput):
date = widget.get_date()
if date and date.isValid():
continue
error_post = True
elif isinstance(widget, FlexibleDateTimeInput):
date = widget.get_datetime()
if date and date.isValid():
continue
error_post = True
elif isinstance(
widget,
(
@@ -1477,6 +1573,355 @@ class AutoForm(QWidget):
set_form_data(self.widget_registry, data)
class FlexibleDateInput(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._read_only = False
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# simple text field allows for easy editing and deletion
self.line_edit = QLineEdit()
self.line_edit.setPlaceholderText("TT.MM.JJJJ")
self.line_edit.setInputMask("99.99.9999;_") # force date structure
self.button = QPushButton("📅")
self.button.setFixedWidth(30)
layout.addWidget(self.line_edit)
layout.addWidget(self.button)
self.calendar = QCalendarWidget()
self.calendar.setWindowFlags(Qt.WindowType.Popup) # calendar as free-floating window
self.calendar.setFirstDayOfWeek(Qt.DayOfWeek.Monday)
self.calendar.setGridVisible(True)
self.calendar.setStyleSheet("""
QCalendarWidget QAbstractItemView {
selection-background-color: #2980b9;
selection-color: white;
}
""")
self.button.clicked.connect(self.show_calendar)
self.calendar.activated.connect(self.set_date)
# signal to validate
self.line_edit.editingFinished.connect(self.validate_input)
self.line_edit.installEventFilter(self)
self.setReadOnly(False)
def eventFilter(
self,
watched: QObject,
event: QEvent,
):
# press enter: direct validation
if watched == self.line_edit:
if event.type() == QEvent.Type.KeyPress:
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): # type: ignore
self.validate_input()
return True # mark event as processed
elif event.type() == QEvent.Type.FocusOut:
self.validate_input()
# no return to not block focus change
return super().eventFilter(watched, event)
def show_calendar(self) -> None:
if self._read_only:
return
pos = self.line_edit.mapToGlobal(self.line_edit.rect().bottomLeft())
self.calendar.move(pos)
self.calendar.show()
def isReadOnly(self) -> bool:
return self._read_only
def setReadOnly(
self,
read_only: bool,
):
self._read_only = read_only
self.line_edit.setReadOnly(read_only)
self.button.setEnabled(not read_only)
current_text = self.line_edit.text()
if read_only:
self.line_edit.setInputMask("")
self.line_edit.setPlaceholderText("kein Datum eingetragen")
pure = (
current_text.replace(".", "")
.replace(":", "")
.replace(" ", "")
.replace("_", "")
.strip()
)
cleaned_date = current_text if pure else ""
self.line_edit.setText(cleaned_date)
self.line_edit.setProperty("styleClass", "stempel")
else:
self.line_edit.setInputMask("99.99.9999;_")
self.line_edit.setPlaceholderText("TT.MM.JJJJ")
def validate_input(self) -> None:
logger_gui.debug("[Widget -- FlexibleDateInput] Trigger input validation")
if self._read_only:
return
focused_widget = QApplication.focusWidget()
if focused_widget == self.button or focused_widget == self.calendar:
return
text = self.line_edit.text()
# field empty
if not text.replace(".", "").replace("_", "").strip():
self.line_edit.setStyleSheet("")
return
# if user wrote input
date = QDate.fromString(text, "dd.MM.yyyy")
if not date.isValid():
self.line_edit.setStyleSheet("border: 1px solid red; background-color: #FFEEEE;")
self.line_edit.selectAll()
# optional: keep focus in field to directly correct entry
# self.line_edit.setFocus()
else:
self.line_edit.setStyleSheet("")
def set_date(
self,
date: QDate,
) -> None:
if self._read_only:
return
if date and date.isValid():
self.line_edit.setText(date.toString("dd.MM.yyyy"))
else:
self.line_edit.clear()
self.line_edit.setText(date.toString("dd.MM.yyyy"))
self.calendar.hide()
def set_pydate(
self,
date: datetime.datetime,
raise_error: bool = False,
) -> None:
date_to_set = QDate(date.year, date.month, date.day)
if raise_error and not date_to_set.isValid():
raise ValueError("Could not parse date object correctly")
self.set_date(date_to_set)
def get_date(self) -> QDate:
# valid or invalid QDate
return QDate.fromString(self.line_edit.text(), "dd.MM.yyyy")
def get_pydate(self) -> datetime.date:
# valid or invalid QDate
date = self.get_date()
py_date = cast(datetime.date, date.toPython())
return py_date
def reset(self) -> None:
self.calendar.hide()
self.line_edit.clear()
class FlexibleDateTimeInput(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._read_only = False
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Simple text field allows for easy editing and deletion (with Time)
self.line_edit = QLineEdit()
self.line_edit.setPlaceholderText("TT.MM.JJJJ hh:mm")
self.line_edit.setInputMask("99.99.9999 99:99;_") # force datetime structure
self.button = QPushButton("📅")
self.button.setFixedWidth(30)
layout.addWidget(self.line_edit)
layout.addWidget(self.button)
self.calendar = QCalendarWidget()
self.calendar.setWindowFlags(Qt.WindowType.Popup) # calendar as free-floating window
self.calendar.setFirstDayOfWeek(Qt.DayOfWeek.Monday)
self.calendar.setGridVisible(True)
self.calendar.setStyleSheet("""
QCalendarWidget QAbstractItemView {
selection-background-color: #2980b9;
selection-color: white;
}
""")
self.button.clicked.connect(self.show_calendar)
self.calendar.activated.connect(self.set_date_from_calendar)
# signal to validate
self.line_edit.editingFinished.connect(self.validate_input)
self.line_edit.installEventFilter(self)
self.setReadOnly(False)
def eventFilter(
self,
watched: QObject,
event: QEvent,
):
# press enter: direct validation
if watched == self.line_edit:
if event.type() == QEvent.Type.KeyPress:
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): # type: ignore
self.validate_input()
return True # mark event as processed
elif event.type() == QEvent.Type.FocusOut:
self.validate_input()
# no return to not block focus change
return super().eventFilter(watched, event)
def show_calendar(self) -> None:
if self._read_only:
return
pos = self.line_edit.mapToGlobal(self.line_edit.rect().bottomLeft())
self.calendar.move(pos)
self.calendar.show()
def isReadOnly(self) -> bool:
return self._read_only
def setReadOnly(
self,
read_only: bool,
):
self._read_only = read_only
self.line_edit.setReadOnly(read_only)
self.button.setEnabled(not read_only)
current_text = self.line_edit.text()
if read_only:
self.line_edit.setInputMask("")
self.line_edit.setPlaceholderText("kein Zeitstempel eingetragen")
pure = (
current_text.replace(".", "")
.replace(":", "")
.replace(" ", "")
.replace("_", "")
.strip()
)
cleaned_datetime = current_text if pure else ""
self.line_edit.setText(cleaned_datetime)
self.line_edit.setProperty("styleClass", "stempel")
else:
self.line_edit.setInputMask("99.99.9999 99:99;_")
self.line_edit.setPlaceholderText("TT.MM.JJJJ hh:mm")
def validate_input(self) -> None:
logger_gui.debug("[Widget -- FlexibleDateTimeInput] Trigger input validation")
if self._read_only:
return
focused_widget = QApplication.focusWidget()
if focused_widget == self.button or focused_widget == self.calendar:
return
text = self.line_edit.text()
# field empty (ignoring dots, colons, spaces, underscores)
pure_text = (
text.replace(".", "").replace(":", "").replace(" ", "").replace("_", "").strip()
)
if not pure_text:
self.line_edit.setStyleSheet("")
return
# check logic for QDateTime
dt = QDateTime.fromString(text, "dd.MM.yyyy hh:mm")
if not dt.isValid():
self.line_edit.setStyleSheet("border: 1px solid red; background-color: #FFEEEE;")
self.line_edit.selectAll()
else:
self.line_edit.setStyleSheet("")
def set_date_from_calendar(
self,
date: QDate,
) -> None:
if self._read_only:
return
# save time user already provided it
current_text = self.line_edit.text()
time_part = "00:00"
# extract time portion (last 5 characters of "dd.MM.yyyy hh:mm")
if len(current_text) >= 16:
potential_time = current_text[11:16]
if "_" not in potential_time: # only if time was fully provided
time_part = potential_time
if date and date.isValid():
formatted_date = date.toString("dd.MM.yyyy")
self.line_edit.setText(f"{formatted_date} {time_part}")
self.line_edit.setStyleSheet("")
else:
self.line_edit.clear()
self.calendar.hide()
def set_datetime(
self,
dt: QDateTime,
) -> None:
if dt and dt.isValid():
self.line_edit.setText(dt.toString("dd.MM.yyyy hh:mm"))
self.line_edit.setStyleSheet("")
else:
self.line_edit.clear()
def set_pydatetime(
self,
dt: datetime.datetime,
raise_error: bool = False,
) -> None:
dt_to_set = QDateTime(dt.year, dt.month, dt.day, dt.hour, dt.minute, 0)
if raise_error and not dt_to_set.isValid():
raise ValueError("Could not parse datetime object correctly")
self.set_datetime(dt_to_set)
def get_datetime(self) -> QDateTime:
# valid or invalid QDateTime
return QDateTime.fromString(self.line_edit.text(), "dd.MM.yyyy hh:mm")
def get_pydatetime(self) -> datetime.datetime:
"""returns current value of the field as UTC datetime
Returns
-------
datetime.datetime
UTC datetime object
"""
# valid or invalid datetime
dt = self.get_datetime()
py_dt = cast(datetime.datetime, dt.toPython())
py_dt = py_dt.replace(tzinfo=TIMEZONE_CEST)
return py_dt.astimezone(datetime.UTC)
def reset(self) -> None:
self.calendar.hide()
self.line_edit.clear()
class DynamicListWidget(QWidget):
"""
a widget, which can generate and manage an arbitrary number of sub forms.
@@ -3206,7 +3651,13 @@ class Page_Consulting_ConsultingSession(QWidget):
self.user_field = QLineEdit(
placeholderText="wird über aktiven Nutzer bei Anlage gesetzt"
)
self.timestamp = QLineEdit() # TODO change to custom date field
self.user_field.setReadOnly(True)
self.user_field.setProperty("styleClass", "stempel")
# ?? date or datetime?
# self.timestamp = FlexibleDateInput()
# self.timestamp.set_pydate(datetime.datetime.now())
self.timestamp = FlexibleDateTimeInput()
self.timestamp.set_pydatetime(datetime.datetime.now())
self.contact = QLineEdit(placeholderText="Freitext")
self.contact_type = QLineEdit(placeholderText="sollte Dropdown sein") # Dropdown
@@ -3260,7 +3711,7 @@ class Page_Consulting_ConsultingSession(QWidget):
"[Page Consulting -- Consulting Session]: State = %s", self.STATE
)
set_widget_value(self.user_field, self.STATE.user_name)
set_widget_value(self.timestamp, self.STATE.timestamp)
set_widget_value(self.timestamp, self.STATE.timestamp.astimezone(TIMEZONE_CEST))
set_widget_value(self.contact, self.STATE.partner)
set_widget_value(self.contact_type, self.STATE.interaction_scheme)
set_widget_value(self.crm_link, self.STATE.topic)
@@ -3269,9 +3720,7 @@ class Page_Consulting_ConsultingSession(QWidget):
def _sync_gui_to_state(self) -> None:
# TODO: get timestamp from custom field self.STATE.timestamp
self.STATE.timestamp = datetime.datetime.strptime(
self.timestamp.text(), "%d.%m.%Y %H:%M:%S"
)
self.STATE.timestamp = self.timestamp.get_pydatetime()
self.STATE.partner = self.contact.text()
self.STATE.interaction_scheme = self.contact_type.text()
self.STATE.topic = self.crm_link.text() # TODO: change to correct link