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.
@@ -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
@@ -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)