Skip to content

Commit

Permalink
Merge branch 'master' of github.com:at-gmbh/personio-py
Browse files Browse the repository at this point in the history
  • Loading branch information
klamann committed Dec 4, 2020
2 parents bacd6c8 + 2d61dd3 commit f98d36e
Show file tree
Hide file tree
Showing 18 changed files with 555 additions and 225 deletions.
7 changes: 4 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ on:
jobs:

build:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: [3.7, 3.8]
name: Test & Build with Python ${{ matrix.python-version }}
os: [ubuntu-latest, windows-latest]
name: Test & Build (Python ${{ matrix.python-version }} on ${{ matrix.os }})
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
Expand Down Expand Up @@ -56,5 +57,5 @@ jobs:
- name: Store distribution package
uses: actions/upload-artifact@v2
with:
name: personio-py_${{ matrix.python-version }}
name: personio-py_${{ matrix.python-version }}_${{ matrix.os }}
path: dist/
4 changes: 4 additions & 0 deletions .github/workflows/docs-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ on:
branches:
- master
paths:
# on documentation change (changelog & contribution guidlines are a part of that)
- 'docs/**'
- 'CHANGELOG.md'
- 'CONTRIBUTING.md'
# also when docstrings in the source code change
- 'src/**'

jobs:
docs:
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/docs-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ on:
branches:
- master
paths:
# on documentation change (changelog & contribution guidlines are a part of that)
- 'docs/**'
- 'CHANGELOG.md'
- 'CONTRIBUTING.md'
# also when docstrings in the source code change
- 'src/**'

jobs:
build:
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ repos:
rev: '3.8.3'
hooks:
- id: flake8
args: ['--max-complexity=10', '--max-line-length=100', '--ignore=F401,W504']
args: ['--max-complexity=10', '--max-line-length=100', '--ignore=F401,W504', '--exclude=tests/*']
9 changes: 6 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +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).

## [Unreleased](https://github.com/at-gmbh/personio-py/compare/v0.1.0...HEAD)
## [Unreleased](https://github.com/at-gmbh/personio-py/compare/v0.1.1...HEAD)

* ...
* add support for new API functions: `get_absences`, `get_attendances`
* add support for paginated API requests (required for attendances & absences)
* make `from_dict()` and `to_dict()` behave consistently
* meta: improve CI builds & tests, better pre-commit hooks

## [0.1.1](https://github.com/at-gmbh/personio-py/tree/v0.1.0) - 2020-08-19
## [0.1.1](https://github.com/at-gmbh/personio-py/tree/v0.1.1) - 2020-08-19

