agencycore-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.
- ac_cli/__init__.py +0 -0
- ac_cli/client.py +81 -0
- ac_cli/commands/__init__.py +0 -0
- ac_cli/commands/auth.py +102 -0
- ac_cli/commands/crm/__init__.py +172 -0
- ac_cli/commands/crm/activities.py +193 -0
- ac_cli/commands/crm/communications.py +377 -0
- ac_cli/commands/crm/companies.py +145 -0
- ac_cli/commands/crm/deals.py +201 -0
- ac_cli/commands/crm/imports.py +74 -0
- ac_cli/commands/crm/lists.py +206 -0
- ac_cli/commands/crm/people.py +154 -0
- ac_cli/commands/envoy/__init__.py +33 -0
- ac_cli/commands/envoy/dashboard.py +27 -0
- ac_cli/commands/envoy/outbox.py +186 -0
- ac_cli/commands/envoy/recipients.py +87 -0
- ac_cli/commands/envoy/sequences.py +200 -0
- ac_cli/commands/envoy/steps.py +131 -0
- ac_cli/commands/health.py +31 -0
- ac_cli/config.py +50 -0
- ac_cli/formatting.py +45 -0
- ac_cli/main.py +26 -0
- agencycore_cli-0.1.0.dist-info/METADATA +131 -0
- agencycore_cli-0.1.0.dist-info/RECORD +27 -0
- agencycore_cli-0.1.0.dist-info/WHEEL +4 -0
- agencycore_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agencycore_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
ac_cli/__init__.py
ADDED
|
File without changes
|
ac_cli/client.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Authenticated httpx client for the AgencyCore API."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from ac_cli.config import load_config, save_config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _refresh_access_token(cfg: dict) -> str:
|
|
10
|
+
"""Use stored refresh_token to obtain a new access_token from Supabase.
|
|
11
|
+
|
|
12
|
+
Persists the new tokens to config. Returns the new access_token.
|
|
13
|
+
Raises typer.Exit on failure.
|
|
14
|
+
"""
|
|
15
|
+
from supabase import create_client
|
|
16
|
+
|
|
17
|
+
supabase_url = cfg.get("supabase_url")
|
|
18
|
+
supabase_anon_key = cfg.get("supabase_anon_key")
|
|
19
|
+
refresh_token = cfg.get("refresh_token")
|
|
20
|
+
|
|
21
|
+
if not supabase_url or not supabase_anon_key or not refresh_token:
|
|
22
|
+
typer.echo("Session expired. Run `ac login` to re-authenticate.")
|
|
23
|
+
raise typer.Exit(code=1)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
sb = create_client(supabase_url, supabase_anon_key)
|
|
27
|
+
response = sb.auth.refresh_session(refresh_token)
|
|
28
|
+
except Exception as exc:
|
|
29
|
+
typer.echo(f"Token refresh failed: {exc}\nRun `ac login` to re-authenticate.")
|
|
30
|
+
raise typer.Exit(code=1) from exc
|
|
31
|
+
|
|
32
|
+
session = response.session
|
|
33
|
+
if not session:
|
|
34
|
+
typer.echo("Token refresh returned no session. Run `ac login` to re-authenticate.")
|
|
35
|
+
raise typer.Exit(code=1)
|
|
36
|
+
|
|
37
|
+
cfg["access_token"] = session.access_token
|
|
38
|
+
cfg["refresh_token"] = session.refresh_token
|
|
39
|
+
save_config(cfg)
|
|
40
|
+
return session.access_token
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _AuthClient(httpx.Client):
|
|
44
|
+
"""httpx.Client that auto-refreshes expired Supabase tokens on 401."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, cfg: dict, **kwargs: object) -> None:
|
|
47
|
+
self._cfg = cfg
|
|
48
|
+
super().__init__(**kwargs)
|
|
49
|
+
|
|
50
|
+
def send(self, request: httpx.Request, **kwargs: object) -> httpx.Response:
|
|
51
|
+
response = super().send(request, **kwargs)
|
|
52
|
+
|
|
53
|
+
if response.status_code == 401:
|
|
54
|
+
new_token = _refresh_access_token(self._cfg)
|
|
55
|
+
request.headers["Authorization"] = f"Bearer {new_token}"
|
|
56
|
+
self.headers["Authorization"] = f"Bearer {new_token}"
|
|
57
|
+
response = super().send(request, **kwargs)
|
|
58
|
+
|
|
59
|
+
return response
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_api_client() -> httpx.Client:
|
|
63
|
+
"""Return an httpx.Client with base URL and auth header from stored config.
|
|
64
|
+
|
|
65
|
+
The client auto-refreshes the Supabase access token on 401 responses.
|
|
66
|
+
Raises typer.Exit if not logged in.
|
|
67
|
+
"""
|
|
68
|
+
cfg = load_config()
|
|
69
|
+
access_token = cfg.get("access_token")
|
|
70
|
+
api_url = cfg.get("api_url")
|
|
71
|
+
|
|
72
|
+
if not access_token or not api_url:
|
|
73
|
+
typer.echo("Not logged in. Run `ac login` first.")
|
|
74
|
+
raise typer.Exit(code=1)
|
|
75
|
+
|
|
76
|
+
return _AuthClient(
|
|
77
|
+
cfg=cfg,
|
|
78
|
+
base_url=api_url,
|
|
79
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
80
|
+
timeout=30.0,
|
|
81
|
+
)
|
|
File without changes
|
ac_cli/commands/auth.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Authentication commands: login, logout, whoami."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import typer
|
|
5
|
+
from rich import print as rprint
|
|
6
|
+
from supabase import create_client
|
|
7
|
+
|
|
8
|
+
from ac_cli.client import get_api_client
|
|
9
|
+
from ac_cli.config import (
|
|
10
|
+
DEV_API_URL,
|
|
11
|
+
DEV_SUPABASE_ANON_KEY,
|
|
12
|
+
DEV_SUPABASE_URL,
|
|
13
|
+
STAGING_API_URL,
|
|
14
|
+
STAGING_SUPABASE_ANON_KEY,
|
|
15
|
+
STAGING_SUPABASE_URL,
|
|
16
|
+
clear_config,
|
|
17
|
+
save_config,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="Authentication commands")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command()
|
|
24
|
+
def login(
|
|
25
|
+
email: str = typer.Option(None, help="Supabase account email"),
|
|
26
|
+
password: str = typer.Option(None, help="Account password"),
|
|
27
|
+
supabase_url: str = typer.Option(None, help="Supabase project URL"),
|
|
28
|
+
supabase_anon_key: str = typer.Option(None, help="Supabase anonymous/public key"),
|
|
29
|
+
api_url: str = typer.Option(None, help="AgencyCore API base URL"),
|
|
30
|
+
dev: bool = typer.Option(False, "--dev", help="Use local dev environment (localhost)"),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Sign in with email and password via Supabase.
|
|
33
|
+
|
|
34
|
+
By default, connects to the staging environment. Pass --dev to use local
|
|
35
|
+
dev services (localhost API + local Supabase).
|
|
36
|
+
"""
|
|
37
|
+
if dev:
|
|
38
|
+
api_url = api_url or DEV_API_URL
|
|
39
|
+
supabase_url = supabase_url or DEV_SUPABASE_URL
|
|
40
|
+
supabase_anon_key = supabase_anon_key or DEV_SUPABASE_ANON_KEY
|
|
41
|
+
rprint("[dim]Using local dev environment[/dim]")
|
|
42
|
+
else:
|
|
43
|
+
api_url = api_url or STAGING_API_URL
|
|
44
|
+
supabase_url = supabase_url or STAGING_SUPABASE_URL
|
|
45
|
+
supabase_anon_key = supabase_anon_key or STAGING_SUPABASE_ANON_KEY
|
|
46
|
+
|
|
47
|
+
if not email:
|
|
48
|
+
email = typer.prompt("Email")
|
|
49
|
+
if not password:
|
|
50
|
+
password = typer.prompt("Password", hide_input=True)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
client = create_client(supabase_url, supabase_anon_key)
|
|
54
|
+
response = client.auth.sign_in_with_password(
|
|
55
|
+
{"email": email, "password": password}
|
|
56
|
+
)
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
rprint(f"[red]Login failed:[/red] {exc}")
|
|
59
|
+
raise typer.Exit(code=1) from exc
|
|
60
|
+
|
|
61
|
+
session = response.session
|
|
62
|
+
if not session:
|
|
63
|
+
rprint("[red]Login failed: no session returned.[/red]")
|
|
64
|
+
raise typer.Exit(code=1)
|
|
65
|
+
|
|
66
|
+
save_config(
|
|
67
|
+
{
|
|
68
|
+
"api_url": api_url,
|
|
69
|
+
"supabase_url": supabase_url,
|
|
70
|
+
"supabase_anon_key": supabase_anon_key,
|
|
71
|
+
"access_token": session.access_token,
|
|
72
|
+
"refresh_token": session.refresh_token,
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
rprint(f"[green]Logged in as {email}[/green]")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command()
|
|
79
|
+
def logout() -> None:
|
|
80
|
+
"""Clear stored credentials."""
|
|
81
|
+
clear_config()
|
|
82
|
+
rprint("[green]Logged out.[/green]")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command()
|
|
86
|
+
def whoami() -> None:
|
|
87
|
+
"""Show the currently authenticated user."""
|
|
88
|
+
with get_api_client() as client:
|
|
89
|
+
try:
|
|
90
|
+
resp = client.get("/whoami")
|
|
91
|
+
resp.raise_for_status()
|
|
92
|
+
except httpx.HTTPStatusError as exc:
|
|
93
|
+
try:
|
|
94
|
+
detail = exc.response.json().get("detail", exc.response.text)
|
|
95
|
+
except Exception:
|
|
96
|
+
detail = exc.response.text
|
|
97
|
+
rprint(f"[red]Error {exc.response.status_code}:[/red] {detail}")
|
|
98
|
+
raise typer.Exit(code=1)
|
|
99
|
+
except httpx.HTTPError as exc:
|
|
100
|
+
rprint(f"[red]Connection error:[/red] {exc}")
|
|
101
|
+
raise typer.Exit(code=1)
|
|
102
|
+
rprint(resp.json())
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""CRM commands: search, companies, people, deals, activities, dashboard, lists."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import typer
|
|
5
|
+
from rich import print as rprint
|
|
6
|
+
|
|
7
|
+
from ac_cli.client import get_api_client
|
|
8
|
+
from ac_cli.formatting import print_json, print_table
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="CRM commands")
|
|
11
|
+
|
|
12
|
+
# -- Shared helpers -----------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
_CRM = "/api/v1/crm"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.callback()
|
|
18
|
+
def crm_callback(
|
|
19
|
+
ctx: typer.Context,
|
|
20
|
+
json_output: bool = typer.Option(False, "--json", help="Output raw JSON"),
|
|
21
|
+
) -> None:
|
|
22
|
+
ctx.ensure_object(dict)
|
|
23
|
+
ctx.obj["json"] = json_output
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _handle_error(exc: httpx.HTTPStatusError) -> None:
|
|
27
|
+
"""Print API error detail and exit."""
|
|
28
|
+
try:
|
|
29
|
+
detail = exc.response.json().get("detail", exc.response.text)
|
|
30
|
+
except Exception:
|
|
31
|
+
detail = exc.response.text
|
|
32
|
+
rprint(f"[red]Error {exc.response.status_code}:[/red] {detail}")
|
|
33
|
+
raise typer.Exit(code=1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _api_request(method: str, path: str, **kwargs: object) -> httpx.Response:
|
|
37
|
+
"""Make an authenticated API request with standard error handling."""
|
|
38
|
+
with get_api_client() as client:
|
|
39
|
+
try:
|
|
40
|
+
resp = getattr(client, method)(path, **kwargs)
|
|
41
|
+
resp.raise_for_status()
|
|
42
|
+
except httpx.HTTPStatusError as exc:
|
|
43
|
+
_handle_error(exc)
|
|
44
|
+
except httpx.HTTPError as exc:
|
|
45
|
+
rprint(f"[red]Connection error:[/red] {exc}")
|
|
46
|
+
raise typer.Exit(code=1)
|
|
47
|
+
return resp
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _build_body(**fields: object) -> dict:
|
|
51
|
+
"""Build API request body from non-None fields."""
|
|
52
|
+
body: dict = {}
|
|
53
|
+
for key, value in fields.items():
|
|
54
|
+
if value is not None:
|
|
55
|
+
if key == "tags" and isinstance(value, str):
|
|
56
|
+
body[key] = [t.strip() for t in value.split(",")]
|
|
57
|
+
else:
|
|
58
|
+
body[key] = value
|
|
59
|
+
return body
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# =============================================================================
|
|
63
|
+
# SEARCH
|
|
64
|
+
# =============================================================================
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command()
|
|
68
|
+
def search(
|
|
69
|
+
ctx: typer.Context,
|
|
70
|
+
query: str = typer.Argument(..., help="Search query"),
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Search across companies, contacts, and deals."""
|
|
73
|
+
resp = _api_request("get", f"{_CRM}/search", params={"q": query})
|
|
74
|
+
|
|
75
|
+
data = resp.json()
|
|
76
|
+
if ctx.obj["json"]:
|
|
77
|
+
print_json(data)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
companies = data.get("companies", [])
|
|
81
|
+
contacts = data.get("contacts", [])
|
|
82
|
+
deals = data.get("deals", [])
|
|
83
|
+
|
|
84
|
+
if companies:
|
|
85
|
+
print_table(
|
|
86
|
+
companies,
|
|
87
|
+
[("name", "Name"), ("industry", "Industry"), ("id", "ID")],
|
|
88
|
+
title="Companies",
|
|
89
|
+
)
|
|
90
|
+
if contacts:
|
|
91
|
+
print_table(
|
|
92
|
+
contacts,
|
|
93
|
+
[("full_name", "Name"), ("email", "Email"), ("id", "ID")],
|
|
94
|
+
title="Contacts",
|
|
95
|
+
)
|
|
96
|
+
if deals:
|
|
97
|
+
print_table(
|
|
98
|
+
deals,
|
|
99
|
+
[("name", "Name"), ("stage", "Stage"), ("id", "ID")],
|
|
100
|
+
title="Deals",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if not companies and not contacts and not deals:
|
|
104
|
+
rprint("[dim]No results found.[/dim]")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# =============================================================================
|
|
108
|
+
# DASHBOARD
|
|
109
|
+
# =============================================================================
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.command("dashboard")
|
|
113
|
+
def dashboard(
|
|
114
|
+
ctx: typer.Context,
|
|
115
|
+
period: int = typer.Option(30, "--period", help="Period in days"),
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Show CRM dashboard summary."""
|
|
118
|
+
resp = _api_request("get", f"{_CRM}/dashboard", params={"period_days": period})
|
|
119
|
+
|
|
120
|
+
data = resp.json()
|
|
121
|
+
if ctx.obj["json"]:
|
|
122
|
+
print_json(data)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
pipeline = data.get("pipeline", {})
|
|
126
|
+
active = data.get("active_pipeline", {})
|
|
127
|
+
leads = data.get("leads", {})
|
|
128
|
+
messages = data.get("messages_sent", {})
|
|
129
|
+
|
|
130
|
+
rprint(f"\n[bold]CRM Dashboard[/bold] (last {data.get('period_days', period)} days)\n")
|
|
131
|
+
|
|
132
|
+
rprint("[bold]Pipeline[/bold]")
|
|
133
|
+
rprint(f" Total deals: {pipeline.get('total_deals', 0)}")
|
|
134
|
+
rprint(f" Total value: ${pipeline.get('total_value', 0):,.2f}")
|
|
135
|
+
stages = pipeline.get("deals_by_stage", {})
|
|
136
|
+
for stage_name, stats in stages.items():
|
|
137
|
+
rprint(f" {stage_name}: {stats.get('count', 0)} deals (${stats.get('value', 0):,.2f})")
|
|
138
|
+
|
|
139
|
+
rprint(f"\n[bold]Active Pipeline[/bold]")
|
|
140
|
+
rprint(f" Active deals: {active.get('active_deals_count', 0)}")
|
|
141
|
+
rprint(f" Total value: ${active.get('total_value', 0):,.2f}")
|
|
142
|
+
rprint(f" Adjusted value: ${active.get('adjusted_value', 0):,.2f}")
|
|
143
|
+
|
|
144
|
+
rprint(f"\n[bold]Leads[/bold]")
|
|
145
|
+
rprint(f" This period: {leads.get('current_period', 0)}")
|
|
146
|
+
rprint(f" Previous: {leads.get('previous_period', 0)}")
|
|
147
|
+
rprint(f" Change: {leads.get('change', 0):+d}")
|
|
148
|
+
rprint(f" Total: {leads.get('total', 0)}")
|
|
149
|
+
|
|
150
|
+
rprint(f"\n[bold]Messages Sent[/bold]")
|
|
151
|
+
rprint(f" This period: {messages.get('current_period', 0)}")
|
|
152
|
+
rprint(f" Previous: {messages.get('previous_period', 0)}")
|
|
153
|
+
rprint(f" Change: {messages.get('change', 0):+d}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# -- Register sub-command groups from submodules ------------------------------
|
|
157
|
+
|
|
158
|
+
from ac_cli.commands.crm.companies import companies_app # noqa: E402
|
|
159
|
+
from ac_cli.commands.crm.people import people_app # noqa: E402
|
|
160
|
+
from ac_cli.commands.crm.deals import deals_app # noqa: E402
|
|
161
|
+
from ac_cli.commands.crm.activities import activities_app # noqa: E402
|
|
162
|
+
from ac_cli.commands.crm.lists import lists_app # noqa: E402
|
|
163
|
+
from ac_cli.commands.crm.communications import communications_app # noqa: E402
|
|
164
|
+
from ac_cli.commands.crm.imports import imports_app # noqa: E402
|
|
165
|
+
|
|
166
|
+
app.add_typer(companies_app, name="companies")
|
|
167
|
+
app.add_typer(people_app, name="people")
|
|
168
|
+
app.add_typer(deals_app, name="deals")
|
|
169
|
+
app.add_typer(activities_app, name="activities")
|
|
170
|
+
app.add_typer(lists_app, name="lists")
|
|
171
|
+
app.add_typer(communications_app, name="comms")
|
|
172
|
+
app.add_typer(imports_app, name="import")
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""CRM activities commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich import print as rprint
|
|
9
|
+
|
|
10
|
+
from ac_cli.commands.crm import _CRM, _api_request, _build_body
|
|
11
|
+
from ac_cli.formatting import print_detail, print_json, print_table
|
|
12
|
+
|
|
13
|
+
activities_app = typer.Typer(help="Activity operations")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@activities_app.command("list")
|
|
17
|
+
def activities_list(
|
|
18
|
+
ctx: typer.Context,
|
|
19
|
+
deal_id: str | None = typer.Option(None, "--deal-id", help="Filter by deal"),
|
|
20
|
+
company_id: str | None = typer.Option(None, "--company-id", help="Filter by company"),
|
|
21
|
+
contact_id: str | None = typer.Option(None, "--contact-id", help="Filter by contact"),
|
|
22
|
+
activity_type: str | None = typer.Option(None, "--type", help="Filter by type"),
|
|
23
|
+
status: str | None = typer.Option(None, help="Filter by status"),
|
|
24
|
+
sort_by: str | None = typer.Option(None, "--sort-by", help="Sort field: due_date or created_at"),
|
|
25
|
+
limit: int = typer.Option(100, help="Max results"),
|
|
26
|
+
offset: int = typer.Option(0, help="Offset"),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""List activities."""
|
|
29
|
+
params: dict = {"limit": limit, "offset": offset}
|
|
30
|
+
if deal_id:
|
|
31
|
+
params["deal_id"] = deal_id
|
|
32
|
+
if company_id:
|
|
33
|
+
params["company_id"] = company_id
|
|
34
|
+
if contact_id:
|
|
35
|
+
params["contact_id"] = contact_id
|
|
36
|
+
if activity_type:
|
|
37
|
+
params["type"] = activity_type
|
|
38
|
+
if status:
|
|
39
|
+
params["status"] = status
|
|
40
|
+
if sort_by:
|
|
41
|
+
params["sort_by"] = sort_by
|
|
42
|
+
|
|
43
|
+
resp = _api_request("get", f"{_CRM}/activities", params=params)
|
|
44
|
+
|
|
45
|
+
data = resp.json()
|
|
46
|
+
if ctx.obj["json"]:
|
|
47
|
+
print_json(data)
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# API returns a flat list
|
|
51
|
+
items = data if isinstance(data, list) else data.get("data", [])
|
|
52
|
+
print_table(
|
|
53
|
+
items,
|
|
54
|
+
[
|
|
55
|
+
("title", "Title"),
|
|
56
|
+
("type", "Type"),
|
|
57
|
+
("status", "Status"),
|
|
58
|
+
("priority", "Priority"),
|
|
59
|
+
("due_date", "Due Date"),
|
|
60
|
+
("id", "ID"),
|
|
61
|
+
],
|
|
62
|
+
title=f"Activities ({len(items)})",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@activities_app.command("get")
|
|
67
|
+
def activities_get(
|
|
68
|
+
ctx: typer.Context,
|
|
69
|
+
activity_id: str = typer.Argument(..., help="Activity ID"),
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Get an activity by ID."""
|
|
72
|
+
resp = _api_request("get", f"{_CRM}/activities/{activity_id}")
|
|
73
|
+
|
|
74
|
+
data = resp.json()
|
|
75
|
+
if ctx.obj["json"]:
|
|
76
|
+
print_json(data)
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
print_detail(data, [
|
|
80
|
+
("id", "ID"),
|
|
81
|
+
("title", "Title"),
|
|
82
|
+
("type", "Type"),
|
|
83
|
+
("status", "Status"),
|
|
84
|
+
("priority", "Priority"),
|
|
85
|
+
("due_date", "Due Date"),
|
|
86
|
+
("completed_at", "Completed At"),
|
|
87
|
+
("description", "Description"),
|
|
88
|
+
("company_id", "Company ID"),
|
|
89
|
+
("contact_id", "Contact ID"),
|
|
90
|
+
("deal_id", "Deal ID"),
|
|
91
|
+
("assigned_to", "Assigned To"),
|
|
92
|
+
("created_at", "Created"),
|
|
93
|
+
("updated_at", "Updated"),
|
|
94
|
+
])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@activities_app.command("create")
|
|
98
|
+
def activities_create(
|
|
99
|
+
ctx: typer.Context,
|
|
100
|
+
activity_type: str = typer.Option(..., "--type", help="Activity type (call, meeting, email, task, note)"),
|
|
101
|
+
title: str = typer.Option(..., help="Activity title"),
|
|
102
|
+
due_date: str | None = typer.Option(None, "--due-date", help="Due date (ISO format)"),
|
|
103
|
+
priority: str | None = typer.Option(None, help="Priority (low, medium, high, urgent)"),
|
|
104
|
+
deal_id: str | None = typer.Option(None, "--deal-id", help="Deal ID"),
|
|
105
|
+
company_id: str | None = typer.Option(None, "--company-id", help="Company ID"),
|
|
106
|
+
contact_id: str | None = typer.Option(None, "--contact-id", help="Contact ID"),
|
|
107
|
+
description: str | None = typer.Option(None, help="Description"),
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Create a new activity."""
|
|
110
|
+
body = _build_body(
|
|
111
|
+
type=activity_type, title=title, due_date=due_date,
|
|
112
|
+
priority=priority, deal_id=deal_id, company_id=company_id,
|
|
113
|
+
contact_id=contact_id, description=description,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
me = _api_request("get", "/whoami")
|
|
117
|
+
body["organization_id"] = me.json()["organization_id"]
|
|
118
|
+
|
|
119
|
+
resp = _api_request("post", f"{_CRM}/activities", json=body)
|
|
120
|
+
|
|
121
|
+
data = resp.json()
|
|
122
|
+
if ctx.obj["json"]:
|
|
123
|
+
print_json(data)
|
|
124
|
+
else:
|
|
125
|
+
rprint(f"[green]Created activity:[/green] {data['title']} ({data['id']})")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@activities_app.command("update")
|
|
129
|
+
def activities_update(
|
|
130
|
+
ctx: typer.Context,
|
|
131
|
+
activity_id: str = typer.Argument(..., help="Activity ID"),
|
|
132
|
+
title: str | None = typer.Option(None, help="Activity title"),
|
|
133
|
+
activity_type: str | None = typer.Option(None, "--type", help="Activity type (call, meeting, email, task, note)"),
|
|
134
|
+
status: str | None = typer.Option(None, help="Status (pending, in_progress, completed, cancelled)"),
|
|
135
|
+
priority: str | None = typer.Option(None, help="Priority (low, medium, high, urgent)"),
|
|
136
|
+
due_date: str | None = typer.Option(None, "--due-date", help="Due date (ISO format)"),
|
|
137
|
+
description: str | None = typer.Option(None, help="Description"),
|
|
138
|
+
deal_id: str | None = typer.Option(None, "--deal-id", help="Deal ID"),
|
|
139
|
+
company_id: str | None = typer.Option(None, "--company-id", help="Company ID"),
|
|
140
|
+
contact_id: str | None = typer.Option(None, "--contact-id", help="Contact ID"),
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Update an existing activity."""
|
|
143
|
+
body = _build_body(
|
|
144
|
+
title=title, type=activity_type, status=status, priority=priority,
|
|
145
|
+
due_date=due_date, description=description, deal_id=deal_id,
|
|
146
|
+
company_id=company_id, contact_id=contact_id,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if not body:
|
|
150
|
+
rprint("[yellow]No fields to update.[/yellow]")
|
|
151
|
+
raise typer.Exit(code=1)
|
|
152
|
+
|
|
153
|
+
resp = _api_request("patch", f"{_CRM}/activities/{activity_id}", json=body)
|
|
154
|
+
|
|
155
|
+
data = resp.json()
|
|
156
|
+
if ctx.obj["json"]:
|
|
157
|
+
print_json(data)
|
|
158
|
+
else:
|
|
159
|
+
rprint(f"[green]Updated activity:[/green] {data['title']} ({data['id']})")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@activities_app.command("complete")
|
|
163
|
+
def activities_complete(
|
|
164
|
+
ctx: typer.Context,
|
|
165
|
+
activity_id: str = typer.Argument(..., help="Activity ID"),
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Mark an activity as completed."""
|
|
168
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
169
|
+
resp = _api_request(
|
|
170
|
+
"patch",
|
|
171
|
+
f"{_CRM}/activities/{activity_id}",
|
|
172
|
+
json={"status": "completed", "completed_at": now},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
data = resp.json()
|
|
176
|
+
if ctx.obj["json"]:
|
|
177
|
+
print_json(data)
|
|
178
|
+
else:
|
|
179
|
+
rprint(f"[green]Completed activity:[/green] {data['title']} ({data['id']})")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@activities_app.command("delete")
|
|
183
|
+
def activities_delete(
|
|
184
|
+
activity_id: str = typer.Argument(..., help="Activity ID"),
|
|
185
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Delete an activity."""
|
|
188
|
+
if not yes:
|
|
189
|
+
typer.confirm(f"Delete activity {activity_id}?", abort=True)
|
|
190
|
+
|
|
191
|
+
_api_request("delete", f"{_CRM}/activities/{activity_id}")
|
|
192
|
+
|
|
193
|
+
rprint(f"[green]Deleted activity {activity_id}[/green]")
|