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.
- agent_content/__init__.py +8 -0
- agent_content/admin.py +51 -0
- agent_content/apps.py +13 -0
- agent_content/auth.py +51 -0
- agent_content/conf.py +23 -0
- agent_content/management/__init__.py +0 -0
- agent_content/management/commands/__init__.py +0 -0
- agent_content/management/commands/agent_content_key.py +65 -0
- agent_content/management/commands/agent_content_scaffold.py +63 -0
- agent_content/migrations/0001_initial.py +47 -0
- agent_content/migrations/__init__.py +0 -0
- agent_content/models.py +102 -0
- agent_content/registry.py +118 -0
- agent_content/urls.py +12 -0
- agent_content/views.py +365 -0
- django_agent_content-0.1.2.dist-info/METADATA +195 -0
- django_agent_content-0.1.2.dist-info/RECORD +19 -0
- django_agent_content-0.1.2.dist-info/WHEEL +4 -0
- django_agent_content-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
agent_content/models.py
ADDED
|
@@ -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,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.
|