magicfeedback 1.0.19__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.
- deepdots_sdk/__init__.py +12 -0
- deepdots_sdk/api/__init__.py +0 -0
- deepdots_sdk/api/campaigns.py +5 -0
- deepdots_sdk/api/companies.py +5 -0
- deepdots_sdk/api/contacts.py +5 -0
- deepdots_sdk/api/feedback.py +5 -0
- deepdots_sdk/api/integrations_questions.py +5 -0
- deepdots_sdk/api/metrics.py +5 -0
- deepdots_sdk/api/products.py +5 -0
- deepdots_sdk/api/reports.py +5 -0
- deepdots_sdk/api/requests.py +5 -0
- deepdots_sdk/api/signals.py +5 -0
- deepdots_sdk/auth.py +5 -0
- deepdots_sdk/client.py +5 -0
- deepdots_sdk/logging_config.py +5 -0
- deepdots_sdk/utils/__init__.py +0 -0
- deepdots_sdk/utils/request.py +5 -0
- magicfeedback-1.0.19.dist-info/METADATA +241 -0
- magicfeedback-1.0.19.dist-info/RECORD +39 -0
- magicfeedback-1.0.19.dist-info/WHEEL +5 -0
- magicfeedback-1.0.19.dist-info/licenses/LICENCE +19 -0
- magicfeedback-1.0.19.dist-info/top_level.txt +2 -0
- magicfeedback_sdk/__init__.py +3 -0
- magicfeedback_sdk/api/__init__.py +0 -0
- magicfeedback_sdk/api/campaigns.py +47 -0
- magicfeedback_sdk/api/companies.py +22 -0
- magicfeedback_sdk/api/contacts.py +31 -0
- magicfeedback_sdk/api/feedback.py +63 -0
- magicfeedback_sdk/api/integrations_questions.py +15 -0
- magicfeedback_sdk/api/metrics.py +15 -0
- magicfeedback_sdk/api/products.py +15 -0
- magicfeedback_sdk/api/reports.py +51 -0
- magicfeedback_sdk/api/requests.py +94 -0
- magicfeedback_sdk/api/signals.py +45 -0
- magicfeedback_sdk/auth.py +217 -0
- magicfeedback_sdk/client.py +117 -0
- magicfeedback_sdk/logging_config.py +7 -0
- magicfeedback_sdk/utils/__init__.py +0 -0
- magicfeedback_sdk/utils/request.py +16 -0
deepdots_sdk/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Deepdots Python SDK.
|
|
2
|
+
|
|
3
|
+
``Deepdots`` is the current name of the company formerly called MagicFeedback.
|
|
4
|
+
This package is the forward-looking import name; the implementation still lives
|
|
5
|
+
in :mod:`magicfeedback_sdk` and is re-exported here module by module, so both
|
|
6
|
+
import paths expose the very same objects. Existing code importing
|
|
7
|
+
``magicfeedback_sdk`` keeps working unchanged.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from magicfeedback_sdk.client import Deepdots, MagicFeedback
|
|
11
|
+
|
|
12
|
+
__all__ = ["Deepdots", "MagicFeedback"]
|
|
File without changes
|
deepdots_sdk/auth.py
ADDED
deepdots_sdk/client.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: magicfeedback
|
|
3
|
+
Version: 1.0.19
|
|
4
|
+
Summary: SDK for the Deepdots API (formerly MagicFeedback)
|
|
5
|
+
Home-page: https://github.com/MagicFeedback/magicfeedback_python_sdk
|
|
6
|
+
Author: Francisco Arias
|
|
7
|
+
Author-email: Francisco Arias <farias@magicfeedback.io>
|
|
8
|
+
Project-URL: Homepage, https://github.com/MagicFeedback/magicfeedback_python_sdk
|
|
9
|
+
Project-URL: Issues, https://github.com/MagicFeedback/magicfeedback_python_sdk/issues
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENCE
|
|
16
|
+
Requires-Dist: requests>=2.0.0
|
|
17
|
+
Requires-Dist: google-cloud-pubsub>=2.0.0
|
|
18
|
+
Requires-Dist: google-cloud-datastore>=2.16.0
|
|
19
|
+
Dynamic: author
|
|
20
|
+
Dynamic: home-page
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
Dynamic: requires-python
|
|
23
|
+
|
|
24
|
+
# Deepdots Python SDK
|
|
25
|
+
|
|
26
|
+
Python SDK for the Deepdots API (the company was formerly called MagicFeedback).
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install deepdots
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The original distribution is still published and still works:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install magicfeedback
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Naming
|
|
41
|
+
|
|
42
|
+
MagicFeedback was renamed **Deepdots**. Both sets of names work and refer to the
|
|
43
|
+
*same* objects, so **no existing code needs to change**:
|
|
44
|
+
|
|
45
|
+
| | Current name | Original name |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| PyPI distribution | `deepdots` | `magicfeedback` |
|
|
48
|
+
| Import package | `deepdots_sdk` | `magicfeedback_sdk` |
|
|
49
|
+
| Client class | `Deepdots` | `MagicFeedback` |
|
|
50
|
+
|
|
51
|
+
Distribution names are written lowercase throughout — that is the packaging
|
|
52
|
+
convention, and PyPI treats names case-insensitively anyway, so
|
|
53
|
+
`pip install MagicFeedback` keeps working for anyone who has it written that way.
|
|
54
|
+
|
|
55
|
+
`deepdots_sdk` re-exports `magicfeedback_sdk` module by module, and `Deepdots`
|
|
56
|
+
is the same class object as `MagicFeedback` — `MagicFeedback is Deepdots` is
|
|
57
|
+
`True`, so `isinstance()` checks and subclasses behave identically. Submodule
|
|
58
|
+
imports work under either name (`from deepdots_sdk.api.feedback import
|
|
59
|
+
FeedbackAPI`). New code should prefer the Deepdots names.
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from deepdots_sdk import Deepdots
|
|
65
|
+
|
|
66
|
+
client = Deepdots("email", "password")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The original names remain fully supported:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from magicfeedback_sdk import MagicFeedback
|
|
73
|
+
|
|
74
|
+
client = MagicFeedback("email", "password")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Authentication
|
|
78
|
+
|
|
79
|
+
The bearer token is resolved from one of two sources, selected with
|
|
80
|
+
`auth_source`:
|
|
81
|
+
|
|
82
|
+
- `"datastore"` **(default)** — read the token cached in Google Cloud Datastore
|
|
83
|
+
by the `update-token` job (kind `token-storage`, email
|
|
84
|
+
`robot@magicfeedback.io`, database `shared`). This avoids an Identity Platform
|
|
85
|
+
login on every use. If the cached token is missing, stale (older than
|
|
86
|
+
`token_max_age_min`, default 50 min) or Datastore is unreachable, the client
|
|
87
|
+
falls back to Identity Platform using `email`/`password`.
|
|
88
|
+
- `"identity"` — always log in via Identity Platform (`signInWithPassword`), the
|
|
89
|
+
original behaviour, with no Datastore lookup.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
# Datastore-cached token (default), with Identity Platform fallback.
|
|
93
|
+
# email/password are only needed for the fallback.
|
|
94
|
+
client = MagicFeedback("email", "password")
|
|
95
|
+
|
|
96
|
+
# Tune the Datastore lookup (all optional; shown with their defaults):
|
|
97
|
+
client = MagicFeedback(
|
|
98
|
+
"email", "password",
|
|
99
|
+
auth_source="datastore",
|
|
100
|
+
gcp_project_id=None, # None => inferred from Application Default Credentials
|
|
101
|
+
datastore_database_id="shared",
|
|
102
|
+
token_kind="token-storage",
|
|
103
|
+
token_email="robot@magicfeedback.io",
|
|
104
|
+
token_max_age_min=50,
|
|
105
|
+
datastore_timeout_s=5.0, # cap the lookup so the fallback stays fast
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Original behaviour — always mint a fresh token via Identity Platform:
|
|
109
|
+
client = MagicFeedback("email", "password", auth_source="identity")
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The Datastore lookup is bounded by `datastore_timeout_s` (default 5s): if the
|
|
113
|
+
cache is unreachable or the credentials are stale, the client falls back to
|
|
114
|
+
Identity Platform within that budget instead of blocking on the Datastore
|
|
115
|
+
client's default ~60s retry deadline.
|
|
116
|
+
|
|
117
|
+
The Datastore path needs the `google-cloud-datastore` package (installed as a
|
|
118
|
+
dependency) and Google Application Default Credentials with read access to the
|
|
119
|
+
token entity (`gcloud auth application-default login` or
|
|
120
|
+
`GOOGLE_APPLICATION_CREDENTIALS`).
|
|
121
|
+
|
|
122
|
+
Helper methods:
|
|
123
|
+
|
|
124
|
+
- `client.refresh_token()` — re-resolve the token (same `auth_source`) and
|
|
125
|
+
update the auth header in place across all sub-API clients. Useful for
|
|
126
|
+
long-lived clients whose token has expired.
|
|
127
|
+
- `client.auth.get_token_from_datastore(allow_stale=False)` — read the cached
|
|
128
|
+
token directly; returns `None` when missing, stale or unreachable.
|
|
129
|
+
|
|
130
|
+
## API Reference
|
|
131
|
+
|
|
132
|
+
### `client.feedbacks`
|
|
133
|
+
- `create(feedback)` — creates a new feedback item. Required fields: `name`, `type`, `identity`, `integrationId`, `companyId`, `productId`.
|
|
134
|
+
- `get(filter=None)` — lists feedback items.
|
|
135
|
+
- `get_id(feedback_id, filter=None)` — retrieves a specific feedback item.
|
|
136
|
+
- `update(feedback_id, feedback)` — updates a feedback item.
|
|
137
|
+
- `delete(feedback_id)` — deletes a feedback item.
|
|
138
|
+
- `upload_attachment(feedback_id, file_path, filename=None, extra_data=None)` — uploads a file and attaches it to a feedback.
|
|
139
|
+
|
|
140
|
+
### `client.contacts`
|
|
141
|
+
- `create(contact)`, `get(filter=None)`, `update(contact_id, contact)`, `delete(contact_id)`
|
|
142
|
+
|
|
143
|
+
### `client.campaigns`
|
|
144
|
+
- `create(campaign)`, `get(filter=None)`
|
|
145
|
+
- `create_session(campaign_id, session)`, `get_sessions(campaign_id, filter=None)`, `get_sessions_feedbacks(campaign_id, filter=None)`
|
|
146
|
+
|
|
147
|
+
### `client.metrics`
|
|
148
|
+
- `get(filter=None)`
|
|
149
|
+
|
|
150
|
+
### `client.products`
|
|
151
|
+
- `get(filter=None)`
|
|
152
|
+
|
|
153
|
+
### `client.companies`
|
|
154
|
+
- `get(filter=None)`, `get_id(id, filter=None)`
|
|
155
|
+
|
|
156
|
+
### `client.integrations_questions`
|
|
157
|
+
- `get(integration_id, filter=None)`
|
|
158
|
+
|
|
159
|
+
### `client.reports`
|
|
160
|
+
- `get(filter=None)`, `get_newsletter(filter=None)`, `update(report_id, report)`
|
|
161
|
+
|
|
162
|
+
### `client.requests`
|
|
163
|
+
- `get(filter=None)`, `get_id(request_id, filter=None)`, `update(request_id, request)`
|
|
164
|
+
|
|
165
|
+
To mark a request DONE/ERROR asynchronously, publish a completion event to the
|
|
166
|
+
`request-done` Pub/Sub topic (project `magicfeedback-prod-api`, topic
|
|
167
|
+
`request-done`); the `request-done` Cloud Function consumes it and PATCHes the
|
|
168
|
+
request. The SDK does not publish this itself — build the envelope with
|
|
169
|
+
`build_done_message` and publish it directly. See
|
|
170
|
+
[`examples/mark_request_done.py`](examples/mark_request_done.py).
|
|
171
|
+
|
|
172
|
+
## Examples
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
# Create a feedback
|
|
176
|
+
client.feedbacks.create({
|
|
177
|
+
"name": "Test Feedback",
|
|
178
|
+
"type": "APP",
|
|
179
|
+
"identity": "MAGICFORM",
|
|
180
|
+
"integrationId": "your-integration-id",
|
|
181
|
+
"companyId": "YOUR_COMPANY",
|
|
182
|
+
"productId": "YOUR_PRODUCT",
|
|
183
|
+
"answers": [
|
|
184
|
+
{"key": "score", "value": "4"},
|
|
185
|
+
{"key": "comment", "value": "Great service!"},
|
|
186
|
+
],
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
# Get a feedback with its attachments
|
|
190
|
+
client.feedbacks.get_id(
|
|
191
|
+
"<feedback_id>",
|
|
192
|
+
filter={"include": [{"relation": "feedbackAttachments"}]}
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Upload a file attachment
|
|
196
|
+
client.feedbacks.upload_attachment(
|
|
197
|
+
"<feedback_id>",
|
|
198
|
+
file_path="/path/to/file.pdf",
|
|
199
|
+
filename="report.pdf", # optional, defaults to file name
|
|
200
|
+
extra_data={"source": "crm"} # optional, any JSON-serialisable dict
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Mark a request DONE via the request-done Pub/Sub topic.
|
|
204
|
+
# The SDK builds the envelope; the producer publishes it directly.
|
|
205
|
+
import json
|
|
206
|
+
from google.cloud import pubsub_v1
|
|
207
|
+
from magicfeedback_sdk.api.requests import build_done_message
|
|
208
|
+
|
|
209
|
+
message = build_done_message(
|
|
210
|
+
"<request_id>",
|
|
211
|
+
"<company_id>",
|
|
212
|
+
output={"value": "…final result…"},
|
|
213
|
+
sources=["<feedbackId1>", "<feedbackId2>"], # optional
|
|
214
|
+
logs="processed 2 items", # optional
|
|
215
|
+
# success=False, error={"message": "processing failed"} # to mark ERROR
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
publisher = pubsub_v1.PublisherClient()
|
|
219
|
+
topic_path = publisher.topic_path("magicfeedback-prod-api", "request-done")
|
|
220
|
+
publisher.publish(topic_path, json.dumps(message).encode("utf-8")).result()
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Logging
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
import logging
|
|
227
|
+
client.set_logging(logging.DEBUG)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## License
|
|
231
|
+
|
|
232
|
+
MIT
|
|
233
|
+
|
|
234
|
+
## Contributing
|
|
235
|
+
|
|
236
|
+
Developing on the SDK itself — layout, tests, and how to cut a release — is
|
|
237
|
+
documented in [DEVELOPERS.md](DEVELOPERS.md).
|
|
238
|
+
|
|
239
|
+
## Contact
|
|
240
|
+
|
|
241
|
+
farias@magicfeedback.io
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
deepdots_sdk/__init__.py,sha256=tn3qquTPVC0n5-J6r3ee8q9klVBZ-XeHT2jUPfjscG0,483
|
|
2
|
+
deepdots_sdk/auth.py,sha256=yv-BD9DaoZvLH5qMXvvJaCW2I0BW6iCnQ1Wvff6qfu4,149
|
|
3
|
+
deepdots_sdk/client.py,sha256=3kH_dehZq6ny_7ugqQ7_KXUZCm1SQgsQtgINXoywwd4,179
|
|
4
|
+
deepdots_sdk/logging_config.py,sha256=vXdTKmZLAHKp-4MxB68QrA0elWzA9RG3oV1_Z5aO1yM,179
|
|
5
|
+
deepdots_sdk/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
deepdots_sdk/api/campaigns.py,sha256=JnCv4y-uN_T1aFOwdz9LUxGmQ47jVYPVz1DJwFX4KJo,169
|
|
7
|
+
deepdots_sdk/api/companies.py,sha256=RB1_mvpFfG7F4CXsDcewYsL8-4BDz9DqVNvjy2yrlCM,169
|
|
8
|
+
deepdots_sdk/api/contacts.py,sha256=qarMJk4k5ktFkoKYHVXHCJ7zuB4ABKTUj-aU8FPJtok,165
|
|
9
|
+
deepdots_sdk/api/feedback.py,sha256=ezKdvlXez1K-odYAxZF2YYPPxjC53SYTFdCAhnHAksI,165
|
|
10
|
+
deepdots_sdk/api/integrations_questions.py,sha256=b6wBnpK2WLUnNrOX-wDVHybZThdJH7X1xuX2q9DjAPY,219
|
|
11
|
+
deepdots_sdk/api/metrics.py,sha256=dgmTXhMCCVJVpCeK09dzKXbGfPK_Uyh5ZpeEgU8tm9s,161
|
|
12
|
+
deepdots_sdk/api/products.py,sha256=LQyI9cOg7CJIMjX5pBKmo5i89kMQqn9LS8x-xTmcqcM,165
|
|
13
|
+
deepdots_sdk/api/reports.py,sha256=bd1mGM85bQlbf-KuObdO4MH9H3aRnW193cUvedEfGlA,161
|
|
14
|
+
deepdots_sdk/api/requests.py,sha256=9a_MtWj2SnhodyvcsIxxwtnA8bdFZSBNnqLHxYYFfXA,207
|
|
15
|
+
deepdots_sdk/api/signals.py,sha256=Ds00AXHMs4Ownf3Rf305q2TjUQulr8DBSr_lRifioS0,161
|
|
16
|
+
deepdots_sdk/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
deepdots_sdk/utils/request.py,sha256=OhpwFvUEOT3xKnq9y-HXyUD7dPS3xoJ7DD8Mt2fGc4I,169
|
|
18
|
+
magicfeedback-1.0.19.dist-info/licenses/LICENCE,sha256=gIVSj35jJtSTUxMd2h9qra0bz8Brc-RjdBwC1_rEar0,1065
|
|
19
|
+
magicfeedback_sdk/__init__.py,sha256=emHv--vGaLa8zsJxRB-pq9obWKUeeOFsZAFTTV1OdZQ,85
|
|
20
|
+
magicfeedback_sdk/auth.py,sha256=aLlHIMxJ92gmNjM1EzV4DOVX6oksQK0UIXiWY57Fx9U,8840
|
|
21
|
+
magicfeedback_sdk/client.py,sha256=HKgZJMXS3IzEobmDckOXDAIVynat_WamZ_5GnaPIEiQ,5621
|
|
22
|
+
magicfeedback_sdk/logging_config.py,sha256=WGthGRBhLRKEnCEFmjoy3uoVdAZBL09cnvrwSmYWaMk,165
|
|
23
|
+
magicfeedback_sdk/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
24
|
+
magicfeedback_sdk/api/campaigns.py,sha256=wgvG5mh_lXFpDxxqTHSytogv1scLa8OX8q_KWXrQMDE,1924
|
|
25
|
+
magicfeedback_sdk/api/companies.py,sha256=GAJHaSlxzYBl2dod6QY-mRAcS_Vbb5gVkCLNooeU0nQ,727
|
|
26
|
+
magicfeedback_sdk/api/contacts.py,sha256=-QlRypGcjZGgU3xYNPRkDll3aHb8VBHZAqMnQO9fWfs,1213
|
|
27
|
+
magicfeedback_sdk/api/feedback.py,sha256=GWBfvAk5TjDJCDlsptT5x150ZOkHZF84BP6BSlOhXg8,2612
|
|
28
|
+
magicfeedback_sdk/api/integrations_questions.py,sha256=UR8NjnpGctKtgCM6pbetwprPKcNpU5d4JCjerbgV1xI,525
|
|
29
|
+
magicfeedback_sdk/api/metrics.py,sha256=G7m21ZPj3R1piqWFtXka3k3Y7swFNpvWXO9bkbWbcCg,463
|
|
30
|
+
magicfeedback_sdk/api/products.py,sha256=v0P0chPZi2Ck-dIiXk2BQGmyRE1YFaH6kiLoVqra0CA,465
|
|
31
|
+
magicfeedback_sdk/api/reports.py,sha256=iAQIuClhG-bw_aer7B4mv-Qx2vCWypOzK6hBj4cZrus,1687
|
|
32
|
+
magicfeedback_sdk/api/requests.py,sha256=vMqdOo3Y_eD_3pV7sttpj-n9TZoaS5FFoWVTXet49Wo,2990
|
|
33
|
+
magicfeedback_sdk/api/signals.py,sha256=o9OAlH5lHbM4z2nuxbsKfoXOXBeUzxwyR9F_tbvzZk8,1481
|
|
34
|
+
magicfeedback_sdk/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
35
|
+
magicfeedback_sdk/utils/request.py,sha256=QjiWdYrNCuzHnscNdD7N99wOcfA2HtemiDUuBAt0T3I,506
|
|
36
|
+
magicfeedback-1.0.19.dist-info/METADATA,sha256=dvEczXSxAGbOHJcImKmZkHEtZh25ozv48XumJH3QY7I,8199
|
|
37
|
+
magicfeedback-1.0.19.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
38
|
+
magicfeedback-1.0.19.dist-info/top_level.txt,sha256=oDg5H1DxkwB80gZaj6_EbLYWKf__SPVQM6t16SaEags,31
|
|
39
|
+
magicfeedback-1.0.19.dist-info/RECORD,,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2024 MagicFeedback OÜ
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from magicfeedback_sdk.utils.request import make_request
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CampaignsAPI:
|
|
5
|
+
def __init__(self, base_url, headers, logger):
|
|
6
|
+
self.base_url = base_url
|
|
7
|
+
self.headers = headers
|
|
8
|
+
self.logger = logger
|
|
9
|
+
|
|
10
|
+
def create(self, campaign):
|
|
11
|
+
url = f"{self.base_url}/campaigns"
|
|
12
|
+
required_fields = ["name", "companyId"]
|
|
13
|
+
for field in required_fields:
|
|
14
|
+
if field not in campaign:
|
|
15
|
+
raise ValueError(f"Missing required field: {field}")
|
|
16
|
+
return make_request("POST", url, self.headers, json=campaign, logger=self.logger)
|
|
17
|
+
|
|
18
|
+
def get(self, filter=None):
|
|
19
|
+
url = f"{self.base_url}/campaigns"
|
|
20
|
+
if filter:
|
|
21
|
+
import json
|
|
22
|
+
url += f"?filter={json.dumps(filter)}"
|
|
23
|
+
return make_request("GET", url, self.headers, logger=self.logger)
|
|
24
|
+
|
|
25
|
+
def create_session(self, campaign_id, session):
|
|
26
|
+
url = f"{self.base_url}/campaigns/{campaign_id}/session"
|
|
27
|
+
required_fields = ["crmContactId"]
|
|
28
|
+
for field in required_fields:
|
|
29
|
+
if field not in session:
|
|
30
|
+
raise ValueError(f"Missing required field: {field}")
|
|
31
|
+
if not session.get("crmContactId"):
|
|
32
|
+
raise ValueError("Contact ID cannot be empty.")
|
|
33
|
+
return make_request("POST", url, self.headers, json=session, logger=self.logger)
|
|
34
|
+
|
|
35
|
+
def get_sessions(self, campaign_id, filter=None):
|
|
36
|
+
url = f"{self.base_url}/campaigns/{campaign_id}/session"
|
|
37
|
+
if filter:
|
|
38
|
+
import json
|
|
39
|
+
url += f"?filter={json.dumps(filter)}"
|
|
40
|
+
return make_request("GET", url, self.headers, logger=self.logger)
|
|
41
|
+
|
|
42
|
+
def get_sessions_feedbacks(self, campaign_id, filter=None):
|
|
43
|
+
url = f"{self.base_url}/campaigns/{campaign_id}/sessions/feedback"
|
|
44
|
+
if filter:
|
|
45
|
+
import json
|
|
46
|
+
url += f"?filter={json.dumps(filter)}"
|
|
47
|
+
return make_request("GET", url, self.headers, logger=self.logger)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from magicfeedback_sdk.utils.request import make_request
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CompaniesAPI:
|
|
5
|
+
def __init__(self, base_url, headers, logger):
|
|
6
|
+
self.base_url = base_url
|
|
7
|
+
self.headers = headers
|
|
8
|
+
self.logger = logger
|
|
9
|
+
|
|
10
|
+
def get(self, filter=None):
|
|
11
|
+
url = f"{self.base_url}/companies"
|
|
12
|
+
if filter:
|
|
13
|
+
import json
|
|
14
|
+
url += f"?filter={json.dumps(filter)}"
|
|
15
|
+
return make_request("GET", url, self.headers, logger=self.logger)
|
|
16
|
+
|
|
17
|
+
def get_id(self, id, filter=None):
|
|
18
|
+
url = f"{self.base_url}/companies/{id}"
|
|
19
|
+
if filter:
|
|
20
|
+
import json
|
|
21
|
+
url += f"?filter={json.dumps(filter)}"
|
|
22
|
+
return make_request("GET", url, self.headers, logger=self.logger)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from magicfeedback_sdk.utils.request import make_request
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ContactsAPI:
|
|
5
|
+
def __init__(self, base_url, headers, logger):
|
|
6
|
+
self.base_url = base_url
|
|
7
|
+
self.headers = headers
|
|
8
|
+
self.logger = logger
|
|
9
|
+
|
|
10
|
+
def create(self, contact):
|
|
11
|
+
url = f"{self.base_url}/crm/contacts"
|
|
12
|
+
required_fields = ["name", "lastname", "email", "companyId"]
|
|
13
|
+
for field in required_fields:
|
|
14
|
+
if field not in contact:
|
|
15
|
+
raise ValueError(f"Missing required field: {field}")
|
|
16
|
+
return make_request("POST", url, self.headers, json=contact, logger=self.logger)
|
|
17
|
+
|
|
18
|
+
def get(self, filter=None):
|
|
19
|
+
url = f"{self.base_url}/crm/contacts"
|
|
20
|
+
if filter:
|
|
21
|
+
import json
|
|
22
|
+
url += f"?filter={json.dumps(filter)}"
|
|
23
|
+
return make_request("GET", url, self.headers, logger=self.logger)
|
|
24
|
+
|
|
25
|
+
def update(self, contact_id, contact):
|
|
26
|
+
url = f"{self.base_url}/crm/contacts/{contact_id}"
|
|
27
|
+
return make_request("PATCH", url, self.headers, json=contact, logger=self.logger)
|
|
28
|
+
|
|
29
|
+
def delete(self, contact_id):
|
|
30
|
+
url = f"{self.base_url}/crm/contacts/{contact_id}"
|
|
31
|
+
return make_request("DELETE", url, self.headers, logger=self.logger)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from magicfeedback_sdk.utils.request import make_request
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FeedbackAPI:
|
|
5
|
+
def __init__(self, base_url, headers, logger):
|
|
6
|
+
self.base_url = base_url
|
|
7
|
+
self.headers = headers
|
|
8
|
+
self.logger = logger
|
|
9
|
+
|
|
10
|
+
def create(self, feedback):
|
|
11
|
+
url = f"{self.base_url}/feedbacks"
|
|
12
|
+
required_fields = ["name", "type", "identity", "integrationId", "companyId", "productId"]
|
|
13
|
+
for field in required_fields:
|
|
14
|
+
if field not in feedback:
|
|
15
|
+
raise ValueError(f"Missing required field: {field}")
|
|
16
|
+
|
|
17
|
+
if "answers" in feedback and isinstance(feedback["answers"], list):
|
|
18
|
+
for answer in feedback["answers"]:
|
|
19
|
+
if "value" in answer and not isinstance(answer["value"], list):
|
|
20
|
+
answer["value"] = [answer["value"]]
|
|
21
|
+
|
|
22
|
+
return make_request("POST", url, self.headers, json=feedback, logger=self.logger)
|
|
23
|
+
|
|
24
|
+
def get_id(self, feedback_id, filter=None):
|
|
25
|
+
url = f"{self.base_url}/feedbacks/{feedback_id}"
|
|
26
|
+
if filter:
|
|
27
|
+
import json
|
|
28
|
+
url += f"?filter={json.dumps(filter)}"
|
|
29
|
+
return make_request("GET", url, self.headers, logger=self.logger)
|
|
30
|
+
|
|
31
|
+
def get(self, filter=None):
|
|
32
|
+
url = f"{self.base_url}/feedbacks"
|
|
33
|
+
if filter:
|
|
34
|
+
import json
|
|
35
|
+
url += f"?filter={json.dumps(filter)}"
|
|
36
|
+
return make_request("GET", url, self.headers, logger=self.logger)
|
|
37
|
+
|
|
38
|
+
def update(self, feedback_id, feedback):
|
|
39
|
+
url = f"{self.base_url}/feedbacks/{feedback_id}"
|
|
40
|
+
return make_request("PATCH", url, self.headers, json=feedback, logger=self.logger)
|
|
41
|
+
|
|
42
|
+
def delete(self, feedback_id):
|
|
43
|
+
url = f"{self.base_url}/feedbacks/{feedback_id}"
|
|
44
|
+
return make_request("DELETE", url, self.headers, logger=self.logger)
|
|
45
|
+
|
|
46
|
+
def upload_attachment(self, feedback_id, file_path, filename=None, extra_data=None):
|
|
47
|
+
url = f"{self.base_url}/feedbacks/{feedback_id}/attachments"
|
|
48
|
+
|
|
49
|
+
with open(file_path, "rb") as f:
|
|
50
|
+
file_bytes = f.read()
|
|
51
|
+
|
|
52
|
+
display_name = filename or file_path.split("/")[-1]
|
|
53
|
+
|
|
54
|
+
files = {"file": (display_name, file_bytes)}
|
|
55
|
+
data = {"filename": display_name}
|
|
56
|
+
if extra_data is not None:
|
|
57
|
+
import json
|
|
58
|
+
data["extraData"] = json.dumps(extra_data) if not isinstance(extra_data, str) else extra_data
|
|
59
|
+
|
|
60
|
+
# Remove Content-Type so requests sets it automatically with the multipart boundary
|
|
61
|
+
headers = {k: v for k, v in self.headers.items() if k.lower() != "content-type"}
|
|
62
|
+
|
|
63
|
+
return make_request("POST", url, headers, files=files, data=data, logger=self.logger)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from magicfeedback_sdk.utils.request import make_request
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class IntegrationsQuestionsAPI:
|
|
5
|
+
def __init__(self, base_url, headers, logger):
|
|
6
|
+
self.base_url = base_url
|
|
7
|
+
self.headers = headers
|
|
8
|
+
self.logger = logger
|
|
9
|
+
|
|
10
|
+
def get(self, integration_id, filter=None):
|
|
11
|
+
url = f"{self.base_url}/integrations/{integration_id}/questions"
|
|
12
|
+
if filter:
|
|
13
|
+
import json
|
|
14
|
+
url += f"?filter={json.dumps(filter)}"
|
|
15
|
+
return make_request("GET", url, self.headers, logger=self.logger)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from magicfeedback_sdk.utils.request import make_request
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MetricsAPI:
|
|
5
|
+
def __init__(self, base_url, headers, logger):
|
|
6
|
+
self.base_url = base_url
|
|
7
|
+
self.headers = headers
|
|
8
|
+
self.logger = logger
|
|
9
|
+
|
|
10
|
+
def get(self, filter=None):
|
|
11
|
+
url = f"{self.base_url}/metrics"
|
|
12
|
+
if filter:
|
|
13
|
+
import json
|
|
14
|
+
url += f"?filter={json.dumps(filter)}"
|
|
15
|
+
return make_request("GET", url, self.headers, logger=self.logger)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from magicfeedback_sdk.utils.request import make_request
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ProductsAPI:
|
|
5
|
+
def __init__(self, base_url, headers, logger):
|
|
6
|
+
self.base_url = base_url
|
|
7
|
+
self.headers = headers
|
|
8
|
+
self.logger = logger
|
|
9
|
+
|
|
10
|
+
def get(self, filter=None):
|
|
11
|
+
url = f"{self.base_url}/products"
|
|
12
|
+
if filter:
|
|
13
|
+
import json
|
|
14
|
+
url += f"?filter={json.dumps(filter)}"
|
|
15
|
+
return make_request("GET", url, self.headers, logger=self.logger)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# reports.py
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from magicfeedback_sdk.utils.request import make_request
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ReportsAPI:
|
|
9
|
+
"""
|
|
10
|
+
Lightweight client for reporting-related endpoints.
|
|
11
|
+
|
|
12
|
+
Currently supports:
|
|
13
|
+
- GET /reporting/report/newsletter?filter=<LoopBackFilterJSON>
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, base_url: str, headers: Dict[str, str], logger: Any = None):
|
|
17
|
+
self.base_url = base_url.rstrip("/")
|
|
18
|
+
self.headers = headers
|
|
19
|
+
self.logger = logger
|
|
20
|
+
|
|
21
|
+
def get(self, filter=None):
|
|
22
|
+
url = f"{self.base_url}/reporting/report"
|
|
23
|
+
if filter:
|
|
24
|
+
import json
|
|
25
|
+
url += f"?filter={json.dumps(filter)}"
|
|
26
|
+
return make_request("GET", url, self.headers, logger=self.logger)
|
|
27
|
+
|
|
28
|
+
def get_newsletter(self, filter=None):
|
|
29
|
+
url = f"{self.base_url}/reporting/report/newsletter"
|
|
30
|
+
if filter:
|
|
31
|
+
import json
|
|
32
|
+
url += f"?filter={json.dumps(filter)}"
|
|
33
|
+
return make_request("GET", url, self.headers, logger=self.logger)
|
|
34
|
+
|
|
35
|
+
def update(self, report_id, report):
|
|
36
|
+
url = f"{self.base_url}/reporting/report/{report_id}"
|
|
37
|
+
return make_request("PATCH", url, self.headers, json=report, logger=self.logger)
|
|
38
|
+
|
|
39
|
+
def regenerate(self, report_id, body=None):
|
|
40
|
+
"""
|
|
41
|
+
Regenerate/republish a report.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
report_id: Report UUID to regenerate
|
|
45
|
+
body: Optional request body for publish configuration
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Response from the publish endpoint
|
|
49
|
+
"""
|
|
50
|
+
url = f"{self.base_url}/reporting/report/{report_id}/publish"
|
|
51
|
+
return make_request("POST", url, self.headers, json=body or {}, logger=self.logger)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# requests.py
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
from magicfeedback_sdk.utils.request import make_request
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def build_done_message(
|
|
8
|
+
request_id: str,
|
|
9
|
+
company_id: str,
|
|
10
|
+
output: Any = None,
|
|
11
|
+
success: bool = True,
|
|
12
|
+
status: Optional[str] = None,
|
|
13
|
+
sources: Optional[List[Any]] = None,
|
|
14
|
+
sources_key: Optional[str] = None,
|
|
15
|
+
logs: Optional[str] = None,
|
|
16
|
+
quality: Optional[Dict[str, Any]] = None,
|
|
17
|
+
error: Any = None,
|
|
18
|
+
) -> Dict[str, Any]:
|
|
19
|
+
"""
|
|
20
|
+
Build the completion envelope consumed by the `request-done` Cloud Function
|
|
21
|
+
(MagicFeedback_Functions/functions/request-done). The CF PATCHes
|
|
22
|
+
/requests/{id} to DONE (or ERROR) from this payload.
|
|
23
|
+
|
|
24
|
+
The SDK does not publish this message itself — producers (e.g. notebooks /
|
|
25
|
+
workers) build the envelope with this helper and publish it to the
|
|
26
|
+
`request-done` Pub/Sub topic directly. See examples/mark_request_done.py.
|
|
27
|
+
|
|
28
|
+
Only `id`, `companyId` and `success` are always present; the rest are
|
|
29
|
+
included only when provided, to keep messages small.
|
|
30
|
+
"""
|
|
31
|
+
if not request_id:
|
|
32
|
+
raise ValueError("request_id is required")
|
|
33
|
+
if not company_id:
|
|
34
|
+
raise ValueError("company_id is required")
|
|
35
|
+
|
|
36
|
+
message: Dict[str, Any] = {
|
|
37
|
+
"id": request_id,
|
|
38
|
+
"companyId": company_id,
|
|
39
|
+
"success": bool(success),
|
|
40
|
+
}
|
|
41
|
+
if status is not None:
|
|
42
|
+
message["status"] = status
|
|
43
|
+
if output is not None:
|
|
44
|
+
message["output"] = output
|
|
45
|
+
if sources is not None:
|
|
46
|
+
message["sources"] = list(sources)
|
|
47
|
+
if sources_key is not None:
|
|
48
|
+
message["sourcesKey"] = sources_key
|
|
49
|
+
if logs is not None:
|
|
50
|
+
message["logs"] = logs
|
|
51
|
+
if quality is not None:
|
|
52
|
+
message["quality"] = quality
|
|
53
|
+
if error is not None:
|
|
54
|
+
message["error"] = error
|
|
55
|
+
return message
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RequestsAPI:
|
|
59
|
+
"""
|
|
60
|
+
Lightweight client for reporting-related endpoints.
|
|
61
|
+
|
|
62
|
+
Currently supports:
|
|
63
|
+
- GET /requests?filter=<LoopBackFilterJSON>
|
|
64
|
+
- GET /requests/{id}?filter=<LoopBackFilterJSON>
|
|
65
|
+
- PATCH /requests/{id}
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
base_url: str,
|
|
71
|
+
headers: Dict[str, str],
|
|
72
|
+
logger: Any = None,
|
|
73
|
+
):
|
|
74
|
+
self.base_url = base_url.rstrip("/")
|
|
75
|
+
self.headers = headers
|
|
76
|
+
self.logger = logger
|
|
77
|
+
|
|
78
|
+
def get(self, filter=None):
|
|
79
|
+
url = f"{self.base_url}/requests"
|
|
80
|
+
if filter:
|
|
81
|
+
import json
|
|
82
|
+
url += f"?filter={json.dumps(filter)}"
|
|
83
|
+
return make_request("GET", url, self.headers, logger=self.logger)
|
|
84
|
+
|
|
85
|
+
def get_id(self, request_id, filter=None):
|
|
86
|
+
url = f"{self.base_url}/requests/{request_id}"
|
|
87
|
+
if filter:
|
|
88
|
+
import json
|
|
89
|
+
url += f"?filter={json.dumps(filter)}"
|
|
90
|
+
return make_request("GET", url, self.headers, logger=self.logger)
|
|
91
|
+
|
|
92
|
+
def update(self, request_id, request):
|
|
93
|
+
url = f"{self.base_url}/requests/{request_id}"
|
|
94
|
+
return make_request("PATCH", url, self.headers, json=request, logger=self.logger)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# signals.py
|
|
2
|
+
from typing import Any, Dict
|
|
3
|
+
|
|
4
|
+
from magicfeedback_sdk.utils.request import make_request
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SignalsAPI:
|
|
8
|
+
"""
|
|
9
|
+
Lightweight client for signals endpoints.
|
|
10
|
+
|
|
11
|
+
Currently supports:
|
|
12
|
+
- GET /signals?filter=<LoopBackFilterJSON>
|
|
13
|
+
- GET /signals/count?filter=<LoopBackFilterJSON>
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, base_url: str, headers: Dict[str, str], logger: Any = None):
|
|
17
|
+
self.base_url = base_url.rstrip("/")
|
|
18
|
+
self.headers = headers
|
|
19
|
+
self.logger = logger
|
|
20
|
+
|
|
21
|
+
def get(self, filter=None, filter_by_metrics=None):
|
|
22
|
+
url = f"{self.base_url}/signals"
|
|
23
|
+
params = []
|
|
24
|
+
if filter:
|
|
25
|
+
import json
|
|
26
|
+
params.append(f"filter={json.dumps(filter)}")
|
|
27
|
+
if filter_by_metrics:
|
|
28
|
+
import json
|
|
29
|
+
params.append(f"filterByMetrics={json.dumps(filter_by_metrics)}")
|
|
30
|
+
if params:
|
|
31
|
+
url += "?" + "&".join(params)
|
|
32
|
+
return make_request("GET", url, self.headers, logger=self.logger)
|
|
33
|
+
|
|
34
|
+
def count(self, filter=None, filter_by_metrics=None):
|
|
35
|
+
url = f"{self.base_url}/signals/count"
|
|
36
|
+
params = []
|
|
37
|
+
if filter:
|
|
38
|
+
import json
|
|
39
|
+
params.append(f"filter={json.dumps(filter)}")
|
|
40
|
+
if filter_by_metrics:
|
|
41
|
+
import json
|
|
42
|
+
params.append(f"filterByMetrics={json.dumps(filter_by_metrics)}")
|
|
43
|
+
if params:
|
|
44
|
+
url += "?" + "&".join(params)
|
|
45
|
+
return make_request("GET", url, self.headers, logger=self.logger)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthManager:
|
|
9
|
+
"""Resolves the API bearer token from one of two sources.
|
|
10
|
+
|
|
11
|
+
* ``"datastore"`` (default) — read the token cached in Google Cloud
|
|
12
|
+
Datastore by the ``update-token`` job (kind=``token-storage``,
|
|
13
|
+
email=``robot@magicfeedback.io``, database=``shared``). This avoids a call
|
|
14
|
+
to Identity Platform on every SDK use. When the cached token is missing,
|
|
15
|
+
stale (older than ``token_max_age_min`` minutes) or Datastore is
|
|
16
|
+
unreachable, a fresh token is minted via Identity Platform as a fallback
|
|
17
|
+
(requires ``user``/``password``).
|
|
18
|
+
* ``"identity"`` — always mint a fresh token via Identity Platform
|
|
19
|
+
(``signInWithPassword``). This is the original SDK behaviour and performs
|
|
20
|
+
no Datastore lookup.
|
|
21
|
+
|
|
22
|
+
The Datastore path uses the ``google-cloud-datastore`` package (a declared
|
|
23
|
+
dependency) and Google Application Default Credentials with read access to
|
|
24
|
+
the token entity. Missing/invalid credentials or an unreachable Datastore
|
|
25
|
+
degrade to the Identity Platform fallback; the lookup is bounded by
|
|
26
|
+
``datastore_timeout_s`` so the fallback happens in seconds rather than
|
|
27
|
+
blocking on the client's default ~60s retry deadline.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
IDENTITY_URL = (
|
|
31
|
+
"https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key={key}"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
ip_key: str,
|
|
37
|
+
logger,
|
|
38
|
+
*,
|
|
39
|
+
gcp_project_id: Optional[str] = None,
|
|
40
|
+
datastore_database_id: str = "shared",
|
|
41
|
+
token_kind: str = "token-storage",
|
|
42
|
+
token_email: str = "robot@magicfeedback.io",
|
|
43
|
+
token_max_age_min: int = 50,
|
|
44
|
+
datastore_timeout_s: float = 5.0,
|
|
45
|
+
):
|
|
46
|
+
self.ip_key = ip_key
|
|
47
|
+
self.logger = logger
|
|
48
|
+
# Datastore token cache configuration (matches the update-token job).
|
|
49
|
+
self.gcp_project_id = gcp_project_id
|
|
50
|
+
self.datastore_database_id = datastore_database_id
|
|
51
|
+
self.token_kind = token_kind
|
|
52
|
+
self.token_email = token_email
|
|
53
|
+
self.token_max_age_min = token_max_age_min
|
|
54
|
+
# Wall-clock budget for the whole Datastore lookup. Without it the
|
|
55
|
+
# client's default ~60s retry deadline would make the Identity
|
|
56
|
+
# fallback block for up to a minute on stale/unreachable credentials.
|
|
57
|
+
self.datastore_timeout_s = datastore_timeout_s
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
# Public entry point
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
def get_api_key(self, user=None, password=None, source: str = "datastore") -> str:
|
|
63
|
+
"""Return a bearer token from ``source``.
|
|
64
|
+
|
|
65
|
+
``"datastore"`` prefers the cached token and falls back to Identity
|
|
66
|
+
Platform when it is missing/stale/unreachable. ``"identity"`` always
|
|
67
|
+
logs in via Identity Platform.
|
|
68
|
+
"""
|
|
69
|
+
if source == "identity":
|
|
70
|
+
return self.identity_login(user, password)
|
|
71
|
+
if source == "datastore":
|
|
72
|
+
token = self.get_token_from_datastore()
|
|
73
|
+
if token:
|
|
74
|
+
return token
|
|
75
|
+
self.logger.info(
|
|
76
|
+
"No fresh token in Datastore (kind=%s, email=%s); "
|
|
77
|
+
"falling back to Identity Platform login",
|
|
78
|
+
self.token_kind,
|
|
79
|
+
self.token_email,
|
|
80
|
+
)
|
|
81
|
+
return self.identity_login(user, password)
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"Unknown auth source {source!r}; expected 'datastore' or 'identity'"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
# Identity Platform
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
def identity_login(self, user, password) -> str:
|
|
90
|
+
if not user or not password:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
"Identity Platform login requires both user and password. "
|
|
93
|
+
"Provide them to MagicFeedback(...) or ensure a fresh token "
|
|
94
|
+
"exists in Datastore."
|
|
95
|
+
)
|
|
96
|
+
self.logger.info("Logging in with user: %s", user)
|
|
97
|
+
|
|
98
|
+
url = self.IDENTITY_URL.format(key=self.ip_key)
|
|
99
|
+
headers = {"Content-Type": "application/json"}
|
|
100
|
+
payload = json.dumps({
|
|
101
|
+
"email": user,
|
|
102
|
+
"password": password,
|
|
103
|
+
"returnSecureToken": True
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
response = requests.post(url, headers=headers, data=payload)
|
|
107
|
+
response.raise_for_status()
|
|
108
|
+
data = response.json()
|
|
109
|
+
token = data.get("idToken")
|
|
110
|
+
if not token:
|
|
111
|
+
raise RuntimeError("idToken not found in Identity Platform response")
|
|
112
|
+
return token
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
# Datastore token cache
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
def get_token_from_datastore(self, *, allow_stale: bool = False) -> Optional[str]:
|
|
118
|
+
"""Return the token cached in Datastore, or ``None``.
|
|
119
|
+
|
|
120
|
+
Returns ``None`` (rather than raising) when the token entity is missing,
|
|
121
|
+
has no ``token`` field, is stale (older than ``token_max_age_min`` and
|
|
122
|
+
``allow_stale`` is False), or Datastore is unreachable — so callers can
|
|
123
|
+
fall back to another source.
|
|
124
|
+
|
|
125
|
+
:param allow_stale: return the cached token even if older than
|
|
126
|
+
``token_max_age_min`` (still ``None`` when absent/unreachable).
|
|
127
|
+
"""
|
|
128
|
+
try:
|
|
129
|
+
entity = self._fetch_token_entity()
|
|
130
|
+
except Exception as exc: # noqa: BLE001 - Datastore optional; caller falls back
|
|
131
|
+
self.logger.warning(
|
|
132
|
+
"Datastore token lookup failed (%s); token unavailable from cache",
|
|
133
|
+
exc,
|
|
134
|
+
)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
if not entity:
|
|
138
|
+
self.logger.info(
|
|
139
|
+
"No token entity in Datastore (kind=%s, email=%s)",
|
|
140
|
+
self.token_kind,
|
|
141
|
+
self.token_email,
|
|
142
|
+
)
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
token = entity.get("token")
|
|
146
|
+
if not token:
|
|
147
|
+
self.logger.warning(
|
|
148
|
+
"Datastore token entity found but missing 'token' field "
|
|
149
|
+
"(kind=%s, email=%s)",
|
|
150
|
+
self.token_kind,
|
|
151
|
+
self.token_email,
|
|
152
|
+
)
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
age = self._entity_age_minutes(entity)
|
|
156
|
+
if age is not None:
|
|
157
|
+
self.logger.info("Cached token age: %s minutes", age)
|
|
158
|
+
if age >= self.token_max_age_min and not allow_stale:
|
|
159
|
+
self.logger.info(
|
|
160
|
+
"Datastore token is stale (age=%s min >= %s min)",
|
|
161
|
+
age,
|
|
162
|
+
self.token_max_age_min,
|
|
163
|
+
)
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
self.logger.info("Using token from Datastore cache")
|
|
167
|
+
return str(token)
|
|
168
|
+
|
|
169
|
+
def _ds_client(self):
|
|
170
|
+
from google.cloud import datastore # lazy: optional dependency
|
|
171
|
+
if self.gcp_project_id:
|
|
172
|
+
return datastore.Client(
|
|
173
|
+
project=self.gcp_project_id,
|
|
174
|
+
database=self.datastore_database_id,
|
|
175
|
+
)
|
|
176
|
+
return datastore.Client(database=self.datastore_database_id)
|
|
177
|
+
|
|
178
|
+
def _fetch_token_entity(self):
|
|
179
|
+
from google.api_core.retry import Retry # lazy: ships with datastore
|
|
180
|
+
|
|
181
|
+
client = self._ds_client()
|
|
182
|
+
query = client.query(kind=self.token_kind)
|
|
183
|
+
query.add_filter("email", "=", self.token_email)
|
|
184
|
+
# Bound the total wall-clock: a per-call ``timeout`` alone does not cap
|
|
185
|
+
# the retry loop, so also cap the retry deadline. This turns an
|
|
186
|
+
# unreachable/reauth-needed Datastore into a fast fallback instead of a
|
|
187
|
+
# ~60s stall.
|
|
188
|
+
retry = Retry(deadline=self.datastore_timeout_s)
|
|
189
|
+
results = list(
|
|
190
|
+
query.fetch(limit=1, retry=retry, timeout=self.datastore_timeout_s)
|
|
191
|
+
)
|
|
192
|
+
return results[0] if results else None
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def _entity_age_minutes(entity) -> Optional[int]:
|
|
196
|
+
"""Minutes since the entity's ``generatedAt``, or ``None`` if unknown.
|
|
197
|
+
|
|
198
|
+
Handles both a native ``datetime`` (as returned by the Datastore client)
|
|
199
|
+
and an ISO-8601 string; naive values are treated as UTC.
|
|
200
|
+
"""
|
|
201
|
+
ga = entity.get("generatedAt")
|
|
202
|
+
if not ga:
|
|
203
|
+
return None
|
|
204
|
+
if isinstance(ga, str):
|
|
205
|
+
try:
|
|
206
|
+
if ga.endswith("Z"):
|
|
207
|
+
ga = ga[:-1] + "+00:00"
|
|
208
|
+
dt = datetime.fromisoformat(ga)
|
|
209
|
+
except Exception: # noqa: BLE001 - unparseable timestamp => unknown age
|
|
210
|
+
return None
|
|
211
|
+
elif isinstance(ga, datetime):
|
|
212
|
+
dt = ga
|
|
213
|
+
else:
|
|
214
|
+
return None
|
|
215
|
+
if dt.tzinfo is None:
|
|
216
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
217
|
+
return int((datetime.now(timezone.utc) - dt).total_seconds() // 60)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from magicfeedback_sdk.api.campaigns import CampaignsAPI
|
|
2
|
+
from magicfeedback_sdk.api.contacts import ContactsAPI
|
|
3
|
+
from magicfeedback_sdk.api.feedback import FeedbackAPI
|
|
4
|
+
from magicfeedback_sdk.api.integrations_questions import IntegrationsQuestionsAPI
|
|
5
|
+
from magicfeedback_sdk.api.metrics import MetricsAPI
|
|
6
|
+
from magicfeedback_sdk.api.products import ProductsAPI
|
|
7
|
+
from magicfeedback_sdk.api.reports import ReportsAPI
|
|
8
|
+
from magicfeedback_sdk.api.requests import RequestsAPI
|
|
9
|
+
from magicfeedback_sdk.api.companies import CompaniesAPI
|
|
10
|
+
from magicfeedback_sdk.api.signals import SignalsAPI
|
|
11
|
+
from magicfeedback_sdk.auth import AuthManager
|
|
12
|
+
from magicfeedback_sdk.logging_config import configure_logger
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MagicFeedback:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
user: str = None,
|
|
19
|
+
password: str = None,
|
|
20
|
+
base_url: str = "https://api.magicfeedback.io",
|
|
21
|
+
ip_key: str = "AIzaSyAKcR895VURSQZSN2T_RD6jX_9y5HRmH80",
|
|
22
|
+
auth_source: str = "datastore",
|
|
23
|
+
gcp_project_id: str = None,
|
|
24
|
+
datastore_database_id: str = "shared",
|
|
25
|
+
token_kind: str = "token-storage",
|
|
26
|
+
token_email: str = "robot@magicfeedback.io",
|
|
27
|
+
token_max_age_min: int = 50,
|
|
28
|
+
datastore_timeout_s: float = 5.0,
|
|
29
|
+
):
|
|
30
|
+
"""Create a MagicFeedback API client.
|
|
31
|
+
|
|
32
|
+
By default (``auth_source="datastore"``) the bearer token is read from
|
|
33
|
+
the Google Cloud Datastore cache maintained by the ``update-token`` job,
|
|
34
|
+
avoiding an Identity Platform login on every use. If the cached token is
|
|
35
|
+
missing/stale/unreachable, the client falls back to Identity Platform
|
|
36
|
+
(which needs ``user``/``password``). Pass ``auth_source="identity"`` to
|
|
37
|
+
always log in via Identity Platform (the original behaviour, no
|
|
38
|
+
Datastore lookup).
|
|
39
|
+
|
|
40
|
+
:param user: Identity Platform email (required for the identity source
|
|
41
|
+
and for the datastore fallback).
|
|
42
|
+
:param password: Identity Platform password (see ``user``).
|
|
43
|
+
:param auth_source: ``"datastore"`` (default) or ``"identity"``.
|
|
44
|
+
:param gcp_project_id: GCP project owning the token Datastore; when
|
|
45
|
+
omitted, resolved from Application Default Credentials.
|
|
46
|
+
:param datastore_database_id: Datastore database holding the token
|
|
47
|
+
(default ``"shared"``).
|
|
48
|
+
:param token_kind: Datastore kind of the token entity
|
|
49
|
+
(default ``"token-storage"``).
|
|
50
|
+
:param token_email: ``email`` key identifying the token entity
|
|
51
|
+
(default ``"robot@magicfeedback.io"``).
|
|
52
|
+
:param token_max_age_min: cached tokens older than this (minutes) are
|
|
53
|
+
treated as stale and trigger the Identity Platform fallback.
|
|
54
|
+
:param datastore_timeout_s: wall-clock budget (seconds) for the
|
|
55
|
+
Datastore lookup; on timeout/error the client falls back to Identity
|
|
56
|
+
Platform. Keeps the fallback fast instead of blocking on the
|
|
57
|
+
Datastore client's default ~60s retry deadline.
|
|
58
|
+
"""
|
|
59
|
+
self.logger = configure_logger()
|
|
60
|
+
self.base_url = base_url
|
|
61
|
+
self.ip_key = ip_key
|
|
62
|
+
|
|
63
|
+
# Kept so refresh_token() can re-resolve the token the same way.
|
|
64
|
+
self._user = user
|
|
65
|
+
self._password = password
|
|
66
|
+
self.auth_source = auth_source
|
|
67
|
+
|
|
68
|
+
self.auth = AuthManager(
|
|
69
|
+
ip_key,
|
|
70
|
+
self.logger,
|
|
71
|
+
gcp_project_id=gcp_project_id,
|
|
72
|
+
datastore_database_id=datastore_database_id,
|
|
73
|
+
token_kind=token_kind,
|
|
74
|
+
token_email=token_email,
|
|
75
|
+
token_max_age_min=token_max_age_min,
|
|
76
|
+
datastore_timeout_s=datastore_timeout_s,
|
|
77
|
+
)
|
|
78
|
+
self.api_key = self.auth.get_api_key(user, password, source=auth_source)
|
|
79
|
+
self.headers = {"Authorization": f"Bearer {self.api_key}"}
|
|
80
|
+
|
|
81
|
+
# APIs
|
|
82
|
+
self.feedbacks = FeedbackAPI(self.base_url, self.headers, self.logger)
|
|
83
|
+
self.contacts = ContactsAPI(self.base_url, self.headers, self.logger)
|
|
84
|
+
self.campaigns = CampaignsAPI(self.base_url, self.headers, self.logger)
|
|
85
|
+
self.metrics = MetricsAPI(self.base_url, self.headers, self.logger)
|
|
86
|
+
self.integrations_questions = IntegrationsQuestionsAPI(self.base_url, self.headers, self.logger)
|
|
87
|
+
self.products = ProductsAPI(self.base_url, self.headers, self.logger)
|
|
88
|
+
self.reports = ReportsAPI(self.base_url, self.headers, self.logger)
|
|
89
|
+
self.companies = CompaniesAPI(self.base_url, self.headers, self.logger)
|
|
90
|
+
self.requests = RequestsAPI(self.base_url, self.headers, self.logger)
|
|
91
|
+
self.signals = SignalsAPI(self.base_url, self.headers, self.logger)
|
|
92
|
+
|
|
93
|
+
def set_logging(self, level):
|
|
94
|
+
self.logger.setLevel(level)
|
|
95
|
+
|
|
96
|
+
def refresh_token(self):
|
|
97
|
+
"""Re-resolve the API token (same ``auth_source`` as construction) and
|
|
98
|
+
update the auth header in place.
|
|
99
|
+
|
|
100
|
+
The sub-API clients (``self.feedbacks`` etc.) share this ``headers``
|
|
101
|
+
dict by reference, so mutating it here transparently updates them all —
|
|
102
|
+
useful for long-lived clients whose token has expired.
|
|
103
|
+
|
|
104
|
+
:return: the new bearer token.
|
|
105
|
+
"""
|
|
106
|
+
self.api_key = self.auth.get_api_key(
|
|
107
|
+
self._user, self._password, source=self.auth_source
|
|
108
|
+
)
|
|
109
|
+
self.headers["Authorization"] = f"Bearer {self.api_key}"
|
|
110
|
+
return self.api_key
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# The company was renamed MagicFeedback -> Deepdots. ``Deepdots`` is the
|
|
114
|
+
# forward-looking name for this client; ``MagicFeedback`` remains the defining
|
|
115
|
+
# name so existing tracebacks and reprs are unchanged. Both refer to the same
|
|
116
|
+
# class object, so isinstance() and subclassing behave identically either way.
|
|
117
|
+
Deepdots = MagicFeedback
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from typing import Any, Dict
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def make_request(method: str, url: str, headers: Dict[str, str], json=None, files=None, data=None, logger=None) -> Dict[str, Any]:
|
|
7
|
+
response = requests.request(method, url, headers=headers, json=json, files=files, data=data)
|
|
8
|
+
response.raise_for_status()
|
|
9
|
+
|
|
10
|
+
if logger:
|
|
11
|
+
logger.debug("Status code: %s", response.status_code)
|
|
12
|
+
logger.debug("Response: %s", response.text)
|
|
13
|
+
|
|
14
|
+
if response.text:
|
|
15
|
+
return response.json()
|
|
16
|
+
return {}
|