Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions .github/workflows/time-reporting.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,19 @@ jobs:
${{ secrets.GOOGLE_OAUTH2_SERVICE }}
EOM

- name: Lock Toggl time entries
id: lock
env:
TOGGL_API_TOKEN: "${{ secrets.TOGGL_API_TOKEN }}"
TOGGL_WORKSPACE_ID: "${{ secrets.TOGGL_WORKSPACE_ID }}"
run: |
ARGS=""
if [[ -n "${{ github.event.inputs.end-date }}" ]]; then
ARGS="$ARGS --date=${{ github.event.inputs.end-date }}"
fi

compiler-admin time lock $ARGS

- name: Download Toggl time entries
id: download
env:
Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,29 @@ Options:
Commands:
convert Convert a time report from one format into another.
download Download a Toggl time report in CSV format.
lock Lock Toggl time entries.
verify Verify time entry CSV files.
```

### Locking time entries

Use this command to lock Toggl time entries up to some date (defaulting to the last day of the prior month).

```bash
$ compiler-admin time lock --help
Usage: compiler-admin time lock [OPTIONS]

Lock Toggl time entries.

Options:
--date TEXT The date to lock time entries, formatted as YYYY-MM-DD.
Defaults to the last day of the previous month.
--help Show this message and exit.
```

### Downloading a Toggl report

Use this command to download a time report from Toggl in CSV format:
Use this command to download a time report from Toggl in CSV format (defaulting to the prior month):

```bash
$ compiler-admin time download --help
Expand Down
23 changes: 23 additions & 0 deletions compiler_admin/api/toggl.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ class Toggl:

API_BASE_URL = "https://api.track.toggl.com"
API_REPORTS_BASE_URL = "reports/api/v3"
API_VERSION_URL = "api/v9"
API_WORKSPACE = "workspace/{}"
API_WORKSPACES = "workspaces/{}"
API_HEADERS = {"Content-Type": "application/json", "User-Agent": "compilerla/compiler-admin:{}".format(__version__)}

def __init__(self, api_token: str, workspace_id: int, **kwargs):
Expand All @@ -33,6 +35,11 @@ def workspace_url_fragment(self):
"""The workspace portion of an API URL."""
return Toggl.API_WORKSPACE.format(self.workspace_id)

@property
def workspaces_url_fragment(self):
"""The workspaces portion of an API URL."""
return Toggl.API_WORKSPACES.format(self.workspace_id)

def _authorization_header(self):
"""Gets an `Authorization: Basic xyz` header using the Toggl API token.

Expand All @@ -42,6 +49,10 @@ def _authorization_header(self):
creds64 = b64encode(bytes(creds, "utf-8")).decode("utf-8")
return {"Authorization": "Basic {}".format(creds64)}

def _make_api_url(self, endpoint: str):
"""Get a fully formed URL for the Toggl API version endpoint."""
return "/".join((Toggl.API_BASE_URL, Toggl.API_VERSION_URL, self.workspaces_url_fragment, endpoint))

def _make_report_url(self, endpoint: str):
"""Get a fully formed URL for the Toggl Reports API v3 endpoint.

Expand Down Expand Up @@ -108,3 +119,15 @@ def post_reports(self, endpoint: str, **kwargs) -> requests.Response:
response.raise_for_status()

return response

def update_workspace_preferences(self, **kwargs) -> requests.Response:
"""Update workspace preferences.

See https://engineering.toggl.com/docs/api/preferences/#post-update-workspace-preferences.
"""
url = self._make_api_url("preferences")

response = self.session.post(url, json=kwargs, timeout=self.timeout)
response.raise_for_status()

return response
2 changes: 2 additions & 0 deletions compiler_admin/commands/time/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from compiler_admin.commands.time.convert import convert
from compiler_admin.commands.time.download import download
from compiler_admin.commands.time.lock import lock
from compiler_admin.commands.time.verify import verify


Expand All @@ -15,4 +16,5 @@ def time():

time.add_command(convert)
time.add_command(download)
time.add_command(lock)
time.add_command(verify)
25 changes: 25 additions & 0 deletions compiler_admin/commands/time/lock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from datetime import date, datetime, timedelta

import click

from compiler_admin.services.toggl import lock_time_entries


@click.command()
@click.option(
"--date",
"lock_date_str",
help="The date to lock time entries, formatted as YYYY-MM-DD. Defaults to the last day of the previous month.",
)
def lock(lock_date_str):
"""Lock Toggl time entries."""
if lock_date_str:
lock_date = datetime.strptime(lock_date_str, "%Y-%m-%d")
else:
today = date.today()
first_day_of_current_month = today.replace(day=1)
lock_date = first_day_of_current_month - timedelta(days=1)

