crowdtime-cli 0.18.0__tar.gz → 0.20.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.18.0 → crowdtime_cli-0.20.0}/PKG-INFO +1 -1
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/pyproject.toml +1 -1
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/__init__.py +1 -1
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/client.py +19 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/auth_cmd.py +3 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/clients_cmd.py +70 -18
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/log_cmd.py +31 -5
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/pto_cmd.py +122 -13
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/team_cmd.py +176 -3
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/timesheet_cmd.py +25 -4
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/formatters.py +60 -8
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/models.py +7 -7
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +14 -6
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +44 -18
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +0 -19
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +3 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/.gitignore +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/LICENSE +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/README.md +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/auth.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/__init__.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/calendar_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/projects_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/config.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/main.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/oauth.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/resolvers.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/utils.py +0 -0
- {crowdtime_cli-0.18.0 → crowdtime_cli-0.20.0}/src/crowdtime_cli/version_check.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crowdtime-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.20.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
|
|
@@ -133,6 +133,25 @@ class CrowdTimeClient:
|
|
|
133
133
|
response = self._request("DELETE", url)
|
|
134
134
|
return response
|
|
135
135
|
|
|
136
|
+
def current_user_id(self) -> str | None:
|
|
137
|
+
"""The authenticated user's id, cached in the config after the first lookup.
|
|
138
|
+
|
|
139
|
+
``ct auth login`` refreshes the cache and ``ct auth logout`` clears it,
|
|
140
|
+
so the cached id always belongs to the stored token.
|
|
141
|
+
"""
|
|
142
|
+
cached = self.config.get("user.id")
|
|
143
|
+
if cached:
|
|
144
|
+
return str(cached)
|
|
145
|
+
try:
|
|
146
|
+
me = self.get("/auth/me/", org_scoped=False)
|
|
147
|
+
except APIError:
|
|
148
|
+
return None
|
|
149
|
+
user_id = me.get("id") if isinstance(me, dict) else None
|
|
150
|
+
if not user_id:
|
|
151
|
+
return None
|
|
152
|
+
self.config.set("user.id", str(user_id))
|
|
153
|
+
return str(user_id)
|
|
154
|
+
|
|
136
155
|
def _request(self, method: str, url: str, **kwargs: Any) -> Any:
|
|
137
156
|
"""Execute an HTTP request with error handling."""
|
|
138
157
|
if _DEBUG:
|
|
@@ -59,6 +59,7 @@ def _login_with_token(token: str, output_json: bool) -> None:
|
|
|
59
59
|
client = CrowdTimeClient(require_auth=True, require_org=False)
|
|
60
60
|
data = client.get("/api/v1/auth/me/", org_scoped=False)
|
|
61
61
|
user = User(**data)
|
|
62
|
+
get_config().set("user.id", user.id) # read back by current_user_id()
|
|
62
63
|
|
|
63
64
|
if output_json:
|
|
64
65
|
print_json(user)
|
|
@@ -118,6 +119,7 @@ def _login_with_browser(output_json: bool) -> None:
|
|
|
118
119
|
client = CrowdTimeClient(require_auth=True, require_org=False)
|
|
119
120
|
data = client.get("/api/v1/auth/me/", org_scoped=False)
|
|
120
121
|
user = User(**data)
|
|
122
|
+
config.set("user.id", user.id) # read back by current_user_id()
|
|
121
123
|
|
|
122
124
|
if output_json:
|
|
123
125
|
print_json(user)
|
|
@@ -140,6 +142,7 @@ def logout() -> None:
|
|
|
140
142
|
return
|
|
141
143
|
|
|
142
144
|
clear_token()
|
|
145
|
+
get_config().set("user.id", "")
|
|
143
146
|
format_success("Logged out. Token removed.")
|
|
144
147
|
|
|
145
148
|
|
|
@@ -84,7 +84,9 @@ def list_clients(
|
|
|
84
84
|
for c in clients_list:
|
|
85
85
|
client_id = str(c.get("id", ""))
|
|
86
86
|
name = c.get("name", "")
|
|
87
|
-
contact
|
|
87
|
+
# The primary contact record; contact_name is a legacy field (CRO-83).
|
|
88
|
+
primary = c.get("primary_contact") or {}
|
|
89
|
+
contact = primary.get("name") or c.get("contact_name", "") or "-"
|
|
88
90
|
project_count = str(c.get("project_count", 0))
|
|
89
91
|
is_archived = c.get("is_archived", False)
|
|
90
92
|
status = "[red]Archived[/red]" if is_archived else "[green]Active[/green]"
|
|
@@ -117,11 +119,30 @@ def show(
|
|
|
117
119
|
console.print(f"\n[bold]{data.get('name')}[/bold]")
|
|
118
120
|
console.print(f" ID: {data.get('id')}")
|
|
119
121
|
console.print(f" Currency: {data.get('currency', '-')}")
|
|
120
|
-
|
|
121
|
-
if
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
contacts = data.get("contacts") or []
|
|
123
|
+
if contacts:
|
|
124
|
+
for contact in contacts:
|
|
125
|
+
line = f" Contact: {contact.get('name', '')}"
|
|
126
|
+
if contact.get("email"):
|
|
127
|
+
line += f" <{contact['email']}>"
|
|
128
|
+
if contact.get("phone"):
|
|
129
|
+
line += f" {contact['phone']}"
|
|
130
|
+
if contact.get("role"):
|
|
131
|
+
line += f" — {contact['role']}"
|
|
132
|
+
flags = [
|
|
133
|
+
label for key, label in (("is_primary", "primary"), ("receives_invoices", "invoices"))
|
|
134
|
+
if contact.get(key)
|
|
135
|
+
]
|
|
136
|
+
if flags:
|
|
137
|
+
line += f" [dim]({', '.join(flags)})[/dim]"
|
|
138
|
+
console.print(line)
|
|
139
|
+
else:
|
|
140
|
+
# Legacy contact fields, only on clients that never got a contact record.
|
|
141
|
+
console.print(f" Contact: {data.get('contact_name') or '-'}")
|
|
142
|
+
if data.get("contact_email"):
|
|
143
|
+
console.print(f" Email: {data['contact_email']}")
|
|
144
|
+
if data.get("contact_phone"):
|
|
145
|
+
console.print(f" Phone: {data['contact_phone']}")
|
|
125
146
|
if data.get("address"):
|
|
126
147
|
console.print(f" Address: {data['address']}")
|
|
127
148
|
status = "Archived" if data.get("is_archived") else "Active"
|
|
@@ -149,7 +170,7 @@ def create(
|
|
|
149
170
|
country: Optional[str] = typer.Option(None, "--country", help="Country."),
|
|
150
171
|
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
151
172
|
) -> None:
|
|
152
|
-
"""Create a new client.
|
|
173
|
+
"""Create a new client. --contact/--email add its primary contact.
|
|
153
174
|
|
|
154
175
|
Examples:
|
|
155
176
|
ct clients create "Acme Corp"
|
|
@@ -157,13 +178,13 @@ def create(
|
|
|
157
178
|
"""
|
|
158
179
|
api_client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
159
180
|
|
|
181
|
+
if contact_email and not contact_name:
|
|
182
|
+
format_error("--email needs --contact (the contact's name).")
|
|
183
|
+
raise typer.Exit(1)
|
|
184
|
+
|
|
160
185
|
payload: dict = {"name": name}
|
|
161
186
|
if currency:
|
|
162
187
|
payload["currency"] = currency
|
|
163
|
-
if contact_name:
|
|
164
|
-
payload["contact_name"] = contact_name
|
|
165
|
-
if contact_email:
|
|
166
|
-
payload["contact_email"] = contact_email
|
|
167
188
|
if address_line1:
|
|
168
189
|
payload["address_line1"] = address_line1
|
|
169
190
|
if address_line2:
|
|
@@ -180,10 +201,22 @@ def create(
|
|
|
180
201
|
try:
|
|
181
202
|
data = api_client.post("/clients/", data=payload)
|
|
182
203
|
|
|
204
|
+
# Contacts are records of their own, shown on the client page and used
|
|
205
|
+
# for invoicing — not the legacy contact_* fields on the client (CRO-83).
|
|
206
|
+
if contact_name:
|
|
207
|
+
contact = api_client.post(f"/clients/{data.get('id')}/contacts/", data={
|
|
208
|
+
"name": contact_name,
|
|
209
|
+
"email": contact_email or "",
|
|
210
|
+
"is_primary": True,
|
|
211
|
+
})
|
|
212
|
+
data["contacts"] = [contact]
|
|
213
|
+
|
|
183
214
|
if output_json:
|
|
184
215
|
print_json(data)
|
|
185
216
|
else:
|
|
186
217
|
format_success(f"Client '{data.get('name')}' created (ID: {data.get('id')})")
|
|
218
|
+
if contact_name:
|
|
219
|
+
console.print(f" Primary contact: {contact_name}" + (f" <{contact_email}>" if contact_email else ""))
|
|
187
220
|
except APIError as e:
|
|
188
221
|
format_error(e.message)
|
|
189
222
|
raise typer.Exit(1)
|
|
@@ -203,7 +236,7 @@ def edit(
|
|
|
203
236
|
country: Optional[str] = typer.Option(None, "--country", help="Country."),
|
|
204
237
|
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
205
238
|
) -> None:
|
|
206
|
-
"""Edit an existing client.
|
|
239
|
+
"""Edit an existing client. --contact/--email update its primary contact.
|
|
207
240
|
|
|
208
241
|
Examples:
|
|
209
242
|
ct clients edit <client-id> --name "New Name"
|
|
@@ -216,10 +249,6 @@ def edit(
|
|
|
216
249
|
payload["name"] = name
|
|
217
250
|
if currency is not None:
|
|
218
251
|
payload["currency"] = currency
|
|
219
|
-
if contact_name is not None:
|
|
220
|
-
payload["contact_name"] = contact_name
|
|
221
|
-
if contact_email is not None:
|
|
222
|
-
payload["contact_email"] = contact_email
|
|
223
252
|
if address_line1 is not None:
|
|
224
253
|
payload["address_line1"] = address_line1
|
|
225
254
|
if city is not None:
|
|
@@ -231,12 +260,35 @@ def edit(
|
|
|
231
260
|
if country is not None:
|
|
232
261
|
payload["country"] = country
|
|
233
262
|
|
|
234
|
-
|
|
263
|
+
contact_payload: dict = {}
|
|
264
|
+
if contact_name is not None:
|
|
265
|
+
contact_payload["name"] = contact_name
|
|
266
|
+
if contact_email is not None:
|
|
267
|
+
contact_payload["email"] = contact_email
|
|
268
|
+
|
|
269
|
+
if not payload and not contact_payload:
|
|
235
270
|
format_error("No changes specified. Use --name, --currency, --contact, etc.")
|
|
236
271
|
raise typer.Exit(1)
|
|
237
272
|
|
|
238
273
|
try:
|
|
239
|
-
|
|
274
|
+
if payload:
|
|
275
|
+
data = api_client.patch(f"/clients/{client_id}/", data=payload)
|
|
276
|
+
else:
|
|
277
|
+
data = api_client.get(f"/clients/{client_id}/")
|
|
278
|
+
|
|
279
|
+
# Contacts are records of their own, shown on the client page and used
|
|
280
|
+
# for invoicing (CRO-83): update the primary one, or create it.
|
|
281
|
+
if contact_payload:
|
|
282
|
+
contacts = extract_results(api_client.get(f"/clients/{client_id}/contacts/"))
|
|
283
|
+
primary = next((c for c in contacts if c.get("is_primary")), contacts[0] if contacts else None)
|
|
284
|
+
if primary:
|
|
285
|
+
api_client.patch(f"/clients/{client_id}/contacts/{primary['id']}/", data=contact_payload)
|
|
286
|
+
elif contact_payload.get("name"):
|
|
287
|
+
api_client.post(f"/clients/{client_id}/contacts/", data={**contact_payload, "is_primary": True})
|
|
288
|
+
else:
|
|
289
|
+
format_error("This client has no contact yet — pass --contact (the contact's name) with --email.")
|
|
290
|
+
raise typer.Exit(1)
|
|
291
|
+
data = api_client.get(f"/clients/{client_id}/")
|
|
240
292
|
|
|
241
293
|
if output_json:
|
|
242
294
|
print_json(data)
|
|
@@ -381,11 +381,23 @@ def list_entries(
|
|
|
381
381
|
task: Optional[str] = typer.Option(None, "--task", "-t", help="Filter by task name or ID."),
|
|
382
382
|
billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", "-b/-B",
|
|
383
383
|
help="Filter by billable status."),
|
|
384
|
-
user: Optional[str] = typer.Option(
|
|
384
|
+
user: Optional[str] = typer.Option(
|
|
385
|
+
None, "--user", "-u",
|
|
386
|
+
help="Filter by user ID. Only people whose time you can see: yourself, your direct "
|
|
387
|
+
"reports (manager), and anyone on projects you manage (project manager).",
|
|
388
|
+
),
|
|
389
|
+
mine: bool = typer.Option(
|
|
390
|
+
False, "--mine",
|
|
391
|
+
help="Only your own entries. Managers and project managers otherwise also see "
|
|
392
|
+
"the time of the people and projects they manage.",
|
|
393
|
+
),
|
|
385
394
|
format_output: str = typer.Option("table", "--format", help="Output format: table, json, csv."),
|
|
386
395
|
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
387
396
|
) -> None:
|
|
388
|
-
"""List time entries with optional filters.
|
|
397
|
+
"""List time entries with optional filters.
|
|
398
|
+
|
|
399
|
+
The table and CSV show a User column whenever an entry isn't yours.
|
|
400
|
+
"""
|
|
389
401
|
effective_date = date
|
|
390
402
|
if today_flag:
|
|
391
403
|
effective_date = "today"
|
|
@@ -402,6 +414,7 @@ def list_entries(
|
|
|
402
414
|
task=task,
|
|
403
415
|
billable=billable,
|
|
404
416
|
user=user,
|
|
417
|
+
mine=mine,
|
|
405
418
|
format_output=format_output,
|
|
406
419
|
output_json=output_json,
|
|
407
420
|
)
|
|
@@ -417,6 +430,7 @@ def _list_entries(
|
|
|
417
430
|
task: str | None = None,
|
|
418
431
|
billable: bool | None = None,
|
|
419
432
|
user: str | None = None,
|
|
433
|
+
mine: bool = False,
|
|
420
434
|
format_output: str = "table",
|
|
421
435
|
output_json: bool = False,
|
|
422
436
|
) -> None:
|
|
@@ -425,6 +439,14 @@ def _list_entries(
|
|
|
425
439
|
|
|
426
440
|
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
427
441
|
|
|
442
|
+
# Known up front so other people's entries can be told apart (CRO-85).
|
|
443
|
+
me = client.current_user_id()
|
|
444
|
+
if mine:
|
|
445
|
+
if not me:
|
|
446
|
+
format_error("Could not resolve your user id. Run ct auth login again.")
|
|
447
|
+
raise typer.Exit(1)
|
|
448
|
+
user = me
|
|
449
|
+
|
|
428
450
|
params: dict = {}
|
|
429
451
|
|
|
430
452
|
if date_str:
|
|
@@ -484,7 +506,10 @@ def _list_entries(
|
|
|
484
506
|
elif format_output == "csv":
|
|
485
507
|
_print_csv(entries)
|
|
486
508
|
else:
|
|
487
|
-
|
|
509
|
+
# Someone else's entry with no owner column looks like your own;
|
|
510
|
+
# the column appears as soon as the list holds one (CRO-85).
|
|
511
|
+
foreign = any(e.user and e.user != me for e in entries)
|
|
512
|
+
format_entries_table(entries, show_date=True, show_user=bool(user) or foreign)
|
|
488
513
|
except APIError as e:
|
|
489
514
|
format_error(e.message)
|
|
490
515
|
raise typer.Exit(1)
|
|
@@ -492,13 +517,14 @@ def _list_entries(
|
|
|
492
517
|
|
|
493
518
|
def _print_csv(entries: list[TimeEntry]) -> None:
|
|
494
519
|
"""Print entries as CSV (plain output, safe for piping)."""
|
|
495
|
-
print("id,date,project,task,description,duration,billable,hourly_rate,billable_amount")
|
|
520
|
+
print("id,date,user,project,task,description,duration,billable,hourly_rate,billable_amount")
|
|
496
521
|
for e in entries:
|
|
497
522
|
desc = (e.description or "").replace('"', '""')
|
|
523
|
+
user_name = (e.user_name or "").replace('"', '""')
|
|
498
524
|
rate = getattr(e, "hourly_rate", None) or ""
|
|
499
525
|
amount = getattr(e, "billable_amount", None) or ""
|
|
500
526
|
print(
|
|
501
|
-
f'{e.id},{e.date or ""},'
|
|
527
|
+
f'{e.id},{e.date or ""},"{user_name}",'
|
|
502
528
|
f'{e.project_name or ""},{e.task_name or ""},'
|
|
503
529
|
f'"{desc}",{e.duration or 0},'
|
|
504
530
|
f'{"yes" if e.is_billable else "no"},'
|
|
@@ -46,7 +46,11 @@ from ..models import (
|
|
|
46
46
|
)
|
|
47
47
|
from ..utils import format_date, format_duration, parse_date, parse_date_range
|
|
48
48
|
|
|
49
|
-
app = typer.Typer(name="pto", help="Manage
|
|
49
|
+
app = typer.Typer(name="pto", help="Manage time off requests.")
|
|
50
|
+
# Hidden from help: nothing may tell someone without paid time off that it
|
|
51
|
+
# exists (CRO-84). Admins and people who have it know where to look.
|
|
52
|
+
allowance_app = typer.Typer(name="allowance", help="Paid time off allowances (admin+).")
|
|
53
|
+
app.add_typer(allowance_app, name="allowance", hidden=True)
|
|
50
54
|
console = Console()
|
|
51
55
|
|
|
52
56
|
|
|
@@ -258,12 +262,6 @@ def add_cmd(
|
|
|
258
262
|
help="Decimal hours per day. Omit for full-day from membership.",
|
|
259
263
|
),
|
|
260
264
|
notes: Optional[str] = typer.Option(None, "--notes", "-n", help="Optional free-text notes."),
|
|
261
|
-
paid: Optional[bool] = typer.Option(
|
|
262
|
-
None, "--paid/--unpaid",
|
|
263
|
-
help="Whether these hours are paid. Omit to follow the organization's "
|
|
264
|
-
"policy for this kind. Account managers and above only — the API "
|
|
265
|
-
"rejects the request from anyone below, it is not ignored.",
|
|
266
|
-
),
|
|
267
265
|
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
268
266
|
) -> None:
|
|
269
267
|
"""On-behalf PTO entry, auto-approved. Project manager+, within their scope."""
|
|
@@ -289,18 +287,13 @@ def add_cmd(
|
|
|
289
287
|
payload["hours_per_day"] = str(hpd)
|
|
290
288
|
if notes is not None:
|
|
291
289
|
payload["notes"] = notes
|
|
292
|
-
# Declared since the flag was added but never sent, so --unpaid silently
|
|
293
|
-
# booked paid leave. The backend defaults every kind to paid, so the
|
|
294
|
-
# omission was not neutral.
|
|
295
|
-
if paid is not None:
|
|
296
|
-
payload["is_paid"] = paid
|
|
297
290
|
|
|
298
291
|
try:
|
|
299
292
|
data = client.post("/time-off/", data=payload)
|
|
300
293
|
except APIError as e:
|
|
301
294
|
# Surface the rejection as a friendly error — the backend refuses
|
|
302
295
|
# `user` values outside the caller's visible set, refuses it entirely
|
|
303
|
-
# below project manager
|
|
296
|
+
# below project manager.
|
|
304
297
|
format_error(e.message)
|
|
305
298
|
raise typer.Exit(1)
|
|
306
299
|
|
|
@@ -721,3 +714,119 @@ def summary_cmd(
|
|
|
721
714
|
|
|
722
715
|
summary = TimeOffSummary(**data)
|
|
723
716
|
console.print(format_timeoff_summary_panel(summary))
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
# ── Paid time off (CRO-84) ──────────────────────────────────────────────
|
|
720
|
+
# Whether someone is paid for leave follows the allowance an admin gave them;
|
|
721
|
+
# nobody flags a request as paid. The API answers 404 to anyone who may not
|
|
722
|
+
# know, so these commands print the same thing for "none" and "not allowed".
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _print_balance(data: dict) -> None:
|
|
726
|
+
b = data.get("balance")
|
|
727
|
+
name = data.get("name") or data.get("email")
|
|
728
|
+
days = data.get("paid_time_off_days_per_year")
|
|
729
|
+
if not b:
|
|
730
|
+
console.print(f"[bold]{name}[/bold]: no paid time off — their leave is unpaid.")
|
|
731
|
+
return
|
|
732
|
+
console.print(
|
|
733
|
+
f"[bold]{name}[/bold]: {b['remaining_days']} of {b['allowance_days']} paid days left "
|
|
734
|
+
f"({b['year_start']} → {b['year_end']})"
|
|
735
|
+
)
|
|
736
|
+
console.print(
|
|
737
|
+
f" used {b['used_days']} · requested {b['pending_days']} · unpaid {b['unpaid_days']}"
|
|
738
|
+
f" [dim]{days} days/year from {data.get('paid_time_off_start_date')}[/dim]"
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
@app.command("balance", hidden=True)
|
|
743
|
+
def balance_cmd(
|
|
744
|
+
user: Optional[str] = typer.Option(None, "--user", "-u", help="Someone whose leave you approve (email or UUID). Default: you."),
|
|
745
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
746
|
+
) -> None:
|
|
747
|
+
"""Your paid time off balance, or that of someone whose leave you approve."""
|
|
748
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
749
|
+
try:
|
|
750
|
+
user_id = _resolve_user_to_uuid(client, user) if user else client.current_user_id()
|
|
751
|
+
data = client.get(f"/members/{user_id}/paid-time-off/")
|
|
752
|
+
except APIError as e:
|
|
753
|
+
if e.status_code == 404:
|
|
754
|
+
console.print("[dim]No balance to show.[/dim]")
|
|
755
|
+
return
|
|
756
|
+
format_error(e.message)
|
|
757
|
+
raise typer.Exit(1)
|
|
758
|
+
if output_json:
|
|
759
|
+
print_json(data)
|
|
760
|
+
return
|
|
761
|
+
_print_balance(data)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
@allowance_app.command("list")
|
|
765
|
+
def allowance_list_cmd(
|
|
766
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
767
|
+
) -> None:
|
|
768
|
+
"""Every member's paid time off allowance and remaining days. Admin+."""
|
|
769
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
770
|
+
try:
|
|
771
|
+
rows = client.get("/members/paid-time-off/").get("results", [])
|
|
772
|
+
except APIError as e:
|
|
773
|
+
format_error("Only admins can list allowances." if e.status_code == 404 else e.message)
|
|
774
|
+
raise typer.Exit(1)
|
|
775
|
+
if output_json:
|
|
776
|
+
print_json(rows)
|
|
777
|
+
return
|
|
778
|
+
from rich.table import Table
|
|
779
|
+
|
|
780
|
+
table = Table(show_header=True, header_style="bold")
|
|
781
|
+
table.add_column("User ID", style="dim")
|
|
782
|
+
table.add_column("Name")
|
|
783
|
+
table.add_column("Days/year", justify="right")
|
|
784
|
+
table.add_column("Counting from")
|
|
785
|
+
table.add_column("Left this year", justify="right")
|
|
786
|
+
for r in rows:
|
|
787
|
+
b = r.get("balance")
|
|
788
|
+
table.add_row(
|
|
789
|
+
r["user_id"], r.get("name") or r.get("email"),
|
|
790
|
+
str(r["paid_time_off_days_per_year"]) if r.get("paid_time_off_days_per_year") else "—",
|
|
791
|
+
str(r.get("paid_time_off_start_date") or "—"),
|
|
792
|
+
str(b["remaining_days"]) if b else "—",
|
|
793
|
+
)
|
|
794
|
+
console.print(table)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
@allowance_app.command("set")
|
|
798
|
+
def allowance_set_cmd(
|
|
799
|
+
user: str = typer.Argument(..., help="Member (email or UUID)."),
|
|
800
|
+
days: str = typer.Option(..., "--days", help="Paid days per allowance year; 0 for none."),
|
|
801
|
+
start: Optional[str] = typer.Option(
|
|
802
|
+
None, "--from",
|
|
803
|
+
help="When the allowance starts counting (YYYY-MM-DD): the day they joined, or the day "
|
|
804
|
+
"this arrangement started. Defaults to the day they joined.",
|
|
805
|
+
),
|
|
806
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
807
|
+
) -> None:
|
|
808
|
+
"""Set someone's paid time off. Admin+, never your own.
|
|
809
|
+
|
|
810
|
+
Applies to leave approved from now on; leave already approved keeps what it was paid.
|
|
811
|
+
"""
|
|
812
|
+
try:
|
|
813
|
+
value = Decimal(days)
|
|
814
|
+
except InvalidOperation:
|
|
815
|
+
format_error(f"--days must be a number, got '{days}'.")
|
|
816
|
+
raise typer.Exit(1)
|
|
817
|
+
payload: dict = {"paid_time_off_days_per_year": str(value)}
|
|
818
|
+
if start:
|
|
819
|
+
payload["paid_time_off_start_date"] = format_date(_parse_date_or_exit(start))
|
|
820
|
+
|
|
821
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
822
|
+
try:
|
|
823
|
+
user_id = _resolve_user_to_uuid(client, user)
|
|
824
|
+
data = client.patch(f"/members/{user_id}/paid-time-off/", data=payload)
|
|
825
|
+
except APIError as e:
|
|
826
|
+
format_error("Member not found." if e.status_code == 404 else e.message)
|
|
827
|
+
raise typer.Exit(1)
|
|
828
|
+
if output_json:
|
|
829
|
+
print_json(data)
|
|
830
|
+
return
|
|
831
|
+
format_success("Paid time off updated.")
|
|
832
|
+
_print_balance(data)
|