Skip to content

Commit 49c3a3c

Browse files
authored
Feat: time lock command (#60)
2 parents 0a09a5b + 7ae0198 commit 49c3a3c

File tree

9 files changed

+159
-2
lines changed

9 files changed

+159
-2
lines changed

.github/workflows/time-reporting.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,19 @@ jobs:
6262
${{ secrets.GOOGLE_OAUTH2_SERVICE }}
6363
EOM
6464
65+
- name: Lock Toggl time entries
66+
id: lock
67+
env:
68+
TOGGL_API_TOKEN: "${{ secrets.TOGGL_API_TOKEN }}"
69+
TOGGL_WORKSPACE_ID: "${{ secrets.TOGGL_WORKSPACE_ID }}"
70+
run: |
71+
ARGS=""
72+
if [[ -n "${{ github.event.inputs.end-date }}" ]]; then
73+
ARGS="$ARGS --date=${{ github.event.inputs.end-date }}"
74+
fi
75+
76+
compiler-admin time lock $ARGS
77+
6578
- name: Download Toggl time entries
6679
id: download
6780
env:

README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,29 @@ Options:
7474
Commands:
7575
convert Convert a time report from one format into another.
7676
download Download a Toggl time report in CSV format.
77+
lock Lock Toggl time entries.
7778
verify Verify time entry CSV files.
7879
```
7980

81+
### Locking time entries
82+
83+
Use this command to lock Toggl time entries up to some date (defaulting to the last day of the prior month).
84+
85+
```bash
86+
$ compiler-admin time lock --help
87+
Usage: compiler-admin time lock [OPTIONS]
88+
89+
Lock Toggl time entries.
90+
91+
Options:
92+
--date TEXT The date to lock time entries, formatted as YYYY-MM-DD.
93+
Defaults to the last day of the previous month.
94+
--help Show this message and exit.
95+
```
96+
8097
### Downloading a Toggl report
8198

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

84101
```bash
85102
$ compiler-admin time download --help

compiler_admin/api/toggl.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ class Toggl:
1414

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

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

38+
@property
39+
def workspaces_url_fragment(self):
40+
"""The workspaces portion of an API URL."""
41+
return Toggl.API_WORKSPACES.format(self.workspace_id)
42+
3643
def _authorization_header(self):
3744
"""Gets an `Authorization: Basic xyz` header using the Toggl API token.
3845
@@ -42,6 +49,10 @@ def _authorization_header(self):
4249
creds64 = b64encode(bytes(creds, "utf-8")).decode("utf-8")
4350
return {"Authorization": "Basic {}".format(creds64)}
4451

52+
def _make_api_url(self, endpoint: str):
53+
"""Get a fully formed URL for the Toggl API version endpoint."""
54+
return "/".join((Toggl.API_BASE_URL, Toggl.API_VERSION_URL, self.workspaces_url_fragment, endpoint))
55+
4556
def _make_report_url(self, endpoint: str):
4657
"""Get a fully formed URL for the Toggl Reports API v3 endpoint.
4758
@@ -108,3 +119,15 @@ def post_reports(self, endpoint: str, **kwargs) -> requests.Response:
108119
response.raise_for_status()
109120

110121
return response
122+
123+
def update_workspace_preferences(self, **kwargs) -> requests.Response:
124+
"""Update workspace preferences.
125+
126+
See https://engineering.toggl.com/docs/api/preferences/#post-update-workspace-preferences.
127+
"""
128+
url = self._make_api_url("preferences")
129+
130+
response = self.session.post(url, json=kwargs, timeout=self.timeout)
131+
response.raise_for_status()
132+
133+
return response

compiler_admin/commands/time/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from compiler_admin.commands.time.convert import convert
44
from compiler_admin.commands.time.download import download
5+
from compiler_admin.commands.time.lock import lock
56
from compiler_admin.commands.time.verify import verify
67

78

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

1617
time.add_command(convert)
1718
time.add_command(download)
19+
time.add_command(lock)
1820
time.add_command(verify)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from datetime import date, datetime, timedelta
2+
3+
import click
4+
5+
from compiler_admin.services.toggl import lock_time_entries
6+
7+
8+
@click.command()
9+
@click.option(
10+
"--date",
11+
"lock_date_str",
12+
help="The date to lock time entries, formatted as YYYY-MM-DD. Defaults to the last day of the previous month.",
13+
)
14+
def lock(lock_date_str):
15+
"""Lock Toggl time entries."""
16+
if lock_date_str:
17+
lock_date = datetime.strptime(lock_date_str, "%Y-%m-%d")
18+
else:
19+
today = date.today()
20+
first_day_of_current_month = today.replace(day=1)
21+
lock_date = first_day_of_current_month - timedelta(days=1)
22+
23+
click.echo(f"Locking time entries on or before: {lock_date.strftime('%Y-%m-%d')}")
24+
lock_time_entries(lock_date)
25+
click.echo("Done.")

compiler_admin/services/toggl.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,20 @@ def download_time_entries(
227227
files.write_csv(output_path, df, columns=output_cols)
228228

229229

230+
def lock_time_entries(lock_date: datetime):
231+
"""Lock time entries on the given date.
232+
233+
Args:
234+
lock_date (datetime): The date to lock time entries.
235+
"""
236+
token = os.environ.get("TOGGL_API_TOKEN")
237+
workspace = os.environ.get("TOGGL_WORKSPACE_ID")
238+
toggl = Toggl(token, workspace)
239+
240+
lock_date_str = lock_date.strftime("%Y-%m-%d")
241+
toggl.update_workspace_preferences(report_locked_at=lock_date_str)
242+
243+
230244
def normalize_summary(toggl_summary: TimeSummary) -> TimeSummary:
231245
"""Normalize a Toggl TimeSummary to match the Harvest format."""
232246
info = project_info()

tests/api/test_toggl.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
import pytest
55

66
from compiler_admin import __version__
7-
from compiler_admin.api.toggl import __name__ as MODULE, Toggl
7+
from compiler_admin.api.toggl import Toggl
8+
from compiler_admin.api.toggl import __name__ as MODULE
89

910

1011
@pytest.fixture
@@ -48,6 +49,15 @@ def test_toggl_init(toggl):
4849
assert toggl.timeout == 5
4950

5051

52+
def test_toggl_make_api_url(toggl):
53+
url = toggl._make_api_url("endpoint")
54+
55+
assert url.startswith(toggl.API_BASE_URL)
56+
assert toggl.API_VERSION_URL in url
57+
assert toggl.workspaces_url_fragment in url
58+
assert "/endpoint" in url
59+
60+
5161
def test_toggl_make_report_url(toggl):
5262
url = toggl._make_report_url("endpoint")
5363

@@ -90,3 +100,14 @@ def test_toggl_detailed_time_entries_dynamic_timeout(mock_requests, toggl):
90100

91101
mock_requests.post.assert_called_once()
92102
assert mock_requests.post.call_args.kwargs["timeout"] == 30
103+
104+
105+
def test_toggl_update_workspace_preferences(mock_requests, toggl, mocker):
106+
url = "http://fake.url"
107+
mocker.patch.object(toggl, "_make_api_url", return_value=url)
108+
prefs = {"pref1": "value1", "pref2": True}
109+
response = toggl.update_workspace_preferences(**prefs)
110+
111+
response.raise_for_status.assert_called_once()
112+
toggl._make_api_url.assert_called_once_with("preferences")
113+
mock_requests.post.assert_called_once_with(url, json=prefs, timeout=toggl.timeout)

tests/commands/time/test_lock.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from datetime import date, datetime, timedelta
2+
3+
import pytest
4+
from click.testing import CliRunner
5+
6+
from compiler_admin import RESULT_SUCCESS
7+
from compiler_admin.commands.time.lock import lock, __name__ as MODULE
8+
9+
10+
@pytest.fixture
11+
def mock_lock_time_entries(mocker):
12+
return mocker.patch(f"{MODULE}.lock_time_entries")
13+
14+
15+
def test_lock_default_date(mock_lock_time_entries):
16+
runner = CliRunner()
17+
result = runner.invoke(lock)
18+
19+
assert result.exit_code == RESULT_SUCCESS
20+
today = date.today()
21+
first_day_of_current_month = today.replace(day=1)
22+
lock_date = first_day_of_current_month - timedelta(days=1)
23+
mock_lock_time_entries.assert_called_once_with(lock_date)
24+
25+
26+
def test_lock_with_date(mock_lock_time_entries):
27+
runner = CliRunner()
28+
lock_date_str = "2025-10-11"
29+
result = runner.invoke(lock, ["--date", lock_date_str])
30+
31+
assert result.exit_code == RESULT_SUCCESS
32+
lock_date = datetime.strptime(lock_date_str, "%Y-%m-%d")
33+
mock_lock_time_entries.assert_called_once_with(lock_date)

tests/services/test_toggl.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
convert_to_harvest,
2020
convert_to_justworks,
2121
download_time_entries,
22+
lock_time_entries,
2223
summarize,
2324
TOGGL_COLUMNS,
2425
HARVEST_COLUMNS,
@@ -255,6 +256,14 @@ def test_download_time_entries(toggl_file):
255256
assert response_df[col].equals(mock_df[col])
256257

257258

259+
@pytest.mark.usefixtures("mock_toggl_api_env")
260+
def test_lock_time_entries(mock_toggl_api):
261+
lock_date = datetime(2025, 10, 11)
262+
lock_time_entries(lock_date)
263+
264+
mock_toggl_api.update_workspace_preferences.assert_called_once_with(report_locked_at="2025-10-11")
265+
266+
258267
def test_summarize(toggl_file):
259268
"""Test that summarize returns a valid TimeSummary object."""
260269
summary = summarize(toggl_file)

0 commit comments

Comments
 (0)