django-agent-content 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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']
agent_content/admin.py ADDED
@@ -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).')
agent_content/apps.py ADDED
@@ -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'))
agent_content/auth.py ADDED
@@ -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
agent_content/conf.py ADDED
@@ -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)
@@ -0,0 +1,47 @@
1
+ # Generated by Django 5.2.17 on 2026-10-14 09:06
2
+
3
+ import django.db.models.deletion
4
+ from django.conf import settings
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ initial = True
11
+
12
+ dependencies = [
13
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14
+ ]
15
+
16
+ operations = [
17
+ migrations.CreateModel(
18
+ name='APIKey',
19
+ fields=[
20
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21
+ ('label', models.CharField(help_text='Where it is used, e.g. "Claude Code on my laptop".', max_length=100)),
22
+ ('prefix', models.CharField(editable=False, help_text='The start of the key, to recognise it.', max_length=12)),
23
+ ('key_hash', models.CharField(editable=False, max_length=64, unique=True)),
24
+ ('created_at', models.DateTimeField(auto_now_add=True)),
25
+ ('expires_at', models.DateTimeField(blank=True, help_text='Empty means it never expires.', null=True)),
26
+ ('last_used_at', models.DateTimeField(blank=True, editable=False, null=True)),
27
+ ('revoked_at', models.DateTimeField(blank=True, editable=False, null=True)),
28
+ ('user', models.ForeignKey(help_text='The key acts as this user, with its permissions. Use a dedicated user, e.g. "agent-bot".', on_delete=django.db.models.deletion.CASCADE, related_name='agent_keys', to=settings.AUTH_USER_MODEL)),
29
+ ],
30
+ options={
31
+ 'verbose_name': 'API key',
32
+ 'ordering': ['-created_at'],
33
+ },
34
+ ),
35
+ migrations.CreateModel(
36
+ name='KeyUsage',
37
+ fields=[
38
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
39
+ ('minute', models.DateTimeField()),
40
+ ('count', models.PositiveIntegerField(default=0)),
41
+ ('key', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='agent_content.apikey')),
42
+ ],
43
+ options={
44
+ 'constraints': [models.UniqueConstraint(fields=('key', 'minute'), name='agent_content_usage_once_per_minute')],
45
+ },
46
+ ),
47
+ ]
File without changes
@@ -0,0 +1,102 @@
1
+ import hashlib
2
+ import secrets
3
+
4
+ from django.conf import settings
5
+ from django.db import models
6
+ from django.db.models import F
7
+ from django.utils import timezone
8
+
9
+ PREFIX = 'ac_'
10
+
11
+
12
+ def _hash(raw):
13
+ return hashlib.sha256(raw.encode()).hexdigest()
14
+
15
+
16
+ class APIKey(models.Model):
17
+ """A key an agent sends as `Authorization: Bearer <key>`. Only its hash is stored; the key is shown once."""
18
+
19
+ user = models.ForeignKey(
20
+ settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='agent_keys',
21
+ help_text='The key acts as this user, with its permissions. Use a dedicated user, e.g. "agent-bot".',
22
+ )
23
+ label = models.CharField(max_length=100, help_text='Where it is used, e.g. "Claude Code on my laptop".')
24
+ prefix = models.CharField(max_length=12, editable=False, help_text='The start of the key, to recognise it.')
25
+ key_hash = models.CharField(max_length=64, unique=True, editable=False)
26
+ created_at = models.DateTimeField(auto_now_add=True)
27
+ expires_at = models.DateTimeField(null=True, blank=True, help_text='Empty means it never expires.')
28
+ last_used_at = models.DateTimeField(null=True, blank=True, editable=False)
29
+ revoked_at = models.DateTimeField(null=True, blank=True, editable=False)
30
+
31
+ class Meta:
32
+ verbose_name = 'API key'
33
+ ordering = ['-created_at']
34
+
35
+ def __str__(self):
36
+ return f'{self.label} ({self.prefix}…)'
37
+
38
+ def assign_new_key(self):
39
+ """Give this (unsaved) key its secret. Returns the raw key: it exists only here, so show it to the person now."""
40
+ raw = PREFIX + secrets.token_urlsafe(32)
41
+ self.prefix, self.key_hash = raw[:10], _hash(raw)
42
+ return raw
43
+
44
+ @classmethod
45
+ def generate(cls, user, label, expires_at=None):
46
+ key = cls(user=user, label=label, expires_at=expires_at)
47
+ raw = key.assign_new_key()
48
+ key.save()
49
+ return key, raw
50
+
51
+ @classmethod
52
+ def find(cls, raw):
53
+ """The usable key for a raw value, or None (unknown, revoked, expired, or its user is inactive)."""
54
+ key = cls.objects.select_related('user').filter(key_hash=_hash(raw)).first()
55
+ return key if key and key.is_usable else None
56
+
57
+ @property
58
+ def is_usable(self):
59
+ expired = self.expires_at is not None and self.expires_at <= timezone.now()
60
+ return self.revoked_at is None and not expired and self.user.is_active
61
+
62
+ @property
63
+ def status(self):
64
+ if self.revoked_at:
65
+ return 'revoked'
66
+ if self.expires_at and self.expires_at <= timezone.now():
67
+ return 'expired'
68
+ return 'active'
69
+
70
+ def revoke(self):
71
+ if not self.revoked_at:
72
+ self.revoked_at = timezone.now()
73
+ self.save(update_fields=['revoked_at'])
74
+
75
+ def touch(self):
76
+ """Record use, at most once a minute, so busy agents don't write on every request."""
77
+ now = timezone.now()
78
+ if not self.last_used_at or (now - self.last_used_at).total_seconds() > 60:
79
+ APIKey.objects.filter(pk=self.pk).update(last_used_at=now)
80
+
81
+
82
+ class KeyUsage(models.Model):
83
+ """Requests per key per minute, for the rate limit. Old rows are removed as new minutes start."""
84
+
85
+ key = models.ForeignKey(APIKey, on_delete=models.CASCADE, related_name='+')
86
+ minute = models.DateTimeField()
87
+ count = models.PositiveIntegerField(default=0)
88
+
89
+ class Meta:
90
+ constraints = [models.UniqueConstraint(fields=['key', 'minute'], name='agent_content_usage_once_per_minute')]
91
+
92
+ @classmethod
93
+ def hit(cls, key):
94
+ """Count a request; returns how many this key made in the current minute."""
95
+ minute = timezone.now().replace(second=0, microsecond=0)
96
+ if not cls.objects.filter(key=key, minute=minute).update(count=F('count') + 1):
97
+ _, created = cls.objects.get_or_create(key=key, minute=minute, defaults={'count': 1})
98
+ if created:
99
+ cls.objects.filter(minute__lt=minute).delete()
100
+ else:
101
+ cls.objects.filter(key=key, minute=minute).update(count=F('count') + 1)
102
+ return cls.objects.get(key=key, minute=minute).count
@@ -0,0 +1,118 @@
1
+ """What agents may edit. Apps describe their models in `agent_resources.py`; the API is built from these descriptions.
2
+
3
+ register(Resource(Post, lookup='slug', fields=['title', 'body'], create=True, defaults={'status': 'draft'}))
4
+ """
5
+
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Callable
8
+
9
+ from django.utils.text import slugify
10
+
11
+
12
+ @dataclass
13
+ class Relation:
14
+ """How agents refer to a related object (a ForeignKey or ManyToMany field): by `field` instead of its id.
15
+ With create=True, unknown values are created; create_defaults(value) supplies the other fields (e.g. a slug)."""
16
+
17
+ field: str = 'pk'
18
+ create: bool = False
19
+ create_defaults: Callable[[str], dict] | None = None
20
+
21
+
22
+ def slug_defaults(value):
23
+ """create_defaults for models with a slug next to the name."""
24
+ return {'slug': slugify(value)}
25
+
26
+
27
+ @dataclass
28
+ class Action:
29
+ """Something done to one object, e.g. publishing: POST <resource>/<lookup>/<name>/ with params as JSON.
30
+ fn(obj, data) does it and returns the object. description and params tell agents what it does and takes."""
31
+
32
+ fn: Callable
33
+ description: str = ''
34
+ params: dict[str, str] = field(default_factory=dict) # name -> what it is, e.g. {'published_at': 'ISO date, optional'}
35
+
36
+
37
+ @dataclass
38
+ class Inline:
39
+ """Child rows edited as one list, e.g. an article's notes: GET or PUT <resource>/<lookup>/<name>/.
40
+ PUT replaces the whole list (rows not sent are removed), in the order given."""
41
+
42
+ model: Any
43
+ fk: str
44
+ fields: list[str]
45
+ order_field: str | None = None
46
+
47
+
48
+ @dataclass
49
+ class Resource:
50
+ model: Any
51
+ fields: list[str] # what agents may write (and read)
52
+ name: str | None = None # URL name; default: the model's plural, e.g. "posts"
53
+ singular: str | None = None # one item, for tool names; default: the model's name
54
+ description: str = '' # what this content is, for agents
55
+ lookup: str = 'pk' # field used in item URLs
56
+ read_only: list[str] = field(default_factory=list) # extra fields agents may read but not write
57
+ computed: dict[str, Callable] = field(default_factory=dict) # read-only values, e.g. {'url': lambda o: ...}
58
+ list_fields: list[str] | None = None # fields in list results; default: lookup + first few
59
+ relations: dict[str, Relation] = field(default_factory=dict)
60
+ create: bool = False
61
+ defaults: dict = field(default_factory=dict) # forced on create, e.g. {'status': 'draft'}
62
+ locked_fields: list[str] = field(default_factory=list) # can't change while locked_if(obj) is true
63
+ locked_if: Callable | None = None
64
+ filters: list[str] = field(default_factory=list) # fields allowed as ?field=value on lists
65
+ search: Callable | None = None # search(queryset, text) for ?q=
66
+ queryset: Callable | None = None # queryset() if not model.objects.all()
67
+ singleton: Callable | None = None # get() for one-object resources (e.g. a site profile)
68
+ actions: dict[str, Action | Callable] = field(default_factory=dict) # POST <lookup>/<name>/
69
+ inlines: dict[str, Inline] = field(default_factory=dict)
70
+
71
+ def __post_init__(self):
72
+ if self.name is None:
73
+ self.name = slugify(str(self.model._meta.verbose_name_plural))
74
+ if self.singular is None:
75
+ self.singular = self.name if self.singleton else slugify(str(self.model._meta.verbose_name)).replace('-', '_')
76
+ # a plain function is an action without params; its docstring's first line describes it
77
+ self.actions = {name: action if isinstance(action, Action)
78
+ else Action(action, ((action.__doc__ or '').strip().splitlines() or [''])[0])
79
+ for name, action in self.actions.items()}
80
+ if self.list_fields is None:
81
+ self.list_fields = list(dict.fromkeys([self.lookup] + self.fields[:2] + self.read_only[:3]))
82
+
83
+ @property
84
+ def label(self):
85
+ return f'{self.model._meta.app_label}.{self.model._meta.model_name}'
86
+
87
+ def get_queryset(self):
88
+ return self.queryset() if self.queryset else self.model._default_manager.all()
89
+
90
+ def permissions(self):
91
+ """The Django permissions a key's user needs to use this resource fully."""
92
+ opts = self.model._meta
93
+ perms = {f'{opts.app_label}.view_{opts.model_name}', f'{opts.app_label}.change_{opts.model_name}'}
94
+ if self.create:
95
+ perms.add(f'{opts.app_label}.add_{opts.model_name}')
96
+ for name, relation in self.relations.items():
97
+ related = self.model._meta.get_field(name).related_model._meta
98
+ perms.add(f'{related.app_label}.view_{related.model_name}')
99
+ if relation.create:
100
+ perms.add(f'{related.app_label}.add_{related.model_name}')
101
+ for inline in self.inlines.values():
102
+ child = inline.model._meta
103
+ perms |= {f'{child.app_label}.{action}_{child.model_name}' for action in ('view', 'add', 'change', 'delete')}
104
+ return perms
105
+
106
+
107
+ _registry: dict[str, Resource] = {}
108
+
109
+
110
+ def register(resource: Resource) -> Resource:
111
+ if resource.name in _registry:
112
+ raise ValueError(f'An agent resource named "{resource.name}" is already registered.')
113
+ _registry[resource.name] = resource
114
+ return resource
115
+
116
+
117
+ def resources() -> dict[str, Resource]:
118
+ return _registry
agent_content/urls.py ADDED
@@ -0,0 +1,12 @@
1
+ from django.urls import path
2
+
3
+ from . import views
4
+
5
+ app_name = 'agent_content'
6
+
7
+ urlpatterns = [
8
+ path('', views.index, name='index'),
9
+ path('<slug:name>/', views.collection, name='collection'),
10
+ path('<slug:name>/<str:lookup>/', views.item, name='item'),
11
+ path('<slug:name>/<str:lookup>/<slug:sub>/', views.member, name='member'),
12
+ ]
agent_content/views.py ADDED
@@ -0,0 +1,365 @@
1
+ """The API, built from the registered resources (see registry.py). JSON in, JSON out; saves go through
2
+ ModelForms and model.save(), so validation and side effects match the admin. Nothing is ever deleted,
3
+ except child rows replaced through an inline."""
4
+
5
+ from django.contrib.admin.models import ADDITION, CHANGE, LogEntry
6
+ from django.contrib.admin.utils import construct_change_message
7
+ from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
8
+ from django.db import models, transaction
9
+ from django.forms import modelform_factory
10
+ from django.forms.models import model_to_dict
11
+ from django.http import JsonResponse
12
+
13
+ from .auth import agent_view, error
14
+ from .registry import resources
15
+
16
+ API_VERSION = 1
17
+ LIST_LIMIT = 100
18
+
19
+
20
+ class APIError(Exception):
21
+ def __init__(self, status, message, **extra):
22
+ super().__init__(message)
23
+ self.response = error(status, message, **extra)
24
+
25
+
26
+ # reading
27
+
28
+ def _value(obj, name, resource):
29
+ try:
30
+ field = obj._meta.get_field(name)
31
+ except FieldDoesNotExist: # e.g. a property listed in read_only
32
+ field = None
33
+ relation = resource.relations.get(name)
34
+ if field is not None and field.many_to_many:
35
+ return [getattr(item, relation.field) if relation else item.pk for item in getattr(obj, name).all()]
36
+ if field is not None and field.many_to_one:
37
+ related = getattr(obj, name)
38
+ if related is None:
39
+ return None
40
+ return getattr(related, relation.field) if relation else related.pk
41
+ value = getattr(obj, name)
42
+ if isinstance(value, models.fields.files.FieldFile):
43
+ return value.url if value else None
44
+ return value
45
+
46
+
47
+ def serialize(obj, resource, full=True):
48
+ """All readable fields, computed values and inline lists; or with full=False, just the list fields."""
49
+ if not full:
50
+ return {name: _value(obj, name, resource) for name in resource.list_fields}
51
+ names = dict.fromkeys([resource.lookup] + resource.fields + resource.read_only)
52
+ data = {name: _value(obj, name, resource) for name in names}
53
+ data.update({name: fn(obj) for name, fn in resource.computed.items()})
54
+ data.update({name: _inline_rows(obj, inline) for name, inline in resource.inlines.items()})
55
+ return data
56
+
57
+
58
+ def _inline_rows(obj, inline):
59
+ rows = inline.model._default_manager.filter(**{inline.fk: obj})
60
+ if inline.order_field:
61
+ rows = rows.order_by(inline.order_field, 'pk')
62
+ return [{name: getattr(row, name) for name in inline.fields} for row in rows]
63
+
64
+
65
+ KINDS = {
66
+ 'CharField': 'string', 'SlugField': 'string', 'URLField': 'string', 'EmailField': 'string', 'TextField': 'text',
67
+ 'BooleanField': 'boolean', 'DateTimeField': 'datetime', 'DateField': 'date', 'TimeField': 'time',
68
+ 'DecimalField': 'number', 'FloatField': 'number', 'JSONField': 'json',
69
+ }
70
+
71
+
72
+ def _field_info(model, name, required, writable=True, relation=None):
73
+ """How an agent should fill one field: kind, label, help text, limits, choices."""
74
+ field = model._meta.get_field(name)
75
+ internal = field.get_internal_type()
76
+ if field.many_to_many:
77
+ kind = 'list'
78
+ elif field.many_to_one:
79
+ kind = 'reference'
80
+ else:
81
+ kind = KINDS.get(internal, 'integer' if 'Integer' in internal or internal == 'AutoField' else 'string')
82
+ info = {'kind': kind, 'label': str(field.verbose_name), 'writable': writable, 'required': required}
83
+ if getattr(field, 'help_text', ''):
84
+ info['help'] = str(field.help_text)
85
+ if getattr(field, 'max_length', None) and kind in ('string', 'text'):
86
+ info['max_length'] = field.max_length
87
+ if field.choices:
88
+ info['choices'] = [value for value, _ in field.choices]
89
+ if relation:
90
+ info['refer_by'] = relation.field
91
+ info['creates_missing'] = relation.create
92
+ return info
93
+
94
+
95
+ def describe(resource):
96
+ """Everything an agent (or an MCP server generating tools) needs to use a resource."""
97
+ form_fields = modelform_factory(resource.model, fields=resource.fields)().fields
98
+ fields = {}
99
+ for name in dict.fromkeys(resource.fields + resource.read_only):
100
+ try:
101
+ writable = name in resource.fields
102
+ required = writable and name in form_fields and form_fields[name].required
103
+ fields[name] = _field_info(resource.model, name, required, writable, resource.relations.get(name))
104
+ except FieldDoesNotExist: # e.g. a property listed in read_only
105
+ fields[name] = {'kind': 'string', 'writable': False, 'required': False}
106
+ if name in resource.locked_fields:
107
+ fields[name]['locked_when_published'] = True
108
+ inlines = {}
109
+ for name, inline in resource.inlines.items():
110
+ child = modelform_factory(inline.model, fields=inline.fields)().fields
111
+ inlines[name] = {'fields': {f: _field_info(inline.model, f, child[f].required) for f in inline.fields}}
112
+ return {
113
+ 'name': resource.name,
114
+ 'singular': resource.singular,
115
+ 'model': str(resource.model._meta.verbose_name),
116
+ 'description': resource.description,
117
+ 'singleton': resource.singleton is not None,
118
+ 'lookup': None if resource.singleton else resource.lookup,
119
+ 'create': resource.create,
120
+ 'create_defaults': {key: str(value) for key, value in resource.defaults.items()},
121
+ 'fields': fields,
122
+ 'computed': list(resource.computed),
123
+ 'filters': resource.filters,
124
+ 'search': resource.search is not None,
125
+ 'actions': {name: {'description': action.description, 'params': action.params}
126
+ for name, action in resource.actions.items()},
127
+ 'inlines': inlines,
128
+ }
129
+
130
+
131
+ # writing
132
+
133
+ def _resolve_relations(resource, data):
134
+ """Turn names (or other refer_by values) into ids, creating missing ones where the relation allows it."""
135
+ out, errors = dict(data), {}
136
+ for name, relation in resource.relations.items():
137
+ if name not in data:
138
+ continue
139
+ field = resource.model._meta.get_field(name)
140
+ manager = field.related_model._default_manager
141
+
142
+ def find(value):
143
+ obj = manager.filter(**{relation.field: value}).first()
144
+ if obj is None and relation.create:
145
+ extra = relation.create_defaults(value) if relation.create_defaults else {}
146
+ obj = manager.create(**{relation.field: value, **extra})
147
+ return obj
148
+
149
+ values = data[name]
150
+ if field.many_to_many:
151
+ if not isinstance(values, list):
152
+ errors[name] = ['Send a list.']
153
+ continue
154
+ found = [find(value) for value in values]
155
+ missing = [value for value, obj in zip(values, found) if obj is None]
156
+ if missing:
157
+ errors[name] = [f'Not found: {", ".join(map(str, missing))}.']
158
+ out[name] = [obj.pk for obj in found if obj]
159
+ elif values in (None, ''):
160
+ out[name] = None
161
+ else:
162
+ obj = find(values)
163
+ if obj is None:
164
+ choices = ', '.join(str(v) for v in manager.values_list(relation.field, flat=True)[:30])
165
+ errors[name] = [f'No {field.related_model._meta.verbose_name} "{values}". Existing: {choices}.']
166
+ else:
167
+ out[name] = obj.pk
168
+ return out, errors
169
+
170
+
171
+ def _current_values(instance, resource):
172
+ values = model_to_dict(instance, fields=resource.fields)
173
+ for name, value in values.items():
174
+ if isinstance(value, list): # many-to-many: model_to_dict gives objects; forms want ids
175
+ values[name] = [getattr(item, 'pk', item) for item in value]
176
+ return values
177
+
178
+
179
+ def save(request, resource, instance, data):
180
+ """Validate and save a create (instance=None) or a partial update. Returns the saved object."""
181
+ unknown = sorted(set(data) - set(resource.fields))
182
+ if unknown:
183
+ raise APIError(400, f'These fields can\'t be written: {", ".join(unknown)}. Writable: {", ".join(resource.fields)}.')
184
+
185
+ if instance is not None and resource.locked_if and resource.locked_if(instance):
186
+ current = _current_values(instance, resource)
187
+ locked = [name for name in resource.locked_fields if name in data and data[name] != current.get(name)]
188
+ if locked:
189
+ raise APIError(400, f'{", ".join(locked)} can\'t change once published (links to it would break).')
190
+
191
+ adding = instance is None
192
+ with transaction.atomic():
193
+ data, errors = _resolve_relations(resource, data)
194
+ if errors:
195
+ transaction.set_rollback(True)
196
+ raise APIError(400, 'Some fields are invalid.', fields=errors)
197
+
198
+ # a create validates every writable field; an update only the ones sent, so the rest stay untouched
199
+ target = resource.model(**resource.defaults) if adding else instance
200
+ fields = resource.fields if adding else [name for name in resource.fields if name in data]
201
+ form = modelform_factory(resource.model, fields=fields)(data=data, instance=target)
202
+ if not form.is_valid():
203
+ transaction.set_rollback(True)
204
+ raise APIError(400, 'Some fields are invalid.', fields={
205
+ name: [item['message'] for item in items] for name, items in form.errors.get_json_data().items()})
206
+ obj = form.save()
207
+ log(request, obj, ADDITION if adding else CHANGE, construct_change_message(form, None, adding))
208
+ return obj
209
+
210
+
211
+ def log(request, obj, flag, message):
212
+ """Record the change in the admin's history for this object, under the key's user."""
213
+ if hasattr(LogEntry.objects, 'log_actions'): # Django 5.1+
214
+ LogEntry.objects.log_actions(request.user.pk, [obj], flag, message, single_object=True)
215
+ else:
216
+ from django.contrib.contenttypes.models import ContentType
217
+ LogEntry.objects.log_action(request.user.pk, ContentType.objects.get_for_model(obj).pk,
218
+ obj.pk, str(obj), flag, message)
219
+
220
+
221
+ def replace_inline(request, resource, obj, name, inline):
222
+ rows = request.data.get('items')
223
+ if not isinstance(rows, list):
224
+ raise APIError(400, f'Send {{"items": [...]}}, each with: {", ".join(inline.fields)}.')
225
+ Form = modelform_factory(inline.model, fields=inline.fields)
226
+ forms = [Form(data=row if isinstance(row, dict) else {}) for row in rows]
227
+ errors = {f'items[{i}]': {k: [e['message'] for e in v] for k, v in form.errors.get_json_data().items()}
228
+ for i, form in enumerate(forms) if not form.is_valid()}
229
+ if errors:
230
+ raise APIError(400, 'Some items are invalid.', fields=errors)
231
+ with transaction.atomic():
232
+ inline.model._default_manager.filter(**{inline.fk: obj}).delete()
233
+ for position, form in enumerate(forms):
234
+ row = form.save(commit=False)
235
+ setattr(row, inline.fk, obj)
236
+ if inline.order_field:
237
+ setattr(row, inline.order_field, position)
238
+ row.save()
239
+ log(request, obj, CHANGE, [{'changed': {'fields': [name]}}])
240
+
241
+
242
+ # helpers
243
+
244
+ def _resource(name):
245
+ resource = resources().get(name)
246
+ if resource is None:
247
+ raise APIError(404, f'No resource "{name}". Available: {", ".join(resources())}.')
248
+ return resource
249
+
250
+
251
+ def _require(request, perm):
252
+ if not request.user.has_perm(perm):
253
+ raise APIError(403, f'This key\'s user lacks the permission "{perm}".')
254
+
255
+
256
+ def _perm(resource, action):
257
+ return f'{resource.model._meta.app_label}.{action}_{resource.model._meta.model_name}'
258
+
259
+
260
+ def _object(resource, lookup=None):
261
+ if resource.singleton:
262
+ return resource.singleton()
263
+ obj = resource.get_queryset().filter(**{resource.lookup: lookup}).first()
264
+ if obj is None:
265
+ raise APIError(404, f'No {resource.model._meta.verbose_name} with {resource.lookup} "{lookup}".')
266
+ return obj
267
+
268
+
269
+ def handles_errors(view):
270
+ def wrapper(request, *args, **kwargs):
271
+ try:
272
+ return view(request, *args, **kwargs)
273
+ except APIError as exc:
274
+ return exc.response
275
+ except ValidationError as exc:
276
+ return error(400, ' '.join(exc.messages))
277
+ except ObjectDoesNotExist as exc:
278
+ return error(404, str(exc))
279
+ return wrapper
280
+
281
+
282
+ def _not_allowed(*methods):
283
+ response = error(405, f'Use {" or ".join(methods)}.')
284
+ response['Allow'] = ', '.join(methods)
285
+ return response
286
+
287
+
288
+ # views
289
+
290
+ @agent_view
291
+ @handles_errors
292
+ def index(request):
293
+ visible = [r for r in resources().values() if request.user.has_perm(_perm(r, 'view'))]
294
+ return JsonResponse({'version': API_VERSION, 'resources': [describe(r) for r in visible]})
295
+
296
+
297
+ @agent_view
298
+ @handles_errors
299
+ def collection(request, name):
300
+ resource = _resource(name)
301
+ if resource.singleton:
302
+ return _item(request, resource, None)
303
+ if request.method == 'GET':
304
+ _require(request, _perm(resource, 'view'))
305
+ queryset = resource.get_queryset()
306
+ for key in resource.filters:
307
+ if key in request.GET:
308
+ queryset = queryset.filter(**{key: request.GET[key]})
309
+ if (text := request.GET.get('q', '').strip()) and resource.search:
310
+ queryset = resource.search(queryset, text)
311
+ items = list(queryset[:LIST_LIMIT])
312
+ return JsonResponse({'results': [serialize(obj, resource, full=False) for obj in items]})
313
+ if request.method == 'POST':
314
+ if not resource.create:
315
+ raise APIError(405, f'New {resource.name} can\'t be created through the API.')
316
+ _require(request, _perm(resource, 'add'))
317
+ obj = save(request, resource, None, request.data)
318
+ return JsonResponse(serialize(obj, resource), status=201)
319
+ return _not_allowed('GET', 'POST')
320
+
321
+
322
+ @agent_view
323
+ @handles_errors
324
+ def item(request, name, lookup):
325
+ return _item(request, _resource(name), lookup)
326
+
327
+
328
+ def _item(request, resource, lookup):
329
+ if request.method == 'GET':
330
+ _require(request, _perm(resource, 'view'))
331
+ return JsonResponse(serialize(_object(resource, lookup), resource))
332
+ if request.method == 'PATCH':
333
+ _require(request, _perm(resource, 'change'))
334
+ obj = save(request, resource, _object(resource, lookup), request.data)
335
+ return JsonResponse(serialize(obj, resource))
336
+ return _not_allowed('GET', 'PATCH')
337
+
338
+
339
+ @agent_view
340
+ @handles_errors
341
+ def member(request, name, lookup, sub):
342
+ """POST <lookup>/<action>/ runs an action; GET or PUT <lookup>/<inline>/ reads or replaces child rows."""
343
+ resource = _resource(name)
344
+ obj = _object(resource, lookup)
345
+ if sub in resource.actions:
346
+ if request.method != 'POST':
347
+ return _not_allowed('POST')
348
+ _require(request, _perm(resource, 'change'))
349
+ with transaction.atomic():
350
+ obj = resource.actions[sub].fn(obj, request.data) or obj
351
+ log(request, obj, CHANGE, [{'changed': {'fields': [sub]}}])
352
+ return JsonResponse(serialize(obj, resource))
353
+ if sub in resource.inlines:
354
+ inline = resource.inlines[sub]
355
+ if request.method == 'GET':
356
+ _require(request, _perm(resource, 'view'))
357
+ return JsonResponse({'items': _inline_rows(obj, inline)})
358
+ if request.method == 'PUT':
359
+ for action in ('add', 'change', 'delete'):
360
+ _require(request, f'{inline.model._meta.app_label}.{action}_{inline.model._meta.model_name}')
361
+ replace_inline(request, resource, obj, sub, inline)
362
+ return JsonResponse({'items': _inline_rows(obj, inline)})
363
+ return _not_allowed('GET', 'PUT')
364
+ raise APIError(404, f'No action or list "{sub}" on {resource.name}. '
365
+ f'Actions: {", ".join(resource.actions) or "none"}. Lists: {", ".join(resource.inlines) or "none"}.')
@@ -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,19 @@
1
+ agent_content/__init__.py,sha256=L0Oe9vkh5q3wnoIk1dkjtNmUoM8SXvFju-RGDTtrgqc,403
2
+ agent_content/admin.py,sha256=Hh40TrOaFm7MRwhOEzTReAVRbdPipr8rwsAzClRlHoQ,2144
3
+ agent_content/apps.py,sha256=wmietMxygacqGWutSecMYwnQilbF8nqcFP5DsiJOFNE,471
4
+ agent_content/auth.py,sha256=BIwCsava_BL7PrkW8SCdO3PgNvfml28pksDUeONkHm4,1932
5
+ agent_content/conf.py,sha256=17S7q4Kc-9SmxpJwwwbMzxbBV7qH1s-tfdMbYkehiSU,829
6
+ agent_content/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ agent_content/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ agent_content/management/commands/agent_content_key.py,sha256=YOn_2KcdPqeCg3m-rT2ae_a6dfw-Krbsgp9L5ZVdWGY,3605
9
+ agent_content/management/commands/agent_content_scaffold.py,sha256=Jtc1cPO8cWPV4cSWi0Iu7dggO7BJRUVjztD8SPkcG60,2856
10
+ agent_content/migrations/0001_initial.py,sha256=RPJqJ2QyAN1ygP5wrVrLe1vJfDtZRT_Wk2KiZsxzKZY,2307
11
+ agent_content/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ agent_content/models.py,sha256=eCZDMa0l7blq3B45oi-cgi8tk3uCFG27BpQ5jaw4qwc,4113
13
+ agent_content/registry.py,sha256=wadp1qzgAmmLjrxs_4DoKo3ap8AYTDWIw94dhIlYUxo,5504
14
+ agent_content/urls.py,sha256=V048UfRmwpNEIkQQBVuokj04f4hFlyngQ81Qgfy_ob4,344
15
+ agent_content/views.py,sha256=UYaUJhNzmzXSzE-t1pwB77ZJl7ED8o8JvYfQccwsNz8,15653
16
+ django_agent_content-0.1.2.dist-info/METADATA,sha256=Rn3epAYvuCCfC8-seEvQ2aknQ8KPKmiNc0iIqb_qEUo,9339
17
+ django_agent_content-0.1.2.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
18
+ django_agent_content-0.1.2.dist-info/licenses/LICENSE,sha256=HkvALIV_w9d5KzBmCM0H1y24BmWMGIaL3P-EqUHtxhI,1070
19
+ django_agent_content-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.5.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.