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
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,10 @@ Allows authorized users to create, edit, and manage document pages. Support for

---

## 🛠 What's New (v0.2.16)
## 🛠 What's New (v0.2.18)

- Improved toolbar behavior in document edit/create windows.
- `Duplicate` and `Delete` now only mark a document as changed when pages were actually modified.
- Reduced accidental “unsaved changes” state when pressing toolbar actions with no selected pages.
- Fixed an issue where selected Department or Category could occasionally lose visual highlight after collapsing and expanding sidebar groups.
- Improved sidebar behavior so the previously selected item is restored more reliably after expanding a group.

---

Expand Down
7 changes: 3 additions & 4 deletions README_RU.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,10 @@

---

## 🛠 Что нового (v0.2.16)
## 🛠 Что нового (v0.2.18)

- Улучшено поведение панели инструментов в окнах создания и редактирования документа.
- `Дублировать` и `Удалить` теперь помечают документ как изменённый только при реальном изменении страниц.
- Снижен риск ложного состояния “есть несохранённые изменения” при нажатии действий без выбранных страниц.
- Исправлена ошибка, из-за которой при сворачивании и разворачивании групп в боковой панели иногда визуально пропадала подсветка выбранного Отдела или Категории.
- Улучшено поведение боковой панели: после разворачивания группы выбранный пункт теперь восстанавливается стабильнее.

---

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.16"
APP_VERSION = "0.2.18"

class Application:
"""
Expand Down
9 changes: 9 additions & 0 deletions core/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import tempfile
import subprocess
import logging
from pathlib import Path
from packaging import version
from PyQt5.QtCore import QObject, pyqtSignal, QThread, Qt, QTimer
from PyQt5.QtWidgets import QDialog, QMessageBox
Expand Down Expand Up @@ -247,6 +248,14 @@ def _show_install_confirmation(self, file_path):
self._install_update(file_path)

def _install_update(self, file_path):
installer_path = Path(file_path)
if not installer_path.exists() or installer_path.stat().st_size <= 0:
NotificationService().show_toast("error", "Ошибка", "Файл обновления поврежден или не найден.")
return
if sys.platform == "win32" and installer_path.suffix.lower() != ".exe":
NotificationService().show_toast("error", "Ошибка", "Некорректный формат установщика обновления.")
return

try:
if sys.platform == "win32":
os.startfile(file_path)
Expand Down
14 changes: 11 additions & 3 deletions modules/auth/mvc/auth_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,10 @@ def logout(self) -> None:
"""
def delete_file(file_path: Path) -> None:
if file_path.exists():
file_path.unlink()
try:
file_path.unlink()
except OSError as e:
logging.warning(f"Failed to delete file {file_path}: {e}")

def disable_auto_login(user_id: int) -> None:
profile_path = self.APP_DIR / "Profiles" / f"user_data_{user_id}.json"
Expand All @@ -269,8 +272,11 @@ def disable_auto_login(user_id: int) -> None:
return

profile_data["auto_login"] = False
with open(profile_path, "w", encoding="utf-8") as f:
json.dump(profile_data, f, indent=4, ensure_ascii=False)
try:
with open(profile_path, "w", encoding="utf-8") as f:
json.dump(profile_data, f, indent=4, ensure_ascii=False)
except OSError as e:
logging.warning(f"Failed to disable auto-login in {profile_path}: {e}")

# Get last user id
last_logged_data = read_json(self.LOCAL_DIR_LAST_LOGGED)
Expand All @@ -288,6 +294,8 @@ def disable_auto_login(user_id: int) -> None:
logging.info(f"Tokens for user_id {user_id} deleted from keyring.")
except keyring_errors.PasswordDeleteError:
logging.info(f"Tokens for user_id {user_id} not found in keyring, skipping deletion.")
except Exception as e:
logging.warning(f"Failed to delete tokens for user_id {user_id}: {e}")

disable_auto_login(user_id)

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 @@ -304,11 +304,13 @@ def _on_files_dropped(self, files: list) -> None:
msg += f"\n...и еще {len(oversized_files) - 5}"
NotificationService().show_toast("error", "Ошибка загрузки", msg)

added_any = False
for file_path in valid_files:
self.model.add_pending_file(file_path)
self.view.add_file_widget(file_path)
if self.model.add_pending_file(file_path):
self.view.add_file_widget(file_path)
added_any = True

if valid_files:
if added_any:
self._on_document_data_changed()


Expand Down
45 changes: 39 additions & 6 deletions modules/document_editor/mvc/document_editor_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import keyring
import requests
import os

from pathlib import Path