- This is the first release of the Personio API client library
- Created Python module `personio_py`
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,17 +93,17 @@ 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

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
* [`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
* [`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
Expand Down
165 changes: 98 additions & 67 deletions src/personio_py/client.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
"""
Implementation of the Personio API functions
"""
import logging
import os
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple, Union
from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar, Union
from urllib.parse import urljoin

import requests
from requests import Response

from personio_py import Absence, AbsenceType, Attendance, DynamicMapping, Employee
from personio_py.errors import MissingCredentialsError, PersonioApiError, PersonioError
from personio_py.models import PersonioResource

logger = logging.getLogger('personio_py')

PersonioResourceType = TypeVar('PersonioResourceType', bound=PersonioResource, covariant=True)


class Personio:
"""
Expand Down Expand Up @@ -96,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:
Expand All @@ -121,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()
Expand Down Expand Up @@ -161,10 +166,10 @@ def request_paginated(
data_acc = []
while True:
response = self.request_json(path, method, params, data, auth_rotation=auth_rotation)
data = response['data']
if data:
data_acc.extend(data)
params['offset'] += len(data)
resp_data = response['data']
if resp_data:
data_acc.extend(resp_data)
params['offset'] += len(resp_data)
else:
break
# return the accumulated data
Expand Down Expand Up @@ -205,7 +210,7 @@ def get_employees(self) -> List[Employee]:
:return: list of ``Employee`` instances
"""
response = self.request_json('company/employees')
employees = [Employee.from_dict(d['attributes'], self) for d in response['data']]
employees = [Employee.from_dict(d, self) for d in response['data']]
return employees

def get_employee(self, employee_id: int) -> Employee:
Expand All @@ -216,20 +221,22 @@ def get_employee(self, employee_id: int) -> Employee:
:return: an ``Employee`` instance or a PersonioApiError, if the employee does not exist
"""
response = self.request_json(f'company/employees/{employee_id}')
employee_dict = response['data']['attributes']
employee = Employee.from_dict(employee_dict, self)
employee = Employee.from_dict(response['data'], self)
return employee

def get_employee_picture(self, employee_id: int, width: int = None) -> Optional[bytes]:
def get_employee_picture(self, employee: Union[int, Employee], width: int = None) \
-> Optional[bytes]:
"""
Get the profile picture of the employee with the specified ID as image file
Get the profile picture of the specified employee as image file
(usually png or jpg).
:param employee_id: the Personio ID of the employee to fetch
:param employee: get the picture of this employee or the employee with
the specified Personio ID
:param width: optionally scale the profile picture to this width.
Defaults to the original width of the profile picture.
:return: the profile picture as png or jpg file (bytes)
"""
employee_id = employee.id_ if isinstance(employee, Employee) else int(employee)
path = f'company/employees/{employee_id}/profile-picture'
if width:
path += f'/{width}'
Expand Down Expand Up @@ -261,100 +268,123 @@ def update_employee(self, employee: Employee):
"""
placeholder; not ready to be used
"""
# TODO implement
pass
raise NotImplementedError()

def get_attendances(self, employee_ids: Union[int, List[int]], start_date: datetime = None,
end_date: datetime = None) -> List[Attendance]:
"""
placeholder; not ready to be used
def get_attendances(self, employees: Union[int, List[int], Employee, List[Employee]],
start_date: datetime = None, end_date: datetime = None) -> List[Attendance]:
"""
# TODO automatically resolve paginated requests
Get a list of all attendance records for the employees with the specified IDs
employee_ids, start_date, end_date = self._normalize_timeframe_params(
employee_ids, start_date, end_date)
params = {
"start_date": start_date.isoformat()[:10],
"end_date": end_date.isoformat()[:10],
"employees[]": employee_ids,
"limit": 200,
"offset": 0
}
response = self.request_json('company/attendances', params=params)
attendances = [Attendance.from_dict(d, self) for d in response['data']]
return attendances
Note that internally, multiple requests may be made by this function due to limitations
of the Personio API: Only a limited number of records can be retrieved in a single request
and only a limited number of employee IDs can be passed as URL parameters. The results
are still presented as a single list, no matter how many requests are made.
:param employees: a single employee or a list of employee objects or IDs.
Attendance records for all matching employees will be retrieved.
:param start_date: only return attendance records from this date (inclusive, optional)
: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)

def create_attendances(self, attendances: List[Attendance]):
"""
placeholder; not ready to be used
"""
# 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]:
"""
placeholder; not ready to be used
Get a list of all available absence types, e.g. "paid vacation" or "parental leave".
The absence types are used to classify the absences of employees
(see ``get_absences`` to get a list of all absences for the employees).
Each ``Absence`` also contains the ``AbsenceType`` for this instance; the purpose
of this function is to provide you with a list of all possible options that can show up.
"""
# TODO implement
pass
response = self.request_json('company/time-off-types')
absence_types = [AbsenceType.from_dict(d, self) for d in response['data']]
return absence_types

def get_absences(self, employee_ids: Union[int, List[int]], 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]:
"""
placeholder; not ready to be used
Get a list of all absence records for the employees with the specified IDs.
Note that internally, multiple requests may be made by this function due to limitations
of the Personio API: Only a limited number of records can be retrieved in a single request
and only a limited number of employee IDs can be passed as URL parameters. The results
are still presented as a single list, no matter how many requests are made.
:param employees: a single employee or a list of employee objects or IDs.
Absence records for all matching employees will be retrieved.
:param start_date: only return absence records from this date (inclusive, optional)
:param end_date: only return absence records up to this date (inclusive, optional)
:return: list of ``Absence`` records for the specified employees
"""
employee_ids, start_date, end_date = self._normalize_timeframe_params(
employee_ids, start_date, end_date)
params = {
"employees[]": employee_ids,
"start_date": start_date.isoformat()[:10],
"end_date": end_date.isoformat()[:10],
}
response = self.request_paginated('company/time-offs', params=params)
absences = [Absence.from_dict(d['attributes'], self) for d in response['data']]
return absences
return self._get_employee_metadata(
'company/time-offs', Absence, employees, start_date, end_date)

def get_absence(self, absence_id: int) -> Absence:
"""
placeholder; not ready to be used
"""
# TODO implement
pass
raise NotImplementedError()

def create_absence(self, absence: Absence):
"""
placeholder; not ready to be used
"""
# TODO implement
pass
raise NotImplementedError()

def delete_absence(self, absence_id: int):
"""
placeholder; not ready to be used
"""
# TODO implement
pass
raise NotImplementedError()

def _get_employee_metadata(
self, path: str, resource_cls: Type[PersonioResourceType],
employees: Union[int, List[int], Employee, List[Employee]], start_date: datetime = None,
end_date: datetime = None) -> List[PersonioResourceType]:
# resolve params to match API requirements
employees, start_date, end_date = self._normalize_timeframe_params(
employees, start_date, end_date)
params = {
"start_date": start_date.isoformat()[:10],
"end_date": end_date.isoformat()[:10],
}
# request in batches of up to 50 employees (keeps URL length well below 2000 chars)
data_acc = []
for i in range(0, len(employees), 50):
params["employees[]"] = employees[i:i + 50]
response = self.request_paginated(path, params=params)
data_acc.extend(response['data'])
# create objects from accumulated API responses
parsed_data = [resource_cls.from_dict(d, self) for d in data_acc]
return parsed_data

@classmethod
def _normalize_timeframe_params(
cls, employee_ids: Union[int, List[int]], start_date: datetime = None,
end_date: datetime = None) -> Tuple[List[int], datetime, datetime]:
cls, employees: Union[int, List[int], Employee, List[Employee]],
start_date: datetime = None, end_date: datetime = None) \
-> Tuple[List[int], datetime, datetime]:
"""
Whenever we need a list of employee IDs, a start date and an end date, this function comes
in handy:
Expand All @@ -363,17 +393,18 @@ def _normalize_timeframe_params(
* sets the start date way into the past, if it was not provided
* sets the end date way into the future, if it was not provided
:param employee_ids: a single employee ID or a list of employee IDs
:param employees: a single employee or a list of employees (employee objects or just IDs)
:param start_date: a start date (optional)
:param end_date: an end date (optional)
:return: a tuple of (list of employee IDs, start date, end date), no None values.
"""
if not employee_ids:
if not employees:
raise ValueError("need at least one employee ID, got nothing")
if start_date is None:
start_date = datetime(1900, 1, 1)
if end_date is None:
end_date = datetime(datetime.now().year + 10, 1, 1)
if not isinstance(employee_ids, list):
employee_ids = [employee_ids]
if not isinstance(employees, list):
employees = [employees]
employee_ids = [(e.id_ if isinstance(e, Employee) else e) for e in employees]
return employee_ids, start_date, end_date
Loading

0 comments on commit f98d36e

Please sign in to comment.