Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions README_RU.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@

---

## 💡 Что нового (v0.2.1) - Стабильность и профиль

Это патч-обновление повышает надежность и улучшает поведение при работе с профилем.

### 🛠 Улучшено

- Исправлен сбой при открытии профиля для аккаунтов без заполненного отображаемого имени.
- Повышена надежность обновления данных профиля после обновлений backend-сервиса.
- Улучшена обработка пустых значений в названиях документов и страниц.
- Повышена стабильность поиска и загрузки документов в пограничных сценариях.

---

## 💡 Что нового (v0.2.0) - Профиль и Настройки

Это обновление добавляет персонализацию и удобство использования.
Expand Down
2 changes: 1 addition & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down
8 changes: 5 additions & 3 deletions modules/document_editor/mvc/document_editor_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
self.view.update_save_button_state(state=self.model.is_document_edited)
47 changes: 30 additions & 17 deletions modules/main/mvc/main_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
42 changes: 39 additions & 3 deletions modules/main/mvc/main_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
)

Expand Down Expand Up @@ -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]:
Expand Down
13 changes: 9 additions & 4 deletions modules/profile/profile_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""

Expand Down Expand Up @@ -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 ""

Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 16 additions & 1 deletion tests/test_document_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
controller.window.close.assert_called_once()
40 changes: 40 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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={},
)
34 changes: 34 additions & 0 deletions tests/test_main_model.py
Original file line number Diff line number Diff line change
@@ -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

Loading
Loading