diff --git a/ofsc/models/__init__.py b/ofsc/models/__init__.py index ab75976..ebf86b5 100644 --- a/ofsc/models/__init__.py +++ b/ofsc/models/__init__.py @@ -112,6 +112,8 @@ CapacityAreaWorkZonesV1Response as CapacityAreaWorkZonesV1Response, CapacityCategory as CapacityCategory, CapacityCategoryListResponse as CapacityCategoryListResponse, + CapacityCategoryWorkSkill as CapacityCategoryWorkSkill, + CapacityCategoryWorkSkillGroup as CapacityCategoryWorkSkillGroup, Condition as Condition, EnumerationValue as EnumerationValue, EnumerationValueList as EnumerationValueList, diff --git a/ofsc/models/metadata.py b/ofsc/models/metadata.py index 8ed7ca0..14ebec4 100644 --- a/ofsc/models/metadata.py +++ b/ofsc/models/metadata.py @@ -475,16 +475,34 @@ def __getitem__(self, item): return self.root[item] +class CapacityCategoryWorkSkill(BaseModel): + # extra="allow" preserves audit fields (created_by, creation_date, + # last_updated_by, last_update_date, last_update_login) and any future fields. + model_config = ConfigDict(extra="allow") + label: str + ratio: Optional[int] = None + startDate: Optional[str] = None + end_date: Optional[str] = None + + +class CapacityCategoryWorkSkillGroup(BaseModel): + model_config = ConfigDict(extra="allow") + label: str + startDate: Optional[str] = None + end_date: Optional[str] = None + links: Optional[list[Link]] = None + + class CapacityCategory(BaseModel): + model_config = ConfigDict(extra="allow") label: str name: str timeSlots: Optional[ItemList] = None translations: Annotated[Optional[TranslationList], Field(alias="translations")] = None - workSkillGroups: Optional[ItemList] = None - workSkills: Optional[ItemList] = None + workSkillGroups: Optional[list[CapacityCategoryWorkSkillGroup]] = None + workSkills: Optional[list[CapacityCategoryWorkSkill]] = None active: bool links: Optional[list[Link]] = None - model_config = ConfigDict(extra="ignore") class CapacityCategoryListResponse(OFSResponseList[CapacityCategory]): diff --git a/pyproject.toml b/pyproject.toml index bef41a7..de7d854 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ofsc" -version = "2.26.1" +version = "2.26.2" description = "Python wrapper for Oracle Field Service API" authors = [{ name = "Borja Toron", email = "borja.toron@gmail.com" }] requires-python = ">=3.13" diff --git a/tests/metadata/test_capacity_categories.py b/tests/metadata/test_capacity_categories.py index eaf86ec..dcb8f30 100644 --- a/tests/metadata/test_capacity_categories.py +++ b/tests/metadata/test_capacity_categories.py @@ -30,3 +30,107 @@ def test_get_capacity_category(instance, pp, demo_data): ) assert metadata_response.label == category assert metadata_response.name == capacity_categories[category].get("name") + + +def test_capacity_category_nested_fields_are_preserved(): + """Nested workSkills/workSkillGroups fields must not be silently dropped. + + Mirrors the real OFS API shape (synthetic labels). Regression test for #175. + """ + from ofsc.models import ( + CapacityCategory, + CapacityCategoryWorkSkill, + CapacityCategoryWorkSkillGroup, + ) + + payload = { + "label": "DEMO_CAT", + "name": "Demo Category", + "active": True, + "workSkills": [ + { + "label": "S_DEMO", + "ratio": 100, + "startDate": "2023-01-06", + "end_date": "2025-01-04", + "created_by": "alice", + "creation_date": "2023-01-06 10:00:00", + "last_updated_by": "bob", + "last_update_date": "2024-01-01 12:00:00", + "last_update_login": "bob_login", + } + ], + "workSkillGroups": [ + { + "label": "Demo Group", + "startDate": "2025-01-05", + "end_date": "", + "created_by": "carol", + "creation_date": "2025-01-05 09:00:00", + "last_updated_by": "carol", + "last_update_date": "2025-01-05 09:00:00", + "last_update_login": "carol_login", + "links": [ + { + "rel": "canonical", + "href": "https://x.example/rest/ofscMetadata/v1/workSkillGroups/Demo%20Group", + } + ], + } + ], + "timeSlots": [{"label": "AM"}, {"label": "PM"}], + "translations": [{"language": "en", "name": "Demo Category", "languageISO": "en-US"}], + "links": [{"rel": "canonical", "href": "https://x.example/cat/DEMO_CAT"}], + } + + cat = CapacityCategory.model_validate(payload) + + # workSkills: declared fields are typed and populated + skill = cat.workSkills[0] + assert isinstance(skill, CapacityCategoryWorkSkill) + assert skill.label == "S_DEMO" + assert skill.ratio == 100 + assert skill.startDate == "2023-01-06" + assert skill.end_date == "2025-01-04" + + # workSkills: undeclared audit fields preserved via extra="allow" + assert skill.model_extra["created_by"] == "alice" + assert skill.model_extra["last_update_login"] == "bob_login" + + # workSkillGroups: declared fields + links typed and populated + group = cat.workSkillGroups[0] + assert isinstance(group, CapacityCategoryWorkSkillGroup) + assert group.label == "Demo Group" + assert group.startDate == "2025-01-05" + assert group.end_date == "" + assert group.links[0].rel == "canonical" + assert group.model_extra["created_by"] == "carol" + + # timeSlots unchanged (label only) + assert cat.timeSlots[0].label == "AM" + + # round-trip: extras survive model_dump() + dumped = cat.model_dump() + assert dumped["workSkills"][0]["created_by"] == "alice" + assert dumped["workSkills"][0]["ratio"] == 100 + + +def test_capacity_category_with_empty_nested_lists(): + """A category with no skills/groups (the COPPER shape) parses cleanly.""" + from ofsc.models import CapacityCategory + + cat = CapacityCategory.model_validate( + { + "label": "COPPER", + "name": "Copper", + "active": True, + "workSkills": [], + "workSkillGroups": [], + "timeSlots": [{"label": "AM"}], + "translations": [{"language": "en", "name": "Copper"}], + "links": [], + } + ) + assert cat.workSkills == [] + assert cat.workSkillGroups == [] + assert cat.label == "COPPER" diff --git a/tests/test_model.py b/tests/test_model.py index bcc1bec..68251c1 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -404,8 +404,14 @@ def test_capacity_category_model_list(): assert item.translations == TranslationList.model_validate(capacityCategoryList["items"][idx]["translations"]) expected_links = [Link.model_validate(link) for link in capacityCategoryList["items"][idx]["links"]] assert item.links == expected_links - # assert item.workSkills == capacityCategoryList["items"][idx]["workSkills"] - assert item.workSkillGroups == ItemList.model_validate(capacityCategoryList["items"][idx]["workSkillGroups"]) + # workSkills now preserves ratio/startDate, previously dropped (see #175) + expected_ws = capacityCategoryList["items"][idx]["workSkills"] + assert len(item.workSkills) == len(expected_ws) + for ws_obj, ws_data in zip(item.workSkills, expected_ws): + assert ws_obj.label == ws_data["label"] + assert ws_obj.ratio == ws_data.get("ratio") + assert ws_obj.startDate == ws_data.get("startDate") + assert item.workSkillGroups == capacityCategoryList["items"][idx]["workSkillGroups"] # endregion diff --git a/uv.lock b/uv.lock index 5608894..04db3d7 100644 --- a/uv.lock +++ b/uv.lock @@ -424,7 +424,7 @@ wheels = [ [[package]] name = "ofsc" -version = "2.26.0" +version = "2.26.2" source = { editable = "." } dependencies = [ { name = "cachetools" },