wealthbox-cli 1.0.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.
- wealthbox_cli-1.0.0.dist-info/METADATA +230 -0
- wealthbox_cli-1.0.0.dist-info/RECORD +54 -0
- wealthbox_cli-1.0.0.dist-info/WHEEL +4 -0
- wealthbox_cli-1.0.0.dist-info/entry_points.txt +3 -0
- wealthbox_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
- wealthbox_tools/__init__.py +51 -0
- wealthbox_tools/cli/__init__.py +3 -0
- wealthbox_tools/cli/_config.py +43 -0
- wealthbox_tools/cli/_util.py +315 -0
- wealthbox_tools/cli/activity.py +42 -0
- wealthbox_tools/cli/categories.py +41 -0
- wealthbox_tools/cli/comments.py +51 -0
- wealthbox_tools/cli/config.py +47 -0
- wealthbox_tools/cli/contacts.py +412 -0
- wealthbox_tools/cli/events.py +168 -0
- wealthbox_tools/cli/households.py +41 -0
- wealthbox_tools/cli/main.py +55 -0
- wealthbox_tools/cli/me.py +25 -0
- wealthbox_tools/cli/notes.py +97 -0
- wealthbox_tools/cli/opportunities.py +198 -0
- wealthbox_tools/cli/projects.py +102 -0
- wealthbox_tools/cli/tasks.py +180 -0
- wealthbox_tools/cli/users.py +24 -0
- wealthbox_tools/cli/workflows.py +172 -0
- wealthbox_tools/client/__init__.py +45 -0
- wealthbox_tools/client/activity.py +13 -0
- wealthbox_tools/client/base.py +216 -0
- wealthbox_tools/client/categories.py +14 -0
- wealthbox_tools/client/comments.py +14 -0
- wealthbox_tools/client/contacts.py +44 -0
- wealthbox_tools/client/events.py +31 -0
- wealthbox_tools/client/households.py +30 -0
- wealthbox_tools/client/me.py +11 -0
- wealthbox_tools/client/notes.py +28 -0
- wealthbox_tools/client/opportunities.py +31 -0
- wealthbox_tools/client/projects.py +28 -0
- wealthbox_tools/client/tasks.py +31 -0
- wealthbox_tools/client/users.py +14 -0
- wealthbox_tools/client/workflows.py +44 -0
- wealthbox_tools/models/__init__.py +134 -0
- wealthbox_tools/models/activity.py +13 -0
- wealthbox_tools/models/comments.py +13 -0
- wealthbox_tools/models/common.py +123 -0
- wealthbox_tools/models/contacts.py +131 -0
- wealthbox_tools/models/custom_fields.py +19 -0
- wealthbox_tools/models/enums.py +209 -0
- wealthbox_tools/models/events.py +63 -0
- wealthbox_tools/models/households.py +17 -0
- wealthbox_tools/models/notes.py +28 -0
- wealthbox_tools/models/opportunities.py +48 -0
- wealthbox_tools/models/projects.py +27 -0
- wealthbox_tools/models/tasks.py +76 -0
- wealthbox_tools/models/workflows.py +50 -0
- wealthbox_tools/py.typed +0 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from wealthbox_tools.models import (
|
|
8
|
+
WorkflowCreateInput,
|
|
9
|
+
WorkflowListQuery,
|
|
10
|
+
WorkflowResourceType,
|
|
11
|
+
WorkflowStatus,
|
|
12
|
+
WorkflowStepCompleteInput,
|
|
13
|
+
WorkflowTemplateListQuery,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from ._util import OutputFormat, build_linked_to, handle_errors, output_result, parse_more_fields, run_client
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
20
|
+
help="Manage Wealthbox workflows.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
templates_app = typer.Typer(
|
|
25
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
26
|
+
help="List workflow templates.",
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
)
|
|
29
|
+
app.add_typer(templates_app, name="templates")
|
|
30
|
+
|
|
31
|
+
_DEFAULT_FIELDS = ["id", "label", "status", "workflow_template", "linked_to", "created_at"]
|
|
32
|
+
_TEMPLATE_DEFAULT_FIELDS = ["id", "name", "description", "status"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command("list", help="List workflows with optional filters.")
|
|
36
|
+
@handle_errors
|
|
37
|
+
def list_workflows(
|
|
38
|
+
resource_id: int | None = typer.Option(
|
|
39
|
+
None, "--resource-id", help="Filter by linked resource ID (requires --resource-type)"
|
|
40
|
+
),
|
|
41
|
+
resource_type: WorkflowResourceType | None = typer.Option(
|
|
42
|
+
None, "--resource-type", help="Filter by linked resource type: Contact, Project"
|
|
43
|
+
),
|
|
44
|
+
status: WorkflowStatus | None = typer.Option(None, "--status", help="active, completed, or scheduled"),
|
|
45
|
+
updated_since: str | None = typer.Option(None, "--updated-since"),
|
|
46
|
+
updated_before: str | None = typer.Option(None, "--updated-before"),
|
|
47
|
+
page: int | None = typer.Option(None),
|
|
48
|
+
per_page: int | None = typer.Option(None, "--per-page", help="Results per page (max 100)"),
|
|
49
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all fields"),
|
|
50
|
+
token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
|
|
51
|
+
fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
|
|
52
|
+
) -> None:
|
|
53
|
+
query = WorkflowListQuery(
|
|
54
|
+
resource_id=resource_id,
|
|
55
|
+
resource_type=resource_type,
|
|
56
|
+
status=status,
|
|
57
|
+
updated_since=updated_since,
|
|
58
|
+
updated_before=updated_before,
|
|
59
|
+
page=page,
|
|
60
|
+
per_page=per_page,
|
|
61
|
+
)
|
|
62
|
+
output_result(
|
|
63
|
+
run_client(token, lambda c: c.list_workflows(query)), fmt, fields=None if verbose else _DEFAULT_FIELDS
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command("get", help="Get a single workflow by ID.")
|
|
68
|
+
@handle_errors
|
|
69
|
+
def get_workflow(
|
|
70
|
+
workflow_id: int = typer.Argument(..., help="Workflow ID"),
|
|
71
|
+
token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
|
|
72
|
+
fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
|
|
73
|
+
) -> None:
|
|
74
|
+
output_result(run_client(token, lambda c: c.get_workflow(workflow_id)), fmt)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command("add", help="Create a new workflow from a template.")
|
|
78
|
+
@handle_errors
|
|
79
|
+
def add_workflow(
|
|
80
|
+
template: int = typer.Option(..., "--template", help="Workflow template ID — see: wbox workflows templates list"),
|
|
81
|
+
label: str | None = typer.Option(None, "--label", help="Optional label for this workflow instance"),
|
|
82
|
+
contact: int | None = typer.Option(None, "--contact", help="Link to a Contact by ID"),
|
|
83
|
+
project: int | None = typer.Option(None, "--project", help="Link to a Project by ID"),
|
|
84
|
+
opportunity: int | None = typer.Option(None, "--opportunity", help="Link to an Opportunity by ID"),
|
|
85
|
+
visible_to: str | None = typer.Option(None, "--visible-to"),
|
|
86
|
+
starts_at: str | None = typer.Option(None, "--starts-at", help="Start date (e.g. 2026-06-01)"),
|
|
87
|
+
more_fields: str | None = typer.Option(
|
|
88
|
+
None, "--more-fields", help="JSON object for additional fields (e.g. workflow_milestones)"
|
|
89
|
+
),
|
|
90
|
+
token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
|
|
91
|
+
fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
|
|
92
|
+
) -> None:
|
|
93
|
+
payload: dict[str, Any] = {
|
|
94
|
+
"workflow_template": template,
|
|
95
|
+
"label": label,
|
|
96
|
+
"visible_to": visible_to,
|
|
97
|
+
"starts_at": starts_at,
|
|
98
|
+
"linked_to": build_linked_to(contact, project, opportunity),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if more_fields:
|
|
102
|
+
_reserved = {"workflow_template", "label", "visible_to", "starts_at", "linked_to"}
|
|
103
|
+
payload.update(parse_more_fields(more_fields, _reserved))
|
|
104
|
+
|
|
105
|
+
input_model = WorkflowCreateInput(**{k: v for k, v in payload.items() if v is not None})
|
|
106
|
+
output_result(run_client(token, lambda c: c.create_workflow(input_model)), fmt)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command("complete-step", help="Mark a workflow step as complete.")
|
|
110
|
+
@handle_errors
|
|
111
|
+
def complete_workflow_step(
|
|
112
|
+
workflow_id: int = typer.Argument(..., help="Workflow ID"),
|
|
113
|
+
step_id: int = typer.Argument(..., help="Step ID"),
|
|
114
|
+
outcome_id: int | None = typer.Option(
|
|
115
|
+
None, "--outcome-id", help="Workflow outcome ID (if step has multiple outcomes)"
|
|
116
|
+
),
|
|
117
|
+
due_date: str | None = typer.Option(
|
|
118
|
+
None, "--due-date", help="Due date when restarting a step (requires --due-date-set)"
|
|
119
|
+
),
|
|
120
|
+
due_date_set: bool = typer.Option(False, "--due-date-set", help="Whether the restarted step has a due date"),
|
|
121
|
+
token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
|
|
122
|
+
fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
|
|
123
|
+
) -> None:
|
|
124
|
+
data = WorkflowStepCompleteInput(
|
|
125
|
+
workflow_outcome_id=outcome_id,
|
|
126
|
+
due_date_set=due_date_set,
|
|
127
|
+
due_date=due_date,
|
|
128
|
+
)
|
|
129
|
+
output_result(run_client(token, lambda c: c.complete_workflow_step(workflow_id, step_id, data)), fmt)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.command("revert-step", help="Revert a completed workflow step.")
|
|
133
|
+
@handle_errors
|
|
134
|
+
def revert_workflow_step(
|
|
135
|
+
workflow_id: int = typer.Argument(..., help="Workflow ID"),
|
|
136
|
+
step_id: int = typer.Argument(..., help="Step ID"),
|
|
137
|
+
token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
|
|
138
|
+
fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
|
|
139
|
+
) -> None:
|
|
140
|
+
output_result(run_client(token, lambda c: c.revert_workflow_step(workflow_id, step_id)), fmt)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@templates_app.command("list", help="List available workflow templates.")
|
|
144
|
+
@handle_errors
|
|
145
|
+
def list_workflow_templates(
|
|
146
|
+
resource_id: int | None = typer.Option(None, "--resource-id", help="Filter by linked resource ID"),
|
|
147
|
+
resource_type: WorkflowResourceType | None = typer.Option(
|
|
148
|
+
None, "--resource-type", help="Filter by linked resource type: Contact, Project"
|
|
149
|
+
),
|
|
150
|
+
status: WorkflowStatus | None = typer.Option(None, "--status", help="active, completed, or scheduled"),
|
|
151
|
+
updated_since: str | None = typer.Option(None, "--updated-since"),
|
|
152
|
+
updated_before: str | None = typer.Option(None, "--updated-before"),
|
|
153
|
+
page: int | None = typer.Option(None),
|
|
154
|
+
per_page: int | None = typer.Option(None, "--per-page", help="Results per page (max 100)"),
|
|
155
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all fields"),
|
|
156
|
+
token: str | None = typer.Option(None, envvar="WEALTHBOX_TOKEN", hidden=True),
|
|
157
|
+
fmt: OutputFormat = typer.Option(OutputFormat.JSON, "--format"),
|
|
158
|
+
) -> None:
|
|
159
|
+
query = WorkflowTemplateListQuery(
|
|
160
|
+
resource_id=resource_id,
|
|
161
|
+
resource_type=resource_type,
|
|
162
|
+
status=status,
|
|
163
|
+
updated_since=updated_since,
|
|
164
|
+
updated_before=updated_before,
|
|
165
|
+
page=page,
|
|
166
|
+
per_page=per_page,
|
|
167
|
+
)
|
|
168
|
+
output_result(
|
|
169
|
+
run_client(token, lambda c: c.list_workflow_templates(query)),
|
|
170
|
+
fmt,
|
|
171
|
+
fields=None if verbose else _TEMPLATE_DEFAULT_FIELDS,
|
|
172
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .activity import ActivityMixin
|
|
4
|
+
from .base import WealthboxAPIError, _WealthboxBase
|
|
5
|
+
from .categories import CategoriesMixin
|
|
6
|
+
from .comments import CommentsMixin
|
|
7
|
+
from .contacts import ContactsMixin
|
|
8
|
+
from .events import EventsMixin
|
|
9
|
+
from .households import HouseholdsMixin
|
|
10
|
+
from .me import MeMixin
|
|
11
|
+
from .notes import NotesMixin
|
|
12
|
+
from .opportunities import OpportunitiesMixin
|
|
13
|
+
from .projects import ProjectsMixin
|
|
14
|
+
from .tasks import TasksMixin
|
|
15
|
+
from .users import UsersMixin
|
|
16
|
+
from .workflows import WorkflowsMixin
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WealthboxClient(
|
|
20
|
+
ActivityMixin,
|
|
21
|
+
CategoriesMixin,
|
|
22
|
+
CommentsMixin,
|
|
23
|
+
ContactsMixin,
|
|
24
|
+
EventsMixin,
|
|
25
|
+
HouseholdsMixin,
|
|
26
|
+
MeMixin,
|
|
27
|
+
NotesMixin,
|
|
28
|
+
OpportunitiesMixin,
|
|
29
|
+
ProjectsMixin,
|
|
30
|
+
TasksMixin,
|
|
31
|
+
UsersMixin,
|
|
32
|
+
WorkflowsMixin,
|
|
33
|
+
_WealthboxBase,
|
|
34
|
+
):
|
|
35
|
+
"""Async Wealthbox CRM API client.
|
|
36
|
+
|
|
37
|
+
Usage::
|
|
38
|
+
|
|
39
|
+
async with WealthboxClient() as client:
|
|
40
|
+
me = await client.get_me()
|
|
41
|
+
contacts = await client.list_contacts()
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = ["WealthboxClient", "WealthboxAPIError"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from wealthbox_tools.models import ActivityListQuery
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ActivityMixin:
|
|
9
|
+
"""Activity Resource"""
|
|
10
|
+
async def list_activity(self, query: ActivityListQuery | None = None) -> dict[str, Any]:
|
|
11
|
+
params = query.model_dump(exclude_none=True) if query else None
|
|
12
|
+
resp = await self._request("GET", "/activity", params=params) # type: ignore[attr-defined]
|
|
13
|
+
return resp.json()
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import collections
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import pathlib
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
BASE_URL = "https://api.crmworkspace.com/v1"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WealthboxAPIError(Exception):
|
|
21
|
+
"""Raised when the Wealthbox API returns an error response."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, status_code: int, detail: str, response: httpx.Response | httpx.Request):
|
|
24
|
+
self.status_code = status_code
|
|
25
|
+
self.detail = detail
|
|
26
|
+
self.response = response
|
|
27
|
+
super().__init__(f"[{status_code}] {detail}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RateLimiter:
|
|
31
|
+
"""Async sliding-window rate limiter: 300 requests per 5-minute window."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
rate: float = 1.0, # kept for backward compat, unused
|
|
36
|
+
window: float = 300.0, # 5-minute window
|
|
37
|
+
limit: int = 300, # 300 req per window = 1/s average
|
|
38
|
+
state_file: pathlib.Path | None = None,
|
|
39
|
+
):
|
|
40
|
+
self._window = window
|
|
41
|
+
self._limit = limit
|
|
42
|
+
self._timestamps: collections.deque[float] = collections.deque()
|
|
43
|
+
self._lock = asyncio.Lock()
|
|
44
|
+
self._state_file = state_file or pathlib.Path.home() / ".wbox_rate_limit.json"
|
|
45
|
+
self._load_state()
|
|
46
|
+
|
|
47
|
+
def _load_state(self) -> None:
|
|
48
|
+
try:
|
|
49
|
+
raw = json.loads(self._state_file.read_text())
|
|
50
|
+
wall_ts = raw.get("timestamps", [])
|
|
51
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
52
|
+
return
|
|
53
|
+
now_mono, now_wall = time.monotonic(), time.time()
|
|
54
|
+
offset = now_mono - now_wall # wall → monotonic
|
|
55
|
+
cutoff = now_wall - self._window
|
|
56
|
+
for ts in wall_ts:
|
|
57
|
+
if ts > cutoff:
|
|
58
|
+
self._timestamps.append(ts + offset)
|
|
59
|
+
|
|
60
|
+
def _prune_expired(self, now: float) -> None:
|
|
61
|
+
while self._timestamps and (now - self._timestamps[0]) >= self._window:
|
|
62
|
+
self._timestamps.popleft()
|
|
63
|
+
|
|
64
|
+
def _save_state(self) -> None:
|
|
65
|
+
try:
|
|
66
|
+
now_mono, now_wall = time.monotonic(), time.time()
|
|
67
|
+
offset = now_wall - now_mono # monotonic → wall
|
|
68
|
+
cutoff = now_wall - self._window
|
|
69
|
+
wall_ts = [ts + offset for ts in self._timestamps if (ts + offset) > cutoff]
|
|
70
|
+
tmp = self._state_file.with_suffix(".tmp")
|
|
71
|
+
tmp.write_text(json.dumps({"timestamps": wall_ts}))
|
|
72
|
+
os.replace(tmp, self._state_file) # atomic swap
|
|
73
|
+
except OSError:
|
|
74
|
+
pass # never crash CLI over a cache file
|
|
75
|
+
|
|
76
|
+
async def acquire(self) -> None:
|
|
77
|
+
async with self._lock:
|
|
78
|
+
now = time.monotonic()
|
|
79
|
+
self._prune_expired(now)
|
|
80
|
+
if len(self._timestamps) >= self._limit:
|
|
81
|
+
sleep_for = self._window - (now - self._timestamps[0])
|
|
82
|
+
if sleep_for > 0:
|
|
83
|
+
await asyncio.sleep(sleep_for)
|
|
84
|
+
self._prune_expired(time.monotonic())
|
|
85
|
+
self._timestamps.append(time.monotonic())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _WealthboxBase:
|
|
89
|
+
"""Core HTTP client. Resource mixins are combined into WealthboxClient."""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
token: str | None = None,
|
|
94
|
+
base_url: str = BASE_URL,
|
|
95
|
+
rate_limit: bool = True,
|
|
96
|
+
max_429_retries: int = 5,
|
|
97
|
+
):
|
|
98
|
+
self._token = token or os.environ.get("WEALTHBOX_TOKEN", "")
|
|
99
|
+
if not self._token:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
"Wealthbox token required. Pass token= or set WEALTHBOX_TOKEN env var."
|
|
102
|
+
)
|
|
103
|
+
self._rate_limiter = RateLimiter() if rate_limit else None
|
|
104
|
+
self._max_429_retries = max_429_retries
|
|
105
|
+
self._http = httpx.AsyncClient(
|
|
106
|
+
base_url=base_url,
|
|
107
|
+
headers={"ACCESS_TOKEN": self._token},
|
|
108
|
+
timeout=30.0,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async def _request(
|
|
112
|
+
self,
|
|
113
|
+
method: str,
|
|
114
|
+
path: str,
|
|
115
|
+
*,
|
|
116
|
+
params: dict | None = None,
|
|
117
|
+
json: dict | None = None,
|
|
118
|
+
) -> httpx.Response:
|
|
119
|
+
if self._rate_limiter:
|
|
120
|
+
await self._rate_limiter.acquire()
|
|
121
|
+
|
|
122
|
+
retries = 0
|
|
123
|
+
while True:
|
|
124
|
+
try:
|
|
125
|
+
response = await self._http.request(method, path, params=params, json=json)
|
|
126
|
+
except httpx.RequestError as exc:
|
|
127
|
+
raise WealthboxAPIError(0, f"Request failed: {exc}", exc.request) from exc # type: ignore[arg-type]
|
|
128
|
+
|
|
129
|
+
if response.status_code != 429:
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
retries += 1
|
|
133
|
+
if retries > self._max_429_retries:
|
|
134
|
+
raise WealthboxAPIError(
|
|
135
|
+
429,
|
|
136
|
+
f"Rate limited after {self._max_429_retries} retries.",
|
|
137
|
+
response,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
raw_retry = response.headers.get("Retry-After", "5")
|
|
141
|
+
try:
|
|
142
|
+
retry_after = float(raw_retry)
|
|
143
|
+
except ValueError:
|
|
144
|
+
log.warning("Malformed Retry-After header %r, defaulting to 5s", raw_retry)
|
|
145
|
+
retry_after = 5.0
|
|
146
|
+
|
|
147
|
+
await asyncio.sleep(max(retry_after, 0.0))
|
|
148
|
+
|
|
149
|
+
if response.is_error:
|
|
150
|
+
try:
|
|
151
|
+
detail = response.json().get("error", response.text)
|
|
152
|
+
except Exception:
|
|
153
|
+
detail = response.text
|
|
154
|
+
raise WealthboxAPIError(response.status_code, detail, response)
|
|
155
|
+
|
|
156
|
+
return response
|
|
157
|
+
|
|
158
|
+
async def fetch_all_pages(
|
|
159
|
+
self,
|
|
160
|
+
path: str,
|
|
161
|
+
params: dict[str, Any],
|
|
162
|
+
collection_key: str,
|
|
163
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
164
|
+
) -> dict[str, Any]:
|
|
165
|
+
"""
|
|
166
|
+
Fetch every page from a paginated list endpoint.
|
|
167
|
+
|
|
168
|
+
Calls GET `path` with `params` (plus page/per_page overrides),
|
|
169
|
+
collecting items under `collection_key` until all pages are fetched.
|
|
170
|
+
Calls on_progress(page_num, total_fetched) after each page if provided.
|
|
171
|
+
Returns {"<collection_key>": [...all items...], "meta": {"total_count": N}}.
|
|
172
|
+
"""
|
|
173
|
+
all_items: list[Any] = []
|
|
174
|
+
base_params = {**params, "per_page": 100}
|
|
175
|
+
page = 1
|
|
176
|
+
|
|
177
|
+
while True:
|
|
178
|
+
base_params["page"] = page
|
|
179
|
+
resp = await self._request("GET", path, params=base_params)
|
|
180
|
+
try:
|
|
181
|
+
data = resp.json()
|
|
182
|
+
except Exception:
|
|
183
|
+
raise WealthboxAPIError(
|
|
184
|
+
resp.status_code,
|
|
185
|
+
f"Invalid JSON in response from {path} (page {page})",
|
|
186
|
+
resp,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
items = data.get(collection_key, [])
|
|
190
|
+
all_items.extend(items)
|
|
191
|
+
|
|
192
|
+
if on_progress:
|
|
193
|
+
on_progress(page, len(all_items))
|
|
194
|
+
|
|
195
|
+
total_count = data.get("meta", {}).get("total_count")
|
|
196
|
+
if total_count is not None:
|
|
197
|
+
if page * 100 >= total_count:
|
|
198
|
+
break
|
|
199
|
+
else:
|
|
200
|
+
if len(items) < 100: # fallback if meta missing
|
|
201
|
+
break
|
|
202
|
+
|
|
203
|
+
page += 1
|
|
204
|
+
|
|
205
|
+
return {collection_key: all_items, "meta": {"total_count": len(all_items)}}
|
|
206
|
+
|
|
207
|
+
async def aclose(self) -> None:
|
|
208
|
+
if self._rate_limiter:
|
|
209
|
+
self._rate_limiter._save_state()
|
|
210
|
+
await self._http.aclose()
|
|
211
|
+
|
|
212
|
+
async def __aenter__(self) -> "_WealthboxBase": # type: ignore[return-value]
|
|
213
|
+
return self # type: ignore[return-value]
|
|
214
|
+
|
|
215
|
+
async def __aexit__(self, *args: object) -> None:
|
|
216
|
+
await self.aclose()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from wealthbox_tools.models import CategoryListQuery, CategoryType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CategoriesMixin:
|
|
9
|
+
"""Categories Resources"""
|
|
10
|
+
|
|
11
|
+
async def list_categories(self, category: CategoryType, query: CategoryListQuery | None = None) -> dict[str, Any]:
|
|
12
|
+
params = query.model_dump(exclude_none=True) if query else None
|
|
13
|
+
resp = await self._request("GET", f"/categories/{category}", params=params) # type: ignore[attr-defined]
|
|
14
|
+
return resp.json()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from wealthbox_tools.models import CommentListQuery
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CommentsMixin:
|
|
9
|
+
"""Comments Resource"""
|
|
10
|
+
|
|
11
|
+
async def list_comments(self, query: CommentListQuery | None = None) -> dict[str, Any]:
|
|
12
|
+
params = query.model_dump(exclude_none=True) if query else None
|
|
13
|
+
resp = await self._request("GET", "/comments", params=params) # type: ignore[attr-defined]
|
|
14
|
+
return resp.json()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from wealthbox_tools.models import ContactCreateInput, ContactListQuery, ContactUpdateInput
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ContactsMixin:
|
|
10
|
+
"""Contact resource methods. Mixed into WealthboxClient."""
|
|
11
|
+
|
|
12
|
+
async def list_contacts(self, query: ContactListQuery | None = None) -> dict[str, Any]:
|
|
13
|
+
params = query.model_dump(exclude_none=True) if query else None
|
|
14
|
+
resp = await self._request("GET", "/contacts", params=params) # type: ignore[attr-defined]
|
|
15
|
+
return resp.json()
|
|
16
|
+
|
|
17
|
+
async def get_contact(self, contact_id: int) -> dict[str, Any]:
|
|
18
|
+
resp = await self._request("GET", f"/contacts/{contact_id}") # type: ignore[attr-defined]
|
|
19
|
+
return resp.json()
|
|
20
|
+
|
|
21
|
+
async def create_contact(self, data: ContactCreateInput) -> dict[str, Any]:
|
|
22
|
+
payload = data.model_dump(exclude_none=True)
|
|
23
|
+
resp = await self._request("POST", "/contacts", json=payload) # type: ignore[attr-defined]
|
|
24
|
+
return resp.json()
|
|
25
|
+
|
|
26
|
+
async def update_contact(self, contact_id: int, data: ContactUpdateInput) -> dict[str, Any]:
|
|
27
|
+
payload = data.model_dump(exclude_unset=True)
|
|
28
|
+
resp = await self._request("PUT", f"/contacts/{contact_id}", json=payload) # type: ignore[attr-defined]
|
|
29
|
+
return resp.json()
|
|
30
|
+
|
|
31
|
+
async def delete_contact(self, contact_id: int) -> None:
|
|
32
|
+
await self._request("DELETE", f"/contacts/{contact_id}") # type: ignore[attr-defined]
|
|
33
|
+
|
|
34
|
+
async def list_all_contacts(
|
|
35
|
+
self,
|
|
36
|
+
query: ContactListQuery | None = None,
|
|
37
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
"""Fetch all pages of contacts. Use when client-side filtering requires the full dataset."""
|
|
40
|
+
params = query.model_dump(exclude_none=True) if query else {}
|
|
41
|
+
# Remove pagination fields — fetch_all_pages controls these
|
|
42
|
+
params.pop("page", None)
|
|
43
|
+
params.pop("per_page", None)
|
|
44
|
+
return await self.fetch_all_pages("/contacts", params, "contacts", on_progress=on_progress) # type: ignore[attr-defined]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from wealthbox_tools.models import EventCreateInput, EventListQuery, EventUpdateInput
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EventsMixin:
|
|
9
|
+
"""Event resource methods. Mixed into WealthboxClient."""
|
|
10
|
+
|
|
11
|
+
async def list_events(self, query: EventListQuery | None = None) -> dict[str, Any]:
|
|
12
|
+
params = query.model_dump(exclude_none=True) if query else None
|
|
13
|
+
resp = await self._request("GET", "/events", params=params) # type: ignore[attr-defined]
|
|
14
|
+
return resp.json()
|
|
15
|
+
|
|
16
|
+
async def get_event(self, event_id: int) -> dict[str, Any]:
|
|
17
|
+
resp = await self._request("GET", f"/events/{event_id}") # type: ignore[attr-defined]
|
|
18
|
+
return resp.json()
|
|
19
|
+
|
|
20
|
+
async def create_event(self, data: EventCreateInput) -> dict[str, Any]:
|
|
21
|
+
payload = data.model_dump(exclude_none=True)
|
|
22
|
+
resp = await self._request("POST", "/events", json=payload) # type: ignore[attr-defined]
|
|
23
|
+
return resp.json()
|
|
24
|
+
|
|
25
|
+
async def update_event(self, event_id: int, data: EventUpdateInput) -> dict[str, Any]:
|
|
26
|
+
payload = data.model_dump(exclude_unset=True)
|
|
27
|
+
resp = await self._request("PUT", f"/events/{event_id}", json=payload) # type: ignore[attr-defined]
|
|
28
|
+
return resp.json()
|
|
29
|
+
|
|
30
|
+
async def delete_event(self, event_id: int) -> None:
|
|
31
|
+
await self._request("DELETE", f"/events/{event_id}") # type: ignore[attr-defined]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from wealthbox_tools.models import HouseholdMemberInput
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HouseholdsMixin:
|
|
9
|
+
"""Household membership methods."""
|
|
10
|
+
|
|
11
|
+
async def add_household_member(
|
|
12
|
+
self, household_id: int, data: HouseholdMemberInput
|
|
13
|
+
) -> dict[str, Any]:
|
|
14
|
+
payload = data.model_dump(exclude_none=True)
|
|
15
|
+
resp = await self._request( # type: ignore[attr-defined]
|
|
16
|
+
"POST", f"/households/{household_id}/members", json=payload
|
|
17
|
+
)
|
|
18
|
+
return resp.json()
|
|
19
|
+
|
|
20
|
+
async def remove_household_member(
|
|
21
|
+
self, household_id: int, member_id: int
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
resp = await self._request( # type: ignore[attr-defined]
|
|
24
|
+
"DELETE", f"/households/{household_id}/members/{member_id}"
|
|
25
|
+
)
|
|
26
|
+
# Wealthbox typically returns the household body, but may return empty body.
|
|
27
|
+
try:
|
|
28
|
+
return resp.json()
|
|
29
|
+
except ValueError:
|
|
30
|
+
return {"ok": True, "household_id": household_id, "member_id": member_id}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from wealthbox_tools.models import NoteCreateInput, NoteListQuery, NoteUpdateInput
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NotesMixin:
|
|
9
|
+
"""Note resource methods. Mixed into WealthboxClient. (No delete — API does not support it.)"""
|
|
10
|
+
|
|
11
|
+
async def list_notes(self, query: NoteListQuery | None = None) -> dict[str, Any]:
|
|
12
|
+
params = query.model_dump(exclude_none=True) if query else None
|
|
13
|
+
resp = await self._request("GET", "/notes", params=params) # type: ignore[attr-defined]
|
|
14
|
+
return resp.json()
|
|
15
|
+
|
|
16
|
+
async def get_note(self, note_id: int) -> dict[str, Any]:
|
|
17
|
+
resp = await self._request("GET", f"/notes/{note_id}") # type: ignore[attr-defined]
|
|
18
|
+
return resp.json()
|
|
19
|
+
|
|
20
|
+
async def create_note(self, data: NoteCreateInput) -> dict[str, Any]:
|
|
21
|
+
payload = data.model_dump(exclude_none=True)
|
|
22
|
+
resp = await self._request("POST", "/notes", json=payload) # type: ignore[attr-defined]
|
|
23
|
+
return resp.json()
|
|
24
|
+
|
|
25
|
+
async def update_note(self, note_id: int, data: NoteUpdateInput) -> dict[str, Any]:
|
|
26
|
+
payload = data.model_dump(exclude_unset=True)
|
|
27
|
+
resp = await self._request("PUT", f"/notes/{note_id}", json=payload) # type: ignore[attr-defined]
|
|
28
|
+
return resp.json()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from wealthbox_tools.models import OpportunityCreateInput, OpportunityListQuery, OpportunityUpdateInput
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OpportunitiesMixin:
|
|
9
|
+
"""Opportunities Resource"""
|
|
10
|
+
|
|
11
|
+
async def list_opportunities(self, query: OpportunityListQuery | None = None) -> dict[str, Any]:
|
|
12
|
+
params = query.model_dump(exclude_none=True) if query else None
|
|
13
|
+
resp = await self._request("GET", "/opportunities", params=params) # type: ignore[attr-defined]
|
|
14
|
+
return resp.json()
|
|
15
|
+
|
|
16
|
+
async def get_opportunity(self, opportunity_id: int) -> dict[str, Any]:
|
|
17
|
+
resp = await self._request("GET", f"/opportunities/{opportunity_id}") # type: ignore[attr-defined]
|
|
18
|
+
return resp.json()
|
|
19
|
+
|
|
20
|
+
async def create_opportunity(self, data: OpportunityCreateInput) -> dict[str, Any]:
|
|
21
|
+
payload = data.model_dump(exclude_none=True)
|
|
22
|
+
resp = await self._request("POST", "/opportunities", json=payload) # type: ignore[attr-defined]
|
|
23
|
+
return resp.json()
|
|
24
|
+
|
|
25
|
+
async def update_opportunity(self, opportunity_id: int, data: OpportunityUpdateInput) -> dict[str, Any]:
|
|
26
|
+
payload = data.model_dump(exclude_unset=True)
|
|
27
|
+
resp = await self._request("PUT", f"/opportunities/{opportunity_id}", json=payload) # type: ignore[attr-defined]
|
|
28
|
+
return resp.json()
|
|
29
|
+
|
|
30
|
+
async def delete_opportunity(self, opportunity_id: int) -> None:
|
|
31
|
+
await self._request("DELETE", f"/opportunities/{opportunity_id}") # type: ignore[attr-defined]
|