From 9751da460c2874fdebef0b8b4c05e7bb159d6c24 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Tue, 24 Nov 2020 15:25:08 +0100 Subject: [PATCH 01/29] Place authentication in importable file for use with test cases placed in multiple files --- tests/apitest_shared.py | 32 +++++++++++ tests/test_api.py | 118 ---------------------------------------- tests/test_api_raw.py | 44 +++++++++++++++ 3 files changed, 76 insertions(+), 118 deletions(-) create mode 100644 tests/apitest_shared.py delete mode 100644 tests/test_api.py create mode 100644 tests/test_api_raw.py diff --git a/tests/apitest_shared.py b/tests/apitest_shared.py new file mode 100644 index 0000000..5fd742b --- /dev/null +++ b/tests/apitest_shared.py @@ -0,0 +1,32 @@ +import os +import pytest + +from personio_py import Personio, PersonioError + +# Personio client authentication +CLIENT_ID = os.getenv('CLIENT_ID') +CLIENT_SECRET = os.getenv('CLIENT_SECRET') +personio = Personio(client_id=CLIENT_ID, client_secret=CLIENT_SECRET) + +shared_test_data = {} + +# deactivate all tests that rely on a specific personio instance +try: + personio.authenticate() + can_authenticate = True + # This is used to ensure the test check for existing objects + test_employee = personio.get_employees()[0] + shared_test_data = { + 'test_employee': { + 'id': test_employee.id_, + 'first_name': test_employee.first_name, + 'last_name': test_employee.last_name, + 'email': test_employee.email, + 'hire_date': test_employee.hire_date + } + } +except PersonioError: + can_authenticate = False +skip_if_no_auth = pytest.mark.skipif(not can_authenticate, reason="Personio authentication failed") + + diff --git a/tests/test_api.py b/tests/test_api.py deleted file mode 100644 index 5f5b09c..0000000 --- a/tests/test_api.py +++ /dev/null @@ -1,118 +0,0 @@ -import os -from datetime import datetime - -import pytest - -from personio_py import Department, Employee, Personio, PersonioError - -# Personio client authentication -CLIENT_ID = os.getenv('CLIENT_ID') -CLIENT_SECRET = os.getenv('CLIENT_SECRET') -personio = Personio(client_id=CLIENT_ID, client_secret=CLIENT_SECRET) - -# deactivate all tests that rely on a specific personio instance -try: - personio.authenticate() - can_authenticate = True -except PersonioError: - can_authenticate = False -skip_if_no_auth = pytest.mark.skipif(not can_authenticate, reason="Personio authentication failed") - - -@skip_if_no_auth -def test_raw_api_employees(): - response = personio.request_json('company/employees') - employees = response['data'] - assert len(employees) > 0 - id_0 = employees[0]['attributes']['id']['value'] - employee_0 = personio.request_json(f'company/employees/{id_0}') - assert employee_0 - - -@skip_if_no_auth -def test_raw_api_attendances(): - params = { - "start_date": "2020-01-01", - "end_date": "2020-06-01", - "employees[]": [1142212, 1142211], - "limit": 200, - "offset": 0 - } - attendances = personio.request_json('company/attendances', params=params) - assert attendances - - -@skip_if_no_auth -def test_raw_api_absence_types(): - params = {"limit": 200, "offset": 0} - absence_types = personio.request_json('company/time-off-types', params=params) - assert len(absence_types['data']) > 10 - - -@skip_if_no_auth -def test_raw_api_absences(): - params = { - "start_date": "2020-01-01", - "end_date": "2020-06-01", - "employees[]": [1142212], # [2007207, 2007248] - "limit": 200, - "offset": 0 - } - absences = personio.request_json('company/time-offs', params=params) - assert absences - - -@skip_if_no_auth -def test_get_employees(): - employees = personio.get_employees() - assert len(employees) > 0 - - -@skip_if_no_auth -def test_get_employee(): - employee = personio.get_employee(2007207) - assert employee.first_name == 'Sebastian' - d = employee.to_dict() - assert d - response = personio.request_json(f'company/employees/2007207') - api_attr = response['data']['attributes'] - assert d == api_attr - - -@skip_if_no_auth -def test_get_employee_picture(): - employee = Employee(client=personio, id_=2007207) - picture = employee.picture() - assert picture - - -@skip_if_no_auth -def test_create_employee(): - ada = Employee( - first_name='Ada', - last_name='Lovelace', - email='ada@example.org', - gender='female', - position='first programmer ever', - department=Department(name='Operations'), - hire_date=datetime(1835, 2, 1), - weekly_working_hours="35", - ) - ada_created = personio.create_employee(ada, refresh=True) - assert ada.first_name == ada_created.first_name - assert ada.email == ada_created.email - assert ada_created.id_ - assert ada_created.last_modified_at.isoformat()[:10] == datetime.now().isoformat()[:10] - assert ada_created.status == 'active' - - -@skip_if_no_auth -def test_get_absences(): - absences = personio.get_absences(2007207) - assert len(absences) > 0 - - -@skip_if_no_auth -def test_get_attendances(): - attendances = personio.get_attendances(2007207) - assert len(attendances) > 0 diff --git a/tests/test_api_raw.py b/tests/test_api_raw.py new file mode 100644 index 0000000..4adf618 --- /dev/null +++ b/tests/test_api_raw.py @@ -0,0 +1,44 @@ +from .apitest_shared import * + + +@skip_if_no_auth +def test_raw_api_employees(): + response = personio.request_json('company/employees') + employees = response['data'] + assert len(employees) > 0 + id_0 = employees[0]['attributes']['id']['value'] + employee_0 = personio.request_json(f'company/employees/{id_0}') + assert employee_0 + + +@skip_if_no_auth +def test_raw_api_attendances(): + params = { + "start_date": "2020-01-01", + "end_date": "2020-06-01", + "employees[]": [1142212, 1142211], + "limit": 200, + "offset": 0 + } + attendances = personio.request_json('company/attendances', params=params) + assert attendances + + +@skip_if_no_auth +def test_raw_api_absence_types(): + params = {"limit": 200, "offset": 0} + absence_types = personio.request_json('company/time-off-types', params=params) + assert len(absence_types['data']) >= 10 # Personio test accounts know 10 different absence types + + +@skip_if_no_auth +def test_raw_api_absences(): + params = { + "start_date": "2020-01-01", + "end_date": "2020-06-01", + "employees[]": [1142212], # [2007207, 2007248] + "limit": 200, + "offset": 0 + } + absences = personio.request_json('company/time-offs', params=params) + assert absences From 0921a1e4b8ebe8bd4f4272346d43e7ba7da4c9ff Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Thu, 26 Nov 2020 16:37:49 +0100 Subject: [PATCH 02/29] Readd existing tests --- tests/test_api.py | 58 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/test_api.py diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..2215493 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,58 @@ +from personio_py import Employee, Department +from tests.apitest_shared import * +from datetime import datetime + +@skip_if_no_auth +def test_get_employees(): + employees = personio.get_employees() + assert len(employees) > 0 + + +@skip_if_no_auth +def test_get_employee(): + employee = personio.get_employee(2007207) + assert employee.first_name == 'Sebastian' + d = employee.to_dict() + assert d + response = personio.request_json(f'company/employees/2007207') + api_attr = response['data']['attributes'] + assert d == api_attr + + +@skip_if_no_auth +def test_get_employee_picture(): + employee = Employee(client=personio, id_=2007207) + picture = employee.picture() + assert picture + + +@skip_if_no_auth +def test_create_employee(): + ada = Employee( + first_name='Ada', + last_name='Lovelace', + email='ada@example.org', + gender='female', + position='first programmer ever', + department=Department(name='Operations'), + hire_date=datetime(1835, 2, 1), + weekly_working_hours="35", + ) + ada_created = personio.create_employee(ada, refresh=True) + assert ada.first_name == ada_created.first_name + assert ada.email == ada_created.email + assert ada_created.id_ + assert ada_created.last_modified_at.isoformat()[:10] == datetime.now().isoformat()[:10] + assert ada_created.status == 'active' + + +@skip_if_no_auth +def test_get_absences(): + absences = personio.get_absences(2007207) + assert len(absences) > 0 + + +@skip_if_no_auth +def test_get_attendances(): + attendances = personio.get_attendances(2007207) + assert len(attendances) > 0 From e74b6459b7e43bf5b5b89d81c0797b6e0729b509 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Mon, 30 Nov 2020 11:25:40 +0100 Subject: [PATCH 03/29] Implement absence methods. Add raw api tests for absences --- src/personio_py/client.py | 88 ++++++++++++++--- src/personio_py/models.py | 36 +++++-- tests/test_api_absences.py | 194 +++++++++++++++++++++++++++++++++++++ 3 files changed, 294 insertions(+), 24 deletions(-) create mode 100644 tests/test_api_absences.py diff --git a/src/personio_py/client.py b/src/personio_py/client.py index 4ecc5f1..b070503 100644 --- a/src/personio_py/client.py +++ b/src/personio_py/client.py @@ -102,7 +102,7 @@ def request(self, path: str, method='GET', params: Dict[str, Any] = None, _headers.update(headers) # make the request url = urljoin(self.base_url, path) - response = requests.request(method, url, headers=_headers, params=params, data=data) + response = requests.request(method, url, headers=_headers, params=params, json=data) # re-new the authorization header authorization = response.headers.get('Authorization') if authorization: @@ -127,8 +127,7 @@ def request_json(self, path: str, method='GET', params: Dict[str, Any] = None, during this request (default: True for json requests) :return: the parsed json response, when the request was successful, or a PersonioApiError """ - headers = {'accept': 'application/json'} - response = self.request(path, method, params, data, headers, auth_rotation=auth_rotation) + response = self.request(path, method, params, data, auth_rotation=auth_rotation) if response.ok: try: return response.json() @@ -346,26 +345,63 @@ def get_absences(self, employees: Union[int, List[int], Employee, List[Employee] return self._get_employee_metadata( 'company/time-offs', Absence, employees, start_date, end_date) - def get_absence(self, absence_id: int) -> Absence: + def get_absence(self, absence: int or Absence, remote_query_id=False) -> Absence: """ - placeholder; not ready to be used + Get an absence record from a given id. + + :param absence: The absence id to fetch. + :param remote_query_id: Whether it is allowed to make a remote ID query. """ - # TODO implement - pass + if isinstance(absence, int): + response = self.request_json('company/time-offs/' + str(absence)) + return Absence.from_dict(response['data'], self) + else: + if absence.id_: + return self.get_absence(absence.id_) + elif absence.id_ is None and remote_query_id: + self.__add_remote_absence_id(absence) + return self.get_absence(absence.id_) + else: + raise ValueError("Id is required to get an absence record") - def create_absence(self, absence: Absence): + def create_absence(self, absence: Absence) -> bool: """ - placeholder; not ready to be used + Creates an absence record on the Personio servers + + :param absence: The absence object to be created """ - # TODO implement - pass + data = absence.to_body_params() + response = self.request_json('company/time-offs', method='POST', data=data) + if response['success']: + absence.id_ = response['data']['attributes']['id'] + return True + return False - def delete_absence(self, absence_id: int): + def delete_absence(self, absence: Absence or int, remote_query_id=False): """ - placeholder; not ready to be used + Delete an existing record + + Either an absence id or o remote query is required. Remote queries are only executed if required. + + :param absence: The Absence object holding the new data or an absence record id to delete. + :param remote_query_id: Allow a remote query for the id if it is not set within the given Absence object. + :raises: + ValueError: If a query is required but not allowed or the query does not provide exactly one result. """ - # TODO implement - pass + if isinstance(absence, int): + response = self.request_json(path='company/time-offs/' + str(absence), method='DELETE') + return response['success'] + elif isinstance(absence, Absence): + if absence.id_ is not None: + return self.delete_absence(absence.id_) + else: + if remote_query_id: + absence = self.__add_remote_absence_id(absence) + return self.delete_absence(absence.id_) + else: + raise ValueError("You either need to provide the absence id or allow a remote query.") + else: + raise ValueError("absence must be an Absence object or an integer") def _get_employee_metadata( self, path: str, resource_cls: Type[PersonioResourceType], @@ -416,3 +452,25 @@ def _normalize_timeframe_params( employees = [employees] employee_ids = [(e.id_ if isinstance(e, Employee) else e) for e in employees] return employee_ids, start_date, end_date + + def __add_remote_absence_id(self, absence: Absence) -> Absence: + """ + Queries the API for an absence record matching the given Absence object and adds the remote id. + + :param absence: The absence object to be updated + :return: The absence object with the absence_id set + """ + if absence.employee is None: + raise ValueError("For a remote query an employee_id is required") + if absence.start_date is None: + raise ValueError("For a remote query a start date is required") + if absence.end_date is None: + raise ValueError("For a remote query an end date is required") + matching_remote_absences = self.get_absences(employees=[absence.employee.id_], + start_date=absence.start_date, end_date=absence.end_date) + if len(matching_remote_absences) == 0: + raise ValueError("The absence to patch was not found") + elif len(matching_remote_absences) > 1: + raise ValueError("More than one absence found.") + absence.id_ = matching_remote_absences[0].id_ + return absence diff --git a/src/personio_py/models.py b/src/personio_py/models.py index 081a879..30fcb30 100644 --- a/src/personio_py/models.py +++ b/src/personio_py/models.py @@ -573,8 +573,8 @@ def __init__(self, start_date: datetime = None, end_date: datetime = None, days_count: float = None, - half_day_start: int = None, - half_day_end: int = None, + half_day_start: bool = None, + half_day_end: bool = None, time_off_type: AbsenceType = None, employee: ShortEmployee = None, created_by: str = None, @@ -588,19 +588,37 @@ def __init__(self, self.start_date = start_date self.end_date = end_date self.days_count = days_count - self.half_day_start = half_day_start - self.half_day_end = half_day_end + self.half_day_start = False + if half_day_start and half_day_start > 0: + self.half_day_start = True + self.half_day_end = False + if half_day_end and half_day_end > 0: + self.half_day_end = True self.time_off_type = time_off_type self.employee = employee self.created_by = created_by self.certificate = certificate self.created_at = created_at - def _create(self, client: 'Personio'): - pass - - def _delete(self, client: 'Personio'): - pass + def _create(self, client: 'Personio' = None): + return get_client(self, client).create_absence(self) + + def _delete(self, client: 'Personio' = None, allow_remote_query: bool = False): + return get_client(self, client).delete_absence(self, remote_query_id=allow_remote_query) + + def to_body_params(self): + data = { + 'employee_id': self.employee.id_, + 'time_off_type_id': self.time_off_type.id_, + 'start_date': self.start_date.strftime("%Y-%m-%d"), + 'end_date': self.end_date.strftime("%Y-%m-%d"), + 'half_day_start': self.half_day_start or False, + 'half_day_end': self.half_day_end or False + } + if self.comment is not None: + data['comment'] = self.comment + print(data) + return data class Attendance(WritablePersonioResource): diff --git a/tests/test_api_absences.py b/tests/test_api_absences.py new file mode 100644 index 0000000..439662b --- /dev/null +++ b/tests/test_api_absences.py @@ -0,0 +1,194 @@ +from .apitest_shared import * +from datetime import timedelta, date + +from personio_py import Employee, ShortEmployee, Personio, PersonioError, Absence, AbsenceType + + +@skip_if_no_auth +@pytest.mark.parametrize("half_day_start", [True, False]) +@pytest.mark.parametrize("half_day_end", [True, False]) +def test_create_absences(half_day_start: bool, half_day_end: bool): + """ + Test the creation of absence records on the server. + """ + # Prepare data + test_data = shared_test_data['test_employee'] + test_user = personio.get_employee(test_data['id']) + start_date = date(2021, 1, 1) + end_date = date(2021, 1, 10) + + # Ensure there are no left absences + delete_all_absences_of_employee(test_user) + + # Start test + absence_to_create = create_absence_for_user(test_user, + start_date=start_date, + end_date=end_date, + half_day_start=half_day_start, + half_day_end=half_day_end) + assert absence_to_create.id_ is None + absence_to_create.create(personio) + assert absence_to_create.id_ + remote_absence = personio.get_absence(absence=absence_to_create) + assert remote_absence.half_day_start is half_day_start + assert remote_absence.half_day_end is half_day_end + assert remote_absence.start_date - start_date < timedelta(seconds=1) + assert remote_absence.end_date - end_date < timedelta(seconds=1) + + +@skip_if_no_auth +def test_get_absences_from_id(): + user = prepare_test_get_absences() + absence_id = create_absence_for_user(user, create=True).id_ + absence = personio.get_absence(absence_id) + assert absence.id_ == absence_id + + +@skip_if_no_auth +def test_get_absences_from_absence_object(): + user = prepare_test_get_absences() + remote_absence = create_absence_for_user(user, create=True) + absence = personio.get_absence(remote_absence) + assert absence.id_ == remote_absence.id_ + + +@skip_if_no_auth +def test_get_absences_from_absence_object_without_id_remote_query(): + user = prepare_test_get_absences() + remote_absence = create_absence_for_user(user, create=True) + absence_id = remote_absence.id_ + remote_absence.id_ = None + absence = personio.get_absence(remote_absence, remote_query_id=True) + assert absence.id_ == absence_id + + +@skip_if_no_auth +def test_get_absences_from_absence_object_without_id_no_remote_query(): + user = prepare_test_get_absences() + remote_absence = create_absence_for_user(user, create=True) + remote_absence.id_ = None + with pytest.raises(ValueError): + personio.get_absence(remote_absence, remote_query_id=False) + + +@skip_if_no_auth +def test_delete_absences_from_model_no_client(): + test_data = shared_test_data['test_employee'] + test_user = personio.get_employee(test_data['id']) + delete_all_absences_of_employee(test_user) + absence = create_absence_for_user(test_user, create=True) + with pytest.raises(PersonioError): + absence.delete() + + +@skip_if_no_auth +def test_delete_absences_from_model_passed_client(): + test_data = shared_test_data['test_employee'] + test_user = personio.get_employee(test_data['id']) + delete_all_absences_of_employee(test_user) + absence = create_absence_for_user(test_user, create=True) + assert absence.delete(client=personio) is True + + +@skip_if_no_auth +def test_delete_absences_from_model_with_client(): + test_data = shared_test_data['test_employee'] + test_user = personio.get_employee(test_data['id']) + delete_all_absences_of_employee(test_user) + absence = create_absence_for_user(test_user, create=True) + absence._client = personio + assert absence.delete() is True + + +@skip_if_no_auth +def test_delete_absence_from_absence_id(): + test_data = shared_test_data['test_employee'] + test_user = personio.get_employee(test_data['id']) + delete_all_absences_of_employee(test_user) + absence = create_absence_for_user(test_user, create=True) + assert personio.delete_absence(absence.id_) is True + + +@skip_if_no_auth +def test_delete_absences_from_client_object_with_id(): + test_data = shared_test_data['test_employee'] + test_user = personio.get_employee(test_data['id']) + delete_all_absences_of_employee(test_user) + absence = create_absence_for_user(test_user, create=True) + assert personio.delete_absence(absence) is True + + +@skip_if_no_auth +def test_delete_absences_from_client_object_with_no_id_query(): + test_data = shared_test_data['test_employee'] + test_user = personio.get_employee(test_data['id']) + delete_all_absences_of_employee(test_user) + absence = create_absence_for_user(test_user, create=True) + absence.id_ = None + assert personio.delete_absence(absence, remote_query_id=True) is True + + +@skip_if_no_auth +def test_delete_absences_from_client_object_with_no_id_no_query(): + test_data = shared_test_data['test_employee'] + test_user = personio.get_employee(test_data['id']) + delete_all_absences_of_employee(test_user) + absence = create_absence_for_user(test_user, create=True) + absence.id_ = None + with pytest.raises(ValueError): + personio.delete_absence(absence, remote_query_id=False) + + +def delete_absences(client: Personio, absences: [int] or [Absence]): + for absence in absences: + client.delete_absence(absence) + + +def create_absences(client: Personio, absences: [Absence]): + for absence in absences: + client.create_absence(absence) + + +def delete_all_absences_of_employee(employee: Employee): + absences = personio.get_absences(employee) + delete_absences(personio, absences) + + +def create_absence_for_user(employee: Employee, + time_off_type: AbsenceType = None, + start_date: date = None, + end_date: date = None, + half_day_start: bool = False, + half_day_end: bool = False, + comment: str = None, + create: bool = False) -> Absence: + if not time_off_type: + absence_types = personio.get_absence_types() + time_off_type = [absence_type for absence_type in absence_types if absence_type.name == "Unpaid vacation"][0] + if not start_date: + start_date = date(2021, 1, 1) + if not end_date: + end_date = date(2021, 1, 10) + + absence_to_create = Absence( + start_date=start_date, + end_date=end_date, + time_off_type=time_off_type, + employee=employee, + half_day_start=half_day_start, + half_day_end=half_day_end, + comment=comment + ) + if create: + absence_to_create.create(personio) + return absence_to_create + + +def prepare_test_get_absences() -> Employee: + test_data = shared_test_data['test_employee'] + test_user = personio.get_employee(test_data['id']) + + # Be sure there are no leftover absences + delete_all_absences_of_employee(test_user) + return test_user + From 79d8c2f97b9959b1c9833cd425bbfb1d47544f01 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Tue, 24 Nov 2020 19:58:25 +0100 Subject: [PATCH 04/29] Raise NotImplementedError if a function is called which is not implemented (#10) --- src/personio_py/client.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/personio_py/client.py b/src/personio_py/client.py index b070503..7123084 100644 --- a/src/personio_py/client.py +++ b/src/personio_py/client.py @@ -268,8 +268,7 @@ def update_employee(self, employee: Employee): """ placeholder; not ready to be used """ - # TODO implement - pass + raise NotImplementedError() def get_attendances(self, employees: Union[int, List[int], Employee, List[Employee]], start_date: datetime = None, end_date: datetime = None) -> List[Attendance]: @@ -296,22 +295,19 @@ def create_attendances(self, attendances: List[Attendance]): """ # attendances can be created individually, but here you can push a huge bunch of items # in a single request, which can be significantly faster - # TODO implement - pass + raise NotImplementedError() def update_attendance(self, attendance_id: int): """ placeholder; not ready to be used """ - # TODO implement - pass + raise NotImplementedError() def delete_attendance(self, attendance_id: int): """ placeholder; not ready to be used """ - # TODO implement - pass + raise NotImplementedError() def get_absence_types(self) -> List[AbsenceType]: """ From 202c8204d4cab7cde230584cd42ebdaa0c179962 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Mon, 30 Nov 2020 18:06:38 +0100 Subject: [PATCH 05/29] Move absence mock tests / test data to new files to keep tests readable once more tests are added --- tests/mock_data.py | 171 --------------------------- tests/test_mock_api.py | 65 ----------- tests/test_mock_api_absence_data.py | 173 ++++++++++++++++++++++++++++ tests/test_mock_api_absences.py | 73 ++++++++++++ 4 files changed, 246 insertions(+), 236 deletions(-) create mode 100644 tests/test_mock_api_absence_data.py create mode 100644 tests/test_mock_api_absences.py diff --git a/tests/mock_data.py b/tests/mock_data.py index 3591c25..e9fcae9 100644 --- a/tests/mock_data.py +++ b/tests/mock_data.py @@ -709,177 +709,6 @@ """ json_dict_employee_ada = json.loads(json_string_employee_ada) -json_string_absence_types = """ -{ - "success": true, - "data": [{ - "type": "TimeOffType", - "attributes": { - "id": 195824, - "name": "vacation" - } - }, { - "type": "TimeOffType", - "attributes": { - "id": 195825, - "name": "paid leave" - } - }, { - "type": "TimeOffType", - "attributes": { - "id": 195826, - "name": "sick" - } - } - ] -} -""" -json_dict_absence_types = json.loads(json_string_absence_types) - -json_string_absence_alan = """ -{ - "success": true, - "data": [{ - "type": "TimeOffPeriod", - "attributes": { - "id": 17205942, - "status": "approved", - "comment": "marathon starts at noon", - "start_date": "1944-09-01T00:00:00+02:00", - "end_date": "1944-09-01T00:00:00+02:00", - "days_count": 0.5, - "half_day_start": 0, - "half_day_end": 1, - "time_off_type": { - "type": "TimeOffType", - "attributes": { - "id": 195824, - "name": "vacation" - } - }, - "employee": { - "type": "Employee", - "attributes": { - "id": { - "label": "ID", - "value": 2116365 - }, - "first_name": { - "label": "First name", - "value": "Alan" - }, - "last_name": { - "label": "Last name", - "value": "Turing" - }, - "email": { - "label": "Email", - "value": "alan@example.org" - } - } - }, - "created_by": "Alan Turing", - "certificate": { - "status": "not-required" - }, - "created_at": "2020-08-21T18:07:06+02:00" - } - }, { - "type": "TimeOffPeriod", - "attributes": { - "id": 17205932, - "status": "approved", - "comment": "don't you just hate mondays sometimes?", - "start_date": "1944-07-03T00:00:00+02:00", - "end_date": "1944-07-03T00:00:00+02:00", - "days_count": 1, - "half_day_start": 0, - "half_day_end": 0, - "time_off_type": { - "type": "TimeOffType", - "attributes": { - "id": 195824, - "name": "vacation" - } - }, - "employee": { - "type": "Employee", - "attributes": { - "id": { - "label": "ID", - "value": 2116365 - }, - "first_name": { - "label": "First name", - "value": "Alan" - }, - "last_name": { - "label": "Last name", - "value": "Turing" - }, - "email": { - "label": "Email", - "value": "alan@example.org" - } - } - }, - "created_by": "Alan Turing", - "certificate": { - "status": "not-required" - }, - "created_at": "2020-08-21T18:06:02+02:00" - } - }, { - "type": "TimeOffPeriod", - "attributes": { - "id": 17205920, - "status": "approved", - "comment": "summer vacation", - "start_date": "1944-08-07T00:00:00+02:00", - "end_date": "1944-08-20T00:00:00+02:00", - "days_count": 10, - "half_day_start": 0, - "half_day_end": 0, - "time_off_type": { - "type": "TimeOffType", - "attributes": { - "id": 195824, - "name": "vacation" - } - }, - "employee": { - "type": "Employee", - "attributes": { - "id": { - "label": "ID", - "value": 2116365 - }, - "first_name": { - "label": "First name", - "value": "Alan" - }, - "last_name": { - "label": "Last name", - "value": "Turing" - }, - "email": { - "label": "Email", - "value": "alan@example.org" - } - } - }, - "created_by": "Alan Turing", - "certificate": { - "status": "not-required" - }, - "created_at": "2020-08-21T18:05:04+02:00" - } - } - ] -} -""" -json_dict_absence_alan = json.loads(json_string_absence_alan) - json_string_empty_response = """ { "success": true, diff --git a/tests/test_mock_api.py b/tests/test_mock_api.py index c983d0f..0cbfd65 100644 --- a/tests/test_mock_api.py +++ b/tests/test_mock_api.py @@ -92,61 +92,6 @@ def test_auth_rotation_fail(): assert "authorization header" in str(e.value).lower() -@responses.activate -def test_get_absences(): - # configure personio & get absences for alan - mock_absences() - personio = mock_personio() - absences = personio.get_absences(2116365) - # validate - assert len(absences) == 3 - selection = [a for a in absences if "marathon" in a.comment.lower()] - assert len(selection) == 1 - marathon = selection[0] - assert marathon.start_date == date(1944, 9, 1) - assert marathon.half_day_start == 0 - assert marathon.half_day_end == 1 - assert marathon.status == 'approved' - # validate serialization - source_dict = json_dict_absence_alan['data'][0] - target_dict = marathon.to_dict() - compare_labeled_attributes(source_dict, target_dict) - - -@responses.activate -def test_get_absences_from_employee_objects(): - # mock endpoints & get absences for all employees - mock_employees() - mock_absences() - personio = mock_personio() - employees = personio.get_employees() - assert employees - absences = personio.get_absences(employees) - # the response is not important (it does not match the input), but the function should accept - # a list of Employee objects as parameter and return a result - assert absences - - -@responses.activate -def test_get_absence_types(): - # mock the get absence types endpoint - responses.add( - responses.GET, 'https://api.personio.de/v1/company/time-off-types', status=200, - json=json_dict_absence_types, adding_headers={'Authorization': 'Bearer foo'}) - # configure personio & get absences for alan - personio = mock_personio() - absence_types = personio.get_absence_types() - # non-empty contents - assert len(absence_types) == 3 - for at in absence_types: - assert at.id_ > 0 - assert isinstance(at.name, str) - # serialization matches input - for source_dict, at in zip(json_dict_absence_types['data'], absence_types): - target_dict = at.to_dict() - assert source_dict == target_dict - - @responses.activate def test_get_attendance(): # mock the get absences endpoint (with different array offsets) @@ -190,16 +135,6 @@ def mock_employees(): json=json_dict_employees, adding_headers={'Authorization': 'Bearer rotated_dummy_token'}) -def mock_absences(): - # mock the get absences endpoint (with different array offsets) - responses.add( - responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=0.*'), - status=200, json=json_dict_absence_alan, adding_headers={'Authorization': 'Bearer foo'}) - responses.add( - responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=3.*'), - status=200, json=json_dict_empty_response, adding_headers={'Authorization': 'Bearer bar'}) - - def compare_labeled_attributes(expected: Dict, actual: Dict): if actual == expected: # fast lane - exact match diff --git a/tests/test_mock_api_absence_data.py b/tests/test_mock_api_absence_data.py new file mode 100644 index 0000000..1bff6d9 --- /dev/null +++ b/tests/test_mock_api_absence_data.py @@ -0,0 +1,173 @@ +import json + +json_string_absence_alan = """ +{ + "success": true, + "data": [{ + "type": "TimeOffPeriod", + "attributes": { + "id": 17205942, + "status": "approved", + "comment": "marathon starts at noon", + "start_date": "1944-09-01T00:00:00+02:00", + "end_date": "1944-09-01T00:00:00+02:00", + "days_count": 0.5, + "half_day_start": 0, + "half_day_end": 1, + "time_off_type": { + "type": "TimeOffType", + "attributes": { + "id": 195824, + "name": "vacation" + } + }, + "employee": { + "type": "Employee", + "attributes": { + "id": { + "label": "ID", + "value": 2116365 + }, + "first_name": { + "label": "First name", + "value": "Alan" + }, + "last_name": { + "label": "Last name", + "value": "Turing" + }, + "email": { + "label": "Email", + "value": "alan@example.org" + } + } + }, + "created_by": "Alan Turing", + "certificate": { + "status": "not-required" + }, + "created_at": "2020-08-21T18:07:06+02:00" + } + }, { + "type": "TimeOffPeriod", + "attributes": { + "id": 17205932, + "status": "approved", + "comment": "don't you just hate mondays sometimes?", + "start_date": "1944-07-03T00:00:00+02:00", + "end_date": "1944-07-03T00:00:00+02:00", + "days_count": 1, + "half_day_start": 0, + "half_day_end": 0, + "time_off_type": { + "type": "TimeOffType", + "attributes": { + "id": 195824, + "name": "vacation" + } + }, + "employee": { + "type": "Employee", + "attributes": { + "id": { + "label": "ID", + "value": 2116365 + }, + "first_name": { + "label": "First name", + "value": "Alan" + }, + "last_name": { + "label": "Last name", + "value": "Turing" + }, + "email": { + "label": "Email", + "value": "alan@example.org" + } + } + }, + "created_by": "Alan Turing", + "certificate": { + "status": "not-required" + }, + "created_at": "2020-08-21T18:06:02+02:00" + } + }, { + "type": "TimeOffPeriod", + "attributes": { + "id": 17205920, + "status": "approved", + "comment": "summer vacation", + "start_date": "1944-08-07T00:00:00+02:00", + "end_date": "1944-08-20T00:00:00+02:00", + "days_count": 10, + "half_day_start": 0, + "half_day_end": 0, + "time_off_type": { + "type": "TimeOffType", + "attributes": { + "id": 195824, + "name": "vacation" + } + }, + "employee": { + "type": "Employee", + "attributes": { + "id": { + "label": "ID", + "value": 2116365 + }, + "first_name": { + "label": "First name", + "value": "Alan" + }, + "last_name": { + "label": "Last name", + "value": "Turing" + }, + "email": { + "label": "Email", + "value": "alan@example.org" + } + } + }, + "created_by": "Alan Turing", + "certificate": { + "status": "not-required" + }, + "created_at": "2020-08-21T18:05:04+02:00" + } + } + ] +} +""" +json_dict_absence_alan = json.loads(json_string_absence_alan) + + +json_string_absence_types = """ +{ + "success": true, + "data": [{ + "type": "TimeOffType", + "attributes": { + "id": 195824, + "name": "vacation" + } + }, { + "type": "TimeOffType", + "attributes": { + "id": 195825, + "name": "paid leave" + } + }, { + "type": "TimeOffType", + "attributes": { + "id": 195826, + "name": "sick" + } + } + ] +} +""" +json_dict_absence_types = json.loads(json_string_absence_types) diff --git a/tests/test_mock_api_absences.py b/tests/test_mock_api_absences.py new file mode 100644 index 0000000..3ea57dc --- /dev/null +++ b/tests/test_mock_api_absences.py @@ -0,0 +1,73 @@ +from datetime import date + +import responses +import re + +from tests.test_mock_api import mock_personio, compare_labeled_attributes, mock_employees +from tests.mock_data import json_dict_empty_response +from tests.test_mock_api_absence_data import json_dict_absence_alan, json_dict_absence_types + + +@responses.activate +def test_get_absences(): + # configure personio & get absences for alan + mock_absences() + personio = mock_personio() + absences = personio.get_absences(2116365) + # validate + assert len(absences) == 3 + selection = [a for a in absences if "marathon" in a.comment.lower()] + assert len(selection) == 1 + marathon = selection[0] + assert marathon.start_date == date(1944, 9, 1) + assert marathon.half_day_start == 0 + assert marathon.half_day_end == 1 + assert marathon.status == 'approved' + # validate serialization + source_dict = json_dict_absence_alan['data'][0] + target_dict = marathon.to_dict() + compare_labeled_attributes(source_dict, target_dict) + + +@responses.activate +def test_get_absences_from_employee_objects(): + # mock endpoints & get absences for all employees + mock_employees() + mock_absences() + personio = mock_personio() + employees = personio.get_employees() + assert employees + absences = personio.get_absences(employees) + # the response is not important (it does not match the input), but the function should accept + # a list of Employee objects as parameter and return a result + assert absences + + +@responses.activate +def test_get_absence_types(): + # mock the get absence types endpoint + responses.add( + responses.GET, 'https://api.personio.de/v1/company/time-off-types', status=200, + json=json_dict_absence_types, adding_headers={'Authorization': 'Bearer foo'}) + # configure personio & get absences for alan + personio = mock_personio() + absence_types = personio.get_absence_types() + # non-empty contents + assert len(absence_types) == 3 + for at in absence_types: + assert at.id_ > 0 + assert isinstance(at.name, str) + # serialization matches input + for source_dict, at in zip(json_dict_absence_types['data'], absence_types): + target_dict = at.to_dict() + assert source_dict == target_dict + + +def mock_absences(): + # mock the get absences endpoint (with different array offsets) + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=0.*'), + status=200, json=json_dict_absence_alan, adding_headers={'Authorization': 'Bearer foo'}) + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=3.*'), + status=200, json=json_dict_empty_response, adding_headers={'Authorization': 'Bearer bar'}) From a2bd5fbca225eb51ceee77ad495eeb5d8064112c Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Tue, 1 Dec 2020 17:44:43 +0100 Subject: [PATCH 06/29] Changed client to be public. Add mock tests for deleting absences and absence id queries --- src/personio_py/models.py | 12 ++-- tests/test_mock_api.py | 1 + tests/test_mock_api_absence_data.py | 65 +++++++++++++++++++++ tests/test_mock_api_absences.py | 88 ++++++++++++++++++++++++++++- 4 files changed, 159 insertions(+), 7 deletions(-) diff --git a/src/personio_py/models.py b/src/personio_py/models.py index 30fcb30..70996c4 100644 --- a/src/personio_py/models.py +++ b/src/personio_py/models.py @@ -67,7 +67,7 @@ class PersonioResource: def __init__(self, client: 'Personio' = None, **kwargs): super().__init__() - self._client = client + self.client = client @classmethod def _field_mapping(cls) -> Dict[str, FieldMapping]: @@ -282,7 +282,7 @@ def _delete(self, client: 'Personio'): UnsupportedMethodError('delete', self.__class__) def _check_client(self, client: 'Personio' = None) -> 'Personio': - client = client or self._client + client = client or self.client if not client: raise PersonioError() if not client.authenticated: @@ -471,14 +471,14 @@ class ShortEmployee(LabeledAttributesMixin): def __init__(self, client: 'Personio' = None, id_: int = None, first_name: str = None, last_name: str = None, email: str = None, **kwargs): super().__init__(**kwargs) - self._client = client + self.client = client self.id_ = id_ self.first_name = first_name self.last_name = last_name self.email = email def resolve(self, client: 'Personio' = None) -> 'Employee': - client = client or self._client + client = client or self.client if client: return client.get_employee(self.id_) else: @@ -819,7 +819,7 @@ def log_once(level: int, message: str): def get_client(resource: PersonioResource, client: 'Personio' = None): - if resource._client or client: - return resource._client or client + if resource.client or client: + return resource.client or client raise PersonioError(f"no Personio client reference is available, please provide it to " f"your {type(resource).__name__} or as function parameter") diff --git a/tests/test_mock_api.py b/tests/test_mock_api.py index 0cbfd65..7bf9134 100644 --- a/tests/test_mock_api.py +++ b/tests/test_mock_api.py @@ -10,6 +10,7 @@ iso_date_match = re.compile(r'\d\d\d\d-\d\d-\d\d') + @responses.activate def test_authenticate_ok(): # mock a successful authentication response diff --git a/tests/test_mock_api_absence_data.py b/tests/test_mock_api_absence_data.py index 1bff6d9..4ef561a 100644 --- a/tests/test_mock_api_absence_data.py +++ b/tests/test_mock_api_absence_data.py @@ -144,6 +144,60 @@ """ json_dict_absence_alan = json.loads(json_string_absence_alan) +json_string_absence_alan_single = """ +{ + "success": true, + "data": [{ + "type": "TimeOffPeriod", + "attributes": { + "id": 17205942, + "status": "approved", + "comment": "marathon starts at noon", + "start_date": "1944-09-01T00:00:00+02:00", + "end_date": "1944-09-01T00:00:00+02:00", + "days_count": 0.5, + "half_day_start": 0, + "half_day_end": 1, + "time_off_type": { + "type": "TimeOffType", + "attributes": { + "id": 195824, + "name": "vacation" + } + }, + "employee": { + "type": "Employee", + "attributes": { + "id": { + "label": "ID", + "value": 2116365 + }, + "first_name": { + "label": "First name", + "value": "Alan" + }, + "last_name": { + "label": "Last name", + "value": "Turing" + }, + "email": { + "label": "Email", + "value": "alan@example.org" + } + } + }, + "created_by": "Alan Turing", + "certificate": { + "status": "not-required" + }, + "created_at": "2020-08-21T18:07:06+02:00" + } + } + ] +} +""" +json_dict_absence_alan_first = json.loads(json_string_absence_alan_single) + json_string_absence_types = """ { @@ -171,3 +225,14 @@ } """ json_dict_absence_types = json.loads(json_string_absence_types) + +json_string_delete_absence = """ +{ + "success": true, + "data": + { + "message": "The absence period was deleted." + } +} +""" +json_dict_delete_absence = json.loads(json_string_delete_absence) diff --git a/tests/test_mock_api_absences.py b/tests/test_mock_api_absences.py index 3ea57dc..f05de16 100644 --- a/tests/test_mock_api_absences.py +++ b/tests/test_mock_api_absences.py @@ -1,11 +1,73 @@ from datetime import date +import pytest import responses import re +from personio_py import PersonioError from tests.test_mock_api import mock_personio, compare_labeled_attributes, mock_employees from tests.mock_data import json_dict_empty_response -from tests.test_mock_api_absence_data import json_dict_absence_alan, json_dict_absence_types +from tests.test_mock_api_absence_data import json_dict_absence_alan, json_dict_absence_types, json_dict_delete_absence, \ + json_dict_absence_alan_first + + +@responses.activate +def test_delete_absence(): + mock_delete_absence() + personio = mock_personio() + result = personio.delete_absence(2116365) + assert result is True + + mock_absences() + absence = personio.get_absences(2116365)[0] + absence.delete() + absence.client = None + with pytest.raises(PersonioError): + absence.delete() + absence.delete(client=personio) + absence.client = personio + absence.id_ = None + with pytest.raises(ValueError): + absence.delete() + with pytest.raises(ValueError): + personio.delete_absence(None) + + +@responses.activate +def test_delete_absence_remote_query(): + mock_single_absences() + personio = mock_personio() + absence = personio.get_absences(111222333)[0] + absence.id_ = None + mock_delete_absence() + personio.delete_absence(absence, remote_query_id=True) + absence.id_ = None + start_date = absence.start_date + absence.start_date = None + with pytest.raises(ValueError): + personio.delete_absence(absence, remote_query_id=True) + absence.start_date = start_date + end_date = absence.end_date + absence.end_date = None + with pytest.raises(ValueError): + personio.delete_absence(absence, remote_query_id=True) + absence.end_date = end_date + employee = absence.employee + absence.employee = None + with pytest.raises(ValueError): + personio.delete_absence(absence, remote_query_id=True) + absence.employee = employee + responses.reset() + mock_absences() + personio = mock_personio() + with pytest.raises(ValueError): + personio.delete_absence(absence, remote_query_id=True) + responses.reset() + mock_no_absences() + personio = mock_personio() + with pytest.raises(ValueError): + personio.delete_absence(absence, remote_query_id=True) + @responses.activate @@ -71,3 +133,27 @@ def mock_absences(): responses.add( responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=3.*'), status=200, json=json_dict_empty_response, adding_headers={'Authorization': 'Bearer bar'}) + + +def mock_single_absences(): + # mock the get absences endpoint (with different array offsets) + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=0.*'), + status=200, json=json_dict_absence_alan_first, adding_headers={'Authorization': 'Bearer foo'}) + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=1.*'), + status=200, json=json_dict_empty_response, adding_headers={'Authorization': 'Bearer bar'}) + + +def mock_no_absences(): + # mock the get absences endpoint + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=0.*'), + status=200, json=json_dict_empty_response, adding_headers={'Authorization': 'Bearer bar'}) + + +def mock_delete_absence(): + # mock the delete endpoint + responses.add( + responses.DELETE, re.compile('https://api.personio.de/v1/company/time-offs/*'), + status=200, json=json_dict_delete_absence, adding_headers={'Authorization': 'Bearer bar'}) From 638ac34b6bc4c329a1c1b17a3b0441cfe3c6726c Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Wed, 2 Dec 2020 09:52:10 +0100 Subject: [PATCH 07/29] Moved mocking related data and functions to tests/mock folder. Added mock test for creating absences --- tests/mock/__init__.py | 0 .../absence_data.py} | 57 ++++++++++++++- tests/mock/absences_mock_functions.py | 52 +++++++++++++ tests/test_mock_api_absences.py | 73 +++++++------------ 4 files changed, 135 insertions(+), 47 deletions(-) create mode 100644 tests/mock/__init__.py rename tests/{test_mock_api_absence_data.py => mock/absence_data.py} (79%) create mode 100644 tests/mock/absences_mock_functions.py diff --git a/tests/mock/__init__.py b/tests/mock/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_mock_api_absence_data.py b/tests/mock/absence_data.py similarity index 79% rename from tests/test_mock_api_absence_data.py rename to tests/mock/absence_data.py index 4ef561a..84181fb 100644 --- a/tests/test_mock_api_absence_data.py +++ b/tests/mock/absence_data.py @@ -229,10 +229,63 @@ json_string_delete_absence = """ { "success": true, - "data": - { + "data": { "message": "The absence period was deleted." } } """ json_dict_delete_absence = json.loads(json_string_delete_absence) + +json_string_absence_create_no_halfdays = """ +{ + "success":true, + "data":{ + "type":"TimeOffPeriod", + "attributes":{ + "id":22809350, + "status":"approved", + "comment":"", + "start_date":"2021-01-01T00:00:00+01:00", + "end_date":"2021-01-10T00:00:00+01:00", + "days_count":5, + "half_day_start":1, + "half_day_end":1, + "time_off_type":{ + "type":"TimeOffType", + "attributes":{ + "id":243402, + "name":"Unpaid vacation", + "category":"unpaid_vacation" + } + }, + "employee":{ + "type":"Employee", + "attributes":{ + "id":{ + "label":"ID", + "value":2628890 + }, + "first_name":{ + "label":"First name", + "value":"Alan" + }, + "last_name":{ + "label":"Last name", + "value":"Turing" + }, + "email":{ + "label":"Email", + "value":"alan.turing@cetitec.com" + } + } + }, + "created_by":"API", + "certificate":{ + "status":"not-required" + }, + "created_at":"2020-12-01T18:24:11+01:00" + } + } +} +""" +json_dict_absence_create_no_halfdays = json.loads(json_string_absence_create_no_halfdays) diff --git a/tests/mock/absences_mock_functions.py b/tests/mock/absences_mock_functions.py new file mode 100644 index 0000000..adff332 --- /dev/null +++ b/tests/mock/absences_mock_functions.py @@ -0,0 +1,52 @@ +import re +import responses + +from tests.mock_data import json_dict_empty_response +from tests.mock.absence_data import json_dict_absence_alan, json_dict_absence_alan_first, \ + json_dict_delete_absence, json_dict_absence_create_no_halfdays, json_dict_absence_types + + +def mock_absence_types(): + # mock the get absence types endpoint + responses.add( + responses.GET, 'https://api.personio.de/v1/company/time-off-types', status=200, + json=json_dict_absence_types, adding_headers={'Authorization': 'Bearer foo'}) + +def mock_absences(): + # mock the get absences endpoint (with different array offsets) + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=0.*'), + status=200, json=json_dict_absence_alan, adding_headers={'Authorization': 'Bearer foo'}) + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=3.*'), + status=200, json=json_dict_empty_response, adding_headers={'Authorization': 'Bearer bar'}) + + +def mock_single_absences(): + # mock the get absences endpoint (with different array offsets) + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=0.*'), + status=200, json=json_dict_absence_alan_first, adding_headers={'Authorization': 'Bearer foo'}) + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=1.*'), + status=200, json=json_dict_empty_response, adding_headers={'Authorization': 'Bearer bar'}) + + +def mock_no_absences(): + # mock the get absences endpoint + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=0.*'), + status=200, json=json_dict_empty_response, adding_headers={'Authorization': 'Bearer bar'}) + + +def mock_delete_absence(): + # mock the delete endpoint + responses.add( + responses.DELETE, re.compile('https://api.personio.de/v1/company/time-offs/*'), + status=200, json=json_dict_delete_absence, adding_headers={'Authorization': 'Bearer bar'}) + + +def mock_create_absence_no_halfdays(): + responses.add( + responses.POST, 'https://api.personio.de/v1/company/time-offs', + status=200, json=json_dict_absence_create_no_halfdays, adding_headers={'Authorization': 'Bearer bar'}) diff --git a/tests/test_mock_api_absences.py b/tests/test_mock_api_absences.py index f05de16..83ffeb0 100644 --- a/tests/test_mock_api_absences.py +++ b/tests/test_mock_api_absences.py @@ -1,14 +1,35 @@ from datetime import date import pytest -import responses -import re -from personio_py import PersonioError +from personio_py import PersonioError, Absence, Employee from tests.test_mock_api import mock_personio, compare_labeled_attributes, mock_employees -from tests.mock_data import json_dict_empty_response -from tests.test_mock_api_absence_data import json_dict_absence_alan, json_dict_absence_types, json_dict_delete_absence, \ - json_dict_absence_alan_first +from tests.mock.absence_data import json_dict_absence_types +from tests.mock.absences_mock_functions import * + + +@responses.activate +def test_create_absence(): + mock_absence_types() + mock_create_absence_no_halfdays() + personio = mock_personio() + absence_type = personio.get_absence_types()[0] + employee = Employee( + first_name="Alan", + last_name='Turing', + email='alan.turing@cetitec.com' + ) + absence = Absence( + client=personio, + employee=employee, + start_date=date(2020, 1, 1), + end_date=date(2020, 1, 10), + half_day_start=False, + half_day_end=False, + time_off_type=absence_type) + absence.create() + assert absence.id_ + @responses.activate @@ -69,7 +90,6 @@ def test_delete_absence_remote_query(): personio.delete_absence(absence, remote_query_id=True) - @responses.activate def test_get_absences(): # configure personio & get absences for alan @@ -107,10 +127,7 @@ def test_get_absences_from_employee_objects(): @responses.activate def test_get_absence_types(): - # mock the get absence types endpoint - responses.add( - responses.GET, 'https://api.personio.de/v1/company/time-off-types', status=200, - json=json_dict_absence_types, adding_headers={'Authorization': 'Bearer foo'}) + mock_absence_types() # configure personio & get absences for alan personio = mock_personio() absence_types = personio.get_absence_types() @@ -123,37 +140,3 @@ def test_get_absence_types(): for source_dict, at in zip(json_dict_absence_types['data'], absence_types): target_dict = at.to_dict() assert source_dict == target_dict - - -def mock_absences(): - # mock the get absences endpoint (with different array offsets) - responses.add( - responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=0.*'), - status=200, json=json_dict_absence_alan, adding_headers={'Authorization': 'Bearer foo'}) - responses.add( - responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=3.*'), - status=200, json=json_dict_empty_response, adding_headers={'Authorization': 'Bearer bar'}) - - -def mock_single_absences(): - # mock the get absences endpoint (with different array offsets) - responses.add( - responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=0.*'), - status=200, json=json_dict_absence_alan_first, adding_headers={'Authorization': 'Bearer foo'}) - responses.add( - responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=1.*'), - status=200, json=json_dict_empty_response, adding_headers={'Authorization': 'Bearer bar'}) - - -def mock_no_absences(): - # mock the get absences endpoint - responses.add( - responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=0.*'), - status=200, json=json_dict_empty_response, adding_headers={'Authorization': 'Bearer bar'}) - - -def mock_delete_absence(): - # mock the delete endpoint - responses.add( - responses.DELETE, re.compile('https://api.personio.de/v1/company/time-offs/*'), - status=200, json=json_dict_delete_absence, adding_headers={'Authorization': 'Bearer bar'}) From 88a976826894c7d2e7948564bd945f936b1523e9 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Wed, 2 Dec 2020 12:28:22 +0100 Subject: [PATCH 08/29] Update import statement --- tests/test_api_raw.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_api_raw.py b/tests/test_api_raw.py index 4adf618..557bce0 100644 --- a/tests/test_api_raw.py +++ b/tests/test_api_raw.py @@ -1,4 +1,4 @@ -from .apitest_shared import * +from tests.apitest_shared import * @skip_if_no_auth From 380068c63b11af5575a0559c346da62e73b16008 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Wed, 2 Dec 2020 12:48:17 +0100 Subject: [PATCH 09/29] Don't use shared_test_data dict, cache retrieval of valid online user --- tests/apitest_shared.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/tests/apitest_shared.py b/tests/apitest_shared.py index 5fd742b..533a4f5 100644 --- a/tests/apitest_shared.py +++ b/tests/apitest_shared.py @@ -1,6 +1,8 @@ import os import pytest +from functools import lru_cache + from personio_py import Personio, PersonioError # Personio client authentication @@ -8,23 +10,17 @@ CLIENT_SECRET = os.getenv('CLIENT_SECRET') personio = Personio(client_id=CLIENT_ID, client_secret=CLIENT_SECRET) -shared_test_data = {} + +#@lru_cache +def get_test_employee(): + return personio.get_employees()[0] + # deactivate all tests that rely on a specific personio instance try: personio.authenticate() can_authenticate = True # This is used to ensure the test check for existing objects - test_employee = personio.get_employees()[0] - shared_test_data = { - 'test_employee': { - 'id': test_employee.id_, - 'first_name': test_employee.first_name, - 'last_name': test_employee.last_name, - 'email': test_employee.email, - 'hire_date': test_employee.hire_date - } - } except PersonioError: can_authenticate = False skip_if_no_auth = pytest.mark.skipif(not can_authenticate, reason="Personio authentication failed") From 48c3777fc5eeb546a7e79020469e88a20727d62c Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Wed, 2 Dec 2020 12:48:17 +0100 Subject: [PATCH 10/29] Don't use shared_test_data dict, cache retrieval of valid online user --- tests/apitest_shared.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/tests/apitest_shared.py b/tests/apitest_shared.py index 5fd742b..6bc72e5 100644 --- a/tests/apitest_shared.py +++ b/tests/apitest_shared.py @@ -1,6 +1,8 @@ import os import pytest +from functools import lru_cache + from personio_py import Personio, PersonioError # Personio client authentication @@ -8,23 +10,17 @@ CLIENT_SECRET = os.getenv('CLIENT_SECRET') personio = Personio(client_id=CLIENT_ID, client_secret=CLIENT_SECRET) -shared_test_data = {} + +@lru_cache +def get_test_employee(): + return personio.get_employees()[0] + # deactivate all tests that rely on a specific personio instance try: personio.authenticate() can_authenticate = True # This is used to ensure the test check for existing objects - test_employee = personio.get_employees()[0] - shared_test_data = { - 'test_employee': { - 'id': test_employee.id_, - 'first_name': test_employee.first_name, - 'last_name': test_employee.last_name, - 'email': test_employee.email, - 'hire_date': test_employee.hire_date - } - } except PersonioError: can_authenticate = False skip_if_no_auth = pytest.mark.skipif(not can_authenticate, reason="Personio authentication failed") From 6fcc7b4916de60a64b328aa6ec285672e1baf2c5 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Wed, 2 Dec 2020 12:59:22 +0100 Subject: [PATCH 11/29] use get_test_employee function instead of shared dict --- tests/test_api_absences.py | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/tests/test_api_absences.py b/tests/test_api_absences.py index 439662b..12044d1 100644 --- a/tests/test_api_absences.py +++ b/tests/test_api_absences.py @@ -12,8 +12,7 @@ def test_create_absences(half_day_start: bool, half_day_end: bool): Test the creation of absence records on the server. """ # Prepare data - test_data = shared_test_data['test_employee'] - test_user = personio.get_employee(test_data['id']) + test_user = get_test_employee() start_date = date(2021, 1, 1) end_date = date(2021, 1, 10) @@ -73,8 +72,7 @@ def test_get_absences_from_absence_object_without_id_no_remote_query(): @skip_if_no_auth def test_delete_absences_from_model_no_client(): - test_data = shared_test_data['test_employee'] - test_user = personio.get_employee(test_data['id']) + test_user = get_test_employee() delete_all_absences_of_employee(test_user) absence = create_absence_for_user(test_user, create=True) with pytest.raises(PersonioError): @@ -83,8 +81,7 @@ def test_delete_absences_from_model_no_client(): @skip_if_no_auth def test_delete_absences_from_model_passed_client(): - test_data = shared_test_data['test_employee'] - test_user = personio.get_employee(test_data['id']) + test_user = get_test_employee() delete_all_absences_of_employee(test_user) absence = create_absence_for_user(test_user, create=True) assert absence.delete(client=personio) is True @@ -92,18 +89,16 @@ def test_delete_absences_from_model_passed_client(): @skip_if_no_auth def test_delete_absences_from_model_with_client(): - test_data = shared_test_data['test_employee'] - test_user = personio.get_employee(test_data['id']) + test_user = get_test_employee() delete_all_absences_of_employee(test_user) absence = create_absence_for_user(test_user, create=True) - absence._client = personio + absence.client = personio assert absence.delete() is True @skip_if_no_auth def test_delete_absence_from_absence_id(): - test_data = shared_test_data['test_employee'] - test_user = personio.get_employee(test_data['id']) + test_user = get_test_employee() delete_all_absences_of_employee(test_user) absence = create_absence_for_user(test_user, create=True) assert personio.delete_absence(absence.id_) is True @@ -111,8 +106,7 @@ def test_delete_absence_from_absence_id(): @skip_if_no_auth def test_delete_absences_from_client_object_with_id(): - test_data = shared_test_data['test_employee'] - test_user = personio.get_employee(test_data['id']) + test_user = get_test_employee() delete_all_absences_of_employee(test_user) absence = create_absence_for_user(test_user, create=True) assert personio.delete_absence(absence) is True @@ -120,8 +114,7 @@ def test_delete_absences_from_client_object_with_id(): @skip_if_no_auth def test_delete_absences_from_client_object_with_no_id_query(): - test_data = shared_test_data['test_employee'] - test_user = personio.get_employee(test_data['id']) + test_user = get_test_employee() delete_all_absences_of_employee(test_user) absence = create_absence_for_user(test_user, create=True) absence.id_ = None @@ -130,8 +123,7 @@ def test_delete_absences_from_client_object_with_no_id_query(): @skip_if_no_auth def test_delete_absences_from_client_object_with_no_id_no_query(): - test_data = shared_test_data['test_employee'] - test_user = personio.get_employee(test_data['id']) + test_user = get_test_employee() delete_all_absences_of_employee(test_user) absence = create_absence_for_user(test_user, create=True) absence.id_ = None @@ -185,8 +177,7 @@ def create_absence_for_user(employee: Employee, def prepare_test_get_absences() -> Employee: - test_data = shared_test_data['test_employee'] - test_user = personio.get_employee(test_data['id']) + test_user = get_test_employee() # Be sure there are no leftover absences delete_all_absences_of_employee(test_user) From a7699c63433ae0676a56c1cd6b673d45df2bd89f Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Wed, 2 Dec 2020 13:06:48 +0100 Subject: [PATCH 12/29] Don't require python3.8 to run the tests --- tests/apitest_shared.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/apitest_shared.py b/tests/apitest_shared.py index 6bc72e5..3fa33ff 100644 --- a/tests/apitest_shared.py +++ b/tests/apitest_shared.py @@ -11,7 +11,7 @@ personio = Personio(client_id=CLIENT_ID, client_secret=CLIENT_SECRET) -@lru_cache +@lru_cache(maxsize=1) def get_test_employee(): return personio.get_employees()[0] From 4760c9543f9f5b47af48f25000761631ef82cc02 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Wed, 2 Dec 2020 17:50:10 +0100 Subject: [PATCH 13/29] Add mock tests for getting absences --- tests/mock/absence_data.py | 54 +++++++++++++++++++++++++++ tests/mock/absences_mock_functions.py | 9 ++++- tests/test_mock_api_absences.py | 20 +++++++++- 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/tests/mock/absence_data.py b/tests/mock/absence_data.py index 84181fb..32f737c 100644 --- a/tests/mock/absence_data.py +++ b/tests/mock/absence_data.py @@ -289,3 +289,57 @@ } """ json_dict_absence_create_no_halfdays = json.loads(json_string_absence_create_no_halfdays) + + +json_string_get_absence = """ +{ + "success":true, + "data":{ + "type":"TimeOffPeriod", + "attributes":{ + "id":2628890, + "status":"approved", + "comment":"", + "start_date":"2021-01-01T00:00:00+01:00", + "end_date":"2021-01-10T00:00:00+01:00", + "days_count":5, + "half_day_start":0, + "half_day_end":1, + "time_off_type":{ + "type":"TimeOffType", + "attributes":{ + "id":243402, + "name":"Unpaid vacation", + "category":"unpaid_vacation" + } + }, + "employee":{ + "type":"Employee", + "attributes":{ + "id":{ + "label":"ID", + "value":2628890 + }, + "first_name":{ + "label":"First name", + "value":"Alan" + }, + "last_name":{ + "label":"Last name", + "value":"Turing" + }, + "email":{ + "label":"Email", + "value":"alan.turing@cetitec.com" + } + } + }, + "created_by":"API", + "certificate":{ + "status":"not-required" + }, + "created_at":"2020-12-02T17:28:34+01:00" + } + } +}""" +json_dict_get_absence = json.loads(json_string_get_absence) diff --git a/tests/mock/absences_mock_functions.py b/tests/mock/absences_mock_functions.py index adff332..d522450 100644 --- a/tests/mock/absences_mock_functions.py +++ b/tests/mock/absences_mock_functions.py @@ -3,7 +3,7 @@ from tests.mock_data import json_dict_empty_response from tests.mock.absence_data import json_dict_absence_alan, json_dict_absence_alan_first, \ - json_dict_delete_absence, json_dict_absence_create_no_halfdays, json_dict_absence_types + json_dict_delete_absence, json_dict_absence_create_no_halfdays, json_dict_absence_types, json_dict_get_absence def mock_absence_types(): @@ -12,6 +12,7 @@ def mock_absence_types(): responses.GET, 'https://api.personio.de/v1/company/time-off-types', status=200, json=json_dict_absence_types, adding_headers={'Authorization': 'Bearer foo'}) + def mock_absences(): # mock the get absences endpoint (with different array offsets) responses.add( @@ -50,3 +51,9 @@ def mock_create_absence_no_halfdays(): responses.add( responses.POST, 'https://api.personio.de/v1/company/time-offs', status=200, json=json_dict_absence_create_no_halfdays, adding_headers={'Authorization': 'Bearer bar'}) + + +def mock_get_absence(): + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/time-offs/.*'), + status=200, json=json_dict_get_absence, adding_headers={'Authorization': 'Bearer bar'}) diff --git a/tests/test_mock_api_absences.py b/tests/test_mock_api_absences.py index 83ffeb0..1d0c404 100644 --- a/tests/test_mock_api_absences.py +++ b/tests/test_mock_api_absences.py @@ -4,10 +4,27 @@ from personio_py import PersonioError, Absence, Employee from tests.test_mock_api import mock_personio, compare_labeled_attributes, mock_employees -from tests.mock.absence_data import json_dict_absence_types from tests.mock.absences_mock_functions import * +@responses.activate +def test_get_absence(): + personio = mock_personio() + mock_get_absence() + absence_id_only = Absence(id_=2628890) + absence = personio.get_absence(absence_id_only) + assert absence.employee.first_name == 'Alan' + assert absence.employee.last_name == 'Turing' + assert absence.id_ == 2628890 + assert absence.start_date == date(2021, 1, 1) + assert absence.end_date == date(2021, 1, 10) + absence.id_ = None + with pytest.raises(ValueError): + personio.get_absence(absence, remote_query_id=False) + mock_single_absences() + personio.get_absence(absence, remote_query_id=True) + + @responses.activate def test_create_absence(): mock_absence_types() @@ -31,7 +48,6 @@ def test_create_absence(): assert absence.id_ - @responses.activate def test_delete_absence(): mock_delete_absence() From 5b0bb4351b1f32ae29768e793385b4db72826242 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Fri, 4 Dec 2020 13:39:16 +0100 Subject: [PATCH 14/29] Use unions for type hints --- src/personio_py/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/personio_py/client.py b/src/personio_py/client.py index 7123084..5128bcf 100644 --- a/src/personio_py/client.py +++ b/src/personio_py/client.py @@ -341,7 +341,7 @@ def get_absences(self, employees: Union[int, List[int], Employee, List[Employee] return self._get_employee_metadata( 'company/time-offs', Absence, employees, start_date, end_date) - def get_absence(self, absence: int or Absence, remote_query_id=False) -> Absence: + def get_absence(self, absence: Union[Absence, int], remote_query_id=False) -> Absence: """ Get an absence record from a given id. @@ -373,7 +373,7 @@ def create_absence(self, absence: Absence) -> bool: return True return False - def delete_absence(self, absence: Absence or int, remote_query_id=False): + def delete_absence(self, absence: Union[Absence, int], remote_query_id=False): """ Delete an existing record From d5b892c72e0495299f464170c3d1dd60b162a368 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Mon, 7 Dec 2020 17:41:10 +0100 Subject: [PATCH 15/29] Revert client visibility change. Client is a protected member again. --- src/personio_py/models.py | 8 ++++---- tests/test_api_absences.py | 3 +-- tests/test_mock_api_absences.py | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/personio_py/models.py b/src/personio_py/models.py index 70996c4..19d1516 100644 --- a/src/personio_py/models.py +++ b/src/personio_py/models.py @@ -67,7 +67,7 @@ class PersonioResource: def __init__(self, client: 'Personio' = None, **kwargs): super().__init__() - self.client = client + self._client = client @classmethod def _field_mapping(cls) -> Dict[str, FieldMapping]: @@ -282,7 +282,7 @@ def _delete(self, client: 'Personio'): UnsupportedMethodError('delete', self.__class__) def _check_client(self, client: 'Personio' = None) -> 'Personio': - client = client or self.client + client = client or self._client if not client: raise PersonioError() if not client.authenticated: @@ -819,7 +819,7 @@ def log_once(level: int, message: str): def get_client(resource: PersonioResource, client: 'Personio' = None): - if resource.client or client: - return resource.client or client + if resource._client or client: + return resource._client or client raise PersonioError(f"no Personio client reference is available, please provide it to " f"your {type(resource).__name__} or as function parameter") diff --git a/tests/test_api_absences.py b/tests/test_api_absences.py index 12044d1..b7bc6c2 100644 --- a/tests/test_api_absences.py +++ b/tests/test_api_absences.py @@ -92,7 +92,7 @@ def test_delete_absences_from_model_with_client(): test_user = get_test_employee() delete_all_absences_of_employee(test_user) absence = create_absence_for_user(test_user, create=True) - absence.client = personio + absence._client = personio assert absence.delete() is True @@ -182,4 +182,3 @@ def prepare_test_get_absences() -> Employee: # Be sure there are no leftover absences delete_all_absences_of_employee(test_user) return test_user - diff --git a/tests/test_mock_api_absences.py b/tests/test_mock_api_absences.py index 1d0c404..c975f0d 100644 --- a/tests/test_mock_api_absences.py +++ b/tests/test_mock_api_absences.py @@ -58,11 +58,11 @@ def test_delete_absence(): mock_absences() absence = personio.get_absences(2116365)[0] absence.delete() - absence.client = None + absence._client = None with pytest.raises(PersonioError): absence.delete() absence.delete(client=personio) - absence.client = personio + absence._client = personio absence.id_ = None with pytest.raises(ValueError): absence.delete() From f7e8968cff2e26ecd799f8364bd1e56e3b95d613 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Wed, 13 Jan 2021 10:40:52 +0100 Subject: [PATCH 16/29] Updates according to comment on github --- src/personio_py/models.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/personio_py/models.py b/src/personio_py/models.py index 19d1516..5846386 100644 --- a/src/personio_py/models.py +++ b/src/personio_py/models.py @@ -573,8 +573,8 @@ def __init__(self, start_date: datetime = None, end_date: datetime = None, days_count: float = None, - half_day_start: bool = None, - half_day_end: bool = None, + half_day_start: bool = False, + half_day_end: bool = False, time_off_type: AbsenceType = None, employee: ShortEmployee = None, created_by: str = None, @@ -588,12 +588,8 @@ def __init__(self, self.start_date = start_date self.end_date = end_date self.days_count = days_count - self.half_day_start = False - if half_day_start and half_day_start > 0: - self.half_day_start = True - self.half_day_end = False - if half_day_end and half_day_end > 0: - self.half_day_end = True + self.half_day_start = bool(half_day_start) + self.half_day_end = bool(half_day_end) self.time_off_type = time_off_type self.employee = employee self.created_by = created_by @@ -617,7 +613,6 @@ def to_body_params(self): } if self.comment is not None: data['comment'] = self.comment - print(data) return data From 6fb32a42fc83775405e47e8c9c1877732c9eb742 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Wed, 13 Jan 2021 13:00:25 +0100 Subject: [PATCH 17/29] Fix: duplicated entries returned by API if pagination offset > total number of elements --- src/personio_py/client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/personio_py/client.py b/src/personio_py/client.py index 5128bcf..15cc0c5 100644 --- a/src/personio_py/client.py +++ b/src/personio_py/client.py @@ -169,7 +169,10 @@ def request_paginated( resp_data = response['data'] if resp_data: data_acc.extend(resp_data) - params['offset'] += len(resp_data) + if response['metadata']['current_page'] == response['metadata']['total_pages']: + break + else: + params['offset'] += len(resp_data) else: break # return the accumulated data From df95c36dbc086ffcf4013b629ccbf2787d3a8fec Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Wed, 13 Jan 2021 14:46:59 +0100 Subject: [PATCH 18/29] Don't allow remote id queries for absence delete operations --- src/personio_py/client.py | 24 ++++++++++++------------ src/personio_py/models.py | 4 ++-- tests/test_api.py | 6 ------ tests/test_api_absences.py | 13 ++----------- 4 files changed, 16 insertions(+), 31 deletions(-) diff --git a/src/personio_py/client.py b/src/personio_py/client.py index 15cc0c5..d38877f 100644 --- a/src/personio_py/client.py +++ b/src/personio_py/client.py @@ -376,16 +376,18 @@ def create_absence(self, absence: Absence) -> bool: return True return False - def delete_absence(self, absence: Union[Absence, int], remote_query_id=False): + def delete_absence(self, absence: Union[Absence, int]): """ Delete an existing record - Either an absence id or o remote query is required. Remote queries are only executed if required. + Either an absence id or o remote query is required. + Remote queries are only executed if required. - :param absence: The Absence object holding the new data or an absence record id to delete. - :param remote_query_id: Allow a remote query for the id if it is not set within the given Absence object. + :param absence: The Absence object holding + the new data or an absence record id to delete. :raises: - ValueError: If a query is required but not allowed or the query does not provide exactly one result. + ValueError: If a query is required but not allowed + or the query does not provide exactly one result. """ if isinstance(absence, int): response = self.request_json(path='company/time-offs/' + str(absence), method='DELETE') @@ -394,11 +396,7 @@ def delete_absence(self, absence: Union[Absence, int], remote_query_id=False): if absence.id_ is not None: return self.delete_absence(absence.id_) else: - if remote_query_id: - absence = self.__add_remote_absence_id(absence) - return self.delete_absence(absence.id_) - else: - raise ValueError("You either need to provide the absence id or allow a remote query.") + raise ValueError("Only an absence with an absence id can be deleted.") else: raise ValueError("absence must be an Absence object or an integer") @@ -454,7 +452,8 @@ def _normalize_timeframe_params( def __add_remote_absence_id(self, absence: Absence) -> Absence: """ - Queries the API for an absence record matching the given Absence object and adds the remote id. + Queries the API for an absence record matching + the given Absence object and adds the remote id. :param absence: The absence object to be updated :return: The absence object with the absence_id set @@ -466,7 +465,8 @@ def __add_remote_absence_id(self, absence: Absence) -> Absence: if absence.end_date is None: raise ValueError("For a remote query an end date is required") matching_remote_absences = self.get_absences(employees=[absence.employee.id_], - start_date=absence.start_date, end_date=absence.end_date) + start_date=absence.start_date, + end_date=absence.end_date) if len(matching_remote_absences) == 0: raise ValueError("The absence to patch was not found") elif len(matching_remote_absences) > 1: diff --git a/src/personio_py/models.py b/src/personio_py/models.py index 5846386..1694f1e 100644 --- a/src/personio_py/models.py +++ b/src/personio_py/models.py @@ -599,8 +599,8 @@ def __init__(self, def _create(self, client: 'Personio' = None): return get_client(self, client).create_absence(self) - def _delete(self, client: 'Personio' = None, allow_remote_query: bool = False): - return get_client(self, client).delete_absence(self, remote_query_id=allow_remote_query) + def _delete(self, client: 'Personio' = None): + return get_client(self, client).delete_absence(self) def to_body_params(self): data = { diff --git a/tests/test_api.py b/tests/test_api.py index 2215493..d6c6a45 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -46,12 +46,6 @@ def test_create_employee(): assert ada_created.status == 'active' -@skip_if_no_auth -def test_get_absences(): - absences = personio.get_absences(2007207) - assert len(absences) > 0 - - @skip_if_no_auth def test_get_attendances(): attendances = personio.get_attendances(2007207) diff --git a/tests/test_api_absences.py b/tests/test_api_absences.py index b7bc6c2..5a8d747 100644 --- a/tests/test_api_absences.py +++ b/tests/test_api_absences.py @@ -113,22 +113,13 @@ def test_delete_absences_from_client_object_with_id(): @skip_if_no_auth -def test_delete_absences_from_client_object_with_no_id_query(): - test_user = get_test_employee() - delete_all_absences_of_employee(test_user) - absence = create_absence_for_user(test_user, create=True) - absence.id_ = None - assert personio.delete_absence(absence, remote_query_id=True) is True - - -@skip_if_no_auth -def test_delete_absences_from_client_object_with_no_id_no_query(): +def test_delete_absences_from_client_object_with_no_id(): test_user = get_test_employee() delete_all_absences_of_employee(test_user) absence = create_absence_for_user(test_user, create=True) absence.id_ = None with pytest.raises(ValueError): - personio.delete_absence(absence, remote_query_id=False) + personio.delete_absence(absence) def delete_absences(client: Personio, absences: [int] or [Absence]): From 539fd1efda22d833cf1bb2aa36160f2005f7d856 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Wed, 13 Jan 2021 14:56:41 +0100 Subject: [PATCH 19/29] include metadata in mock responses for absences --- tests/mock/absence_data.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/mock/absence_data.py b/tests/mock/absence_data.py index 32f737c..249ed21 100644 --- a/tests/mock/absence_data.py +++ b/tests/mock/absence_data.py @@ -3,6 +3,10 @@ json_string_absence_alan = """ { "success": true, + "metadata":{ + "current_page":1, + "total_pages":1 + }, "data": [{ "type": "TimeOffPeriod", "attributes": { @@ -147,6 +151,10 @@ json_string_absence_alan_single = """ { "success": true, + "metadata":{ + "current_page":1, + "total_pages":1 + }, "data": [{ "type": "TimeOffPeriod", "attributes": { @@ -202,6 +210,10 @@ json_string_absence_types = """ { "success": true, + "metadata":{ + "current_page":1, + "total_pages":1 + }, "data": [{ "type": "TimeOffType", "attributes": { @@ -229,6 +241,10 @@ json_string_delete_absence = """ { "success": true, + "metadata":{ + "current_page":1, + "total_pages":1 + }, "data": { "message": "The absence period was deleted." } @@ -239,6 +255,10 @@ json_string_absence_create_no_halfdays = """ { "success":true, + "metadata":{ + "current_page":1, + "total_pages":1 + }, "data":{ "type":"TimeOffPeriod", "attributes":{ @@ -294,6 +314,10 @@ json_string_get_absence = """ { "success":true, + "metadata":{ + "current_page":1, + "total_pages":1 + }, "data":{ "type":"TimeOffPeriod", "attributes":{ From 1dc0bed771c27a469270d4d06dc28e8868f6f008 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Wed, 13 Jan 2021 15:13:50 +0100 Subject: [PATCH 20/29] Always query for remote ids for absence get operations. Updated api mock tests --- src/personio_py/client.py | 10 ++--- src/personio_py/models.py | 2 +- tests/mock_data.py | 4 ++ tests/test_mock_api_absences.py | 70 ++++++++++++++------------------- 4 files changed, 37 insertions(+), 49 deletions(-) diff --git a/src/personio_py/client.py b/src/personio_py/client.py index d38877f..646910d 100644 --- a/src/personio_py/client.py +++ b/src/personio_py/client.py @@ -344,12 +344,11 @@ def get_absences(self, employees: Union[int, List[int], Employee, List[Employee] return self._get_employee_metadata( 'company/time-offs', Absence, employees, start_date, end_date) - def get_absence(self, absence: Union[Absence, int], remote_query_id=False) -> Absence: + def get_absence(self, absence: Union[Absence, int]) -> Absence: """ Get an absence record from a given id. :param absence: The absence id to fetch. - :param remote_query_id: Whether it is allowed to make a remote ID query. """ if isinstance(absence, int): response = self.request_json('company/time-offs/' + str(absence)) @@ -357,11 +356,9 @@ def get_absence(self, absence: Union[Absence, int], remote_query_id=False) -> Ab else: if absence.id_: return self.get_absence(absence.id_) - elif absence.id_ is None and remote_query_id: + elif absence.id_ is None: self.__add_remote_absence_id(absence) return self.get_absence(absence.id_) - else: - raise ValueError("Id is required to get an absence record") def create_absence(self, absence: Absence) -> bool: """ @@ -380,8 +377,7 @@ def delete_absence(self, absence: Union[Absence, int]): """ Delete an existing record - Either an absence id or o remote query is required. - Remote queries are only executed if required. + An absence id is required. :param absence: The Absence object holding the new data or an absence record id to delete. diff --git a/src/personio_py/models.py b/src/personio_py/models.py index 1694f1e..76191bc 100644 --- a/src/personio_py/models.py +++ b/src/personio_py/models.py @@ -276,7 +276,7 @@ def delete(self, client: 'Personio' = None): client = self._check_client(client) return self._delete(client) else: - raise UnsupportedMethodError('delete', self.__class__) + raise ValueError("Cannot delete without an api client") def _delete(self, client: 'Personio'): UnsupportedMethodError('delete', self.__class__) diff --git a/tests/mock_data.py b/tests/mock_data.py index e9fcae9..f45bfc0 100644 --- a/tests/mock_data.py +++ b/tests/mock_data.py @@ -720,6 +720,10 @@ json_string_attendance_rms = """ { "success": true, + "metadata":{ + "current_page":1, + "total_pages":1 + }, "data": [{ "id": 33479712, "type": "AttendancePeriod", diff --git a/tests/test_mock_api_absences.py b/tests/test_mock_api_absences.py index c975f0d..f1e24e1 100644 --- a/tests/test_mock_api_absences.py +++ b/tests/test_mock_api_absences.py @@ -8,7 +8,7 @@ @responses.activate -def test_get_absence(): +def test_get_absence_from_id(): personio = mock_personio() mock_get_absence() absence_id_only = Absence(id_=2628890) @@ -18,11 +18,17 @@ def test_get_absence(): assert absence.id_ == 2628890 assert absence.start_date == date(2021, 1, 1) assert absence.end_date == date(2021, 1, 10) - absence.id_ = None - with pytest.raises(ValueError): - personio.get_absence(absence, remote_query_id=False) + + +@responses.activate +def test_get_absence_from_object_without_id(): + personio = mock_personio() + mock_get_absence() mock_single_absences() - personio.get_absence(absence, remote_query_id=True) + absence_id_only = Absence(id_=2628890) + absence = personio.get_absence(absence_id_only) + absence.id_ = None + personio.get_absence(absence) @responses.activate @@ -55,55 +61,37 @@ def test_delete_absence(): result = personio.delete_absence(2116365) assert result is True + +@responses.activate +def test_delete_absence_no_client(): + personio = mock_personio() mock_absences() absence = personio.get_absences(2116365)[0] - absence.delete() absence._client = None with pytest.raises(PersonioError): absence.delete() - absence.delete(client=personio) - absence._client = personio - absence.id_ = None - with pytest.raises(ValueError): - absence.delete() - with pytest.raises(ValueError): - personio.delete_absence(None) @responses.activate -def test_delete_absence_remote_query(): - mock_single_absences() +def test_delete_absence_passed_client(): personio = mock_personio() - absence = personio.get_absences(111222333)[0] - absence.id_ = None - mock_delete_absence() - personio.delete_absence(absence, remote_query_id=True) - absence.id_ = None - start_date = absence.start_date - absence.start_date = None - with pytest.raises(ValueError): - personio.delete_absence(absence, remote_query_id=True) - absence.start_date = start_date - end_date = absence.end_date - absence.end_date = None - with pytest.raises(ValueError): - personio.delete_absence(absence, remote_query_id=True) - absence.end_date = end_date - employee = absence.employee - absence.employee = None - with pytest.raises(ValueError): - personio.delete_absence(absence, remote_query_id=True) - absence.employee = employee - responses.reset() mock_absences() + mock_delete_absence() + absence = personio.get_absences(2116365)[0] + absence._client = None + assert absence.delete(client=personio) is True + + +@responses.activate +def test_delete_absence_no_id(): personio = mock_personio() + mock_absences() + absence = personio.get_absences(2116365)[0] + absence.id_ = None with pytest.raises(ValueError): - personio.delete_absence(absence, remote_query_id=True) - responses.reset() - mock_no_absences() - personio = mock_personio() + absence.delete() with pytest.raises(ValueError): - personio.delete_absence(absence, remote_query_id=True) + personio.delete_absence(absence) @responses.activate From 6d2bf98aae9a2e81452d6098f6e6e7575e202db2 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Thu, 14 Jan 2021 09:14:45 +0100 Subject: [PATCH 21/29] Update online api tests for absences (remote id query is not a passable argument anymore) --- tests/test_api_absences.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/tests/test_api_absences.py b/tests/test_api_absences.py index 5a8d747..898c646 100644 --- a/tests/test_api_absences.py +++ b/tests/test_api_absences.py @@ -52,24 +52,15 @@ def test_get_absences_from_absence_object(): @skip_if_no_auth -def test_get_absences_from_absence_object_without_id_remote_query(): +def test_get_absences_from_absence_object_without_id(): user = prepare_test_get_absences() remote_absence = create_absence_for_user(user, create=True) absence_id = remote_absence.id_ remote_absence.id_ = None - absence = personio.get_absence(remote_absence, remote_query_id=True) + absence = personio.get_absence(remote_absence) assert absence.id_ == absence_id -@skip_if_no_auth -def test_get_absences_from_absence_object_without_id_no_remote_query(): - user = prepare_test_get_absences() - remote_absence = create_absence_for_user(user, create=True) - remote_absence.id_ = None - with pytest.raises(ValueError): - personio.get_absence(remote_absence, remote_query_id=False) - - @skip_if_no_auth def test_delete_absences_from_model_no_client(): test_user = get_test_employee() From 8d9fc66300043879660a84ca00dee51db63ce740 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Thu, 21 Jan 2021 17:34:09 +0100 Subject: [PATCH 22/29] restructure mocking data and functions --- tests/mock/__init__.py | 0 tests/mock/absence_data.py | 369 ------------------------- tests/mock/absences_mock_functions.py | 59 ---- tests/mock_data.py | 371 ++++++++++++++++++++++++++ tests/test_mock_api_absences.py | 58 +++- 5 files changed, 428 insertions(+), 429 deletions(-) delete mode 100644 tests/mock/__init__.py delete mode 100644 tests/mock/absence_data.py delete mode 100644 tests/mock/absences_mock_functions.py diff --git a/tests/mock/__init__.py b/tests/mock/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/mock/absence_data.py b/tests/mock/absence_data.py deleted file mode 100644 index 249ed21..0000000 --- a/tests/mock/absence_data.py +++ /dev/null @@ -1,369 +0,0 @@ -import json - -json_string_absence_alan = """ -{ - "success": true, - "metadata":{ - "current_page":1, - "total_pages":1 - }, - "data": [{ - "type": "TimeOffPeriod", - "attributes": { - "id": 17205942, - "status": "approved", - "comment": "marathon starts at noon", - "start_date": "1944-09-01T00:00:00+02:00", - "end_date": "1944-09-01T00:00:00+02:00", - "days_count": 0.5, - "half_day_start": 0, - "half_day_end": 1, - "time_off_type": { - "type": "TimeOffType", - "attributes": { - "id": 195824, - "name": "vacation" - } - }, - "employee": { - "type": "Employee", - "attributes": { - "id": { - "label": "ID", - "value": 2116365 - }, - "first_name": { - "label": "First name", - "value": "Alan" - }, - "last_name": { - "label": "Last name", - "value": "Turing" - }, - "email": { - "label": "Email", - "value": "alan@example.org" - } - } - }, - "created_by": "Alan Turing", - "certificate": { - "status": "not-required" - }, - "created_at": "2020-08-21T18:07:06+02:00" - } - }, { - "type": "TimeOffPeriod", - "attributes": { - "id": 17205932, - "status": "approved", - "comment": "don't you just hate mondays sometimes?", - "start_date": "1944-07-03T00:00:00+02:00", - "end_date": "1944-07-03T00:00:00+02:00", - "days_count": 1, - "half_day_start": 0, - "half_day_end": 0, - "time_off_type": { - "type": "TimeOffType", - "attributes": { - "id": 195824, - "name": "vacation" - } - }, - "employee": { - "type": "Employee", - "attributes": { - "id": { - "label": "ID", - "value": 2116365 - }, - "first_name": { - "label": "First name", - "value": "Alan" - }, - "last_name": { - "label": "Last name", - "value": "Turing" - }, - "email": { - "label": "Email", - "value": "alan@example.org" - } - } - }, - "created_by": "Alan Turing", - "certificate": { - "status": "not-required" - }, - "created_at": "2020-08-21T18:06:02+02:00" - } - }, { - "type": "TimeOffPeriod", - "attributes": { - "id": 17205920, - "status": "approved", - "comment": "summer vacation", - "start_date": "1944-08-07T00:00:00+02:00", - "end_date": "1944-08-20T00:00:00+02:00", - "days_count": 10, - "half_day_start": 0, - "half_day_end": 0, - "time_off_type": { - "type": "TimeOffType", - "attributes": { - "id": 195824, - "name": "vacation" - } - }, - "employee": { - "type": "Employee", - "attributes": { - "id": { - "label": "ID", - "value": 2116365 - }, - "first_name": { - "label": "First name", - "value": "Alan" - }, - "last_name": { - "label": "Last name", - "value": "Turing" - }, - "email": { - "label": "Email", - "value": "alan@example.org" - } - } - }, - "created_by": "Alan Turing", - "certificate": { - "status": "not-required" - }, - "created_at": "2020-08-21T18:05:04+02:00" - } - } - ] -} -""" -json_dict_absence_alan = json.loads(json_string_absence_alan) - -json_string_absence_alan_single = """ -{ - "success": true, - "metadata":{ - "current_page":1, - "total_pages":1 - }, - "data": [{ - "type": "TimeOffPeriod", - "attributes": { - "id": 17205942, - "status": "approved", - "comment": "marathon starts at noon", - "start_date": "1944-09-01T00:00:00+02:00", - "end_date": "1944-09-01T00:00:00+02:00", - "days_count": 0.5, - "half_day_start": 0, - "half_day_end": 1, - "time_off_type": { - "type": "TimeOffType", - "attributes": { - "id": 195824, - "name": "vacation" - } - }, - "employee": { - "type": "Employee", - "attributes": { - "id": { - "label": "ID", - "value": 2116365 - }, - "first_name": { - "label": "First name", - "value": "Alan" - }, - "last_name": { - "label": "Last name", - "value": "Turing" - }, - "email": { - "label": "Email", - "value": "alan@example.org" - } - } - }, - "created_by": "Alan Turing", - "certificate": { - "status": "not-required" - }, - "created_at": "2020-08-21T18:07:06+02:00" - } - } - ] -} -""" -json_dict_absence_alan_first = json.loads(json_string_absence_alan_single) - - -json_string_absence_types = """ -{ - "success": true, - "metadata":{ - "current_page":1, - "total_pages":1 - }, - "data": [{ - "type": "TimeOffType", - "attributes": { - "id": 195824, - "name": "vacation" - } - }, { - "type": "TimeOffType", - "attributes": { - "id": 195825, - "name": "paid leave" - } - }, { - "type": "TimeOffType", - "attributes": { - "id": 195826, - "name": "sick" - } - } - ] -} -""" -json_dict_absence_types = json.loads(json_string_absence_types) - -json_string_delete_absence = """ -{ - "success": true, - "metadata":{ - "current_page":1, - "total_pages":1 - }, - "data": { - "message": "The absence period was deleted." - } -} -""" -json_dict_delete_absence = json.loads(json_string_delete_absence) - -json_string_absence_create_no_halfdays = """ -{ - "success":true, - "metadata":{ - "current_page":1, - "total_pages":1 - }, - "data":{ - "type":"TimeOffPeriod", - "attributes":{ - "id":22809350, - "status":"approved", - "comment":"", - "start_date":"2021-01-01T00:00:00+01:00", - "end_date":"2021-01-10T00:00:00+01:00", - "days_count":5, - "half_day_start":1, - "half_day_end":1, - "time_off_type":{ - "type":"TimeOffType", - "attributes":{ - "id":243402, - "name":"Unpaid vacation", - "category":"unpaid_vacation" - } - }, - "employee":{ - "type":"Employee", - "attributes":{ - "id":{ - "label":"ID", - "value":2628890 - }, - "first_name":{ - "label":"First name", - "value":"Alan" - }, - "last_name":{ - "label":"Last name", - "value":"Turing" - }, - "email":{ - "label":"Email", - "value":"alan.turing@cetitec.com" - } - } - }, - "created_by":"API", - "certificate":{ - "status":"not-required" - }, - "created_at":"2020-12-01T18:24:11+01:00" - } - } -} -""" -json_dict_absence_create_no_halfdays = json.loads(json_string_absence_create_no_halfdays) - - -json_string_get_absence = """ -{ - "success":true, - "metadata":{ - "current_page":1, - "total_pages":1 - }, - "data":{ - "type":"TimeOffPeriod", - "attributes":{ - "id":2628890, - "status":"approved", - "comment":"", - "start_date":"2021-01-01T00:00:00+01:00", - "end_date":"2021-01-10T00:00:00+01:00", - "days_count":5, - "half_day_start":0, - "half_day_end":1, - "time_off_type":{ - "type":"TimeOffType", - "attributes":{ - "id":243402, - "name":"Unpaid vacation", - "category":"unpaid_vacation" - } - }, - "employee":{ - "type":"Employee", - "attributes":{ - "id":{ - "label":"ID", - "value":2628890 - }, - "first_name":{ - "label":"First name", - "value":"Alan" - }, - "last_name":{ - "label":"Last name", - "value":"Turing" - }, - "email":{ - "label":"Email", - "value":"alan.turing@cetitec.com" - } - } - }, - "created_by":"API", - "certificate":{ - "status":"not-required" - }, - "created_at":"2020-12-02T17:28:34+01:00" - } - } -}""" -json_dict_get_absence = json.loads(json_string_get_absence) diff --git a/tests/mock/absences_mock_functions.py b/tests/mock/absences_mock_functions.py deleted file mode 100644 index d522450..0000000 --- a/tests/mock/absences_mock_functions.py +++ /dev/null @@ -1,59 +0,0 @@ -import re -import responses - -from tests.mock_data import json_dict_empty_response -from tests.mock.absence_data import json_dict_absence_alan, json_dict_absence_alan_first, \ - json_dict_delete_absence, json_dict_absence_create_no_halfdays, json_dict_absence_types, json_dict_get_absence - - -def mock_absence_types(): - # mock the get absence types endpoint - responses.add( - responses.GET, 'https://api.personio.de/v1/company/time-off-types', status=200, - json=json_dict_absence_types, adding_headers={'Authorization': 'Bearer foo'}) - - -def mock_absences(): - # mock the get absences endpoint (with different array offsets) - responses.add( - responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=0.*'), - status=200, json=json_dict_absence_alan, adding_headers={'Authorization': 'Bearer foo'}) - responses.add( - responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=3.*'), - status=200, json=json_dict_empty_response, adding_headers={'Authorization': 'Bearer bar'}) - - -def mock_single_absences(): - # mock the get absences endpoint (with different array offsets) - responses.add( - responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=0.*'), - status=200, json=json_dict_absence_alan_first, adding_headers={'Authorization': 'Bearer foo'}) - responses.add( - responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=1.*'), - status=200, json=json_dict_empty_response, adding_headers={'Authorization': 'Bearer bar'}) - - -def mock_no_absences(): - # mock the get absences endpoint - responses.add( - responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=0.*'), - status=200, json=json_dict_empty_response, adding_headers={'Authorization': 'Bearer bar'}) - - -def mock_delete_absence(): - # mock the delete endpoint - responses.add( - responses.DELETE, re.compile('https://api.personio.de/v1/company/time-offs/*'), - status=200, json=json_dict_delete_absence, adding_headers={'Authorization': 'Bearer bar'}) - - -def mock_create_absence_no_halfdays(): - responses.add( - responses.POST, 'https://api.personio.de/v1/company/time-offs', - status=200, json=json_dict_absence_create_no_halfdays, adding_headers={'Authorization': 'Bearer bar'}) - - -def mock_get_absence(): - responses.add( - responses.GET, re.compile('https://api.personio.de/v1/company/time-offs/.*'), - status=200, json=json_dict_get_absence, adding_headers={'Authorization': 'Bearer bar'}) diff --git a/tests/mock_data.py b/tests/mock_data.py index f45bfc0..1b8f518 100644 --- a/tests/mock_data.py +++ b/tests/mock_data.py @@ -768,3 +768,374 @@ } """ json_dict_attendance_rms = json.loads(json_string_attendance_rms) + + +import json + +json_string_absence_alan = """ +{ + "success": true, + "metadata":{ + "current_page":1, + "total_pages":1 + }, + "data": [{ + "type": "TimeOffPeriod", + "attributes": { + "id": 17205942, + "status": "approved", + "comment": "marathon starts at noon", + "start_date": "1944-09-01T00:00:00+02:00", + "end_date": "1944-09-01T00:00:00+02:00", + "days_count": 0.5, + "half_day_start": 0, + "half_day_end": 1, + "time_off_type": { + "type": "TimeOffType", + "attributes": { + "id": 195824, + "name": "vacation" + } + }, + "employee": { + "type": "Employee", + "attributes": { + "id": { + "label": "ID", + "value": 2116365 + }, + "first_name": { + "label": "First name", + "value": "Alan" + }, + "last_name": { + "label": "Last name", + "value": "Turing" + }, + "email": { + "label": "Email", + "value": "alan@example.org" + } + } + }, + "created_by": "Alan Turing", + "certificate": { + "status": "not-required" + }, + "created_at": "2020-08-21T18:07:06+02:00" + } + }, { + "type": "TimeOffPeriod", + "attributes": { + "id": 17205932, + "status": "approved", + "comment": "don't you just hate mondays sometimes?", + "start_date": "1944-07-03T00:00:00+02:00", + "end_date": "1944-07-03T00:00:00+02:00", + "days_count": 1, + "half_day_start": 0, + "half_day_end": 0, + "time_off_type": { + "type": "TimeOffType", + "attributes": { + "id": 195824, + "name": "vacation" + } + }, + "employee": { + "type": "Employee", + "attributes": { + "id": { + "label": "ID", + "value": 2116365 + }, + "first_name": { + "label": "First name", + "value": "Alan" + }, + "last_name": { + "label": "Last name", + "value": "Turing" + }, + "email": { + "label": "Email", + "value": "alan@example.org" + } + } + }, + "created_by": "Alan Turing", + "certificate": { + "status": "not-required" + }, + "created_at": "2020-08-21T18:06:02+02:00" + } + }, { + "type": "TimeOffPeriod", + "attributes": { + "id": 17205920, + "status": "approved", + "comment": "summer vacation", + "start_date": "1944-08-07T00:00:00+02:00", + "end_date": "1944-08-20T00:00:00+02:00", + "days_count": 10, + "half_day_start": 0, + "half_day_end": 0, + "time_off_type": { + "type": "TimeOffType", + "attributes": { + "id": 195824, + "name": "vacation" + } + }, + "employee": { + "type": "Employee", + "attributes": { + "id": { + "label": "ID", + "value": 2116365 + }, + "first_name": { + "label": "First name", + "value": "Alan" + }, + "last_name": { + "label": "Last name", + "value": "Turing" + }, + "email": { + "label": "Email", + "value": "alan@example.org" + } + } + }, + "created_by": "Alan Turing", + "certificate": { + "status": "not-required" + }, + "created_at": "2020-08-21T18:05:04+02:00" + } + } + ] +} +""" +json_dict_absence_alan = json.loads(json_string_absence_alan) + +json_string_absence_alan_single = """ +{ + "success": true, + "metadata":{ + "current_page":1, + "total_pages":1 + }, + "data": [{ + "type": "TimeOffPeriod", + "attributes": { + "id": 17205942, + "status": "approved", + "comment": "marathon starts at noon", + "start_date": "1944-09-01T00:00:00+02:00", + "end_date": "1944-09-01T00:00:00+02:00", + "days_count": 0.5, + "half_day_start": 0, + "half_day_end": 1, + "time_off_type": { + "type": "TimeOffType", + "attributes": { + "id": 195824, + "name": "vacation" + } + }, + "employee": { + "type": "Employee", + "attributes": { + "id": { + "label": "ID", + "value": 2116365 + }, + "first_name": { + "label": "First name", + "value": "Alan" + }, + "last_name": { + "label": "Last name", + "value": "Turing" + }, + "email": { + "label": "Email", + "value": "alan@example.org" + } + } + }, + "created_by": "Alan Turing", + "certificate": { + "status": "not-required" + }, + "created_at": "2020-08-21T18:07:06+02:00" + } + } + ] +} +""" +json_dict_absence_alan_first = json.loads(json_string_absence_alan_single) + + +json_string_absence_types = """ +{ + "success": true, + "metadata":{ + "current_page":1, + "total_pages":1 + }, + "data": [{ + "type": "TimeOffType", + "attributes": { + "id": 195824, + "name": "vacation" + } + }, { + "type": "TimeOffType", + "attributes": { + "id": 195825, + "name": "paid leave" + } + }, { + "type": "TimeOffType", + "attributes": { + "id": 195826, + "name": "sick" + } + } + ] +} +""" +json_dict_absence_types = json.loads(json_string_absence_types) + +json_string_delete_absence = """ +{ + "success": true, + "metadata":{ + "current_page":1, + "total_pages":1 + }, + "data": { + "message": "The absence period was deleted." + } +} +""" +json_dict_delete_absence = json.loads(json_string_delete_absence) + +json_string_absence_create_no_halfdays = """ +{ + "success":true, + "metadata":{ + "current_page":1, + "total_pages":1 + }, + "data":{ + "type":"TimeOffPeriod", + "attributes":{ + "id":22809350, + "status":"approved", + "comment":"", + "start_date":"2021-01-01T00:00:00+01:00", + "end_date":"2021-01-10T00:00:00+01:00", + "days_count":5, + "half_day_start":1, + "half_day_end":1, + "time_off_type":{ + "type":"TimeOffType", + "attributes":{ + "id":243402, + "name":"Unpaid vacation", + "category":"unpaid_vacation" + } + }, + "employee":{ + "type":"Employee", + "attributes":{ + "id":{ + "label":"ID", + "value":2628890 + }, + "first_name":{ + "label":"First name", + "value":"Alan" + }, + "last_name":{ + "label":"Last name", + "value":"Turing" + }, + "email":{ + "label":"Email", + "value":"alan.turing@cetitec.com" + } + } + }, + "created_by":"API", + "certificate":{ + "status":"not-required" + }, + "created_at":"2020-12-01T18:24:11+01:00" + } + } +} +""" +json_dict_absence_create_no_halfdays = json.loads(json_string_absence_create_no_halfdays) + + +json_string_get_absence = """ +{ + "success":true, + "metadata":{ + "current_page":1, + "total_pages":1 + }, + "data":{ + "type":"TimeOffPeriod", + "attributes":{ + "id":2628890, + "status":"approved", + "comment":"", + "start_date":"2021-01-01T00:00:00+01:00", + "end_date":"2021-01-10T00:00:00+01:00", + "days_count":5, + "half_day_start":0, + "half_day_end":1, + "time_off_type":{ + "type":"TimeOffType", + "attributes":{ + "id":243402, + "name":"Unpaid vacation", + "category":"unpaid_vacation" + } + }, + "employee":{ + "type":"Employee", + "attributes":{ + "id":{ + "label":"ID", + "value":2628890 + }, + "first_name":{ + "label":"First name", + "value":"Alan" + }, + "last_name":{ + "label":"Last name", + "value":"Turing" + }, + "email":{ + "label":"Email", + "value":"alan.turing@cetitec.com" + } + } + }, + "created_by":"API", + "certificate":{ + "status":"not-required" + }, + "created_at":"2020-12-02T17:28:34+01:00" + } + } +}""" +json_dict_get_absence = json.loads(json_string_get_absence) diff --git a/tests/test_mock_api_absences.py b/tests/test_mock_api_absences.py index f1e24e1..2afb75e 100644 --- a/tests/test_mock_api_absences.py +++ b/tests/test_mock_api_absences.py @@ -1,10 +1,13 @@ from datetime import date import pytest +import responses +import re from personio_py import PersonioError, Absence, Employee from tests.test_mock_api import mock_personio, compare_labeled_attributes, mock_employees -from tests.mock.absences_mock_functions import * +from tests.mock_data import json_dict_absence_alan, json_dict_absence_types, json_dict_empty_response,\ + json_dict_delete_absence, json_dict_absence_alan_first, json_dict_absence_create_no_halfdays, json_dict_get_absence @responses.activate @@ -144,3 +147,56 @@ def test_get_absence_types(): for source_dict, at in zip(json_dict_absence_types['data'], absence_types): target_dict = at.to_dict() assert source_dict == target_dict + + +def mock_absence_types(): + # mock the get absence types endpoint + responses.add( + responses.GET, 'https://api.personio.de/v1/company/time-off-types', status=200, + json=json_dict_absence_types, adding_headers={'Authorization': 'Bearer foo'}) + + +def mock_absences(): + # mock the get absences endpoint (with different array offsets) + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=0.*'), + status=200, json=json_dict_absence_alan, adding_headers={'Authorization': 'Bearer foo'}) + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=3.*'), + status=200, json=json_dict_empty_response, adding_headers={'Authorization': 'Bearer bar'}) + + +def mock_single_absences(): + # mock the get absences endpoint (with different array offsets) + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=0.*'), + status=200, json=json_dict_absence_alan_first, adding_headers={'Authorization': 'Bearer foo'}) + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=1.*'), + status=200, json=json_dict_empty_response, adding_headers={'Authorization': 'Bearer bar'}) + + +def mock_no_absences(): + # mock the get absences endpoint + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/time-offs?.*offset=0.*'), + status=200, json=json_dict_empty_response, adding_headers={'Authorization': 'Bearer bar'}) + + +def mock_delete_absence(): + # mock the delete endpoint + responses.add( + responses.DELETE, re.compile('https://api.personio.de/v1/company/time-offs/*'), + status=200, json=json_dict_delete_absence, adding_headers={'Authorization': 'Bearer bar'}) + + +def mock_create_absence_no_halfdays(): + responses.add( + responses.POST, 'https://api.personio.de/v1/company/time-offs', + status=200, json=json_dict_absence_create_no_halfdays, adding_headers={'Authorization': 'Bearer bar'}) + + +def mock_get_absence(): + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/time-offs/.*'), + status=200, json=json_dict_get_absence, adding_headers={'Authorization': 'Bearer bar'}) From 60c4ee2687ff075b0819ea4ad8343e876e968aec Mon Sep 17 00:00:00 2001 From: Sebastian Straub Date: Fri, 22 Jan 2021 17:16:38 +0100 Subject: [PATCH 23/29] duplicate import & whitespace --- tests/mock_data.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/mock_data.py b/tests/mock_data.py index 1b8f518..57df9c1 100644 --- a/tests/mock_data.py +++ b/tests/mock_data.py @@ -769,9 +769,6 @@ """ json_dict_attendance_rms = json.loads(json_string_attendance_rms) - -import json - json_string_absence_alan = """ { "success": true, @@ -978,7 +975,6 @@ """ json_dict_absence_alan_first = json.loads(json_string_absence_alan_single) - json_string_absence_types = """ { "success": true, @@ -1082,7 +1078,6 @@ """ json_dict_absence_create_no_halfdays = json.loads(json_string_absence_create_no_halfdays) - json_string_get_absence = """ { "success":true, From d6aa78fc4adb84bd5161aff0364450638b0903a2 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Fri, 22 Jan 2021 19:26:21 +0100 Subject: [PATCH 24/29] Update README.md --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index dac444f..2a0be39 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,12 @@ Available * [`GET /company/employees`](https://developer.personio.de/reference#get_company-employees): list all employees * [`GET /company/employees/{id}`](https://developer.personio.de/reference#get_company-employees-employee-id): get the employee with the specified ID * [`GET /company/employees/{id}/profile-picture/{width}`](https://developer.personio.de/reference#get_company-employees-employee-id-profile-picture-width): get the profile picture of the specified employee -* [`GET /company/time-offs`](https://developer.personio.de/reference#get_company-time-offs): fetch absence data for the company employees * [`GET /company/attendances`](https://developer.personio.de/reference#get_company-attendances): fetch attendance data for the company employees +* [`GET /company/time-off-types`](https://developer.personio.de/reference#get_company-time-off-types): get a list of available absences types +* [`GET /company/time-offs`](https://developer.personio.de/reference#get_company-time-offs): fetch absence data for the company employees +* [`POST /company/time-offs`](https://developer.personio.de/reference#post_company-time-offs): add absence data for the company employees +* [`GET /company/time-offs/{id}`](https://developer.personio.de/reference#get_company-time-offs-id): get the absence entry with the specified ID +* [`DELETE /company/time-offs/{id}`](https://developer.personio.de/reference#delete_company-time-offs-id): delete the absence entry with the specified ID Work in Progress @@ -102,10 +106,6 @@ Work in Progress * [`POST /company/attendances`](https://developer.personio.de/reference#post_company-attendances): add attendance data for the company employees * [`DELETE /company/attendances/{id}`](https://developer.personio.de/reference#delete_company-attendances-id): delete the attendance entry with the specified ID * [`PATCH /company/attendances/{id}`](https://developer.personio.de/reference#patch_company-attendances-id): update the attendance entry with the specified ID -* [`GET /company/time-off-types`](https://developer.personio.de/reference#get_company-time-off-types): get a list of available absences types -* [`POST /company/time-offs`](https://developer.personio.de/reference#post_company-time-offs): add absence data for the company employees -* [`DELETE /company/time-offs/{id}`](https://developer.personio.de/reference#delete_company-time-offs-id): delete the absence entry with the specified ID -* [`GET /company/time-offs/{id}`](https://developer.personio.de/reference#get_company-time-offs-id): get the absence entry with the specified ID ## Contact From 5ebd8a35ac0fdabfccd1aa5e71432e21b5dbb362 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Wed, 3 Feb 2021 15:05:00 +0100 Subject: [PATCH 25/29] Use fstrings to format url endpoint of the get_absence method --- src/personio_py/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/personio_py/client.py b/src/personio_py/client.py index 646910d..99474e9 100644 --- a/src/personio_py/client.py +++ b/src/personio_py/client.py @@ -351,7 +351,7 @@ def get_absence(self, absence: Union[Absence, int]) -> Absence: :param absence: The absence id to fetch. """ if isinstance(absence, int): - response = self.request_json('company/time-offs/' + str(absence)) + response = self.request_json(f'company/time-offs/{absence}') return Absence.from_dict(response['data'], self) else: if absence.id_: From 1771fe8c85f96c3bfb67f0c196aed58064390377 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Wed, 3 Feb 2021 15:07:26 +0100 Subject: [PATCH 26/29] catch unchecked case in get_absence method --- src/personio_py/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/personio_py/client.py b/src/personio_py/client.py index 99474e9..8b6a5d1 100644 --- a/src/personio_py/client.py +++ b/src/personio_py/client.py @@ -356,7 +356,7 @@ def get_absence(self, absence: Union[Absence, int]) -> Absence: else: if absence.id_: return self.get_absence(absence.id_) - elif absence.id_ is None: + else: self.__add_remote_absence_id(absence) return self.get_absence(absence.id_) From 8d74e7b8d8e00c05c1c68fe49e459d4af0684de2 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Mon, 8 Feb 2021 11:19:32 +0100 Subject: [PATCH 27/29] Limit tests against the api t specific time ranges (try to avoid conflicts when in production) --- tests/apitest_shared.py | 5 +++++ tests/test_api_absences.py | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/apitest_shared.py b/tests/apitest_shared.py index 354396c..33e6692 100644 --- a/tests/apitest_shared.py +++ b/tests/apitest_shared.py @@ -1,10 +1,15 @@ import os from functools import lru_cache +from datetime import date import pytest from personio_py import Personio, PersonioError +# Test time. if used on a personio instance, only touch entries during this time range +NOT_BEFORE = date(year=2030, month=1, day=1) +NOT_AFTER = date(year=2030, month=12, day=31) + # Personio client authentication CLIENT_ID = os.getenv('CLIENT_ID') CLIENT_SECRET = os.getenv('CLIENT_SECRET') diff --git a/tests/test_api_absences.py b/tests/test_api_absences.py index 898c646..2016db1 100644 --- a/tests/test_api_absences.py +++ b/tests/test_api_absences.py @@ -124,7 +124,7 @@ def create_absences(client: Personio, absences: [Absence]): def delete_all_absences_of_employee(employee: Employee): - absences = personio.get_absences(employee) + absences = personio.get_absences(employee, start_date=NOT_BEFORE, end_date=NOT_AFTER) delete_absences(personio, absences) @@ -140,9 +140,9 @@ def create_absence_for_user(employee: Employee, absence_types = personio.get_absence_types() time_off_type = [absence_type for absence_type in absence_types if absence_type.name == "Unpaid vacation"][0] if not start_date: - start_date = date(2021, 1, 1) + start_date = date(2031, 1, 1) if not end_date: - end_date = date(2021, 1, 10) + end_date = date(2031, 1, 10) absence_to_create = Absence( start_date=start_date, From f4b732afa9a61927f2086489329d15a386535bf9 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Mon, 8 Feb 2021 12:02:55 +0100 Subject: [PATCH 28/29] Changes requested for merge --- src/personio_py/client.py | 6 +++--- src/personio_py/models.py | 10 +++++----- tests/apitest_shared.py | 4 ++-- tests/test_api_absences.py | 8 ++++---- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/personio_py/client.py b/src/personio_py/client.py index 8b6a5d1..10d6899 100644 --- a/src/personio_py/client.py +++ b/src/personio_py/client.py @@ -386,7 +386,7 @@ def delete_absence(self, absence: Union[Absence, int]): or the query does not provide exactly one result. """ if isinstance(absence, int): - response = self.request_json(path='company/time-offs/' + str(absence), method='DELETE') + response = self.request_json(path=f'company/time-offs/{absence}', method='DELETE') return response['success'] elif isinstance(absence, Absence): if absence.id_ is not None: @@ -464,8 +464,8 @@ def __add_remote_absence_id(self, absence: Absence) -> Absence: start_date=absence.start_date, end_date=absence.end_date) if len(matching_remote_absences) == 0: - raise ValueError("The absence to patch was not found") + raise PersonioError("The absence to patch was not found") elif len(matching_remote_absences) > 1: - raise ValueError("More than one absence found.") + raise PersonioError("More than one absence found.") absence.id_ = matching_remote_absences[0].id_ return absence diff --git a/src/personio_py/models.py b/src/personio_py/models.py index 76191bc..08d8aee 100644 --- a/src/personio_py/models.py +++ b/src/personio_py/models.py @@ -276,7 +276,7 @@ def delete(self, client: 'Personio' = None): client = self._check_client(client) return self._delete(client) else: - raise ValueError("Cannot delete without an api client") + raise UnsupportedMethodError('delete', self.__class__) def _delete(self, client: 'Personio'): UnsupportedMethodError('delete', self.__class__) @@ -471,14 +471,14 @@ class ShortEmployee(LabeledAttributesMixin): def __init__(self, client: 'Personio' = None, id_: int = None, first_name: str = None, last_name: str = None, email: str = None, **kwargs): super().__init__(**kwargs) - self.client = client + self._client = client self.id_ = id_ self.first_name = first_name self.last_name = last_name self.email = email def resolve(self, client: 'Personio' = None) -> 'Employee': - client = client or self.client + client = client or self._client if client: return client.get_employee(self.id_) else: @@ -608,8 +608,8 @@ def to_body_params(self): 'time_off_type_id': self.time_off_type.id_, 'start_date': self.start_date.strftime("%Y-%m-%d"), 'end_date': self.end_date.strftime("%Y-%m-%d"), - 'half_day_start': self.half_day_start or False, - 'half_day_end': self.half_day_end or False + 'half_day_start': self.half_day_start, + 'half_day_end': self.half_day_end } if self.comment is not None: data['comment'] = self.comment diff --git a/tests/apitest_shared.py b/tests/apitest_shared.py index 33e6692..2a980c9 100644 --- a/tests/apitest_shared.py +++ b/tests/apitest_shared.py @@ -7,8 +7,8 @@ from personio_py import Personio, PersonioError # Test time. if used on a personio instance, only touch entries during this time range -NOT_BEFORE = date(year=2030, month=1, day=1) -NOT_AFTER = date(year=2030, month=12, day=31) +NOT_BEFORE = date(year=2022, month=1, day=1) +NOT_AFTER = date(year=2022, month=12, day=31) # Personio client authentication CLIENT_ID = os.getenv('CLIENT_ID') diff --git a/tests/test_api_absences.py b/tests/test_api_absences.py index 2016db1..d7362e3 100644 --- a/tests/test_api_absences.py +++ b/tests/test_api_absences.py @@ -13,8 +13,8 @@ def test_create_absences(half_day_start: bool, half_day_end: bool): """ # Prepare data test_user = get_test_employee() - start_date = date(2021, 1, 1) - end_date = date(2021, 1, 10) + start_date = date(year=2022, month=1, day=1) + end_date = date(year=2022, month=1, day=10) # Ensure there are no left absences delete_all_absences_of_employee(test_user) @@ -140,9 +140,9 @@ def create_absence_for_user(employee: Employee, absence_types = personio.get_absence_types() time_off_type = [absence_type for absence_type in absence_types if absence_type.name == "Unpaid vacation"][0] if not start_date: - start_date = date(2031, 1, 1) + start_date = date(year=2022, month=1, day=1) if not end_date: - end_date = date(2031, 1, 10) + end_date = date(year=2022, month=1, day=10) absence_to_create = Absence( start_date=start_date, From cbf67cebf25cde047b6a8334dd49c42af3223334 Mon Sep 17 00:00:00 2001 From: Philip Flohr Date: Mon, 8 Feb 2021 12:16:37 +0100 Subject: [PATCH 29/29] Create absence: return updated object instead of bool --- src/personio_py/client.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/personio_py/client.py b/src/personio_py/client.py index 10d6899..51d497a 100644 --- a/src/personio_py/client.py +++ b/src/personio_py/client.py @@ -360,18 +360,19 @@ def get_absence(self, absence: Union[Absence, int]) -> Absence: self.__add_remote_absence_id(absence) return self.get_absence(absence.id_) - def create_absence(self, absence: Absence) -> bool: + def create_absence(self, absence: Absence) -> Absence: """ Creates an absence record on the Personio servers :param absence: The absence object to be created + :raises PersonioError: If the absence could not be created on the Personio servers """ data = absence.to_body_params() response = self.request_json('company/time-offs', method='POST', data=data) if response['success']: absence.id_ = response['data']['attributes']['id'] - return True - return False + return absence + raise PersonioError("Could not create absence") def delete_absence(self, absence: Union[Absence, int]): """