From f53e28fb7f36e43cf5d90ba6af93467068f21f93 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Fri, 1 Aug 2025 13:34:10 -0600 Subject: [PATCH 01/42] Various custom template, project and task changes --- .vscode/launch.json | 15 ++++++++ src/albert/collections/companies.py | 44 ++++++++++++++++++++++ src/albert/collections/custom_templates.py | 22 +++++++++++ src/albert/collections/parameter_groups.py | 26 +++++++++++++ src/albert/collections/tasks.py | 2 +- src/albert/collections/units.py | 1 + src/albert/resources/acls.py | 1 + src/albert/resources/custom_templates.py | 13 +++++-- src/albert/resources/parameter_groups.py | 1 + src/albert/resources/sheets.py | 7 ++++ src/albert/resources/tagged_base.py | 2 + 11 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..3ff6055b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Current File", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" + } + ] +} diff --git a/src/albert/collections/companies.py b/src/albert/collections/companies.py index 9db5e3c0..4f25e401 100644 --- a/src/albert/collections/companies.py +++ b/src/albert/collections/companies.py @@ -1,3 +1,4 @@ +from __future__ import annotations import logging from collections.abc import Iterator @@ -222,3 +223,46 @@ def update(self, *, company: Company) -> Company: self.session.patch(url, json=patch_payload.model_dump(mode="json", by_alias=True)) updated_company = self.get_by_id(id=company.id) return updated_company + + def merge(self, *, parent_id: str, child_ids: str | list[str]) -> Company: + """ + Merge one or more child companies into a single parent company. + + Parameters + ---------- + parent_id : str + The ID of the company that will remain as the parent. + child_ids : Union[str, List[str]] + A single company ID or a list of company IDs to merge into the parent. + + Returns + ------- + Company + The updated parent Company object. + """ + # allow passing a single ID as a string + if isinstance(child_ids, str): + child_ids = [child_ids] + + if not child_ids: + msg = "At least one child company ID must be provided for merge." + logger.error(msg) + raise AlbertException(msg) + + payload = { + "parentId": parent_id, + "ChildCompanies": [{"id": child_id} for child_id in child_ids], + } + + url = f"{self.base_path}/merge" + response = self.session.post(url, json=payload) + if response.status_code == 206: + msg = f"Merge returned partial content (206). Check that all ACLs are valid." + logger.error(msg) + raise AlbertException(msg) + response.raise_for_status() + + try: + return Company(**response.json()) + except (ValueError, TypeError): + return self.get_by_id(id=parent_id) \ No newline at end of file diff --git a/src/albert/collections/custom_templates.py b/src/albert/collections/custom_templates.py index 03d90cfa..81cfb9f3 100644 --- a/src/albert/collections/custom_templates.py +++ b/src/albert/collections/custom_templates.py @@ -8,6 +8,7 @@ from albert.utils.pagination import AlbertPaginator, PaginationMode + class CustomTemplatesCollection(BaseCollection): """CustomTemplatesCollection is a collection class for managing CustomTemplate entities in the Albert platform.""" @@ -81,3 +82,24 @@ def deserialize(items: list[dict]) -> Iterator[CustomTemplate]: params=params, deserialize=deserialize, ) + + def create(self, *, custom_template: CustomTemplate) -> CustomTemplate: + """Creates a new custom template. + + Parameters + ---------- + custom_template : CustomTemplate + The custom template to create. + + Returns + ------- + CustomTemplate + The created CustomTemplate object. + """ + + response = self.session.post( + url=self.base_path, + json=[custom_template.model_dump(mode="json", by_alias=True, exclude_unset=True, exclude_none=True)], + ) + return CustomTemplate(**response.json()[0]) + \ No newline at end of file diff --git a/src/albert/collections/parameter_groups.py b/src/albert/collections/parameter_groups.py index 479c9d4d..8981f86c 100644 --- a/src/albert/collections/parameter_groups.py +++ b/src/albert/collections/parameter_groups.py @@ -208,6 +208,30 @@ def update(self, *, parameter_group: ParameterGroup) -> ParameterGroup: url=enum_url, json=ep, ) + required_params: list[dict] = [] + for existing_param in existing.parameters: + # find the matching updated param by its row_id + updated_param = next( + (parameter for parameter in parameter_group.parameters if parameter.sequence == existing_param.sequence), + None + ) + if not updated_param: + continue + + if existing_param.required != updated_param.required: + required_params.append({ + "operation": "update", + "attribute": "required", + "rowId": existing_param.sequence, + "oldValue": existing_param.required, + "newValue": updated_param.required, + }) + + if required_params: + self.session.patch( + url=path, + json={"data": required_params}, + ) if len(general_patches.data) > 0: # patch the general patches self.session.patch( @@ -216,3 +240,5 @@ def update(self, *, parameter_group: ParameterGroup) -> ParameterGroup: ) return self.get_by_id(id=parameter_group.id) + + \ No newline at end of file diff --git a/src/albert/collections/tasks.py b/src/albert/collections/tasks.py index 7fee1cf5..305782f7 100644 --- a/src/albert/collections/tasks.py +++ b/src/albert/collections/tasks.py @@ -57,7 +57,7 @@ def create(self, *, task: BaseTask) -> BaseTask: """Create a new task. Tasks can be of different types, such as PropertyTask, and are created using the provided task object. Parameters - ---------- + ---------- task : BaseTask The task object to create. diff --git a/src/albert/collections/units.py b/src/albert/collections/units.py index 83405d71..912ce0c9 100644 --- a/src/albert/collections/units.py +++ b/src/albert/collections/units.py @@ -48,6 +48,7 @@ def create(self, *, unit: Unit) -> Unit: f"Unit with the name {hit.name} already exists. Returning the existing unit." ) return hit + response = self.session.post( self.base_path, json=unit.model_dump(by_alias=True, exclude_unset=True, mode="json") ) diff --git a/src/albert/resources/acls.py b/src/albert/resources/acls.py index e336cadd..185b043a 100644 --- a/src/albert/resources/acls.py +++ b/src/albert/resources/acls.py @@ -16,6 +16,7 @@ class AccessControlLevel(str, Enum): INVENTORY_OWNER = "InventoryOwner" INVENTORY_VIEWER = "InventoryViewer" CUSTOM_TEMPLATE_OWNER = "CustomTemplateOwner" + CUSTOM_TEMPLATE_VIEWER = "CustomTemplateViewer" class ACL(BaseAlbertModel): diff --git a/src/albert/resources/custom_templates.py b/src/albert/resources/custom_templates.py index d6484400..9b02d5df 100644 --- a/src/albert/resources/custom_templates.py +++ b/src/albert/resources/custom_templates.py @@ -14,6 +14,7 @@ from albert.resources.tagged_base import BaseTaggedResource from albert.resources.tasks import TaskSource from albert.resources.users import User +from albert.resources.tags import Tag class DataTemplateInventory(EntityLink): @@ -39,6 +40,7 @@ class TemplateCategory(str, Enum): class Priority(str, Enum): LOW = "Low" HIGH = "High" + MEDIUM = "Medium" class GeneralData(BaseTaggedResource): @@ -51,6 +53,7 @@ class GeneralData(BaseTaggedResource): priority: Priority | None = Field(default=None) sources: list[TaskSource] | None = Field(alias="Sources", default=None) parent_id: str | None = Field(alias="parentId", default=None) + notes: str | None = Field(default=None) class JobStatus(str, Enum): @@ -109,6 +112,8 @@ class BatchData(BaseTaggedResource): inventories: list[DataTemplateInventory] | None = Field(default=None, alias="Inventories") priority: Priority # enum?! workflow: list[EntityLink] = Field(default=None, alias="Workflow") + notes: str | None = Field(default=None) + tags: list[Tag] | None = Field(default=None, alias="Tags") class PropertyData(BaseTaggedResource): @@ -121,6 +126,8 @@ class PropertyData(BaseTaggedResource): project: SerializeAsEntityLink[Project] | None = Field(alias="Project", default=None) inventories: list[DataTemplateInventory] | None = Field(default=None, alias="Inventories") due_date: str | None = Field(alias="dueDate", default=None) + tags: list[Tag] | None = Field(default=None, alias="Tags") + notes: str | None = Field(default=None) class SheetData(BaseTaggedResource): @@ -178,7 +185,7 @@ class CustomTemplate(BaseTaggedResource): category : TemplateCategory The category of the template. Allowed values are `Property Task`, `Property`, `Batch`, `Sheet`, `Notebook`, and `General`. metadata : Dict[str, str | List[EntityLink] | EntityLink] | None - The metadata of the template. Allowed Metadata fields can be found using Custim Fields. + The metadata of the template. Allowed Metadata fields can be found using Custom Fields. data : CustomTemplateData | None The data of the template. team : List[TeamACL] | None @@ -188,7 +195,7 @@ class CustomTemplate(BaseTaggedResource): """ name: str - id: str = Field(alias="albertId") + id: str | None = Field(alias="albertId", default=None) category: TemplateCategory = Field(default=TemplateCategory.GENERAL) metadata: dict[str, MetadataItem] | None = Field(default=None, alias="Metadata") data: CustomTemplateData | None = Field(default=None, alias="Data") @@ -204,4 +211,4 @@ def add_missing_category(cls, data: dict[str, Any]) -> dict[str, Any]: if "Data" in data and "category" in data and "category" not in data["Data"]: data["Data"]["category"] = data["category"] - return data + return data \ No newline at end of file diff --git a/src/albert/resources/parameter_groups.py b/src/albert/resources/parameter_groups.py index f7f55870..347dbcae 100644 --- a/src/albert/resources/parameter_groups.py +++ b/src/albert/resources/parameter_groups.py @@ -102,6 +102,7 @@ class ParameterValue(BaseAlbertModel): unit: SerializeAsEntityLink[Unit] | None = Field(alias="Unit", default=None) added: AuditFields | None = Field(alias="Added", default=None, exclude=True) validation: list[ValueValidation] | None = Field(default_factory=list) + required: bool | None = Field(default=False) # Read-only fields name: str | None = Field(default=None, exclude=True, frozen=True) diff --git a/src/albert/resources/sheets.py b/src/albert/resources/sheets.py index a55aee7a..4cdab4d3 100644 --- a/src/albert/resources/sheets.py +++ b/src/albert/resources/sheets.py @@ -53,6 +53,7 @@ class DesignType(str, Enum): APPS = "apps" PRODUCTS = "products" RESULTS = "results" + PROCESS = "process" class Cell(BaseResource): @@ -482,6 +483,12 @@ def add_formulation( return self.get_column(column_id=col_id) def _get_row_id_for_component(self, *, inventory_item, existing_cells, enforce_order): + + #Checks if that inventory row already exists + sheet_inv_id = inventory_item.id + for r in self.product_design.rows: + if r.inventory_id == sheet_inv_id: + return r.row_id self.grid = None # within a sheet, the "INV" prefix is dropped diff --git a/src/albert/resources/tagged_base.py b/src/albert/resources/tagged_base.py index 1c6ddb5a..0b1ddb16 100644 --- a/src/albert/resources/tagged_base.py +++ b/src/albert/resources/tagged_base.py @@ -28,6 +28,8 @@ def convert_tags(cls, data: dict[str, Any]) -> dict[str, Any]: tags = data.get("tags") if not tags: tags = data.get("Tags") + if not tags and "Data" in data: + tags = data["Data"].get("tags") or data["Data"].get("Tags") if tags: new_tags = [] for t in tags: From dd6d36f22dc8ef3a0b77129a7f1b1d9533495848 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Fri, 1 Aug 2025 14:39:14 -0600 Subject: [PATCH 02/42] Delete .vscode/launch.json --- .vscode/launch.json | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 3ff6055b..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Python Debugger: Current File", - "type": "debugpy", - "request": "launch", - "program": "${file}", - "console": "integratedTerminal" - } - ] -} From 6ac42f63762b1d171559a3f8758f10dd2c8bc7ca Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Mon, 4 Aug 2025 10:25:05 -0600 Subject: [PATCH 03/42] Merge --- src/albert/collections/companies.py | 3 --- src/albert/collections/custom_templates.py | 6 +++--- src/albert/collections/tasks.py | 5 ----- src/albert/collections/units.py | 10 ---------- src/albert/resources/custom_templates.py | 14 ++++---------- 5 files changed, 7 insertions(+), 31 deletions(-) diff --git a/src/albert/collections/companies.py b/src/albert/collections/companies.py index 219502df..6258e20f 100644 --- a/src/albert/collections/companies.py +++ b/src/albert/collections/companies.py @@ -1,8 +1,5 @@ -<<<<<<< HEAD from __future__ import annotations import logging -======= ->>>>>>> main from collections.abc import Iterator from albert.collections.base import BaseCollection diff --git a/src/albert/collections/custom_templates.py b/src/albert/collections/custom_templates.py index af2e2f38..edc66802 100644 --- a/src/albert/collections/custom_templates.py +++ b/src/albert/collections/custom_templates.py @@ -88,7 +88,7 @@ def search( ], ) -<<<<<<< HEAD + def create(self, *, custom_template: CustomTemplate) -> CustomTemplate: """Creates a new custom template. @@ -109,7 +109,7 @@ def create(self, *, custom_template: CustomTemplate) -> CustomTemplate: ) return CustomTemplate(**response.json()[0]) -======= + def get_all( self, *, @@ -142,4 +142,4 @@ def get_all( yield self.get_by_id(id=item.id) except AlbertHTTPError as e: logger.warning(f"Error hydrating custom template {item.id}: {e}") ->>>>>>> main + diff --git a/src/albert/collections/tasks.py b/src/albert/collections/tasks.py index 9dac7acd..187cf708 100644 --- a/src/albert/collections/tasks.py +++ b/src/albert/collections/tasks.py @@ -61,13 +61,8 @@ def create(self, *, task: PropertyTask | GeneralTask | BatchTask) -> BaseTask: """Create a new task. Tasks can be of different types, such as PropertyTask, and are created using the provided task object. Parameters -<<<<<<< HEAD - ---------- - task : BaseTask -======= ---------- task : PropertyTask | GeneralTask | BatchTask ->>>>>>> main The task object to create. Returns diff --git a/src/albert/collections/units.py b/src/albert/collections/units.py index ff3ec41d..603b1447 100644 --- a/src/albert/collections/units.py +++ b/src/albert/collections/units.py @@ -42,16 +42,6 @@ def create(self, *, unit: Unit) -> Unit: Unit The created Unit object. """ -<<<<<<< HEAD - hit = self.get_by_name(name=unit.name, exact_match=True) - if hit is not None: - logging.warning( - f"Unit with the name {hit.name} already exists. Returning the existing unit." - ) - return hit - -======= ->>>>>>> main response = self.session.post( self.base_path, json=unit.model_dump(by_alias=True, exclude_unset=True, mode="json") ) diff --git a/src/albert/resources/custom_templates.py b/src/albert/resources/custom_templates.py index 6a273035..e7f75127 100644 --- a/src/albert/resources/custom_templates.py +++ b/src/albert/resources/custom_templates.py @@ -16,12 +16,9 @@ from albert.resources.sheets import DesignType, Sheet from albert.resources.tagged_base import BaseTaggedResource from albert.resources.tasks import TaskSource -<<<<<<< HEAD -from albert.resources.users import User from albert.resources.tags import Tag -======= from albert.resources.users import User, UserClass ->>>>>>> main + class DataTemplateInventory(EntityLink): @@ -199,7 +196,7 @@ class CustomTemplate(BaseTaggedResource): category : TemplateCategory The category of the template. Allowed values are `Property Task`, `Property`, `Batch`, `Sheet`, `Notebook`, and `General`. metadata : Dict[str, str | List[EntityLink] | EntityLink] | None - The metadata of the template. Allowed Metadata fields can be found using Custom Fields. + The metadata of the template. Allowed Metadata fields can be found using Custim Fields. data : CustomTemplateData | None The data of the template. team : List[TeamACL] | None @@ -209,7 +206,7 @@ class CustomTemplate(BaseTaggedResource): """ name: str - id: str | None = Field(alias="albertId", default=None) + id: str = Field(alias="albertId") category: TemplateCategory = Field(default=TemplateCategory.GENERAL) metadata: dict[str, MetadataItem] | None = Field(default=None, alias="Metadata") data: CustomTemplateData | None = Field(default=None, alias="Data") @@ -225,9 +222,6 @@ def add_missing_category(cls, data: dict[str, Any]) -> dict[str, Any]: if "Data" in data and "category" in data and "category" not in data["Data"]: data["Data"]["category"] = data["category"] -<<<<<<< HEAD - return data -======= return data @@ -261,4 +255,4 @@ class CustomTemplateSearchItem(BaseAlbertModel, HydrationMixin[CustomTemplate]): data: CustomTemplateSearchItemData | None = None acl: list[CustomTemplateSearchItemACL] team: list[CustomTemplateSearchItemTeam] ->>>>>>> main + From 62fef2cc4f9577c0df6e4b38fa538711bc4522bd Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Tue, 5 Aug 2025 07:23:46 -0600 Subject: [PATCH 04/42] Edits --- src/albert/resources/custom_templates.py | 2 +- src/albert/utils/_patch.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/albert/resources/custom_templates.py b/src/albert/resources/custom_templates.py index e7f75127..6a005d08 100644 --- a/src/albert/resources/custom_templates.py +++ b/src/albert/resources/custom_templates.py @@ -45,7 +45,7 @@ class Priority(str, Enum): LOW = "Low" MEDIUM = "Medium" HIGH = "High" - MEDIUM = "Medium" + class GeneralData(BaseTaggedResource): diff --git a/src/albert/utils/_patch.py b/src/albert/utils/_patch.py index 55e63fe8..94825a3a 100644 --- a/src/albert/utils/_patch.py +++ b/src/albert/utils/_patch.py @@ -330,6 +330,9 @@ def generate_enum_patches( existing_enums: list[EnumValidationValue], updated_enums: list[EnumValidationValue] ) -> list[dict]: """Generate enum patches for a data column or parameter validation.""" + existing_enums = existing_enums or [] + updated_enums = updated_enums or [] + enum_patches = [] existing_enum = [x for x in existing_enums if isinstance(x, EnumValidationValue)] updated_enum = [x for x in updated_enums if isinstance(x, EnumValidationValue)] From 6671f1ffb3d95777b236289e1c125959a1d58302 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Thu, 7 Aug 2025 07:41:02 -0600 Subject: [PATCH 05/42] Version Bump --- src/albert/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/albert/__init__.py b/src/albert/__init__.py index 753813aa..d87f55d9 100644 --- a/src/albert/__init__.py +++ b/src/albert/__init__.py @@ -4,4 +4,4 @@ __all__ = ["Albert", "AlbertClientCredentials", "AlbertSSOClient"] -__version__ = "1.2.4" +__version__ = "1.2.5" From dde33ebb80df592bde67e6b954fea5be4d672f7a Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Thu, 7 Aug 2025 07:43:23 -0600 Subject: [PATCH 06/42] Version Bump --- src/albert/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/albert/__init__.py b/src/albert/__init__.py index d87f55d9..fc429259 100644 --- a/src/albert/__init__.py +++ b/src/albert/__init__.py @@ -4,4 +4,4 @@ __all__ = ["Albert", "AlbertClientCredentials", "AlbertSSOClient"] -__version__ = "1.2.5" +__version__ = "1.2.7" From 071561b5f3ab49575e2934d3ba7ca1282eb602e3 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Thu, 7 Aug 2025 07:58:28 -0600 Subject: [PATCH 07/42] Formatting --- src/albert/collections/companies.py | 4 ++-- src/albert/collections/custom_templates.py | 1 - src/albert/resources/custom_templates.py | 6 +++--- src/albert/resources/sheets.py | 4 ++-- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/albert/collections/companies.py b/src/albert/collections/companies.py index 6258e20f..18073cac 100644 --- a/src/albert/collections/companies.py +++ b/src/albert/collections/companies.py @@ -1,5 +1,5 @@ from __future__ import annotations -import logging + from collections.abc import Iterator from albert.collections.base import BaseCollection @@ -280,7 +280,7 @@ def merge(self, *, parent_id: str, child_ids: str | list[str]) -> Company: url = f"{self.base_path}/merge" response = self.session.post(url, json=payload) if response.status_code == 206: - msg = f"Merge returned partial content (206). Check that all ACLs are valid." + msg = "Merge returned partial content (206). Check that all ACLs are valid." logger.error(msg) raise AlbertException(msg) response.raise_for_status() diff --git a/src/albert/collections/custom_templates.py b/src/albert/collections/custom_templates.py index edc66802..bf72102e 100644 --- a/src/albert/collections/custom_templates.py +++ b/src/albert/collections/custom_templates.py @@ -9,7 +9,6 @@ from albert.resources.custom_templates import CustomTemplate, CustomTemplateSearchItem - class CustomTemplatesCollection(BaseCollection): """CustomTemplatesCollection is a collection class for managing CustomTemplate entities in the Albert platform.""" diff --git a/src/albert/resources/custom_templates.py b/src/albert/resources/custom_templates.py index 6a005d08..bcf87b67 100644 --- a/src/albert/resources/custom_templates.py +++ b/src/albert/resources/custom_templates.py @@ -15,12 +15,11 @@ from albert.resources.projects import Project from albert.resources.sheets import DesignType, Sheet from albert.resources.tagged_base import BaseTaggedResource -from albert.resources.tasks import TaskSource from albert.resources.tags import Tag +from albert.resources.tasks import TaskSource from albert.resources.users import User, UserClass - class DataTemplateInventory(EntityLink): batch_size: float | None = Field(default=None, alias="batchSize") sheet: list[Sheet | EntityLink] | None = Field(default=None) @@ -59,6 +58,7 @@ class GeneralData(BaseTaggedResource): sources: list[TaskSource] | None = Field(alias="Sources", default=None) parent_id: str | None = Field(alias="parentId", default=None) notes: str | None = Field(default=None) + tags: list[Tag] | None = Field(default=None, alias="Tags") class JobStatus(str, Enum): @@ -206,7 +206,7 @@ class CustomTemplate(BaseTaggedResource): """ name: str - id: str = Field(alias="albertId") + id: str | None = Field(default=None, alias="albertId") category: TemplateCategory = Field(default=TemplateCategory.GENERAL) metadata: dict[str, MetadataItem] | None = Field(default=None, alias="Metadata") data: CustomTemplateData | None = Field(default=None, alias="Data") diff --git a/src/albert/resources/sheets.py b/src/albert/resources/sheets.py index d2e15ed3..1c8303ba 100644 --- a/src/albert/resources/sheets.py +++ b/src/albert/resources/sheets.py @@ -302,10 +302,10 @@ class Sheet(BaseSessionResource): # noqa:F811 """ - id: str = Field(alias="albertId") + id: str | None = Field(default=None, alias="albertId") name: str formulations: list[SheetFormulationRef] = Field(default_factory=list, alias="Formulas") - hidden: bool + hidden: bool = Field(default=False) _app_design: Design = PrivateAttr(default=None) _product_design: Design = PrivateAttr(default=None) _result_design: Design = PrivateAttr(default=None) From cbae0279f7d0019a5a3998ed1e1bc47fccc75da1 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Mon, 11 Aug 2025 09:38:53 -0600 Subject: [PATCH 08/42] Formatting --- src/albert/collections/companies.py | 2 +- src/albert/collections/custom_templates.py | 9 ++++---- src/albert/collections/parameter_groups.py | 26 +++++++++++++--------- src/albert/resources/custom_templates.py | 2 -- src/albert/resources/sheets.py | 3 +-- src/albert/utils/_patch.py | 2 +- 6 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/albert/collections/companies.py b/src/albert/collections/companies.py index 18073cac..5a58e7d9 100644 --- a/src/albert/collections/companies.py +++ b/src/albert/collections/companies.py @@ -288,4 +288,4 @@ def merge(self, *, parent_id: str, child_ids: str | list[str]) -> Company: try: return Company(**response.json()) except (ValueError, TypeError): - return self.get_by_id(id=parent_id) \ No newline at end of file + return self.get_by_id(id=parent_id) diff --git a/src/albert/collections/custom_templates.py b/src/albert/collections/custom_templates.py index bf72102e..f4b818df 100644 --- a/src/albert/collections/custom_templates.py +++ b/src/albert/collections/custom_templates.py @@ -87,7 +87,6 @@ def search( ], ) - def create(self, *, custom_template: CustomTemplate) -> CustomTemplate: """Creates a new custom template. @@ -104,10 +103,13 @@ def create(self, *, custom_template: CustomTemplate) -> CustomTemplate: response = self.session.post( url=self.base_path, - json=[custom_template.model_dump(mode="json", by_alias=True, exclude_unset=True, exclude_none=True)], + json=[ + custom_template.model_dump( + mode="json", by_alias=True, exclude_unset=True, exclude_none=True + ) + ], ) return CustomTemplate(**response.json()[0]) - def get_all( self, @@ -141,4 +143,3 @@ def get_all( yield self.get_by_id(id=item.id) except AlbertHTTPError as e: logger.warning(f"Error hydrating custom template {item.id}: {e}") - diff --git a/src/albert/collections/parameter_groups.py b/src/albert/collections/parameter_groups.py index 59eb4010..3a0f9851 100644 --- a/src/albert/collections/parameter_groups.py +++ b/src/albert/collections/parameter_groups.py @@ -254,20 +254,26 @@ def update(self, *, parameter_group: ParameterGroup) -> ParameterGroup: for existing_param in existing.parameters: # find the matching updated param by its row_id updated_param = next( - (parameter for parameter in parameter_group.parameters if parameter.sequence == existing_param.sequence), - None + ( + parameter + for parameter in parameter_group.parameters + if parameter.sequence == existing_param.sequence + ), + None, ) if not updated_param: continue if existing_param.required != updated_param.required: - required_params.append({ - "operation": "update", - "attribute": "required", - "rowId": existing_param.sequence, - "oldValue": existing_param.required, - "newValue": updated_param.required, - }) + required_params.append( + { + "operation": "update", + "attribute": "required", + "rowId": existing_param.sequence, + "oldValue": existing_param.required, + "newValue": updated_param.required, + } + ) if required_params: self.session.patch( @@ -282,5 +288,3 @@ def update(self, *, parameter_group: ParameterGroup) -> ParameterGroup: ) return self.get_by_id(id=parameter_group.id) - - \ No newline at end of file diff --git a/src/albert/resources/custom_templates.py b/src/albert/resources/custom_templates.py index bcf87b67..7578bbda 100644 --- a/src/albert/resources/custom_templates.py +++ b/src/albert/resources/custom_templates.py @@ -46,7 +46,6 @@ class Priority(str, Enum): HIGH = "High" - class GeneralData(BaseTaggedResource): category: Literal[TemplateCategory.GENERAL] = TemplateCategory.GENERAL name: str | None = Field(default=None) @@ -255,4 +254,3 @@ class CustomTemplateSearchItem(BaseAlbertModel, HydrationMixin[CustomTemplate]): data: CustomTemplateSearchItemData | None = None acl: list[CustomTemplateSearchItemACL] team: list[CustomTemplateSearchItemTeam] - diff --git a/src/albert/resources/sheets.py b/src/albert/resources/sheets.py index 1c8303ba..eaf46a3f 100644 --- a/src/albert/resources/sheets.py +++ b/src/albert/resources/sheets.py @@ -483,8 +483,7 @@ def add_formulation( return self.get_column(column_id=col_id) def _get_row_id_for_component(self, *, inventory_item, existing_cells, enforce_order): - - #Checks if that inventory row already exists + # Checks if that inventory row already exists sheet_inv_id = inventory_item.id for r in self.product_design.rows: if r.inventory_id == sheet_inv_id: diff --git a/src/albert/utils/_patch.py b/src/albert/utils/_patch.py index 94825a3a..ea9c4419 100644 --- a/src/albert/utils/_patch.py +++ b/src/albert/utils/_patch.py @@ -331,7 +331,7 @@ def generate_enum_patches( ) -> list[dict]: """Generate enum patches for a data column or parameter validation.""" existing_enums = existing_enums or [] - updated_enums = updated_enums or [] + updated_enums = updated_enums or [] enum_patches = [] existing_enum = [x for x in existing_enums if isinstance(x, EnumValidationValue)] From cfc9dd2308374170eaf16246de415dd71425523b Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Mon, 11 Aug 2025 09:41:22 -0600 Subject: [PATCH 09/42] Update __init__.py --- src/albert/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/albert/__init__.py b/src/albert/__init__.py index 00b04299..fc429259 100644 --- a/src/albert/__init__.py +++ b/src/albert/__init__.py @@ -5,4 +5,3 @@ __all__ = ["Albert", "AlbertClientCredentials", "AlbertSSOClient"] __version__ = "1.2.7" - From 72acc57351572341b91c6bf5bb608b5153057c25 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Wed, 13 Aug 2025 07:21:37 -0600 Subject: [PATCH 10/42] Updated NotebookData --- src/albert/resources/custom_templates.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/albert/resources/custom_templates.py b/src/albert/resources/custom_templates.py index 7578bbda..fe0e3f7a 100644 --- a/src/albert/resources/custom_templates.py +++ b/src/albert/resources/custom_templates.py @@ -143,6 +143,7 @@ class SheetData(BaseTaggedResource): class NotebookData(BaseTaggedResource): + id: str category: Literal[TemplateCategory.NOTEBOOK] = TemplateCategory.NOTEBOOK From 7812ddf4597ef2e61919841d15f1c5a6d4e81163 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Wed, 13 Aug 2025 07:32:37 -0600 Subject: [PATCH 11/42] Version Bump --- src/albert/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/albert/__init__.py b/src/albert/__init__.py index fc429259..38e1eaab 100644 --- a/src/albert/__init__.py +++ b/src/albert/__init__.py @@ -4,4 +4,4 @@ __all__ = ["Albert", "AlbertClientCredentials", "AlbertSSOClient"] -__version__ = "1.2.7" +__version__ = "1.3.1" From 432f869652d3f1dbc0897ee591448b1a4fc96018 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Mon, 25 Aug 2025 10:18:11 -0600 Subject: [PATCH 12/42] pyTest Changes --- src/albert/collections/custom_templates.py | 17 +++++++++++ tests/collections/test_custom_templates.py | 33 ++++++++++++++++++++++ tests/conftest.py | 14 +++++++++ tests/seeding.py | 12 ++++++++ 4 files changed, 76 insertions(+) diff --git a/src/albert/collections/custom_templates.py b/src/albert/collections/custom_templates.py index f4b818df..6acb7d55 100644 --- a/src/albert/collections/custom_templates.py +++ b/src/albert/collections/custom_templates.py @@ -143,3 +143,20 @@ def get_all( yield self.get_by_id(id=item.id) except AlbertHTTPError as e: logger.warning(f"Error hydrating custom template {item.id}: {e}") + + def delete(self, *, id: str) -> None: + """ + Delete a Custom Template by ID. + + Parameters + ---------- + id : str + The Albert ID of the custom template to delete. + + Raises + ------ + AlbertHTTPError + If the API responds with a non-2xx status (e.g., 404 if not found). + """ + url = f"{self.base_path}/{id}" + self.session.delete(url) \ No newline at end of file diff --git a/tests/collections/test_custom_templates.py b/tests/collections/test_custom_templates.py index 608f0098..e0f5f6a5 100644 --- a/tests/collections/test_custom_templates.py +++ b/tests/collections/test_custom_templates.py @@ -4,6 +4,8 @@ CustomTemplateSearchItem, CustomTemplateSearchItemData, _CustomTemplateDataUnion, + GeneralData, + TemplateCategory, ) @@ -58,3 +60,34 @@ def test_hydrate_custom_template(client: Albert): # identity checks assert hydrated.id == custom_template.id assert hydrated.name == custom_template.name +# Put mock data in and test create general task +# add a delete function write after +#run test locally if just making minor change +#uv run pytest -x + +def test_create_custom_template_from_seed( + caplog, + client: Albert, + seed_prefix: str, + seeded_custom_templates: list[CustomTemplate], +): + """Test creating a new custom template.""" + seed = seeded_custom_templates[0] + + new_template = CustomTemplate( + name=seed_prefix, + category=seed.category, + data=( + seed.data.model_copy(update={"name": seed_prefix}, deep=True) + if getattr(seed, "data", None) is not None + else None + ), + ) + + created = client.custom_templates.create(custom_template=new_template) + + assert isinstance(created, CustomTemplate) + assert created.name == new_template.name + assert created.category == new_template.category + if new_template.data is not None and hasattr(new_template.data, "name"): + assert getattr(created.data, "name", None) == new_template.data.name \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 43745399..5daf8691 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,6 +41,7 @@ generate_cas_seeds, generate_company_seeds, generate_custom_fields, + generate_custom_template_seeds, generate_data_column_seeds, generate_data_template_seeds, generate_inventory_seeds, @@ -223,6 +224,19 @@ def seeded_locations(client: Albert, seed_prefix: str) -> Iterator[list[Location with suppress(NotFoundError): client.locations.delete(id=location.id) +@pytest.fixture(scope="session") +def seeded_custom_templates(client: Albert, seed_prefix: str) -> Iterator[list[Location]]: + seeded = [] + for custom_template in generate_custom_template_seeds(seed_prefix): + created_custom_template = client.custom_templates.create(custom_template=custom_template) + seeded.append(created_custom_template) + + yield seeded + + for custom_template in seeded: + with suppress(NotFoundError): + client.custom_templates.delete(id=custom_template.id) + @pytest.fixture(scope="session") def seeded_projects( diff --git a/tests/seeding.py b/tests/seeding.py index 428830cc..1c7df2fc 100644 --- a/tests/seeding.py +++ b/tests/seeding.py @@ -1,3 +1,4 @@ +from typing import Iterator from uuid import uuid4 from albert.core.shared.enums import SecurityClass @@ -13,6 +14,7 @@ FieldType, ServiceType, ) +from albert.resources.custom_templates import CustomTemplate, GeneralData, TemplateCategory from albert.resources.data_columns import DataColumn from albert.resources.data_templates import DataColumnValue, DataTemplate from albert.resources.inventory import ( @@ -1557,3 +1559,13 @@ def generate_report_seeds( project_id=seeded_projects[0].id if seeded_projects else None, ), ] + +def generate_custom_template_seeds(prefix: str) -> Iterator[CustomTemplate]: + for i in range(2): + yield CustomTemplate( + name=f"{prefix}-general-{i}", + category=TemplateCategory.GENERAL, + data=GeneralData( + name=f"{prefix}-general-{i}" + ), + ) \ No newline at end of file From 2abc3d64f887fcad3ba191294cb052713f3537c0 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Mon, 25 Aug 2025 10:30:45 -0600 Subject: [PATCH 13/42] Update __init__.py --- src/albert/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/albert/__init__.py b/src/albert/__init__.py index e7bfaf4d..6318ca09 100644 --- a/src/albert/__init__.py +++ b/src/albert/__init__.py @@ -4,4 +4,4 @@ __all__ = ["Albert", "AlbertClientCredentials", "AlbertSSOClient"] -__version__ = "1.5.1" +__version__ = "1.5.2" From 8303e49d5305fc21d9d9f691a45871467288b7d3 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Mon, 25 Aug 2025 10:37:03 -0600 Subject: [PATCH 14/42] Formatting --- tests/collections/test_custom_templates.py | 8 -------- tests/seeding.py | 1 - 2 files changed, 9 deletions(-) diff --git a/tests/collections/test_custom_templates.py b/tests/collections/test_custom_templates.py index 2cd941e0..563487b4 100644 --- a/tests/collections/test_custom_templates.py +++ b/tests/collections/test_custom_templates.py @@ -4,8 +4,6 @@ CustomTemplateSearchItem, CustomTemplateSearchItemData, _CustomTemplateDataUnion, - GeneralData, - TemplateCategory, ) @@ -62,12 +60,6 @@ def test_hydrate_custom_template(client: Albert): assert hydrated.name == custom_template.name -# Put mock data in and test create general task -# add a delete function write after -# run test locally if just making minor change -# uv run pytest -x - - def test_create_custom_template_from_seed( caplog, client: Albert, diff --git a/tests/seeding.py b/tests/seeding.py index f3f84f76..c7fd62aa 100644 --- a/tests/seeding.py +++ b/tests/seeding.py @@ -1,4 +1,3 @@ -from typing import Iterator from uuid import uuid4 from albert.core.shared.enums import SecurityClass From 4b0a7e4d3ccdda5516d1e20652299690d39b0622 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Mon, 25 Aug 2025 10:43:01 -0600 Subject: [PATCH 15/42] Update seeding.py --- tests/seeding.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/seeding.py b/tests/seeding.py index c7fd62aa..e9d364b4 100644 --- a/tests/seeding.py +++ b/tests/seeding.py @@ -1551,10 +1551,12 @@ def generate_report_seeds( ] -def generate_custom_template_seeds(prefix: str) -> Iterator[CustomTemplate]: - for i in range(2): - yield CustomTemplate( +def generate_custom_template_seeds(prefix: str) -> list[CustomTemplate]: + return [ + CustomTemplate( name=f"{prefix}-general-{i}", category=TemplateCategory.GENERAL, data=GeneralData(name=f"{prefix}-general-{i}"), ) + for i in range(2) + ] From 1ccce057570e738fcc2dc76acf59f55cefcf3546 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Mon, 8 Sep 2025 07:24:00 -0600 Subject: [PATCH 16/42] Update src/albert/collections/custom_templates.py Co-authored-by: Prasad --- src/albert/collections/custom_templates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/albert/collections/custom_templates.py b/src/albert/collections/custom_templates.py index a5c83e1c..d340ff1a 100644 --- a/src/albert/collections/custom_templates.py +++ b/src/albert/collections/custom_templates.py @@ -144,7 +144,7 @@ def get_all( except AlbertHTTPError as e: logger.warning(f"Error hydrating custom template {item.id}: {e}") - def delete(self, *, id: str) -> None: + def delete(self, *, id: CustomTemplateId) -> None: """ Delete a Custom Template by ID. From 9900edab32ffaa59e95348e961b4b6215501285a Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Mon, 8 Sep 2025 07:27:36 -0600 Subject: [PATCH 17/42] Update custom_templates.py --- src/albert/resources/custom_templates.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/albert/resources/custom_templates.py b/src/albert/resources/custom_templates.py index fe0e3f7a..a8b659a2 100644 --- a/src/albert/resources/custom_templates.py +++ b/src/albert/resources/custom_templates.py @@ -57,7 +57,6 @@ class GeneralData(BaseTaggedResource): sources: list[TaskSource] | None = Field(alias="Sources", default=None) parent_id: str | None = Field(alias="parentId", default=None) notes: str | None = Field(default=None) - tags: list[Tag] | None = Field(default=None, alias="Tags") class JobStatus(str, Enum): @@ -118,7 +117,6 @@ class BatchData(BaseTaggedResource): priority: Priority # enum?! workflow: list[EntityLink] = Field(default=None, alias="Workflow") notes: str | None = Field(default=None) - tags: list[Tag] | None = Field(default=None, alias="Tags") class PropertyData(BaseTaggedResource): @@ -131,7 +129,6 @@ class PropertyData(BaseTaggedResource): project: SerializeAsEntityLink[Project] | None = Field(alias="Project", default=None) inventories: list[DataTemplateInventory] | None = Field(default=None, alias="Inventories") due_date: str | None = Field(alias="dueDate", default=None) - tags: list[Tag] | None = Field(default=None, alias="Tags") notes: str | None = Field(default=None) From d9bc7a2b5ebe24abe9acf62329ed5e8e6f496cce Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Mon, 8 Sep 2025 07:28:44 -0600 Subject: [PATCH 18/42] Update custom_templates.py --- src/albert/collections/custom_templates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/albert/collections/custom_templates.py b/src/albert/collections/custom_templates.py index d340ff1a..3469a66a 100644 --- a/src/albert/collections/custom_templates.py +++ b/src/albert/collections/custom_templates.py @@ -87,7 +87,7 @@ def search( ], ) - def create(self, *, custom_template: CustomTemplate) -> CustomTemplate: + def create(self, *, custom_templates: list[CustomTemplate]) -> list[CustomTemplate]: """Creates a new custom template. Parameters From ae5b3072ca51c8a5066e84c1bf09f3fcfda90fd5 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Mon, 8 Sep 2025 07:42:14 -0600 Subject: [PATCH 19/42] Update __init__.py --- src/albert/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/albert/__init__.py b/src/albert/__init__.py index 6318ca09..27075d2f 100644 --- a/src/albert/__init__.py +++ b/src/albert/__init__.py @@ -4,4 +4,4 @@ __all__ = ["Albert", "AlbertClientCredentials", "AlbertSSOClient"] -__version__ = "1.5.2" +__version__ = "1.5.6" From 9a971527e980b7f5c80712087d4bac4bf831ccb0 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Mon, 8 Sep 2025 07:47:35 -0600 Subject: [PATCH 20/42] Update custom_templates.py --- src/albert/collections/custom_templates.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/albert/collections/custom_templates.py b/src/albert/collections/custom_templates.py index 3469a66a..576a39e7 100644 --- a/src/albert/collections/custom_templates.py +++ b/src/albert/collections/custom_templates.py @@ -5,6 +5,7 @@ from albert.core.pagination import AlbertPaginator from albert.core.session import AlbertSession from albert.core.shared.enums import PaginationMode +from albert.core.shared.identifiers import CustomTemplateId from albert.exceptions import AlbertHTTPError from albert.resources.custom_templates import CustomTemplate, CustomTemplateSearchItem @@ -104,7 +105,7 @@ def create(self, *, custom_templates: list[CustomTemplate]) -> list[CustomTempla response = self.session.post( url=self.base_path, json=[ - custom_template.model_dump( + custom_templates.model_dump( mode="json", by_alias=True, exclude_unset=True, exclude_none=True ) ], From ef78ee4584a52246223ccc4e289b1870e6c5f049 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Mon, 8 Sep 2025 07:50:56 -0600 Subject: [PATCH 21/42] Update custom_templates.py --- src/albert/resources/custom_templates.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/albert/resources/custom_templates.py b/src/albert/resources/custom_templates.py index a8b659a2..eefd80a4 100644 --- a/src/albert/resources/custom_templates.py +++ b/src/albert/resources/custom_templates.py @@ -15,7 +15,6 @@ from albert.resources.projects import Project from albert.resources.sheets import DesignType, Sheet from albert.resources.tagged_base import BaseTaggedResource -from albert.resources.tags import Tag from albert.resources.tasks import TaskSource from albert.resources.users import User, UserClass From 33f317ff44a80d6ddb054d9ff8c2cc26682534a4 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Mon, 8 Sep 2025 08:00:27 -0600 Subject: [PATCH 22/42] Update custom_templates.py --- src/albert/collections/custom_templates.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/albert/collections/custom_templates.py b/src/albert/collections/custom_templates.py index 576a39e7..30cc1061 100644 --- a/src/albert/collections/custom_templates.py +++ b/src/albert/collections/custom_templates.py @@ -88,7 +88,7 @@ def search( ], ) - def create(self, *, custom_templates: list[CustomTemplate]) -> list[CustomTemplate]: + def create(self, *, custom_template: list[CustomTemplate]) -> list[CustomTemplate]: """Creates a new custom template. Parameters @@ -105,7 +105,7 @@ def create(self, *, custom_templates: list[CustomTemplate]) -> list[CustomTempla response = self.session.post( url=self.base_path, json=[ - custom_templates.model_dump( + custom_template.model_dump( mode="json", by_alias=True, exclude_unset=True, exclude_none=True ) ], From b870c02b118a6311eaa604b6ff3d7cfc06d21808 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Thu, 11 Sep 2025 07:28:02 -0600 Subject: [PATCH 23/42] Removed Custom Template Seed Function --- src/albert/__init__.py | 1 - tests/conftest.py | 14 +++++++------- tests/seeding.py | 11 ----------- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/src/albert/__init__.py b/src/albert/__init__.py index 7ce807ee..27075d2f 100644 --- a/src/albert/__init__.py +++ b/src/albert/__init__.py @@ -5,4 +5,3 @@ __all__ = ["Albert", "AlbertClientCredentials", "AlbertSSOClient"] __version__ = "1.5.6" - diff --git a/tests/conftest.py b/tests/conftest.py index 253845ce..d6cb017f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Iterator from contextlib import suppress +from albert.resources.custom_templates import CustomTemplate, GeneralData import pytest from albert import Albert, AlbertClientCredentials @@ -41,7 +42,6 @@ generate_cas_seeds, generate_company_seeds, generate_custom_fields, - generate_custom_template_seeds, generate_data_column_seeds, generate_data_template_seeds, generate_inventory_seeds, @@ -225,17 +225,17 @@ def seeded_locations(client: Albert, seed_prefix: str) -> Iterator[list[Location @pytest.fixture(scope="session") -def seeded_custom_templates(client: Albert, seed_prefix: str) -> Iterator[list[Location]]: +def seeded_custom_templates(client: Albert, seed_prefix: str): seeded = [] - for custom_template in generate_custom_template_seeds(seed_prefix): - created_custom_template = client.custom_templates.create(custom_template=custom_template) - seeded.append(created_custom_template) + data = GeneralData(name=f"{seed_prefix}-general") + created = client.custom_templates.create(custom_template={"data": data}) + seeded.append(created) yield seeded - for custom_template in seeded: + for t in seeded: with suppress(NotFoundError): - client.custom_templates.delete(id=custom_template.id) + client.custom_templates.delete(id=t.id) @pytest.fixture(scope="session") diff --git a/tests/seeding.py b/tests/seeding.py index e9d364b4..3a413914 100644 --- a/tests/seeding.py +++ b/tests/seeding.py @@ -1549,14 +1549,3 @@ def generate_report_seeds( project_id=seeded_projects[0].id if seeded_projects else None, ), ] - - -def generate_custom_template_seeds(prefix: str) -> list[CustomTemplate]: - return [ - CustomTemplate( - name=f"{prefix}-general-{i}", - category=TemplateCategory.GENERAL, - data=GeneralData(name=f"{prefix}-general-{i}"), - ) - for i in range(2) - ] From a1c0297d35704434e6413960324242b8671b9af7 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Thu, 11 Sep 2025 07:30:55 -0600 Subject: [PATCH 24/42] Removed Unused imports --- tests/conftest.py | 2 +- tests/seeding.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index d6cb017f..34563dd8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,6 @@ from collections.abc import Iterator from contextlib import suppress -from albert.resources.custom_templates import CustomTemplate, GeneralData import pytest from albert import Albert, AlbertClientCredentials @@ -15,6 +14,7 @@ from albert.resources.cas import Cas from albert.resources.companies import Company from albert.resources.custom_fields import CustomField +from albert.resources.custom_templates import GeneralData from albert.resources.data_columns import DataColumn from albert.resources.data_templates import DataTemplate from albert.resources.files import FileCategory, FileInfo, FileNamespace diff --git a/tests/seeding.py b/tests/seeding.py index 3a413914..f7d82e3f 100644 --- a/tests/seeding.py +++ b/tests/seeding.py @@ -13,7 +13,6 @@ FieldType, ServiceType, ) -from albert.resources.custom_templates import CustomTemplate, GeneralData, TemplateCategory from albert.resources.data_columns import DataColumn from albert.resources.data_templates import DataColumnValue, DataTemplate from albert.resources.inventory import ( From 19cc1ea479beeab49366718f148693598b6e6765 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Thu, 11 Sep 2025 10:09:25 -0600 Subject: [PATCH 25/42] Update conftest.py --- tests/conftest.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 34563dd8..efbc7cf7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,7 @@ from albert.resources.cas import Cas from albert.resources.companies import Company from albert.resources.custom_fields import CustomField -from albert.resources.custom_templates import GeneralData +from albert.resources.custom_templates import CustomTemplate, GeneralData from albert.resources.data_columns import DataColumn from albert.resources.data_templates import DataTemplate from albert.resources.files import FileCategory, FileInfo, FileNamespace @@ -228,7 +228,8 @@ def seeded_locations(client: Albert, seed_prefix: str) -> Iterator[list[Location def seeded_custom_templates(client: Albert, seed_prefix: str): seeded = [] data = GeneralData(name=f"{seed_prefix}-general") - created = client.custom_templates.create(custom_template={"data": data}) + custom_template=CustomTemplate(**data) + created = client.custom_templates.create(custom_template=custom_template) seeded.append(created) yield seeded From c1e8ac662644ad17165ce7fb3dc24107f57bcbcd Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Thu, 11 Sep 2025 10:11:18 -0600 Subject: [PATCH 26/42] Update conftest.py --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index efbc7cf7..f9617052 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -228,7 +228,7 @@ def seeded_locations(client: Albert, seed_prefix: str) -> Iterator[list[Location def seeded_custom_templates(client: Albert, seed_prefix: str): seeded = [] data = GeneralData(name=f"{seed_prefix}-general") - custom_template=CustomTemplate(**data) + custom_template = CustomTemplate(**data) created = client.custom_templates.create(custom_template=custom_template) seeded.append(created) From 5203b2dabdfb5d40765292d031e4c45d0a6201c0 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Thu, 11 Sep 2025 10:22:50 -0600 Subject: [PATCH 27/42] Update conftest.py --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index f9617052..dcca5b62 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -228,7 +228,7 @@ def seeded_locations(client: Albert, seed_prefix: str) -> Iterator[list[Location def seeded_custom_templates(client: Albert, seed_prefix: str): seeded = [] data = GeneralData(name=f"{seed_prefix}-general") - custom_template = CustomTemplate(**data) + custom_template = CustomTemplate(name=f"{seed_prefix}-general", data=data) created = client.custom_templates.create(custom_template=custom_template) seeded.append(created) From b5395e1200e4d868188b7adb1900e69aaed20a2d Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Fri, 12 Sep 2025 10:51:26 -0600 Subject: [PATCH 28/42] Update conftest.py --- tests/conftest.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index dcca5b62..a4cea322 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,7 @@ from albert.resources.cas import Cas from albert.resources.companies import Company from albert.resources.custom_fields import CustomField -from albert.resources.custom_templates import CustomTemplate, GeneralData +from albert.resources.custom_templates import CustomTemplate, GeneralData, TemplateCategory from albert.resources.data_columns import DataColumn from albert.resources.data_templates import DataTemplate from albert.resources.files import FileCategory, FileInfo, FileNamespace @@ -228,7 +228,9 @@ def seeded_locations(client: Albert, seed_prefix: str) -> Iterator[list[Location def seeded_custom_templates(client: Albert, seed_prefix: str): seeded = [] data = GeneralData(name=f"{seed_prefix}-general") - custom_template = CustomTemplate(name=f"{seed_prefix}-general", data=data) + custom_template = CustomTemplate( + name=f"{seed_prefix}-general", data=data, category=TemplateCategory.GENERAL + ) created = client.custom_templates.create(custom_template=custom_template) seeded.append(created) From 82b208084e7cd0a2c3391418bb52d1c83b5e5614 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Thu, 18 Sep 2025 09:33:10 -0600 Subject: [PATCH 29/42] Update custom_templates.py --- src/albert/resources/custom_templates.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/albert/resources/custom_templates.py b/src/albert/resources/custom_templates.py index f5d1cfd4..b315f6c3 100644 --- a/src/albert/resources/custom_templates.py +++ b/src/albert/resources/custom_templates.py @@ -116,6 +116,7 @@ class BatchData(BaseTaggedResource): priority: Priority # enum?! workflow: list[EntityLink] = Field(default=None, alias="Workflow") notes: str | None = Field(default=None) + due_date: str | None = Field(alias="dueDate", default=None) class PropertyData(BaseTaggedResource): From b7e400046930e4cffa9e352411f4c7f7e381978d Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Thu, 18 Sep 2025 09:45:30 -0600 Subject: [PATCH 30/42] Update __init__.py --- src/albert/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/albert/__init__.py b/src/albert/__init__.py index 27075d2f..35b6da2f 100644 --- a/src/albert/__init__.py +++ b/src/albert/__init__.py @@ -4,4 +4,4 @@ __all__ = ["Albert", "AlbertClientCredentials", "AlbertSSOClient"] -__version__ = "1.5.6" +__version__ = "1.5.7" From 8f5ff2e3a6dd3c6c206843128cd446bb2a38f242 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Thu, 18 Sep 2025 12:39:35 -0600 Subject: [PATCH 31/42] Update custom_templates.py --- src/albert/collections/custom_templates.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/albert/collections/custom_templates.py b/src/albert/collections/custom_templates.py index 8c6f588a..878767af 100644 --- a/src/albert/collections/custom_templates.py +++ b/src/albert/collections/custom_templates.py @@ -110,7 +110,21 @@ def create(self, *, custom_template: list[CustomTemplate]) -> list[CustomTemplat ) ], ) - return CustomTemplate(**response.json()[0]) + obj = response.json()[0] + tags = (obj.get("Data")).get("Tags") or [] + + def _resolve_tags(tid: str) -> dict: + r = self.session.get(url=f"/api/v3/tags/{tid}") + if r.ok: + d = r.json() + item = (d.get("Items") or [d])[0] if isinstance(d, dict) and "Items" in d else d + return {"albertId": item.get("albertId", tid), "name": item.get("name")} + return {"albertId": tid} + + if tags: + obj["Data"]["Tags"] = [_resolve_tags(t.get("id")) for t in tags] + + return CustomTemplate(**obj) def get_all( self, From 17b2b7ee45a78b0f72540d270cf41c599d741c75 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Thu, 18 Sep 2025 14:17:17 -0600 Subject: [PATCH 32/42] Update _patch.py --- src/albert/utils/_patch.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/albert/utils/_patch.py b/src/albert/utils/_patch.py index 7620f74c..e6b0e8df 100644 --- a/src/albert/utils/_patch.py +++ b/src/albert/utils/_patch.py @@ -433,6 +433,7 @@ def generate_parameter_patches( unit_patch = _parameter_unit_patches(existing_param, updated_param) value_patch = _parameter_value_patches(existing_param, updated_param) validation_patch = parameter_validation_patch(existing_param, updated_param) + required_patch = _parameter_required_patches(existing_param, updated_param) if unit_patch: parameter_patches.append(unit_patch) @@ -440,6 +441,8 @@ def generate_parameter_patches( parameter_patches.append(value_patch) if validation_patch: parameter_patches.append(validation_patch) + if required_patch: + parameter_patches.append(required_patch) if ( updated_param.validation is not None and updated_param.validation != [] @@ -549,3 +552,36 @@ def generate_parameter_group_patches( general_patches.data.extend(tag_patches) return general_patches, new_parameters, parameter_enum_patches + +def _parameter_required_patches( + initial_parameter_value: ParameterValue, updated_parameter_value: ParameterValue +) -> PGPatchDatum | None: + """Generate a Patch for a parameter's `required` flag.""" + + if initial_parameter_value.required == updated_parameter_value.required: + return None + elif initial_parameter_value.required is None: + if updated_parameter_value.required is not None: + return PGPatchDatum( + operation="add", + attribute="required", + newValue=updated_parameter_value.required, + rowId=updated_parameter_value.sequence, + ) + elif updated_parameter_value.required is None: + if initial_parameter_value.required is not None: + return PGPatchDatum( + operation="delete", + attribute="required", + oldValue=initial_parameter_value.required, + rowId=updated_parameter_value.sequence, + ) + elif initial_parameter_value.required != updated_parameter_value.required: + return PGPatchDatum( + operation="update", + attribute="required", + oldValue=initial_parameter_value.required, + newValue=updated_parameter_value.required, + rowId=updated_parameter_value.sequence, + ) + return None \ No newline at end of file From 4fadcb34a5ed741da3979a9ee3763e254c5150ac Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Thu, 18 Sep 2025 14:18:49 -0600 Subject: [PATCH 33/42] Update _patch.py --- src/albert/utils/_patch.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/albert/utils/_patch.py b/src/albert/utils/_patch.py index e6b0e8df..a742789b 100644 --- a/src/albert/utils/_patch.py +++ b/src/albert/utils/_patch.py @@ -433,7 +433,7 @@ def generate_parameter_patches( unit_patch = _parameter_unit_patches(existing_param, updated_param) value_patch = _parameter_value_patches(existing_param, updated_param) validation_patch = parameter_validation_patch(existing_param, updated_param) - required_patch = _parameter_required_patches(existing_param, updated_param) + required_patch = _parameter_required_patches(existing_param, updated_param) if unit_patch: parameter_patches.append(unit_patch) @@ -553,6 +553,7 @@ def generate_parameter_group_patches( return general_patches, new_parameters, parameter_enum_patches + def _parameter_required_patches( initial_parameter_value: ParameterValue, updated_parameter_value: ParameterValue ) -> PGPatchDatum | None: @@ -584,4 +585,4 @@ def _parameter_required_patches( newValue=updated_parameter_value.required, rowId=updated_parameter_value.sequence, ) - return None \ No newline at end of file + return None From cdfded98a8ac0e25250033254950b9156ee29ae2 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Thu, 2 Oct 2025 16:37:14 -0600 Subject: [PATCH 34/42] Sheet and ACL edits --- src/albert/resources/custom_templates.py | 14 ++- src/albert/resources/sheets.py | 111 ++++++++++++++++++++++- 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/src/albert/resources/custom_templates.py b/src/albert/resources/custom_templates.py index b315f6c3..43db6949 100644 --- a/src/albert/resources/custom_templates.py +++ b/src/albert/resources/custom_templates.py @@ -158,22 +158,26 @@ class ACLType(str, Enum): class TeamACL(ACL): - type: Literal[ACLType.TEAM] = ACLType.TEAM + # accept either backend token or SDK enum value + type: Literal[ACLType.TEAM, "CustomTemplateTeam"] = ACLType.TEAM class OwnerACL(ACL): - type: Literal[ACLType.OWNER] = ACLType.OWNER + type: Literal[ACLType.OWNER, "CustomTemplateOwner"] = ACLType.OWNER class MemberACL(ACL): - type: Literal[ACLType.MEMBER] = ACLType.MEMBER + type: Literal[ACLType.MEMBER, "CustomTemplateMember"] = ACLType.MEMBER class ViewerACL(ACL): - type: Literal[ACLType.VIEWER] = ACLType.VIEWER + type: Literal[ACLType.VIEWER, "CustomTemplateViewer"] = ACLType.VIEWER -ACLEntry = Annotated[TeamACL | OwnerACL | MemberACL | ViewerACL, Field(discriminator="type")] +ACLEntry = Annotated[ + TeamACL | OwnerACL | MemberACL | ViewerACL, + Field(discriminator="type"), +] class TemplateACL(BaseResource): diff --git a/src/albert/resources/sheets.py b/src/albert/resources/sheets.py index ad2c7ee4..4bcd301d 100644 --- a/src/albert/resources/sheets.py +++ b/src/albert/resources/sheets.py @@ -185,6 +185,16 @@ def _grid_to_cell_df(self, *, grid_response): records: list[dict[str, Cell]] = [] index: list[str] = [] for item in items: + + def _normalize_value(v): + if v is None: + return "" + if isinstance(v, list): + return "" if not v else (v[0] if isinstance(v[0], str) else str(v[0])) + if isinstance(v, str | dict): + return v + return str(v) + this_row_id = item["rowId"] this_index = item["rowUniqueId"] row_label = item.get("lableName") or item.get("name") @@ -209,7 +219,10 @@ def _grid_to_cell_df(self, *, grid_response): raw_id = c.pop("id", None) inv = (raw_id if raw_id.startswith("INV") else f"INV{raw_id}") if raw_id else None c["inventory_id"] = inv - + c["value"] = _normalize_value(c.get("value", "")) + fmt = c.get("cellFormat") + if fmt is None or isinstance(fmt, list): + c["cellFormat"] = {} cell = Cell(**c) col_id = c["colId"] @@ -601,6 +614,62 @@ def add_formulation( self.update_cells(cells=all_cells) return self.get_column(column_id=col_id) + def append_components_to_formulation( + self, + *, + formulation_name: str | None = None, + column_id: str | None = None, + inventory_id: InventoryId | None = None, + components: list[Component], + enforce_order: bool = False, + ) -> Column: + """ + Append (or upsert) components into an existing formulation column + without clearing other cells. + + You must specify exactly one of: column_id, inventory_id, or formulation_name. + """ + # 1) Resolve target column + col = self.get_column( + column_id=column_id, inventory_id=inventory_id, column_name=formulation_name + ) + col_id = col.column_id + + # 2) Build Cell updates for just the given components + all_cells: list[Cell] = [] + self.grid = None # refresh caches + + for component in components: + row_id = self._get_row_id_for_component( + inventory_item=component.inventory_item, + existing_cells=all_cells, + enforce_order=enforce_order, + ) + if row_id is None: + raise AlbertException(f"no component with id {component.inventory_item.id}") + + value = str(component.amount) + min_value = str(component.min_value) if component.min_value is not None else None + max_value = str(component.max_value) if component.max_value is not None else None + + this_cell = Cell( + column_id=col_id, + row_id=row_id, + value=value, + calculation="", + type=CellType.INVENTORY, + design_id=self.product_design.id, + name=col.name or formulation_name or "", + inventory_id=col.inventory_id, + min_value=min_value, + max_value=max_value, + ) + all_cells.append(this_cell) + + # 3) Upsert only these cells + self.update_cells(cells=all_cells) + return self.get_column(column_id=col_id) + def _get_row_id_for_component(self, *, inventory_item, existing_cells, enforce_order): # Checks if that inventory row already exists sheet_inv_id = inventory_item.id @@ -976,6 +1045,46 @@ def add_blank_column(self, *, name: str, position: dict = None): self.grid = None # reset the known grid. We could probably make this nicer later. return Column(**data[0]) + def add_lookup_column(self, *, name: str, position: dict | None = None) -> "Column": + """ + Create a Lookup (LKP) column at a position relative to a reference column. + + position: {"reference_id": "COL8", "position": "leftOf" | "rightOf"} + """ + if position is None: + position = {"reference_id": self.leftmost_pinned_column, "position": "rightOf"} + + endpoint = f"/api/v3/worksheet/sheet/{self.id}/columns" + payload = [ + { + "type": "LKP", + "name": name, + "referenceId": position["reference_id"], + "position": position["position"], + } + ] + + resp = self.session.post(endpoint, json=payload) + + if resp.status_code >= 400: + # Optional: graceful fallback to a BLK column + # (Remove this block if you want hard failure instead.) + blk_payload = [ + { + "type": "BLK", + "name": name, + "referenceId": position["reference_id"], + "position": position["position"], + } + ] + resp = self.session.post(endpoint, json=blk_payload) + + data = resp.json()[0] + data["sheet"] = self + data["session"] = self.session + self.grid = None + return Column(**data) + def delete_column(self, *, column_id: str) -> None: endpoint = f"/api/v3/worksheet/sheet/{self.id}/columns" payload = [{"colId": column_id}] From a47f81380fd071af99e42c6987e27cf6730a34fc Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Thu, 2 Oct 2025 16:39:55 -0600 Subject: [PATCH 35/42] Update __init__.py --- src/albert/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/albert/__init__.py b/src/albert/__init__.py index 35b6da2f..a2a2f481 100644 --- a/src/albert/__init__.py +++ b/src/albert/__init__.py @@ -4,4 +4,4 @@ __all__ = ["Albert", "AlbertClientCredentials", "AlbertSSOClient"] -__version__ = "1.5.7" +__version__ = "1.6.6" From a22277491a4f4a81e47e36abf43ea4f8da87087c Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Wed, 8 Oct 2025 11:48:43 -0600 Subject: [PATCH 36/42] Fixed data templates --- src/albert/collections/data_templates.py | 5 +++-- src/albert/collections/notebooks.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/albert/collections/data_templates.py b/src/albert/collections/data_templates.py index 75a1a9ee..15d9f53b 100644 --- a/src/albert/collections/data_templates.py +++ b/src/albert/collections/data_templates.py @@ -93,7 +93,7 @@ def _add_param_enums( data_template = self.get_by_id(id=data_template_id) existing_parameters = data_template.parameter_values - for parameter in new_parameters: + for index, parameter in enumerate(new_parameters, start=1): this_sequence = next( ( p.sequence @@ -102,6 +102,7 @@ def _add_param_enums( ), None, ) + rowId = f"ROW{index}" enum_patches = [] if ( parameter.validation @@ -170,7 +171,7 @@ def _add_param_enums( if len(enum_patches) > 0: enum_response = self.session.put( - f"{self.base_path}/{data_template_id}/parameters/{this_sequence}/enums", + f"{self.base_path}/{data_template_id}/parameters/{rowId}/enums", json=enum_patches, ) return [EnumValidationValue(**x) for x in enum_response.json()] diff --git a/src/albert/collections/notebooks.py b/src/albert/collections/notebooks.py index 2741cdba..6acae1c7 100644 --- a/src/albert/collections/notebooks.py +++ b/src/albert/collections/notebooks.py @@ -13,6 +13,7 @@ PutBlockPayload, PutOperation, ) +from albert.resources.notebooks import BlockType class NotebookCollection(BaseCollection): From c5a3fcc07eea4cd2e3656891f88da87542ece7e1 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Thu, 9 Oct 2025 13:50:32 -0600 Subject: [PATCH 37/42] Fixed workflow return errors. --- src/albert/collections/workflows.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/albert/collections/workflows.py b/src/albert/collections/workflows.py index 522972dc..60ec80b4 100644 --- a/src/albert/collections/workflows.py +++ b/src/albert/collections/workflows.py @@ -64,7 +64,10 @@ def create(self, *, workflows: list[Workflow]) -> list[Workflow]: for x in workflows ], ) - return [Workflow(**x) for x in response.json()] + try: + return [Workflow(**x) for x in response.json()] + except Exception: + return [Workflow(id=response.json()[0].get('existingAlbertId') or response.json()[0].get('albertId'),name=response.json()[0].get('name'),ParameterGroups=[])] def _hydrate_parameter_groups(self, *, workflow: Workflow) -> None: """Populate parameter setpoints when only an ID is provided.""" From 50b6b2d2983b647cf9d75c9e725c930207b4cb47 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Thu, 9 Oct 2025 15:06:51 -0600 Subject: [PATCH 38/42] Update data_templates.py --- src/albert/collections/data_templates.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/albert/collections/data_templates.py b/src/albert/collections/data_templates.py index 15d9f53b..fbb10f57 100644 --- a/src/albert/collections/data_templates.py +++ b/src/albert/collections/data_templates.py @@ -93,6 +93,8 @@ def _add_param_enums( data_template = self.get_by_id(id=data_template_id) existing_parameters = data_template.parameter_values + all_results: list[EnumValidationValue] = [] + for index, parameter in enumerate(new_parameters, start=1): this_sequence = next( ( @@ -174,7 +176,9 @@ def _add_param_enums( f"{self.base_path}/{data_template_id}/parameters/{rowId}/enums", json=enum_patches, ) - return [EnumValidationValue(**x) for x in enum_response.json()] + all_results.extend([EnumValidationValue(**x) for x in enum_response.json()]) + + return all_results @validate_call def get_by_id(self, *, id: DataTemplateId) -> DataTemplate: From 7564462b75add9b420ac7d4805c897b0a87f56a0 Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Fri, 10 Oct 2025 17:09:25 -0600 Subject: [PATCH 39/42] Update sheets.py --- src/albert/resources/sheets.py | 154 ++++++++++++++++++++++++++++++++- 1 file changed, 153 insertions(+), 1 deletion(-) diff --git a/src/albert/resources/sheets.py b/src/albert/resources/sheets.py index 4bcd301d..557fe705 100644 --- a/src/albert/resources/sheets.py +++ b/src/albert/resources/sheets.py @@ -376,6 +376,73 @@ def _get_grid(self): self._columns = self._get_columns(grid_response=resp_json) self._rows = self._get_rows(grid_response=resp_json) return self._grid_to_cell_df(grid_response=resp_json) + def group_rows( + self, + *, + name: str, + child_row_ids: list[str] | list["Row"], + reference_id: str | None = None, + position: str = "above", + ) -> dict: + """ + Create a row group within this design. + + Parameters + ---------- + name : str + Display name for the group header. + child_row_ids : list[str] | list[Row] + Rows to group, as rowId strings or Row objects. + reference_id : str | None + A rowId used as an anchor for placement (defaults to first child). + position : str + One of: "above", "below". (Server may also accept others.) + + Returns + ------- + dict + Raw JSON response from the API. + """ + # Accept Row objects or plain rowId strings + ids: list[str] = [ + r.row_id if hasattr(r, "row_id") else str(r) # type: ignore[attr-defined] + for r in child_row_ids + ] + if not ids: + raise AlbertException("child_row_ids must include at least one rowId") + + # Fallback anchor if not provided + reference_id = reference_id or ids[0] + + # Endpoint observed from your example: + # /api/v3/worksheet/{DESIGN_ID}/designs/groups + endpoint = f"/api/v3/worksheet/{self.id}/designs/groups" + + payload = { + "name": name, + "referenceId": reference_id, + "position": position, + "ChildRows": [{"rowId": rid} for rid in ids], + } + + # Some environments may use /worksheet/design/{id}/groups; try that if needed. + resp = self.session.put(endpoint, json=payload) + if resp.status_code >= 400: + alt_endpoint = f"/api/v3/worksheet/design/{self.id}/groups" + resp = self.session.put(alt_endpoint, json=payload) + + if resp.status_code >= 400: + raise AlbertException( + f"Failed to group rows: {resp.status_code} {getattr(resp, 'text', '')}" + ) + + # Grouping changes the layout; clear cached grids/rows/columns + if self.sheet is not None: + self.sheet.grid = None + self._rows = None + self._columns = None + + return resp.json() if hasattr(resp, "json") else {} @property def columns(self) -> list["Column"]: @@ -385,8 +452,13 @@ def columns(self) -> list["Column"]: @property def rows(self) -> list["Row"]: + if self.design_type == 'process': + return [] if not self._rows: - self._get_grid() + try: + self._get_grid() + except Exception as e: + print(e) return self._rows @@ -821,6 +893,86 @@ def add_inventory_row( id=row_dict["id"], manufacturer=row_dict["manufacturer"], ) + def add_lookup_row( + self, + *, + name: str, + row_name: str | None = None, + design: DesignType | str | None = DesignType.APPS, + position: dict | None = None, + ) -> Row: + if design == DesignType.RESULTS: + raise AlbertException("You cannot add rows to the results design") + position = position or {"reference_id": "ROW1", "position": "above"} + design_id = self._get_design_id(design=design) + endpoint = f"/api/v3/worksheet/design/{design_id}/rows" + payload = [{ + "type": "LKP", + "name": name, + "referenceId": position["reference_id"], + "position": position["position"], + "labelName": "" if row_name is None else row_name, + }] + resp = self.session.post(endpoint, json=payload) + self.grid = None + data = resp.json()[0] if isinstance(resp.json(), list) else resp.json() + return Row( + rowId=data["rowId"], + type=data["type"], + session=self.session, + design=self._get_design(design=design), + sheet=self, + name=data.get("name") or data.get("lableName") or row_name or name, + inventory_id=data.get("id"), + manufacturer=data.get("manufacturer"), + ) + def add_app_row( + self, + *, + app_id: str, + name: str, + config: dict[str, str] | tuple[str, str] | None = None, + design: DesignType | str | None = DesignType.APPS, + position: dict | None = None, + ) -> Row: + if design == DesignType.RESULTS: + raise AlbertException("You cannot add rows to the results design") + + position = position or {"reference_id": "ROW1", "position": "above"} + design_id = self._get_design_id(design=design) + endpoint = f"/api/v3/worksheet/design/{design_id}/rows" + + app_id = app_id if app_id.startswith("APP") else f"APP{app_id}" + cfg = None + if isinstance(config, dict) and config: + k, v = next(iter(config.items())) + cfg = {"key": k, "value": v} + elif isinstance(config, tuple) and len(config) == 2: + cfg = {"key": config[0], "value": config[1]} + + payload = [{ + "type": "APP", + "referenceId": position["reference_id"], + "id": app_id, + "position": position["position"], + "name": name, + **({"config": cfg} if cfg else {}), + }] + + resp = self.session.post(endpoint, json=payload) + self.grid = None + + data = resp.json()[0] if isinstance(resp.json(), list) else resp.json() + return Row( + rowId=data["rowId"], + type=data["type"], + session=self.session, + design=self._get_design(design=design), + sheet=self, + name=data.get("name") or name, + inventory_id=data.get("id"), + manufacturer=data.get("manufacturer"), + ) def _filter_cells(self, *, cells: list[Cell], response_dict: dict): updated = [] From 81713b40ab560aa2336a520396aa1c4c2670fc15 Mon Sep 17 00:00:00 2001 From: tmtAl <120138095+tmtAl@users.noreply.github.com> Date: Thu, 16 Oct 2025 10:49:31 -0700 Subject: [PATCH 40/42] Add IMAGE type to DataType enum and refactor patch logic Introduces 'IMAGE' as a new value in the DataType enum. Refactors the patching logic for required parameters in ParameterGroupCollection to avoid accumulating multiple updates and streamlines the session patch call. --- src/albert/collections/parameter_groups.py | 19 ++++++++++++------- src/albert/resources/parameter_groups.py | 1 + 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/albert/collections/parameter_groups.py b/src/albert/collections/parameter_groups.py index 2fe6c87e..7f109e1e 100644 --- a/src/albert/collections/parameter_groups.py +++ b/src/albert/collections/parameter_groups.py @@ -271,21 +271,26 @@ def update(self, *, parameter_group: ParameterGroup) -> ParameterGroup: continue if existing_param.required != updated_param.required: - required_params.append( + required_params = [ { "operation": "update", "attribute": "required", "rowId": existing_param.sequence, "oldValue": existing_param.required, "newValue": updated_param.required, - } - ) - - if required_params: - self.session.patch( + }] + + self.session.patch( url=path, json={"data": required_params}, - ) + ) + + # if required_params: + # self.session.patch( + # url=path, + # json={"data": required_params}, + # ) + if len(general_patches.data) > 0: # patch the general patches self.session.patch( diff --git a/src/albert/resources/parameter_groups.py b/src/albert/resources/parameter_groups.py index cc4f9f1b..8e8efce8 100644 --- a/src/albert/resources/parameter_groups.py +++ b/src/albert/resources/parameter_groups.py @@ -27,6 +27,7 @@ class DataType(str, Enum): NUMBER = "number" STRING = "string" ENUM = "enum" + IMAGE = "image" class Operator(str, Enum): From 25aa71cf69bb6514d4452d8971d45006d299f81f Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Fri, 17 Oct 2025 13:42:35 -0600 Subject: [PATCH 41/42] Update sheets.py --- src/albert/resources/sheets.py | 445 ++++++++++++++++++++++++++------- 1 file changed, 349 insertions(+), 96 deletions(-) diff --git a/src/albert/resources/sheets.py b/src/albert/resources/sheets.py index 557fe705..9ced9e38 100644 --- a/src/albert/resources/sheets.py +++ b/src/albert/resources/sheets.py @@ -1,6 +1,6 @@ from enum import Enum -from typing import Any, ForwardRef, Union - +from typing import Any, ForwardRef, Union, Optional +from pydantic.config import ConfigDict import pandas as pd from pydantic import Field, PrivateAttr, field_validator, model_validator, validate_call @@ -148,6 +148,66 @@ class DesignState(BaseResource): collapsed: bool | None = False +class Group(BaseAlbertModel): + row_id: str + name: str | None = None + child_row_ids: list[str] = Field(default_factory=list) + +def _groups_from_sequence(seq: list[dict]) -> list[Group]: + """ + Try to infer groups from GET /worksheet/design/{id}/rows/sequence. + We accept multiple shapes: look for parent-like keys and child arrays. + Returns [] if we can't infer anything. + """ + if not isinstance(seq, list): + return [] + + # Build lookup + by_row: dict[str, dict] = {} + for item in seq: + rid = item.get("rowId") or item.get("id") + if rid: + by_row[rid] = item + + # Accept several possible keys + def children_of(item: dict) -> list[str]: + for k in ("children", "childRows", "ChildRows", "rows"): + v = item.get(k) + if isinstance(v, list): + if v and isinstance(v[0], dict): + return [x.get("rowId") or x.get("id") for x in v if (x.get("rowId") or x.get("id"))] + return [str(x) for x in v] + return [] + + def parent_of(item: dict) -> str | None: + for k in ("parentId", "groupId", "parentRowId"): + if item.get(k): + return item[k] + return None + + # 1) Prefer explicit parent→children + groups: list[Group] = [] + for item in seq: + rid = item.get("rowId") or item.get("id") + if not rid: + continue + kids = children_of(item) + if kids: + groups.append(Group(row_id=rid, name=item.get("name"), child_row_ids=[k for k in kids if k])) + + if groups: + return groups + + # 2) If only child→parent exists, invert it + parent_map: dict[str, list[str]] = {} + for item in seq: + rid = item.get("rowId") or item.get("id") + pid = parent_of(item) + if rid and pid: + parent_map.setdefault(pid, []).append(rid) + + return [Group(row_id=pid, name=by_row.get(pid, {}).get("name"), child_row_ids=kids) + for pid, kids in parent_map.items()] class Design(BaseSessionResource): """A Design in a Sheet. Designs are sheet subsections that are largly abstracted away from the user. @@ -176,6 +236,7 @@ class Design(BaseSessionResource): _columns: list["Column"] | None = PrivateAttr(default=None) _sheet: Union["Sheet", None] = PrivateAttr(default=None) # noqa _leftmost_pinned_column: str | None = PrivateAttr(default=None) + _groups_cache: list[Group] | None = PrivateAttr(default=None) def _grid_to_cell_df(self, *, grid_response): items = grid_response.get("Items") or [] @@ -326,6 +387,85 @@ def _get_columns(self, *, grid_response: dict) -> list["Column"]: return cols + # def _get_rows(self, *, grid_response: dict) -> list["Row"]: + # """ + # Parse the /grid response into a list of Row models. + + # Parameters + # ---------- + # grid_response : dict + # The JSON-decoded payload from GET /worksheet/.../grid. + + # Returns + # ------- + # list[Row] + # One Row per item in `Items` + # """ + # items = grid_response.get("Items") or [] + # if not items: + # return [] + + # rows: list[Row] = [] + # for v in items: + # raw_id = v.get("id") + # if raw_id and not str(raw_id).startswith("INV"): + # raw_id = f"INV{raw_id}" + # inv_id = raw_id + + # row_label = v.get("lableName") or v.get("name") + + + # rows.append( + # Row( + # rowId=v["rowId"], + # type=v["type"], + # session=self.session, + # design=self, + # sheet=self.sheet, + # name=row_label, + # manufacturer=v.get("manufacturer"), + # inventory_id=inv_id, + # config=v.get("config"), + # ) + # ) + # seq = grid_response.get("RowSequence") or [] + # if seq: + # groups_inline = _groups_from_sequence(seq) + # if groups_inline: + # by_id = {r.row_id: r for r in rows} + # # children → parent + # for g in groups_inline: + # for cid in g.child_row_ids: + # ch = by_id.get(cid) + # if ch: + # ch.parent_row_id = g.row_id + # # header rows (if present) + # for g in groups_inline: + # if g.row_id in by_id: + # by_id[g.row_id].child_row_ids = list(g.child_row_ids) + # # warm the cache so later calls are cheap + # self._groups_cache = groups_inline + # return rows # we’re done + # try: + # groups = self.list_groups() + # except Exception: + # groups = [] + + # if groups: + # by_id = {r.row_id: r for r in rows} + # # Always mark children → parent + # for g in groups: + # for cid in g.child_row_ids: + # ch = by_id.get(cid) + # if ch: + # ch.parent_row_id = g.row_id + # # Mark headers if present in grid + # for g in groups: + # if g.row_id in by_id: + # by_id[g.row_id].child_row_ids = list(g.child_row_ids) + + # return rows + def _get_rows(self, *, grid_response: dict) -> list["Row"]: """ Parse the /grid response into a list of Row models. @@ -350,7 +490,6 @@ def _get_rows(self, *, grid_response: dict) -> list["Row"]: if raw_id and not str(raw_id).startswith("INV"): raw_id = f"INV{raw_id}" inv_id = raw_id - row_label = v.get("lableName") or v.get("name") rows.append( @@ -363,11 +502,50 @@ def _get_rows(self, *, grid_response: dict) -> list["Row"]: name=row_label, manufacturer=v.get("manufacturer"), inventory_id=inv_id, + config=v.get("config"), ) ) - return rows + by_id = {r.row_id: r for r in rows} + + # Extract parent-child from rowHierarchy + for v in items: + hierarchy = v.get("rowHierarchy", []) + if len(hierarchy) < 2: + continue + row_ids = [h for h in hierarchy if h != self.design_type.value] + if len(row_ids) > 1: + by_id[v["rowId"]].parent_row_id = row_ids[-2] + + # Build child_row_ids + for row in rows: + if row.parent_row_id and row.parent_row_id in by_id: + parent = by_id[row.parent_row_id] + if row.row_id not in parent.child_row_ids: + parent.child_row_ids.append(row.row_id) + + seq = grid_response.get("RowSequence") or [] + if seq: + groups_inline = _groups_from_sequence(seq) + if groups_inline: + for g in groups_inline: + for cid in g.child_row_ids: + ch = by_id.get(cid) + if ch and not ch.parent_row_id: + ch.parent_row_id = g.row_id + for g in groups_inline: + if g.row_id in by_id: + existing = set(by_id[g.row_id].child_row_ids) + by_id[g.row_id].child_row_ids = list(existing | set(g.child_row_ids)) + self._groups_cache = groups_inline + return rows + + groups = [Group(row_id=r.row_id, name=r.name, child_row_ids=r.child_row_ids) + for r in rows if r.child_row_ids] + if groups: + self._groups_cache = groups + return rows def _get_grid(self): endpoint = f"/api/v3/worksheet/{self.id}/{self.design_type.value}/grid" response = self.session.get(endpoint) @@ -376,6 +554,29 @@ def _get_grid(self): self._columns = self._get_columns(grid_response=resp_json) self._rows = self._get_rows(grid_response=resp_json) return self._grid_to_cell_df(grid_response=resp_json) + def _hydrate_groups_from_sequence(self) -> list[Group]: + r = self.session.get(f"/api/v3/worksheet/design/{self.id}/rows/sequence") + if r.status_code >= 400: + return [] + seq = r.json() + groups = _groups_from_sequence(seq) + self._groups_cache = groups + return groups + + def list_groups(self, *, refresh: bool = False) -> list[Group]: + if self._groups_cache is not None and not refresh: + return self._groups_cache + + # No GET /groups exists → try sequence as a fallback + groups = self._hydrate_groups_from_sequence() + self._groups_cache = groups or [] # [] = unknown + return self._groups_cache + + def _clear_layout_caches(self): + if self.sheet is not None: + self.sheet.grid = None + self._rows = None + self._columns = None def group_rows( self, *, @@ -387,22 +588,11 @@ def group_rows( """ Create a row group within this design. - Parameters - ---------- - name : str - Display name for the group header. - child_row_ids : list[str] | list[Row] - Rows to group, as rowId strings or Row objects. - reference_id : str | None - A rowId used as an anchor for placement (defaults to first child). - position : str - One of: "above", "below". (Server may also accept others.) - - Returns - ------- - dict - Raw JSON response from the API. + Contract enforced here: + - referenceId MUST be one of ChildRows and MUST be the first item. + - If caller gives a reference_id not in children, we ignore it and use children[0]. """ + # Accept Row objects or plain rowId strings ids: list[str] = [ r.row_id if hasattr(r, "row_id") else str(r) # type: ignore[attr-defined] @@ -411,38 +601,50 @@ def group_rows( if not ids: raise AlbertException("child_row_ids must include at least one rowId") - # Fallback anchor if not provided - reference_id = reference_id or ids[0] + # Deduplicate while preserving order + seen = set() + ids = [x for x in ids if not (x in seen or seen.add(x))] - # Endpoint observed from your example: - # /api/v3/worksheet/{DESIGN_ID}/designs/groups - endpoint = f"/api/v3/worksheet/{self.id}/designs/groups" + # Enforce API requirement: + # - if reference_id provided and in ids -> move it to front + # - else -> reference_id = ids[0] + if reference_id and reference_id in ids: + ids = [reference_id] + [x for x in ids if x != reference_id] + else: + reference_id = ids[0] + endpoint = f"/api/v3/worksheet/{self.id}/designs/groups" payload = { "name": name, - "referenceId": reference_id, + "referenceId": reference_id, # now guaranteed to be ids[0] "position": position, - "ChildRows": [{"rowId": rid} for rid in ids], + "ChildRows": [{"rowId": rid} for rid in ids], # referenceId is first } - # Some environments may use /worksheet/design/{id}/groups; try that if needed. resp = self.session.put(endpoint, json=payload) if resp.status_code >= 400: - alt_endpoint = f"/api/v3/worksheet/design/{self.id}/groups" - resp = self.session.put(alt_endpoint, json=payload) - + alt = f"/api/v3/worksheet/design/{self.id}/groups" + resp = self.session.put(alt, json=payload) if resp.status_code >= 400: - raise AlbertException( - f"Failed to group rows: {resp.status_code} {getattr(resp, 'text', '')}" - ) + raise AlbertException(f"Grouping failed: {resp.status_code} {getattr(resp, 'text', '')}") - # Grouping changes the layout; clear cached grids/rows/columns - if self.sheet is not None: - self.sheet.grid = None - self._rows = None - self._columns = None + data = resp.json() if hasattr(resp, "json") else {} + + # Best-effort cache update + try: + group = Group( + row_id=data.get("rowId"), + name=data.get("name"), + child_row_ids=[d.get("rowId") for d in (data.get("ChildRows") or []) if d.get("rowId")], + ) + existing = {g.row_id: g for g in (self._groups_cache or [])} + existing[group.row_id] = group + self._groups_cache = list(existing.values()) + except Exception: + self._groups_cache = None - return resp.json() if hasattr(resp, "json") else {} + self._clear_layout_caches() + return data @property def columns(self) -> list["Column"]: @@ -943,12 +1145,6 @@ def add_app_row( endpoint = f"/api/v3/worksheet/design/{design_id}/rows" app_id = app_id if app_id.startswith("APP") else f"APP{app_id}" - cfg = None - if isinstance(config, dict) and config: - k, v = next(iter(config.items())) - cfg = {"key": k, "value": v} - elif isinstance(config, tuple) and len(config) == 2: - cfg = {"key": config[0], "value": config[1]} payload = [{ "type": "APP", @@ -956,7 +1152,7 @@ def add_app_row( "id": app_id, "position": position["position"], "name": name, - **({"config": cfg} if cfg else {}), + "config": config }] resp = self.session.post(endpoint, json=payload) @@ -972,6 +1168,7 @@ def add_app_row( name=data.get("name") or name, inventory_id=data.get("id"), manufacturer=data.get("manufacturer"), + config=(data.get("config") or cfg), ) def _filter_cells(self, *, cells: list[Cell], response_dict: dict): @@ -1144,10 +1341,13 @@ def update_cells(self, *, cells: list[Cell]): this_url = f"/api/v3/worksheet/{design_id}/values" for payload in payloads: - response = self.session.patch( + try: + response = self.session.patch( this_url, json=[payload], # The API expects a list of changes ) + except Exception as e: + continue original_cell = next( ( @@ -1176,66 +1376,97 @@ def update_cells(self, *, cells: list[Cell]): self.grid = None return (updated, failed) - def add_blank_column(self, *, name: str, position: dict = None): - if position is None: - position = {"reference_id": self.leftmost_pinned_column, "position": "rightOf"} - endpoint = f"/api/v3/worksheet/sheet/{self.id}/columns" - payload = [ - { - "type": "BLK", - "name": name, - "referenceId": position["reference_id"], - "position": position["position"], - } - ] - response = self.session.post(endpoint, json=payload) + def add_blank_column(self, col_or_name=None, *, position=None): + # accept Column, dict, or string + if isinstance(col_or_name, Column): + nm = col_or_name.name or "" + elif isinstance(col_or_name, dict): + nm = col_or_name.get("name", "") + position = position or col_or_name.get("position") + else: + nm = str(col_or_name or "") + extra = {} - data = response.json() - data[0]["sheet"] = self - data[0]["session"] = self.session - self.grid = None # reset the known grid. We could probably make this nicer later. - return Column(**data[0]) + if position is None: + ref = self.columns[-1].column_id if getattr(self, "columns", None) else self.leftmost_pinned_column + position = {"reference_id": ref, "position": "rightOf"} - def add_lookup_column(self, *, name: str, position: dict | None = None) -> "Column": - """ - Create a Lookup (LKP) column at a position relative to a reference column. + payload = [{ + "type": "BLK", + "name": nm, + "referenceId": position["reference_id"], + "position": position["position"], + }] + resp = self.session.post(f"/api/v3/worksheet/sheet/{self.id}/columns", json=payload) + data = resp.json()[0]; data["sheet"] = self; data["session"] = self.session + self.grid = None + return Column(**data) - position: {"reference_id": "COL8", "position": "leftOf" | "rightOf"} - """ - if position is None: - position = {"reference_id": self.leftmost_pinned_column, "position": "rightOf"} - endpoint = f"/api/v3/worksheet/sheet/{self.id}/columns" - payload = [ - { - "type": "LKP", - "name": name, - "referenceId": position["reference_id"], - "position": position["position"], - } - ] + def add_lookup_column(self, col_or_name=None, *, position=None): + if isinstance(col_or_name, Column): + nm = col_or_name.name or "" + elif isinstance(col_or_name, dict): + nm = col_or_name.get("name", "") + else: + nm = str(col_or_name or "") + extra = {} - resp = self.session.post(endpoint, json=payload) + if position is None: + ref = self.columns[-1].column_id if getattr(self, "columns", None) else self.leftmost_pinned_column + position = {"reference_id": ref, "position": "rightOf"} + payload = [{ + "type": "LKP", + "name": nm, + "referenceId": position["reference_id"], + "position": position["position"], + }] + resp = self.session.post(f"/api/v3/worksheet/sheet/{self.id}/columns", json=payload) if resp.status_code >= 400: - # Optional: graceful fallback to a BLK column - # (Remove this block if you want hard failure instead.) - blk_payload = [ - { - "type": "BLK", - "name": name, - "referenceId": position["reference_id"], - "position": position["position"], - } - ] - resp = self.session.post(endpoint, json=blk_payload) + payload[0]["type"] = "BLK" + resp = self.session.post(f"/api/v3/worksheet/sheet/{self.id}/columns", json=payload) - data = resp.json()[0] - data["sheet"] = self - data["session"] = self.session + data = resp.json()[0]; data["sheet"] = self; data["session"] = self.session self.grid = None return Column(**data) + + def set_columns_pinned(self, *, col_ids: list[str], pinned: str | None) -> None: + """Pin columns: pinned in {'left','right',None}.""" + payload = {"data": [{ + "operation": "update", + "attribute": "pinned", + "colIds": col_ids, + "newValue": pinned, + }]} + self.session.patch(f"/api/v3/worksheet/sheet/{self.id}/columns", json=payload) + self.grid = None + + def set_columns_width(self, *, col_ids: list[str], width: str) -> None: + """Set width like '142px' for one or many columns.""" + payload = {"data": [{ + "operation": "update", + "attribute": "columnWidth", + "colIds": col_ids, + "newValue": width, + }]} + self.session.patch(f"/api/v3/worksheet/sheet/{self.id}/columns", json=payload) + self.grid = None + + def set_column_hidden(self, *, col_id: str, hidden: bool, old_value: bool | None = None) -> None: + """Hide/show a single column. If you know the previous value, pass it.""" + data = { + "operation": "update", + "attribute": "hidden", + "colId": col_id, + "newValue": hidden, + } + if old_value is not None: + data["oldValue"] = old_value + payload = {"data": [data]} + self.session.patch(f"/api/v3/worksheet/sheet/{self.id}/columns", json=payload) + self.grid = None def delete_column(self, *, column_id: str) -> None: endpoint = f"/api/v3/worksheet/sheet/{self.id}/columns" @@ -1459,6 +1690,11 @@ def recolor_cells(self, color: CellColor): new_cells.append(cell_copy) return self.sheet.update_cells(cells=new_cells) +class Config(BaseSessionResource): + key: str + value: str + model_config = ConfigDict(extra='ignore') + class Row(BaseSessionResource): # noqa:F811 """A row in a Sheet @@ -1493,6 +1729,9 @@ class Row(BaseSessionResource): # noqa:F811 name: str | None = Field(default=None) inventory_id: str | None = Field(default=None, alias="id") manufacturer: str | None = Field(default=None) + config: Optional[Config] = Field(default=None) + parent_row_id: str | None = Field(default=None) + child_row_ids: list[str] = Field(default_factory=list) @property def row_unique_id(self): @@ -1501,6 +1740,20 @@ def row_unique_id(self): @property def cells(self) -> list[Cell]: return self.sheet.grid.loc[self.row_unique_id] + + @property + def is_group_header(self) -> bool: + return bool(self.child_row_ids) + @field_validator("config", mode="before") + @classmethod + def _coerce_config(cls, v): + if v is None or isinstance(v, Config): + return v + if isinstance(v, dict): + return Config(**v) + if isinstance(v, (tuple, list)) and len(v) == 2: + return Config(key=v[0], value=v[1]) + return v def recolor_cells(self, color: CellColor): new_cells = [] From e66c75e8a94933e1a00edc6ca07d6877ce354afe Mon Sep 17 00:00:00 2001 From: TomAlbertInvent Date: Mon, 27 Oct 2025 14:49:39 -0600 Subject: [PATCH 42/42] Rename ACL field and extend parent_id types Renamed 'users_with_access' to 'acl' in DataTemplate for clarity and consistency. Added CustomTemplateId as a valid type for Notebook's parent_id to support additional parent relationships. --- src/albert/resources/data_templates.py | 2 +- src/albert/resources/notebooks.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/albert/resources/data_templates.py b/src/albert/resources/data_templates.py index 024dca5c..1367ab82 100644 --- a/src/albert/resources/data_templates.py +++ b/src/albert/resources/data_templates.py @@ -53,7 +53,7 @@ class DataTemplate(BaseTaggedResource): description: str | None = None security_class: SecurityClass | None = None verified: bool = False - users_with_access: list[SerializeAsEntityLink[User]] | None = Field(alias="ACL", default=None) + acl: list[SerializeAsEntityLink[User]] | None = Field(alias="ACL", default=None) data_column_values: list[DataColumnValue] | None = Field(alias="DataColumns", default=None) parameter_values: list[ParameterValue] | None = Field(alias="Parameters", default=None) deleted_parameters: list[ParameterValue] | None = Field( diff --git a/src/albert/resources/notebooks.py b/src/albert/resources/notebooks.py index f2f135ff..5c31fe00 100644 --- a/src/albert/resources/notebooks.py +++ b/src/albert/resources/notebooks.py @@ -8,7 +8,7 @@ from pydantic import Field, model_validator from albert.core.base import BaseAlbertModel -from albert.core.shared.identifiers import LinkId, NotebookId, ProjectId, SynthesisId, TaskId +from albert.core.shared.identifiers import LinkId, NotebookId, ProjectId, SynthesisId, TaskId, CustomTemplateId from albert.core.shared.models.base import BaseResource, EntityLink from albert.exceptions import AlbertException from albert.resources.acls import ACL @@ -220,7 +220,7 @@ class NotebookLink(BaseAlbertModel): class Notebook(BaseResource): id: NotebookId | None = Field(default=None, alias="albertId") name: str = Field(default="Untitled Notebook") - parent_id: ProjectId | TaskId = Field(..., alias="parentId") + parent_id: ProjectId | TaskId | CustomTemplateId = Field(..., alias="parentId") version: datetime | None = Field(default=None) blocks: list[NotebookBlock] = Field(default_factory=list) links: list[NotebookLink] | None = Field(default=None)