crowdtime-cli 0.17.1__tar.gz → 0.18.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/PKG-INFO +12 -3
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/README.md +11 -2
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/pyproject.toml +1 -1
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/__init__.py +1 -1
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/auth.py +19 -2
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/client.py +47 -4
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/auth_cmd.py +8 -1
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/config_cmd.py +4 -2
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/insights_cmd.py +4 -2
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/log_cmd.py +7 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/org_cmd.py +5 -2
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/report_cmd.py +1 -1
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/timesheet_cmd.py +42 -13
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/version_cmd.py +2 -2
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/config.py +16 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/oauth.py +25 -3
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +45 -27
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +49 -34
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +8 -6
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +45 -23
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/version_check.py +17 -1
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/.gitignore +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/LICENSE +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/__init__.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/calendar_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/projects_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/pto_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/team_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/formatters.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/main.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/models.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/resolvers.py +0 -0
- {crowdtime_cli-0.17.1 → crowdtime_cli-0.18.0}/src/crowdtime_cli/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crowdtime-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.18.0
|
|
4
4
|
Summary: AI-powered time tracking CLI — a modern, developer-friendly alternative to Harvest
|
|
5
5
|
Project-URL: Homepage, https://crowdtime.lat
|
|
6
6
|
Project-URL: Documentation, https://crowdtime.lat/docs
|
|
@@ -56,10 +56,19 @@ Track time, manage projects, log expenses, generate reports, submit timesheets,
|
|
|
56
56
|
## Installation
|
|
57
57
|
|
|
58
58
|
```bash
|
|
59
|
-
|
|
59
|
+
pipx install crowdtime-cli
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
Requires Python 3.11
|
|
62
|
+
Requires Python 3.11+. [pipx](https://pipx.pypa.io/) installs the CLI in its own isolated environment and puts `crowdtime` and `ct` on your PATH. On Debian, Ubuntu and other systems where the system Python is "externally managed", plain `pip install` into the system Python is blocked, so use pipx (or `uv tool install crowdtime-cli`).
|
|
63
|
+
|
|
64
|
+
Prefer pip? Install into a virtualenv:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
python3 -m venv ~/.venvs/crowdtime
|
|
68
|
+
~/.venvs/crowdtime/bin/pip install crowdtime-cli
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
To upgrade later: `pipx upgrade crowdtime-cli` (or `pip install -U crowdtime-cli` in the virtualenv).
|
|
63
72
|
|
|
64
73
|
## Quick Start
|
|
65
74
|
|
|
@@ -21,10 +21,19 @@ Track time, manage projects, log expenses, generate reports, submit timesheets,
|
|
|
21
21
|
## Installation
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
|
|
24
|
+
pipx install crowdtime-cli
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Requires Python 3.11
|
|
27
|
+
Requires Python 3.11+. [pipx](https://pipx.pypa.io/) installs the CLI in its own isolated environment and puts `crowdtime` and `ct` on your PATH. On Debian, Ubuntu and other systems where the system Python is "externally managed", plain `pip install` into the system Python is blocked, so use pipx (or `uv tool install crowdtime-cli`).
|
|
28
|
+
|
|
29
|
+
Prefer pip? Install into a virtualenv:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
python3 -m venv ~/.venvs/crowdtime
|
|
33
|
+
~/.venvs/crowdtime/bin/pip install crowdtime-cli
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
To upgrade later: `pipx upgrade crowdtime-cli` (or `pip install -U crowdtime-cli` in the virtualenv).
|
|
28
37
|
|
|
29
38
|
## Quick Start
|
|
30
39
|
|
|
@@ -5,6 +5,8 @@ Stores API tokens in the system keyring, with file-based fallback.
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
|
|
10
12
|
from .config import CONFIG_DIR
|
|
@@ -51,8 +53,23 @@ def clear_token() -> None:
|
|
|
51
53
|
def _save_token_file(token: str) -> None:
|
|
52
54
|
"""File-based fallback for token storage."""
|
|
53
55
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
# Created owner-only from the start — writing first and tightening the
|
|
57
|
+
# mode afterwards leaves the token world-readable in between.
|
|
58
|
+
fd = os.open(CREDENTIALS_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
59
|
+
try:
|
|
60
|
+
# The mode above only applies to a new file; an existing one keeps
|
|
61
|
+
# whatever it had, so tighten it before the token goes in.
|
|
62
|
+
if hasattr(os, "fchmod"):
|
|
63
|
+
os.fchmod(fd, 0o600)
|
|
64
|
+
except OSError:
|
|
65
|
+
pass
|
|
66
|
+
with os.fdopen(fd, "w") as f:
|
|
67
|
+
f.write(token)
|
|
68
|
+
print(
|
|
69
|
+
"Warning: no system keyring available — the API token was stored in "
|
|
70
|
+
f"plain text at {CREDENTIALS_FILE} (readable only by you).",
|
|
71
|
+
file=sys.stderr,
|
|
72
|
+
)
|
|
56
73
|
|
|
57
74
|
|
|
58
75
|
def _get_token_file() -> str | None:
|
|
@@ -7,16 +7,39 @@ and friendly error handling.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import os
|
|
10
|
+
import sys
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
12
13
|
import httpx
|
|
13
14
|
from rich.console import Console
|
|
15
|
+
from rich.markup import escape
|
|
14
16
|
|
|
15
17
|
from .auth import get_token
|
|
16
|
-
from .config import get_config
|
|
18
|
+
from .config import INSECURE_SERVER_URL_WARNING, get_config, is_insecure_server_url
|
|
17
19
|
|
|
18
20
|
console = Console(stderr=True)
|
|
19
21
|
_DEBUG = os.environ.get("CROWDTIME_DEBUG", "").lower() in ("1", "true", "yes")
|
|
22
|
+
# One warning per invocation, however many clients a command creates.
|
|
23
|
+
_warned_insecure_url = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _error_message(detail: Any) -> str:
|
|
27
|
+
"""Turn an API error body into one readable line.
|
|
28
|
+
|
|
29
|
+
Validation errors arrive as ``{"field": ["message", ...]}``; printing the
|
|
30
|
+
dict verbatim buries the reason the server gave.
|
|
31
|
+
"""
|
|
32
|
+
if isinstance(detail, dict):
|
|
33
|
+
if isinstance(detail.get("detail"), str):
|
|
34
|
+
return detail["detail"]
|
|
35
|
+
parts = []
|
|
36
|
+
for field, value in detail.items():
|
|
37
|
+
text = "; ".join(str(v) for v in value) if isinstance(value, (list, tuple)) else str(value)
|
|
38
|
+
parts.append(text if field in ("non_field_errors", "detail") else f"{field}: {text}")
|
|
39
|
+
return " | ".join(parts) if parts else str(detail)
|
|
40
|
+
if isinstance(detail, list):
|
|
41
|
+
return "; ".join(str(item) for item in detail)
|
|
42
|
+
return str(detail)
|
|
20
43
|
|
|
21
44
|
|
|
22
45
|
class APIError(Exception):
|
|
@@ -37,6 +60,14 @@ class CrowdTimeClient:
|
|
|
37
60
|
self.base_url = self.config.server_url
|
|
38
61
|
self.token = get_token()
|
|
39
62
|
self.org_slug = self.config.organization
|
|
63
|
+
# Extra line a command can set before a call, shown if the server
|
|
64
|
+
# answers 403 — for pointing at the command that would work instead.
|
|
65
|
+
self.forbidden_hint: str | None = None
|
|
66
|
+
|
|
67
|
+
global _warned_insecure_url
|
|
68
|
+
if not _warned_insecure_url and is_insecure_server_url(self.base_url):
|
|
69
|
+
_warned_insecure_url = True
|
|
70
|
+
print(INSECURE_SERVER_URL_WARNING.format(url=self.base_url), file=sys.stderr)
|
|
40
71
|
|
|
41
72
|
if require_auth and not self.token:
|
|
42
73
|
console.print(
|
|
@@ -138,7 +169,20 @@ class CrowdTimeClient:
|
|
|
138
169
|
raise SystemExit(1)
|
|
139
170
|
|
|
140
171
|
if response.status_code == 403:
|
|
141
|
-
|
|
172
|
+
# The server says why (not your entry, already invoiced, no
|
|
173
|
+
# approval authority, read-only token…) — pass that on instead of
|
|
174
|
+
# a generic refusal.
|
|
175
|
+
reason = "You don't have access to this resource."
|
|
176
|
+
try:
|
|
177
|
+
body = response.json()
|
|
178
|
+
if isinstance(body, dict) and isinstance(body.get("detail"), str) and body["detail"]:
|
|
179
|
+
reason = body["detail"]
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
console.print(f"[red]Permission denied.[/red] {escape(reason)}")
|
|
183
|
+
hint = getattr(self, "forbidden_hint", None)
|
|
184
|
+
if hint:
|
|
185
|
+
console.print(f"[dim]{escape(hint)}[/dim]")
|
|
142
186
|
raise SystemExit(1)
|
|
143
187
|
|
|
144
188
|
if response.status_code == 402:
|
|
@@ -179,8 +223,7 @@ class CrowdTimeClient:
|
|
|
179
223
|
detail = response.json()
|
|
180
224
|
except Exception:
|
|
181
225
|
detail = response.text
|
|
182
|
-
|
|
183
|
-
raise APIError(msg, status_code=response.status_code, detail=detail)
|
|
226
|
+
raise APIError(_error_message(detail), status_code=response.status_code, detail=detail)
|
|
184
227
|
|
|
185
228
|
if not response.content:
|
|
186
229
|
return None
|
|
@@ -81,7 +81,14 @@ def _login_with_browser(output_json: bool) -> None:
|
|
|
81
81
|
console.print(f"[dim]Server: {server_url}[/dim]")
|
|
82
82
|
console.print(f"[dim]Waiting for authentication (up to 2 minutes)...[/dim]\n")
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
def _show_code(code: str) -> None:
|
|
85
|
+
console.print(f"Verification code: [bold]{code}[/bold]")
|
|
86
|
+
console.print(
|
|
87
|
+
"[dim]The browser will ask you to authorize this login. "
|
|
88
|
+
"Only approve it if it shows the same code.[/dim]\n"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
result = run_oauth_flow(server_url, on_verification_code=_show_code)
|
|
85
92
|
|
|
86
93
|
if result is None:
|
|
87
94
|
console.print()
|
|
@@ -8,7 +8,7 @@ import subprocess
|
|
|
8
8
|
import typer
|
|
9
9
|
from rich.console import Console
|
|
10
10
|
|
|
11
|
-
from ..config import get_config
|
|
11
|
+
from ..config import INSECURE_SERVER_URL_WARNING, get_config, is_insecure_server_url
|
|
12
12
|
from ..formatters import format_error, format_success, print_json
|
|
13
13
|
|
|
14
14
|
app = typer.Typer(name="config", help="Manage CLI configuration.")
|
|
@@ -23,11 +23,13 @@ def config_set(
|
|
|
23
23
|
"""Set a configuration value.
|
|
24
24
|
|
|
25
25
|
Examples:
|
|
26
|
-
ct config set server.url https://api.crowdtime.
|
|
26
|
+
ct config set server.url https://api.crowdtime.lat
|
|
27
27
|
ct config set defaults.project myproject
|
|
28
28
|
ct config set defaults.daily_target 7h
|
|
29
29
|
"""
|
|
30
30
|
config = get_config()
|
|
31
|
+
if key == "server.url" and is_insecure_server_url(value):
|
|
32
|
+
typer.echo(INSECURE_SERVER_URL_WARNING.format(url=value), err=True)
|
|
31
33
|
# Convert boolean strings
|
|
32
34
|
if value.lower() in ("true", "yes", "1"):
|
|
33
35
|
config.set(key, True)
|
|
@@ -571,7 +571,8 @@ def profitability_insights(
|
|
|
571
571
|
|
|
572
572
|
Revenue is computed from billable time entries. Cost is derived from team
|
|
573
573
|
compensation configs (hourly rate or prorated salary). Margin = Revenue - Cost.
|
|
574
|
-
Requires manager+ role. Cost
|
|
574
|
+
Requires manager+ role. Cost and margin are shown to admins and owners only
|
|
575
|
+
(they derive from payroll); account managers get revenue and hours.
|
|
575
576
|
|
|
576
577
|
Examples:
|
|
577
578
|
ct insights profitability
|
|
@@ -671,7 +672,8 @@ def profitability_insights(
|
|
|
671
672
|
|
|
672
673
|
if not has_cost:
|
|
673
674
|
console.print(
|
|
674
|
-
"[dim]ℹ Cost
|
|
675
|
+
"[dim]ℹ Cost and margin are not shown. They derive from payroll, so they are "
|
|
676
|
+
"visible to admins and owners only, and need compensation configured in Payroll.[/dim]"
|
|
675
677
|
)
|
|
676
678
|
|
|
677
679
|
console.print()
|
|
@@ -277,8 +277,15 @@ def bulk_delete(
|
|
|
277
277
|
if skipped:
|
|
278
278
|
for s in skipped:
|
|
279
279
|
console.print(f" [yellow]Skipped[/yellow] {s['id']}: {s['reason']}")
|
|
280
|
+
# Entries that aren't yours are not reported at all — the server
|
|
281
|
+
# treats them as not found.
|
|
282
|
+
unmatched = len(set(ids)) - deleted_count - len(skipped)
|
|
280
283
|
if not deleted_count and not skipped:
|
|
281
284
|
console.print("[dim]No matching entries found.[/dim]")
|
|
285
|
+
elif unmatched > 0:
|
|
286
|
+
console.print(
|
|
287
|
+
f" [dim]{unmatched} ID(s) not found (wrong ID, or not your entry).[/dim]"
|
|
288
|
+
)
|
|
282
289
|
except APIError as e:
|
|
283
290
|
format_error(e.message)
|
|
284
291
|
raise typer.Exit(1)
|
|
@@ -231,7 +231,10 @@ def update_member(
|
|
|
231
231
|
capacity: Optional[float] = typer.Option(None, "--capacity", help="Weekly capacity hours."),
|
|
232
232
|
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
233
233
|
) -> None:
|
|
234
|
-
"""Update an organization member's role, rate, or capacity.
|
|
234
|
+
"""Update an organization member's role, rate, or capacity (admin+).
|
|
235
|
+
|
|
236
|
+
You cannot grant a role above your own, and only the owner can change
|
|
237
|
+
the owner's membership. Ownership is not assignable here.
|
|
235
238
|
|
|
236
239
|
Examples:
|
|
237
240
|
ct org update-member <user-id> --role project_manager
|
|
@@ -363,7 +366,7 @@ def revoke_invite(
|
|
|
363
366
|
def invitations(
|
|
364
367
|
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
365
368
|
) -> None:
|
|
366
|
-
"""List pending invitations for the current organization."""
|
|
369
|
+
"""List pending invitations for the current organization (admin+)."""
|
|
367
370
|
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
368
371
|
|
|
369
372
|
try:
|
|
@@ -649,7 +649,7 @@ def saved_delete(
|
|
|
649
649
|
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
650
650
|
|
|
651
651
|
if not force:
|
|
652
|
-
if not typer.confirm(f"Delete saved report {report_id
|
|
652
|
+
if not typer.confirm(f"Delete saved report {report_id}?"):
|
|
653
653
|
console.print("[dim]Cancelled.[/dim]")
|
|
654
654
|
raise typer.Exit(0)
|
|
655
655
|
|
|
@@ -83,6 +83,14 @@ def _print_timesheets_table(timesheets: list[dict], show_user: bool = False) ->
|
|
|
83
83
|
console.print(table)
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
_REVIEW_BY_PROJECT_HINT = (
|
|
87
|
+
"A whole timesheet can be decided by an admin, by the person's direct manager, or by "
|
|
88
|
+
"someone who manages every project in it. Otherwise review your project's portion: "
|
|
89
|
+
"ct timesheet approvals <timesheet-id>, then ct timesheet approve|reject <timesheet-id> "
|
|
90
|
+
"--project <project-id>."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
86
94
|
def _print_team_overview(data: dict) -> None:
|
|
87
95
|
"""Print team overview in a Rich table."""
|
|
88
96
|
from rich.table import Table
|
|
@@ -113,6 +121,7 @@ def _print_team_overview(data: dict) -> None:
|
|
|
113
121
|
table.add_column("Entries", justify="right", width=8)
|
|
114
122
|
table.add_column("Status", width=14)
|
|
115
123
|
|
|
124
|
+
any_by_project = False
|
|
116
125
|
for m in members:
|
|
117
126
|
# When project filter is active, surface the per-project approval
|
|
118
127
|
# status rather than the whole-timesheet status.
|
|
@@ -123,8 +132,12 @@ def _print_team_overview(data: dict) -> None:
|
|
|
123
132
|
status = m.get("timesheet_status")
|
|
124
133
|
style = _status_style(status)
|
|
125
134
|
|
|
126
|
-
|
|
127
|
-
|
|
135
|
+
# Null for people who only share a project with the caller: their
|
|
136
|
+
# hours here are that project's, not their whole week.
|
|
137
|
+
capacity_pct = m.get("capacity_percent")
|
|
138
|
+
if capacity_pct is None:
|
|
139
|
+
pct_display = "[dim]—[/dim]"
|
|
140
|
+
elif capacity_pct > 100:
|
|
128
141
|
pct_display = f"[red]{capacity_pct}%[/red]"
|
|
129
142
|
elif capacity_pct >= 80:
|
|
130
143
|
pct_display = f"[green]{capacity_pct}%[/green]"
|
|
@@ -133,17 +146,27 @@ def _print_team_overview(data: dict) -> None:
|
|
|
133
146
|
|
|
134
147
|
period_cap = m.get("period_capacity_hours", "40.0")
|
|
135
148
|
|
|
149
|
+
review_by_project = m.get("can_review_timesheet") is False
|
|
150
|
+
if review_by_project:
|
|
151
|
+
any_by_project = True
|
|
152
|
+
|
|
136
153
|
table.add_row(
|
|
137
|
-
m.get("user_name", ""),
|
|
138
|
-
format_duration(Decimal(str(m.get("total_hours"
|
|
154
|
+
m.get("user_name", "") + (" *" if review_by_project else ""),
|
|
155
|
+
format_duration(Decimal(str(m.get("total_hours") or 0))),
|
|
139
156
|
f"{period_cap}h",
|
|
140
157
|
pct_display,
|
|
141
|
-
format_duration(Decimal(str(m.get("billable_hours"
|
|
158
|
+
format_duration(Decimal(str(m.get("billable_hours") or 0))),
|
|
142
159
|
str(m.get("entry_count", 0)),
|
|
143
160
|
f"[{style}]{_status_label(status)}[/{style}]",
|
|
144
161
|
)
|
|
145
162
|
|
|
146
163
|
console.print(table)
|
|
164
|
+
if any_by_project:
|
|
165
|
+
console.print(
|
|
166
|
+
"[dim]* Shares a project with you but is not your direct report: hours shown are "
|
|
167
|
+
"for your projects only, and you review their timesheet per project "
|
|
168
|
+
"(ct timesheet approve <id> --project <project-id>).[/dim]"
|
|
169
|
+
)
|
|
147
170
|
|
|
148
171
|
|
|
149
172
|
# ─── Commands ─────────────────────────────────────────────────────
|
|
@@ -282,7 +305,8 @@ def approve_timesheet(
|
|
|
282
305
|
) -> None:
|
|
283
306
|
"""Approve a submitted timesheet (project_manager+).
|
|
284
307
|
|
|
285
|
-
Without --project: approves
|
|
308
|
+
Without --project: approves the whole timesheet. Allowed for admins, the
|
|
309
|
+
person's direct manager, or someone who manages every project in it.
|
|
286
310
|
With --project: approves only that project's portion (PM for that project).
|
|
287
311
|
|
|
288
312
|
Examples:
|
|
@@ -294,7 +318,7 @@ def approve_timesheet(
|
|
|
294
318
|
|
|
295
319
|
if not force and not output_json:
|
|
296
320
|
scope = f"project portion for {project}" if project else "all projects"
|
|
297
|
-
if not typer.confirm(f"Approve timesheet {timesheet_id
|
|
321
|
+
if not typer.confirm(f"Approve timesheet {timesheet_id} ({scope})?"):
|
|
298
322
|
console.print("[dim]Cancelled.[/dim]")
|
|
299
323
|
raise typer.Exit(0)
|
|
300
324
|
|
|
@@ -309,13 +333,14 @@ def approve_timesheet(
|
|
|
309
333
|
data=data or None,
|
|
310
334
|
)
|
|
311
335
|
else:
|
|
336
|
+
client.forbidden_hint = _REVIEW_BY_PROJECT_HINT
|
|
312
337
|
result = client.post(f"/timesheets/{timesheet_id}/approve/", data=data or None)
|
|
313
338
|
|
|
314
339
|
if output_json:
|
|
315
340
|
print_json(result)
|
|
316
341
|
else:
|
|
317
342
|
if project:
|
|
318
|
-
project_name = result.get("project_name") or project
|
|
343
|
+
project_name = result.get("project_name") or project
|
|
319
344
|
ts_status = result.get("timesheet_status", "")
|
|
320
345
|
format_success(f"Project '{project_name}' approved.")
|
|
321
346
|
if ts_status == "approved":
|
|
@@ -344,7 +369,9 @@ def reject_timesheet(
|
|
|
344
369
|
"""Reject a submitted timesheet with notes (project_manager+).
|
|
345
370
|
|
|
346
371
|
Rejecting any project portion rejects the entire timesheet.
|
|
347
|
-
The user must fix entries and resubmit.
|
|
372
|
+
The user must fix entries and resubmit. Without --project you need
|
|
373
|
+
authority over the whole timesheet (admin, direct manager, or managing
|
|
374
|
+
every project in it); with --project, managing that project is enough.
|
|
348
375
|
|
|
349
376
|
Examples:
|
|
350
377
|
ct timesheet reject <timesheet-id> --notes "Missing entries for Wednesday"
|
|
@@ -354,7 +381,7 @@ def reject_timesheet(
|
|
|
354
381
|
|
|
355
382
|
if not force and not output_json:
|
|
356
383
|
console.print("[yellow]Rejecting will return the entire timesheet for revision.[/yellow]")
|
|
357
|
-
if not typer.confirm(f"Reject timesheet {timesheet_id
|
|
384
|
+
if not typer.confirm(f"Reject timesheet {timesheet_id}?"):
|
|
358
385
|
console.print("[dim]Cancelled.[/dim]")
|
|
359
386
|
raise typer.Exit(0)
|
|
360
387
|
|
|
@@ -365,6 +392,7 @@ def reject_timesheet(
|
|
|
365
392
|
data={"notes": notes},
|
|
366
393
|
)
|
|
367
394
|
else:
|
|
395
|
+
client.forbidden_hint = _REVIEW_BY_PROJECT_HINT
|
|
368
396
|
result = client.post(f"/timesheets/{timesheet_id}/reject/", data={
|
|
369
397
|
"notes": notes,
|
|
370
398
|
})
|
|
@@ -373,7 +401,7 @@ def reject_timesheet(
|
|
|
373
401
|
print_json(result)
|
|
374
402
|
else:
|
|
375
403
|
if project:
|
|
376
|
-
project_name = result.get("project_name") or project
|
|
404
|
+
project_name = result.get("project_name") or project
|
|
377
405
|
format_success(f"Project '{project_name}' rejected. Timesheet returned for revision.")
|
|
378
406
|
else:
|
|
379
407
|
user_name = result.get("user_name", "user")
|
|
@@ -402,7 +430,7 @@ def recall_timesheet(
|
|
|
402
430
|
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
403
431
|
|
|
404
432
|
if not force and not output_json:
|
|
405
|
-
if not typer.confirm(f"Recall timesheet {timesheet_id
|
|
433
|
+
if not typer.confirm(f"Recall timesheet {timesheet_id} back to draft?"):
|
|
406
434
|
console.print("[dim]Cancelled.[/dim]")
|
|
407
435
|
raise typer.Exit(0)
|
|
408
436
|
|
|
@@ -439,7 +467,8 @@ def reopen_timesheet(
|
|
|
439
467
|
The reviewer-side undo for an approval granted in error: clears the
|
|
440
468
|
approval, unlocks the period's time entries and returns the timesheet to
|
|
441
469
|
draft so the user can fix it and resubmit. Blocked if any of that time is
|
|
442
|
-
already on a finalized invoice.
|
|
470
|
+
already on a finalized invoice. Needs authority over the whole timesheet:
|
|
471
|
+
admin, the person's direct manager, or managing every project in it.
|
|
443
472
|
|
|
444
473
|
Examples:
|
|
445
474
|
ct timesheet reopen <timesheet-id> --notes "Approved before Friday's entries were added"
|
|
@@ -7,7 +7,7 @@ from rich.console import Console
|
|
|
7
7
|
|
|
8
8
|
from .. import __version__
|
|
9
9
|
from ..formatters import print_json
|
|
10
|
-
from ..version_check import _parse_simple, get_latest, is_outdated
|
|
10
|
+
from ..version_check import _parse_simple, get_latest, is_outdated, upgrade_command
|
|
11
11
|
|
|
12
12
|
app = typer.Typer(name="version", help="Show installed and latest CLI versions.")
|
|
13
13
|
console = Console()
|
|
@@ -51,7 +51,7 @@ def version(
|
|
|
51
51
|
elif outdated:
|
|
52
52
|
console.print(
|
|
53
53
|
f"[yellow]\u26a0 A newer version is available. "
|
|
54
|
-
f"Run [bold]
|
|
54
|
+
f"Run [bold]{upgrade_command()}[/bold] to upgrade.[/yellow]"
|
|
55
55
|
)
|
|
56
56
|
else:
|
|
57
57
|
console.print("[green]\u2713 You are up to date.[/green]")
|
|
@@ -34,6 +34,22 @@ DEFAULT_CONFIG = {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
INSECURE_SERVER_URL_WARNING = (
|
|
38
|
+
"Warning: server.url is {url} — plain http sends your API token "
|
|
39
|
+
"unencrypted. Use https for anything but a local server."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_insecure_server_url(url: str) -> bool:
|
|
46
|
+
"""True for a plain-http server URL that isn't on this machine."""
|
|
47
|
+
from urllib.parse import urlsplit
|
|
48
|
+
|
|
49
|
+
parts = urlsplit(str(url).strip())
|
|
50
|
+
return parts.scheme == "http" and (parts.hostname or "") not in _LOCAL_HOSTS
|
|
51
|
+
|
|
52
|
+
|
|
37
53
|
class CrowdTimeConfig:
|
|
38
54
|
"""Manages CrowdTime CLI configuration."""
|
|
39
55
|
|
|
@@ -10,20 +10,39 @@ import hashlib
|
|
|
10
10
|
import secrets
|
|
11
11
|
import time
|
|
12
12
|
import webbrowser
|
|
13
|
+
from collections.abc import Callable
|
|
13
14
|
|
|
14
15
|
import httpx
|
|
15
16
|
|
|
16
17
|
|
|
17
|
-
def
|
|
18
|
+
def verification_code(state: str) -> str:
|
|
19
|
+
"""Short code the server shows on its consent page for this login.
|
|
20
|
+
|
|
21
|
+
Comparing it with the terminal confirms the browser is authorizing the
|
|
22
|
+
session started here. Must match ``cli_verification_code`` on the server.
|
|
23
|
+
"""
|
|
24
|
+
digest = hashlib.sha256(f'cli-verify:{state}'.encode()).hexdigest().upper()
|
|
25
|
+
return f'{digest[:4]}-{digest[4:8]}'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_oauth_flow(
|
|
29
|
+
server_url: str,
|
|
30
|
+
timeout: int = 120,
|
|
31
|
+
on_verification_code: Callable[[str], None] | None = None,
|
|
32
|
+
) -> tuple[str, str] | None:
|
|
18
33
|
"""Run the browser-based OAuth login flow with PKCE protection.
|
|
19
34
|
|
|
20
35
|
1. Opens browser for Google OAuth on the server
|
|
21
|
-
2.
|
|
22
|
-
|
|
36
|
+
2. The user confirms on the server's consent page that the code shown
|
|
37
|
+
there matches the one printed here
|
|
38
|
+
3. Polls the server until the auth code is available
|
|
39
|
+
4. Exchanges the code + PKCE verifier for an API token
|
|
23
40
|
|
|
24
41
|
Args:
|
|
25
42
|
server_url: The CrowdTime server URL (e.g. https://api.crowdtime.lat).
|
|
26
43
|
timeout: Max seconds to wait for the user to complete OAuth.
|
|
44
|
+
on_verification_code: Called with the code to display before the
|
|
45
|
+
browser opens.
|
|
27
46
|
|
|
28
47
|
Returns:
|
|
29
48
|
Tuple of (api_token, org_slug) on success, or None on failure/timeout.
|
|
@@ -40,6 +59,9 @@ def run_oauth_flow(server_url: str, timeout: int = 120) -> tuple[str, str] | Non
|
|
|
40
59
|
f'?state={state}&code_challenge={code_challenge}'
|
|
41
60
|
)
|
|
42
61
|
|
|
62
|
+
if on_verification_code is not None:
|
|
63
|
+
on_verification_code(verification_code(state))
|
|
64
|
+
|
|
43
65
|
if not webbrowser.open(login_url):
|
|
44
66
|
return None
|
|
45
67
|
|