diff --git a/plugins/DigitalLibrary/src/DFFileExportAndUploadManager.py b/plugins/DigitalLibrary/src/DFFileExportAndUploadManager.py index 93e24a0651..69163f9cdf 100644 --- a/plugins/DigitalLibrary/src/DFFileExportAndUploadManager.py +++ b/plugins/DigitalLibrary/src/DFFileExportAndUploadManager.py @@ -2,6 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. import json import threading +from json import JSONDecodeError from typing import List, Dict, Any, Callable, Union, Optional from PyQt5.QtCore import QUrl @@ -43,7 +44,7 @@ class DFFileExportAndUploadManager: self._library_project_id = library_project_id # type: str self._library_project_name = library_project_name # type: str self._file_name = file_name # type: str - + self._upload_jobs = [] # type: List[ExportFileJob] self._formats = formats # type: List[str] self._api = DigitalFactoryApiClient(application = CuraApplication.getInstance(), on_error = lambda error: Logger.log("e", str(error))) @@ -80,6 +81,8 @@ class DFFileExportAndUploadManager: ) self._generic_success_message.actionTriggered.connect(self._onMessageActionTriggered) + + def _onCuraProjectFileExported(self, job: ExportFileJob) -> None: """Handler for when the DF Library workspace file (3MF) has been created locally. @@ -271,7 +274,11 @@ class DFFileExportAndUploadManager: def extractErrorTitle(reply_body: Optional[str]) -> str: error_title = "" if reply_body: - reply_dict = json.loads(reply_body) + try: + reply_dict = json.loads(reply_body) + except JSONDecodeError: + Logger.logException("w", "Unable to extract title from reply body") + return error_title if "errors" in reply_dict and len(reply_dict["errors"]) >= 1 and "title" in reply_dict["errors"][0]: error_title = reply_dict["errors"][0]["title"] return error_title @@ -313,8 +320,13 @@ class DFFileExportAndUploadManager: QDesktopServices.openUrl(QUrl(project_url)) message.hide() + def start(self) -> None: + for job in self._upload_jobs: + job.start() + def initializeFileUploadJobMetadata(self) -> Dict[str, Any]: metadata = {} + self._upload_jobs = [] if "3mf" in self._formats and "3mf" in self._file_handlers and self._file_handlers["3mf"]: filename_3mf = self._file_name + ".3mf" metadata[filename_3mf] = { @@ -335,7 +347,7 @@ class DFFileExportAndUploadManager: } job_3mf = ExportFileJob(self._file_handlers["3mf"], self._nodes, self._file_name, "3mf") job_3mf.finished.connect(self._onCuraProjectFileExported) - job_3mf.start() + self._upload_jobs.append(job_3mf) if "ufp" in self._formats and "ufp" in self._file_handlers and self._file_handlers["ufp"]: filename_ufp = self._file_name + ".ufp" @@ -357,5 +369,5 @@ class DFFileExportAndUploadManager: } job_ufp = ExportFileJob(self._file_handlers["ufp"], self._nodes, self._file_name, "ufp") job_ufp.finished.connect(self._onPrintFileExported) - job_ufp.start() + self._upload_jobs.append(job_ufp) return metadata diff --git a/plugins/DigitalLibrary/src/DigitalFactoryController.py b/plugins/DigitalLibrary/src/DigitalFactoryController.py index 33fcc506e7..8f65faa2a3 100644 --- a/plugins/DigitalLibrary/src/DigitalFactoryController.py +++ b/plugins/DigitalLibrary/src/DigitalFactoryController.py @@ -541,6 +541,7 @@ class DigitalFactoryController(QObject): on_upload_success = self.uploadFileSuccess.emit, on_upload_finished = self.uploadFileFinished.emit, on_upload_progress = self.uploadFileProgress.emit) + self.file_upload_manager.start() # Save the project id to make sure it will be preselected the next time the user opens the save dialog self._current_workspace_information.setEntryToStore("digital_factory", "library_project_id", library_project_id) diff --git a/plugins/DigitalLibrary/src/DigitalFactoryFileModel.py b/plugins/DigitalLibrary/src/DigitalFactoryFileModel.py index 718bd11cd2..535cce0e8f 100644 --- a/plugins/DigitalLibrary/src/DigitalFactoryFileModel.py +++ b/plugins/DigitalLibrary/src/DigitalFactoryFileModel.py @@ -40,6 +40,7 @@ class DigitalFactoryFileModel(ListModel): def setFiles(self, df_files_in_project: List[DigitalFactoryFileResponse]) -> None: if self._files == df_files_in_project: return + self.clear() self._files = df_files_in_project self._update() diff --git a/plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py b/plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py index 30c04c7177..d76774cab1 100644 --- a/plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py +++ b/plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py @@ -21,7 +21,7 @@ class DigitalFactoryProjectModel(ListModel): dfProjectModelChanged = pyqtSignal() - def __init__(self, parent = None): + def __init__(self, parent = None) -> None: super().__init__(parent) self.addRoleName(self.DisplayNameRole, "displayName") self.addRoleName(self.LibraryProjectIdRole, "libraryProjectId") diff --git a/plugins/DigitalLibrary/tests/TestDFFileExportAndUploadManager.py b/plugins/DigitalLibrary/tests/TestDFFileExportAndUploadManager.py new file mode 100644 index 0000000000..2fb0ae4142 --- /dev/null +++ b/plugins/DigitalLibrary/tests/TestDFFileExportAndUploadManager.py @@ -0,0 +1,48 @@ +from unittest.mock import MagicMock, patch + +import pytest +from src.DFFileExportAndUploadManager import DFFileExportAndUploadManager + + +@pytest.fixture +def upload_manager(): + file_handler = MagicMock(name = "file_handler") + file_handler.getSupportedFileTypesWrite = MagicMock(return_value = [{ + "id": "test", + "extension": ".3mf", + "description": "nope", + "mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", + "mode": "binary", + "hide_in_file_dialog": True, + }]) + node = MagicMock(name = "SceneNode") + application = MagicMock(name = "CuraApplication") + with patch("cura.CuraApplication.CuraApplication.getInstance", MagicMock(return_value = application)): + return DFFileExportAndUploadManager(file_handlers = {"3mf": file_handler}, + nodes = [node], + library_project_id = "test_library_project_id", + library_project_name = "test_library_project_name", + file_name = "file_name", + formats = ["3mf"], + on_upload_error = MagicMock(), + on_upload_success = MagicMock(), + on_upload_finished = MagicMock(), + on_upload_progress = MagicMock()) + + +@pytest.mark.parametrize("input,expected_result", + [("", ""), + ("invalid json! {}", ""), + ("{\"errors\": [{}]}", ""), + ("{\"errors\": [{\"title\": \"some title\"}]}", "some title")]) +def test_extractErrorTitle(upload_manager, input, expected_result): + assert upload_manager.extractErrorTitle(input) == expected_result + + +def test_exportJobError(upload_manager): + mocked_application = MagicMock() + with patch("UM.Application.Application.getInstance", MagicMock(return_value = mocked_application)): + upload_manager._onJobExportError("file_name.3mf") + + # Ensure that message was displayed + mocked_application.showMessageSignal.emit.assert_called_once() diff --git a/plugins/DigitalLibrary/tests/TestDigitalFactoryFileModel.py b/plugins/DigitalLibrary/tests/TestDigitalFactoryFileModel.py new file mode 100644 index 0000000000..7817d03516 --- /dev/null +++ b/plugins/DigitalLibrary/tests/TestDigitalFactoryFileModel.py @@ -0,0 +1,73 @@ +from pathlib import Path + +from src.DigitalFactoryFileModel import DigitalFactoryFileModel +from src.DigitalFactoryFileResponse import DigitalFactoryFileResponse + + +file_1 = DigitalFactoryFileResponse(client_id = "client_id_1", + content_type = "zomg", + file_name = "file_1.3mf", + file_id = "file_id_1", + library_project_id = "project_id_1", + status = "test", + user_id = "user_id_1", + username = "username_1", + uploaded_at = "2021-04-07T10:33:25.000Z") + +file_2 = DigitalFactoryFileResponse(client_id ="client_id_2", + content_type = "zomg", + file_name = "file_2.3mf", + file_id = "file_id_2", + library_project_id = "project_id_2", + status = "test", + user_id = "user_id_2", + username = "username_2", + uploaded_at = "2021-02-06T09:33:22.000Z") + +file_wtf = DigitalFactoryFileResponse(client_id ="client_id_1", + content_type = "zomg", + file_name = "file_3.wtf", + file_id = "file_id_3", + library_project_id = "project_id_1", + status = "test", + user_id = "user_id_1", + username = "username_1", + uploaded_at = "2021-04-06T12:33:25.000Z") + + +def test_setFiles(): + model = DigitalFactoryFileModel() + + assert model.count == 0 + + model.setFiles([file_1, file_2]) + assert model.count == 2 + + assert model.getItem(0)["fileName"] == "file_1.3mf" + assert model.getItem(1)["fileName"] == "file_2.3mf" + + +def test_clearProjects(): + model = DigitalFactoryFileModel() + model.setFiles([file_1, file_2]) + model.clearFiles() + assert model.count == 0 + + +def test_setProjectMultipleTimes(): + model = DigitalFactoryFileModel() + model.setFiles([file_1, file_2]) + model.setFiles([file_2]) + assert model.count == 1 + assert model.getItem(0)["fileName"] == "file_2.3mf" + + +def test_setFilter(): + model = DigitalFactoryFileModel() + + model.setFiles([file_1, file_2, file_wtf]) + model.setFilters({"file_name": lambda x: Path(x).suffix[1:].lower() in ["3mf"]}) + assert model.count == 2 + + model.clearFilters() + assert model.count == 3 diff --git a/plugins/DigitalLibrary/tests/TestDigitalFactoryProjectModel.py b/plugins/DigitalLibrary/tests/TestDigitalFactoryProjectModel.py new file mode 100644 index 0000000000..890f62f3f6 --- /dev/null +++ b/plugins/DigitalLibrary/tests/TestDigitalFactoryProjectModel.py @@ -0,0 +1,55 @@ + +from src.DigitalFactoryProjectModel import DigitalFactoryProjectModel +from src.DigitalFactoryProjectResponse import DigitalFactoryProjectResponse + + +project_1 = DigitalFactoryProjectResponse(library_project_id = "omg", + display_name = "zomg", + username = "nope", + organization_shared = True) + +project_2 = DigitalFactoryProjectResponse(library_project_id = "omg2", + display_name = "zomg2", + username = "nope", + organization_shared = False) + + +def test_setProjects(): + model = DigitalFactoryProjectModel() + + assert model.count == 0 + + model.setProjects([project_1, project_2]) + assert model.count == 2 + + assert model.getItem(0)["displayName"] == "zomg" + assert model.getItem(1)["displayName"] == "zomg2" + + +def test_clearProjects(): + model = DigitalFactoryProjectModel() + model.setProjects([project_1, project_2]) + model.clearProjects() + assert model.count == 0 + + +def test_setProjectMultipleTimes(): + model = DigitalFactoryProjectModel() + model.setProjects([project_1, project_2]) + model.setProjects([project_2]) + assert model.count == 1 + assert model.getItem(0)["displayName"] == "zomg2" + + +def test_extendProjects(): + model = DigitalFactoryProjectModel() + + assert model.count == 0 + + model.setProjects([project_1]) + assert model.count == 1 + + model.extendProjects([project_2]) + assert model.count == 2 + assert model.getItem(0)["displayName"] == "zomg" + assert model.getItem(1)["displayName"] == "zomg2" diff --git a/plugins/DigitalLibrary/tests/TestDigitalLibraryApiClient.py b/plugins/DigitalLibrary/tests/TestDigitalLibraryApiClient.py new file mode 100644 index 0000000000..ba0a0b15b4 --- /dev/null +++ b/plugins/DigitalLibrary/tests/TestDigitalLibraryApiClient.py @@ -0,0 +1,86 @@ +from unittest.mock import MagicMock + +import pytest + +from cura.CuraApplication import CuraApplication +from src.DigitalFactoryApiClient import DigitalFactoryApiClient +from src.PaginationManager import PaginationManager + + +@pytest.fixture +def application(): + app = MagicMock(spec=CuraApplication, name = "Mocked Cura Application") + return app + + +@pytest.fixture +def pagination_manager(): + manager = MagicMock(name = "Mocked Pagination Manager") + return manager + + +@pytest.fixture +def api_client(application, pagination_manager): + api_client = DigitalFactoryApiClient(application, MagicMock()) + api_client._projects_pagination_mgr = pagination_manager + return api_client + + +def test_getProjectsFirstPage(api_client): + # setup + http_manager = MagicMock() + api_client._http = http_manager + pagination_manager = api_client._projects_pagination_mgr + pagination_manager.limit = 20 + + finished_callback = MagicMock() + failed_callback = MagicMock() + + # Call + api_client.getProjectsFirstPage(on_finished = finished_callback, failed = failed_callback) + + # Asserts + pagination_manager.reset.assert_called_once() # Should be called since we asked for new set of projects + http_manager.get.assert_called_once() + args = http_manager.get.call_args_list[0] + + # Ensure that it's called with the right limit + assert args[0][0] == "https://api.ultimaker.com/cura/v1/projects?limit=20" + + # Change the limit & try again + http_manager.get.reset_mock() + pagination_manager.limit = 80 + api_client.getProjectsFirstPage(on_finished = finished_callback, failed = failed_callback) + args = http_manager.get.call_args_list[0] + + # Ensure that it's called with the right limit + assert args[0][0] == "https://api.ultimaker.com/cura/v1/projects?limit=80" + + +def test_getMoreProjects_noNewProjects(api_client): + api_client.hasMoreProjectsToLoad = MagicMock(return_value = False) + http_manager = MagicMock() + api_client._http = http_manager + + finished_callback = MagicMock() + failed_callback = MagicMock() + api_client.getMoreProjects(finished_callback, failed_callback) + + http_manager.get.assert_not_called() + + +def test_getMoreProjects_hasNewProjects(api_client): + api_client.hasMoreProjectsToLoad = MagicMock(return_value = True) + http_manager = MagicMock() + api_client._http = http_manager + + finished_callback = MagicMock() + failed_callback = MagicMock() + api_client.getMoreProjects(finished_callback, failed_callback) + + http_manager.get.assert_called_once() + + +def test_clear(api_client): + api_client.clear() + api_client._projects_pagination_mgr.reset.assert_called_once() diff --git a/plugins/DigitalLibrary/tests/conftest.py b/plugins/DigitalLibrary/tests/conftest.py new file mode 100644 index 0000000000..1d36e14108 --- /dev/null +++ b/plugins/DigitalLibrary/tests/conftest.py @@ -0,0 +1,10 @@ +# Prevents error: "PyCapsule_GetPointer called with incorrect name" with conflicting SIP configurations between Arcus and PyQt: Import custom Sip bindings first! +import Savitar # Dont remove this line +import Arcus # No really. Don't. It needs to be there! +import pynest2d # Really! + + +# Ensure that the importing for all tests work +import sys +import os +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))