mirror of
https://github.com/Ultimaker/Cura.git
synced 2025-07-06 22:47:29 -06:00
Merge branch 'master' into graphics_buffer_update
This commit is contained in:
commit
458fbd35f1
4327 changed files with 77905 additions and 22116 deletions
2
.github/ISSUE_TEMPLATE.md
vendored
2
.github/ISSUE_TEMPLATE.md
vendored
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
name: Bug report
|
||||
name: Old Bug report
|
||||
about: Create a report to help us fix issues.
|
||||
title: ''
|
||||
labels: 'Type: Bug'
|
||||
|
|
26
.github/ISSUE_TEMPLATE/bugreport.yaml
vendored
26
.github/ISSUE_TEMPLATE/bugreport.yaml
vendored
|
@ -1,7 +1,6 @@
|
|||
name: Bug Report
|
||||
description: Create a report to help us fix issues.
|
||||
labels: "Type: Bug"
|
||||
issue_body: true
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
@ -15,7 +14,7 @@ body:
|
|||
attributes:
|
||||
label: Application Version
|
||||
description: The version of Cura this issue occurs with.
|
||||
placeholder: 4.8.0
|
||||
placeholder: 4.9.0
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
@ -56,13 +55,28 @@ body:
|
|||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Additional information & file uploads
|
||||
|
||||
Please be sure to add the following files:
|
||||
* For slicing issues, upload a **project file** that clearly shows the bug.
|
||||
To save a project file go to `File -> Save project`. Please make sure to .zip your project file. For big files you may need to use WeTransfer or similar file sharing sites.
|
||||
G-code files are not project files!
|
||||
* **Screenshots** of showing the problem, perhaps before/after images.
|
||||
* A **log file**, see [here](https://github.com/Ultimaker/Cura#logging-issues) how to find the log file.
|
||||
* A **log file** for crashes and similar issues.
|
||||
You can find your log file here:
|
||||
Windows: `%APPDATA%\cura\<Cura version>\cura.log` or usually `C:\Users\\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
|
||||
MacOS: `$USER/Library/Application Support/cura/<Cura version>/cura.log`
|
||||
Ubuntu/Linus: `$USER/.local/share/cura/<Cura version>/cura.log`
|
||||
|
||||
If the Cura user interface still starts, you can also reach this directory from the application menu in Help -> Show settings folder
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist of files to include
|
||||
options:
|
||||
- label: Log file
|
||||
- label: Project file
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information & file uploads
|
||||
description: You can add these files and additional information that is relevant to the issue in the comments below.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
You can add these files and additional information that is relevant to the issue in the comments below.
|
||||
|
|
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Have questions or need support?
|
||||
url: https://community.ultimaker.com/
|
||||
about: Please get in touch on our Ultimaker Community Forum!
|
10
.github/ISSUE_TEMPLATE/featurerequest.yaml
vendored
10
.github/ISSUE_TEMPLATE/featurerequest.yaml
vendored
|
@ -1,7 +1,6 @@
|
|||
name: Feature Request
|
||||
description: Suggest an idea for this project.
|
||||
labels: "Type: New Feature"
|
||||
issue_body: true
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
@ -28,7 +27,7 @@ body:
|
|||
- type: textarea
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered. Again, if possible, think about why these alternatives are not working out.
|
||||
description: A clear and concise description of any alternative solutions or features you've considered. If possible, think about why these alternatives are not working out.
|
||||
placeholder: The alternatives I've considered are...
|
||||
validations:
|
||||
required: true
|
||||
|
@ -39,8 +38,7 @@ body:
|
|||
placeholder: It will affect...
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
- type: textarea
|
||||
attributes:
|
||||
value: |
|
||||
## Additional information & file uploads
|
||||
You can add pictures or files to visualize your feature request in the comments below.
|
||||
label: Additional information & file uploads
|
||||
description: You can add pictures or files to visualize your feature request in the comments below.
|
14
README.md
14
README.md
|
@ -10,13 +10,13 @@ For crashes and similar issues, please attach the following information:
|
|||
|
||||
* (On Windows) The log as produced by dxdiag (start -> run -> dxdiag -> save output)
|
||||
* The Cura GUI log file, located at
|
||||
* `%APPDATA%\cura\<Cura version>\cura.log` (Windows), or usually `C:\Users\\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
|
||||
* `$USER/Library/Application Support/cura/<Cura version>/cura.log` (OSX)
|
||||
* `$USER/.local/share/cura/<Cura version>/cura.log` (Ubuntu/Linux)
|
||||
* `%APPDATA%\cura\<Cura version>\cura.log` (Windows), or usually `C:\Users\<your username>\AppData\Roaming\cura\<Cura version>\cura.log`
|
||||
* `$HOME/Library/Application Support/cura/<Cura version>/cura.log` (OSX)
|
||||
* `$HOME/.local/share/cura/<Cura version>/cura.log` (Ubuntu/Linux)
|
||||
|
||||
If the Cura user interface still starts, you can also reach this directory from the application menu in Help -> Show settings folder
|
||||
|
||||
For additional support, you could also ask in the #cura channel on FreeNode IRC. For help with development, there is also the #cura-dev channel.
|
||||
For additional support, you could also ask in the [#cura channel](https://web.libera.chat/#cura) on [libera.chat](https://libera.chat/). For help with development, there is also the [#cura-dev channel](https://web.libera.chat/#cura-dev).
|
||||
|
||||
Dependencies
|
||||
------------
|
||||
|
@ -26,10 +26,16 @@ Dependencies
|
|||
* [PySerial](https://github.com/pyserial/pyserial) Only required for USB printing support.
|
||||
* [python-zeroconf](https://github.com/jstasiak/python-zeroconf) Only required to detect mDNS-enabled printers.
|
||||
|
||||
For a list of required Python packages, with their recommended version, see `requirements.txt`.
|
||||
|
||||
This list is not exhaustive at the moment, please check the links in the next section for more details.
|
||||
|
||||
Build scripts
|
||||
-------------
|
||||
Please check out [cura-build](https://github.com/Ultimaker/cura-build) for detailed building instructions.
|
||||
|
||||
If you want to build the entire environment from scratch before building Cura as well, [cura-build-environment](https://github.com/Ultimaker/cura-build) might be a starting point before cura-build. (Again, see cura-build for more details.)
|
||||
|
||||
Running from Source
|
||||
-------------
|
||||
Please check our [Wiki page](https://github.com/Ultimaker/Cura/wiki/Running-Cura-from-Source) for details about running Cura from source.
|
||||
|
|
|
@ -40,7 +40,7 @@ class Account(QObject):
|
|||
"""
|
||||
|
||||
# The interval in which sync services are automatically triggered
|
||||
SYNC_INTERVAL = 30.0 # seconds
|
||||
SYNC_INTERVAL = 60.0 # seconds
|
||||
Q_ENUMS(SyncState)
|
||||
|
||||
loginStateChanged = pyqtSignal(bool)
|
||||
|
@ -58,6 +58,11 @@ class Account(QObject):
|
|||
manualSyncEnabledChanged = pyqtSignal(bool)
|
||||
updatePackagesEnabledChanged = pyqtSignal(bool)
|
||||
|
||||
CLIENT_SCOPES = "account.user.read drive.backup.read drive.backup.write packages.download " \
|
||||
"packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write " \
|
||||
"library.project.read library.project.write cura.printjob.read cura.printjob.write " \
|
||||
"cura.mesh.read cura.mesh.write"
|
||||
|
||||
def __init__(self, application: "CuraApplication", parent = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._application = application
|
||||
|
@ -79,10 +84,7 @@ class Account(QObject):
|
|||
CALLBACK_PORT=self._callback_port,
|
||||
CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port),
|
||||
CLIENT_ID="um----------------------------ultimaker_cura",
|
||||
CLIENT_SCOPES="account.user.read drive.backup.read drive.backup.write packages.download "
|
||||
"packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write "
|
||||
"library.project.read library.project.write cura.printjob.read cura.printjob.write "
|
||||
"cura.mesh.read cura.mesh.write",
|
||||
CLIENT_SCOPES=self.CLIENT_SCOPES,
|
||||
AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data",
|
||||
AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root),
|
||||
AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root)
|
||||
|
@ -107,7 +109,6 @@ class Account(QObject):
|
|||
self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged)
|
||||
self._authorization_service.loadAuthDataFromPreferences()
|
||||
|
||||
|
||||
@pyqtProperty(int, notify=syncStateChanged)
|
||||
def syncState(self):
|
||||
return self._sync_state
|
||||
|
@ -176,6 +177,7 @@ class Account(QObject):
|
|||
if error_message:
|
||||
if self._error_message:
|
||||
self._error_message.hide()
|
||||
Logger.log("w", "Failed to login: %s", error_message)
|
||||
self._error_message = Message(error_message, title = i18n_catalog.i18nc("@info:title", "Login failed"))
|
||||
self._error_message.show()
|
||||
self._logged_in = False
|
||||
|
|
|
@ -13,7 +13,7 @@ DEFAULT_CURA_DEBUG_MODE = False
|
|||
# Each release has a fixed SDK version coupled with it. It doesn't make sense to make it configurable because, for
|
||||
# example Cura 3.2 with SDK version 6.1 will not work. So the SDK version is hard-coded here and left out of the
|
||||
# CuraVersion.py.in template.
|
||||
CuraSDKVersion = "7.4.0"
|
||||
CuraSDKVersion = "7.6.0"
|
||||
|
||||
try:
|
||||
from cura.CuraVersion import CuraAppName # type: ignore
|
||||
|
|
|
@ -36,6 +36,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
|
|||
found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects
|
||||
node_items: A list of the nodes return by libnest2d, which contain the new positions on the buildplate
|
||||
"""
|
||||
spacing = int(1.5 * factor) # 1.5mm spacing.
|
||||
|
||||
machine_width = build_volume.getWidth()
|
||||
machine_depth = build_volume.getDepth()
|
||||
|
@ -75,7 +76,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
|
|||
# Clip the disallowed areas so that they don't overlap the bounding box (The arranger chokes otherwise)
|
||||
clipped_area = area.intersectionConvexHulls(build_plate_polygon)
|
||||
|
||||
if clipped_area.getPoints() is not None: # numpy array has to be explicitly checked against None
|
||||
if clipped_area.getPoints() is not None and len(clipped_area.getPoints()) > 2: # numpy array has to be explicitly checked against None
|
||||
for point in clipped_area.getPoints():
|
||||
converted_points.append(Point(int(point[0] * factor), int(point[1] * factor)))
|
||||
|
||||
|
@ -88,7 +89,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
|
|||
converted_points = []
|
||||
hull_polygon = node.callDecoration("getConvexHull")
|
||||
|
||||
if hull_polygon is not None and hull_polygon.getPoints() is not None: # numpy array has to be explicitly checked against None
|
||||
if hull_polygon is not None and hull_polygon.getPoints() is not None and len(hull_polygon.getPoints()) > 2: # numpy array has to be explicitly checked against None
|
||||
for point in hull_polygon.getPoints():
|
||||
converted_points.append(Point(point[0] * factor, point[1] * factor))
|
||||
item = Item(converted_points)
|
||||
|
@ -99,7 +100,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
|
|||
config = NfpConfig()
|
||||
config.accuracy = 1.0
|
||||
|
||||
num_bins = nest(node_items, build_plate_bounding_box, 10000, config)
|
||||
num_bins = nest(node_items, build_plate_bounding_box, spacing, config)
|
||||
|
||||
# Strip the fixed items (previously placed) and the disallowed areas from the results again.
|
||||
node_items = list(filter(lambda item: not item.isFixed(), node_items))
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import numpy
|
||||
import copy
|
||||
from typing import Optional, Tuple, TYPE_CHECKING
|
||||
from typing import Optional, Tuple, TYPE_CHECKING, Union
|
||||
|
||||
from UM.Math.Polygon import Polygon
|
||||
|
||||
|
@ -14,14 +14,14 @@ if TYPE_CHECKING:
|
|||
class ShapeArray:
|
||||
"""Polygon representation as an array for use with :py:class:`cura.Arranging.Arrange.Arrange`"""
|
||||
|
||||
def __init__(self, arr: numpy.array, offset_x: float, offset_y: float, scale: float = 1) -> None:
|
||||
def __init__(self, arr: numpy.ndarray, offset_x: float, offset_y: float, scale: float = 1) -> None:
|
||||
self.arr = arr
|
||||
self.offset_x = offset_x
|
||||
self.offset_y = offset_y
|
||||
self.scale = scale
|
||||
|
||||
@classmethod
|
||||
def fromPolygon(cls, vertices: numpy.array, scale: float = 1) -> "ShapeArray":
|
||||
def fromPolygon(cls, vertices: numpy.ndarray, scale: float = 1) -> "ShapeArray":
|
||||
"""Instantiate from a bunch of vertices
|
||||
|
||||
:param vertices:
|
||||
|
@ -98,7 +98,7 @@ class ShapeArray:
|
|||
return offset_shape_arr, hull_shape_arr
|
||||
|
||||
@classmethod
|
||||
def arrayFromPolygon(cls, shape: Tuple[int, int], vertices: numpy.array) -> numpy.array:
|
||||
def arrayFromPolygon(cls, shape: Union[Tuple[int, int], numpy.ndarray], vertices: numpy.ndarray) -> numpy.ndarray:
|
||||
"""Create :py:class:`numpy.ndarray` with dimensions defined by shape
|
||||
|
||||
Fills polygon defined by vertices with ones, all other values zero
|
||||
|
@ -110,7 +110,7 @@ class ShapeArray:
|
|||
:return: numpy array with dimensions defined by shape
|
||||
"""
|
||||
|
||||
base_array = numpy.zeros(shape, dtype = numpy.int32) # Initialize your array of zeros
|
||||
base_array = numpy.zeros(shape, dtype = numpy.int32) # type: ignore # Initialize your array of zeros
|
||||
|
||||
fill = numpy.ones(base_array.shape) * True # Initialize boolean array defining shape fill
|
||||
|
||||
|
@ -126,7 +126,7 @@ class ShapeArray:
|
|||
return base_array
|
||||
|
||||
@classmethod
|
||||
def _check(cls, p1: numpy.array, p2: numpy.array, base_array: numpy.array) -> Optional[numpy.array]:
|
||||
def _check(cls, p1: numpy.ndarray, p2: numpy.ndarray, base_array: numpy.ndarray) -> Optional[numpy.ndarray]:
|
||||
"""Return indices that mark one side of the line, used by arrayFromPolygon
|
||||
|
||||
Uses the line defined by p1 and p2 to check array of
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from PyQt5.QtCore import QTimer
|
||||
|
@ -6,6 +6,8 @@ from typing import Any, TYPE_CHECKING
|
|||
|
||||
from UM.Logger import Logger
|
||||
|
||||
import time
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
||||
|
@ -56,8 +58,8 @@ class AutoSave:
|
|||
|
||||
def _onTimeout(self) -> None:
|
||||
self._saving = True # To prevent the save process from triggering another autosave.
|
||||
Logger.log("d", "Autosaving preferences, instances and profiles")
|
||||
|
||||
save_start_time = time.time()
|
||||
self._application.saveSettings()
|
||||
|
||||
Logger.log("d", "Autosaving preferences, instances and profiles took %s seconds", time.time() - save_start_time)
|
||||
self._saving = False
|
||||
|
|
|
@ -5,14 +5,16 @@ import io
|
|||
import os
|
||||
import re
|
||||
import shutil
|
||||
from copy import deepcopy
|
||||
from zipfile import ZipFile, ZIP_DEFLATED, BadZipfile
|
||||
from typing import Dict, Optional, TYPE_CHECKING
|
||||
from typing import Dict, Optional, TYPE_CHECKING, List
|
||||
|
||||
from UM import i18nCatalog
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from UM.Platform import Platform
|
||||
from UM.Resources import Resources
|
||||
from UM.Version import Version
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cura.CuraApplication import CuraApplication
|
||||
|
@ -27,6 +29,11 @@ class Backup:
|
|||
IGNORED_FILES = [r"cura\.log", r"plugins\.json", r"cache", r"__pycache__", r"\.qmlc", r"\.pyc"]
|
||||
"""These files should be ignored when making a backup."""
|
||||
|
||||
IGNORED_FOLDERS = [] # type: List[str]
|
||||
|
||||
SECRETS_SETTINGS = ["general/ultimaker_auth_data"]
|
||||
"""Secret preferences that need to obfuscated when making a backup of Cura"""
|
||||
|
||||
catalog = i18nCatalog("cura")
|
||||
"""Re-use translation catalog"""
|
||||
|
||||
|
@ -43,6 +50,9 @@ class Backup:
|
|||
|
||||
Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir)
|
||||
|
||||
# obfuscate sensitive secrets
|
||||
secrets = self._obfuscate()
|
||||
|
||||
# Ensure all current settings are saved.
|
||||
self._application.saveSettings()
|
||||
|
||||
|
@ -67,8 +77,9 @@ class Backup:
|
|||
machine_count = max(len([s for s in files if "machine_instances/" in s]) - 1, 0) # If people delete their profiles but not their preferences, it can still make a backup, and report -1 profiles. Server crashes on this.
|
||||
material_count = max(len([s for s in files if "materials/" in s]) - 1, 0)
|
||||
profile_count = max(len([s for s in files if "quality_changes/" in s]) - 1, 0)
|
||||
plugin_count = len([s for s in files if "plugin.json" in s])
|
||||
|
||||
# We don't store plugins anymore, since if you can make backups, you have an account (and the plugins are
|
||||
# on the marketplace anyway)
|
||||
plugin_count = 0
|
||||
# Store the archive and metadata so the BackupManager can fetch them when needed.
|
||||
self.zip_file = buffer.getvalue()
|
||||
self.meta_data = {
|
||||
|
@ -78,6 +89,8 @@ class Backup:
|
|||
"profile_count": str(profile_count),
|
||||
"plugin_count": str(plugin_count)
|
||||
}
|
||||
# Restore the obfuscated settings
|
||||
self._illuminate(**secrets)
|
||||
|
||||
def _makeArchive(self, buffer: "io.BytesIO", root_path: str) -> Optional[ZipFile]:
|
||||
"""Make a full archive from the given root path with the given name.
|
||||
|
@ -85,8 +98,7 @@ class Backup:
|
|||
:param root_path: The root directory to archive recursively.
|
||||
:return: The archive as bytes.
|
||||
"""
|
||||
|
||||
ignore_string = re.compile("|".join(self.IGNORED_FILES))
|
||||
ignore_string = re.compile("|".join(self.IGNORED_FILES + self.IGNORED_FOLDERS))
|
||||
try:
|
||||
archive = ZipFile(buffer, "w", ZIP_DEFLATED)
|
||||
for root, folders, files in os.walk(root_path):
|
||||
|
@ -123,8 +135,8 @@ class Backup:
|
|||
"Tried to restore a Cura backup without having proper data or meta data."))
|
||||
return False
|
||||
|
||||
current_version = self._application.getVersion()
|
||||
version_to_restore = self.meta_data.get("cura_release", "master")
|
||||
current_version = Version(self._application.getVersion())
|
||||
version_to_restore = Version(self.meta_data.get("cura_release", "master"))
|
||||
|
||||
if current_version < version_to_restore:
|
||||
# Cannot restore version newer than current because settings might have changed.
|
||||
|
@ -134,8 +146,16 @@ class Backup:
|
|||
"Tried to restore a Cura backup that is higher than the current version."))
|
||||
return False
|
||||
|
||||
# Get the current secrets and store since the back-up doesn't contain those
|
||||
secrets = self._obfuscate()
|
||||
|
||||
version_data_dir = Resources.getDataStoragePath()
|
||||
try:
|
||||
archive = ZipFile(io.BytesIO(self.zip_file), "r")
|
||||
except LookupError as e:
|
||||
Logger.log("d", f"The following error occurred while trying to restore a Cura backup: {str(e)}")
|
||||
self._showMessage(self.catalog.i18nc("@info:backup_failed", "The following error occurred while trying to restore a Cura backup:") + str(e))
|
||||
return False
|
||||
extracted = self._extractArchive(archive, version_data_dir)
|
||||
|
||||
# Under Linux, preferences are stored elsewhere, so we copy the file to there.
|
||||
|
@ -146,6 +166,12 @@ class Backup:
|
|||
Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file)
|
||||
shutil.move(backup_preferences_file, preferences_file)
|
||||
|
||||
# Read the preferences from the newly restored configuration (or else the cached Preferences will override the restored ones)
|
||||
self._application.readPreferencesFromConfiguration()
|
||||
|
||||
# Restore the obfuscated settings
|
||||
self._illuminate(**secrets)
|
||||
|
||||
return extracted
|
||||
|
||||
@staticmethod
|
||||
|
@ -167,9 +193,36 @@ class Backup:
|
|||
Logger.log("d", "Removing current data in location: %s", target_path)
|
||||
Resources.factoryReset()
|
||||
Logger.log("d", "Extracting backup to location: %s", target_path)
|
||||
name_list = archive.namelist()
|
||||
for archive_filename in name_list:
|
||||
try:
|
||||
archive.extractall(target_path)
|
||||
archive.extract(archive_filename, target_path)
|
||||
except (PermissionError, EnvironmentError):
|
||||
Logger.logException("e", "Unable to extract the backup due to permission or file system errors.")
|
||||
return False
|
||||
Logger.logException("e", f"Unable to extract the file {archive_filename} from the backup due to permission or file system errors.")
|
||||
CuraApplication.getInstance().processEvents()
|
||||
return True
|
||||
|
||||
def _obfuscate(self) -> Dict[str, str]:
|
||||
"""
|
||||
Obfuscate and remove the secret preferences that are specified in SECRETS_SETTINGS
|
||||
|
||||
:return: a dictionary of the removed secrets. Note: the '/' is replaced by '__'
|
||||
"""
|
||||
preferences = self._application.getPreferences()
|
||||
secrets = {}
|
||||
for secret in self.SECRETS_SETTINGS:
|
||||
secrets[secret.replace("/", "__")] = deepcopy(preferences.getValue(secret))
|
||||
preferences.setValue(secret, None)
|
||||
self._application.savePreferences()
|
||||
return secrets
|
||||
|
||||
def _illuminate(self, **kwargs) -> None:
|
||||
"""
|
||||
Restore the obfuscated settings
|
||||
|
||||
:param kwargs: a dict of obscured preferences. Note: the '__' of the keys will be replaced by '/'
|
||||
"""
|
||||
preferences = self._application.getPreferences()
|
||||
for key, value in kwargs.items():
|
||||
preferences.setValue(key.replace("__", "/"), value)
|
||||
self._application.savePreferences()
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
from typing import Dict, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
from UM.Logger import Logger
|
||||
from UM.Version import Version
|
||||
from cura.Backups.Backup import Backup
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
@ -52,6 +53,7 @@ class BackupsManager:
|
|||
|
||||
backup = Backup(self._application, zip_file = zip_file, meta_data = meta_data)
|
||||
restored = backup.restore()
|
||||
|
||||
if restored:
|
||||
# At this point, Cura will need to restart for the changes to take effect.
|
||||
# We don't want to store the data at this point as that would override the just-restored backup.
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import numpy
|
||||
|
@ -916,6 +916,8 @@ class BuildVolume(SceneNode):
|
|||
return {}
|
||||
|
||||
for area in self._global_container_stack.getProperty("machine_disallowed_areas", "value"):
|
||||
if len(area) == 0:
|
||||
continue # Numpy doesn't deal well with 0-length arrays, since it can't determine the dimensionality of them.
|
||||
polygon = Polygon(numpy.array(area, numpy.float32))
|
||||
polygon = polygon.getMinkowskiHull(Polygon.approximatedCircle(border_size))
|
||||
machine_disallowed_polygons.append(polygon)
|
||||
|
|
|
@ -67,11 +67,15 @@ class CuraActions(QObject):
|
|||
current_node = parent_node
|
||||
parent_node = current_node.getParent()
|
||||
|
||||
# This was formerly done with SetTransformOperation but because of
|
||||
# unpredictable matrix deconstruction it was possible that mirrors
|
||||
# could manifest as rotations. Centering is therefore done by
|
||||
# moving the node to negative whatever its position is:
|
||||
center_operation = TranslateOperation(current_node, -current_node._position)
|
||||
# Find out where the bottom of the object is
|
||||
bbox = current_node.getBoundingBox()
|
||||
if bbox:
|
||||
center_y = current_node.getWorldPosition().y - bbox.bottom
|
||||
else:
|
||||
center_y = 0
|
||||
|
||||
# Move the object so that it's bottom is on to of the buildplate
|
||||
center_operation = TranslateOperation(current_node, Vector(0, center_y, 0), set_position = True)
|
||||
operation.addOperation(center_operation)
|
||||
operation.push()
|
||||
|
||||
|
|
|
@ -129,7 +129,7 @@ class CuraApplication(QtApplication):
|
|||
# SettingVersion represents the set of settings available in the machine/extruder definitions.
|
||||
# You need to make sure that this version number needs to be increased if there is any non-backwards-compatible
|
||||
# changes of the settings.
|
||||
SettingVersion = 16
|
||||
SettingVersion = 17
|
||||
|
||||
Created = False
|
||||
|
||||
|
@ -257,6 +257,9 @@ class CuraApplication(QtApplication):
|
|||
from cura.CuraPackageManager import CuraPackageManager
|
||||
self._package_manager_class = CuraPackageManager
|
||||
|
||||
from UM.CentralFileStorage import CentralFileStorage
|
||||
CentralFileStorage.setIsEnterprise(ApplicationMetadata.IsEnterpriseVersion)
|
||||
|
||||
@pyqtProperty(str, constant=True)
|
||||
def ultimakerCloudApiRootUrl(self) -> str:
|
||||
return UltimakerCloudConstants.CuraCloudAPIRoot
|
||||
|
@ -467,6 +470,7 @@ class CuraApplication(QtApplication):
|
|||
("user", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.UserInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("definition_changes", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.DefinitionChangesContainer, "application/x-uranium-instancecontainer"),
|
||||
("variant", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.VariantInstanceContainer, "application/x-uranium-instancecontainer"),
|
||||
("setting_visibility", SettingVisibilityPresetsModel.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.SettingVisibilityPreset, "application/x-uranium-preferences"),
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -603,6 +607,15 @@ class CuraApplication(QtApplication):
|
|||
@pyqtSlot()
|
||||
def closeApplication(self) -> None:
|
||||
Logger.log("i", "Close application")
|
||||
|
||||
# Workaround: Before closing the window, remove the global stack.
|
||||
# This is necessary because as the main window gets closed, hundreds of QML elements get updated which often
|
||||
# request the global stack. However as the Qt-side of the Machine Manager is being dismantled, the conversion of
|
||||
# the Global Stack to a QObject fails.
|
||||
# If instead we first take down the global stack, PyQt will just convert `None` to `null` which succeeds, and
|
||||
# the QML code then gets `null` as the global stack and can deal with that as it deems fit.
|
||||
self.getMachineManager().setActiveMachine(None)
|
||||
|
||||
main_window = self.getMainWindow()
|
||||
if main_window is not None:
|
||||
main_window.close()
|
||||
|
@ -695,6 +708,8 @@ class CuraApplication(QtApplication):
|
|||
@pyqtSlot(str)
|
||||
def discardOrKeepProfileChangesClosed(self, option: str) -> None:
|
||||
global_stack = self.getGlobalContainerStack()
|
||||
if global_stack is None:
|
||||
return
|
||||
if option == "discard":
|
||||
for extruder in global_stack.extruderList:
|
||||
extruder.userChanges.clear()
|
||||
|
@ -1516,12 +1531,8 @@ class CuraApplication(QtApplication):
|
|||
|
||||
# Compute the center of the objects
|
||||
object_centers = []
|
||||
# Forget about the translation that the original objects have
|
||||
zero_translation = Matrix(data=numpy.zeros(3))
|
||||
for mesh, node in zip(meshes, group_node.getChildren()):
|
||||
transformation = node.getLocalTransformation()
|
||||
transformation.setTranslation(zero_translation)
|
||||
transformed_mesh = mesh.getTransformed(transformation)
|
||||
transformed_mesh = mesh.getTransformed(Matrix()) # Forget about the transformations that the original object had.
|
||||
center = transformed_mesh.getCenterPosition()
|
||||
if center is not None:
|
||||
object_centers.append(center)
|
||||
|
@ -1536,7 +1547,7 @@ class CuraApplication(QtApplication):
|
|||
|
||||
# Move each node to the same position.
|
||||
for mesh, node in zip(meshes, group_node.getChildren()):
|
||||
node.setTransformation(Matrix())
|
||||
node.setTransformation(Matrix()) # Removes any changes in position and rotation.
|
||||
# Align the object around its zero position
|
||||
# and also apply the offset to center it inside the group.
|
||||
node.setPosition(-mesh.getZeroPosition() - offset)
|
||||
|
@ -1857,6 +1868,7 @@ class CuraApplication(QtApplication):
|
|||
else:
|
||||
node = CuraSceneNode()
|
||||
node.setMeshData(original_node.getMeshData())
|
||||
node.source_mime_type = original_node.source_mime_type
|
||||
|
||||
# Setting meshdata does not apply scaling.
|
||||
if original_node.getScale() != Vector(1.0, 1.0, 1.0):
|
||||
|
|
|
@ -73,7 +73,7 @@ class LayerPolygon:
|
|||
|
||||
# When type is used as index returns true if type == LayerPolygon.InfillType or type == LayerPolygon.SkinType or type == LayerPolygon.SupportInfillType
|
||||
# Should be generated in better way, not hardcoded.
|
||||
self._is_infill_or_skin_type_map = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], dtype = numpy.bool)
|
||||
self._is_infill_or_skin_type_map = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], dtype = bool)
|
||||
|
||||
self._build_cache_line_mesh_mask = None # type: Optional[numpy.ndarray]
|
||||
self._build_cache_needed_points = None # type: Optional[numpy.ndarray]
|
||||
|
@ -81,18 +81,17 @@ class LayerPolygon:
|
|||
def buildCache(self) -> None:
|
||||
# For the line mesh we do not draw Infill or Jumps. Therefore those lines are filtered out.
|
||||
self._build_cache_line_mesh_mask = numpy.ones(self._jump_mask.shape, dtype = bool)
|
||||
mesh_line_count = numpy.sum(self._build_cache_line_mesh_mask)
|
||||
self._index_begin = 0
|
||||
self._index_end = mesh_line_count
|
||||
self._index_end = cast(int, numpy.sum(self._build_cache_line_mesh_mask))
|
||||
|
||||
self._build_cache_needed_points = numpy.ones((len(self._types), 2), dtype = numpy.bool)
|
||||
self._build_cache_needed_points = numpy.ones((len(self._types), 2), dtype = bool)
|
||||
# Only if the type of line segment changes do we need to add an extra vertex to change colors
|
||||
self._build_cache_needed_points[1:, 0][:, numpy.newaxis] = self._types[1:] != self._types[:-1]
|
||||
# Mark points as unneeded if they are of types we don't want in the line mesh according to the calculated mask
|
||||
numpy.logical_and(self._build_cache_needed_points, self._build_cache_line_mesh_mask, self._build_cache_needed_points )
|
||||
|
||||
self._vertex_begin = 0
|
||||
self._vertex_end = numpy.sum( self._build_cache_needed_points )
|
||||
self._vertex_end = cast(int, numpy.sum(self._build_cache_needed_points))
|
||||
|
||||
def build(self, vertex_offset: int, index_offset: int, vertices: numpy.ndarray, colors: numpy.ndarray, line_dimensions: numpy.ndarray, feedrates: numpy.ndarray, extruders: numpy.ndarray, line_types: numpy.ndarray, indices: numpy.ndarray) -> None:
|
||||
"""Set all the arrays provided by the function caller, representing the LayerPolygon
|
||||
|
|
|
@ -53,6 +53,9 @@ class ExtrudersModel(ListModel):
|
|||
EnabledRole = Qt.UserRole + 11
|
||||
"""Is the extruder enabled?"""
|
||||
|
||||
MaterialTypeRole = Qt.UserRole + 12
|
||||
"""The type of the material (e.g. PLA, ABS, PETG, etc.)."""
|
||||
|
||||
defaultColors = ["#ffc924", "#86ec21", "#22eeee", "#245bff", "#9124ff", "#ff24c8"]
|
||||
"""List of colours to display if there is no material or the material has no known colour. """
|
||||
|
||||
|
@ -75,6 +78,7 @@ class ExtrudersModel(ListModel):
|
|||
self.addRoleName(self.StackRole, "stack")
|
||||
self.addRoleName(self.MaterialBrandRole, "material_brand")
|
||||
self.addRoleName(self.ColorNameRole, "color_name")
|
||||
self.addRoleName(self.MaterialTypeRole, "material_type")
|
||||
self._update_extruder_timer = QTimer()
|
||||
self._update_extruder_timer.setInterval(100)
|
||||
self._update_extruder_timer.setSingleShot(True)
|
||||
|
@ -193,7 +197,8 @@ class ExtrudersModel(ListModel):
|
|||
"variant": extruder.variant.getName() if extruder.variant else "", # e.g. print core
|
||||
"stack": extruder,
|
||||
"material_brand": material_brand,
|
||||
"color_name": color_name
|
||||
"color_name": color_name,
|
||||
"material_type": extruder.material.getMetaDataEntry("material") if extruder.material else "",
|
||||
}
|
||||
|
||||
items.append(item)
|
||||
|
@ -210,7 +215,7 @@ class ExtrudersModel(ListModel):
|
|||
"id": "",
|
||||
"name": catalog.i18nc("@menuitem", "Not overridden"),
|
||||
"enabled": True,
|
||||
"color": "#ffffff",
|
||||
"color": "transparent",
|
||||
"index": -1,
|
||||
"definition": "",
|
||||
"material": "",
|
||||
|
@ -218,6 +223,7 @@ class ExtrudersModel(ListModel):
|
|||
"stack": None,
|
||||
"material_brand": "",
|
||||
"color_name": "",
|
||||
"material_type": "",
|
||||
}
|
||||
items.append(item)
|
||||
if self._items != items:
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
@ -34,4 +34,4 @@ def fetchLayerHeight(quality_group: "QualityGroup") -> float:
|
|||
if isinstance(layer_height, SettingFunction):
|
||||
layer_height = layer_height(global_stack)
|
||||
|
||||
return float(layer_height)
|
||||
return round(float(layer_height), 3)
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import copy # To duplicate materials.
|
||||
from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot # To allow the preference page proxy to be used from the actual preferences page.
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl
|
||||
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||
import uuid # To generate new GUIDs for new materials.
|
||||
import zipfile # To export all materials in a .zip archive.
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
from UM.Logger import Logger
|
||||
|
@ -20,11 +21,6 @@ if TYPE_CHECKING:
|
|||
catalog = i18nCatalog("cura")
|
||||
|
||||
class MaterialManagementModel(QObject):
|
||||
"""Proxy class to the materials page in the preferences.
|
||||
|
||||
This class handles the actions in that page, such as creating new materials, renaming them, etc.
|
||||
"""
|
||||
|
||||
favoritesChanged = pyqtSignal(str)
|
||||
"""Triggered when a favorite is added or removed.
|
||||
|
||||
|
@ -79,6 +75,7 @@ class MaterialManagementModel(QObject):
|
|||
|
||||
:param material_node: The material to remove.
|
||||
"""
|
||||
Logger.info(f"Removing material {material_node.container_id}")
|
||||
|
||||
container_registry = CuraContainerRegistry.getInstance()
|
||||
materials_this_base_file = container_registry.findContainersMetadata(base_file = material_node.base_file)
|
||||
|
@ -194,6 +191,7 @@ class MaterialManagementModel(QObject):
|
|||
|
||||
:return: The root material ID of the duplicate material.
|
||||
"""
|
||||
Logger.info(f"Duplicating material {material_node.base_file} to {new_base_id}")
|
||||
return self.duplicateMaterialByBaseFile(material_node.base_file, new_base_id, new_metadata)
|
||||
|
||||
@pyqtSlot(result = str)
|
||||
|
@ -262,3 +260,40 @@ class MaterialManagementModel(QObject):
|
|||
self.favoritesChanged.emit(material_base_file)
|
||||
except ValueError: # Material was not in the favorites list.
|
||||
Logger.log("w", "Material {material_base_file} was already not a favorite material.".format(material_base_file = material_base_file))
|
||||
|
||||
@pyqtSlot(result = QUrl)
|
||||
def getPreferredExportAllPath(self) -> QUrl:
|
||||
"""
|
||||
Get the preferred path to export materials to.
|
||||
|
||||
If there is a removable drive, that should be the preferred path. Otherwise it should be the most recent local
|
||||
file path.
|
||||
:return: The preferred path to export all materials to.
|
||||
"""
|
||||
cura_application = cura.CuraApplication.CuraApplication.getInstance()
|
||||
device_manager = cura_application.getOutputDeviceManager()
|
||||
devices = device_manager.getOutputDevices()
|
||||
for device in devices:
|
||||
if device.__class__.__name__ == "RemovableDriveOutputDevice":
|
||||
return QUrl.fromLocalFile(device.getId())
|
||||
else: # No removable drives? Use local path.
|
||||
return cura_application.getDefaultPath("dialog_material_path")
|
||||
|
||||
@pyqtSlot(QUrl)
|
||||
def exportAll(self, file_path: QUrl) -> None:
|
||||
"""
|
||||
Export all materials to a certain file path.
|
||||
:param file_path: The path to export the materials to.
|
||||
"""
|
||||
registry = CuraContainerRegistry.getInstance()
|
||||
|
||||
archive = zipfile.ZipFile(file_path.toLocalFile(), "w", compression = zipfile.ZIP_DEFLATED)
|
||||
for metadata in registry.findInstanceContainersMetadata(type = "material"):
|
||||
if metadata["base_file"] != metadata["id"]: # Only process base files.
|
||||
continue
|
||||
if metadata["id"] == "empty_material": # Don't export the empty material.
|
||||
continue
|
||||
material = registry.findContainers(id = metadata["id"])[0]
|
||||
suffix = registry.getMimeTypeForContainer(type(material)).preferredSuffix
|
||||
filename = metadata["id"] + "." + suffix
|
||||
archive.writestr(filename, material.serialize())
|
||||
|
|
|
@ -99,7 +99,7 @@ class QualitySettingsModel(ListModel):
|
|||
if self._selected_position == self.GLOBAL_STACK_POSITION:
|
||||
quality_node = quality_group.node_for_global
|
||||
else:
|
||||
quality_node = quality_group.nodes_for_extruders.get(str(self._selected_position))
|
||||
quality_node = quality_group.nodes_for_extruders.get(self._selected_position)
|
||||
settings_keys = quality_group.getAllKeys()
|
||||
quality_containers = []
|
||||
if quality_node is not None and quality_node.container is not None:
|
||||
|
@ -114,10 +114,13 @@ class QualitySettingsModel(ListModel):
|
|||
global_container = None if len(global_containers) == 0 else global_containers[0]
|
||||
extruders_containers = {pos: container_registry.findContainers(id = quality_changes_group.metadata_per_extruder[pos]["id"]) for pos in quality_changes_group.metadata_per_extruder}
|
||||
extruders_container = {pos: None if not containers else containers[0] for pos, containers in extruders_containers.items()}
|
||||
quality_changes_metadata = None
|
||||
if self._selected_position == self.GLOBAL_STACK_POSITION and global_container:
|
||||
quality_changes_metadata = global_container.getMetaData()
|
||||
else:
|
||||
quality_changes_metadata = extruders_container.get(str(self._selected_position))
|
||||
extruder = extruders_container.get(self._selected_position)
|
||||
if extruder:
|
||||
quality_changes_metadata = extruder.getMetaData()
|
||||
if quality_changes_metadata is not None: # It can be None if number of extruders are changed during runtime.
|
||||
container = container_registry.findContainers(id = quality_changes_metadata["id"])
|
||||
if container:
|
||||
|
|
|
@ -19,6 +19,8 @@ class SettingVisibilityPresetsModel(QObject):
|
|||
onItemsChanged = pyqtSignal()
|
||||
activePresetChanged = pyqtSignal()
|
||||
|
||||
Version = 2
|
||||
|
||||
def __init__(self, preferences: Preferences, parent = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
import random
|
||||
from hashlib import sha512
|
||||
from base64 import b64encode
|
||||
from typing import Optional, Any, Dict, Tuple
|
||||
|
||||
from typing import Optional
|
||||
import requests
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
|
@ -115,7 +115,7 @@ class AuthorizationHelpers:
|
|||
token_request = requests.get(check_token_url, headers = {
|
||||
"Authorization": "Bearer {}".format(access_token)
|
||||
})
|
||||
except requests.exceptions.ConnectionError:
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
|
||||
# Connection was suddenly dropped. Nothing we can do about that.
|
||||
Logger.logException("w", "Something failed while attempting to parse the JWT token")
|
||||
return None
|
||||
|
|
|
@ -113,7 +113,9 @@ class AuthorizationService:
|
|||
# The token could not be refreshed using the refresh token. We should login again.
|
||||
return None
|
||||
# Ensure it gets stored as otherwise we only have it in memory. The stored refresh token has been deleted
|
||||
# from the server already.
|
||||
# from the server already. Do not store the auth_data if we could not get new auth_data (eg due to a
|
||||
# network error), since this would cause an infinite loop trying to get new auth-data
|
||||
if self._auth_data.success:
|
||||
self._storeAuthData(self._auth_data)
|
||||
return self._auth_helpers.parseJWT(self._auth_data.access_token)
|
||||
|
||||
|
@ -255,10 +257,9 @@ class AuthorizationService:
|
|||
self._auth_data = auth_data
|
||||
if auth_data:
|
||||
self._user_profile = self.getUserProfile()
|
||||
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(vars(auth_data)))
|
||||
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(auth_data.dump()))
|
||||
else:
|
||||
self._user_profile = None
|
||||
self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY)
|
||||
|
||||
self.accessTokenChanged.emit()
|
||||
|
||||
|
|
83
cura/OAuth2/KeyringAttribute.py
Normal file
83
cura/OAuth2/KeyringAttribute.py
Normal file
|
@ -0,0 +1,83 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Type, TYPE_CHECKING, Optional, List
|
||||
|
||||
import keyring
|
||||
from keyring.backend import KeyringBackend
|
||||
from keyring.errors import NoKeyringError, PasswordSetError, KeyringLocked
|
||||
|
||||
from UM.Logger import Logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cura.OAuth2.Models import BaseModel
|
||||
|
||||
# Need to do some extra workarounds on windows:
|
||||
import sys
|
||||
from UM.Platform import Platform
|
||||
if Platform.isWindows() and hasattr(sys, "frozen"):
|
||||
import win32timezone
|
||||
from keyring.backends.Windows import WinVaultKeyring
|
||||
keyring.set_keyring(WinVaultKeyring())
|
||||
if Platform.isOSX() and hasattr(sys, "frozen"):
|
||||
from keyring.backends.macOS import Keyring
|
||||
keyring.set_keyring(Keyring())
|
||||
|
||||
# Even if errors happen, we don't want this stored locally:
|
||||
DONT_EVER_STORE_LOCALLY: List[str] = ["refresh_token"]
|
||||
|
||||
|
||||
class KeyringAttribute:
|
||||
"""
|
||||
Descriptor for attributes that need to be stored in the keyring. With Fallback behaviour to the preference cfg file
|
||||
"""
|
||||
def __get__(self, instance: "BaseModel", owner: type) -> Optional[str]:
|
||||
if self._store_secure: # type: ignore
|
||||
try:
|
||||
value = keyring.get_password("cura", self._keyring_name)
|
||||
return value if value != "" else None
|
||||
except NoKeyringError:
|
||||
self._store_secure = False
|
||||
Logger.logException("w", "No keyring backend present")
|
||||
return getattr(instance, self._name)
|
||||
except KeyringLocked:
|
||||
self._store_secure = False
|
||||
Logger.log("i", "Access to the keyring was denied.")
|
||||
return getattr(instance, self._name)
|
||||
else:
|
||||
return getattr(instance, self._name)
|
||||
|
||||
def __set__(self, instance: "BaseModel", value: Optional[str]):
|
||||
if self._store_secure:
|
||||
setattr(instance, self._name, None)
|
||||
if value is not None:
|
||||
try:
|
||||
keyring.set_password("cura", self._keyring_name, value)
|
||||
except (PasswordSetError, KeyringLocked):
|
||||
self._store_secure = False
|
||||
if self._name not in DONT_EVER_STORE_LOCALLY:
|
||||
setattr(instance, self._name, value)
|
||||
Logger.logException("w", "Keyring access denied")
|
||||
except NoKeyringError:
|
||||
self._store_secure = False
|
||||
if self._name not in DONT_EVER_STORE_LOCALLY:
|
||||
setattr(instance, self._name, value)
|
||||
Logger.logException("w", "No keyring backend present")
|
||||
except BaseException as e:
|
||||
# A BaseException can occur in Windows when the keyring attempts to write a token longer than 1024
|
||||
# characters in the Windows Credentials Manager.
|
||||
self._store_secure = False
|
||||
if self._name not in DONT_EVER_STORE_LOCALLY:
|
||||
setattr(instance, self._name, value)
|
||||
Logger.log("w", "Keyring failed: {}".format(e))
|
||||
else:
|
||||
setattr(instance, self._name, value)
|
||||
|
||||
def __set_name__(self, owner: type, name: str):
|
||||
self._name = "_{}".format(name)
|
||||
self._keyring_name = name
|
||||
self._store_secure = False
|
||||
try:
|
||||
self._store_secure = KeyringBackend.viable
|
||||
except NoKeyringError:
|
||||
Logger.logException("w", "Could not use keyring")
|
||||
setattr(owner, self._name, None)
|
|
@ -54,6 +54,7 @@ class LocalAuthorizationServer:
|
|||
if self._web_server:
|
||||
# If the server is already running (because of a previously aborted auth flow), we don't have to start it.
|
||||
# We still inject the new verification code though.
|
||||
Logger.log("d", "Auth web server was already running. Updating the verification code")
|
||||
self._web_server.setVerificationCode(verification_code)
|
||||
return
|
||||
|
||||
|
@ -85,6 +86,7 @@ class LocalAuthorizationServer:
|
|||
except OSError:
|
||||
# OS error can happen if the socket was already closed. We really don't care about that case.
|
||||
pass
|
||||
Logger.log("d", "Local oauth2 web server was shut down")
|
||||
self._web_server = None
|
||||
self._web_server_thread = None
|
||||
|
||||
|
@ -96,12 +98,13 @@ class LocalAuthorizationServer:
|
|||
|
||||
:return: None
|
||||
"""
|
||||
Logger.log("d", "Local web server for authorization has started")
|
||||
if self._web_server:
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
self._web_server.serve_forever()
|
||||
except OSError as e:
|
||||
Logger.warning(str(e))
|
||||
except OSError:
|
||||
Logger.logException("w", "An exception happened while serving the auth server")
|
||||
else:
|
||||
# Leave the default behavior in non-windows platforms
|
||||
self._web_server.serve_forever()
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Optional, Dict, Any, List
|
||||
from typing import Optional, Dict, Any, List, Union
|
||||
from copy import deepcopy
|
||||
from cura.OAuth2.KeyringAttribute import KeyringAttribute
|
||||
|
||||
|
||||
class BaseModel:
|
||||
|
@ -37,12 +39,29 @@ class AuthenticationResponse(BaseModel):
|
|||
# Data comes from the token response with success flag and error message added.
|
||||
success = True # type: bool
|
||||
token_type = None # type: Optional[str]
|
||||
access_token = None # type: Optional[str]
|
||||
refresh_token = None # type: Optional[str]
|
||||
expires_in = None # type: Optional[str]
|
||||
scope = None # type: Optional[str]
|
||||
err_message = None # type: Optional[str]
|
||||
received_at = None # type: Optional[str]
|
||||
access_token = KeyringAttribute()
|
||||
refresh_token = KeyringAttribute()
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self.access_token = kwargs.pop("access_token", None)
|
||||
self.refresh_token = kwargs.pop("refresh_token", None)
|
||||
super(AuthenticationResponse, self).__init__(**kwargs)
|
||||
|
||||
def dump(self) -> Dict[str, Union[bool, Optional[str]]]:
|
||||
"""
|
||||
Dumps the dictionary of Authentication attributes. KeyringAttributes are transformed to public attributes
|
||||
If the keyring was used, these will have a None value, otherwise they will have the secret value
|
||||
|
||||
:return: Dictionary of Authentication attributes
|
||||
"""
|
||||
dumped = deepcopy(vars(self))
|
||||
dumped["access_token"] = dumped.pop("_access_token")
|
||||
dumped["refresh_token"] = dumped.pop("_refresh_token")
|
||||
return dumped
|
||||
|
||||
|
||||
class ResponseStatus(BaseModel):
|
||||
|
|
|
@ -119,21 +119,23 @@ class CuraSceneNode(SceneNode):
|
|||
self._aabb = None
|
||||
if self._mesh_data:
|
||||
self._aabb = self._mesh_data.getExtents(self.getWorldTransformation(copy = False))
|
||||
else: # If there is no mesh_data, use a bounding box that encompasses the local (0,0,0)
|
||||
position = self.getWorldPosition()
|
||||
self._aabb = AxisAlignedBox(minimum = position, maximum = position)
|
||||
|
||||
for child in self.getAllChildren():
|
||||
if child.callDecoration("isNonPrintingMesh"):
|
||||
# Non-printing-meshes inside a group should not affect push apart or drop to build plate
|
||||
continue
|
||||
if not child.getMeshData():
|
||||
# Nodes without mesh data should not affect bounding boxes of their parents.
|
||||
child_bb = child.getBoundingBox()
|
||||
if child_bb is None or child_bb.minimum == child_bb.maximum:
|
||||
# Child had a degenerate bounding box, such as an empty group. Don't count it along.
|
||||
continue
|
||||
if self._aabb is None:
|
||||
self._aabb = child.getBoundingBox()
|
||||
self._aabb = child_bb
|
||||
else:
|
||||
self._aabb = self._aabb + child.getBoundingBox()
|
||||
self._aabb = self._aabb + child_bb
|
||||
|
||||
if self._aabb is None: # No children that should be included? Just use your own position then, but it's an invalid AABB.
|
||||
position = self.getWorldPosition()
|
||||
self._aabb = AxisAlignedBox(minimum = position, maximum = position)
|
||||
|
||||
def __deepcopy__(self, memo: Dict[int, object]) -> "CuraSceneNode":
|
||||
"""Taken from SceneNode, but replaced SceneNode with CuraSceneNode"""
|
||||
|
@ -142,6 +144,7 @@ class CuraSceneNode(SceneNode):
|
|||
copy.setTransformation(self.getLocalTransformation(copy= False))
|
||||
copy.setMeshData(self._mesh_data)
|
||||
copy.setVisible(cast(bool, deepcopy(self._visible, memo)))
|
||||
copy.source_mime_type = cast(str, deepcopy(self.source_mime_type, memo))
|
||||
copy._selectable = cast(bool, deepcopy(self._selectable, memo))
|
||||
copy._name = cast(str, deepcopy(self._name, memo))
|
||||
for decorator in self._decorators:
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import os
|
||||
|
@ -241,6 +241,7 @@ class ContainerManager(QObject):
|
|||
file_url = file_url_or_string.toLocalFile()
|
||||
else:
|
||||
file_url = file_url_or_string
|
||||
Logger.info(f"Importing material from {file_url}")
|
||||
|
||||
if not file_url or not os.path.exists(file_url):
|
||||
return {"status": "error", "message": "Invalid path"}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import os
|
||||
|
@ -381,8 +381,9 @@ class CuraContainerRegistry(ContainerRegistry):
|
|||
if profile_count > 1:
|
||||
continue
|
||||
# Only one profile found, this should not ever be the case, so that profile needs to be removed!
|
||||
Logger.log("d", "Found an invalid quality_changes profile with the name %s. Going to remove that now", profile_name)
|
||||
invalid_quality_changes = ContainerRegistry.getInstance().findContainersMetadata(name=profile_name)
|
||||
if invalid_quality_changes:
|
||||
Logger.log("d", "Found an invalid quality_changes profile with the name %s. Going to remove that now", profile_name)
|
||||
self.removeContainer(invalid_quality_changes[0]["id"])
|
||||
|
||||
@override(ContainerRegistry)
|
||||
|
|
|
@ -86,6 +86,14 @@ class GlobalStack(CuraContainerStack):
|
|||
def supportsNetworkConnection(self):
|
||||
return self.getMetaDataEntry("supports_network_connection", False)
|
||||
|
||||
@pyqtProperty(bool, constant = True)
|
||||
def supportsMaterialExport(self):
|
||||
"""
|
||||
Whether the printer supports Cura's export format of material profiles.
|
||||
:return: ``True`` if it supports it, or ``False`` if not.
|
||||
"""
|
||||
return self.getMetaDataEntry("supports_material_export", False)
|
||||
|
||||
@classmethod
|
||||
def getLoadingPriority(cls) -> int:
|
||||
return 2
|
||||
|
|
|
@ -25,8 +25,8 @@ class Snapshot:
|
|||
pixels = numpy.frombuffer(pixel_array, dtype=numpy.uint8).reshape([height, width, 4])
|
||||
# Find indices of non zero pixels
|
||||
nonzero_pixels = numpy.nonzero(pixels)
|
||||
min_y, min_x, min_a_ = numpy.amin(nonzero_pixels, axis=1)
|
||||
max_y, max_x, max_a_ = numpy.amax(nonzero_pixels, axis=1)
|
||||
min_y, min_x, min_a_ = numpy.amin(nonzero_pixels, axis=1) # type: ignore
|
||||
max_y, max_x, max_a_ = numpy.amax(nonzero_pixels, axis=1) # type: ignore
|
||||
|
||||
return min_x, max_x, min_y, max_y
|
||||
|
||||
|
|
|
@ -132,9 +132,26 @@ class ObjectsModel(ListModel):
|
|||
|
||||
is_group = bool(node.callDecoration("isGroup"))
|
||||
|
||||
name_handled_as_group = False
|
||||
force_rename = False
|
||||
if not is_group:
|
||||
# Handle names for individual nodes
|
||||
if is_group:
|
||||
# Handle names for grouped nodes
|
||||
original_name = self._group_name_prefix
|
||||
|
||||
current_name = node.getName()
|
||||
if current_name.startswith(self._group_name_prefix):
|
||||
# This group has a standard group name, but we may need to renumber it
|
||||
name_index = int(current_name.split("#")[-1])
|
||||
name_handled_as_group = True
|
||||
elif not current_name:
|
||||
# Force rename this group because this node has not been named as a group yet, probably because
|
||||
# it's a newly created group.
|
||||
name_index = 0
|
||||
force_rename = True
|
||||
name_handled_as_group = True
|
||||
|
||||
if not is_group or not name_handled_as_group:
|
||||
# Handle names for individual nodes or groups that already have a non-group name
|
||||
name = node.getName()
|
||||
|
||||
name_match = self._naming_regex.fullmatch(name)
|
||||
|
@ -144,18 +161,6 @@ class ObjectsModel(ListModel):
|
|||
else:
|
||||
original_name = name_match.groups()[0]
|
||||
name_index = int(name_match.groups()[1])
|
||||
else:
|
||||
# Handle names for grouped nodes
|
||||
original_name = self._group_name_prefix
|
||||
|
||||
current_name = node.getName()
|
||||
if current_name.startswith(self._group_name_prefix):
|
||||
name_index = int(current_name.split("#")[-1])
|
||||
else:
|
||||
# Force rename this group because this node has not been named as a group yet, probably because
|
||||
# it's a newly created group.
|
||||
name_index = 0
|
||||
force_rename = True
|
||||
|
||||
if original_name not in name_to_node_info_dict:
|
||||
# Keep track of 2 things:
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import collections
|
||||
|
@ -6,9 +6,11 @@ from typing import Optional, Dict, List, cast
|
|||
|
||||
from PyQt5.QtCore import QObject, pyqtSlot
|
||||
|
||||
from UM.i18n import i18nCatalog
|
||||
from UM.Resources import Resources
|
||||
from UM.Version import Version
|
||||
|
||||
catalog = i18nCatalog("cura")
|
||||
|
||||
#
|
||||
# This manager provides means to load texts to QML.
|
||||
|
@ -30,10 +32,11 @@ class TextManager(QObject):
|
|||
# Load change log texts and organize them with a dict
|
||||
try:
|
||||
file_path = Resources.getPath(Resources.Texts, "change_log.txt")
|
||||
except FileNotFoundError:
|
||||
except FileNotFoundError as e:
|
||||
# I have no idea how / when this happens, but we're getting crash reports about it.
|
||||
return ""
|
||||
return catalog.i18nc("@text:window", "The release notes could not be opened.") + "<br>" + str(e)
|
||||
change_logs_dict = {} # type: Dict[Version, Dict[str, List[str]]]
|
||||
try:
|
||||
with open(file_path, "r", encoding = "utf-8") as f:
|
||||
open_version = None # type: Optional[Version]
|
||||
open_header = "" # Initialise to an empty header in case there is no "*" in the first line of the changelog
|
||||
|
@ -54,6 +57,8 @@ class TextManager(QObject):
|
|||
if open_header not in change_logs_dict[cast(Version, open_version)]:
|
||||
change_logs_dict[cast(Version, open_version)][open_header] = []
|
||||
change_logs_dict[cast(Version, open_version)][open_header].append(line)
|
||||
except EnvironmentError as e:
|
||||
return catalog.i18nc("@text:window", "The release notes could not be opened.") + "<br>" + str(e)
|
||||
|
||||
# Format changelog text
|
||||
content = ""
|
||||
|
|
|
@ -239,9 +239,6 @@ class WelcomePagesModel(ListModel):
|
|||
{"id": "user_agreement",
|
||||
"page_url": self._getBuiltinWelcomePagePath("UserAgreementContent.qml"),
|
||||
},
|
||||
{"id": "whats_new",
|
||||
"page_url": self._getBuiltinWelcomePagePath("WhatsNewContent.qml"),
|
||||
},
|
||||
{"id": "data_collections",
|
||||
"page_url": self._getBuiltinWelcomePagePath("DataCollectionsContent.qml"),
|
||||
},
|
||||
|
@ -259,13 +256,21 @@ class WelcomePagesModel(ListModel):
|
|||
},
|
||||
{"id": "add_cloud_printers",
|
||||
"page_url": self._getBuiltinWelcomePagePath("AddCloudPrintersView.qml"),
|
||||
"is_final_page": True, # If we end up in this page, the next button will close the dialog
|
||||
"next_page_button_text": self._catalog.i18nc("@action:button", "Finish"),
|
||||
"next_page_button_text": self._catalog.i18nc("@action:button", "Next"),
|
||||
"next_page_id": "whats_new",
|
||||
},
|
||||
{"id": "machine_actions",
|
||||
"page_url": self._getBuiltinWelcomePagePath("FirstStartMachineActionsContent.qml"),
|
||||
"should_show_function": self.shouldShowMachineActions,
|
||||
},
|
||||
{"id": "whats_new",
|
||||
"page_url": self._getBuiltinWelcomePagePath("WhatsNewContent.qml"),
|
||||
"next_page_button_text": self._catalog.i18nc("@action:button", "Skip"),
|
||||
},
|
||||
{"id": "changelog",
|
||||
"page_url": self._getBuiltinWelcomePagePath("ChangelogContent.qml"),
|
||||
"next_page_button_text": self._catalog.i18nc("@action:button", "Finish"),
|
||||
},
|
||||
]
|
||||
|
||||
pages_to_show = all_pages_list
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
# Copyright (c) 2019 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from .WelcomePagesModel import WelcomePagesModel
|
||||
|
||||
import os
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSlot
|
||||
from UM.Logger import Logger
|
||||
from UM.Resources import Resources
|
||||
|
||||
#
|
||||
# This Qt ListModel is more or less the same the WelcomePagesModel, except that this model is only for showing the
|
||||
|
@ -10,13 +14,84 @@ from .WelcomePagesModel import WelcomePagesModel
|
|||
#
|
||||
class WhatsNewPagesModel(WelcomePagesModel):
|
||||
|
||||
image_formats = [".png", ".jpg", ".jpeg", ".gif", ".svg"]
|
||||
text_formats = [".txt", ".htm", ".html"]
|
||||
image_key = "image"
|
||||
text_key = "text"
|
||||
|
||||
@staticmethod
|
||||
def _collectOrdinalFiles(resource_type: int, include: List[str]) -> Tuple[Dict[int, str], int]:
|
||||
result = {} #type: Dict[int, str]
|
||||
highest = -1
|
||||
try:
|
||||
folder_path = Resources.getPath(resource_type, "whats_new")
|
||||
for _, _, files in os.walk(folder_path):
|
||||
for filename in files:
|
||||
basename = os.path.basename(filename)
|
||||
base, ext = os.path.splitext(basename)
|
||||
if ext.lower() not in include or not base.isdigit():
|
||||
continue
|
||||
page_no = int(base)
|
||||
highest = max(highest, page_no)
|
||||
result[page_no] = os.path.join(folder_path, filename)
|
||||
except FileNotFoundError:
|
||||
Logger.logException("w", "Could not find 'whats_new' folder for resource-type {0}".format(resource_type))
|
||||
return result, highest
|
||||
|
||||
@staticmethod
|
||||
def _loadText(filename: str) -> str:
|
||||
result = ""
|
||||
try:
|
||||
with open(filename, "r", encoding="utf-8") as file:
|
||||
result = file.read()
|
||||
except OSError:
|
||||
Logger.logException("w", "Could not open {0}".format(filename))
|
||||
return result
|
||||
|
||||
def initialize(self) -> None:
|
||||
self._pages = []
|
||||
self._pages.append({"id": "whats_new",
|
||||
"page_url": self._getBuiltinWelcomePagePath("WhatsNewContent.qml"),
|
||||
"next_page_button_text": self._catalog.i18nc("@action:button", "Skip"),
|
||||
"next_page_id": "changelog"
|
||||
})
|
||||
self._pages.append({"id": "changelog",
|
||||
"page_url": self._getBuiltinWelcomePagePath("ChangelogContent.qml"),
|
||||
"next_page_button_text": self._catalog.i18nc("@action:button", "Close"),
|
||||
})
|
||||
self.setItems(self._pages)
|
||||
|
||||
images, max_image = WhatsNewPagesModel._collectOrdinalFiles(Resources.Images, WhatsNewPagesModel.image_formats)
|
||||
texts, max_text = WhatsNewPagesModel._collectOrdinalFiles(Resources.Texts, WhatsNewPagesModel.text_formats)
|
||||
highest = max(max_image, max_text)
|
||||
|
||||
self._subpages = [] #type: List[Dict[str, Optional[str]]]
|
||||
for n in range(0, highest + 1):
|
||||
self._subpages.append({
|
||||
WhatsNewPagesModel.image_key: None if n not in images else images[n],
|
||||
WhatsNewPagesModel.text_key: None if n not in texts else self._loadText(texts[n])
|
||||
})
|
||||
if len(self._subpages) == 0:
|
||||
self._subpages.append({WhatsNewPagesModel.text_key: "~ There Is Nothing New Under The Sun ~"})
|
||||
|
||||
def _getSubpageItem(self, page: int, item: str) -> Optional[str]:
|
||||
if 0 <= page < self.subpageCount and item in self._subpages[page]:
|
||||
return self._subpages[page][item]
|
||||
else:
|
||||
return None
|
||||
|
||||
@pyqtProperty(int, constant = True)
|
||||
def subpageCount(self) -> int:
|
||||
return len(self._subpages)
|
||||
|
||||
@pyqtSlot(int, result = str)
|
||||
def getSubpageImageSource(self, page: int) -> str:
|
||||
result = self._getSubpageItem(page, WhatsNewPagesModel.image_key)
|
||||
return "file:///" + (result if result else Resources.getPath(Resources.Images, "cura-icon.png"))
|
||||
|
||||
@pyqtSlot(int, result = str)
|
||||
def getSubpageText(self, page: int) -> str:
|
||||
result = self._getSubpageItem(page, WhatsNewPagesModel.text_key)
|
||||
return result if result else "* * *"
|
||||
|
||||
__all__ = ["WhatsNewPagesModel"]
|
||||
|
|
|
@ -16,14 +16,6 @@ import argparse
|
|||
import faulthandler
|
||||
import os
|
||||
|
||||
# Workaround for a race condition on certain systems where there
|
||||
# is a race condition between Arcus and PyQt. Importing Arcus
|
||||
# first seems to prevent Sip from going into a state where it
|
||||
# tries to create PyQt objects on a non-main thread.
|
||||
import Arcus # @UnusedImport
|
||||
import Savitar # @UnusedImport
|
||||
import pynest2d # @UnusedImport
|
||||
|
||||
from PyQt5.QtNetwork import QSslConfiguration, QSslSocket
|
||||
|
||||
from UM.Platform import Platform
|
||||
|
|
|
@ -7,7 +7,7 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
|||
PROJECT_DIR="$( cd "${SCRIPT_DIR}/.." && pwd )"
|
||||
|
||||
# Make sure that environment variables are set properly
|
||||
source /opt/rh/devtoolset-7/enable
|
||||
source /opt/rh/devtoolset-8/enable
|
||||
export PATH="${CURA_BUILD_ENV_PATH}/bin:${PATH}"
|
||||
export PKG_CONFIG_PATH="${CURA_BUILD_ENV_PATH}/lib/pkgconfig:${PKG_CONFIG_PATH}"
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2020 Ultimaker B.V.
|
||||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from configparser import ConfigParser
|
||||
|
@ -412,7 +412,12 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
|
|||
quality_container_id = parser["containers"][str(_ContainerIndexes.Quality)]
|
||||
quality_type = "empty_quality"
|
||||
if quality_container_id not in ("empty", "empty_quality"):
|
||||
if quality_container_id in instance_container_info_dict:
|
||||
quality_type = instance_container_info_dict[quality_container_id].parser["metadata"]["quality_type"]
|
||||
else: # If a version upgrade changed the quality profile in the stack, we'll need to look for it in the built-in profiles instead of the workspace.
|
||||
quality_matches = ContainerRegistry.getInstance().findContainersMetadata(id = quality_container_id)
|
||||
if quality_matches: # If there's no profile with this ID, leave it empty_quality.
|
||||
quality_type = quality_matches[0]["quality_type"]
|
||||
|
||||
# Get machine info
|
||||
serialized = archive.open(global_stack_file).read().decode("utf-8")
|
||||
|
@ -1157,7 +1162,7 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
|
|||
return
|
||||
machine_manager.setQualityChangesGroup(quality_changes_group, no_dialog = True)
|
||||
else:
|
||||
self._quality_type_to_apply = self._quality_type_to_apply.lower()
|
||||
self._quality_type_to_apply = self._quality_type_to_apply.lower() if self._quality_type_to_apply else None
|
||||
quality_group_dict = container_tree.getCurrentQualityGroups()
|
||||
if self._quality_type_to_apply in quality_group_dict:
|
||||
quality_group = quality_group_dict[self._quality_type_to_apply]
|
||||
|
|
|
@ -419,7 +419,7 @@ UM.Dialog
|
|||
width: warningLabel.height
|
||||
height: width
|
||||
|
||||
source: UM.Theme.getIcon("notice")
|
||||
source: UM.Theme.getIcon("Information")
|
||||
color: palette.text
|
||||
|
||||
}
|
||||
|
|
|
@ -3,6 +3,6 @@
|
|||
"author": "Ultimaker B.V.",
|
||||
"version": "1.0.1",
|
||||
"description": "Provides support for reading 3MF files.",
|
||||
"api": "7.4.0",
|
||||
"api": 7,
|
||||
"i18n-catalog": "cura"
|
||||
}
|
||||
|
|
|
@ -3,6 +3,6 @@
|
|||
"author": "Ultimaker B.V.",
|
||||
"version": "1.0.1",
|
||||
"description": "Provides support for writing 3MF files.",
|
||||
"api": "7.4.0",
|
||||
"api": 7,
|
||||
"i18n-catalog": "cura"
|
||||
}
|
||||
|
|
|
@ -157,22 +157,22 @@ class AMFReader(MeshReader):
|
|||
tri_faces = tri_node.faces
|
||||
tri_vertices = tri_node.vertices
|
||||
|
||||
indices = []
|
||||
vertices = []
|
||||
indices_list = []
|
||||
vertices_list = []
|
||||
|
||||
index_count = 0
|
||||
face_count = 0
|
||||
for tri_face in tri_faces:
|
||||
face = []
|
||||
for tri_index in tri_face:
|
||||
vertices.append(tri_vertices[tri_index])
|
||||
vertices_list.append(tri_vertices[tri_index])
|
||||
face.append(index_count)
|
||||
index_count += 1
|
||||
indices.append(face)
|
||||
indices_list.append(face)
|
||||
face_count += 1
|
||||
|
||||
vertices = numpy.asarray(vertices, dtype = numpy.float32)
|
||||
indices = numpy.asarray(indices, dtype = numpy.int32)
|
||||
vertices = numpy.asarray(vertices_list, dtype = numpy.float32)
|
||||
indices = numpy.asarray(indices_list, dtype = numpy.int32)
|
||||
normals = calculateNormalsFromIndexedVertices(vertices, indices, face_count)
|
||||
|
||||
mesh_data = MeshData(vertices = vertices, indices = indices, normals = normals,file_name = file_name)
|
||||
|
|
|
@ -3,5 +3,5 @@
|
|||
"author": "fieldOfView",
|
||||
"version": "1.0.0",
|
||||
"description": "Provides support for reading AMF files.",
|
||||
"api": "7.4.0"
|
||||
"api": 7
|
||||
}
|
||||
|
|
|
@ -3,6 +3,6 @@
|
|||
"author": "Ultimaker B.V.",
|
||||
"description": "Backup and restore your configuration.",
|
||||
"version": "1.2.0",
|
||||
"api": "7.4.0",
|
||||
"api": 7,
|
||||
"i18n-catalog": "cura"
|
||||
}
|
||||
|
|
|
@ -5,7 +5,6 @@ import threading
|
|||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import sentry_sdk
|
||||
from PyQt5.QtNetwork import QNetworkReply
|
||||
|
||||
from UM.Job import Job
|
||||
|
@ -99,13 +98,7 @@ class CreateBackupJob(Job):
|
|||
if HttpRequestManager.safeHttpStatus(reply) == 400:
|
||||
errors = json.loads(replyText)["errors"]
|
||||
if "moreThanMaximum" in [error["code"] for error in errors if error["meta"] and error["meta"]["field_name"] == "backup_size"]:
|
||||
if self._backup_zip is None: # will never happen; keep mypy happy
|
||||
zip_error = "backup is None."
|
||||
else:
|
||||
zip_error = "{} exceeds max size.".format(str(len(self._backup_zip)))
|
||||
sentry_sdk.capture_message("backup failed: {}".format(zip_error), level ="warning")
|
||||
self.backup_upload_error_message = catalog.i18nc("@error:file_size", "The backup exceeds the maximum file size.")
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
self._job_done.set()
|
||||
return
|
||||
|
|
|
@ -34,6 +34,9 @@ class DrivePluginExtension(QObject, Extension):
|
|||
# Signal emitted when preferences changed (like auto-backup).
|
||||
preferencesChanged = pyqtSignal()
|
||||
|
||||
# Signal emitted when the id of the backup-to-be-restored is changed
|
||||
backupIdBeingRestoredChanged = pyqtSignal(arguments = ["backup_id_being_restored"])
|
||||
|
||||
DATE_FORMAT = "%d/%m/%Y %H:%M:%S"
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
@ -45,6 +48,7 @@ class DrivePluginExtension(QObject, Extension):
|
|||
self._backups = [] # type: List[Dict[str, Any]]
|
||||
self._is_restoring_backup = False
|
||||
self._is_creating_backup = False
|
||||
self._backup_id_being_restored = ""
|
||||
|
||||
# Initialize services.
|
||||
preferences = CuraApplication.getInstance().getPreferences()
|
||||
|
@ -52,6 +56,7 @@ class DrivePluginExtension(QObject, Extension):
|
|||
|
||||
# Attach signals.
|
||||
CuraApplication.getInstance().getCuraAPI().account.loginStateChanged.connect(self._onLoginStateChanged)
|
||||
CuraApplication.getInstance().applicationShuttingDown.connect(self._onApplicationShuttingDown)
|
||||
self._drive_api_service.restoringStateChanged.connect(self._onRestoringStateChanged)
|
||||
self._drive_api_service.creatingStateChanged.connect(self._onCreatingStateChanged)
|
||||
|
||||
|
@ -75,6 +80,10 @@ class DrivePluginExtension(QObject, Extension):
|
|||
if self._drive_window:
|
||||
self._drive_window.show()
|
||||
|
||||
def _onApplicationShuttingDown(self):
|
||||
if self._drive_window:
|
||||
self._drive_window.hide()
|
||||
|
||||
def _autoBackup(self) -> None:
|
||||
preferences = CuraApplication.getInstance().getPreferences()
|
||||
if preferences.getValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY) and self._isLastBackupTooLongAgo():
|
||||
|
@ -100,10 +109,11 @@ class DrivePluginExtension(QObject, Extension):
|
|||
if logged_in:
|
||||
self.refreshBackups()
|
||||
|
||||
def _onRestoringStateChanged(self, is_restoring: bool = False, error_message: str = None) -> None:
|
||||
def _onRestoringStateChanged(self, is_restoring: bool = False, error_message: Optional[str] = None) -> None:
|
||||
self._is_restoring_backup = is_restoring
|
||||
self.restoringStateChanged.emit()
|
||||
if error_message:
|
||||
self.backupIdBeingRestored = ""
|
||||
Message(error_message, title = catalog.i18nc("@info:title", "Backup")).show()
|
||||
|
||||
def _onCreatingStateChanged(self, is_creating: bool = False, error_message: str = None) -> None:
|
||||
|
@ -152,6 +162,7 @@ class DrivePluginExtension(QObject, Extension):
|
|||
for backup in self._backups:
|
||||
if backup.get("backup_id") == backup_id:
|
||||
self._drive_api_service.restoreBackup(backup)
|
||||
self.setBackupIdBeingRestored(backup_id)
|
||||
return
|
||||
Logger.log("w", "Unable to find backup with the ID %s", backup_id)
|
||||
|
||||
|
@ -166,3 +177,12 @@ class DrivePluginExtension(QObject, Extension):
|
|||
def _backupDeletedCallback(self, success: bool):
|
||||
if success:
|
||||
self.refreshBackups()
|
||||
|
||||
def setBackupIdBeingRestored(self, backup_id_being_restored: str) -> None:
|
||||
if backup_id_being_restored != self._backup_id_being_restored:
|
||||
self._backup_id_being_restored = backup_id_being_restored
|
||||
self.backupIdBeingRestoredChanged.emit()
|
||||
|
||||
@pyqtProperty(str, fset = setBackupIdBeingRestored, notify = backupIdBeingRestoredChanged)
|
||||
def backupIdBeingRestored(self) -> str:
|
||||
return self._backup_id_being_restored
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import threading
|
||||
|
@ -56,6 +59,7 @@ class RestoreBackupJob(Job):
|
|||
return
|
||||
|
||||
# We store the file in a temporary path fist to ensure integrity.
|
||||
try:
|
||||
temporary_backup_file = NamedTemporaryFile(delete = False)
|
||||
with open(temporary_backup_file.name, "wb") as write_backup:
|
||||
app = CuraApplication.getInstance()
|
||||
|
@ -64,6 +68,11 @@ class RestoreBackupJob(Job):
|
|||
write_backup.write(bytes_read)
|
||||
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
|
||||
app.processEvents()
|
||||
except EnvironmentError as e:
|
||||
Logger.log("e", f"Unable to save backed up files due to computer limitations: {str(e)}")
|
||||
self.restore_backup_error_message = self.DEFAULT_ERROR_MESSAGE
|
||||
self._job_done.set()
|
||||
return
|
||||
|
||||
if not self._verifyMd5Hash(temporary_backup_file.name, self._backup.get("md5_hash", "")):
|
||||
# Don't restore the backup if the MD5 hashes do not match.
|
||||
|
|
|
@ -20,7 +20,7 @@ RowLayout
|
|||
{
|
||||
id: infoButton
|
||||
text: catalog.i18nc("@button", "Want more?")
|
||||
iconSource: UM.Theme.getIcon("info")
|
||||
iconSource: UM.Theme.getIcon("Information")
|
||||
onClicked: Qt.openUrlExternally("https://goo.gl/forms/QACEP8pP3RV60QYG2")
|
||||
visible: backupListFooter.showInfoButton
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ RowLayout
|
|||
{
|
||||
id: createBackupButton
|
||||
text: catalog.i18nc("@button", "Backup Now")
|
||||
iconSource: UM.Theme.getIcon("plus")
|
||||
iconSource: UM.Theme.getIcon("Plus")
|
||||
enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
|
||||
onClicked: CuraDrive.createBackup()
|
||||
busy: CuraDrive.isCreatingBackup
|
||||
|
|
|
@ -38,7 +38,7 @@ Item
|
|||
height: UM.Theme.getSize("section_icon").height
|
||||
color: UM.Theme.getColor("small_button_text")
|
||||
hoverColor: UM.Theme.getColor("small_button_text_hover")
|
||||
iconSource: UM.Theme.getIcon("info")
|
||||
iconSource: UM.Theme.getIcon("Information")
|
||||
onClicked: backupListItem.showDetails = !backupListItem.showDetails
|
||||
}
|
||||
|
||||
|
@ -71,6 +71,7 @@ Item
|
|||
text: catalog.i18nc("@button", "Restore")
|
||||
enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
|
||||
onClicked: confirmRestoreDialog.visible = true
|
||||
busy: CuraDrive.backupIdBeingRestored == modelData.backup_id && CuraDrive.isRestoringBackup
|
||||
}
|
||||
|
||||
UM.SimpleButton
|
||||
|
@ -79,7 +80,7 @@ Item
|
|||
height: UM.Theme.getSize("message_close").height
|
||||
color: UM.Theme.getColor("small_button_text")
|
||||
hoverColor: UM.Theme.getColor("small_button_text_hover")
|
||||
iconSource: UM.Theme.getIcon("cross1")
|
||||
iconSource: UM.Theme.getIcon("Cancel")
|
||||
onClicked: confirmDeleteDialog.visible = true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright (c) 2018 Ultimaker B.V.
|
||||
// Copyright (c) 2021 Ultimaker B.V.
|
||||
// Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import QtQuick 2.7
|
||||
|
@ -17,7 +17,7 @@ ColumnLayout
|
|||
// Cura version
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("application")
|
||||
iconSource: UM.Theme.getIcon("UltimakerCura")
|
||||
label: catalog.i18nc("@backuplist:label", "Cura Version")
|
||||
value: backupDetailsData.metadata.cura_release
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ ColumnLayout
|
|||
// Machine count.
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("printer_single")
|
||||
iconSource: UM.Theme.getIcon("Printer")
|
||||
label: catalog.i18nc("@backuplist:label", "Machines")
|
||||
value: backupDetailsData.metadata.machine_count
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ ColumnLayout
|
|||
// Material count
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("category_material")
|
||||
iconSource: UM.Theme.getIcon("Spool")
|
||||
label: catalog.i18nc("@backuplist:label", "Materials")
|
||||
value: backupDetailsData.metadata.material_count
|
||||
}
|
||||
|
@ -41,7 +41,7 @@ ColumnLayout
|
|||
// Profile count.
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("settings")
|
||||
iconSource: UM.Theme.getIcon("Sliders")
|
||||
label: catalog.i18nc("@backuplist:label", "Profiles")
|
||||
value: backupDetailsData.metadata.profile_count
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ ColumnLayout
|
|||
// Plugin count.
|
||||
BackupListItemDetailsRow
|
||||
{
|
||||
iconSource: UM.Theme.getIcon("plugin")
|
||||
iconSource: UM.Theme.getIcon("Plugin")
|
||||
label: catalog.i18nc("@backuplist:label", "Plugins")
|
||||
value: backupDetailsData.metadata.plugin_count
|
||||
}
|
||||
|
|
|
@ -4,12 +4,12 @@
|
|||
import argparse #To run the engine in debug mode if the front-end is in debug mode.
|
||||
from collections import defaultdict
|
||||
import os
|
||||
from PyQt5.QtCore import QObject, QTimer, pyqtSlot
|
||||
from PyQt5.QtCore import QObject, QTimer, QUrl, pyqtSlot
|
||||
import sys
|
||||
from time import time
|
||||
from typing import Any, cast, Dict, List, Optional, Set, TYPE_CHECKING
|
||||
|
||||
from PyQt5.QtGui import QImage
|
||||
from PyQt5.QtGui import QDesktopServices, QImage
|
||||
|
||||
from UM.Backend.Backend import Backend, BackendState
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
|
@ -157,6 +157,18 @@ class CuraEngineBackend(QObject, Backend):
|
|||
self.determineAutoSlicing()
|
||||
application.getPreferences().preferenceChanged.connect(self._onPreferencesChanged)
|
||||
|
||||
self._slicing_error_message = Message(
|
||||
text = catalog.i18nc("@message", "Slicing failed with an unexpected error. Please consider reporting a bug on our issue tracker."),
|
||||
title = catalog.i18nc("@message:title", "Slicing failed")
|
||||
)
|
||||
self._slicing_error_message.addAction(
|
||||
action_id = "report_bug",
|
||||
name = catalog.i18nc("@message:button", "Report a bug"),
|
||||
description = catalog.i18nc("@message:description", "Report a bug on Ultimaker Cura's issue tracker."),
|
||||
icon = "[no_icon]"
|
||||
)
|
||||
self._slicing_error_message.actionTriggered.connect(self._reportBackendError)
|
||||
|
||||
self._snapshot = None #type: Optional[QImage]
|
||||
|
||||
application.initializationFinished.connect(self.initialize)
|
||||
|
@ -922,9 +934,22 @@ class CuraEngineBackend(QObject, Backend):
|
|||
|
||||
if not self._restart:
|
||||
if self._process: # type: ignore
|
||||
Logger.log("d", "Backend quit with return code %s. Resetting process and socket.", self._process.wait()) # type: ignore
|
||||
return_code = self._process.wait()
|
||||
if return_code != 0:
|
||||
Logger.log("e", f"Backend exited abnormally with return code {return_code}!")
|
||||
self._slicing_error_message.show()
|
||||
self.setState(BackendState.Error)
|
||||
self.stopSlicing()
|
||||
else:
|
||||
Logger.log("d", "Backend finished slicing. Resetting process and socket.")
|
||||
self._process = None # type: ignore
|
||||
|
||||
def _reportBackendError(self, _message_id: str, _action_id: str) -> None:
|
||||
"""
|
||||
Triggered when the user wants to report an error in the back-end.
|
||||
"""
|
||||
QDesktopServices.openUrl(QUrl("https://github.com/Ultimaker/Cura/issues/new/choose"))
|
||||
|
||||
def _onGlobalStackChanged(self) -> None:
|
||||
"""Called when the global container stack changes"""
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"name": "CuraEngine Backend",
|
||||
"author": "Ultimaker B.V.",
|
||||
"description": "Provides the link to the CuraEngine slicing backend.",
|
||||
"api": "7.4.0",
|
||||
"api": 7,
|
||||
"version": "1.0.1",
|
||||
"i18n-catalog": "cura"
|
||||
}
|
||||
|
|
|
@ -3,6 +3,6 @@
|
|||
"author": "Ultimaker B.V.",
|
||||
"version": "1.0.1",
|
||||
"description": "Provides support for importing Cura profiles.",
|
||||
"api": "7.4.0",
|
||||
"api": 7,
|
||||
"i18n-catalog": "cura"
|
||||
}
|
||||
|
|
|
@ -3,6 +3,6 @@
|
|||
"author": "Ultimaker B.V.",
|
||||
"version": "1.0.1",
|
||||
"description": "Provides support for exporting Cura profiles.",
|
||||
"api": "7.4.0",
|
||||
"api": 7,
|
||||
"i18n-catalog":"cura"
|
||||
}
|
||||
|
|
17
plugins/DigitalLibrary/__init__.py
Normal file
17
plugins/DigitalLibrary/__init__.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from UM.MimeTypeDatabase import MimeType, MimeTypeDatabase
|
||||
from .src import DigitalFactoryFileProvider, DigitalFactoryOutputDevicePlugin, DigitalFactoryController
|
||||
|
||||
|
||||
def getMetaData():
|
||||
return {}
|
||||
|
||||
|
||||
def register(app):
|
||||
df_controller = DigitalFactoryController.DigitalFactoryController(app)
|
||||
return {
|
||||
"file_provider": DigitalFactoryFileProvider.DigitalFactoryFileProvider(df_controller),
|
||||
"output_device": DigitalFactoryOutputDevicePlugin.DigitalFactoryOutputDevicePlugin(df_controller)
|
||||
}
|
8
plugins/DigitalLibrary/plugin.json
Normal file
8
plugins/DigitalLibrary/plugin.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "Ultimaker Digital Library",
|
||||
"author": "Ultimaker B.V.",
|
||||
"description": "Connects to the Digital Library, allowing Cura to open files from and save files to the Digital Library.",
|
||||
"version": "1.0.0",
|
||||
"api": 7,
|
||||
"i18n-catalog": "cura"
|
||||
}
|
6
plugins/DigitalLibrary/resources/images/arrow_down.svg
Normal file
6
plugins/DigitalLibrary/resources/images/arrow_down.svg
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Artwork" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
|
||||
<polygon style="fill:#000E1A;" points="19.7,13.3 18.3,11.9 13,17.2 13,3 11,3 11,17.2 5.7,11.9 4.3,13.3 12,21 "/>
|
||||
</svg>
|
After Width: | Height: | Size: 470 B |
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 19 KiB |
3
plugins/DigitalLibrary/resources/images/placeholder.svg
Normal file
3
plugins/DigitalLibrary/resources/images/placeholder.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
|
||||
<path d="M24,44,7,33.4V14.6L24,4,41,14.6V33.4ZM9,32.3l15,9.3,15-9.3V15.7L24,6.4,9,15.7Z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 184 B |
|
@ -0,0 +1,52 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 378.13 348.13" version="1.1">
|
||||
<defs
|
||||
id="defs7">
|
||||
<style
|
||||
id="style2">
|
||||
.cls-2,.cls-6{fill:#c5dbfb;}
|
||||
.cls-6,.cls-7{stroke-width:2px;}
|
||||
.cls-7{fill:#f3f8fe;}
|
||||
.cls-6,.cls-7{stroke:#061884;}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-2" d="M43,17V3H83a2,2,0,0,1,2,2V17Z" />
|
||||
<path fill="white" d="M 3 1 C 1.8954305 1 1 1.8954305 1 3 L 1 67 C 1 68.104569 1.8954305 69 3 69 L 72.152344 69 A 100 100 0 0 1 89 49.873047 L 89 19 C 89 17.895431 88.104569 17 87 17 L 56 17 L 40 1 L 3 1 z " />
|
||||
<path fill="#c5dbfb" d="M 3 0 C 1.3549904 0 0 1.3549904 0 3 L 0 67 C 0 68.64501 1.3549904 70 3 70 L 71.484375 70 A 100 100 0 0 1 72.835938 68 L 3 68 C 2.4358706 68 2 67.564129 2 67 L 2 3 C 2 2.4358706 2.4358706 2 3 2 L 39.585938 2 L 55.585938 18 L 87 18 C 87.564129 18 88 18.435871 88 19 L 88 50.765625 A 100 100 0 0 1 90 49.007812 L 90 19 C 90 17.35499 88.64501 16 87 16 L 56.414062 16 L 40.414062 0 L 3 0 z " />
|
||||
<path class="cls-2" d="M153,17V3h40a2,2,0,0,1,2,2V17Z" />
|
||||
<path fill="white" d="M 113 1 C 111.89543 1 111 1.8954305 111 3 L 111 35.201172 A 100 100 0 0 1 155 25 A 100 100 0 0 1 199 35.201172 L 199 19 C 199 17.895431 198.10457 17 197 17 L 166 17 L 150 1 L 113 1 z " />
|
||||
<path fill="#c5dbfb" d="M 113 0 C 111.35499 0 110 1.3549904 110 3 L 110 35.699219 A 100 100 0 0 1 112 34.716797 L 112 3 C 112 2.4358706 112.43587 2 113 2 L 149.58594 2 L 165.58594 18 L 197 18 C 197.56413 18 198 18.435871 198 19 L 198 34.716797 A 100 100 0 0 1 200 35.699219 L 200 19 C 200 17.35499 198.64501 16 197 16 L 166.41406 16 L 150.41406 0 L 113 0 z " />
|
||||
<path class="cls-2" d="M263,17V3h40a2,2,0,0,1,2,2V17Z" />
|
||||
<path fill="white" d="M 223 1 C 221.89543 1 221 1.8954305 221 3 L 221 49.875 A 100 100 0 0 1 237.84961 69 L 307 69 C 308.10457 69 309 68.104569 309 67 L 309 19 C 309 17.895431 308.10457 17 307 17 L 276 17 L 260 1 L 223 1 z " />
|
||||
<path fill="#c5dbfb" d="M 223 0 C 221.35499 0 220 1.3549904 220 3 L 220 49.005859 A 100 100 0 0 1 222 50.765625 L 222 3 C 222 2.4358706 222.43587 2 223 2 L 259.58594 2 L 275.58594 18 L 307 18 C 307.56413 18 308 18.435871 308 19 L 308 67 C 308 67.564129 307.56413 68 307 68 L 237.16406 68 A 100 100 0 0 1 238.51562 70 L 307 70 C 308.64501 70 310 68.64501 310 67 L 310 19 C 310 17.35499 308.64501 16 307 16 L 276.41406 16 L 260.41406 0 L 223 0 z " />
|
||||
<path fill="#c5dbfb" d="M 43 93 L 43 107 L 56.634766 107 A 100 100 0 0 1 60.259766 93 L 43 93 z " />
|
||||
<path fill="white" d="M 3 91 C 1.8954305 91 1 91.895431 1 93 L 1 157 C 1 158.10457 1.8954305 159 3 159 L 60.958984 159 A 100 100 0 0 1 55 125 A 100 100 0 0 1 56.634766 107 L 56 107 L 40 91 L 3 91 z " />
|
||||
<path fill="#c5dbfb" d="M 3 90 C 1.3549904 90 0 91.35499 0 93 L 0 157 C 0 158.64501 1.3549904 160 3 160 L 61.324219 160 A 100 100 0 0 1 60.603516 158 L 3 158 C 2.4358706 158 2 157.56413 2 157 L 2 93 C 2 92.435871 2.4358706 92 3 92 L 39.585938 92 L 55.585938 108 L 56.455078 108 A 100 100 0 0 1 56.822266 106 L 56.414062 106 L 40.414062 90 L 3 90 z " />
|
||||
<path class="cls-2" d="M263,107V93h40a2,2,0,0,1,2,2v12Z" />
|
||||
<path fill="white" d="M 249.04102 91 A 100 100 0 0 1 255 125 A 100 100 0 0 1 249.04102 159 L 307 159 C 308.10457 159 309 158.10457 309 157 L 309 109 C 309 107.89543 308.10457 107 307 107 L 276 107 L 260 91 L 249.04102 91 z " />
|
||||
<path fill="#c5dbfb" d="M 248.67578 90 A 100 100 0 0 1 249.39648 92 L 259.58594 92 L 275.58594 108 L 307 108 C 307.56413 108 308 108.43587 308 109 L 308 157 C 308 157.56413 307.56413 158 307 158 L 249.39844 158 A 100 100 0 0 1 248.67383 160 L 307 160 C 308.64501 160 310 158.64501 310 157 L 310 109 C 310 107.35499 308.64501 106 307 106 L 276.41406 106 L 260.41406 90 L 248.67578 90 z " />
|
||||
<path fill="#c5dbfb" d="M 43 183 L 43 197 L 85 197 L 85 196.41406 A 100 100 0 0 1 73.539062 183 L 43 183 z " />
|
||||
<path fill="white" d="M 3 181 C 1.8954305 181 1 181.89543 1 183 L 1 247 C 1 248.10457 1.8954305 249 3 249 L 87 249 C 88.104569 249 89 248.10457 89 247 L 89 200.125 A 100 100 0 0 1 85.603516 197 L 56 197 L 40 181 L 3 181 z " />
|
||||
<path fill="#c5dbfb" d="M 3 180 C 1.3549904 180 0 181.35499 0 183 L 0 247 C 0 248.64501 1.3549904 250 3 250 L 87 250 C 88.64501 250 90 248.64501 90 247 L 90 200.99414 A 100 100 0 0 1 88 199.23438 L 88 247 C 88 247.56413 87.564129 248 87 248 L 3 248 C 2.4358706 248 2 247.56413 2 247 L 2 183 C 2 182.43587 2.4358706 182 3 182 L 39.585938 182 L 55.585938 198 L 86.65625 198 A 100 100 0 0 1 84.580078 196 L 56.414062 196 L 40.414062 180 L 3 180 z " />
|
||||
<path fill="white" d="M 111 214.79883 L 111 247 C 111 248.10457 111.89543 249 113 249 L 197 249 C 198.10457 249 199 248.10457 199 247 L 199 214.79883 A 100 100 0 0 1 155 225 A 100 100 0 0 1 111 214.79883 z " />
|
||||
<path fill="#c5dbfb" d="M 110 214.30078 L 110 247 C 110 248.64501 111.35499 250 113 250 L 197 250 C 198.64501 250 200 248.64501 200 247 L 200 214.30078 A 100 100 0 0 1 198 215.2832 L 198 247 C 198 247.56413 197.56413 248 197 248 L 113 248 C 112.43587 248 112 247.56413 112 247 L 112 215.2832 A 100 100 0 0 1 110 214.30078 z " />
|
||||
<path class="cls-2" d="M263,197V183h40a2,2,0,0,1,2,2v12Z" />
|
||||
<path fill="white" d="M 237.84766 181 A 100 100 0 0 1 221 200.12695 L 221 247 C 221 248.10457 221.89543 249 223 249 L 307 249 C 308.10457 249 309 248.10457 309 247 L 309 199 C 309 197.89543 308.10457 197 307 197 L 276 197 L 260 181 L 237.84766 181 z " />
|
||||
<path fill="#c5dbfb" d="M 238.51562 180 A 100 100 0 0 1 237.16406 182 L 259.58594 182 L 275.58594 198 L 307 198 C 307.56413 198 308 198.43587 308 199 L 308 247 C 308 247.56413 307.56413 248 307 248 L 223 248 C 222.43587 248 222 247.56413 222 247 L 222 199.23438 A 100 100 0 0 1 220 200.99219 L 220 247 C 220 248.64501 221.35499 250 223 250 L 307 250 C 308.64501 250 310 248.64501 310 247 L 310 199 C 310 197.35499 308.64501 196 307 196 L 276.41406 196 L 260.41406 180 L 238.51562 180 z " />
|
||||
<path class="cls-6" d="M351.12,322.62h20a10,10,0,0,1,10,10v7a0,0,0,0,1,0,0h-40a0,0,0,0,1,0,0v-7A10,10,0,0,1,351.12,322.62Z" transform="translate(850.61 309.91) rotate(135)" />
|
||||
<rect class="cls-7" x="293.75" y="225.25" width="40" height="117" transform="translate(-108.74 304.96) rotate(-45)" />
|
||||
<polyline class="cls-7" points="213.69 199.25 252.58 238.14 267.43 223.29 228.54 184.4" />
|
||||
<path fill="white" stroke="#061884" stroke-width="2px" d="M 154.94141 30 A 95 95 0 0 0 60 125 A 95 95 0 0 0 155 220 A 95 95 0 0 0 250 125 A 95 95 0 0 0 155 30 A 95 95 0 0 0 154.94141 30 z M 154.82812 40 A 85 85 0 0 1 155 40 A 85 85 0 0 1 240 125 A 85 85 0 0 1 155 210 A 85 85 0 0 1 70 125 A 85 85 0 0 1 154.82812 40 z " />
|
||||
<path class="cls-6" d="M256.37,227.87h20a10,10,0,0,1,10,10v7a0,0,0,0,1,0,0h-40a0,0,0,0,1,0,0v-7a10,10,0,0,1,10-10Z" transform="translate(-89.12 257.58) rotate(-45)" />
|
||||
<path fill="white" d="M 154.94141 45 A 80 80 0 0 0 111 58.185547 L 111 67 C 111 68.104569 111.89543 69 113 69 L 197 69 C 198.10457 69 199 68.104569 199 67 L 199 58.1875 A 80 80 0 0 0 155 45 A 80 80 0 0 0 154.94141 45 z " />
|
||||
<path fill="#061884" d="M 112 57.539062 A 80 80 0 0 0 110 58.857422 L 110 67 C 110 68.64501 111.35499 70 113 70 L 197 70 C 198.64501 70 200 68.64501 200 67 L 200 58.857422 A 80 80 0 0 0 198 57.541016 L 198 67 C 198 67.564129 197.56413 68 197 68 L 113 68 C 112.43587 68 112 67.564129 112 67 L 112 57.539062 z " />
|
||||
<path fill="#196ef0" d="M 81.679688 93 A 80 80 0 0 0 77.050781 107 L 85 107 L 85 95 A 2 2 0 0 0 83 93 L 81.679688 93 z " />
|
||||
<path fill="white" d="M 77.050781 107 A 80 80 0 0 0 75 125 A 80 80 0 0 0 82.585938 159 L 87 159 C 88.104569 159 89 158.10457 89 157 L 89 109 C 89 107.89543 88.104569 107 87 107 L 77.050781 107 z " />
|
||||
<path fill="#061884" d="M 77.289062 106 A 80 80 0 0 0 76.828125 108 L 87 108 C 87.564129 108 88 108.43587 88 109 L 88 157 C 88 157.56413 87.564129 158 87 158 L 82.125 158 A 80 80 0 0 0 83.0625 160 L 87 160 C 88.64501 160 90 158.64501 90 157 L 90 109 C 90 107.35499 88.64501 106 87 106 L 77.289062 106 z " />
|
||||
<path fill="white" d="M 223 91 C 221.89543 91 221 91.895431 221 93 L 221 157 C 221 158.10457 221.89543 159 223 159 L 227.41406 159 A 80 80 0 0 0 235 125 A 80 80 0 0 0 227.41406 91 L 223 91 z " />
|
||||
<path fill="#061884" d="M 223 90 C 221.35499 90 220 91.35499 220 93 L 220 157 C 220 158.64501 221.35499 160 223 160 L 226.9375 160 A 80 80 0 0 0 227.87695 158 L 223 158 C 222.43587 158 222 157.56413 222 157 L 222 93 C 222 92.435871 222.43587 92 223 92 L 227.875 92 A 80 80 0 0 0 226.9375 90 L 223 90 z " />
|
||||
<path fill="#196ef0" d="M 153 183 L 153 197 L 189.86914 197 A 80 80 0 0 0 195 194.28125 L 195 185 A 2 2 0 0 0 193 183 L 153 183 z "/>
|
||||
<path fill="white" d="M 113 181 C 111.89543 181 111 181.89543 111 183 L 111 191.8125 A 80 80 0 0 0 155 205 A 80 80 0 0 0 189.86914 197 L 166 197 L 150 181 L 113 181 z " />
|
||||
<path fill="#061884" d="M 113 180 C 111.35499 180 110 181.35499 110 183 L 110 191.14258 A 80 80 0 0 0 112 192.45898 L 112 183 C 112 182.43587 112.43587 182 113 182 L 149.58594 182 L 165.58594 198 L 187.72461 198 A 80 80 0 0 0 191.86328 196 L 166.41406 196 L 150.41406 180 L 113 180 z " />
|
||||
<path fill="#061884" d="m 149.18,133.69 v -3.48 a 14.36,14.36 0 0 1 1.74,-7.25 20.17,20.17 0 0 1 6.4,-6.17 25.87,25.87 0 0 0 5.68,-4.79 7,7 0 0 0 1.48,-4.34 4.13,4.13 0 0 0 -1.93,-3.62 9,9 0 0 0 -5.14,-1.3 24.94,24.94 0 0 0 -7.34,1.16 45.2,45.2 0 0 0 -7.78,3.31 l -5.37,-10.64 a 48.41,48.41 0 0 1 9.89,-4.21 40.25,40.25 0 0 1 11.67,-1.61 q 9.57,0 14.9,4.43 a 14.16,14.16 0 0 1 5.32,11.41 15.41,15.41 0 0 1 -2.55,9 30.38,30.38 0 0 1 -7.92,7.34 32.11,32.11 0 0 0 -5.23,4.37 5.91,5.91 0 0 0 -1.34,4 v 2.41 z m -1.61,15.12 q 0,-4.38 2.46,-6.12 a 10,10 0 0 1 5.95,-1.75 9.69,9.69 0 0 1 5.77,1.75 q 2.46,1.74 2.46,6.12 0,4.22 -2.46,6 a 9.42,9.42 0 0 1 -5.77,1.84 9.69,9.69 0 0 1 -5.95,-1.84 q -2.46,-1.81 -2.46,-6 z" />
|
||||
</svg>
|
After Width: | Height: | Size: 9.6 KiB |
9
plugins/DigitalLibrary/resources/images/update.svg
Normal file
9
plugins/DigitalLibrary/resources/images/update.svg
Normal file
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Artwork" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
|
||||
<path d="M12,19.6L4.4,12L7,9.4V12h2V6H3v2h2.6L3.7,9.9c-1.2,1.2-1.2,3.1,0,4.2l6.2,6.2c1.2,1.2,3.1,1.2,4.2,0l0.6-0.6l-1.4-1.4
|
||||
L12,19.6z"/>
|
||||
<path d="M20.3,9.9l-6.2-6.2c-1.2-1.2-3.1-1.2-4.2,0L9.3,4.3l1.4,1.4L12,4.4l7.6,7.6L17,14.6V12h-2v6h6v-2h-2.6l1.9-1.9
|
||||
C21.5,12.9,21.5,11.1,20.3,9.9z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 646 B |
159
plugins/DigitalLibrary/resources/qml/CreateNewProjectPopup.qml
Normal file
159
plugins/DigitalLibrary/resources/qml/CreateNewProjectPopup.qml
Normal file
|
@ -0,0 +1,159 @@
|
|||
// Copyright (C) 2021 Ultimaker B.V.
|
||||
|
||||
import QtQuick 2.10
|
||||
import QtQuick.Window 2.2
|
||||
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
|
||||
import QtQuick.Controls 2.3
|
||||
import QtQuick.Controls.Styles 1.4
|
||||
|
||||
import UM 1.2 as UM
|
||||
import Cura 1.6 as Cura
|
||||
|
||||
import DigitalFactory 1.0 as DF
|
||||
|
||||
|
||||
Popup
|
||||
{
|
||||
id: base
|
||||
|
||||
padding: UM.Theme.getSize("default_margin").width
|
||||
|
||||
closePolicy: Popup.CloseOnEscape
|
||||
focus: true
|
||||
modal: true
|
||||
background: Cura.RoundedRectangle
|
||||
{
|
||||
cornerSide: Cura.RoundedRectangle.Direction.All
|
||||
border.color: UM.Theme.getColor("lining")
|
||||
border.width: UM.Theme.getSize("default_lining").width
|
||||
radius: UM.Theme.getSize("default_radius").width
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
color: UM.Theme.getColor("main_background")
|
||||
}
|
||||
|
||||
Connections
|
||||
{
|
||||
target: manager
|
||||
|
||||
function onCreatingNewProjectStatusChanged(status)
|
||||
{
|
||||
if (status == DF.RetrievalStatus.Success)
|
||||
{
|
||||
base.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onOpened:
|
||||
{
|
||||
newProjectNameTextField.text = ""
|
||||
newProjectNameTextField.focus = true
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
id: createNewLibraryProjectLabel
|
||||
text: "Create new Library project"
|
||||
font: UM.Theme.getFont("medium")
|
||||
color: UM.Theme.getColor("small_button_text")
|
||||
anchors
|
||||
{
|
||||
top: parent.top
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
}
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
id: projectNameLabel
|
||||
text: "Project Name"
|
||||
font: UM.Theme.getFont("default")
|
||||
color: UM.Theme.getColor("text")
|
||||
anchors
|
||||
{
|
||||
top: createNewLibraryProjectLabel.bottom
|
||||
topMargin: UM.Theme.getSize("default_margin").width
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
}
|
||||
}
|
||||
|
||||
Cura.TextField
|
||||
{
|
||||
id: newProjectNameTextField
|
||||
width: parent.width
|
||||
anchors
|
||||
{
|
||||
top: projectNameLabel.bottom
|
||||
topMargin: UM.Theme.getSize("thin_margin").width
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
}
|
||||
validator: RegExpValidator
|
||||
{
|
||||
regExp: /^[^\\\/\*\?\|\[\]]{0,99}$/
|
||||
}
|
||||
|
||||
text: PrintInformation.jobName
|
||||
font: UM.Theme.getFont("default")
|
||||
placeholderText: "Enter a name for your new project."
|
||||
onAccepted:
|
||||
{
|
||||
if (verifyProjectCreationButton.enabled)
|
||||
{
|
||||
verifyProjectCreationButton.clicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
id: errorWhileCreatingProjectLabel
|
||||
text: manager.projectCreationErrorText
|
||||
width: parent.width
|
||||
wrapMode: Text.WordWrap
|
||||
font: UM.Theme.getFont("default")
|
||||
color: UM.Theme.getColor("error")
|
||||
visible: manager.creatingNewProjectStatus == DF.RetrievalStatus.Failed
|
||||
anchors
|
||||
{
|
||||
top: newProjectNameTextField.bottom
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
}
|
||||
}
|
||||
|
||||
Cura.SecondaryButton
|
||||
{
|
||||
id: cancelProjectCreationButton
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
|
||||
text: "Cancel"
|
||||
|
||||
onClicked:
|
||||
{
|
||||
base.close()
|
||||
}
|
||||
busy: false
|
||||
}
|
||||
|
||||
Cura.PrimaryButton
|
||||
{
|
||||
id: verifyProjectCreationButton
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
text: "Create"
|
||||
enabled: newProjectNameTextField.text.length >= 2 && !busy
|
||||
|
||||
onClicked:
|
||||
{
|
||||
manager.createLibraryProjectAndSetAsPreselected(newProjectNameTextField.text)
|
||||
}
|
||||
busy: manager.creatingNewProjectStatus == DF.RetrievalStatus.InProgress
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
// Copyright (C) 2021 Ultimaker B.V.
|
||||
|
||||
import QtQuick 2.10
|
||||
import QtQuick.Window 2.2
|
||||
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
|
||||
import QtQuick.Controls 2.3
|
||||
import QtQuick.Controls.Styles 1.4
|
||||
|
||||
import UM 1.2 as UM
|
||||
import Cura 1.6 as Cura
|
||||
|
||||
import DigitalFactory 1.0 as DF
|
||||
|
||||
Window
|
||||
{
|
||||
id: digitalFactoryOpenDialogBase
|
||||
title: "Open file from Library"
|
||||
|
||||
modality: Qt.ApplicationModal
|
||||
width: 800 * screenScaleFactor
|
||||
height: 600 * screenScaleFactor
|
||||
minimumWidth: 800 * screenScaleFactor
|
||||
minimumHeight: 600 * screenScaleFactor
|
||||
|
||||
Shortcut
|
||||
{
|
||||
sequence: "Esc"
|
||||
onActivated: digitalFactoryOpenDialogBase.close()
|
||||
}
|
||||
color: UM.Theme.getColor("main_background")
|
||||
|
||||
SelectProjectPage
|
||||
{
|
||||
visible: manager.selectedProjectIndex == -1
|
||||
createNewProjectButtonVisible: false
|
||||
}
|
||||
|
||||
OpenProjectFilesPage
|
||||
{
|
||||
visible: manager.selectedProjectIndex >= 0
|
||||
onOpenFilePressed: digitalFactoryOpenDialogBase.close()
|
||||
}
|
||||
|
||||
|
||||
BusyIndicator
|
||||
{
|
||||
// Shows up while Cura is waiting to receive the user's projects from the digital factory library
|
||||
id: retrievingProjectsBusyIndicator
|
||||
|
||||
anchors {
|
||||
verticalCenter: parent.verticalCenter
|
||||
horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
|
||||
width: parent.width / 4
|
||||
height: width
|
||||
visible: manager.retrievingProjectsStatus == DF.RetrievalStatus.InProgress
|
||||
running: visible
|
||||
palette.dark: UM.Theme.getColor("text")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
// Copyright (C) 2021 Ultimaker B.V.
|
||||
|
||||
import QtQuick 2.10
|
||||
import QtQuick.Window 2.2
|
||||
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
|
||||
import QtQuick.Controls 2.3
|
||||
import QtQuick.Controls.Styles 1.4
|
||||
|
||||
import UM 1.2 as UM
|
||||
import Cura 1.6 as Cura
|
||||
|
||||
import DigitalFactory 1.0 as DF
|
||||
|
||||
Window
|
||||
{
|
||||
id: digitalFactorySaveDialogBase
|
||||
title: "Save Cura project to Library"
|
||||
|
||||
modality: Qt.ApplicationModal
|
||||
width: 800 * screenScaleFactor
|
||||
height: 600 * screenScaleFactor
|
||||
minimumWidth: 800 * screenScaleFactor
|
||||
minimumHeight: 600 * screenScaleFactor
|
||||
|
||||
Shortcut
|
||||
{
|
||||
sequence: "Esc"
|
||||
onActivated: digitalFactorySaveDialogBase.close()
|
||||
}
|
||||
color: UM.Theme.getColor("main_background")
|
||||
|
||||
SelectProjectPage
|
||||
{
|
||||
visible: manager.selectedProjectIndex == -1
|
||||
createNewProjectButtonVisible: true
|
||||
}
|
||||
|
||||
SaveProjectFilesPage
|
||||
{
|
||||
visible: manager.selectedProjectIndex >= 0
|
||||
onSavePressed: digitalFactorySaveDialogBase.close()
|
||||
onSelectDifferentProjectPressed: manager.clearProjectSelection()
|
||||
}
|
||||
|
||||
|
||||
BusyIndicator
|
||||
{
|
||||
// Shows up while Cura is waiting to receive the user's projects from the digital factory library
|
||||
id: retrievingProjectsBusyIndicator
|
||||
|
||||
anchors {
|
||||
verticalCenter: parent.verticalCenter
|
||||
horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
|
||||
width: parent.width / 4
|
||||
height: width
|
||||
visible: manager.retrievingProjectsStatus == DF.RetrievalStatus.InProgress
|
||||
running: visible
|
||||
palette.dark: UM.Theme.getColor("text")
|
||||
}
|
||||
}
|
129
plugins/DigitalLibrary/resources/qml/LoadMoreProjectsCard.qml
Normal file
129
plugins/DigitalLibrary/resources/qml/LoadMoreProjectsCard.qml
Normal file
|
@ -0,0 +1,129 @@
|
|||
// Copyright (C) 2021 Ultimaker B.V.
|
||||
import QtQuick 2.10
|
||||
import QtQuick.Controls 2.3
|
||||
|
||||
import UM 1.2 as UM
|
||||
import Cura 1.6 as Cura
|
||||
|
||||
Cura.RoundedRectangle
|
||||
{
|
||||
id: base
|
||||
cornerSide: Cura.RoundedRectangle.Direction.All
|
||||
border.color: UM.Theme.getColor("lining")
|
||||
border.width: UM.Theme.getSize("default_lining").width
|
||||
radius: UM.Theme.getSize("default_radius").width
|
||||
signal clicked()
|
||||
property var hasMoreProjectsToLoad
|
||||
enabled: hasMoreProjectsToLoad
|
||||
color: UM.Theme.getColor("main_background")
|
||||
|
||||
MouseArea
|
||||
{
|
||||
id: cardMouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
}
|
||||
|
||||
Row
|
||||
{
|
||||
id: projectInformationRow
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
UM.RecolorImage
|
||||
{
|
||||
id: projectImage
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: UM.Theme.getSize("section").height
|
||||
height: width
|
||||
color: UM.Theme.getColor("text_link")
|
||||
source: "../images/arrow_down.svg"
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
id: displayNameLabel
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Load more projects"
|
||||
color: UM.Theme.getColor("text_link")
|
||||
font: UM.Theme.getFont("medium_bold")
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted:
|
||||
{
|
||||
cardMouseArea.clicked.connect(base.clicked)
|
||||
}
|
||||
|
||||
states:
|
||||
[
|
||||
State
|
||||
{
|
||||
name: "canLoadMoreProjectsAndHovered";
|
||||
when: base.hasMoreProjectsToLoad && cardMouseArea.containsMouse
|
||||
PropertyChanges
|
||||
{
|
||||
target: projectImage
|
||||
color: UM.Theme.getColor("text_link")
|
||||
source: "../images/arrow_down.svg"
|
||||
}
|
||||
PropertyChanges
|
||||
{
|
||||
target: displayNameLabel
|
||||
color: UM.Theme.getColor("text_link")
|
||||
text: "Load more projects"
|
||||
}
|
||||
PropertyChanges
|
||||
{
|
||||
target: base
|
||||
color: UM.Theme.getColor("action_button_hovered")
|
||||
}
|
||||
},
|
||||
|
||||
State
|
||||
{
|
||||
name: "canLoadMoreProjectsAndNotHovered";
|
||||
when: base.hasMoreProjectsToLoad && !cardMouseArea.containsMouse
|
||||
PropertyChanges
|
||||
{
|
||||
target: projectImage
|
||||
color: UM.Theme.getColor("text_link")
|
||||
source: "../images/arrow_down.svg"
|
||||
}
|
||||
PropertyChanges
|
||||
{
|
||||
target: displayNameLabel
|
||||
color: UM.Theme.getColor("text_link")
|
||||
text: "Load more projects"
|
||||
}
|
||||
PropertyChanges
|
||||
{
|
||||
target: base
|
||||
color: UM.Theme.getColor("main_background")
|
||||
}
|
||||
},
|
||||
|
||||
State
|
||||
{
|
||||
name: "noMoreProjectsToLoad"
|
||||
when: !base.hasMoreProjectsToLoad
|
||||
PropertyChanges
|
||||
{
|
||||
target: projectImage
|
||||
color: UM.Theme.getColor("action_button_disabled_text")
|
||||
source: "../images/update.svg"
|
||||
}
|
||||
PropertyChanges
|
||||
{
|
||||
target: displayNameLabel
|
||||
color: UM.Theme.getColor("action_button_disabled_text")
|
||||
text: "No more projects to load"
|
||||
}
|
||||
PropertyChanges
|
||||
{
|
||||
target: base
|
||||
color: UM.Theme.getColor("action_button_disabled")
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
203
plugins/DigitalLibrary/resources/qml/OpenProjectFilesPage.qml
Normal file
203
plugins/DigitalLibrary/resources/qml/OpenProjectFilesPage.qml
Normal file
|
@ -0,0 +1,203 @@
|
|||
// Copyright (C) 2021 Ultimaker B.V.
|
||||
|
||||
import QtQuick 2.10
|
||||
import QtQuick.Window 2.2
|
||||
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
|
||||
import QtQuick.Controls 2.3
|
||||
import QtQuick.Controls.Styles 1.4
|
||||
|
||||
import UM 1.2 as UM
|
||||
import Cura 1.6 as Cura
|
||||
|
||||
import DigitalFactory 1.0 as DF
|
||||
|
||||
|
||||
Item
|
||||
{
|
||||
id: base
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
|
||||
property var fileModel: manager.digitalFactoryFileModel
|
||||
|
||||
signal openFilePressed()
|
||||
signal selectDifferentProjectPressed()
|
||||
|
||||
anchors
|
||||
{
|
||||
fill: parent
|
||||
margins: UM.Theme.getSize("default_margin").width
|
||||
}
|
||||
|
||||
ProjectSummaryCard
|
||||
{
|
||||
id: projectSummaryCard
|
||||
|
||||
anchors.top: parent.top
|
||||
|
||||
property var selectedItem: manager.digitalFactoryProjectModel.getItem(manager.selectedProjectIndex)
|
||||
|
||||
imageSource: selectedItem.thumbnailUrl || "../images/placeholder.svg"
|
||||
projectNameText: selectedItem.displayName || ""
|
||||
projectUsernameText: selectedItem.username || ""
|
||||
projectLastUpdatedText: "Last updated: " + selectedItem.lastUpdated
|
||||
cardMouseAreaEnabled: false
|
||||
}
|
||||
|
||||
Rectangle
|
||||
{
|
||||
id: projectFilesContent
|
||||
width: parent.width
|
||||
anchors.top: projectSummaryCard.bottom
|
||||
anchors.topMargin: UM.Theme.getSize("default_margin").width
|
||||
anchors.bottom: selectDifferentProjectButton.top
|
||||
anchors.bottomMargin: UM.Theme.getSize("default_margin").width
|
||||
|
||||
color: UM.Theme.getColor("main_background")
|
||||
border.width: UM.Theme.getSize("default_lining").width
|
||||
border.color: UM.Theme.getColor("lining")
|
||||
|
||||
|
||||
Cura.TableView
|
||||
{
|
||||
id: filesTableView
|
||||
anchors.fill: parent
|
||||
model: manager.digitalFactoryFileModel
|
||||
visible: model.count != 0 && manager.retrievingFileStatus != DF.RetrievalStatus.InProgress
|
||||
selectionMode: OldControls.SelectionMode.SingleSelection
|
||||
onDoubleClicked:
|
||||
{
|
||||
manager.setSelectedFileIndices([row]);
|
||||
openFilesButton.clicked();
|
||||
}
|
||||
|
||||
OldControls.TableViewColumn
|
||||
{
|
||||
id: fileNameColumn
|
||||
role: "fileName"
|
||||
title: "Name"
|
||||
width: Math.round(filesTableView.width / 3)
|
||||
}
|
||||
|
||||
OldControls.TableViewColumn
|
||||
{
|
||||
id: usernameColumn
|
||||
role: "username"
|
||||
title: "Uploaded by"
|
||||
width: Math.round(filesTableView.width / 3)
|
||||
}
|
||||
|
||||
OldControls.TableViewColumn
|
||||
{
|
||||
role: "uploadedAt"
|
||||
title: "Uploaded at"
|
||||
}
|
||||
|
||||
Connections
|
||||
{
|
||||
target: filesTableView.selection
|
||||
function onSelectionChanged()
|
||||
{
|
||||
let newSelection = [];
|
||||
filesTableView.selection.forEach(function(rowIndex) { newSelection.push(rowIndex); });
|
||||
manager.setSelectedFileIndices(newSelection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
id: emptyProjectLabel
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Select a project to view its files."
|
||||
font: UM.Theme.getFont("default")
|
||||
color: UM.Theme.getColor("setting_category_text")
|
||||
|
||||
Connections
|
||||
{
|
||||
target: manager
|
||||
function onSelectedProjectIndexChanged(newProjectIndex)
|
||||
{
|
||||
emptyProjectLabel.visible = (newProjectIndex == -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
id: noFilesInProjectLabel
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: (manager.digitalFactoryFileModel.count == 0 && !emptyProjectLabel.visible && !retrievingFilesBusyIndicator.visible)
|
||||
text: "No supported files in this project."
|
||||
font: UM.Theme.getFont("default")
|
||||
color: UM.Theme.getColor("setting_category_text")
|
||||
}
|
||||
|
||||
BusyIndicator
|
||||
{
|
||||
// Shows up while Cura is waiting to receive the files of a project from the digital factory library
|
||||
id: retrievingFilesBusyIndicator
|
||||
|
||||
anchors
|
||||
{
|
||||
verticalCenter: parent.verticalCenter
|
||||
horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
|
||||
width: parent.width / 4
|
||||
height: width
|
||||
visible: manager.retrievingFilesStatus == DF.RetrievalStatus.InProgress
|
||||
running: visible
|
||||
palette.dark: UM.Theme.getColor("text")
|
||||
}
|
||||
|
||||
Connections
|
||||
{
|
||||
target: manager.digitalFactoryFileModel
|
||||
|
||||
function onItemsChanged()
|
||||
{
|
||||
// Make sure no files are selected when the file model changes
|
||||
filesTableView.currentRow = -1
|
||||
filesTableView.selection.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
Cura.SecondaryButton
|
||||
{
|
||||
id: selectDifferentProjectButton
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
text: "Change Library project"
|
||||
|
||||
onClicked:
|
||||
{
|
||||
manager.clearProjectSelection()
|
||||
}
|
||||
busy: false
|
||||
}
|
||||
|
||||
Cura.PrimaryButton
|
||||
{
|
||||
id: openFilesButton
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
text: "Open"
|
||||
enabled: filesTableView.selection.count > 0
|
||||
onClicked:
|
||||
{
|
||||
manager.openSelectedFiles()
|
||||
}
|
||||
busy: false
|
||||
}
|
||||
|
||||
Component.onCompleted:
|
||||
{
|
||||
openFilesButton.clicked.connect(base.openFilePressed)
|
||||
selectDifferentProjectButton.clicked.connect(base.selectDifferentProjectPressed)
|
||||
}
|
||||
}
|
92
plugins/DigitalLibrary/resources/qml/ProjectSummaryCard.qml
Normal file
92
plugins/DigitalLibrary/resources/qml/ProjectSummaryCard.qml
Normal file
|
@ -0,0 +1,92 @@
|
|||
// Copyright (C) 2021 Ultimaker B.V.
|
||||
import QtQuick 2.10
|
||||
import QtQuick.Controls 2.3
|
||||
|
||||
import UM 1.2 as UM
|
||||
import Cura 1.6 as Cura
|
||||
|
||||
Cura.RoundedRectangle
|
||||
{
|
||||
id: base
|
||||
width: parent.width
|
||||
height: projectImage.height + 2 * UM.Theme.getSize("default_margin").width
|
||||
cornerSide: Cura.RoundedRectangle.Direction.All
|
||||
border.color: UM.Theme.getColor("lining")
|
||||
border.width: UM.Theme.getSize("default_lining").width
|
||||
radius: UM.Theme.getSize("default_radius").width
|
||||
color: UM.Theme.getColor("main_background")
|
||||
signal clicked()
|
||||
property alias imageSource: projectImage.source
|
||||
property alias projectNameText: displayNameLabel.text
|
||||
property alias projectUsernameText: usernameLabel.text
|
||||
property alias projectLastUpdatedText: lastUpdatedLabel.text
|
||||
property alias cardMouseAreaEnabled: cardMouseArea.enabled
|
||||
|
||||
onVisibleChanged: color = UM.Theme.getColor("main_background")
|
||||
|
||||
MouseArea
|
||||
{
|
||||
id: cardMouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onEntered: base.color = UM.Theme.getColor("action_button_hovered")
|
||||
onExited: base.color = UM.Theme.getColor("main_background")
|
||||
onClicked: base.clicked()
|
||||
}
|
||||
Row
|
||||
{
|
||||
id: projectInformationRow
|
||||
width: parent.width
|
||||
padding: UM.Theme.getSize("default_margin").width
|
||||
spacing: UM.Theme.getSize("default_margin").width
|
||||
|
||||
Image
|
||||
{
|
||||
id: projectImage
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: UM.Theme.getSize("toolbox_thumbnail_small").width
|
||||
height: Math.round(width * 3/4)
|
||||
sourceSize.width: width
|
||||
sourceSize.height: height
|
||||
fillMode: Image.PreserveAspectFit
|
||||
mipmap: true
|
||||
}
|
||||
Column
|
||||
{
|
||||
id: projectLabelsColumn
|
||||
height: projectImage.height
|
||||
width: parent.width - x - UM.Theme.getSize("default_margin").width
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Label
|
||||
{
|
||||
id: displayNameLabel
|
||||
width: parent.width
|
||||
height: Math.round(parent.height / 3)
|
||||
elide: Text.ElideRight
|
||||
color: UM.Theme.getColor("text")
|
||||
font: UM.Theme.getFont("default_bold")
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
id: usernameLabel
|
||||
width: parent.width
|
||||
height: Math.round(parent.height / 3)
|
||||
elide: Text.ElideRight
|
||||
color: UM.Theme.getColor("small_button_text")
|
||||
font: UM.Theme.getFont("default")
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
id: lastUpdatedLabel
|
||||
width: parent.width
|
||||
height: Math.round(parent.height / 3)
|
||||
elide: Text.ElideRight
|
||||
color: UM.Theme.getColor("small_button_text")
|
||||
font: UM.Theme.getFont("default")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
259
plugins/DigitalLibrary/resources/qml/SaveProjectFilesPage.qml
Normal file
259
plugins/DigitalLibrary/resources/qml/SaveProjectFilesPage.qml
Normal file
|
@ -0,0 +1,259 @@
|
|||
// Copyright (C) 2021 Ultimaker B.V.
|
||||
|
||||
import QtQuick 2.10
|
||||
import QtQuick.Window 2.2
|
||||
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
|
||||
import QtQuick.Controls 2.3
|
||||
import QtQuick.Controls.Styles 1.4
|
||||
|
||||
import UM 1.2 as UM
|
||||
import Cura 1.6 as Cura
|
||||
|
||||
import DigitalFactory 1.0 as DF
|
||||
|
||||
|
||||
Item
|
||||
{
|
||||
id: base
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
property var fileModel: manager.digitalFactoryFileModel
|
||||
|
||||
signal savePressed()
|
||||
signal selectDifferentProjectPressed()
|
||||
|
||||
anchors
|
||||
{
|
||||
fill: parent
|
||||
margins: UM.Theme.getSize("default_margin").width
|
||||
}
|
||||
|
||||
ProjectSummaryCard
|
||||
{
|
||||
id: projectSummaryCard
|
||||
|
||||
anchors.top: parent.top
|
||||
|
||||
property var selectedItem: manager.digitalFactoryProjectModel.getItem(manager.selectedProjectIndex)
|
||||
|
||||
imageSource: selectedItem.thumbnailUrl || "../images/placeholder.svg"
|
||||
projectNameText: selectedItem.displayName || ""
|
||||
projectUsernameText: selectedItem.username || ""
|
||||
projectLastUpdatedText: "Last updated: " + selectedItem.lastUpdated
|
||||
cardMouseAreaEnabled: false
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
id: fileNameLabel
|
||||
anchors.top: projectSummaryCard.bottom
|
||||
anchors.topMargin: UM.Theme.getSize("default_margin").height
|
||||
text: "Cura project name"
|
||||
font: UM.Theme.getFont("medium")
|
||||
color: UM.Theme.getColor("text")
|
||||
}
|
||||
|
||||
|
||||
Cura.TextField
|
||||
{
|
||||
id: dfFilenameTextfield
|
||||
width: parent.width
|
||||
anchors.left: parent.left
|
||||
anchors.top: fileNameLabel.bottom
|
||||
anchors.topMargin: UM.Theme.getSize("thin_margin").height
|
||||
validator: RegExpValidator
|
||||
{
|
||||
regExp: /^[\w\-\. ()]{0,255}$/
|
||||
}
|
||||
|
||||
text: PrintInformation.jobName
|
||||
font: UM.Theme.getFont("medium")
|
||||
placeholderText: "Enter the name of the file."
|
||||
onAccepted: { if (saveButton.enabled) {saveButton.clicked()}}
|
||||
}
|
||||
|
||||
|
||||
Rectangle
|
||||
{
|
||||
id: projectFilesContent
|
||||
width: parent.width
|
||||
anchors.top: dfFilenameTextfield.bottom
|
||||
anchors.topMargin: UM.Theme.getSize("wide_margin").height
|
||||
anchors.bottom: selectDifferentProjectButton.top
|
||||
anchors.bottomMargin: UM.Theme.getSize("default_margin").width
|
||||
|
||||
color: UM.Theme.getColor("main_background")
|
||||
border.width: UM.Theme.getSize("default_lining").width
|
||||
border.color: UM.Theme.getColor("lining")
|
||||
|
||||
|
||||
Cura.TableView
|
||||
{
|
||||
id: filesTableView
|
||||
anchors.fill: parent
|
||||
model: manager.digitalFactoryFileModel
|
||||
visible: model.count != 0 && manager.retrievingFileStatus != DF.RetrievalStatus.InProgress
|
||||
selectionMode: OldControls.SelectionMode.NoSelection
|
||||
|
||||
OldControls.TableViewColumn
|
||||
{
|
||||
id: fileNameColumn
|
||||
role: "fileName"
|
||||
title: "@tableViewColumn:title", "Name"
|
||||
width: Math.round(filesTableView.width / 3)
|
||||
}
|
||||
|
||||
OldControls.TableViewColumn
|
||||
{
|
||||
id: usernameColumn
|
||||
role: "username"
|
||||
title: "Uploaded by"
|
||||
width: Math.round(filesTableView.width / 3)
|
||||
}
|
||||
|
||||
OldControls.TableViewColumn
|
||||
{
|
||||
role: "uploadedAt"
|
||||
title: "Uploaded at"
|
||||
}
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
id: emptyProjectLabel
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Select a project to view its files."
|
||||
font: UM.Theme.getFont("default")
|
||||
color: UM.Theme.getColor("setting_category_text")
|
||||
|
||||
Connections
|
||||
{
|
||||
target: manager
|
||||
function onSelectedProjectIndexChanged()
|
||||
{
|
||||
emptyProjectLabel.visible = (manager.newProjectIndex == -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
id: noFilesInProjectLabel
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: (manager.digitalFactoryFileModel.count == 0 && !emptyProjectLabel.visible && !retrievingFilesBusyIndicator.visible)
|
||||
text: "No supported files in this project."
|
||||
font: UM.Theme.getFont("default")
|
||||
color: UM.Theme.getColor("setting_category_text")
|
||||
}
|
||||
|
||||
BusyIndicator
|
||||
{
|
||||
// Shows up while Cura is waiting to receive the files of a project from the digital factory library
|
||||
id: retrievingFilesBusyIndicator
|
||||
|
||||
anchors
|
||||
{
|
||||
verticalCenter: parent.verticalCenter
|
||||
horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
|
||||
width: parent.width / 4
|
||||
height: width
|
||||
visible: manager.retrievingFilesStatus == DF.RetrievalStatus.InProgress
|
||||
running: visible
|
||||
palette.dark: UM.Theme.getColor("text")
|
||||
}
|
||||
|
||||
Connections
|
||||
{
|
||||
target: manager.digitalFactoryFileModel
|
||||
|
||||
function onItemsChanged()
|
||||
{
|
||||
// Make sure no files are selected when the file model changes
|
||||
filesTableView.currentRow = -1
|
||||
filesTableView.selection.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
Cura.SecondaryButton
|
||||
{
|
||||
id: selectDifferentProjectButton
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
text: "Change Library project"
|
||||
|
||||
onClicked:
|
||||
{
|
||||
manager.selectedProjectIndex = -1
|
||||
}
|
||||
busy: false
|
||||
}
|
||||
|
||||
Cura.PrimaryButton
|
||||
{
|
||||
id: saveButton
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
text: "Save"
|
||||
enabled: (asProjectCheckbox.checked || asSlicedCheckbox.checked) && dfFilenameTextfield.text.length >= 1
|
||||
|
||||
onClicked:
|
||||
{
|
||||
let saveAsFormats = [];
|
||||
if (asProjectCheckbox.checked)
|
||||
{
|
||||
saveAsFormats.push("3mf");
|
||||
}
|
||||
if (asSlicedCheckbox.checked)
|
||||
{
|
||||
saveAsFormats.push("ufp");
|
||||
}
|
||||
manager.saveFileToSelectedProject(dfFilenameTextfield.text, saveAsFormats);
|
||||
}
|
||||
busy: false
|
||||
}
|
||||
|
||||
Row
|
||||
{
|
||||
|
||||
id: saveAsFormatRow
|
||||
anchors.verticalCenter: saveButton.verticalCenter
|
||||
anchors.right: saveButton.left
|
||||
anchors.rightMargin: UM.Theme.getSize("thin_margin").height
|
||||
width: childrenRect.width
|
||||
spacing: UM.Theme.getSize("default_margin").width
|
||||
|
||||
Cura.CheckBox
|
||||
{
|
||||
id: asProjectCheckbox
|
||||
height: UM.Theme.getSize("checkbox").height
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: true
|
||||
text: "Save Cura project"
|
||||
font: UM.Theme.getFont("medium")
|
||||
}
|
||||
|
||||
Cura.CheckBox
|
||||
{
|
||||
id: asSlicedCheckbox
|
||||
height: UM.Theme.getSize("checkbox").height
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
enabled: UM.Backend.state == UM.Backend.Done
|
||||
checked: UM.Backend.state == UM.Backend.Done
|
||||
text: "Save print file"
|
||||
font: UM.Theme.getFont("medium")
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted:
|
||||
{
|
||||
saveButton.clicked.connect(base.savePressed)
|
||||
selectDifferentProjectButton.clicked.connect(base.selectDifferentProjectPressed)
|
||||
}
|
||||
}
|
231
plugins/DigitalLibrary/resources/qml/SelectProjectPage.qml
Normal file
231
plugins/DigitalLibrary/resources/qml/SelectProjectPage.qml
Normal file
|
@ -0,0 +1,231 @@
|
|||
// Copyright (C) 2021 Ultimaker B.V.
|
||||
// Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import QtQuick 2.10
|
||||
import QtQuick.Window 2.2
|
||||
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
|
||||
import QtQuick.Controls 2.3
|
||||
import QtQuick.Controls.Styles 1.4
|
||||
import QtQuick.Layouts 1.1
|
||||
|
||||
import UM 1.2 as UM
|
||||
import Cura 1.6 as Cura
|
||||
|
||||
import DigitalFactory 1.0 as DF
|
||||
|
||||
|
||||
Item
|
||||
{
|
||||
id: base
|
||||
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
property bool createNewProjectButtonVisible: true
|
||||
|
||||
anchors
|
||||
{
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
margins: UM.Theme.getSize("default_margin").width
|
||||
}
|
||||
|
||||
RowLayout
|
||||
{
|
||||
id: headerRow
|
||||
|
||||
anchors
|
||||
{
|
||||
top: parent.top
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
}
|
||||
height: childrenRect.height
|
||||
spacing: UM.Theme.getSize("default_margin").width
|
||||
|
||||
Cura.TextField
|
||||
{
|
||||
id: searchBar
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: createNewProjectButton.height
|
||||
|
||||
onTextEdited: manager.projectFilter = text //Update the search filter when editing this text field.
|
||||
|
||||
leftIcon: UM.Theme.getIcon("Magnifier")
|
||||
placeholderText: "Search"
|
||||
}
|
||||
|
||||
Cura.SecondaryButton
|
||||
{
|
||||
id: createNewProjectButton
|
||||
|
||||
text: "New Library project"
|
||||
visible: createNewProjectButtonVisible && manager.userAccountCanCreateNewLibraryProject && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed)
|
||||
|
||||
onClicked:
|
||||
{
|
||||
createNewProjectPopup.open()
|
||||
}
|
||||
busy: manager.creatingNewProjectStatus == DF.RetrievalStatus.InProgress
|
||||
}
|
||||
|
||||
|
||||
Cura.SecondaryButton
|
||||
{
|
||||
id: upgradePlanButton
|
||||
|
||||
text: "Upgrade plan"
|
||||
iconSource: UM.Theme.getIcon("LinkExternal")
|
||||
visible: createNewProjectButtonVisible && !manager.userAccountCanCreateNewLibraryProject && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed)
|
||||
tooltip: "You have reached the maximum number of projects allowed by your subscription. Please upgrade to the Professional subscription to create more projects."
|
||||
tooltipWidth: parent.width * 0.5
|
||||
|
||||
onClicked: Qt.openUrlExternally("https://ultimaker.com/software/ultimaker-essentials/sign-up-cura?utm_source=cura&utm_medium=software&utm_campaign=lib-max")
|
||||
}
|
||||
}
|
||||
|
||||
Item
|
||||
{
|
||||
id: noLibraryProjectsContainer
|
||||
anchors
|
||||
{
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
}
|
||||
visible: manager.digitalFactoryProjectModel.count == 0 && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed)
|
||||
|
||||
Column
|
||||
{
|
||||
anchors.centerIn: parent
|
||||
spacing: UM.Theme.getSize("thin_margin").height
|
||||
Image
|
||||
{
|
||||
id: digitalFactoryImage
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
source: searchBar.text === "" ? "../images/digital_factory.svg" : "../images/projects_not_found.svg"
|
||||
fillMode: Image.PreserveAspectFit
|
||||
width: parent.width - 2 * UM.Theme.getSize("thick_margin").width
|
||||
}
|
||||
|
||||
Label
|
||||
{
|
||||
id: noLibraryProjectsLabel
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: searchBar.text === "" ? "It appears that you don't have any projects in the Library yet." : "No projects found that match the search query."
|
||||
font: UM.Theme.getFont("medium")
|
||||
color: UM.Theme.getColor("text")
|
||||
}
|
||||
|
||||
Cura.TertiaryButton
|
||||
{
|
||||
id: visitDigitalLibraryButton
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: "Visit Digital Library"
|
||||
onClicked: Qt.openUrlExternally(CuraApplication.ultimakerDigitalFactoryUrl + "/app/library")
|
||||
visible: searchBar.text === "" //Show the link to Digital Library when there are no projects in the user's Library.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item
|
||||
{
|
||||
id: projectListContainer
|
||||
anchors
|
||||
{
|
||||
top: headerRow.bottom
|
||||
topMargin: UM.Theme.getSize("default_margin").height
|
||||
bottom: parent.bottom
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
}
|
||||
visible: manager.digitalFactoryProjectModel.count > 0
|
||||
|
||||
// Use a flickable and a column with a repeater instead of a ListView in a ScrollView, because the ScrollView cannot
|
||||
// have additional children (aside from the view inside it), which wouldn't allow us to add the LoadMoreProjectsCard
|
||||
// in it.
|
||||
Flickable
|
||||
{
|
||||
id: flickableView
|
||||
clip: true
|
||||
contentWidth: parent.width
|
||||
contentHeight: projectsListView.implicitHeight
|
||||
anchors.fill: parent
|
||||
|
||||
ScrollBar.vertical: ScrollBar
|
||||
{
|
||||
// Vertical ScrollBar, styled similarly to the scrollBar in the settings panel
|
||||
id: verticalScrollBar
|
||||
visible: flickableView.contentHeight > flickableView.height
|
||||
|
||||
background: Rectangle
|
||||
{
|
||||
implicitWidth: UM.Theme.getSize("scrollbar").width
|
||||
radius: Math.round(implicitWidth / 2)
|
||||
color: UM.Theme.getColor("scrollbar_background")
|
||||
}
|
||||
|
||||
contentItem: Rectangle
|
||||
{
|
||||
id: scrollViewHandle
|
||||
implicitWidth: UM.Theme.getSize("scrollbar").width
|
||||
radius: Math.round(implicitWidth / 2)
|
||||
|
||||
color: verticalScrollBar.pressed ? UM.Theme.getColor("scrollbar_handle_down") : verticalScrollBar.hovered ? UM.Theme.getColor("scrollbar_handle_hover") : UM.Theme.getColor("scrollbar_handle")
|
||||
Behavior on color { ColorAnimation { duration: 50; } }
|
||||
}
|
||||
}
|
||||
|
||||
Column
|
||||
{
|
||||
id: projectsListView
|
||||
width: verticalScrollBar.visible ? parent.width - verticalScrollBar.width - UM.Theme.getSize("default_margin").width : parent.width
|
||||
anchors.top: parent.top
|
||||
spacing: UM.Theme.getSize("narrow_margin").width
|
||||
|
||||
Repeater
|
||||
{
|
||||
model: manager.digitalFactoryProjectModel
|
||||
delegate: ProjectSummaryCard
|
||||
{
|
||||
id: projectSummaryCard
|
||||
imageSource: model.thumbnailUrl || "../images/placeholder.svg"
|
||||
projectNameText: model.displayName
|
||||
projectUsernameText: model.username
|
||||
projectLastUpdatedText: "Last updated: " + model.lastUpdated
|
||||
|
||||
onClicked:
|
||||
{
|
||||
manager.selectedProjectIndex = index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LoadMoreProjectsCard
|
||||
{
|
||||
id: loadMoreProjectsCard
|
||||
height: UM.Theme.getSize("toolbox_thumbnail_small").height
|
||||
width: parent.width
|
||||
visible: manager.digitalFactoryProjectModel.count > 0
|
||||
hasMoreProjectsToLoad: manager.hasMoreProjectsToLoad
|
||||
|
||||
onClicked:
|
||||
{
|
||||
manager.loadMoreProjects()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CreateNewProjectPopup
|
||||
{
|
||||
id: createNewProjectPopup
|
||||
width: 400 * screenScaleFactor
|
||||
height: 220 * screenScaleFactor
|
||||
x: Math.round((parent.width - width) / 2)
|
||||
y: Math.round((parent.height - height) / 2)
|
||||
}
|
||||
}
|
74
plugins/DigitalLibrary/src/BaseModel.py
Normal file
74
plugins/DigitalLibrary/src/BaseModel.py
Normal file
|
@ -0,0 +1,74 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import TypeVar, Dict, List, Any, Type, Union
|
||||
|
||||
|
||||
# Type variable used in the parse methods below, which should be a subclass of BaseModel.
|
||||
T = TypeVar("T", bound="BaseModel")
|
||||
|
||||
|
||||
class BaseModel:
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
self.__dict__.update(kwargs)
|
||||
self.validate()
|
||||
|
||||
# Validates the model, raising an exception if the model is invalid.
|
||||
def validate(self) -> None:
|
||||
pass
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Checks whether the two models are equal.
|
||||
|
||||
:param other: The other model.
|
||||
:return: True if they are equal, False if they are different.
|
||||
"""
|
||||
return type(self) == type(other) and self.toDict() == other.toDict()
|
||||
|
||||
def __ne__(self, other) -> bool:
|
||||
"""Checks whether the two models are different.
|
||||
|
||||
:param other: The other model.
|
||||
:return: True if they are different, False if they are the same.
|
||||
"""
|
||||
return type(self) != type(other) or self.toDict() != other.toDict()
|
||||
|
||||
def toDict(self) -> Dict[str, Any]:
|
||||
"""Converts the model into a serializable dictionary"""
|
||||
|
||||
return self.__dict__
|
||||
|
||||
@staticmethod
|
||||
def parseModel(model_class: Type[T], values: Union[T, Dict[str, Any]]) -> T:
|
||||
"""Parses a single model.
|
||||
|
||||
:param model_class: The model class.
|
||||
:param values: The value of the model, which is usually a dictionary, but may also be already parsed.
|
||||
:return: An instance of the model_class given.
|
||||
"""
|
||||
if isinstance(values, dict):
|
||||
return model_class(**values)
|
||||
return values
|
||||
|
||||
@classmethod
|
||||
def parseModels(cls, model_class: Type[T], values: List[Union[T, Dict[str, Any]]]) -> List[T]:
|
||||
"""Parses a list of models.
|
||||
|
||||
:param model_class: The model class.
|
||||
:param values: The value of the list. Each value is usually a dictionary, but may also be already parsed.
|
||||
:return: A list of instances of the model_class given.
|
||||
"""
|
||||
return [cls.parseModel(model_class, value) for value in values]
|
||||
|
||||
@staticmethod
|
||||
def parseDate(date: Union[str, datetime]) -> datetime:
|
||||
"""Parses the given date string.
|
||||
|
||||
:param date: The date to parse.
|
||||
:return: The parsed date.
|
||||
"""
|
||||
if isinstance(date, datetime):
|
||||
return date
|
||||
return datetime.strptime(date, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc)
|
31
plugins/DigitalLibrary/src/CloudError.py
Normal file
31
plugins/DigitalLibrary/src/CloudError.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Dict, Optional, Any
|
||||
|
||||
from .BaseModel import BaseModel
|
||||
|
||||
|
||||
class CloudError(BaseModel):
|
||||
"""Class representing errors generated by the servers, according to the JSON-API standard."""
|
||||
|
||||
def __init__(self, id: str, code: str, title: str, http_status: str, detail: Optional[str] = None,
|
||||
meta: Optional[Dict[str, Any]] = None, **kwargs) -> None:
|
||||
"""Creates a new error object.
|
||||
|
||||
:param id: Unique identifier for this particular occurrence of the problem.
|
||||
:param title: A short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence
|
||||
of the problem, except for purposes of localization.
|
||||
:param code: An application-specific error code, expressed as a string value.
|
||||
:param detail: A human-readable explanation specific to this occurrence of the problem. Like title, this field's
|
||||
value can be localized.
|
||||
:param http_status: The HTTP status code applicable to this problem, converted to string.
|
||||
:param meta: Non-standard meta-information about the error, depending on the error code.
|
||||
"""
|
||||
|
||||
self.id = id
|
||||
self.code = code
|
||||
self.http_status = http_status
|
||||
self.title = title
|
||||
self.detail = detail
|
||||
self.meta = meta
|
||||
super().__init__(**kwargs)
|
373
plugins/DigitalLibrary/src/DFFileExportAndUploadManager.py
Normal file
373
plugins/DigitalLibrary/src/DFFileExportAndUploadManager.py
Normal file
|
@ -0,0 +1,373 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# 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
|
||||
from PyQt5.QtGui import QDesktopServices
|
||||
from PyQt5.QtNetwork import QNetworkReply
|
||||
|
||||
from UM.FileHandler.FileHandler import FileHandler
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from .DFLibraryFileUploadRequest import DFLibraryFileUploadRequest
|
||||
from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse
|
||||
from .DFPrintJobUploadRequest import DFPrintJobUploadRequest
|
||||
from .DFPrintJobUploadResponse import DFPrintJobUploadResponse
|
||||
from .DigitalFactoryApiClient import DigitalFactoryApiClient
|
||||
from .ExportFileJob import ExportFileJob
|
||||
|
||||
|
||||
class DFFileExportAndUploadManager:
|
||||
"""
|
||||
Class responsible for exporting the scene and uploading the exported data to the Digital Factory Library. Since 3mf
|
||||
and UFP files may need to be uploaded at the same time, this class keeps a single progress and success message for
|
||||
both files and updates those messages according to the progress of both the file job uploads.
|
||||
"""
|
||||
def __init__(self, file_handlers: Dict[str, FileHandler],
|
||||
nodes: List[SceneNode],
|
||||
library_project_id: str,
|
||||
library_project_name: str,
|
||||
file_name: str,
|
||||
formats: List[str],
|
||||
on_upload_error: Callable[[], Any],
|
||||
on_upload_success: Callable[[], Any],
|
||||
on_upload_finished: Callable[[], Any] ,
|
||||
on_upload_progress: Callable[[int], Any]) -> None:
|
||||
|
||||
self._file_handlers = file_handlers # type: Dict[str, FileHandler]
|
||||
self._nodes = nodes # type: List[SceneNode]
|
||||
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)))
|
||||
|
||||
# Functions of the parent class that should be called based on the upload process output
|
||||
self._on_upload_error = on_upload_error
|
||||
self._on_upload_success = on_upload_success
|
||||
self._on_upload_finished = on_upload_finished
|
||||
self._on_upload_progress = on_upload_progress
|
||||
|
||||
# Lock used for updating the progress message (since the progress is changed by two parallel upload jobs) or
|
||||
# show the success message (once both upload jobs are done)
|
||||
self._message_lock = threading.Lock()
|
||||
|
||||
self._file_upload_job_metadata = self.initializeFileUploadJobMetadata() # type: Dict[str, Dict[str, Any]]
|
||||
|
||||
self.progress_message = Message(
|
||||
title = "Uploading...",
|
||||
text = "Uploading files to '{}'".format(self._library_project_name),
|
||||
progress = -1,
|
||||
lifetime = 0,
|
||||
dismissable = False,
|
||||
use_inactivity_timer = False
|
||||
)
|
||||
|
||||
self._generic_success_message = Message(
|
||||
text = "Your {} uploaded to '{}'.".format("file was" if len(self._file_upload_job_metadata) <= 1 else "files were", self._library_project_name),
|
||||
title = "Upload successful",
|
||||
lifetime = 0,
|
||||
)
|
||||
self._generic_success_message.addAction(
|
||||
"open_df_project",
|
||||
"Open project",
|
||||
"open-folder", "Open the project containing the file in Digital Library"
|
||||
)
|
||||
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.
|
||||
|
||||
It can now be sent over the Digital Factory API.
|
||||
"""
|
||||
if not job.getOutput():
|
||||
self._onJobExportError(job.getFileName())
|
||||
return
|
||||
self._file_upload_job_metadata[job.getFileName()]["export_job_output"] = job.getOutput()
|
||||
request = DFLibraryFileUploadRequest(
|
||||
content_type = job.getMimeType(),
|
||||
file_name = job.getFileName(),
|
||||
file_size = len(job.getOutput()),
|
||||
library_project_id = self._library_project_id
|
||||
)
|
||||
self._api.requestUpload3MF(request, on_finished = self._uploadFileData, on_error = self._onRequestUploadCuraProjectFileFailed)
|
||||
|
||||
def _onPrintFileExported(self, job: ExportFileJob) -> None:
|
||||
"""Handler for when the DF Library print job file (UFP) has been created locally.
|
||||
|
||||
It can now be sent over the Digital Factory API.
|
||||
"""
|
||||
if not job.getOutput():
|
||||
self._onJobExportError(job.getFileName())
|
||||
return
|
||||
self._file_upload_job_metadata[job.getFileName()]["export_job_output"] = job.getOutput()
|
||||
request = DFPrintJobUploadRequest(
|
||||
content_type = job.getMimeType(),
|
||||
job_name = job.getFileName(),
|
||||
file_size = len(job.getOutput()),
|
||||
library_project_id = self._library_project_id
|
||||
)
|
||||
self._api.requestUploadUFP(request, on_finished = self._uploadFileData, on_error = self._onRequestUploadPrintFileFailed)
|
||||
|
||||
def _uploadFileData(self, file_upload_response: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse]) -> None:
|
||||
"""Uploads the exported file data after the file or print job upload has been registered at the Digital Factory
|
||||
Library API.
|
||||
|
||||
:param file_upload_response: The response received from the Digital Factory Library API.
|
||||
"""
|
||||
if isinstance(file_upload_response, DFLibraryFileUploadResponse):
|
||||
file_name = file_upload_response.file_name
|
||||
elif isinstance(file_upload_response, DFPrintJobUploadResponse):
|
||||
file_name = file_upload_response.job_name if file_upload_response.job_name is not None else ""
|
||||
else:
|
||||
Logger.log("e", "Wrong response type received. Aborting uploading file to the Digital Library")
|
||||
return
|
||||
with self._message_lock:
|
||||
self.progress_message.show()
|
||||
self._file_upload_job_metadata[file_name]["file_upload_response"] = file_upload_response
|
||||
job_output = self._file_upload_job_metadata[file_name]["export_job_output"]
|
||||
|
||||
with self._message_lock:
|
||||
self._file_upload_job_metadata[file_name]["upload_status"] = "uploading"
|
||||
|
||||
self._api.uploadExportedFileData(file_upload_response,
|
||||
job_output,
|
||||
on_finished = self._onFileUploadFinished,
|
||||
on_success = self._onUploadSuccess,
|
||||
on_progress = self._onUploadProgress,
|
||||
on_error = self._onUploadError)
|
||||
|
||||
def _onUploadProgress(self, filename: str, progress: int) -> None:
|
||||
"""
|
||||
Updates the progress message according to the total progress of the two files and displays it to the user. It is
|
||||
made thread-safe with a lock, since the progress can be updated by two separate upload jobs
|
||||
|
||||
:param filename: The name of the file for which we have progress (including the extension).
|
||||
:param progress: The progress percentage
|
||||
"""
|
||||
with self._message_lock:
|
||||
self._file_upload_job_metadata[filename]["upload_progress"] = progress
|
||||
self._file_upload_job_metadata[filename]["upload_status"] = "uploading"
|
||||
total_progress = self.getTotalProgress()
|
||||
self.progress_message.setProgress(total_progress)
|
||||
self.progress_message.show()
|
||||
self._on_upload_progress(progress)
|
||||
|
||||
def _onUploadSuccess(self, filename: str) -> None:
|
||||
"""
|
||||
Sets the upload status to success and the progress of the file with the given filename to 100%. This function is
|
||||
should be called only if the file has uploaded all of its data successfully (i.e. no error occurred during the
|
||||
upload process).
|
||||
|
||||
:param filename: The name of the file that was uploaded successfully (including the extension).
|
||||
"""
|
||||
with self._message_lock:
|
||||
self._file_upload_job_metadata[filename]["upload_status"] = "success"
|
||||
self._file_upload_job_metadata[filename]["upload_progress"] = 100
|
||||
self._on_upload_success()
|
||||
|
||||
def _onFileUploadFinished(self, filename: str) -> None:
|
||||
"""
|
||||
Callback that makes sure the correct messages are displayed according to the statuses of the individual jobs.
|
||||
|
||||
This function is called whenever an upload job has finished, regardless if it had errors or was successful.
|
||||
Both jobs have to have finished for the messages to show.
|
||||
|
||||
:param filename: The name of the file that has finished uploading (including the extension).
|
||||
"""
|
||||
with self._message_lock:
|
||||
|
||||
# All files have finished their uploading process
|
||||
if all([(file_upload_job["upload_progress"] == 100 and file_upload_job["upload_status"] != "uploading") for file_upload_job in self._file_upload_job_metadata.values()]):
|
||||
|
||||
# Reset and hide the progress message
|
||||
self.progress_message.setProgress(-1)
|
||||
self.progress_message.hide()
|
||||
|
||||
# All files were successfully uploaded.
|
||||
if all([(file_upload_job["upload_status"] == "success") for file_upload_job in self._file_upload_job_metadata.values()]):
|
||||
# Show a single generic success message for all files
|
||||
self._generic_success_message.show()
|
||||
else: # One or more files failed to upload.
|
||||
# Show individual messages for each file, according to their statuses
|
||||
for filename, upload_job_metadata in self._file_upload_job_metadata.items():
|
||||
if upload_job_metadata["upload_status"] == "success":
|
||||
upload_job_metadata["file_upload_success_message"].show()
|
||||
else:
|
||||
upload_job_metadata["file_upload_failed_message"].show()
|
||||
|
||||
# Call the parent's finished function
|
||||
self._on_upload_finished()
|
||||
|
||||
def _onJobExportError(self, filename: str) -> None:
|
||||
"""
|
||||
Displays an appropriate message when the process to export a file fails.
|
||||
|
||||
:param filename: The name of the file that failed to be exported (including the extension).
|
||||
"""
|
||||
Logger.log("d", "Error while exporting file '{}'".format(filename))
|
||||
with self._message_lock:
|
||||
# Set the progress to 100% when the upload job fails, to avoid having the progress message stuck
|
||||
self._file_upload_job_metadata[filename]["upload_status"] = "failed"
|
||||
self._file_upload_job_metadata[filename]["upload_progress"] = 100
|
||||
self._file_upload_job_metadata[filename]["file_upload_failed_message"] = Message(
|
||||
text = "Failed to export the file '{}'. The upload process is aborted.".format(filename),
|
||||
title = "Export error",
|
||||
lifetime = 0
|
||||
)
|
||||
self._on_upload_error()
|
||||
self._onFileUploadFinished(filename)
|
||||
|
||||
def _onRequestUploadCuraProjectFileFailed(self, reply: "QNetworkReply", network_error: "QNetworkReply.NetworkError") -> None:
|
||||
"""
|
||||
Displays an appropriate message when the request to upload the Cura project file (.3mf) to the Digital Library fails.
|
||||
This means that something went wrong with the initial request to create a "file" entry in the digital library.
|
||||
"""
|
||||
reply_string = bytes(reply.readAll()).decode()
|
||||
filename_3mf = self._file_name + ".3mf"
|
||||
Logger.log("d", "An error occurred while uploading the Cura project file '{}' to the Digital Library project '{}': {}".format(filename_3mf, self._library_project_id, reply_string))
|
||||
with self._message_lock:
|
||||
# Set the progress to 100% when the upload job fails, to avoid having the progress message stuck
|
||||
self._file_upload_job_metadata[filename_3mf]["upload_status"] = "failed"
|
||||
self._file_upload_job_metadata[filename_3mf]["upload_progress"] = 100
|
||||
|
||||
human_readable_error = self.extractErrorTitle(reply_string)
|
||||
self._file_upload_job_metadata[filename_3mf]["file_upload_failed_message"] = Message(
|
||||
text = "Failed to upload the file '{}' to '{}'. {}".format(filename_3mf, self._library_project_name, human_readable_error),
|
||||
title = "File upload error",
|
||||
lifetime = 0
|
||||
)
|
||||
self._on_upload_error()
|
||||
self._onFileUploadFinished(filename_3mf)
|
||||
|
||||
def _onRequestUploadPrintFileFailed(self, reply: "QNetworkReply", network_error: "QNetworkReply.NetworkError") -> None:
|
||||
"""
|
||||
Displays an appropriate message when the request to upload the print file (.ufp) to the Digital Library fails.
|
||||
This means that something went wrong with the initial request to create a "file" entry in the digital library.
|
||||
"""
|
||||
reply_string = bytes(reply.readAll()).decode()
|
||||
filename_ufp = self._file_name + ".ufp"
|
||||
Logger.log("d", "An error occurred while uploading the print job file '{}' to the Digital Library project '{}': {}".format(filename_ufp, self._library_project_id, reply_string))
|
||||
with self._message_lock:
|
||||
# Set the progress to 100% when the upload job fails, to avoid having the progress message stuck
|
||||
self._file_upload_job_metadata[filename_ufp]["upload_status"] = "failed"
|
||||
self._file_upload_job_metadata[filename_ufp]["upload_progress"] = 100
|
||||
|
||||
human_readable_error = self.extractErrorTitle(reply_string)
|
||||
self._file_upload_job_metadata[filename_ufp]["file_upload_failed_message"] = Message(
|
||||
title = "File upload error",
|
||||
text = "Failed to upload the file '{}' to '{}'. {}".format(filename_ufp, self._library_project_name, human_readable_error),
|
||||
lifetime = 0
|
||||
)
|
||||
self._on_upload_error()
|
||||
self._onFileUploadFinished(filename_ufp)
|
||||
|
||||
@staticmethod
|
||||
def extractErrorTitle(reply_body: Optional[str]) -> str:
|
||||
error_title = ""
|
||||
if 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
|
||||
|
||||
def _onUploadError(self, filename: str, reply: "QNetworkReply", error: "QNetworkReply.NetworkError") -> None:
|
||||
"""
|
||||
Displays the given message if uploading the mesh has failed due to a generic error (i.e. lost connection).
|
||||
If one of the two files fail, this error function will set its progress as finished, to make sure that the
|
||||
progress message doesn't get stuck.
|
||||
|
||||
:param filename: The name of the file that failed to upload (including the extension).
|
||||
"""
|
||||
reply_string = bytes(reply.readAll()).decode()
|
||||
Logger.log("d", "Error while uploading '{}' to the Digital Library project '{}'. Reply: {}".format(filename, self._library_project_id, reply_string))
|
||||
with self._message_lock:
|
||||
# Set the progress to 100% when the upload job fails, to avoid having the progress message stuck
|
||||
self._file_upload_job_metadata[filename]["upload_status"] = "failed"
|
||||
self._file_upload_job_metadata[filename]["upload_progress"] = 100
|
||||
human_readable_error = self.extractErrorTitle(reply_string)
|
||||
self._file_upload_job_metadata[filename]["file_upload_failed_message"] = Message(
|
||||
title = "File upload error",
|
||||
text = "Failed to upload the file '{}' to '{}'. {}".format(self._file_name, self._library_project_name, human_readable_error),
|
||||
lifetime = 0
|
||||
)
|
||||
|
||||
self._on_upload_error()
|
||||
|
||||
def getTotalProgress(self) -> int:
|
||||
"""
|
||||
Returns the total upload progress of all the upload jobs
|
||||
|
||||
:return: The average progress percentage
|
||||
"""
|
||||
return int(sum([file_upload_job["upload_progress"] for file_upload_job in self._file_upload_job_metadata.values()]) / len(self._file_upload_job_metadata.values()))
|
||||
|
||||
def _onMessageActionTriggered(self, message, action):
|
||||
if action == "open_df_project":
|
||||
project_url = "{}/app/library/project/{}?wait_for_new_files=true".format(CuraApplication.getInstance().ultimakerDigitalFactoryUrl, self._library_project_id)
|
||||
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] = {
|
||||
"export_job_output" : None,
|
||||
"upload_progress" : -1,
|
||||
"upload_status" : "",
|
||||
"file_upload_response": None,
|
||||
"file_upload_success_message": Message(
|
||||
text = "'{}' was uploaded to '{}'.".format(filename_3mf, self._library_project_name),
|
||||
title = "Upload successful",
|
||||
lifetime = 0,
|
||||
),
|
||||
"file_upload_failed_message": Message(
|
||||
text = "Failed to upload the file '{}' to '{}'.".format(filename_3mf, self._library_project_name),
|
||||
title = "File upload error",
|
||||
lifetime = 0
|
||||
)
|
||||
}
|
||||
job_3mf = ExportFileJob(self._file_handlers["3mf"], self._nodes, self._file_name, "3mf")
|
||||
job_3mf.finished.connect(self._onCuraProjectFileExported)
|
||||
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"
|
||||
metadata[filename_ufp] = {
|
||||
"export_job_output" : None,
|
||||
"upload_progress" : -1,
|
||||
"upload_status" : "",
|
||||
"file_upload_response": None,
|
||||
"file_upload_success_message": Message(
|
||||
text = "'{}' was uploaded to '{}'.".format(filename_ufp, self._library_project_name),
|
||||
title = "Upload successful",
|
||||
lifetime = 0,
|
||||
),
|
||||
"file_upload_failed_message": Message(
|
||||
text = "Failed to upload the file '{}' to '{}'.".format(filename_ufp, self._library_project_name),
|
||||
title = "File upload error",
|
||||
lifetime = 0
|
||||
)
|
||||
}
|
||||
job_ufp = ExportFileJob(self._file_handlers["ufp"], self._nodes, self._file_name, "ufp")
|
||||
job_ufp.finished.connect(self._onPrintFileExported)
|
||||
self._upload_jobs.append(job_ufp)
|
||||
return metadata
|
149
plugins/DigitalLibrary/src/DFFileUploader.py
Normal file
149
plugins/DigitalLibrary/src/DFFileUploader.py
Normal file
|
@ -0,0 +1,149 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
|
||||
from typing import Callable, Any, cast, Optional, Union
|
||||
|
||||
from UM.Logger import Logger
|
||||
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
|
||||
from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse
|
||||
from .DFPrintJobUploadResponse import DFPrintJobUploadResponse
|
||||
|
||||
|
||||
class DFFileUploader:
|
||||
"""Class responsible for uploading meshes to the the digital factory library in separate requests."""
|
||||
|
||||
# The maximum amount of times to retry if the server returns one of the RETRY_HTTP_CODES
|
||||
MAX_RETRIES = 10
|
||||
|
||||
# The HTTP codes that should trigger a retry.
|
||||
RETRY_HTTP_CODES = {500, 502, 503, 504}
|
||||
|
||||
def __init__(self,
|
||||
http: HttpRequestManager,
|
||||
df_file: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse],
|
||||
data: bytes,
|
||||
on_finished: Callable[[str], Any],
|
||||
on_success: Callable[[str], Any],
|
||||
on_progress: Callable[[str, int], Any],
|
||||
on_error: Callable[[str, "QNetworkReply", "QNetworkReply.NetworkError"], Any]
|
||||
) -> None:
|
||||
"""Creates a mesh upload object.
|
||||
|
||||
:param http: The network access manager that will handle the HTTP requests.
|
||||
:param df_file: The file response that was received by the Digital Factory after registering the upload.
|
||||
:param data: The mesh bytes to be uploaded.
|
||||
:param on_finished: The method to be called when done.
|
||||
:param on_success: The method to be called when the upload is successful.
|
||||
:param on_progress: The method to be called when the progress changes (receives a percentage 0-100).
|
||||
:param on_error: The method to be called when an error occurs.
|
||||
"""
|
||||
|
||||
self._http = http # type: HttpRequestManager
|
||||
self._df_file = df_file # type: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse]
|
||||
self._file_name = ""
|
||||
if isinstance(self._df_file, DFLibraryFileUploadResponse):
|
||||
self._file_name = self._df_file.file_name
|
||||
elif isinstance(self._df_file, DFPrintJobUploadResponse):
|
||||
if self._df_file.job_name is not None:
|
||||
self._file_name = self._df_file.job_name
|
||||
else:
|
||||
self._file_name = ""
|
||||
else:
|
||||
raise TypeError("Incorrect input type")
|
||||
self._data = data # type: bytes
|
||||
|
||||
self._on_finished = on_finished
|
||||
self._on_success = on_success
|
||||
self._on_progress = on_progress
|
||||
self._on_error = on_error
|
||||
|
||||
self._retries = 0
|
||||
self._finished = False
|
||||
|
||||
def start(self) -> None:
|
||||
"""Starts uploading the mesh."""
|
||||
|
||||
if self._finished:
|
||||
# reset state.
|
||||
self._retries = 0
|
||||
self._finished = False
|
||||
self._upload()
|
||||
|
||||
def stop(self):
|
||||
"""Stops uploading the mesh, marking it as finished."""
|
||||
|
||||
Logger.log("i", "Finished uploading")
|
||||
self._finished = True # Signal to any ongoing retries that we should stop retrying.
|
||||
self._on_finished(self._file_name)
|
||||
|
||||
def _upload(self) -> None:
|
||||
"""
|
||||
Uploads the file to the Digital Factory Library project
|
||||
"""
|
||||
if self._finished:
|
||||
raise ValueError("The upload is already finished")
|
||||
if isinstance(self._df_file, DFLibraryFileUploadResponse):
|
||||
Logger.log("i", "Uploading Cura project file '{file_name}' via link '{upload_url}'".format(file_name = self._df_file.file_name, upload_url = self._df_file.upload_url))
|
||||
elif isinstance(self._df_file, DFPrintJobUploadResponse):
|
||||
Logger.log("i", "Uploading Cura print file '{file_name}' via link '{upload_url}'".format(file_name = self._df_file.job_name, upload_url = self._df_file.upload_url))
|
||||
self._http.put(
|
||||
url = cast(str, self._df_file.upload_url),
|
||||
headers_dict = {"Content-Type": cast(str, self._df_file.content_type)},
|
||||
data = self._data,
|
||||
callback = self._onUploadFinished,
|
||||
error_callback = self._onUploadError,
|
||||
upload_progress_callback = self._onUploadProgressChanged
|
||||
)
|
||||
|
||||
def _onUploadProgressChanged(self, bytes_sent: int, bytes_total: int) -> None:
|
||||
"""Handles an update to the upload progress
|
||||
|
||||
:param bytes_sent: The amount of bytes sent in the current request.
|
||||
:param bytes_total: The amount of bytes to send in the current request.
|
||||
"""
|
||||
Logger.debug("Cloud upload progress %s / %s", bytes_sent, bytes_total)
|
||||
if bytes_total:
|
||||
self._on_progress(self._file_name, int(bytes_sent / len(self._data) * 100))
|
||||
|
||||
def _onUploadError(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
|
||||
"""Handles an error uploading."""
|
||||
|
||||
body = bytes(reply.peek(reply.bytesAvailable())).decode()
|
||||
Logger.log("e", "Received error while uploading: %s", body)
|
||||
self._on_error(self._file_name, reply, error)
|
||||
self.stop()
|
||||
|
||||
def _onUploadFinished(self, reply: QNetworkReply) -> None:
|
||||
"""
|
||||
Checks whether a chunk of data was uploaded successfully, starting the next chunk if needed.
|
||||
"""
|
||||
|
||||
Logger.log("i", "Finished callback %s %s",
|
||||
reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString())
|
||||
|
||||
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) # type: Optional[int]
|
||||
if not status_code:
|
||||
Logger.log("e", "Reply contained no status code.")
|
||||
self._onUploadError(reply, None)
|
||||
return
|
||||
|
||||
# check if we should retry the last chunk
|
||||
if self._retries < self.MAX_RETRIES and status_code in self.RETRY_HTTP_CODES:
|
||||
self._retries += 1
|
||||
Logger.log("i", "Retrying %s/%s request %s", self._retries, self.MAX_RETRIES, reply.url().toString())
|
||||
try:
|
||||
self._upload()
|
||||
except ValueError: # Asynchronously it could have completed in the meanwhile.
|
||||
pass
|
||||
return
|
||||
|
||||
# Http codes that are not to be retried are assumed to be errors.
|
||||
if status_code > 308:
|
||||
self._onUploadError(reply, None)
|
||||
return
|
||||
|
||||
Logger.log("d", "status_code: %s, Headers: %s, body: %s", status_code,
|
||||
[bytes(header).decode() for header in reply.rawHeaderList()], bytes(reply.readAll()).decode())
|
||||
self._on_success(self._file_name)
|
||||
self.stop()
|
16
plugins/DigitalLibrary/src/DFLibraryFileUploadRequest.py
Normal file
16
plugins/DigitalLibrary/src/DFLibraryFileUploadRequest.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
# Model that represents the request to upload a file to a DF Library project
|
||||
from .BaseModel import BaseModel
|
||||
|
||||
|
||||
class DFLibraryFileUploadRequest(BaseModel):
|
||||
|
||||
def __init__(self, content_type: str, file_name: str, file_size: int, library_project_id: str, **kwargs) -> None:
|
||||
|
||||
self.content_type = content_type
|
||||
self.file_name = file_name
|
||||
self.file_size = file_size
|
||||
self.library_project_id = library_project_id
|
||||
super().__init__(**kwargs)
|
49
plugins/DigitalLibrary/src/DFLibraryFileUploadResponse.py
Normal file
49
plugins/DigitalLibrary/src/DFLibraryFileUploadResponse.py
Normal file
|
@ -0,0 +1,49 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from .BaseModel import BaseModel
|
||||
|
||||
|
||||
class DFLibraryFileUploadResponse(BaseModel):
|
||||
"""
|
||||
Model that represents the response received from the Digital Factory after requesting to upload a file in a Library project
|
||||
"""
|
||||
|
||||
def __init__(self, client_id: str, content_type: str, file_id: str, file_name: str, library_project_id: str,
|
||||
status: str, uploaded_at: str, user_id: str, username: str, download_url: Optional[str] = None,
|
||||
file_size: Optional[int] = None, status_description: Optional[str] = None,
|
||||
upload_url: Optional[str] = None, **kwargs) -> None:
|
||||
|
||||
"""
|
||||
:param client_id: The ID of the OAuth2 client that uploaded this file
|
||||
:param content_type: The content type of the Digital Library project file
|
||||
:param file_id: The ID of the library project file
|
||||
:param file_name: The name of the file
|
||||
:param library_project_id: The ID of the library project, in which the file will be uploaded
|
||||
:param status: The status of the Digital Library project file
|
||||
:param uploaded_at: The time on which the file was uploaded
|
||||
:param user_id: The ID of the user that uploaded this file
|
||||
:param username: The user's unique username
|
||||
:param download_url: A signed URL to download the resulting file. Only available when the job is finished
|
||||
:param file_size: The size of the uploaded file (in bytes)
|
||||
:param status_description: Contains more details about the status, e.g. the cause of failures
|
||||
:param upload_url: The one-time use URL where the file must be uploaded to (only if status is uploading)
|
||||
:param kwargs: Other keyword arguments that may be included in the response
|
||||
"""
|
||||
|
||||
self.client_id = client_id # type: str
|
||||
self.content_type = content_type # type: str
|
||||
self.file_id = file_id # type: str
|
||||
self.file_name = file_name # type: str
|
||||
self.library_project_id = library_project_id # type: str
|
||||
self.status = status # type: str
|
||||
self.uploaded_at = self.parseDate(uploaded_at) # type: datetime
|
||||
self.user_id = user_id # type: str
|
||||
self.username = username # type: str
|
||||
self.download_url = download_url # type: Optional[str]
|
||||
self.file_size = file_size # type: Optional[int]
|
||||
self.status_description = status_description # type: Optional[str]
|
||||
self.upload_url = upload_url # type: Optional[str]
|
||||
super().__init__(**kwargs)
|
21
plugins/DigitalLibrary/src/DFPrintJobUploadRequest.py
Normal file
21
plugins/DigitalLibrary/src/DFPrintJobUploadRequest.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from .BaseModel import BaseModel
|
||||
|
||||
|
||||
# Model that represents the request to upload a print job to the cloud
|
||||
class DFPrintJobUploadRequest(BaseModel):
|
||||
|
||||
def __init__(self, job_name: str, file_size: int, content_type: str, library_project_id: str, **kwargs) -> None:
|
||||
"""Creates a new print job upload request.
|
||||
|
||||
:param job_name: The name of the print job.
|
||||
:param file_size: The size of the file in bytes.
|
||||
:param content_type: The content type of the print job (e.g. text/plain or application/gzip)
|
||||
"""
|
||||
|
||||
self.job_name = job_name
|
||||
self.file_size = file_size
|
||||
self.content_type = content_type
|
||||
self.library_project_id = library_project_id
|
||||
super().__init__(**kwargs)
|
35
plugins/DigitalLibrary/src/DFPrintJobUploadResponse.py
Normal file
35
plugins/DigitalLibrary/src/DFPrintJobUploadResponse.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import Optional
|
||||
|
||||
from .BaseModel import BaseModel
|
||||
|
||||
|
||||
# Model that represents the response received from the cloud after requesting to upload a print job
|
||||
class DFPrintJobUploadResponse(BaseModel):
|
||||
|
||||
def __init__(self, job_id: str, status: str, download_url: Optional[str] = None, job_name: Optional[str] = None,
|
||||
upload_url: Optional[str] = None, content_type: Optional[str] = None,
|
||||
status_description: Optional[str] = None, slicing_details: Optional[dict] = None, **kwargs) -> None:
|
||||
"""Creates a new print job response model.
|
||||
|
||||
:param job_id: The job unique ID, e.g. 'kBEeZWEifXbrXviO8mRYLx45P8k5lHVGs43XKvRniPg='.
|
||||
:param status: The status of the print job.
|
||||
:param status_description: Contains more details about the status, e.g. the cause of failures.
|
||||
:param download_url: A signed URL to download the resulting status. Only available when the job is finished.
|
||||
:param job_name: The name of the print job.
|
||||
:param slicing_details: Model for slice information.
|
||||
:param upload_url: The one-time use URL where the toolpath must be uploaded to (only if status is uploading).
|
||||
:param content_type: The content type of the print job (e.g. text/plain or application/gzip)
|
||||
:param generated_time: The datetime when the object was generated on the server-side.
|
||||
"""
|
||||
|
||||
self.job_id = job_id
|
||||
self.status = status
|
||||
self.download_url = download_url
|
||||
self.job_name = job_name
|
||||
self.upload_url = upload_url
|
||||
self.content_type = content_type
|
||||
self.status_description = status_description
|
||||
self.slicing_details = slicing_details
|
||||
super().__init__(**kwargs)
|
381
plugins/DigitalLibrary/src/DigitalFactoryApiClient.py
Normal file
381
plugins/DigitalLibrary/src/DigitalFactoryApiClient.py
Normal file
|
@ -0,0 +1,381 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import json
|
||||
from json import JSONDecodeError
|
||||
import re
|
||||
from time import time
|
||||
from typing import List, Any, Optional, Union, Type, Tuple, Dict, cast, TypeVar, Callable
|
||||
|
||||
from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
|
||||
|
||||
from UM.Logger import Logger
|
||||
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
|
||||
from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from cura.UltimakerCloud import UltimakerCloudConstants
|
||||
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
|
||||
from .DFPrintJobUploadResponse import DFPrintJobUploadResponse
|
||||
from .BaseModel import BaseModel
|
||||
from .CloudError import CloudError
|
||||
from .DFFileUploader import DFFileUploader
|
||||
from .DFLibraryFileUploadRequest import DFLibraryFileUploadRequest
|
||||
from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse
|
||||
from .DFPrintJobUploadRequest import DFPrintJobUploadRequest
|
||||
from .DigitalFactoryFeatureBudgetResponse import DigitalFactoryFeatureBudgetResponse
|
||||
from .DigitalFactoryFileResponse import DigitalFactoryFileResponse
|
||||
from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse
|
||||
from .PaginationLinks import PaginationLinks
|
||||
from .PaginationManager import PaginationManager
|
||||
|
||||
CloudApiClientModel = TypeVar("CloudApiClientModel", bound=BaseModel)
|
||||
"""The generic type variable used to document the methods below."""
|
||||
|
||||
|
||||
class DigitalFactoryApiClient:
|
||||
# The URL to access the digital factory.
|
||||
ROOT_PATH = UltimakerCloudConstants.CuraCloudAPIRoot
|
||||
CURA_API_ROOT = "{}/cura/v1".format(ROOT_PATH)
|
||||
|
||||
DEFAULT_REQUEST_TIMEOUT = 10 # seconds
|
||||
|
||||
# In order to avoid garbage collection we keep the callbacks in this list.
|
||||
_anti_gc_callbacks = [] # type: List[Callable[[Any], None]]
|
||||
|
||||
def __init__(self, application: CuraApplication, on_error: Callable[[List[CloudError]], None], projects_limit_per_page: Optional[int] = None) -> None:
|
||||
"""Initializes a new digital factory API client.
|
||||
|
||||
:param application:
|
||||
:param on_error: The callback to be called whenever we receive errors from the server.
|
||||
"""
|
||||
super().__init__()
|
||||
self._application = application
|
||||
self._account = application.getCuraAPI().account
|
||||
self._scope = JsonDecoratorScope(UltimakerCloudScope(application))
|
||||
self._http = HttpRequestManager.getInstance()
|
||||
self._on_error = on_error
|
||||
self._file_uploader = None # type: Optional[DFFileUploader]
|
||||
self._library_max_private_projects: Optional[int] = None
|
||||
|
||||
self._projects_pagination_mgr = PaginationManager(limit = projects_limit_per_page) if projects_limit_per_page else None # type: Optional[PaginationManager]
|
||||
|
||||
def checkUserHasAccess(self, callback: Callable) -> None:
|
||||
"""Checks if the user has any sort of access to the digital library.
|
||||
A user is considered to have access if the max-# of private projects is greater then 0 (or -1 for unlimited).
|
||||
"""
|
||||
|
||||
def callbackWrap(response: Optional[Any] = None, *args, **kwargs) -> None:
|
||||
if (response is not None and isinstance(response, DigitalFactoryFeatureBudgetResponse) and
|
||||
response.library_max_private_projects is not None):
|
||||
callback(
|
||||
response.library_max_private_projects == -1 or # Note: -1 is unlimited
|
||||
response.library_max_private_projects > 0)
|
||||
self._library_max_private_projects = response.library_max_private_projects
|
||||
else:
|
||||
Logger.warning(f"Digital Factory: Response is not a feature budget, likely an error: {str(response)}")
|
||||
callback(False)
|
||||
|
||||
self._http.get(f"{self.CURA_API_ROOT}/feature_budgets",
|
||||
scope = self._scope,
|
||||
callback = self._parseCallback(callbackWrap, DigitalFactoryFeatureBudgetResponse, callbackWrap),
|
||||
error_callback = callbackWrap,
|
||||
timeout = self.DEFAULT_REQUEST_TIMEOUT)
|
||||
|
||||
def checkUserCanCreateNewLibraryProject(self, callback: Callable) -> None:
|
||||
"""
|
||||
Checks if the user is allowed to create new library projects.
|
||||
A user is allowed to create new library projects if the haven't reached their maximum allowed private projects.
|
||||
"""
|
||||
|
||||
def callbackWrap(response: Optional[Any] = None, *args, **kwargs) -> None:
|
||||
if response is not None:
|
||||
if isinstance(response, DigitalFactoryProjectResponse): # The user has only one private project
|
||||
callback(True)
|
||||
elif isinstance(response, list) and all(isinstance(r, DigitalFactoryProjectResponse) for r in response):
|
||||
callback(len(response) < cast(int, self._library_max_private_projects))
|
||||
else:
|
||||
Logger.warning(f"Digital Factory: Incorrect response type received when requesting private projects: {str(response)}")
|
||||
callback(False)
|
||||
else:
|
||||
Logger.warning(f"Digital Factory: Response is empty, likely an error: {str(response)}")
|
||||
callback(False)
|
||||
|
||||
if self._library_max_private_projects is not None and self._library_max_private_projects > 0:
|
||||
# The user has a limit in the number of private projects they can create. Check whether they have already
|
||||
# reached that limit.
|
||||
# Note: Set the pagination manager to None when doing this get request, or else the next/previous links
|
||||
# of the pagination will become corrupted
|
||||
url = f"{self.CURA_API_ROOT}/projects?shared=false&limit={self._library_max_private_projects}"
|
||||
self._http.get(url,
|
||||
scope = self._scope,
|
||||
callback = self._parseCallback(callbackWrap, DigitalFactoryProjectResponse, callbackWrap, pagination_manager = None),
|
||||
error_callback = callbackWrap,
|
||||
timeout = self.DEFAULT_REQUEST_TIMEOUT)
|
||||
else:
|
||||
# If the limit is -1, then the user is allowed unlimited projects. If its 0 then they are not allowed to
|
||||
# create any projects
|
||||
callback(self._library_max_private_projects == -1)
|
||||
|
||||
def getProject(self, library_project_id: str, on_finished: Callable[[DigitalFactoryProjectResponse], Any], failed: Callable) -> None:
|
||||
"""
|
||||
Retrieves a digital factory project by its library project id.
|
||||
|
||||
:param library_project_id: The id of the library project
|
||||
:param on_finished: The function to be called after the result is parsed.
|
||||
:param failed: The function to be called if the request fails.
|
||||
"""
|
||||
url = "{}/projects/{}".format(self.CURA_API_ROOT, library_project_id)
|
||||
|
||||
self._http.get(url,
|
||||
scope = self._scope,
|
||||
callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse, failed),
|
||||
error_callback = failed,
|
||||
timeout = self.DEFAULT_REQUEST_TIMEOUT)
|
||||
|
||||
def getProjectsFirstPage(self, search_filter: str, on_finished: Callable[[List[DigitalFactoryProjectResponse]], Any], failed: Callable) -> None:
|
||||
"""
|
||||
Retrieves digital factory projects for the user that is currently logged in.
|
||||
|
||||
If a projects pagination manager exists, then it attempts to get the first page of the paginated projects list,
|
||||
according to the limit set in the pagination manager. If there is no projects pagination manager, this function
|
||||
leaves the project limit to the default set on the server side (999999).
|
||||
|
||||
:param search_filter: Text to filter the search results. If given an empty string, results are not filtered.
|
||||
:param on_finished: The function to be called after the result is parsed.
|
||||
:param failed: The function to be called if the request fails.
|
||||
"""
|
||||
url = f"{self.CURA_API_ROOT}/projects"
|
||||
query_character = "?"
|
||||
if self._projects_pagination_mgr:
|
||||
self._projects_pagination_mgr.reset() # reset to clear all the links and response metadata
|
||||
url += f"{query_character}limit={self._projects_pagination_mgr.limit}"
|
||||
query_character = "&"
|
||||
if search_filter != "":
|
||||
url += f"{query_character}search={search_filter}"
|
||||
|
||||
self._http.get(url,
|
||||
scope = self._scope,
|
||||
callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse, failed, pagination_manager = self._projects_pagination_mgr),
|
||||
error_callback = failed,
|
||||
timeout = self.DEFAULT_REQUEST_TIMEOUT)
|
||||
|
||||
def getMoreProjects(self,
|
||||
on_finished: Callable[[List[DigitalFactoryProjectResponse]], Any],
|
||||
failed: Callable) -> None:
|
||||
"""Retrieves the next page of the paginated projects list from the API, provided that there is any.
|
||||
|
||||
:param on_finished: The function to be called after the result is parsed.
|
||||
:param failed: The function to be called if the request fails.
|
||||
"""
|
||||
|
||||
if self.hasMoreProjectsToLoad():
|
||||
url = cast(PaginationLinks, cast(PaginationManager, self._projects_pagination_mgr).links).next_page
|
||||
self._http.get(url,
|
||||
scope = self._scope,
|
||||
callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse, failed, pagination_manager = self._projects_pagination_mgr),
|
||||
error_callback = failed,
|
||||
timeout = self.DEFAULT_REQUEST_TIMEOUT)
|
||||
else:
|
||||
Logger.log("d", "There are no more projects to load.")
|
||||
|
||||
def hasMoreProjectsToLoad(self) -> bool:
|
||||
"""
|
||||
Determines whether the client can get more pages of projects list from the API.
|
||||
|
||||
:return: Whether there are more pages in the projects list available to be retrieved from the API.
|
||||
"""
|
||||
return self._projects_pagination_mgr is not None and self._projects_pagination_mgr.links is not None and self._projects_pagination_mgr.links.next_page is not None
|
||||
|
||||
def getListOfFilesInProject(self, library_project_id: str, on_finished: Callable[[List[DigitalFactoryFileResponse]], Any], failed: Callable) -> None:
|
||||
"""Retrieves the list of files contained in the project with library_project_id from the Digital Factory Library.
|
||||
|
||||
:param library_project_id: The id of the digital factory library project in which the files are included
|
||||
:param on_finished: The function to be called after the result is parsed.
|
||||
:param failed: The function to be called if the request fails.
|
||||
"""
|
||||
|
||||
url = "{}/projects/{}/files".format(self.CURA_API_ROOT, library_project_id)
|
||||
self._http.get(url,
|
||||
scope = self._scope,
|
||||
callback = self._parseCallback(on_finished, DigitalFactoryFileResponse, failed),
|
||||
error_callback = failed,
|
||||
timeout = self.DEFAULT_REQUEST_TIMEOUT)
|
||||
|
||||
def _parseCallback(self,
|
||||
on_finished: Union[Callable[[CloudApiClientModel], Any],
|
||||
Callable[[List[CloudApiClientModel]], Any]],
|
||||
model: Type[CloudApiClientModel],
|
||||
on_error: Optional[Callable] = None,
|
||||
pagination_manager: Optional[PaginationManager] = None) -> Callable[[QNetworkReply], None]:
|
||||
|
||||
"""
|
||||
Creates a callback function so that it includes the parsing of the response into the correct model.
|
||||
The callback is added to the 'finished' signal of the reply. If a paginated request was made and a pagination
|
||||
manager is given, the pagination metadata will be held there.
|
||||
|
||||
:param on_finished: The callback in case the response is successful. Depending on the endpoint it will be either
|
||||
a list or a single item.
|
||||
:param model: The type of the model to convert the response to.
|
||||
:param on_error: The callback in case the response is ... less successful.
|
||||
:param pagination_manager: Holds the pagination links and metadata contained in paginated responses.
|
||||
If no pagination manager is provided, the pagination metadata is ignored.
|
||||
"""
|
||||
|
||||
def parse(reply: QNetworkReply) -> None:
|
||||
|
||||
self._anti_gc_callbacks.remove(parse)
|
||||
|
||||
# Don't try to parse the reply if we didn't get one
|
||||
if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None:
|
||||
if on_error is not None:
|
||||
on_error()
|
||||
return
|
||||
|
||||
status_code, response = self._parseReply(reply)
|
||||
if status_code >= 300 and on_error is not None:
|
||||
on_error()
|
||||
else:
|
||||
self._parseModels(response, on_finished, model, pagination_manager = pagination_manager)
|
||||
|
||||
self._anti_gc_callbacks.append(parse)
|
||||
return parse
|
||||
|
||||
@staticmethod
|
||||
def _parseReply(reply: QNetworkReply) -> Tuple[int, Dict[str, Any]]:
|
||||
"""Parses the given JSON network reply into a status code and a dictionary, handling unexpected errors as well.
|
||||
|
||||
:param reply: The reply from the server.
|
||||
:return: A tuple with a status code and a dictionary.
|
||||
"""
|
||||
|
||||
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
|
||||
try:
|
||||
response = bytes(reply.readAll()).decode()
|
||||
return status_code, json.loads(response)
|
||||
except (UnicodeDecodeError, JSONDecodeError, ValueError) as err:
|
||||
error = CloudError(code = type(err).__name__, title = str(err), http_code = str(status_code),
|
||||
id = str(time()), http_status = "500")
|
||||
Logger.logException("e", "Could not parse the stardust response: %s", error.toDict())
|
||||
return status_code, {"errors": [error.toDict()]}
|
||||
|
||||
def _parseModels(self,
|
||||
response: Dict[str, Any],
|
||||
on_finished: Union[Callable[[CloudApiClientModel], Any],
|
||||
Callable[[List[CloudApiClientModel]], Any]],
|
||||
model_class: Type[CloudApiClientModel],
|
||||
pagination_manager: Optional[PaginationManager] = None) -> None:
|
||||
"""Parses the given models and calls the correct callback depending on the result.
|
||||
|
||||
:param response: The response from the server, after being converted to a dict.
|
||||
:param on_finished: The callback in case the response is successful.
|
||||
:param model_class: The type of the model to convert the response to. It may either be a single record or a list.
|
||||
:param pagination_manager: Holds the pagination links and metadata contained in paginated responses.
|
||||
If no pagination manager is provided, the pagination metadata is ignored.
|
||||
"""
|
||||
|
||||
if "data" in response:
|
||||
data = response["data"]
|
||||
if "meta" in response and pagination_manager:
|
||||
pagination_manager.setResponseMeta(response["meta"])
|
||||
if "links" in response and pagination_manager:
|
||||
pagination_manager.setLinks(response["links"])
|
||||
if isinstance(data, list):
|
||||
results = [model_class(**c) for c in data] # type: List[CloudApiClientModel]
|
||||
on_finished_list = cast(Callable[[List[CloudApiClientModel]], Any], on_finished)
|
||||
on_finished_list(results)
|
||||
else:
|
||||
result = model_class(**data) # type: CloudApiClientModel
|
||||
on_finished_item = cast(Callable[[CloudApiClientModel], Any], on_finished)
|
||||
on_finished_item(result)
|
||||
elif "errors" in response:
|
||||
self._on_error([CloudError(**error) for error in response["errors"]])
|
||||
else:
|
||||
Logger.log("e", "Cannot find data or errors in the cloud response: %s", response)
|
||||
|
||||
def requestUpload3MF(self, request: DFLibraryFileUploadRequest,
|
||||
on_finished: Callable[[DFLibraryFileUploadResponse], Any],
|
||||
on_error: Optional[Callable[["QNetworkReply", "QNetworkReply.NetworkError"], None]] = None) -> None:
|
||||
|
||||
"""Requests the Digital Factory to register the upload of a file in a library project.
|
||||
|
||||
:param request: The request object.
|
||||
:param on_finished: The function to be called after the result is parsed.
|
||||
:param on_error: The callback in case the request fails.
|
||||
"""
|
||||
|
||||
url = "{}/files/upload".format(self.CURA_API_ROOT)
|
||||
data = json.dumps({"data": request.toDict()}).encode()
|
||||
|
||||
self._http.put(url,
|
||||
scope = self._scope,
|
||||
data = data,
|
||||
callback = self._parseCallback(on_finished, DFLibraryFileUploadResponse),
|
||||
error_callback = on_error,
|
||||
timeout = self.DEFAULT_REQUEST_TIMEOUT)
|
||||
|
||||
def requestUploadUFP(self, request: DFPrintJobUploadRequest,
|
||||
on_finished: Callable[[DFPrintJobUploadResponse], Any],
|
||||
on_error: Optional[Callable[["QNetworkReply", "QNetworkReply.NetworkError"], None]] = None) -> None:
|
||||
"""Requests the Digital Factory to register the upload of a file in a library project.
|
||||
|
||||
:param request: The request object.
|
||||
:param on_finished: The function to be called after the result is parsed.
|
||||
:param on_error: The callback in case the request fails.
|
||||
"""
|
||||
|
||||
url = "{}/jobs/upload".format(self.CURA_API_ROOT)
|
||||
data = json.dumps({"data": request.toDict()}).encode()
|
||||
|
||||
self._http.put(url,
|
||||
scope = self._scope,
|
||||
data = data,
|
||||
callback = self._parseCallback(on_finished, DFPrintJobUploadResponse),
|
||||
error_callback = on_error,
|
||||
timeout = self.DEFAULT_REQUEST_TIMEOUT)
|
||||
|
||||
def uploadExportedFileData(self,
|
||||
df_file_upload_response: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse],
|
||||
mesh: bytes,
|
||||
on_finished: Callable[[str], Any],
|
||||
on_success: Callable[[str], Any],
|
||||
on_progress: Callable[[str, int], Any],
|
||||
on_error: Callable[[str, "QNetworkReply", "QNetworkReply.NetworkError"], Any]) -> None:
|
||||
|
||||
"""Uploads an exported file (in bytes) to the Digital Factory Library.
|
||||
|
||||
:param df_file_upload_response: The response received after requesting an upload with `self.requestUpload`.
|
||||
:param mesh: The mesh data (in bytes) to be uploaded.
|
||||
:param on_finished: The function to be called after the upload has finished. Called both after on_success and on_error.
|
||||
It receives the name of the file that has finished uploading.
|
||||
:param on_success: The function to be called if the upload was successful.
|
||||
It receives the name of the file that was uploaded successfully.
|
||||
:param on_progress: A function to be called during upload progress. It receives a percentage (0-100).
|
||||
It receives the name of the file for which the upload progress should be updated.
|
||||
:param on_error: A function to be called if the upload fails.
|
||||
It receives the name of the file that produced errors during the upload process.
|
||||
"""
|
||||
|
||||
self._file_uploader = DFFileUploader(self._http, df_file_upload_response, mesh, on_finished, on_success, on_progress, on_error)
|
||||
self._file_uploader.start()
|
||||
|
||||
def createNewProject(self, project_name: str, on_finished: Callable[[DigitalFactoryProjectResponse], Any], on_error: Callable) -> None:
|
||||
""" Create a new project in the Digital Factory.
|
||||
|
||||
:param project_name: Name of the new to be created project.
|
||||
:param on_finished: The function to be called after the result is parsed.
|
||||
:param on_error: The function to be called if anything goes wrong.
|
||||
"""
|
||||
Logger.log("i", "Attempt to create new DF project '{}'.".format(project_name))
|
||||
|
||||
url = "{}/projects".format(self.CURA_API_ROOT)
|
||||
data = json.dumps({"data": {"display_name": project_name}}).encode()
|
||||
self._http.put(url,
|
||||
scope = self._scope,
|
||||
data = data,
|
||||
callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse),
|
||||
error_callback = on_error,
|
||||
timeout = self.DEFAULT_REQUEST_TIMEOUT)
|
||||
|
||||
def clear(self) -> None:
|
||||
if self._projects_pagination_mgr is not None:
|
||||
self._projects_pagination_mgr.reset()
|
620
plugins/DigitalLibrary/src/DigitalFactoryController.py
Normal file
620
plugins/DigitalLibrary/src/DigitalFactoryController.py
Normal file
|
@ -0,0 +1,620 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any, cast
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal, QObject, pyqtSlot, pyqtProperty, Q_ENUMS, QTimer, QUrl
|
||||
from PyQt5.QtNetwork import QNetworkReply
|
||||
from PyQt5.QtQml import qmlRegisterType, qmlRegisterUncreatableType
|
||||
|
||||
from UM.FileHandler.FileHandler import FileHandler
|
||||
from UM.Logger import Logger
|
||||
from UM.Message import Message
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from UM.Signal import Signal
|
||||
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
|
||||
from cura.API import Account
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
|
||||
from .DFFileExportAndUploadManager import DFFileExportAndUploadManager
|
||||
from .DigitalFactoryApiClient import DigitalFactoryApiClient
|
||||
from .DigitalFactoryFileModel import DigitalFactoryFileModel
|
||||
from .DigitalFactoryFileResponse import DigitalFactoryFileResponse
|
||||
from .DigitalFactoryProjectModel import DigitalFactoryProjectModel
|
||||
from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse
|
||||
|
||||
|
||||
class RetrievalStatus(IntEnum):
|
||||
"""
|
||||
The status of an http get request.
|
||||
|
||||
This is not an enum, because we want to use it in QML and QML doesn't recognize Python enums.
|
||||
"""
|
||||
Idle = 0
|
||||
InProgress = 1
|
||||
Success = 2
|
||||
Failed = 3
|
||||
|
||||
|
||||
class DFRetrievalStatus(QObject):
|
||||
"""
|
||||
Used as an intermediate QObject that registers the RetrievalStatus as a recognizable enum in QML, so that it can
|
||||
be used within QML objects as DigitalFactory.RetrievalStatus.<status>
|
||||
"""
|
||||
|
||||
Q_ENUMS(RetrievalStatus)
|
||||
|
||||
|
||||
class DigitalFactoryController(QObject):
|
||||
|
||||
DISK_WRITE_BUFFER_SIZE = 256 * 1024 # 256 KB
|
||||
|
||||
selectedProjectIndexChanged = pyqtSignal(int, arguments = ["newProjectIndex"])
|
||||
"""Signal emitted whenever the selected project is changed in the projects dropdown menu"""
|
||||
|
||||
selectedFileIndicesChanged = pyqtSignal("QList<int>", arguments = ["newFileIndices"])
|
||||
"""Signal emitted whenever the selected file is changed in the files table"""
|
||||
|
||||
retrievingProjectsStatusChanged = pyqtSignal(int, arguments = ["status"])
|
||||
"""Signal emitted whenever the status of the 'retrieving projects' http get request is changed"""
|
||||
|
||||
retrievingFilesStatusChanged = pyqtSignal(int, arguments = ["status"])
|
||||
"""Signal emitted whenever the status of the 'retrieving files in project' http get request is changed"""
|
||||
|
||||
creatingNewProjectStatusChanged = pyqtSignal(int, arguments = ["status"])
|
||||
"""Signal emitted whenever the status of the 'create new library project' http get request is changed"""
|
||||
|
||||
hasMoreProjectsToLoadChanged = pyqtSignal()
|
||||
"""Signal emitted whenever the variable hasMoreProjectsToLoad is changed. This variable is used to determine if
|
||||
the paginated list of projects has more pages to show"""
|
||||
|
||||
preselectedProjectChanged = pyqtSignal()
|
||||
"""Signal emitted whenever a preselected project is set. Whenever there is a preselected project, it means that it is
|
||||
the only project in the ProjectModel. When the preselected project is invalidated, the ProjectsModel needs to be
|
||||
retrieved again."""
|
||||
|
||||
projectCreationErrorTextChanged = pyqtSignal()
|
||||
"""Signal emitted whenever the creation of a new project fails and a specific error message is returned from the
|
||||
server."""
|
||||
|
||||
"""Signals to inform about the process of the file upload"""
|
||||
uploadStarted = Signal()
|
||||
uploadFileProgress = Signal()
|
||||
uploadFileSuccess = Signal()
|
||||
uploadFileError = Signal()
|
||||
uploadFileFinished = Signal()
|
||||
|
||||
"""Signal to inform about the state of user access."""
|
||||
userAccessStateChanged = pyqtSignal(bool)
|
||||
|
||||
"""Signal to inform whether the user is allowed to create more Library projects."""
|
||||
userCanCreateNewLibraryProjectChanged = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, application: CuraApplication) -> None:
|
||||
super().__init__(parent = None)
|
||||
|
||||
self._application = application
|
||||
self._dialog = None # type: Optional["QObject"]
|
||||
|
||||
self.file_handlers = {} # type: Dict[str, FileHandler]
|
||||
self.nodes = None # type: Optional[List[SceneNode]]
|
||||
self.file_upload_manager = None # type: Optional[DFFileExportAndUploadManager]
|
||||
self._has_preselected_project = False # type: bool
|
||||
|
||||
self._api = DigitalFactoryApiClient(self._application, on_error = lambda error: Logger.log("e", str(error)), projects_limit_per_page = 20)
|
||||
|
||||
# Indicates whether there are more pages of projects that can be loaded from the API
|
||||
self._has_more_projects_to_load = False
|
||||
|
||||
self._account = self._application.getInstance().getCuraAPI().account # type: Account
|
||||
self._account.loginStateChanged.connect(self._onLoginStateChanged)
|
||||
self._current_workspace_information = CuraApplication.getInstance().getCurrentWorkspaceInformation()
|
||||
|
||||
# Initialize the project model
|
||||
self._project_model = DigitalFactoryProjectModel()
|
||||
self._selected_project_idx = -1
|
||||
self._project_creation_error_text = "Something went wrong while creating a new project. Please try again."
|
||||
self._project_filter = ""
|
||||
self._project_filter_change_timer = QTimer()
|
||||
self._project_filter_change_timer.setInterval(200)
|
||||
self._project_filter_change_timer.setSingleShot(True)
|
||||
self._project_filter_change_timer.timeout.connect(self._applyProjectFilter)
|
||||
|
||||
# Initialize the file model
|
||||
self._file_model = DigitalFactoryFileModel()
|
||||
self._selected_file_indices = [] # type: List[int]
|
||||
|
||||
# Filled after the application has been initialized
|
||||
self._supported_file_types = {} # type: Dict[str, str]
|
||||
|
||||
# For cleaning up the files afterwards:
|
||||
self._erase_temp_files_lock = threading.Lock()
|
||||
|
||||
# The statuses which indicate whether Cura is waiting for a response from the DigitalFactory API
|
||||
self.retrieving_files_status = RetrievalStatus.Idle
|
||||
self.retrieving_projects_status = RetrievalStatus.Idle
|
||||
self.creating_new_project_status = RetrievalStatus.Idle
|
||||
|
||||
self._application.engineCreatedSignal.connect(self._onEngineCreated)
|
||||
self._application.initializationFinished.connect(self._applicationInitializationFinished)
|
||||
|
||||
self._user_has_access = False
|
||||
self._user_account_can_create_new_project = False
|
||||
|
||||
def clear(self) -> None:
|
||||
self._project_model.clearProjects()
|
||||
self._api.clear()
|
||||
self._has_preselected_project = False
|
||||
self.preselectedProjectChanged.emit()
|
||||
|
||||
self.setRetrievingFilesStatus(RetrievalStatus.Idle)
|
||||
self.setRetrievingProjectsStatus(RetrievalStatus.Idle)
|
||||
self.setCreatingNewProjectStatus(RetrievalStatus.Idle)
|
||||
|
||||
self.setSelectedProjectIndex(-1)
|
||||
|
||||
def _onLoginStateChanged(self, logged_in: bool) -> None:
|
||||
def callback(has_access, **kwargs):
|
||||
self._user_has_access = has_access
|
||||
self.userAccessStateChanged.emit(logged_in)
|
||||
|
||||
self._api.checkUserHasAccess(callback)
|
||||
|
||||
def userAccountHasLibraryAccess(self) -> bool:
|
||||
"""
|
||||
Checks whether the currently logged in user account has access to the Digital Library
|
||||
|
||||
:return: True if the user account has Digital Library access, else False
|
||||
"""
|
||||
if self._user_has_access:
|
||||
self._api.checkUserCanCreateNewLibraryProject(callback = self.setCanCreateNewLibraryProject)
|
||||
return self._user_has_access
|
||||
|
||||
def initialize(self, preselected_project_id: Optional[str] = None) -> None:
|
||||
self.clear()
|
||||
|
||||
if self._account.isLoggedIn and self.userAccountHasLibraryAccess():
|
||||
self.setRetrievingProjectsStatus(RetrievalStatus.InProgress)
|
||||
if preselected_project_id:
|
||||
self._api.getProject(preselected_project_id, on_finished = self.setProjectAsPreselected, failed = self._onGetProjectFailed)
|
||||
else:
|
||||
self._api.getProjectsFirstPage(search_filter = self._project_filter, on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed)
|
||||
|
||||
def setProjectAsPreselected(self, df_project: DigitalFactoryProjectResponse) -> None:
|
||||
"""
|
||||
Sets the received df_project as the preselected one. When a project is preselected, it should be the only
|
||||
project inside the model, so this function first makes sure to clear the projects model.
|
||||
|
||||
:param df_project: The library project intended to be set as preselected
|
||||
"""
|
||||
self._project_model.clearProjects()
|
||||
self._project_model.setProjects([df_project])
|
||||
self.setSelectedProjectIndex(0)
|
||||
self.setHasPreselectedProject(True)
|
||||
self.setRetrievingProjectsStatus(RetrievalStatus.Success)
|
||||
self.setCreatingNewProjectStatus(RetrievalStatus.Success)
|
||||
|
||||
def _onGetProjectFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
|
||||
reply_string = bytes(reply.readAll()).decode()
|
||||
self.setHasPreselectedProject(False)
|
||||
Logger.log("w", "Something went wrong while trying to retrieve a the preselected Digital Library project. Error: {}".format(reply_string))
|
||||
|
||||
def _onGetProjectsFirstPageFinished(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
|
||||
"""
|
||||
Set the first page of projects received from the digital factory library in the project model. Called whenever
|
||||
the retrieval of the first page of projects is successful.
|
||||
|
||||
:param df_projects: A list of all the Digital Factory Library projects linked to the user's account
|
||||
"""
|
||||
self.setHasMoreProjectsToLoad(self._api.hasMoreProjectsToLoad())
|
||||
self._project_model.setProjects(df_projects)
|
||||
self.setRetrievingProjectsStatus(RetrievalStatus.Success)
|
||||
|
||||
@pyqtSlot()
|
||||
def loadMoreProjects(self) -> None:
|
||||
"""
|
||||
Initiates the process of retrieving the next page of the projects list from the API.
|
||||
"""
|
||||
self._api.getMoreProjects(on_finished = self.loadMoreProjectsFinished, failed = self._onGetProjectsFailed)
|
||||
self.setRetrievingProjectsStatus(RetrievalStatus.InProgress)
|
||||
|
||||
def loadMoreProjectsFinished(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
|
||||
"""
|
||||
Set the projects received from the digital factory library in the project model. Called whenever the retrieval
|
||||
of the projects is successful.
|
||||
|
||||
:param df_projects: A list of all the Digital Factory Library projects linked to the user's account
|
||||
"""
|
||||
self.setHasMoreProjectsToLoad(self._api.hasMoreProjectsToLoad())
|
||||
self._project_model.extendProjects(df_projects)
|
||||
self.setRetrievingProjectsStatus(RetrievalStatus.Success)
|
||||
|
||||
def _onGetProjectsFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
|
||||
"""
|
||||
Error function, called whenever the retrieval of projects fails.
|
||||
"""
|
||||
self.setRetrievingProjectsStatus(RetrievalStatus.Failed)
|
||||
Logger.log("w", "Failed to retrieve the list of projects from the Digital Library. Error encountered: {}".format(error))
|
||||
|
||||
def getProjectFilesFinished(self, df_files_in_project: List[DigitalFactoryFileResponse]) -> None:
|
||||
"""
|
||||
Set the files received from the digital factory library in the file model. The files are filtered to only
|
||||
contain the files which can be opened by Cura.
|
||||
Called whenever the retrieval of the files is successful.
|
||||
|
||||
:param df_files_in_project: A list of all the Digital Factory Library files that exist in a library project
|
||||
"""
|
||||
# Filter to show only the files that can be opened in Cura
|
||||
self._file_model.setFilters({"file_name": lambda x: Path(x).suffix[1:].lower() in self._supported_file_types}) # the suffix is in format '.xyz', so omit the dot at the start
|
||||
self._file_model.setFiles(df_files_in_project)
|
||||
self.setRetrievingFilesStatus(RetrievalStatus.Success)
|
||||
|
||||
def getProjectFilesFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
|
||||
"""
|
||||
Error function, called whenever the retrieval of the files in a library project fails.
|
||||
"""
|
||||
Logger.log("w", "Failed to retrieve the list of files in project '{}' from the Digital Library".format(self._project_model._projects[self._selected_project_idx]))
|
||||
self.setRetrievingFilesStatus(RetrievalStatus.Failed)
|
||||
|
||||
@pyqtSlot()
|
||||
def clearProjectSelection(self) -> None:
|
||||
"""
|
||||
Clear the selected project.
|
||||
"""
|
||||
if self._has_preselected_project:
|
||||
self.setHasPreselectedProject(False)
|
||||
else:
|
||||
self.setSelectedProjectIndex(-1)
|
||||
|
||||
@pyqtSlot(int)
|
||||
def setSelectedProjectIndex(self, project_idx: int) -> None:
|
||||
"""
|
||||
Sets the index of the project which is currently selected in the dropdown menu. Then, it uses the project_id of
|
||||
that project to retrieve the list of files included in that project and display it in the interface.
|
||||
|
||||
:param project_idx: The index of the currently selected project
|
||||
"""
|
||||
if project_idx < -1 or project_idx >= len(self._project_model.items):
|
||||
Logger.log("w", "The selected project index is invalid.")
|
||||
project_idx = -1 # -1 is a valid index for the combobox and it is handled as "nothing is selected"
|
||||
self._selected_project_idx = project_idx
|
||||
self.selectedProjectIndexChanged.emit(project_idx)
|
||||
|
||||
# Clear the files from the previously-selected project and refresh the files model with the newly-selected-
|
||||
# project's files
|
||||
self._file_model.clearFiles()
|
||||
self.selectedFileIndicesChanged.emit([])
|
||||
if 0 <= project_idx < len(self._project_model.items):
|
||||
library_project_id = self._project_model.items[project_idx]["libraryProjectId"]
|
||||
self.setRetrievingFilesStatus(RetrievalStatus.InProgress)
|
||||
self._api.getListOfFilesInProject(library_project_id, on_finished = self.getProjectFilesFinished, failed = self.getProjectFilesFailed)
|
||||
|
||||
@pyqtProperty(int, fset = setSelectedProjectIndex, notify = selectedProjectIndexChanged)
|
||||
def selectedProjectIndex(self) -> int:
|
||||
return self._selected_project_idx
|
||||
|
||||
@pyqtSlot("QList<int>")
|
||||
def setSelectedFileIndices(self, file_indices: List[int]) -> None:
|
||||
"""
|
||||
Sets the index of the file which is currently selected in the list of files.
|
||||
|
||||
:param file_indices: The index of the currently selected file
|
||||
"""
|
||||
if file_indices != self._selected_file_indices:
|
||||
self._selected_file_indices = file_indices
|
||||
self.selectedFileIndicesChanged.emit(file_indices)
|
||||
|
||||
def setProjectFilter(self, new_filter: str) -> None:
|
||||
"""
|
||||
Called when the user wants to change the search filter for projects.
|
||||
|
||||
The filter is not immediately applied. There is some delay to allow the user to finish typing.
|
||||
:param new_filter: The new filter that the user wants to apply.
|
||||
"""
|
||||
self._project_filter = new_filter
|
||||
self._project_filter_change_timer.start()
|
||||
|
||||
"""
|
||||
Signal to notify Qt that the applied filter has changed.
|
||||
"""
|
||||
projectFilterChanged = pyqtSignal()
|
||||
|
||||
@pyqtProperty(str, notify = projectFilterChanged, fset = setProjectFilter)
|
||||
def projectFilter(self) -> str:
|
||||
"""
|
||||
The current search filter being applied to the project list.
|
||||
:return: The current search filter being applied to the project list.
|
||||
"""
|
||||
return self._project_filter
|
||||
|
||||
def _applyProjectFilter(self) -> None:
|
||||
"""
|
||||
Actually apply the current filter to search for projects with the user-defined search string.
|
||||
:return:
|
||||
"""
|
||||
self.clear()
|
||||
self.projectFilterChanged.emit()
|
||||
self._api.getProjectsFirstPage(search_filter = self._project_filter, on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed)
|
||||
|
||||
@pyqtProperty(QObject, constant = True)
|
||||
def digitalFactoryProjectModel(self) -> "DigitalFactoryProjectModel":
|
||||
return self._project_model
|
||||
|
||||
@pyqtProperty(QObject, constant = True)
|
||||
def digitalFactoryFileModel(self) -> "DigitalFactoryFileModel":
|
||||
return self._file_model
|
||||
|
||||
def setHasMoreProjectsToLoad(self, has_more_projects_to_load: bool) -> None:
|
||||
"""
|
||||
Set the value that indicates whether there are more pages of projects that can be loaded from the API
|
||||
|
||||
:param has_more_projects_to_load: Whether there are more pages of projects
|
||||
"""
|
||||
if has_more_projects_to_load != self._has_more_projects_to_load:
|
||||
self._has_more_projects_to_load = has_more_projects_to_load
|
||||
self.hasMoreProjectsToLoadChanged.emit()
|
||||
|
||||
@pyqtProperty(bool, fset = setHasMoreProjectsToLoad, notify = hasMoreProjectsToLoadChanged)
|
||||
def hasMoreProjectsToLoad(self) -> bool:
|
||||
"""
|
||||
:return: whether there are more pages for projects that can be loaded from the API
|
||||
"""
|
||||
return self._has_more_projects_to_load
|
||||
|
||||
@pyqtSlot(str)
|
||||
def createLibraryProjectAndSetAsPreselected(self, project_name: Optional[str]) -> None:
|
||||
"""
|
||||
Creates a new project with the given name in the Digital Library.
|
||||
|
||||
:param project_name: The name that will be used for the new project
|
||||
"""
|
||||
if project_name:
|
||||
self._api.createNewProject(project_name, self.setProjectAsPreselected, self._createNewLibraryProjectFailed)
|
||||
self.setCreatingNewProjectStatus(RetrievalStatus.InProgress)
|
||||
else:
|
||||
Logger.log("w", "No project name provided while attempting to create a new project. Aborting the project creation.")
|
||||
|
||||
def _createNewLibraryProjectFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
|
||||
reply_string = bytes(reply.readAll()).decode()
|
||||
|
||||
self._project_creation_error_text = "Something went wrong while creating the new project. Please try again."
|
||||
if reply_string:
|
||||
reply_dict = json.loads(reply_string)
|
||||
if "errors" in reply_dict and len(reply_dict["errors"]) >= 1 and "title" in reply_dict["errors"][0]:
|
||||
self._project_creation_error_text = "Error while creating the new project: {}".format(reply_dict["errors"][0]["title"])
|
||||
self.projectCreationErrorTextChanged.emit()
|
||||
|
||||
self.setCreatingNewProjectStatus(RetrievalStatus.Failed)
|
||||
Logger.log("e", "Something went wrong while trying to create a new a project. Error: {}".format(reply_string))
|
||||
|
||||
def setRetrievingProjectsStatus(self, new_status: RetrievalStatus) -> None:
|
||||
"""
|
||||
Sets the status of the "retrieving library projects" http call.
|
||||
|
||||
:param new_status: The new status
|
||||
"""
|
||||
self.retrieving_projects_status = new_status
|
||||
self.retrievingProjectsStatusChanged.emit(int(new_status))
|
||||
|
||||
@pyqtProperty(int, fset = setRetrievingProjectsStatus, notify = retrievingProjectsStatusChanged)
|
||||
def retrievingProjectsStatus(self) -> int:
|
||||
return int(self.retrieving_projects_status)
|
||||
|
||||
def setRetrievingFilesStatus(self, new_status: RetrievalStatus) -> None:
|
||||
"""
|
||||
Sets the status of the "retrieving files list in the selected library project" http call.
|
||||
|
||||
:param new_status: The new status
|
||||
"""
|
||||
self.retrieving_files_status = new_status
|
||||
self.retrievingFilesStatusChanged.emit(int(new_status))
|
||||
|
||||
@pyqtProperty(int, fset = setRetrievingFilesStatus, notify = retrievingFilesStatusChanged)
|
||||
def retrievingFilesStatus(self) -> int:
|
||||
return int(self.retrieving_files_status)
|
||||
|
||||
def setCreatingNewProjectStatus(self, new_status: RetrievalStatus) -> None:
|
||||
"""
|
||||
Sets the status of the "creating new library project" http call.
|
||||
|
||||
:param new_status: The new status
|
||||
"""
|
||||
self.creating_new_project_status = new_status
|
||||
self.creatingNewProjectStatusChanged.emit(int(new_status))
|
||||
|
||||
@pyqtProperty(int, fset = setCreatingNewProjectStatus, notify = creatingNewProjectStatusChanged)
|
||||
def creatingNewProjectStatus(self) -> int:
|
||||
return int(self.creating_new_project_status)
|
||||
|
||||
@staticmethod
|
||||
def _onEngineCreated() -> None:
|
||||
qmlRegisterUncreatableType(DFRetrievalStatus, "DigitalFactory", 1, 0, "RetrievalStatus", "Could not create RetrievalStatus enum type")
|
||||
|
||||
def _applicationInitializationFinished(self) -> None:
|
||||
self._supported_file_types = self._application.getInstance().getMeshFileHandler().getSupportedFileTypesRead()
|
||||
|
||||
# Although Cura supports these, it's super confusing in this context to show them.
|
||||
for extension in ["jpg", "jpeg", "png", "bmp", "gif"]:
|
||||
if extension in self._supported_file_types:
|
||||
del self._supported_file_types[extension]
|
||||
|
||||
@pyqtSlot()
|
||||
def openSelectedFiles(self) -> None:
|
||||
""" Downloads, then opens all files selected in the Qt frontend open dialog.
|
||||
"""
|
||||
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
if temp_dir is None or temp_dir == "":
|
||||
Logger.error("Digital Library: Couldn't create temporary directory to store to-be downloaded files.")
|
||||
return
|
||||
|
||||
if self._selected_project_idx < 0 or len(self._selected_file_indices) < 1:
|
||||
Logger.error("Digital Library: No project or no file selected on open action.")
|
||||
return
|
||||
|
||||
to_erase_on_done_set = {
|
||||
os.path.join(temp_dir, self._file_model.getItem(i)["fileName"]).replace('\\', '/')
|
||||
for i in self._selected_file_indices}
|
||||
|
||||
def onLoadedCallback(filename_done: str) -> None:
|
||||
filename_done = os.path.join(temp_dir, filename_done).replace('\\', '/')
|
||||
with self._erase_temp_files_lock:
|
||||
if filename_done in to_erase_on_done_set:
|
||||
try:
|
||||
os.remove(filename_done)
|
||||
to_erase_on_done_set.remove(filename_done)
|
||||
if len(to_erase_on_done_set) < 1 and os.path.exists(temp_dir):
|
||||
os.rmdir(temp_dir)
|
||||
except (IOError, OSError) as ex:
|
||||
Logger.error("Can't erase temporary (in) {0} because {1}.", temp_dir, str(ex))
|
||||
|
||||
# Save the project id to make sure it will be preselected the next time the user opens the save dialog
|
||||
CuraApplication.getInstance().getCurrentWorkspaceInformation().setEntryToStore("digital_factory", "library_project_id", library_project_id)
|
||||
|
||||
# Disconnect the signals so that they are not fired every time another (project) file is loaded
|
||||
app.fileLoaded.disconnect(onLoadedCallback)
|
||||
app.workspaceLoaded.disconnect(onLoadedCallback)
|
||||
|
||||
app = CuraApplication.getInstance()
|
||||
app.fileLoaded.connect(onLoadedCallback) # fired when non-project files are loaded
|
||||
app.workspaceLoaded.connect(onLoadedCallback) # fired when project files are loaded
|
||||
|
||||
project_name = self._project_model.getItem(self._selected_project_idx)["displayName"]
|
||||
for file_index in self._selected_file_indices:
|
||||
file_item = self._file_model.getItem(file_index)
|
||||
file_name = file_item["fileName"]
|
||||
download_url = file_item["downloadUrl"]
|
||||
library_project_id = file_item["libraryProjectId"]
|
||||
self._openSelectedFile(temp_dir, project_name, file_name, download_url)
|
||||
|
||||
def _openSelectedFile(self, temp_dir: str, project_name: str, file_name: str, download_url: str) -> None:
|
||||
""" Downloads, then opens, the single specified file.
|
||||
|
||||
:param temp_dir: The already created temporary directory where the files will be stored.
|
||||
:param project_name: Name of the project the file belongs to (used for error reporting).
|
||||
:param file_name: Name of the file to be downloaded and opened (used for error reporting).
|
||||
:param download_url: This url will be downloaded, then the downloaded file will be opened in Cura.
|
||||
"""
|
||||
if not download_url:
|
||||
Logger.log("e", "No download url for file '{}'".format(file_name))
|
||||
return
|
||||
|
||||
progress_message = Message(text = "{0}/{1}".format(project_name, file_name), dismissable = False, lifetime = 0,
|
||||
progress = 0, title = "Downloading...")
|
||||
progress_message.setProgress(0)
|
||||
progress_message.show()
|
||||
|
||||
def progressCallback(rx: int, rt: int) -> None:
|
||||
progress_message.setProgress(math.floor(rx * 100.0 / rt))
|
||||
|
||||
def finishedCallback(reply: QNetworkReply) -> None:
|
||||
progress_message.hide()
|
||||
try:
|
||||
with open(os.path.join(temp_dir, file_name), "wb+") as temp_file:
|
||||
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
|
||||
while bytes_read:
|
||||
temp_file.write(bytes_read)
|
||||
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
|
||||
CuraApplication.getInstance().processEvents()
|
||||
temp_file_name = temp_file.name
|
||||
except IOError as ex:
|
||||
Logger.logException("e", "Can't write Digital Library file {0}/{1} download to temp-directory {2}.",
|
||||
ex, project_name, file_name, temp_dir)
|
||||
Message(
|
||||
text = "Failed to write to temporary file for '{}'.".format(file_name),
|
||||
title = "File-system error",
|
||||
lifetime = 10
|
||||
).show()
|
||||
return
|
||||
|
||||
CuraApplication.getInstance().readLocalFile(
|
||||
QUrl.fromLocalFile(temp_file_name), add_to_recent_files = False)
|
||||
|
||||
def errorCallback(reply: QNetworkReply, error: QNetworkReply.NetworkError, p = project_name,
|
||||
f = file_name) -> None:
|
||||
progress_message.hide()
|
||||
Logger.error("An error {0} {1} occurred while downloading {2}/{3}".format(str(error), str(reply), p, f))
|
||||
Message(
|
||||
text = "Failed Digital Library download for '{}'.".format(f),
|
||||
title = "Network error {}".format(error),
|
||||
lifetime = 10
|
||||
).show()
|
||||
|
||||
download_manager = HttpRequestManager.getInstance()
|
||||
download_manager.get(download_url, callback = finishedCallback, download_progress_callback = progressCallback,
|
||||
error_callback = errorCallback, scope = UltimakerCloudScope(CuraApplication.getInstance()))
|
||||
|
||||
def setHasPreselectedProject(self, new_has_preselected_project: bool) -> None:
|
||||
if not new_has_preselected_project:
|
||||
# The preselected project was the only one in the model, at index 0, so when we set the has_preselected_project to
|
||||
# false, we also need to clean it from the projects model
|
||||
self._project_model.clearProjects()
|
||||
self.setSelectedProjectIndex(-1)
|
||||
self._api.getProjectsFirstPage(search_filter = self._project_filter, on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed)
|
||||
self._api.checkUserCanCreateNewLibraryProject(callback = self.setCanCreateNewLibraryProject)
|
||||
self.setRetrievingProjectsStatus(RetrievalStatus.InProgress)
|
||||
self._has_preselected_project = new_has_preselected_project
|
||||
self.preselectedProjectChanged.emit()
|
||||
|
||||
@pyqtProperty(bool, fset = setHasPreselectedProject, notify = preselectedProjectChanged)
|
||||
def hasPreselectedProject(self) -> bool:
|
||||
return self._has_preselected_project
|
||||
|
||||
def setCanCreateNewLibraryProject(self, can_create_new_library_project: bool) -> None:
|
||||
self._user_account_can_create_new_project = can_create_new_library_project
|
||||
self.userCanCreateNewLibraryProjectChanged.emit(self._user_account_can_create_new_project)
|
||||
|
||||
@pyqtProperty(bool, fset = setCanCreateNewLibraryProject, notify = userCanCreateNewLibraryProjectChanged)
|
||||
def userAccountCanCreateNewLibraryProject(self) -> bool:
|
||||
return self._user_account_can_create_new_project
|
||||
|
||||
@pyqtSlot(str, "QStringList")
|
||||
def saveFileToSelectedProject(self, filename: str, formats: List[str]) -> None:
|
||||
"""
|
||||
Function triggered whenever the Save button is pressed.
|
||||
|
||||
:param filename: The name (without the extension) that will be used for the files
|
||||
:param formats: List of the formats the scene will be exported to. Can include 3mf, ufp, or both
|
||||
"""
|
||||
if self._selected_project_idx == -1:
|
||||
Logger.log("e", "No DF Library project is selected.")
|
||||
return
|
||||
|
||||
if filename == "":
|
||||
Logger.log("w", "The file name cannot be empty.")
|
||||
Message(text = "Cannot upload file with an empty name to the Digital Library", title = "Empty file name provided", lifetime = 0).show()
|
||||
return
|
||||
|
||||
self._saveFileToSelectedProjectHelper(filename, formats)
|
||||
|
||||
def _saveFileToSelectedProjectHelper(self, filename: str, formats: List[str]) -> None:
|
||||
# Indicate we have started sending a job.
|
||||
self.uploadStarted.emit()
|
||||
|
||||
library_project_id = self._project_model.items[self._selected_project_idx]["libraryProjectId"]
|
||||
library_project_name = self._project_model.items[self._selected_project_idx]["displayName"]
|
||||
|
||||
# Use the file upload manager to export and upload the 3mf and/or ufp files to the DF Library project
|
||||
self.file_upload_manager = DFFileExportAndUploadManager(file_handlers = self.file_handlers, nodes = cast(List[SceneNode], self.nodes),
|
||||
library_project_id = library_project_id,
|
||||
library_project_name = library_project_name,
|
||||
file_name = filename, formats = formats,
|
||||
on_upload_error = self.uploadFileError.emit,
|
||||
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)
|
||||
|
||||
@pyqtProperty(str, notify = projectCreationErrorTextChanged)
|
||||
def projectCreationErrorText(self) -> str:
|
||||
return self._project_creation_error_text
|
|
@ -0,0 +1,43 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from .BaseModel import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class DigitalFactoryFeatureBudgetResponse(BaseModel):
|
||||
"""Class representing the capabilities of a user account for Digital Library.
|
||||
NOTE: For each max_..._projects fields, '-1' means unlimited!
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
library_can_use_business_value: Optional[bool] = False,
|
||||
library_can_use_comments: Optional[bool] = False,
|
||||
library_can_use_status: Optional[bool] = False,
|
||||
library_can_use_tags: Optional[bool] = False,
|
||||
library_can_use_technical_requirements: Optional[bool] = False,
|
||||
library_max_organization_shared_projects: Optional[int] = None, # -1 means unlimited
|
||||
library_max_private_projects: Optional[int] = None, # -1 means unlimited
|
||||
library_max_team_shared_projects: Optional[int] = None, # -1 means unlimited
|
||||
**kwargs) -> None:
|
||||
|
||||
self.library_can_use_business_value = library_can_use_business_value
|
||||
self.library_can_use_comments = library_can_use_comments
|
||||
self.library_can_use_status = library_can_use_status
|
||||
self.library_can_use_tags = library_can_use_tags
|
||||
self.library_can_use_technical_requirements = library_can_use_technical_requirements
|
||||
self.library_max_organization_shared_projects = library_max_organization_shared_projects # -1 means unlimited
|
||||
self.library_max_private_projects = library_max_private_projects # -1 means unlimited
|
||||
self.library_max_team_shared_projects = library_max_team_shared_projects # -1 means unlimited
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "max private: {}, max org: {}, max team: {}".format(
|
||||
self.library_max_private_projects,
|
||||
self.library_max_organization_shared_projects,
|
||||
self.library_max_team_shared_projects)
|
||||
|
||||
# Validates the model, raising an exception if the model is invalid.
|
||||
def validate(self) -> None:
|
||||
super().validate()
|
||||
# No validation for now, as the response can be "data: []", which should be interpreted as all False and 0's
|
116
plugins/DigitalLibrary/src/DigitalFactoryFileModel.py
Normal file
116
plugins/DigitalLibrary/src/DigitalFactoryFileModel.py
Normal file
|
@ -0,0 +1,116 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import List, Dict, Callable
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
|
||||
from UM.Logger import Logger
|
||||
from UM.Qt.ListModel import ListModel
|
||||
from .DigitalFactoryFileResponse import DigitalFactoryFileResponse
|
||||
|
||||
|
||||
DIGITAL_FACTORY_DISPLAY_DATETIME_FORMAT = "%d-%m-%Y %H:%M"
|
||||
|
||||
|
||||
class DigitalFactoryFileModel(ListModel):
|
||||
FileNameRole = Qt.UserRole + 1
|
||||
FileIdRole = Qt.UserRole + 2
|
||||
FileSizeRole = Qt.UserRole + 3
|
||||
LibraryProjectIdRole = Qt.UserRole + 4
|
||||
DownloadUrlRole = Qt.UserRole + 5
|
||||
UsernameRole = Qt.UserRole + 6
|
||||
UploadedAtRole = Qt.UserRole + 7
|
||||
|
||||
dfFileModelChanged = pyqtSignal()
|
||||
|
||||
def __init__(self, parent = None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.addRoleName(self.FileNameRole, "fileName")
|
||||
self.addRoleName(self.FileIdRole, "fileId")
|
||||
self.addRoleName(self.FileSizeRole, "fileSize")
|
||||
self.addRoleName(self.LibraryProjectIdRole, "libraryProjectId")
|
||||
self.addRoleName(self.DownloadUrlRole, "downloadUrl")
|
||||
self.addRoleName(self.UsernameRole, "username")
|
||||
self.addRoleName(self.UploadedAtRole, "uploadedAt")
|
||||
|
||||
self._files = [] # type: List[DigitalFactoryFileResponse]
|
||||
self._filters = {} # type: Dict[str, Callable]
|
||||
|
||||
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()
|
||||
|
||||
def clearFiles(self) -> None:
|
||||
self.clear()
|
||||
self._files.clear()
|
||||
self.dfFileModelChanged.emit()
|
||||
|
||||
def _update(self) -> None:
|
||||
filtered_files_list = self.getFilteredFilesList()
|
||||
|
||||
for file in filtered_files_list:
|
||||
self.appendItem({
|
||||
"fileName" : file.file_name,
|
||||
"fileId" : file.file_id,
|
||||
"fileSize": file.file_size,
|
||||
"libraryProjectId": file.library_project_id,
|
||||
"downloadUrl": file.download_url,
|
||||
"username": file.username,
|
||||
"uploadedAt": file.uploaded_at.strftime(DIGITAL_FACTORY_DISPLAY_DATETIME_FORMAT)
|
||||
})
|
||||
|
||||
self.dfFileModelChanged.emit()
|
||||
|
||||
def setFilters(self, filters: Dict[str, Callable]) -> None:
|
||||
"""
|
||||
Sets the filters and updates the files model to contain only the files that meet all of the filters.
|
||||
|
||||
:param filters: The filters to be applied
|
||||
example:
|
||||
{
|
||||
"attribute_name1": function_to_be_applied_on_DigitalFactoryFileResponse_attribute1,
|
||||
"attribute_name2": function_to_be_applied_on_DigitalFactoryFileResponse_attribute2
|
||||
}
|
||||
"""
|
||||
self.clear()
|
||||
self._filters = filters
|
||||
self._update()
|
||||
|
||||
def clearFilters(self) -> None:
|
||||
"""
|
||||
Clears all the model filters
|
||||
"""
|
||||
self.setFilters({})
|
||||
|
||||
def getFilteredFilesList(self) -> List[DigitalFactoryFileResponse]:
|
||||
"""
|
||||
Lists the files that meet all the filters specified in the self._filters. This is achieved by applying each
|
||||
filter function on the corresponding attribute for all the filters in the self._filters. If all of them are
|
||||
true, the file is added to the filtered files list.
|
||||
In order for this to work, the self._filters should be in the format:
|
||||
{
|
||||
"attribute_name": function_to_be_applied_on_the_DigitalFactoryFileResponse_attribute
|
||||
}
|
||||
|
||||
:return: The list of files that meet all the specified filters
|
||||
"""
|
||||
if not self._filters:
|
||||
return self._files
|
||||
|
||||
filtered_files_list = []
|
||||
for file in self._files:
|
||||
filter_results = []
|
||||
for attribute, filter_func in self._filters.items():
|
||||
try:
|
||||
filter_results.append(filter_func(getattr(file, attribute)))
|
||||
except AttributeError:
|
||||
Logger.log("w", "Attribute '{}' doesn't exist in objects of type '{}'".format(attribute, type(file)))
|
||||
all_filters_met = all(filter_results)
|
||||
if all_filters_met:
|
||||
filtered_files_list.append(file)
|
||||
|
||||
return filtered_files_list
|
62
plugins/DigitalLibrary/src/DigitalFactoryFileProvider.py
Normal file
62
plugins/DigitalLibrary/src/DigitalFactoryFileProvider.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
import os
|
||||
|
||||
from UM.FileProvider import FileProvider
|
||||
from UM.Logger import Logger
|
||||
from cura.API import Account
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from .DigitalFactoryController import DigitalFactoryController
|
||||
|
||||
|
||||
class DigitalFactoryFileProvider(FileProvider):
|
||||
|
||||
def __init__(self, df_controller: DigitalFactoryController) -> None:
|
||||
super().__init__()
|
||||
self._controller = df_controller
|
||||
|
||||
self.menu_item_display_text = "From Digital Library"
|
||||
self.shortcut = "Ctrl+Shift+O"
|
||||
plugin_path = os.path.dirname(os.path.dirname(__file__))
|
||||
self._dialog_path = os.path.join(plugin_path, "resources", "qml", "DigitalFactoryOpenDialog.qml")
|
||||
self._dialog = None
|
||||
|
||||
self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account
|
||||
self._controller.userAccessStateChanged.connect(self._onUserAccessStateChanged)
|
||||
self.enabled = self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess()
|
||||
self.priority = 10
|
||||
|
||||
def run(self) -> None:
|
||||
"""
|
||||
Function called every time the 'From Digital Factory' option of the 'Open File(s)' submenu is triggered
|
||||
"""
|
||||
self.loadWindow()
|
||||
|
||||
if self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess():
|
||||
self._controller.initialize()
|
||||
|
||||
if not self._dialog:
|
||||
Logger.log("e", "Unable to create the Digital Library Open dialog.")
|
||||
return
|
||||
self._dialog.show()
|
||||
|
||||
def loadWindow(self) -> None:
|
||||
"""
|
||||
Create the GUI window for the Digital Library Open dialog. If the window is already open, bring the focus on it.
|
||||
"""
|
||||
|
||||
if self._dialog: # Dialogue is already open.
|
||||
self._dialog.requestActivate() # Bring the focus on the dialogue.
|
||||
return
|
||||
|
||||
self._dialog = CuraApplication.getInstance().createQmlComponent(self._dialog_path, {"manager": self._controller})
|
||||
if not self._dialog:
|
||||
Logger.log("e", "Unable to create the Digital Library Open dialog.")
|
||||
|
||||
def _onUserAccessStateChanged(self, logged_in: bool) -> None:
|
||||
"""
|
||||
Sets the enabled status of the DigitalFactoryFileProvider according to the account's login status
|
||||
:param logged_in: The new login status
|
||||
"""
|
||||
self.enabled = logged_in and self._controller.userAccountHasLibraryAccess()
|
||||
self.enabledChanged.emit()
|
57
plugins/DigitalLibrary/src/DigitalFactoryFileResponse.py
Normal file
57
plugins/DigitalLibrary/src/DigitalFactoryFileResponse.py
Normal file
|
@ -0,0 +1,57 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from .BaseModel import BaseModel
|
||||
|
||||
DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
|
||||
|
||||
|
||||
class DigitalFactoryFileResponse(BaseModel):
|
||||
"""Class representing a file in a digital factory project."""
|
||||
|
||||
def __init__(self, client_id: str, content_type: str, file_id: str, file_name: str, library_project_id: str,
|
||||
status: str, user_id: str, username: str, uploaded_at: str, download_url: Optional[str] = "", status_description: Optional[str] = "",
|
||||
file_size: Optional[int] = 0, upload_url: Optional[str] = "", **kwargs) -> None:
|
||||
"""
|
||||
Creates a new DF file response object
|
||||
|
||||
:param client_id:
|
||||
:param content_type:
|
||||
:param file_id:
|
||||
:param file_name:
|
||||
:param library_project_id:
|
||||
:param status:
|
||||
:param user_id:
|
||||
:param username:
|
||||
:param download_url:
|
||||
:param status_description:
|
||||
:param file_size:
|
||||
:param upload_url:
|
||||
:param kwargs:
|
||||
"""
|
||||
|
||||
self.client_id = client_id
|
||||
self.content_type = content_type
|
||||
self.download_url = download_url
|
||||
self.file_id = file_id
|
||||
self.file_name = file_name
|
||||
self.file_size = file_size
|
||||
self.library_project_id = library_project_id
|
||||
self.status = status
|
||||
self.status_description = status_description
|
||||
self.upload_url = upload_url
|
||||
self.user_id = user_id
|
||||
self.username = username
|
||||
self.uploaded_at = datetime.strptime(uploaded_at, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "File: {}, from: {}, File ID: {}, Project ID: {}, Download URL: {}".format(self.file_name, self.username, self.file_id, self.library_project_id, self.download_url)
|
||||
|
||||
# Validates the model, raising an exception if the model is invalid.
|
||||
def validate(self) -> None:
|
||||
super().validate()
|
||||
if not self.file_id:
|
||||
raise ValueError("file_id is required in Digital Library file")
|
118
plugins/DigitalLibrary/src/DigitalFactoryOutputDevice.py
Normal file
118
plugins/DigitalLibrary/src/DigitalFactoryOutputDevice.py
Normal file
|
@ -0,0 +1,118 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Uranium is released under the terms of the LGPLv3 or higher.
|
||||
import os
|
||||
from typing import Optional, List
|
||||
|
||||
from UM.FileHandler.FileHandler import FileHandler
|
||||
from UM.Logger import Logger
|
||||
from UM.OutputDevice import OutputDeviceError
|
||||
from UM.OutputDevice.ProjectOutputDevice import ProjectOutputDevice
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
from cura.API import Account
|
||||
from cura.CuraApplication import CuraApplication
|
||||
from .DigitalFactoryController import DigitalFactoryController
|
||||
|
||||
|
||||
class DigitalFactoryOutputDevice(ProjectOutputDevice):
|
||||
"""Implements an OutputDevice that supports saving to the digital factory library."""
|
||||
|
||||
def __init__(self, plugin_id, df_controller: DigitalFactoryController, add_to_output_devices: bool = False, parent = None) -> None:
|
||||
super().__init__(device_id = "digital_factory", add_to_output_devices = add_to_output_devices, parent = parent)
|
||||
|
||||
self.setName("Digital Library") # Doesn't need to be translated
|
||||
self.setShortDescription("Save to Library")
|
||||
self.setDescription("Save to Library")
|
||||
self.setIconName("save")
|
||||
self.menu_entry_text = "To Digital Library"
|
||||
self.shortcut = "Ctrl+Shift+S"
|
||||
self._plugin_id = plugin_id
|
||||
self._controller = df_controller
|
||||
|
||||
plugin_path = os.path.dirname(os.path.dirname(__file__))
|
||||
self._dialog_path = os.path.join(plugin_path, "resources", "qml", "DigitalFactorySaveDialog.qml")
|
||||
self._dialog = None
|
||||
|
||||
# Connect the write signals
|
||||
self._controller.uploadStarted.connect(self._onWriteStarted)
|
||||
self._controller.uploadFileProgress.connect(self.writeProgress.emit)
|
||||
self._controller.uploadFileError.connect(self._onWriteError)
|
||||
self._controller.uploadFileSuccess.connect(self.writeSuccess.emit)
|
||||
self._controller.uploadFileFinished.connect(self._onWriteFinished)
|
||||
|
||||
self._priority = -1 # Negative value to ensure that it will have less priority than the LocalFileOutputDevice (which has 0)
|
||||
self._application = CuraApplication.getInstance()
|
||||
|
||||
self._writing = False
|
||||
|
||||
self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account
|
||||
self._controller.userAccessStateChanged.connect(self._onUserAccessStateChanged)
|
||||
self.enabled = self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess()
|
||||
|
||||
self._current_workspace_information = CuraApplication.getInstance().getCurrentWorkspaceInformation()
|
||||
|
||||
def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional[FileHandler] = None, **kwargs) -> None:
|
||||
"""Request the specified nodes to be written.
|
||||
|
||||
Function called every time the 'To Digital Factory' option of the 'Save Project' submenu is triggered or when the
|
||||
"Save to Library" action button is pressed (upon slicing).
|
||||
|
||||
:param nodes: A collection of scene nodes that should be written to the file.
|
||||
:param file_name: A suggestion for the file name to write to.
|
||||
:param limit_mimetypes: Limit the possible mimetypes to use for writing to these types.
|
||||
:param file_handler: The handler responsible for reading and writing mesh files.
|
||||
:param kwargs: Keyword arguments.
|
||||
"""
|
||||
|
||||
if self._writing:
|
||||
raise OutputDeviceError.DeviceBusyError()
|
||||
self.loadWindow()
|
||||
|
||||
if self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess():
|
||||
self._controller.nodes = nodes
|
||||
|
||||
df_workspace_information = self._current_workspace_information.getPluginMetadata("digital_factory")
|
||||
self._controller.initialize(preselected_project_id = df_workspace_information.get("library_project_id"))
|
||||
|
||||
if not self._dialog:
|
||||
Logger.log("e", "Unable to create the Digital Library Save dialog.")
|
||||
return
|
||||
self._dialog.show()
|
||||
|
||||
def loadWindow(self) -> None:
|
||||
"""
|
||||
Create the GUI window for the Digital Library Save dialog. If the window is already open, bring the focus on it.
|
||||
"""
|
||||
|
||||
if self._dialog: # Dialogue is already open.
|
||||
self._dialog.requestActivate() # Bring the focus on the dialogue.
|
||||
return
|
||||
|
||||
if not self._controller.file_handlers:
|
||||
self._controller.file_handlers = {
|
||||
"3mf": CuraApplication.getInstance().getWorkspaceFileHandler(),
|
||||
"ufp": CuraApplication.getInstance().getMeshFileHandler()
|
||||
}
|
||||
|
||||
self._dialog = CuraApplication.getInstance().createQmlComponent(self._dialog_path, {"manager": self._controller})
|
||||
if not self._dialog:
|
||||
Logger.log("e", "Unable to create the Digital Library Save dialog.")
|
||||
|
||||
def _onUserAccessStateChanged(self, logged_in: bool) -> None:
|
||||
"""
|
||||
Sets the enabled status of the DigitalFactoryOutputDevice according to the account's login status
|
||||
:param logged_in: The new login status
|
||||
"""
|
||||
self.enabled = logged_in and self._controller.userAccountHasLibraryAccess()
|
||||
self.enabledChanged.emit()
|
||||
|
||||
def _onWriteStarted(self) -> None:
|
||||
self._writing = True
|
||||
self.writeStarted.emit(self)
|
||||
|
||||
def _onWriteFinished(self) -> None:
|
||||
self._writing = False
|
||||
self.writeFinished.emit(self)
|
||||
|
||||
def _onWriteError(self) -> None:
|
||||
self._writing = False
|
||||
self.writeError.emit(self)
|
|
@ -0,0 +1,18 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Uranium is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin
|
||||
from .DigitalFactoryOutputDevice import DigitalFactoryOutputDevice
|
||||
from .DigitalFactoryController import DigitalFactoryController
|
||||
|
||||
|
||||
class DigitalFactoryOutputDevicePlugin(OutputDevicePlugin):
|
||||
def __init__(self, df_controller: DigitalFactoryController) -> None:
|
||||
super().__init__()
|
||||
self.df_controller = df_controller
|
||||
|
||||
def start(self) -> None:
|
||||
self.getOutputDeviceManager().addProjectOutputDevice(DigitalFactoryOutputDevice(plugin_id = self.getPluginId(), df_controller = self.df_controller, add_to_output_devices = True))
|
||||
|
||||
def stop(self) -> None:
|
||||
self.getOutputDeviceManager().removeProjectOutputDevice("digital_factory")
|
64
plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py
Normal file
64
plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py
Normal file
|
@ -0,0 +1,64 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from typing import List, Optional
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
|
||||
from UM.Logger import Logger
|
||||
from UM.Qt.ListModel import ListModel
|
||||
from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse
|
||||
|
||||
PROJECT_UPDATED_AT_DATETIME_FORMAT = "%d-%m-%Y"
|
||||
|
||||
|
||||
class DigitalFactoryProjectModel(ListModel):
|
||||
DisplayNameRole = Qt.UserRole + 1
|
||||
LibraryProjectIdRole = Qt.UserRole + 2
|
||||
DescriptionRole = Qt.UserRole + 3
|
||||
ThumbnailUrlRole = Qt.UserRole + 5
|
||||
UsernameRole = Qt.UserRole + 6
|
||||
LastUpdatedRole = Qt.UserRole + 7
|
||||
|
||||
dfProjectModelChanged = pyqtSignal()
|
||||
|
||||
def __init__(self, parent = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.addRoleName(self.DisplayNameRole, "displayName")
|
||||
self.addRoleName(self.LibraryProjectIdRole, "libraryProjectId")
|
||||
self.addRoleName(self.DescriptionRole, "description")
|
||||
self.addRoleName(self.ThumbnailUrlRole, "thumbnailUrl")
|
||||
self.addRoleName(self.UsernameRole, "username")
|
||||
self.addRoleName(self.LastUpdatedRole, "lastUpdated")
|
||||
self._projects = [] # type: List[DigitalFactoryProjectResponse]
|
||||
|
||||
def setProjects(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
|
||||
if self._projects == df_projects:
|
||||
return
|
||||
self._items.clear()
|
||||
self._projects = df_projects
|
||||
# self.sortProjectsBy("display_name")
|
||||
self._update(df_projects)
|
||||
|
||||
def extendProjects(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
|
||||
if not df_projects:
|
||||
return
|
||||
self._projects.extend(df_projects)
|
||||
# self.sortProjectsBy("display_name")
|
||||
self._update(df_projects)
|
||||
|
||||
def clearProjects(self) -> None:
|
||||
self.clear()
|
||||
self._projects.clear()
|
||||
self.dfProjectModelChanged.emit()
|
||||
|
||||
def _update(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
|
||||
for project in df_projects:
|
||||
self.appendItem({
|
||||
"displayName" : project.display_name,
|
||||
"libraryProjectId" : project.library_project_id,
|
||||
"description": project.description,
|
||||
"thumbnailUrl": project.thumbnail_url,
|
||||
"username": project.username,
|
||||
"lastUpdated": project.last_updated.strftime(PROJECT_UPDATED_AT_DATETIME_FORMAT) if project.last_updated else "",
|
||||
})
|
||||
self.dfProjectModelChanged.emit()
|
65
plugins/DigitalLibrary/src/DigitalFactoryProjectResponse.py
Normal file
65
plugins/DigitalLibrary/src/DigitalFactoryProjectResponse.py
Normal file
|
@ -0,0 +1,65 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from .BaseModel import BaseModel
|
||||
from .DigitalFactoryFileResponse import DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT
|
||||
|
||||
|
||||
class DigitalFactoryProjectResponse(BaseModel):
|
||||
"""Class representing a cloud project."""
|
||||
|
||||
def __init__(self,
|
||||
library_project_id: str,
|
||||
display_name: str,
|
||||
username: str,
|
||||
organization_shared: bool,
|
||||
last_updated: Optional[str] = None,
|
||||
created_at: Optional[str] = None,
|
||||
thumbnail_url: Optional[str] = None,
|
||||
organization_id: Optional[str] = None,
|
||||
created_by_user_id: Optional[str] = None,
|
||||
description: Optional[str] = "",
|
||||
tags: Optional[List[str]] = None,
|
||||
team_ids: Optional[List[str]] = None,
|
||||
status: Optional[str] = None,
|
||||
technical_requirements: Optional[Dict[str, Any]] = None,
|
||||
**kwargs) -> None:
|
||||
"""
|
||||
Creates a new digital factory project response object
|
||||
:param library_project_id:
|
||||
:param display_name:
|
||||
:param username:
|
||||
:param organization_shared:
|
||||
:param thumbnail_url:
|
||||
:param created_by_user_id:
|
||||
:param description:
|
||||
:param tags:
|
||||
:param kwargs:
|
||||
"""
|
||||
|
||||
self.library_project_id = library_project_id
|
||||
self.display_name = display_name
|
||||
self.description = description
|
||||
self.username = username
|
||||
self.organization_shared = organization_shared
|
||||
self.organization_id = organization_id
|
||||
self.created_by_user_id = created_by_user_id
|
||||
self.thumbnail_url = thumbnail_url
|
||||
self.tags = tags
|
||||
self.team_ids = team_ids
|
||||
self.created_at = datetime.strptime(created_at, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT) if created_at else None
|
||||
self.last_updated = datetime.strptime(last_updated, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT) if last_updated else None
|
||||
self.status = status
|
||||
self.technical_requirements = technical_requirements
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "Project: {}, Id: {}, from: {}".format(self.display_name, self.library_project_id, self.username)
|
||||
|
||||
# Validates the model, raising an exception if the model is invalid.
|
||||
def validate(self) -> None:
|
||||
super().validate()
|
||||
if not self.library_project_id:
|
||||
raise ValueError("library_project_id is required on cloud project")
|
55
plugins/DigitalLibrary/src/ExportFileJob.py
Normal file
55
plugins/DigitalLibrary/src/ExportFileJob.py
Normal file
|
@ -0,0 +1,55 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
import io
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from UM.FileHandler.FileHandler import FileHandler
|
||||
from UM.FileHandler.FileWriter import FileWriter
|
||||
from UM.FileHandler.WriteFileJob import WriteFileJob
|
||||
from UM.Logger import Logger
|
||||
from UM.MimeTypeDatabase import MimeTypeDatabase
|
||||
from UM.OutputDevice import OutputDeviceError
|
||||
from UM.Scene.SceneNode import SceneNode
|
||||
|
||||
|
||||
class ExportFileJob(WriteFileJob):
|
||||
"""Job that exports the build plate to the correct file format for the Digital Factory Library project."""
|
||||
|
||||
def __init__(self, file_handler: FileHandler, nodes: List[SceneNode], job_name: str, extension: str) -> None:
|
||||
file_types = file_handler.getSupportedFileTypesWrite()
|
||||
if len(file_types) == 0:
|
||||
Logger.log("e", "There are no file types available to write with!")
|
||||
raise OutputDeviceError.WriteRequestFailedError("There are no file types available to write with!")
|
||||
|
||||
mode = None
|
||||
file_writer = None
|
||||
for file_type in file_types:
|
||||
if file_type["extension"] == extension:
|
||||
file_writer = file_handler.getWriter(file_type["id"])
|
||||
mode = file_type.get("mode")
|
||||
super().__init__(file_writer, self.createStream(mode = mode), nodes, mode)
|
||||
|
||||
# Determine the filename.
|
||||
self.setFileName("{}.{}".format(job_name, extension))
|
||||
|
||||
def getOutput(self) -> bytes:
|
||||
"""Get the job result as bytes as that is what we need to upload to the Digital Factory Library."""
|
||||
|
||||
output = self.getStream().getvalue()
|
||||
if isinstance(output, str):
|
||||
output = output.encode("utf-8")
|
||||
return output
|
||||
|
||||
def getMimeType(self) -> str:
|
||||
"""Get the mime type of the selected export file type."""
|
||||
return MimeTypeDatabase.getMimeTypeForFile(self.getFileName()).name
|
||||
|
||||
@staticmethod
|
||||
def createStream(mode) -> Union[io.BytesIO, io.StringIO]:
|
||||
"""Creates the right kind of stream based on the preferred format."""
|
||||
|
||||
if mode == FileWriter.OutputMode.TextMode:
|
||||
return io.StringIO()
|
||||
else:
|
||||
return io.BytesIO()
|
30
plugins/DigitalLibrary/src/PaginationLinks.py
Normal file
30
plugins/DigitalLibrary/src/PaginationLinks.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class PaginationLinks:
|
||||
"""Model containing pagination links."""
|
||||
|
||||
def __init__(self,
|
||||
first: Optional[str] = None,
|
||||
last: Optional[str] = None,
|
||||
next: Optional[str] = None,
|
||||
prev: Optional[str] = None,
|
||||
**kwargs) -> None:
|
||||
"""
|
||||
Creates a new digital factory project response object
|
||||
:param first: The URL for the first page.
|
||||
:param last: The URL for the last page.
|
||||
:param next: The URL for the next page.
|
||||
:param prev: The URL for the prev page.
|
||||
:param kwargs:
|
||||
"""
|
||||
|
||||
self.first_page = first
|
||||
self.last_page = last
|
||||
self.next_page = next
|
||||
self.prev_page = prev
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "Pagination Links | First: {}, Last: {}, Next: {}, Prev: {}".format(self.first_page, self.last_page, self.next_page, self.prev_page)
|
43
plugins/DigitalLibrary/src/PaginationManager.py
Normal file
43
plugins/DigitalLibrary/src/PaginationManager.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from .PaginationLinks import PaginationLinks
|
||||
from .PaginationMetadata import PaginationMetadata
|
||||
from .ResponseMeta import ResponseMeta
|
||||
|
||||
|
||||
class PaginationManager:
|
||||
|
||||
def __init__(self, limit: int) -> None:
|
||||
self.limit = limit # The limit of items per page
|
||||
self.meta = None # type: Optional[ResponseMeta] # The metadata of the paginated response
|
||||
self.links = None # type: Optional[PaginationLinks] # The pagination-related links
|
||||
|
||||
def setResponseMeta(self, meta: Optional[Dict[str, Any]]) -> None:
|
||||
self.meta = None
|
||||
|
||||
if meta:
|
||||
page = None
|
||||
if "page" in meta:
|
||||
page = PaginationMetadata(**meta["page"])
|
||||
self.meta = ResponseMeta(page)
|
||||
|
||||
def setLinks(self, links: Optional[Dict[str, str]]) -> None:
|
||||
self.links = PaginationLinks(**links) if links else None
|
||||
|
||||
def setLimit(self, new_limit: int) -> None:
|
||||
"""
|
||||
Sets the limit of items per page.
|
||||
|
||||
:param new_limit: The new limit of items per page
|
||||
"""
|
||||
self.limit = new_limit
|
||||
self.reset()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""
|
||||
Sets the metadata and links to None.
|
||||
"""
|
||||
self.meta = None
|
||||
self.links = None
|
25
plugins/DigitalLibrary/src/PaginationMetadata.py
Normal file
25
plugins/DigitalLibrary/src/PaginationMetadata.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class PaginationMetadata:
|
||||
"""Class representing the metadata related to pagination."""
|
||||
|
||||
def __init__(self,
|
||||
total_count: Optional[int] = None,
|
||||
total_pages: Optional[int] = None,
|
||||
**kwargs) -> None:
|
||||
"""
|
||||
Creates a new digital factory project response object
|
||||
:param total_count: The total count of items.
|
||||
:param total_pages: The total number of pages when pagination is applied.
|
||||
:param kwargs:
|
||||
"""
|
||||
|
||||
self.total_count = total_count
|
||||
self.total_pages = total_pages
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "PaginationMetadata | Total Count: {}, Total Pages: {}".format(self.total_count, self.total_pages)
|
24
plugins/DigitalLibrary/src/ResponseMeta.py
Normal file
24
plugins/DigitalLibrary/src/ResponseMeta.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .PaginationMetadata import PaginationMetadata
|
||||
|
||||
|
||||
class ResponseMeta:
|
||||
"""Class representing the metadata included in a Digital Library response (if any)"""
|
||||
|
||||
def __init__(self,
|
||||
page: Optional[PaginationMetadata] = None,
|
||||
**kwargs) -> None:
|
||||
"""
|
||||
Creates a new digital factory project response object
|
||||
:param page: Metadata related to pagination
|
||||
:param kwargs:
|
||||
"""
|
||||
|
||||
self.page = page
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "Response Meta | {}".format(self.page)
|
0
plugins/DigitalLibrary/src/__init__.py
Normal file
0
plugins/DigitalLibrary/src/__init__.py
Normal file
|
@ -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()
|
73
plugins/DigitalLibrary/tests/TestDigitalFactoryFileModel.py
Normal file
73
plugins/DigitalLibrary/tests/TestDigitalFactoryFileModel.py
Normal file
|
@ -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
|
|
@ -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"
|
89
plugins/DigitalLibrary/tests/TestDigitalLibraryApiClient.py
Normal file
89
plugins/DigitalLibrary/tests/TestDigitalLibraryApiClient.py
Normal file
|
@ -0,0 +1,89 @@
|
|||
# Copyright (c) 2021 Ultimaker B.V.
|
||||
# Cura is released under the terms of the LGPLv3 or higher.
|
||||
|
||||
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(search_filter = "filter", 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&search=filter"
|
||||
|
||||
# Change the limit & try again
|
||||
http_manager.get.reset_mock()
|
||||
pagination_manager.limit = 80
|
||||
api_client.getProjectsFirstPage(search_filter = "filter", 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&search=filter"
|
||||
|
||||
|
||||
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()
|
5
plugins/DigitalLibrary/tests/conftest.py
Normal file
5
plugins/DigitalLibrary/tests/conftest.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
|
||||
# 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__)), ".."))
|
|
@ -3,6 +3,6 @@
|
|||
"author": "Ultimaker B.V.",
|
||||
"version": "1.0.1",
|
||||
"description": "Checks for firmware updates.",
|
||||
"api": "7.4.0",
|
||||
"api": 7,
|
||||
"i18n-catalog": "cura"
|
||||
}
|
||||
|
|
|
@ -3,6 +3,6 @@
|
|||
"author": "Ultimaker B.V.",
|
||||
"version": "1.0.1",
|
||||
"description": "Provides a machine actions for updating firmware.",
|
||||
"api": "7.4.0",
|
||||
"api": 7,
|
||||
"i18n-catalog": "cura"
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue