diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 49c346a..ec84427 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,22 +1,22 @@ repos: -- repo: https://github.com/pre-commit/pre-commit-hooks + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v2.5.0 hooks: - - id: check-added-large-files - - id: check-ast - - id: check-builtin-literals - - id: check-merge-conflict - - id: check-yaml - - id: end-of-file-fixer - - id: mixed-line-ending - - id: trailing-whitespace -- repo: https://github.com/asottile/pyupgrade + - id: check-added-large-files + - id: check-ast + - id: check-builtin-literals + - id: check-merge-conflict + - id: check-yaml + - id: end-of-file-fixer + - id: mixed-line-ending + - id: trailing-whitespace + - repo: https://github.com/asottile/pyupgrade rev: v2.7.2 hooks: - - id: pyupgrade - args: [--py37-plus] -- repo: https://gitlab.com/pycqa/flake8 - rev: '3.8.3' + - id: pyupgrade + args: [ --py37-plus ] + - repo: https://github.com/pycqa/flake8 + rev: '6.0.0' hooks: - - id: flake8 - args: ['--max-complexity=10', '--max-line-length=100', '--ignore=F401,W504', '--exclude=tests/*'] + - id: flake8 + args: [ '--max-complexity=10', '--max-line-length=100', '--ignore=F401,W504', '--exclude=tests/*,setup.py' ] diff --git a/CHANGELOG.md b/CHANGELOG.md index c28df46..6e8f076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.3](https://github.com/at-gmbh/personio-py/tree/v0.2.3) - 2023-02-08 + +* debug attendance feature +* add support for attendance paginated API requests + ## [Unreleased](https://github.com/at-gmbh/personio-py/compare/v0.2.2...HEAD) diff --git a/README.md b/README.md index aac4a0d..32b866f 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,9 @@ Available * [`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/attendances`](https://developer.personio.de/reference#get_company-attendances): fetch attendance data for the company employees +* [`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 * [`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 @@ -101,9 +104,6 @@ Work in Progress * [`POST /company/employees`](https://developer.personio.de/reference#post_company-employees): create a new employee * [`PATCH /company/employees/{id}`](https://developer.personio.de/reference#patch_company-employees-employee-id): update an existing employee entry -* [`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 ## Contact diff --git a/setup.py b/setup.py index c739b46..8c974d7 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(fname): """Utility function to read the README file.""" - return open(os.path.join(os.path.dirname(__file__), fname), 'r', encoding='utf-8').read() + return open(os.path.join(os.path.dirname(__file__), fname), encoding='utf-8').read() class DistCommand(Command): diff --git a/src/personio_py/client.py b/src/personio_py/client.py index 5ee9ed6..a81d879 100644 --- a/src/personio_py/client.py +++ b/src/personio_py/client.py @@ -35,6 +35,8 @@ class Personio: BASE_URL = "https://api.personio.de/v1/" """base URL of the Personio HTTP API""" + ATTENDANCE_URL = 'company/attendances' + ABSENCE_URL = 'company/time-offs' def __init__(self, base_url: str = None, client_id: str = None, client_secret: str = None, dynamic_fields: List[DynamicMapping] = None): @@ -138,9 +140,9 @@ def request_json(self, path: str, method='GET', params: Dict[str, Any] = None, else: raise PersonioApiError.from_response(response) - def request_paginated( - self, path: str, method='GET', params: Dict[str, Any] = None, - data: Dict[str, Any] = None, auth_rotation=True, limit=200) -> Dict[str, Any]: + def request_paginated(self, path: str, method='GET', params: Dict[str, Any] = None, + data: Dict[str, Any] = None, auth_rotation=True, limit=200 + ) -> Dict[str, Any]: """ Make a request against the Personio API, expecting a json response that may be paginated, i.e. not all results might have been returned after the first request. Will continue @@ -159,22 +161,36 @@ def request_paginated( that is enforced on the server side) :return: the parsed json response, when the request was successful, or a PersonioApiError """ - # prepare the params dict (need limit and offset as parameters) + if self.ABSENCE_URL == path: + offset = 1 + url_type = 'absence' + elif self.ATTENDANCE_URL == path: + offset = 0 + url_type = 'attendance' + else: + raise ValueError(f"Invalid path: {path}") + if params is None: params = {} params['limit'] = limit - params['offset'] = 1 - # continue making requests until no more data is returned + params['offset'] = offset data_acc = [] while True: response = self.request_json(path, method, params, data, auth_rotation=auth_rotation) - resp_data = response['data'] + resp_data = response.get('data') if resp_data: - data_acc.extend(resp_data) - if response['metadata']['current_page'] == response['metadata']['total_pages']: - break - else: - params['offset'] += 1 + if url_type == 'absence': + data_acc.extend(resp_data) + if response['metadata']['current_page'] == response['metadata']['total_pages']: + break + else: + params['offset'] += 1 + elif url_type == 'attendance': + if params['offset'] >= response['metadata']['total_elements']: + break + else: + data_acc.extend(resp_data) + params['offset'] += limit else: break # return the accumulated data @@ -211,6 +227,7 @@ def request_image(self, path: str, method='GET', params: Dict[str, Any] = None, def get_employees(self) -> List[Employee]: """ Get a list of all employee records in your account. + Does not involve pagination. :return: list of ``Employee`` instances """ @@ -221,6 +238,7 @@ def get_employees(self) -> List[Employee]: def get_employee(self, employee_id: int) -> Employee: """ Get a single employee with the specified ID. + Does not involve pagination. :param employee_id: the Personio ID of the employee to fetch :return: an ``Employee`` instance or a PersonioApiError, if the employee does not exist @@ -275,8 +293,9 @@ def update_employee(self, employee: Employee): """ raise NotImplementedError() - def get_attendances(self, employees: Union[int, List[int], Employee, List[Employee]], - start_date: datetime = None, end_date: datetime = None) -> List[Attendance]: + def get_attendances( + self, employees: Union[int, List[int], Employee, List[Employee]], + start_date: datetime = None, end_date: datetime = None) -> List[Attendance]: """ Get a list of all attendance records for the employees with the specified IDs @@ -291,28 +310,85 @@ def get_attendances(self, employees: Union[int, List[int], Employee, List[Employ :param end_date: only return attendance records up to this date (inclusive, optional) :return: list of ``Attendance`` records for the specified employees """ - return self._get_employee_metadata( - 'company/attendances', Attendance, employees, start_date, end_date) + attendances = self._get_employee_metadata( + self.ATTENDANCE_URL, Attendance, employees, start_date, end_date) + for attendance in attendances: + attendance._client = self + return attendances - def create_attendances(self, attendances: List[Attendance]): - """ - placeholder; not ready to be used + def create_attendances(self, attendances: List[Attendance]) -> bool: """ - # attendances can be created individually, but here you can push a huge bunch of items - # in a single request, which can be significantly faster - raise NotImplementedError() + Create all given attendance records. - def update_attendance(self, attendance_id: int): - """ - placeholder; not ready to be used - """ - raise NotImplementedError() + Note: If one or more attendances can not be created, other attendances will be created but + their corresponding objects passed as attendances will not be updated. - def delete_attendance(self, attendance_id: int): + :param attendances: A list of attendance records to be created. """ - placeholder; not ready to be used + data_to_send = [ + attendance.to_body_params(patch_existing_attendance=False) for attendance in attendances + ] + response = self.request_json( + path=self.ATTENDANCE_URL, + method='POST', + data={"attendances": data_to_send} + ) + if response['success']: + for attendance, response_id in zip(attendances, response['data']['id']): + attendance.id_ = response_id + attendance.client = self + return True + return False + + def update_attendance(self, attendance: Attendance): + """ + Update an existing attendance record + + Either an attendance id or o remote query is required. Remote queries are only executed + if required. An Attendance object returned by get_attendances() include the attendance id. + DO NOT SET THE ID YOURSELF. + + :param attendance: The Attendance object holding the new data. + :raises: + ValueError: If a query is required but not allowed or the query does not provide + exactly one result. + """ + if attendance.id_ is not None: + # remote query not necessary + response = self.request_json( + path=f'{self.ATTENDANCE_URL}/{attendance.id_}', + method='PATCH', + data=attendance.to_body_params(patch_existing_attendance=True) + ) + return response + else: + raise ValueError("You need to provide the attendance id") + + def delete_attendance(self, attendance: Attendance or int): """ - raise NotImplementedError() + Delete an existing record + + Either an attendance id or o remote query is required. Remote queries are only + executed if required. An Attendance object returned by get_attendances() include the + attendance id. DO NOT SET THE ID YOURSELF. + + :param attendance: The Attendance object holding the new data or an attendance record id to + delete. + :raises: + ValueError: If a query is required but not allowed or the query does not provide + exactly one result. + """ + if isinstance(attendance, int): + response = self.request_json(path=f'{self.ATTENDANCE_URL}/{attendance}', + method='DELETE') + return response + elif isinstance(attendance, Attendance): + if attendance.id_ is not None: + return self.delete_attendance(attendance.id_) + else: + raise ValueError("You need to provide the attendance") + else: + raise ValueError("attendance must be an Attendance object or an integer") def get_absence_types(self) -> List[AbsenceType]: """ @@ -327,8 +403,9 @@ def get_absence_types(self) -> List[AbsenceType]: absence_types = [AbsenceType.from_dict(d, self) for d in response['data']] return absence_types - def get_absences(self, employees: Union[int, List[int], Employee, List[Employee]], - start_date: datetime = None, end_date: datetime = None) -> List[Absence]: + def get_absences( + self, employees: Union[int, List[int], Employee, List[Employee]], + start_date: datetime = None, end_date: datetime = None) -> List[Absence]: """ Get a list of all absence records for the employees with the specified IDs. @@ -344,7 +421,7 @@ def get_absences(self, employees: Union[int, List[int], Employee, List[Employee] :return: list of ``Absence`` records for the specified employees """ return self._get_employee_metadata( - 'company/time-offs', Absence, employees, start_date, end_date) + self.ABSENCE_URL, Absence, employees, start_date, end_date) def get_absence(self, absence: Union[Absence, int]) -> Absence: """ @@ -353,7 +430,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(f'company/time-offs/{absence}') + response = self.request_json(f'{self.ABSENCE_URL}/{absence}') return Absence.from_dict(response['data'], self) else: if absence.id_: @@ -370,7 +447,7 @@ def create_absence(self, absence: Absence) -> Absence: :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) + response = self.request_json(self.ABSENCE_URL, method='POST', data=data) if response['success']: absence.id_ = response['data']['attributes']['id'] return absence @@ -383,12 +460,12 @@ def delete_absence(self, absence: Union[Absence, int]): An absence id is required. :param absence: The Absence object holding - the new data or an absence record id to delete. + 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. + or the query does not provide exactly one result. """ if isinstance(absence, int): - response = self.request_json(path=f'company/time-offs/{absence}', method='DELETE') + response = self.request_json(path=f'{self.ABSENCE_URL}/{absence}', method='DELETE') return response['success'] elif isinstance(absence, Absence): if absence.id_ is not None: diff --git a/src/personio_py/models.py b/src/personio_py/models.py index 8b92b8c..fc60fd5 100644 --- a/src/personio_py/models.py +++ b/src/personio_py/models.py @@ -9,9 +9,11 @@ from typing import Any, Dict, List, NamedTuple, Optional, TYPE_CHECKING, Tuple, Type, TypeVar from personio_py import PersonioError, UnsupportedMethodError -from personio_py.mapping import BooleanFieldMapping, DateFieldMapping, DateTimeFieldMapping, \ - DurationFieldMapping, DynamicMapping, FieldMapping, ListFieldMapping, NumericFieldMapping, \ +from personio_py.mapping import ( + BooleanFieldMapping, DateFieldMapping, DateTimeFieldMapping, + DurationFieldMapping, DynamicMapping, FieldMapping, ListFieldMapping, NumericFieldMapping, ObjectFieldMapping +) if TYPE_CHECKING: # only type checkers may import Personio, otherwise we get an evil circular import error @@ -670,13 +672,46 @@ def to_dict(self, nested=False) -> Dict[str, Any]: return d def _create(self, client: 'Personio'): - pass + get_client(self, client).create_attendances([self]) def _update(self, client: 'Personio'): - pass + get_client(self, client).update_attendance(self) def _delete(self, client: 'Personio'): - pass + get_client(self, client).delete_attendance(self) + + def to_body_params(self, patch_existing_attendance=False): + """ + Return the Attendance object in the representation expected by the Personio API + + For an attendance record to be created all_values_required needs to be True. + For patch operations only the attendance id is required, but it is not + included into the body params. + + :param patch_existing_attendance Get patch body. If False a create body is returned. + """ + if patch_existing_attendance: + if self.id_ is None: + raise ValueError("An attendance id is required") + body_dict = {} + if self.date is not None: + body_dict['date'] = self.date.strftime("%Y-%m-%d") + if self.start_time is not None: + body_dict['start_time'] = str(self.start_time) + if self.end_time is not None: + body_dict['end_time'] = str(self.end_time) + if self.break_duration is not None: + body_dict['break'] = self.break_duration + if self.comment is not None: + body_dict['comment'] = self.comment + return body_dict + else: + return {"employee": self.employee_id, + "date": self.date.strftime("%Y-%m-%d"), + "start_time": self.start_time, + "end_time": self.end_time, + "break": self.break_duration or 0, + "comment": self.comment or ""} class Employee(WritablePersonioResource, LabeledAttributesMixin): diff --git a/src/personio_py/version.py b/src/personio_py/version.py index 020ed73..d93b5b2 100644 --- a/src/personio_py/version.py +++ b/src/personio_py/version.py @@ -1 +1 @@ -__version__ = '0.2.2' +__version__ = '0.2.3' diff --git a/tests/apitest_shared.py b/tests/apitest_shared.py index 2a980c9..fc14809 100644 --- a/tests/apitest_shared.py +++ b/tests/apitest_shared.py @@ -1,6 +1,6 @@ import os -from functools import lru_cache from datetime import date +from functools import lru_cache import pytest @@ -15,7 +15,7 @@ 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 +# deactivate all tests that rely on a specific Personio instance try: personio.authenticate() can_authenticate = True @@ -27,3 +27,8 @@ @lru_cache(maxsize=1) def get_test_employee(): return personio.get_employees()[0] + + +@lru_cache(maxsize=1) +def get_test_employee_for_attendances(): + return personio.get_employee(13603465) diff --git a/tests/mock_data.py b/tests/mock_data.py index 5a74db6..8cc877f 100644 --- a/tests/mock_data.py +++ b/tests/mock_data.py @@ -721,7 +721,8 @@ { "success": true, "metadata":{ - "current_page":1, + "total_elements": 3, + "current_page":0, "total_pages":1 }, "data": [{ @@ -1147,3 +1148,34 @@ } }""" json_dict_get_absence = json.loads(json_string_get_absence) + +json_string_attendance_create_no_break = """ +{ + "success":true, + "data":{ + "id": [83648700], + "message": "success" + } +} +""" +json_dict_attendance_create_no_break = json.loads(json_string_attendance_create_no_break) + +json_string_attendance_patch = """ +{ + "success":true, + "data":{ + "message": "success" + } +} +""" +json_dict_attendance_patch = json.loads(json_string_attendance_patch) + +json_string_attendance_delete = """ +{ + "success":true, + "data":{ + "message": "success" + } +} +""" +json_dict_attendance_delete = json.loads(json_string_attendance_delete) diff --git a/tests/test_api.py b/tests/test_api.py index d6c6a45..431397c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,8 @@ +from datetime import datetime + from personio_py import Employee, Department from tests.apitest_shared import * -from datetime import datetime + @skip_if_no_auth def test_get_employees(): diff --git a/tests/test_api_absences.py b/tests/test_api_absences.py index d7362e3..4418401 100644 --- a/tests/test_api_absences.py +++ b/tests/test_api_absences.py @@ -1,7 +1,7 @@ -from .apitest_shared import * -from datetime import timedelta, date +from datetime import timedelta -from personio_py import Employee, ShortEmployee, Personio, PersonioError, Absence, AbsenceType +from personio_py import Employee, Absence, AbsenceType +from tests.apitest_shared import * @skip_if_no_auth @@ -138,7 +138,10 @@ def create_absence_for_user(employee: Employee, 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] + 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(year=2022, month=1, day=1) if not end_date: diff --git a/tests/test_api_attendances.py b/tests/test_api_attendances.py new file mode 100644 index 0000000..1237c60 --- /dev/null +++ b/tests/test_api_attendances.py @@ -0,0 +1,120 @@ +from datetime import datetime + +from personio_py import Employee, Attendance +from tests.apitest_shared import * + + +@skip_if_no_auth +def test_get_attendances(): + employee = get_test_employee_for_attendances() + attendances = personio.get_attendances(employee) + assert len(attendances) == 11 + assert isinstance(attendances[0], Attendance) + assert attendances[0].id_ == 162804610 + assert attendances[0].employee_id == employee.id_ + assert attendances[0]._client == personio + + +@skip_if_no_auth +def test_create_attendances(): + employee_id = get_test_employee().id_ + employee = personio.get_employee(employee_id) + delete_all_attendances_for_employee(employee) + attendances = personio.get_attendances([employee_id]) + assert len(attendances) == 0 + created_attendance = create_attendance_for_user(employee_id, create=True) + attendances = personio.get_attendances([employee_id]) + assert len(attendances) == 1 + personio.delete_attendance(created_attendance.id_) + + +@skip_if_no_auth +def test_delete_attendance_from_client_id(): + employee_id = get_test_employee().id_ + employee = personio.get_employee(employee_id) + delete_all_attendances_for_employee(employee) + attendance = create_attendance_for_user(employee_id, create=True) + assert len(personio.get_attendances([employee_id])) == 1 + personio.delete_attendance(attendance.id_) + assert len(personio.get_attendances([employee_id])) == 0 + + +@skip_if_no_auth +def test_delete_attendance_from_client_object_with_id(): + employee_id = get_test_employee().id_ + employee = personio.get_employee(employee_id) + delete_all_attendances_for_employee(employee) + attendance = create_attendance_for_user(employee_id, create=True) + assert len(personio.get_attendances([employee_id])) == 1 + personio.delete_attendance(attendance) + assert len(personio.get_attendances([employee_id])) == 0 + + +@skip_if_no_auth +def test_delete_attendance_from_model_passed_client(): + employee_id = get_test_employee().id_ + employee = personio.get_employee(employee_id) + delete_all_attendances_for_employee(employee) + attendance = create_attendance_for_user(employee_id, create=True) + assert len(personio.get_attendances([employee_id])) == 1 + attendance.delete(client=personio) + assert len(personio.get_attendances([employee_id])) == 0 + + +@skip_if_no_auth +def test_delete_attendance_from_model_with_client(): + employee_id = get_test_employee().id_ + employee = personio.get_employee(employee_id) + delete_all_attendances_for_employee(employee) + attendance = create_attendance_for_user(employee_id, create=True) + assert len(personio.get_attendances([employee_id])) == 1 + attendance._client = personio + attendance.delete() + assert len(personio.get_attendances([employee_id])) == 0 + + +def delete_all_attendances_for_employee(employee: Employee): + attendances = personio.get_attendances([employee.id_]) + for attendance in attendances: + attendance.delete(personio) + + +def prepare_test_get_attendances() -> Employee: + test_data = get_test_employee() + test_user = personio.get_employee(test_data['id']) + + # Be sure there are no leftover attendances + delete_all_attendances_for_employee(test_user) + return test_user + + +def create_attendance_for_user( + employee_id: int, + date: datetime = None, + start_time: str = None, + end_time: str = None, + break_duration: int = None, + comment: str = None, + is_holiday: bool = None, + is_on_time_off: bool = None, + create: bool = False): + if not date: + date = datetime(2021, 1, 1) + if not start_time: + start_time = "08:00" + if not end_time: + end_time = "17:00" + + attendance_to_create = Attendance( + employee_id=employee_id, + date=date, + start_time=start_time, + end_time=end_time, + break_duration=break_duration, + comment=comment, + is_holiday=is_holiday, + is_on_time_off=is_on_time_off + ) + if create: + attendance_to_create.create(personio) + return attendance_to_create diff --git a/tests/test_api_raw.py b/tests/test_api_raw.py index 557bce0..ff3d975 100644 --- a/tests/test_api_raw.py +++ b/tests/test_api_raw.py @@ -28,7 +28,8 @@ def test_raw_api_attendances(): 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 + # Personio test accounts know 10 different absence types + assert len(absence_types['data']) >= 10 @skip_if_no_auth diff --git a/tests/test_mock_api.py b/tests/test_mock_api.py index edc9580..7562049 100644 --- a/tests/test_mock_api.py +++ b/tests/test_mock_api.py @@ -95,11 +95,11 @@ def test_auth_rotation_fail(): @responses.activate def test_get_attendance(): - # mock the get absences endpoint (with different array offsets) + # mock the get attendances endpoint (with different array offsets) responses.add( - responses.GET, re.compile('https://api.personio.de/v1/company/attendances?.*offset=1.*'), + responses.GET, re.compile('https://api.personio.de/v1/company/attendances?.*offset=*'), status=200, json=json_dict_attendance_rms, adding_headers={'Authorization': 'Bearer foo'}) - # configure personio & get absences for alan + # configure personio & get attendances for alan personio = mock_personio() attendances = personio.get_attendances(2116366) # validate diff --git a/tests/test_mock_api_absences.py b/tests/test_mock_api_absences.py index 6e36865..f334d9f 100644 --- a/tests/test_mock_api_absences.py +++ b/tests/test_mock_api_absences.py @@ -1,13 +1,20 @@ +import re from datetime import date import pytest import responses -import re from personio_py import PersonioError, Absence, Employee +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 +) from tests.test_mock_api import mock_personio, compare_labeled_attributes, mock_employees -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 @@ -152,45 +159,66 @@ def test_get_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'}) + 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=1.*'), - status=200, json=json_dict_absence_alan, adding_headers={'Authorization': 'Bearer foo'}) + responses.GET, + re.compile('https://api.personio.de/v1/company/time-offs?.*offset=1.*'), + status=200, + json=json_dict_absence_alan, + adding_headers={'Authorization': 'Bearer foo'}) 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=1.*'), - status=200, json=json_dict_absence_alan_first, adding_headers={'Authorization': 'Bearer foo'}) + responses.GET, + re.compile('https://api.personio.de/v1/company/time-offs?.*offset=1.*'), + status=200, + json=json_dict_absence_alan_first, + adding_headers={'Authorization': 'Bearer foo'}) def mock_no_absences(): # mock the get absences endpoint 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'}) + 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_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'}) + 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'}) + 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'}) + 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_attendances.py b/tests/test_mock_api_attendances.py new file mode 100644 index 0000000..32f0bae --- /dev/null +++ b/tests/test_mock_api_attendances.py @@ -0,0 +1,94 @@ +import re +from datetime import timedelta, date + +import responses + +from personio_py import Attendance, Employee +from tests.mock_data import ( + json_dict_attendance_create_no_break, + json_dict_attendance_rms, json_dict_attendance_patch, json_dict_attendance_delete +) +from tests.test_mock_api import compare_labeled_attributes, mock_personio + + +@responses.activate +def test_create_attendance(): + mock_create_attendance() + personio = mock_personio() + employee = Employee( + first_name="Alan", + last_name='Turing', + email='alan.turing@cetitec.com' + ) + attendance = Attendance( + client=personio, + employee=employee, + date=date(2020, 1, 10), + start_time="09:00", + end_time="17:00", + break_duration=0 + ) + attendance.create() + assert attendance.id_ + +@responses.activate +def test_get_attendance(): + mock_attendances() + # configure personio & get absences for alan + personio = mock_personio() + attendances = personio.get_attendances(2116366) + # validate + assert len(attendances) == 3 + selection = [a for a in attendances if "release" in a.comment.lower()] + assert len(selection) == 1 + release = selection[0] + assert "free software" in release.comment + assert release.date == date(1985, 3, 20) + assert release.start_time == timedelta(seconds=11*60*60) + assert release.end_time == timedelta(seconds=12.5*60*60) + assert release.break_duration == 60 + assert release.employee_id == 2116366 + # validate serialization + source_dict = json_dict_attendance_rms['data'][0] + target_dict = release.to_dict() + compare_labeled_attributes(source_dict, target_dict) + +@responses.activate +def test_patch_attendances(): + mock_attendances() + mock_patch_attendance() + personio = mock_personio() + attendances = personio.get_attendances(2116366) + attendance_to_patch = attendances[0] + attendance_to_patch.break_duration = 1 + personio.update_attendance(attendance_to_patch) + +@responses.activate +def test_delete_attendances(): + mock_attendances() + mock_delete_attendance() + personio = mock_personio() + attendances = personio.get_attendances(2116366) + attendance_to_delete = attendances[0] + personio.delete_attendance(attendance_to_delete) + +def mock_attendances(): + # mock the get absences endpoint (with different array offsets) + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/attendances?.*'), + status=200, json=json_dict_attendance_rms, adding_headers={'Authorization': 'Bearer foo'}) + +def mock_create_attendance(): + responses.add( + responses.POST, 'https://api.personio.de/v1/company/attendances', + status=200, json=json_dict_attendance_create_no_break, adding_headers={'Authorization': 'Bearer bar'}) + +def mock_patch_attendance(): + responses.add( + responses.PATCH, 'https://api.personio.de/v1/company/attendances/33479712', + status=200, json=json_dict_attendance_patch, adding_headers={'Authorization': 'Bearer bar'}) + +def mock_delete_attendance(): + responses.add( + responses.DELETE, 'https://api.personio.de/v1/company/attendances/33479712', + status=200, json=json_dict_attendance_delete, adding_headers={'Authorization': 'Bearer bar'})