Skip to content

Commit

Permalink
feat: upgrade reports API to v3
Browse files Browse the repository at this point in the history
  • Loading branch information
andreabak committed May 21, 2024
1 parent d6aec5e commit f6e2d0e
Show file tree
Hide file tree
Showing 4 changed files with 68 additions and 45 deletions.
85 changes: 50 additions & 35 deletions toggl/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -645,10 +645,13 @@ def set_duration(name, instance, value, init=False): # type: (str, base.Entity,
return True # Any change will result in updated instance's state.


def format_duration(value, config=None): # type: (int, utils.Config) -> str
def format_duration(value, config=None): # type: (typing.Optional[int], utils.Config) -> typing.Optional[str]
"""
Formatting the duration into HOURS:MINUTES:SECOND format.
"""
if value is None:
return None

if value < 0:
config = config or utils.Config.factory()
value = pendulum.now(tz=config.tz).int_timestamp + value
Expand Down Expand Up @@ -710,36 +713,35 @@ def current(self, config=None): # type: (utils.Config) -> typing.Optional[TimeE

return self.entity_cls.deserialize(config=config, **fetched_entity)

def _build_reports_url(self, start, stop, page, wid):
url = '/details?user_agent=toggl_cli&workspace_id={}&page={}'.format(wid, page)
def _prepare_reports_request(self, start, stop, first_row_number, wid): # type: (datetime.datetime, datetime.datetime, int, int) -> (str, typing.Dict[str, typing.Any])
url = f'/workspace/{wid}/search/time_entries'

data = {
"first_row_number": first_row_number or None,
}
if start is not None:
url += '&since={}'.format(quote_plus(start.isoformat()))

data['start_date'] = start.date().isoformat()
if stop is not None:
url += '&until={}'.format(quote_plus(stop.isoformat()))
data['end_date'] = stop.date().isoformat()

return url
return url, data

def _should_fetch_more(self, page, returned): # type: (int, typing.Dict) -> bool
return page * returned['per_page'] < returned['total_count']

