diff --git a/.github/workflows/time-reporting.yml b/.github/workflows/time-reporting.yml index a657c23..c3bd7d3 100644 --- a/.github/workflows/time-reporting.yml +++ b/.github/workflows/time-reporting.yml @@ -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: diff --git a/README.md b/README.md index f441f47..33d27f2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/compiler_admin/api/toggl.py b/compiler_admin/api/toggl.py index 910c022..4b664dd 100644 --- a/compiler_admin/api/toggl.py +++ b/compiler_admin/api/toggl.py @@ -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): @@ -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. @@ -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. @@ -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 diff --git a/compiler_admin/commands/time/__init__.py b/compiler_admin/commands/time/__init__.py index ec2f983..46dab15 100644 --- a/compiler_admin/commands/time/__init__.py +++ b/compiler_admin/commands/time/__init__.py @@ -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 @@ -15,4 +16,5 @@ def time(): time.add_command(convert) time.add_command(download) +time.add_command(lock) time.add_command(verify) diff --git a/compiler_admin/commands/time/lock.py b/compiler_admin/commands/time/lock.py new file mode 100644 index 0000000..c022143 --- /dev/null +++ b/compiler_admin/commands/time/lock.py @@ -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.") diff --git a/compiler_admin/services/toggl.py b/compiler_admin/services/toggl.py index 6d2cca0..f11f489 100644 --- a/compiler_admin/services/toggl.py +++ b/compiler_admin/services/toggl.py @@ -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() diff --git a/tests/api/test_toggl.py b/tests/api/test_toggl.py index 9635477..d57c63a 100644 --- a/tests/api/test_toggl.py +++ b/tests/api/test_toggl.py @@ -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 @@ -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") @@ -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) diff --git a/tests/commands/time/test_lock.py b/tests/commands/time/test_lock.py new file mode 100644 index 0000000..44ddc1d --- /dev/null +++ b/tests/commands/time/test_lock.py @@ -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) diff --git a/tests/services/test_toggl.py b/tests/services/test_toggl.py index c1596e3..a19a66e 100644 --- a/tests/services/test_toggl.py +++ b/tests/services/test_toggl.py @@ -19,6 +19,7 @@ convert_to_harvest, convert_to_justworks, download_time_entries, + lock_time_entries, summarize, TOGGL_COLUMNS, HARVEST_COLUMNS, @@ -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)