diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 41b2cd2..118783d 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -26,18 +26,18 @@ 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 - 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/.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/.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..485e335 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,5 +1,30 @@ # Change Log +## v0.0.2.9 + +### New Features + +- 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 + +- 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 +- Workflow: workflow.manager is now a Person object + ## v0.0.2.8 ### Bug Fixes 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 c7e5e4d..9eb65d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "xurrent" -version = "0.0.2.8" +version = "0.0.2.9" authors = [ { name="Fabian Steiner", email="fabian@stei-ner.net" }, ] 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 987835a..90e8c21 100644 --- a/src/xurrent/core.py +++ b/src/xurrent/core.py @@ -1,4 +1,6 @@ from __future__ import annotations # Needed for forward references +from datetime import datetime +import time import requests import logging import json @@ -17,6 +19,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 @@ -28,6 +35,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 @@ -38,6 +46,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 4c070b5..0942b7e 100644 --- a/src/xurrent/people.py +++ b/src/xurrent/people.py @@ -1,4 +1,5 @@ -from .core import XurrentApiHelper +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 @@ -14,9 +15,9 @@ class PeoplePredefinedFilter(str, Enum): T = TypeVar('T', bound='Person') -class Person(): +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,18 +27,18 @@ 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. """ 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): @@ -48,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 @@ -56,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 = ''): """ @@ -104,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 72ec312..86187c1 100644 --- a/src/xurrent/requests.py +++ b/src/xurrent/requests.py @@ -1,10 +1,38 @@ from __future__ import annotations # Needed for forward references -from .core import XurrentApiHelper -from .core import JsonSerializableDict +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 + + def __str__(self): + return self.value + +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 @@ -38,7 +66,15 @@ class PredefinedNotesFilter(str, Enum): class Request(JsonSerializableDict): #https://developer.4me.com/v1/requests/ - resourceUrl = '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] + category: Optional[RequestCategory] + status: Optional[RequestStatus] + team: Optional[Team] def __init__(self, connection_object: XurrentApiHelper, @@ -52,46 +88,60 @@ 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 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 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) + 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 + 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: - 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: """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})") + 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') and isinstance(self.created_by, Person)): + output += f", created_by={self.created_by.ref_str()}" + if(hasattr(self, 'workflow') and isinstance(self.workflow, 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: @@ -111,9 +161,9 @@ 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, 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]: @@ -123,7 +173,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: @@ -137,7 +187,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)): @@ -151,7 +201,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: @@ -166,8 +216,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): @@ -178,10 +227,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: @@ -195,12 +250,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, 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): """ @@ -219,10 +282,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): """ @@ -233,10 +295,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): """ @@ -245,10 +306,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 @@ -259,6 +319,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 46ce406..1da8fb1 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,9 +30,9 @@ class TaskStatus(str, Enum): canceled = "canceled" # Canceled -class Task(): +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,18 +41,18 @@ 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: """ 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: @@ -64,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: @@ -98,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: """ @@ -170,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 c72e486..ca396a0 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): @@ -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): """ @@ -42,9 +48,9 @@ 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' + __resourceUrl__ = 'workflows' def __init__(self, connection_object: XurrentApiHelper, @@ -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) @@ -65,6 +74,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): @@ -78,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 @@ -86,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: @@ -106,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') @@ -136,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: @@ -156,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) @@ -173,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): """ @@ -189,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): """ @@ -200,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): """ @@ -211,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/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 +