Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Feature/attendances #35

Merged
merged 40 commits into from
Mar 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
20942b8
Place authentication in importable file for use with test cases place…
Nov 24, 2020
c6b7770
Readd existing tests
Nov 26, 2020
9cc99ff
Implement attendance functions.
Nov 30, 2020
b29afe4
Add api tests for querying remote attendance ids
Nov 30, 2020
18bc524
Make client public accessible. Add api test for deleting attendances.
Nov 30, 2020
9de540f
Move attendances mock tests / test data to new files to keep tests re…
Nov 30, 2020
aa801a7
Fix: duplicated entries returned by API if pagination offset > total …
Jan 13, 2021
c03e1d1
Place authentication in importable file for use with test cases place…
Dec 3, 2020
3ca6223
Adapt test to user retrieval function, fix missing underscore in chec…
Jan 21, 2021
989aa6b
Merge branch 'master' into feature/attendances
klamann Mar 9, 2021
af6fd2d
Merge branch 'master' of github.com:at-gmbh/personio-py into feature/…
Jan 17, 2022
f27f9b1
Remove duplicated absence testing code. Move the mock absences data t…
Jan 19, 2022
11f7657
Fix attendance test
Jan 19, 2022
31b655d
Update function description
Jan 19, 2022
174f427
Convert timedelta to str when updating an attendance
Jan 19, 2022
f6a18e8
Add more mock tests
Jan 19, 2022
34f5791
add mock test for attendance delete
Jan 19, 2022
4a1a9e5
Move attandence POST/PATCH/DELETE from WIP to available
Jan 19, 2022
cbb5392
Fix dependency issue by pinning mistune package to 0.8.4
Jan 19, 2022
901f06f
Install mistune before sphinx
Jan 19, 2022
5851de2
adding suggested changes to requirements and github action
Feb 3, 2022
6519bc1
Code cleanup
Mar 7, 2022
6bc21fd
Add attendance functions
Feb 7, 2023
01bfbe9
Adjust mock tests and pagination
Feb 8, 2023
792848c
Revert absence mock data
Feb 8, 2023
0daf34c
Cleanup code style
Feb 8, 2023
8cd2e10
Remove white space
Feb 8, 2023
88720af
Add version
Feb 8, 2023
5c7f7d1
Remove white space
Feb 8, 2023
14bf7e1
Modify docstrings
Feb 8, 2023
f0b937e
Modify docstrings
Feb 8, 2023
f37a522
refactor request paginated
Mar 3, 2023
4b4a33d
remove white space
Mar 3, 2023
08cda7c
remove white space
Mar 3, 2023
81156b2
Merge branch 'master' into feature/attendances
klamann Mar 3, 2023
437ae5f
Merge branch 'attendances' into attendance-fateme
Mar 15, 2023
a91de6d
Clean up merge and remove remote query id
Mar 15, 2023
d3c32bd
Merge pull request #34 from at-gmbh/feature/attendance-fateme
fatemetardasti96 Mar 16, 2023
66d21b4
Add indentation
Mar 16, 2023
29e60bd
fix a couple of code style thingies
klamann Mar 24, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 16 additions & 16 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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' ]
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
153 changes: 115 additions & 38 deletions src/personio_py/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
"""
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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]:
"""
Expand All @@ -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.

Expand All @@ -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:
"""
Expand All @@ -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_:
Expand All @@ -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
Expand All @@ -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:
Expand Down
45 changes: 40 additions & 5 deletions src/personio_py/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion src/personio_py/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '0.2.2'
__version__ = '0.2.3'
Loading