qodev-apollo-api 0.1.2__tar.gz → 0.2.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.
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/CHANGELOG.md +28 -0
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/PKG-INFO +1 -1
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/pyproject.toml +1 -1
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/src/qodev_apollo_api/__init__.py +7 -1
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/src/qodev_apollo_api/client.py +51 -6
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/src/qodev_apollo_api/models.py +56 -13
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/src/qodev_apollo_api/utils.py +64 -1
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/tests/test_client.py +62 -7
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/tests/test_models.py +48 -6
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/tests/test_utils.py +62 -1
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/uv.lock +1 -1
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/.github/workflows/ci.yml +0 -0
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/.github/workflows/publish.yml +0 -0
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/.gitignore +0 -0
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/.pre-commit-config.yaml +0 -0
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/CLAUDE.md +0 -0
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/LICENSE +0 -0
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/Makefile +0 -0
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/README.md +0 -0
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/src/qodev_apollo_api/exceptions.py +0 -0
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/src/qodev_apollo_api/py.typed +0 -0
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/tests/__init__.py +0 -0
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/tests/integration/__init__.py +0 -0
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/tests/integration/validate_all_models.py +0 -0
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/tests/integration/validate_email_task_flow.py +0 -0
- {qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/tests/test_exceptions.py +0 -0
|
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-06-02
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- `create_note()` posted `{"note": <plaintext>}`, which Apollo silently ignores — notes were created with empty content. It now serialises `content` to ProseMirror JSON and posts it in the `content` field (the format Apollo stores and `search_notes()` reads back). Closes #6.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- `markdown_to_prosemirror()` in `utils` — inverse of `prosemirror_to_markdown()` (title, paragraphs, bullet/ordered lists).
|
|
17
|
+
- `create_note(..., title=...)` — optional note title (rendered as the ProseMirror `noteTitle`).
|
|
18
|
+
- `ApolloClient.delete_note(note_id)` — `DELETE /notes/{id}`.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- `create_note()` association args (`contact_ids`, `account_ids`, `opportunity_ids`) are now keyword-only, so the new positional `title` can't be confused with them.
|
|
22
|
+
|
|
23
|
+
## [0.1.3] - 2026-02-23
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- `ActionItemTask` subclass for `action_item` task type
|
|
27
|
+
- `OtherTask` fallback subclass — `resolve_task()` now returns `OtherTask` for unknown task types instead of raising `ValidationError`
|
|
28
|
+
- `OpportunityContactRoleType` model for role type definitions (Decision Maker, Buyer, etc.)
|
|
29
|
+
- `ApolloClient.list_opportunity_contact_role_types()` — lookup endpoint for role type ID → name mapping (undocumented `POST /opportunity_contact_role_types/search`)
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- `Deal.closed_date`, `Deal.actual_close_date`, `Deal.next_step_date` now parsed as `datetime` (were `str`)
|
|
33
|
+
- `EmploymentHistory.start_date`, `EmploymentHistory.end_date` now parsed as `date` (were `str`)
|
|
34
|
+
- `CallSummaryNextStep.due_at` now parsed as `datetime` (was `str`)
|
|
35
|
+
- `NewsArticle.published_at`, `JobPosting.posted_at` now parsed as `datetime` (were `str`)
|
|
36
|
+
- `search_conversations()` default and max limit corrected from 100 to 25 (Apollo API caps at 25)
|
|
37
|
+
|
|
10
38
|
## [0.1.2] - 2026-02-23
|
|
11
39
|
|
|
12
40
|
### Added
|
|
@@ -9,6 +9,7 @@ from .models import (
|
|
|
9
9
|
AccountDetail,
|
|
10
10
|
AccountPlaybookStatus,
|
|
11
11
|
AccountQueue,
|
|
12
|
+
ActionItemTask,
|
|
12
13
|
ApolloModel,
|
|
13
14
|
BaseTask,
|
|
14
15
|
CalendarEvent,
|
|
@@ -53,8 +54,10 @@ from .models import (
|
|
|
53
54
|
NewsArticle,
|
|
54
55
|
Note,
|
|
55
56
|
OpportunityContactRole,
|
|
57
|
+
OpportunityContactRoleType,
|
|
56
58
|
OpportunityRoleEntry,
|
|
57
59
|
OrganizationRef,
|
|
60
|
+
OtherTask,
|
|
58
61
|
OutreachTaskMessage,
|
|
59
62
|
PaginatedResponse,
|
|
60
63
|
PhoneEntry,
|
|
@@ -79,6 +82,7 @@ __all__ = [
|
|
|
79
82
|
"AccountDetail",
|
|
80
83
|
"AccountPlaybookStatus",
|
|
81
84
|
"AccountQueue",
|
|
85
|
+
"ActionItemTask",
|
|
82
86
|
"ApolloClient",
|
|
83
87
|
"ApolloError",
|
|
84
88
|
"ApolloModel",
|
|
@@ -126,8 +130,10 @@ __all__ = [
|
|
|
126
130
|
"NewsArticle",
|
|
127
131
|
"Note",
|
|
128
132
|
"OpportunityContactRole",
|
|
133
|
+
"OpportunityContactRoleType",
|
|
129
134
|
"OpportunityRoleEntry",
|
|
130
135
|
"OrganizationRef",
|
|
136
|
+
"OtherTask",
|
|
131
137
|
"OutreachTaskMessage",
|
|
132
138
|
"PaginatedResponse",
|
|
133
139
|
"PhoneEntry",
|
|
@@ -145,4 +151,4 @@ __all__ = [
|
|
|
145
151
|
"resolve_task",
|
|
146
152
|
]
|
|
147
153
|
|
|
148
|
-
__version__ = "0.
|
|
154
|
+
__version__ = "0.2.0"
|
|
@@ -27,6 +27,7 @@ from .models import (
|
|
|
27
27
|
LinkedInConnectTask,
|
|
28
28
|
LinkedInMessageTask,
|
|
29
29
|
Note,
|
|
30
|
+
OpportunityContactRoleType,
|
|
30
31
|
PaginatedResponse,
|
|
31
32
|
Pipeline,
|
|
32
33
|
SortOrder,
|
|
@@ -37,7 +38,7 @@ from .models import (
|
|
|
37
38
|
TaskType,
|
|
38
39
|
resolve_task,
|
|
39
40
|
)
|
|
40
|
-
from .utils import normalize_linkedin_url, prosemirror_to_markdown
|
|
41
|
+
from .utils import markdown_to_prosemirror, normalize_linkedin_url, prosemirror_to_markdown
|
|
41
42
|
|
|
42
43
|
|
|
43
44
|
class ApolloClient:
|
|
@@ -493,6 +494,28 @@ class ApolloClient:
|
|
|
493
494
|
page=1,
|
|
494
495
|
)
|
|
495
496
|
|
|
497
|
+
async def list_opportunity_contact_role_types(
|
|
498
|
+
self,
|
|
499
|
+
) -> PaginatedResponse[OpportunityContactRoleType]:
|
|
500
|
+
"""List all opportunity contact role types (undocumented endpoint).
|
|
501
|
+
|
|
502
|
+
Returns role type definitions (e.g., Decision Maker, Buyer, Champion)
|
|
503
|
+
that map to OpportunityRoleEntry.opportunity_contact_role_type_id.
|
|
504
|
+
|
|
505
|
+
Returns:
|
|
506
|
+
Paginated response with OpportunityContactRoleType items
|
|
507
|
+
"""
|
|
508
|
+
result = await self._post("/opportunity_contact_role_types/search", {})
|
|
509
|
+
role_types = [
|
|
510
|
+
OpportunityContactRoleType.model_validate(rt)
|
|
511
|
+
for rt in result.get("opportunity_contact_role_types", [])
|
|
512
|
+
]
|
|
513
|
+
return PaginatedResponse[OpportunityContactRoleType](
|
|
514
|
+
items=role_types,
|
|
515
|
+
total=len(role_types),
|
|
516
|
+
page=1,
|
|
517
|
+
)
|
|
518
|
+
|
|
496
519
|
# ========================================================================
|
|
497
520
|
# ENRICHMENT
|
|
498
521
|
# ========================================================================
|
|
@@ -574,14 +597,23 @@ class ApolloClient:
|
|
|
574
597
|
async def create_note(
|
|
575
598
|
self,
|
|
576
599
|
content: str,
|
|
600
|
+
title: str | None = None,
|
|
601
|
+
*,
|
|
577
602
|
contact_ids: list[str] | None = None,
|
|
578
603
|
account_ids: list[str] | None = None,
|
|
579
604
|
opportunity_ids: list[str] | None = None,
|
|
580
605
|
) -> dict:
|
|
581
606
|
"""Create a note.
|
|
582
607
|
|
|
608
|
+
Apollo stores note bodies in the ``content`` field as ProseMirror JSON,
|
|
609
|
+
so the plain text / Markdown ``content`` is converted via
|
|
610
|
+
:func:`~qodev_apollo_api.utils.markdown_to_prosemirror` before posting.
|
|
611
|
+
(Posting ``{"note": <plaintext>}`` is silently ignored by Apollo and
|
|
612
|
+
produces an empty note.)
|
|
613
|
+
|
|
583
614
|
Args:
|
|
584
|
-
content: Note
|
|
615
|
+
content: Note body as plain text / Markdown.
|
|
616
|
+
title: Optional note title (rendered as the ProseMirror noteTitle).
|
|
585
617
|
contact_ids: List of contact IDs to associate
|
|
586
618
|
account_ids: List of account IDs to associate
|
|
587
619
|
opportunity_ids: List of opportunity IDs to associate
|
|
@@ -589,7 +621,9 @@ class ApolloClient:
|
|
|
589
621
|
Returns:
|
|
590
622
|
Raw API response with created note data
|
|
591
623
|
"""
|
|
592
|
-
data: dict[str, str | list[str]] = {
|
|
624
|
+
data: dict[str, str | list[str]] = {
|
|
625
|
+
"content": markdown_to_prosemirror(content, title=title)
|
|
626
|
+
}
|
|
593
627
|
if contact_ids:
|
|
594
628
|
data["contact_ids"] = contact_ids
|
|
595
629
|
if account_ids:
|
|
@@ -599,6 +633,17 @@ class ApolloClient:
|
|
|
599
633
|
|
|
600
634
|
return await self._post("/notes", data)
|
|
601
635
|
|
|
636
|
+
async def delete_note(self, note_id: str) -> dict:
|
|
637
|
+
"""Delete a note by ID.
|
|
638
|
+
|
|
639
|
+
Args:
|
|
640
|
+
note_id: Apollo note ID.
|
|
641
|
+
|
|
642
|
+
Returns:
|
|
643
|
+
Raw API response.
|
|
644
|
+
"""
|
|
645
|
+
return await self._request("DELETE", f"/notes/{note_id}")
|
|
646
|
+
|
|
602
647
|
# ========================================================================
|
|
603
648
|
# ACTIVITIES
|
|
604
649
|
# ========================================================================
|
|
@@ -1058,19 +1103,19 @@ class ApolloClient:
|
|
|
1058
1103
|
# ========================================================================
|
|
1059
1104
|
|
|
1060
1105
|
async def search_conversations(
|
|
1061
|
-
self, page: int = 1, limit: int =
|
|
1106
|
+
self, page: int = 1, limit: int = 25, **filters
|
|
1062
1107
|
) -> PaginatedResponse[Conversation]:
|
|
1063
1108
|
"""Search recorded conversations (Zoom/Teams/Meet).
|
|
1064
1109
|
|
|
1065
1110
|
Args:
|
|
1066
1111
|
page: Page number (default 1)
|
|
1067
|
-
limit: Results per page (default
|
|
1112
|
+
limit: Results per page (default 25, max 25)
|
|
1068
1113
|
**filters: Additional filters
|
|
1069
1114
|
|
|
1070
1115
|
Returns:
|
|
1071
1116
|
Paginated response with Conversation items
|
|
1072
1117
|
"""
|
|
1073
|
-
data = {"page": page, "per_page": min(limit,
|
|
1118
|
+
data = {"page": page, "per_page": min(limit, 25), **filters}
|
|
1074
1119
|
result = await self._post("/conversations/search", data)
|
|
1075
1120
|
|
|
1076
1121
|
conversations = [Conversation.model_validate(c) for c in result.get("conversations", [])]
|
|
@@ -6,12 +6,12 @@ All models use extra="allow" to capture any new/undocumented API fields.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
from datetime import datetime
|
|
9
|
+
from datetime import date, datetime
|
|
10
10
|
from decimal import Decimal
|
|
11
11
|
from enum import StrEnum
|
|
12
12
|
from typing import Annotated, Any, Generic, Literal, TypeAlias, TypeVar
|
|
13
13
|
|
|
14
|
-
from pydantic import BaseModel, ConfigDict, Discriminator, Field, TypeAdapter
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Discriminator, Field, TypeAdapter, ValidationError
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class ApolloModel(BaseModel):
|
|
@@ -183,6 +183,17 @@ class OpportunityContactRole(ApolloModel):
|
|
|
183
183
|
updated_at: datetime | None = None
|
|
184
184
|
|
|
185
185
|
|
|
186
|
+
class OpportunityContactRoleType(ApolloModel):
|
|
187
|
+
"""Role type definition for opportunity contact roles (e.g., Decision Maker, Buyer)."""
|
|
188
|
+
|
|
189
|
+
id: str
|
|
190
|
+
name: str | None = None
|
|
191
|
+
team_id: str | None = None
|
|
192
|
+
crm_api_name: str | None = None
|
|
193
|
+
crm_label: str | None = None
|
|
194
|
+
display_order: float | None = None
|
|
195
|
+
|
|
196
|
+
|
|
186
197
|
class CrmNote(ApolloModel):
|
|
187
198
|
"""CRM-synced note reference."""
|
|
188
199
|
|
|
@@ -346,8 +357,8 @@ class EmploymentHistory(ApolloModel):
|
|
|
346
357
|
title: str | None = None
|
|
347
358
|
organization_name: str | None = None
|
|
348
359
|
organization_id: str | None = None
|
|
349
|
-
start_date:
|
|
350
|
-
end_date:
|
|
360
|
+
start_date: date | None = None
|
|
361
|
+
end_date: date | None = None
|
|
351
362
|
|
|
352
363
|
# Additional fields from API
|
|
353
364
|
key: str | None = None
|
|
@@ -554,8 +565,8 @@ class Deal(ApolloModel):
|
|
|
554
565
|
created_by_id: str | None = None
|
|
555
566
|
|
|
556
567
|
# Close
|
|
557
|
-
closed_date:
|
|
558
|
-
actual_close_date:
|
|
568
|
+
closed_date: datetime | None = None
|
|
569
|
+
actual_close_date: datetime | None = None
|
|
559
570
|
is_closed: bool | None = None
|
|
560
571
|
is_won: bool | None = None
|
|
561
572
|
closed_lost_reason: str | None = None
|
|
@@ -563,7 +574,7 @@ class Deal(ApolloModel):
|
|
|
563
574
|
|
|
564
575
|
# Next Steps
|
|
565
576
|
next_step: str | None = None
|
|
566
|
-
next_step_date:
|
|
577
|
+
next_step_date: datetime | None = None
|
|
567
578
|
next_step_last_updated_at: datetime | None = None
|
|
568
579
|
current_solutions: str | None = None
|
|
569
580
|
deal_source: str | None = None
|
|
@@ -775,6 +786,8 @@ class TaskType(StrEnum):
|
|
|
775
786
|
LINKEDIN_ACTIONS = "linkedin_actions"
|
|
776
787
|
CONTACT_ACTION_ITEM = "contact_action_item"
|
|
777
788
|
ACCOUNT_ACTION_ITEM = "account_action_item"
|
|
789
|
+
ACTION_ITEM = "action_item"
|
|
790
|
+
OTHER = "other"
|
|
778
791
|
|
|
779
792
|
|
|
780
793
|
class LinkedInTemplate(ApolloModel):
|
|
@@ -919,6 +932,24 @@ class AccountActionItemTask(BaseTask):
|
|
|
919
932
|
type: Literal[TaskType.ACCOUNT_ACTION_ITEM] = TaskType.ACCOUNT_ACTION_ITEM
|
|
920
933
|
|
|
921
934
|
|
|
935
|
+
class ActionItemTask(BaseTask):
|
|
936
|
+
"""Generic action item task."""
|
|
937
|
+
|
|
938
|
+
type: Literal[TaskType.ACTION_ITEM] = TaskType.ACTION_ITEM
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
class OtherTask(BaseTask):
|
|
942
|
+
"""Fallback for task types not yet modelled in the library.
|
|
943
|
+
|
|
944
|
+
Used by resolve_task() when the API returns a type value that doesn't
|
|
945
|
+
match any known TaskType variant. The raw type string from the API
|
|
946
|
+
is preserved in ``original_type``.
|
|
947
|
+
"""
|
|
948
|
+
|
|
949
|
+
type: Literal[TaskType.OTHER] = TaskType.OTHER
|
|
950
|
+
original_type: str
|
|
951
|
+
|
|
952
|
+
|
|
922
953
|
# ---------------------------------------------------------------------------
|
|
923
954
|
# Discriminated union for polymorphic task deserialization
|
|
924
955
|
# ---------------------------------------------------------------------------
|
|
@@ -934,7 +965,8 @@ _DiscriminatedTask = Annotated[
|
|
|
934
965
|
| LinkedInViewProfileTask
|
|
935
966
|
| LinkedInActionsTask
|
|
936
967
|
| ContactActionItemTask
|
|
937
|
-
| AccountActionItemTask
|
|
968
|
+
| AccountActionItemTask
|
|
969
|
+
| ActionItemTask,
|
|
938
970
|
Discriminator("type"),
|
|
939
971
|
]
|
|
940
972
|
|
|
@@ -950,6 +982,8 @@ Task: TypeAlias = (
|
|
|
950
982
|
| LinkedInActionsTask
|
|
951
983
|
| ContactActionItemTask
|
|
952
984
|
| AccountActionItemTask
|
|
985
|
+
| ActionItemTask
|
|
986
|
+
| OtherTask
|
|
953
987
|
)
|
|
954
988
|
|
|
955
989
|
_task_adapter: TypeAdapter[_DiscriminatedTask] = TypeAdapter(_DiscriminatedTask)
|
|
@@ -958,9 +992,18 @@ _task_adapter: TypeAdapter[_DiscriminatedTask] = TypeAdapter(_DiscriminatedTask)
|
|
|
958
992
|
def resolve_task(data: dict[str, Any]) -> Task:
|
|
959
993
|
"""Validate a raw task dict into the most specific Task subclass.
|
|
960
994
|
|
|
961
|
-
|
|
995
|
+
Falls back to OtherTask for unknown task types. The raw type string
|
|
996
|
+
is preserved in ``OtherTask.original_type``.
|
|
997
|
+
Raises ValidationError only if the data is structurally invalid
|
|
998
|
+
(e.g. missing required ``id`` field).
|
|
962
999
|
"""
|
|
963
|
-
|
|
1000
|
+
try:
|
|
1001
|
+
return _task_adapter.validate_python(data)
|
|
1002
|
+
except ValidationError:
|
|
1003
|
+
raw_type = data.get("type")
|
|
1004
|
+
if raw_type is None:
|
|
1005
|
+
raise
|
|
1006
|
+
return OtherTask.model_validate({**data, "type": "other", "original_type": raw_type})
|
|
964
1007
|
|
|
965
1008
|
|
|
966
1009
|
class Email(ApolloModel):
|
|
@@ -1127,7 +1170,7 @@ class CallSummaryNextStep(ApolloModel):
|
|
|
1127
1170
|
|
|
1128
1171
|
id: str | None = None
|
|
1129
1172
|
step: str | None = None
|
|
1130
|
-
due_at:
|
|
1173
|
+
due_at: datetime | None = None
|
|
1131
1174
|
action_type: str | None = None
|
|
1132
1175
|
task_id: str | None = None
|
|
1133
1176
|
participant_id: str | None = None
|
|
@@ -1209,7 +1252,7 @@ class NewsArticle(BaseModel):
|
|
|
1209
1252
|
|
|
1210
1253
|
title: str | None = None
|
|
1211
1254
|
url: str | None = None
|
|
1212
|
-
published_at:
|
|
1255
|
+
published_at: datetime | None = None
|
|
1213
1256
|
category: str | None = None
|
|
1214
1257
|
summary: str | None = None
|
|
1215
1258
|
|
|
@@ -1221,7 +1264,7 @@ class JobPosting(BaseModel):
|
|
|
1221
1264
|
location: str | None = None
|
|
1222
1265
|
department: str | None = None
|
|
1223
1266
|
url: str | None = None
|
|
1224
|
-
posted_at:
|
|
1267
|
+
posted_at: datetime | None = None
|
|
1225
1268
|
|
|
1226
1269
|
|
|
1227
1270
|
# ============================================================================
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"""Utility functions for Apollo client.
|
|
2
2
|
|
|
3
|
-
Includes ProseMirror JSON
|
|
3
|
+
Includes ProseMirror JSON <-> Markdown conversion for notes.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import json
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
_ORDERED_ITEM_RE = re.compile(r"^\d+\.\s+(.*)$")
|
|
7
10
|
|
|
8
11
|
|
|
9
12
|
def prosemirror_to_markdown(content_json: str) -> tuple[str, str]:
|
|
@@ -103,6 +106,66 @@ def _extract_text_from_list_item(item: dict) -> str:
|
|
|
103
106
|
return " ".join(text_parts)
|
|
104
107
|
|
|
105
108
|
|
|
109
|
+
def markdown_to_prosemirror(content: str, title: str | None = None) -> str:
|
|
110
|
+
"""Convert plain text / lightweight Markdown to a ProseMirror JSON string.
|
|
111
|
+
|
|
112
|
+
Apollo's ``POST /notes`` stores the note body in the ``content`` field as a
|
|
113
|
+
ProseMirror JSON string; this is the inverse of :func:`prosemirror_to_markdown`
|
|
114
|
+
for the node types Apollo notes use.
|
|
115
|
+
|
|
116
|
+
Supports:
|
|
117
|
+
* an optional note title (``noteTitle`` node)
|
|
118
|
+
* paragraphs (one per line; blank lines become empty paragraphs)
|
|
119
|
+
* bullet lists (lines starting with ``- `` or ``* ``)
|
|
120
|
+
* ordered lists (lines starting with ``1. ``)
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
content: Note body as plain text / Markdown.
|
|
124
|
+
title: Optional note title (rendered as the ProseMirror ``noteTitle``).
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
ProseMirror document as a JSON string, ready to post to ``content``.
|
|
128
|
+
"""
|
|
129
|
+
nodes: list[dict] = []
|
|
130
|
+
if title:
|
|
131
|
+
nodes.append({"type": "noteTitle", "content": [{"type": "text", "text": title}]})
|
|
132
|
+
|
|
133
|
+
def _paragraph(text: str) -> dict:
|
|
134
|
+
# Empty text nodes are invalid in ProseMirror — emit a bare paragraph.
|
|
135
|
+
if not text:
|
|
136
|
+
return {"type": "paragraph"}
|
|
137
|
+
return {"type": "paragraph", "content": [{"type": "text", "text": text}]}
|
|
138
|
+
|
|
139
|
+
def _list_item(text: str) -> dict:
|
|
140
|
+
return {"type": "listItem", "content": [_paragraph(text)]}
|
|
141
|
+
|
|
142
|
+
lines = (content or "").split("\n")
|
|
143
|
+
i = 0
|
|
144
|
+
while i < len(lines):
|
|
145
|
+
stripped = lines[i].strip()
|
|
146
|
+
|
|
147
|
+
if stripped[:2] in ("- ", "* "):
|
|
148
|
+
items = []
|
|
149
|
+
while i < len(lines) and lines[i].strip()[:2] in ("- ", "* "):
|
|
150
|
+
items.append(_list_item(lines[i].strip()[2:].strip()))
|
|
151
|
+
i += 1
|
|
152
|
+
nodes.append({"type": "bulletList", "content": items})
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
if _ORDERED_ITEM_RE.match(stripped):
|
|
156
|
+
items = []
|
|
157
|
+
while i < len(lines) and (m := _ORDERED_ITEM_RE.match(lines[i].strip())):
|
|
158
|
+
items.append(_list_item(m.group(1).strip()))
|
|
159
|
+
i += 1
|
|
160
|
+
nodes.append({"type": "orderedList", "content": items})
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
nodes.append(_paragraph(lines[i]))
|
|
164
|
+
i += 1
|
|
165
|
+
|
|
166
|
+
return json.dumps({"type": "doc", "content": nodes}, ensure_ascii=False)
|
|
167
|
+
|
|
168
|
+
|
|
106
169
|
def normalize_linkedin_url(url: str) -> str:
|
|
107
170
|
"""Normalize LinkedIn URL for comparison.
|
|
108
171
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Tests for Apollo client methods."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
4
5
|
from datetime import UTC, datetime
|
|
5
6
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
@@ -27,6 +28,7 @@ from qodev_apollo_api.models import (
|
|
|
27
28
|
LinkedInConnectTask,
|
|
28
29
|
LinkedInMessageTask,
|
|
29
30
|
Note,
|
|
31
|
+
OpportunityContactRoleType,
|
|
30
32
|
PaginatedResponse,
|
|
31
33
|
Pipeline,
|
|
32
34
|
SortOrder,
|
|
@@ -412,7 +414,21 @@ async def test_search_conversations(client: ApolloClient):
|
|
|
412
414
|
assert len(result.items) == 1
|
|
413
415
|
assert isinstance(result.items[0], Conversation)
|
|
414
416
|
|
|
415
|
-
|
|
417
|
+
call_args = client._client.request.call_args
|
|
418
|
+
assert call_args[0] == ("POST", "/conversations/search")
|
|
419
|
+
assert call_args[1]["json"]["per_page"] == 25
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
async def test_search_conversations_caps_limit(client: ApolloClient):
|
|
423
|
+
"""Test search_conversations caps limit at 25 (Apollo API maximum)."""
|
|
424
|
+
client._client.request.return_value = _make_response(
|
|
425
|
+
{"conversations": [], "pagination": {"total_entries": 0}}
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
await client.search_conversations(limit=100)
|
|
429
|
+
|
|
430
|
+
payload = client._client.request.call_args[1]["json"]
|
|
431
|
+
assert payload["per_page"] == 25
|
|
416
432
|
|
|
417
433
|
|
|
418
434
|
# ============================================================================
|
|
@@ -545,6 +561,31 @@ async def test_list_all_stages(client: ApolloClient):
|
|
|
545
561
|
assert len(result.items) == 2
|
|
546
562
|
|
|
547
563
|
|
|
564
|
+
async def test_list_opportunity_contact_role_types(client: ApolloClient):
|
|
565
|
+
"""Test POST /opportunity_contact_role_types/search returns role types."""
|
|
566
|
+
client._client.request.return_value = _make_response(
|
|
567
|
+
{
|
|
568
|
+
"opportunity_contact_role_types": [
|
|
569
|
+
{"id": "rt1", "name": "Decision Maker", "display_order": 3.0},
|
|
570
|
+
{"id": "rt2", "name": "Buyer", "display_order": 1.0},
|
|
571
|
+
]
|
|
572
|
+
}
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
result = await client.list_opportunity_contact_role_types()
|
|
576
|
+
|
|
577
|
+
assert isinstance(result, PaginatedResponse)
|
|
578
|
+
assert len(result.items) == 2
|
|
579
|
+
assert all(isinstance(rt, OpportunityContactRoleType) for rt in result.items)
|
|
580
|
+
assert result.items[0].name == "Decision Maker"
|
|
581
|
+
assert result.items[1].name == "Buyer"
|
|
582
|
+
assert result.total == 2
|
|
583
|
+
|
|
584
|
+
call_args = client._client.request.call_args
|
|
585
|
+
assert call_args[0] == ("POST", "/opportunity_contact_role_types/search")
|
|
586
|
+
assert call_args[1]["json"] == {}
|
|
587
|
+
|
|
588
|
+
|
|
548
589
|
async def test_get_contact_stages(client: ApolloClient):
|
|
549
590
|
"""Test GET /contact_stages returns list[dict]."""
|
|
550
591
|
client._client.request.return_value = _make_response(
|
|
@@ -1069,18 +1110,23 @@ async def test_search_people(client: ApolloClient):
|
|
|
1069
1110
|
|
|
1070
1111
|
|
|
1071
1112
|
async def test_create_note(client: ApolloClient):
|
|
1072
|
-
"""
|
|
1073
|
-
client._client.request.return_value = _make_response({"note": {"id": "n1"
|
|
1113
|
+
"""create_note posts ProseMirror JSON in `content` (not plaintext in `note`)."""
|
|
1114
|
+
client._client.request.return_value = _make_response({"note": {"id": "n1"}})
|
|
1074
1115
|
|
|
1075
|
-
result = await client.create_note("Hello")
|
|
1116
|
+
result = await client.create_note("Hello", title="Greeting")
|
|
1076
1117
|
|
|
1077
|
-
assert result == {"note": {"id": "n1"
|
|
1118
|
+
assert result == {"note": {"id": "n1"}}
|
|
1078
1119
|
|
|
1079
1120
|
call_args = client._client.request.call_args
|
|
1080
1121
|
assert call_args[0] == ("POST", "/notes")
|
|
1081
1122
|
payload = call_args[1]["json"]
|
|
1082
|
-
assert
|
|
1123
|
+
assert "note" not in payload # old (ignored) field must be gone
|
|
1083
1124
|
assert "contact_ids" not in payload
|
|
1125
|
+
doc = json.loads(payload["content"])
|
|
1126
|
+
assert doc["type"] == "doc"
|
|
1127
|
+
assert doc["content"][0]["type"] == "noteTitle"
|
|
1128
|
+
assert doc["content"][0]["content"][0]["text"] == "Greeting"
|
|
1129
|
+
assert doc["content"][1]["content"][0]["text"] == "Hello"
|
|
1084
1130
|
|
|
1085
1131
|
|
|
1086
1132
|
async def test_create_note_with_associations(client: ApolloClient):
|
|
@@ -1095,12 +1141,21 @@ async def test_create_note_with_associations(client: ApolloClient):
|
|
|
1095
1141
|
)
|
|
1096
1142
|
|
|
1097
1143
|
payload = client._client.request.call_args[1]["json"]
|
|
1098
|
-
assert payload["
|
|
1144
|
+
assert "Meeting notes" in payload["content"] # body serialised into ProseMirror
|
|
1099
1145
|
assert payload["contact_ids"] == ["c1", "c2"]
|
|
1100
1146
|
assert payload["account_ids"] == ["a1"]
|
|
1101
1147
|
assert payload["opportunity_ids"] == ["o1"]
|
|
1102
1148
|
|
|
1103
1149
|
|
|
1150
|
+
async def test_delete_note(client: ApolloClient):
|
|
1151
|
+
"""delete_note issues DELETE /notes/{id}."""
|
|
1152
|
+
client._client.request.return_value = _make_response({})
|
|
1153
|
+
|
|
1154
|
+
await client.delete_note("n1")
|
|
1155
|
+
|
|
1156
|
+
client._client.request.assert_called_once_with("DELETE", "/notes/n1")
|
|
1157
|
+
|
|
1158
|
+
|
|
1104
1159
|
async def test_get_api_usage(client: ApolloClient):
|
|
1105
1160
|
"""Test POST /usage_stats/api_usage_stats."""
|
|
1106
1161
|
usage_data = {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Tests for Apollo models."""
|
|
2
2
|
|
|
3
|
-
from datetime import datetime
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
4
|
|
|
5
5
|
import pytest
|
|
6
6
|
from pydantic import ValidationError
|
|
@@ -12,6 +12,7 @@ from qodev_apollo_api.models import (
|
|
|
12
12
|
AccountDetail,
|
|
13
13
|
AccountPlaybookStatus,
|
|
14
14
|
AccountQueue,
|
|
15
|
+
ActionItemTask,
|
|
15
16
|
ApolloModel,
|
|
16
17
|
BaseTask,
|
|
17
18
|
Call,
|
|
@@ -51,8 +52,10 @@ from qodev_apollo_api.models import (
|
|
|
51
52
|
LinkedInViewProfileTask,
|
|
52
53
|
Note,
|
|
53
54
|
OpportunityContactRole,
|
|
55
|
+
OpportunityContactRoleType,
|
|
54
56
|
OpportunityRoleEntry,
|
|
55
57
|
OrganizationRef,
|
|
58
|
+
OtherTask,
|
|
56
59
|
PaginatedResponse,
|
|
57
60
|
PhoneEntry,
|
|
58
61
|
Pipeline,
|
|
@@ -564,6 +567,36 @@ def test_deal_with_embedded_account():
|
|
|
564
567
|
assert deal.account.name == "Acme Corp"
|
|
565
568
|
|
|
566
569
|
|
|
570
|
+
# ============================================================================
|
|
571
|
+
# OPPORTUNITY CONTACT ROLE TYPE
|
|
572
|
+
# ============================================================================
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def test_opportunity_contact_role_type_model():
|
|
576
|
+
"""Test OpportunityContactRoleType model validation."""
|
|
577
|
+
rt = OpportunityContactRoleType.model_validate(
|
|
578
|
+
{
|
|
579
|
+
"id": "rt1",
|
|
580
|
+
"name": "Decision Maker",
|
|
581
|
+
"team_id": "team1",
|
|
582
|
+
"crm_api_name": None,
|
|
583
|
+
"crm_label": None,
|
|
584
|
+
"display_order": 3.0,
|
|
585
|
+
}
|
|
586
|
+
)
|
|
587
|
+
assert rt.id == "rt1"
|
|
588
|
+
assert rt.name == "Decision Maker"
|
|
589
|
+
assert rt.display_order == 3.0
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def test_opportunity_contact_role_type_minimal():
|
|
593
|
+
"""Test OpportunityContactRoleType with only required fields."""
|
|
594
|
+
rt = OpportunityContactRoleType.model_validate({"id": "rt1"})
|
|
595
|
+
assert rt.id == "rt1"
|
|
596
|
+
assert rt.name is None
|
|
597
|
+
assert rt.display_order is None
|
|
598
|
+
|
|
599
|
+
|
|
567
600
|
# ============================================================================
|
|
568
601
|
# PIPELINE & STAGE
|
|
569
602
|
# ============================================================================
|
|
@@ -811,6 +844,12 @@ def test_resolve_task_account_action_item():
|
|
|
811
844
|
assert isinstance(result, AccountActionItemTask)
|
|
812
845
|
|
|
813
846
|
|
|
847
|
+
def test_resolve_task_action_item():
|
|
848
|
+
"""Test resolve_task returns ActionItemTask."""
|
|
849
|
+
result = resolve_task({"id": "1", "type": "action_item"})
|
|
850
|
+
assert isinstance(result, ActionItemTask)
|
|
851
|
+
|
|
852
|
+
|
|
814
853
|
def test_resolve_task_linkedin_interact():
|
|
815
854
|
"""Test resolve_task returns LinkedInInteractTask."""
|
|
816
855
|
result = resolve_task({"id": "1", "type": "linkedin_step_interact_post"})
|
|
@@ -842,10 +881,13 @@ def test_resolve_task_missing_type_raises():
|
|
|
842
881
|
resolve_task({"id": "1", "status": "complete"})
|
|
843
882
|
|
|
844
883
|
|
|
845
|
-
def
|
|
846
|
-
"""Test resolve_task
|
|
847
|
-
|
|
848
|
-
|
|
884
|
+
def test_resolve_task_unknown_type_falls_back_to_other_task():
|
|
885
|
+
"""Test resolve_task falls back to OtherTask for unknown type values."""
|
|
886
|
+
result = resolve_task({"id": "1", "type": "some_future_task_type"})
|
|
887
|
+
assert isinstance(result, OtherTask)
|
|
888
|
+
assert result.type == TaskType.OTHER
|
|
889
|
+
assert result.original_type == "some_future_task_type"
|
|
890
|
+
assert result.id == "1"
|
|
849
891
|
|
|
850
892
|
|
|
851
893
|
def test_task_with_full_emailer_message():
|
|
@@ -1036,7 +1078,7 @@ def test_call_summary_with_structured_items():
|
|
|
1036
1078
|
assert len(summary.next_steps) == 1
|
|
1037
1079
|
assert isinstance(summary.next_steps[0], CallSummaryNextStep)
|
|
1038
1080
|
assert summary.next_steps[0].step == "Schedule demo"
|
|
1039
|
-
assert summary.next_steps[0].due_at ==
|
|
1081
|
+
assert summary.next_steps[0].due_at == datetime(2026, 2, 24, tzinfo=UTC)
|
|
1040
1082
|
assert len(summary.pain_points) == 1
|
|
1041
1083
|
assert isinstance(summary.pain_points[0], CallSummaryPoint)
|
|
1042
1084
|
assert summary.pain_points[0].text == "Catching bugs early"
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
|
|
5
|
-
from qodev_apollo_api.utils import
|
|
5
|
+
from qodev_apollo_api.utils import (
|
|
6
|
+
markdown_to_prosemirror,
|
|
7
|
+
normalize_linkedin_url,
|
|
8
|
+
prosemirror_to_markdown,
|
|
9
|
+
)
|
|
6
10
|
|
|
7
11
|
|
|
8
12
|
def test_prosemirror_to_markdown_simple():
|
|
@@ -111,3 +115,60 @@ def test_normalize_linkedin_url_empty():
|
|
|
111
115
|
"""Test normalization of empty URL."""
|
|
112
116
|
assert normalize_linkedin_url("") == ""
|
|
113
117
|
assert normalize_linkedin_url(None) == ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_markdown_to_prosemirror_simple():
|
|
121
|
+
"""Title + body become a noteTitle node and a paragraph node."""
|
|
122
|
+
doc = json.loads(markdown_to_prosemirror("Hello world", title="My Title"))
|
|
123
|
+
assert doc["type"] == "doc"
|
|
124
|
+
assert doc["content"][0] == {
|
|
125
|
+
"type": "noteTitle",
|
|
126
|
+
"content": [{"type": "text", "text": "My Title"}],
|
|
127
|
+
}
|
|
128
|
+
assert doc["content"][1] == {
|
|
129
|
+
"type": "paragraph",
|
|
130
|
+
"content": [{"type": "text", "text": "Hello world"}],
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_markdown_to_prosemirror_blank_line_is_empty_paragraph():
|
|
135
|
+
"""Blank lines become bare paragraphs (empty text nodes are invalid)."""
|
|
136
|
+
doc = json.loads(markdown_to_prosemirror("a\n\nb"))
|
|
137
|
+
assert doc["content"] == [
|
|
138
|
+
{"type": "paragraph", "content": [{"type": "text", "text": "a"}]},
|
|
139
|
+
{"type": "paragraph"},
|
|
140
|
+
{"type": "paragraph", "content": [{"type": "text", "text": "b"}]},
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_markdown_to_prosemirror_no_title():
|
|
145
|
+
"""Without a title there is no noteTitle node."""
|
|
146
|
+
doc = json.loads(markdown_to_prosemirror("body only"))
|
|
147
|
+
assert all(n["type"] != "noteTitle" for n in doc["content"])
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_markdown_to_prosemirror_lists():
|
|
151
|
+
"""Bullet and ordered list lines become bulletList / orderedList nodes."""
|
|
152
|
+
doc = json.loads(markdown_to_prosemirror("- one\n- two\n1. first\n2. second"))
|
|
153
|
+
types = [n["type"] for n in doc["content"]]
|
|
154
|
+
assert types == ["bulletList", "orderedList"]
|
|
155
|
+
assert len(doc["content"][0]["content"]) == 2 # two bullet items
|
|
156
|
+
assert doc["content"][0]["content"][0]["content"][0]["content"][0]["text"] == "one"
|
|
157
|
+
assert doc["content"][1]["content"][1]["content"][0]["content"][0]["text"] == "second"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_markdown_prosemirror_roundtrip():
|
|
161
|
+
"""markdown_to_prosemirror output is readable back by prosemirror_to_markdown."""
|
|
162
|
+
pm = markdown_to_prosemirror("First para\n\nSecond para", title="Round Trip")
|
|
163
|
+
title, markdown = prosemirror_to_markdown(pm)
|
|
164
|
+
assert title == "Round Trip"
|
|
165
|
+
assert markdown == "First para\n\nSecond para"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_markdown_to_prosemirror_empty():
|
|
169
|
+
"""Empty content yields a valid empty doc."""
|
|
170
|
+
doc = json.loads(markdown_to_prosemirror(""))
|
|
171
|
+
assert doc["type"] == "doc"
|
|
172
|
+
# No invalid empty text nodes anywhere.
|
|
173
|
+
for node in doc["content"]:
|
|
174
|
+
assert node.get("content") != [{"type": "text", "text": ""}]
|
|
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
|
{qodev_apollo_api-0.1.2 → qodev_apollo_api-0.2.0}/tests/integration/validate_email_task_flow.py
RENAMED
|
File without changes
|
|
File without changes
|