diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e8f076..6c523a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,14 @@ 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 +## [Unreleased](https://github.com/at-gmbh/personio-py/compare/v0.2.3...HEAD) -* debug attendance feature -* add support for attendance paginated API requests +... -## [Unreleased](https://github.com/at-gmbh/personio-py/compare/v0.2.2...HEAD) +## [0.2.3](https://github.com/at-gmbh/personio-py/tree/v0.2.3) - 2023-05-05 +* add support for Projects ([#36](https://github.com/at-gmbh/personio-py/pull/36)) +* add support for attendances, with paginated API requests ([#35](https://github.com/at-gmbh/personio-py/pull/35)) ## [0.2.2](https://github.com/at-gmbh/personio-py/tree/v0.2.2) - 2022-07-04 diff --git a/README.md b/README.md index 32b866f..105fbbb 100644 --- a/README.md +++ b/README.md @@ -84,26 +84,59 @@ This project is released on [PyPI](https://pypi.org/project/personio-py/). Most ## API Functions -Available - -* [`POST /auth`](https://developer.personio.de/reference#auth): fully transparent authentication handling -* [`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/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 -* [`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 - -* [`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 +Since the [Personio API](https://developer.personio.de/reference/introduction) gets extended over time, personio-py usually only implements a subset of all available API features. This section gives an overview, which API functions are accessible through personio-py. + +### Available + +Authentication + +* [`POST /auth`](https://developer.personio.de/reference/post_auth-1): fully transparent authentication handling + +Employees + +* [`GET /company/employees`](https://developer.personio.de/reference/get_company-employees): list all employees +* [`POST /company/employees`](https://developer.personio.de/reference/post_company-employees): create a new employee +* [`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 + +Attendances + +* [`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 + +Projects + +* [`GET /company/attendances/projects`](https://developer.personio.de/reference/get_company-attendances-projects): provides a list of all company projects +* [`POST /company/attendances/projects`](https://developer.personio.de/reference/post_company-attendances-projects): creates a project into the company account +* [`DELETE /company/attendances/projects/{id}`](https://developer.personio.de/reference/delete_company-attendances-projects-id): deletes a project from the company account +* [`PATCH /company/attendances/projects/{id}`](https://developer.personio.de/reference/patch_company-attendances-projects-id): updates a project with the given data + +Absences + +* [`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 + +### Not yet implemented + +* [`PATCH /company/employees/{id}`](https://developer.personio.de/reference/patch_company-employees-employee-id): update an existing employee entry +* [`GET /company/employees/{employee_id}/absences/balance`](https://developer.personio.de/reference/get_company-employees-employee-id-absences-balance): retrieve the absence balance for a specific employee +* [`GET /company/employees/custom-attributes`](https://developer.personio.de/reference/get_company-employees-custom-attributes): this endpoint is an alias for /company/employees/attributes +* [`GET /company/employees/attributes`](https://developer.personio.de/reference/get_company-employees-attributes): lists all the allowed attributes per API credentials including custom (dynamic) attributes. +* [`GET /company/absence-periods`](https://developer.personio.de/reference/get_company-absence-periods) +* [`POST /company/absence-periods`](https://developer.personio.de/reference/post_company-absence-periods) +* [`DELETE /company/absence-periods/{id}`](https://developer.personio.de/reference/delete_company-absence-periods-id) + +* [`GET /company/document-categories`](https://developer.personio.de/reference/get_company-document-categories): this endpoint is responsible for fetching all document categories of the company +* [`POST /company/documents`](https://developer.personio.de/reference/post_company-documents): this endpoint is responsible for uploading documents for the company employees +* [`GET /company/custom-reports/reports`](https://developer.personio.de/reference/listreports): this endpoint provides you with metadata about existing custom reports in your Personio account, such as report name, report type, report date / timeframe +* [`GET /company/custom-reports/reports/{report_id}`](https://developer.personio.de/reference/listreportitems): this endpoint provides you with the data of an existing Custom Report +* [`GET /company/custom-reports/columns`](https://developer.personio.de/reference/listcolumns): this endpoint provides human-readable labels for report table columns +* all of the [recruiting API](https://developer.personio.de/reference/introduction-1) ## Contact diff --git a/src/personio_py/__init__.py b/src/personio_py/__init__.py index 40a11f6..f03cca1 100644 --- a/src/personio_py/__init__.py +++ b/src/personio_py/__init__.py @@ -31,5 +31,6 @@ ShortEmployee, Team, WorkSchedule, + Project ) from personio_py.client import Personio diff --git a/src/personio_py/client.py b/src/personio_py/client.py index a81d879..b26ce2d 100644 --- a/src/personio_py/client.py +++ b/src/personio_py/client.py @@ -10,7 +10,7 @@ import requests from requests import Response -from personio_py import Absence, AbsenceType, Attendance, DynamicMapping, Employee +from personio_py import Absence, AbsenceType, Attendance, DynamicMapping, Employee, Project from personio_py.errors import MissingCredentialsError, PersonioApiError, PersonioError from personio_py.models import PersonioResource from personio_py.search import SearchIndex @@ -37,6 +37,7 @@ class Personio: """base URL of the Personio HTTP API""" ATTENDANCE_URL = 'company/attendances' ABSENCE_URL = 'company/time-offs' + PROJECT_URL = 'company/attendances/projects' def __init__(self, base_url: str = None, client_id: str = None, client_secret: str = None, dynamic_fields: List[DynamicMapping] = None): @@ -514,6 +515,62 @@ def invalidate_index(self): """ self.search_index.invalidate() + def get_projects(self) -> List[Project]: + """ + Get a list of all company projects. + + :return: list of ``Project`` records + """ + response = self.request_json(self.PROJECT_URL) + projects = [Project.from_dict(d, self) for d in response['data']] + return projects + + def create_project(self, project: Project) -> Project: + """ + Creates a project record on the Personio servers. + + :param project: The project object to be created + :raises PersonioError: If the project could not be created on the Personio servers + """ + data = project.to_body_params() + response = self.request_json(self.PROJECT_URL, method='POST', data=data) + if response['success']: + project.id_ = response['data']['id'] + return project + raise PersonioError("Could not create project") + + def update_project(self, project: Project) -> Project: + """ + Updates a project record on the Personio servers. + + :param project: The project object to be updated + :raises PersonioErrror: If the project could not be created on the Personio servers + """ + data = project.to_body_params() + response = self.request_json(f'{self.PROJECT_URL}/{project.id_}', method='PATCH', data=data) + if response['success']: + return project + raise PersonioError("Could not update project") + + def delete_project(self, project: Union[Project, int]) -> None: + """ + Deletes a project record on the Personio servers. + + :param project: The project object to be updated + :raises ValueError: If a query is required but not allowed + or the query does not provide exactly one result. + """ + if isinstance(project, int): + response = self.request(f'{self.PROJECT_URL}/{project}', method='DELETE') + return response + elif isinstance(project, Project): + if project.id_ is not None: + return self.delete_project(project.id_) + else: + raise ValueError("Only a project with a project id can be deleted.") + else: + raise ValueError("project must be a Project object or an integer") + def _get_employee_metadata( self, path: str, resource_cls: Type[PersonioResourceType], employees: Union[int, List[int], Employee, List[Employee]], start_date: datetime = None, diff --git a/src/personio_py/models.py b/src/personio_py/models.py index fc60fd5..9211c60 100644 --- a/src/personio_py/models.py +++ b/src/personio_py/models.py @@ -623,6 +623,52 @@ def to_body_params(self): return data +class Project(WritablePersonioResource): + + _api_type_name = "Project" + _field_mapping_list = [ + # note: the id is actually not in the attributes dict, but one level higher + NumericFieldMapping('id', 'id_', int), + FieldMapping('name', 'name', str), + BooleanFieldMapping('active', 'active'), + DateTimeFieldMapping('created_at', 'created_at'), + DateTimeFieldMapping('updated_at', 'updated_at') + ] + + def __init__(self, client: 'Personio' = None, dynamic: Dict[str, Any] = None, + dynamic_raw: List['DynamicAttr'] = None, id_: int = None, name: str = None, + active: bool = None, created_at: datetime = None, updated_at: datetime = None, + **kwargs): + super().__init__(client=client, dynamic=dynamic, dynamic_raw=dynamic_raw, **kwargs) + self.id_ = id_ + self.name = name + self.active = active + self.created_at = created_at + self.updated_at = updated_at + + def _create(self, client: 'Personio' = None): + return get_client(self, client).create_project(self) + + def _delete(self, client: 'Personio' = None): + return get_client(self, client).delete_project(self) + + def _update(self, client: 'Personio' = None): + return get_client(self, client).update_project(self) + + def to_dict(self, nested=False) -> Dict[str, Any]: + # yes, this is weird an unnecessary, but that's how the api works + d = super().to_dict() + d['id'] = self.id_ + del d['attributes']['id'] + return d + + def to_body_params(self): + data = { + 'name': self.name, + 'active': self.active} + return data + + class Attendance(WritablePersonioResource): _api_type_name = "AttendancePeriod" diff --git a/tests/mock_data.py b/tests/mock_data.py index 8cc877f..f53b7b1 100644 --- a/tests/mock_data.py +++ b/tests/mock_data.py @@ -1179,3 +1179,76 @@ } """ json_dict_attendance_delete = json.loads(json_string_attendance_delete) + +json_string_project_rms = """ +{ + "success": true, + "data": [ + { + "id": 238751, + "type": "Project", + "attributes": { + "name": "conwik project", + "active": true, + "created_at": "2019-03-01T16:00:00", + "updated_at": "2020-03-02T16:11:35" + } + }, { + "id": 238750, + "type": "Project", + "attributes": { + "name": "mock project", + "active": true, + "created_at": "2023-03-28T16:11:35", + "updated_at": "2023-03-28T16:11:35" + } + }, { + "id": 238752, + "type": "Project", + "attributes": { + "name": "alianz project", + "active": false, + "created_at": "2020-10-10T10:11:35", + "updated_at": "2023-02-22T17:11:35" + } + } + ] +} +""" +json_dict_project_rms = json.loads(json_string_project_rms) + +json_string_project_create = """ +{ + "success":true, + "data":{ + "id": 83648600, + "message": "success" + } +} +""" +json_dict_project_create = json.loads(json_string_project_create) + +json_string_project_delete = """ +{ + "text":"", + "status_code": 204 +} +""" +json_dict_project_delete = json.loads(json_string_project_delete) + +json_string_project_update = """ +{ + "success": true, + "data": { + "id": 238760, + "type": "Project", + "attributes": { + "name": "updated mock project", + "active": true, + "created_at": "2023-03-28T16:11:35", + "updated_at": "2023-03-28T16:11:52" + } + } +} +""" +json_dict_project_update = json.loads(json_string_project_update) diff --git a/tests/test_api_project.py b/tests/test_api_project.py new file mode 100644 index 0000000..6c88193 --- /dev/null +++ b/tests/test_api_project.py @@ -0,0 +1,52 @@ +from .apitest_shared import * +from personio_py import Project + + +@skip_if_no_auth +def test_get_projects(): + create_test_project(name = "test project") + projects = personio.get_projects() + + assert len(projects)>0 + assert projects[-1].name == "test project" + assert projects[-1].active == False + personio.delete_project(projects[-1]) + + +@skip_if_no_auth +def test_update_project(): + """ + Test the update of project records on the server. + """ + project = create_test_project(name="initial project", active=True) + project.name = "updated project" + updated_project = personio.update_project(project) + + assert updated_project.name == "updated project" + + project.active = False + updated_project = personio.update_project(project) + + assert updated_project.active == False + + personio.delete_project(project) + +@skip_if_no_auth +def test_delete_project_by_id(): + project = create_test_project(name="delete project by id", active=True) + response = personio.delete_project(project.id_) + + assert response.status_code == 204 + +@skip_if_no_auth +def test_delete_project_by_project_object(): + project = create_test_project(name="delete project by object", active=True) + response = personio.delete_project(project) + + assert response.status_code == 204 + +def create_test_project(name: str = 'project name', active: bool = False): + p = Project(name=name, active=active) + project = personio.create_project(p) + + return project diff --git a/tests/test_mock_api_project.py b/tests/test_mock_api_project.py new file mode 100644 index 0000000..48c5110 --- /dev/null +++ b/tests/test_mock_api_project.py @@ -0,0 +1,90 @@ +import re +from datetime import datetime + +import responses + +from personio_py import Project +from tests.mock_data import ( + json_dict_project_rms, json_dict_project_update, json_dict_project_delete, + json_dict_project_create +) +from tests.test_mock_api import compare_labeled_attributes, mock_personio + + +@responses.activate +def test_create_project(): + mock_create_project() + personio = mock_personio() + + project = Project( + client=personio, + name="project one", + active=True, + created_at=datetime.strptime("2023-03-28T16:24:36", "%Y-%m-%dT%H:%M:%S"), + updated_at=datetime.strptime("2023-03-28T16:24:36", "%Y-%m-%dT%H:%M:%S") + ) + project.create() + assert project.id_ + +@responses.activate +def test_get_project(): + mock_projects() + # configure personio & get absences for alan + personio = mock_personio() + projects = personio.get_projects() + # validate + assert len(projects) == 3 + selection = [a for a in projects if "conwik" in a.name.lower()] + assert len(selection) == 1 + release = selection[0] # an instance of Project + assert release.active == True + assert release.created_at == datetime(2019,3,1,16,0,0) + assert release.updated_at == datetime(2020,3,2,16,11,35) + assert release.id_ == 238751 + # validate serialization + source_dict = json_dict_project_rms['data'][0] + target_dict = release.to_dict() + compare_labeled_attributes(source_dict, target_dict) + +@responses.activate +def test_update_projects(): + mock_projects() + mock_update_project() + personio = mock_personio() + projects = personio.get_projects() + projects_to_update = projects[0] + projects_to_update.active = False + updated_project = personio.update_project(projects_to_update) + assert updated_project.active == False + + +@responses.activate +def test_delete_attendances(): + mock_projects() + mock_delete_project() + personio = mock_personio() + projects = personio.get_projects() + project_to_delete = projects[0] + response = personio.delete_project(project_to_delete) + assert response.status_code == 204 + +def mock_projects(): + # mock the get absences endpoint (with different array offsets) + responses.add( + responses.GET, re.compile('https://api.personio.de/v1/company/attendances/projects?.*'), + status=200, json=json_dict_project_rms, adding_headers={'Authorization': 'Bearer foo'}) + +def mock_create_project(): + responses.add( + responses.POST, 'https://api.personio.de/v1/company/attendances/projects', + status=200, json=json_dict_project_create, adding_headers={'Authorization': 'Bearer bar'}) + +def mock_update_project(): + responses.add( + responses.PATCH, 'https://api.personio.de/v1/company/attendances/projects/238751', + status=200, json=json_dict_project_update, adding_headers={'Authorization': 'Bearer bar'}) + +def mock_delete_project(): + responses.add( + responses.DELETE, 'https://api.personio.de/v1/company/attendances/projects/238751', + status=204, json=json_dict_project_delete, adding_headers={'Authorization': 'Bearer bar'}) diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 0000000..a2d510b --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,26 @@ +from datetime import datetime + +from personio_py import Project + +project_dict = { + 'id': 1, + 'type': 'Project', + 'attributes': { + 'name': 'Project name', + 'active': True, + 'created_at': '1835-12-01T13:15:00+00:00', + 'updated_at': '1836-12-01T13:15:00+00:00', + } +} + + +def test_parse_project(): + project = Project.from_dict(project_dict) + assert project + assert project.name == 'Project name' + assert project.active == True + +def test_serialize_project(): + project = Project.from_dict(project_dict) + d = project.to_dict() + assert d == project_dict