16 Commits

Author SHA1 Message Date
KatKatKateryna 237381939f Prompt to login, if no accounts locally yet (#46)
build_qgis / build-connector (push) Has been cancelled
build_qgis / deploy-installers (push) Has been cancelled
* upgrade specklepy

* load UI without account

* call account widget, resize account search window
2025-05-16 21:27:19 +01:00
KatKatKateryna 6a8b2d9e28 Desktop services auth (#44)
build_qgis / build-connector (push) Has been cancelled
build_qgis / deploy-installers (push) Has been cancelled
* add accounts via desktop service

* remove widget on bckgr click

* correct Add new account btn behavior

* don't duplicate account cards

* refresh workspace list dropdown; add READY button

* filter out personal workspace projects from projects
2025-05-04 23:54:12 +01:00
KatKatKateryna 54241d427e send non-horizontal polygons as meshes (#39)
* send non-horizontal polygons as meshes

* merge mesh per feature

* fix

* substiture regions with meshes

* move to utils
2025-05-02 13:57:26 +01:00
KatKatKateryna 600c4460c1 Fix workspace access (#42)
* cleaned permissions

* remove Workspace field on project creation

* project creation in the correct workspace

* .
2025-05-01 23:13:13 +01:00
Jedd Morgan 8c49ff8ee0 refactor(ci): Update workflow to use new consolidated deployment workflow (#36)
* test1

* trigger

* Correct wild card

* is_public_release

* use upstream fork

* target main
2025-05-01 16:34:38 +02:00
KatKatKateryna 5bd67fa171 Add project search by selected Workspace (#41)
* add workspace selection menu, update specklepy

* styling

* add filter by workspace id

* customize workspace query (possibly includes non-writeable project by active user)

* filter roles
2025-05-01 16:28:08 +02:00
KatKatKateryna 0f5957cbd0 check for QtVatiant NULL for symbols lookup (#40) 2025-05-01 11:10:43 +01:00
KatKatKateryna 6cd0d47dae add RenderMaterials and update specklepy with requirements (#38)
build_qgis / build-connector (push) Has been cancelled
build_qgis / deploy-installers (push) Has been cancelled
2025-04-22 16:01:23 +01:00
KatKatKateryna 72e6566334 Send regions with Meshes for displayValue (#35)
build_qgis / build-connector (push) Has been cancelled
build_qgis / deploy-installers (push) Has been cancelled
* send Regions

* send displayValue meshes

* fix meshing

* fixed again

* specklepy update and typo
2025-03-29 02:00:57 +08:00
KatKatKateryna 19b87984e4 clear UI if task exited (#34) 2025-03-14 08:29:02 +08:00
KatKatKateryna c694cf57a9 release-triggering commit
build_qgis / build-connector (push) Has been cancelled
build_qgis / deploy-installers (push) Has been cancelled
2025-03-06 10:42:55 +00:00
KatKatKateryna 2f54e90cdf Metrics version fix (#32)
build_qgis / build-connector (push) Has been cancelled
build_qgis / deploy-installers (push) Has been cancelled
* calculate metrics inside the plugin

* calculate and store metric vals in Utils
2025-02-28 20:30:46 +08:00
KatKatKateryna 65a36557be update specklepy dependency
build_qgis / build-connector (push) Has been cancelled
build_qgis / deploy-installers (push) Has been cancelled
2025-02-26 16:47:15 +00:00
KatKatKateryna b635cbde8a add version and experimental tag (#31) 2025-02-27 00:35:21 +08:00
KatKatKateryna df710deefe rename hostapp for dependencies folder 2025-02-26 16:21:36 +00:00
KatKatKateryna 318daf7b48 rename workflow 2025-02-26 15:10:04 +00:00
24 changed files with 1615 additions and 967 deletions
+18 -9
View File
@@ -1,8 +1,8 @@
name: build_powerbi
name: build_qgis
on:
push:
branches: ["main", "dev", "release/*", "alan/*"] # Continuous delivery on every long-lived branch
tags: ["v3.*"] # Manual delivery on every 3.x tag
branches: ["main", "installer-test/**"] # Continuous delivery on every long-lived branch
tags: ["v3.*.*"] # Manual delivery on every v3.x tag
env:
ZipName: "qgis.zip"
@@ -41,6 +41,10 @@ jobs:
run: |
python plugin_utils/patch_requirements.py
- name: Update version in plugin metadata.txt
run: |
python plugin_utils/patch_version.py ${{ env.GitVersion_FullSemVer }}
- name: Install dependencies
run: |
pip install pb_tool==3.1.0 pyqt5==5.15.9
@@ -71,16 +75,21 @@ jobs:
runs-on: ubuntu-latest
needs: build-connector
env:
IS_TAG_BUILD: ${{ github.ref_type == 'tag' }}
IS_RELEASE_BRANCH: ${{ startsWith(github.ref_name, 'release/') || github.ref_name == 'main'}}
IS_PUBLIC_RELEASE: ${{ github.ref_type == 'tag' }}
steps:
- name: 🔫 Trigger Build QGIS
uses: ALEEF02/workflow-dispatch@v3.0.0
- name: 🔫 Trigger Build Installer(s)
uses: the-actions-org/workflow-dispatch@v4.0.0
with:
workflow: Build QGIS
workflow: Build Installers
repo: specklesystems/connector-installers
token: ${{ secrets.CONNECTORS_GH_TOKEN }}
inputs: '{ "run_id": "${{ github.run_id }}", "semver": "${{ needs.build-connector.outputs.semver }}", "file_version": "${{ needs.build-connector.outputs.file-version }}", "public_release": ${{ env.IS_TAG_BUILD }} }'
inputs: '{
"run_id": "${{ github.run_id }}",
"semver": "${{ needs.build-connector.outputs.semver }}",
"file_version": "${{ needs.build-connector.outputs.file-version }}",
"repo": "${{ github.repository }}",
"is_public_release": ${{ env.IS_PUBLIC_RELEASE }}
}'
ref: main
wait-for-completion: true
wait-for-completion-interval: 10s
+1 -21
View File
@@ -11,8 +11,6 @@ try:
from plugin_utils.installer import ensure_dependencies, startDebugger
from plugin_utils.panel_logging import logger
from qgis.core import Qgis
# noinspection PyPep8Naming
def classFactory(iface): # pylint: disable=invalid-name
"""Load SpeckleQGIS class from file SpeckleQGIS.
@@ -27,29 +25,11 @@ try:
# Ensure dependencies are installed in the machine
startDebugger()
ensure_dependencies("QGIS")
ensure_dependencies("QGISv3")
from speckle_qgis_v3 import SpeckleQGIS
from specklepy.logging import metrics
version = (
Qgis.QGIS_VERSION.encode("iso-8859-1", errors="ignore")
.decode("utf-8")
.split(".")[0]
)
metrics.set_host_app("qgis", version)
return SpeckleQGIS(iface)
class EmptyClass:
# https://docs.qgis.org/3.28/en/docs/pyqgis_developer_cookbook/plugins/plugins.html#mainplugin-py
def __init__(self, iface):
pass
def initGui(self):
pass
def unload(self):
pass
except ModuleNotFoundError:
pass
-97
View File
@@ -1,97 +0,0 @@
import re
import sys
def patch_installer(tag):
"""Patches the installer with the correct connector version and specklepy version"""
iss_file = "speckle-sharp-ci-tools/qgis.iss"
metadata = "metadata.txt"
plugin_start_file = "speckle_qgis.py"
try:
with open(iss_file, "r") as file:
lines = file.readlines()
new_lines = []
for i, line in enumerate(lines):
if "#define AppVersion " in line:
line = f'#define AppVersion "{tag.split("-")[0]}"\n'
if "#define AppInfoVersion " in line:
line = f'#define AppInfoVersion "{tag}"\n'
new_lines.append(line)
with open(iss_file, "w") as file:
file.writelines(new_lines)
print(f"Patched installer with connector v{tag} ")
file.close()
except:
pass
with open(metadata, "r") as file:
lines = file.readlines()
new_lines = []
for i, line in enumerate(lines):
if "version=" in line:
line = f"version={tag}\n" # .split("-")[0]
if "experimental=" in line:
if "-" in tag:
line = f"experimental=True\n" # .split("-")[0]
elif len(tag.split(".")) == 3 and tag != "0.0.99":
line = f"experimental=False\n" # .split("-")[0]
new_lines.append(line)
with open(metadata, "w") as file:
file.writelines(new_lines)
print(f"Patched metadata v{tag} ")
file.close()
with open(plugin_start_file, "r") as file:
lines = file.readlines()
for i, line in enumerate(lines):
if "self.version = " in line:
lines[i] = (
lines[i].split('"')[0]
+ '"'
+ tag.split("-")[0]
+ '"'
+ lines[i].split('"')[2]
)
break
with open(plugin_start_file, "w") as file:
file.writelines(lines)
print(f"Patched GIS start file with connector v{tag} and specklepy ")
file.close()
r"""
def whlFileRename(fileName: str):
with open(fileName, "r") as file:
lines = file.readlines()
for i, line in enumerate(lines):
if "-py3-none-any.whl" in line:
p1 = line.split("-py3-none-any.whl")[0].split("-")[0]
p2 = f'{tag.split("-")[0]}'
p3 = line.split("-py3-none-any.whl")[1]
lines[i] = p1+"-"+p2+"-py3-none-any.whl"+p3
with open(fileName, "w") as file:
file.writelines(lines)
print(f"Patched toolbox_installer with connector v{tag} and specklepy ")
file.close()
whlFileRename(conda_file)
whlFileRename(toolbox_install_file)
whlFileRename(toolbox_manual_install_file)
"""
def main():
if len(sys.argv) < 2:
return
tag = sys.argv[1]
if not re.match(r"([0-9]+)\.([0-9]+)\.([0-9]+)", tag):
raise ValueError(f"Invalid tag provided: {tag}")
print(f"Patching version: {tag}")
# patch_connector(tag.split("-")[0]) if I need to edit a connector file
patch_installer(tag)
if __name__ == "__main__":
main()
+39
View File
@@ -0,0 +1,39 @@
import sys
def patch_installer(tag):
"""Patches the installer with the correct connector version and specklepy version"""
metadata = "metadata.txt"
with open(metadata, "r") as file:
lines = file.readlines()
new_lines = []
for i, line in enumerate(lines):
if "version=" in line:
line = f"version={tag}\n"
if "experimental=" in line:
if "-" in tag:
line = f"experimental=True\n"
elif len(tag.split(".")) == 3 and tag != "0.0.99" and "-" not in tag:
line = f"experimental=False\n" # .split("-")[0]
new_lines.append(line)
with open(metadata, "w") as file:
file.writelines(new_lines)
print(f"Patched metadata v{tag} ")
file.close()
def main():
if len(sys.argv) < 2:
return
tag = sys.argv[1]
print(f"Patching version: {tag}")
patch_installer(tag)
if __name__ == "__main__":
main()
+25 -25
View File
@@ -1,51 +1,51 @@
annotated-types==0.7.0
anyio==4.8.0
anyio==4.9.0
appdirs==1.4.4
attrs==25.1.0
attrs==25.3.0
backoff==2.2.1
certifi==2025.1.31
charset-normalizer==3.4.1
certifi==2025.4.26
charset-normalizer==3.4.2
click-plugins==1.1.1
click==8.1.8
click==8.2.0
cligj==0.7.2
colorama==0.4.6
deprecated==1.2.18
earcut==1.1.5
exceptiongroup==1.2.2
exceptiongroup==1.3.0
fiona==1.10.1
geopandas==0.13.2
geovoronoi==0.4.0
gql==3.5.0
graphql-core==3.2.6
h11==0.14.0
httpcore==1.0.7
gql==3.5.2
graphql-core==3.2.4
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
idna==3.10
multidict==6.1.0
multidict==6.4.3
numpy==1.26.4
packaging==24.2
packaging==25.0
pandas==2.2.3
propcache==0.2.1
pydantic-core==2.27.2
pydantic-settings==2.7.1
pydantic==2.10.6
propcache==0.3.1
pydantic-core==2.33.2
pydantic-settings==2.9.1
pydantic==2.11.4
pyproj==3.7.1
pyshp==2.3.1
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
pytz==2025.1
python-dotenv==1.1.0
pytz==2025.2
requests-toolbelt==1.0.0
requests==2.31.0
scipy==1.15.2
shapely==2.0.7
scipy==1.15.3
shapely==2.1.0
six==1.17.0
sniffio==1.3.1
specklepy==3.0.0a2
stringcase==1.2.0
typing-extensions==4.12.2
tzdata==2025.1
specklepy==3.0.0a15
typing-extensions==4.13.2
typing-inspection==0.4.0
tzdata==2025.2
ujson==5.10.0
urllib3==2.2.1
websockets==11.0.3
wrapt==1.17.2
yarl==1.18.3
yarl==1.20.0
Generated
+758 -700
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -17,7 +17,7 @@ geopandas = "0.13.2"
geovoronoi = "0.4.0"
scipy = "^1.13.0"
earcut = "1.1.5"
specklepy = {version = "^3.0.0a1", allow-prereleases = true}
specklepy = {version = "^3.0.0a15", allow-prereleases = true}
[tool.poetry.group.dev.dependencies]
pytest = "^7.1.3"
+11 -4
View File
@@ -6,7 +6,7 @@ from specklepy.objects.models.collections.collection import Collection
from specklepy.objects.proxies import ColorProxy
from qgis.core import QgsLayerTreeGroup, QgsVectorLayer, QgsRasterLayer, QgsFeature
from PyQt5.QtCore import pyqtSignal, QObject, QTimer
from PyQt5.QtCore import pyqtSignal, QObject, QTimer, QVariant
class MetaQObject(type(QObject), type(DocumentModelStore)):
@@ -209,8 +209,11 @@ class QgisColorUnpacker:
) -> Any:
feature_value_for_rendering = feature.attribute(self.stored_renderer_field)
category_index = renderer.categoryIndexForValue(feature_value_for_rendering)
value_symbol = renderer.categories()[category_index].symbol()
if not isinstance(feature_value_for_rendering, QVariant): # for QVariant.NULL
category_index = renderer.categoryIndexForValue(feature_value_for_rendering)
value_symbol = renderer.categories()[category_index].symbol()
else:
value_symbol = renderer.sourceSymbol()
if not value_symbol:
value_symbol = renderer.sourceSymbol()
@@ -223,7 +226,11 @@ class QgisColorUnpacker:
) -> Any:
feature_value_for_rendering = feature.attribute(self.stored_renderer_field)
value_symbol = renderer.symbolForValue(feature_value_for_rendering)
if not isinstance(feature_value_for_rendering, QVariant): # for QVariant.NULL
value_symbol = renderer.symbolForValue(feature_value_for_rendering)
else:
value_symbol = renderer.sourceSymbol()
if not value_symbol:
value_symbol = renderer.sourceSymbol()
@@ -1,6 +1,7 @@
from dataclasses import dataclass
from typing import Any, List, Optional
from specklepy.objects.geometry import Region
from speckle.ui.bindings import SelectionInfo
from speckle.ui.models import ModelCard, SenderModelCard
@@ -234,3 +235,35 @@ class QgisLayerUtils:
selected_object_ids=[layer.id or layer.name for layer in selected_layers],
summary=f"{len(selected_layers)} layers ({", ".join(object_types)})",
)
def confirm_features_type(self, converted_features: List["QgisObject"]) -> None:
# check if it's a Polygon layer and it has vertical data (needs to be converted to Meshes)
convert_regions_to_meshes = False
for feature in converted_features:
polygon_dataset = True
for display_region in feature.displayValue:
if not isinstance(display_region, Region):
polygon_dataset = False
break
else: # if Region
if getattr(
display_region, "3d", None
): # sufficient condition to convert all dataset features to meshes
convert_regions_to_meshes = True
break
if convert_regions_to_meshes or not polygon_dataset:
break
# modify list of features if needed
if convert_regions_to_meshes:
for i, feature in enumerate(converted_features):
display_meshes = []
# replace displayValue of Region list to Mesh list
for display_region in feature.displayValue: # region
display_meshes.extend(display_region.displayValue)
converted_features[i].displayValue = display_meshes
@@ -19,12 +19,14 @@ from speckle.ui.models import SendInfo
from specklepy.objects.base import Base
# from specklepy.objects.data import QgisObject
from specklepy.objects.geometry.mesh import Mesh
from specklepy.objects.geometry import Mesh
from specklepy.objects.models.collections.collection import Collection
from qgis.core import QgsProject, QgsVectorLayer, QgsRasterLayer
from speckle.host_apps.qgis.connectors.utils import UNSUPPORTED_PROVIDERS
from specklepy.objects.other import RenderMaterial
from specklepy.objects.proxies import RenderMaterialProxy
class QgisRootObjectBuilder(IRootObjectBuilder):
@@ -148,6 +150,17 @@ class QgisRootObjectBuilder(IRootObjectBuilder):
self.color_unpacker.color_proxy_cache.values()
)
# duplicate colors into render materials
root_collection[ProxyKeys().RENDER_MATERIAL] = [
RenderMaterialProxy(
objects=x.objects,
value=RenderMaterial(
applicationId=x.applicationId, name=x.applicationId, diffuse=x.value
),
)
for x in self.color_unpacker.color_proxy_cache.values()
]
return RootObjectBuilderResult(
root_object=root_collection,
conversion_results=results,
@@ -174,6 +187,9 @@ class QgisRootObjectBuilder(IRootObjectBuilder):
feature, get_speckle_app_id(feature, layer_app_id)
)
# for 3d polygons: replace Regions with Meshes
self.layer_utils.confirm_features_type(converted_features)
return converted_features
def convert_raster_feature(
+39 -2
View File
@@ -1,15 +1,52 @@
import os
from typing import Callable
from pathlib import Path
from specklepy.logging import metrics
from speckle.sdk.connectors_common.threading import ThreadContext
from qgis.core import Qgis
from qgis.core import Qgis, QgsApplication
UNSUPPORTED_PROVIDERS = ["WFS", "wms", "wcs", "vectortile"]
HOST_APP_FULL_VERSION = (
Qgis.QGIS_VERSION.encode("iso-8859-1", errors="ignore")
.decode("utf-8")
.split("-")[0]
)
UNSUPPORTED_PROVIDERS = ["WFS", "wms", "wcs", "vectortile"]
def get_core_version():
metadata_path = os.path.join(
QgsApplication.qgisSettingsDirPath(),
"python",
"plugins",
"speckle-qgis-v3",
"metadata.txt",
)
core_version = "3.0.099-alpha"
with open(metadata_path, "r") as file:
for i, line in enumerate(file.readlines()):
if "version=" in line:
core_version = line.replace("version=", "").replace("\n", "")
break
file.close()
return core_version
CORE_VERSION = get_core_version()
def setup_metrics():
# set hostApp and hostAppVersion
version = (
Qgis.QGIS_VERSION.encode("iso-8859-1", errors="ignore")
.decode("utf-8")
.split(".")[0]
)
metrics.set_host_app("qgis", version)
class QgisThreadContext(ThreadContext):
@@ -0,0 +1,102 @@
import math
from typing import List
import earcut.earcut
from specklepy.objects.geometry.mesh import Mesh
from specklepy.objects.geometry.polyline import Polyline
def generate_region_mesh(boundary: Polyline, inner_loops: List[Polyline], units: str):
"""Generate Speckle Mesh for a planar shape represented by boundary and inner loops."""
# Get a 'list of coordinate tuples' for boundary points
vertices3d_tuples: List[List[float]] = _flat_coords_to_tuples(boundary)
# Get a list of 'lists of coordinate tuples' for inner loops
loops3d_tuples_list: List[List[List[float]]] = []
for loop in inner_loops:
vertices3d_loop_tuples = _flat_coords_to_tuples(loop)
loops3d_tuples_list.append(vertices3d_loop_tuples)
# triangulate region
all_coords, triangles = _get_all_coords_and_triangles(
vertices3d_tuples, loops3d_tuples_list
)
# construct mesh
mesh: Mesh = _construct_mesh_from_triangles(all_coords, triangles, units)
return mesh
def _flat_coords_to_tuples(polyline: Polyline):
"""Reduce resolution of the given polyline (if vertices exceed max amount),
and return the list of vertices' coordinate tuples."""
max_points = 1000
coef = math.ceil(len(polyline.value) / (3 * max_points))
# Get a list of coordinate tuples for polyline points
points_count = int(len(polyline.value) / 3)
coordinates_tuples: List[List[float]] = [
(
polyline.value[i * coef * 3],
polyline.value[i * coef * 3 + 1],
polyline.value[i * coef * 3 + 2],
)
for i, _ in enumerate(polyline.value)
if i * coef < points_count
]
return coordinates_tuples
def _get_all_coords_and_triangles(
vertices3d_tuples: List[List[float]], loops3d_tuples: List[List[List[float]]]
):
"""Triangulate the shape given tuple lists of boundary and loops' coordinates.
Return full flat list of triangulated vertices and list of triangle tuples."""
data = earcut.earcut.flatten([vertices3d_tuples] + loops3d_tuples)
triangles_flat_list = earcut.earcut.earcut(data["vertices"], data["holes"], dim=3)
triangle_tuples = [
[
triangles_flat_list[3 * i],
triangles_flat_list[3 * i + 1],
triangles_flat_list[3 * i + 2],
]
for i, _ in enumerate(triangles_flat_list)
if i < len(triangles_flat_list) / 3
]
return data["vertices"], triangle_tuples
def _construct_mesh_from_triangles(all_coords, triangles, units) -> Mesh:
"""Construct Speckle Mesh given a flat list of coordinates and a list of triangles
(defined by tuples with vertices' indices)."""
total_vertices = 0
vertices = []
faces = []
for trg in triangles:
# make sure all faces are clockwise (facing down). Seems earcut already returns clockwise faces
vertices.extend(
all_coords[3 * trg[0] : 3 * trg[0] + 3]
+ all_coords[3 * trg[1] : 3 * trg[1] + 3]
+ all_coords[3 * trg[2] : 3 * trg[2] + 3]
)
faces.extend(
[
3,
total_vertices,
total_vertices + 1,
total_vertices + 2,
]
)
total_vertices += 3
return Mesh(vertices=vertices, faces=faces, units=units)
@@ -3,9 +3,9 @@ from typing import List
from speckle.host_apps.qgis.connectors.extensions import get_speckle_app_id
from speckle.host_apps.qgis.converters.settings import QgisConversionSettings
from specklepy.objects.geometry.point import Point
from specklepy.objects.geometry.polyline import Polyline
from specklepy.objects.geometry.mesh import Mesh
from speckle.host_apps.qgis.converters.to_speckle.mesher import generate_region_mesh
from specklepy.objects.base import Base
from specklepy.objects.geometry import Mesh, Point, Polyline, Region
from qgis.core import (
QgsAbstractGeometry,
@@ -124,7 +124,7 @@ class PolygonToSpeckleConverter:
self._conversion_settings = conversion_settings
self._polyline_converter = polyline_converter
def convert(self, target: QgsAbstractGeometry) -> List[Polyline]:
def convert(self, target: QgsAbstractGeometry) -> List[Base]:
wkb_type = target.wkbType()
@@ -142,18 +142,39 @@ class PolygonToSpeckleConverter:
or wkb_type == QgsWkbTypes.CurvePolygonM
or wkb_type == QgsWkbTypes.CurvePolygonZM
):
all_curves = []
all_regions = []
all_z_values = []
for part in target.parts():
all_curves.append(
self._polyline_converter.convert(part.exteriorRing())[0]
boundary = self._polyline_converter.convert(part.exteriorRing())[0]
all_z_values.extend(
[x for i, x in enumerate(boundary.value) if (i + 1) % 3 == 0]
)
inner_loops = []
for i in range(part.numInteriorRings()):
all_curves.append(
inner_loops.append(
self._polyline_converter.convert(part.interiorRing(i))[0]
)
return all_curves
display_mesh: Mesh = generate_region_mesh(
boundary, inner_loops, self._conversion_settings.speckle_units
)
new_region = Region(
boundary=boundary,
innerLoops=inner_loops,
hasHatchPattern=False,
displayValue=[display_mesh],
units=self._conversion_settings.speckle_units,
)
# hacky way to indicate that all features in the dataset should be sent as Meshes instead of Regions
if len(list(set(all_z_values))) > 1:
new_region["3d"] = True
all_regions.append(new_region)
# return list of Meshes, if not horizontal Polygon
return all_regions
raise ValueError(f"Geometry of type '{type(target)}' cannot be converted")
+22 -17
View File
@@ -96,24 +96,29 @@ class SpeckleQGISv3Module:
on_operation_progressed: "IProgress[CardProgress]",
ct: "CancellationToken",
):
# first, update UI status
self.dockwidget.activity_start_signal.emit(
model_card_id, "Converting and sending.."
)
print("_execute_send_operation, send_operation.execute:")
# execute and return send operation results
send_operation_result: SendOperationResult = (
self.connector_module.send_operation.execute(
objects, send_info, on_operation_progressed, ct
# wrap into exception handler, which will cancel task and UI progress, instead of giving ipression that task still loads
try:
# first, update UI status
self.dockwidget.activity_start_signal.emit(
model_card_id, "Converting and sending.."
)
)
self.connector_module.send_binding.commads.set_model_send_result(
model_card_id=model_card_id,
version_id=send_operation_result.root_obj_id,
send_conversion_results=send_operation_result.converted_references,
)
print("_execute_send_operation -> send_operation.execute:")
# execute and return send operation results
send_operation_result: SendOperationResult = (
self.connector_module.send_operation.execute(
objects, send_info, on_operation_progressed, ct
)
)
self.connector_module.send_binding.commads.set_model_send_result(
model_card_id=model_card_id,
version_id=send_operation_result.root_obj_id,
send_conversion_results=send_operation_result.converted_references,
)
except Exception as e:
# TODO: also show an error message
print(e)
self._cancel_operation(model_card_id)
def _cancel_operation(self, model_card_id: str):
+2 -2
View File
@@ -1,7 +1,7 @@
from dataclasses import dataclass
from typing import Any, Dict, List
from speckle.host_apps.qgis.connectors.utils import HOST_APP_FULL_VERSION
from speckle.host_apps.qgis.connectors.utils import HOST_APP_FULL_VERSION, CORE_VERSION
from speckle.sdk.connectors_common.api import IClientFactory, IOperations
from speckle.sdk.connectors_common.builders import (
IRootObjectBuilder,
@@ -136,7 +136,7 @@ class SendOperation:
account,
{
"hostAppFullVersion": HOST_APP_FULL_VERSION,
"core_version": "3.0.099",
"core_version": CORE_VERSION,
"ui": "dui3",
"workspace_id": get_project_workspace_id(client, send_info.project_id),
},
+48 -16
View File
@@ -9,6 +9,7 @@ from specklepy.core.api.models.current import (
ResourceCollection,
)
from specklepy.core.api.resources.current.project_resource import ProjectResource
from specklepy.core.api.resources.current.workspace_resource import Workspace
from speckle.ui.utils.utils import (
create_new_project_query,
create_new_model_query,
@@ -28,6 +29,7 @@ class UiSearchUtils(QObject):
cursor_projects: Any = None
cursor_models: Any = None
speckle_client: SpeckleClient = None
current_workspace: Optional[Workspace] = None
batch_size: int = None
add_selection_filter_signal = pyqtSignal(SenderModelCard)
add_models_search_signal = pyqtSignal(Project)
@@ -36,30 +38,25 @@ class UiSearchUtils(QObject):
new_model_widget_signal = pyqtSignal(str)
change_account_and_projects_signal = pyqtSignal()
refresh_models_signal = pyqtSignal()
open_add_new_account_widget_signal = pyqtSignal()
add_new_account_signal = pyqtSignal()
clear_project_search_bar_signal = pyqtSignal()
clear_model_search_bar_signal = pyqtSignal()
def __init__(self):
super().__init__()
accounts: List[Account] = get_accounts()
if len(accounts) == 0: # TODO handle no local accounts
raise SpeckleException(
"Add accounts via Speckle Desktop Manager in order to start"
)
self.speckle_client: SpeckleClient = get_authenticate_client_for_account(
accounts[0]
)
self.batch_size = QUERY_BATCH_SIZE
def get_accounts_content(self):
accounts: List[Account] = get_accounts()
if len(accounts) == 0: # TODO handle no local accounts
raise SpeckleException(
"Add accounts via Speckle Desktop Manager in order to start"
if len(accounts) > 0:
self.speckle_client: SpeckleClient = get_authenticate_client_for_account(
accounts[0]
)
def get_accounts_content(self) -> List[List[Any]]:
accounts: List[Account] = get_accounts()
content_list = [
[
partial(self._replace_projects_list_with_new_account, acc),
@@ -77,11 +74,13 @@ class UiSearchUtils(QObject):
self.change_account_and_projects_signal.emit()
def get_account_initials(self):
if self.speckle_client is None:
return "?"
name = self.speckle_client.account.userInfo.name
if isinstance(name, str) and len(name) > 0:
return name[0]
return "X"
return "?"
def create_new_project(self, name: str, workspace_id: Optional[str] = None):
create_new_project_query(self.speckle_client, name, workspace_id)
@@ -90,6 +89,9 @@ class UiSearchUtils(QObject):
create_new_model_query(self.speckle_client, project_id, model_name)
def get_new_projects_content(self, clear_cursor=False):
workspace_id: Optional[str] = (
self.current_workspace.id if self.current_workspace else None
)
if clear_cursor:
self.cursor_projects = None
@@ -97,9 +99,18 @@ class UiSearchUtils(QObject):
content_list: List[List] = []
projects_resource_collection: ResourceCollection[Project] = (
get_projects_from_client(
speckle_client=self.speckle_client, cursor=self.cursor_projects
speckle_client=self.speckle_client,
workspace_id=workspace_id,
cursor=self.cursor_projects,
)
)
# filter out projects without workspace (if None)
if self.current_workspace is None:
projects_resource_collection.items = [
x for x in projects_resource_collection.items if x.workspace_id is None
]
self.cursor_projects = projects_resource_collection.cursor
content_list: List[List] = (
self._create_project_content_list_from_resource_collection(
@@ -111,15 +122,27 @@ class UiSearchUtils(QObject):
def get_new_projects_content_with_name_condition(self, name_filter: str):
workspace_id: Optional[str] = (
self.current_workspace.id if self.current_workspace else None
)
self.cursor_projects = None
projects_resource_collection: ResourceCollection[Project] = (
get_projects_from_client(
speckle_client=self.speckle_client,
workspace_id=workspace_id,
cursor=self.cursor_projects,
filter_keyword=name_filter,
)
)
# filter out projects without workspace (if None)
if self.current_workspace is None:
projects_resource_collection.items = [
x for x in projects_resource_collection.items if x.workspace_id is None
]
self.cursor_projects = projects_resource_collection.cursor
content_list: List[List] = (
self._create_project_content_list_from_resource_collection(
@@ -137,11 +160,14 @@ class UiSearchUtils(QObject):
content_list: List[List] = []
for project in projects_batch:
role = "can edit" if project.role is None else project.role.split(":")[-1]
# make sure to pass the actual project, not a reference to a variable
project_content = [
partial(self._emit_function_add_models_signal, project),
project.name,
project.role.split(":")[-1],
role,
f"updated {time_ago(project.updated_at)}",
]
content_list.append(project_content)
@@ -231,6 +257,12 @@ class UiSearchUtils(QObject):
)
)
def get_workspaces(self) -> List[Workspace]:
if self.speckle_client is None:
return []
workspaces = self.speckle_client.active_user.get_workspaces().items
return workspaces
def get_version_search_widget_content(self, project: ProjectResource) -> List[List]:
"""Add search cards for models (only valid for Receive workflow)."""
+76 -15
View File
@@ -9,8 +9,10 @@ from specklepy.core.api.inputs.model_inputs import CreateModelInput
from specklepy.core.api.inputs.project_inputs import (
ProjectCreateInput,
ProjectModelsFilter,
WorkspaceProjectCreateInput,
)
from specklepy.core.api.inputs.user_inputs import UserProjectsFilter
from specklepy.core.api.inputs.project_inputs import WorksaceProjectsFilter
from specklepy.core.api.models.current import (
Model,
Project,
@@ -49,19 +51,51 @@ def get_authenticate_client_for_account(account: Account) -> SpeckleClient:
def get_projects_from_client(
speckle_client: SpeckleClient, cursor=None, filter_keyword: Optional[str] = None
speckle_client: SpeckleClient,
workspace_id: Optional[str],
cursor=None,
filter_keyword: Optional[str] = None,
) -> ResourceCollection[Project]:
results = []
# create search filters for user query and workspace query
project_user_filter = UserProjectsFilter(search="", workspaceId=workspace_id)
project_workspace_filter = WorksaceProjectsFilter(
search="", with_project_role_only=False
)
if speckle_client is not None:
# possible GraphQLException
results: ResourceCollection[Project] = speckle_client.active_user.get_projects(
limit=100 if filter_keyword else QUERY_BATCH_SIZE,
cursor=cursor,
filter=(
UserProjectsFilter(search=filter_keyword) if filter_keyword else None
),
)
# for personal projects, use active_user query
if workspace_id is None:
if isinstance(filter_keyword, str):
project_user_filter.search = filter_keyword
# possible GraphQLException
results: ResourceCollection[Project] = (
speckle_client.active_user.get_projects(
limit=100 if filter_keyword else QUERY_BATCH_SIZE,
cursor=cursor,
filter=project_user_filter,
)
)
# for workspace projects, use workspace query (active user.get_projects doesn't return projects created by others, even for admin role)
else:
if isinstance(filter_keyword, str):
project_workspace_filter.search = filter_keyword
# possible GraphQLException
results: ResourceCollection[Project] = (
speckle_client.workspace.get_projects(
workspace_id=workspace_id,
limit=100 if filter_keyword else QUERY_BATCH_SIZE,
cursor=cursor,
filter=project_workspace_filter,
)
)
if not isinstance(results, ResourceCollection):
# TODO: handle
@@ -71,6 +105,20 @@ def get_projects_from_client(
# TODO add a warning
pass
results.items = [
item
for item in results.items
if (
(
item.role is None
and speckle_client.project.get_permissions(
item.id
).can_create_model.authorized
)
or (isinstance(item.role, str) and not item.role.endswith("viewer"))
) # "None" for "implicit" owner or viewer roles (if not explicitly invited)
]
return results
@@ -79,7 +127,7 @@ def get_models_from_client(
project: Project,
cursor=None,
filter_keyword: Optional[str] = None,
) -> ResourceCollection[Project]:
) -> ResourceCollection[Model]:
results = []
if speckle_client is not None:
@@ -130,12 +178,25 @@ def create_new_project_query(
result = None
if speckle_client is not None:
# possible GraphQLException
result: Project = speckle_client.project.create(
input=ProjectCreateInput(
name=project_name, description=None, visibility=None
if workspace_id:
# possible GraphQLException
result: Project = speckle_client.project.create_in_workspace(
input=WorkspaceProjectCreateInput(
name=project_name,
description=None,
visibility=None,
workspaceId=workspace_id,
)
)
else:
# possible GraphQLException
result: Project = speckle_client.project.create(
input=ProjectCreateInput(
name=project_name, description=None, visibility=None
)
)
)
if not isinstance(result, Project):
# TODO: handle
+59
View File
@@ -5,6 +5,7 @@ from speckle.sdk.connectors_common.operations import SendOperationResult
from speckle.ui.bindings import IBasicConnectorBinding, SelectionInfo
from speckle.ui.models import ModelCard, SenderModelCard
from speckle.ui.widgets.widget_account_search import AccountSearchWidget
from speckle.ui.widgets.widget_add_account import AddAccountWidget
from speckle.ui.widgets.widget_model_card import ModelCardWidget
from speckle.ui.widgets.widget_model_cards_list import ModelCardsWidget
from speckle.ui.widgets.widget_model_search import ModelSearchWidget
@@ -55,6 +56,7 @@ class SpeckleQGISv3Dialog(QDockWidget):
widget_project_search: ProjectSearchWidget = None
widget_model_search: ModelSearchWidget = None
widget_account_search: AccountSearchWidget = None
widget_account_add: AddAccountWidget = None
widget_new_project: NewProjectWidget = None
widget_new_model: NewModelWidget = None
widget_model_cards: ModelCardsWidget = None
@@ -221,6 +223,9 @@ class SpeckleQGISv3Dialog(QDockWidget):
if self.widget_account_search:
self._remove_widget_account_search()
if self.widget_account_add:
self._remove_widget_account_add()
if self.widget_new_project:
self._remove_widget_new_project()
@@ -247,6 +252,9 @@ class SpeckleQGISv3Dialog(QDockWidget):
elif self.widget_account_search == widget:
self._remove_widget_account_search()
elif self.widget_account_add == widget:
self._remove_widget_account_add()
elif self.widget_new_project == widget:
self._remove_widget_new_project()
@@ -269,6 +277,9 @@ class SpeckleQGISv3Dialog(QDockWidget):
if self.widget_account_search:
self._remove_widget_account_search()
if self.widget_account_add:
self._remove_widget_account_add()
if self.widget_new_project:
self._remove_widget_new_project()
@@ -287,6 +298,10 @@ class SpeckleQGISv3Dialog(QDockWidget):
self.widget_account_search.setParent(None)
self.widget_account_search = None
def _remove_widget_account_add(self):
self.widget_account_add.setParent(None)
self.widget_account_add = None
def _remove_widget_new_project(self):
self.widget_new_project.setParent(None)
self.widget_new_project = None
@@ -378,6 +393,10 @@ class SpeckleQGISv3Dialog(QDockWidget):
self.main_widget.layout.addWidget(self.widget_project_search)
self.main_widget.layout.setCurrentWidget(self.widget_project_search)
# if no accounts are present, open Select Account widget
if self.widget_project_search.ui_search_content.speckle_client is None:
self._open_select_accounts_widget()
self.widget_project_search.ui_search_content.add_selection_filter_signal.connect(
self._create_selection_filter_widget
)
@@ -405,6 +424,16 @@ class SpeckleQGISv3Dialog(QDockWidget):
self._update_project_list
)
def _update_account_list(self):
# close AddAccount widget
# can be called from AddAccount widget
if self.widget_account_add:
self._remove_widget_account_add()
# refresh accounts in the AccountSearch widget
self.widget_account_search.refresh_accounts()
def _update_project_list(self):
# can be called from CreateAccount or NewProject widgets
@@ -413,6 +442,13 @@ class SpeckleQGISv3Dialog(QDockWidget):
if self.widget_new_project:
self._remove_widget_new_project()
# get list of workspaces
self.widget_project_search.workspaces = (
self.widget_project_search.ui_search_content.get_workspaces()
)
self.widget_project_search._fill_workspace_dropdown()
# refresh projects for the selected workspace
self.widget_project_search.refresh_projects()
def _update_model_list(self):
@@ -477,6 +513,29 @@ class SpeckleQGISv3Dialog(QDockWidget):
# subscribe to close-on-background-click event
self._subscribe_to_close_on_background_click(self.widget_account_search)
# subscribe to select_account_signal signal
self.widget_account_search.ui_search_content.open_add_new_account_widget_signal.connect(
self._open_add_account_widget
)
# subscribe to add_new_account_signal signal
self.widget_account_search.ui_search_content.add_new_account_signal.connect(
self._update_account_list
)
def _open_add_account_widget(self):
if not self.widget_account_add:
self.widget_account_add = AddAccountWidget(
parent=self,
ui_search_content=self.widget_project_search.ui_search_content,
)
# add widgets to the layout
self.main_widget.layout.addWidget(self.widget_account_add)
self.main_widget.layout.setCurrentWidget(self.widget_account_add)
# subscribe to close-on-background-click event
self._subscribe_to_close_on_background_click(self.widget_account_add)
def _open_select_models_widget(self, project):
if not self.widget_model_search:
+36 -7
View File
@@ -3,6 +3,13 @@ from speckle.ui.widgets.widget_cards_list_temporary import (
CardsListTemporaryWidget,
)
from speckle.ui.widgets.utils.global_resources import (
BACKGR_COLOR,
BACKGR_COLOR_LIGHT,
)
from PyQt5.QtWidgets import QPushButton, QSizePolicy
class AccountSearchWidget(CardsListTemporaryWidget):
@@ -13,25 +20,47 @@ class AccountSearchWidget(CardsListTemporaryWidget):
*,
parent=None,
label_text: str = "Select account",
ui_search_content: UiSearchUtils = None
ui_search_content: UiSearchUtils = None,
):
self.parent = parent
self.ui_search_content = ui_search_content
# customize load_more function
self._load_more = lambda: self._add_accounts(clear_cursor=False)
# initialize the inherited widget, passing the card content
super(AccountSearchWidget, self).__init__(
parent=parent, label_text=label_text, cards_content_list=[]
parent=parent,
label_text=label_text,
cards_content_list=[],
init_load_more_btn=False,
)
self.refresh_accounts()
self._add_accounts(clear_cursor=True)
button_create = self._create_add_button()
self.scroll_container.layout().addWidget(button_create)
def _add_accounts(self, clear_cursor=False):
def _create_add_button(self) -> QPushButton:
button_create = QPushButton("Add new account")
button_create.clicked.connect(
self.ui_search_content.open_add_new_account_widget_signal.emit
)
button_create.setStyleSheet(
"QPushButton {"
+ f"color:white;border-radius: 7px;margin:5px;padding: 5px;height: 20px;text-align: center;{BACKGR_COLOR}"
+ "} QPushButton:hover { "
+ f"{BACKGR_COLOR_LIGHT};"
+ " }"
)
return button_create
def refresh_accounts(self, clear_cursor=False):
all_accounts = self.ui_search_content.get_accounts_content()
if len(all_accounts) == 0:
self.scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
else:
self.scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self._remove_all_cards()
self._add_more_cards(
all_accounts, clear_cursor, self.ui_search_content.batch_size
)
+207
View File
@@ -0,0 +1,207 @@
from speckle.ui.utils.search_widget_utils import UiSearchUtils
from speckle.ui.widgets.background_widget import BackgroundWidget
from speckle.ui.widgets.utils.global_resources import (
BACKGR_COLOR,
BACKGR_COLOR_LIGHT,
BACKGR_COLOR_WHITE,
WIDGET_SIDE_BUFFER,
ZERO_MARGIN_PADDING,
)
import webbrowser
from PyQt5.QtGui import QColor
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QVBoxLayout,
QWidget,
QStackedLayout,
QPushButton,
QLabel,
QLineEdit,
QGraphicsDropShadowEffect,
)
class AddAccountWidget(QWidget):
ui_search_content: UiSearchUtils = None
_message_card: QWidget = (
None # needs to be here, so it can be called on resize event
)
server_url_widget: QLineEdit = None
def __init__(
self,
*,
parent=None,
label_text: str = "Add new account",
ui_search_content: UiSearchUtils = None,
):
super(AddAccountWidget, self).__init__(parent)
self.parent = parent
self.ui_search_content = ui_search_content
# align with the parent widget size
self.resize(
parent.frameSize().width(),
parent.frameSize().height(),
)
self._add_background()
self.layout = QStackedLayout()
self.layout.addWidget(self.background)
self._fill_message_card(label_text)
content = QWidget()
content.layout = QVBoxLayout(self)
content.layout.setContentsMargins(0, 0, 0, 0)
content.layout.setAlignment(Qt.AlignCenter)
content.layout.addWidget(self._message_card)
self.layout.addWidget(content)
def _create_widget_label(self, label_text: str, props: str = ""):
label = QLabel(label_text)
# for some reason, "margin-left" doesn't make any effect here
label.setStyleSheet(
"QLabel {"
+ f"{ZERO_MARGIN_PADDING}padding-left:{int(WIDGET_SIDE_BUFFER/2)};"
+ f"padding-top:{int(WIDGET_SIDE_BUFFER/4)}; margin-bottom:{int(WIDGET_SIDE_BUFFER/4)};"
+ f"text-align:left;{props}"
+ "}"
)
return label
def _create_text_widget(self, label_text: str, props: str = ""):
label = QLabel(label_text)
# for some reason, "margin-left" doesn't make any effect here
label.setStyleSheet(
"QLabel {"
+ f"{ZERO_MARGIN_PADDING}padding-left:5px;padding-right:5px;padding-bottom:5px;"
+ f"text-align:left;{props}"
+ "}"
)
return label
def _add_background(self):
self.background = BackgroundWidget(parent=self, transparent=False)
self.background.show()
def _add_drop_shadow(self, item=None):
if not item:
item = self
# create drop shadow effect
self._shadow_effect = QGraphicsDropShadowEffect()
self._shadow_effect.setOffset(2, 2)
self._shadow_effect.setBlurRadius(8)
self._shadow_effect.setColor(QColor.fromRgb(100, 100, 100, 150))
item.setGraphicsEffect(self._shadow_effect)
def _fill_message_card(self, label_text: str):
self._message_card = QWidget()
self._message_card.setAttribute(Qt.WA_StyledBackground, True)
self._message_card.setStyleSheet(
"QWidget {" + "border-radius: 10px;" + f"{BACKGR_COLOR_WHITE}" + "}"
)
boxLayout = QVBoxLayout(self._message_card)
label_main = self._create_widget_label(label_text)
boxLayout.addWidget(label_main)
# add text 1
label = self._create_text_widget("Server URL:")
boxLayout.addWidget(label)
# add text input 1
self.server_url_widget = QLineEdit("https://app.speckle.systems")
self.server_url_widget.setMaxLength(40)
self.server_url_widget.setStyleSheet(
"QLineEdit { "
+ f"{ZERO_MARGIN_PADDING}margin-left:{int(WIDGET_SIDE_BUFFER/6)};margin-right:{int(WIDGET_SIDE_BUFFER/6)};"
+ "border: 1px solid lightgrey; height: 30px; border-radius: 5px; "
+ "}"
)
boxLayout.addWidget(self.server_url_widget)
button_create = self._create_add_button()
boxLayout.addWidget(button_create)
self._add_drop_shadow(self._message_card)
def _create_add_button(self) -> QPushButton:
button_create = QPushButton("Create")
button_create.setStyleSheet(
"QPushButton {"
+ f"color:white;border-radius: 7px;margin:5px;padding: 5px;height: 20px;text-align: center;{BACKGR_COLOR}"
+ "} QPushButton:hover { "
+ f"{BACKGR_COLOR_LIGHT};"
+ " }"
)
button_create.clicked.connect(self._add_account)
button_create.clicked.connect(lambda: self._create_ready_button(button_create))
return button_create
def _create_ready_button(self, button):
try:
button.clicked.disconnect(self._add_account)
button.clicked.disconnect(self._create_ready_button)
except:
pass # ignore if methods already disconnected
button.setText("READY!")
button.clicked.connect(self._exit_widget)
button.setStyleSheet(
"QPushButton {"
+ f"color:white;border-radius: 7px;margin:5px;padding: 5px;height: 20px;text-align: center;{BACKGR_COLOR}"
+ "} QPushButton:hover { "
+ f"{BACKGR_COLOR_LIGHT};"
+ " }"
)
def _exit_widget(self):
# the next signal will trigger closing the widget and refreshing project list
self.ui_search_content.add_new_account_signal.emit()
def _add_account(self):
# create a new account, authenticate and write to DB
server_url: str = self.server_url_widget.text()
# Logic to handle sign in
api_url = "http://localhost:29364"
url = f"{api_url}/auth/add-account?serverUrl={server_url}"
webbrowser.open(url)
def resizeEvent(self, event=None):
QWidget.resizeEvent(self, event)
try:
self.background.resize(
self.parent.frameSize().width(),
self.parent.frameSize().height(),
)
self._message_card.setGeometry(
int(0.5 * WIDGET_SIDE_BUFFER),
int(
(self.parent.frameSize().height() - self._message_card.height()) / 2
),
self.parent.frameSize().width() - 1 * WIDGET_SIDE_BUFFER,
self._message_card.height(),
)
except RuntimeError as e:
# e.g. Widget was deleted
pass
@@ -17,9 +17,9 @@ from speckle.ui.widgets.widget_card_from_list import CardInListWidget
class CardsListTemporaryWidget(QWidget):
background: BackgroundWidget = None
scroll_area: QtWidgets.QScrollArea = None
cards_list_widget: QWidget = None # needed here to resize child elements
load_more_btn: QPushButton = None
scroll_area: QtWidgets.QScrollArea = None
scroll_container: QWidget = None # overall container, added after the label
@@ -29,9 +29,11 @@ class CardsListTemporaryWidget(QWidget):
parent=None,
label_text: str = "Label",
cards_content_list: List[List],
init_load_more_btn: bool = True,
):
super(CardsListTemporaryWidget, self).__init__(parent)
self.parent: "SpeckleQGISv3Dialog" = parent
self.init_load_more_btn = init_load_more_btn
# align with the parent widget size
self.resize(
@@ -82,7 +84,7 @@ class CardsListTemporaryWidget(QWidget):
return scroll_container
def _create_container(self):
def _create_container(self) -> QWidget:
scroll_container = QWidget()
scroll_container.setAttribute(QtCore.Qt.WA_StyledBackground, True)
@@ -114,8 +116,8 @@ class CardsListTemporaryWidget(QWidget):
self.scroll_area.setAlignment(Qt.AlignHCenter)
# create a widget inside scroll area
cards_list_widget = self._create_area_with_cards(cards_content_list)
self.scroll_area.setWidget(cards_list_widget)
self.cards_list_widget = self._create_area_with_cards(cards_content_list)
self.scroll_area.setWidget(self.cards_list_widget)
return self.scroll_area
@@ -155,22 +157,21 @@ class CardsListTemporaryWidget(QWidget):
def _create_area_with_cards(self, cards_content_list: List[List]) -> QWidget:
self.cards_list_widget = QWidget()
self.cards_list_widget.setStyleSheet(
"QWidget {" + f"{ZERO_MARGIN_PADDING}" + "}"
)
_ = QVBoxLayout(self.cards_list_widget)
cards_list_widget = QWidget()
cards_list_widget.setStyleSheet("QWidget {" + f"{ZERO_MARGIN_PADDING}" + "}")
_ = QVBoxLayout(cards_list_widget)
# in case the input argument was missing or None, don't create any cards
if isinstance(cards_content_list, list):
for content in cards_content_list:
project_card = CardInListWidget(content)
self.cards_list_widget.layout().addWidget(project_card)
cards_list_widget.layout().addWidget(project_card)
self._create_load_more_btn()
self.cards_list_widget.layout().addWidget(self.load_more_btn)
if self.init_load_more_btn:
self._create_load_more_btn()
cards_list_widget.layout().addWidget(self.load_more_btn)
return self.cards_list_widget
return cards_list_widget
def _add_more_cards(
self, new_cards_content_list: list, keep_scroll_on_top=False, batch_size=1
@@ -188,6 +189,7 @@ class CardsListTemporaryWidget(QWidget):
assigned_cards_list_widget = self._create_area_with_cards(existing_content)
self.scroll_area.setWidget(assigned_cards_list_widget)
self.cards_list_widget = assigned_cards_list_widget
# scroll down
if not keep_scroll_on_top:
@@ -195,9 +197,8 @@ class CardsListTemporaryWidget(QWidget):
vbar.setValue(vbar.maximum())
# style LoadMore buttom
if len(new_cards_content_list) < batch_size:
if self.load_more_btn and len(new_cards_content_list) < batch_size:
self._style_load_btn(active=False, text="No more items found")
return
def _remove_all_cards(self):
all_count = self.cards_list_widget.layout().count()
+12 -22
View File
@@ -28,7 +28,6 @@ class NewProjectWidget(QWidget):
None # needs to be here, so it can be called on resize event
)
project_name_widget: QLineEdit = None
workspace_widget: QLineEdit = None
def __init__(
self,
@@ -131,22 +130,6 @@ class NewProjectWidget(QWidget):
)
boxLayout.addWidget(self.project_name_widget)
# add text 2
label2 = self._create_text_widget("Workspaces:")
label2.setEnabled(False)
boxLayout.addWidget(label2)
# add text input 2
self.workspace_widget = QLineEdit()
self.workspace_widget.setStyleSheet(
"QLineEdit { "
+ f"{ZERO_MARGIN_PADDING}margin-left:{int(WIDGET_SIDE_BUFFER/6)};margin-right:{int(WIDGET_SIDE_BUFFER/6)};"
+ "border: 1px solid lightgrey; height: 30px; border-radius: 5px; "
+ "}"
)
self.workspace_widget.setEnabled(False)
boxLayout.addWidget(self.workspace_widget)
button_create = self._create_create_button()
boxLayout.addWidget(button_create)
@@ -154,20 +137,27 @@ class NewProjectWidget(QWidget):
def _create_create_button(self) -> QPushButton:
button_publish = QPushButton("Create")
button_publish.clicked.connect(self._create_project_and_exit_widget)
button_publish.setStyleSheet(
button_create = QPushButton("Create")
button_create.clicked.connect(self._create_project_and_exit_widget)
button_create.setStyleSheet(
"QPushButton {"
+ f"color:white;border-radius: 7px;margin:5px;padding: 5px;height: 20px;text-align: center;{BACKGR_COLOR}"
+ "} QPushButton:hover { "
+ f"{BACKGR_COLOR_LIGHT};"
+ " }"
)
return button_publish
return button_create
def _create_project_and_exit_widget(self):
self.ui_search_content.create_new_project(self.project_name_widget.text(), None)
workspace_id = (
self.ui_search_content.current_workspace.id
if self.ui_search_content.current_workspace
else None
)
self.ui_search_content.create_new_project(
self.project_name_widget.text(), workspace_id
)
# the next signal will trigger closing the widget and refreshing project list
self.ui_search_content.change_account_and_projects_signal.emit()
+60 -4
View File
@@ -1,4 +1,4 @@
from typing import Optional
from typing import List, Optional
from speckle.ui.widgets.utils.global_resources import (
BACKGR_COLOR,
BACKGR_COLOR_LIGHT,
@@ -18,6 +18,8 @@ from PyQt5.QtWidgets import (
QWidget,
QLineEdit,
QPushButton,
QComboBox,
QSizePolicy,
)
@@ -26,6 +28,8 @@ class ProjectSearchWidget(CardsListTemporaryWidget):
ui_search_content: UiSearchUtils = None
account_switch_btn: QPushButton = None
search_widget: QLineEdit = None
workspaces_dropdown: QComboBox = None
workspaces: List["Workspace"] = None
def __init__(
self,
@@ -39,6 +43,7 @@ class ProjectSearchWidget(CardsListTemporaryWidget):
# get content for project cards
self.ui_search_content = UiSearchUtils()
self.workspaces = self.ui_search_content.get_workspaces()
# customize load_more function
self._load_more = lambda: self._add_projects(clear_cursor=False)
@@ -49,11 +54,23 @@ class ProjectSearchWidget(CardsListTemporaryWidget):
label_text=label_text,
cards_content_list=[],
)
self._add_search_and_account_switch_line()
self._add_project_search_and_project_add_line()
self._add_workspace_search_and_account_switch_line()
self._add_projects(clear_cursor=True)
def _add_projects(self, clear_cursor=False, name_filter: Optional[str] = None):
if self.ui_search_content.speckle_client is None:
return
workspace_id = None # default to "Personal Projects"
index = self.workspaces_dropdown.currentIndex()
if index < len(self.workspaces):
workspace_id = self.workspaces[index].id
self.ui_search_content.current_workspace = self.workspaces[index]
else: # personal projects
self.ui_search_content.current_workspace = None
if name_filter is None:
# just get the projects in batches
new_project_cards: list = self.ui_search_content.get_new_projects_content(
@@ -75,15 +92,20 @@ class ProjectSearchWidget(CardsListTemporaryWidget):
self.resizeEvent()
def refresh_projects(self, name_filter: Optional[str] = None):
if self.account_switch_btn:
self.account_switch_btn.setText(
self.ui_search_content.get_account_initials()
)
self._remove_all_cards()
self._add_projects(clear_cursor=True, name_filter=name_filter)
def clear_search_bar(self):
self.search_widget.setText("")
def _add_search_and_account_switch_line(self):
def _add_project_search_and_project_add_line(self):
# create a line widget
# create an empty widget
line = QWidget()
line.setStyleSheet(
"QWidget {"
@@ -104,12 +126,46 @@ class ProjectSearchWidget(CardsListTemporaryWidget):
new_project_btn = self._create_new_project_btn()
layout_line.addWidget(new_project_btn)
self.scroll_container.layout().insertWidget(1, line)
def _add_workspace_search_and_account_switch_line(self):
# create an empty widget
line = QWidget()
line.setStyleSheet(
"QWidget {"
+ f"border-radius: 0px;color:white;{ZERO_MARGIN_PADDING}"
+ f"margin-left:{int(WIDGET_SIDE_BUFFER/4)};margin-right:{int(WIDGET_SIDE_BUFFER/4)};text-align: left;"
+ "}"
)
layout_line = QHBoxLayout(line)
layout_line.setAlignment(Qt.AlignLeft)
layout_line.setContentsMargins(10, 0, 0, 0)
# workspaces selection dropdown
self.workspaces_dropdown = QComboBox()
self.workspaces_dropdown.currentIndexChanged.connect(self.refresh_projects)
self._fill_workspace_dropdown()
layout_line.addWidget(self.workspaces_dropdown)
# Account switch buttom
self.account_switch_btn = self._create_account_switch_btn()
layout_line.addWidget(self.account_switch_btn)
self.scroll_container.layout().insertWidget(1, line)
def _fill_workspace_dropdown(self):
self.workspaces_dropdown.clear()
self.workspaces_dropdown.addItems([x.name for x in self.workspaces])
self.workspaces_dropdown.addItem("Personal Projects")
self.workspaces_dropdown.setStyleSheet(
"""QComboBox { background-color: white; border: 1px solid lightgrey; border-radius: 5px; color: black; height: 30px; padding: 0px 0px 0px 10px; }"""
)
self.workspaces_dropdown.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
def _create_search_widget(self):
text_box = QLineEdit()
text_box.setMaxLength(20)
+3
View File
@@ -2,6 +2,7 @@
import os.path
from typing import Optional
from speckle.host_apps.qgis.connectors.utils import setup_metrics
from speckle.host_apps.qgis.qgis_module import SpeckleQGISv3Module
# Initialize Qt resources from file resources.py
@@ -30,6 +31,8 @@ class SpeckleQGIS(SpeckleQGISv3Module):
application at run time.
:type iface: QgsInterface
"""
setup_metrics()
super(SpeckleQGIS, self).__init__(iface)
# initialize plugin directory