fopost-django 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fopost_django-0.1.0/.gitignore +16 -0
- fopost_django-0.1.0/LICENSE +21 -0
- fopost_django-0.1.0/PKG-INFO +281 -0
- fopost_django-0.1.0/README.md +244 -0
- fopost_django-0.1.0/examples/README.md +18 -0
- fopost_django-0.1.0/examples/blog/receivers.py +40 -0
- fopost_django-0.1.0/examples/blog/views.py +34 -0
- fopost_django-0.1.0/examples/create_and_publish.py +47 -0
- fopost_django-0.1.0/pyproject.toml +76 -0
- fopost_django-0.1.0/src/fopost_django/__init__.py +43 -0
- fopost_django-0.1.0/src/fopost_django/_client.py +65 -0
- fopost_django-0.1.0/src/fopost_django/apps.py +25 -0
- fopost_django-0.1.0/src/fopost_django/checks.py +78 -0
- fopost_django-0.1.0/src/fopost_django/conf.py +185 -0
- fopost_django-0.1.0/src/fopost_django/management/__init__.py +0 -0
- fopost_django-0.1.0/src/fopost_django/management/_base.py +39 -0
- fopost_django-0.1.0/src/fopost_django/management/commands/__init__.py +0 -0
- fopost_django-0.1.0/src/fopost_django/management/commands/fopost_accounts.py +52 -0
- fopost_django-0.1.0/src/fopost_django/management/commands/fopost_post.py +99 -0
- fopost_django-0.1.0/src/fopost_django/py.typed +0 -0
- fopost_django-0.1.0/src/fopost_django/signals.py +59 -0
- fopost_django-0.1.0/src/fopost_django/urls.py +19 -0
- fopost_django-0.1.0/src/fopost_django/views.py +78 -0
- fopost_django-0.1.0/src/fopost_django/webhooks.py +48 -0
- fopost_django-0.1.0/tests/__init__.py +0 -0
- fopost_django-0.1.0/tests/conftest.py +72 -0
- fopost_django-0.1.0/tests/settings.py +25 -0
- fopost_django-0.1.0/tests/test_checks.py +40 -0
- fopost_django-0.1.0/tests/test_client.py +51 -0
- fopost_django-0.1.0/tests/test_commands.py +140 -0
- fopost_django-0.1.0/tests/test_conf.py +85 -0
- fopost_django-0.1.0/tests/test_webhooks.py +121 -0
- fopost_django-0.1.0/tests/urls.py +5 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Porter Bridge, LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fopost-django
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Django integration for the FoPost API. Schedule and publish to +30 social platforms from your Django project.
|
|
5
|
+
Project-URL: Homepage, https://fopost.com
|
|
6
|
+
Project-URL: Documentation, https://fopost.com/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/fopost/fopost-django
|
|
8
|
+
Project-URL: Issues, https://github.com/fopost/fopost-django/issues
|
|
9
|
+
Project-URL: Support, https://fopost.com/contact
|
|
10
|
+
Author: FoPost, Porter Bridge, LLC
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: api,django,fopost,publishing,scheduling,sdk,social-media,webhooks
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Environment :: Web Environment
|
|
16
|
+
Classifier: Framework :: Django
|
|
17
|
+
Classifier: Framework :: Django :: 4.2
|
|
18
|
+
Classifier: Framework :: Django :: 5.0
|
|
19
|
+
Classifier: Framework :: Django :: 5.1
|
|
20
|
+
Classifier: Framework :: Django :: 5.2
|
|
21
|
+
Classifier: Intended Audience :: Developers
|
|
22
|
+
Classifier: Programming Language :: Python :: 3
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
27
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
28
|
+
Classifier: Typing :: Typed
|
|
29
|
+
Requires-Python: >=3.10
|
|
30
|
+
Requires-Dist: django>=4.2
|
|
31
|
+
Requires-Dist: fopost<1.0,>=0.1
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: pytest-django>=4.8; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
35
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# FoPost for Django
|
|
39
|
+
|
|
40
|
+
[](https://pypi.org/project/fopost-django/)
|
|
41
|
+
[](https://pypi.org/project/fopost-django/)
|
|
42
|
+
[](https://github.com/fopost/fopost-django/actions/workflows/ci.yml)
|
|
43
|
+
[](LICENSE)
|
|
44
|
+
|
|
45
|
+
Official Django integration for the [FoPost](https://fopost.com) API. Schedule and publish to +30
|
|
46
|
+
social platforms from your Django project.
|
|
47
|
+
|
|
48
|
+
This is a thin wrapper around the [`fopost`](https://github.com/fopost/fopost-python) Python SDK. It
|
|
49
|
+
adds Django settings, a lazily-built shared client, two management commands, system checks, and a
|
|
50
|
+
signed webhook receiver that fires Django signals. Every platform connection, token refresh, and
|
|
51
|
+
delivery happens on the hosted API, so there is nothing to run yourself — and **no models and no
|
|
52
|
+
migrations**, because this package stores nothing.
|
|
53
|
+
|
|
54
|
+
## Requirements
|
|
55
|
+
|
|
56
|
+
- Python 3.10 or newer
|
|
57
|
+
- Django 4.2, 5.0, 5.1, or 5.2
|
|
58
|
+
- A FoPost API key from [app.fopost.com/api-keys](https://app.fopost.com/api-keys)
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install fopost-django
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Settings
|
|
67
|
+
|
|
68
|
+
Add the app and one settings dict:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import os
|
|
72
|
+
|
|
73
|
+
INSTALLED_APPS = [
|
|
74
|
+
# ...
|
|
75
|
+
"fopost_django",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
FOPOST = {
|
|
79
|
+
"API_KEY": os.environ["FOPOST_API_KEY"],
|
|
80
|
+
"WEBHOOK_SECRET": os.environ["FOPOST_WEBHOOK_SECRET"],
|
|
81
|
+
"DEFAULT_WORKSPACE_ID": os.environ.get("FOPOST_WORKSPACE_ID"),
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Key | Default | Env fallback | What it does |
|
|
86
|
+
| --- | --- | --- | --- |
|
|
87
|
+
| `API_KEY` | none, **required** | `FOPOST_API_KEY` | Your API key |
|
|
88
|
+
| `BASE_URL` | `https://api.fopost.com/v1` | `FOPOST_BASE_URL` | API root |
|
|
89
|
+
| `TIMEOUT` | `30.0` | — | Seconds to wait for one request |
|
|
90
|
+
| `MAX_RETRIES` | `3` | — | Attempts for a rate limited request |
|
|
91
|
+
| `DEFAULT_WORKSPACE_ID` | `None` | `FOPOST_WORKSPACE_ID` | Workspace the management commands use when `--workspace` is left out |
|
|
92
|
+
| `WEBHOOK_SECRET` | `None` | `FOPOST_WEBHOOK_SECRET` | Secret the webhook receiver verifies signatures against |
|
|
93
|
+
| `HTTP_CLIENT` | `None` | — | Advanced: an `httpx.Client` to send through, for a proxy or a test transport |
|
|
94
|
+
|
|
95
|
+
The whole dict is optional as long as `FOPOST_API_KEY` is in the environment. Django refuses to
|
|
96
|
+
start without a key — an `ImproperlyConfigured` at boot beats a 401 in a customer's request — and
|
|
97
|
+
`manage.py check` warns about the softer misconfigurations (a webhook URL wired up with no secret, a
|
|
98
|
+
plaintext base URL, an API key hardcoded into settings).
|
|
99
|
+
|
|
100
|
+
## Quick start
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from django.http import JsonResponse
|
|
104
|
+
from fopost_django import client
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def announce(request):
|
|
108
|
+
workspace = client.workspaces.list()[0]
|
|
109
|
+
accounts = client.accounts.list(workspace_id=workspace.id)
|
|
110
|
+
|
|
111
|
+
post = client.posts.create(
|
|
112
|
+
workspace_id=workspace.id,
|
|
113
|
+
content="Shipping today: scheduled posting straight from Django.",
|
|
114
|
+
accounts=[a.id for a in accounts],
|
|
115
|
+
)
|
|
116
|
+
client.posts.publish(post.id)
|
|
117
|
+
|
|
118
|
+
return JsonResponse({"post_id": post.id})
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`client` is a lazy proxy, so importing it at module scope never touches settings. Prefer an explicit
|
|
122
|
+
call? `get_client()` returns the same memoized instance:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from fopost_django import get_client
|
|
126
|
+
|
|
127
|
+
get_client().posts.list(workspace_id=workspace_id, status="scheduled")
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The client is built once per process, on first use, behind a lock, and rebuilt automatically if
|
|
131
|
+
`settings.FOPOST` changes (which is what `override_settings` does in your tests).
|
|
132
|
+
|
|
133
|
+
## Management commands
|
|
134
|
+
|
|
135
|
+
### `fopost_accounts`
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
python manage.py fopost_accounts --workspace 9b2f6c1e-...
|
|
139
|
+
python manage.py fopost_accounts --json
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Lists the social accounts connected to a workspace. Falls back to
|
|
143
|
+
`FOPOST["DEFAULT_WORKSPACE_ID"]`, and to every workspace the key reaches when neither is set.
|
|
144
|
+
|
|
145
|
+
### `fopost_post`
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
# A draft
|
|
149
|
+
python manage.py fopost_post -a acc_1 -a acc_2 --text "Hello from Django"
|
|
150
|
+
|
|
151
|
+
# Scheduled
|
|
152
|
+
python manage.py fopost_post -a acc_1 --text "Later" --schedule-at 2026-09-01T10:00:00Z
|
|
153
|
+
|
|
154
|
+
# Out the door now
|
|
155
|
+
python manage.py fopost_post -a acc_1 --text "Now" --publish
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
| Flag | What it does |
|
|
159
|
+
| --- | --- |
|
|
160
|
+
| `-w`, `--workspace` | Workspace id. Defaults to `FOPOST["DEFAULT_WORKSPACE_ID"]` |
|
|
161
|
+
| `-a`, `--account` | A connected account. Repeat for more than one. At least one is required |
|
|
162
|
+
| `-t`, `--text` | The post body. Required |
|
|
163
|
+
| `--title` | Title, for platforms that use one |
|
|
164
|
+
| `--label` | A label id to attach. Repeat for more than one |
|
|
165
|
+
| `--schedule-at` | ISO 8601 datetime. A naive value is read in the project's current timezone |
|
|
166
|
+
| `--publish` | Queue the post for delivery straight after creating it |
|
|
167
|
+
|
|
168
|
+
`--schedule-at` and `--publish` are mutually exclusive. API failures come back as ordinary
|
|
169
|
+
`CommandError` output, not a traceback.
|
|
170
|
+
|
|
171
|
+
## Receiving webhooks
|
|
172
|
+
|
|
173
|
+
Add the URLs:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
from django.urls import include, path
|
|
177
|
+
|
|
178
|
+
urlpatterns = [
|
|
179
|
+
path("fopost/", include("fopost_django.urls")),
|
|
180
|
+
]
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
That serves the receiver at `/fopost/webhook/` (reversible as `reverse("fopost:webhook")`). Register
|
|
184
|
+
that URL at [app.fopost.com](https://app.fopost.com), copy the secret it shows you into
|
|
185
|
+
`FOPOST["WEBHOOK_SECRET"]`, and connect a receiver:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from django.dispatch import receiver
|
|
189
|
+
from fopost_django.signals import post_published, post_failed
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@receiver(post_published)
|
|
193
|
+
def on_published(sender, event, data, payload, request, delivery_id, **kwargs):
|
|
194
|
+
Article.objects.filter(fopost_post_id=data["postId"]).update(announced=True)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@receiver(post_failed)
|
|
198
|
+
def on_failed(sender, data, **kwargs):
|
|
199
|
+
logger.error("FoPost post %s failed", data.get("postId"))
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Connect them from your app config's `ready()`, the usual way.
|
|
203
|
+
|
|
204
|
+
| Signal | FoPost event |
|
|
205
|
+
| --- | --- |
|
|
206
|
+
| `post_published` | `post.published` |
|
|
207
|
+
| `post_failed` | `post.failed` |
|
|
208
|
+
| `post_partially_failed` | `post.partially_failed` |
|
|
209
|
+
| `delivery_published` | `delivery.published` |
|
|
210
|
+
| `delivery_failed` | `delivery.failed` |
|
|
211
|
+
| `delivery_delayed` | `delivery.delayed` |
|
|
212
|
+
| `account_health_changed` | `account.health_changed` |
|
|
213
|
+
| `webhook_received` | every verified delivery, whatever the event |
|
|
214
|
+
|
|
215
|
+
Every receiver gets the same keyword arguments: `event`, `data`, `payload` (the whole envelope, with
|
|
216
|
+
its `timestamp`), `request`, and `delivery_id` — the `X-FoPost-Delivery` header, which stays the same
|
|
217
|
+
across retries and so makes a good idempotency key.
|
|
218
|
+
|
|
219
|
+
**How it is verified.** FoPost signs the raw request body with HMAC-SHA256, keyed on the webhook
|
|
220
|
+
secret, and sends the hex digest as `X-FoPost-Signature: sha256=<digest>`. The view recomputes it
|
|
221
|
+
over `request.body` and compares with `hmac.compare_digest`. A missing, malformed, or wrong
|
|
222
|
+
signature is a `403` before any signal fires; a body that is not a JSON object is a `400`. The view
|
|
223
|
+
is `csrf_exempt` and accepts `POST` only.
|
|
224
|
+
|
|
225
|
+
**Failures are meant to propagate.** If a receiver raises, the response is a 5xx and FoPost retries
|
|
226
|
+
the delivery with backoff. Keep receivers quick and idempotent, or hand the work to a task queue.
|
|
227
|
+
|
|
228
|
+
## Testing your own code
|
|
229
|
+
|
|
230
|
+
Point the SDK at a stub transport instead of the network:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
import httpx
|
|
234
|
+
from django.test import override_settings
|
|
235
|
+
from fopost_django import reset_client
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def handler(request):
|
|
239
|
+
return httpx.Response(200, json={"data": {"id": "post_1", "status": "draft"}})
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
with override_settings(
|
|
243
|
+
FOPOST={
|
|
244
|
+
"API_KEY": "fp_test",
|
|
245
|
+
"HTTP_CLIENT": httpx.Client(transport=httpx.MockTransport(handler)),
|
|
246
|
+
}
|
|
247
|
+
):
|
|
248
|
+
reset_client()
|
|
249
|
+
...
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`override_settings` already invalidates the cached client; `reset_client()` is there for the cases
|
|
253
|
+
where you swap the transport by hand.
|
|
254
|
+
|
|
255
|
+
## The rest of the API
|
|
256
|
+
|
|
257
|
+
Posts, accounts, workspaces, labels, AI, pagination, error classes and retry behaviour all live in
|
|
258
|
+
the parent SDK. See [`fopost` on PyPI](https://pypi.org/project/fopost/) and its
|
|
259
|
+
[README](https://github.com/fopost/fopost-python#readme); everything it documents works through
|
|
260
|
+
`fopost_django.client` unchanged.
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
from fopost import FopostError, RateLimitError
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
client.posts.publish(post_id)
|
|
267
|
+
except RateLimitError as exc:
|
|
268
|
+
retry_in = exc.retry_after
|
|
269
|
+
except FopostError as exc:
|
|
270
|
+
print(exc.status, exc.code, exc.message)
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Links
|
|
274
|
+
|
|
275
|
+
- Docs — <https://fopost.com/docs>
|
|
276
|
+
- Issues — <https://github.com/fopost/fopost-django/issues>
|
|
277
|
+
- Support — <https://fopost.com/contact>
|
|
278
|
+
|
|
279
|
+
## License
|
|
280
|
+
|
|
281
|
+
MIT. Copyright (c) 2026 Porter Bridge, LLC.
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# FoPost for Django
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/fopost-django/)
|
|
4
|
+
[](https://pypi.org/project/fopost-django/)
|
|
5
|
+
[](https://github.com/fopost/fopost-django/actions/workflows/ci.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
Official Django integration for the [FoPost](https://fopost.com) API. Schedule and publish to +30
|
|
9
|
+
social platforms from your Django project.
|
|
10
|
+
|
|
11
|
+
This is a thin wrapper around the [`fopost`](https://github.com/fopost/fopost-python) Python SDK. It
|
|
12
|
+
adds Django settings, a lazily-built shared client, two management commands, system checks, and a
|
|
13
|
+
signed webhook receiver that fires Django signals. Every platform connection, token refresh, and
|
|
14
|
+
delivery happens on the hosted API, so there is nothing to run yourself — and **no models and no
|
|
15
|
+
migrations**, because this package stores nothing.
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- Python 3.10 or newer
|
|
20
|
+
- Django 4.2, 5.0, 5.1, or 5.2
|
|
21
|
+
- A FoPost API key from [app.fopost.com/api-keys](https://app.fopost.com/api-keys)
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install fopost-django
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Settings
|
|
30
|
+
|
|
31
|
+
Add the app and one settings dict:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import os
|
|
35
|
+
|
|
36
|
+
INSTALLED_APPS = [
|
|
37
|
+
# ...
|
|
38
|
+
"fopost_django",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
FOPOST = {
|
|
42
|
+
"API_KEY": os.environ["FOPOST_API_KEY"],
|
|
43
|
+
"WEBHOOK_SECRET": os.environ["FOPOST_WEBHOOK_SECRET"],
|
|
44
|
+
"DEFAULT_WORKSPACE_ID": os.environ.get("FOPOST_WORKSPACE_ID"),
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
| Key | Default | Env fallback | What it does |
|
|
49
|
+
| --- | --- | --- | --- |
|
|
50
|
+
| `API_KEY` | none, **required** | `FOPOST_API_KEY` | Your API key |
|
|
51
|
+
| `BASE_URL` | `https://api.fopost.com/v1` | `FOPOST_BASE_URL` | API root |
|
|
52
|
+
| `TIMEOUT` | `30.0` | — | Seconds to wait for one request |
|
|
53
|
+
| `MAX_RETRIES` | `3` | — | Attempts for a rate limited request |
|
|
54
|
+
| `DEFAULT_WORKSPACE_ID` | `None` | `FOPOST_WORKSPACE_ID` | Workspace the management commands use when `--workspace` is left out |
|
|
55
|
+
| `WEBHOOK_SECRET` | `None` | `FOPOST_WEBHOOK_SECRET` | Secret the webhook receiver verifies signatures against |
|
|
56
|
+
| `HTTP_CLIENT` | `None` | — | Advanced: an `httpx.Client` to send through, for a proxy or a test transport |
|
|
57
|
+
|
|
58
|
+
The whole dict is optional as long as `FOPOST_API_KEY` is in the environment. Django refuses to
|
|
59
|
+
start without a key — an `ImproperlyConfigured` at boot beats a 401 in a customer's request — and
|
|
60
|
+
`manage.py check` warns about the softer misconfigurations (a webhook URL wired up with no secret, a
|
|
61
|
+
plaintext base URL, an API key hardcoded into settings).
|
|
62
|
+
|
|
63
|
+
## Quick start
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from django.http import JsonResponse
|
|
67
|
+
from fopost_django import client
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def announce(request):
|
|
71
|
+
workspace = client.workspaces.list()[0]
|
|
72
|
+
accounts = client.accounts.list(workspace_id=workspace.id)
|
|
73
|
+
|
|
74
|
+
post = client.posts.create(
|
|
75
|
+
workspace_id=workspace.id,
|
|
76
|
+
content="Shipping today: scheduled posting straight from Django.",
|
|
77
|
+
accounts=[a.id for a in accounts],
|
|
78
|
+
)
|
|
79
|
+
client.posts.publish(post.id)
|
|
80
|
+
|
|
81
|
+
return JsonResponse({"post_id": post.id})
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`client` is a lazy proxy, so importing it at module scope never touches settings. Prefer an explicit
|
|
85
|
+
call? `get_client()` returns the same memoized instance:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from fopost_django import get_client
|
|
89
|
+
|
|
90
|
+
get_client().posts.list(workspace_id=workspace_id, status="scheduled")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The client is built once per process, on first use, behind a lock, and rebuilt automatically if
|
|
94
|
+
`settings.FOPOST` changes (which is what `override_settings` does in your tests).
|
|
95
|
+
|
|
96
|
+
## Management commands
|
|
97
|
+
|
|
98
|
+
### `fopost_accounts`
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
python manage.py fopost_accounts --workspace 9b2f6c1e-...
|
|
102
|
+
python manage.py fopost_accounts --json
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Lists the social accounts connected to a workspace. Falls back to
|
|
106
|
+
`FOPOST["DEFAULT_WORKSPACE_ID"]`, and to every workspace the key reaches when neither is set.
|
|
107
|
+
|
|
108
|
+
### `fopost_post`
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# A draft
|
|
112
|
+
python manage.py fopost_post -a acc_1 -a acc_2 --text "Hello from Django"
|
|
113
|
+
|
|
114
|
+
# Scheduled
|
|
115
|
+
python manage.py fopost_post -a acc_1 --text "Later" --schedule-at 2026-09-01T10:00:00Z
|
|
116
|
+
|
|
117
|
+
# Out the door now
|
|
118
|
+
python manage.py fopost_post -a acc_1 --text "Now" --publish
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
| Flag | What it does |
|
|
122
|
+
| --- | --- |
|
|
123
|
+
| `-w`, `--workspace` | Workspace id. Defaults to `FOPOST["DEFAULT_WORKSPACE_ID"]` |
|
|
124
|
+
| `-a`, `--account` | A connected account. Repeat for more than one. At least one is required |
|
|
125
|
+
| `-t`, `--text` | The post body. Required |
|
|
126
|
+
| `--title` | Title, for platforms that use one |
|
|
127
|
+
| `--label` | A label id to attach. Repeat for more than one |
|
|
128
|
+
| `--schedule-at` | ISO 8601 datetime. A naive value is read in the project's current timezone |
|
|
129
|
+
| `--publish` | Queue the post for delivery straight after creating it |
|
|
130
|
+
|
|
131
|
+
`--schedule-at` and `--publish` are mutually exclusive. API failures come back as ordinary
|
|
132
|
+
`CommandError` output, not a traceback.
|
|
133
|
+
|
|
134
|
+
## Receiving webhooks
|
|
135
|
+
|
|
136
|
+
Add the URLs:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from django.urls import include, path
|
|
140
|
+
|
|
141
|
+
urlpatterns = [
|
|
142
|
+
path("fopost/", include("fopost_django.urls")),
|
|
143
|
+
]
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
That serves the receiver at `/fopost/webhook/` (reversible as `reverse("fopost:webhook")`). Register
|
|
147
|
+
that URL at [app.fopost.com](https://app.fopost.com), copy the secret it shows you into
|
|
148
|
+
`FOPOST["WEBHOOK_SECRET"]`, and connect a receiver:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from django.dispatch import receiver
|
|
152
|
+
from fopost_django.signals import post_published, post_failed
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@receiver(post_published)
|
|
156
|
+
def on_published(sender, event, data, payload, request, delivery_id, **kwargs):
|
|
157
|
+
Article.objects.filter(fopost_post_id=data["postId"]).update(announced=True)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@receiver(post_failed)
|
|
161
|
+
def on_failed(sender, data, **kwargs):
|
|
162
|
+
logger.error("FoPost post %s failed", data.get("postId"))
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Connect them from your app config's `ready()`, the usual way.
|
|
166
|
+
|
|
167
|
+
| Signal | FoPost event |
|
|
168
|
+
| --- | --- |
|
|
169
|
+
| `post_published` | `post.published` |
|
|
170
|
+
| `post_failed` | `post.failed` |
|
|
171
|
+
| `post_partially_failed` | `post.partially_failed` |
|
|
172
|
+
| `delivery_published` | `delivery.published` |
|
|
173
|
+
| `delivery_failed` | `delivery.failed` |
|
|
174
|
+
| `delivery_delayed` | `delivery.delayed` |
|
|
175
|
+
| `account_health_changed` | `account.health_changed` |
|
|
176
|
+
| `webhook_received` | every verified delivery, whatever the event |
|
|
177
|
+
|
|
178
|
+
Every receiver gets the same keyword arguments: `event`, `data`, `payload` (the whole envelope, with
|
|
179
|
+
its `timestamp`), `request`, and `delivery_id` — the `X-FoPost-Delivery` header, which stays the same
|
|
180
|
+
across retries and so makes a good idempotency key.
|
|
181
|
+
|
|
182
|
+
**How it is verified.** FoPost signs the raw request body with HMAC-SHA256, keyed on the webhook
|
|
183
|
+
secret, and sends the hex digest as `X-FoPost-Signature: sha256=<digest>`. The view recomputes it
|
|
184
|
+
over `request.body` and compares with `hmac.compare_digest`. A missing, malformed, or wrong
|
|
185
|
+
signature is a `403` before any signal fires; a body that is not a JSON object is a `400`. The view
|
|
186
|
+
is `csrf_exempt` and accepts `POST` only.
|
|
187
|
+
|
|
188
|
+
**Failures are meant to propagate.** If a receiver raises, the response is a 5xx and FoPost retries
|
|
189
|
+
the delivery with backoff. Keep receivers quick and idempotent, or hand the work to a task queue.
|
|
190
|
+
|
|
191
|
+
## Testing your own code
|
|
192
|
+
|
|
193
|
+
Point the SDK at a stub transport instead of the network:
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
import httpx
|
|
197
|
+
from django.test import override_settings
|
|
198
|
+
from fopost_django import reset_client
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def handler(request):
|
|
202
|
+
return httpx.Response(200, json={"data": {"id": "post_1", "status": "draft"}})
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
with override_settings(
|
|
206
|
+
FOPOST={
|
|
207
|
+
"API_KEY": "fp_test",
|
|
208
|
+
"HTTP_CLIENT": httpx.Client(transport=httpx.MockTransport(handler)),
|
|
209
|
+
}
|
|
210
|
+
):
|
|
211
|
+
reset_client()
|
|
212
|
+
...
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`override_settings` already invalidates the cached client; `reset_client()` is there for the cases
|
|
216
|
+
where you swap the transport by hand.
|
|
217
|
+
|
|
218
|
+
## The rest of the API
|
|
219
|
+
|
|
220
|
+
Posts, accounts, workspaces, labels, AI, pagination, error classes and retry behaviour all live in
|
|
221
|
+
the parent SDK. See [`fopost` on PyPI](https://pypi.org/project/fopost/) and its
|
|
222
|
+
[README](https://github.com/fopost/fopost-python#readme); everything it documents works through
|
|
223
|
+
`fopost_django.client` unchanged.
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
from fopost import FopostError, RateLimitError
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
client.posts.publish(post_id)
|
|
230
|
+
except RateLimitError as exc:
|
|
231
|
+
retry_in = exc.retry_after
|
|
232
|
+
except FopostError as exc:
|
|
233
|
+
print(exc.status, exc.code, exc.message)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Links
|
|
237
|
+
|
|
238
|
+
- Docs — <https://fopost.com/docs>
|
|
239
|
+
- Issues — <https://github.com/fopost/fopost-django/issues>
|
|
240
|
+
- Support — <https://fopost.com/contact>
|
|
241
|
+
|
|
242
|
+
## License
|
|
243
|
+
|
|
244
|
+
MIT. Copyright (c) 2026 Porter Bridge, LLC.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
## `create_and_publish.py`
|
|
4
|
+
|
|
5
|
+
A standalone script — no project needed. It configures Django in-process, creates a post in the
|
|
6
|
+
first workspace your key can reach, and queues it for delivery.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install fopost-django
|
|
10
|
+
export FOPOST_API_KEY=fp_your_key_here
|
|
11
|
+
python examples/create_and_publish.py
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## `blog/`
|
|
15
|
+
|
|
16
|
+
Fragments from a realistic Django app: `receivers.py` reacts to FoPost webhooks, `views.py`
|
|
17
|
+
announces a newly published article. Copy them into an app of your own — they are not runnable on
|
|
18
|
+
their own.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""React to FoPost webhooks.
|
|
2
|
+
|
|
3
|
+
Import this module from your app config's ``ready()`` so the receivers connect::
|
|
4
|
+
|
|
5
|
+
class BlogConfig(AppConfig):
|
|
6
|
+
name = "blog"
|
|
7
|
+
|
|
8
|
+
def ready(self):
|
|
9
|
+
from . import receivers # noqa: F401
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
from django.dispatch import receiver
|
|
17
|
+
|
|
18
|
+
from fopost_django.signals import account_health_changed, post_failed, post_published
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@receiver(post_published)
|
|
24
|
+
def mark_article_announced(sender, event, data, delivery_id, **kwargs):
|
|
25
|
+
"""A post reached every account it was aimed at."""
|
|
26
|
+
from .models import Article
|
|
27
|
+
|
|
28
|
+
Article.objects.filter(fopost_post_id=data["postId"]).update(announced=True)
|
|
29
|
+
logger.info("FoPost delivery %s announced post %s", delivery_id, data["postId"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@receiver(post_failed)
|
|
33
|
+
def alert_on_failure(sender, event, data, **kwargs):
|
|
34
|
+
logger.error("FoPost post %s failed to publish: %s", data.get("postId"), data)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@receiver(account_health_changed)
|
|
38
|
+
def note_account_trouble(sender, data, **kwargs):
|
|
39
|
+
if data.get("healthStatus") != "healthy":
|
|
40
|
+
logger.warning("FoPost account %s needs reconnecting", data.get("accountId"))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Announce an article on social when an editor publishes it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.http import HttpRequest, JsonResponse
|
|
7
|
+
from django.shortcuts import get_object_or_404
|
|
8
|
+
from fopost import FopostError
|
|
9
|
+
|
|
10
|
+
from fopost_django import client
|
|
11
|
+
|
|
12
|
+
from .models import Article
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def announce(request: HttpRequest, slug: str) -> JsonResponse:
|
|
16
|
+
article = get_object_or_404(Article, slug=slug)
|
|
17
|
+
accounts = client.accounts.list(workspace_id=settings.FOPOST["DEFAULT_WORKSPACE_ID"])
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
post = client.posts.create(
|
|
21
|
+
workspace_id=settings.FOPOST["DEFAULT_WORKSPACE_ID"],
|
|
22
|
+
content=[
|
|
23
|
+
f"New on the blog: {article.title}",
|
|
24
|
+
{"text": article.url, "media": []},
|
|
25
|
+
],
|
|
26
|
+
accounts=[a.id for a in accounts if a.platform in {"twitter", "linkedin", "bluesky"}],
|
|
27
|
+
)
|
|
28
|
+
client.posts.publish(post.id)
|
|
29
|
+
except FopostError as exc:
|
|
30
|
+
return JsonResponse({"error": exc.code, "detail": exc.message}, status=502)
|
|
31
|
+
|
|
32
|
+
article.fopost_post_id = post.id
|
|
33
|
+
article.save(update_fields=["fopost_post_id"])
|
|
34
|
+
return JsonResponse({"post_id": post.id, "status": post.status})
|