django-agent-content 0.1.2__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.
- django_agent_content-0.1.2/LICENSE +21 -0
- django_agent_content-0.1.2/PKG-INFO +195 -0
- django_agent_content-0.1.2/README.md +162 -0
- django_agent_content-0.1.2/agent_content/__init__.py +8 -0
- django_agent_content-0.1.2/agent_content/admin.py +51 -0
- django_agent_content-0.1.2/agent_content/apps.py +13 -0
- django_agent_content-0.1.2/agent_content/auth.py +51 -0
- django_agent_content-0.1.2/agent_content/conf.py +23 -0
- django_agent_content-0.1.2/agent_content/management/__init__.py +0 -0
- django_agent_content-0.1.2/agent_content/management/commands/__init__.py +0 -0
- django_agent_content-0.1.2/agent_content/management/commands/agent_content_key.py +65 -0
- django_agent_content-0.1.2/agent_content/management/commands/agent_content_scaffold.py +63 -0
- django_agent_content-0.1.2/agent_content/migrations/0001_initial.py +47 -0
- django_agent_content-0.1.2/agent_content/migrations/__init__.py +0 -0
- django_agent_content-0.1.2/agent_content/models.py +102 -0
- django_agent_content-0.1.2/agent_content/registry.py +118 -0
- django_agent_content-0.1.2/agent_content/urls.py +12 -0
- django_agent_content-0.1.2/agent_content/views.py +365 -0
- django_agent_content-0.1.2/pyproject.toml +45 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sushan Poudel
|
|
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,195 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-agent-content
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Django app that lets AI agents and MCP clients (Claude Code, Cursor and others) edit your site's content safely: API keys, permissions, drafts, no deletes.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: django,mcp,model context protocol,ai agents,llm,claude,cms,content api,headless cms,django admin,api keys
|
|
8
|
+
Author: Sushan Poudel
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Framework :: Django :: 4.2
|
|
14
|
+
Classifier: Framework :: Django :: 5.2
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
25
|
+
Requires-Dist: Django (>=4.2)
|
|
26
|
+
Project-URL: Changelog, https://github.com/hexa01/django-agent-content#changelog
|
|
27
|
+
Project-URL: Documentation, https://github.com/hexa01/django-agent-content#readme
|
|
28
|
+
Project-URL: Homepage, https://github.com/hexa01/django-agent-content
|
|
29
|
+
Project-URL: Issues, https://github.com/hexa01/django-agent-content/issues
|
|
30
|
+
Project-URL: MCP server, https://github.com/hexa01/agent-content-mcp
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# django-agent-content
|
|
34
|
+
|
|
35
|
+
**Django + MCP: let AI agents edit your site's content, safely.**
|
|
36
|
+
|
|
37
|
+
Claude Code, Cursor and other MCP clients (through [agent-content-mcp](https://github.com/hexa01/agent-content-mcp)),
|
|
38
|
+
or any HTTP client, can read and edit the models you choose: blog posts, pages, profiles, products. You decide which
|
|
39
|
+
models and fields are editable. Agents use revocable API keys that act as a Django user, so permissions and the admin's
|
|
40
|
+
change history work as usual. New content can be forced to drafts, and nothing is ever deleted.
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
AI agent ──> MCP server ──HTTPS + API key──> your Django site: /api/agent/ ──> your models (via ModelForms)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install "django-agent-content @ https://github.com/hexa01/django-agent-content/archive/refs/tags/v0.1.1.tar.gz"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
# settings.py
|
|
54
|
+
INSTALLED_APPS = [..., 'agent_content']
|
|
55
|
+
|
|
56
|
+
# urls.py
|
|
57
|
+
path('api/agent/', include('agent_content.urls')),
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
python manage.py migrate
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Requires Django 4.2+ and works with any database Django supports: it uses your project's `DATABASES`.
|
|
65
|
+
|
|
66
|
+
## Say what agents may edit
|
|
67
|
+
|
|
68
|
+
In each app whose content agents should edit, add `agent_resources.py` (like `admin.py`). Start from a scaffold:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
python manage.py agent_content_scaffold blog # writes blog/agent_resources.py, everything commented out
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Then uncomment and adjust:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from agent_content import Action, Relation, Resource, register, slug_defaults
|
|
78
|
+
|
|
79
|
+
from .models import Post
|
|
80
|
+
|
|
81
|
+
register(Resource(
|
|
82
|
+
Post,
|
|
83
|
+
lookup='slug', # /api/agent/posts/<slug>/
|
|
84
|
+
fields=['title', 'slug', 'excerpt', 'body', 'category', 'tags'], # what agents may write
|
|
85
|
+
read_only=['status', 'published_at'], # what they may only read
|
|
86
|
+
relations={'category': Relation('name'), # refer to related objects by name, not id
|
|
87
|
+
'tags': Relation('name', create=True, create_defaults=slug_defaults)},
|
|
88
|
+
create=True, defaults={'status': 'draft'}, # new posts are always drafts
|
|
89
|
+
locked_fields=['slug'], locked_if=lambda post: post.status == 'published', # links never break
|
|
90
|
+
filters=['status'], # ?status=draft
|
|
91
|
+
actions={'publish': Action(publish, 'Make the post public.', # POST /api/agent/posts/<slug>/publish/
|
|
92
|
+
{'published_at': 'ISO date to show, optional'})},
|
|
93
|
+
))
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Only listed fields are exposed. Saves go through a ModelForm and your model's `save()`, so validation and side
|
|
97
|
+
effects match the admin.
|
|
98
|
+
|
|
99
|
+
### Full example
|
|
100
|
+
|
|
101
|
+
[`tests/testapp`](https://github.com/hexa01/django-agent-content/tree/main/tests/testapp) is a small working app used by the tests:
|
|
102
|
+
[`models.py`](https://github.com/hexa01/django-agent-content/blob/main/tests/testapp/models.py) and its [`agent_resources.py`](https://github.com/hexa01/django-agent-content/blob/main/tests/testapp/agent_resources.py) show most
|
|
103
|
+
options together: references by name, tags created when missing, drafts by default, a slug that locks once
|
|
104
|
+
published, a filter, search, a `publish` action with a parameter, an inline list (an article's notes) and a
|
|
105
|
+
single-object resource (site settings).
|
|
106
|
+
|
|
107
|
+
### Resource options
|
|
108
|
+
|
|
109
|
+
| Option | Meaning |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `fields` | Writable (and readable) fields. |
|
|
112
|
+
| `name` | URL name; default: the model's plural, e.g. `posts`. |
|
|
113
|
+
| `lookup` | Field used in item URLs; default `pk`. |
|
|
114
|
+
| `singular` | One item's name, used for tool names (`get_post`); default: the model's name. |
|
|
115
|
+
| `description` | What this content is, shown to agents. |
|
|
116
|
+
| `read_only` | Readable but not writable. |
|
|
117
|
+
| `computed` | Read-only values: `{'url': lambda obj: obj.get_absolute_url()}`. |
|
|
118
|
+
| `list_fields` | Fields shown in lists. |
|
|
119
|
+
| `relations` | `{'field': Relation('name', create=False)}` for ForeignKey/ManyToMany fields. |
|
|
120
|
+
| `create`, `defaults` | Allow creating; `defaults` are forced on create (e.g. a draft status). |
|
|
121
|
+
| `locked_fields`, `locked_if` | Fields that can't change while `locked_if(obj)` is true. |
|
|
122
|
+
| `filters`, `search` | `?field=value` filters; `search(queryset, text)` for `?q=`. |
|
|
123
|
+
| `queryset` | `queryset()` if not `Model.objects.all()`. |
|
|
124
|
+
| `singleton` | `get()` for one-object resources such as site settings: `/api/agent/<name>/`. |
|
|
125
|
+
| `actions` | `{'publish': Action(fn, 'what it does', {'param': 'what it is'})}`: `POST <lookup>/<name>/`; `fn(obj, data)` returns the object. A plain function also works (its docstring describes it). |
|
|
126
|
+
| `inlines` | `{'notes': Inline(Note, fk='article', fields=[...], order_field='position')}`: `GET`/`PUT <lookup>/notes/` replaces the list. |
|
|
127
|
+
|
|
128
|
+
## API keys
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
python manage.py agent_content_key --label "Claude Code, laptop" # never expires
|
|
132
|
+
python manage.py agent_content_key --label "CI" --days 90
|
|
133
|
+
python manage.py agent_content_key --list
|
|
134
|
+
python manage.py agent_content_key --revoke ac_Ab12cd # by prefix, or --revoke all --user agent-bot
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The key is printed once; only its SHA-256 hash is stored. By default it acts as the user `agent-bot` (created if
|
|
138
|
+
missing, with no password and no admin access) and is granted exactly the permissions your registered resources need.
|
|
139
|
+
Keys can also be created, revoked and checked (last used, expiry) under **Admin › Agent content › API keys**.
|
|
140
|
+
|
|
141
|
+
Agents send `Authorization: Bearer <key>`.
|
|
142
|
+
|
|
143
|
+
## Endpoints
|
|
144
|
+
|
|
145
|
+
| Request | Does |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `GET /api/agent/` | API version and a description of every resource the key may see: each field's kind, label, help text, limits, choices and whether it's required, plus actions with their params. [agent-content-mcp](https://github.com/hexa01/agent-content-mcp) builds its tools from this. |
|
|
148
|
+
| `GET <name>/` | List (`?field=value`, `?q=`). |
|
|
149
|
+
| `POST <name>/` | Create (when `create=True`). |
|
|
150
|
+
| `GET`, `PATCH <name>/<lookup>/` | Read, or change only the fields sent. |
|
|
151
|
+
| `POST <name>/<lookup>/<action>/` | Run an action. |
|
|
152
|
+
| `GET`, `PUT <name>/<lookup>/<inline>/` | Read or replace child rows. |
|
|
153
|
+
|
|
154
|
+
Errors are JSON: `{"error": "...", "fields": {"slug": ["..."]}}`, written so an agent can fix its request.
|
|
155
|
+
|
|
156
|
+
## Settings
|
|
157
|
+
|
|
158
|
+
All optional.
|
|
159
|
+
|
|
160
|
+
| Setting | Default | Meaning |
|
|
161
|
+
|---|---|---|
|
|
162
|
+
| `AGENT_CONTENT_MODULE` | `'agent_resources'` | File name looked up in each app. |
|
|
163
|
+
| `AGENT_CONTENT_ADMIN_BASE` | `'django.contrib.admin.ModelAdmin'` | Base class for the key admin, e.g. `'unfold.admin.ModelAdmin'` for Unfold. |
|
|
164
|
+
| `AGENT_CONTENT_RATE_LIMIT` | `120` | Requests per minute per key. |
|
|
165
|
+
| `AGENT_CONTENT_MAX_BODY` | `1000000` | Largest request body in bytes. |
|
|
166
|
+
| `AGENT_CONTENT_REQUIRE_HTTPS` | `None` | Refuse plain HTTP; `None` means "unless `DEBUG`". Behind a proxy, set Django's `SECURE_PROXY_SSL_HEADER`. |
|
|
167
|
+
|
|
168
|
+
Tip: add `Disallow: /api/` to your `robots.txt`.
|
|
169
|
+
|
|
170
|
+
## Security
|
|
171
|
+
|
|
172
|
+
- Keys are hashed, can expire, and can be revoked at any time; keys of inactive users stop working.
|
|
173
|
+
- What a key can do is its user's Django permissions. The API has no delete endpoints (inline lists replace their rows).
|
|
174
|
+
- HTTPS is required outside `DEBUG`; requests are rate-limited and size-limited.
|
|
175
|
+
- Every change is recorded in the admin's history under the key's user.
|
|
176
|
+
|
|
177
|
+
## Development
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
python -m venv .venv && .venv/bin/pip install "Django>=5.2"
|
|
181
|
+
.venv/bin/python runtests.py # SQLite, no project needed
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Changelog
|
|
185
|
+
|
|
186
|
+
- **0.1.2**: package metadata for PyPI (summary, keywords, classifiers, links); README example app and wording.
|
|
187
|
+
- **0.1.1**: updates change only the fields sent (unsent text is left exactly as it was); actions take a
|
|
188
|
+
description and params (`Action`); the API description adds field kinds, labels, `required`, `singular` and
|
|
189
|
+
create defaults.
|
|
190
|
+
- **0.1.0**: first release.
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT
|
|
195
|
+
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# django-agent-content
|
|
2
|
+
|
|
3
|
+
**Django + MCP: let AI agents edit your site's content, safely.**
|
|
4
|
+
|
|
5
|
+
Claude Code, Cursor and other MCP clients (through [agent-content-mcp](https://github.com/hexa01/agent-content-mcp)),
|
|
6
|
+
or any HTTP client, can read and edit the models you choose: blog posts, pages, profiles, products. You decide which
|
|
7
|
+
models and fields are editable. Agents use revocable API keys that act as a Django user, so permissions and the admin's
|
|
8
|
+
change history work as usual. New content can be forced to drafts, and nothing is ever deleted.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
AI agent ──> MCP server ──HTTPS + API key──> your Django site: /api/agent/ ──> your models (via ModelForms)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install "django-agent-content @ https://github.com/hexa01/django-agent-content/archive/refs/tags/v0.1.1.tar.gz"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
# settings.py
|
|
22
|
+
INSTALLED_APPS = [..., 'agent_content']
|
|
23
|
+
|
|
24
|
+
# urls.py
|
|
25
|
+
path('api/agent/', include('agent_content.urls')),
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python manage.py migrate
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires Django 4.2+ and works with any database Django supports: it uses your project's `DATABASES`.
|
|
33
|
+
|
|
34
|
+
## Say what agents may edit
|
|
35
|
+
|
|
36
|
+
In each app whose content agents should edit, add `agent_resources.py` (like `admin.py`). Start from a scaffold:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
python manage.py agent_content_scaffold blog # writes blog/agent_resources.py, everything commented out
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then uncomment and adjust:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from agent_content import Action, Relation, Resource, register, slug_defaults
|
|
46
|
+
|
|
47
|
+
from .models import Post
|
|
48
|
+
|
|
49
|
+
register(Resource(
|
|
50
|
+
Post,
|
|
51
|
+
lookup='slug', # /api/agent/posts/<slug>/
|
|
52
|
+
fields=['title', 'slug', 'excerpt', 'body', 'category', 'tags'], # what agents may write
|
|
53
|
+
read_only=['status', 'published_at'], # what they may only read
|
|
54
|
+
relations={'category': Relation('name'), # refer to related objects by name, not id
|
|
55
|
+
'tags': Relation('name', create=True, create_defaults=slug_defaults)},
|
|
56
|
+
create=True, defaults={'status': 'draft'}, # new posts are always drafts
|
|
57
|
+
locked_fields=['slug'], locked_if=lambda post: post.status == 'published', # links never break
|
|
58
|
+
filters=['status'], # ?status=draft
|
|
59
|
+
actions={'publish': Action(publish, 'Make the post public.', # POST /api/agent/posts/<slug>/publish/
|
|
60
|
+
{'published_at': 'ISO date to show, optional'})},
|
|
61
|
+
))
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Only listed fields are exposed. Saves go through a ModelForm and your model's `save()`, so validation and side
|
|
65
|
+
effects match the admin.
|
|
66
|
+
|
|
67
|
+
### Full example
|
|
68
|
+
|
|
69
|
+
[`tests/testapp`](https://github.com/hexa01/django-agent-content/tree/main/tests/testapp) is a small working app used by the tests:
|
|
70
|
+
[`models.py`](https://github.com/hexa01/django-agent-content/blob/main/tests/testapp/models.py) and its [`agent_resources.py`](https://github.com/hexa01/django-agent-content/blob/main/tests/testapp/agent_resources.py) show most
|
|
71
|
+
options together: references by name, tags created when missing, drafts by default, a slug that locks once
|
|
72
|
+
published, a filter, search, a `publish` action with a parameter, an inline list (an article's notes) and a
|
|
73
|
+
single-object resource (site settings).
|
|
74
|
+
|
|
75
|
+
### Resource options
|
|
76
|
+
|
|
77
|
+
| Option | Meaning |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `fields` | Writable (and readable) fields. |
|
|
80
|
+
| `name` | URL name; default: the model's plural, e.g. `posts`. |
|
|
81
|
+
| `lookup` | Field used in item URLs; default `pk`. |
|
|
82
|
+
| `singular` | One item's name, used for tool names (`get_post`); default: the model's name. |
|
|
83
|
+
| `description` | What this content is, shown to agents. |
|
|
84
|
+
| `read_only` | Readable but not writable. |
|
|
85
|
+
| `computed` | Read-only values: `{'url': lambda obj: obj.get_absolute_url()}`. |
|
|
86
|
+
| `list_fields` | Fields shown in lists. |
|
|
87
|
+
| `relations` | `{'field': Relation('name', create=False)}` for ForeignKey/ManyToMany fields. |
|
|
88
|
+
| `create`, `defaults` | Allow creating; `defaults` are forced on create (e.g. a draft status). |
|
|
89
|
+
| `locked_fields`, `locked_if` | Fields that can't change while `locked_if(obj)` is true. |
|
|
90
|
+
| `filters`, `search` | `?field=value` filters; `search(queryset, text)` for `?q=`. |
|
|
91
|
+
| `queryset` | `queryset()` if not `Model.objects.all()`. |
|
|
92
|
+
| `singleton` | `get()` for one-object resources such as site settings: `/api/agent/<name>/`. |
|
|
93
|
+
| `actions` | `{'publish': Action(fn, 'what it does', {'param': 'what it is'})}`: `POST <lookup>/<name>/`; `fn(obj, data)` returns the object. A plain function also works (its docstring describes it). |
|
|
94
|
+
| `inlines` | `{'notes': Inline(Note, fk='article', fields=[...], order_field='position')}`: `GET`/`PUT <lookup>/notes/` replaces the list. |
|
|
95
|
+
|
|
96
|
+
## API keys
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
python manage.py agent_content_key --label "Claude Code, laptop" # never expires
|
|
100
|
+
python manage.py agent_content_key --label "CI" --days 90
|
|
101
|
+
python manage.py agent_content_key --list
|
|
102
|
+
python manage.py agent_content_key --revoke ac_Ab12cd # by prefix, or --revoke all --user agent-bot
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The key is printed once; only its SHA-256 hash is stored. By default it acts as the user `agent-bot` (created if
|
|
106
|
+
missing, with no password and no admin access) and is granted exactly the permissions your registered resources need.
|
|
107
|
+
Keys can also be created, revoked and checked (last used, expiry) under **Admin › Agent content › API keys**.
|
|
108
|
+
|
|
109
|
+
Agents send `Authorization: Bearer <key>`.
|
|
110
|
+
|
|
111
|
+
## Endpoints
|
|
112
|
+
|
|
113
|
+
| Request | Does |
|
|
114
|
+
|---|---|
|
|
115
|
+
| `GET /api/agent/` | API version and a description of every resource the key may see: each field's kind, label, help text, limits, choices and whether it's required, plus actions with their params. [agent-content-mcp](https://github.com/hexa01/agent-content-mcp) builds its tools from this. |
|
|
116
|
+
| `GET <name>/` | List (`?field=value`, `?q=`). |
|
|
117
|
+
| `POST <name>/` | Create (when `create=True`). |
|
|
118
|
+
| `GET`, `PATCH <name>/<lookup>/` | Read, or change only the fields sent. |
|
|
119
|
+
| `POST <name>/<lookup>/<action>/` | Run an action. |
|
|
120
|
+
| `GET`, `PUT <name>/<lookup>/<inline>/` | Read or replace child rows. |
|
|
121
|
+
|
|
122
|
+
Errors are JSON: `{"error": "...", "fields": {"slug": ["..."]}}`, written so an agent can fix its request.
|
|
123
|
+
|
|
124
|
+
## Settings
|
|
125
|
+
|
|
126
|
+
All optional.
|
|
127
|
+
|
|
128
|
+
| Setting | Default | Meaning |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| `AGENT_CONTENT_MODULE` | `'agent_resources'` | File name looked up in each app. |
|
|
131
|
+
| `AGENT_CONTENT_ADMIN_BASE` | `'django.contrib.admin.ModelAdmin'` | Base class for the key admin, e.g. `'unfold.admin.ModelAdmin'` for Unfold. |
|
|
132
|
+
| `AGENT_CONTENT_RATE_LIMIT` | `120` | Requests per minute per key. |
|
|
133
|
+
| `AGENT_CONTENT_MAX_BODY` | `1000000` | Largest request body in bytes. |
|
|
134
|
+
| `AGENT_CONTENT_REQUIRE_HTTPS` | `None` | Refuse plain HTTP; `None` means "unless `DEBUG`". Behind a proxy, set Django's `SECURE_PROXY_SSL_HEADER`. |
|
|
135
|
+
|
|
136
|
+
Tip: add `Disallow: /api/` to your `robots.txt`.
|
|
137
|
+
|
|
138
|
+
## Security
|
|
139
|
+
|
|
140
|
+
- Keys are hashed, can expire, and can be revoked at any time; keys of inactive users stop working.
|
|
141
|
+
- What a key can do is its user's Django permissions. The API has no delete endpoints (inline lists replace their rows).
|
|
142
|
+
- HTTPS is required outside `DEBUG`; requests are rate-limited and size-limited.
|
|
143
|
+
- Every change is recorded in the admin's history under the key's user.
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
python -m venv .venv && .venv/bin/pip install "Django>=5.2"
|
|
149
|
+
.venv/bin/python runtests.py # SQLite, no project needed
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Changelog
|
|
153
|
+
|
|
154
|
+
- **0.1.2**: package metadata for PyPI (summary, keywords, classifiers, links); README example app and wording.
|
|
155
|
+
- **0.1.1**: updates change only the fields sent (unsent text is left exactly as it was); actions take a
|
|
156
|
+
description and params (`Action`); the API description adds field kinds, labels, `required`, `singular` and
|
|
157
|
+
create defaults.
|
|
158
|
+
- **0.1.0**: first release.
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Let AI agents edit a Django site's content through a small, permission-checked JSON API.
|
|
2
|
+
|
|
3
|
+
Apps say what may be edited in an `agent_resources.py` (see registry.py); keys are managed in admin
|
|
4
|
+
or with `manage.py agent_content_key`."""
|
|
5
|
+
|
|
6
|
+
from .registry import Action, Inline, Relation, Resource, register, slug_defaults
|
|
7
|
+
|
|
8
|
+
__all__ = ['Action', 'Inline', 'Relation', 'Resource', 'register', 'slug_defaults']
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from django.contrib import admin, messages
|
|
2
|
+
from django.utils import timezone
|
|
3
|
+
from django.utils.html import format_html
|
|
4
|
+
from django.utils.module_loading import import_string
|
|
5
|
+
|
|
6
|
+
from . import conf
|
|
7
|
+
from .models import APIKey
|
|
8
|
+
|
|
9
|
+
# a site using an admin theme (e.g. Unfold) sets AGENT_CONTENT_ADMIN_BASE to the theme's ModelAdmin
|
|
10
|
+
BaseAdmin = import_string(conf.get('AGENT_CONTENT_ADMIN_BASE'))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@admin.register(APIKey)
|
|
14
|
+
class APIKeyAdmin(BaseAdmin):
|
|
15
|
+
list_display = ['label', 'user', 'key_start', 'state', 'expires_at', 'last_used_at', 'created_at']
|
|
16
|
+
list_filter = ['user']
|
|
17
|
+
search_fields = ['label', 'prefix', 'user__username']
|
|
18
|
+
actions = ['revoke_keys']
|
|
19
|
+
fields = ['user', 'label', 'expires_at']
|
|
20
|
+
|
|
21
|
+
def get_readonly_fields(self, request, obj=None):
|
|
22
|
+
# once created, only the label and expiry can change; the key itself never does
|
|
23
|
+
return ['user', 'key_start', 'state', 'created_at', 'last_used_at', 'revoked_at'] if obj else []
|
|
24
|
+
|
|
25
|
+
def get_fields(self, request, obj=None):
|
|
26
|
+
return self.fields + (['key_start', 'state', 'created_at', 'last_used_at', 'revoked_at'] if obj else [])
|
|
27
|
+
|
|
28
|
+
@admin.display(description='Key')
|
|
29
|
+
def key_start(self, key):
|
|
30
|
+
return f'{key.prefix}…'
|
|
31
|
+
|
|
32
|
+
@admin.display(description='Status')
|
|
33
|
+
def state(self, key):
|
|
34
|
+
return key.status
|
|
35
|
+
|
|
36
|
+
def save_model(self, request, obj, form, change):
|
|
37
|
+
if change:
|
|
38
|
+
return super().save_model(request, obj, form, change)
|
|
39
|
+
raw = obj.assign_new_key()
|
|
40
|
+
super().save_model(request, obj, form, change)
|
|
41
|
+
self.message_user(request, format_html(
|
|
42
|
+
'New key for {}: <code style="user-select:all">{}</code> Copy it now: it won\'t be shown again.', obj.user, raw,
|
|
43
|
+
), messages.WARNING)
|
|
44
|
+
|
|
45
|
+
def has_delete_permission(self, request, obj=None):
|
|
46
|
+
return False # revoke instead, so the history of which key did what stays readable
|
|
47
|
+
|
|
48
|
+
@admin.action(description='Revoke selected keys')
|
|
49
|
+
def revoke_keys(self, request, queryset):
|
|
50
|
+
count = queryset.filter(revoked_at__isnull=True).update(revoked_at=timezone.now())
|
|
51
|
+
self.message_user(request, f'Revoked {count} key(s).')
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
from django.utils.module_loading import autodiscover_modules
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AgentContentConfig(AppConfig):
|
|
6
|
+
default_auto_field = 'django.db.models.BigAutoField'
|
|
7
|
+
name = 'agent_content'
|
|
8
|
+
verbose_name = 'Agent content'
|
|
9
|
+
|
|
10
|
+
def ready(self):
|
|
11
|
+
# load every app's agent_resources.py (or the AGENT_CONTENT_MODULE name), like admin.py is loaded
|
|
12
|
+
from . import conf
|
|
13
|
+
autodiscover_modules(conf.get('AGENT_CONTENT_MODULE'))
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from functools import wraps
|
|
3
|
+
|
|
4
|
+
from django.http import JsonResponse
|
|
5
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
6
|
+
|
|
7
|
+
from . import conf
|
|
8
|
+
from .models import APIKey, KeyUsage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def error(status, message, **extra):
|
|
12
|
+
return JsonResponse({'error': message, **extra}, status=status)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def agent_view(view):
|
|
16
|
+
"""Every API request: HTTPS, an API key (not a session, so no CSRF), rate and size limits, JSON body.
|
|
17
|
+
The view runs with request.user set to the key's user and request.data holding the parsed body."""
|
|
18
|
+
|
|
19
|
+
@csrf_exempt
|
|
20
|
+
@wraps(view)
|
|
21
|
+
def wrapper(request, *args, **kwargs):
|
|
22
|
+
if conf.get('AGENT_CONTENT_REQUIRE_HTTPS') and not request.is_secure():
|
|
23
|
+
return error(403, 'HTTPS is required.')
|
|
24
|
+
|
|
25
|
+
scheme, _, raw = request.headers.get('Authorization', '').partition(' ')
|
|
26
|
+
key = APIKey.find(raw.strip()) if scheme.lower() == 'bearer' and raw else None
|
|
27
|
+
if key is None:
|
|
28
|
+
response = error(401, 'A valid API key is required: Authorization: Bearer <key>.')
|
|
29
|
+
response['WWW-Authenticate'] = 'Bearer'
|
|
30
|
+
return response
|
|
31
|
+
|
|
32
|
+
if KeyUsage.hit(key) > conf.get('AGENT_CONTENT_RATE_LIMIT'):
|
|
33
|
+
return error(429, 'Too many requests. Try again in a minute.')
|
|
34
|
+
key.touch()
|
|
35
|
+
|
|
36
|
+
request.data = {}
|
|
37
|
+
if request.method in ('POST', 'PUT', 'PATCH'):
|
|
38
|
+
if len(request.body) > conf.get('AGENT_CONTENT_MAX_BODY'):
|
|
39
|
+
return error(413, 'The request body is too large.')
|
|
40
|
+
try:
|
|
41
|
+
request.data = json.loads(request.body or b'{}')
|
|
42
|
+
except ValueError:
|
|
43
|
+
return error(400, 'The request body must be JSON.')
|
|
44
|
+
if not isinstance(request.data, dict):
|
|
45
|
+
return error(400, 'The request body must be a JSON object.')
|
|
46
|
+
|
|
47
|
+
request.user = key.user
|
|
48
|
+
request.agent_key = key
|
|
49
|
+
return view(request, *args, **kwargs)
|
|
50
|
+
|
|
51
|
+
return wrapper
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Settings, each optional. Override any of them in the project's settings.py."""
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
|
|
5
|
+
DEFAULTS = {
|
|
6
|
+
# the file each app uses to register what agents may edit
|
|
7
|
+
'AGENT_CONTENT_MODULE': 'agent_resources',
|
|
8
|
+
# admin base class for the API key admin, e.g. 'unfold.admin.ModelAdmin'
|
|
9
|
+
'AGENT_CONTENT_ADMIN_BASE': 'django.contrib.admin.ModelAdmin',
|
|
10
|
+
# requests per minute per key
|
|
11
|
+
'AGENT_CONTENT_RATE_LIMIT': 120,
|
|
12
|
+
# largest accepted request body, in bytes
|
|
13
|
+
'AGENT_CONTENT_MAX_BODY': 1_000_000,
|
|
14
|
+
# refuse plain http; None means "only when DEBUG is off"
|
|
15
|
+
'AGENT_CONTENT_REQUIRE_HTTPS': None,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get(name):
|
|
20
|
+
value = getattr(settings, name, DEFAULTS[name])
|
|
21
|
+
if name == 'AGENT_CONTENT_REQUIRE_HTTPS' and value is None:
|
|
22
|
+
return not settings.DEBUG
|
|
23
|
+
return value
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
|
|
3
|
+
from django.contrib.auth import get_user_model
|
|
4
|
+
from django.contrib.auth.models import Permission
|
|
5
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
6
|
+
from django.utils import timezone
|
|
7
|
+
|
|
8
|
+
from agent_content.models import APIKey
|
|
9
|
+
from agent_content.registry import resources
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Command(BaseCommand):
|
|
13
|
+
help = ('Create an API key (printed once), or list/revoke keys. The user is created if missing, as a non-staff '
|
|
14
|
+
'account (it can\'t log into admin) with exactly the permissions the registered resources need.')
|
|
15
|
+
|
|
16
|
+
def add_arguments(self, parser):
|
|
17
|
+
parser.add_argument('--user', default='agent-bot', help='Username the key acts as (default: agent-bot).')
|
|
18
|
+
parser.add_argument('--label', default='Agent', help='Where the key is used, e.g. "Claude Code, laptop".')
|
|
19
|
+
expiry = parser.add_mutually_exclusive_group()
|
|
20
|
+
expiry.add_argument('--days', type=int, help='Expire after this many days.')
|
|
21
|
+
expiry.add_argument('--never', action='store_true', help='Never expire (the default).')
|
|
22
|
+
parser.add_argument('--no-grant', action='store_true', help='Don\'t change the user\'s permissions.')
|
|
23
|
+
parser.add_argument('--list', action='store_true', help='List keys instead of creating one.')
|
|
24
|
+
parser.add_argument('--revoke', metavar='PREFIX', help='Revoke the key starting with PREFIX, or "all" for the user\'s keys.')
|
|
25
|
+
|
|
26
|
+
def handle(self, *args, **options):
|
|
27
|
+
User = get_user_model()
|
|
28
|
+
if options['list']:
|
|
29
|
+
for key in APIKey.objects.select_related('user'):
|
|
30
|
+
self.stdout.write(f'{key.prefix}… {key.status:8} {key.user} {key.label} '
|
|
31
|
+
f'expires {key.expires_at or "never"} last used {key.last_used_at or "never"}')
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
if options['revoke']:
|
|
35
|
+
keys = APIKey.objects.filter(revoked_at__isnull=True)
|
|
36
|
+
keys = keys.filter(user__username=options['user']) if options['revoke'] == 'all' else keys.filter(prefix__startswith=options['revoke'])
|
|
37
|
+
count = keys.update(revoked_at=timezone.now())
|
|
38
|
+
self.stdout.write(self.style.SUCCESS(f'Revoked {count} key(s).'))
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
user, created = User.objects.get_or_create(
|
|
42
|
+
username=options['user'], defaults={'is_staff': False, 'is_active': True})
|
|
43
|
+
if created:
|
|
44
|
+
user.set_unusable_password()
|
|
45
|
+
user.save()
|
|
46
|
+
if not options['no_grant']:
|
|
47
|
+
self.grant(user)
|
|
48
|
+
|
|
49
|
+
expires = timezone.now() + timedelta(days=options['days']) if options['days'] else None
|
|
50
|
+
key, raw = APIKey.generate(user, options['label'], expires)
|
|
51
|
+
self.stdout.write(self.style.SUCCESS(f'Key for {user} ({"expires " + str(expires.date()) if expires else "never expires"}):'))
|
|
52
|
+
self.stdout.write(raw)
|
|
53
|
+
self.stdout.write('Copy it now: it is not stored and won\'t be shown again.')
|
|
54
|
+
|
|
55
|
+
def grant(self, user):
|
|
56
|
+
needed = set().union(*(r.permissions() for r in resources().values())) if resources() else set()
|
|
57
|
+
if not needed:
|
|
58
|
+
raise CommandError('No agent resources are registered (no agent_resources.py found).')
|
|
59
|
+
for perm in sorted(needed):
|
|
60
|
+
app_label, codename = perm.split('.')
|
|
61
|
+
try:
|
|
62
|
+
user.user_permissions.add(Permission.objects.get(content_type__app_label=app_label, codename=codename))
|
|
63
|
+
except Permission.DoesNotExist:
|
|
64
|
+
raise CommandError(f'Permission {perm} does not exist. Run migrate first.')
|
|
65
|
+
self.stdout.write(f'{user} can: {", ".join(sorted(needed))}')
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from django.apps import apps
|
|
4
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
5
|
+
from django.db import models
|
|
6
|
+
|
|
7
|
+
from agent_content import conf
|
|
8
|
+
|
|
9
|
+
HEADER = '''"""What AI agents may edit in this app, through agent_content's API.
|
|
10
|
+
|
|
11
|
+
Everything below starts commented out: uncomment the models and fields agents should be able to change.
|
|
12
|
+
Only listed fields are exposed. Docs: the Resource options in agent_content/registry.py.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from agent_content import Inline, Relation, Resource, register, slug_defaults # noqa: F401
|
|
16
|
+
|
|
17
|
+
'''
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Command(BaseCommand):
|
|
21
|
+
help = 'Write a starter agent_resources.py for an app, listing its models and editable fields (all commented out).'
|
|
22
|
+
|
|
23
|
+
def add_arguments(self, parser):
|
|
24
|
+
parser.add_argument('app_label')
|
|
25
|
+
|
|
26
|
+
def handle(self, app_label, **options):
|
|
27
|
+
try:
|
|
28
|
+
config = apps.get_app_config(app_label)
|
|
29
|
+
except LookupError:
|
|
30
|
+
raise CommandError(f'No installed app "{app_label}".')
|
|
31
|
+
path = Path(config.path) / f'{conf.get("AGENT_CONTENT_MODULE")}.py'
|
|
32
|
+
if path.exists():
|
|
33
|
+
raise CommandError(f'{path} already exists; not overwriting it.')
|
|
34
|
+
|
|
35
|
+
blocks = [self.block(model) for model in config.get_models()]
|
|
36
|
+
imports = f'# from .models import {", ".join(m.__name__ for m in config.get_models())}\n\n' if blocks else ''
|
|
37
|
+
path.write_text(HEADER + imports + '\n\n'.join(blocks) + '\n')
|
|
38
|
+
self.stdout.write(self.style.SUCCESS(f'Wrote {path}. Review it, uncomment what agents may edit, then restart.'))
|
|
39
|
+
|
|
40
|
+
def block(self, model):
|
|
41
|
+
fields, relations = [], []
|
|
42
|
+
for field in model._meta.get_fields():
|
|
43
|
+
if not getattr(field, 'concrete', False) or field.auto_created or not field.editable:
|
|
44
|
+
continue
|
|
45
|
+
if isinstance(field, (models.FileField, models.AutoField)) or field.name in ('password', 'is_superuser', 'is_staff'):
|
|
46
|
+
continue
|
|
47
|
+
fields.append(field.name)
|
|
48
|
+
if field.many_to_one or field.many_to_many:
|
|
49
|
+
relations.append(field.name)
|
|
50
|
+
lookup = 'slug' if any(f.name == 'slug' for f in model._meta.fields) else 'pk'
|
|
51
|
+
lines = [
|
|
52
|
+
f'# register(Resource(',
|
|
53
|
+
f'# {model.__name__},',
|
|
54
|
+
f'# lookup={lookup!r},',
|
|
55
|
+
f'# fields={fields!r},',
|
|
56
|
+
f'# # read_only=[], # readable but not writable, e.g. status or dates',
|
|
57
|
+
f'# # create=False, # True to allow new ones; set defaults={{...}} to force e.g. a draft status',
|
|
58
|
+
]
|
|
59
|
+
if relations:
|
|
60
|
+
rel = ', '.join(f'{name!r}: Relation("name")' for name in relations)
|
|
61
|
+
lines.append(f'# # relations={{{rel}}}, # refer to related objects by a field instead of id')
|
|
62
|
+
lines.append('# ))')
|
|
63
|
+
return '\n'.join(lines)
|