diff --git a/README.md b/README.md index eb0988d..52da745 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,19 @@ Allows authorized users to create, edit, and manage document pages. Support for --- +## 💡 What's New (v0.2.1) - Stability & Profile Improvements + +This patch release improves reliability and profile editing behavior. + +### 🛠 Improved + +- Fixed a crash when opening the profile window for accounts without a filled display name. +- Improved reliability of profile data updates after backend deployment updates. +- Improved handling of empty values in document and page names. +- Improved stability of search and document loading in edge cases. + +--- + ## 💡 What's New (v0.2.0) - Profile & Settings This update focuses on personalization and user experience. diff --git a/README_RU.md b/README_RU.md index cb6edee..7797807 100644 --- a/README_RU.md +++ b/README_RU.md @@ -35,6 +35,19 @@ --- +## 💡 Что нового (v0.2.1) - Стабильность и профиль + +Это патч-обновление повышает надежность и улучшает поведение при работе с профилем. + +### 🛠 Улучшено + +- Исправлен сбой при открытии профиля для аккаунтов без заполненного отображаемого имени. +- Повышена надежность обновления данных профиля после обновлений backend-сервиса. +- Улучшена обработка пустых значений в названиях документов и страниц. +- Повышена стабильность поиска и загрузки документов в пограничных сценариях. + +--- + ## 💡 Что нового (v0.2.0) - Профиль и Настройки Это обновление добавляет персонализацию и удобство использования. diff --git a/app.py b/app.py index f89933d..d277c84 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") -APP_VERSION = "0.2.0" +APP_VERSION = "0.2.1" class Application: """ diff --git a/modules/document_editor/mvc/document_editor_controller.py b/modules/document_editor/mvc/document_editor_controller.py index 0fcd350..7e06425 100644 --- a/modules/document_editor/mvc/document_editor_controller.py +++ b/modules/document_editor/mvc/document_editor_controller.py @@ -76,7 +76,8 @@ def _generate_tags(self) -> None: # Get tags from document name document_tags = {} - document_name_tags = re.sub(r"[^\w\s]", "", document_data.get("name", "")).split() + document_name = str(document_data.get("name") or "") + document_name_tags = re.sub(r"[^\w\s]", "", document_name).split() for tag in document_name_tags: if len(tag) < 4: continue @@ -86,7 +87,8 @@ def _generate_tags(self) -> None: # Get tags from document pages pages_tags = {} for page in document_data.get("pages", []): - page_tags = re.sub(r"[^\w\s]", "", page.get("name")).split() + page_name = str(page.get("name") or "") + page_tags = re.sub(r"[^\w\s]", "", page_name).split() for tag in page_tags: if len(tag) < 4: continue @@ -663,4 +665,4 @@ def _update_generate_button_state(self): def _update_save_document_button_state(self): """Updates the save document button state.""" - self.view.update_save_button_state(state=self.model.is_document_edited) \ No newline at end of file + self.view.update_save_button_state(state=self.model.is_document_edited) diff --git a/modules/main/mvc/main_controller.py b/modules/main/mvc/main_controller.py index c00ccde..964f014 100644 --- a/modules/main/mvc/main_controller.py +++ b/modules/main/mvc/main_controller.py @@ -400,9 +400,13 @@ def _show_profile_dialog(self) -> None: ) if updated_data: + current_user_id = user_data.get("id") or user_data.get("user_id") + if current_user_id is None: + current_user_id = self.model.get_current_user_id() + worker = APIWorker( self.model.update_user_profile, - user_id=user_data['id'], + user_id=current_user_id, data=updated_data ) self.active_workers.add(worker) @@ -446,13 +450,16 @@ def _on_department_selected(self, selected, deselected) -> None: if dept_id is not None and dept_id != self.model.current_department_id: self.model.current_department_id = dept_id self.model.current_category_id = None - - # Explicitly clear documents to avoid showing stale data from previous department - self._update_documents_list() + self._update_categories_list() - + + # With active search keep previous table until new results arrive. + # This avoids a blank table if the first request after idle fails. if self.view.get_search_text(): self._on_search_lineedit_text_changed() + else: + # No search active: clear stale documents and load current category data. + self._update_documents_list() self._update_create_category_state() self._update_create_document_state() @@ -713,11 +720,7 @@ def _search_data_task( # Remove tags from query to get clean text clean_query = re.sub(r'@[^\s]+', '', query).strip() - cat_id = self.model.current_category_id - group_id = None - if isinstance(cat_id, str) and cat_id.startswith("virtual_"): - group_id = int(cat_id.split("_")[1]) - cat_id = None + cat_id, group_id = self._resolve_category_and_group(self.model.current_category_id) search_result = self.model.search_data( query=clean_query, @@ -919,7 +922,7 @@ def _setup_connections(self) -> None: def _on_filter_tag_toggled(self, checked: bool, text: str) -> None: """Handles filter tag toggle event.""" tag_query = f"@{text}" - current_text = self.view.get_search_text() + current_text = self.view.get_search_text() or "" parts = current_text.split() # Case-insensitive handling @@ -1021,12 +1024,7 @@ def _load_more_documents(self) -> None: self.is_loading = True try: - cat_id = self.model.current_category_id - group_id = None - - if isinstance(cat_id, str) and cat_id.startswith("virtual_"): - group_id = int(cat_id.split("_")[1]) - cat_id = None + cat_id, group_id = self._resolve_category_and_group(self.model.current_category_id) worker = APIWorker( self.model.fetch_documents, @@ -1047,6 +1045,21 @@ def _load_more_documents(self) -> None: self.is_loading = False logger.error(f"Failed to start load worker: {e}", exc_info=True) self._handle_error(e, "Ошибка загрузки") + + def _resolve_category_and_group(self, category_id): + """Converts virtual category IDs to a group_id safely.""" + group_id = None + cat_id = category_id + + if isinstance(category_id, str) and category_id.startswith("virtual_"): + suffix = category_id.split("_", 1)[1].strip() + if suffix.isdigit(): + group_id = int(suffix) + else: + logger.warning(f"Invalid virtual category id format: {category_id}") + cat_id = None + + return cat_id, group_id def _on_load_more_finished(self, docs: list) -> None: if self.sender() != self.current_load_worker: diff --git a/modules/main/mvc/main_model.py b/modules/main/mvc/main_model.py index 0729f99..c0afe35 100644 --- a/modules/main/mvc/main_model.py +++ b/modules/main/mvc/main_model.py @@ -44,6 +44,7 @@ def get_user_data(self) -> dict | None: return None data = self.api.get_user_data(token) + data = self._normalize_user_data(data) return data @@ -79,15 +80,38 @@ def get_full_user_data(self) -> dict | None: return None # This might be the same as get_user_data, but let's assume it could be different - return self.api.get_user_data(token) + data = self.api.get_user_data(token) + return self._normalize_user_data(data) + + def get_current_user_id(self) -> int | None: + """Returns the currently logged-in user id from local session storage.""" + if self.mode == "guest": + return None + + last_logged = read_json(self.LOCAL_DIR_LAST_LOGGED) + if not isinstance(last_logged, dict): + return None + + user_id = last_logged.get("user_id") + if user_id is None: + return None + + try: + return int(user_id) + except (TypeError, ValueError): + return None - def update_user_profile(self, user_id: int, data: dict) -> dict: + def update_user_profile(self, user_id: int | None, data: dict) -> dict: """Updates the user's profile data.""" + resolved_user_id = user_id if user_id is not None else self.get_current_user_id() + if resolved_user_id is None: + raise ValueError("Cannot update profile: missing current user id") + # The data dict should contain 'username', 'department' (ID) return self._make_authorized_request( self.api.update_user_data, - user_id=user_id, + user_id=resolved_user_id, data=data ) @@ -330,6 +354,18 @@ def _get_user_token(self) -> str | None: ) return access_token + + @staticmethod + def _normalize_user_data(data: dict | None) -> dict | None: + """Normalizes API user payload to a flat user dict.""" + if not isinstance(data, dict): + return None + + nested_user = data.get("user") + if isinstance(nested_user, dict): + return nested_user + + return data def _get_departments(self) -> list[dict]: diff --git a/modules/profile/profile_dialog.py b/modules/profile/profile_dialog.py index eab9f20..71d2175 100644 --- a/modules/profile/profile_dialog.py +++ b/modules/profile/profile_dialog.py @@ -71,8 +71,7 @@ def _get_original_department_id(self) -> int | None: def _populate_initial_data(self): """Fills the widgets with the current user data.""" - username = self.user_data.get("username", "") - parts = username.split() + parts = self._split_username(self.user_data.get("username")) first_name = parts[0] if parts else "" last_name = " ".join(parts[1:]) if len(parts) > 1 else "" @@ -101,8 +100,7 @@ def _validate_changes(self): """Enables the save button only if there are actual changes.""" is_changed = False - username = self.user_data.get("username", "") - parts = username.split() + parts = self._split_username(self.user_data.get("username")) original_first_name = parts[0] if parts else "" original_last_name = " ".join(parts[1:]) if len(parts) > 1 else "" @@ -130,6 +128,13 @@ def _validate_changes(self): self.ui.accept_pushButton.setEnabled(is_changed) + @staticmethod + def _split_username(username) -> list[str]: + """Returns username parts safely even when the source value is None.""" + if username is None: + return [] + return str(username).split() + def get_updated_data(self) -> dict: """Returns the updated user data from the form.""" return { diff --git a/tests/test_document_editor.py b/tests/test_document_editor.py index fa58976..7b29c40 100644 --- a/tests/test_document_editor.py +++ b/tests/test_document_editor.py @@ -139,6 +139,21 @@ def test_generate_tags(self, controller): assert "Page" in tags assert "Gamma" in tags + def test_generate_tags_handles_none_names(self, controller): + """Tag generation should not crash when name fields are None.""" + controller.view.get_document_data.return_value = { + "name": None, + "pages": [ + {"name": None}, + {"name": "Valid Tag Name"}, + ], + } + + controller.view.set_document_tags.reset_mock() + controller._generate_tags() + + controller.view.set_document_tags.assert_called_once() + def test_save_button_clicked(self, controller): """Test save button handler initiates background task.""" @@ -191,4 +206,4 @@ def test_delete_document_clicked(self, controller): controller.model.delete_document.assert_called_once() controller.window.document_deleted.emit.assert_called_once() - controller.window.close.assert_called_once() \ No newline at end of file + controller.window.close.assert_called_once() diff --git a/tests/test_main.py b/tests/test_main.py index 76bf886..cdbed17 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -88,6 +88,21 @@ def test_department_selection(self, controller): assert controller.model.current_category_id is None controller.view.update_categories.assert_called() + def test_department_selection_with_search_does_not_clear_table(self, controller): + """With active search, department switch should not clear table before search response.""" + mock_index = Mock() + mock_index.data.return_value = 2 + mock_selection = Mock() + mock_selection.indexes.return_value = [mock_index] + controller.view.get_search_text.return_value = "query" + + with patch.object(controller, "_update_documents_list") as mock_update_docs, \ + patch.object(controller, "_on_search_lineedit_text_changed") as mock_search_changed: + controller._on_department_selected(mock_selection, None) + + mock_update_docs.assert_not_called() + mock_search_changed.assert_called_once() + def test_category_selection(self, controller): """Test selecting a category triggers document loading.""" @@ -244,3 +259,28 @@ def test_logout(self, controller): controller.logout_requested.connect(mock_slot) controller._on_logout_clicked() mock_slot.assert_called_once() + + def test_filter_tag_toggle_handles_none_search_text(self, controller): + """Filter toggling should handle empty/None search text safely.""" + controller.view.get_search_text.return_value = None + controller.view.set_search_text.reset_mock() + + controller._on_filter_tag_toggled(True, "alpha") + + controller.view.set_search_text.assert_called_once_with("@alpha") + + def test_search_task_handles_invalid_virtual_category_id(self, controller): + """Malformed virtual category id should not raise and should skip group filter.""" + controller.model.current_category_id = "virtual_x" + controller.model.search_data.return_value = [] + + result = controller._search_data_task("query", {}, []) + + assert result == [] + controller.model.search_data.assert_called_once_with( + query="query", + tags=[], + category_id=None, + group_id=None, + filters={}, + ) diff --git a/tests/test_main_model.py b/tests/test_main_model.py new file mode 100644 index 0000000..d5c3c22 --- /dev/null +++ b/tests/test_main_model.py @@ -0,0 +1,34 @@ +from unittest.mock import patch + +from modules.main.mvc.main_model import MainModel + + +class TestMainModel: + def _build_model(self): + with patch("modules.main.mvc.main_model.load_config", return_value={"base_url": "http://localhost"}), \ + patch("modules.main.mvc.main_model.get_app_data_dir"), \ + patch("modules.main.mvc.main_model.get_local_data_dir"): + return MainModel(mode="auth") + + def test_get_user_data_normalizes_nested_payload(self): + model = self._build_model() + model._get_user_token = lambda: "token" + model.api.get_user_data = lambda token: {"user": {"id": 7, "email": "u@x.test"}} + + data = model.get_user_data() + + assert data == {"id": 7, "email": "u@x.test"} + + def test_get_full_user_data_normalizes_nested_payload(self): + model = self._build_model() + model._get_user_token = lambda: "token" + model.api.get_user_data = lambda token: {"user": {"id": 3, "username": "Test User"}} + + data = model.get_full_user_data() + + assert data == {"id": 3, "username": "Test User"} + + def test_normalize_user_data_keeps_flat_payload(self): + flat = {"id": 1, "email": "flat@x.test"} + assert MainModel._normalize_user_data(flat) == flat + diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py index b926899..e5d9145 100644 --- a/utils/whats_new_modal.py +++ b/utils/whats_new_modal.py @@ -17,35 +17,17 @@ RELEASE_NOTES = { - "0.2.0": [ + "0.2.1": [ { - "title": "Редактирование профиля", + "title": "Стабильность и профиль", "items": [ - "Теперь можно редактировать данные профиля пользователя прямо в приложении: имя, фамилию и отдел.", - "Диалог профиля доступен из главного меню приложения.", + "Исправлен сбой при открытии профиля для аккаунтов без заполненного отображаемого имени.", + "Повышена надежность обновления данных профиля после обновлений backend-сервиса.", + "Улучшена обработка пустых значений в названиях документов и страниц.", + "Повышена стабильность поиска и загрузки документов в пограничных сценариях.", ], }, - { - "title": "Сохранение настроек", - "items": [ - "Приложение теперь запоминает персональные настройки отдельно для каждого пользователя.", - "Выбранная тема интерфейса сохраняется между сессиями.", - "Последние использованные фильтры поиска сохраняются и ускоряют повторную работу.", - ], - }, - { - "title": "Также в этом обновлении", - "items": [ - "Добавлено отдельное окно «Что нового», которое показывается после обновления и отображает список изменений версии.", - "Небольшие улучшения интерфейса и общие исправления ошибок для более стабильной работы.", - "Улучшена стабильность после простоя: список документов и результаты поиска больше не исчезают при ошибках повторной загрузки.", - "Стартовая загрузка главного окна вынесена из UI-потока, поэтому приложение меньше подвисает при открытии.", - "Сценарии авторизации теперь корректно завершаются ошибкой, если системное хранилище сессии недоступно.", - "API-клиент теперь корректно обрабатывает пустые успешные ответы, например 204 No Content.", - "При выходе из аккаунта теперь явно отключается auto-login для текущего профиля.", - ], - }, - ] + ], }