crowdtime-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,134 @@
1
+ """Organization commands: list, switch, members, invite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from ..client import APIError, CrowdTimeClient
12
+ from ..config import get_config
13
+ from ..formatters import format_error, format_members_table, format_success, print_json
14
+ from ..models import Organization
15
+
16
+ app = typer.Typer(name="org", help="Manage organizations.")
17
+ console = Console()
18
+
19
+
20
+ @app.command("list")
21
+ def list_orgs(
22
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
23
+ ) -> None:
24
+ """List organizations you belong to."""
25
+ client = CrowdTimeClient(require_auth=True, require_org=False)
26
+
27
+ try:
28
+ data = client.get("/api/v1/organizations/", org_scoped=False)
29
+ orgs_list = data if isinstance(data, list) else data.get("results", [])
30
+ orgs = [Organization(**item) for item in orgs_list]
31
+
32
+ if output_json:
33
+ print_json(orgs)
34
+ return
35
+
36
+ if not orgs:
37
+ console.print("[dim]No organizations found.[/dim]")
38
+ return
39
+
40
+ current_slug = client.config.organization
41
+
42
+ table = Table(show_header=True, header_style="bold")
43
+ table.add_column("", width=3)
44
+ table.add_column("Name", width=20)
45
+ table.add_column("Slug", width=15)
46
+ table.add_column("Role", width=10)
47
+ table.add_column("Members", justify="right", width=8)
48
+
49
+ for org in orgs:
50
+ marker = "[green]*[/green]" if org.slug == current_slug else " "
51
+ table.add_row(
52
+ marker,
53
+ org.name,
54
+ org.slug,
55
+ org.role or "",
56
+ str(org.member_count or ""),
57
+ )
58
+
59
+ console.print(table)
60
+ if current_slug:
61
+ console.print(f"\n[dim]* = current organization ({current_slug})[/dim]")
62
+ except APIError as e:
63
+ format_error(e.message)
64
+ raise typer.Exit(1)
65
+
66
+
67
+ @app.command("switch")
68
+ def switch_org(
69
+ slug: str = typer.Argument(..., help="Organization slug to switch to."),
70
+ ) -> None:
71
+ """Switch the active organization."""
72
+ # Verify the org exists
73
+ client = CrowdTimeClient(require_auth=True, require_org=False)
74
+
75
+ try:
76
+ data = client.get("/api/v1/organizations/", org_scoped=False)
77
+ orgs_list = data if isinstance(data, list) else data.get("results", [])
78
+ slugs = [o.get("slug", "") for o in orgs_list]
79
+
80
+ if slug not in slugs:
81
+ format_error(f"Organization '{slug}' not found. Available: {', '.join(slugs)}")
82
+ raise typer.Exit(1)
83
+
84
+ except APIError as e:
85
+ format_error(e.message)
86
+ raise typer.Exit(1)
87
+
88
+ config = get_config()
89
+ config.set("defaults.organization", slug)
90
+ format_success(f"Switched to organization '{slug}'")
91
+
92
+
93
+ @app.command()
94
+ def members(
95
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
96
+ ) -> None:
97
+ """List members of the current organization."""
98
+ client = CrowdTimeClient(require_auth=True, require_org=True)
99
+
100
+ try:
101
+ data = client.get("/members/")
102
+ members_list = data if isinstance(data, list) else data.get("results", [])
103
+
104
+ if output_json:
105
+ print_json(members_list)
106
+ else:
107
+ format_members_table(members_list)
108
+ except APIError as e:
109
+ format_error(e.message)
110
+ raise typer.Exit(1)
111
+
112
+
113
+ @app.command()
114
+ def invite(
115
+ email: str = typer.Argument(..., help="Email address to invite."),
116
+ role: str = typer.Option("member", "--role", "-r", help="Role: admin, manager, member."),
117
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
118
+ ) -> None:
119
+ """Invite a member to the current organization."""
120
+ client = CrowdTimeClient(require_auth=True, require_org=True)
121
+
122
+ try:
123
+ data = client.post("/members/invite/", data={
124
+ "email": email,
125
+ "role": role,
126
+ })
127
+
128
+ if output_json:
129
+ print_json(data)
130
+ else:
131
+ format_success(f"Invitation sent to {email} (role: {role})")
132
+ except APIError as e:
133
+ format_error(e.message)
134
+ raise typer.Exit(1)
@@ -0,0 +1,175 @@
1
+ """Project commands: list, show, create, archive, switch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from ..client import APIError, CrowdTimeClient
11
+ from ..config import get_config
12
+ from ..formatters import format_error, format_projects_table, format_success, print_json
13
+ from ..models import Project
14
+
15
+ app = typer.Typer(name="projects", help="Manage projects.")
16
+ console = Console()
17
+
18
+
19
+ def _resolve_or_create_client(api_client: CrowdTimeClient, client_name: str) -> str:
20
+ """Look up a client by name; create one if it doesn't exist. Returns the client UUID."""
21
+ # Search for existing clients matching the name
22
+ data = api_client.get("/clients/", params={"search": client_name})
23
+ clients_list = data if isinstance(data, list) else data.get("results", [])
24
+
25
+ # Exact match (case-insensitive)
26
+ for c in clients_list:
27
+ if c.get("name", "").lower() == client_name.lower():
28
+ return c["id"]
29
+
30
+ # No match — auto-create the client
31
+ console.print(f"[dim]Client '{client_name}' not found — creating it...[/dim]")
32
+ new_client = api_client.post("/clients/", data={"name": client_name})
33
+ return new_client["id"]
34
+
35
+
36
+ @app.command("list")
37
+ def list_projects(
38
+ archived: bool = typer.Option(False, "--archived", "-a", help="Include archived projects."),
39
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
40
+ ) -> None:
41
+ """List all projects in the current organization."""
42
+ client = CrowdTimeClient(require_auth=True, require_org=True)
43
+
44
+ params: dict = {}
45
+ if archived:
46
+ params["status"] = "archived"
47
+
48
+ try:
49
+ data = client.get("/projects/", params=params)
50
+ projects_list = data if isinstance(data, list) else data.get("results", [])
51
+ projects = [Project(**item) for item in projects_list]
52
+
53
+ if output_json:
54
+ print_json(projects)
55
+ else:
56
+ # Show which is default
57
+ default_proj = client.config.default_project
58
+ format_projects_table(projects)
59
+ if default_proj:
60
+ console.print(f"\n[dim]Default project: {default_proj}[/dim]")
61
+ except APIError as e:
62
+ format_error(e.message)
63
+ raise typer.Exit(1)
64
+
65
+
66
+ @app.command()
67
+ def show(
68
+ project_id: str = typer.Argument(..., help="Project ID or slug."),
69
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
70
+ ) -> None:
71
+ """Show details for a specific project."""
72
+ client = CrowdTimeClient(require_auth=True, require_org=True)
73
+
74
+ try:
75
+ data = client.get(f"/projects/{project_id}/")
76
+ project = Project(**data)
77
+
78
+ if output_json:
79
+ print_json(project)
80
+ else:
81
+ console.print(f"\n[bold]{project.name}[/bold]")
82
+ console.print(f" ID: {project.id}")
83
+ console.print(f" Code: {project.code}")
84
+ console.print(f" Client: {project.client or '-'}")
85
+ console.print(f" Status: {project.status}")
86
+ console.print(f" Billable: {'Yes' if project.is_billable else 'No'}")
87
+ if project.budget_hours:
88
+ console.print(f" Budget: {project.budget_hours}h")
89
+ if project.description:
90
+ console.print(f" Notes: {project.description}")
91
+ console.print()
92
+ except APIError as e:
93
+ if e.status_code == 404:
94
+ format_error(f"Project '{project_id}' not found.")
95
+ else:
96
+ format_error(e.message)
97
+ raise typer.Exit(1)
98
+
99
+
100
+ @app.command()
101
+ def create(
102
+ name: str = typer.Argument(..., help="Project name."),
103
+ client_name: Optional[str] = typer.Option(None, "--client", "-c", help="Client name."),
104
+ billable: bool = typer.Option(True, "--billable/--no-billable", "-b/-B",
105
+ help="Mark as billable."),
106
+ color: Optional[str] = typer.Option(None, "--color", help="Project color (hex)."),
107
+ budget: Optional[float] = typer.Option(None, "--budget", help="Budget in hours."),
108
+ code: Optional[str] = typer.Option(None, "--code", help="Short project code."),
109
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
110
+ ) -> None:
111
+ """Create a new project."""
112
+ api_client = CrowdTimeClient(require_auth=True, require_org=True)
113
+
114
+ payload: dict = {"name": name, "is_billable": billable}
115
+ if color:
116
+ payload["color"] = color
117
+ if budget:
118
+ payload["budget_hours"] = budget
119
+ if code:
120
+ payload["code"] = code
121
+
122
+ # Resolve client name to UUID (or auto-create)
123
+ if client_name:
124
+ try:
125
+ client_id = _resolve_or_create_client(api_client, client_name)
126
+ payload["client"] = str(client_id)
127
+ except APIError as e:
128
+ format_error(f"Failed to resolve client '{client_name}': {e.message}")
129
+ raise typer.Exit(1)
130
+
131
+ try:
132
+ data = api_client.post("/projects/", data=payload)
133
+ project = Project(**data)
134
+
135
+ if output_json:
136
+ print_json(project)
137
+ else:
138
+ format_success(f"Project '{project.name}' created (ID: {project.id})")
139
+ except APIError as e:
140
+ format_error(e.message)
141
+ raise typer.Exit(1)
142
+
143
+
144
+ @app.command()
145
+ def archive(
146
+ project_id: str = typer.Argument(..., help="Project ID or slug to archive."),
147
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
148
+ ) -> None:
149
+ """Archive a project."""
150
+ if not force:
151
+ if not typer.confirm(f"Archive project '{project_id}'?"):
152
+ console.print("[dim]Cancelled.[/dim]")
153
+ return
154
+
155
+ client = CrowdTimeClient(require_auth=True, require_org=True)
156
+
157
+ try:
158
+ data = client.patch(f"/projects/{project_id}/", data={"status": "archived"})
159
+ format_success(f"Project '{project_id}' archived.")
160
+ except APIError as e:
161
+ if e.status_code == 404:
162
+ format_error(f"Project '{project_id}' not found.")
163
+ else:
164
+ format_error(e.message)
165
+ raise typer.Exit(1)
166
+
167
+
168
+ @app.command("switch")
169
+ def switch_project(
170
+ slug: str = typer.Argument(..., help="Project slug or name to set as default."),
171
+ ) -> None:
172
+ """Set a project as the default for new entries."""
173
+ config = get_config()
174
+ config.set("defaults.project", slug)
175
+ format_success(f"Default project set to '{slug}'")
@@ -0,0 +1,242 @@
1
+ """Report commands: report, projects, team."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date as date_type, timedelta
6
+ from typing import Optional
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from ..client import APIError, CrowdTimeClient
12
+ from ..formatters import format_error, format_report, print_json
13
+ from ..utils import format_date, parse_date
14
+
15
+ app = typer.Typer(name="report", help="Generate time reports.")
16
+ console = Console()
17
+
18
+
19
+ def _resolve_period(
20
+ today_flag: bool = False,
21
+ yesterday_flag: bool = False,
22
+ week: bool = False,
23
+ last_week: bool = False,
24
+ month: bool = False,
25
+ from_date: str | None = None,
26
+ to_date: str | None = None,
27
+ ) -> tuple[str, str]:
28
+ """Resolve date range from flags."""
29
+ today = date_type.today()
30
+
31
+ if today_flag:
32
+ d = format_date(today)
33
+ return d, d
34
+ if yesterday_flag:
35
+ d = format_date(today - timedelta(days=1))
36
+ return d, d
37
+ if week:
38
+ start = today - timedelta(days=today.weekday())
39
+ return format_date(start), format_date(today)
40
+ if last_week:
41
+ start = today - timedelta(days=today.weekday() + 7)
42
+ end = start + timedelta(days=6)
43
+ return format_date(start), format_date(end)
44
+ if month:
45
+ start = today.replace(day=1)
46
+ return format_date(start), format_date(today)
47
+ if from_date and to_date:
48
+ try:
49
+ return format_date(parse_date(from_date)), format_date(parse_date(to_date))
50
+ except ValueError as e:
51
+ format_error(str(e))
52
+ raise typer.Exit(1)
53
+ if from_date:
54
+ try:
55
+ return format_date(parse_date(from_date)), format_date(today)
56
+ except ValueError as e:
57
+ format_error(str(e))
58
+ raise typer.Exit(1)
59
+
60
+ # Default: this week
61
+ start = today - timedelta(days=today.weekday())
62
+ return format_date(start), format_date(today)
63
+
64
+
65
+ @app.callback(invoke_without_command=True)
66
+ def report(
67
+ ctx: typer.Context,
68
+ today_flag: bool = typer.Option(False, "--today", help="Report for today."),
69
+ yesterday_flag: bool = typer.Option(False, "--yesterday", help="Report for yesterday."),
70
+ week: bool = typer.Option(False, "--week", "-w", help="Report for this week."),
71
+ last_week: bool = typer.Option(False, "--last-week", help="Report for last week."),
72
+ month: bool = typer.Option(False, "--month", "-m", help="Report for this month."),
73
+ from_date: Optional[str] = typer.Option(None, "--from", help="Start date."),
74
+ to_date: Optional[str] = typer.Option(None, "--to", help="End date."),
75
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Filter by project."),
76
+ group_by: str = typer.Option("project", "--group-by", "-g",
77
+ help="Group by: project, task, day, client."),
78
+ format_output: str = typer.Option("table", "--format", "-f",
79
+ help="Output format: table, json, csv, markdown."),
80
+ ) -> None:
81
+ """Generate a time report.
82
+
83
+ Examples:
84
+ ct report --week
85
+ ct report --month --group-by task
86
+ ct report --from 2026-03-01 --to 2026-03-10 -p myproject
87
+ """
88
+ if ctx.invoked_subcommand is not None:
89
+ return
90
+
91
+ period_from, period_to = _resolve_period(
92
+ today_flag, yesterday_flag, week, last_week, month, from_date, to_date
93
+ )
94
+
95
+ client = CrowdTimeClient(require_auth=True, require_org=True)
96
+
97
+ params: dict = {
98
+ "from": period_from,
99
+ "to": period_to,
100
+ "group_by": group_by,
101
+ }
102
+ if project:
103
+ params["project"] = project
104
+
105
+ endpoint = "/reports/project-summary/" if group_by == "project" else "/reports/time-detail/"
106
+
107
+ try:
108
+ data = client.get(endpoint, params=params)
109
+ results = data if isinstance(data, list) else data.get("results", [])
110
+
111
+ if format_output == "json":
112
+ print_json(results)
113
+ elif format_output == "csv":
114
+ _print_report_csv(results, group_by)
115
+ elif format_output == "markdown":
116
+ _print_report_markdown(results, group_by, period_from, period_to)
117
+ else:
118
+ console.print(f"\n[dim]Period: {period_from} to {period_to}[/dim]")
119
+ format_report(results, group_by=group_by)
120
+ except APIError as e:
121
+ format_error(e.message)
122
+ raise typer.Exit(1)
123
+
124
+
125
+ @app.command("projects")
126
+ def project_report(
127
+ week: bool = typer.Option(False, "--week", "-w", help="This week."),
128
+ month: bool = typer.Option(True, "--month", "-m", help="This month (default)."),
129
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
130
+ ) -> None:
131
+ """Project breakdown report."""
132
+ period_from, period_to = _resolve_period(week=week, month=month)
133
+
134
+ client = CrowdTimeClient(require_auth=True, require_org=True)
135
+
136
+ try:
137
+ data = client.get("/reports/project-summary/", params={
138
+ "from": period_from,
139
+ "to": period_to,
140
+ })
141
+ results = data if isinstance(data, list) else data.get("results", [])
142
+
143
+ if output_json:
144
+ print_json(results)
145
+ else:
146
+ console.print(f"\n[dim]Period: {period_from} to {period_to}[/dim]")
147
+ format_report(results, group_by="project")
148
+ except APIError as e:
149
+ format_error(e.message)
150
+ raise typer.Exit(1)
151
+
152
+
153
+ @app.command("team")
154
+ def team_report(
155
+ week: bool = typer.Option(False, "--week", "-w", help="This week."),
156
+ month: bool = typer.Option(True, "--month", "-m", help="This month (default)."),
157
+ member: Optional[str] = typer.Option(None, "--member", help="Filter by team member."),
158
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
159
+ ) -> None:
160
+ """Team utilization report."""
161
+ period_from, period_to = _resolve_period(week=week, month=month)
162
+
163
+ client = CrowdTimeClient(require_auth=True, require_org=True)
164
+
165
+ params: dict = {
166
+ "from": period_from,
167
+ "to": period_to,
168
+ }
169
+ if member:
170
+ params["member"] = member
171
+
172
+ try:
173
+ data = client.get("/reports/team-utilization/", params=params)
174
+ results = data if isinstance(data, list) else data.get("results", [])
175
+
176
+ if output_json:
177
+ print_json(results)
178
+ else:
179
+ console.print(f"\n[dim]Team Report: {period_from} to {period_to}[/dim]")
180
+ _print_team_table(results)
181
+ except APIError as e:
182
+ format_error(e.message)
183
+ raise typer.Exit(1)
184
+
185
+
186
+ def _print_team_table(data: list[dict]) -> None:
187
+ """Print team utilization table."""
188
+ from rich.table import Table
189
+ from ..utils import format_duration
190
+ from decimal import Decimal
191
+
192
+ if not data:
193
+ console.print("[dim]No data.[/dim]")
194
+ return
195
+
196
+ table = Table(show_header=True, header_style="bold", title="Team Utilization")
197
+ table.add_column("Member", width=20)
198
+ table.add_column("Hours", justify="right", width=10)
199
+ table.add_column("Billable", justify="right", width=10)
200
+ table.add_column("Utilization", justify="right", width=12)
201
+
202
+ for row in data:
203
+ hours = Decimal(str(row.get("hours", 0)))
204
+ billable = Decimal(str(row.get("billable_hours", 0)))
205
+ utilization = row.get("utilization", 0)
206
+ table.add_row(
207
+ row.get("member", ""),
208
+ format_duration(hours),
209
+ format_duration(billable),
210
+ f"{utilization}%",
211
+ )
212
+
213
+ console.print(table)
214
+
215
+
216
+ def _print_report_csv(data: list[dict], group_by: str) -> None:
217
+ """Print report as CSV."""
218
+ console.print(f"{group_by},hours,billable_hours,entries_count")
219
+ for row in data:
220
+ console.print(
221
+ f'{row.get(group_by, row.get("project", ""))},'
222
+ f'{row.get("hours", 0)},'
223
+ f'{row.get("billable_hours", 0)},'
224
+ f'{row.get("entries_count", 0)}'
225
+ )
226
+
227
+
228
+ def _print_report_markdown(data: list[dict], group_by: str, from_d: str, to_d: str) -> None:
229
+ """Print report as Markdown."""
230
+ from ..utils import format_duration
231
+ from decimal import Decimal
232
+
233
+ console.print(f"## Time Report ({from_d} to {to_d})\n")
234
+ console.print(f"| {group_by.capitalize()} | Hours | Billable | Entries |")
235
+ console.print("|---|---|---|---|")
236
+ for row in data:
237
+ hours = format_duration(Decimal(str(row.get("hours", 0))))
238
+ billable = format_duration(Decimal(str(row.get("billable_hours", 0))))
239
+ console.print(
240
+ f"| {row.get(group_by, row.get('project', ''))} "
241
+ f"| {hours} | {billable} | {row.get('entries_count', 0)} |"
242
+ )