Dev #7

Merged
foefl merged 74 commits from dev into main 2026-07-31 14:19:01 +00:00
3 changed files with 162 additions and 105 deletions
Showing only changes of commit 5563e5a6b9 - Show all commits
+26 -6
View File
@@ -244,13 +244,16 @@ def page_consulting_to_db(
with db.ENGINE.begin() as conn:
if consultation_data.vorgang_id is None:
# insert new "Vorgang"
insert_data = consultation_data.model_dump(exclude={"vorgang_id", "beratungen"})
insert_data_process = consultation_data.model_dump(
exclude={"vorgang_id", "beratungen"}
)
logger.debug(
"[Consulting Page] Call insert 'Vorgang' with data:\n%s", pformat(insert_data)
"[Consulting Page] Call insert 'Vorgang' with data:\n%s",
pformat(insert_data_process),
)
stmt = db.beratung_vorgang.insert()
ret = conn.execute(stmt, insert_data)
ret = conn.execute(stmt, insert_data_process)
if ret.rowcount == 0:
raise IOError("Entry was not inserted correctly")
prim_keys = ret.inserted_primary_key
@@ -261,9 +264,14 @@ def page_consulting_to_db(
else:
logger.debug(
"[Consulting Page] VorgangID already set. ID: %d",
"[Consulting Page] VorgangID already set. ID: %d. Update...",
consultation_data.vorgang_id,
)
update_data_process = consultation_data.model_dump(exclude={"beratungen"})
stmt = db.beratung_vorgang.update().where(
db.beratung_vorgang.c.vorgang_id == consultation_data.vorgang_id
)
conn.execute(stmt, update_data_process)
rows_for_db_insert: list[dict[str, Any]] = []
rows_for_db_update: list[dict[str, Any]] = []
@@ -337,12 +345,24 @@ def page_consulting_from_db(
beratungs_typ=consultation_data_db["beratungs_typ"],
erstellt=consultation_data_db["erstellt"],
aktualisiert=consultation_data_db["aktualisiert"],
geloescht=consultation_data_db["geloescht"],
beratungen=[],
)
# get all consultation sessions of this process
if consultation_data.geloescht is not None:
logger.info(
(
"[Consulting Page] Obtained entry for ID=%d. This entry was marked "
"as deleted."
),
cons_id,
)
# get all consultation sessions of this process, only the ones
# which are not marked as deleted
stmt = db.beratung_einzelberatung.select().where(
db.beratung_einzelberatung.c.vorgang_id == cons_id
db.beratung_einzelberatung.c.vorgang_id == cons_id,
db.beratung_einzelberatung.c.geloescht.is_(None),
)
ret = conn.execute(stmt)
# empty results possible
+10 -2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import datetime
import enum
import json
from typing import Annotated, Any, Final, Literal
@@ -38,7 +39,7 @@ def translate_pydantic_errors(
err_type = error.get("type", None)
if err_type is None or err_type not in GERMAN_ERROR_MESSAGES:
error["msg"] = "[fehleden Übersetzung, nur Englisch]: " + error["msg"]
error["msg"] = "[fehlende Übersetzung, nur Englisch]: " + error["msg"]
translated_errors.append(error)
continue
@@ -346,6 +347,11 @@ class Grunderfassung_Sprachen(BaseModel):
# ** Consulting
class ConsultingType(enum.StrEnum):
PAUSCHAL = enum.auto()
INDIVIDUAL = enum.auto()
class Beratungsgespraech_Vorgang(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
@@ -353,11 +359,12 @@ class Beratungsgespraech_Vorgang(BaseModel):
un_id: int | None
pers_id: int | None
titel: str
beratungs_typ: Literal["pauschal", "individuell"]
beratungs_typ: ConsultingType
# default in SQLAlchemy with lambda and timezone-aware datetime
erstellt: AwareDatetime | None = Field(default=None, exclude=True)
aktualisiert: AwareDatetime | None = Field(default=None, exclude=True) # see above
geloescht: AwareDatetime | None = None
beratungen: list[Beratungsgespraech_Einzelgespraech]
@@ -379,3 +386,4 @@ class Beratungsgespraech_Einzelgespraech(BaseModel):
# default in SQLAlchemy with lambda and timezone-aware datetime
erstellt: AwareDatetime | None = Field(default=None, exclude=True)
aktualisiert: AwareDatetime | None = Field(default=None, exclude=True) # see above
geloescht: AwareDatetime | None = None
+126 -97
View File
@@ -3,10 +3,8 @@ from __future__ import annotations
import copy
import dataclasses as dc
import datetime
import enum
import re
import sys
import time
import traceback
from collections import defaultdict
from collections.abc import Container, Iterable, Sequence
@@ -33,10 +31,13 @@ from PySide6.QtCore import (
QDate,
QDateTime,
QEvent,
QLibraryInfo,
QLocale,
QObject,
Qt,
QTimer,
QtMsgType,
QTranslator,
Signal,
qInstallMessageHandler,
)
@@ -81,6 +82,7 @@ from wce_crm.data_models import (
COLUMN_SEP,
Beratungsgespraech_Einzelgespraech,
Beratungsgespraech_Vorgang,
ConsultingType,
FlatBaseModel,
Grunderfassung_Personen,
Grunderfassung_Unternehmen,
@@ -344,11 +346,6 @@ class Page_InitRecPerson_State(PageState[Module]):
pers_id: RecId | None = None
class ConsultingType(enum.StrEnum):
PAUSCHAL = enum.auto()
INDIVIDUAL = enum.auto()
@dc.dataclass(slots=True, kw_only=True)
class Page_Consulting_State(PageState[Module]):
session: Session
@@ -360,6 +357,7 @@ class Page_Consulting_State(PageState[Module]):
cons_sessions: list[Page_Consulting_ConsultingSession_State] = dc.field(
default_factory=list
)
ist_geloescht: bool = False
@dc.dataclass(slots=True, kw_only=True)
@@ -377,6 +375,7 @@ class Page_Consulting_ConsultingSession_State(PageState[Module]):
)
anmerkungen: str | None = None
rueckmeldung: str | None = None
ist_geloescht: bool = False
@dc.dataclass(slots=True, kw_only=True)
@@ -845,29 +844,6 @@ def get_form_data(
else:
value = get_widget_value(widget)
# 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(
# widget,
# (
# DynamicListWidget,
# DynamicDropdownWidgetNumeric,
# DynamicDropdownWidgetOption,
# CustomWidget,
# ),
# ):
# # this is a special data structure with some assumptions of the widget's internals
# # DynamicListWidget: this is a list -> each dynamic list contains a list
# # of such dictionaries (handled below)
# value = widget.get_form_data()
if isinstance(value, dict):
raw_data.update(value)
else:
@@ -1854,9 +1830,11 @@ class FlexibleDateInput(QWidget):
# valid or invalid QDate
return QDate.fromString(self.line_edit.text(), "dd.MM.yyyy")
def get_pydate(self) -> datetime.date:
def get_pydate(self) -> datetime.date | None:
# valid or invalid QDate
date = self.get_date()
if not date.isValid():
return None
py_date = cast(datetime.date, date.toPython())
return py_date
@@ -2065,7 +2043,7 @@ class FlexibleDateTimeInput(QWidget):
# valid or invalid QDateTime
return QDateTime.fromString(self.line_edit.text(), "dd.MM.yyyy hh:mm")
def get_pydatetime(self) -> datetime.datetime:
def get_pydatetime(self) -> datetime.datetime | None:
"""returns current value of the field as UTC datetime
Returns
@@ -2075,6 +2053,8 @@ class FlexibleDateTimeInput(QWidget):
"""
# valid or invalid datetime
dt = self.get_datetime()
if not dt.isValid():
return None
py_dt = cast(datetime.datetime, dt.toPython())
py_dt = py_dt.replace(tzinfo=TIMEZONE_CEST)
@@ -2488,7 +2468,6 @@ class DynamicDropdownWidgetOption(QWidget):
sub_form = SubForm(container, prefix_parent=self.prefix, index=number_form)
form_field_def = copy.copy(self.assigned_form_fields)
# form_field_def.label = form_field_def.enhanced_label(f"{number_form}")
sub_form.full_keys = _build_ui_recursively(
schema=form_field_def,
@@ -2746,6 +2725,7 @@ class Page_NewInitRec(QWidget):
self,
new_state: Page_NewInitRec_State,
) -> None:
set_page_state(self.STATE, new_state)
self._sync_state_to_GUI()
def get_state(self) -> Page_NewInitRec_State:
@@ -3371,42 +3351,6 @@ class Page_CompanyProfile(QWidget):
txt = f"Unternehmen: {name}"
self.title_company_name.setText(txt)
# def load_from_id(
# self,
# id_: InitRecId,
# ) -> None:
# assert id_ is not None, "tried to load with InitRecId None"
# assert id_, "tried to load with invalid InitRecId"
# logger_gui.debug(f"Called function to load with ID: {id_}")
# if id_ < 0:
# # just clear and do nothing else
# logger_gui.debug("just reset with ID < 0")
# self.table_master_data.reset()
# self.set_company_name("")
# self.current_id = id_
# return
# if id_ == self.current_id:
# logger_gui.debug("ID did not change. Do nothing")
# self.current_id = id_
# return
# init_rec = backend.initrec_company_get_initial_recording(id_)
# ma_id = cast(ExtMaId, init_rec["Partnersuche__un_suche"])
# assert ma_id is not None, "no ma_id obtained"
# comp_info = backend.initrec_comp_search_get_info(ma_id)
# data: dict[str, Any] = {}
# for feature, value in comp_info.items():
# if feature in self.COMPANY_FEATURES_MASTER_DATA:
# data[self.COMPANY_FEATURES_MASTER_DATA[feature]] = value
# self.table_master_data.load_data(data)
# self.set_company_name(data["Name:"])
# self.current_id = id_
class MasterDataTable(QWidget):
def __init__(self):
@@ -3668,13 +3612,6 @@ class Page_Consulting(QWidget):
separator1.setFrameShadow(QFrame.Shadow.Sunken)
container_layout.addWidget(separator1)
# layout_change_title = QHBoxLayout()
# self.change_title_text = QLineEdit(placeholderText="Unternehmensname eingeben...")
# layout_change_title.addWidget(self.change_title_text)
# change_title_btn = QPushButton("ändern")
# layout_change_title.addWidget(change_title_btn)
# change_title_btn.clicked.connect(self.set_company_name)
# container_layout.addLayout(layout_change_title)
btn_get_data_table = QPushButton("Debug call 'get_state' from table and page")
btn_get_data_table.clicked.connect(self._debug_get_state)
container_layout.addWidget(btn_get_data_table)
@@ -3937,13 +3874,23 @@ class Page_Consulting(QWidget):
self.STATE.un_id = data.un_id
self.STATE.pers_id = data.pers_id
self.STATE.titel = data.titel
self.STATE.ist_geloescht = data.geloescht is not None
if update_only:
for gui_state, pydantic_state in zip(self.STATE.cons_sessions, data.beratungen):
relevant_gui_states = [s for s in self.STATE.cons_sessions if not s.ist_geloescht]
rel_cons_session_states = [s for s in data.beratungen if s.geloescht is None]
print(f">>>>>>> {pformat(relevant_gui_states)}")
assert len(relevant_gui_states) == len(rel_cons_session_states)
for gui_state, pydantic_state in zip(
relevant_gui_states, rel_cons_session_states
):
gui_state.beratung_id = pydantic_state.beratung_id
else:
self.STATE.cons_sessions.clear()
for pydantic_state in data.beratungen:
# Deleted sessions are not part of the retrieved data from the backend if this
# page is initialised from a fresh backend call to get data. (Updates do
# indeed have data which must be filtered -- see above)
cons_session_state = Page_Consulting_ConsultingSession_State(
session=self.STATE.session,
nutzer_id=pydantic_state.nutzer_id,
@@ -3989,10 +3936,32 @@ class Page_Consulting(QWidget):
pydantic_sessions: list[Beratungsgespraech_Einzelgespraech] = []
timestamp_deleted: datetime.datetime | None = None
if self.STATE.ist_geloescht:
timestamp_deleted = datetime.datetime.now(datetime.UTC)
for row in self.STATE.cons_sessions:
logger_page_consulting.debug("[Consulting Page] Row state:\n%s", row)
if row.beratung_id is None and row.ist_geloescht:
logger_page_consulting.debug(
(
"[Consulting Page] Session which was never saved in the database "
"and is marked for deletion - ignore."
)
)
continue
timestamp_deleted_session: datetime.datetime | None = None
if timestamp_deleted is not None:
# if process is deleted, all sessions must be marked as deleted
timestamp_deleted_session = timestamp_deleted
elif row.ist_geloescht:
timestamp_deleted_session = datetime.datetime.now(datetime.UTC)
cons_session_dict = dc.asdict(row)
cons_session = Beratungsgespraech_Einzelgespraech(
vorgang_id=self.STATE.vorgang_id,
geloescht=timestamp_deleted_session,
**cons_session_dict,
)
pydantic_sessions.append(cons_session)
@@ -4003,6 +3972,7 @@ class Page_Consulting(QWidget):
pers_id=self.STATE.pers_id,
titel=self.STATE.titel,
beratungs_typ=self.STATE.beratungs_typ, # type: ignore
geloescht=timestamp_deleted,
beratungen=pydantic_sessions,
)
@@ -4016,6 +3986,10 @@ class Page_Consulting(QWidget):
if DEBUG_NO_DATABASE:
return
logger_page_consulting.debug("[Consulting Page] Validated Pydantic successfully.")
logger_page_consulting.debug(
"[Consulting Page] Pydantic model:\n%s", consulting_process
)
# !! should be placed in a try block to handle errors
res = backend.page_consulting_to_db(consulting_process)
if res.status != STATUS_HANDLER.SUCCESS:
@@ -4042,21 +4016,20 @@ class Page_Consulting(QWidget):
msg_box.exec()
return
updated_data = res.unwrap()
updated_consulting_process = res.unwrap()
logger_page_consulting.info(
"[Consulting Page] Load state from modified Pydantic model"
)
self._load_from_pydantic(updated_data, update_only=True)
self._load_from_pydantic(updated_consulting_process, update_only=True)
logger_page_consulting.info("[Consulting Page] Data saved successfully")
# TODO status message successful to user in GUI
msg_box = get_message_box(
QMessageBox.Icon.Information,
"Speichern erfolgreich",
"Die Daten wurden erfolgreich gespeichert",
)
msg_box.exec()
# TODO check triggers for rest of application
# self.update_triggered.emit()
# self._activate_delete()
finally:
@@ -4173,7 +4146,7 @@ class Page_Consulting_ConsultingSession(QWidget):
self.btn_delete.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_delete.setStyleSheet("color: #cc0000; font-weight: bold;")
self.btn_delete.clicked.connect(lambda: self.delete_request.emit(self))
self.btn_delete.clicked.connect(self._delete_request)
self.btn_delete.setFixedWidth(30)
layout.addWidget(self.btn_delete)
@@ -4206,7 +4179,17 @@ class Page_Consulting_ConsultingSession(QWidget):
layout.setStretch(i, stretch)
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
self._sync_state_to_GUI() # ?? only needed if entry is from database?
self._sync_state_to_GUI()
def _delete_request(self) -> None:
confirm = get_user_confirmation(
self,
"Löschen bestätigen",
"Soll der Eintrag wirklich gelöscht werden?",
)
if confirm:
self.STATE.ist_geloescht = True
self.delete_request.emit(self)
def _sync_state_to_GUI(self) -> None:
logger_page_consulting.debug(
@@ -4276,7 +4259,7 @@ class Page_Consulting_Table(QWidget):
self.btn_add.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_add.setObjectName("BlueAddButton") # ID vergeben
main_layout.addWidget(self.btn_add)
self.btn_add.clicked.connect(lambda: self.add_row(None))
self.btn_add.clicked.connect(lambda: self.add_row(None, False))
self.btn_add.setStyleSheet("""
#BlueAddButton {
@@ -4417,7 +4400,8 @@ class Page_Consulting_Table(QWidget):
def add_row(
self,
row_state: Page_Consulting_ConsultingSession_State | None = None,
row_state: Page_Consulting_ConsultingSession_State | None,
state_exists: bool,
) -> None:
if row_state is None:
row_state = Page_Consulting_ConsultingSession_State(
@@ -4429,10 +4413,13 @@ class Page_Consulting_Table(QWidget):
row = Page_Consulting_ConsultingSession(state=row_state)
row.delete_request.connect(self.remove_row)
# sys.exit(0)
index = self.container_layout.count() - 1
self.container_layout.insertWidget(index, row)
self.STATE.child_modules.append(row)
if not state_exists:
self.STATE.row_states.append(row.get_state())
def remove_row(
self,
@@ -4440,36 +4427,49 @@ class Page_Consulting_Table(QWidget):
) -> None:
self.container_layout.removeWidget(table_row_widget)
self.STATE.child_modules.remove(table_row_widget)
# do not remove the corresponding state since it is tracked
# to be post-processed when the database saving operation is triggered
table_row_widget.deleteLater()
def reset(self) -> None:
def remove_all_rows(self) -> None:
while self.STATE.child_modules:
self.remove_row(self.STATE.child_modules[0])
def _sync_state_to_GUI(self) -> None:
self.setUpdatesEnabled(False)
self.reset()
self.remove_all_rows()
# states_to_show = [s for s in self.STATE.row_states if not s.ist_geloescht]
# for row_state in states_to_show:
for row_state in self.STATE.row_states:
self.add_row(row_state)
if not row_state.ist_geloescht:
self.add_row(row_state, True)
self.setUpdatesEnabled(True)
def _sync_GUI_to_state(self) -> None:
logger_page_consulting.debug("[Page -- Consulting] Table: Call syncing GUI to state")
row_data: list[Page_Consulting_ConsultingSession_State] = []
logger_page_consulting.debug(
(
"[Page -- Consulting] Table: Row data is managed when entries are added. "
"There is no action needed. All states are obtained by reference and set "
"by their row components/widgets. Therefore, this method calls the "
"method `get_state` of all ACTIVE components to update their respective "
"states with the current user inputs."
)
)
logger_page_consulting.debug(
"[Page -- Consulting] Table - Child modules: %s",
pformat(self.STATE.child_modules),
)
# Child modules are ACTIVE elements, viewable and changeable by the user.
# The states are already part of this widget and linked by reference, but
# the current user input must be synced with these states. Therefore, the
# `get_state` method of these children is called, which implicitly updates
# their state. The returned state reference is not needed.
for row in self.STATE.child_modules:
row_data.append(row.get_state())
assert len(row_data) == len(self.STATE.child_modules), (
"mismatch in row count and data length"
)
self.STATE.row_states = row_data
_ = row.get_state()
def get_state(self) -> Page_Consulting_Table_State:
logger_page_consulting.debug("[Page Consulting -- Table] Syncing GUI to state...")
@@ -4536,7 +4536,6 @@ def clear_layout(
if layout is None:
return
# for idx in range(start_idx, layout.count()):
while layout.count():
child = layout.takeAt(0)
if child is None:
@@ -4864,6 +4863,25 @@ def get_message_box(
return msg_box
def get_user_confirmation(
parent_widget: QWidget,
title: str,
message: str,
) -> bool:
response = QMessageBox.question(
parent_widget,
title,
message,
buttons=(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No),
defaultButton=QMessageBox.StandardButton.No,
)
if response == QMessageBox.StandardButton.Yes:
return True
else:
return False
def pydantic_validation_error_handling(
val_error: ValidationError,
) -> None:
@@ -4959,15 +4977,26 @@ if TYPE_CHECKING:
_w1: type[WrapperModule] = Page_InitRecCompany
_w2: type[WrapperModule] = Page_InitRecPerson
if __name__ == "__main__":
def main() -> None:
sys.excepthook = global_exception_handler
qInstallMessageHandler(qt_message_handler)
try:
app = QApplication(sys.argv)
app.setStyleSheet(QSS)
translator = QTranslator()
translation_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
if translator.load(QLocale.system(), "qtbase", "_", translation_path):
app.installTranslator(translator)
window = MainWindow()
window.show()
sys.exit(app.exec())
except Exception as err:
logger_gui.critical("Fehler beim Starten der Anwendung:\n%s", str(err), exc_info=True)
if __name__ == "__main__":
main()