From a1fd8bd6bfbfae6fa835e75fe236cd227d2ce787 Mon Sep 17 00:00:00 2001 From: Jarre Knockaert Date: Thu, 4 Dec 2025 09:59:26 +0100 Subject: [PATCH 01/14] update api python sample to new query format --- QonicApi.py | 41 +------- QonicApiLib.py | 43 ++++++++ oauth.py | 11 ++- sample.py | 263 ++++++++++++++++++++----------------------------- 4 files changed, 162 insertions(+), 196 deletions(-) create mode 100644 QonicApiLib.py diff --git a/QonicApi.py b/QonicApi.py index 1e47eeb..1aaf284 100644 --- a/QonicApi.py +++ b/QonicApi.py @@ -2,43 +2,9 @@ import uuid from typing import Dict, List, Optional, Any, Iterable import requests -from oauth import login - - -class QonicApiError(Exception): - def __init__(self, response: requests.Response): - self.response = response - try: - self.data = response.json() - except ValueError: - self.data = None - - message = f"{response.status_code} {response.reason}" - if isinstance(self.data, dict): - details = self.data.get("errorDetails") or self.data.get("detail") - code = self.data.get("error") or self.data.get("type") - parts = [message] - if code: - parts.append(str(code)) - if details: - parts.append(str(details)) - message = " - ".join(parts) - - super().__init__(message) - - -class ModificationInputError: - def __init__(self, guid: str, field: str, error: str, description: str): - self.guid = guid - self.field = field - self.error = error - self.description = description - - def __str__(self) -> str: - return f"{self.guid}: {self.field}: {self.error}: {self.description}" - - __repr__ = __str__ +from QonicApiLib import QonicApiError, ModificationInputError, ProductFilter +from oauth import login class QonicApi: def __init__(self): @@ -130,7 +96,7 @@ def query_products( project_id: str, model_id: str, fields: Iterable[str], - filters: Dict[str, Any] | None = None + filters: Iterable[ProductFilter] | None = None ) -> Dict[str, Any]: body = { "fields": list(fields), @@ -345,3 +311,4 @@ def update_type(self, project_id: str, library_guid: str, type_guid: str, change def delete_type(self, project_id: str, library_guid: str, type_guid: str) -> None: self._delete(f"projects/{project_id}/types/{library_guid}/types/{type_guid}") + diff --git a/QonicApiLib.py b/QonicApiLib.py new file mode 100644 index 0000000..c1f3249 --- /dev/null +++ b/QonicApiLib.py @@ -0,0 +1,43 @@ +from typing import TypedDict, Any + +import requests + + +class QonicApiError(Exception): + def __init__(self, response: requests.Response): + self.response = response + try: + self.data = response.json() + except ValueError: + self.data = None + + message = f"{response.status_code} {response.reason}" + if isinstance(self.data, dict): + details = self.data.get("errorDetails") or self.data.get("detail") + code = self.data.get("error") or self.data.get("type") + parts = [message] + if code: + parts.append(str(code)) + if details: + parts.append(str(details)) + message = " - ".join(parts) + + super().__init__(message) + + +class ModificationInputError: + def __init__(self, guid: str, field: str, error: str, description: str): + self.guid = guid + self.field = field + self.error = error + self.description = description + + def __str__(self) -> str: + return f"{self.guid}: {self.field}: {self.error}: {self.description}" + + __repr__ = __str__ + +class ProductFilter(TypedDict): + property: str + value: Any + operator: str diff --git a/oauth.py b/oauth.py index 60650ff..2c149d9 100644 --- a/oauth.py +++ b/oauth.py @@ -107,7 +107,7 @@ def login() -> dict: raise SystemExit("No code received from Qonic!") # 4) Exchange code for tokens (include codeVerifier) - resp = requests.post( + response = requests.post( f"{API_URL}/auth/token", data={ "client_id": CLIENT_ID, @@ -118,9 +118,12 @@ def login() -> dict: "grant_type": "authorization_code" }, timeout=30, - ) - resp.raise_for_status() - return resp.json() + ).json() + + if 'access_token' not in response: + raise SystemExit(f"Token exchange failed: {response}") + + return response if __name__ == "__main__": print(login()) \ No newline at end of file diff --git a/sample.py b/sample.py index efa2929..b27ddac 100644 --- a/sample.py +++ b/sample.py @@ -1,5 +1,4 @@ from random import randint -import re import time import os import json @@ -7,6 +6,7 @@ import requests from QonicApi import QonicApi import printMethods +from QonicApiLib import ProductFilter def wait_for_operation(api: QonicApi, operation_id: str): @@ -19,6 +19,23 @@ def wait_for_operation(api: QonicApi, operation_id: str): time.sleep(2) +def run_product_modification(api: QonicApi, project_id: str, model_id: str, changes: dict) -> bool: + print("Starting modification session") + api.start_session(project_id, model_id) + try: + errors = api.modify_products(project_id, model_id, changes) + if errors: + print(errors) + return False + finally: + print("Closing modification session") + api.end_session(project_id, model_id) + + print("Modification is done") + print() + return True + + def handle_model_queries(api: QonicApi, project_id: str): models = api.list_models(project_id) print("your models:") @@ -30,139 +47,89 @@ def handle_model_queries(api: QonicApi, project_id: str): print() available_fields = api.get_available_product_fields(project_id, model_id) - print("fields:") - for field in available_fields: - print(f"{field}") - print() + print("fields: " + ', '.join(available_fields[:10]) + " ...") print("Querying the Guid, Class, Name and FireRating fields, filtered on Class Beam...") print() - properties = api.query_products( - project_id, - model_id, - fields=["Guid", "Class", "Name", "FireRating"], - filters={"Class": "Beam"}, - ) + + fields = ["Guid", "Class", "Name", "FireRating"] + filters: list[ProductFilter] = [{"property": "Class", "value": "Beam", "operator": "Contains"}] + properties = api.query_products(project_id, model_id, fields=fields, filters=filters) print() for row in properties: print(f"{row}") print() - print("Starting modification session") - if len(properties) > 0: - current_fire_rating = properties[0]["FireRating"] - - if current_fire_rating["PropertySet"] is None and current_fire_rating["Value"] is None: - api.start_session(project_id, model_id) - try: - fire_rating = f"F{randint(1, 200)}" - - print(f"Adding FireRating to first row {fire_rating}") - changes = { - "add": { - "FireRating": { - properties[0]["Guid"]: { - "PropertySet": "Pset_BeamCommon", - "Value": fire_rating, - } - } - } - } - errors = api.modify_products(project_id, model_id, changes) - if len(errors) > 0: - print(str(errors)) - return - finally: - print("Closing modification session") - api.end_session(project_id, model_id) - print("Modification is done") - print() - print("Querying data again") - - properties = api.query_products( - project_id, - model_id, - fields=["Guid", "Class", "Name", "FireRating"], - filters={"Class": "Beam"}, - ) - - print("Showing only the first row:") - print(properties[0]) - - print() - print("Starting modification session to reset value") - api.start_session(project_id, model_id) - - try: - fire_rating = f"F{randint(1, 200)}" - print(f"Clearing FireRating of first row") - changes = { - "update": { - "FireRating": { - properties[0]["Guid"]: None - } - } - } - errors = api.modify_products(project_id, model_id, changes) - if len(errors) > 0: - print(errors) - return - finally: - print("Closing modification session") - api.end_session(project_id, model_id) - print("Modification is done") - print() - print("Quering data again") - - properties = api.query_products( - project_id, - model_id, - fields=["Guid", "Class", "Name", "FireRating"], - filters={"Class": "Beam"}, - ) - - print("Showing only the first row:") - print(properties[0]) - - print() - print("Starting modification to delete propety") - api.start_session(project_id, model_id) - - try: - fire_rating = f"F{randint(1, 200)}" - print(f"Clearing FireRating of first row") - changes = { - "delete": { - "FireRating": { - properties[0]["Guid"]: None - } - } - } - errors = api.modify_products(project_id, model_id, changes) - if len(errors) > 0: - print(errors) - return - finally: - print("Closing modification session") - api.end_session(project_id, model_id) - print("Modification is done") - print() - print("Quering data again") - - properties = api.query_products( - project_id, - model_id, - fields=["Guid", "Class", "Name", "FireRating"], - filters={"Class": "Beam"}, - ) - if len(properties) > 0: - print("Showing only the first row:") - print(properties[0]) - else: - print("Beam already has a fire rating a change modification needed") - else: + if not properties: print("No beams to add a fire rating to") + return + + current_fire_rating = properties[0]["FireRating"] + if current_fire_rating["PropertySet"] is not None or current_fire_rating["Value"] is not None: + print("Beam already has a fire rating a change modification needed") + return + + fire_rating = f"F{randint(1, 200)}" + print(f"Adding FireRating to first row {fire_rating}") + add_changes = { + "add": { + "FireRating": { + properties[0]["Guid"]: { + "PropertySet": "Pset_BeamCommon", + "Value": fire_rating, + } + } + } + } + + if not run_product_modification(api, project_id, model_id, add_changes): + return + + print("Querying data again") + properties = api.query_products(project_id, model_id, fields=fields, filters=filters) + + print("Showing only the first row:") + print(properties[0]) + + print() + print("Starting modification session to reset value") + update_changes = { + "update": { + "FireRating": { + properties[0]["Guid"]: None + } + } + } + + if not run_product_modification(api, project_id, model_id, update_changes): + return + + print("Querying data again") + properties = api.query_products(project_id, model_id, fields=fields, filters=filters) + + print("Showing only the first row:") + print(properties[0]) + + print() + print("Starting modification to delete property") + delete_changes = { + "delete": { + "FireRating": { + properties[0]["Guid"]: None + } + } + } + + if not run_product_modification(api, project_id, model_id, delete_changes): + return + + print("Querying data again") + properties = api.query_products(project_id, model_id, fields=fields, filters=filters) + + if properties: + print("Showing only the first row:") + print(properties[0]) def handle_codifications(api: QonicApi, project_id: str): @@ -377,15 +344,12 @@ def handle_custom_properties(api: QonicApi, project_id: str): model_id = input("Enter a model id: ") print() - print("Quering the Guid, Class, Name fields, filtered on Class Wall...") + print("Querying the Guid, Class, Name fields, filtered on Class Wall...") print() - fields_str = "Guid Class Name" - properties = api.query_products( - project_id, - model_id, - fields=re.split(r"[;,\s]+", fields_str.strip()), - filters={"Class": "Wall"}, - ) + + fields = ["Guid", "Class", "Name"] + filters: list[ProductFilter] = [{"property": "Class", "value": "Wall", "operator": "Contains"}] + properties = api.query_products(project_id, model_id, fields=fields, filters=filters) print() for row in properties: @@ -410,7 +374,7 @@ def handle_custom_properties(api: QonicApi, project_id: str): } } errors = api.modify_products(project_id, model_id, changes) - if len(errors) > 0: + if errors: print(str(errors)) return finally: @@ -429,24 +393,18 @@ def handle_delete_product(api: QonicApi, project_id: str): print() available_fields = api.get_available_product_fields(project_id, model_id) - print("fields:") - for field in available_fields: - print(f"{field}") + print("fields: " + ', '.join(available_fields[:10]) + " ...") print() - print("Quering the Guid, Class, Name and FireRating fields, filtered on Class Beam...") + print("Querying the Guid, Class, Name and FireRating fields, filtered on Class Beam...") print() - params = {"Fields": ["Guid", "Class", "Name"], "Filters": {"Class": "Wall"}} - properties = api.query_products( - project_id, - model_id, - fields=params["Fields"], - filters=params["Filters"], - ) + fields = ["Guid", "Class", "Name"] + filters: list[ProductFilter] = [{"property": "Class", "value": "Wall", "operator": "Contains"}] + properties = api.query_products(project_id, model_id, fields=fields, filters=filters) initial_amount_of_walls = len(properties) - print(f"Found{initial_amount_of_walls} walls") + print(f"Found {initial_amount_of_walls} walls") print("Deleting the first wall") @@ -464,16 +422,11 @@ def handle_delete_product(api: QonicApi, project_id: str): api.end_session(project_id, model_id) print("Modification is done") print() - print("Quering data again") + print("Querying data again") - properties_after = api.query_products( - project_id, - model_id, - fields=params["Fields"], - filters=params["Filters"], - ) + properties_after = api.query_products(project_id, model_id, fields=fields, filters=filters) amount_after_delete = len(properties_after) - print(f"Found {amount_after_delete }walls") + print(f"Found {amount_after_delete} walls") def handle_create_model(api: QonicApi, project_id: str): @@ -496,7 +449,7 @@ def handle_create_model(api: QonicApi, project_id: str): resp.raise_for_status() print("Upload finished") - model_name = upload_file_name if '.' not in upload_file_name else upload_file_name.split('.')[0] + model_name = upload_file_name if "." not in upload_file_name else upload_file_name.split(".")[0] result = api.create_model( project_id, model_name=model_name, @@ -641,7 +594,7 @@ def main(): elif choose.startswith("9"): handle_calculate_quantities(api, project_id) elif choose.startswith("10"): - exit() + break else: print("Please choose an option from 1-10") From 2b4880fcd6c9f8e8a26cfd767c6595404ca23722 Mon Sep 17 00:00:00 2001 From: Jarre Knockaert Date: Mon, 22 Dec 2025 11:19:11 +0100 Subject: [PATCH 02/14] other: improve path handling for ifc export --- oauth.py | 6 +++--- sample.py | 52 ++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/oauth.py b/oauth.py index 2c149d9..99d1c1b 100644 --- a/oauth.py +++ b/oauth.py @@ -120,10 +120,10 @@ def login() -> dict: timeout=30, ).json() - if 'access_token' not in response: - raise SystemExit(f"Token exchange failed: {response}") + if 'error' in result: + raise SystemExit(f"Token exchange failed: {result['errorDetails']}") - return response + return result if __name__ == "__main__": print(login()) \ No newline at end of file diff --git a/sample.py b/sample.py index b27ddac..dd8e2fc 100644 --- a/sample.py +++ b/sample.py @@ -1,3 +1,4 @@ +from pathlib import Path from random import randint import time import os @@ -464,7 +465,20 @@ def handle_create_model(api: QonicApi, project_id: str): print(f"Final import status: {operation['status']}") -def handle_export_model(api: QonicApi, project_id: str): +def _resolve_output_path(user_input: str, model_id: str) -> Path: + p = Path(user_input).expanduser() + + as_str = str(user_input) + if as_str.endswith(("/", "\\")) or (p.exists() and p.is_dir()): + p = p / f"{model_id}.ifc" + + if p.suffix == "": + p = p / f"{model_id}.ifc" + + return p + + +def handle_export_model(api: "QonicApi", project_id: str) -> None: models = api.list_models(project_id) if not models: print("No models found") @@ -475,34 +489,52 @@ def handle_export_model(api: QonicApi, project_id: str): print(f"{model['id']} - {model['name']}") print() - model_id = input("Enter a model id: ") - print("Starting IFC export") + model_id = input("Enter a model id: ").strip() + if not model_id: + print("No model id provided") + return + + output_raw = input( + "Enter a directory or path to save the IFC file (e.g. ./exports/ or ./exports/export.ifc): " + ).strip() + if not output_raw: + print("No output path provided") + return + + output_path = _resolve_output_path(output_raw, model_id) + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + except Exception as e: + print(f"Could not create directory {output_path.parent}: {e}") + return + + if output_path.exists(): + print(f"Output file {output_path} already exists, please remove it first") + return + + print("Starting IFC export") operation = api.start_export_ifc(project_id, model_id) operation_id = operation["id"] print(f"Export operation id {operation_id}") final_operation = wait_for_operation(api, operation_id) - if final_operation["status"] != "Ready": + if final_operation.get("status") != "Ready": print("Export operation did not complete successfully") return result_url = api.get_export_ifc_result_url(project_id, model_id, operation_id) - output_path = input("Enter a path on disk to save the IFC file (e.g. export.ifc): ").strip() - if not output_path: - print("No output path provided") - return - print("Downloading IFC file") + print(f"Downloading IFC file to {output_path}") resp = requests.get(result_url, stream=True) resp.raise_for_status() + with open(output_path, "wb") as f: for chunk in resp.iter_content(chunk_size=8192): if chunk: f.write(chunk) print(f"IFC file saved to {output_path}") - def handle_calculate_quantities(api: QonicApi, project_id: str): models = api.list_models(project_id) if not models: From cb51eaf52a1a6151759e7352966eff33f3a7d5d0 Mon Sep 17 00:00:00 2001 From: Jarre Knockaert Date: Mon, 22 Dec 2025 11:40:13 +0100 Subject: [PATCH 03/14] other: improve choose action flow --- oauth.py | 7 ++-- sample.py | 105 ++++++++++++++++++++++++++++++------------------------ 2 files changed, 64 insertions(+), 48 deletions(-) diff --git a/oauth.py b/oauth.py index 99d1c1b..aa083e2 100644 --- a/oauth.py +++ b/oauth.py @@ -107,7 +107,7 @@ def login() -> dict: raise SystemExit("No code received from Qonic!") # 4) Exchange code for tokens (include codeVerifier) - response = requests.post( + result = requests.post( f"{API_URL}/auth/token", data={ "client_id": CLIENT_ID, @@ -120,9 +120,12 @@ def login() -> dict: timeout=30, ).json() - if 'error' in result: + if 'errorDetails' in result: raise SystemExit(f"Token exchange failed: {result['errorDetails']}") + if 'access_token' not in result: + raise SystemExit("No access token received from Qonic!") + return result if __name__ == "__main__": diff --git a/sample.py b/sample.py index dd8e2fc..9f1368e 100644 --- a/sample.py +++ b/sample.py @@ -3,6 +3,7 @@ import time import os import json +from typing import Callable import requests from QonicApi import QonicApi @@ -535,6 +536,7 @@ def handle_export_model(api: "QonicApi", project_id: str) -> None: f.write(chunk) print(f"IFC file saved to {output_path}") + def handle_calculate_quantities(api: QonicApi, project_id: str): models = api.list_models(project_id) if not models: @@ -579,57 +581,68 @@ def handle_calculate_quantities(api: QonicApi, project_id: str): print("Quantities result:") print(json.dumps(data, indent=2)) +def _choose_project(projects: list[dict]) -> str | None: + if not projects: + print("No projects found.") + return None -def main(): - api = QonicApi() - api.authorize() - projects = api.list_projects() print("your projects:") - for project in projects: - print(f"{project['id']} - {project['name']}") - - print() - project_id = input("Enter a project id: ") + for p in projects: + print(f"{p['id']} - {p['name']}") print() + valid_ids = {str(p["id"]) for p in projects} + while True: - print("Choose what to do next:") - print("1: Model Queries") - print("2: Codifications") - print("3: Materials") - print("4: Locations") - print("5: CustomProperties") - print("6: Delete Product") - print("7: Create model") - print("8: Export model") - print("9: Calculate quantities") - print("10: Exit") - choose = input() - - print() - if choose.startswith("1"): - handle_model_queries(api, project_id) - elif choose.startswith("2"): - handle_codifications(api, project_id) - elif choose.startswith("3"): - handle_materials(api, project_id) - elif choose.startswith("4"): - handle_locations(api, project_id) - elif choose.startswith("5"): - handle_custom_properties(api, project_id) - elif choose.startswith("6"): - handle_delete_product(api, project_id) - elif choose.startswith("7"): - handle_create_model(api, project_id) - elif choose.startswith("8"): - handle_export_model(api, project_id) - elif choose.startswith("9"): - handle_calculate_quantities(api, project_id) - elif choose.startswith("10"): - break - else: - print("Please choose an option from 1-10") + pid = input("Enter a project id (or blank to exit): ").strip() + if not pid: + return None + if pid in valid_ids: + return pid + print("Invalid project id. Please pick one from the list.\n") + +def _choose_action(actions: dict[str, tuple[str, Callable[[], None]]]) -> str: + print("Choose what to do next:") + for key, (label, _) in actions.items(): + print(f"{key}: {label}") + + return input(f"Enter choice ({', '.join(actions.keys())}): ").strip() + +def main() -> None: + api = QonicApi() + api.authorize() + + project_id = _choose_project(api.list_projects()) + if not project_id: + return + + actions: dict[str, tuple[str, Callable[[], None]]] = { + "1": ("Model Queries", lambda: handle_model_queries(api, project_id)), + "2": ("Codifications", lambda: handle_codifications(api, project_id)), + "3": ("Materials", lambda: handle_materials(api, project_id)), + "4": ("Locations", lambda: handle_locations(api, project_id)), + "5": ("CustomProperties", lambda: handle_custom_properties(api, project_id)), + "6": ("Delete Product", lambda: handle_delete_product(api, project_id)), + "7": ("Create model", lambda: handle_create_model(api, project_id)), + "8": ("Export model", lambda: handle_export_model(api, project_id)), + "9": ("Calculate quantities", lambda: handle_calculate_quantities(api, project_id)), + "10": ("Exit", lambda: exit()), + } + + try: + while True: + print() + choice = _choose_action(actions) + if choice not in actions: + print("Please choose a valid option.\n") + continue + + _, action = actions[choice] + action() + + except (KeyboardInterrupt, EOFError): + print("\nExiting...") if __name__ == "__main__": - main() \ No newline at end of file + main() From ef9acfc5fb425a6b6ec89da192b34bd551ea290b Mon Sep 17 00:00:00 2001 From: Jarre Knockaert Date: Wed, 28 Jan 2026 16:41:09 +0100 Subject: [PATCH 04/14] fix: update endpoints with newest url and fix quantities input --- QonicApi.py | 22 +++++++++++----------- sample.py | 4 +++- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/QonicApi.py b/QonicApi.py index 1aaf284..b51ee5e 100644 --- a/QonicApi.py +++ b/QonicApi.py @@ -89,7 +89,7 @@ def list_models(self, project_id: str) -> List[Any]: return self.get(f"projects/{project_id}/models").get("models", []) def get_available_product_fields(self, project_id: str, model_id: str) -> List[str]: - return self.get(f"projects/{project_id}/models/{model_id}/products/available-data").get("fields", []) + return self.get(f"projects/{project_id}/models/{model_id}/products/properties/available-data").get("fields", []) def query_products( self, @@ -102,20 +102,20 @@ def query_products( "fields": list(fields), "filters": filters or {}, } - return self._post(f"projects/{project_id}/models/{model_id}/products/query", json=body).get("result", {}) + return self._post(f"projects/{project_id}/models/{model_id}/products/properties/query", json=body).get("result", {}) def calculate_quantities(self, project_id: str, model_id: str, calculators: Iterable[str], - filters: Dict[str, Any] | None = None) -> Dict[str, Any]: + filters: Iterable[ProductFilter] | None = None) -> Dict[str, Any]: body = { "calculators": list(calculators), "filters": filters or {}, } - return self._post(f"projects/{project_id}/models/{model_id}/quantities", json=body) + return self._post(f"projects/{project_id}/models/{model_id}/products/quantities/query", json=body) def get_quantities_result_url(self, project_id: str, model_id: str, operation_id: str) -> str: resp = self._request( "GET", - f"projects/{project_id}/models/{model_id}/quantities/{operation_id}/result", + f"projects/{project_id}/models/{model_id}/products/quantities/{operation_id}/result", allow_redirects=False, ) location = resp.headers.get("Location") @@ -149,15 +149,15 @@ def modify_products(self, project_id: str, model_id: str, changes: Dict[str, Any def delete_product(self, project_id: str, model_id: str, guid: str) -> None: self._delete(f"projects/{project_id}/models/{model_id}/products/{guid}") - def publish_changes(self, project_id: str, title: Optional[str] = None, description: Optional[str] = None) -> None: + def publish_changes(self, project_id: str, model_id: str, title: Optional[str] = None, description: Optional[str] = None) -> None: body = { "title": title, "description": description, } - self._post(f"projects/{project_id}//publish", json=body) + self._post(f"projects/{project_id}/models/{model_id}/publish", json=body) - def discard_changes(self, project_id: str) -> None: - self._post(f"projects/{project_id}/discard") + def discard_changes(self, project_id: str, model_id: str) -> None: + self._post(f"projects/{project_id}/models/{model_id}/discard") def get_upload_url(self) -> str: data = self.get("upload-url") @@ -268,8 +268,8 @@ def create_material(self, project_id: str, library_guid: str, material: Dict[str result = self._post(f"projects/{project_id}/material-libraries/{library_guid}/materials", json=material) return result if isinstance(result, dict) else {} - def update_material(self, project_id: str, material_guid: str, material: Dict[str, Any], ) -> None: - self._put(f"projects/{project_id}/material-libraries/{material_guid}/materials/{material_guid}", json=material) + def update_material(self, project_id: str, library_guid: str, material_guid: str, material: Dict[str, Any], ) -> None: + self._put(f"projects/{project_id}/material-libraries/{library_guid}/materials/{material_guid}", json=material) def delete_material(self, project_id: str, library_guid: str, material_guid: str) -> None: self._delete(f"projects/{project_id}/material-libraries/{library_guid}/materials/{material_guid}") diff --git a/sample.py b/sample.py index 9f1368e..57639c7 100644 --- a/sample.py +++ b/sample.py @@ -230,6 +230,7 @@ def handle_materials(api: QonicApi, project_id: str): new_material_id = new_material["guid"] api.update_material( project_id, + library_id, new_material_id, updated_material, ) @@ -552,11 +553,12 @@ def handle_calculate_quantities(api: QonicApi, project_id: str): print() print("Starting quantity calculation of 'Length' and 'GrossArea' for all products with class 'Wall'") + filters: list[ProductFilter] = [{"property": "Class", "value": "Wall", "operator": "Contains"}] operation = api.calculate_quantities( project_id, model_id, calculators=["Length", "GrossArea"], - filters={"Class": "Wall"}, + filters=filters ) operation_id = operation["id"] print(f"Quantities operation id {operation_id}") From fe89f43dfc8587500aa7d2a69dd705721137e0c0 Mon Sep 17 00:00:00 2001 From: Jarre Date: Tue, 24 Mar 2026 10:17:07 +0100 Subject: [PATCH 05/14] fix: replace deprecated discipline field with tags array (#19) --- QonicApi.py | 5 +++-- sample.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/QonicApi.py b/QonicApi.py index b51ee5e..661975c 100644 --- a/QonicApi.py +++ b/QonicApi.py @@ -170,15 +170,16 @@ def create_model( model_name: str, upload_url: str, upload_file_name: str, - discipline: str, + tags: Optional[List[str]] = None, default_role: Optional[str] = None, ) -> Dict[str, Any]: body = { "modelName": model_name, "uploadUrl": upload_url, "uploadFileName": upload_file_name, - "discipline": discipline, } + if tags is not None: + body["tags"] = tags if default_role is not None: body["defaultRole"] = default_role diff --git a/sample.py b/sample.py index 57639c7..2f202ff 100644 --- a/sample.py +++ b/sample.py @@ -458,7 +458,7 @@ def handle_create_model(api: QonicApi, project_id: str): model_name=model_name, upload_url=upload_url, upload_file_name=upload_file_name, - discipline="Other", + tags=["Architecture"], ) print(f"Created model with id {result['modelId']}") From 68070c49b34ee0f10ad9c92b9a25305093945d93 Mon Sep 17 00:00:00 2001 From: Jarre Date: Mon, 8 Jun 2026 13:58:09 +0200 Subject: [PATCH 06/14] fix: fetch full library before printing codification codes (#25) --- sample.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sample.py b/sample.py index 2f202ff..d47f12e 100644 --- a/sample.py +++ b/sample.py @@ -141,7 +141,8 @@ def handle_codifications(api: QonicApi, project_id: str): print("No codification libraries found") return - printMethods.printCodificationLibrary(libraries[0]) + first_library = api.get_codification_library(project_id, libraries[0]["guid"]) + printMethods.printCodificationLibrary(first_library) print("Adding new TestCodificationLibrary") data = { From 3790ef3fcc3f48bcd1cd2964199483e422064e00 Mon Sep 17 00:00:00 2001 From: Jarre Date: Mon, 8 Jun 2026 14:10:46 +0200 Subject: [PATCH 07/14] fix: guard against null codes in printCodificationLibrary (#26) --- printMethods.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/printMethods.py b/printMethods.py index 0a4b269..7797b75 100644 --- a/printMethods.py +++ b/printMethods.py @@ -6,7 +6,7 @@ def printCodificationLibrary(data): print("Codes:") # Loop through classifications and properties - for classification in data["codes"]: + for classification in data.get("codes") or []: print(f" {classification['identification']} {classification['name']}") print("-----------------------------------------") From 92524b12b963f6131225629824e2fafc7ac9ad1e Mon Sep 17 00:00:00 2001 From: Jarre Date: Fri, 19 Jun 2026 10:19:58 +0200 Subject: [PATCH 08/14] feat: public client support and token management methods (#29) --- .env.example | 1 + QonicApi.py | 9 +++++++++ oauth.py | 20 ++++++++++++-------- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 530e5b4..fd42577 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ QONIC_SCOPES=projects:read projects:write models:read models:write issues:read l QONIC_REDIRECT_URI=http://localhost:8765/callback QONIC_LOCAL_PORT=8765 QONIC_CLIENT_ID= +# Leave blank for public clients (no client secret required) QONIC_CLIENT_SECRET= \ No newline at end of file diff --git a/QonicApi.py b/QonicApi.py index 661975c..43b0bbc 100644 --- a/QonicApi.py +++ b/QonicApi.py @@ -313,3 +313,12 @@ def update_type(self, project_id: str, library_guid: str, type_guid: str, change def delete_type(self, project_id: str, library_guid: str, type_guid: str) -> None: self._delete(f"projects/{project_id}/types/{library_guid}/types/{type_guid}") + def list_tokens(self) -> List[Dict[str, Any]]: + return self.get("auth/tokens") + + def revoke_token(self, token_id: str) -> None: + self._delete(f"auth/tokens/{token_id}") + + def revoke_consent(self, app_id: str) -> None: + self._delete(f"auth/consents/{app_id}") + diff --git a/oauth.py b/oauth.py index aa083e2..bd039e7 100644 --- a/oauth.py +++ b/oauth.py @@ -107,16 +107,20 @@ def login() -> dict: raise SystemExit("No code received from Qonic!") # 4) Exchange code for tokens (include codeVerifier) + # Public clients have no secret; omit client_secret when not configured. + token_data = { + "client_id": CLIENT_ID, + "code": code, + "redirect_uri": REDIRECT_URI, + "code_verifier": code_verifier, + "grant_type": "authorization_code", + } + if CLIENT_SECRET: + token_data["client_secret"] = CLIENT_SECRET + result = requests.post( f"{API_URL}/auth/token", - data={ - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "code": code, - "redirect_uri": REDIRECT_URI, - "code_verifier": code_verifier, - "grant_type": "authorization_code" - }, + data=token_data, timeout=30, ).json() From c17abc9726534203ce7126b3a3697cdb732e4abe Mon Sep 17 00:00:00 2001 From: Jarre Date: Mon, 6 Jul 2026 10:25:25 +0200 Subject: [PATCH 09/14] feat[auth]: use default public API sample client (#31) (#32) --- .env.example | 2 -- README.md | 16 ++++++---------- oauth.py | 44 +++++++++++++++++++++++++++++--------------- 3 files changed, 35 insertions(+), 27 deletions(-) diff --git a/.env.example b/.env.example index fd42577..bd60dcc 100644 --- a/.env.example +++ b/.env.example @@ -2,5 +2,3 @@ QONIC_SCOPES=projects:read projects:write models:read models:write issues:read l QONIC_REDIRECT_URI=http://localhost:8765/callback QONIC_LOCAL_PORT=8765 QONIC_CLIENT_ID= -# Leave blank for public clients (no client secret required) -QONIC_CLIENT_SECRET= \ No newline at end of file diff --git a/README.md b/README.md index 257d0a7..2f7aa45 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,9 @@ Follow these steps to configure and run the Python example for the Qonic API. ### 1. Prerequisites -- Python 3.10+ -- A Qonic Application in the [Developer Portal](https://developer.qonic.com) +- Python 3.10+ -You’ll need: -- Client ID and Client Secret -- Whitelisted Redirect URI (e.g. http://localhost:8765/callback) +By default, this sample uses Qonic's pre-registered public OAuth client. To test your own app registration, set `QONIC_CLIENT_ID` to your application's client id and make sure `QONIC_REDIRECT_URI` is whitelisted. ### 2. Copy the environment template ```bash @@ -22,10 +19,6 @@ cp .env.example .env ### 3. Configure .env ```bash -# From your Developer Portal application -QONIC_CLIENT_ID=YOUR_CLIENT_ID -QONIC_CLIENT_SECRET=YOUR_CLIENT_SECRET - # Must exactly match a whitelisted redirect URI QONIC_REDIRECT_URI=http://localhost:8765/callback @@ -34,12 +27,15 @@ QONIC_LOCAL_PORT=8765 # Space-separated scopes required for the sample QONIC_SCOPES=projects:read models:read + +# Optional: override Qonic's default sample client +QONIC_CLIENT_ID=YOUR_CLIENT_ID ``` **Notes** - QONIC_REDIRECT_URI must match a whitelisted Redirect URI exactly (scheme, host, port, path). - The sample spins up a tiny local HTTP server on QONIC_LOCAL_PORT to receive the authorization code. -- PKCE is supported out of the box; no extra setup is required. +- PKCE is used out of the box; no client secret is required or sent. ### 4. Install dependencies diff --git a/oauth.py b/oauth.py index bd039e7..af63ad3 100644 --- a/oauth.py +++ b/oauth.py @@ -18,7 +18,7 @@ REDIRECT_URI = os.getenv("QONIC_REDIRECT_URI", "http://localhost:8765/callback") LOCAL_PORT = os.getenv("QONIC_LOCAL_PORT", 8765) CLIENT_ID = os.getenv("QONIC_CLIENT_ID") -CLIENT_SECRET = os.getenv("QONIC_CLIENT_SECRET") +APPLICATION_KEY = "api_python_sample" # --- PKCE helpers ------------------------------------------------------------ @@ -70,9 +70,27 @@ def run_local_server(): server.server_close() return OAuthHandler.result +def get_client_id() -> str: + if CLIENT_ID: + return CLIENT_ID + + response = requests.get(f"{API_URL}/public-api-applications/config", timeout=30) + response.raise_for_status() + applications = response.json() + client_id = applications.get(APPLICATION_KEY) + if not client_id: + raise SystemExit( + f"No default Qonic public API application client id found for {APPLICATION_KEY}. " + "Set QONIC_CLIENT_ID to use your own Developer Portal application." + ) + + return client_id + # --- Main login flow (now with PKCE) ---------------------------------------- def login() -> dict: + client_id = get_client_id() + # 1) Prepare PKCE + state code_verifier = make_code_verifier(64) code_challenge = make_code_challenge(code_verifier) @@ -80,7 +98,7 @@ def login() -> dict: # 2) Open authorize URL (with PKCE + state) params = { - "client_id": CLIENT_ID, + "client_id": client_id, "scope": SCOPE, "redirect_uri": REDIRECT_URI, "state": state, @@ -107,20 +125,16 @@ def login() -> dict: raise SystemExit("No code received from Qonic!") # 4) Exchange code for tokens (include codeVerifier) - # Public clients have no secret; omit client_secret when not configured. - token_data = { - "client_id": CLIENT_ID, - "code": code, - "redirect_uri": REDIRECT_URI, - "code_verifier": code_verifier, - "grant_type": "authorization_code", - } - if CLIENT_SECRET: - token_data["client_secret"] = CLIENT_SECRET - + # Public client uses PKCE; no client secret is sent. result = requests.post( f"{API_URL}/auth/token", - data=token_data, + data={ + "client_id": client_id, + "code": code, + "redirect_uri": REDIRECT_URI, + "code_verifier": code_verifier, + "grant_type": "authorization_code", + }, timeout=30, ).json() @@ -133,4 +147,4 @@ def login() -> dict: return result if __name__ == "__main__": - print(login()) \ No newline at end of file + print(login()) From 07de34c10ad0427e4c66ec1328a71e8d55e08712 Mon Sep 17 00:00:00 2001 From: Jarre Date: Mon, 6 Jul 2026 10:49:16 +0200 Subject: [PATCH 10/14] fix: open modification session for location writes (#33) --- sample.py | 100 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 58 insertions(+), 42 deletions(-) diff --git a/sample.py b/sample.py index d47f12e..206e713 100644 --- a/sample.py +++ b/sample.py @@ -256,55 +256,71 @@ def handle_locations(api: QonicApi, project_id: str): printMethods.printLocations(location) print("-----------------------------------------") - print("Add new site location") - - new_site = { - "name": "NewLocation", - "type": "Site", - "parentGuid": None, - } - - site = api.create_location( - project_id, - new_site, - ) + # Location writes are project-scoped, but a modification session is always + # anchored to a model, so pick one to open the session on. + models = api.list_models(project_id) + print("your models:") + for model in models: + print(f"{model['id']} - {model['name']}") + print() - guid_site = [prop for prop in site["properties"] if prop["name"] == "Guid"][0]["value"] + model_id = input("Enter a model id: ") + print() - print("Add new building") - new_building = { - "name": "NewBuilding", - "type": "Building", - "parentGuid": guid_site, - } + print("Starting modification session") + api.start_session(project_id, model_id) + try: + print("Add new site location") + new_site = { + "name": "NewLocation", + "type": "Site", + "parentGuid": None, + } - api.create_location( - project_id, - new_building, - ) + site = api.create_location( + project_id, + new_site, + ) - print("Update site name to newSite") - updated_site = { - "name": "newSite" - } - api.update_location( - project_id, - guid_site, - updated_site, - ) + guid_site = [prop for prop in site["properties"] if prop["name"] == "Guid"][0]["value"] - print("Show added locations") - locations = api.get_locations(project_id) + print("Add new building") + new_building = { + "name": "NewBuilding", + "type": "Building", + "parentGuid": guid_site, + } - for location in locations: - if [prop for prop in site["properties"] if prop["name"] == "Guid"][0]["value"] == guid_site: - printMethods.printLocations(location) + api.create_location( + project_id, + new_building, + ) - print("Delete added locations") - api.delete_location( - project_id, - guid_site, - ) + print("Update site name to newSite") + updated_site = { + "name": "newSite" + } + api.update_location( + project_id, + guid_site, + updated_site, + ) + + print("Show added locations") + locations = api.get_locations(project_id) + + for location in locations: + if [prop for prop in site["properties"] if prop["name"] == "Guid"][0]["value"] == guid_site: + printMethods.printLocations(location) + + print("Delete added locations") + api.delete_location( + project_id, + guid_site, + ) + finally: + print("Closing modification session") + api.end_session(project_id, model_id) def handle_custom_properties(api: QonicApi, project_id: str): From 910648bc11ea1bce72d15bbe9442d953b5326c36 Mon Sep 17 00:00:00 2001 From: Jarre Date: Mon, 6 Jul 2026 11:07:50 +0200 Subject: [PATCH 11/14] fix: open a modification session for codification, material and custom-property writes (#34) --- sample.py | 277 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 157 insertions(+), 120 deletions(-) diff --git a/sample.py b/sample.py index 206e713..baf2812 100644 --- a/sample.py +++ b/sample.py @@ -144,55 +144,72 @@ def handle_codifications(api: QonicApi, project_id: str): first_library = api.get_codification_library(project_id, libraries[0]["guid"]) printMethods.printCodificationLibrary(first_library) - print("Adding new TestCodificationLibrary") - data = { - "name": "CodificationTestLibrary", - "description": "codification library for testing", - } + # Codification writes are project-scoped, but a modification session is + # always anchored to a model, so pick one to open the session on. + models = api.list_models(project_id) + print("your models:") + for model in models: + print(f"{model['id']} - {model['name']}") + print() - new_library = api.create_codification_library(project_id, data) + model_id = input("Enter a model id: ") + print() - library_id = new_library["guid"] + print("Starting modification session") + api.start_session(project_id, model_id) + try: + print("Adding new TestCodificationLibrary") + data = { + "name": "CodificationTestLibrary", + "description": "codification library for testing", + } - new_code_to_add = { - "name": "NewRootCode", - "identification": "0", - "description": "NewCodeForTesting", - "parentId": None, - } - print("Adding new root code the library") - new_code = api.create_classification_code( - project_id, - library_id, - new_code_to_add, - ) + new_library = api.create_codification_library(project_id, data) - print("updating the name of the new code from NewRootcode to updatedName") - updated_code = { - "name": "updatedName" - } - api.update_classification_code( - project_id, - library_id, - new_code["guid"], - updated_code, - ) + library_id = new_library["guid"] - print("Show new library") - newly_added_library = api.get_codification_library(project_id, library_id) - printMethods.printCodificationLibrary(newly_added_library) + new_code_to_add = { + "name": "NewRootCode", + "identification": "0", + "description": "NewCodeForTesting", + "parentId": None, + } + print("Adding new root code the library") + new_code = api.create_classification_code( + project_id, + library_id, + new_code_to_add, + ) - print("Delete newly added code") - api.delete_classification_code( - project_id, - library_id, - new_code["guid"], - ) - print("Delete new library") - api.delete_codification_library( - project_id, - library_id, - ) + print("updating the name of the new code from NewRootcode to updatedName") + updated_code = { + "name": "updatedName" + } + api.update_classification_code( + project_id, + library_id, + new_code["guid"], + updated_code, + ) + + print("Show new library") + newly_added_library = api.get_codification_library(project_id, library_id) + printMethods.printCodificationLibrary(newly_added_library) + + print("Delete newly added code") + api.delete_classification_code( + project_id, + library_id, + new_code["guid"], + ) + print("Delete new library") + api.delete_codification_library( + project_id, + library_id, + ) + finally: + print("Closing modification session") + api.end_session(project_id, model_id) def handle_materials(api: QonicApi, project_id: str): @@ -205,48 +222,65 @@ def handle_materials(api: QonicApi, project_id: str): printMethods.printMaterials(materials["materialProperties"][0]) - new_library = api.create_material_library( - project_id, - {"Name": "TestMaterial"}, - ) + # Material writes are project-scoped, but a modification session is always + # anchored to a model, so pick one to open the session on. + models = api.list_models(project_id) + print("your models:") + for model in models: + print(f"{model['id']} - {model['name']}") + print() - library_id = new_library["guid"] - new_material = { - "name": "Concrete", - "description": "test", - "color": "#785B3DFF", - } - print("Adding new material to the library") - new_material = api.create_material( - project_id, - library_id, - new_material, - ) + model_id = input("Enter a model id: ") + print() - updated_material = { - "name": "Concrete", - "description": "concrete test material", - "color": "#49C73EFF", - } - new_material_id = new_material["guid"] - api.update_material( - project_id, - library_id, - new_material_id, - updated_material, - ) - materials = api.get_material_overview(project_id) + print("Starting modification session") + api.start_session(project_id, model_id) + try: + new_library = api.create_material_library( + project_id, + {"Name": "TestMaterial"}, + ) - printMethods.printMaterials( - [lib for lib in materials["materialProperties"] if lib["guid"] == library_id][0] - ) + library_id = new_library["guid"] + new_material = { + "name": "Concrete", + "description": "test", + "color": "#785B3DFF", + } + print("Adding new material to the library") + new_material = api.create_material( + project_id, + library_id, + new_material, + ) - print("Delete material again") - api.delete_material( - project_id, - library_id, - new_material_id, - ) + updated_material = { + "name": "Concrete", + "description": "concrete test material", + "color": "#49C73EFF", + } + new_material_id = new_material["guid"] + api.update_material( + project_id, + library_id, + new_material_id, + updated_material, + ) + materials = api.get_material_overview(project_id) + + printMethods.printMaterials( + [lib for lib in materials["materialProperties"] if lib["guid"] == library_id][0] + ) + + print("Delete material again") + api.delete_material( + project_id, + library_id, + new_material_id, + ) + finally: + print("Closing modification session") + api.end_session(project_id, model_id) def handle_locations(api: QonicApi, project_id: str): @@ -329,30 +363,9 @@ def handle_custom_properties(api: QonicApi, project_id: str): custom_properties = api.get_custom_properties(project_id) printMethods.printCustomProperties(custom_properties) - print("Create new propertyset: TestSet") - - property_set_to_add = { - "Name": "TestSet", - "EntityTypes": [{"Value": "IfcWall"}, {"Value": "IfcBeam"}, {"Value": "IfcSlab"}], - } - added_set = api.create_property_set( - project_id, - property_set_to_add, - ) - - print("Add property to set: TestProperty") - - property_to_add = { - "Name": "TestProperty", - "DataType": "String", - } - - api.add_property_definition( - project_id, - added_set["id"], - property_to_add, - ) - + # Property-set and property-definition writes are project-scoped, but a + # modification session is always anchored to a model, so pick one and open + # the session before any write (including create_property_set). print("Choose model to add this property to") models = api.list_models(project_id) @@ -364,25 +377,49 @@ def handle_custom_properties(api: QonicApi, project_id: str): model_id = input("Enter a model id: ") print() - print("Querying the Guid, Class, Name fields, filtered on Class Wall...") - print() + print("Starting modification session") + api.start_session(project_id, model_id) + try: + print("Create new propertyset: TestSet") - fields = ["Guid", "Class", "Name"] - filters: list[ProductFilter] = [{"property": "Class", "value": "Wall", "operator": "Contains"}] - properties = api.query_products(project_id, model_id, fields=fields, filters=filters) + property_set_to_add = { + "Name": "TestSet", + "EntityTypes": [{"Value": "IfcWall"}, {"Value": "IfcBeam"}, {"Value": "IfcSlab"}], + } + added_set = api.create_property_set( + project_id, + property_set_to_add, + ) - print() - for row in properties: - print(f"{row}") + print("Add property to set: TestProperty") - print("Adding test property to first wall") - print(" Starting modification session") - if not properties: - print("No walls found") - return + property_to_add = { + "Name": "TestProperty", + "DataType": "String", + } - api.start_session(project_id, model_id) - try: + api.add_property_definition( + project_id, + added_set["id"], + property_to_add, + ) + + print("Querying the Guid, Class, Name fields, filtered on Class Wall...") + print() + + fields = ["Guid", "Class", "Name"] + filters: list[ProductFilter] = [{"property": "Class", "value": "Wall", "operator": "Contains"}] + properties = api.query_products(project_id, model_id, fields=fields, filters=filters) + + print() + for row in properties: + print(f"{row}") + + if not properties: + print("No walls found") + return + + print("Adding test property to first wall") changes = { "add": { "TestProperty": { From 4f60322c53390738bf8dc66bd13cd2899bdbc205 Mon Sep 17 00:00:00 2001 From: Tom De Smet Date: Wed, 29 Jul 2026 09:40:16 +0200 Subject: [PATCH 12/14] fix: update model upload and async result endpoints for V1.27 (#35) --- QonicApi.py | 47 ++++++++++++++++++++++++----------------------- sample.py | 14 +++++++++----- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/QonicApi.py b/QonicApi.py index 43b0bbc..b5c68c2 100644 --- a/QonicApi.py +++ b/QonicApi.py @@ -113,15 +113,11 @@ def calculate_quantities(self, project_id: str, model_id: str, calculators: Iter return self._post(f"projects/{project_id}/models/{model_id}/products/quantities/query", json=body) def get_quantities_result_url(self, project_id: str, model_id: str, operation_id: str) -> str: - resp = self._request( - "GET", - f"projects/{project_id}/models/{model_id}/products/quantities/{operation_id}/result", - allow_redirects=False, - ) - location = resp.headers.get("Location") - if not location: - raise QonicApiError(resp) - return location + data = self.get(f"projects/{project_id}/models/{model_id}/products/quantities/{operation_id}/result") + download_url = data.get("downloadUrl") + if not download_url: + raise ValueError("The API returned no download url for the quantities result") + return download_url def get_operation(self, operation_id: str) -> Dict[str, Any]: return self.get(f"operations/{operation_id}") @@ -159,23 +155,32 @@ def publish_changes(self, project_id: str, model_id: str, title: Optional[str] = def discard_changes(self, project_id: str, model_id: str) -> None: self._post(f"projects/{project_id}/models/{model_id}/discard") - def get_upload_url(self) -> str: - data = self.get("upload-url") - return data["uploadUrl"] + def get_upload_url(self, file_name: Optional[str] = None) -> Dict[str, str]: + """Request a single upload slot. + + Returns a dict with 'uploadUrl' (where to PUT the file) and 'key' + (what to pass back as uploadKey when creating the model). + """ + params = {"fileName": file_name} if file_name else None + data = self.get("files/upload-url", params=params) + items = data.get("items") or [] + if not items: + raise ValueError("The API returned no upload url") + return items[0] def create_model( self, project_id: str, *, model_name: str, - upload_url: str, + upload_key: str, upload_file_name: str, tags: Optional[List[str]] = None, default_role: Optional[str] = None, ) -> Dict[str, Any]: body = { "modelName": model_name, - "uploadUrl": upload_url, + "uploadKey": upload_key, "uploadFileName": upload_file_name, } if tags is not None: @@ -189,15 +194,11 @@ def start_export_ifc(self, project_id: str, model_id: str) -> Dict[str, Any]: return self._post(f"projects/{project_id}/models/{model_id}/export-ifc") def get_export_ifc_result_url(self, project_id: str, model_id: str, operation_id: str) -> str: - resp = self._request( - "GET", - f"projects/{project_id}/models/{model_id}/export-ifc/{operation_id}/result", - allow_redirects=False, - ) - location = resp.headers.get("Location") - if not location: - raise QonicApiError(resp) - return location + data = self.get(f"projects/{project_id}/models/{model_id}/export-ifc/{operation_id}/result") + download_url = data.get("downloadUrl") + if not download_url: + raise ValueError("The API returned no download url for the ifc export") + return download_url def list_codification_libraries(self, project_id: str) -> List[Dict[str, Any]]: data = self.get(f"projects/{project_id}/codifications") diff --git a/sample.py b/sample.py index baf2812..d656345 100644 --- a/sample.py +++ b/sample.py @@ -487,19 +487,23 @@ def handle_delete_product(api: QonicApi, project_id: str): def handle_create_model(api: QonicApi, project_id: str): - print("Requesting upload URL") - upload_url = api.get_upload_url() - print("Upload URL received") local_path = input("Enter the path to the local model file to upload: ").strip() while not local_path or not os.path.isfile(local_path): if not local_path: print("No file path provided") - if not os.path.isfile(local_path): + else: print("File does not exist") local_path = input("Enter the path to the local model file to upload: ").strip() upload_file_name = os.path.basename(local_path) + + print("Requesting upload URL") + upload = api.get_upload_url(upload_file_name) + upload_url = upload["uploadUrl"] + upload_key = upload["key"] + print("Upload URL received") + print(f"Uploading {upload_file_name} to storage") with open(local_path, "rb") as f: resp = requests.put(upload_url, data=f) @@ -510,7 +514,7 @@ def handle_create_model(api: QonicApi, project_id: str): result = api.create_model( project_id, model_name=model_name, - upload_url=upload_url, + upload_key=upload_key, upload_file_name=upload_file_name, tags=["Architecture"], ) From bf40ff6d3305c07de02d24bdf91e9897f6df6e5f Mon Sep 17 00:00:00 2001 From: Tom De Smet Date: Wed, 29 Jul 2026 10:29:36 +0200 Subject: [PATCH 13/14] Revert "feat[auth]: use default public API sample client (#31) (#32)" This reverts commit c17abc9726534203ce7126b3a3697cdb732e4abe. The public client flow only works where /v1/public-api-applications/config is reachable (develop, beta). On rc, demo and production it returns 401, and since the commit also stopped sending client_secret the token exchange fails with 500, leaving no working auth path. main already reverted the same commit in fb9966e. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017waV7tkWye4BStKry3rqjU --- .env.example | 2 ++ README.md | 16 ++++++++++------ oauth.py | 44 +++++++++++++++----------------------------- 3 files changed, 27 insertions(+), 35 deletions(-) diff --git a/.env.example b/.env.example index bd60dcc..fd42577 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,5 @@ QONIC_SCOPES=projects:read projects:write models:read models:write issues:read l QONIC_REDIRECT_URI=http://localhost:8765/callback QONIC_LOCAL_PORT=8765 QONIC_CLIENT_ID= +# Leave blank for public clients (no client secret required) +QONIC_CLIENT_SECRET= \ No newline at end of file diff --git a/README.md b/README.md index 2f7aa45..257d0a7 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,12 @@ Follow these steps to configure and run the Python example for the Qonic API. ### 1. Prerequisites -- Python 3.10+ +- Python 3.10+ +- A Qonic Application in the [Developer Portal](https://developer.qonic.com) -By default, this sample uses Qonic's pre-registered public OAuth client. To test your own app registration, set `QONIC_CLIENT_ID` to your application's client id and make sure `QONIC_REDIRECT_URI` is whitelisted. +You’ll need: +- Client ID and Client Secret +- Whitelisted Redirect URI (e.g. http://localhost:8765/callback) ### 2. Copy the environment template ```bash @@ -19,6 +22,10 @@ cp .env.example .env ### 3. Configure .env ```bash +# From your Developer Portal application +QONIC_CLIENT_ID=YOUR_CLIENT_ID +QONIC_CLIENT_SECRET=YOUR_CLIENT_SECRET + # Must exactly match a whitelisted redirect URI QONIC_REDIRECT_URI=http://localhost:8765/callback @@ -27,15 +34,12 @@ QONIC_LOCAL_PORT=8765 # Space-separated scopes required for the sample QONIC_SCOPES=projects:read models:read - -# Optional: override Qonic's default sample client -QONIC_CLIENT_ID=YOUR_CLIENT_ID ``` **Notes** - QONIC_REDIRECT_URI must match a whitelisted Redirect URI exactly (scheme, host, port, path). - The sample spins up a tiny local HTTP server on QONIC_LOCAL_PORT to receive the authorization code. -- PKCE is used out of the box; no client secret is required or sent. +- PKCE is supported out of the box; no extra setup is required. ### 4. Install dependencies diff --git a/oauth.py b/oauth.py index af63ad3..bd039e7 100644 --- a/oauth.py +++ b/oauth.py @@ -18,7 +18,7 @@ REDIRECT_URI = os.getenv("QONIC_REDIRECT_URI", "http://localhost:8765/callback") LOCAL_PORT = os.getenv("QONIC_LOCAL_PORT", 8765) CLIENT_ID = os.getenv("QONIC_CLIENT_ID") -APPLICATION_KEY = "api_python_sample" +CLIENT_SECRET = os.getenv("QONIC_CLIENT_SECRET") # --- PKCE helpers ------------------------------------------------------------ @@ -70,27 +70,9 @@ def run_local_server(): server.server_close() return OAuthHandler.result -def get_client_id() -> str: - if CLIENT_ID: - return CLIENT_ID - - response = requests.get(f"{API_URL}/public-api-applications/config", timeout=30) - response.raise_for_status() - applications = response.json() - client_id = applications.get(APPLICATION_KEY) - if not client_id: - raise SystemExit( - f"No default Qonic public API application client id found for {APPLICATION_KEY}. " - "Set QONIC_CLIENT_ID to use your own Developer Portal application." - ) - - return client_id - # --- Main login flow (now with PKCE) ---------------------------------------- def login() -> dict: - client_id = get_client_id() - # 1) Prepare PKCE + state code_verifier = make_code_verifier(64) code_challenge = make_code_challenge(code_verifier) @@ -98,7 +80,7 @@ def login() -> dict: # 2) Open authorize URL (with PKCE + state) params = { - "client_id": client_id, + "client_id": CLIENT_ID, "scope": SCOPE, "redirect_uri": REDIRECT_URI, "state": state, @@ -125,16 +107,20 @@ def login() -> dict: raise SystemExit("No code received from Qonic!") # 4) Exchange code for tokens (include codeVerifier) - # Public client uses PKCE; no client secret is sent. + # Public clients have no secret; omit client_secret when not configured. + token_data = { + "client_id": CLIENT_ID, + "code": code, + "redirect_uri": REDIRECT_URI, + "code_verifier": code_verifier, + "grant_type": "authorization_code", + } + if CLIENT_SECRET: + token_data["client_secret"] = CLIENT_SECRET + result = requests.post( f"{API_URL}/auth/token", - data={ - "client_id": client_id, - "code": code, - "redirect_uri": REDIRECT_URI, - "code_verifier": code_verifier, - "grant_type": "authorization_code", - }, + data=token_data, timeout=30, ).json() @@ -147,4 +133,4 @@ def login() -> dict: return result if __name__ == "__main__": - print(login()) + print(login()) \ No newline at end of file From 37bf1d4342aec22e028fb7e7027f219546e8bf09 Mon Sep 17 00:00:00 2001 From: Tom De Smet Date: Wed, 29 Jul 2026 11:14:01 +0200 Subject: [PATCH 14/14] Revert "feat: public client support and token management methods (#29)" This reverts commit 92524b12b963f6131225629824e2fafc7ac9ad1e. Matches main, which reverted the identical change in #28. The token management methods target auth/tokens and auth/consents/{appId}, neither of which exists in V1.27. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017waV7tkWye4BStKry3rqjU --- .env.example | 1 - QonicApi.py | 9 --------- oauth.py | 20 ++++++++------------ 3 files changed, 8 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index fd42577..530e5b4 100644 --- a/.env.example +++ b/.env.example @@ -2,5 +2,4 @@ QONIC_SCOPES=projects:read projects:write models:read models:write issues:read l QONIC_REDIRECT_URI=http://localhost:8765/callback QONIC_LOCAL_PORT=8765 QONIC_CLIENT_ID= -# Leave blank for public clients (no client secret required) QONIC_CLIENT_SECRET= \ No newline at end of file diff --git a/QonicApi.py b/QonicApi.py index b5c68c2..ecd2cb1 100644 --- a/QonicApi.py +++ b/QonicApi.py @@ -314,12 +314,3 @@ def update_type(self, project_id: str, library_guid: str, type_guid: str, change def delete_type(self, project_id: str, library_guid: str, type_guid: str) -> None: self._delete(f"projects/{project_id}/types/{library_guid}/types/{type_guid}") - def list_tokens(self) -> List[Dict[str, Any]]: - return self.get("auth/tokens") - - def revoke_token(self, token_id: str) -> None: - self._delete(f"auth/tokens/{token_id}") - - def revoke_consent(self, app_id: str) -> None: - self._delete(f"auth/consents/{app_id}") - diff --git a/oauth.py b/oauth.py index bd039e7..aa083e2 100644 --- a/oauth.py +++ b/oauth.py @@ -107,20 +107,16 @@ def login() -> dict: raise SystemExit("No code received from Qonic!") # 4) Exchange code for tokens (include codeVerifier) - # Public clients have no secret; omit client_secret when not configured. - token_data = { - "client_id": CLIENT_ID, - "code": code, - "redirect_uri": REDIRECT_URI, - "code_verifier": code_verifier, - "grant_type": "authorization_code", - } - if CLIENT_SECRET: - token_data["client_secret"] = CLIENT_SECRET - result = requests.post( f"{API_URL}/auth/token", - data=token_data, + data={ + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "code": code, + "redirect_uri": REDIRECT_URI, + "code_verifier": code_verifier, + "grant_type": "authorization_code" + }, timeout=30, ).json()