def _deserialize_from_reports(self, config, entity_dict, wid):
entity = {
'id': entity_dict['id'],
'start': entity_dict['start'],
'stop': entity_dict['end'],
'duration': entity_dict['dur'] / 1000,
'description': entity_dict['description'],
'tags': entity_dict['tags'],
'pid': entity_dict['pid'],
'tid': entity_dict['tid'],
'uid': entity_dict['uid'],
'wid': wid,
'billable': entity_dict['billable'],
def _generate_from_reports(self, config, group_dict, wid): # type: (utils.Config, dict, int) -> typing.Iterator[TimeEntry]
group_keys = {"description", "project_id", "task_id", "user_id", "tags_ids", "billable"}
common_values = {
"workspace_id": wid,
**dict(filter(lambda kv: kv[0] in group_keys, group_dict.items()))
}

return self.entity_cls.deserialize(config=config, **entity)
for entity in group_dict["time_entries"]:
entity_values = {
**common_values,
"id": entity["id"],
"start": entity["start"],
"stop": entity["stop"],
}
entity = self.entity_cls.deserialize(config=config, **entity_values)
entity.duration = get_duration("duration", entity)
yield entity

def all_from_reports(self, start=None, stop=None, workspace=None, config=None): # type: (typing.Optional[datetime_type], typing.Optional[datetime_type], typing.Union[str, int, Workspace], typing.Optional[utils.Config]) -> typing.Generator[TimeEntry, None, None]
"""
Expand All @@ -754,8 +756,13 @@ def all_from_reports(self, start=None, stop=None, workspace=None, config=None):
"""
from .. import toggl

if start is None:
start = (pendulum.now() - pendulum.duration(days=6)).start_of("day")
if stop is None:
stop = (start + pendulum.duration(days=7)).end_of("day")

config = config or utils.Config.factory()
page = 1
first_row_number = 1

if workspace is None:
wid = config.default_workspace.id
Expand All @@ -771,19 +778,20 @@ def all_from_reports(self, start=None, stop=None, workspace=None, config=None):
wid = config.default_workspace.id

while True:
url = self._build_reports_url(start, stop, page, wid)
returned = utils.toggl(url, 'get', config=config, address=toggl.REPORTS_URL)
url, data = self._prepare_reports_request(start, stop, first_row_number, wid)
response = utils.toggl_request(url, 'post', data=json.dumps(data), config=config, address=toggl.REPORTS_URL)
results = response.json()

if not returned.get('data'):
if not results:
return

for entity in returned.get('data'):
yield self._deserialize_from_reports(config, entity, wid)
for group in results:
yield from self._generate_from_reports(config, group, wid)

if not self._should_fetch_more(page, returned):
next_row_number = int(response.headers.get('X-Next-Row-Number', 0))
if not next_row_number or next_row_number == first_row_number:
return

page += 1
first_row_number = next_row_number


class TimeEntry(WorkspacedEntity):
Expand All @@ -799,7 +807,7 @@ class TimeEntry(WorkspacedEntity):
Project to which the Time entry is linked to.
"""

task = fields.MappingField(Task, 'tid', premium=True)
task = fields.MappingField(Task, 'task_id', premium=True)
"""
Task to which the Time entry is linked to.
Expand Down Expand Up @@ -832,6 +840,11 @@ class TimeEntry(WorkspacedEntity):
calculated as current_time + duration, where current_time is the current time in seconds since epoch.
"""

is_running = fields.BooleanField(default=False)
"""
Whether the time entry is currently running.
"""

created_with = fields.StringField(required=True, default='TogglCLI', read=False)
"""
Information who created the time entry.
Expand All @@ -842,6 +855,8 @@ class TimeEntry(WorkspacedEntity):
Set of tags associated with the time entry.
"""

# TODO: tags_ids

objects = TimeEntrySet()

def __init__(self, start, stop=None, duration=None, **kwargs):
Expand Down
2 changes: 1 addition & 1 deletion toggl/toggl.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from toggl import cli

TOGGL_URL = "https://api.track.toggl.com/api/v9"
REPORTS_URL = "https://api.track.toggl.com/reports/api/v2"
REPORTS_URL = "https://api.track.toggl.com/reports/api/v3"
WEB_CLIENT_ADDRESS = "https://track.toggl.com/"


Expand Down
2 changes: 1 addition & 1 deletion toggl/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
from toggl.utils.others import toggl, SubCommandsGroup
from toggl.utils.others import toggl, toggl_request, SubCommandsGroup
from toggl.utils.config import Config
24 changes: 16 additions & 8 deletions toggl/utils/others.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def handle_error(response):
)


def _toggl_request(url, method, data, headers, auth):
def _http_request(url, method, data, headers, auth):
logger.info('Sending {} to \'{}\' data: {}'.format(method.upper(), url, json.dumps(data)))
if method == 'delete':
response = requests.delete(url, auth=auth, data=data, headers=headers)
Expand All @@ -145,9 +145,9 @@ def _toggl_request(url, method, data, headers, auth):
return response


def toggl(url, method, data=None, headers=None, config=None, address=None):
def toggl_request(url, method, data=None, headers=None, config=None, address=None):
"""
Makes an HTTP request to toggl.com. Returns the parsed JSON as dict.
Makes an HTTP request to toggl.com. Returns the response object.
"""
from ..toggl import TOGGL_URL

Expand All @@ -159,20 +159,28 @@ def toggl(url, method, data=None, headers=None, config=None, address=None):

url = "{}{}".format(address or TOGGL_URL, url)

tries = config.retries if config.retries and config.retries > 1 else 1 # There needs to be at least one try!
tries = max(config.retries or 1, 1) # There needs to be at least one try!

exception = None
for _ in range(tries):
try:
logger.debug('Default workspace: {}'.format(config._default_workspace))
response = _toggl_request(url, method, data, headers, config.get_auth())
response_json = response.json() if response.text else None
logger.debug('Response {}:\n{}'.format(response.status_code, pformat(response_json)))
return response_json
response = _http_request(url, method, data, headers, config.get_auth())
return response
except (exceptions.TogglThrottlingException, requests.exceptions.ConnectionError) as e:
sleep(0.1) # Lets give Toggl API some time to recover
exception = e
# TODO: Make it exponential

# If retries failed then 'e' contains the last Exception/Error, lets re-raise it!
raise exception


def toggl(url, method, data=None, headers=None, config=None, address=None):
"""
Makes an HTTP request to toggl.com. Returns the parsed JSON as dict.
"""
response = toggl_request(url, method, data=data, headers=headers, config=config, address=address)
response_json = response.json() if response.text else None
logger.debug('Response {}:\n{}'.format(response.status_code, pformat(response_json)))
return response_json

0 comments on commit f6e2d0e

Please sign in to comment.