diff --git a/src/wce_crm/backend/backend.py b/src/wce_crm/backend/backend.py index cb43348..970e4d1 100644 --- a/src/wce_crm/backend/backend.py +++ b/src/wce_crm/backend/backend.py @@ -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 diff --git a/src/wce_crm/data_models.py b/src/wce_crm/data_models.py index f007e0b..3c5772c 100644 --- a/src/wce_crm/data_models.py +++ b/src/wce_crm/data_models.py @@ -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 diff --git a/src/wce_crm/gui.py b/src/wce_crm/gui.py index 0447313..4f9cdd9 100644 --- a/src/wce_crm/gui.py +++ b/src/wce_crm/gui.py @@ -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()