From ae48f72500753118a1cad703af664e8ed55bddce Mon Sep 17 00:00:00 2001 From: foefl Date: Fri, 3 Jul 2026 10:32:39 +0200 Subject: [PATCH] implement custom datetime and date widgets based on customer requirements --- src/wce_crm/constants.py | 4 + src/wce_crm/form_defs.py | 2 + src/wce_crm/gui.py | 461 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 461 insertions(+), 6 deletions(-) diff --git a/src/wce_crm/constants.py b/src/wce_crm/constants.py index 70ac77d..8c9f695 100644 --- a/src/wce_crm/constants.py +++ b/src/wce_crm/constants.py @@ -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") diff --git a/src/wce_crm/form_defs.py b/src/wce_crm/form_defs.py index c000d8c..c58f359 100644 --- a/src/wce_crm/form_defs.py +++ b/src/wce_crm/form_defs.py @@ -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() diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index e5f1d0f..97f168b 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -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