From 8532107b465bcd2bd8d0e042a9847f9a936b8d06 Mon Sep 17 00:00:00 2001 From: "Ing. Fabian Franz Steiner BSc." Date: Tue, 10 Dec 2024 14:31:22 +0100 Subject: [PATCH 1/7] update test script --- .github/workflows/python-package.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 41b2cd2..2f8c1c9 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -26,14 +26,14 @@ jobs: python-version: ${{ matrix.python-version }} - name: Set environment variables env: - DEMO_API_TOKEN: ${{ secrets.DEMO_API_TOKEN }} - DEMO_APIACCOUNT: ${{ vars.DEMO_APIACCOUNT }} - DEMO_APIURL: ${{ vars.DEMO_APIURL }} + API_TOKEN: ${{ secrets.DEMO_API_TOKEN }} + APIACCOUNT: ${{ vars.DEMO_APIACCOUNT }} + APIURL: ${{ vars.DEMO_APIURL }} run: | echo "Environment variables set:" - echo "DEMO_API_TOKEN=${{ secrets.DEMO_API_TOKEN }}" # Avoid printing sensitive secrets in real workflows - echo "DEMO_APIACCOUNT=${{ vars.DEMO_APIACCOUNT }}" - echo "DEMO_APIURL=${{ vars.DEMO_APIURL }}" + echo "APITOKEN=*******" + echo "APIACCOUNT=${{ vars.DEMO_APIACCOUNT }}" + echo "APIURL=${{ vars.DEMO_APIURL }}" - name: Install dependencies run: | python -m pip install --upgrade pip From f18f206aff38b9af81ddc3f1421b8e47b62a5caa Mon Sep 17 00:00:00 2001 From: "Ing. Fabian Franz Steiner BSc." Date: Tue, 10 Dec 2024 18:04:26 +0100 Subject: [PATCH 2/7] Update version to 0.0.2.9; add new features and bug fixes, enhance JSON serialization for datetime and lists, and implement reference string methods for core classes --- .vscode/settings.json | 1 + ChangeLog.md | 16 +++++ pyproject.toml | 2 +- src/xurrent/core.py | 6 ++ src/xurrent/people.py | 10 ++- src/xurrent/requests.py | 93 ++++++++++++++++++++++++---- src/xurrent/tasks.py | 10 ++- src/xurrent/workflows.py | 8 ++- tests/requests_test.py | 127 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 255 insertions(+), 18 deletions(-) create mode 100644 tests/requests_test.py diff --git a/.vscode/settings.json b/.vscode/settings.json index ad7af29..1265994 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { + "editor.wordWrap": "on", "python.testing.pytestArgs": [ "tests" ], diff --git a/ChangeLog.md b/ChangeLog.md index d168c39..81c46d6 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,5 +1,21 @@ # Change Log +## v0.0.2.9 + +### New Features + +- Request, Workflow, Task, Person: add non static methods: ref_str() --> return a reference string +- core: JSONSerializableDict: handle datetime and list of objects + +### Bugfixes + +- Person, Workflow, Task: inherit JsonSerializableDict --> make serializable +- Request: close: make it possible to close a without a note (using default note) + +### Breaking Changes + +- Request: request.created_by, request.requested_by, request.requested_for, request.member are now Person objects + ## v0.0.2.8 ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index c7e5e4d..716d3cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "xurrent" -version = "0.0.2.8" +version = "0.0.2.9" authors = [ { name="Fabian Steiner", email="fabian@stei-ner.net" }, ] diff --git a/src/xurrent/core.py b/src/xurrent/core.py index 987835a..5b9080d 100644 --- a/src/xurrent/core.py +++ b/src/xurrent/core.py @@ -1,4 +1,5 @@ from __future__ import annotations # Needed for forward references +from datetime import datetime import requests import logging import json @@ -17,6 +18,11 @@ def to_dict(self) -> dict: # Recursively call to_dict on nested JsonSerializableDict objects if isinstance(value, JsonSerializableDict): result[key] = value.to_dict() + elif isinstance(value, list): + #call to_dict on each item in the list + result[key] = [item.to_dict() for item in value] + elif isinstance(value, datetime): + result[key] = value.isoformat() else: result[key] = value return result diff --git a/src/xurrent/people.py b/src/xurrent/people.py index 4c070b5..9a4ab0b 100644 --- a/src/xurrent/people.py +++ b/src/xurrent/people.py @@ -1,4 +1,4 @@ -from .core import XurrentApiHelper +from .core import XurrentApiHelper, JsonSerializableDict from typing import Optional, List, Dict, Type, TypeVar from enum import Enum @@ -14,7 +14,7 @@ class PeoplePredefinedFilter(str, Enum): T = TypeVar('T', bound='Person') -class Person(): +class Person(JsonSerializableDict): #https://developer.4me.com/v1/people/ resourceUrl = 'people' @@ -38,6 +38,12 @@ def __str__(self) -> str: """ return f"Person(id={self.id}, name={self.name}, primary_email={self.primary_email})" + def ref_str(self) -> str: + """ + Return a string representation of the object. + """ + return f"Person(id={self.id}, name={self.name})" + @classmethod def from_data(cls, connection_object: XurrentApiHelper, data) -> T: if not isinstance(data, dict): diff --git a/src/xurrent/requests.py b/src/xurrent/requests.py index 72ec312..1589720 100644 --- a/src/xurrent/requests.py +++ b/src/xurrent/requests.py @@ -1,6 +1,5 @@ from __future__ import annotations # Needed for forward references -from .core import XurrentApiHelper -from .core import JsonSerializableDict +from .core import XurrentApiHelper, JsonSerializableDict from enum import Enum from datetime import datetime from typing import Optional, List, Dict, Type, TypeVar @@ -39,6 +38,11 @@ class PredefinedNotesFilter(str, Enum): class Request(JsonSerializableDict): #https://developer.4me.com/v1/requests/ resourceUrl = 'requests' + references = ['workflow', 'requested_by', 'requested_for', 'created_by', 'member'] + workflow: Optional[Workflow] + requested_by: Optional[Person] + requested_for: Optional[Person] + created_by: Optional[Person] def __init__(self, connection_object: XurrentApiHelper, @@ -52,12 +56,15 @@ def __init__(self, next_target_at: Optional[datetime] = None, completed_at: Optional[datetime] = None, team: Optional[Dict[str, str]] = None, - member: Optional[Dict[str, str]] = 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, **kwargs): self.id = id self._connection_object = connection_object # Private attribute for connection object @@ -70,28 +77,92 @@ def __init__(self, self.next_target_at = next_target_at self.completed_at = completed_at self.team = team - self.member = member self.grouped_into = grouped_into self.service_instance = service_instance self.created_at = created_at self.updated_at = updated_at - if(workflow): - from .workflows import Workflow - self.workflow = Workflow.from_data(connection_object, workflow) + self.workflow = workflow + self.requested_by = requested_by + self.requested_for = requested_for + self.created_by = created_by + self.member = member # Initialize any additional attributes for key, value in kwargs.items(): setattr(self, key, value) def __update_object__(self, data) -> None: + """ + Update the current request instance with new data. + + :param data: Dictionary containing updated data + """ if data.get('id') != self.id: raise ValueError(f"ID mismatch: {self.id} != {data.get('id')}") for key, value in data.items(): + if key in self.references: + continue setattr(self, key, value) + self.__update_references__(workflow=data.get('workflow'), requested_by=data.get('requested_by'), requested_for=data.get('requested_for'), created_by=data.get('created_by'), member=data.get('member')) + + def __update_references__(self, workflow, requested_by, requested_for, created_by, member) -> None: + """ + Update the references of the request object. + + :param workflow: Workflow data + :param requested_by: Requested by person data + :param requested_for: Requested for person data + :param created_by: Created by person data + :param member: Member person data (who the request is assigned to) + """ + if workflow: + from .workflows import Workflow + self.workflow = Workflow.from_data(self._connection_object, workflow) + else: + self.workflow = None + if member: + from .people import Person + self.member = Person.from_data(self._connection_object, member) + else: + self.member = None + if created_by: + from .people import Person + self.created_by = Person.from_data(self._connection_object, created_by) + else: + self.created_by = None + if requested_by: + from .people import Person + if created_by and created_by.get('id') != requested_by.get('id'): + self.requested_by = Person.from_data(self._connection_object, requested_by) + else: + self.requested_by = self.created_by + else: + self.requested_by = None + if self.created_by: + self.requested_by = self.created_by + if requested_for: + from .people import Person + if requested_by.get('id') != requested_for.get('id'): + self.requested_for = Person.from_data(self._connection_object, requested_for) + else: + self.requested_for = self.requested_by + else: + self.requested_for = None + if self.requested_by: + self.requested_for = self.requested_by def __str__(self) -> str: """Provide a human-readable string representation of the object.""" - return (f"Request(id={self.id}, subject={self.subject}, category={self.category}, " - f"status={self.status}, impact={self.impact})") + output: str = f"Request(id={self.id}, subject={self.subject}, category={self.category}, status={self.status}, impact={self.impact}" + if(hasattr(self, 'created_by')): + output += f", created_by={self.created_by.ref_str()}" + if(hasattr(self, 'workflow')): + output += f", workflow={self.workflow.ref_str()}" + output += ")" + return output + + def ref_str(self) -> str: + """Provide a human-readable string representation of the object.""" + return f"Request(id={self.id}, subject={self.subject})" @classmethod def from_data(cls, connection_object: XurrentApiHelper, data) -> T: @@ -113,7 +184,7 @@ def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: """ uri = f'{connection_object.base_url}/{cls.resourceUrl}/{id}' response = connection_object.api_call(uri, 'GET') - return cls.from_data(connection_object, response) + return cls.from_data(connection_object=connection_object, data=response) @classmethod def get_requests(cls, connection_object: XurrentApiHelper, predefinedFiler: PredefinedFilter = None,queryfilter: dict = None) -> List[T]: @@ -195,7 +266,7 @@ def update_by_id(connection_object: XurrentApiHelper, id: int, data: dict) -> T: request = Request(connection_object, id) return request.update(data) - def close(self, note: str, completion_reason: CompletionReason = CompletionReason.solved): + def close(self, note: str = "Request closed over API.", completion_reason: CompletionReason = CompletionReason.solved): """ Close the current request instance. :return: Response from the API call diff --git a/src/xurrent/tasks.py b/src/xurrent/tasks.py index 46ce406..05d3925 100644 --- a/src/xurrent/tasks.py +++ b/src/xurrent/tasks.py @@ -1,4 +1,4 @@ -from .core import XurrentApiHelper +from .core import XurrentApiHelper, JsonSerializableDict from .workflows import Workflow from enum import Enum from typing import Optional, List, Dict, Type, TypeVar @@ -30,7 +30,7 @@ class TaskStatus(str, Enum): canceled = "canceled" # Canceled -class Task(): +class Task(JsonSerializableDict): #https://developer.4me.com/v1/tasks/ resourceUrl = 'tasks' @@ -53,6 +53,12 @@ def __str__(self) -> str: Return a string representation of the object. """ return f"Task(id={self.id}, subject={self.subject}, workflow={self.workflow})" + + def ref_str(self) -> str: + """ + Return a string representation of the object. + """ + return f"Task(id={self.id}, subject={self.subject})" @classmethod def from_data(cls, connection_object: XurrentApiHelper, data) -> T: diff --git a/src/xurrent/workflows.py b/src/xurrent/workflows.py index c72e486..1d14246 100644 --- a/src/xurrent/workflows.py +++ b/src/xurrent/workflows.py @@ -1,7 +1,7 @@ from __future__ import annotations # Needed for forward references from datetime import datetime from typing import Optional, List, Dict -from .core import XurrentApiHelper +from .core import XurrentApiHelper, JsonSerializableDict from enum import Enum class WorkflowCompletionReason(str, Enum): @@ -42,7 +42,7 @@ class WorkflowPredefinedFilter(str, Enum): managed_by_me = 'managed_by_me' #/workflows/managed_by_me: List all workflows which manager is the API user -class Workflow: +class Workflow(JsonSerializableDict): # Endpoint for workflows resourceUrl = 'workflows' @@ -65,6 +65,10 @@ def __str__(self) -> str: """Provide a human-readable string representation of the object.""" return (f"Workflow(id={self.id}, subject={self.subject}, status={self.status}, manager={self.manager}") + def ref_str(self) -> str: + """Provide a human-readable string representation of the object.""" + return (f"Workflow(id={self.id}, subject={self.subject})") + @classmethod def from_data(cls, connection_object: XurrentApiHelper, data: dict): if not isinstance(data, dict): diff --git a/tests/requests_test.py b/tests/requests_test.py new file mode 100644 index 0000000..cd25eb4 --- /dev/null +++ b/tests/requests_test.py @@ -0,0 +1,127 @@ +import pytest +from unittest.mock import patch, MagicMock +from datetime import datetime +import os +import sys + +# Add the `../src` directory to sys.path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"))) + +from xurrent.requests import Request, CompletionReason, PredefinedFilter, PredefinedNotesFilter +from xurrent.core import XurrentApiHelper +from xurrent.people import Person +from xurrent.workflows import Workflow + +# FILE: src/xurrent/test_requests.py + + +@pytest.fixture +def mock_connection(): + magicMock= MagicMock(spec=XurrentApiHelper) + magicMock.base_url = "https://api.example.com" + return magicMock + +@pytest.fixture +def x_api_helper(): + # Retrieve environment variables + # Load .env file only if the environment variables are not already set + if not os.getenv("APITOKEN") or not os.getenv("APIACCOUNT") or not os.getenv("APIURL"): + load_dotenv() + api_token = os.getenv("APITOKEN") # Fetches DEMO_API_TOKEN, returns None if not set + api_account = os.getenv("APIACCOUNT") + api_url = os.getenv("APIURL") + + # Check if the environment variables are properly set + if not all([api_token, api_account, api_url]): + raise EnvironmentError("One or more environment variables are missing.") + helper = XurrentApiHelper(api_url, api_token, api_account, True) + return helper + +@pytest.fixture +def request_instance(mock_connection): + return Request( + connection_object=mock_connection, + id=1, + source="source", + sourceID="sourceID", + subject="subject", + category="category", + impact="impact", + status="status", + next_target_at=datetime.now(), + completed_at=datetime.now(), + team={"name": "team"}, + member=Person(connection_object=mock_connection,id=1, name="member"), + grouped_into=2, + service_instance={"name": "service_instance"}, + created_at=datetime.now(), + updated_at=datetime.now(), + workflow=Workflow(connection_object=mock_connection,id=1, subject="workflow"), + requested_by=Person(connection_object=mock_connection,id=2, name="requested_by"), + requested_for=Person(connection_object=mock_connection,id=3, name="requested_for"), + created_by=Person(connection_object=mock_connection,id=4, name="created_by") + ) + +def test_request_initialization(request_instance): + assert isinstance(request_instance, Request) + assert request_instance.resourceUrl == "requests" + assert request_instance.id == 1 + assert request_instance.subject == "subject" + assert isinstance(request_instance.member, Person) + assert request_instance.member.name == "member" + assert isinstance(request_instance.requested_by, Person) + assert request_instance.requested_by.name == "requested_by" + assert isinstance(request_instance.requested_for, Person) + assert request_instance.requested_for.name == "requested_for" + assert isinstance(request_instance.created_by, Person) + assert request_instance.created_by.name == "created_by" + assert isinstance(request_instance.workflow, Workflow) + assert request_instance.workflow.subject == "workflow" + +def test_to_json(request_instance): + json_data = request_instance.to_json() + assert type(json_data) == str + +def test_to_string(request_instance): + workflow_ref = request_instance.workflow.ref_str() + created_by_ref = request_instance.created_by.ref_str() + assert str(request_instance) == f"Request(id=1, subject=subject, category=category, status=status, impact=impact, created_by={created_by_ref}, workflow={workflow_ref})" + + +def test_request_from_data(mock_connection): + request_data = { + "id": 1, + "source": "source", + "sourceID": "sourceID", + "subject": "subject", + "category": "category", + "impact": "impact", + "status": "status", + "next_target_at": datetime.now(), + "completed_at": datetime.now(), + "team": {"name": "team"}, + "member": {"id": 1, "name": "member"}, + "grouped_into": 2, + "service_instance": {"name": "service_instance"}, + "created_at": datetime.now(), + "updated_at": datetime.now(), + "workflow": {"id": 1, "subject": "workflow"}, + "requested_by": {"id": 2, "name": "requested_by"}, + "requested_for": {"id": 3, "name": "requested_for"}, + "created_by": {"id": 4, "name": "created_by"} + } + request = Request.from_data(mock_connection, request_data) + assert isinstance(request, Request) + assert request.id == 1 + assert request.subject == "subject" + #TODO: Member, requested_by, requested_for, created_by, workflow are not being converted correctly + assert isinstance(request.member, Person) + assert request.member.name == "member" + assert isinstance(request.requested_by, Person) + assert request.requested_by.name == "requested_by" + assert isinstance(request.requested_for, Person) + assert request.requested_for.name == "requested_for" + assert isinstance(request.created_by, Person) + assert request.created_by.name == "created_by" + assert isinstance(request.workflow, Workflow) + assert request.workflow.subject == "workflow" \ No newline at end of file From 2797f143d94734c1ceddda8600d36efa799d0df3 Mon Sep 17 00:00:00 2001 From: "Ing. Fabian Franz Steiner BSc." Date: Wed, 11 Dec 2024 11:12:17 +0100 Subject: [PATCH 3/7] Add WorkflowCategory enum and update Workflow instantiation; enhance Request class to handle Person objects --- ChangeLog.md | 3 +++ src/xurrent/requests.py | 14 +++++++++----- src/xurrent/workflows.py | 13 +++++++++++-- tests/requests_test.py | 4 ++-- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 81c46d6..5793cf2 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -6,6 +6,8 @@ - Request, Workflow, Task, Person: add non static methods: ref_str() --> return a reference string - core: JSONSerializableDict: handle datetime and list of objects +- Workflow: add WorkflowCategory enum +- Workflow: use WorkflowCategory and WorkflowStatus enums on instantiation ### Bugfixes @@ -15,6 +17,7 @@ ### Breaking Changes - Request: request.created_by, request.requested_by, request.requested_for, request.member are now Person objects +- Workflow: workflow.manager is now a Person object ## v0.0.2.8 diff --git a/src/xurrent/requests.py b/src/xurrent/requests.py index 1589720..2998816 100644 --- a/src/xurrent/requests.py +++ b/src/xurrent/requests.py @@ -81,11 +81,15 @@ def __init__(self, self.service_instance = service_instance self.created_at = created_at self.updated_at = updated_at - self.workflow = workflow - self.requested_by = requested_by - self.requested_for = requested_for - self.created_by = created_by - self.member = member + from .workflows import Workflow + self.workflow = workflow if isinstance(workflow, Workflow) else Workflow.from_data(connection_object, workflow) if workflow 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.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 + + # Initialize any additional attributes for key, value in kwargs.items(): setattr(self, key, value) diff --git a/src/xurrent/workflows.py b/src/xurrent/workflows.py index 1d14246..d2aa300 100644 --- a/src/xurrent/workflows.py +++ b/src/xurrent/workflows.py @@ -32,6 +32,12 @@ def is_valid_workflow_status(cls, value): except KeyError: return False +class WorkflowCategory(str, Enum): + standard = "standard" # Standard - Approved Workflow Template Was Used + non_standard = "non_standard" # Non-Standard - Approved Workflow Template Not Available + emergency = "emergency" # Emergency - Required for Incident Resolution + order = "order" # Order - Organization Order Workflow + class WorkflowPredefinedFilter(str, Enum): """ @@ -52,12 +58,15 @@ def __init__(self, subject: Optional[str] = None, status: Optional[str] = None, manager: Optional[Dict] = None, + category: Optional[WorkflowCategory] = None, **kwargs): self.id = id self._connection_object = connection_object self.subject = subject - self.status = status - self.manager = manager + self.status = WorkflowStatus(status) if status else None + self.category = WorkflowCategory(category) if category else None + 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) diff --git a/tests/requests_test.py b/tests/requests_test.py index cd25eb4..011c5eb 100644 --- a/tests/requests_test.py +++ b/tests/requests_test.py @@ -114,7 +114,6 @@ def test_request_from_data(mock_connection): assert isinstance(request, Request) assert request.id == 1 assert request.subject == "subject" - #TODO: Member, requested_by, requested_for, created_by, workflow are not being converted correctly assert isinstance(request.member, Person) assert request.member.name == "member" assert isinstance(request.requested_by, Person) @@ -124,4 +123,5 @@ def test_request_from_data(mock_connection): assert isinstance(request.created_by, Person) assert request.created_by.name == "created_by" assert isinstance(request.workflow, Workflow) - assert request.workflow.subject == "workflow" \ No newline at end of file + assert request.workflow.subject == "workflow" + From 3cbe0dbaa9097123c3c3e8ac94a1b3f452c2c0c6 Mon Sep 17 00:00:00 2001 From: "Ing. Fabian Franz Steiner BSc." Date: Wed, 11 Dec 2024 12:36:36 +0100 Subject: [PATCH 4/7] Update Python workflow to include 'mock' dependency and add test for retrieving request by ID --- .github/workflows/python-package.yml | 2 +- tests/requests_test.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 2f8c1c9..118783d 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -37,7 +37,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install flake8 pytest python-dotenv + python -m pip install flake8 pytest python-dotenv mock if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Test with pytest env: diff --git a/tests/requests_test.py b/tests/requests_test.py index 011c5eb..59054f1 100644 --- a/tests/requests_test.py +++ b/tests/requests_test.py @@ -125,3 +125,20 @@ def test_request_from_data(mock_connection): assert isinstance(request.workflow, Workflow) assert request.workflow.subject == "workflow" +def test_get_request_by_id(mock_connection): + resource_id = 123 + subject = "Test subject" + mock_response = {'id': resource_id, 'subject': subject} + mock_connection.api_call.return_value = mock_response + + + # Act + result = Request.get_by_id(connection_object=mock_connection, id=resource_id) + + # Assert + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/requests/{resource_id}", "GET" + ) + assert isinstance(result, Request) # Ensure the result is of type `Request` + assert result.id == resource_id # Ensure the ID matches the expected value + assert result.subject == subject # Ensure the subject matches the expected value \ No newline at end of file From 986f44a579a3435b1338f254226bea09664e9a54 Mon Sep 17 00:00:00 2001 From: "Ing. Fabian Franz Steiner BSc." Date: Wed, 11 Dec 2024 20:20:09 +0100 Subject: [PATCH 5/7] Add contributing guidelines, pre-commit configuration, and enhance API helper with user teams --- .gitignore | 3 + .pre-commit-config.yaml | 8 + .vscode/extensions.json | 6 + ChangeLog.md | 8 +- Contributing.md | 16 ++ README.md | 4 + pyproject.toml | 25 ++- src/xurrent/core.py | 2 + src/xurrent/people.py | 37 ++-- src/xurrent/requests.py | 154 +++++++------- src/xurrent/tasks.py | 19 +- src/xurrent/teams.py | 114 +++++++++++ src/xurrent/workflows.py | 43 ++-- tests/{ => integration}/core_test.py | 8 +- tests/integration/requests_test.py | 90 +++++++++ tests/requests_test.py | 144 ------------- tests/unit_tests/requests_unit_test.py | 267 +++++++++++++++++++++++++ 17 files changed, 656 insertions(+), 292 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 .vscode/extensions.json create mode 100644 Contributing.md create mode 100644 src/xurrent/teams.py rename tests/{ => integration}/core_test.py (84%) create mode 100644 tests/integration/requests_test.py delete mode 100644 tests/requests_test.py create mode 100644 tests/unit_tests/requests_unit_test.py diff --git a/.gitignore b/.gitignore index b06f8a2..6898463 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,6 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# poetry +poetry.lock \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..26fb6d4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,8 @@ +repos: + - repo: local + hooks: + - id: run-tests + name: Run unit tests + entry: pytest ./tests/unit_tests + language: system + types: [python] \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..7494b87 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "elagil.pre-commit-helper", + "ms-python.python" + ] +} \ No newline at end of file diff --git a/ChangeLog.md b/ChangeLog.md index 5793cf2..485e335 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -4,10 +4,16 @@ ### New Features -- Request, Workflow, Task, Person: add non static methods: ref_str() --> return a reference string +- Request, Workflow, Task, Person, Team: add non static methods: ref_str() --> return a reference string +- Request: add RequestCategory enum - core: JSONSerializableDict: handle datetime and list of objects - Workflow: add WorkflowCategory enum - Workflow: use WorkflowCategory and WorkflowStatus enums on instantiation +- Team: add Team class +- Team: add enum TeamPredefinedFilter +- People: add non static methods: get_teams +- Tests: add tests for Request +- Tests: add pre-commit hooks yaml file ### Bugfixes diff --git a/Contributing.md b/Contributing.md new file mode 100644 index 0000000..26139bb --- /dev/null +++ b/Contributing.md @@ -0,0 +1,16 @@ +# Contributing + +## Setup + +1. Clone the repository +2. pip install poetry +3. poetry install --with dev +4. poetry shell +5. pre-commit install + +## Activate the virtual environment + +```bash +poetry shell +``` + diff --git a/README.md b/README.md index 6101e6a..c5dca94 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ This module is used to interact with the Xurrent API. It provides a set of class [ChangeLog.md](https://github.com/fasteiner/xurrent-python/blob/main/ChangeLog.md) +## Contributing + +[Contributing.md](Contributing.md) + ## Usage ### Basic Usage diff --git a/pyproject.toml b/pyproject.toml index 716d3cd..9eb65d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [ ] description = "A python module to interact with the Xurrent API." readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.9" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", @@ -15,4 +15,25 @@ classifiers = [ [project.urls] Homepage = "https://github.com/fasteiner/xurrent-python" -Issues = "https://github.com/fasteiner/xurrent-python/issues" \ No newline at end of file +Issues = "https://github.com/fasteiner/xurrent-python/issues" +[tool.poetry] +name = "xurrent" +version = "0.0.2.9" +description = "A python module to interact with the Xurrent API." +authors = ["Ing. Fabian Franz Steiner BSc. "] +readme = "README.md" + +[tool.poetry.dependencies] +python = ">=3.9" +requests = "^2.32.3" + + +[tool.poetry.group.dev.dependencies] +pytest = "^8.3.4" +python-dotenv = "^1.0.1" +mock = "^5.1.0" +pre-commit = "^4.0.1" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/src/xurrent/core.py b/src/xurrent/core.py index 5b9080d..f69f923 100644 --- a/src/xurrent/core.py +++ b/src/xurrent/core.py @@ -34,6 +34,7 @@ def to_json(self): class XurrentApiHelper: api_user: Person # Forward declaration with a string + api_user_teams: List[Team] # Forward declaration with a string def __init__(self, base_url, api_key, api_account, resolve_user=True): self.base_url = base_url @@ -44,6 +45,7 @@ def __init__(self, base_url, api_key, api_account, resolve_user=True): # Import Person lazily from .people import Person self.api_user = Person.get_me(self) + self.api_user_teams = self.api_user.get_teams() def __append_per_page(self, uri, per_page=100): """ diff --git a/src/xurrent/people.py b/src/xurrent/people.py index 9a4ab0b..0942b7e 100644 --- a/src/xurrent/people.py +++ b/src/xurrent/people.py @@ -1,3 +1,4 @@ +from __future__ import annotations # Needed for forward references from .core import XurrentApiHelper, JsonSerializableDict from typing import Optional, List, Dict, Type, TypeVar @@ -16,7 +17,7 @@ class PeoplePredefinedFilter(str, Enum): class Person(JsonSerializableDict): #https://developer.4me.com/v1/people/ - resourceUrl = 'people' + __resourceUrl__ = 'people' def __init__(self, connection_object: XurrentApiHelper, id, name: str = None, primary_email: str = None,**kwargs): self._connection_object = connection_object @@ -26,12 +27,6 @@ def __init__(self, connection_object: XurrentApiHelper, id, name: str = None, pr for key, value in kwargs.items(): setattr(self, key, value) - def __update_object__(self, data) -> None: - if data.get('id') != self.id: - raise ValueError(f"ID mismatch: {self.id} != {data.get('id')}") - for key, value in data.items(): - setattr(self, key, value) - def __str__(self) -> str: """ Return a string representation of the object. @@ -54,7 +49,7 @@ def from_data(cls, connection_object: XurrentApiHelper, data) -> T: @classmethod def get_by_id(cls, connection_object: XurrentApiHelper, id): - uri = f'{connection_object.base_url}/{cls.resourceUrl}/{id}' + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) @classmethod @@ -62,24 +57,32 @@ def get_me(cls, connection_object: XurrentApiHelper): """ Retrieve the person object for the authenticated user. """ - uri = f'{connection_object.base_url}/{cls.resourceUrl}/me' + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/me' return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) @classmethod def get_people(cls, connection_object: XurrentApiHelper, predefinedFilter: PeoplePredefinedFilter = None, queryfilter: dict = None) -> List[T]: - uri = f'{connection_object.base_url}/{cls.resourceUrl}' + 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, person) for person in response] + + def get_teams(self) -> List[Team]: + """ + Retrieve the teams of the person. + """ + from .teams import Team + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/teams' + response = self._connection_object.api_call(uri, 'GET') + return [Team.from_data(self._connection_object, team) for team in response] def update(self, data): - uri = f'{self._connection_object.base_url}/{self.resourceUrl}/{self.id}' + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' response = self._connection_object.api_call(uri, 'PATCH', data) - self.__update_object__(response) - return self + return People.from_data(self._connection_object,response) def disable(self, prefix: str = '', postfix: str = ''): """ @@ -110,28 +113,28 @@ def create(cls, connection_object: XurrentApiHelper, data: dict): :param connection_object: Xurrent Connection object :param data: Data dictionary (containing the data for the new person) """ - uri = f'{connection_object.base_url}/{cls.resourceUrl}' + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' return cls.from_data(connection_object, connection_object.api_call(uri, 'POST', data)) def archive(self): """ Archive the person. """ - uri = f'{self._connection_object.base_url}/{self.resourceUrl}/{self.id}/archive' + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/archive' return self._connection_object.api_call(uri, 'POST') def trash(self): """ Trash the person. """ - uri = f'{self._connection_object.base_url}/{self.resourceUrl}/{self.id}/trash' + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/trash' return self._connection_object.api_call(uri, 'POST') def restore(self): """ Restore the person. """ - uri = f'{self._connection_object.base_url}/{self.resourceUrl}/{self.id}/restore' + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/restore' return self._connection_object.api_call(uri, 'POST') \ No newline at end of file diff --git a/src/xurrent/requests.py b/src/xurrent/requests.py index 2998816..4e6e783 100644 --- a/src/xurrent/requests.py +++ b/src/xurrent/requests.py @@ -1,9 +1,35 @@ 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 +class RequestCategory(str, Enum): + incident = "incident" # Incident - Request for Incident Resolution + rfc = "rfc" # RFC - Request for Change + rfi = "rfi" # RFI - Request for Information + reservation = "reservation" # Reservation - Request for Reservation + order = "order" # Order - Request for Purchase + fulfillment = "fulfillment" # Fulfillment - Request for Order Fulfillment + complaint = "complaint" # Complaint - Request for Support Improvement + compliment = "compliment" # Compliment - Request for Bestowal of Praise + other = "other" # Other - Request is Out of Scope + +class RequestStatus(str, Enum): + declined = "declined" # Declined + on_backlog = "on_backlog" # On Backlog + assigned = "assigned" # Assigned + accepted = "accepted" # Accepted + in_progress = "in_progress" # In Progress + waiting_for = "waiting_for" # Waiting for… + waiting_for_customer = "waiting_for_customer" # Waiting for Customer + reservation_pending = "reservation_pending" # Reservation Pending + workflow_pending = "workflow_pending" # Workflow Pending + project_pending = "project_pending" # Project Pending + completed = "completed" # Completed + class CompletionReason(str, Enum): solved = "solved" # Solved - Root Cause Analysis Not Required workaround = "workaround" # Workaround - Root Cause Not Removed @@ -37,12 +63,15 @@ class PredefinedNotesFilter(str, Enum): class Request(JsonSerializableDict): #https://developer.4me.com/v1/requests/ - resourceUrl = 'requests' - references = ['workflow', 'requested_by', 'requested_for', 'created_by', 'member'] + __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] + category: Optional[RequestCategory] + status: Optional[RequestStatus] + team: Optional[Team] def __init__(self, connection_object: XurrentApiHelper, @@ -71,7 +100,7 @@ def __init__(self, self.source = source self.sourceID = sourceID self.subject = subject - self.category = category + self.category = RequestCategory(category) if isinstance(category, str) else category if category else None self.impact = impact self.status = status self.next_target_at = next_target_at @@ -88,78 +117,21 @@ 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 + self.team = team if isinstance(team, Team) else Team.from_data(connection_object, team) if team else None # Initialize any additional attributes for key, value in kwargs.items(): setattr(self, key, value) - def __update_object__(self, data) -> None: - """ - Update the current request instance with new data. - - :param data: Dictionary containing updated data - """ - if data.get('id') != self.id: - raise ValueError(f"ID mismatch: {self.id} != {data.get('id')}") - for key, value in data.items(): - if key in self.references: - continue - setattr(self, key, value) - self.__update_references__(workflow=data.get('workflow'), requested_by=data.get('requested_by'), requested_for=data.get('requested_for'), created_by=data.get('created_by'), member=data.get('member')) - - def __update_references__(self, workflow, requested_by, requested_for, created_by, member) -> None: - """ - Update the references of the request object. - - :param workflow: Workflow data - :param requested_by: Requested by person data - :param requested_for: Requested for person data - :param created_by: Created by person data - :param member: Member person data (who the request is assigned to) - """ - if workflow: - from .workflows import Workflow - self.workflow = Workflow.from_data(self._connection_object, workflow) - else: - self.workflow = None - if member: - from .people import Person - self.member = Person.from_data(self._connection_object, member) - else: - self.member = None - if created_by: - from .people import Person - self.created_by = Person.from_data(self._connection_object, created_by) - else: - self.created_by = None - if requested_by: - from .people import Person - if created_by and created_by.get('id') != requested_by.get('id'): - self.requested_by = Person.from_data(self._connection_object, requested_by) - else: - self.requested_by = self.created_by - else: - self.requested_by = None - if self.created_by: - self.requested_by = self.created_by - if requested_for: - from .people import Person - if requested_by.get('id') != requested_for.get('id'): - self.requested_for = Person.from_data(self._connection_object, requested_for) - else: - self.requested_for = self.requested_by - else: - self.requested_for = None - if self.requested_by: - self.requested_for = self.requested_by - def __str__(self) -> str: """Provide a human-readable string representation of the object.""" + from .workflows import Workflow + from .people import Person output: str = f"Request(id={self.id}, subject={self.subject}, category={self.category}, status={self.status}, impact={self.impact}" - if(hasattr(self, 'created_by')): + if(hasattr(self, 'created_by') and isinstance(self.created_by, Person)): output += f", created_by={self.created_by.ref_str()}" - if(hasattr(self, 'workflow')): + if(hasattr(self, 'workflow') and isinstance(self.workflow, Workflow)): output += f", workflow={self.workflow.ref_str()}" output += ")" return output @@ -186,7 +158,7 @@ def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> T: :param id: ID of the request to retrieve :return: Instance of Request """ - uri = f'{connection_object.base_url}/{cls.resourceUrl}/{id}' + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' response = connection_object.api_call(uri, 'GET') return cls.from_data(connection_object=connection_object, data=response) @@ -198,7 +170,7 @@ def get_requests(cls, connection_object: XurrentApiHelper, predefinedFiler: Pred :param id: ID of the request to retrieve :return: Request data """ - uri = f'{connection_object.base_url}/{cls.resourceUrl}' + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' if predefinedFiler: uri += f'/{predefinedFiler}' if queryfilter: @@ -212,7 +184,7 @@ def add_note(self, note: dict) -> dict: :param note: Dictionary containing the note data :return: Response from the API call (the note that was added) """ - uri = f'{self._connection_object.base_url}/{self.resourceUrl}/{self.id}/notes' + 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)): @@ -226,7 +198,7 @@ def get_notes(self, predefinedFilter: PredefinedNotesFilter=None, queryfilter : """ if not self.id: raise ValueError("Request instance must have an ID to get notes.") - uri = f'{self._connection_object.base_url}/{self.resourceUrl}/{self.id}/notes' + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/notes' if predefinedFilter: uri += f'/{predefinedFilter}' if queryfilter: @@ -241,8 +213,7 @@ def get_note_by_id(self, note_id) -> dict: """ if not self.id: raise ValueError("Request instance must have an ID to get notes.") - uri = f'{self._connection_object.base_url}/{self.resourceUrl}/{self.id}/notes/{note_id}' - return self._connection_object.api_call(uri, 'GET') + return self.get_notes(queryfilter={'id': note_id})[0] def update(self, data: dict): @@ -253,10 +224,16 @@ def update(self, data: dict): """ if not self.id: raise ValueError("Request instance must have an ID to update.") - uri = f'{self._connection_object.base_url}/{self.resourceUrl}/{self.id}' + # check if the category is valid + if data.get('category') and not isinstance(data.get('category'), RequestCategory): + data['category'] = RequestCategory(data.get('category')) + # check if the status is valid + if data.get('status') and not isinstance(data.get('status'), RequestStatus): + data['status'] = RequestStatus(data.get('status')) + + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' response = self._connection_object.api_call(uri, 'PATCH', data) - self.__update_object__(response) - return self + return Request.from_data(self._connection_object,response) @staticmethod def update_by_id(connection_object: XurrentApiHelper, id: int, data: dict) -> T: @@ -270,12 +247,20 @@ def update_by_id(connection_object: XurrentApiHelper, id: int, data: dict) -> T: request = Request(connection_object, id) return request.update(data) - def close(self, note: str = "Request closed over API.", completion_reason: CompletionReason = CompletionReason.solved): + def close(self, note: str = "Request closed over API.", completion_reason: CompletionReason = CompletionReason.solved, member_id: int = None, team_id: int = None): """ Close the current request instance. :return: Response from the API call """ - return self.update({'status': 'completed', 'completion_reason': completion_reason, 'note': note}) + if not member_id: + member_id = self._connection_object.api_user.id + if not member_id: + raise ValueError("Member ID must be provided to close the request.") + if not team_id: + team_id = self._connection_object.api_user_teams[0].id + if not team_id: + raise ValueError("Team ID must be provided to close the request.") + return self.update({'status': 'completed', 'completion_reason': completion_reason, 'note': note, "member_id": member_id, 'team_id': team_id}) def close_and_trash(self, note: str = "Closing and trashing request", completion_reason: CompletionReason = CompletionReason.solved): """ @@ -294,10 +279,9 @@ def archive(self): """ if not self.id: raise ValueError("Request instance must have an ID to archive.") - uri = f'{self._connection_object.base_url}/{self.resourceUrl}/{self.id}/archive' + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/archive' response = self._connection_object.api_call(uri, 'POST') - self.__update_object__(response) - return self + return Request.from_data(self._connection_object,response) def trash(self): """ @@ -308,10 +292,9 @@ def trash(self): """ if not self.id: raise ValueError("Request instance must have an ID to trash.") - uri = f'{self._connection_object.base_url}/{self.resourceUrl}/{self.id}/trash' + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/trash' response = self._connection_object.api_call(uri, 'POST') - self.__update_object__(response) - return self + return Request.from_data(self._connection_object,response) def restore(self): """ @@ -320,10 +303,9 @@ def restore(self): """ if not self.id: raise ValueError("Request instance must have an ID to restore.") - uri = f'{self._connection_object.base_url}/{self.resourceUrl}/{self.id}/restore' + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/restore' response = self._connection_object.api_call(uri, 'POST') - self.__update_object__(response) - return self + return Request.from_data(self._connection_object,response) @classmethod @@ -334,6 +316,6 @@ def create(cls, connection_object: XurrentApiHelper, data: dict): :param data: Dictionary containing request data :return: Instance of Request """ - uri = f'{connection_object.base_url}/{cls.resourceUrl}' + 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/tasks.py b/src/xurrent/tasks.py index 05d3925..1da8fb1 100644 --- a/src/xurrent/tasks.py +++ b/src/xurrent/tasks.py @@ -32,7 +32,7 @@ class TaskStatus(str, Enum): class Task(JsonSerializableDict): #https://developer.4me.com/v1/tasks/ - resourceUrl = 'tasks' + __resourceUrl__ = 'tasks' def __init__(self, connection_object: XurrentApiHelper, id, subject: str = None, workflow: dict = None,description: str = None, **kwargs): self._connection_object = connection_object @@ -41,12 +41,6 @@ def __init__(self, connection_object: XurrentApiHelper, id, subject: str = None, self.workflow = workflow for key, value in kwargs.items(): setattr(self, key, value) - - def __update_object__(self, data) -> None: - if int(data.get('id')) != int(self.id): - raise ValueError(f"ID mismatch: {self.id} != {data.get('id')}") - for key, value in data.items(): - setattr(self, key, value) def __str__(self) -> str: """ @@ -70,12 +64,12 @@ def from_data(cls, connection_object: XurrentApiHelper, data) -> T: @classmethod def get_by_id(cls, connection_object: XurrentApiHelper, id) -> T: - uri = f'{connection_object.base_url}/{Task.resourceUrl}/{id}' + uri = f'{connection_object.base_url}/{Task.__resourceUrl__}/{id}' return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) @classmethod def get_tasks(cls, connection_object: XurrentApiHelper, predefinedFilter: TaskPredefinedFilter = None, queryfilter: dict = None) -> List[T]: - uri = f'{connection_object.base_url}/{cls.resourceUrl}' + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' if predefinedFilter: uri = f'{uri}/{predefinedFilter}' if queryfilter: @@ -104,10 +98,9 @@ def update_by_id(connection_object: XurrentApiHelper, id, data) -> T: return task.update(data) def update(self, data) -> T: - uri = f'{self._connection_object.base_url}/{Task.resourceUrl}/{self.id}' + uri = f'{self._connection_object.base_url}/{Task.__resourceUrl__}/{self.id}' response = self._connection_object.api_call(uri, 'PATCH', data) - self.__update_object__(response) - return self + return Task.from_data(self._connection_object,response) def close(self, note: str = None, member_id: int = None) -> T: """ @@ -176,6 +169,6 @@ def create(cls, connection_object: XurrentApiHelper, workflowID: int,data: dict) :param workflowID: ID of the workflow to create the task in :param data: Data to create the task with """ - uri = f'{connection_object.base_url}/workflows/{workflowID}/{cls.resourceUrl}' + 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) diff --git a/src/xurrent/teams.py b/src/xurrent/teams.py new file mode 100644 index 0000000..1e3ce10 --- /dev/null +++ b/src/xurrent/teams.py @@ -0,0 +1,114 @@ +from .core import XurrentApiHelper, JsonSerializableDict +from typing import Optional, List, Dict, Type, TypeVar +from .people import Person + +from enum import Enum + +class TeamPredefinedFilter(str, Enum): + disabled = "disabled" # List all disabled teams + enabled = "enabled" # List all enabled teams + +T = TypeVar('T', bound='Team') + +class Team(JsonSerializableDict): + __resourceUrl__ = 'teams' + + def __init__(self, connection_object: XurrentApiHelper, id, name: str = None, description: str = None, **kwargs): + self._connection_object = connection_object + self.id = id + self.name = name + self.description = description + for key, value in kwargs.items(): + setattr(self, key, value) + + def __str__(self) -> str: + """ + Return a string representation of the object. + """ + return f"Team(id={self.id}, name={self.name}, description={self.description})" + + def ref_str(self) -> str: + """ + Return a string representation of the object. + """ + return f"Team(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) -> 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_teams(cls, connection_object: XurrentApiHelper, predefinedFilter: TeamPredefinedFilter = 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, team) for team in response] + + def get_members(self) -> List[Person]: + """ + Retrieve the members of the team. + """ + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/members' + response = self._connection_object.api_call(uri, 'GET') + return [Person.from_data(self._connection_object, person) for person in response] + + def update(self, data) -> T: + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' + response = self._connection_object.api_call(uri, 'PATCH', data) + return Team.from_data(self._connection_object,response) + + @classmethod + def create(cls, connection_object: XurrentApiHelper, data: dict) -> T: + """ + Create a new team object. + + :param connection_object: Xurrent Connection object + :param data: Data dictionary (containing the data for the new team) + """ + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' + return cls.from_data(connection_object, connection_object.api_call(uri, 'POST', data)) + + def enable(self, new_name: str = None) -> T: + """ + Enable the team. + """ + return self.update({'disabled': False, 'name': new_name or self.name}) + + def disable(self, prefix: str = '', postfix: str = '') -> T: + """ + Disable the team. + """ + return self.update({'disabled': True, 'name': f'{prefix}{self.name}{postfix}'}) + + def archive(self) -> T: + """ + Archive the team. + """ + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/archive' + return self._connection_object.api_call(uri, 'POST') + + def restore(self) -> T: + """ + Restore the team. + """ + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/restore' + return self._connection_object.api_call(uri, 'POST') + + def trash(self) -> T: + """ + Trash the team. + """ + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/trash' + return self._connection_object.api_call(uri, 'POST') diff --git a/src/xurrent/workflows.py b/src/xurrent/workflows.py index d2aa300..ca396a0 100644 --- a/src/xurrent/workflows.py +++ b/src/xurrent/workflows.py @@ -50,7 +50,7 @@ class WorkflowPredefinedFilter(str, Enum): class Workflow(JsonSerializableDict): # Endpoint for workflows - resourceUrl = 'workflows' + __resourceUrl__ = 'workflows' def __init__(self, connection_object: XurrentApiHelper, @@ -91,7 +91,7 @@ def get_by_id(cls, connection_object: XurrentApiHelper, id: int) -> dict: """ Retrieve a workflow by its ID. """ - uri = f'{connection_object.base_url}/{cls.resourceUrl}/{id}' + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}/{id}' return cls.from_data(connection_object, connection_object.api_call(uri, 'GET')) @classmethod @@ -99,7 +99,7 @@ def get_workflows(cls, connection_object: XurrentApiHelper, predefinedFilter: Wo """ Retrieve all workflows. """ - uri = f'{connection_object.base_url}/{cls.resourceUrl}' + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' if predefinedFilter: uri = f'{uri}/{predefinedFilter}' if queryfilter: @@ -119,7 +119,7 @@ def get_tasks(self, queryfilter: dict = None) -> List[Task]: """ Retrieve all tasks associated with the current workflow instance. """ - uri = f'{self._connection_object.base_url}/{self.resourceUrl}/{self.id}/tasks' + 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') @@ -149,12 +149,11 @@ def update(self, data: dict): """ if not self.id: raise ValueError("Workflow instance must have an ID to update.") - uri = f'{self._connection_object.base_url}/{self.resourceUrl}/{self.id}' + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' if not WorkflowStatus.is_valid_workflow_status(data.get('status')): raise ValueError(f"Invalid status: {data.get('status')}") response = self._connection_object.api_call(uri, 'PATCH', data) - self.__update_object__(response) - return self + return Workflow.from_data(self._connection_object,response) @staticmethod def update_by_id(connection_object: XurrentApiHelper, id: int, data: dict) -> dict: @@ -169,7 +168,7 @@ def create(cls, connection_object: XurrentApiHelper, data: dict): """ Create a new workflow. """ - uri = f'{connection_object.base_url}/{cls.resourceUrl}' + uri = f'{connection_object.base_url}/{cls.__resourceUrl__}' response = connection_object.api_call(uri, 'POST', data) return cls.from_data(connection_object, response) @@ -186,15 +185,14 @@ def close(self, note="closed.", completion_reason=WorkflowCompletionReason.compl """ if not self.id: raise ValueError("Workflow instance must have an ID to close.") - uri = f'{self._connection_object.base_url}/{self.resourceUrl}/{self.id}' + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}' response = self._connection_object.api_call(uri, 'PATCH', { 'note': note, 'manager_id': self._connection_object.api_user.id, 'status': WorkflowStatus.completed, 'completion_reason': completion_reason }) - self.__update_object__(response) - return self + return Workflow.from_data(self._connection_object,response) def archive(self): """ @@ -202,10 +200,9 @@ def archive(self): """ if not self.id: raise ValueError("Workflow instance must have an ID to archive.") - uri = f'{self._connection_object.base_url}/{self.resourceUrl}/{self.id}/archive' + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/archive' response = self._connection_object.api_call(uri, 'POST') - self.__update_object__(response) - return self + return Workflow.from_data(self._connection_object,response) def trash(self): """ @@ -213,10 +210,9 @@ def trash(self): """ if not self.id: raise ValueError("Workflow instance must have an ID to trash.") - uri = f'{self._connection_object.base_url}/{self.resourceUrl}/{self.id}/trash' + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/trash' response = self._connection_object.api_call(uri, 'POST') - self.__update_object__(response) - return self + return Workflow.from_data(self._connection_object,response) def restore(self): """ @@ -224,16 +220,7 @@ def restore(self): """ if not self.id: raise ValueError("Workflow instance must have an ID to restore.") - uri = f'{self._connection_object.base_url}/{self.resourceUrl}/{self.id}/restore' + uri = f'{self._connection_object.base_url}/{self.__resourceUrl__}/{self.id}/restore' response = self._connection_object.api_call(uri, 'POST') - self.__update_object__(response) - return self + return Workflow.from_data(self._connection_object,response) - def __update_object__(self, data): - """ - Update the instance properties with new data. - """ - if data.get('id') != self.id: - raise ValueError(f"ID mismatch: {self.id} != {data.get('id')}") - for key, value in data.items(): - setattr(self, key, value) diff --git a/tests/core_test.py b/tests/integration/core_test.py similarity index 84% rename from tests/core_test.py rename to tests/integration/core_test.py index 97a1726..5192428 100644 --- a/tests/core_test.py +++ b/tests/integration/core_test.py @@ -4,10 +4,11 @@ from dotenv import load_dotenv # Add the `../src` directory to sys.path -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"))) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) # Now you can import the module from xurrent.core import XurrentApiHelper +from xurrent.teams import Team from xurrent.people import Person # FILE: src/xurrent/test_core.py @@ -40,5 +41,10 @@ def test_api_helper_setup(x_api_helper): assert isinstance(x_api_helper.api_user, Person) assert x_api_helper.api_user.id is not None assert isinstance(x_api_helper.api_user.id, int) + + #check if the api_user_teams is a list + assert isinstance(x_api_helper.api_user_teams, list) + for team in x_api_helper.api_user_teams: + assert isinstance(team, Team) \ No newline at end of file diff --git a/tests/integration/requests_test.py b/tests/integration/requests_test.py new file mode 100644 index 0000000..7575fd0 --- /dev/null +++ b/tests/integration/requests_test.py @@ -0,0 +1,90 @@ +import pytest +from unittest.mock import patch, MagicMock +from datetime import datetime +import os +import sys +from requests.exceptions import HTTPError +from dotenv import load_dotenv + +# Add the `../src` directory to sys.path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.requests import Request, CompletionReason, PredefinedFilter, PredefinedNotesFilter +from xurrent.core import XurrentApiHelper +from xurrent.teams import Team +from xurrent.people import Person +from xurrent.workflows import Workflow + +# FILE: src/xurrent/test_requests.py + +@pytest.fixture +def x_api_helper(): + # Retrieve environment variables + # Load .env file only if the environment variables are not already set + if not os.getenv("APITOKEN") or not os.getenv("APIACCOUNT") or not os.getenv("APIURL"): + load_dotenv() + api_token = os.getenv("APITOKEN") # Fetches DEMO_API_TOKEN, returns None if not set + api_account = os.getenv("APIACCOUNT") + api_url = os.getenv("APIURL") + + # Check if the environment variables are properly set + if not all([api_token, api_account, api_url]): + raise EnvironmentError("One or more environment variables are missing.") + helper = XurrentApiHelper(api_url, api_token, api_account, True) + return helper + + +def test_integration(x_api_helper): + # Create a new request + new_request = Request.create( + connection_object=x_api_helper, + data={ + "subject": "Integration Test Request", + "category": "other", + "subject": "Integration Test Request" + } + ) + + assert isinstance(new_request, Request) + assert new_request.subject == "Integration Test Request" + assert new_request.category == "other" + + # write a note + note_data = {"text": "Integration Test Note", "internal": True} + new_note = new_request.add_note(note_data) + # print("new note: ",new_note) + last_note = new_request.get_note_by_id(new_note["id"]) + # print("last note: ",last_note) + # print("last note text: ",last_note["text"]) + assert last_note["text"] == "Integration Test Note" + assert last_note["person"]["id"] == x_api_helper.api_user.id + + # try archiving the request (should fail) + with pytest.raises(HTTPError): + new_request.archive() + + # close the request + closed_request = new_request.close("Integration Test Close", CompletionReason.solved) + assert closed_request.status == "completed" + assert closed_request.completion_reason == "solved" + assert hasattr(closed_request, "archived") == False + assert hasattr(closed_request, "trashed") == False + assert closed_request.member.id == x_api_helper.api_user.id + assert closed_request.team.id == x_api_helper.api_user_teams[0].id + + # archive the request + archived_request = closed_request.archive() + assert hasattr(archived_request, "archived") == True + assert archived_request.archived == True + + #restore the request + restored_request = archived_request.restore() + if hasattr(restored_request, "archived"): + assert restored_request.archived == False + else: + assert hasattr(restored_request, "archived") == False + + # trash the request + trashed_request = restored_request.trash() + assert hasattr(trashed_request, "trashed") == True + assert trashed_request.trashed == True diff --git a/tests/requests_test.py b/tests/requests_test.py deleted file mode 100644 index 59054f1..0000000 --- a/tests/requests_test.py +++ /dev/null @@ -1,144 +0,0 @@ -import pytest -from unittest.mock import patch, MagicMock -from datetime import datetime -import os -import sys - -# Add the `../src` directory to sys.path -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"))) - -from xurrent.requests import Request, CompletionReason, PredefinedFilter, PredefinedNotesFilter -from xurrent.core import XurrentApiHelper -from xurrent.people import Person -from xurrent.workflows import Workflow - -# FILE: src/xurrent/test_requests.py - - -@pytest.fixture -def mock_connection(): - magicMock= MagicMock(spec=XurrentApiHelper) - magicMock.base_url = "https://api.example.com" - return magicMock - -@pytest.fixture -def x_api_helper(): - # Retrieve environment variables - # Load .env file only if the environment variables are not already set - if not os.getenv("APITOKEN") or not os.getenv("APIACCOUNT") or not os.getenv("APIURL"): - load_dotenv() - api_token = os.getenv("APITOKEN") # Fetches DEMO_API_TOKEN, returns None if not set - api_account = os.getenv("APIACCOUNT") - api_url = os.getenv("APIURL") - - # Check if the environment variables are properly set - if not all([api_token, api_account, api_url]): - raise EnvironmentError("One or more environment variables are missing.") - helper = XurrentApiHelper(api_url, api_token, api_account, True) - return helper - -@pytest.fixture -def request_instance(mock_connection): - return Request( - connection_object=mock_connection, - id=1, - source="source", - sourceID="sourceID", - subject="subject", - category="category", - impact="impact", - status="status", - next_target_at=datetime.now(), - completed_at=datetime.now(), - team={"name": "team"}, - member=Person(connection_object=mock_connection,id=1, name="member"), - grouped_into=2, - service_instance={"name": "service_instance"}, - created_at=datetime.now(), - updated_at=datetime.now(), - workflow=Workflow(connection_object=mock_connection,id=1, subject="workflow"), - requested_by=Person(connection_object=mock_connection,id=2, name="requested_by"), - requested_for=Person(connection_object=mock_connection,id=3, name="requested_for"), - created_by=Person(connection_object=mock_connection,id=4, name="created_by") - ) - -def test_request_initialization(request_instance): - assert isinstance(request_instance, Request) - assert request_instance.resourceUrl == "requests" - assert request_instance.id == 1 - assert request_instance.subject == "subject" - assert isinstance(request_instance.member, Person) - assert request_instance.member.name == "member" - assert isinstance(request_instance.requested_by, Person) - assert request_instance.requested_by.name == "requested_by" - assert isinstance(request_instance.requested_for, Person) - assert request_instance.requested_for.name == "requested_for" - assert isinstance(request_instance.created_by, Person) - assert request_instance.created_by.name == "created_by" - assert isinstance(request_instance.workflow, Workflow) - assert request_instance.workflow.subject == "workflow" - -def test_to_json(request_instance): - json_data = request_instance.to_json() - assert type(json_data) == str - -def test_to_string(request_instance): - workflow_ref = request_instance.workflow.ref_str() - created_by_ref = request_instance.created_by.ref_str() - assert str(request_instance) == f"Request(id=1, subject=subject, category=category, status=status, impact=impact, created_by={created_by_ref}, workflow={workflow_ref})" - - -def test_request_from_data(mock_connection): - request_data = { - "id": 1, - "source": "source", - "sourceID": "sourceID", - "subject": "subject", - "category": "category", - "impact": "impact", - "status": "status", - "next_target_at": datetime.now(), - "completed_at": datetime.now(), - "team": {"name": "team"}, - "member": {"id": 1, "name": "member"}, - "grouped_into": 2, - "service_instance": {"name": "service_instance"}, - "created_at": datetime.now(), - "updated_at": datetime.now(), - "workflow": {"id": 1, "subject": "workflow"}, - "requested_by": {"id": 2, "name": "requested_by"}, - "requested_for": {"id": 3, "name": "requested_for"}, - "created_by": {"id": 4, "name": "created_by"} - } - request = Request.from_data(mock_connection, request_data) - assert isinstance(request, Request) - assert request.id == 1 - assert request.subject == "subject" - assert isinstance(request.member, Person) - assert request.member.name == "member" - assert isinstance(request.requested_by, Person) - assert request.requested_by.name == "requested_by" - assert isinstance(request.requested_for, Person) - assert request.requested_for.name == "requested_for" - assert isinstance(request.created_by, Person) - assert request.created_by.name == "created_by" - assert isinstance(request.workflow, Workflow) - assert request.workflow.subject == "workflow" - -def test_get_request_by_id(mock_connection): - resource_id = 123 - subject = "Test subject" - mock_response = {'id': resource_id, 'subject': subject} - mock_connection.api_call.return_value = mock_response - - - # Act - result = Request.get_by_id(connection_object=mock_connection, id=resource_id) - - # Assert - mock_connection.api_call.assert_called_once_with( - f"{mock_connection.base_url}/requests/{resource_id}", "GET" - ) - assert isinstance(result, Request) # Ensure the result is of type `Request` - assert result.id == resource_id # Ensure the ID matches the expected value - assert result.subject == subject # Ensure the subject matches the expected value \ No newline at end of file diff --git a/tests/unit_tests/requests_unit_test.py b/tests/unit_tests/requests_unit_test.py new file mode 100644 index 0000000..473009c --- /dev/null +++ b/tests/unit_tests/requests_unit_test.py @@ -0,0 +1,267 @@ +import pytest +from unittest.mock import patch, MagicMock +from datetime import datetime +import os +import sys +from requests.exceptions import HTTPError + +# Add the `../src` directory to sys.path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src"))) + +from xurrent.requests import Request, CompletionReason, PredefinedFilter, PredefinedNotesFilter +from xurrent.core import XurrentApiHelper +from xurrent.teams import Team +from xurrent.people import Person +from xurrent.workflows import Workflow + +# FILE: src/xurrent/test_requests.py + + +@pytest.fixture +def mock_connection(): + magicMock= MagicMock(spec=XurrentApiHelper) + magicMock.base_url = "https://api.example.com" + magicMock.api_user = Person(connection_object=magicMock, id=1, name="api_user") + magicMock.api_user_teams = [Team(connection_object=magicMock, id=1, name="api_user_team")] + return magicMock + + +@pytest.fixture +def request_instance(mock_connection): + return Request( + connection_object=mock_connection, + id=1, + source="source", + sourceID="sourceID", + subject="subject", + category="rfc", + impact="impact", + status="status", + next_target_at=datetime.now(), + completed_at=datetime.now(), + team=Team(connection_object=mock_connection,id=1876789, name="team"), + member=Person(connection_object=mock_connection,id=1, name="member"), + grouped_into=2, + service_instance={"name": "service_instance"}, + created_at=datetime.now(), + updated_at=datetime.now(), + workflow=Workflow(connection_object=mock_connection,id=1, subject="workflow"), + requested_by=Person(connection_object=mock_connection,id=2, name="requested_by"), + requested_for=Person(connection_object=mock_connection,id=3, name="requested_for"), + created_by=Person(connection_object=mock_connection,id=4, name="created_by") + ) + +def test_request_initialization(request_instance): + assert isinstance(request_instance, Request) + assert request_instance.__resourceUrl__ == "requests" + assert request_instance.id == 1 + assert request_instance.subject == "subject" + assert isinstance(request_instance.member, Person) + assert request_instance.member.name == "member" + assert isinstance(request_instance.requested_by, Person) + assert request_instance.requested_by.name == "requested_by" + assert isinstance(request_instance.requested_for, Person) + assert request_instance.requested_for.name == "requested_for" + assert isinstance(request_instance.created_by, Person) + assert request_instance.created_by.name == "created_by" + assert isinstance(request_instance.workflow, Workflow) + assert request_instance.workflow.subject == "workflow" + +def test_to_json(request_instance): + json_data = request_instance.to_json() + assert type(json_data) == str + +def test_to_string(request_instance): + workflow_ref = request_instance.workflow.ref_str() + created_by_ref = request_instance.created_by.ref_str() + assert str(request_instance) == f"Request(id=1, subject=subject, category=rfc, status=status, impact=impact, created_by={created_by_ref}, workflow={workflow_ref})" + + request_instance.workflow = None + request_instance.created_by = None + assert str(request_instance) == "Request(id=1, subject=subject, category=rfc, status=status, impact=impact)" + + +def test_request_from_data(mock_connection): + request_data = { + "id": 1, + "source": "source", + "sourceID": "sourceID", + "subject": "subject", + "category": "rfc", + "impact": "impact", + "status": "status", + "next_target_at": datetime.now(), + "completed_at": datetime.now(), + "team": {"id":1876789,"name": "team"}, + "member": {"id": 1, "name": "member"}, + "grouped_into": 2, + "service_instance": {"name": "service_instance"}, + "created_at": datetime.now(), + "updated_at": datetime.now(), + "workflow": {"id": 1, "subject": "workflow"}, + "requested_by": {"id": 2, "name": "requested_by"}, + "requested_for": {"id": 3, "name": "requested_for"}, + "created_by": {"id": 4, "name": "created_by"} + } + request = Request.from_data(mock_connection, request_data) + assert isinstance(request, Request) + assert request.id == 1 + assert request.subject == "subject" + assert isinstance(request.member, Person) + assert request.member.name == "member" + assert isinstance(request.requested_by, Person) + assert request.requested_by.name == "requested_by" + assert isinstance(request.requested_for, Person) + assert request.requested_for.name == "requested_for" + assert isinstance(request.created_by, Person) + assert request.created_by.name == "created_by" + assert isinstance(request.workflow, Workflow) + assert request.workflow.subject == "workflow" + assert request.team.name == "team" + assert isinstance(request.team, Team) + + +def test_get_request_by_id(mock_connection): + resource_id = 123 + subject = "Test subject" + mock_response = {'id': resource_id, 'subject': subject} + mock_connection.api_call.return_value = mock_response + + + # Act + result = Request.get_by_id(connection_object=mock_connection, id=resource_id) + + # Assert + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/requests/{resource_id}", "GET" + ) + assert isinstance(result, Request) # Ensure the result is of type `Request` + assert result.id == resource_id # Ensure the ID matches the expected value + assert result.subject == subject # Ensure the subject matches the expected value + + +def test_request_add_note_string(mock_connection, request_instance): + note_text = "This is a test note." + mock_connection.api_call.return_value = {"id": 1, "text": note_text} + + response = request_instance.add_note(note_text) + + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/requests/{request_instance.id}/notes", "POST", {"text": note_text} + ) + + assert response["text"] == note_text + +def test_request_add_note_dict(mock_connection, request_instance): + note_data = {"text": "This is a test note.", "internal": True} + note_data_response = note_data.copy() + note_data_response["id"] = 1 + mock_connection.api_call.return_value = note_data_response + + response = request_instance.add_note(note_data) + + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/requests/{request_instance.id}/notes", "POST", note_data + ) + + assert response == note_data_response + +def test_request_get_notes(mock_connection, request_instance): + notes_data = [{"id": 1, "text": "Note 1"}, {"id": 2, "text": "Note 2"}] + mock_connection.api_call.return_value = notes_data + + response = request_instance.get_notes() + + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/requests/{request_instance.id}/notes", "GET" + ) + + assert response == notes_data + +def test_request_get_note_by_id(mock_connection, request_instance): + note_id = 123 + note_data = {"id": note_id, "text": "Specific Note"} + mock_connection.api_call.return_value = [note_data] + + response = request_instance.get_note_by_id(note_id) + + assert response == note_data + + +def test_request_close(mock_connection, request_instance): + mock_connection.api_call.return_value = { + "id": 1, + "status": "completed", + "completion_reason": "solved" + } + + response = request_instance.close(note="Closing test", completion_reason=CompletionReason.solved) + + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/requests/{request_instance.id}", "PATCH", + {"status": "completed", "completion_reason": "solved", "note": "Closing test", "member_id": mock_connection.api_user.id, "team_id": mock_connection.api_user_teams[0].id} + ) + + assert isinstance(response, Request) + assert response.status == "completed" + assert response.completion_reason == "solved" + +def test_request_close_and_trash(mock_connection, request_instance): + mock_connection.api_call.side_effect = [ + {"status": "completed", "completion_reason": "solved", "id": request_instance.id, "trashed": False}, + {"status": "completed", "completion_reason": "solved", "id": request_instance.id, "trashed": True} + ] + + response = request_instance.close_and_trash(note="Closing and trashing test") + + assert mock_connection.api_call.call_count == 2 + + close_call = mock_connection.api_call.call_args_list[0] + trash_call = mock_connection.api_call.call_args_list[1] + + assert isinstance(response, Request) + assert close_call.args[1] == "PATCH" + assert trash_call.args[1] == "POST" + assert response.status == "completed" + assert response.trashed == True + + +def test_request_archive(mock_connection, request_instance): + mock_connection.api_call.return_value = {"status": "completed", "id": request_instance.id, "archived": True} + + response = request_instance.archive() + + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/requests/{request_instance.id}/archive", "POST" + ) + + assert isinstance(response, Request) + assert response.archived == True + +def test_request_restore(mock_connection, request_instance): + request_instance.archived = True + mock_connection.api_call.return_value = {"archived": False, "trashed": False, "id": request_instance.id} + + response = request_instance.restore() + + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/requests/{request_instance.id}/restore", "POST" + ) + + assert isinstance(response, Request) + assert response.archived == False + assert response.trashed == False + +def test_request_trash(mock_connection, request_instance): + request_instance.trashed = False + mock_connection.api_call.return_value = {"status": "completed", "id": request_instance.id, "trashed": True} + + response = request_instance.trash() + + mock_connection.api_call.assert_called_once_with( + f"{mock_connection.base_url}/requests/{request_instance.id}/trash", "POST" + ) + + assert isinstance(response, Request) + assert response.trashed == True + From e9ec14f90a97108120aff5bc52c8c39c888bc65e Mon Sep 17 00:00:00 2001 From: "Ing. Fabian Franz Steiner BSc." Date: Wed, 11 Dec 2024 20:26:17 +0100 Subject: [PATCH 6/7] add time library --- src/xurrent/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/xurrent/core.py b/src/xurrent/core.py index f69f923..90e8c21 100644 --- a/src/xurrent/core.py +++ b/src/xurrent/core.py @@ -1,5 +1,6 @@ from __future__ import annotations # Needed for forward references from datetime import datetime +import time import requests import logging import json From d73c62dbc75a27a7a0f9c868c9c3c9a40f17702f Mon Sep 17 00:00:00 2001 From: "Ing. Fabian Franz Steiner BSc." Date: Wed, 11 Dec 2024 20:28:52 +0100 Subject: [PATCH 7/7] Enhance RequestCategory enum with string representation method --- src/xurrent/requests.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/xurrent/requests.py b/src/xurrent/requests.py index 4e6e783..86187c1 100644 --- a/src/xurrent/requests.py +++ b/src/xurrent/requests.py @@ -17,6 +17,9 @@ class RequestCategory(str, Enum): compliment = "compliment" # Compliment - Request for Bestowal of Praise other = "other" # Other - Request is Out of Scope + def __str__(self): + return self.value + class RequestStatus(str, Enum): declined = "declined" # Declined on_backlog = "on_backlog" # On Backlog