crowdtime-cli 0.11.0__tar.gz → 0.12.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.11.0 → crowdtime_cli-0.12.0}/.gitignore +5 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/PKG-INFO +1 -1
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/pyproject.toml +1 -1
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/__init__.py +1 -1
- crowdtime_cli-0.12.0/src/crowdtime_cli/commands/team_cmd.py +415 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +14 -6
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +29 -9
- crowdtime_cli-0.11.0/src/crowdtime_cli/commands/team_cmd.py +0 -218
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/LICENSE +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/README.md +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/auth.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/client.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/__init__.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/projects_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/timesheet_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/config.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/formatters.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/main.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/models.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/oauth.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/resolvers.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/utils.py +0 -0
- {crowdtime_cli-0.11.0 → crowdtime_cli-0.12.0}/src/crowdtime_cli/version_check.py +0 -0
|
@@ -13,6 +13,10 @@ build/
|
|
|
13
13
|
venv/
|
|
14
14
|
ENV/
|
|
15
15
|
|
|
16
|
+
# Accidentally-created user/pip caches when pip runs with HOME=repo
|
|
17
|
+
.cache/
|
|
18
|
+
.local/
|
|
19
|
+
|
|
16
20
|
# Environment
|
|
17
21
|
.env
|
|
18
22
|
|
|
@@ -65,3 +69,4 @@ coolify_deployment.md
|
|
|
65
69
|
pypi_deployment.md
|
|
66
70
|
sentry-workflow.md
|
|
67
71
|
linear-workflow.md
|
|
72
|
+
seeds-mentores.md
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crowdtime-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.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
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""Team commands: manage manager → direct-report assignments.
|
|
2
|
+
|
|
3
|
+
Mirrors Harvest's manager model. Admins assign members as direct reports
|
|
4
|
+
to a manager; the manager can see those members' hours across any project
|
|
5
|
+
without being added as a PM to each project individually.
|
|
6
|
+
|
|
7
|
+
Bulk operations use the delta-based ``/manager-assignments/bulk-set/``
|
|
8
|
+
endpoint — the client computes ``add`` and ``remove`` sets, keeping
|
|
9
|
+
concurrent edits from clobbering each other.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
from ..client import APIError, CrowdTimeClient
|
|
21
|
+
from ..formatters import extract_results, format_error, format_success, print_json
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(name="team", help="Manage direct-report assignments.")
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _members_list(client: CrowdTimeClient) -> list[dict]:
|
|
28
|
+
data = client.get("/members/")
|
|
29
|
+
return extract_results(data)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_membership(client: CrowdTimeClient, ref: str, *, members: Optional[list[dict]] = None) -> dict:
|
|
33
|
+
"""Resolve a membership by email, user UUID, or membership UUID.
|
|
34
|
+
|
|
35
|
+
Accepts an optional pre-fetched ``members`` list to avoid repeat HTTP
|
|
36
|
+
calls when resolving many refs at once.
|
|
37
|
+
"""
|
|
38
|
+
members = members or _members_list(client)
|
|
39
|
+
ref_lower = ref.lower()
|
|
40
|
+
for m in members:
|
|
41
|
+
if (m.get("user", {}).get("email") or "").lower() == ref_lower:
|
|
42
|
+
return m
|
|
43
|
+
for m in members:
|
|
44
|
+
if m.get("id") == ref:
|
|
45
|
+
return m
|
|
46
|
+
for m in members:
|
|
47
|
+
if m.get("user", {}).get("id") == ref:
|
|
48
|
+
return m
|
|
49
|
+
raise APIError(
|
|
50
|
+
f"No member found matching '{ref}'. Use email, user ID, or membership ID.",
|
|
51
|
+
status_code=404,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _current_direct_report_ids(client: CrowdTimeClient, manager_id: str) -> set[str]:
|
|
56
|
+
"""Return the set of membership IDs currently assigned to ``manager_id``."""
|
|
57
|
+
data = client.get("/manager-assignments/", params={"manager": manager_id})
|
|
58
|
+
rows = extract_results(data)
|
|
59
|
+
return {row["member"] for row in rows}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _render_bulk_result(result: dict, *, members_by_id: dict[str, dict]) -> None:
|
|
63
|
+
"""Pretty-print the bulk-set response."""
|
|
64
|
+
added = result.get("added") or []
|
|
65
|
+
removed = result.get("removed") or []
|
|
66
|
+
skipped = result.get("skipped") or {}
|
|
67
|
+
|
|
68
|
+
def _label(mid: str) -> str:
|
|
69
|
+
m = members_by_id.get(mid)
|
|
70
|
+
if not m:
|
|
71
|
+
return mid
|
|
72
|
+
u = m.get("user") or {}
|
|
73
|
+
return u.get("full_name") or u.get("email") or mid
|
|
74
|
+
|
|
75
|
+
if added:
|
|
76
|
+
console.print(f"[green]+ added[/green] {', '.join(_label(x) for x in added)}")
|
|
77
|
+
if removed:
|
|
78
|
+
console.print(f"[red]- removed[/red] {', '.join(_label(x) for x in removed)}")
|
|
79
|
+
for bucket_key, color, noun in (
|
|
80
|
+
("already_assigned", "dim", "already assigned"),
|
|
81
|
+
("not_assigned", "dim", "not assigned"),
|
|
82
|
+
("not_in_org", "yellow", "not in org"),
|
|
83
|
+
("invalid_role", "yellow", "invalid"),
|
|
84
|
+
):
|
|
85
|
+
items = skipped.get(bucket_key) or []
|
|
86
|
+
if items:
|
|
87
|
+
console.print(f"[{color}]· {noun}: {', '.join(_label(x) for x in items)}[/{color}]")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _bulk_set(
|
|
91
|
+
client: CrowdTimeClient,
|
|
92
|
+
manager_id: str,
|
|
93
|
+
*,
|
|
94
|
+
add: List[str],
|
|
95
|
+
remove: List[str],
|
|
96
|
+
members: list[dict],
|
|
97
|
+
output_json: bool,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Call the bulk-set endpoint and render the result."""
|
|
100
|
+
if not add and not remove:
|
|
101
|
+
console.print("[dim]Nothing to change.[/dim]")
|
|
102
|
+
return
|
|
103
|
+
members_by_id = {m["id"]: m for m in members}
|
|
104
|
+
try:
|
|
105
|
+
result = client.post("/manager-assignments/bulk-set/", data={
|
|
106
|
+
"manager": manager_id,
|
|
107
|
+
"add": add,
|
|
108
|
+
"remove": remove,
|
|
109
|
+
})
|
|
110
|
+
except APIError as e:
|
|
111
|
+
format_error(e.message)
|
|
112
|
+
raise typer.Exit(1)
|
|
113
|
+
if output_json:
|
|
114
|
+
print_json(result)
|
|
115
|
+
return
|
|
116
|
+
added_n = len(result.get("added") or [])
|
|
117
|
+
removed_n = len(result.get("removed") or [])
|
|
118
|
+
if added_n or removed_n:
|
|
119
|
+
parts = []
|
|
120
|
+
if added_n:
|
|
121
|
+
parts.append(f"{added_n} added")
|
|
122
|
+
if removed_n:
|
|
123
|
+
parts.append(f"{removed_n} removed")
|
|
124
|
+
format_success("; ".join(parts))
|
|
125
|
+
_render_bulk_result(result, members_by_id=members_by_id)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.command("assign")
|
|
129
|
+
def assign(
|
|
130
|
+
manager: str = typer.Argument(..., help="Manager (email, user ID, or membership ID)."),
|
|
131
|
+
members: Optional[List[str]] = typer.Argument(None, help="One or more members to assign (email, user ID, or membership ID)."),
|
|
132
|
+
assign_all: bool = typer.Option(False, "--all", help="Assign every active member of the organization."),
|
|
133
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Assign one or more members as direct reports to a manager.
|
|
136
|
+
|
|
137
|
+
Admin+ only. The member's hours become visible to the manager across
|
|
138
|
+
all projects — no per-project PM assignment needed.
|
|
139
|
+
|
|
140
|
+
Examples:
|
|
141
|
+
ct team assign alice@acme.com bob@acme.com
|
|
142
|
+
ct team assign alice@acme.com bob@acme.com carol@acme.com dave@acme.com
|
|
143
|
+
ct team assign alice@acme.com --all
|
|
144
|
+
"""
|
|
145
|
+
if bool(members) == assign_all:
|
|
146
|
+
format_error(
|
|
147
|
+
"Pass one or more members, OR use --all. Not both, not neither."
|
|
148
|
+
if members and assign_all
|
|
149
|
+
else "Specify members to assign, or pass --all."
|
|
150
|
+
)
|
|
151
|
+
raise typer.Exit(2)
|
|
152
|
+
|
|
153
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
154
|
+
try:
|
|
155
|
+
all_members = _members_list(client)
|
|
156
|
+
manager_m = _resolve_membership(client, manager, members=all_members)
|
|
157
|
+
except APIError as e:
|
|
158
|
+
format_error(e.message)
|
|
159
|
+
raise typer.Exit(1)
|
|
160
|
+
|
|
161
|
+
if assign_all:
|
|
162
|
+
add_ids = [
|
|
163
|
+
m["id"] for m in all_members
|
|
164
|
+
if m.get("is_active", True) and m["id"] != manager_m["id"]
|
|
165
|
+
]
|
|
166
|
+
else:
|
|
167
|
+
try:
|
|
168
|
+
resolved = [
|
|
169
|
+
_resolve_membership(client, ref, members=all_members)
|
|
170
|
+
for ref in members
|
|
171
|
+
]
|
|
172
|
+
except APIError as e:
|
|
173
|
+
format_error(e.message)
|
|
174
|
+
raise typer.Exit(1)
|
|
175
|
+
add_ids = [m["id"] for m in resolved]
|
|
176
|
+
|
|
177
|
+
_bulk_set(
|
|
178
|
+
client, manager_m["id"],
|
|
179
|
+
add=add_ids, remove=[],
|
|
180
|
+
members=all_members, output_json=output_json,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@app.command("unassign")
|
|
185
|
+
def unassign(
|
|
186
|
+
manager: str = typer.Argument(..., help="Manager (email, user ID, or membership ID)."),
|
|
187
|
+
members: Optional[List[str]] = typer.Argument(None, help="One or more direct reports to remove. Mutually exclusive with --all."),
|
|
188
|
+
unassign_all: bool = typer.Option(False, "--all", help="Remove every current direct report of this manager."),
|
|
189
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Remove one or more direct-report assignments from a manager.
|
|
192
|
+
|
|
193
|
+
Admin+ only.
|
|
194
|
+
|
|
195
|
+
Examples:
|
|
196
|
+
ct team unassign alice@acme.com bob@acme.com
|
|
197
|
+
ct team unassign alice@acme.com --all
|
|
198
|
+
"""
|
|
199
|
+
if bool(members) == unassign_all:
|
|
200
|
+
format_error(
|
|
201
|
+
"Pass one or more members, OR use --all. Not both, not neither."
|
|
202
|
+
if members and unassign_all
|
|
203
|
+
else "Specify members to unassign, or pass --all."
|
|
204
|
+
)
|
|
205
|
+
raise typer.Exit(2)
|
|
206
|
+
|
|
207
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
208
|
+
try:
|
|
209
|
+
all_members = _members_list(client)
|
|
210
|
+
manager_m = _resolve_membership(client, manager, members=all_members)
|
|
211
|
+
except APIError as e:
|
|
212
|
+
format_error(e.message)
|
|
213
|
+
raise typer.Exit(1)
|
|
214
|
+
|
|
215
|
+
if unassign_all:
|
|
216
|
+
try:
|
|
217
|
+
remove_ids = list(_current_direct_report_ids(client, manager_m["id"]))
|
|
218
|
+
except APIError as e:
|
|
219
|
+
format_error(e.message)
|
|
220
|
+
raise typer.Exit(1)
|
|
221
|
+
else:
|
|
222
|
+
try:
|
|
223
|
+
resolved = [
|
|
224
|
+
_resolve_membership(client, ref, members=all_members)
|
|
225
|
+
for ref in members
|
|
226
|
+
]
|
|
227
|
+
except APIError as e:
|
|
228
|
+
format_error(e.message)
|
|
229
|
+
raise typer.Exit(1)
|
|
230
|
+
remove_ids = [m["id"] for m in resolved]
|
|
231
|
+
|
|
232
|
+
_bulk_set(
|
|
233
|
+
client, manager_m["id"],
|
|
234
|
+
add=[], remove=remove_ids,
|
|
235
|
+
members=all_members, output_json=output_json,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@app.command("set")
|
|
240
|
+
def set_reports(
|
|
241
|
+
manager: str = typer.Argument(..., help="Manager (email, user ID, or membership ID)."),
|
|
242
|
+
members: List[str] = typer.Option(
|
|
243
|
+
[], "--member", "-m",
|
|
244
|
+
help="Member to include as a direct report. Repeat for each. Pass zero to clear all.",
|
|
245
|
+
),
|
|
246
|
+
all_members: bool = typer.Option(False, "--all", help="Set the direct reports to every active org member."),
|
|
247
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
248
|
+
) -> None:
|
|
249
|
+
"""Declaratively set the full list of direct reports for a manager.
|
|
250
|
+
|
|
251
|
+
Computes the diff against the current assignments and applies it in one
|
|
252
|
+
atomic call. Members not in the list are unassigned; new members are
|
|
253
|
+
added. Admin+ only.
|
|
254
|
+
|
|
255
|
+
Examples:
|
|
256
|
+
ct team set alice@acme.com --member bob@acme.com --member carol@acme.com
|
|
257
|
+
ct team set alice@acme.com --all # replace with every org member
|
|
258
|
+
ct team set alice@acme.com # clear all reports (no --member flags)
|
|
259
|
+
"""
|
|
260
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
all_active = _members_list(client)
|
|
264
|
+
manager_m = _resolve_membership(client, manager, members=all_active)
|
|
265
|
+
except APIError as e:
|
|
266
|
+
format_error(e.message)
|
|
267
|
+
raise typer.Exit(1)
|
|
268
|
+
|
|
269
|
+
if all_members:
|
|
270
|
+
if members:
|
|
271
|
+
format_error("Pass --member or --all, not both.")
|
|
272
|
+
raise typer.Exit(2)
|
|
273
|
+
desired = {
|
|
274
|
+
m["id"] for m in all_active
|
|
275
|
+
if m.get("is_active", True) and m["id"] != manager_m["id"]
|
|
276
|
+
}
|
|
277
|
+
else:
|
|
278
|
+
try:
|
|
279
|
+
desired = {
|
|
280
|
+
_resolve_membership(client, ref, members=all_active)["id"]
|
|
281
|
+
for ref in members
|
|
282
|
+
}
|
|
283
|
+
except APIError as e:
|
|
284
|
+
format_error(e.message)
|
|
285
|
+
raise typer.Exit(1)
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
current = _current_direct_report_ids(client, manager_m["id"])
|
|
289
|
+
except APIError as e:
|
|
290
|
+
format_error(e.message)
|
|
291
|
+
raise typer.Exit(1)
|
|
292
|
+
|
|
293
|
+
add_ids = sorted(desired - current)
|
|
294
|
+
remove_ids = sorted(current - desired)
|
|
295
|
+
|
|
296
|
+
_bulk_set(
|
|
297
|
+
client, manager_m["id"],
|
|
298
|
+
add=add_ids, remove=remove_ids,
|
|
299
|
+
members=all_active, output_json=output_json,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@app.command("list")
|
|
304
|
+
def list_assignments(
|
|
305
|
+
manager: Optional[str] = typer.Option(None, "--manager", "-m", help="Filter to a manager (ID, user ID, or email)."),
|
|
306
|
+
member: Optional[str] = typer.Option(None, "--member", help="Filter to a member (ID, user ID, or email)."),
|
|
307
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
308
|
+
) -> None:
|
|
309
|
+
"""List manager→direct-report assignments.
|
|
310
|
+
|
|
311
|
+
Admin+ sees all assignments in the org. Other roles see only rows where
|
|
312
|
+
they are the manager.
|
|
313
|
+
"""
|
|
314
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
315
|
+
|
|
316
|
+
params: dict = {}
|
|
317
|
+
try:
|
|
318
|
+
if manager:
|
|
319
|
+
m = _resolve_membership(client, manager)
|
|
320
|
+
params["manager"] = m["id"]
|
|
321
|
+
if member:
|
|
322
|
+
m = _resolve_membership(client, member)
|
|
323
|
+
params["member"] = m["id"]
|
|
324
|
+
except APIError as e:
|
|
325
|
+
format_error(e.message)
|
|
326
|
+
raise typer.Exit(1)
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
data = client.get("/manager-assignments/", params=params)
|
|
330
|
+
except APIError as e:
|
|
331
|
+
format_error(e.message)
|
|
332
|
+
raise typer.Exit(1)
|
|
333
|
+
|
|
334
|
+
rows = extract_results(data)
|
|
335
|
+
|
|
336
|
+
if output_json:
|
|
337
|
+
print_json(rows)
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
if not rows:
|
|
341
|
+
console.print("[dim]No direct-report assignments.[/dim]")
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
table = Table(show_header=True, header_style="bold")
|
|
345
|
+
table.add_column("Assignment ID", style="dim")
|
|
346
|
+
table.add_column("Manager")
|
|
347
|
+
table.add_column("Manager Email", style="dim")
|
|
348
|
+
table.add_column("Direct Report")
|
|
349
|
+
table.add_column("Report Email", style="dim")
|
|
350
|
+
|
|
351
|
+
for row in rows:
|
|
352
|
+
table.add_row(
|
|
353
|
+
row.get("id", ""),
|
|
354
|
+
row.get("manager_name") or "-",
|
|
355
|
+
row.get("manager_email") or "-",
|
|
356
|
+
row.get("member_name") or "-",
|
|
357
|
+
row.get("member_email") or "-",
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
console.print(table)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@app.command("reports")
|
|
364
|
+
def reports(
|
|
365
|
+
manager: Optional[str] = typer.Option(None, "--manager", "-m", help="Manager to list reports for. Defaults to you."),
|
|
366
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
367
|
+
) -> None:
|
|
368
|
+
"""Show direct reports of a manager (defaults to the current user).
|
|
369
|
+
|
|
370
|
+
Examples:
|
|
371
|
+
ct team reports
|
|
372
|
+
ct team reports --manager alice@acme.com
|
|
373
|
+
"""
|
|
374
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
375
|
+
|
|
376
|
+
params: dict = {}
|
|
377
|
+
if manager:
|
|
378
|
+
try:
|
|
379
|
+
m = _resolve_membership(client, manager)
|
|
380
|
+
params["manager"] = m["id"]
|
|
381
|
+
except APIError as e:
|
|
382
|
+
format_error(e.message)
|
|
383
|
+
raise typer.Exit(1)
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
data = client.get("/manager-assignments/", params=params)
|
|
387
|
+
except APIError as e:
|
|
388
|
+
format_error(e.message)
|
|
389
|
+
raise typer.Exit(1)
|
|
390
|
+
|
|
391
|
+
rows = extract_results(data)
|
|
392
|
+
|
|
393
|
+
if output_json:
|
|
394
|
+
print_json(rows)
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
if not rows:
|
|
398
|
+
console.print("[dim]No direct reports.[/dim]")
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
table = Table(show_header=True, header_style="bold")
|
|
402
|
+
table.add_column("Direct Report")
|
|
403
|
+
table.add_column("Email", style="dim")
|
|
404
|
+
table.add_column("User ID", style="dim")
|
|
405
|
+
table.add_column("Assignment ID", style="dim")
|
|
406
|
+
|
|
407
|
+
for row in rows:
|
|
408
|
+
table.add_row(
|
|
409
|
+
row.get("member_name") or "-",
|
|
410
|
+
row.get("member_email") or "-",
|
|
411
|
+
row.get("member_user_id") or "-",
|
|
412
|
+
row.get("id", ""),
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
console.print(table)
|
|
@@ -366,14 +366,22 @@ ct payroll payments --period 2026-03 --status paid
|
|
|
366
366
|
|
|
367
367
|
### Team (Direct Reports)
|
|
368
368
|
```bash
|
|
369
|
-
ct team list
|
|
370
|
-
ct team list --manager alice@company.com
|
|
371
|
-
ct team reports
|
|
372
|
-
ct team reports --manager alice@company.com
|
|
373
|
-
ct team assign alice@co.com bob@co.com
|
|
374
|
-
ct team
|
|
369
|
+
ct team list # All direct-report assignments (admin+) or your own
|
|
370
|
+
ct team list --manager alice@company.com # Filter to a specific manager
|
|
371
|
+
ct team reports # Show YOUR direct reports
|
|
372
|
+
ct team reports --manager alice@company.com # Someone else's reports (admin+)
|
|
373
|
+
ct team assign alice@co.com bob@co.com # Assign bob as a direct report of alice (admin+)
|
|
374
|
+
ct team assign alice@co.com bob@co.com carol@co.com # Assign multiple in one call
|
|
375
|
+
ct team assign alice@co.com --all # Assign every active org member as her reports
|
|
376
|
+
ct team unassign alice@co.com bob@co.com # Remove one or more reports (by member, not assignment id)
|
|
377
|
+
ct team unassign alice@co.com --all # Clear all her direct reports
|
|
378
|
+
ct team set alice@co.com --member bob@co.com --member carol@co.com # Declarative: replace the full set
|
|
379
|
+
ct team set alice@co.com # Clear all (zero --member flags)
|
|
380
|
+
ct team set alice@co.com --all # Set her reports to the full org
|
|
375
381
|
```
|
|
376
382
|
|
|
383
|
+
All mutating commands (`assign`, `unassign`, `set`) hit the atomic `bulk-set` endpoint — changes apply in a single transaction and the response reports `added` / `removed` / `skipped` buckets.
|
|
384
|
+
|
|
377
385
|
**Visibility rule**: a `manager` or `project_manager` sees time entries / timesheets / team reports only for users in their visible set, which is the union of:
|
|
378
386
|
1. **Direct reports** — members assigned via `ct team assign`.
|
|
379
387
|
2. **PM-project members** — users of projects where the caller holds `project_role = project_manager`.
|
|
@@ -2473,28 +2473,48 @@ Shows the direct reports of a manager. Defaults to the current user. Same shape
|
|
|
2473
2473
|
### ct team assign
|
|
2474
2474
|
|
|
2475
2475
|
```
|
|
2476
|
-
ct team assign MANAGER MEMBER [--json]
|
|
2476
|
+
ct team assign MANAGER MEMBER [MEMBER...] [--json]
|
|
2477
|
+
ct team assign MANAGER --all [--json]
|
|
2477
2478
|
```
|
|
2478
2479
|
|
|
2479
|
-
Assigns
|
|
2480
|
+
Assigns one or more members as direct reports to `MANAGER`. Admin or owner only.
|
|
2480
2481
|
|
|
2481
|
-
Each
|
|
2482
|
+
Each positional accepts an email, user ID, or membership ID. `--all` assigns every active member of the organization (excluding the manager themselves). Mutually exclusive with positional members.
|
|
2482
2483
|
|
|
2483
2484
|
- Manager must have role `project_manager`, `manager`, `admin`, or `owner`.
|
|
2484
|
-
-
|
|
2485
|
-
-
|
|
2485
|
+
- Members must be in the same organization.
|
|
2486
|
+
- Already-assigned members are silently skipped (reported in `skipped.already_assigned`).
|
|
2486
2487
|
|
|
2487
|
-
Endpoint: `POST /manager-assignments/`
|
|
2488
|
+
Endpoint: `POST /manager-assignments/bulk-set/` with `{manager, add: [ids]}`.
|
|
2488
2489
|
|
|
2489
2490
|
### ct team unassign
|
|
2490
2491
|
|
|
2491
2492
|
```
|
|
2492
|
-
ct team unassign
|
|
2493
|
+
ct team unassign MANAGER MEMBER [MEMBER...] [--json]
|
|
2494
|
+
ct team unassign MANAGER --all [--json]
|
|
2493
2495
|
```
|
|
2494
2496
|
|
|
2495
|
-
Removes
|
|
2497
|
+
Removes one or more direct-report assignments from `MANAGER`. Admin or owner only.
|
|
2496
2498
|
|
|
2497
|
-
|
|
2499
|
+
Each positional is a member (email, user ID, or membership ID) — **not** an assignment ID. `--all` clears every current direct report for this manager.
|
|
2500
|
+
|
|
2501
|
+
Endpoint: `POST /manager-assignments/bulk-set/` with `{manager, remove: [ids]}`.
|
|
2502
|
+
|
|
2503
|
+
### ct team set
|
|
2504
|
+
|
|
2505
|
+
```
|
|
2506
|
+
ct team set MANAGER [--member EMAIL]... [--all] [--json]
|
|
2507
|
+
```
|
|
2508
|
+
|
|
2509
|
+
Declaratively sets the full list of direct reports for `MANAGER`. The CLI fetches the current assignments, computes the diff against the desired set, and applies both adds and removes in a single atomic call. Admin or owner only.
|
|
2510
|
+
|
|
2511
|
+
Examples:
|
|
2512
|
+
|
|
2513
|
+
- `ct team set alice@co.com --member bob --member carol` — reports are exactly {bob, carol}; anyone else currently assigned is unassigned.
|
|
2514
|
+
- `ct team set alice@co.com` — clear all (zero `--member` flags).
|
|
2515
|
+
- `ct team set alice@co.com --all` — reports are every active org member.
|
|
2516
|
+
|
|
2517
|
+
Endpoint: `POST /manager-assignments/bulk-set/` with both `add` and `remove` computed client-side.
|
|
2498
2518
|
|
|
2499
2519
|
### Visibility semantics
|
|
2500
2520
|
|
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
"""Team commands: manage manager → direct-report assignments.
|
|
2
|
-
|
|
3
|
-
Mirrors Harvest's manager model. Admins assign members as direct reports
|
|
4
|
-
to a manager; the manager can see those members' hours across any project
|
|
5
|
-
without being added as a PM to each project individually.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
from typing import Optional
|
|
11
|
-
|
|
12
|
-
import typer
|
|
13
|
-
from rich.console import Console
|
|
14
|
-
from rich.table import Table
|
|
15
|
-
|
|
16
|
-
from ..client import APIError, CrowdTimeClient
|
|
17
|
-
from ..formatters import extract_results, format_error, format_success, print_json
|
|
18
|
-
|
|
19
|
-
app = typer.Typer(name="team", help="Manage direct-report assignments.")
|
|
20
|
-
console = Console()
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _resolve_membership(client: CrowdTimeClient, ref: str) -> dict:
|
|
24
|
-
"""Resolve a membership by UUID, user UUID, or email. Returns the membership dict."""
|
|
25
|
-
data = client.get("/members/")
|
|
26
|
-
members_list = extract_results(data)
|
|
27
|
-
|
|
28
|
-
ref_lower = ref.lower()
|
|
29
|
-
# Exact email match
|
|
30
|
-
for m in members_list:
|
|
31
|
-
if (m.get("user", {}).get("email") or "").lower() == ref_lower:
|
|
32
|
-
return m
|
|
33
|
-
# Membership ID match
|
|
34
|
-
for m in members_list:
|
|
35
|
-
if m.get("id") == ref:
|
|
36
|
-
return m
|
|
37
|
-
# User ID match
|
|
38
|
-
for m in members_list:
|
|
39
|
-
if m.get("user", {}).get("id") == ref:
|
|
40
|
-
return m
|
|
41
|
-
|
|
42
|
-
raise APIError(
|
|
43
|
-
f"No member found matching '{ref}'. Use email, user ID, or membership ID.",
|
|
44
|
-
status_code=404,
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
@app.command("assign")
|
|
49
|
-
def assign(
|
|
50
|
-
manager: str = typer.Argument(..., help="Manager (email, user ID, or membership ID)."),
|
|
51
|
-
member: str = typer.Argument(..., help="Member to assign (email, user ID, or membership ID)."),
|
|
52
|
-
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
53
|
-
) -> None:
|
|
54
|
-
"""Assign a member as a direct report to a manager.
|
|
55
|
-
|
|
56
|
-
Admin+ only. The member's hours become visible to the manager across
|
|
57
|
-
all projects — no per-project PM assignment needed.
|
|
58
|
-
|
|
59
|
-
Examples:
|
|
60
|
-
ct team assign alice@acme.com bob@acme.com
|
|
61
|
-
ct team assign <manager-membership-id> <member-membership-id>
|
|
62
|
-
"""
|
|
63
|
-
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
64
|
-
|
|
65
|
-
try:
|
|
66
|
-
manager_m = _resolve_membership(client, manager)
|
|
67
|
-
member_m = _resolve_membership(client, member)
|
|
68
|
-
except APIError as e:
|
|
69
|
-
format_error(e.message)
|
|
70
|
-
raise typer.Exit(1)
|
|
71
|
-
|
|
72
|
-
try:
|
|
73
|
-
data = client.post("/manager-assignments/", data={
|
|
74
|
-
"manager": manager_m["id"],
|
|
75
|
-
"member": member_m["id"],
|
|
76
|
-
})
|
|
77
|
-
except APIError as e:
|
|
78
|
-
format_error(e.message)
|
|
79
|
-
raise typer.Exit(1)
|
|
80
|
-
|
|
81
|
-
if output_json:
|
|
82
|
-
print_json(data)
|
|
83
|
-
return
|
|
84
|
-
manager_name = manager_m.get("user", {}).get("full_name") or manager_m.get("user", {}).get("email")
|
|
85
|
-
member_name = member_m.get("user", {}).get("full_name") or member_m.get("user", {}).get("email")
|
|
86
|
-
format_success(f"{member_name} now reports to {manager_name}.")
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
@app.command("unassign")
|
|
90
|
-
def unassign(
|
|
91
|
-
assignment_id: str = typer.Argument(..., help="Manager assignment ID (from `ct team list`)."),
|
|
92
|
-
) -> None:
|
|
93
|
-
"""Remove a direct-report assignment.
|
|
94
|
-
|
|
95
|
-
Admin+ only.
|
|
96
|
-
"""
|
|
97
|
-
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
98
|
-
try:
|
|
99
|
-
client.delete(f"/manager-assignments/{assignment_id}/")
|
|
100
|
-
except APIError as e:
|
|
101
|
-
format_error(e.message)
|
|
102
|
-
raise typer.Exit(1)
|
|
103
|
-
format_success(f"Assignment {assignment_id} removed.")
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
@app.command("list")
|
|
107
|
-
def list_assignments(
|
|
108
|
-
manager: Optional[str] = typer.Option(None, "--manager", "-m", help="Filter to a manager (ID, user ID, or email)."),
|
|
109
|
-
member: Optional[str] = typer.Option(None, "--member", help="Filter to a member (ID, user ID, or email)."),
|
|
110
|
-
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
111
|
-
) -> None:
|
|
112
|
-
"""List manager→direct-report assignments.
|
|
113
|
-
|
|
114
|
-
Admin+ sees all assignments in the org. Other roles see only rows where
|
|
115
|
-
they are the manager.
|
|
116
|
-
"""
|
|
117
|
-
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
118
|
-
|
|
119
|
-
params: dict = {}
|
|
120
|
-
try:
|
|
121
|
-
if manager:
|
|
122
|
-
m = _resolve_membership(client, manager)
|
|
123
|
-
params["manager"] = m["id"]
|
|
124
|
-
if member:
|
|
125
|
-
m = _resolve_membership(client, member)
|
|
126
|
-
params["member"] = m["id"]
|
|
127
|
-
except APIError as e:
|
|
128
|
-
format_error(e.message)
|
|
129
|
-
raise typer.Exit(1)
|
|
130
|
-
|
|
131
|
-
try:
|
|
132
|
-
data = client.get("/manager-assignments/", params=params)
|
|
133
|
-
except APIError as e:
|
|
134
|
-
format_error(e.message)
|
|
135
|
-
raise typer.Exit(1)
|
|
136
|
-
|
|
137
|
-
rows = extract_results(data)
|
|
138
|
-
|
|
139
|
-
if output_json:
|
|
140
|
-
print_json(rows)
|
|
141
|
-
return
|
|
142
|
-
|
|
143
|
-
if not rows:
|
|
144
|
-
console.print("[dim]No direct-report assignments.[/dim]")
|
|
145
|
-
return
|
|
146
|
-
|
|
147
|
-
table = Table(show_header=True, header_style="bold")
|
|
148
|
-
table.add_column("Assignment ID", style="dim")
|
|
149
|
-
table.add_column("Manager")
|
|
150
|
-
table.add_column("Manager Email", style="dim")
|
|
151
|
-
table.add_column("Direct Report")
|
|
152
|
-
table.add_column("Report Email", style="dim")
|
|
153
|
-
|
|
154
|
-
for row in rows:
|
|
155
|
-
table.add_row(
|
|
156
|
-
row.get("id", ""),
|
|
157
|
-
row.get("manager_name") or "-",
|
|
158
|
-
row.get("manager_email") or "-",
|
|
159
|
-
row.get("member_name") or "-",
|
|
160
|
-
row.get("member_email") or "-",
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
console.print(table)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
@app.command("reports")
|
|
167
|
-
def reports(
|
|
168
|
-
manager: Optional[str] = typer.Option(None, "--manager", "-m", help="Manager to list reports for. Defaults to you."),
|
|
169
|
-
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
170
|
-
) -> None:
|
|
171
|
-
"""Show direct reports of a manager (defaults to the current user).
|
|
172
|
-
|
|
173
|
-
Examples:
|
|
174
|
-
ct team reports
|
|
175
|
-
ct team reports --manager alice@acme.com
|
|
176
|
-
"""
|
|
177
|
-
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
178
|
-
|
|
179
|
-
params: dict = {}
|
|
180
|
-
if manager:
|
|
181
|
-
try:
|
|
182
|
-
m = _resolve_membership(client, manager)
|
|
183
|
-
params["manager"] = m["id"]
|
|
184
|
-
except APIError as e:
|
|
185
|
-
format_error(e.message)
|
|
186
|
-
raise typer.Exit(1)
|
|
187
|
-
|
|
188
|
-
try:
|
|
189
|
-
data = client.get("/manager-assignments/", params=params)
|
|
190
|
-
except APIError as e:
|
|
191
|
-
format_error(e.message)
|
|
192
|
-
raise typer.Exit(1)
|
|
193
|
-
|
|
194
|
-
rows = extract_results(data)
|
|
195
|
-
|
|
196
|
-
if output_json:
|
|
197
|
-
print_json(rows)
|
|
198
|
-
return
|
|
199
|
-
|
|
200
|
-
if not rows:
|
|
201
|
-
console.print("[dim]No direct reports.[/dim]")
|
|
202
|
-
return
|
|
203
|
-
|
|
204
|
-
table = Table(show_header=True, header_style="bold")
|
|
205
|
-
table.add_column("Direct Report")
|
|
206
|
-
table.add_column("Email", style="dim")
|
|
207
|
-
table.add_column("User ID", style="dim")
|
|
208
|
-
table.add_column("Assignment ID", style="dim")
|
|
209
|
-
|
|
210
|
-
for row in rows:
|
|
211
|
-
table.add_row(
|
|
212
|
-
row.get("member_name") or "-",
|
|
213
|
-
row.get("member_email") or "-",
|
|
214
|
-
row.get("member_user_id") or "-",
|
|
215
|
-
row.get("id", ""),
|
|
216
|
-
)
|
|
217
|
-
|
|
218
|
-
console.print(table)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|