Expand All @@ -22,6 +23,7 @@ def __init__(
self.document_data = document_data if document_data is not None else {}
self.pages = pages if pages is not None else []
self.pending_files = []
self._pending_file_keys = set()

self.config_data = load_config()

Expand Down Expand Up @@ -137,24 +139,55 @@ def upload_file(self, file_path: str) -> dict:
return self._make_authorized_request(self.api.upload_file, document_id=doc_id, file_path=file_path)


def add_pending_file(self, file_path: str) -> None:
"""Adds a file to the pending upload list."""
if file_path not in self.pending_files:
self.pending_files.append(file_path)
@staticmethod
def _pending_file_key(file_path: str) -> str:
"""Normalizes file path for duplicate detection."""
try:
normalized = str(Path(file_path).expanduser().resolve(strict=False))
except Exception:
normalized = str(file_path)
return normalized.lower() if os.name == "nt" else normalized

def _rebuild_pending_file_keys(self) -> None:
self._pending_file_keys = {self._pending_file_key(path) for path in self.pending_files}

def add_pending_file(self, file_path: str) -> bool:
"""Adds a file to the pending upload list.

Returns:
bool: True if file added, False if duplicate.
"""
self._rebuild_pending_file_keys()
key = self._pending_file_key(file_path)
if key in self._pending_file_keys:
return False
self.pending_files.append(file_path)
self._pending_file_keys.add(key)
return True

def remove_pending_file(self, file_path: str) -> None:
"""Removes a file from the pending upload list."""
if file_path in self.pending_files:
self.pending_files.remove(file_path)
self._rebuild_pending_file_keys()
remove_key = self._pending_file_key(file_path)
to_remove = None
for path in self.pending_files:
if self._pending_file_key(path) == remove_key:
to_remove = path
break
if to_remove is not None:
self.pending_files.remove(to_remove)
self._pending_file_keys.discard(remove_key)


def upload_pending_files(self) -> None:
"""Uploads all pending files."""
self._rebuild_pending_file_keys()
errors = []
for file_path in list(self.pending_files):
try:
self.upload_file(file_path)
self.pending_files.remove(file_path)
self._pending_file_keys.discard(self._pending_file_key(file_path))
except Exception as e:
errors.append(f"Не удалось загрузить {Path(file_path).name}: {e}")

Expand Down
11 changes: 11 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,17 @@ def test_logout(self, model):
mock_json_dump.assert_called()
model.LOCAL_DIR_LAST_LOGGED.unlink.assert_called_once()

def test_logout_handles_file_delete_errors_gracefully(self, model):
model.LOCAL_DIR_LAST_LOGGED.exists.return_value = True
model.LOCAL_DIR_LAST_LOGGED.unlink.side_effect = OSError("locked")

with patch("modules.auth.mvc.auth_model.read_json", side_effect=[{"user_id": 1}, {"auto_login": True}]), \
patch("modules.auth.mvc.auth_model.keyring.delete_password"), \
patch("builtins.open", new_callable=MagicMock), \
patch("json.dump"):
# Should not raise even if unlink fails.
model.logout()


def test_verify_token(self, model):
"""Test token verification logic."""
Expand Down
91 changes: 90 additions & 1 deletion tests/test_custom_widgets.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import pytest
from unittest.mock import Mock, patch
from PyQt5.QtCore import Qt
from PyQt5.QtCore import Qt, QItemSelectionModel
from PyQt5.QtGui import QIcon

from ui.custom_widgets.lineedits import TagsLineEdit
Expand Down Expand Up @@ -138,6 +138,95 @@ def test_selection(self, mock_tm, qapp):
sidebar.set_selected("B")
assert sidebar.get_selected_id() == "B"

@patch("ui.custom_widgets.treeview.ThemeManagerInstance")
def test_expand_restores_programmatic_selection_after_transient_clear(self, mock_tm, qapp):
"""Expanded group should restore last active item even if selection was transiently cleared."""
sidebar = SidebarBlock()
items = [
SidebarItem(id="A", title="A"),
SidebarItem(id="B", title="B")
]
sidebar.set_items(items, group_title="Departments")

model = sidebar.model()
group_index = model.index(0, 0)
b_index = model.index(1, 0, group_index)
selection_model = sidebar.selectionModel()

selection_model.setCurrentIndex(
b_index,
QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows
)
assert sidebar.get_selected_id() == "B"

sidebar.collapse(group_index)
qapp.processEvents()

selection_model.clearSelection()
selection_model.setCurrentIndex(group_index, QItemSelectionModel.NoUpdate)

sidebar.expand(group_index)
qapp.processEvents()

assert sidebar.get_selected_id() == "B"

@patch("ui.custom_widgets.treeview.ThemeManagerInstance")
def test_expand_restores_selection_for_numeric_ids(self, mock_tm, qapp):
"""Expanded group should restore selected item when ids are numeric."""
sidebar = SidebarBlock()
items = [
SidebarItem(id=1, title="Dept 1"),
SidebarItem(id=2, title="Dept 2")
]
sidebar.set_items(items, group_title="Departments")

model = sidebar.model()
group_index = model.index(0, 0)
dept2_index = model.index(1, 0, group_index)
selection_model = sidebar.selectionModel()

selection_model.setCurrentIndex(
dept2_index,
QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows
)
assert sidebar.get_selected_id() == 2

sidebar.collapse(group_index)
qapp.processEvents()

selection_model.clearSelection()
selection_model.setCurrentIndex(group_index, QItemSelectionModel.NoUpdate)

sidebar.expand(group_index)
qapp.processEvents()

assert sidebar.get_selected_id() == 2

@patch("ui.custom_widgets.treeview.ThemeManagerInstance")
def test_set_items_preserves_selected_item_on_reload(self, mock_tm, qapp):
"""Reloading items should keep previously selected item when it still exists."""
sidebar = SidebarBlock()
initial_items = [
SidebarItem(id="A", title="Dept A", count=1),
SidebarItem(id="B", title="Dept B", count=2),
SidebarItem(id="C", title="Dept C", count=3),
]
sidebar.set_items(initial_items, group_title="Departments")
sidebar.set_selected("B")
assert sidebar.get_selected_id() == "B"

reloaded_items = [
SidebarItem(id="A", title="Dept A", count=10),
SidebarItem(id="B", title="Dept B", count=20),
SidebarItem(id="C", title="Dept C", count=30),
]
sidebar.set_items(reloaded_items, group_title="Departments")

assert sidebar.get_selected_id() == "B"
selected_rows = sidebar.selectionModel().selectedRows(0)
assert len(selected_rows) == 1
assert selected_rows[0].data(ROLE_ID) == "B"



class TestThemeSwitch:
Expand Down
28 changes: 27 additions & 1 deletion tests/test_document_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,27 @@ def path_side_effect(arg):
# Since model is a Mock, we check if the attribute was set
assert controller.model.is_document_edited is True

def test_files_dropped_duplicate_paths_are_ignored(self, controller):
files = ["/tmp/doc1.pdf", "/tmp/doc1.pdf"]

with patch("modules.document_editor.mvc.document_editor_controller.Path") as mock_path:
def path_side_effect(arg):
p = MagicMock()
p.name = str(arg).split("/")[-1]
p.suffix = "." + str(arg).split(".")[-1]
p.stat.return_value.st_size = 1024
return p

mock_path.side_effect = path_side_effect
controller.model.add_pending_file.side_effect = [True, False]
controller.model.is_document_edited = False

controller._on_files_dropped(files)

# Only first unique file should be shown in UI.
assert controller.view.add_file_widget.call_count == 1
assert controller.model.is_document_edited is True


def test_files_dropped_blocked_extension(self, controller):
"""Test dropping files with blocked extensions."""
Expand Down Expand Up @@ -202,6 +223,11 @@ def test_upload_file(self, model):
model.upload_file(file_path)
model.api.upload_file.assert_called_once()

def test_add_pending_file_deduplicates_paths(self, model):
assert model.add_pending_file("C:/Temp/test.pdf") is True
assert model.add_pending_file("C:/Temp/test.pdf") is False
assert len(model.pending_files) == 1


def test_upload_pending_files_success(self, model):
"""Test uploading multiple pending files."""
Expand Down Expand Up @@ -229,4 +255,4 @@ def test_upload_pending_files_failure(self, model):

assert "Upload failed" in str(excinfo.value)
# File should remain in pending if failed
assert "f1.pdf" in model.pending_files
assert "f1.pdf" in model.pending_files
25 changes: 24 additions & 1 deletion tests/test_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ def test_show_toast(self, mock_toast_class, mock_tm):
mock_toast_class.return_value = mock_toast_instance

# Call method
service.show_toast("success", "Title", "Message")
with patch("utils.notifications.notification_service.time.monotonic", return_value=100.0):
service.show_toast("success", "Title", "Message")

# Verify that ToastNotification was created with correct parameters
mock_toast_class.assert_called_once_with(
Expand All @@ -59,6 +60,28 @@ def test_show_toast(self, mock_toast_class, mock_tm):
mock_toast_instance.destroyed.connect.assert_called_once()
mock_toast_instance.show_animated.assert_called_once()

@patch("utils.notifications.notification_service.ThemeManagerInstance")
@patch("utils.notifications.notification_service.ToastNotification")
def test_show_toast_deduplicates_burst_identical_messages(self, mock_toast_class, mock_tm):
service = NotificationService()
mock_window = Mock(spec=QWidget)
mock_window.height.return_value = 600
mock_window.width.return_value = 800
service.set_main_window(mock_window)

toast_instance = Mock()
toast_instance.height.return_value = 80
toast_instance.width.return_value = 220
toast_instance.destroyed = Mock()
toast_instance.destroyed.connect = Mock()
mock_toast_class.return_value = toast_instance

with patch("utils.notifications.notification_service.time.monotonic", side_effect=[100.0, 100.2]):
service.show_toast("info", "Sync", "Done")
service.show_toast("info", "Sync", "Done")

mock_toast_class.assert_called_once()

@patch("utils.notifications.notification_service.ThemeManagerInstance")
def test_destroyed_toast_is_removed_from_stack(self, mock_tm):
service = NotificationService()
Expand Down
Loading
Loading