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.
Files changed (39) hide show
  1. deepdots_sdk/__init__.py +12 -0
  2. deepdots_sdk/api/__init__.py +0 -0
  3. deepdots_sdk/api/campaigns.py +5 -0
  4. deepdots_sdk/api/companies.py +5 -0
  5. deepdots_sdk/api/contacts.py +5 -0
  6. deepdots_sdk/api/feedback.py +5 -0
  7. deepdots_sdk/api/integrations_questions.py +5 -0
  8. deepdots_sdk/api/metrics.py +5 -0
  9. deepdots_sdk/api/products.py +5 -0
  10. deepdots_sdk/api/reports.py +5 -0
  11. deepdots_sdk/api/requests.py +5 -0
  12. deepdots_sdk/api/signals.py +5 -0
  13. deepdots_sdk/auth.py +5 -0
  14. deepdots_sdk/client.py +5 -0
  15. deepdots_sdk/logging_config.py +5 -0
  16. deepdots_sdk/utils/__init__.py +0 -0
  17. deepdots_sdk/utils/request.py +5 -0
  18. magicfeedback-1.0.19.dist-info/METADATA +241 -0
  19. magicfeedback-1.0.19.dist-info/RECORD +39 -0
  20. magicfeedback-1.0.19.dist-info/WHEEL +5 -0
  21. magicfeedback-1.0.19.dist-info/licenses/LICENCE +19 -0
  22. magicfeedback-1.0.19.dist-info/top_level.txt +2 -0
  23. magicfeedback_sdk/__init__.py +3 -0
  24. magicfeedback_sdk/api/__init__.py +0 -0
  25. magicfeedback_sdk/api/campaigns.py +47 -0
  26. magicfeedback_sdk/api/companies.py +22 -0
  27. magicfeedback_sdk/api/contacts.py +31 -0
  28. magicfeedback_sdk/api/feedback.py +63 -0
  29. magicfeedback_sdk/api/integrations_questions.py +15 -0
  30. magicfeedback_sdk/api/metrics.py +15 -0
  31. magicfeedback_sdk/api/products.py +15 -0
  32. magicfeedback_sdk/api/reports.py +51 -0
  33. magicfeedback_sdk/api/requests.py +94 -0
  34. magicfeedback_sdk/api/signals.py +45 -0
  35. magicfeedback_sdk/auth.py +217 -0
  36. magicfeedback_sdk/client.py +117 -0
  37. magicfeedback_sdk/logging_config.py +7 -0
  38. magicfeedback_sdk/utils/__init__.py +0 -0
  39. magicfeedback_sdk/utils/request.py +16 -0
@@ -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
@@ -0,0 +1,5 @@
1
+ """Re-export of :mod:`magicfeedback_sdk.api.campaigns` under the Deepdots name."""
2
+
3
+ from magicfeedback_sdk.api.campaigns import CampaignsAPI
4
+
5
+ __all__ = ['CampaignsAPI']
@@ -0,0 +1,5 @@
1
+ """Re-export of :mod:`magicfeedback_sdk.api.companies` under the Deepdots name."""
2
+
3
+ from magicfeedback_sdk.api.companies import CompaniesAPI
4
+
5
+ __all__ = ['CompaniesAPI']
@@ -0,0 +1,5 @@
1
+ """Re-export of :mod:`magicfeedback_sdk.api.contacts` under the Deepdots name."""
2
+
3
+ from magicfeedback_sdk.api.contacts import ContactsAPI
4
+
5
+ __all__ = ['ContactsAPI']
@@ -0,0 +1,5 @@
1
+ """Re-export of :mod:`magicfeedback_sdk.api.feedback` under the Deepdots name."""
2
+
3
+ from magicfeedback_sdk.api.feedback import FeedbackAPI
4
+
5
+ __all__ = ['FeedbackAPI']
@@ -0,0 +1,5 @@
1
+ """Re-export of :mod:`magicfeedback_sdk.api.integrations_questions` under the Deepdots name."""
2
+
3
+ from magicfeedback_sdk.api.integrations_questions import IntegrationsQuestionsAPI
4
+
5
+ __all__ = ['IntegrationsQuestionsAPI']
@@ -0,0 +1,5 @@
1
+ """Re-export of :mod:`magicfeedback_sdk.api.metrics` under the Deepdots name."""
2
+
3
+ from magicfeedback_sdk.api.metrics import MetricsAPI
4
+
5
+ __all__ = ['MetricsAPI']
@@ -0,0 +1,5 @@
1
+ """Re-export of :mod:`magicfeedback_sdk.api.products` under the Deepdots name."""
2
+
3
+ from magicfeedback_sdk.api.products import ProductsAPI
4
+
5
+ __all__ = ['ProductsAPI']
@@ -0,0 +1,5 @@
1
+ """Re-export of :mod:`magicfeedback_sdk.api.reports` under the Deepdots name."""
2
+
3
+ from magicfeedback_sdk.api.reports import ReportsAPI
4
+
5
+ __all__ = ['ReportsAPI']
@@ -0,0 +1,5 @@
1
+ """Re-export of :mod:`magicfeedback_sdk.api.requests` under the Deepdots name."""
2
+
3
+ from magicfeedback_sdk.api.requests import build_done_message, RequestsAPI
4
+
5
+ __all__ = ['build_done_message', 'RequestsAPI']
@@ -0,0 +1,5 @@
1
+ """Re-export of :mod:`magicfeedback_sdk.api.signals` under the Deepdots name."""
2
+
3
+ from magicfeedback_sdk.api.signals import SignalsAPI
4
+
5
+ __all__ = ['SignalsAPI']
deepdots_sdk/auth.py ADDED
@@ -0,0 +1,5 @@
1
+ """Re-export of :mod:`magicfeedback_sdk.auth` under the Deepdots name."""
2
+
3
+ from magicfeedback_sdk.auth import AuthManager
4
+
5
+ __all__ = ["AuthManager"]
deepdots_sdk/client.py ADDED
@@ -0,0 +1,5 @@
1
+ """Re-export of :mod:`magicfeedback_sdk.client` under the Deepdots name."""
2
+
3
+ from magicfeedback_sdk.client import Deepdots, MagicFeedback
4
+
5
+ __all__ = ["Deepdots", "MagicFeedback"]
@@ -0,0 +1,5 @@
1
+ """Re-export of :mod:`magicfeedback_sdk.logging_config` under the Deepdots name."""
2
+
3
+ from magicfeedback_sdk.logging_config import configure_logger
4
+
5
+ __all__ = ["configure_logger"]
File without changes
@@ -0,0 +1,5 @@
1
+ """Re-export of :mod:`magicfeedback_sdk.utils.request` under the Deepdots name."""
2
+
3
+ from magicfeedback_sdk.utils.request import make_request
4
+
5
+ __all__ = ["make_request"]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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.  
@@ -0,0 +1,2 @@
1
+ deepdots_sdk
2
+ magicfeedback_sdk
@@ -0,0 +1,3 @@
1
+ from .client import Deepdots, MagicFeedback
2
+
3
+ __all__ = ["Deepdots", "MagicFeedback"]
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
@@ -0,0 +1,7 @@
1
+ import logging
2
+
3
+
4
+ def configure_logger(name="magicfeedback_sdk"):
5
+ logger = logging.getLogger(name)
6
+ logger.addHandler(logging.NullHandler())
7
+ return logger
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 {}