click.echo(f"Locking time entries on or before: {lock_date.strftime('%Y-%m-%d')}")
lock_time_entries(lock_date)
click.echo("Done.")
14 changes: 14 additions & 0 deletions compiler_admin/services/toggl.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,20 @@ def download_time_entries(
files.write_csv(output_path, df, columns=output_cols)


def lock_time_entries(lock_date: datetime):
"""Lock time entries on the given date.

Args:
lock_date (datetime): The date to lock time entries.
"""
token = os.environ.get("TOGGL_API_TOKEN")
workspace = os.environ.get("TOGGL_WORKSPACE_ID")
toggl = Toggl(token, workspace)

lock_date_str = lock_date.strftime("%Y-%m-%d")
toggl.update_workspace_preferences(report_locked_at=lock_date_str)


def normalize_summary(toggl_summary: TimeSummary) -> TimeSummary:
"""Normalize a Toggl TimeSummary to match the Harvest format."""
info = project_info()
Expand Down
23 changes: 22 additions & 1 deletion tests/api/test_toggl.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
import pytest

from compiler_admin import __version__
from compiler_admin.api.toggl import __name__ as MODULE, Toggl
from compiler_admin.api.toggl import Toggl
from compiler_admin.api.toggl import __name__ as MODULE


@pytest.fixture
Expand Down Expand Up @@ -48,6 +49,15 @@ def test_toggl_init(toggl):
assert toggl.timeout == 5


def test_toggl_make_api_url(toggl):
url = toggl._make_api_url("endpoint")

assert url.startswith(toggl.API_BASE_URL)
assert toggl.API_VERSION_URL in url
assert toggl.workspaces_url_fragment in url
assert "/endpoint" in url


def test_toggl_make_report_url(toggl):
url = toggl._make_report_url("endpoint")

Expand Down Expand Up @@ -90,3 +100,14 @@ def test_toggl_detailed_time_entries_dynamic_timeout(mock_requests, toggl):

mock_requests.post.assert_called_once()
assert mock_requests.post.call_args.kwargs["timeout"] == 30


def test_toggl_update_workspace_preferences(mock_requests, toggl, mocker):
url = "http://fake.url"
mocker.patch.object(toggl, "_make_api_url", return_value=url)
prefs = {"pref1": "value1", "pref2": True}
response = toggl.update_workspace_preferences(**prefs)

response.raise_for_status.assert_called_once()
toggl._make_api_url.assert_called_once_with("preferences")
mock_requests.post.assert_called_once_with(url, json=prefs, timeout=toggl.timeout)
33 changes: 33 additions & 0 deletions tests/commands/time/test_lock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from datetime import date, datetime, timedelta

import pytest
from click.testing import CliRunner

from compiler_admin import RESULT_SUCCESS
from compiler_admin.commands.time.lock import lock, __name__ as MODULE


@pytest.fixture
def mock_lock_time_entries(mocker):
return mocker.patch(f"{MODULE}.lock_time_entries")


def test_lock_default_date(mock_lock_time_entries):
runner = CliRunner()
result = runner.invoke(lock)

assert result.exit_code == RESULT_SUCCESS
today = date.today()
first_day_of_current_month = today.replace(day=1)
lock_date = first_day_of_current_month - timedelta(days=1)
mock_lock_time_entries.assert_called_once_with(lock_date)


def test_lock_with_date(mock_lock_time_entries):
runner = CliRunner()
lock_date_str = "2025-10-11"
result = runner.invoke(lock, ["--date", lock_date_str])

assert result.exit_code == RESULT_SUCCESS
lock_date = datetime.strptime(lock_date_str, "%Y-%m-%d")
mock_lock_time_entries.assert_called_once_with(lock_date)
9 changes: 9 additions & 0 deletions tests/services/test_toggl.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
convert_to_harvest,
convert_to_justworks,
download_time_entries,
lock_time_entries,
summarize,
TOGGL_COLUMNS,
HARVEST_COLUMNS,
Expand Down Expand Up @@ -255,6 +256,14 @@ def test_download_time_entries(toggl_file):
assert response_df[col].equals(mock_df[col])


@pytest.mark.usefixtures("mock_toggl_api_env")
def test_lock_time_entries(mock_toggl_api):
lock_date = datetime(2025, 10, 11)
lock_time_entries(lock_date)

mock_toggl_api.update_workspace_preferences.assert_called_once_with(report_locked_at="2025-10-11")


def test_summarize(toggl_file):
"""Test that summarize returns a valid TimeSummary object."""
summary = summarize(toggl_file)
Expand Down
Loading