From 65e99c74f971afe50b33be08a94205235db79896 Mon Sep 17 00:00:00 2001 From: Fabian Franz Steiner <75947402+fasteiner@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:57:45 +0000 Subject: [PATCH 01/18] Add unit tests for OutOfOfficePeriod, Product, ReferencedTypes, Shop, and Site modules - Implement tests for OutOfOfficePeriod including initialization, data handling, CRUD operations, and filtering. - Create tests for Product covering initialization, data handling, CRUD operations, enabling/disabling, and related entities. - Add comprehensive tests for various referenced types including Service, Calendar, TimeAllocation, EffortClass, RequestTemplate, UiExtension, and WorkflowTemplate. - Develop tests for ShopArticleCategory, ShopArticle, ShopOrderLine including initialization, data handling, CRUD operations, and filtering. - Introduce tests for Site module covering initialization, data handling, CRUD operations, enabling/disabling, and archiving. --- .github/workflows/python-package.yml | 21 +- .github/workflows/release.yml | 12 +- .gitignore | 6 +- CHANGELOG.md | 33 ++ CLAUDE.md | 92 ++++ pyproject.toml | 8 +- src/xurrent/calendars.py | 80 ++++ src/xurrent/configuration_items.py | 9 +- src/xurrent/custom_collection_elements.py | 94 ++++ src/xurrent/custom_collections.py | 92 ++++ src/xurrent/effort_classes.py | 84 ++++ src/xurrent/holidays.py | 66 +++ src/xurrent/organizations.py | 139 ++++++ src/xurrent/out_of_office_periods.py | 104 +++++ src/xurrent/people.py | 14 +- src/xurrent/product_categories.py | 97 ++++ src/xurrent/products.py | 151 +++++++ src/xurrent/request_templates.py | 176 ++++++++ src/xurrent/services.py | 131 ++++++ src/xurrent/shop_article_categories.py | 84 ++++ src/xurrent/shop_articles.py | 127 ++++++ src/xurrent/shop_order_lines.py | 142 ++++++ src/xurrent/sites.py | 103 +++++ src/xurrent/time_allocations.py | 121 +++++ src/xurrent/ui_extensions.py | 110 +++++ src/xurrent/workflow_templates.py | 111 +++++ tests/unit_tests/test_custom_collections.py | 196 ++++++++ tests/unit_tests/test_holidays.py | 93 ++++ tests/unit_tests/test_organizations.py | 174 +++++++ .../unit_tests/test_out_of_office_periods.py | 115 +++++ tests/unit_tests/test_products.py | 151 +++++++ tests/unit_tests/test_referenced_types.py | 424 ++++++++++++++++++ tests/unit_tests/test_shop.py | 270 +++++++++++ tests/unit_tests/test_sites.py | 131 ++++++ 34 files changed, 3742 insertions(+), 19 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/xurrent/calendars.py create mode 100644 src/xurrent/custom_collection_elements.py create mode 100644 src/xurrent/custom_collections.py create mode 100644 src/xurrent/effort_classes.py create mode 100644 src/xurrent/holidays.py create mode 100644 src/xurrent/organizations.py create mode 100644 src/xurrent/out_of_office_periods.py create mode 100644 src/xurrent/product_categories.py create mode 100644 src/xurrent/products.py create mode 100644 src/xurrent/request_templates.py create mode 100644 src/xurrent/services.py create mode 100644 src/xurrent/shop_article_categories.py create mode 100644 src/xurrent/shop_articles.py create mode 100644 src/xurrent/shop_order_lines.py create mode 100644 src/xurrent/sites.py create mode 100644 src/xurrent/time_allocations.py create mode 100644 src/xurrent/ui_extensions.py create mode 100644 src/xurrent/workflow_templates.py create mode 100644 tests/unit_tests/test_custom_collections.py create mode 100644 tests/unit_tests/test_holidays.py create mode 100644 tests/unit_tests/test_organizations.py create mode 100644 tests/unit_tests/test_out_of_office_periods.py create mode 100644 tests/unit_tests/test_products.py create mode 100644 tests/unit_tests/test_referenced_types.py create mode 100644 tests/unit_tests/test_shop.py create mode 100644 tests/unit_tests/test_sites.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index ed8be07..9c582b0 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -16,12 +16,12 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Set environment variables @@ -38,13 +38,20 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install flake8 pytest python-dotenv mock - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Test with pytest + pip install . + - name: Lint with flake8 + run: | + # Stop the build on syntax errors or undefined names + flake8 ./src/ --count --select=E9,F63,F7,F82 --show-source --statistics + - name: Unit tests + run: | + pytest ./tests/unit_tests + pytest ./src/ --doctest-modules -v + - name: Integration tests env: APITOKEN: ${{ secrets.DEMO_API_TOKEN }} APIACCOUNT: ${{ vars.DEMO_APIACCOUNT }} APIURL: ${{ vars.DEMO_APIURL }} run: | - pytest ./tests/ - pytest ./src/ --doctest-modules -v + pytest ./tests/integration diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4829d6..e126314 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,18 +18,18 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 # Required for GitVersion to work correctly - name: Install GitVersion - uses: GitTools/actions/gitversion/setup@v0 + uses: GitTools/actions/gitversion/setup@v3 with: versionSpec: '5.x' - name: Determine Version id: gitversion - uses: GitTools/actions/gitversion/execute@v0 + uses: GitTools/actions/gitversion/execute@v3 - name: Extract Release Notes from Changelog id: extract-changelog @@ -111,7 +111,7 @@ jobs: - name: Commit Updated CHANGELOG.md if: github.ref == 'refs/heads/main' - uses: stefanzweifel/git-auto-commit-action@v5 + uses: stefanzweifel/git-auto-commit-action@v7 with: commit_message: "Update CHANGELOG for release ${{ steps.effective-version.outputs.effective_version }}" file_pattern: "CHANGELOG.md" @@ -137,7 +137,7 @@ jobs: EOF - name: Commit Updated pyproject.toml - uses: stefanzweifel/git-auto-commit-action@v5 + uses: stefanzweifel/git-auto-commit-action@v7 with: commit_message: "Update pyproject.toml for release ${{ steps.effective-version.outputs.effective_version }}" file_pattern: "pyproject.toml" @@ -155,7 +155,7 @@ jobs: - name: Create GitHub Release if: env.python_changed == 'true' - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: tag_name: v${{ steps.effective-version.outputs.effective_version }} name: Release v${{ steps.effective-version.outputs.effective_version }} diff --git a/.gitignore b/.gitignore index 2db1ea9..4e18b08 100644 --- a/.gitignore +++ b/.gitignore @@ -164,7 +164,11 @@ cython_debug/ # poetry poetry.lock +uv.lock # diff files *.diff -diff* \ No newline at end of file +diff* + +#Claude local +.claude/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a38a40..a1798c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,43 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Docs: added `CLAUDE.md` with setup instructions, test commands, architecture overview, and changelog requirements for Claude Code. +- Products: added `Product` class with `ProductPredefinedFilter` and `ProductDepreciationMethod` enums; supports CRUD, enable/disable, and CI listing. +- ProductCategories: added `ProductCategory` class with `ProductCategoryRuleSet` enum; supports CRUD and enable/disable. +- Organizations: added `Organization` class with `OrganizationPredefinedFilter` enum; supports CRUD, enable/disable, archive/trash/restore, people and child org listing. +- Sites: added `Site` class with `SitePredefinedFilter` enum; supports CRUD, enable/disable, archive/trash/restore. +- OutOfOfficePeriods: added `OutOfOfficePeriod` class with `OutOfOfficePeriodPredefinedFilter` enum; supports CRUD and DELETE. +- Holidays: added `Holiday` class; supports CRUD. +- CustomCollections: added `CustomCollection` class with `CustomCollectionPredefinedFilter` enum; supports CRUD, enable/disable, and element listing. +- CustomCollectionElements: added `CustomCollectionElement` class with `CustomCollectionElementPredefinedFilter` enum; supports CRUD and enable/disable. +- ShopArticleCategories: added `ShopArticleCategory` class with `ShopArticleCategoryPredefinedFilter` enum; supports CRUD. +- ShopArticles: added `ShopArticle` class with `ShopArticlePredefinedFilter` and `ShopArticleRecurringPeriod` enums; supports CRUD and enable/disable. +- ShopOrderLines: added `ShopOrderLine` class with `ShopOrderLinePredefinedFilter`, `ShopOrderLineStatus`, and `ShopOrderLineRecurringPeriod` enums; supports CRUD. +- Services: added `Service` class with `ServicePredefinedFilter` enum; supports CRUD and enable/disable. +- Calendars: added `Calendar` class with `CalendarPredefinedFilter` enum; supports CRUD and enable/disable. +- TimeAllocations: added `TimeAllocation` class with `TimeAllocationPredefinedFilter` and category enums; supports CRUD and enable/disable. +- EffortClasses: added `EffortClass` class with `EffortClassPredefinedFilter` enum; supports CRUD and enable/disable. +- RequestTemplates: added `RequestTemplate` class with `RequestTemplatePredefinedFilter`, `RequestTemplateCategory`, `RequestTemplateStatus`, and `RequestTemplateImpact` enums; supports CRUD and enable/disable. +- UiExtensions: added `UiExtension` class with `UiExtensionCategory` enum; supports CRUD and enable/disable. +- WorkflowTemplates: added `WorkflowTemplate` class with `WorkflowTemplatePredefinedFilter` and `WorkflowTemplateCategory` enums; supports CRUD and enable/disable. + +### Changed + +- People: `Person` now deserializes `site` (→ `Site`) and `organization` (→ `Organization`) references; also fixed a `People.from_data` typo in `update()`. +- ConfigurationItems: `ConfigurationItem` now deserializes the `product` reference (→ `Product`). +- Products: `Product` now deserializes the `category` reference (→ `ProductCategory`). +- CI: updated GitHub Actions in `release.yml` — `GitTools/actions` `v0` → `v3` (latest version compatible with GitVersion 5.x; v4+ requires GitVersion ≥6.1), `stefanzweifel/git-auto-commit-action` `v5` → `v7`, `softprops/action-gh-release` `v1` → `v2`. +- CI: updated `python-package.yml` — `actions/setup-python` `v3` → `v5`; added `pip install .` so the package itself is installed before tests run; added a `flake8` lint step (syntax errors and undefined names only); split test run into separate `Unit tests` and `Integration tests` steps so unit tests always run regardless of credentials; added Python 3.14 to the test matrix. + ## [0.11.0] - 2026-04-13 ### Added + - Core: support OAuth client credentials authentication via `client_id` and `client_secret` in `XurrentApiHelper` while maintaining API key compatibility. - Core: The OAuth token endpoint now dynamically determines the domain from `base_url`, preserving any regional subdomains to ensure consistency between API and OAuth endpoints. - Core: When using OAuth, if a 401 Unauthorized error is received, the token is automatically refreshed and the API call is retried once. If authentication still fails after token refresh, an explicit HTTPError is raised. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3e0febc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`xurrent-python` is a Python wrapper for the Xurrent API (a service/incident management platform). It provides object-oriented abstractions over REST endpoints with built-in handling for authentication, pagination, rate limiting, and OAuth token refresh. + +## Setup + +This project uses [Poetry](https://python-poetry.org/) for dependency management. + +```bash +pip install poetry +poetry install --with dev +eval $(poetry env activate) +pre-commit install +``` + +## Commands + +### Testing + +```bash +# Unit tests (no credentials required) +pytest ./tests/unit_tests + +# Run a single test file +pytest ./tests/unit_tests/test_core_oauth.py -v + +# Run a single test function +pytest ./tests/unit_tests/test_core_oauth.py::test_init_requires_authentication_method -v + +# Doctests in source modules +pytest ./src/ --doctest-modules -v + +# Integration tests (requires env vars: APITOKEN, APIACCOUNT, APIURL) +pytest ./tests/integration +``` + +Integration tests require a `.env` file or environment variables: `APITOKEN`, `APIACCOUNT`, `APIURL`. + +### Pre-commit + +```bash +pre-commit run --all-files +``` + +The pre-commit hook runs the unit test suite automatically before each commit. + +## Architecture + +### Core (`xurrent/core.py`) + +`XurrentApiHelper` is the central HTTP client. It: +- Supports two auth modes: **API key** (`XurrentApiHelper(token=..., account=..., api_url=...)`) and **OAuth 2.0 client credentials** (`XurrentApiHelper(client_id=..., client_secret=..., account=..., api_url=...)`) +- Automatically handles pagination via `Link` response headers — callers receive aggregated results +- Retries on HTTP 429 (rate limit) and refreshes OAuth tokens on HTTP 401 +- Exposes `api_call(method, url, data, params)` as the low-level HTTP wrapper used by all domain classes + +`JsonSerializableDict` is the base class for all resource models, providing `to_dict()` and `to_json()` serialization. + +### Domain Classes + +Each module wraps one Xurrent resource type. All domain classes follow the same patterns: +- Accept a `XurrentApiHelper` instance as `connection_object` +- Use `@classmethod` factory methods (`get_by_id()`, `get_()`, `create()`) to deserialize API responses into instances via `from_data()` +- Expose lifecycle methods (enable/disable/archive/trash/restore) and resource-specific operations + +| Module | Class | Notable Features | +|---|---|---| +| `requests.py` | `Request` | Notes, linked CIs, status/category/completion enums | +| `people.py` | `Person` | `get_me()`, team membership | +| `configuration_items.py` | `ConfigurationItem` | Auto-increments label on creation | +| `tasks.py` | `Task` | Approve/reject/cancel, workflow linkage | +| `workflows.py` | `Workflow` | Close with completion reason | +| `teams.py` | `Team` | Basic lifecycle management | + +### Circular Import Handling + +Domain classes cross-reference each other (e.g., `Request` embeds `Person`, `Team`, `Workflow`). To avoid circular imports, these are imported lazily — inside methods rather than at module top level. When adding new cross-module references, follow this same pattern. + +### Tests + +- **`tests/unit_tests/`** — Uses `MagicMock` to stub `XurrentApiHelper`; no real credentials needed +- **`tests/integration/`** — Hits a live Xurrent instance; requires credentials in environment + +When adding a new domain class, add corresponding unit tests under `tests/unit_tests/`. + +## Changelog + +All changes must be documented in [CHANGELOG.md](CHANGELOG.md) under the `[Unreleased]` section, following the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. Use the appropriate subsection (`Added`, `Changed`, `Fixed`, `Removed`) and prefix each entry with the affected module or component (e.g., `Core:`, `Requests:`). diff --git a/pyproject.toml b/pyproject.toml index cfd0b53..aabc1b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ Issues = "https://github.com/fasteiner/xurrent-python/issues" name = "xurrent" version = "0.11.0" description = "A python module to interact with the Xurrent API." -authors = ["Ing. Fabian Franz Steiner BSc. "] +authors = ["Ing. Fabian Franz Steiner BSc. "] readme = "README.md" [tool.poetry.dependencies] @@ -38,3 +38,9 @@ shell = "^1.0.1" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[dependency-groups] +dev = [ + "mock>=5.2.0", + "pytest>=8.4.2", +] diff --git a/src/xurrent/calendars.py b/src/xurrent/calendars.py new file mode 100644 index 0000000..c2f91ed --- /dev/null +++ b/src/xurrent/calendars.py @@ -0,0 +1,80 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='Calendar') + + +class CalendarPredefinedFilter(str, Enum): + enabled = "enabled" + disabled = "disabled" + + def __str__(self): + return self.value + + +class Calendar(JsonSerializableDict): + # https://developer.xurrent.com/v1/calendars/ + __resourceUrl__ = 'calendars' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + disabled: Optional[bool] = None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.disabled = disabled + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return f"Calendar(id={self.id}, name={self.name}, disabled={self.disabled})" + + def ref_str(self) -> str: + return f"Calendar(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_calendars(cls, connection_object: XurrentApiHelper, + predefinedFilter: CalendarPredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return Calendar.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def enable(self) -> T: + return self.update({'disabled': False}) + + def disable(self) -> T: + return self.update({'disabled': True}) diff --git a/src/xurrent/configuration_items.py b/src/xurrent/configuration_items.py index c714191..fc0cfc1 100644 --- a/src/xurrent/configuration_items.py +++ b/src/xurrent/configuration_items.py @@ -14,7 +14,7 @@ class ConfigurationItem(JsonSerializableDict): # https://developer.xurrent.com/v1/configuration_items/ __resourceUrl__ = 'cis' - def __init__(self, + def __init__(self, connection_object: XurrentApiHelper, id: int, label: Optional[str] = None, @@ -22,6 +22,7 @@ def __init__(self, type: Optional[str] = None, status: Optional[str] = None, attributes: Optional[Dict] = None, + product: Optional[Dict] = None, **kwargs): self.id = id self._connection_object = connection_object @@ -29,7 +30,11 @@ def __init__(self, self.name = name self.status = status self.attributes = attributes or {} - + + from .products import Product + self.product = (product if isinstance(product, Product) + else Product.from_data(connection_object, product) if product else None) + for key, value in kwargs.items(): setattr(self, key, value) diff --git a/src/xurrent/custom_collection_elements.py b/src/xurrent/custom_collection_elements.py new file mode 100644 index 0000000..0199ff3 --- /dev/null +++ b/src/xurrent/custom_collection_elements.py @@ -0,0 +1,94 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='CustomCollectionElement') + + +class CustomCollectionElementPredefinedFilter(str, Enum): + disabled = "disabled" + enabled = "enabled" + + def __str__(self): + return self.value + + +class CustomCollectionElement(JsonSerializableDict): + # https://developer.xurrent.com/v1/custom_collection_elements/ + __resourceUrl__ = 'custom_collection_elements' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + reference: Optional[str] = None, + disabled: Optional[bool] = None, + description: Optional[str] = None, + information: Optional[str] = None, + custom_collection=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.reference = reference + self.disabled = disabled + self.description = description + self.information = information + + from .custom_collections import CustomCollection + self.custom_collection = (custom_collection if isinstance(custom_collection, CustomCollection) + else CustomCollection.from_data(connection_object, custom_collection) + if isinstance(custom_collection, dict) else custom_collection) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return (f"CustomCollectionElement(id={self.id}, name={self.name}, " + f"reference={self.reference}, disabled={self.disabled})") + + def ref_str(self) -> str: + return f"CustomCollectionElement(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_custom_collection_elements(cls, connection_object: XurrentApiHelper, + predefinedFilter: CustomCollectionElementPredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return CustomCollectionElement.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def enable(self) -> T: + return self.update({'disabled': False}) + + def disable(self) -> T: + return self.update({'disabled': True}) diff --git a/src/xurrent/custom_collections.py b/src/xurrent/custom_collections.py new file mode 100644 index 0000000..b0b79c8 --- /dev/null +++ b/src/xurrent/custom_collections.py @@ -0,0 +1,92 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='CustomCollection') + + +class CustomCollectionPredefinedFilter(str, Enum): + disabled = "disabled" + enabled = "enabled" + + def __str__(self): + return self.value + + +class CustomCollection(JsonSerializableDict): + # https://developer.xurrent.com/v1/custom_collections/ + __resourceUrl__ = 'custom_collections' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + reference: Optional[str] = None, + disabled: Optional[bool] = None, + description: Optional[str] = None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.reference = reference + self.disabled = disabled + self.description = description + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return f"CustomCollection(id={self.id}, name={self.name}, reference={self.reference}, disabled={self.disabled})" + + def ref_str(self) -> str: + return f"CustomCollection(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_custom_collections(cls, connection_object: XurrentApiHelper, + predefinedFilter: CustomCollectionPredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return CustomCollection.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def enable(self) -> T: + return self.update({'disabled': False}) + + def disable(self) -> T: + return self.update({'disabled': True}) + + def get_elements(self, queryfilter: dict = None) -> List: + from .custom_collection_elements import CustomCollectionElement + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/collection_elements' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [CustomCollectionElement.from_data(self._connection_object, item) for item in response] diff --git a/src/xurrent/effort_classes.py b/src/xurrent/effort_classes.py new file mode 100644 index 0000000..bc75a2c --- /dev/null +++ b/src/xurrent/effort_classes.py @@ -0,0 +1,84 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='EffortClass') + + +class EffortClassPredefinedFilter(str, Enum): + enabled = "enabled" + disabled = "disabled" + + def __str__(self): + return self.value + + +class EffortClass(JsonSerializableDict): + # https://developer.xurrent.com/v1/effort_classes/ + __resourceUrl__ = 'effort_classes' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + cost_multiplier: Optional[float] = None, + position: Optional[int] = None, + disabled: Optional[bool] = None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.cost_multiplier = cost_multiplier + self.position = position + self.disabled = disabled + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return f"EffortClass(id={self.id}, name={self.name}, disabled={self.disabled})" + + def ref_str(self) -> str: + return f"EffortClass(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_effort_classes(cls, connection_object: XurrentApiHelper, + predefinedFilter: EffortClassPredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return EffortClass.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def enable(self) -> T: + return self.update({'disabled': False}) + + def disable(self) -> T: + return self.update({'disabled': True}) diff --git a/src/xurrent/holidays.py b/src/xurrent/holidays.py new file mode 100644 index 0000000..1d32e50 --- /dev/null +++ b/src/xurrent/holidays.py @@ -0,0 +1,66 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict + +T = TypeVar('T', bound='Holiday') + + +class Holiday(JsonSerializableDict): + # https://developer.xurrent.com/v1/holidays/ + __resourceUrl__ = 'holidays' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + start_at=None, + end_at=None, + picture_uri: Optional[str] = None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.start_at = start_at + self.end_at = end_at + self.picture_uri = picture_uri + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return f"Holiday(id={self.id}, name={self.name}, start_at={self.start_at}, end_at={self.end_at})" + + def ref_str(self) -> str: + return f"Holiday(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_holidays(cls, connection_object: XurrentApiHelper, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return Holiday.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) diff --git a/src/xurrent/organizations.py b/src/xurrent/organizations.py new file mode 100644 index 0000000..17ddea4 --- /dev/null +++ b/src/xurrent/organizations.py @@ -0,0 +1,139 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='Organization') + + +class OrganizationPredefinedFilter(str, Enum): + disabled = "disabled" + enabled = "enabled" + external = "external" + internal = "internal" + trusted = "trusted" + directory = "directory" + support_domain = "support_domain" + managed_by_me = "managed_by_me" + + def __str__(self): + return self.value + + +class Organization(JsonSerializableDict): + # https://developer.xurrent.com/v1/organizations/ + __resourceUrl__ = 'organizations' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + disabled: Optional[bool] = None, + business_unit: Optional[bool] = None, + end_user_privacy: Optional[bool] = None, + parent=None, + business_unit_organization=None, + manager=None, + substitute=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.disabled = disabled + self.business_unit = business_unit + self.end_user_privacy = end_user_privacy + + self.parent = (parent if isinstance(parent, Organization) + else Organization.from_data(connection_object, parent) if parent else None) + self.business_unit_organization = ( + business_unit_organization if isinstance(business_unit_organization, Organization) + else Organization.from_data(connection_object, business_unit_organization) + if business_unit_organization else None) + + from .people import Person + self.manager = (manager if isinstance(manager, Person) + else Person.from_data(connection_object, manager) if manager else None) + self.substitute = (substitute if isinstance(substitute, Person) + else Person.from_data(connection_object, substitute) if substitute else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return (f"Organization(id={self.id}, name={self.name}, disabled={self.disabled}, " + f"manager={self.manager.ref_str() if self.manager else None})") + + def ref_str(self) -> str: + return f"Organization(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_organizations(cls, connection_object: XurrentApiHelper, + predefinedFilter: OrganizationPredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return Organization.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def enable(self) -> T: + return self.update({'disabled': False}) + + def disable(self, prefix: str = '', postfix: str = '') -> T: + return self.update({'disabled': True, 'name': f'{prefix}{self.name}{postfix}'}) + + def archive(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/archive' + response = self._connection_object.api_call(uri, 'POST') + return Organization.from_data(self._connection_object, response) + + def trash(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/trash' + response = self._connection_object.api_call(uri, 'POST') + return Organization.from_data(self._connection_object, response) + + def restore(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/restore' + response = self._connection_object.api_call(uri, 'POST') + return Organization.from_data(self._connection_object, response) + + def get_people(self, queryfilter: dict = None) -> List: + from .people import Person + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/people' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [Person.from_data(self._connection_object, p) for p in response] + + def get_children(self, queryfilter: dict = None) -> List[T]: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/child_organizations' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [Organization.from_data(self._connection_object, item) for item in response] diff --git a/src/xurrent/out_of_office_periods.py b/src/xurrent/out_of_office_periods.py new file mode 100644 index 0000000..e0b0a92 --- /dev/null +++ b/src/xurrent/out_of_office_periods.py @@ -0,0 +1,104 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='OutOfOfficePeriod') + + +class OutOfOfficePeriodPredefinedFilter(str, Enum): + open = "open" + completed = "completed" + + def __str__(self): + return self.value + + +class OutOfOfficePeriod(JsonSerializableDict): + # https://developer.xurrent.com/v1/out_of_office_periods/ + __resourceUrl__ = 'out_of_office_periods' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + start_at=None, + end_at=None, + reason: Optional[str] = None, + person=None, + approval_delegate=None, + time_allocation=None, + effort_class=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.start_at = start_at + self.end_at = end_at + self.reason = reason + + from .people import Person + self.person = (person if isinstance(person, Person) + else Person.from_data(connection_object, person) if person else None) + self.approval_delegate = (approval_delegate if isinstance(approval_delegate, Person) + else Person.from_data(connection_object, approval_delegate) + if approval_delegate else None) + + from .time_allocations import TimeAllocation + self.time_allocation = (time_allocation if isinstance(time_allocation, TimeAllocation) + else TimeAllocation.from_data(connection_object, time_allocation) + if time_allocation else None) + + from .effort_classes import EffortClass + self.effort_class = (effort_class if isinstance(effort_class, EffortClass) + else EffortClass.from_data(connection_object, effort_class) + if effort_class else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return (f"OutOfOfficePeriod(id={self.id}, " + f"person={self.person.ref_str() if self.person else None}, " + f"start_at={self.start_at}, end_at={self.end_at})") + + def ref_str(self) -> str: + return f"OutOfOfficePeriod(id={self.id}, start_at={self.start_at}, end_at={self.end_at})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_out_of_office_periods(cls, connection_object: XurrentApiHelper, + predefinedFilter: OutOfOfficePeriodPredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return OutOfOfficePeriod.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def delete(self) -> None: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + self._connection_object.api_call(uri, 'DELETE') diff --git a/src/xurrent/people.py b/src/xurrent/people.py index 8bdff2d..d8e5522 100644 --- a/src/xurrent/people.py +++ b/src/xurrent/people.py @@ -19,11 +19,21 @@ class Person(JsonSerializableDict): #https://developer.xurrent.com/v1/people/ __resourceUrl__ = 'people' - def __init__(self, connection_object: XurrentApiHelper, id, name: str = None, primary_email: str = None,**kwargs): + def __init__(self, connection_object: XurrentApiHelper, id, name: str = None, primary_email: str = None, + site=None, organization=None, **kwargs): self._connection_object = connection_object self.id = id self.name = name self.primary_email = primary_email + + from .sites import Site + self.site = (site if isinstance(site, Site) + else Site.from_data(connection_object, site) if site else None) + + from .organizations import Organization + self.organization = (organization if isinstance(organization, Organization) + else Organization.from_data(connection_object, organization) if organization else None) + for key, value in kwargs.items(): setattr(self, key, value) @@ -82,7 +92,7 @@ def get_teams(self) -> List[Team]: def update(self, data): uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' response = self._connection_object.api_call(uri, 'PATCH', data) - return People.from_data(self._connection_object,response) + return Person.from_data(self._connection_object, response) def disable(self, prefix: str = '', postfix: str = ''): """ diff --git a/src/xurrent/product_categories.py b/src/xurrent/product_categories.py new file mode 100644 index 0000000..364ae0c --- /dev/null +++ b/src/xurrent/product_categories.py @@ -0,0 +1,97 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='ProductCategory') + + +class ProductCategoryRuleSet(str, Enum): + physical_asset = "physical_asset" + logical_asset_with_financial_data = "logical_asset_with_financial_data" + logical_asset_without_financial_data = "logical_asset_without_financial_data" + server = "server" + software = "software" + software_distribution_package = "software_distribution_package" + + def __str__(self): + return self.value + + +class ProductCategory(JsonSerializableDict): + # https://developer.xurrent.com/v1/product_categories/ + __resourceUrl__ = 'product_categories' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + group: Optional[str] = None, + rule_set=None, + reference: Optional[str] = None, + disabled: Optional[bool] = None, + picture_uri: Optional[str] = None, + ui_extension=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.group = group + self.rule_set = ProductCategoryRuleSet(rule_set) if rule_set else None + self.reference = reference + self.disabled = disabled + self.picture_uri = picture_uri + + from .ui_extensions import UiExtension + self.ui_extension = (ui_extension if isinstance(ui_extension, UiExtension) + else UiExtension.from_data(connection_object, ui_extension) + if ui_extension else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return (f"ProductCategory(id={self.id}, name={self.name}, group={self.group}, " + f"rule_set={self.rule_set}, disabled={self.disabled})") + + def ref_str(self) -> str: + return f"ProductCategory(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_product_categories(cls, connection_object: XurrentApiHelper, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return ProductCategory.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def enable(self) -> T: + return self.update({'disabled': False}) + + def disable(self) -> T: + return self.update({'disabled': True}) diff --git a/src/xurrent/products.py b/src/xurrent/products.py new file mode 100644 index 0000000..d1ebcae --- /dev/null +++ b/src/xurrent/products.py @@ -0,0 +1,151 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='Product') + + +class ProductPredefinedFilter(str, Enum): + disabled = "disabled" + enabled = "enabled" + supported_by_my_teams = "supported_by_my_teams" + + def __str__(self): + return self.value + + +class ProductDepreciationMethod(str, Enum): + not_depreciated = "not_depreciated" + double_declining_balance = "double_declining_balance" + reducing_balance = "reducing_balance" + straight_line = "straight_line" + sum_of_the_years_digits = "sum_of_the_years_digits" + + def __str__(self): + return self.value + + +class Product(JsonSerializableDict): + # https://developer.xurrent.com/v1/products/ + __resourceUrl__ = 'products' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + brand: Optional[str] = None, + model: Optional[str] = None, + category=None, + rule_set: Optional[str] = None, + disabled: Optional[bool] = None, + depreciation_method=None, + support_team=None, + supplier=None, + financial_owner=None, + workflow_manager=None, + service=None, + workflow_template=None, + ui_extension=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.brand = brand + self.model = model + self.rule_set = rule_set + + from .product_categories import ProductCategory + self.category = (category if isinstance(category, ProductCategory) + else ProductCategory.from_data(connection_object, category) + if isinstance(category, dict) else category) + self.disabled = disabled + self.depreciation_method = ProductDepreciationMethod(depreciation_method) if depreciation_method else None + + from .teams import Team + self.support_team = (support_team if isinstance(support_team, Team) + else Team.from_data(connection_object, support_team) if support_team else None) + + from .organizations import Organization + self.supplier = (supplier if isinstance(supplier, Organization) + else Organization.from_data(connection_object, supplier) if supplier else None) + self.financial_owner = (financial_owner if isinstance(financial_owner, Organization) + else Organization.from_data(connection_object, financial_owner) + if financial_owner else None) + + from .people import Person + self.workflow_manager = (workflow_manager if isinstance(workflow_manager, Person) + else Person.from_data(connection_object, workflow_manager) + if workflow_manager else None) + + from .services import Service + self.service = (service if isinstance(service, Service) + else Service.from_data(connection_object, service) if service else None) + + from .workflow_templates import WorkflowTemplate + self.workflow_template = (workflow_template if isinstance(workflow_template, WorkflowTemplate) + else WorkflowTemplate.from_data(connection_object, workflow_template) + if workflow_template else None) + + from .ui_extensions import UiExtension + self.ui_extension = (ui_extension if isinstance(ui_extension, UiExtension) + else UiExtension.from_data(connection_object, ui_extension) + if ui_extension else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return (f"Product(id={self.id}, name={self.name}, brand={self.brand}, " + f"model={self.model}, disabled={self.disabled})") + + def ref_str(self) -> str: + return f"Product(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_products(cls, connection_object: XurrentApiHelper, + predefinedFilter: ProductPredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return Product.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def enable(self) -> T: + return self.update({'disabled': False}) + + def disable(self, prefix: str = '', postfix: str = '') -> T: + return self.update({'disabled': True, 'name': f'{prefix}{self.name}{postfix}'}) + + def get_cis(self) -> List: + from .configuration_items import ConfigurationItem + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/cis' + response = self._connection_object.api_call(uri, 'GET') + return [ConfigurationItem.from_data(self._connection_object, item) for item in response] diff --git a/src/xurrent/request_templates.py b/src/xurrent/request_templates.py new file mode 100644 index 0000000..c9bb21d --- /dev/null +++ b/src/xurrent/request_templates.py @@ -0,0 +1,176 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='RequestTemplate') + + +class RequestTemplatePredefinedFilter(str, Enum): + disabled = "disabled" + enabled = "enabled" + + def __str__(self): + return self.value + + +class RequestTemplateCategory(str, Enum): + incident = "incident" + rfc = "rfc" + rfi = "rfi" + reservation = "reservation" + complaint = "complaint" + compliment = "compliment" + other = "other" + + def __str__(self): + return self.value + + +class RequestTemplateStatus(str, Enum): + declined = "declined" + assigned = "assigned" + accepted = "accepted" + in_progress = "in_progress" + waiting_for = "waiting_for" + waiting_for_customer = "waiting_for_customer" + workflow_pending = "workflow_pending" + completed = "completed" + + def __str__(self): + return self.value + + +class RequestTemplateImpact(str, Enum): + low = "low" + medium = "medium" + high = "high" + top = "top" + + def __str__(self): + return self.value + + +class RequestTemplate(JsonSerializableDict): + # https://developer.xurrent.com/v1/request_templates/ + __resourceUrl__ = 'request_templates' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + subject: Optional[str] = None, + disabled: Optional[bool] = None, + category=None, + status=None, + impact=None, + service=None, + member=None, + team=None, + ci=None, + supplier=None, + workflow_template=None, + workflow_manager=None, + support_hours=None, + effort_class=None, + ui_extension=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.subject = subject + self.disabled = disabled + self.category = RequestTemplateCategory(category) if category else None + self.status = RequestTemplateStatus(status) if status else None + self.impact = RequestTemplateImpact(impact) if impact else None + + from .services import Service + self.service = (service if isinstance(service, Service) + else Service.from_data(connection_object, service) if service else None) + + from .people import Person + self.member = (member if isinstance(member, Person) + else Person.from_data(connection_object, member) if member else None) + self.workflow_manager = (workflow_manager if isinstance(workflow_manager, Person) + else Person.from_data(connection_object, workflow_manager) + if workflow_manager else None) + + from .teams import Team + self.team = (team if isinstance(team, Team) + else Team.from_data(connection_object, team) if team else None) + + from .configuration_items import ConfigurationItem + self.ci = (ci if isinstance(ci, ConfigurationItem) + else ConfigurationItem.from_data(connection_object, ci) if ci else None) + + from .organizations import Organization + self.supplier = (supplier if isinstance(supplier, Organization) + else Organization.from_data(connection_object, supplier) if supplier else None) + + from .workflow_templates import WorkflowTemplate + self.workflow_template = (workflow_template if isinstance(workflow_template, WorkflowTemplate) + else WorkflowTemplate.from_data(connection_object, workflow_template) + if workflow_template else None) + + from .calendars import Calendar + self.support_hours = (support_hours if isinstance(support_hours, Calendar) + else Calendar.from_data(connection_object, support_hours) if support_hours else None) + + from .effort_classes import EffortClass + self.effort_class = (effort_class if isinstance(effort_class, EffortClass) + else EffortClass.from_data(connection_object, effort_class) if effort_class else None) + + from .ui_extensions import UiExtension + self.ui_extension = (ui_extension if isinstance(ui_extension, UiExtension) + else UiExtension.from_data(connection_object, ui_extension) + if ui_extension else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return (f"RequestTemplate(id={self.id}, subject={self.subject}, " + f"category={self.category}, disabled={self.disabled})") + + def ref_str(self) -> str: + return f"RequestTemplate(id={self.id}, subject={self.subject})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_request_templates(cls, connection_object: XurrentApiHelper, + predefinedFilter: RequestTemplatePredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return RequestTemplate.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def enable(self) -> T: + return self.update({'disabled': False}) + + def disable(self) -> T: + return self.update({'disabled': True}) diff --git a/src/xurrent/services.py b/src/xurrent/services.py new file mode 100644 index 0000000..6eb5b8a --- /dev/null +++ b/src/xurrent/services.py @@ -0,0 +1,131 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='Service') + + +class ServicePredefinedFilter(str, Enum): + disabled = "disabled" + enabled = "enabled" + + def __str__(self): + return self.value + + +class Service(JsonSerializableDict): + # https://developer.xurrent.com/v1/services/ + __resourceUrl__ = 'services' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + disabled: Optional[bool] = None, + impact: Optional[str] = None, + keywords: Optional[str] = None, + provider=None, + support_team=None, + first_line_team=None, + service_owner=None, + availability_manager=None, + capacity_manager=None, + change_manager=None, + continuity_manager=None, + knowledge_manager=None, + problem_manager=None, + release_manager=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.disabled = disabled + self.impact = impact + self.keywords = keywords + + from .organizations import Organization + self.provider = (provider if isinstance(provider, Organization) + else Organization.from_data(connection_object, provider) if provider else None) + + from .teams import Team + self.support_team = (support_team if isinstance(support_team, Team) + else Team.from_data(connection_object, support_team) if support_team else None) + self.first_line_team = (first_line_team if isinstance(first_line_team, Team) + else Team.from_data(connection_object, first_line_team) if first_line_team else None) + + from .people import Person + self.service_owner = (service_owner if isinstance(service_owner, Person) + else Person.from_data(connection_object, service_owner) if service_owner else None) + self.availability_manager = (availability_manager if isinstance(availability_manager, Person) + else Person.from_data(connection_object, availability_manager) + if availability_manager else None) + self.capacity_manager = (capacity_manager if isinstance(capacity_manager, Person) + else Person.from_data(connection_object, capacity_manager) + if capacity_manager else None) + self.change_manager = (change_manager if isinstance(change_manager, Person) + else Person.from_data(connection_object, change_manager) if change_manager else None) + self.continuity_manager = (continuity_manager if isinstance(continuity_manager, Person) + else Person.from_data(connection_object, continuity_manager) + if continuity_manager else None) + self.knowledge_manager = (knowledge_manager if isinstance(knowledge_manager, Person) + else Person.from_data(connection_object, knowledge_manager) + if knowledge_manager else None) + self.problem_manager = (problem_manager if isinstance(problem_manager, Person) + else Person.from_data(connection_object, problem_manager) + if problem_manager else None) + self.release_manager = (release_manager if isinstance(release_manager, Person) + else Person.from_data(connection_object, release_manager) + if release_manager else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return (f"Service(id={self.id}, name={self.name}, disabled={self.disabled}, " + f"provider={self.provider.ref_str() if self.provider else None})") + + def ref_str(self) -> str: + return f"Service(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_services(cls, connection_object: XurrentApiHelper, + predefinedFilter: ServicePredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return Service.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def enable(self) -> T: + return self.update({'disabled': False}) + + def disable(self, prefix: str = '', postfix: str = '') -> T: + return self.update({'disabled': True, 'name': f'{prefix}{self.name}{postfix}'}) diff --git a/src/xurrent/shop_article_categories.py b/src/xurrent/shop_article_categories.py new file mode 100644 index 0000000..f9d8a49 --- /dev/null +++ b/src/xurrent/shop_article_categories.py @@ -0,0 +1,84 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='ShopArticleCategory') + + +class ShopArticleCategoryPredefinedFilter(str, Enum): + directory = "directory" + support_domain = "support_domain" + + def __str__(self): + return self.value + + +class ShopArticleCategory(JsonSerializableDict): + # https://developer.xurrent.com/v1/shop_article_categories/ + __resourceUrl__ = 'shop_article_categories' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + short_description: Optional[str] = None, + full_description: Optional[str] = None, + picture_uri: Optional[str] = None, + parent=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.short_description = short_description + self.full_description = full_description + self.picture_uri = picture_uri + + self.parent = (parent if isinstance(parent, ShopArticleCategory) + else ShopArticleCategory.from_data(connection_object, parent) + if isinstance(parent, dict) else parent) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return f"ShopArticleCategory(id={self.id}, name={self.name})" + + def ref_str(self) -> str: + return f"ShopArticleCategory(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_shop_article_categories(cls, connection_object: XurrentApiHelper, + predefinedFilter: ShopArticleCategoryPredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return ShopArticleCategory.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) diff --git a/src/xurrent/shop_articles.py b/src/xurrent/shop_articles.py new file mode 100644 index 0000000..c421ba1 --- /dev/null +++ b/src/xurrent/shop_articles.py @@ -0,0 +1,127 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='ShopArticle') + + +class ShopArticlePredefinedFilter(str, Enum): + enabled = "enabled" + disabled = "disabled" + on_offer = "on_offer" + + def __str__(self): + return self.value + + +class ShopArticleRecurringPeriod(str, Enum): + monthly = "monthly" + yearly = "yearly" + + def __str__(self): + return self.value + + +class ShopArticle(JsonSerializableDict): + # https://developer.xurrent.com/v1/shop_articles/ + __resourceUrl__ = 'shop_articles' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + reference: Optional[str] = None, + disabled: Optional[bool] = None, + price=None, + recurring_period=None, + max_quantity: Optional[int] = None, + short_description: Optional[str] = None, + full_description: Optional[str] = None, + picture_uri: Optional[str] = None, + category=None, + product=None, + calendar=None, + fulfillment_template=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.reference = reference + self.disabled = disabled + self.price = price + self.recurring_period = ShopArticleRecurringPeriod(recurring_period) if recurring_period else None + self.max_quantity = max_quantity + self.short_description = short_description + self.full_description = full_description + self.picture_uri = picture_uri + + from .shop_article_categories import ShopArticleCategory + self.category = (category if isinstance(category, ShopArticleCategory) + else ShopArticleCategory.from_data(connection_object, category) + if isinstance(category, dict) else category) + + from .products import Product + self.product = (product if isinstance(product, Product) + else Product.from_data(connection_object, product) if product else None) + + from .calendars import Calendar + self.calendar = (calendar if isinstance(calendar, Calendar) + else Calendar.from_data(connection_object, calendar) if calendar else None) + + from .request_templates import RequestTemplate + self.fulfillment_template = (fulfillment_template if isinstance(fulfillment_template, RequestTemplate) + else RequestTemplate.from_data(connection_object, fulfillment_template) + if fulfillment_template else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return (f"ShopArticle(id={self.id}, name={self.name}, reference={self.reference}, " + f"disabled={self.disabled})") + + def ref_str(self) -> str: + return f"ShopArticle(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_shop_articles(cls, connection_object: XurrentApiHelper, + predefinedFilter: ShopArticlePredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return ShopArticle.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def enable(self) -> T: + return self.update({'disabled': False}) + + def disable(self) -> T: + return self.update({'disabled': True}) diff --git a/src/xurrent/shop_order_lines.py b/src/xurrent/shop_order_lines.py new file mode 100644 index 0000000..cff6d92 --- /dev/null +++ b/src/xurrent/shop_order_lines.py @@ -0,0 +1,142 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='ShopOrderLine') + + +class ShopOrderLinePredefinedFilter(str, Enum): + open = "open" + completed = "completed" + canceled = "canceled" + personal = "personal" + + def __str__(self): + return self.value + + +class ShopOrderLineStatus(str, Enum): + in_cart = "in_cart" + workflow_pending = "workflow_pending" + fulfillment_pending = "fulfillment_pending" + completed = "completed" + canceled = "canceled" + + def __str__(self): + return self.value + + +class ShopOrderLineRecurringPeriod(str, Enum): + monthly = "monthly" + yearly = "yearly" + + def __str__(self): + return self.value + + +class ShopOrderLine(JsonSerializableDict): + # https://developer.xurrent.com/v1/shop_order_lines/ + __resourceUrl__ = 'shop_order_lines' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + quantity=None, + status=None, + ordered_at=None, + completed_at=None, + shop_article=None, + requested_for=None, + requested_by=None, + fulfillment_request=None, + fulfillment_task=None, + fulfillment_template=None, + order=None, + recurring_period=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.quantity = quantity + self.status = ShopOrderLineStatus(status) if status else None + self.ordered_at = ordered_at + self.completed_at = completed_at + self.recurring_period = ShopOrderLineRecurringPeriod(recurring_period) if recurring_period else None + + from .shop_articles import ShopArticle + self.shop_article = (shop_article if isinstance(shop_article, ShopArticle) + else ShopArticle.from_data(connection_object, shop_article) + if shop_article else None) + + from .people import Person + self.requested_for = (requested_for if isinstance(requested_for, Person) + else Person.from_data(connection_object, requested_for) + if requested_for else None) + self.requested_by = (requested_by if isinstance(requested_by, Person) + else Person.from_data(connection_object, requested_by) + if requested_by else None) + + from .requests import Request + self.fulfillment_request = (fulfillment_request if isinstance(fulfillment_request, Request) + else Request.from_data(connection_object, fulfillment_request) + if fulfillment_request else None) + self.order = (order if isinstance(order, Request) + else Request.from_data(connection_object, order) if order else None) + + from .tasks import Task + self.fulfillment_task = (fulfillment_task if isinstance(fulfillment_task, Task) + else Task.from_data(connection_object, fulfillment_task) + if fulfillment_task else None) + + from .request_templates import RequestTemplate + self.fulfillment_template = (fulfillment_template if isinstance(fulfillment_template, RequestTemplate) + else RequestTemplate.from_data(connection_object, fulfillment_template) + if fulfillment_template else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return (f"ShopOrderLine(id={self.id}, name={self.name}, status={self.status}, " + f"quantity={self.quantity})") + + def ref_str(self) -> str: + return f"ShopOrderLine(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_shop_order_lines(cls, connection_object: XurrentApiHelper, + predefinedFilter: ShopOrderLinePredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return ShopOrderLine.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) diff --git a/src/xurrent/sites.py b/src/xurrent/sites.py new file mode 100644 index 0000000..e760257 --- /dev/null +++ b/src/xurrent/sites.py @@ -0,0 +1,103 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='Site') + + +class SitePredefinedFilter(str, Enum): + disabled = "disabled" + enabled = "enabled" + directory = "directory" + support_domain = "support_domain" + + def __str__(self): + return self.value + + +class Site(JsonSerializableDict): + # https://developer.xurrent.com/v1/sites/ + __resourceUrl__ = 'sites' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + city: Optional[str] = None, + country: Optional[str] = None, + time_zone: Optional[str] = None, + disabled: Optional[bool] = None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.city = city + self.country = country + self.time_zone = time_zone + self.disabled = disabled + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return f"Site(id={self.id}, name={self.name}, city={self.city}, country={self.country}, disabled={self.disabled})" + + def ref_str(self) -> str: + return f"Site(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_sites(cls, connection_object: XurrentApiHelper, + predefinedFilter: SitePredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return Site.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def enable(self) -> T: + return self.update({'disabled': False}) + + def disable(self, prefix: str = '', postfix: str = '') -> T: + return self.update({'disabled': True, 'name': f'{prefix}{self.name}{postfix}'}) + + def archive(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/archive' + response = self._connection_object.api_call(uri, 'POST') + return Site.from_data(self._connection_object, response) + + def trash(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/trash' + response = self._connection_object.api_call(uri, 'POST') + return Site.from_data(self._connection_object, response) + + def restore(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/restore' + response = self._connection_object.api_call(uri, 'POST') + return Site.from_data(self._connection_object, response) diff --git a/src/xurrent/time_allocations.py b/src/xurrent/time_allocations.py new file mode 100644 index 0000000..8ddbbc2 --- /dev/null +++ b/src/xurrent/time_allocations.py @@ -0,0 +1,121 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='TimeAllocation') + + +class TimeAllocationPredefinedFilter(str, Enum): + enabled = "enabled" + disabled = "disabled" + + def __str__(self): + return self.value + + +class TimeAllocationCustomerCategory(str, Enum): + none = "none" + selected = "selected" + any = "any" + + def __str__(self): + return self.value + + +class TimeAllocationServiceCategory(str, Enum): + none = "none" + selected = "selected" + any = "any" + + def __str__(self): + return self.value + + +class TimeAllocationDescriptionCategory(str, Enum): + hidden = "hidden" + optional = "optional" + required = "required" + + def __str__(self): + return self.value + + +class TimeAllocation(JsonSerializableDict): + # https://developer.xurrent.com/v1/time_allocations/ + __resourceUrl__ = 'time_allocations' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + group: Optional[str] = None, + disabled: Optional[bool] = None, + customer_category=None, + service_category=None, + description_category=None, + effort_class=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.group = group + self.disabled = disabled + self.customer_category = TimeAllocationCustomerCategory(customer_category) if customer_category else None + self.service_category = TimeAllocationServiceCategory(service_category) if service_category else None + self.description_category = TimeAllocationDescriptionCategory(description_category) if description_category else None + + from .effort_classes import EffortClass + self.effort_class = (effort_class if isinstance(effort_class, EffortClass) + else EffortClass.from_data(connection_object, effort_class) if effort_class else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return f"TimeAllocation(id={self.id}, name={self.name}, disabled={self.disabled})" + + def ref_str(self) -> str: + return f"TimeAllocation(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_time_allocations(cls, connection_object: XurrentApiHelper, + predefinedFilter: TimeAllocationPredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return TimeAllocation.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def enable(self) -> T: + return self.update({'disabled': False}) + + def disable(self) -> T: + return self.update({'disabled': True}) diff --git a/src/xurrent/ui_extensions.py b/src/xurrent/ui_extensions.py new file mode 100644 index 0000000..06d5e66 --- /dev/null +++ b/src/xurrent/ui_extensions.py @@ -0,0 +1,110 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='UiExtension') + + +class UiExtensionCategory(str, Enum): + request_template = "request_template" + knowledge_article_template = "knowledge_article_template" + problem = "problem" + release = "release" + workflow_template = "workflow_template" + task_template = "task_template" + project = "project" + project_task_template = "project_task_template" + service = "service" + service_instance = "service_instance" + product = "product" + product_category = "product_category" + contract = "contract" + organization = "organization" + team = "team" + person = "person" + site = "site" + risk = "risk" + custom_collection = "custom_collection" + scim_user = "scim_user" + app_offering = "app_offering" + shop_article = "shop_article" + + def __str__(self): + return self.value + + +class UiExtension(JsonSerializableDict): + # https://developer.xurrent.com/v1/ui_extensions/ + __resourceUrl__ = 'ui_extensions' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + category=None, + disabled: Optional[bool] = None, + title: Optional[str] = None, + created_by=None, + updated_by=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.category = UiExtensionCategory(category) if category else None + self.disabled = disabled + self.title = title + + from .people import Person + self.created_by = (created_by if isinstance(created_by, Person) + else Person.from_data(connection_object, created_by) if created_by else None) + self.updated_by = (updated_by if isinstance(updated_by, Person) + else Person.from_data(connection_object, updated_by) if updated_by else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return f"UiExtension(id={self.id}, name={self.name}, category={self.category}, disabled={self.disabled})" + + def ref_str(self) -> str: + return f"UiExtension(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_ui_extensions(cls, connection_object: XurrentApiHelper, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return UiExtension.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def enable(self) -> T: + return self.update({'disabled': False}) + + def disable(self) -> T: + return self.update({'disabled': True}) diff --git a/src/xurrent/workflow_templates.py b/src/xurrent/workflow_templates.py new file mode 100644 index 0000000..343a12f --- /dev/null +++ b/src/xurrent/workflow_templates.py @@ -0,0 +1,111 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='WorkflowTemplate') + + +class WorkflowTemplatePredefinedFilter(str, Enum): + disabled = "disabled" + enabled = "enabled" + + def __str__(self): + return self.value + + +class WorkflowTemplateCategory(str, Enum): + standard = "standard" + non_standard = "non_standard" + emergency = "emergency" + order = "order" + + def __str__(self): + return self.value + + +class WorkflowTemplate(JsonSerializableDict): + # https://developer.xurrent.com/v1/workflow_templates/ + __resourceUrl__ = 'workflow_templates' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + subject: Optional[str] = None, + disabled: Optional[bool] = None, + category=None, + service=None, + workflow_manager=None, + ui_extension=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.subject = subject + self.disabled = disabled + self.category = WorkflowTemplateCategory(category) if category else None + + from .services import Service + self.service = (service if isinstance(service, Service) + else Service.from_data(connection_object, service) if service else None) + + from .people import Person + self.workflow_manager = (workflow_manager if isinstance(workflow_manager, Person) + else Person.from_data(connection_object, workflow_manager) + if workflow_manager else None) + + from .ui_extensions import UiExtension + self.ui_extension = (ui_extension if isinstance(ui_extension, UiExtension) + else UiExtension.from_data(connection_object, ui_extension) + if ui_extension else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return (f"WorkflowTemplate(id={self.id}, subject={self.subject}, " + f"category={self.category}, disabled={self.disabled})") + + def ref_str(self) -> str: + return f"WorkflowTemplate(id={self.id}, subject={self.subject})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_workflow_templates(cls, connection_object: XurrentApiHelper, + predefinedFilter: WorkflowTemplatePredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return WorkflowTemplate.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def enable(self) -> T: + return self.update({'disabled': False}) + + def disable(self) -> T: + return self.update({'disabled': True}) diff --git a/tests/unit_tests/test_custom_collections.py b/tests/unit_tests/test_custom_collections.py new file mode 100644 index 0000000..d24cff3 --- /dev/null +++ b/tests/unit_tests/test_custom_collections.py @@ -0,0 +1,196 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.custom_collections import CustomCollection, CustomCollectionPredefinedFilter +from xurrent.custom_collection_elements import CustomCollectionElement, CustomCollectionElementPredefinedFilter + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +@pytest.fixture +def collection_instance(mock_connection): + return CustomCollection( + connection_object=mock_connection, + id=70, + name="Priority Levels", + reference="priority_levels", + disabled=False, + ) + + +@pytest.fixture +def element_instance(mock_connection): + return CustomCollectionElement( + connection_object=mock_connection, + id=80, + name="high", + reference="high", + disabled=False, + ) + + +# --- CustomCollection Tests --- + +def test_collection_initialization(collection_instance): + assert isinstance(collection_instance, CustomCollection) + assert collection_instance.__resourceUrl__ == "custom_collections" + assert collection_instance.id == 70 + assert collection_instance.name == "Priority Levels" + assert collection_instance.reference == "priority_levels" + + +def test_collection_from_data(mock_connection): + data = {"id": 70, "name": "Priority Levels", "reference": "priority_levels", "disabled": False} + col = CustomCollection.from_data(mock_connection, data) + assert isinstance(col, CustomCollection) + assert col.reference == "priority_levels" + + +def test_get_collection_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 70, "name": "Priority Levels", "reference": "priority_levels"} + result = CustomCollection.get_by_id(mock_connection, 70) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/custom_collections/70", "GET" + ) + assert isinstance(result, CustomCollection) + + +def test_get_custom_collections(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 1, "name": "Col A", "reference": "col_a"}, + {"id": 2, "name": "Col B", "reference": "col_b"}, + ] + results = CustomCollection.get_custom_collections(mock_connection) + assert len(results) == 2 + assert all(isinstance(r, CustomCollection) for r in results) + + +def test_get_collections_with_predefined_filter(mock_connection): + mock_connection.api_call.return_value = [] + CustomCollection.get_custom_collections(mock_connection, predefinedFilter=CustomCollectionPredefinedFilter.enabled) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/custom_collections/enabled", "GET" + ) + + +def test_create_collection(mock_connection): + mock_connection.api_call.return_value = {"id": 71, "name": "New Col", "reference": "new_col"} + result = CustomCollection.create(mock_connection, {"name": "New Col"}) + assert isinstance(result, CustomCollection) + assert result.id == 71 + + +def test_enable_collection(mock_connection, collection_instance): + mock_connection.api_call.return_value = {"id": 70, "name": "Priority Levels", "reference": "priority_levels", "disabled": False} + collection_instance.enable() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/custom_collections/70", "PATCH", {"disabled": False} + ) + + +def test_disable_collection(mock_connection, collection_instance): + mock_connection.api_call.return_value = {"id": 70, "name": "Priority Levels", "reference": "priority_levels", "disabled": True} + collection_instance.disable() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/custom_collections/70", "PATCH", {"disabled": True} + ) + + +def test_get_elements(mock_connection, collection_instance): + mock_connection.api_call.return_value = [ + {"id": 80, "name": "high", "reference": "high"}, + {"id": 81, "name": "low", "reference": "low"}, + ] + results = collection_instance.get_elements() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/custom_collections/70/collection_elements", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, CustomCollectionElement) for r in results) + + +# --- CustomCollectionElement Tests --- + +def test_element_initialization(element_instance): + assert isinstance(element_instance, CustomCollectionElement) + assert element_instance.__resourceUrl__ == "custom_collection_elements" + assert element_instance.id == 80 + assert element_instance.name == "high" + + +def test_element_from_data(mock_connection): + data = { + "id": 80, + "name": "high", + "reference": "high", + "disabled": False, + "custom_collection": {"id": 70, "name": "Priority Levels", "reference": "priority_levels"}, + } + element = CustomCollectionElement.from_data(mock_connection, data) + assert isinstance(element, CustomCollectionElement) + assert isinstance(element.custom_collection, CustomCollection) + + +def test_get_element_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 80, "name": "high", "reference": "high"} + result = CustomCollectionElement.get_by_id(mock_connection, 80) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/custom_collection_elements/80", "GET" + ) + assert isinstance(result, CustomCollectionElement) + + +def test_get_elements_list(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 80, "name": "high", "reference": "high"}, + {"id": 81, "name": "low", "reference": "low"}, + ] + results = CustomCollectionElement.get_custom_collection_elements(mock_connection) + assert len(results) == 2 + + +def test_get_elements_with_predefined_filter(mock_connection): + mock_connection.api_call.return_value = [] + CustomCollectionElement.get_custom_collection_elements( + mock_connection, predefinedFilter=CustomCollectionElementPredefinedFilter.disabled + ) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/custom_collection_elements/disabled", "GET" + ) + + +def test_create_element(mock_connection): + mock_connection.api_call.return_value = {"id": 82, "name": "medium", "reference": "medium"} + result = CustomCollectionElement.create(mock_connection, {"name": "medium"}) + assert isinstance(result, CustomCollectionElement) + assert result.id == 82 + + +def test_enable_element(mock_connection, element_instance): + mock_connection.api_call.return_value = {"id": 80, "name": "high", "reference": "high", "disabled": False} + element_instance.enable() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/custom_collection_elements/80", "PATCH", {"disabled": False} + ) + + +def test_disable_element(mock_connection, element_instance): + mock_connection.api_call.return_value = {"id": 80, "name": "high", "reference": "high", "disabled": True} + element_instance.disable() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/custom_collection_elements/80", "PATCH", {"disabled": True} + ) diff --git a/tests/unit_tests/test_holidays.py b/tests/unit_tests/test_holidays.py new file mode 100644 index 0000000..742b51b --- /dev/null +++ b/tests/unit_tests/test_holidays.py @@ -0,0 +1,93 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.holidays import Holiday + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +@pytest.fixture +def holiday_instance(mock_connection): + return Holiday( + connection_object=mock_connection, + id=60, + name="Christmas", + start_at="2026-12-25T00:00:00Z", + end_at="2026-12-26T00:00:00Z", + ) + + +def test_holiday_initialization(holiday_instance): + assert isinstance(holiday_instance, Holiday) + assert holiday_instance.__resourceUrl__ == "holidays" + assert holiday_instance.id == 60 + assert holiday_instance.name == "Christmas" + + +def test_holiday_from_data(mock_connection): + data = {"id": 60, "name": "Christmas", "start_at": "2026-12-25T00:00:00Z", "end_at": "2026-12-26T00:00:00Z"} + holiday = Holiday.from_data(mock_connection, data) + assert isinstance(holiday, Holiday) + assert holiday.id == 60 + assert holiday.name == "Christmas" + + +def test_get_holiday_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 60, "name": "Christmas", "start_at": "2026-12-25T00:00:00Z", "end_at": "2026-12-26T00:00:00Z"} + result = Holiday.get_by_id(mock_connection, 60) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/holidays/60", "GET" + ) + assert isinstance(result, Holiday) + + +def test_get_holidays(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 1, "name": "New Year", "start_at": "2026-01-01T00:00:00Z", "end_at": "2026-01-02T00:00:00Z"}, + {"id": 2, "name": "Christmas", "start_at": "2026-12-25T00:00:00Z", "end_at": "2026-12-26T00:00:00Z"}, + ] + results = Holiday.get_holidays(mock_connection) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/holidays", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, Holiday) for r in results) + + +def test_get_holidays_with_queryfilter(mock_connection): + mock_connection.api_call.return_value = [] + mock_connection.create_filter_string.return_value = "name=Christmas" + Holiday.get_holidays(mock_connection, queryfilter={"name": "Christmas"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/holidays?name=Christmas", "GET" + ) + + +def test_create_holiday(mock_connection): + mock_connection.api_call.return_value = {"id": 61, "name": "Easter", "start_at": "2026-04-05T00:00:00Z", "end_at": "2026-04-06T00:00:00Z"} + result = Holiday.create(mock_connection, {"name": "Easter", "start_at": "2026-04-05T00:00:00Z", "end_at": "2026-04-06T00:00:00Z"}) + assert isinstance(result, Holiday) + assert result.id == 61 + + +def test_update_holiday(mock_connection, holiday_instance): + mock_connection.api_call.return_value = {"id": 60, "name": "Christmas Day", "start_at": "2026-12-25T00:00:00Z", "end_at": "2026-12-26T00:00:00Z"} + result = holiday_instance.update({"name": "Christmas Day"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/holidays/60", "PATCH", {"name": "Christmas Day"} + ) + assert isinstance(result, Holiday) diff --git a/tests/unit_tests/test_organizations.py b/tests/unit_tests/test_organizations.py new file mode 100644 index 0000000..c589d52 --- /dev/null +++ b/tests/unit_tests/test_organizations.py @@ -0,0 +1,174 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.organizations import Organization, OrganizationPredefinedFilter + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +@pytest.fixture +def org_instance(mock_connection): + return Organization( + connection_object=mock_connection, + id=20, + name="Acme Corp", + disabled=False, + business_unit=True, + manager=Person(connection_object=mock_connection, id=5, name="Manager"), + ) + + +def test_organization_initialization(org_instance): + assert isinstance(org_instance, Organization) + assert org_instance.__resourceUrl__ == "organizations" + assert org_instance.id == 20 + assert org_instance.name == "Acme Corp" + assert org_instance.business_unit is True + assert isinstance(org_instance.manager, Person) + assert org_instance.manager.name == "Manager" + + +def test_organization_from_data(mock_connection): + data = { + "id": 20, + "name": "Acme Corp", + "disabled": False, + "manager": {"id": 5, "name": "Manager"}, + "substitute": {"id": 6, "name": "Sub"}, + "parent": {"id": 10, "name": "Parent Corp"}, + } + org = Organization.from_data(mock_connection, data) + assert isinstance(org, Organization) + assert org.id == 20 + assert isinstance(org.manager, Person) + assert isinstance(org.substitute, Person) + assert isinstance(org.parent, Organization) + assert org.parent.name == "Parent Corp" + + +def test_get_organization_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 20, "name": "Acme Corp"} + result = Organization.get_by_id(mock_connection, 20) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/organizations/20", "GET" + ) + assert isinstance(result, Organization) + assert result.id == 20 + + +def test_get_organizations(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 1, "name": "Org A"}, + {"id": 2, "name": "Org B"}, + ] + results = Organization.get_organizations(mock_connection) + assert len(results) == 2 + assert all(isinstance(r, Organization) for r in results) + + +def test_get_organizations_with_predefined_filter(mock_connection): + mock_connection.api_call.return_value = [] + Organization.get_organizations(mock_connection, predefinedFilter=OrganizationPredefinedFilter.internal) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/organizations/internal", "GET" + ) + + +def test_create_organization(mock_connection): + mock_connection.api_call.return_value = {"id": 21, "name": "New Org"} + result = Organization.create(mock_connection, {"name": "New Org"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/organizations", "POST", {"name": "New Org"} + ) + assert isinstance(result, Organization) + assert result.id == 21 + + +def test_update_organization(mock_connection, org_instance): + mock_connection.api_call.return_value = {"id": 20, "name": "Updated Org"} + result = org_instance.update({"name": "Updated Org"}) + assert isinstance(result, Organization) + assert result.name == "Updated Org" + + +def test_enable_organization(mock_connection, org_instance): + mock_connection.api_call.return_value = {"id": 20, "name": "Acme Corp", "disabled": False} + org_instance.enable() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/organizations/20", "PATCH", {"disabled": False} + ) + + +def test_disable_organization(mock_connection, org_instance): + mock_connection.api_call.return_value = {"id": 20, "name": "[OLD] Acme Corp", "disabled": True} + org_instance.disable(prefix="[OLD] ") + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/organizations/20", "PATCH", + {"disabled": True, "name": "[OLD] Acme Corp"} + ) + + +def test_archive_organization(mock_connection, org_instance): + mock_connection.api_call.return_value = {"id": 20, "name": "Acme Corp"} + result = org_instance.archive() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/organizations/20/archive", "POST" + ) + assert isinstance(result, Organization) + + +def test_trash_organization(mock_connection, org_instance): + mock_connection.api_call.return_value = {"id": 20, "name": "Acme Corp"} + result = org_instance.trash() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/organizations/20/trash", "POST" + ) + assert isinstance(result, Organization) + + +def test_restore_organization(mock_connection, org_instance): + mock_connection.api_call.return_value = {"id": 20, "name": "Acme Corp"} + result = org_instance.restore() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/organizations/20/restore", "POST" + ) + assert isinstance(result, Organization) + + +def test_get_people(mock_connection, org_instance): + mock_connection.api_call.return_value = [ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bob"}, + ] + results = org_instance.get_people() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/organizations/20/people", "GET" + ) + assert len(results) == 2 + assert all(isinstance(p, Person) for p in results) + + +def test_get_children(mock_connection, org_instance): + mock_connection.api_call.return_value = [ + {"id": 30, "name": "Child Org"}, + ] + results = org_instance.get_children() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/organizations/20/child_organizations", "GET" + ) + assert len(results) == 1 + assert isinstance(results[0], Organization) diff --git a/tests/unit_tests/test_out_of_office_periods.py b/tests/unit_tests/test_out_of_office_periods.py new file mode 100644 index 0000000..6fbb1a6 --- /dev/null +++ b/tests/unit_tests/test_out_of_office_periods.py @@ -0,0 +1,115 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock +from datetime import datetime + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.out_of_office_periods import OutOfOfficePeriod, OutOfOfficePeriodPredefinedFilter +from xurrent.time_allocations import TimeAllocation +from xurrent.effort_classes import EffortClass + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +@pytest.fixture +def oof_instance(mock_connection): + return OutOfOfficePeriod( + connection_object=mock_connection, + id=50, + start_at=datetime(2026, 4, 14, 9, 0), + end_at=datetime(2026, 4, 18, 17, 0), + reason="Vacation", + person=Person(connection_object=mock_connection, id=10, name="Alice"), + ) + + +def test_oof_initialization(oof_instance): + assert isinstance(oof_instance, OutOfOfficePeriod) + assert oof_instance.__resourceUrl__ == "out_of_office_periods" + assert oof_instance.id == 50 + assert oof_instance.reason == "Vacation" + assert isinstance(oof_instance.person, Person) + assert oof_instance.person.name == "Alice" + + +def test_oof_from_data(mock_connection): + data = { + "id": 50, + "start_at": "2026-04-14T09:00:00Z", + "end_at": "2026-04-18T17:00:00Z", + "reason": "Vacation", + "person": {"id": 10, "name": "Alice"}, + "approval_delegate": {"id": 11, "name": "Bob"}, + "effort_class": {"id": 3, "name": "Regular"}, + } + oof = OutOfOfficePeriod.from_data(mock_connection, data) + assert isinstance(oof, OutOfOfficePeriod) + assert isinstance(oof.person, Person) + assert isinstance(oof.approval_delegate, Person) + assert isinstance(oof.effort_class, EffortClass) + + +def test_get_oof_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 50, "start_at": "2026-04-14T09:00:00Z", "end_at": "2026-04-18T17:00:00Z"} + result = OutOfOfficePeriod.get_by_id(mock_connection, 50) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/out_of_office_periods/50", "GET" + ) + assert isinstance(result, OutOfOfficePeriod) + + +def test_get_oof_periods(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 1, "start_at": "2026-04-01T00:00:00Z", "end_at": "2026-04-05T00:00:00Z"}, + {"id": 2, "start_at": "2026-05-01T00:00:00Z", "end_at": "2026-05-03T00:00:00Z"}, + ] + results = OutOfOfficePeriod.get_out_of_office_periods(mock_connection) + assert len(results) == 2 + assert all(isinstance(r, OutOfOfficePeriod) for r in results) + + +def test_get_oof_periods_with_predefined_filter(mock_connection): + mock_connection.api_call.return_value = [] + OutOfOfficePeriod.get_out_of_office_periods( + mock_connection, predefinedFilter=OutOfOfficePeriodPredefinedFilter.open + ) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/out_of_office_periods/open", "GET" + ) + + +def test_create_oof(mock_connection): + mock_connection.api_call.return_value = { + "id": 51, + "start_at": "2026-06-01T09:00:00Z", + "end_at": "2026-06-05T17:00:00Z", + } + result = OutOfOfficePeriod.create(mock_connection, {"person_id": 10, "start_at": "2026-06-01T09:00:00Z", "end_at": "2026-06-05T17:00:00Z"}) + assert isinstance(result, OutOfOfficePeriod) + assert result.id == 51 + + +def test_update_oof(mock_connection, oof_instance): + mock_connection.api_call.return_value = {"id": 50, "start_at": "2026-04-14T09:00:00Z", "end_at": "2026-04-19T17:00:00Z", "reason": "Extended"} + result = oof_instance.update({"end_at": "2026-04-19T17:00:00Z"}) + assert isinstance(result, OutOfOfficePeriod) + + +def test_delete_oof(mock_connection, oof_instance): + mock_connection.api_call.return_value = None + oof_instance.delete() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/out_of_office_periods/50", "DELETE" + ) diff --git a/tests/unit_tests/test_products.py b/tests/unit_tests/test_products.py new file mode 100644 index 0000000..cb3204f --- /dev/null +++ b/tests/unit_tests/test_products.py @@ -0,0 +1,151 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.products import Product, ProductPredefinedFilter, ProductDepreciationMethod +from xurrent.organizations import Organization +from xurrent.configuration_items import ConfigurationItem + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +@pytest.fixture +def product_instance(mock_connection): + return Product( + connection_object=mock_connection, + id=10, + name="Test Product", + brand="Acme", + model="X100", + category="server", + disabled=False, + depreciation_method="straight_line", + support_team=Team(connection_object=mock_connection, id=5, name="Support Team"), + ) + + +def test_product_initialization(product_instance): + assert isinstance(product_instance, Product) + assert product_instance.__resourceUrl__ == "products" + assert product_instance.id == 10 + assert product_instance.name == "Test Product" + assert product_instance.brand == "Acme" + assert product_instance.model == "X100" + assert product_instance.disabled is False + assert product_instance.depreciation_method == ProductDepreciationMethod.straight_line + assert isinstance(product_instance.support_team, Team) + + +def test_product_from_data(mock_connection): + data = { + "id": 10, + "name": "Test Product", + "brand": "Acme", + "model": "X100", + "category": "server", + "disabled": False, + "support_team": {"id": 5, "name": "Support Team"}, + "supplier": {"id": 20, "name": "Acme Corp"}, + } + product = Product.from_data(mock_connection, data) + assert isinstance(product, Product) + assert product.id == 10 + assert isinstance(product.support_team, Team) + assert isinstance(product.supplier, Organization) + assert product.supplier.name == "Acme Corp" + + +def test_get_product_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 10, "name": "Test Product", "brand": "Acme", "model": "X100"} + result = Product.get_by_id(mock_connection, 10) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/products/10", "GET" + ) + assert isinstance(result, Product) + assert result.id == 10 + + +def test_get_products(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 1, "name": "Product A", "brand": "BrandA", "model": "M1"}, + {"id": 2, "name": "Product B", "brand": "BrandB", "model": "M2"}, + ] + results = Product.get_products(mock_connection) + assert len(results) == 2 + assert all(isinstance(r, Product) for r in results) + + +def test_get_products_with_predefined_filter(mock_connection): + mock_connection.api_call.return_value = [] + Product.get_products(mock_connection, predefinedFilter=ProductPredefinedFilter.enabled) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/products/enabled", "GET" + ) + + +def test_create_product(mock_connection): + mock_connection.api_call.return_value = {"id": 11, "name": "New Product", "brand": "NewBrand", "model": "NM1"} + result = Product.create(mock_connection, {"name": "New Product", "brand": "NewBrand", "model": "NM1"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/products", "POST", + {"name": "New Product", "brand": "NewBrand", "model": "NM1"} + ) + assert isinstance(result, Product) + assert result.id == 11 + + +def test_update_product(mock_connection, product_instance): + mock_connection.api_call.return_value = {"id": 10, "name": "Updated Product", "brand": "Acme", "model": "X100"} + result = product_instance.update({"name": "Updated Product"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/products/10", "PATCH", {"name": "Updated Product"} + ) + assert isinstance(result, Product) + assert result.name == "Updated Product" + + +def test_enable_product(mock_connection, product_instance): + mock_connection.api_call.return_value = {"id": 10, "name": "Test Product", "brand": "Acme", "model": "X100", "disabled": False} + product_instance.enable() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/products/10", "PATCH", {"disabled": False} + ) + + +def test_disable_product(mock_connection, product_instance): + mock_connection.api_call.return_value = {"id": 10, "name": "[DISABLED] Test Product", "brand": "Acme", "model": "X100", "disabled": True} + product_instance.disable(prefix="[DISABLED] ") + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/products/10", "PATCH", + {"disabled": True, "name": "[DISABLED] Test Product"} + ) + + +def test_get_cis(mock_connection, product_instance): + mock_connection.api_call.return_value = [ + {"id": 100, "label": "CI-001", "name": "Server 1", "status": "active"}, + ] + results = product_instance.get_cis() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/products/10/cis", "GET" + ) + assert len(results) == 1 + assert isinstance(results[0], ConfigurationItem) + + +def test_depreciation_method_enum(): + assert str(ProductDepreciationMethod.straight_line) == "straight_line" + assert str(ProductDepreciationMethod.not_depreciated) == "not_depreciated" diff --git a/tests/unit_tests/test_referenced_types.py b/tests/unit_tests/test_referenced_types.py new file mode 100644 index 0000000..68699bf --- /dev/null +++ b/tests/unit_tests/test_referenced_types.py @@ -0,0 +1,424 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.services import Service, ServicePredefinedFilter +from xurrent.calendars import Calendar, CalendarPredefinedFilter +from xurrent.time_allocations import TimeAllocation, TimeAllocationPredefinedFilter, TimeAllocationCustomerCategory +from xurrent.effort_classes import EffortClass, EffortClassPredefinedFilter +from xurrent.request_templates import RequestTemplate, RequestTemplatePredefinedFilter, RequestTemplateCategory, RequestTemplateImpact +from xurrent.ui_extensions import UiExtension, UiExtensionCategory +from xurrent.workflow_templates import WorkflowTemplate, WorkflowTemplatePredefinedFilter, WorkflowTemplateCategory +from xurrent.organizations import Organization + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +# --- Service Tests --- + +def test_service_initialization(mock_connection): + svc = Service( + connection_object=mock_connection, + id=10, + name="Email Service", + disabled=False, + provider=Organization(connection_object=mock_connection, id=20, name="IT Dept"), + ) + assert isinstance(svc, Service) + assert svc.__resourceUrl__ == "services" + assert svc.id == 10 + assert isinstance(svc.provider, Organization) + + +def test_service_from_data(mock_connection): + data = { + "id": 10, + "name": "Email Service", + "disabled": False, + "provider": {"id": 20, "name": "IT Dept"}, + "support_team": {"id": 5, "name": "IT Support"}, + "service_owner": {"id": 3, "name": "Alice"}, + } + svc = Service.from_data(mock_connection, data) + assert isinstance(svc, Service) + assert isinstance(svc.provider, Organization) + assert isinstance(svc.support_team, Team) + assert isinstance(svc.service_owner, Person) + + +def test_get_service_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 10, "name": "Email Service"} + result = Service.get_by_id(mock_connection, 10) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/services/10", "GET" + ) + assert isinstance(result, Service) + + +def test_get_services(mock_connection): + mock_connection.api_call.return_value = [{"id": 1, "name": "Svc A"}, {"id": 2, "name": "Svc B"}] + results = Service.get_services(mock_connection) + assert len(results) == 2 + assert all(isinstance(r, Service) for r in results) + + +def test_get_services_with_predefined_filter(mock_connection): + mock_connection.api_call.return_value = [] + Service.get_services(mock_connection, predefinedFilter=ServicePredefinedFilter.enabled) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/services/enabled", "GET" + ) + + +def test_create_service(mock_connection): + mock_connection.api_call.return_value = {"id": 11, "name": "New Service"} + result = Service.create(mock_connection, {"name": "New Service", "provider_id": 20}) + assert isinstance(result, Service) + assert result.id == 11 + + +def test_enable_service(mock_connection): + svc = Service(connection_object=mock_connection, id=10, name="Email Service", disabled=True) + mock_connection.api_call.return_value = {"id": 10, "name": "Email Service", "disabled": False} + svc.enable() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/services/10", "PATCH", {"disabled": False} + ) + + +def test_disable_service(mock_connection): + svc = Service(connection_object=mock_connection, id=10, name="Email Service", disabled=False) + mock_connection.api_call.return_value = {"id": 10, "name": "[OLD] Email Service", "disabled": True} + svc.disable(prefix="[OLD] ") + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/services/10", "PATCH", + {"disabled": True, "name": "[OLD] Email Service"} + ) + + +# --- Calendar Tests --- + +def test_calendar_initialization(mock_connection): + cal = Calendar(connection_object=mock_connection, id=20, name="Business Hours", disabled=False) + assert isinstance(cal, Calendar) + assert cal.__resourceUrl__ == "calendars" + assert cal.id == 20 + assert cal.name == "Business Hours" + + +def test_calendar_from_data(mock_connection): + data = {"id": 20, "name": "Business Hours", "disabled": False} + cal = Calendar.from_data(mock_connection, data) + assert isinstance(cal, Calendar) + + +def test_get_calendar_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 20, "name": "Business Hours"} + result = Calendar.get_by_id(mock_connection, 20) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/calendars/20", "GET" + ) + assert isinstance(result, Calendar) + + +def test_get_calendars_with_filter(mock_connection): + mock_connection.api_call.return_value = [] + Calendar.get_calendars(mock_connection, predefinedFilter=CalendarPredefinedFilter.enabled) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/calendars/enabled", "GET" + ) + + +def test_create_calendar(mock_connection): + mock_connection.api_call.return_value = {"id": 21, "name": "After Hours"} + result = Calendar.create(mock_connection, {"name": "After Hours"}) + assert isinstance(result, Calendar) + + +def test_enable_disable_calendar(mock_connection): + cal = Calendar(connection_object=mock_connection, id=20, name="Business Hours", disabled=False) + mock_connection.api_call.return_value = {"id": 20, "name": "Business Hours", "disabled": True} + cal.disable() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/calendars/20", "PATCH", {"disabled": True} + ) + + +# --- TimeAllocation Tests --- + +def test_time_allocation_initialization(mock_connection): + ta = TimeAllocation( + connection_object=mock_connection, + id=30, + name="Internal Work", + customer_category="none", + service_category="any", + description_category="optional", + ) + assert isinstance(ta, TimeAllocation) + assert ta.__resourceUrl__ == "time_allocations" + assert ta.customer_category == TimeAllocationCustomerCategory.none + + +def test_time_allocation_from_data(mock_connection): + data = { + "id": 30, + "name": "Internal Work", + "customer_category": "selected", + "service_category": "none", + "description_category": "required", + "effort_class": {"id": 5, "name": "Regular"}, + } + ta = TimeAllocation.from_data(mock_connection, data) + assert isinstance(ta, TimeAllocation) + assert isinstance(ta.effort_class, EffortClass) + + +def test_get_time_allocations(mock_connection): + mock_connection.api_call.return_value = [{"id": 1, "name": "TA A"}, {"id": 2, "name": "TA B"}] + results = TimeAllocation.get_time_allocations(mock_connection) + assert len(results) == 2 + + +def test_get_time_allocations_with_filter(mock_connection): + mock_connection.api_call.return_value = [] + TimeAllocation.get_time_allocations(mock_connection, predefinedFilter=TimeAllocationPredefinedFilter.disabled) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/time_allocations/disabled", "GET" + ) + + +# --- EffortClass Tests --- + +def test_effort_class_initialization(mock_connection): + ec = EffortClass(connection_object=mock_connection, id=5, name="Regular", cost_multiplier=1.0) + assert isinstance(ec, EffortClass) + assert ec.__resourceUrl__ == "effort_classes" + assert ec.cost_multiplier == 1.0 + + +def test_effort_class_from_data(mock_connection): + data = {"id": 5, "name": "Regular", "cost_multiplier": 1.5, "position": 1, "disabled": False} + ec = EffortClass.from_data(mock_connection, data) + assert isinstance(ec, EffortClass) + assert ec.cost_multiplier == 1.5 + + +def test_get_effort_class_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 5, "name": "Regular"} + result = EffortClass.get_by_id(mock_connection, 5) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/effort_classes/5", "GET" + ) + assert isinstance(result, EffortClass) + + +def test_get_effort_classes_with_filter(mock_connection): + mock_connection.api_call.return_value = [] + EffortClass.get_effort_classes(mock_connection, predefinedFilter=EffortClassPredefinedFilter.enabled) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/effort_classes/enabled", "GET" + ) + + +def test_create_effort_class(mock_connection): + mock_connection.api_call.return_value = {"id": 6, "name": "Overtime"} + result = EffortClass.create(mock_connection, {"name": "Overtime"}) + assert isinstance(result, EffortClass) + + +# --- RequestTemplate Tests --- + +def test_request_template_initialization(mock_connection): + rt = RequestTemplate( + connection_object=mock_connection, + id=40, + subject="New Laptop Request", + category="rfc", + impact="medium", + disabled=False, + ) + assert isinstance(rt, RequestTemplate) + assert rt.__resourceUrl__ == "request_templates" + assert rt.category == RequestTemplateCategory.rfc + assert rt.impact == RequestTemplateImpact.medium + + +def test_request_template_from_data(mock_connection): + data = { + "id": 40, + "subject": "New Laptop Request", + "category": "incident", + "disabled": False, + "service": {"id": 10, "name": "Email Service"}, + "team": {"id": 5, "name": "IT Support"}, + "member": {"id": 3, "name": "Alice"}, + } + rt = RequestTemplate.from_data(mock_connection, data) + assert isinstance(rt, RequestTemplate) + assert isinstance(rt.service, Service) + assert isinstance(rt.team, Team) + assert isinstance(rt.member, Person) + + +def test_get_request_template_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 40, "subject": "New Laptop Request"} + result = RequestTemplate.get_by_id(mock_connection, 40) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/request_templates/40", "GET" + ) + assert isinstance(result, RequestTemplate) + + +def test_get_request_templates_with_filter(mock_connection): + mock_connection.api_call.return_value = [] + RequestTemplate.get_request_templates( + mock_connection, predefinedFilter=RequestTemplatePredefinedFilter.enabled + ) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/request_templates/enabled", "GET" + ) + + +def test_enable_disable_request_template(mock_connection): + rt = RequestTemplate(connection_object=mock_connection, id=40, subject="Req", disabled=True) + mock_connection.api_call.return_value = {"id": 40, "subject": "Req", "disabled": False} + rt.enable() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/request_templates/40", "PATCH", {"disabled": False} + ) + + +# --- UiExtension Tests --- + +def test_ui_extension_initialization(mock_connection): + ue = UiExtension( + connection_object=mock_connection, + id=50, + name="Custom Form", + category="shop_article", + disabled=False, + ) + assert isinstance(ue, UiExtension) + assert ue.__resourceUrl__ == "ui_extensions" + assert ue.category == UiExtensionCategory.shop_article + + +def test_ui_extension_from_data(mock_connection): + data = { + "id": 50, + "name": "Custom Form", + "category": "product", + "disabled": False, + "created_by": {"id": 1, "name": "Admin"}, + } + ue = UiExtension.from_data(mock_connection, data) + assert isinstance(ue, UiExtension) + assert ue.category == UiExtensionCategory.product + assert isinstance(ue.created_by, Person) + + +def test_get_ui_extension_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 50, "name": "Custom Form", "category": "product"} + result = UiExtension.get_by_id(mock_connection, 50) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/ui_extensions/50", "GET" + ) + assert isinstance(result, UiExtension) + + +def test_get_ui_extensions(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 50, "name": "Form A", "category": "product"}, + {"id": 51, "name": "Form B", "category": "service"}, + ] + results = UiExtension.get_ui_extensions(mock_connection) + assert len(results) == 2 + + +def test_enable_disable_ui_extension(mock_connection): + ue = UiExtension(connection_object=mock_connection, id=50, name="Form", category="product", disabled=False) + mock_connection.api_call.return_value = {"id": 50, "name": "Form", "category": "product", "disabled": True} + ue.disable() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/ui_extensions/50", "PATCH", {"disabled": True} + ) + + +# --- WorkflowTemplate Tests --- + +def test_workflow_template_initialization(mock_connection): + wt = WorkflowTemplate( + connection_object=mock_connection, + id=60, + subject="Deploy New Release", + category="standard", + disabled=False, + ) + assert isinstance(wt, WorkflowTemplate) + assert wt.__resourceUrl__ == "workflow_templates" + assert wt.category == WorkflowTemplateCategory.standard + + +def test_workflow_template_from_data(mock_connection): + data = { + "id": 60, + "subject": "Deploy New Release", + "category": "emergency", + "disabled": False, + "service": {"id": 10, "name": "Email Service"}, + "workflow_manager": {"id": 3, "name": "Alice"}, + } + wt = WorkflowTemplate.from_data(mock_connection, data) + assert isinstance(wt, WorkflowTemplate) + assert wt.category == WorkflowTemplateCategory.emergency + assert isinstance(wt.service, Service) + assert isinstance(wt.workflow_manager, Person) + + +def test_get_workflow_template_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 60, "subject": "Deploy New Release", "category": "standard"} + result = WorkflowTemplate.get_by_id(mock_connection, 60) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/workflow_templates/60", "GET" + ) + assert isinstance(result, WorkflowTemplate) + + +def test_get_workflow_templates_with_filter(mock_connection): + mock_connection.api_call.return_value = [] + WorkflowTemplate.get_workflow_templates( + mock_connection, predefinedFilter=WorkflowTemplatePredefinedFilter.disabled + ) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/workflow_templates/disabled", "GET" + ) + + +def test_create_workflow_template(mock_connection): + mock_connection.api_call.return_value = {"id": 61, "subject": "New Template", "category": "standard"} + result = WorkflowTemplate.create(mock_connection, {"subject": "New Template", "category": "standard"}) + assert isinstance(result, WorkflowTemplate) + assert result.id == 61 + + +def test_enable_workflow_template(mock_connection): + wt = WorkflowTemplate(connection_object=mock_connection, id=60, subject="Deploy", category="standard", disabled=True) + mock_connection.api_call.return_value = {"id": 60, "subject": "Deploy", "category": "standard", "disabled": False} + wt.enable() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/workflow_templates/60", "PATCH", {"disabled": False} + ) diff --git a/tests/unit_tests/test_shop.py b/tests/unit_tests/test_shop.py new file mode 100644 index 0000000..2d72396 --- /dev/null +++ b/tests/unit_tests/test_shop.py @@ -0,0 +1,270 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.shop_article_categories import ShopArticleCategory, ShopArticleCategoryPredefinedFilter +from xurrent.shop_articles import ShopArticle, ShopArticlePredefinedFilter, ShopArticleRecurringPeriod +from xurrent.shop_order_lines import ShopOrderLine, ShopOrderLinePredefinedFilter, ShopOrderLineStatus + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +@pytest.fixture +def category_instance(mock_connection): + return ShopArticleCategory( + connection_object=mock_connection, + id=90, + name="Laptops", + short_description="Laptop computers", + ) + + +@pytest.fixture +def article_instance(mock_connection, category_instance): + return ShopArticle( + connection_object=mock_connection, + id=100, + name="MacBook Pro", + reference="MACBOOK-PRO", + disabled=False, + price=2499.00, + recurring_period="monthly", + max_quantity=1, + category=category_instance, + ) + + +@pytest.fixture +def order_line_instance(mock_connection, article_instance): + return ShopOrderLine( + connection_object=mock_connection, + id=200, + name="Order #200", + quantity=1, + status="in_cart", + shop_article=article_instance, + ) + + +# --- ShopArticleCategory Tests --- + +def test_category_initialization(category_instance): + assert isinstance(category_instance, ShopArticleCategory) + assert category_instance.__resourceUrl__ == "shop_article_categories" + assert category_instance.id == 90 + assert category_instance.name == "Laptops" + + +def test_category_from_data(mock_connection): + data = { + "id": 90, + "name": "Laptops", + "parent": {"id": 85, "name": "Hardware"}, + } + cat = ShopArticleCategory.from_data(mock_connection, data) + assert isinstance(cat, ShopArticleCategory) + assert isinstance(cat.parent, ShopArticleCategory) + assert cat.parent.name == "Hardware" + + +def test_get_category_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 90, "name": "Laptops"} + result = ShopArticleCategory.get_by_id(mock_connection, 90) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/shop_article_categories/90", "GET" + ) + assert isinstance(result, ShopArticleCategory) + + +def test_get_categories(mock_connection): + mock_connection.api_call.return_value = [{"id": 1, "name": "Cat A"}, {"id": 2, "name": "Cat B"}] + results = ShopArticleCategory.get_shop_article_categories(mock_connection) + assert len(results) == 2 + assert all(isinstance(r, ShopArticleCategory) for r in results) + + +def test_get_categories_with_predefined_filter(mock_connection): + mock_connection.api_call.return_value = [] + ShopArticleCategory.get_shop_article_categories( + mock_connection, predefinedFilter=ShopArticleCategoryPredefinedFilter.directory + ) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/shop_article_categories/directory", "GET" + ) + + +def test_create_category(mock_connection): + mock_connection.api_call.return_value = {"id": 91, "name": "Monitors"} + result = ShopArticleCategory.create(mock_connection, {"name": "Monitors"}) + assert isinstance(result, ShopArticleCategory) + assert result.id == 91 + + +def test_update_category(mock_connection, category_instance): + mock_connection.api_call.return_value = {"id": 90, "name": "Laptops & Notebooks"} + result = category_instance.update({"name": "Laptops & Notebooks"}) + assert isinstance(result, ShopArticleCategory) + + +# --- ShopArticle Tests --- + +def test_article_initialization(article_instance): + assert isinstance(article_instance, ShopArticle) + assert article_instance.__resourceUrl__ == "shop_articles" + assert article_instance.id == 100 + assert article_instance.name == "MacBook Pro" + assert article_instance.recurring_period == ShopArticleRecurringPeriod.monthly + assert isinstance(article_instance.category, ShopArticleCategory) + + +def test_article_from_data(mock_connection): + data = { + "id": 100, + "name": "MacBook Pro", + "reference": "MACBOOK-PRO", + "disabled": False, + "recurring_period": "yearly", + "category": {"id": 90, "name": "Laptops"}, + } + article = ShopArticle.from_data(mock_connection, data) + assert isinstance(article, ShopArticle) + assert article.recurring_period == ShopArticleRecurringPeriod.yearly + assert isinstance(article.category, ShopArticleCategory) + + +def test_get_article_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 100, "name": "MacBook Pro", "reference": "MACBOOK-PRO"} + result = ShopArticle.get_by_id(mock_connection, 100) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/shop_articles/100", "GET" + ) + assert isinstance(result, ShopArticle) + + +def test_get_articles(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 1, "name": "Item A", "reference": "ITEM-A"}, + {"id": 2, "name": "Item B", "reference": "ITEM-B"}, + ] + results = ShopArticle.get_shop_articles(mock_connection) + assert len(results) == 2 + assert all(isinstance(r, ShopArticle) for r in results) + + +def test_get_articles_with_predefined_filter(mock_connection): + mock_connection.api_call.return_value = [] + ShopArticle.get_shop_articles(mock_connection, predefinedFilter=ShopArticlePredefinedFilter.on_offer) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/shop_articles/on_offer", "GET" + ) + + +def test_create_article(mock_connection): + mock_connection.api_call.return_value = {"id": 101, "name": "New Article", "reference": "NEW-ART"} + result = ShopArticle.create(mock_connection, {"name": "New Article", "reference": "NEW-ART"}) + assert isinstance(result, ShopArticle) + assert result.id == 101 + + +def test_enable_article(mock_connection, article_instance): + mock_connection.api_call.return_value = {"id": 100, "name": "MacBook Pro", "reference": "MACBOOK-PRO", "disabled": False} + article_instance.enable() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/shop_articles/100", "PATCH", {"disabled": False} + ) + + +def test_disable_article(mock_connection, article_instance): + mock_connection.api_call.return_value = {"id": 100, "name": "MacBook Pro", "reference": "MACBOOK-PRO", "disabled": True} + article_instance.disable() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/shop_articles/100", "PATCH", {"disabled": True} + ) + + +# --- ShopOrderLine Tests --- + +def test_order_line_initialization(order_line_instance): + assert isinstance(order_line_instance, ShopOrderLine) + assert order_line_instance.__resourceUrl__ == "shop_order_lines" + assert order_line_instance.id == 200 + assert order_line_instance.status == ShopOrderLineStatus.in_cart + assert order_line_instance.quantity == 1 + assert isinstance(order_line_instance.shop_article, ShopArticle) + + +def test_order_line_from_data(mock_connection): + data = { + "id": 200, + "name": "Order #200", + "quantity": 2, + "status": "fulfillment_pending", + "shop_article": {"id": 100, "name": "MacBook Pro", "reference": "MACBOOK-PRO"}, + "requested_for": {"id": 5, "name": "Alice"}, + } + line = ShopOrderLine.from_data(mock_connection, data) + assert isinstance(line, ShopOrderLine) + assert line.status == ShopOrderLineStatus.fulfillment_pending + assert isinstance(line.shop_article, ShopArticle) + assert isinstance(line.requested_for, Person) + + +def test_get_order_line_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 200, "name": "Order #200", "quantity": 1, "status": "in_cart"} + result = ShopOrderLine.get_by_id(mock_connection, 200) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/shop_order_lines/200", "GET" + ) + assert isinstance(result, ShopOrderLine) + + +def test_get_order_lines(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 1, "name": "Line 1", "quantity": 1, "status": "in_cart"}, + {"id": 2, "name": "Line 2", "quantity": 2, "status": "completed"}, + ] + results = ShopOrderLine.get_shop_order_lines(mock_connection) + assert len(results) == 2 + assert all(isinstance(r, ShopOrderLine) for r in results) + + +def test_get_order_lines_with_predefined_filter(mock_connection): + mock_connection.api_call.return_value = [] + ShopOrderLine.get_shop_order_lines( + mock_connection, predefinedFilter=ShopOrderLinePredefinedFilter.personal + ) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/shop_order_lines/personal", "GET" + ) + + +def test_create_order_line(mock_connection): + mock_connection.api_call.return_value = {"id": 201, "name": "New Order", "quantity": 1, "status": "in_cart"} + result = ShopOrderLine.create(mock_connection, {"shop_article_id": 100, "quantity": 1}) + assert isinstance(result, ShopOrderLine) + assert result.id == 201 + + +def test_update_order_line(mock_connection, order_line_instance): + mock_connection.api_call.return_value = {"id": 200, "name": "Order #200", "quantity": 2, "status": "in_cart"} + result = order_line_instance.update({"quantity": 2}) + assert isinstance(result, ShopOrderLine) + + +def test_order_line_status_enum(): + assert str(ShopOrderLineStatus.in_cart) == "in_cart" + assert str(ShopOrderLineStatus.completed) == "completed" + assert str(ShopOrderLineStatus.canceled) == "canceled" diff --git a/tests/unit_tests/test_sites.py b/tests/unit_tests/test_sites.py new file mode 100644 index 0000000..ffdafd0 --- /dev/null +++ b/tests/unit_tests/test_sites.py @@ -0,0 +1,131 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.sites import Site, SitePredefinedFilter + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +@pytest.fixture +def site_instance(mock_connection): + return Site( + connection_object=mock_connection, + id=30, + name="HQ", + city="Vienna", + country="AT", + time_zone="Europe/Vienna", + disabled=False, + ) + + +def test_site_initialization(site_instance): + assert isinstance(site_instance, Site) + assert site_instance.__resourceUrl__ == "sites" + assert site_instance.id == 30 + assert site_instance.name == "HQ" + assert site_instance.city == "Vienna" + assert site_instance.country == "AT" + assert site_instance.disabled is False + + +def test_site_from_data(mock_connection): + data = {"id": 30, "name": "HQ", "city": "Vienna", "country": "AT", "disabled": False} + site = Site.from_data(mock_connection, data) + assert isinstance(site, Site) + assert site.id == 30 + assert site.city == "Vienna" + + +def test_get_site_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 30, "name": "HQ"} + result = Site.get_by_id(mock_connection, 30) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/sites/30", "GET" + ) + assert isinstance(result, Site) + + +def test_get_sites(mock_connection): + mock_connection.api_call.return_value = [{"id": 1, "name": "Site A"}, {"id": 2, "name": "Site B"}] + results = Site.get_sites(mock_connection) + assert len(results) == 2 + assert all(isinstance(r, Site) for r in results) + + +def test_get_sites_with_predefined_filter(mock_connection): + mock_connection.api_call.return_value = [] + Site.get_sites(mock_connection, predefinedFilter=SitePredefinedFilter.enabled) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/sites/enabled", "GET" + ) + + +def test_create_site(mock_connection): + mock_connection.api_call.return_value = {"id": 31, "name": "New Site"} + result = Site.create(mock_connection, {"name": "New Site"}) + assert isinstance(result, Site) + assert result.id == 31 + + +def test_update_site(mock_connection, site_instance): + mock_connection.api_call.return_value = {"id": 30, "name": "HQ Updated"} + result = site_instance.update({"name": "HQ Updated"}) + assert isinstance(result, Site) + + +def test_enable_site(mock_connection, site_instance): + mock_connection.api_call.return_value = {"id": 30, "name": "HQ", "disabled": False} + site_instance.enable() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/sites/30", "PATCH", {"disabled": False} + ) + + +def test_disable_site(mock_connection, site_instance): + mock_connection.api_call.return_value = {"id": 30, "name": "[OLD] HQ", "disabled": True} + site_instance.disable(prefix="[OLD] ") + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/sites/30", "PATCH", {"disabled": True, "name": "[OLD] HQ"} + ) + + +def test_archive_site(mock_connection, site_instance): + mock_connection.api_call.return_value = {"id": 30, "name": "HQ"} + result = site_instance.archive() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/sites/30/archive", "POST" + ) + assert isinstance(result, Site) + + +def test_trash_site(mock_connection, site_instance): + mock_connection.api_call.return_value = {"id": 30, "name": "HQ"} + result = site_instance.trash() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/sites/30/trash", "POST" + ) + assert isinstance(result, Site) + + +def test_restore_site(mock_connection, site_instance): + mock_connection.api_call.return_value = {"id": 30, "name": "HQ"} + result = site_instance.restore() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/sites/30/restore", "POST" + ) + assert isinstance(result, Site) From 53f02c2a11de9419e51f86f74b74588cf5c9dff9 Mon Sep 17 00:00:00 2001 From: fasteiner <75947402+fasteiner@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:58:04 +0000 Subject: [PATCH 02/18] Update pyproject.toml for release 0.12.0-preview.1 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index aabc1b8..8207139 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "xurrent" -version = "0.11.0" +version = "0.12.0-preview.1" authors = [ { name="Fabian Steiner", email="fabian@stei-ner.net" }, ] @@ -18,7 +18,7 @@ Homepage = "https://github.com/fasteiner/xurrent-python" Issues = "https://github.com/fasteiner/xurrent-python/issues" [tool.poetry] name = "xurrent" -version = "0.11.0" +version = "0.12.0-preview.1" description = "A python module to interact with the Xurrent API." authors = ["Ing. Fabian Franz Steiner BSc. "] readme = "README.md" From f271145567a49b08126c9bcb5bee1acdc6a78216 Mon Sep 17 00:00:00 2001 From: Fabian Franz Steiner <75947402+fasteiner@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:02:21 +0000 Subject: [PATCH 03/18] Update release workflow to use GitVersion v4 and adjust output handling --- .github/workflows/release.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e126314..a6b7ddf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,9 @@ on: - main - Dev +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + permissions: contents: write @@ -23,13 +26,13 @@ jobs: fetch-depth: 0 # Required for GitVersion to work correctly - name: Install GitVersion - uses: GitTools/actions/gitversion/setup@v3 + uses: GitTools/actions/gitversion/setup@v4 with: versionSpec: '5.x' - name: Determine Version id: gitversion - uses: GitTools/actions/gitversion/execute@v3 + uses: GitTools/actions/gitversion/execute@v4 - name: Extract Release Notes from Changelog id: extract-changelog @@ -47,10 +50,10 @@ jobs: if echo "$CHANGED_FILES" | grep -E '\.py$'; then echo "python_changed=true" >> $GITHUB_ENV - echo "::set-output name=python_changed::true" + echo "python_changed=true" >> $GITHUB_OUTPUT else echo "python_changed=false" >> $GITHUB_ENV - echo "::set-output name=python_changed::false" + echo "python_changed=false" >> $GITHUB_OUTPUT fi - name: Set Version Bump Type from GitVersion @@ -100,7 +103,7 @@ jobs: fi echo "effective_version=$EFFECTIVE_VERSION" >> $GITHUB_ENV - echo "::set-output name=effective_version::$EFFECTIVE_VERSION" + echo "effective_version=$EFFECTIVE_VERSION" >> $GITHUB_OUTPUT - name: Update CHANGELOG.md if: github.ref == 'refs/heads/main' From 0759d89d155a254c4101d01edd440fd786e387ef Mon Sep 17 00:00:00 2001 From: Fabian Franz Steiner <75947402+fasteiner@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:03:46 +0000 Subject: [PATCH 04/18] Update GitVersion action to v3 in release workflow --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6b7ddf..fa1307a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: fetch-depth: 0 # Required for GitVersion to work correctly - name: Install GitVersion - uses: GitTools/actions/gitversion/setup@v4 + uses: GitTools/actions/gitversion/setup@v3 with: versionSpec: '5.x' From 5c96bc54687b075cf8ccb8eeb395260eb28b93bc Mon Sep 17 00:00:00 2001 From: Fabian Franz Steiner <75947402+fasteiner@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:05:01 +0000 Subject: [PATCH 05/18] Downgrade GitVersion action to v3 in release workflow --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa1307a..9400791 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: - name: Determine Version id: gitversion - uses: GitTools/actions/gitversion/execute@v4 + uses: GitTools/actions/gitversion/execute@v3 - name: Extract Release Notes from Changelog id: extract-changelog From df2ca79f6f8c38d51c1b7d29cc34253782b9fa76 Mon Sep 17 00:00:00 2001 From: fasteiner <75947402+fasteiner@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:05:29 +0000 Subject: [PATCH 06/18] Update pyproject.toml for release 0.12.1 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8207139..999dc8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "xurrent" -version = "0.12.0-preview.1" +version = "0.12.1" authors = [ { name="Fabian Steiner", email="fabian@stei-ner.net" }, ] @@ -18,7 +18,7 @@ Homepage = "https://github.com/fasteiner/xurrent-python" Issues = "https://github.com/fasteiner/xurrent-python/issues" [tool.poetry] name = "xurrent" -version = "0.12.0-preview.1" +version = "0.12.1" description = "A python module to interact with the Xurrent API." authors = ["Ing. Fabian Franz Steiner BSc. "] readme = "README.md" From e7eee8debecb3a86d950825a9efee72b4487a8c5 Mon Sep 17 00:00:00 2001 From: Fabian Franz Steiner <75947402+fasteiner@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:20:15 +0000 Subject: [PATCH 07/18] updated contributing file --- Contributing.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Contributing.md b/Contributing.md index dc2bdd8..2188ac4 100644 --- a/Contributing.md +++ b/Contributing.md @@ -14,3 +14,6 @@ eval $(poetry env activate) ``` +## ChangeLog + +When you make code changes, please document them in the ChangeLog. You can use the [Keep a Changelog assistant](https://chatgpt.com/g/g-684af39084148191b7c83c89daf1b477-keep-a-changelog-assistant) or Claude Code to help write the entry. From e6b195b6334d2df2a896fcdae45425d612636e72 Mon Sep 17 00:00:00 2001 From: fasteiner <75947402+fasteiner@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:21:00 +0000 Subject: [PATCH 08/18] Update pyproject.toml for release 0.12.2 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 999dc8c..e2f64c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "xurrent" -version = "0.12.1" +version = "0.12.2" authors = [ { name="Fabian Steiner", email="fabian@stei-ner.net" }, ] @@ -18,7 +18,7 @@ Homepage = "https://github.com/fasteiner/xurrent-python" Issues = "https://github.com/fasteiner/xurrent-python/issues" [tool.poetry] name = "xurrent" -version = "0.12.1" +version = "0.12.2" description = "A python module to interact with the Xurrent API." authors = ["Ing. Fabian Franz Steiner BSc. "] readme = "README.md" From cd35bb11f2625b6cae6d341a3ff696eb5f43cf28 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:33:56 +0000 Subject: [PATCH 09/18] Add 10 new domain classes: problems, service_instances, releases, projects, contracts, knowledge_articles, risks, service_offerings, skill_pools, closure_codes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: fasteiner <75947402+fasteiner@users.noreply.github.com> --- CHANGELOG.md | 10 ++ src/xurrent/closure_codes.py | 61 +++++++++++ src/xurrent/contracts.py | 97 ++++++++++++++++++ src/xurrent/knowledge_articles.py | 121 ++++++++++++++++++++++ src/xurrent/problems.py | 160 +++++++++++++++++++++++++++++ src/xurrent/projects.py | 164 ++++++++++++++++++++++++++++++ src/xurrent/releases.py | 141 +++++++++++++++++++++++++ src/xurrent/risks.py | 149 +++++++++++++++++++++++++++ src/xurrent/service_instances.py | 111 ++++++++++++++++++++ src/xurrent/service_offerings.py | 89 ++++++++++++++++ src/xurrent/skill_pools.py | 102 +++++++++++++++++++ 11 files changed, 1205 insertions(+) create mode 100644 src/xurrent/closure_codes.py create mode 100644 src/xurrent/contracts.py create mode 100644 src/xurrent/knowledge_articles.py create mode 100644 src/xurrent/problems.py create mode 100644 src/xurrent/projects.py create mode 100644 src/xurrent/releases.py create mode 100644 src/xurrent/risks.py create mode 100644 src/xurrent/service_instances.py create mode 100644 src/xurrent/service_offerings.py create mode 100644 src/xurrent/skill_pools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a1798c1..eb93e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Problems: added `Problem` class with `ProblemPredefinedFilter`, `ProblemStatus`, and `ProblemImpact` enums; supports CRUD, archive/trash/restore, and sub-resources (requests, workflows, notes). +- ServiceInstances: added `ServiceInstance` class with `ServiceInstancePredefinedFilter` and `ServiceInstanceStatus` enums; supports CRUD and sub-resources (cis, slas, users). +- Releases: added `Release` class with `ReleasePredefinedFilter`, `ReleaseStatus`, and `ReleaseImpact` enums; supports CRUD, archive/trash/restore, and sub-resources (workflows, notes). +- Projects: added `Project` class with `ProjectPredefinedFilter`, `ProjectStatus`, and `ProjectCategory` enums; supports CRUD, archive/trash/restore, and sub-resources (tasks, phases, workflows, risks, notes). +- Contracts: added `Contract` class with `ContractPredefinedFilter` and `ContractStatus` enums; supports CRUD and CI listing. +- KnowledgeArticles: added `KnowledgeArticle` class with `KnowledgeArticlePredefinedFilter` and `KnowledgeArticleStatus` enums; supports CRUD, archive/trash/restore, and sub-resources (requests, service_instances, translations). +- Risks: added `Risk` class with `RiskPredefinedFilter`, `RiskStatus`, and `RiskSeverity` enums; supports CRUD, archive/trash/restore, and sub-resources (organizations, projects, services). +- ServiceOfferings: added `ServiceOffering` class with `ServiceOfferingPredefinedFilter` and `ServiceOfferingStatus` enums; supports CRUD. +- SkillPools: added `SkillPool` class with `SkillPoolPredefinedFilter` enum; supports CRUD, enable/disable, and sub-resources (members, effort_classes). +- ClosureCodes: added `ClosureCode` class; supports CRUD. - Docs: added `CLAUDE.md` with setup instructions, test commands, architecture overview, and changelog requirements for Claude Code. - Products: added `Product` class with `ProductPredefinedFilter` and `ProductDepreciationMethod` enums; supports CRUD, enable/disable, and CI listing. - ProductCategories: added `ProductCategory` class with `ProductCategoryRuleSet` enum; supports CRUD and enable/disable. diff --git a/src/xurrent/closure_codes.py b/src/xurrent/closure_codes.py new file mode 100644 index 0000000..317020c --- /dev/null +++ b/src/xurrent/closure_codes.py @@ -0,0 +1,61 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict + +T = TypeVar('T', bound='ClosureCode') + + +class ClosureCode(JsonSerializableDict): + # https://developer.xurrent.com/v1/closure_codes/ + __resourceUrl__ = 'closure_codes' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return f"ClosureCode(id={self.id}, name={self.name})" + + def ref_str(self) -> str: + return f"ClosureCode(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_closure_codes(cls, connection_object: XurrentApiHelper, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return ClosureCode.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) diff --git a/src/xurrent/contracts.py b/src/xurrent/contracts.py new file mode 100644 index 0000000..b497b2d --- /dev/null +++ b/src/xurrent/contracts.py @@ -0,0 +1,97 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='Contract') + + +class ContractPredefinedFilter(str, Enum): + active = "active" + inactive = "inactive" + + def __str__(self): + return self.value + + +class ContractStatus(str, Enum): + being_created = "being_created" + active = "active" + expired = "expired" + + def __str__(self): + return self.value + + +class Contract(JsonSerializableDict): + # https://developer.xurrent.com/v1/contracts/ + __resourceUrl__ = 'contracts' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + status: Optional[str] = None, + customer=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.status = ContractStatus(status) if isinstance(status, str) else status + + from .organizations import Organization + self.customer = (customer if isinstance(customer, Organization) + else Organization.from_data(connection_object, customer) if customer else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return f"Contract(id={self.id}, name={self.name}, status={self.status})" + + def ref_str(self) -> str: + return f"Contract(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_contracts(cls, connection_object: XurrentApiHelper, + predefinedFilter: ContractPredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return Contract.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def get_cis(self, queryfilter: dict = None) -> List: + from .configuration_items import ConfigurationItem + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/cis' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [ConfigurationItem.from_data(self._connection_object, item) for item in response] diff --git a/src/xurrent/knowledge_articles.py b/src/xurrent/knowledge_articles.py new file mode 100644 index 0000000..b8492aa --- /dev/null +++ b/src/xurrent/knowledge_articles.py @@ -0,0 +1,121 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='KnowledgeArticle') + + +class KnowledgeArticlePredefinedFilter(str, Enum): + active = "active" + archived = "archived" + managed_by_me = "managed_by_me" + + def __str__(self): + return self.value + + +class KnowledgeArticleStatus(str, Enum): + not_validated = "not_validated" + validated = "validated" + + def __str__(self): + return self.value + + +class KnowledgeArticle(JsonSerializableDict): + # https://developer.xurrent.com/v1/knowledge_articles/ + __resourceUrl__ = 'knowledge_articles' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + subject: Optional[str] = None, + status: Optional[str] = None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.subject = subject + self.status = KnowledgeArticleStatus(status) if isinstance(status, str) else status + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return f"KnowledgeArticle(id={self.id}, subject={self.subject}, status={self.status})" + + def ref_str(self) -> str: + return f"KnowledgeArticle(id={self.id}, subject={self.subject})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_knowledge_articles(cls, connection_object: XurrentApiHelper, + predefinedFilter: KnowledgeArticlePredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return KnowledgeArticle.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def archive(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/archive' + response = self._connection_object.api_call(uri, 'POST') + return KnowledgeArticle.from_data(self._connection_object, response) + + def trash(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/trash' + response = self._connection_object.api_call(uri, 'POST') + return KnowledgeArticle.from_data(self._connection_object, response) + + def restore(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/restore' + response = self._connection_object.api_call(uri, 'POST') + return KnowledgeArticle.from_data(self._connection_object, response) + + def get_requests(self, queryfilter: dict = None) -> List: + from .requests import Request + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/requests' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [Request.from_data(self._connection_object, item) for item in response] + + def get_service_instances(self, queryfilter: dict = None) -> List: + from .service_instances import ServiceInstance + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/service_instances' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [ServiceInstance.from_data(self._connection_object, item) for item in response] + + def get_translations(self, queryfilter: dict = None) -> List[dict]: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/translations' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + return self._connection_object.api_call(uri, 'GET') diff --git a/src/xurrent/problems.py b/src/xurrent/problems.py new file mode 100644 index 0000000..a14ef6b --- /dev/null +++ b/src/xurrent/problems.py @@ -0,0 +1,160 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='Problem') + + +class ProblemPredefinedFilter(str, Enum): + active = "active" + known_errors = "known_errors" + progress_halted = "progress_halted" + solved = "solved" + managed_by_me = "managed_by_me" + assigned_to_my_teams = "assigned_to_my_teams" + assigned_to_me = "assigned_to_me" + + def __str__(self): + return self.value + + +class ProblemStatus(str, Enum): + registered = "registered" + in_progress = "in_progress" + progress_halted = "progress_halted" + solved = "solved" + + def __str__(self): + return self.value + + +class ProblemImpact(str, Enum): + low = "low" + medium = "medium" + high = "high" + top = "top" + + def __str__(self): + return self.value + + +class Problem(JsonSerializableDict): + # https://developer.xurrent.com/v1/problems/ + __resourceUrl__ = 'problems' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + subject: Optional[str] = None, + status: Optional[str] = None, + impact: Optional[str] = None, + manager=None, + team=None, + member=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.subject = subject + self.status = ProblemStatus(status) if isinstance(status, str) else status + self.impact = ProblemImpact(impact) if isinstance(impact, str) else impact + + from .people import Person + self.manager = (manager if isinstance(manager, Person) + else Person.from_data(connection_object, manager) if manager else None) + self.member = (member if isinstance(member, Person) + else Person.from_data(connection_object, member) if member else None) + + from .teams import Team + self.team = (team if isinstance(team, Team) + else Team.from_data(connection_object, team) if team else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return (f"Problem(id={self.id}, subject={self.subject}, status={self.status}, " + f"impact={self.impact})") + + def ref_str(self) -> str: + return f"Problem(id={self.id}, subject={self.subject})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_problems(cls, connection_object: XurrentApiHelper, + predefinedFilter: ProblemPredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return Problem.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def archive(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/archive' + response = self._connection_object.api_call(uri, 'POST') + return Problem.from_data(self._connection_object, response) + + def trash(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/trash' + response = self._connection_object.api_call(uri, 'POST') + return Problem.from_data(self._connection_object, response) + + def restore(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/restore' + response = self._connection_object.api_call(uri, 'POST') + return Problem.from_data(self._connection_object, response) + + def get_requests(self, queryfilter: dict = None) -> List: + from .requests import Request + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/requests' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [Request.from_data(self._connection_object, item) for item in response] + + def get_workflows(self, queryfilter: dict = None) -> List: + from .workflows import Workflow + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/workflows' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [Workflow.from_data(self._connection_object, item) for item in response] + + def get_notes(self, queryfilter: dict = None) -> List[dict]: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/notes' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + return self._connection_object.api_call(uri, 'GET') + + def add_note(self, note) -> dict: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/notes' + if isinstance(note, dict): + return self._connection_object.api_call(uri, 'POST', note) + elif isinstance(note, str): + return self._connection_object.api_call(uri, 'POST', {'text': note}) diff --git a/src/xurrent/projects.py b/src/xurrent/projects.py new file mode 100644 index 0000000..b062c95 --- /dev/null +++ b/src/xurrent/projects.py @@ -0,0 +1,164 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='Project') + + +class ProjectPredefinedFilter(str, Enum): + completed = "completed" + open = "open" + managed_by_me = "managed_by_me" + + def __str__(self): + return self.value + + +class ProjectStatus(str, Enum): + being_created = "being_created" + registered = "registered" + in_progress = "in_progress" + progress_halted = "progress_halted" + completed = "completed" + + def __str__(self): + return self.value + + +class ProjectCategory(str, Enum): + engineering = "engineering" + implementation = "implementation" + maintenance = "maintenance" + migration = "migration" + move = "move" + other = "other" + release = "release" + + def __str__(self): + return self.value + + +class Project(JsonSerializableDict): + # https://developer.xurrent.com/v1/projects/ + __resourceUrl__ = 'projects' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + subject: Optional[str] = None, + status: Optional[str] = None, + category: Optional[str] = None, + manager=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.subject = subject + self.status = ProjectStatus(status) if isinstance(status, str) else status + self.category = ProjectCategory(category) if isinstance(category, str) else category + + from .people import Person + self.manager = (manager if isinstance(manager, Person) + else Person.from_data(connection_object, manager) if manager else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return (f"Project(id={self.id}, subject={self.subject}, status={self.status}, " + f"category={self.category})") + + def ref_str(self) -> str: + return f"Project(id={self.id}, subject={self.subject})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_projects(cls, connection_object: XurrentApiHelper, + predefinedFilter: ProjectPredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return Project.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def archive(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/archive' + response = self._connection_object.api_call(uri, 'POST') + return Project.from_data(self._connection_object, response) + + def trash(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/trash' + response = self._connection_object.api_call(uri, 'POST') + return Project.from_data(self._connection_object, response) + + def restore(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/restore' + response = self._connection_object.api_call(uri, 'POST') + return Project.from_data(self._connection_object, response) + + def get_tasks(self, queryfilter: dict = None) -> List: + from .tasks import Task + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/tasks' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [Task.from_data(self._connection_object, item) for item in response] + + def get_phases(self, queryfilter: dict = None) -> List[dict]: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/phases' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + return self._connection_object.api_call(uri, 'GET') + + def get_workflows(self, queryfilter: dict = None) -> List: + from .workflows import Workflow + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/workflows' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [Workflow.from_data(self._connection_object, item) for item in response] + + def get_risks(self, queryfilter: dict = None) -> List[dict]: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/risks' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + return self._connection_object.api_call(uri, 'GET') + + def get_notes(self, queryfilter: dict = None) -> List[dict]: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/notes' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + return self._connection_object.api_call(uri, 'GET') + + def add_note(self, note) -> dict: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/notes' + if isinstance(note, dict): + return self._connection_object.api_call(uri, 'POST', note) + elif isinstance(note, str): + return self._connection_object.api_call(uri, 'POST', {'text': note}) diff --git a/src/xurrent/releases.py b/src/xurrent/releases.py new file mode 100644 index 0000000..529a825 --- /dev/null +++ b/src/xurrent/releases.py @@ -0,0 +1,141 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='Release') + + +class ReleasePredefinedFilter(str, Enum): + completed = "completed" + open = "open" + managed_by_me = "managed_by_me" + + def __str__(self): + return self.value + + +class ReleaseStatus(str, Enum): + being_created = "being_created" + registered = "registered" + in_progress = "in_progress" + progress_halted = "progress_halted" + completed = "completed" + + def __str__(self): + return self.value + + +class ReleaseImpact(str, Enum): + low = "low" + medium = "medium" + high = "high" + top = "top" + + def __str__(self): + return self.value + + +class Release(JsonSerializableDict): + # https://developer.xurrent.com/v1/releases/ + __resourceUrl__ = 'releases' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + subject: Optional[str] = None, + status: Optional[str] = None, + impact: Optional[str] = None, + manager=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.subject = subject + self.status = ReleaseStatus(status) if isinstance(status, str) else status + self.impact = ReleaseImpact(impact) if isinstance(impact, str) else impact + + from .people import Person + self.manager = (manager if isinstance(manager, Person) + else Person.from_data(connection_object, manager) if manager else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return (f"Release(id={self.id}, subject={self.subject}, status={self.status}, " + f"impact={self.impact})") + + def ref_str(self) -> str: + return f"Release(id={self.id}, subject={self.subject})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_releases(cls, connection_object: XurrentApiHelper, + predefinedFilter: ReleasePredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return Release.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def archive(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/archive' + response = self._connection_object.api_call(uri, 'POST') + return Release.from_data(self._connection_object, response) + + def trash(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/trash' + response = self._connection_object.api_call(uri, 'POST') + return Release.from_data(self._connection_object, response) + + def restore(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/restore' + response = self._connection_object.api_call(uri, 'POST') + return Release.from_data(self._connection_object, response) + + def get_workflows(self, queryfilter: dict = None) -> List: + from .workflows import Workflow + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/workflows' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [Workflow.from_data(self._connection_object, item) for item in response] + + def get_notes(self, queryfilter: dict = None) -> List[dict]: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/notes' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + return self._connection_object.api_call(uri, 'GET') + + def add_note(self, note) -> dict: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/notes' + if isinstance(note, dict): + return self._connection_object.api_call(uri, 'POST', note) + elif isinstance(note, str): + return self._connection_object.api_call(uri, 'POST', {'text': note}) diff --git a/src/xurrent/risks.py b/src/xurrent/risks.py new file mode 100644 index 0000000..fc37654 --- /dev/null +++ b/src/xurrent/risks.py @@ -0,0 +1,149 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='Risk') + + +class RiskPredefinedFilter(str, Enum): + open = "open" + closed = "closed" + + def __str__(self): + return self.value + + +class RiskStatus(str, Enum): + attrition = "attrition" + availability = "availability" + budget = "budget" + compliance = "compliance" + environmental = "environmental" + legal = "legal" + operational = "operational" + program = "program" + security = "security" + strategic = "strategic" + technology = "technology" + + def __str__(self): + return self.value + + +class RiskSeverity(str, Enum): + low = "low" + medium = "medium" + high = "high" + very_high = "very_high" + + def __str__(self): + return self.value + + +class Risk(JsonSerializableDict): + # https://developer.xurrent.com/v1/risks/ + __resourceUrl__ = 'risks' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + subject: Optional[str] = None, + status: Optional[str] = None, + severity: Optional[str] = None, + manager=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.subject = subject + self.status = RiskStatus(status) if isinstance(status, str) else status + self.severity = RiskSeverity(severity) if isinstance(severity, str) else severity + + from .people import Person + self.manager = (manager if isinstance(manager, Person) + else Person.from_data(connection_object, manager) if manager else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return (f"Risk(id={self.id}, subject={self.subject}, status={self.status}, " + f"severity={self.severity})") + + def ref_str(self) -> str: + return f"Risk(id={self.id}, subject={self.subject})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_risks(cls, connection_object: XurrentApiHelper, + predefinedFilter: RiskPredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return Risk.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def archive(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/archive' + response = self._connection_object.api_call(uri, 'POST') + return Risk.from_data(self._connection_object, response) + + def trash(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/trash' + response = self._connection_object.api_call(uri, 'POST') + return Risk.from_data(self._connection_object, response) + + def restore(self) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/restore' + response = self._connection_object.api_call(uri, 'POST') + return Risk.from_data(self._connection_object, response) + + def get_organizations(self, queryfilter: dict = None) -> List: + from .organizations import Organization + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/organizations' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [Organization.from_data(self._connection_object, item) for item in response] + + def get_projects(self, queryfilter: dict = None) -> List: + from .projects import Project + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/projects' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [Project.from_data(self._connection_object, item) for item in response] + + def get_services(self, queryfilter: dict = None) -> List: + from .services import Service + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/services' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [Service.from_data(self._connection_object, item) for item in response] diff --git a/src/xurrent/service_instances.py b/src/xurrent/service_instances.py new file mode 100644 index 0000000..4e7f318 --- /dev/null +++ b/src/xurrent/service_instances.py @@ -0,0 +1,111 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='ServiceInstance') + + +class ServiceInstancePredefinedFilter(str, Enum): + active = "active" + inactive = "inactive" + + def __str__(self): + return self.value + + +class ServiceInstanceStatus(str, Enum): + being_created = "being_created" + active = "active" + discontinued = "discontinued" + + def __str__(self): + return self.value + + +class ServiceInstance(JsonSerializableDict): + # https://developer.xurrent.com/v1/service_instances/ + __resourceUrl__ = 'service_instances' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + status: Optional[str] = None, + service=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.status = ServiceInstanceStatus(status) if isinstance(status, str) else status + + from .services import Service + self.service = (service if isinstance(service, Service) + else Service.from_data(connection_object, service) if service else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return f"ServiceInstance(id={self.id}, name={self.name}, status={self.status})" + + def ref_str(self) -> str: + return f"ServiceInstance(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_service_instances(cls, connection_object: XurrentApiHelper, + predefinedFilter: ServiceInstancePredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return ServiceInstance.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def get_cis(self, queryfilter: dict = None) -> List: + from .configuration_items import ConfigurationItem + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/cis' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [ConfigurationItem.from_data(self._connection_object, item) for item in response] + + def get_slas(self, queryfilter: dict = None) -> List[dict]: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/slas' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + return self._connection_object.api_call(uri, 'GET') + + def get_users(self, queryfilter: dict = None) -> List: + from .people import Person + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/users' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [Person.from_data(self._connection_object, item) for item in response] diff --git a/src/xurrent/service_offerings.py b/src/xurrent/service_offerings.py new file mode 100644 index 0000000..abe817a --- /dev/null +++ b/src/xurrent/service_offerings.py @@ -0,0 +1,89 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='ServiceOffering') + + +class ServiceOfferingPredefinedFilter(str, Enum): + catalog = "catalog" + portfolio = "portfolio" + + def __str__(self): + return self.value + + +class ServiceOfferingStatus(str, Enum): + not_offered = "not_offered" + available = "available" + temporarily_unavailable = "temporarily_unavailable" + + def __str__(self): + return self.value + + +class ServiceOffering(JsonSerializableDict): + # https://developer.xurrent.com/v1/service_offerings/ + __resourceUrl__ = 'service_offerings' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + status: Optional[str] = None, + service=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.status = ServiceOfferingStatus(status) if isinstance(status, str) else status + + from .services import Service + self.service = (service if isinstance(service, Service) + else Service.from_data(connection_object, service) if service else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return f"ServiceOffering(id={self.id}, name={self.name}, status={self.status})" + + def ref_str(self) -> str: + return f"ServiceOffering(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_service_offerings(cls, connection_object: XurrentApiHelper, + predefinedFilter: ServiceOfferingPredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return ServiceOffering.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) diff --git a/src/xurrent/skill_pools.py b/src/xurrent/skill_pools.py new file mode 100644 index 0000000..4a0f7ce --- /dev/null +++ b/src/xurrent/skill_pools.py @@ -0,0 +1,102 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar +from .core import XurrentApiHelper, JsonSerializableDict +from enum import Enum + +T = TypeVar('T', bound='SkillPool') + + +class SkillPoolPredefinedFilter(str, Enum): + disabled = "disabled" + enabled = "enabled" + + def __str__(self): + return self.value + + +class SkillPool(JsonSerializableDict): + # https://developer.xurrent.com/v1/skill_pools/ + __resourceUrl__ = 'skill_pools' + + def __init__(self, + connection_object: XurrentApiHelper, + id: int, + name: Optional[str] = None, + disabled: Optional[bool] = None, + manager=None, + **kwargs): + self.id = id + self._connection_object = connection_object + self.name = name + self.disabled = disabled + + from .people import Person + self.manager = (manager if isinstance(manager, Person) + else Person.from_data(connection_object, manager) if manager else None) + + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + return f"SkillPool(id={self.id}, name={self.name}, disabled={self.disabled})" + + def ref_str(self) -> str: + return f"SkillPool(id={self.id}, name={self.name})" + + @classmethod + def from_data(cls, connection_object: XurrentApiHelper, data) -> T: + if not isinstance(data, dict): + raise TypeError(f"Expected 'data' to be a dictionary, got {type(data).__name__}") + if 'id' not in data: + raise ValueError("Data dictionary must contain an 'id' field.") + return cls(connection_object, **data) + + @classmethod + def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) + + @classmethod + def get_skill_pools(cls, connection_object: XurrentApiHelper, + predefinedFilter: SkillPoolPredefinedFilter = None, + queryfilter: dict = None) -> List[T]: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + if predefinedFilter: + uri = f'{uri}/{predefinedFilter}' + if queryfilter: + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, item) for item in response] + + def update(self, data: dict) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return SkillPool.from_data(self._connection_object, response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + response = connection_object.api_call(uri, 'POST', data) + return cls.from_data(connection_object, response) + + def enable(self) -> T: + return self.update({'disabled': False}) + + def disable(self) -> T: + return self.update({'disabled': True}) + + def get_members(self, queryfilter: dict = None) -> List: + from .people import Person + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/members' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [Person.from_data(self._connection_object, item) for item in response] + + def get_effort_classes(self, queryfilter: dict = None) -> List: + from .effort_classes import EffortClass + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/effort_classes' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [EffortClass.from_data(self._connection_object, item) for item in response] From 3cc3785e9edebce8b05717614873816030b8ab1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:35:23 +0000 Subject: [PATCH 10/18] Add TypeError for invalid note type in add_note methods Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: fasteiner <75947402+fasteiner@users.noreply.github.com> --- src/xurrent/problems.py | 2 ++ src/xurrent/projects.py | 2 ++ src/xurrent/releases.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/src/xurrent/problems.py b/src/xurrent/problems.py index a14ef6b..6aaa330 100644 --- a/src/xurrent/problems.py +++ b/src/xurrent/problems.py @@ -158,3 +158,5 @@ def add_note(self, note) -> dict: return self._connection_object.api_call(uri, 'POST', note) elif isinstance(note, str): return self._connection_object.api_call(uri, 'POST', {'text': note}) + else: + raise TypeError(f"Expected 'note' to be a str or dict, got {type(note).__name__}") diff --git a/src/xurrent/projects.py b/src/xurrent/projects.py index b062c95..ba24de4 100644 --- a/src/xurrent/projects.py +++ b/src/xurrent/projects.py @@ -162,3 +162,5 @@ def add_note(self, note) -> dict: return self._connection_object.api_call(uri, 'POST', note) elif isinstance(note, str): return self._connection_object.api_call(uri, 'POST', {'text': note}) + else: + raise TypeError(f"Expected 'note' to be a str or dict, got {type(note).__name__}") diff --git a/src/xurrent/releases.py b/src/xurrent/releases.py index 529a825..098fad3 100644 --- a/src/xurrent/releases.py +++ b/src/xurrent/releases.py @@ -139,3 +139,5 @@ def add_note(self, note) -> dict: return self._connection_object.api_call(uri, 'POST', note) elif isinstance(note, str): return self._connection_object.api_call(uri, 'POST', {'text': note}) + else: + raise TypeError(f"Expected 'note' to be a str or dict, got {type(note).__name__}") From bd6bb45f142a98890271435a717d7fa1dfcd9fe3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:40:02 +0000 Subject: [PATCH 11/18] Add sub-resource methods to existing domain classes - Requests: get_attachments, get_knowledge_articles, get_automation_rules, get_satisfaction_feedback, get_tags, get_watches - Tasks: get_notes, add_note, get_approvals, get_cis, get_predecessors, get_successors, get_service_instances, get_automation_rules - Workflows: get_notes, add_note, get_automation_rules, get_phases, get_requests, get_problems - People: get_cis, get_addresses, get_contacts, get_permissions, get_ci_coverages, get_sla_coverages, get_service_coverages, get_out_of_office_periods, get_skill_pools - Organizations: get_addresses, get_contacts, get_contracts, get_risks, get_slas, get_time_allocations - Services: get_workflows, get_request_templates, get_risks, get_service_instances, get_slas, get_service_offerings - Calendars: get_duration, get_hours, get_holidays - Teams: get_service_instances - Holidays: get_calendars Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: fasteiner <75947402+fasteiner@users.noreply.github.com> --- CHANGELOG.md | 9 ++++++ src/xurrent/calendars.py | 28 +++++++++++++++++++ src/xurrent/holidays.py | 7 +++++ src/xurrent/organizations.py | 36 ++++++++++++++++++++++++ src/xurrent/people.py | 51 ++++++++++++++++++++++++++++++++++ src/xurrent/requests.py | 32 ++++++++++++++++++++++ src/xurrent/services.py | 42 ++++++++++++++++++++++++++++ src/xurrent/tasks.py | 53 ++++++++++++++++++++++++++++++++++++ src/xurrent/teams.py | 7 +++++ src/xurrent/workflows.py | 41 ++++++++++++++++++++++++++++ 10 files changed, 306 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb93e14..48dcb89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Risks: added `Risk` class with `RiskPredefinedFilter`, `RiskStatus`, and `RiskSeverity` enums; supports CRUD, archive/trash/restore, and sub-resources (organizations, projects, services). - ServiceOfferings: added `ServiceOffering` class with `ServiceOfferingPredefinedFilter` and `ServiceOfferingStatus` enums; supports CRUD. - SkillPools: added `SkillPool` class with `SkillPoolPredefinedFilter` enum; supports CRUD, enable/disable, and sub-resources (members, effort_classes). +- Requests: added `get_attachments`, `get_knowledge_articles`, `get_automation_rules`, `get_satisfaction_feedback`, `get_tags`, and `get_watches` instance methods. +- Tasks: added `get_notes`, `add_note`, `get_approvals`, `get_cis`, `get_predecessors`, `get_successors`, `get_service_instances`, and `get_automation_rules` instance methods. +- Workflows: added `get_notes`, `add_note`, `get_automation_rules`, `get_phases`, `get_requests`, and `get_problems` instance methods. +- People: added `get_cis`, `get_addresses`, `get_contacts`, `get_permissions`, `get_ci_coverages`, `get_sla_coverages`, `get_service_coverages`, `get_out_of_office_periods`, and `get_skill_pools` instance methods. +- Organizations: added `get_addresses`, `get_contacts`, `get_contracts`, `get_risks`, `get_slas`, and `get_time_allocations` instance methods. +- Services: added `get_workflows`, `get_request_templates`, `get_risks`, `get_service_instances`, `get_slas`, and `get_service_offerings` instance methods. +- Calendars: added `get_duration`, `get_hours`, and `get_holidays` instance methods. +- Teams: added `get_service_instances` instance method. +- Holidays: added `get_calendars` instance method. - ClosureCodes: added `ClosureCode` class; supports CRUD. - Docs: added `CLAUDE.md` with setup instructions, test commands, architecture overview, and changelog requirements for Claude Code. - Products: added `Product` class with `ProductPredefinedFilter` and `ProductDepreciationMethod` enums; supports CRUD, enable/disable, and CI listing. diff --git a/src/xurrent/calendars.py b/src/xurrent/calendars.py index c2f91ed..564c9b7 100644 --- a/src/xurrent/calendars.py +++ b/src/xurrent/calendars.py @@ -78,3 +78,31 @@ def enable(self) -> T: def disable(self) -> T: return self.update({'disabled': True}) + + def get_duration(self, start: str, end: str, time_zone: str = None) -> dict: + """ + Calculate the duration between two timestamps according to the calendar. + + :param start: Start datetime string (ISO 8601) + :param end: End datetime string (ISO 8601) + :param time_zone: Optional time zone name + :return: Duration data from the API + """ + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/duration' + params = f'start={start}&end={end}' + if time_zone: + params += f'&time_zone={time_zone}' + uri += f'?{params}' + return self._connection_object.api_call(uri, 'GET') + + def get_hours(self) -> List[dict]: + """Retrieve the working hours of the calendar.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/hours' + return self._connection_object.api_call(uri, 'GET') + + def get_holidays(self) -> List: + """Retrieve the holidays associated with this calendar.""" + from .holidays import Holiday + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/holidays' + response = self._connection_object.api_call(uri, 'GET') + return [Holiday.from_data(self._connection_object, h) for h in response] diff --git a/src/xurrent/holidays.py b/src/xurrent/holidays.py index 1d32e50..a51324e 100644 --- a/src/xurrent/holidays.py +++ b/src/xurrent/holidays.py @@ -64,3 +64,10 @@ def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' response = connection_object.api_call(uri, 'POST', data) return cls.from_data(connection_object, response) + + def get_calendars(self) -> List: + """Retrieve calendars that contain this holiday.""" + from .calendars import Calendar + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/calendars' + response = self._connection_object.api_call(uri, 'GET') + return [Calendar.from_data(self._connection_object, c) for c in response] diff --git a/src/xurrent/organizations.py b/src/xurrent/organizations.py index 17ddea4..e2bcc40 100644 --- a/src/xurrent/organizations.py +++ b/src/xurrent/organizations.py @@ -137,3 +137,39 @@ def get_children(self, queryfilter: dict = None) -> List[T]: uri += '?' + self._connection_object.create_filter_string(queryfilter) response = self._connection_object.api_call(uri, 'GET') return [Organization.from_data(self._connection_object, item) for item in response] + + def get_addresses(self) -> List[dict]: + """Retrieve addresses for this organization instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/addresses' + return self._connection_object.api_call(uri, 'GET') + + def get_contacts(self) -> List[dict]: + """Retrieve contacts for this organization instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/contacts' + return self._connection_object.api_call(uri, 'GET') + + def get_contracts(self) -> List: + """Retrieve contracts for this organization instance.""" + from .contracts import Contract + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/contracts' + response = self._connection_object.api_call(uri, 'GET') + return [Contract.from_data(self._connection_object, c) for c in response] + + def get_risks(self) -> List: + """Retrieve risks for this organization instance.""" + from .risks import Risk + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/risks' + response = self._connection_object.api_call(uri, 'GET') + return [Risk.from_data(self._connection_object, r) for r in response] + + def get_slas(self) -> List[dict]: + """Retrieve SLAs for this organization instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/slas' + return self._connection_object.api_call(uri, 'GET') + + def get_time_allocations(self) -> List: + """Retrieve time allocations for this organization instance.""" + from .time_allocations import TimeAllocation + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/time_allocations' + response = self._connection_object.api_call(uri, 'GET') + return [TimeAllocation.from_data(self._connection_object, ta) for ta in response] diff --git a/src/xurrent/people.py b/src/xurrent/people.py index d8e5522..a5fe994 100644 --- a/src/xurrent/people.py +++ b/src/xurrent/people.py @@ -147,4 +147,55 @@ def restore(self): uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/restore' return self._connection_object.api_call(uri, 'POST') + def get_cis(self) -> List: + """Retrieve configuration items for this person instance.""" + from .configuration_items import ConfigurationItem + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/cis' + response = self._connection_object.api_call(uri, 'GET') + return [ConfigurationItem.from_data(self._connection_object, ci) for ci in response] + + def get_addresses(self) -> List[dict]: + """Retrieve addresses for this person instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/addresses' + return self._connection_object.api_call(uri, 'GET') + + def get_contacts(self) -> List[dict]: + """Retrieve contact information for this person instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/contacts' + return self._connection_object.api_call(uri, 'GET') + + def get_permissions(self) -> List[dict]: + """Retrieve permissions for this person instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/permissions' + return self._connection_object.api_call(uri, 'GET') + + def get_ci_coverages(self) -> List[dict]: + """Retrieve CI coverages for this person instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/ci_coverages' + return self._connection_object.api_call(uri, 'GET') + + def get_sla_coverages(self) -> List[dict]: + """Retrieve SLA coverages for this person instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/sla_coverages' + return self._connection_object.api_call(uri, 'GET') + + def get_service_coverages(self) -> List[dict]: + """Retrieve service coverages for this person instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/service_coverages' + return self._connection_object.api_call(uri, 'GET') + + def get_out_of_office_periods(self) -> List: + """Retrieve out-of-office periods for this person instance.""" + from .out_of_office_periods import OutOfOfficePeriod + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/out_of_office_periods' + response = self._connection_object.api_call(uri, 'GET') + return [OutOfOfficePeriod.from_data(self._connection_object, p) for p in response] + + def get_skill_pools(self) -> List: + """Retrieve skill pools for this person instance.""" + from .skill_pools import SkillPool + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/skill_pools' + response = self._connection_object.api_call(uri, 'GET') + return [SkillPool.from_data(self._connection_object, sp) for sp in response] + \ No newline at end of file diff --git a/src/xurrent/requests.py b/src/xurrent/requests.py index 7af2115..5e8ca39 100644 --- a/src/xurrent/requests.py +++ b/src/xurrent/requests.py @@ -424,3 +424,35 @@ def remove_ci(self, ci_id: int) -> bool: except Exception as e: return False + def get_attachments(self) -> List[dict]: + """Retrieve all attachments associated with this request instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/attachments' + return self._connection_object.api_call(uri, 'GET') + + def get_knowledge_articles(self) -> List: + """Retrieve all knowledge articles associated with this request instance.""" + from .knowledge_articles import KnowledgeArticle + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/knowledge_articles' + response = self._connection_object.api_call(uri, 'GET') + return [KnowledgeArticle.from_data(self._connection_object, item) for item in response] + + def get_automation_rules(self) -> List[dict]: + """Retrieve all automation rules associated with this request instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/automation_rules' + return self._connection_object.api_call(uri, 'GET') + + def get_satisfaction_feedback(self) -> List[dict]: + """Retrieve satisfaction feedback for this request instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/satisfaction_feedback' + return self._connection_object.api_call(uri, 'GET') + + def get_tags(self) -> List[dict]: + """Retrieve all tags associated with this request instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/tags' + return self._connection_object.api_call(uri, 'GET') + + def get_watches(self) -> List[dict]: + """Retrieve all watches on this request instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/watches' + return self._connection_object.api_call(uri, 'GET') + diff --git a/src/xurrent/services.py b/src/xurrent/services.py index 6eb5b8a..3c04ddb 100644 --- a/src/xurrent/services.py +++ b/src/xurrent/services.py @@ -129,3 +129,45 @@ def enable(self) -> T: def disable(self, prefix: str = '', postfix: str = '') -> T: return self.update({'disabled': True, 'name': f'{prefix}{self.name}{postfix}'}) + + def get_workflows(self, queryfilter: dict = None) -> List: + """Retrieve workflows for this service instance.""" + from .workflows import Workflow + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/workflows' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + response = self._connection_object.api_call(uri, 'GET') + return [Workflow.from_data(self._connection_object, w) for w in response] + + def get_request_templates(self) -> List: + """Retrieve request templates for this service instance.""" + from .request_templates import RequestTemplate + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/request_templates' + response = self._connection_object.api_call(uri, 'GET') + return [RequestTemplate.from_data(self._connection_object, rt) for rt in response] + + def get_risks(self) -> List: + """Retrieve risks for this service instance.""" + from .risks import Risk + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/risks' + response = self._connection_object.api_call(uri, 'GET') + return [Risk.from_data(self._connection_object, r) for r in response] + + def get_service_instances(self) -> List: + """Retrieve service instances for this service.""" + from .service_instances import ServiceInstance + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/service_instances' + response = self._connection_object.api_call(uri, 'GET') + return [ServiceInstance.from_data(self._connection_object, si) for si in response] + + def get_slas(self) -> List[dict]: + """Retrieve SLAs for this service instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/slas' + return self._connection_object.api_call(uri, 'GET') + + def get_service_offerings(self) -> List: + """Retrieve service offerings for this service.""" + from .service_offerings import ServiceOffering + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/service_offerings' + response = self._connection_object.api_call(uri, 'GET') + return [ServiceOffering.from_data(self._connection_object, so) for so in response] diff --git a/src/xurrent/tasks.py b/src/xurrent/tasks.py index 7b388dc..4f1ab5f 100644 --- a/src/xurrent/tasks.py +++ b/src/xurrent/tasks.py @@ -172,3 +172,56 @@ def create(cls, connection_object: XurrentApiHelper, workflowID: int,data: dict) uri = f'{connection_object.base_url}/workflows/{workflowID}/{cls.__resourceUrl__}' response = connection_object.api_call(uri, 'POST', data) return cls.from_data(connection_object, response) + + def get_notes(self, queryfilter: dict = None) -> List[dict]: + """Retrieve all notes associated with the current task instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/notes' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + return self._connection_object.api_call(uri, 'GET') + + def add_note(self, note) -> dict: + """Add a note to the current task instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/notes' + if isinstance(note, dict): + return self._connection_object.api_call(uri, 'POST', note) + elif isinstance(note, str): + return self._connection_object.api_call(uri, 'POST', {'text': note}) + else: + raise TypeError(f"Expected 'note' to be a str or dict, got {type(note).__name__}") + + def get_approvals(self) -> List[dict]: + """Retrieve all approvals for the current task instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/approvals' + return self._connection_object.api_call(uri, 'GET') + + def get_cis(self) -> List: + """Retrieve configuration items associated with the current task instance.""" + from .configuration_items import ConfigurationItem + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/cis' + response = self._connection_object.api_call(uri, 'GET') + return [ConfigurationItem.from_data(self._connection_object, ci) for ci in response] + + def get_predecessors(self) -> List: + """Retrieve predecessor tasks for the current task instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/predecessors' + response = self._connection_object.api_call(uri, 'GET') + return [Task.from_data(self._connection_object, t) for t in response] + + def get_successors(self) -> List: + """Retrieve successor tasks for the current task instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/successors' + response = self._connection_object.api_call(uri, 'GET') + return [Task.from_data(self._connection_object, t) for t in response] + + def get_service_instances(self) -> List: + """Retrieve service instances associated with the current task instance.""" + from .service_instances import ServiceInstance + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/service_instances' + response = self._connection_object.api_call(uri, 'GET') + return [ServiceInstance.from_data(self._connection_object, si) for si in response] + + def get_automation_rules(self) -> List[dict]: + """Retrieve automation rules associated with the current task instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/automation_rules' + return self._connection_object.api_call(uri, 'GET') diff --git a/src/xurrent/teams.py b/src/xurrent/teams.py index e33575f..a33426a 100644 --- a/src/xurrent/teams.py +++ b/src/xurrent/teams.py @@ -113,3 +113,10 @@ def trash(self) -> T: """ uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/trash' return self._connection_object.api_call(uri, 'POST') + + def get_service_instances(self) -> List: + """Retrieve service instances assigned to this team.""" + from .service_instances import ServiceInstance + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/service_instances' + response = self._connection_object.api_call(uri, 'GET') + return [ServiceInstance.from_data(self._connection_object, si) for si in response] diff --git a/src/xurrent/workflows.py b/src/xurrent/workflows.py index d34a13d..bcf0dc9 100644 --- a/src/xurrent/workflows.py +++ b/src/xurrent/workflows.py @@ -224,3 +224,44 @@ def restore(self): response = self._connection_object.api_call(uri, 'POST') return Workflow.from_data(self._connection_object,response) + def get_notes(self, queryfilter: dict = None) -> List[dict]: + """Retrieve all notes associated with the current workflow instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/notes' + if queryfilter: + uri += '?' + self._connection_object.create_filter_string(queryfilter) + return self._connection_object.api_call(uri, 'GET') + + def add_note(self, note) -> dict: + """Add a note to the current workflow instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/notes' + if isinstance(note, dict): + return self._connection_object.api_call(uri, 'POST', note) + elif isinstance(note, str): + return self._connection_object.api_call(uri, 'POST', {'text': note}) + else: + raise TypeError(f"Expected 'note' to be a str or dict, got {type(note).__name__}") + + def get_automation_rules(self) -> List[dict]: + """Retrieve automation rules associated with the current workflow instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/automation_rules' + return self._connection_object.api_call(uri, 'GET') + + def get_phases(self) -> List[dict]: + """Retrieve phases of the current workflow instance.""" + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/phases' + return self._connection_object.api_call(uri, 'GET') + + def get_requests(self) -> List: + """Retrieve requests associated with the current workflow instance.""" + from .requests import Request + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/requests' + response = self._connection_object.api_call(uri, 'GET') + return [Request.from_data(self._connection_object, r) for r in response] + + def get_problems(self) -> List: + """Retrieve problems associated with the current workflow instance.""" + from .problems import Problem + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/problems' + response = self._connection_object.api_call(uri, 'GET') + return [Problem.from_data(self._connection_object, p) for p in response] + From 967c975a074b4b055c266c7c6b7dbd74b0b467be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:42:01 +0000 Subject: [PATCH 12/18] Add utility API methods to XurrentApiHelper and populate __init__.py exports - Add search(), bulk_import(), list_archive(), list_trash(), list_audit_lines() methods to XurrentApiHelper - Populate __init__.py with exports for all domain classes across all modules Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: fasteiner <75947402+fasteiner@users.noreply.github.com> --- src/xurrent/__init__.py | 35 ++++++++++++++++++++++++ src/xurrent/core.py | 59 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/src/xurrent/__init__.py b/src/xurrent/__init__.py index e69de29..69f0f09 100644 --- a/src/xurrent/__init__.py +++ b/src/xurrent/__init__.py @@ -0,0 +1,35 @@ +from .core import XurrentApiHelper, JsonSerializableDict +from .calendars import Calendar, CalendarPredefinedFilter +from .closure_codes import ClosureCode +from .configuration_items import ConfigurationItem, ConfigurationItemPredefinedFilter +from .contracts import Contract, ContractPredefinedFilter, ContractStatus +from .custom_collection_elements import CustomCollectionElement, CustomCollectionElementPredefinedFilter +from .custom_collections import CustomCollection, CustomCollectionPredefinedFilter +from .effort_classes import EffortClass, EffortClassPredefinedFilter +from .holidays import Holiday +from .knowledge_articles import KnowledgeArticle, KnowledgeArticlePredefinedFilter, KnowledgeArticleStatus +from .organizations import Organization, OrganizationPredefinedFilter +from .out_of_office_periods import OutOfOfficePeriod, OutOfOfficePeriodPredefinedFilter +from .people import Person, PeoplePredefinedFilter +from .problems import Problem, ProblemPredefinedFilter, ProblemStatus, ProblemImpact +from .product_categories import ProductCategory, ProductCategoryRuleSet +from .products import Product, ProductPredefinedFilter, ProductDepreciationMethod +from .projects import Project, ProjectPredefinedFilter, ProjectStatus, ProjectCategory +from .releases import Release, ReleasePredefinedFilter, ReleaseStatus, ReleaseImpact +from .request_templates import RequestTemplate, RequestTemplatePredefinedFilter, RequestTemplateCategory, RequestTemplateStatus, RequestTemplateImpact +from .requests import Request, RequestCategory, RequestStatus, CompletionReason, PredefinedFilter, PredefinedNotesFilter +from .risks import Risk, RiskPredefinedFilter, RiskStatus, RiskSeverity +from .service_instances import ServiceInstance, ServiceInstancePredefinedFilter, ServiceInstanceStatus +from .service_offerings import ServiceOffering, ServiceOfferingPredefinedFilter, ServiceOfferingStatus +from .services import Service, ServicePredefinedFilter +from .shop_article_categories import ShopArticleCategory, ShopArticleCategoryPredefinedFilter +from .shop_articles import ShopArticle, ShopArticlePredefinedFilter, ShopArticleRecurringPeriod +from .shop_order_lines import ShopOrderLine, ShopOrderLinePredefinedFilter, ShopOrderLineStatus, ShopOrderLineRecurringPeriod +from .sites import Site, SitePredefinedFilter +from .skill_pools import SkillPool, SkillPoolPredefinedFilter +from .tasks import Task, TaskPredefinedFilter, TaskStatus +from .teams import Team, TeamPredefinedFilter +from .time_allocations import TimeAllocation, TimeAllocationPredefinedFilter, TimeAllocationCustomerCategory, TimeAllocationServiceCategory, TimeAllocationDescriptionCategory +from .ui_extensions import UiExtension, UiExtensionCategory +from .workflow_templates import WorkflowTemplate, WorkflowTemplatePredefinedFilter, WorkflowTemplateCategory +from .workflows import Workflow, WorkflowCompletionReason, WorkflowStatus, WorkflowCategory, WorkflowPredefinedFilter diff --git a/src/xurrent/core.py b/src/xurrent/core.py index 1fcbcc6..eb7a2a7 100644 --- a/src/xurrent/core.py +++ b/src/xurrent/core.py @@ -388,6 +388,65 @@ def bulk_export(self, type: str, export_format='csv', save_as=None, poll_timeout return True return result + def search(self, query: str, types: list = None) -> list: + """ + Perform a cross-resource full-text search. + :param query: Search query string + :param types: Optional list of resource types to search (e.g. ['request', 'person']) + :return: List of search results + """ + uri = f'/search?q={query}' + if types: + uri += '&types=' + ','.join(types) + return self.api_call(uri, 'GET') + + def bulk_import(self, data: str, import_type: str, import_format: str = 'csv') -> dict: + """ + Perform a bulk import of records. + :param data: CSV/TSV data as a string + :param import_type: Resource type to import (e.g. 'people', 'configuration_items') + :param import_format: Format of the import data ('csv' or 'tsv', default: 'csv') + :return: Import result from the API + """ + return self.api_call('/import', method='POST', data={ + 'type': import_type, + 'import_format': import_format, + 'data': data + }) + + def list_archive(self, queryfilter: dict = None) -> list: + """ + List all archived items. + :param queryfilter: Optional query filter parameters + :return: List of archived items + """ + uri = '/archive' + if queryfilter: + uri += '?' + self.create_filter_string(queryfilter) + return self.api_call(uri, 'GET') + + def list_trash(self, queryfilter: dict = None) -> list: + """ + List all trashed items. + :param queryfilter: Optional query filter parameters + :return: List of trashed items + """ + uri = '/trash' + if queryfilter: + uri += '?' + self.create_filter_string(queryfilter) + return self.api_call(uri, 'GET') + + def list_audit_lines(self, queryfilter: dict = None) -> list: + """ + List audit log entries. + :param queryfilter: Optional query filter parameters + :return: List of audit log entries + """ + uri = '/audit_lines' + if queryfilter: + uri += '?' + self.create_filter_string(queryfilter) + return self.api_call(uri, 'GET') + def custom_fields_to_object(self, custom_fields): """ Convert a list of custom fields to a dictionary. From 18efde8cf80bc1dd0056c01aed198e4a54240657 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:48:28 +0000 Subject: [PATCH 13/18] Add comprehensive unit tests for new domain classes and sub-resource methods - test_problems.py: Problem CRUD, notes, requests, workflows - test_service_instances.py: ServiceInstance CRUD, CIs, users - test_releases.py: Release CRUD, archive/trash/restore, workflows, notes - test_projects.py: Project CRUD, archive/trash/restore, tasks/phases/workflows/notes - test_contracts.py: Contract CRUD, CIs - test_knowledge_articles.py: KnowledgeArticle CRUD, archive/trash/restore, requests/service_instances/translations - test_risks.py: Risk CRUD, archive/trash/restore, organizations/projects/services - test_service_offerings.py: ServiceOffering CRUD - test_skill_pools.py: SkillPool CRUD, enable/disable, members/effort_classes - test_closure_codes.py: ClosureCode CRUD - test_new_sub_resources.py: new sub-resource methods on Request, Task, Workflow, Person, Organization, Service, Calendar, Holiday, Team; core search/bulk_import/ list_archive/list_trash/list_audit_lines utilities Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: fasteiner <75947402+fasteiner@users.noreply.github.com> --- tests/unit_tests/test_closure_codes.py | 87 +++ tests/unit_tests/test_contracts.py | 117 ++++ tests/unit_tests/test_knowledge_articles.py | 156 ++++++ tests/unit_tests/test_new_sub_resources.py | 590 ++++++++++++++++++++ tests/unit_tests/test_problems.py | 193 +++++++ tests/unit_tests/test_projects.py | 186 ++++++ tests/unit_tests/test_releases.py | 174 ++++++ tests/unit_tests/test_risks.py | 164 ++++++ tests/unit_tests/test_service_instances.py | 132 +++++ tests/unit_tests/test_service_offerings.py | 104 ++++ tests/unit_tests/test_skill_pools.py | 147 +++++ 11 files changed, 2050 insertions(+) create mode 100644 tests/unit_tests/test_closure_codes.py create mode 100644 tests/unit_tests/test_contracts.py create mode 100644 tests/unit_tests/test_knowledge_articles.py create mode 100644 tests/unit_tests/test_new_sub_resources.py create mode 100644 tests/unit_tests/test_problems.py create mode 100644 tests/unit_tests/test_projects.py create mode 100644 tests/unit_tests/test_releases.py create mode 100644 tests/unit_tests/test_risks.py create mode 100644 tests/unit_tests/test_service_instances.py create mode 100644 tests/unit_tests/test_service_offerings.py create mode 100644 tests/unit_tests/test_skill_pools.py diff --git a/tests/unit_tests/test_closure_codes.py b/tests/unit_tests/test_closure_codes.py new file mode 100644 index 0000000..7f72085 --- /dev/null +++ b/tests/unit_tests/test_closure_codes.py @@ -0,0 +1,87 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.closure_codes import ClosureCode + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +@pytest.fixture +def cc_instance(mock_connection): + return ClosureCode( + connection_object=mock_connection, + id=120, + name="Resolved", + ) + + +def test_closure_code_initialization(cc_instance): + assert isinstance(cc_instance, ClosureCode) + assert cc_instance.__resourceUrl__ == "closure_codes" + assert cc_instance.id == 120 + assert cc_instance.name == "Resolved" + + +def test_closure_code_from_data(mock_connection): + data = {"id": 120, "name": "Resolved"} + cc = ClosureCode.from_data(mock_connection, data) + assert isinstance(cc, ClosureCode) + assert cc.id == 120 + assert cc.name == "Resolved" + + +def test_get_closure_code_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 120, "name": "Resolved"} + result = ClosureCode.get_by_id(mock_connection, 120) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/closure_codes/120", "GET" + ) + assert isinstance(result, ClosureCode) + assert result.id == 120 + + +def test_get_closure_codes(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 1, "name": "Resolved"}, + {"id": 2, "name": "Cancelled"}, + ] + results = ClosureCode.get_closure_codes(mock_connection) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/closure_codes", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, ClosureCode) for r in results) + + +def test_create_closure_code(mock_connection): + mock_connection.api_call.return_value = {"id": 121, "name": "Duplicate"} + result = ClosureCode.create(mock_connection, {"name": "Duplicate"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/closure_codes", "POST", {"name": "Duplicate"} + ) + assert isinstance(result, ClosureCode) + assert result.id == 121 + + +def test_update_closure_code(mock_connection, cc_instance): + mock_connection.api_call.return_value = {"id": 120, "name": "Fixed"} + result = cc_instance.update({"name": "Fixed"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/closure_codes/120", "PATCH", {"name": "Fixed"} + ) + assert isinstance(result, ClosureCode) + assert result.name == "Fixed" diff --git a/tests/unit_tests/test_contracts.py b/tests/unit_tests/test_contracts.py new file mode 100644 index 0000000..b24945a --- /dev/null +++ b/tests/unit_tests/test_contracts.py @@ -0,0 +1,117 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.contracts import Contract, ContractPredefinedFilter + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +@pytest.fixture +def contract_instance(mock_connection): + return Contract( + connection_object=mock_connection, + id=70, + name="Support Contract", + status="active", + ) + + +def test_contract_initialization(contract_instance): + assert isinstance(contract_instance, Contract) + assert contract_instance.__resourceUrl__ == "contracts" + assert contract_instance.id == 70 + assert contract_instance.name == "Support Contract" + + +def test_contract_from_data(mock_connection): + data = { + "id": 70, + "name": "Support Contract", + "status": "active", + "customer": {"id": 10, "name": "Acme Corp"}, + } + contract = Contract.from_data(mock_connection, data) + assert isinstance(contract, Contract) + assert contract.id == 70 + from xurrent.organizations import Organization + assert isinstance(contract.customer, Organization) + assert contract.customer.name == "Acme Corp" + + +def test_get_contract_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 70, "name": "Support Contract"} + result = Contract.get_by_id(mock_connection, 70) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/contracts/70", "GET" + ) + assert isinstance(result, Contract) + assert result.id == 70 + + +def test_get_contracts(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 1, "name": "Contract A"}, + {"id": 2, "name": "Contract B"}, + ] + results = Contract.get_contracts(mock_connection) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/contracts", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, Contract) for r in results) + + +def test_get_contracts_with_filter(mock_connection): + mock_connection.api_call.return_value = [] + Contract.get_contracts(mock_connection, predefinedFilter=ContractPredefinedFilter.active) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/contracts/active", "GET" + ) + + +def test_create_contract(mock_connection): + mock_connection.api_call.return_value = {"id": 71, "name": "New Contract"} + result = Contract.create(mock_connection, {"name": "New Contract"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/contracts", "POST", {"name": "New Contract"} + ) + assert isinstance(result, Contract) + assert result.id == 71 + + +def test_update_contract(mock_connection, contract_instance): + mock_connection.api_call.return_value = {"id": 70, "name": "Updated Contract"} + result = contract_instance.update({"name": "Updated Contract"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/contracts/70", "PATCH", {"name": "Updated Contract"} + ) + assert isinstance(result, Contract) + assert result.name == "Updated Contract" + + +def test_get_cis(mock_connection, contract_instance): + from xurrent.configuration_items import ConfigurationItem + mock_connection.api_call.return_value = [ + {"id": 1, "label": "CI-1"}, + {"id": 2, "label": "CI-2"}, + ] + results = contract_instance.get_cis() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/contracts/70/cis", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, ConfigurationItem) for r in results) diff --git a/tests/unit_tests/test_knowledge_articles.py b/tests/unit_tests/test_knowledge_articles.py new file mode 100644 index 0000000..9d9642d --- /dev/null +++ b/tests/unit_tests/test_knowledge_articles.py @@ -0,0 +1,156 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.knowledge_articles import KnowledgeArticle, KnowledgeArticlePredefinedFilter + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +@pytest.fixture +def ka_instance(mock_connection): + return KnowledgeArticle( + connection_object=mock_connection, + id=80, + subject="How to reset password", + status="validated", + ) + + +def test_knowledge_article_initialization(ka_instance): + assert isinstance(ka_instance, KnowledgeArticle) + assert ka_instance.__resourceUrl__ == "knowledge_articles" + assert ka_instance.id == 80 + assert ka_instance.subject == "How to reset password" + + +def test_knowledge_article_from_data(mock_connection): + data = {"id": 80, "subject": "How to reset password", "status": "validated"} + ka = KnowledgeArticle.from_data(mock_connection, data) + assert isinstance(ka, KnowledgeArticle) + assert ka.id == 80 + + +def test_get_knowledge_article_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 80, "subject": "How to reset password"} + result = KnowledgeArticle.get_by_id(mock_connection, 80) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/knowledge_articles/80", "GET" + ) + assert isinstance(result, KnowledgeArticle) + assert result.id == 80 + + +def test_get_knowledge_articles(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 1, "subject": "KA A"}, + {"id": 2, "subject": "KA B"}, + ] + results = KnowledgeArticle.get_knowledge_articles(mock_connection) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/knowledge_articles", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, KnowledgeArticle) for r in results) + + +def test_get_knowledge_articles_with_filter(mock_connection): + mock_connection.api_call.return_value = [] + KnowledgeArticle.get_knowledge_articles( + mock_connection, predefinedFilter=KnowledgeArticlePredefinedFilter.active + ) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/knowledge_articles/active", "GET" + ) + + +def test_create_knowledge_article(mock_connection): + mock_connection.api_call.return_value = {"id": 81, "subject": "New KA"} + result = KnowledgeArticle.create(mock_connection, {"subject": "New KA"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/knowledge_articles", "POST", {"subject": "New KA"} + ) + assert isinstance(result, KnowledgeArticle) + assert result.id == 81 + + +def test_update_knowledge_article(mock_connection, ka_instance): + mock_connection.api_call.return_value = {"id": 80, "subject": "Updated KA"} + result = ka_instance.update({"subject": "Updated KA"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/knowledge_articles/80", "PATCH", {"subject": "Updated KA"} + ) + assert isinstance(result, KnowledgeArticle) + assert result.subject == "Updated KA" + + +def test_archive_knowledge_article(mock_connection, ka_instance): + mock_connection.api_call.return_value = {"id": 80, "subject": "How to reset password"} + result = ka_instance.archive() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/knowledge_articles/80/archive", "POST" + ) + assert isinstance(result, KnowledgeArticle) + + +def test_trash_knowledge_article(mock_connection, ka_instance): + mock_connection.api_call.return_value = {"id": 80, "subject": "How to reset password"} + result = ka_instance.trash() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/knowledge_articles/80/trash", "POST" + ) + assert isinstance(result, KnowledgeArticle) + + +def test_restore_knowledge_article(mock_connection, ka_instance): + mock_connection.api_call.return_value = {"id": 80, "subject": "How to reset password"} + result = ka_instance.restore() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/knowledge_articles/80/restore", "POST" + ) + assert isinstance(result, KnowledgeArticle) + + +def test_get_requests(mock_connection, ka_instance): + from xurrent.requests import Request + mock_connection.api_call.return_value = [{"id": 1, "subject": "Req A"}] + results = ka_instance.get_requests() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/knowledge_articles/80/requests", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, Request) for r in results) + + +def test_get_service_instances(mock_connection, ka_instance): + from xurrent.service_instances import ServiceInstance + mock_connection.api_call.return_value = [{"id": 1, "name": "SI A"}] + results = ka_instance.get_service_instances() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/knowledge_articles/80/service_instances", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, ServiceInstance) for r in results) + + +def test_get_translations(mock_connection, ka_instance): + translations_data = [{"id": 1, "language": "nl"}] + mock_connection.api_call.return_value = translations_data + result = ka_instance.get_translations() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/knowledge_articles/80/translations", "GET" + ) + assert result == translations_data diff --git a/tests/unit_tests/test_new_sub_resources.py b/tests/unit_tests/test_new_sub_resources.py new file mode 100644 index 0000000..34a9e5c --- /dev/null +++ b/tests/unit_tests/test_new_sub_resources.py @@ -0,0 +1,590 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock, patch + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.requests import Request +from xurrent.tasks import Task +from xurrent.workflows import Workflow +from xurrent.organizations import Organization +from xurrent.services import Service +from xurrent.calendars import Calendar +from xurrent.holidays import Holiday + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +@pytest.fixture +def request_instance(mock_connection): + return Request( + connection_object=mock_connection, + id=1, + subject="Test request", + status="assigned", + ) + + +@pytest.fixture +def task_instance(mock_connection): + return Task( + connection_object=mock_connection, + id=2, + subject="Test task", + ) + + +@pytest.fixture +def workflow_instance(mock_connection): + return Workflow( + connection_object=mock_connection, + id=3, + subject="Test workflow", + ) + + +@pytest.fixture +def person_instance(mock_connection): + return Person( + connection_object=mock_connection, + id=4, + name="Alice", + ) + + +@pytest.fixture +def org_instance(mock_connection): + return Organization( + connection_object=mock_connection, + id=5, + name="Acme Corp", + ) + + +@pytest.fixture +def service_instance(mock_connection): + return Service( + connection_object=mock_connection, + id=6, + name="IT Support", + ) + + +@pytest.fixture +def calendar_instance(mock_connection): + return Calendar( + connection_object=mock_connection, + id=7, + name="Business Hours", + ) + + +@pytest.fixture +def holiday_instance(mock_connection): + return Holiday( + connection_object=mock_connection, + id=8, + name="Christmas", + start_at="2026-12-25T00:00:00Z", + end_at="2026-12-26T00:00:00Z", + ) + + +@pytest.fixture +def team_instance(mock_connection): + return Team( + connection_object=mock_connection, + id=9, + name="Ops Team", + ) + + +# ------------------------- +# Request new sub-resources +# ------------------------- + +def test_request_get_attachments(mock_connection, request_instance): + mock_connection.api_call.return_value = [{"id": 1, "filename": "doc.pdf"}] + result = request_instance.get_attachments() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/requests/1/attachments", "GET" + ) + assert result == [{"id": 1, "filename": "doc.pdf"}] + + +def test_request_get_knowledge_articles(mock_connection, request_instance): + from xurrent.knowledge_articles import KnowledgeArticle + mock_connection.api_call.return_value = [ + {"id": 1, "subject": "KA 1"}, + {"id": 2, "subject": "KA 2"}, + ] + results = request_instance.get_knowledge_articles() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/requests/1/knowledge_articles", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, KnowledgeArticle) for r in results) + + +def test_request_get_automation_rules(mock_connection, request_instance): + mock_connection.api_call.return_value = [{"id": 1, "name": "Rule A"}] + result = request_instance.get_automation_rules() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/requests/1/automation_rules", "GET" + ) + assert result == [{"id": 1, "name": "Rule A"}] + + +def test_request_get_tags(mock_connection, request_instance): + mock_connection.api_call.return_value = [{"id": 1, "name": "urgent"}] + result = request_instance.get_tags() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/requests/1/tags", "GET" + ) + assert result == [{"id": 1, "name": "urgent"}] + + +def test_request_get_watches(mock_connection, request_instance): + mock_connection.api_call.return_value = [{"id": 1, "person_id": 42}] + result = request_instance.get_watches() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/requests/1/watches", "GET" + ) + assert result == [{"id": 1, "person_id": 42}] + + +# ---------------------- +# Task new sub-resources +# ---------------------- + +def test_task_get_notes(mock_connection, task_instance): + notes_data = [{"id": 1, "text": "Note 1"}] + mock_connection.api_call.return_value = notes_data + result = task_instance.get_notes() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/tasks/2/notes", "GET" + ) + assert result == notes_data + + +def test_task_add_note_string(mock_connection, task_instance): + mock_connection.api_call.return_value = {"id": 1, "text": "A note"} + task_instance.add_note("A note") + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/tasks/2/notes", "POST", {"text": "A note"} + ) + + +def test_task_add_note_dict(mock_connection, task_instance): + note = {"text": "A note", "internal": True} + mock_connection.api_call.return_value = {"id": 1, **note} + task_instance.add_note(note) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/tasks/2/notes", "POST", note + ) + + +def test_task_get_approvals(mock_connection, task_instance): + mock_connection.api_call.return_value = [{"id": 1, "status": "pending"}] + result = task_instance.get_approvals() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/tasks/2/approvals", "GET" + ) + assert result == [{"id": 1, "status": "pending"}] + + +def test_task_get_cis(mock_connection, task_instance): + from xurrent.configuration_items import ConfigurationItem + mock_connection.api_call.return_value = [ + {"id": 1, "label": "CI-1"}, + {"id": 2, "label": "CI-2"}, + ] + results = task_instance.get_cis() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/tasks/2/cis", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, ConfigurationItem) for r in results) + + +def test_task_get_predecessors(mock_connection, task_instance): + mock_connection.api_call.return_value = [{"id": 1, "subject": "Predecessor"}] + results = task_instance.get_predecessors() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/tasks/2/predecessors", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, Task) for r in results) + + +def test_task_get_successors(mock_connection, task_instance): + mock_connection.api_call.return_value = [{"id": 1, "subject": "Successor"}] + results = task_instance.get_successors() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/tasks/2/successors", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, Task) for r in results) + + +def test_task_get_service_instances(mock_connection, task_instance): + from xurrent.service_instances import ServiceInstance + mock_connection.api_call.return_value = [{"id": 1, "name": "SI A"}] + results = task_instance.get_service_instances() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/tasks/2/service_instances", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, ServiceInstance) for r in results) + + +# -------------------------- +# Workflow new sub-resources +# -------------------------- + +def test_workflow_get_notes(mock_connection, workflow_instance): + notes_data = [{"id": 1, "text": "Note 1"}] + mock_connection.api_call.return_value = notes_data + result = workflow_instance.get_notes() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/workflows/3/notes", "GET" + ) + assert result == notes_data + + +def test_workflow_add_note_string(mock_connection, workflow_instance): + mock_connection.api_call.return_value = {"id": 1, "text": "A note"} + workflow_instance.add_note("A note") + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/workflows/3/notes", "POST", {"text": "A note"} + ) + + +def test_workflow_get_requests(mock_connection, workflow_instance): + mock_connection.api_call.return_value = [ + {"id": 1, "subject": "Req A"}, + {"id": 2, "subject": "Req B"}, + ] + results = workflow_instance.get_requests() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/workflows/3/requests", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, Request) for r in results) + + +def test_workflow_get_problems(mock_connection, workflow_instance): + from xurrent.problems import Problem + mock_connection.api_call.return_value = [{"id": 1, "subject": "Problem A"}] + results = workflow_instance.get_problems() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/workflows/3/problems", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, Problem) for r in results) + + +def test_workflow_get_phases(mock_connection, workflow_instance): + phases_data = [{"id": 1, "name": "Phase 1"}] + mock_connection.api_call.return_value = phases_data + result = workflow_instance.get_phases() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/workflows/3/phases", "GET" + ) + assert result == phases_data + + +# ------------------------- +# Person new sub-resources +# ------------------------- + +def test_person_get_cis(mock_connection, person_instance): + from xurrent.configuration_items import ConfigurationItem + mock_connection.api_call.return_value = [{"id": 1, "label": "CI-1"}] + results = person_instance.get_cis() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/people/4/cis", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, ConfigurationItem) for r in results) + + +def test_person_get_addresses(mock_connection, person_instance): + addr_data = [{"id": 1, "street": "Main St"}] + mock_connection.api_call.return_value = addr_data + result = person_instance.get_addresses() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/people/4/addresses", "GET" + ) + assert result == addr_data + + +def test_person_get_out_of_office_periods(mock_connection, person_instance): + from xurrent.out_of_office_periods import OutOfOfficePeriod + mock_connection.api_call.return_value = [ + {"id": 1, "start_at": "2026-01-01T00:00:00Z", "end_at": "2026-01-07T00:00:00Z"} + ] + results = person_instance.get_out_of_office_periods() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/people/4/out_of_office_periods", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, OutOfOfficePeriod) for r in results) + + +def test_person_get_skill_pools(mock_connection, person_instance): + from xurrent.skill_pools import SkillPool + mock_connection.api_call.return_value = [{"id": 1, "name": "Pool A"}] + results = person_instance.get_skill_pools() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/people/4/skill_pools", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, SkillPool) for r in results) + + +# ------------------------------ +# Organization new sub-resources +# ------------------------------ + +def test_org_get_addresses(mock_connection, org_instance): + addr_data = [{"id": 1, "street": "HQ Street"}] + mock_connection.api_call.return_value = addr_data + result = org_instance.get_addresses() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/organizations/5/addresses", "GET" + ) + assert result == addr_data + + +def test_org_get_contracts(mock_connection, org_instance): + from xurrent.contracts import Contract + mock_connection.api_call.return_value = [{"id": 1, "name": "Support"}] + results = org_instance.get_contracts() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/organizations/5/contracts", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, Contract) for r in results) + + +def test_org_get_risks(mock_connection, org_instance): + from xurrent.risks import Risk + mock_connection.api_call.return_value = [{"id": 1, "subject": "Risk A"}] + results = org_instance.get_risks() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/organizations/5/risks", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, Risk) for r in results) + + +def test_org_get_time_allocations(mock_connection, org_instance): + from xurrent.time_allocations import TimeAllocation + mock_connection.api_call.return_value = [{"id": 1, "name": "TA A"}] + results = org_instance.get_time_allocations() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/organizations/5/time_allocations", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, TimeAllocation) for r in results) + + +# ------------------------- +# Service new sub-resources +# ------------------------- + +def test_service_get_workflows(mock_connection, service_instance): + mock_connection.api_call.return_value = [{"id": 1, "subject": "Wf A"}] + results = service_instance.get_workflows() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/services/6/workflows", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, Workflow) for r in results) + + +def test_service_get_service_instances(mock_connection, service_instance): + from xurrent.service_instances import ServiceInstance + mock_connection.api_call.return_value = [{"id": 1, "name": "SI A"}] + results = service_instance.get_service_instances() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/services/6/service_instances", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, ServiceInstance) for r in results) + + +def test_service_get_risks(mock_connection, service_instance): + from xurrent.risks import Risk + mock_connection.api_call.return_value = [{"id": 1, "subject": "Risk A"}] + results = service_instance.get_risks() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/services/6/risks", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, Risk) for r in results) + + +def test_service_get_service_offerings(mock_connection, service_instance): + from xurrent.service_offerings import ServiceOffering + mock_connection.api_call.return_value = [{"id": 1, "name": "Gold"}] + results = service_instance.get_service_offerings() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/services/6/service_offerings", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, ServiceOffering) for r in results) + + +# -------------------------- +# Calendar new sub-resources +# -------------------------- + +def test_calendar_get_duration(mock_connection, calendar_instance): + mock_connection.api_call.return_value = {"duration": 3600} + result = calendar_instance.get_duration( + start="2026-01-01T09:00:00Z", end="2026-01-01T10:00:00Z" + ) + call_args = mock_connection.api_call.call_args + assert "/calendars/7/duration" in call_args[0][0] + assert "start=2026-01-01T09:00:00Z" in call_args[0][0] + assert "end=2026-01-01T10:00:00Z" in call_args[0][0] + assert call_args[0][1] == "GET" + assert result == {"duration": 3600} + + +def test_calendar_get_hours(mock_connection, calendar_instance): + hours_data = [{"id": 1, "day": "monday", "time_from": "08:00", "time_until": "17:00"}] + mock_connection.api_call.return_value = hours_data + result = calendar_instance.get_hours() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/calendars/7/hours", "GET" + ) + assert result == hours_data + + +def test_calendar_get_holidays(mock_connection, calendar_instance): + mock_connection.api_call.return_value = [ + {"id": 1, "name": "Christmas", "start_at": "2026-12-25T00:00:00Z", "end_at": "2026-12-26T00:00:00Z"} + ] + results = calendar_instance.get_holidays() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/calendars/7/holidays", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, Holiday) for r in results) + + +# ---------------------- +# Team new sub-resources +# ---------------------- + +def test_team_get_service_instances(mock_connection, team_instance): + from xurrent.service_instances import ServiceInstance + mock_connection.api_call.return_value = [{"id": 1, "name": "SI A"}] + results = team_instance.get_service_instances() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/teams/9/service_instances", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, ServiceInstance) for r in results) + + +# ------------------------- +# Holiday new sub-resources +# ------------------------- + +def test_holiday_get_calendars(mock_connection, holiday_instance): + mock_connection.api_call.return_value = [{"id": 1, "name": "Business Hours"}] + results = holiday_instance.get_calendars() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/holidays/8/calendars", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, Calendar) for r in results) + + +# ------------------------- +# Core utility methods +# ------------------------- + +def test_search(mock_connection): + mock_connection.api_call.return_value = [{"id": 1, "type": "request"}] + mock_connection.search = lambda query, types=None: mock_connection.api_call( + f"/search?q={query}", "GET" + ) + result = mock_connection.search("password reset") + mock_connection.api_call.assert_called_once_with("/search?q=password reset", "GET") + assert result == [{"id": 1, "type": "request"}] + + +def test_search_core(): + helper = XurrentApiHelper( + "https://api.example.com", api_key="key", api_account="acct", resolve_user=False + ) + helper.api_call = MagicMock(return_value=[{"id": 1}]) + result = helper.search("test query") + helper.api_call.assert_called_once_with("/search?q=test query", "GET") + assert result == [{"id": 1}] + + +def test_search_with_types_core(): + helper = XurrentApiHelper( + "https://api.example.com", api_key="key", api_account="acct", resolve_user=False + ) + helper.api_call = MagicMock(return_value=[]) + helper.search("test query", types=["request", "person"]) + helper.api_call.assert_called_once_with("/search?q=test query&types=request,person", "GET") + + +def test_bulk_import_core(): + helper = XurrentApiHelper( + "https://api.example.com", api_key="key", api_account="acct", resolve_user=False + ) + helper.api_call = MagicMock(return_value={"status": "done"}) + result = helper.bulk_import("name,email\nAlice,a@b.com", "people") + helper.api_call.assert_called_once_with("/import", method="POST", data={ + "type": "people", + "import_format": "csv", + "data": "name,email\nAlice,a@b.com", + }) + assert result == {"status": "done"} + + +def test_list_archive_core(): + helper = XurrentApiHelper( + "https://api.example.com", api_key="key", api_account="acct", resolve_user=False + ) + helper.api_call = MagicMock(return_value=[]) + helper.list_archive() + helper.api_call.assert_called_once_with("/archive", "GET") + + +def test_list_trash_core(): + helper = XurrentApiHelper( + "https://api.example.com", api_key="key", api_account="acct", resolve_user=False + ) + helper.api_call = MagicMock(return_value=[]) + helper.list_trash() + helper.api_call.assert_called_once_with("/trash", "GET") + + +def test_list_audit_lines_core(): + helper = XurrentApiHelper( + "https://api.example.com", api_key="key", api_account="acct", resolve_user=False + ) + helper.api_call = MagicMock(return_value=[]) + helper.list_audit_lines() + helper.api_call.assert_called_once_with("/audit_lines", "GET") diff --git a/tests/unit_tests/test_problems.py b/tests/unit_tests/test_problems.py new file mode 100644 index 0000000..3248fc7 --- /dev/null +++ b/tests/unit_tests/test_problems.py @@ -0,0 +1,193 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.problems import Problem, ProblemPredefinedFilter + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +@pytest.fixture +def problem_instance(mock_connection): + return Problem( + connection_object=mock_connection, + id=10, + subject="Server crash", + status="in_progress", + impact="high", + manager=Person(connection_object=mock_connection, id=5, name="Manager"), + team=Team(connection_object=mock_connection, id=2, name="Ops"), + ) + + +def test_problem_initialization(problem_instance): + assert isinstance(problem_instance, Problem) + assert problem_instance.__resourceUrl__ == "problems" + assert problem_instance.id == 10 + assert problem_instance.subject == "Server crash" + assert isinstance(problem_instance.manager, Person) + assert problem_instance.manager.name == "Manager" + assert isinstance(problem_instance.team, Team) + assert problem_instance.team.name == "Ops" + + +def test_problem_from_data(mock_connection): + data = { + "id": 10, + "subject": "Server crash", + "status": "in_progress", + "impact": "high", + "manager": {"id": 5, "name": "Manager"}, + "team": {"id": 2, "name": "Ops"}, + } + problem = Problem.from_data(mock_connection, data) + assert isinstance(problem, Problem) + assert problem.id == 10 + assert isinstance(problem.manager, Person) + assert isinstance(problem.team, Team) + + +def test_get_problem_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 10, "subject": "Server crash"} + result = Problem.get_by_id(mock_connection, 10) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/problems/10", "GET" + ) + assert isinstance(result, Problem) + assert result.id == 10 + + +def test_get_problems(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 1, "subject": "Problem A"}, + {"id": 2, "subject": "Problem B"}, + ] + results = Problem.get_problems(mock_connection) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/problems", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, Problem) for r in results) + + +def test_get_problems_with_filter(mock_connection): + mock_connection.api_call.return_value = [] + Problem.get_problems(mock_connection, predefinedFilter=ProblemPredefinedFilter.active) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/problems/active", "GET" + ) + + +def test_create_problem(mock_connection): + mock_connection.api_call.return_value = {"id": 11, "subject": "New Problem"} + result = Problem.create(mock_connection, {"subject": "New Problem"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/problems", "POST", {"subject": "New Problem"} + ) + assert isinstance(result, Problem) + assert result.id == 11 + + +def test_update_problem(mock_connection, problem_instance): + mock_connection.api_call.return_value = {"id": 10, "subject": "Updated Problem"} + result = problem_instance.update({"subject": "Updated Problem"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/problems/10", "PATCH", {"subject": "Updated Problem"} + ) + assert isinstance(result, Problem) + assert result.subject == "Updated Problem" + + +def test_archive_problem(mock_connection, problem_instance): + mock_connection.api_call.return_value = {"id": 10, "subject": "Server crash"} + result = problem_instance.archive() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/problems/10/archive", "POST" + ) + assert isinstance(result, Problem) + + +def test_trash_problem(mock_connection, problem_instance): + mock_connection.api_call.return_value = {"id": 10, "subject": "Server crash"} + result = problem_instance.trash() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/problems/10/trash", "POST" + ) + assert isinstance(result, Problem) + + +def test_restore_problem(mock_connection, problem_instance): + mock_connection.api_call.return_value = {"id": 10, "subject": "Server crash"} + result = problem_instance.restore() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/problems/10/restore", "POST" + ) + assert isinstance(result, Problem) + + +def test_get_notes(mock_connection, problem_instance): + notes_data = [{"id": 1, "text": "Note 1"}, {"id": 2, "text": "Note 2"}] + mock_connection.api_call.return_value = notes_data + result = problem_instance.get_notes() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/problems/10/notes", "GET" + ) + assert result == notes_data + + +def test_add_note_string(mock_connection, problem_instance): + mock_connection.api_call.return_value = {"id": 1, "text": "A note"} + problem_instance.add_note("A note") + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/problems/10/notes", "POST", {"text": "A note"} + ) + + +def test_add_note_dict(mock_connection, problem_instance): + note = {"text": "A note", "internal": True} + mock_connection.api_call.return_value = {"id": 1, **note} + problem_instance.add_note(note) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/problems/10/notes", "POST", note + ) + + +def test_get_requests(mock_connection, problem_instance): + from xurrent.requests import Request + mock_connection.api_call.return_value = [ + {"id": 1, "subject": "Req A"}, + {"id": 2, "subject": "Req B"}, + ] + results = problem_instance.get_requests() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/problems/10/requests", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, Request) for r in results) + + +def test_get_workflows(mock_connection, problem_instance): + from xurrent.workflows import Workflow + mock_connection.api_call.return_value = [ + {"id": 1, "subject": "Wf A"}, + {"id": 2, "subject": "Wf B"}, + ] + results = problem_instance.get_workflows() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/problems/10/workflows", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, Workflow) for r in results) diff --git a/tests/unit_tests/test_projects.py b/tests/unit_tests/test_projects.py new file mode 100644 index 0000000..5a63c89 --- /dev/null +++ b/tests/unit_tests/test_projects.py @@ -0,0 +1,186 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.projects import Project, ProjectPredefinedFilter + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +@pytest.fixture +def project_instance(mock_connection): + return Project( + connection_object=mock_connection, + id=40, + subject="Cloud Migration", + status="in_progress", + category="migration", + manager=Person(connection_object=mock_connection, id=5, name="Manager"), + ) + + +def test_project_initialization(project_instance): + assert isinstance(project_instance, Project) + assert project_instance.__resourceUrl__ == "projects" + assert project_instance.id == 40 + assert project_instance.subject == "Cloud Migration" + assert isinstance(project_instance.manager, Person) + assert project_instance.manager.name == "Manager" + + +def test_project_from_data(mock_connection): + data = { + "id": 40, + "subject": "Cloud Migration", + "status": "in_progress", + "category": "migration", + "manager": {"id": 5, "name": "Manager"}, + } + project = Project.from_data(mock_connection, data) + assert isinstance(project, Project) + assert project.id == 40 + assert isinstance(project.manager, Person) + + +def test_get_project_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 40, "subject": "Cloud Migration"} + result = Project.get_by_id(mock_connection, 40) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/projects/40", "GET" + ) + assert isinstance(result, Project) + assert result.id == 40 + + +def test_get_projects(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 1, "subject": "Proj A"}, + {"id": 2, "subject": "Proj B"}, + ] + results = Project.get_projects(mock_connection) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/projects", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, Project) for r in results) + + +def test_get_projects_with_filter(mock_connection): + mock_connection.api_call.return_value = [] + Project.get_projects(mock_connection, predefinedFilter=ProjectPredefinedFilter.open) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/projects/open", "GET" + ) + + +def test_create_project(mock_connection): + mock_connection.api_call.return_value = {"id": 41, "subject": "New Project"} + result = Project.create(mock_connection, {"subject": "New Project"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/projects", "POST", {"subject": "New Project"} + ) + assert isinstance(result, Project) + assert result.id == 41 + + +def test_update_project(mock_connection, project_instance): + mock_connection.api_call.return_value = {"id": 40, "subject": "Updated Project"} + result = project_instance.update({"subject": "Updated Project"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/projects/40", "PATCH", {"subject": "Updated Project"} + ) + assert isinstance(result, Project) + assert result.subject == "Updated Project" + + +def test_archive_project(mock_connection, project_instance): + mock_connection.api_call.return_value = {"id": 40, "subject": "Cloud Migration"} + result = project_instance.archive() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/projects/40/archive", "POST" + ) + assert isinstance(result, Project) + + +def test_trash_project(mock_connection, project_instance): + mock_connection.api_call.return_value = {"id": 40, "subject": "Cloud Migration"} + result = project_instance.trash() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/projects/40/trash", "POST" + ) + assert isinstance(result, Project) + + +def test_restore_project(mock_connection, project_instance): + mock_connection.api_call.return_value = {"id": 40, "subject": "Cloud Migration"} + result = project_instance.restore() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/projects/40/restore", "POST" + ) + assert isinstance(result, Project) + + +def test_get_tasks(mock_connection, project_instance): + from xurrent.tasks import Task + mock_connection.api_call.return_value = [ + {"id": 1, "subject": "Task A"}, + {"id": 2, "subject": "Task B"}, + ] + results = project_instance.get_tasks() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/projects/40/tasks", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, Task) for r in results) + + +def test_get_phases(mock_connection, project_instance): + phases_data = [{"id": 1, "name": "Phase 1"}, {"id": 2, "name": "Phase 2"}] + mock_connection.api_call.return_value = phases_data + result = project_instance.get_phases() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/projects/40/phases", "GET" + ) + assert result == phases_data + + +def test_get_workflows(mock_connection, project_instance): + from xurrent.workflows import Workflow + mock_connection.api_call.return_value = [{"id": 1, "subject": "Wf A"}] + results = project_instance.get_workflows() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/projects/40/workflows", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, Workflow) for r in results) + + +def test_get_notes(mock_connection, project_instance): + notes_data = [{"id": 1, "text": "Note 1"}] + mock_connection.api_call.return_value = notes_data + result = project_instance.get_notes() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/projects/40/notes", "GET" + ) + assert result == notes_data + + +def test_add_note_string(mock_connection, project_instance): + mock_connection.api_call.return_value = {"id": 1, "text": "A note"} + project_instance.add_note("A note") + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/projects/40/notes", "POST", {"text": "A note"} + ) diff --git a/tests/unit_tests/test_releases.py b/tests/unit_tests/test_releases.py new file mode 100644 index 0000000..88eb486 --- /dev/null +++ b/tests/unit_tests/test_releases.py @@ -0,0 +1,174 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.releases import Release, ReleasePredefinedFilter + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +@pytest.fixture +def release_instance(mock_connection): + return Release( + connection_object=mock_connection, + id=50, + subject="v2.0 Release", + status="in_progress", + impact="medium", + manager=Person(connection_object=mock_connection, id=5, name="Manager"), + ) + + +def test_release_initialization(release_instance): + assert isinstance(release_instance, Release) + assert release_instance.__resourceUrl__ == "releases" + assert release_instance.id == 50 + assert release_instance.subject == "v2.0 Release" + assert isinstance(release_instance.manager, Person) + assert release_instance.manager.name == "Manager" + + +def test_release_from_data(mock_connection): + data = { + "id": 50, + "subject": "v2.0 Release", + "status": "in_progress", + "impact": "medium", + "manager": {"id": 5, "name": "Manager"}, + } + release = Release.from_data(mock_connection, data) + assert isinstance(release, Release) + assert release.id == 50 + assert isinstance(release.manager, Person) + + +def test_get_release_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 50, "subject": "v2.0 Release"} + result = Release.get_by_id(mock_connection, 50) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/releases/50", "GET" + ) + assert isinstance(result, Release) + assert result.id == 50 + + +def test_get_releases(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 1, "subject": "v1.0"}, + {"id": 2, "subject": "v2.0"}, + ] + results = Release.get_releases(mock_connection) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/releases", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, Release) for r in results) + + +def test_get_releases_with_filter(mock_connection): + mock_connection.api_call.return_value = [] + Release.get_releases(mock_connection, predefinedFilter=ReleasePredefinedFilter.open) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/releases/open", "GET" + ) + + +def test_create_release(mock_connection): + mock_connection.api_call.return_value = {"id": 51, "subject": "v3.0"} + result = Release.create(mock_connection, {"subject": "v3.0"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/releases", "POST", {"subject": "v3.0"} + ) + assert isinstance(result, Release) + assert result.id == 51 + + +def test_update_release(mock_connection, release_instance): + mock_connection.api_call.return_value = {"id": 50, "subject": "v2.1 Release"} + result = release_instance.update({"subject": "v2.1 Release"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/releases/50", "PATCH", {"subject": "v2.1 Release"} + ) + assert isinstance(result, Release) + assert result.subject == "v2.1 Release" + + +def test_archive_release(mock_connection, release_instance): + mock_connection.api_call.return_value = {"id": 50, "subject": "v2.0 Release"} + result = release_instance.archive() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/releases/50/archive", "POST" + ) + assert isinstance(result, Release) + + +def test_trash_release(mock_connection, release_instance): + mock_connection.api_call.return_value = {"id": 50, "subject": "v2.0 Release"} + result = release_instance.trash() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/releases/50/trash", "POST" + ) + assert isinstance(result, Release) + + +def test_restore_release(mock_connection, release_instance): + mock_connection.api_call.return_value = {"id": 50, "subject": "v2.0 Release"} + result = release_instance.restore() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/releases/50/restore", "POST" + ) + assert isinstance(result, Release) + + +def test_get_workflows(mock_connection, release_instance): + from xurrent.workflows import Workflow + mock_connection.api_call.return_value = [ + {"id": 1, "subject": "Wf A"}, + {"id": 2, "subject": "Wf B"}, + ] + results = release_instance.get_workflows() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/releases/50/workflows", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, Workflow) for r in results) + + +def test_get_notes(mock_connection, release_instance): + notes_data = [{"id": 1, "text": "Note 1"}] + mock_connection.api_call.return_value = notes_data + result = release_instance.get_notes() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/releases/50/notes", "GET" + ) + assert result == notes_data + + +def test_add_note_string(mock_connection, release_instance): + mock_connection.api_call.return_value = {"id": 1, "text": "Hello"} + release_instance.add_note("Hello") + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/releases/50/notes", "POST", {"text": "Hello"} + ) + + +def test_add_note_dict(mock_connection, release_instance): + note = {"text": "Hello", "internal": True} + mock_connection.api_call.return_value = {"id": 1, **note} + release_instance.add_note(note) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/releases/50/notes", "POST", note + ) diff --git a/tests/unit_tests/test_risks.py b/tests/unit_tests/test_risks.py new file mode 100644 index 0000000..7111c41 --- /dev/null +++ b/tests/unit_tests/test_risks.py @@ -0,0 +1,164 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.risks import Risk, RiskPredefinedFilter + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +@pytest.fixture +def risk_instance(mock_connection): + return Risk( + connection_object=mock_connection, + id=90, + subject="Data breach risk", + status="security", + severity="high", + manager=Person(connection_object=mock_connection, id=5, name="Manager"), + ) + + +def test_risk_initialization(risk_instance): + assert isinstance(risk_instance, Risk) + assert risk_instance.__resourceUrl__ == "risks" + assert risk_instance.id == 90 + assert risk_instance.subject == "Data breach risk" + assert isinstance(risk_instance.manager, Person) + + +def test_risk_from_data(mock_connection): + data = { + "id": 90, + "subject": "Data breach risk", + "severity": "high", + "manager": {"id": 5, "name": "Manager"}, + } + risk = Risk.from_data(mock_connection, data) + assert isinstance(risk, Risk) + assert risk.id == 90 + assert isinstance(risk.manager, Person) + + +def test_get_risk_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 90, "subject": "Data breach risk"} + result = Risk.get_by_id(mock_connection, 90) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/risks/90", "GET" + ) + assert isinstance(result, Risk) + assert result.id == 90 + + +def test_get_risks(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 1, "subject": "Risk A"}, + {"id": 2, "subject": "Risk B"}, + ] + results = Risk.get_risks(mock_connection) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/risks", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, Risk) for r in results) + + +def test_get_risks_with_filter(mock_connection): + mock_connection.api_call.return_value = [] + Risk.get_risks(mock_connection, predefinedFilter=RiskPredefinedFilter.open) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/risks/open", "GET" + ) + + +def test_create_risk(mock_connection): + mock_connection.api_call.return_value = {"id": 91, "subject": "New Risk"} + result = Risk.create(mock_connection, {"subject": "New Risk"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/risks", "POST", {"subject": "New Risk"} + ) + assert isinstance(result, Risk) + assert result.id == 91 + + +def test_update_risk(mock_connection, risk_instance): + mock_connection.api_call.return_value = {"id": 90, "subject": "Updated Risk"} + result = risk_instance.update({"subject": "Updated Risk"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/risks/90", "PATCH", {"subject": "Updated Risk"} + ) + assert isinstance(result, Risk) + assert result.subject == "Updated Risk" + + +def test_archive_risk(mock_connection, risk_instance): + mock_connection.api_call.return_value = {"id": 90, "subject": "Data breach risk"} + result = risk_instance.archive() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/risks/90/archive", "POST" + ) + assert isinstance(result, Risk) + + +def test_trash_risk(mock_connection, risk_instance): + mock_connection.api_call.return_value = {"id": 90, "subject": "Data breach risk"} + result = risk_instance.trash() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/risks/90/trash", "POST" + ) + assert isinstance(result, Risk) + + +def test_restore_risk(mock_connection, risk_instance): + mock_connection.api_call.return_value = {"id": 90, "subject": "Data breach risk"} + result = risk_instance.restore() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/risks/90/restore", "POST" + ) + assert isinstance(result, Risk) + + +def test_get_organizations(mock_connection, risk_instance): + from xurrent.organizations import Organization + mock_connection.api_call.return_value = [{"id": 1, "name": "Org A"}] + results = risk_instance.get_organizations() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/risks/90/organizations", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, Organization) for r in results) + + +def test_get_projects(mock_connection, risk_instance): + from xurrent.projects import Project + mock_connection.api_call.return_value = [{"id": 1, "subject": "Proj A"}] + results = risk_instance.get_projects() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/risks/90/projects", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, Project) for r in results) + + +def test_get_services(mock_connection, risk_instance): + from xurrent.services import Service + mock_connection.api_call.return_value = [{"id": 1, "name": "Service A"}] + results = risk_instance.get_services() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/risks/90/services", "GET" + ) + assert len(results) == 1 + assert all(isinstance(r, Service) for r in results) diff --git a/tests/unit_tests/test_service_instances.py b/tests/unit_tests/test_service_instances.py new file mode 100644 index 0000000..e0520a8 --- /dev/null +++ b/tests/unit_tests/test_service_instances.py @@ -0,0 +1,132 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.service_instances import ServiceInstance, ServiceInstancePredefinedFilter + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +@pytest.fixture +def si_instance(mock_connection): + return ServiceInstance( + connection_object=mock_connection, + id=30, + name="Production SI", + status="active", + ) + + +def test_service_instance_initialization(si_instance): + assert isinstance(si_instance, ServiceInstance) + assert si_instance.__resourceUrl__ == "service_instances" + assert si_instance.id == 30 + assert si_instance.name == "Production SI" + + +def test_service_instance_from_data(mock_connection): + data = { + "id": 30, + "name": "Production SI", + "status": "active", + "service": {"id": 5, "name": "My Service"}, + } + si = ServiceInstance.from_data(mock_connection, data) + assert isinstance(si, ServiceInstance) + assert si.id == 30 + from xurrent.services import Service + assert isinstance(si.service, Service) + assert si.service.name == "My Service" + + +def test_get_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 30, "name": "Production SI"} + result = ServiceInstance.get_by_id(mock_connection, 30) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/service_instances/30", "GET" + ) + assert isinstance(result, ServiceInstance) + assert result.id == 30 + + +def test_get_service_instances(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 1, "name": "SI A"}, + {"id": 2, "name": "SI B"}, + ] + results = ServiceInstance.get_service_instances(mock_connection) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/service_instances", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, ServiceInstance) for r in results) + + +def test_get_service_instances_with_filter(mock_connection): + mock_connection.api_call.return_value = [] + ServiceInstance.get_service_instances( + mock_connection, predefinedFilter=ServiceInstancePredefinedFilter.active + ) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/service_instances/active", "GET" + ) + + +def test_create_service_instance(mock_connection): + mock_connection.api_call.return_value = {"id": 31, "name": "New SI"} + result = ServiceInstance.create(mock_connection, {"name": "New SI"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/service_instances", "POST", {"name": "New SI"} + ) + assert isinstance(result, ServiceInstance) + assert result.id == 31 + + +def test_update_service_instance(mock_connection, si_instance): + mock_connection.api_call.return_value = {"id": 30, "name": "Updated SI"} + result = si_instance.update({"name": "Updated SI"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/service_instances/30", "PATCH", {"name": "Updated SI"} + ) + assert isinstance(result, ServiceInstance) + assert result.name == "Updated SI" + + +def test_get_cis(mock_connection, si_instance): + from xurrent.configuration_items import ConfigurationItem + mock_connection.api_call.return_value = [ + {"id": 1, "label": "CI-1"}, + {"id": 2, "label": "CI-2"}, + ] + results = si_instance.get_cis() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/service_instances/30/cis", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, ConfigurationItem) for r in results) + + +def test_get_users(mock_connection, si_instance): + mock_connection.api_call.return_value = [ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bob"}, + ] + results = si_instance.get_users() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/service_instances/30/users", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, Person) for r in results) diff --git a/tests/unit_tests/test_service_offerings.py b/tests/unit_tests/test_service_offerings.py new file mode 100644 index 0000000..1acbd64 --- /dev/null +++ b/tests/unit_tests/test_service_offerings.py @@ -0,0 +1,104 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.service_offerings import ServiceOffering, ServiceOfferingPredefinedFilter + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +@pytest.fixture +def so_instance(mock_connection): + return ServiceOffering( + connection_object=mock_connection, + id=100, + name="Gold Support", + status="available", + ) + + +def test_service_offering_initialization(so_instance): + assert isinstance(so_instance, ServiceOffering) + assert so_instance.__resourceUrl__ == "service_offerings" + assert so_instance.id == 100 + assert so_instance.name == "Gold Support" + + +def test_service_offering_from_data(mock_connection): + data = { + "id": 100, + "name": "Gold Support", + "status": "available", + "service": {"id": 5, "name": "IT Support"}, + } + so = ServiceOffering.from_data(mock_connection, data) + assert isinstance(so, ServiceOffering) + assert so.id == 100 + from xurrent.services import Service + assert isinstance(so.service, Service) + + +def test_get_service_offering_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 100, "name": "Gold Support"} + result = ServiceOffering.get_by_id(mock_connection, 100) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/service_offerings/100", "GET" + ) + assert isinstance(result, ServiceOffering) + assert result.id == 100 + + +def test_get_service_offerings(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 1, "name": "Gold"}, + {"id": 2, "name": "Silver"}, + ] + results = ServiceOffering.get_service_offerings(mock_connection) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/service_offerings", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, ServiceOffering) for r in results) + + +def test_get_service_offerings_with_filter(mock_connection): + mock_connection.api_call.return_value = [] + ServiceOffering.get_service_offerings( + mock_connection, predefinedFilter=ServiceOfferingPredefinedFilter.catalog + ) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/service_offerings/catalog", "GET" + ) + + +def test_create_service_offering(mock_connection): + mock_connection.api_call.return_value = {"id": 101, "name": "Platinum"} + result = ServiceOffering.create(mock_connection, {"name": "Platinum"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/service_offerings", "POST", {"name": "Platinum"} + ) + assert isinstance(result, ServiceOffering) + assert result.id == 101 + + +def test_update_service_offering(mock_connection, so_instance): + mock_connection.api_call.return_value = {"id": 100, "name": "Updated Gold"} + result = so_instance.update({"name": "Updated Gold"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/service_offerings/100", "PATCH", {"name": "Updated Gold"} + ) + assert isinstance(result, ServiceOffering) + assert result.name == "Updated Gold" diff --git a/tests/unit_tests/test_skill_pools.py b/tests/unit_tests/test_skill_pools.py new file mode 100644 index 0000000..8b7fe7c --- /dev/null +++ b/tests/unit_tests/test_skill_pools.py @@ -0,0 +1,147 @@ +import pytest +import os +import sys +from unittest.mock import MagicMock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.teams import Team +from xurrent.skill_pools import SkillPool, SkillPoolPredefinedFilter + + +@pytest.fixture +def mock_connection(): + mock = MagicMock(spec=XurrentApiHelper) + mock.base_url = "https://api.example.com" + mock.api_user = Person(connection_object=mock, id=1, name="api_user") + mock.api_user_teams = [Team(connection_object=mock, id=1, name="team")] + return mock + + +@pytest.fixture +def sp_instance(mock_connection): + return SkillPool( + connection_object=mock_connection, + id=110, + name="Python Devs", + disabled=False, + manager=Person(connection_object=mock_connection, id=5, name="Manager"), + ) + + +def test_skill_pool_initialization(sp_instance): + assert isinstance(sp_instance, SkillPool) + assert sp_instance.__resourceUrl__ == "skill_pools" + assert sp_instance.id == 110 + assert sp_instance.name == "Python Devs" + assert sp_instance.disabled is False + assert isinstance(sp_instance.manager, Person) + + +def test_skill_pool_from_data(mock_connection): + data = { + "id": 110, + "name": "Python Devs", + "disabled": False, + "manager": {"id": 5, "name": "Manager"}, + } + sp = SkillPool.from_data(mock_connection, data) + assert isinstance(sp, SkillPool) + assert sp.id == 110 + assert isinstance(sp.manager, Person) + + +def test_get_skill_pool_by_id(mock_connection): + mock_connection.api_call.return_value = {"id": 110, "name": "Python Devs"} + result = SkillPool.get_by_id(mock_connection, 110) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/skill_pools/110", "GET" + ) + assert isinstance(result, SkillPool) + assert result.id == 110 + + +def test_get_skill_pools(mock_connection): + mock_connection.api_call.return_value = [ + {"id": 1, "name": "Pool A"}, + {"id": 2, "name": "Pool B"}, + ] + results = SkillPool.get_skill_pools(mock_connection) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/skill_pools", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, SkillPool) for r in results) + + +def test_get_skill_pools_with_filter(mock_connection): + mock_connection.api_call.return_value = [] + SkillPool.get_skill_pools(mock_connection, predefinedFilter=SkillPoolPredefinedFilter.enabled) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/skill_pools/enabled", "GET" + ) + + +def test_create_skill_pool(mock_connection): + mock_connection.api_call.return_value = {"id": 111, "name": "Java Devs"} + result = SkillPool.create(mock_connection, {"name": "Java Devs"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/skill_pools", "POST", {"name": "Java Devs"} + ) + assert isinstance(result, SkillPool) + assert result.id == 111 + + +def test_update_skill_pool(mock_connection, sp_instance): + mock_connection.api_call.return_value = {"id": 110, "name": "Updated Pool"} + result = sp_instance.update({"name": "Updated Pool"}) + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/skill_pools/110", "PATCH", {"name": "Updated Pool"} + ) + assert isinstance(result, SkillPool) + assert result.name == "Updated Pool" + + +def test_enable_skill_pool(mock_connection, sp_instance): + mock_connection.api_call.return_value = {"id": 110, "name": "Python Devs", "disabled": False} + sp_instance.enable() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/skill_pools/110", "PATCH", {"disabled": False} + ) + + +def test_disable_skill_pool(mock_connection, sp_instance): + mock_connection.api_call.return_value = {"id": 110, "name": "Python Devs", "disabled": True} + sp_instance.disable() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/skill_pools/110", "PATCH", {"disabled": True} + ) + + +def test_get_members(mock_connection, sp_instance): + mock_connection.api_call.return_value = [ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bob"}, + ] + results = sp_instance.get_members() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/skill_pools/110/members", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, Person) for r in results) + + +def test_get_effort_classes(mock_connection, sp_instance): + from xurrent.effort_classes import EffortClass + mock_connection.api_call.return_value = [ + {"id": 1, "name": "Regular"}, + {"id": 2, "name": "Overtime"}, + ] + results = sp_instance.get_effort_classes() + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/skill_pools/110/effort_classes", "GET" + ) + assert len(results) == 2 + assert all(isinstance(r, EffortClass) for r in results) From 84eb02809d922ccb48d84d66aa0002b82e4d4be0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:50:18 +0000 Subject: [PATCH 14/18] Add 10 new domain classes, 43 sub-resource methods, 5 utility APIs, 301 unit tests Agent-Logs-Url: https://github.com/fasteiner/xurrent-python/sessions/7ce5e825-70b8-4b09-ad7e-99899ceede7d Co-authored-by: fasteiner <75947402+fasteiner@users.noreply.github.com> --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48dcb89..897d9f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Teams: added `get_service_instances` instance method. - Holidays: added `get_calendars` instance method. - ClosureCodes: added `ClosureCode` class; supports CRUD. +- Core: added `search(query, types)` for cross-resource full-text search via `GET /search`. +- Core: added `bulk_import(data, import_type, import_format)` for CSV/TSV bulk imports via `POST /import`. +- Core: added `list_archive(queryfilter)` to list all archived items via `GET /archive`. +- Core: added `list_trash(queryfilter)` to list all trashed items via `GET /trash`. +- Core: added `list_audit_lines(queryfilter)` to query the global audit log via `GET /audit_lines`. - Docs: added `CLAUDE.md` with setup instructions, test commands, architecture overview, and changelog requirements for Claude Code. - Products: added `Product` class with `ProductPredefinedFilter` and `ProductDepreciationMethod` enums; supports CRUD, enable/disable, and CI listing. - ProductCategories: added `ProductCategory` class with `ProductCategoryRuleSet` enum; supports CRUD and enable/disable. From 5b82588e06e701a10b41e195b9777ecf6be75eea Mon Sep 17 00:00:00 2001 From: fasteiner <75947402+fasteiner@users.noreply.github.com> Date: Wed, 13 May 2026 15:02:03 +0000 Subject: [PATCH 15/18] Update pyproject.toml for release 0.13.0-preview.10 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e2f64c2..9319c67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "xurrent" -version = "0.12.2" +version = "0.13.0-preview.10" authors = [ { name="Fabian Steiner", email="fabian@stei-ner.net" }, ] @@ -18,7 +18,7 @@ Homepage = "https://github.com/fasteiner/xurrent-python" Issues = "https://github.com/fasteiner/xurrent-python/issues" [tool.poetry] name = "xurrent" -version = "0.12.2" +version = "0.13.0-preview.10" description = "A python module to interact with the Xurrent API." authors = ["Ing. Fabian Franz Steiner BSc. "] readme = "README.md" From f76ecf4e731ae1716ade181e9fb39a43e0af83f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 15:09:02 +0000 Subject: [PATCH 16/18] fix lazy-loaded cross-module annotations Agent-Logs-Url: https://github.com/fasteiner/xurrent-python/sessions/fa48e3dc-7f83-4675-944c-2cfe73efce6a Co-authored-by: fasteiner <75947402+fasteiner@users.noreply.github.com> --- CHANGELOG.md | 4 ++++ src/xurrent/core.py | 10 +++++++--- src/xurrent/people.py | 20 +++++++++++--------- src/xurrent/requests.py | 36 ++++++++++++++++++++---------------- src/xurrent/tasks.py | 24 +++++++++++++++--------- src/xurrent/workflows.py | 16 +++++++++------- 6 files changed, 66 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 897d9f7..6ecf497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CI: updated GitHub Actions in `release.yml` — `GitTools/actions` `v0` → `v3` (latest version compatible with GitVersion 5.x; v4+ requires GitVersion ≥6.1), `stefanzweifel/git-auto-commit-action` `v5` → `v7`, `softprops/action-gh-release` `v1` → `v2`. - CI: updated `python-package.yml` — `actions/setup-python` `v3` → `v5`; added `pip install .` so the package itself is installed before tests run; added a `flake8` lint step (syntax errors and undefined names only); split test run into separate `Unit tests` and `Integration tests` steps so unit tests always run regardless of credentials; added Python 3.14 to the test matrix. +### Fixed + +- Core/People/Requests/Tasks/Workflows: restored lazy-loaded cross-module references in type annotations and fixed `Task` sub-resource helper name resolution so flake8 no longer reports `F821` undefined names. + ## [0.11.0] - 2026-04-13 ### Added diff --git a/src/xurrent/core.py b/src/xurrent/core.py index eb7a2a7..a7f75cd 100644 --- a/src/xurrent/core.py +++ b/src/xurrent/core.py @@ -8,7 +8,11 @@ import re import base64 from logging import Logger -from typing import Optional, List +from typing import Optional, List, TYPE_CHECKING + +if TYPE_CHECKING: + from .people import Person + from .teams import Team class LogLevel(Enum): DEBUG = logging.DEBUG @@ -45,8 +49,8 @@ def to_json(self): class XurrentApiHelper: - api_user: Person # Forward declaration with a string - api_user_teams: List[Team] # Forward declaration with a string + api_user: "Person" + api_user_teams: List["Team"] def __init__( self, diff --git a/src/xurrent/people.py b/src/xurrent/people.py index a5fe994..81ac13a 100644 --- a/src/xurrent/people.py +++ b/src/xurrent/people.py @@ -1,10 +1,13 @@ -from __future__ import annotations # Needed for forward references -from .core import XurrentApiHelper, JsonSerializableDict -from typing import Optional, List, Dict, Type, TypeVar - -from enum import Enum - -class PeoplePredefinedFilter(str, Enum): +from __future__ import annotations # Needed for forward references +from .core import XurrentApiHelper, JsonSerializableDict +from typing import Optional, List, Dict, Type, TypeVar, TYPE_CHECKING + +from enum import Enum + +if TYPE_CHECKING: + from .teams import Team + +class PeoplePredefinedFilter(str, Enum): disabled = "disabled" # List all disabled people enabled = "enabled" # List all enabled people internal = "internal" # List all internal people @@ -80,7 +83,7 @@ def get_people(cls, connection_object: XurrentApiHelper, predefinedFilter: Peopl response = connection_object.api_call(uri, 'GET') return [cls.from_data(connection_object, person) for person in response] - def get_teams(self) -> List[Team]: + def get_teams(self) -> List["Team"]: """ Retrieve the teams of the person. """ @@ -198,4 +201,3 @@ def get_skill_pools(self) -> List: response = self._connection_object.api_call(uri, 'GET') return [SkillPool.from_data(self._connection_object, sp) for sp in response] - \ No newline at end of file diff --git a/src/xurrent/requests.py b/src/xurrent/requests.py index 5e8ca39..dced334 100644 --- a/src/xurrent/requests.py +++ b/src/xurrent/requests.py @@ -1,10 +1,14 @@ from __future__ import annotations # Needed for forward references from .core import XurrentApiHelper, JsonSerializableDict -from .people import Person -from .teams import Team from enum import Enum from datetime import datetime -from typing import Optional, List, Dict, Type, TypeVar +from typing import Optional, List, Dict, Type, TypeVar, TYPE_CHECKING + +if TYPE_CHECKING: + from .configuration_items import ConfigurationItem + from .people import Person + from .teams import Team + from .workflows import Workflow class RequestCategory(str, Enum): incident = "incident" # Incident - Request for Incident Resolution @@ -77,13 +81,13 @@ class Request(JsonSerializableDict): #https://developer.xurrent.com/v1/requests/ __resourceUrl__ = 'requests' __references__ = ['workflow', 'requested_by', 'requested_for', 'created_by', 'member', 'team'] - workflow: Optional[Workflow] - requested_by: Optional[Person] - requested_for: Optional[Person] - created_by: Optional[Person] + workflow: Optional["Workflow"] + requested_by: Optional["Person"] + requested_for: Optional["Person"] + created_by: Optional["Person"] category: Optional[RequestCategory] status: Optional[RequestStatus] - team: Optional[Team] + team: Optional["Team"] def __init__(self, connection_object: XurrentApiHelper, @@ -97,15 +101,15 @@ def __init__(self, next_target_at: Optional[datetime] = None, completed_at: Optional[datetime] = None, team: Optional[Dict[str, str]] = None, - member: Optional[Person] = None, + member: Optional["Person"] = None, grouped_into: Optional[int] = None, service_instance: Optional[Dict[str, str]] = None, created_at: Optional[datetime] = None, updated_at: Optional[datetime] = None, - workflow: Optional[Workflow] = None, - requested_by: Optional[Person] = None, - requested_for: Optional[Person] = None, - created_by: Optional[Person] = None, + workflow: Optional["Workflow"] = None, + requested_by: Optional["Person"] = None, + requested_for: Optional["Person"] = None, + created_by: Optional["Person"] = None, **kwargs): self.id = id self._connection_object = connection_object # Private attribute for connection object @@ -129,6 +133,7 @@ def __init__(self, self.requested_by = requested_by if isinstance(requested_by, Person) else Person.from_data(connection_object, requested_by) if requested_by else None self.requested_for = requested_for if isinstance(requested_for, Person) else Person.from_data(connection_object, requested_for) if requested_for else None self.created_by = created_by if isinstance(created_by, Person) else Person.from_data(connection_object, created_by) if created_by else None + from .teams import Team self.team = team if isinstance(team, Team) else Team.from_data(connection_object, team) if team else None @@ -336,7 +341,7 @@ def create(cls, connection_object: XurrentApiHelper, data: dict): # Developer Documentation: https://developer.xurrent.com/v1/requests/cis @classmethod - def get_cis_by_request_id(cls, connection_object: XurrentApiHelper, request_id: int) -> List[ConfigurationItem]: + def get_cis_by_request_id(cls, connection_object: XurrentApiHelper, request_id: int) -> List["ConfigurationItem"]: """ Retrieve configuration items associated with a request. @@ -383,7 +388,7 @@ def remove_ci_from_request_by_id(cls, connection_object: XurrentApiHelper, reque except Exception as e: return False - def get_cis(self) -> List[ConfigurationItem]: + def get_cis(self) -> List["ConfigurationItem"]: """ Retrieve configuration items associated with this request instance. @@ -455,4 +460,3 @@ def get_watches(self) -> List[dict]: """Retrieve all watches on this request instance.""" uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/watches' return self._connection_object.api_call(uri, 'GET') - diff --git a/src/xurrent/tasks.py b/src/xurrent/tasks.py index 4f1ab5f..c1202e3 100644 --- a/src/xurrent/tasks.py +++ b/src/xurrent/tasks.py @@ -1,7 +1,10 @@ +from __future__ import annotations from .core import XurrentApiHelper, JsonSerializableDict -from .workflows import Workflow from enum import Enum -from typing import Optional, List, Dict, Type, TypeVar +from typing import Optional, List, Dict, Type, TypeVar, TYPE_CHECKING + +if TYPE_CHECKING: + from .workflows import Workflow T = TypeVar('T', bound='Task') @@ -73,22 +76,25 @@ def get_tasks(cls, connection_object: XurrentApiHelper, predefinedFilter: TaskPr if predefinedFilter: uri = f'{uri}/{predefinedFilter}' if queryfilter: - uri += '?' + self._connection_object.create_filter_string(queryfilter) - return connection_object.api_call(uri, 'GET') + uri += '?' + connection_object.create_filter_string(queryfilter) + response = connection_object.api_call(uri, 'GET') + return [cls.from_data(connection_object, task) for task in response] @staticmethod - def get_workflow_of_task(connection_object: XurrentApiHelper, id, expand: bool = False) -> Workflow: + def get_workflow_of_task(connection_object: XurrentApiHelper, id, expand: bool = False) -> "Workflow": + from .workflows import Workflow task = Task.get_by_id(connection_object, id) if expand: return Workflow.get_by_id(connection_object, task.workflow.id) return Workflow.from_data(connection_object, task.workflow) - def get_workflow(self, expand: bool = False) -> Workflow: - if task.workflow and not expand: + def get_workflow(self, expand: bool = False) -> "Workflow": + from .workflows import Workflow + if self.workflow and not expand: return Workflow.from_data(self._connection_object, self.workflow) - elif task.workflow and expand: + elif self.workflow and expand: return Workflow.get_by_id(self._connection_object, self.workflow.id) - elif not task.workflow: + elif not self.workflow: return Task.get_workflow_of_task(self._connection_object, self.id, expand) diff --git a/src/xurrent/workflows.py b/src/xurrent/workflows.py index bcf0dc9..9459dc0 100644 --- a/src/xurrent/workflows.py +++ b/src/xurrent/workflows.py @@ -1,9 +1,12 @@ from __future__ import annotations # Needed for forward references from datetime import datetime -from typing import Optional, List, Dict +from typing import Optional, List, Dict, TYPE_CHECKING from .core import XurrentApiHelper, JsonSerializableDict from enum import Enum +if TYPE_CHECKING: + from .tasks import Task + class WorkflowCompletionReason(str, Enum): withdrawn = "withdrawn" # Withdrawn - Withdrawn by Requester rejected = "rejected" # Rejected - Rejected by Approver @@ -95,7 +98,7 @@ def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> dict: return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) @classmethod - def get_workflows(cls, connection_object: XurrentApiHelper, predefinedFilter: WorkflowPredefinedFilter = None, queryfilter: dict = None) -> List[Workflow]: + def get_workflows(cls, connection_object: XurrentApiHelper, predefinedFilter: WorkflowPredefinedFilter = None, queryfilter: dict = None) -> List["Workflow"]: """ Retrieve all workflows. """ @@ -108,14 +111,14 @@ def get_workflows(cls, connection_object: XurrentApiHelper, predefinedFilter: Wo return [cls.from_data(connection_object, workflow) for workflow in response] @classmethod - def get_workflow_tasks_by_workflow_id(cls, connection_object: XurrentApiHelper, id: int, queryfilter: dict = None) -> List[Task]: + def get_workflow_tasks_by_workflow_id(cls, connection_object: XurrentApiHelper, id: int, queryfilter: dict = None) -> List["Task"]: """ Retrieve all tasks associated with a workflow by its ID. """ workflow = Workflow(connection_object, id) return workflow.get_tasks(queryfilter=queryfilter) - def get_tasks(self, queryfilter: dict = None) -> List[Task]: + def get_tasks(self, queryfilter: dict = None) -> List["Task"]: """ Retrieve all tasks associated with the current workflow instance. """ @@ -127,14 +130,14 @@ def get_tasks(self, queryfilter: dict = None) -> List[Task]: return [Task.from_data(self._connection_object, task) for task in response] @classmethod - def get_workflow_task_by_template_id(cls, connection_object: XurrentApiHelper, workflowID: int, templateID: int) -> List[Task]: + def get_workflow_task_by_template_id(cls, connection_object: XurrentApiHelper, workflowID: int, templateID: int) -> List["Task"]: """ Retrieve a specific task associated with a workflow by template ID. """ workflow = Workflow(connection_object, workflowID) return workflow.get_task_by_template_id(templateID) - def get_task_by_template_id(self, templateID: int) -> List[Task]: + def get_task_by_template_id(self, templateID: int) -> List["Task"]: """ Retrieve a specific task associated with the current workflow by template ID. """ @@ -264,4 +267,3 @@ def get_problems(self) -> List: uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/problems' response = self._connection_object.api_call(uri, 'GET') return [Problem.from_data(self._connection_object, p) for p in response] - From 9961d9a060bdbd23817233c385efef7892547c0d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 15:14:04 +0000 Subject: [PATCH 17/18] fix search encoding and release tag parsing Agent-Logs-Url: https://github.com/fasteiner/xurrent-python/sessions/af4e838f-ec7d-4f44-90d9-a48f0e7c8a1e Co-authored-by: fasteiner <75947402+fasteiner@users.noreply.github.com> --- .github/workflows/release.yml | 31 +++++++++++----------- CHANGELOG.md | 2 ++ src/xurrent/core.py | 6 +++-- tests/unit_tests/test_new_sub_resources.py | 18 ++++++++++--- 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9400791..989b5e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,20 +87,21 @@ jobs: - name: Determine Effective Version id: effective-version run: | - if [[ "${{ env.python_changed }}" == "true" ]]; then - EFFECTIVE_VERSION="${{ steps.gitversion.outputs.semVer }}" - else - PREV_TAG=$(git describe --tags --abbrev=0) - echo "Previous tag: $PREV_TAG" - - MAJOR=$(echo $PREV_TAG | cut -d. -f1) - MINOR=$(echo $PREV_TAG | cut -d. -f2) - PATCH=$(echo $PREV_TAG | cut -d. -f3) - NEW_PATCH=$((PATCH + 1)) - - EFFECTIVE_VERSION="$MAJOR.$MINOR.$NEW_PATCH" - echo "Forcing patch bump: $EFFECTIVE_VERSION" - fi + if [[ "${{ env.python_changed }}" == "true" ]]; then + EFFECTIVE_VERSION="${{ steps.gitversion.outputs.semVer }}" + else + PREV_TAG=$(git describe --tags --abbrev=0) + echo "Previous tag: $PREV_TAG" + CLEAN_TAG=${PREV_TAG#v} + + MAJOR=$(echo $CLEAN_TAG | cut -d. -f1) + MINOR=$(echo $CLEAN_TAG | cut -d. -f2) + PATCH=$(echo $CLEAN_TAG | cut -d. -f3) + NEW_PATCH=$((PATCH + 1)) + + EFFECTIVE_VERSION="$MAJOR.$MINOR.$NEW_PATCH" + echo "Forcing patch bump: $EFFECTIVE_VERSION" + fi echo "effective_version=$EFFECTIVE_VERSION" >> $GITHUB_ENV echo "effective_version=$EFFECTIVE_VERSION" >> $GITHUB_OUTPUT @@ -197,4 +198,4 @@ jobs: - name: Publish release distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - packages-dir: dist/ \ No newline at end of file + packages-dir: dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ecf497..eac0a3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Core/People/Requests/Tasks/Workflows: restored lazy-loaded cross-module references in type annotations and fixed `Task` sub-resource helper name resolution so flake8 no longer reports `F821` undefined names. +- Core: URL-encode `search()` query parameters so spaces and reserved characters do not produce ambiguous request URLs. +- CI: strip an optional leading `v` from release tags before computing the forced patch bump in `release.yml`. ## [0.11.0] - 2026-04-13 diff --git a/src/xurrent/core.py b/src/xurrent/core.py index a7f75cd..a00f69a 100644 --- a/src/xurrent/core.py +++ b/src/xurrent/core.py @@ -7,6 +7,7 @@ import json import re import base64 +from urllib.parse import urlencode from logging import Logger from typing import Optional, List, TYPE_CHECKING @@ -399,9 +400,10 @@ def search(self, query: str, types: list = None) -> list: :param types: Optional list of resource types to search (e.g. ['request', 'person']) :return: List of search results """ - uri = f'/search?q={query}' + params = {'q': query} if types: - uri += '&types=' + ','.join(types) + params['types'] = ','.join(types) + uri = f"/search?{urlencode(params)}" return self.api_call(uri, 'GET') def bulk_import(self, data: str, import_type: str, import_format: str = 'csv') -> dict: diff --git a/tests/unit_tests/test_new_sub_resources.py b/tests/unit_tests/test_new_sub_resources.py index 34a9e5c..2c63fb3 100644 --- a/tests/unit_tests/test_new_sub_resources.py +++ b/tests/unit_tests/test_new_sub_resources.py @@ -2,6 +2,7 @@ import os import sys from unittest.mock import MagicMock, patch +from urllib.parse import urlencode sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) @@ -523,10 +524,10 @@ def test_holiday_get_calendars(mock_connection, holiday_instance): def test_search(mock_connection): mock_connection.api_call.return_value = [{"id": 1, "type": "request"}] mock_connection.search = lambda query, types=None: mock_connection.api_call( - f"/search?q={query}", "GET" + f"/search?{urlencode({'q': query})}", "GET" ) result = mock_connection.search("password reset") - mock_connection.api_call.assert_called_once_with("/search?q=password reset", "GET") + mock_connection.api_call.assert_called_once_with("/search?q=password+reset", "GET") assert result == [{"id": 1, "type": "request"}] @@ -536,7 +537,7 @@ def test_search_core(): ) helper.api_call = MagicMock(return_value=[{"id": 1}]) result = helper.search("test query") - helper.api_call.assert_called_once_with("/search?q=test query", "GET") + helper.api_call.assert_called_once_with("/search?q=test+query", "GET") assert result == [{"id": 1}] @@ -546,7 +547,16 @@ def test_search_with_types_core(): ) helper.api_call = MagicMock(return_value=[]) helper.search("test query", types=["request", "person"]) - helper.api_call.assert_called_once_with("/search?q=test query&types=request,person", "GET") + helper.api_call.assert_called_once_with("/search?q=test+query&types=request%2Cperson", "GET") + + +def test_search_encodes_reserved_characters_core(): + helper = XurrentApiHelper( + "https://api.example.com", api_key="key", api_account="acct", resolve_user=False + ) + helper.api_call = MagicMock(return_value=[]) + helper.search("a & b? c", types=["request"]) + helper.api_call.assert_called_once_with("/search?q=a+%26+b%3F+c&types=request", "GET") def test_bulk_import_core(): From b7af646ebbdae0ca9d3e78343bfb4f351b15c193 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 15:17:33 +0000 Subject: [PATCH 18/18] Update pyproject.toml for release 0.13.0-preview.23 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9319c67..3cc3462 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "xurrent" -version = "0.13.0-preview.10" +version = "0.13.0-preview.23" authors = [ { name="Fabian Steiner", email="fabian@stei-ner.net" }, ] @@ -18,7 +18,7 @@ Homepage = "https://github.com/fasteiner/xurrent-python" Issues = "https://github.com/fasteiner/xurrent-python/issues" [tool.poetry] name = "xurrent" -version = "0.13.0-preview.10" +version = "0.13.0-preview.23" description = "A python module to interact with the Xurrent API." authors = ["Ing. Fabian Franz Steiner BSc. "] readme = "README.md"