django-dg-core 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. django_dg_core-1.0.0/.gitignore +16 -0
  2. django_dg_core-1.0.0/CHANGELOG.md +15 -0
  3. django_dg_core-1.0.0/LICENSE +21 -0
  4. django_dg_core-1.0.0/PKG-INFO +64 -0
  5. django_dg_core-1.0.0/README.md +28 -0
  6. django_dg_core-1.0.0/pyproject.toml +48 -0
  7. django_dg_core-1.0.0/src/datagroceries/core/__init__.py +0 -0
  8. django_dg_core-1.0.0/src/datagroceries/core/api.py +30 -0
  9. django_dg_core-1.0.0/src/datagroceries/core/apps.py +17 -0
  10. django_dg_core-1.0.0/src/datagroceries/core/areas.py +39 -0
  11. django_dg_core-1.0.0/src/datagroceries/core/attachments.py +92 -0
  12. django_dg_core-1.0.0/src/datagroceries/core/authentication.py +128 -0
  13. django_dg_core-1.0.0/src/datagroceries/core/backlinks.py +40 -0
  14. django_dg_core-1.0.0/src/datagroceries/core/checks.py +118 -0
  15. django_dg_core-1.0.0/src/datagroceries/core/history.py +20 -0
  16. django_dg_core-1.0.0/src/datagroceries/core/locale/nl/LC_MESSAGES/django.mo +0 -0
  17. django_dg_core-1.0.0/src/datagroceries/core/locale/nl/LC_MESSAGES/django.po +119 -0
  18. django_dg_core-1.0.0/src/datagroceries/core/locale/nl/LC_MESSAGES/djangojs.mo +0 -0
  19. django_dg_core-1.0.0/src/datagroceries/core/locale/nl/LC_MESSAGES/djangojs.po +62 -0
  20. django_dg_core-1.0.0/src/datagroceries/core/markdown.py +258 -0
  21. django_dg_core-1.0.0/src/datagroceries/core/mcp.py +178 -0
  22. django_dg_core-1.0.0/src/datagroceries/core/mentions.py +66 -0
  23. django_dg_core-1.0.0/src/datagroceries/core/notifications.py +150 -0
  24. django_dg_core-1.0.0/src/datagroceries/core/people.py +190 -0
  25. django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/apple-touch-icon-180.png +0 -0
  26. django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/datagroceries.css +12 -0
  27. django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/editor.css +33 -0
  28. django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/editor.js +245 -0
  29. django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/favicon-32.png +0 -0
  30. django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/favicon.svg +1 -0
  31. django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/bootstrap/LICENSE +21 -0
  32. django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/bootstrap/bootstrap.bundle.min.js +6 -0
  33. django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/bootstrap/bootstrap.min.css +5 -0
  34. django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/bootstrap-icons/LICENSE +21 -0
  35. django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/bootstrap-icons/bootstrap-icons.min.css +5 -0
  36. django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  37. django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  38. django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/easymde/LICENSE +22 -0
  39. django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/easymde/easymde.min.css +7 -0
  40. django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/easymde/easymde.min.js +7 -0
  41. django_dg_core-1.0.0/src/datagroceries/core/templates/dg_core/_editor_assets.html +9 -0
  42. django_dg_core-1.0.0/src/datagroceries/core/templates/dg_core/_markdown_help.html +31 -0
  43. django_dg_core-1.0.0/src/datagroceries/core/templates/dg_core/base.html +60 -0
  44. django_dg_core-1.0.0/src/datagroceries/core/templatetags/__init__.py +0 -0
  45. django_dg_core-1.0.0/src/datagroceries/core/templatetags/dg_core.py +80 -0
  46. django_dg_core-1.0.0/src/datagroceries/core/tests/__init__.py +56 -0
  47. django_dg_core-1.0.0/src/datagroceries/core/tests/test_apps.py +35 -0
  48. django_dg_core-1.0.0/src/datagroceries/core/tests/test_authentication.py +109 -0
  49. django_dg_core-1.0.0/src/datagroceries/core/tests/test_checks.py +40 -0
  50. django_dg_core-1.0.0/src/datagroceries/core/tests/test_languages.py +48 -0
  51. django_dg_core-1.0.0/src/datagroceries/core/tests/test_markdown.py +136 -0
  52. django_dg_core-1.0.0/src/datagroceries/core/tests/test_notifications.py +104 -0
  53. django_dg_core-1.0.0/src/datagroceries/core/tests/test_people.py +115 -0
  54. django_dg_core-1.0.0/src/datagroceries/core/urls.py +27 -0
  55. django_dg_core-1.0.0/src/datagroceries/urls.py +19 -0
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .env
8
+ *.sqlite3
9
+ .DS_Store
10
+
11
+ # The example project
12
+ example/db.sqlite3
13
+ example/media/
14
+
15
+ # Jasper's own instructions for agents
16
+ CLAUDE.local.md
@@ -0,0 +1,15 @@
1
+ # Changelog of django-dg-core
2
+
3
+ ## 1.0.0 (2026-11-07)
4
+
5
+ The first release: the one MCP endpoint for every installed DataGroceries app, the checks on every way in
6
+ (registered token packages and the project's own DRF authentication, with the `401` that points MCP
7
+ clients at their authorization server), people as a Django permission per app and one `People` class,
8
+ notifications by email and Slack with fallback, each in its person's language, @-mentions of the people
9
+ of an area, what names something of one app in another's text, what the apps' REST APIs share, DataGroceries'
10
+ own layout with its assets, a switch between the apps and a language switch, Markdown rendering and the
11
+ Markdown editor, with writing help each app adds to, and references between apps, which may be relative
12
+ to what the text belongs to (and for a page of its own, such as a wiki page, anchors on its headings for
13
+ a table of contents), attachments each app keeps in a table of its own and serves so that no file runs as
14
+ a page, English and Dutch, one URL include, and system checks. Django 5.2 LTS and 6.x, Python 3.11 and
15
+ newer.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jasper Cramwinckel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-dg-core
3
+ Version: 1.0.0
4
+ Summary: What the DataGroceries apps share: one MCP endpoint for all of them, people, notifications, and the checks on every way in.
5
+ Project-URL: Homepage, https://github.com/datagroceries/django-dg
6
+ Project-URL: Source, https://github.com/datagroceries/django-dg
7
+ Project-URL: Documentation, https://github.com/datagroceries/django-dg/blob/main/docs/integrating.md
8
+ Project-URL: Changelog, https://github.com/datagroceries/django-dg/blob/main/packages/django-dg-core/CHANGELOG.md
9
+ Author: Jasper Cramwinckel
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai agents,claude,django,mcp,model context protocol
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Web Environment
15
+ Classifier: Framework :: Django
16
+ Classifier: Framework :: Django :: 5.2
17
+ Classifier: Framework :: Django :: 6.0
18
+ Classifier: Framework :: Django :: 6.1
19
+ Classifier: Intended Audience :: Developers
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
27
+ Requires-Python: >=3.11
28
+ Requires-Dist: django-simple-history
29
+ Requires-Dist: django>=5.2
30
+ Requires-Dist: djangorestframework
31
+ Requires-Dist: markdown
32
+ Requires-Dist: nh3
33
+ Requires-Dist: pymdown-extensions
34
+ Requires-Dist: requests
35
+ Description-Content-Type: text/markdown
36
+
37
+ # django-dg-core
38
+
39
+ What the [DataGroceries](https://github.com/datagroceries/django-dg) apps share. You do not install it on its own: every DataGroceries
40
+ app, such as [django-dg-board](https://pypi.org/project/django-dg-board/), brings it along.
41
+
42
+ - **One MCP endpoint for every installed app** (`dg/mcp`). Each app registers its tools; an agent
43
+ connects once and gets them all. A tool runs the app's own REST view, so the API and the tools follow
44
+ the same rules. Stateless, over plain HTTP, so it runs on any WSGI server.
45
+ - **The checks on every way in.** Core issues no tokens: it lets in whatever the project and the DataGroceries
46
+ packages register (personal tokens and the Claude connector's OAuth from
47
+ [django-dg-connect](https://pypi.org/project/django-dg-connect/), or the project's own DRF
48
+ authentication), and on every request checks that the person may still use the app and that the
49
+ token covers it. A request without credentials gets a `401` that tells MCP clients where to sign in.
50
+ - **People.** Who may use an app is a Django permission; how DataGroceries names people, their @-handles,
51
+ their email address and their image come from one class a project may subclass. No assumptions about
52
+ the user model.
53
+ - **Notifications** by email, Slack, or Slack with email as fallback, once the change is committed.
54
+ - **A layout of its own**, with Bootstrap and Bootstrap Icons in the package, so the apps' pages work in
55
+ any project; override one template to show them in yours.
56
+ - **Markdown**, rendered and sanitised the same way in every app (GitHub's lists, tables, task lists),
57
+ with the Markdown editor, and **references between apps**: an app registers what it recognises in text,
58
+ such as the board's ticket keys, and every app's Markdown links it while that app is installed.
59
+ - **One URL include** for every app, and **system checks** that say what a project still has to add.
60
+
61
+ Django 5.2 LTS and 6.x, Python 3.11 and newer. How to install the apps, and every setting, is in
62
+ [docs/integrating.md](https://github.com/datagroceries/django-dg/blob/main/docs/integrating.md).
63
+
64
+ MIT licensed.
@@ -0,0 +1,28 @@
1
+ # django-dg-core
2
+
3
+ What the [DataGroceries](https://github.com/datagroceries/django-dg) apps share. You do not install it on its own: every DataGroceries
4
+ app, such as [django-dg-board](https://pypi.org/project/django-dg-board/), brings it along.
5
+
6
+ - **One MCP endpoint for every installed app** (`dg/mcp`). Each app registers its tools; an agent
7
+ connects once and gets them all. A tool runs the app's own REST view, so the API and the tools follow
8
+ the same rules. Stateless, over plain HTTP, so it runs on any WSGI server.
9
+ - **The checks on every way in.** Core issues no tokens: it lets in whatever the project and the DataGroceries
10
+ packages register (personal tokens and the Claude connector's OAuth from
11
+ [django-dg-connect](https://pypi.org/project/django-dg-connect/), or the project's own DRF
12
+ authentication), and on every request checks that the person may still use the app and that the
13
+ token covers it. A request without credentials gets a `401` that tells MCP clients where to sign in.
14
+ - **People.** Who may use an app is a Django permission; how DataGroceries names people, their @-handles,
15
+ their email address and their image come from one class a project may subclass. No assumptions about
16
+ the user model.
17
+ - **Notifications** by email, Slack, or Slack with email as fallback, once the change is committed.
18
+ - **A layout of its own**, with Bootstrap and Bootstrap Icons in the package, so the apps' pages work in
19
+ any project; override one template to show them in yours.
20
+ - **Markdown**, rendered and sanitised the same way in every app (GitHub's lists, tables, task lists),
21
+ with the Markdown editor, and **references between apps**: an app registers what it recognises in text,
22
+ such as the board's ticket keys, and every app's Markdown links it while that app is installed.
23
+ - **One URL include** for every app, and **system checks** that say what a project still has to add.
24
+
25
+ Django 5.2 LTS and 6.x, Python 3.11 and newer. How to install the apps, and every setting, is in
26
+ [docs/integrating.md](https://github.com/datagroceries/django-dg/blob/main/docs/integrating.md).
27
+
28
+ MIT licensed.
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "django-dg-core"
3
+ version = "1.0.0"
4
+ description = "What the DataGroceries apps share: one MCP endpoint for all of them, people, notifications, and the checks on every way in."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ authors = [{ name = "Jasper Cramwinckel" }]
10
+ keywords = ["django", "mcp", "model context protocol", "ai agents", "claude"]
11
+ dependencies = [
12
+ "Django>=5.2",
13
+ "django-simple-history",
14
+ "djangorestframework",
15
+ "markdown",
16
+ "nh3",
17
+ "pymdown-extensions",
18
+ "requests",
19
+ ]
20
+ classifiers = [
21
+ "Development Status :: 4 - Beta",
22
+ "Environment :: Web Environment",
23
+ "Framework :: Django",
24
+ "Framework :: Django :: 5.2",
25
+ "Framework :: Django :: 6.0",
26
+ "Framework :: Django :: 6.1",
27
+ "Intended Audience :: Developers",
28
+ "Operating System :: OS Independent",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Programming Language :: Python :: 3.13",
33
+ "Programming Language :: Python :: 3.14",
34
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/datagroceries/django-dg"
39
+ Source = "https://github.com/datagroceries/django-dg"
40
+ Documentation = "https://github.com/datagroceries/django-dg/blob/main/docs/integrating.md"
41
+ Changelog = "https://github.com/datagroceries/django-dg/blob/main/packages/django-dg-core/CHANGELOG.md"
42
+
43
+ [build-system]
44
+ requires = ["hatchling"]
45
+ build-backend = "hatchling.build"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/datagroceries"]
@@ -0,0 +1,30 @@
1
+ """What the DataGroceries apps' REST APIs share: checking a request's body, a page form's errors as the API's, and
2
+ a person as the APIs show them."""
3
+
4
+ from rest_framework.exceptions import ValidationError
5
+
6
+ from datagroceries.core import people
7
+
8
+
9
+ def validated(serializer_class, request, **kwargs) -> dict:
10
+ """The request body, checked by `serializer_class`, refusing fields it does not know."""
11
+ serializer = serializer_class(data=request.data, **kwargs)
12
+ unknown = sorted(set(request.data) - set(serializer.fields))
13
+ if unknown:
14
+ raise ValidationError({'unknown_fields': unknown})
15
+ serializer.is_valid(raise_exception=True)
16
+ return serializer.validated_data
17
+
18
+
19
+ def form_errors(form) -> ValidationError:
20
+ """A page form's errors as a 400; errors not tied to a field come under "detail"."""
21
+ errors = form.errors.get_json_data()
22
+ return ValidationError(
23
+ {('detail' if name == '__all__' else name): [e['message'] for e in field] for name, field in errors.items()},
24
+ )
25
+
26
+
27
+ def person(user) -> dict | None:
28
+ """A person as the APIs show them: by their handle, as @-mentions name them, with their email address
29
+ beside it when they have one."""
30
+ return {'name': people.name(user), 'handle': people.handle(user), 'email': people.email(user)} if user else None
@@ -0,0 +1,17 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class CoreConfig(AppConfig):
5
+ name = 'datagroceries.core'
6
+ label = 'dg_core'
7
+ verbose_name = 'DataGroceries'
8
+ default_auto_field = 'django.db.models.BigAutoField'
9
+
10
+ def ready(self):
11
+ from datagroceries.core import (
12
+ checks, # noqa: F401 - registers the system checks
13
+ markdown,
14
+ )
15
+ from datagroceries.core.mentions import Mentions
16
+
17
+ markdown.register(Mentions)
@@ -0,0 +1,39 @@
1
+ """The areas of the DataGroceries apps that agents reach: one per app, such as the board.
2
+
3
+ An app registers its area in its AppConfig.ready(). An area is a token scope, the Django permission
4
+ that lets someone use it (datagroceries.core.people), a sentence for the agent about it, and its MCP tools; and,
5
+ for the switch between the apps at the top of every page, the URL name of its start page and an icon. A
6
+ token opens an area only while its user still may use it, so losing that right ends it on every token
7
+ at once.
8
+ """
9
+
10
+ import dataclasses
11
+
12
+ from django.core.exceptions import ImproperlyConfigured
13
+
14
+ from datagroceries.core import people
15
+
16
+
17
+ @dataclasses.dataclass(frozen=True)
18
+ class Area:
19
+ scope: str
20
+ label: str
21
+ permission: str
22
+ instructions: str
23
+ tools: tuple = ()
24
+ home: str = ''
25
+ icon: str = ''
26
+
27
+
28
+ AREAS: dict[str, Area] = {}
29
+
30
+
31
+ def register(area: Area):
32
+ if area.scope in AREAS:
33
+ raise ImproperlyConfigured(f'The area {area.scope!r} is registered twice.')
34
+ AREAS[area.scope] = area
35
+
36
+
37
+ def scopes_for(user) -> list[str]:
38
+ """The scopes `user` may use now, in the order their apps registered them."""
39
+ return [scope for scope in AREAS if people.may_use(user, scope)]
@@ -0,0 +1,92 @@
1
+ """Files people attach, as every DataGroceries app keeps them: each app has a table of its own, on `Attachment`,
2
+ saying what a file belongs to, and serves the files with `serve`, only to those who may use the app.
3
+
4
+ Images pasted or dropped into the Markdown editor go to the app's upload address, which answers with
5
+ `editor_upload`.
6
+ """
7
+
8
+ import pathlib
9
+
10
+ from django.conf import settings
11
+ from django.db import models
12
+ from django.http import FileResponse, JsonResponse
13
+ from django.utils import timezone
14
+ from django.utils.text import slugify
15
+
16
+ MAX_BYTES = 20 * 1024 * 1024
17
+ # What is shown in the page; anything else is a download.
18
+ INLINE_CONTENT_TYPES = ('image/png', 'image/jpeg', 'image/gif', 'image/webp')
19
+
20
+
21
+ def upload_to(attachment, filename: str) -> str:
22
+ return f'{attachment._meta.app_label}/{timezone.now():%Y/%m}/{filename}'
23
+
24
+
25
+ class Attachment(models.Model):
26
+ """A file, in the default storage. The app serves it only to its people (`serve`), so the storage must
27
+ not publish it at an address of its own."""
28
+
29
+ INLINE_CONTENT_TYPES = INLINE_CONTENT_TYPES
30
+
31
+ file = models.FileField(upload_to=upload_to)
32
+ name = models.CharField(max_length=255)
33
+ content_type = models.CharField(max_length=100)
34
+ size = models.PositiveBigIntegerField()
35
+ uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='+')
36
+ uploaded_at = models.DateTimeField(auto_now_add=True)
37
+
38
+ class Meta:
39
+ abstract = True
40
+ ordering = ('uploaded_at', 'id')
41
+
42
+ def __str__(self):
43
+ return self.name
44
+
45
+ @property
46
+ def is_image(self) -> bool:
47
+ return self.content_type in INLINE_CONTENT_TYPES
48
+
49
+ @property
50
+ def url_name(self) -> str:
51
+ """The file's name for the end of its address. The Markdown editor inserts an upload as an image only
52
+ when its address ends in an image extension; slugified, so no space or bracket breaks a link."""
53
+ path = pathlib.PurePath(self.name)
54
+ return f'{slugify(path.stem) or "file"}{path.suffix.lower()}'
55
+
56
+ @classmethod
57
+ def from_upload(cls, upload, user, **fields):
58
+ """A new attachment of the uploaded file, by `user`, with the app's own `fields`."""
59
+ return cls.objects.create(
60
+ file=upload,
61
+ name=upload.name,
62
+ content_type=upload.content_type or 'application/octet-stream',
63
+ size=upload.size,
64
+ uploaded_by=user,
65
+ **fields,
66
+ )
67
+
68
+
69
+ def serve(attachment: Attachment) -> FileResponse:
70
+ """The file. Only known image types are shown inline; anything else (HTML, SVG, ...) is a download, so
71
+ an uploaded file can never run as a page on this site."""
72
+ response = FileResponse(
73
+ attachment.file.open('rb'),
74
+ content_type=attachment.content_type if attachment.is_image else 'application/octet-stream',
75
+ as_attachment=not attachment.is_image,
76
+ filename=attachment.name,
77
+ )
78
+ response['X-Content-Type-Options'] = 'nosniff'
79
+ return response
80
+
81
+
82
+ def editor_upload(request, create) -> JsonResponse:
83
+ """The answer to an image the Markdown editor uploads, in the form EasyMDE expects. `create(upload)`
84
+ keeps the image and returns its Attachment."""
85
+ upload = request.FILES.get('image')
86
+ if upload is None:
87
+ return JsonResponse({'error': 'noFileGiven'}, status=400)
88
+ if upload.size > MAX_BYTES:
89
+ return JsonResponse({'error': 'fileTooLarge'}, status=400)
90
+ if upload.content_type not in INLINE_CONTENT_TYPES:
91
+ return JsonResponse({'error': 'typeNotAllowed'}, status=400)
92
+ return JsonResponse({'data': {'filePath': create(upload).get_absolute_url()}})
@@ -0,0 +1,128 @@
1
+ """How a program gets into the DataGroceries apps' API and MCP endpoint. Core checks credentials and never issues
2
+ them (docs/decisions.md); every way in lands on the same check, `opens`.
3
+
4
+ The ways in, in order:
5
+
6
+ 1. Those of DataGroceries packages that issue tokens, such as django-dg-connect's OAuth for the Claude
7
+ connector and its personal tokens: they `register` here, as apps register their areas.
8
+ 2. The host project's own, the DRF authentication classes listed in `DG_AUTHENTICATION_CLASSES`:
9
+ its own tokens, JWT, anything DRF can check.
10
+
11
+ Whatever let a request in, it reaches an area only while its user may use it, and only if the token,
12
+ when it carries scopes, covers it. A request let in without credentials gets a 401 that points at the
13
+ protected resource metadata (RFC 9728), which names the authorization server, so an MCP client given
14
+ only the endpoint's address finds where to sign in.
15
+ """
16
+
17
+ from collections.abc import Callable
18
+
19
+ from django.conf import settings
20
+ from django.dispatch import receiver
21
+ from django.http import Http404, JsonResponse
22
+ from django.urls import reverse
23
+ from django.utils import translation
24
+ from django.utils.module_loading import import_string
25
+ from rest_framework.authentication import BaseAuthentication
26
+ from rest_framework.permissions import BasePermission
27
+ from simple_history.models import HistoricalRecords
28
+ from simple_history.signals import pre_create_historical_record
29
+
30
+ from datagroceries.core import areas, history
31
+
32
+ # What DataGroceries packages that issue tokens registered (`register`).
33
+ _ways_in: list[type] = []
34
+ _authorization_servers: list[Callable] = []
35
+
36
+
37
+ def register(authentication_class: type, authorization_server: Callable | None = None):
38
+ """For a DataGroceries package that issues tokens: its DRF authentication class, and a function that takes the
39
+ request and returns the URL of its authorization server, if it has one."""
40
+ _ways_in.append(authentication_class)
41
+ if authorization_server:
42
+ _authorization_servers.append(authorization_server)
43
+
44
+
45
+ def authorization_server(request) -> str | None:
46
+ """Where an MCP client signs in: the project's own authorization server, `DG_AUTHORIZATION_SERVER`,
47
+ or a registered one."""
48
+ if url := getattr(settings, 'DG_AUTHORIZATION_SERVER', None):
49
+ return url
50
+ return _authorization_servers[0](request) if _authorization_servers else None
51
+
52
+
53
+ class NoWayIn(BaseAuthentication):
54
+ """Lets nobody in. Always last, so that without any other way in a request still gets a 401 that says
55
+ where to sign in, not DRF's 403."""
56
+
57
+ def authenticate(self, request):
58
+ return None
59
+
60
+
61
+ class DataGroceriesAuthentication:
62
+ """For an APIView: every way in, and a 401 that says where to sign in."""
63
+
64
+ def get_authenticators(self):
65
+ extra = getattr(settings, 'DG_AUTHENTICATION_CLASSES', ())
66
+ return [*(cls() for cls in _ways_in), *(import_string(path)() for path in extra), NoWayIn()]
67
+
68
+ def dispatch(self, request, *args, **kwargs):
69
+ # In English whatever the project's language: the API and the tools are for programs and agents,
70
+ # and its error messages come from the same forms and models as the pages'.
71
+ with history.recording(request), translation.override('en'):
72
+ return super().dispatch(request, *args, **kwargs)
73
+
74
+ def get_authenticate_header(self, request):
75
+ if authorization_server(request):
76
+ return f'Bearer resource_metadata="{request.build_absolute_uri(reverse("dg_core:resource_metadata"))}"'
77
+ return 'Bearer'
78
+
79
+
80
+ def opens(request, scope: str) -> bool:
81
+ """Whether the request reaches the area `scope`: its user may use it, and the token, if it carries a
82
+ `scope` (space-separated area names, as OAuth tokens and DataGroceries' personal tokens have), covers it."""
83
+ if not request.user.is_authenticated or scope not in areas.scopes_for(request.user):
84
+ return False
85
+ # A page's request, signed in with a session, has no `auth`, and so no token to narrow it.
86
+ granted = getattr(getattr(request, 'auth', None), 'scope', None)
87
+ return not isinstance(granted, str) or scope in granted.split()
88
+
89
+
90
+ class HasTokenScope(BasePermission):
91
+ """Lets in requests that reach the view's area, `token_scope` (opens)."""
92
+
93
+ message = 'This token does not open this part of the API, or you no longer have access to it.'
94
+
95
+ def has_permission(self, request, view): # type: ignore[override]
96
+ return opens(request, view.token_scope)
97
+
98
+
99
+ def resource_metadata(request):
100
+ """The protected resource metadata of the MCP endpoint (RFC 9728): what it is, and where to sign in
101
+ for it. There is none while nothing issues tokens for the Claude connector."""
102
+ server = authorization_server(request)
103
+ if not server:
104
+ raise Http404('No authorization server.')
105
+ return JsonResponse(
106
+ {
107
+ 'resource': request.build_absolute_uri(reverse('dg_core:mcp')),
108
+ 'authorization_servers': [server],
109
+ 'scopes_supported': list(areas.AREAS),
110
+ 'bearer_methods_supported': ['header'],
111
+ 'resource_name': 'DataGroceries',
112
+ }
113
+ )
114
+
115
+
116
+ @receiver(pre_create_historical_record)
117
+ def mark_token_changes(sender, history_instance, **kwargs):
118
+ """Changes made with a token say so in their history: "via Claude Code on my laptop" for a token with a
119
+ name, "via Claude" for an OAuth token, after its app, or "via API" for a token of the project's own.
120
+
121
+ DRF sets the token on the Django request, which datagroceries.core.history keeps for the request.
122
+ """
123
+ request = getattr(HistoricalRecords.context, 'request', None)
124
+ token = getattr(request, 'auth', None)
125
+ if token is not None and not history_instance.history_change_reason:
126
+ application = getattr(token, 'application', None)
127
+ name = getattr(token, 'name', None) or getattr(application, 'name', None) or 'API'
128
+ history_instance.history_change_reason = f'via {name}'
@@ -0,0 +1,40 @@
1
+ """What names something of one app in another app's text: the wiki pages that name a ticket, shown on the
2
+ ticket. An app that keeps text registers a `Source` from its AppConfig.ready(); an app shows what names
3
+ one of its things with `find`, so neither knows the other.
4
+ """
5
+
6
+ import dataclasses
7
+ import typing
8
+
9
+ from datagroceries.core.authentication import opens
10
+
11
+
12
+ @dataclasses.dataclass(frozen=True)
13
+ class Backlink:
14
+ title: str
15
+ url: str
16
+
17
+
18
+ @dataclasses.dataclass(frozen=True)
19
+ class Source:
20
+ """Text of the area `scope`. `find(name)` gives the places in it that name `name` as a word, such as
21
+ "T-123", outside code; `label` says what they are, as "Wiki pages"."""
22
+
23
+ scope: str
24
+ label: str
25
+ find: typing.Callable[[str], list[Backlink]]
26
+
27
+
28
+ SOURCES: list[Source] = []
29
+
30
+
31
+ def register(source: Source):
32
+ if source not in SOURCES:
33
+ SOURCES.append(source)
34
+
35
+
36
+ def find(request, name: str) -> list[tuple[Source, list[Backlink]]]:
37
+ """The places that name `name`, in the areas the request reaches (its user may use them, and its token,
38
+ if any, opens them), by source; sources without any are left out."""
39
+ found = [(source, source.find(name)) for source in SOURCES if opens(request, source.scope)]
40
+ return [(source, places) for source, places in found if places]
@@ -0,0 +1,118 @@
1
+ """System checks: what `manage.py check` tells whoever installs DataGroceries is still missing. Each message says
2
+ what to do."""
3
+
4
+ from django.conf import settings
5
+ from django.contrib.auth.models import Permission
6
+ from django.core import checks
7
+ from django.core.exceptions import ImproperlyConfigured
8
+ from django.db import DatabaseError
9
+ from django.db.models import Q
10
+ from django.shortcuts import resolve_url
11
+ from django.urls import NoReverseMatch, Resolver404, resolve, reverse
12
+
13
+ from datagroceries.core import areas, notifications
14
+
15
+ REQUIRED_APPS = ('rest_framework', 'simple_history')
16
+ CONTEXT_PROCESSORS = (
17
+ 'django.template.context_processors.request',
18
+ 'django.contrib.auth.context_processors.auth',
19
+ 'django.contrib.messages.context_processors.messages',
20
+ )
21
+
22
+
23
+ @checks.register
24
+ def check_apps(app_configs, **kwargs):
25
+ return [
26
+ checks.Error(
27
+ f"DataGroceries needs '{app}' in INSTALLED_APPS.", hint=f"Add '{app}' to INSTALLED_APPS.", id='dg.E001'
28
+ )
29
+ for app in REQUIRED_APPS
30
+ if app not in settings.INSTALLED_APPS
31
+ ]
32
+
33
+
34
+ @checks.register
35
+ def check_templates(app_configs, **kwargs):
36
+ processors = {
37
+ processor
38
+ for engine in settings.TEMPLATES
39
+ if engine['BACKEND'] == 'django.template.backends.django.DjangoTemplates'
40
+ for processor in engine.get('OPTIONS', {}).get('context_processors', [])
41
+ }
42
+ return [
43
+ checks.Error(
44
+ f"The DataGroceries pages need the context processor '{processor}'.",
45
+ hint="Add it to OPTIONS['context_processors'] of the DjangoTemplates engine in TEMPLATES.",
46
+ id='dg.E002',
47
+ )
48
+ for processor in CONTEXT_PROCESSORS
49
+ if processor not in processors
50
+ ]
51
+
52
+
53
+ @checks.register
54
+ def check_urls(app_configs, **kwargs):
55
+ try:
56
+ reverse('dg_core:mcp')
57
+ except NoReverseMatch:
58
+ return [
59
+ checks.Warning(
60
+ "DataGroceries' pages and its MCP endpoint are not in the URLs.",
61
+ hint="Add path('dg/', include('datagroceries.urls')) to the project's urlpatterns.",
62
+ id='dg.W001',
63
+ )
64
+ ]
65
+ try:
66
+ resolve(resolve_url(settings.LOGIN_URL))
67
+ except (Resolver404, NoReverseMatch):
68
+ return [
69
+ checks.Warning(
70
+ f'LOGIN_URL ({settings.LOGIN_URL}) leads nowhere, so people who are not signed in cannot sign in '
71
+ 'to the DataGroceries pages.',
72
+ hint="Set LOGIN_URL to the project's sign-in page, such as 'admin:login'.",
73
+ id='dg.W002',
74
+ )
75
+ ]
76
+ return []
77
+
78
+
79
+ @checks.register
80
+ def check_people(app_configs, **kwargs):
81
+ """An app that only superusers may use was most likely installed a moment ago."""
82
+ found = []
83
+ for area in areas.AREAS.values():
84
+ app_label, codename = area.permission.split('.')
85
+ try:
86
+ granted = (
87
+ Permission.objects.filter(content_type__app_label=app_label, codename=codename)
88
+ .filter(Q(group__isnull=False) | Q(user__isnull=False))
89
+ .exists()
90
+ )
91
+ except DatabaseError: # not migrated yet
92
+ return []
93
+ if not granted:
94
+ found.append(
95
+ checks.Warning(
96
+ f'Only superusers may use {area.label} yet.',
97
+ hint=f'Grant the permission "{area.permission}" to a group in the admin, and put people in it.',
98
+ id='dg.W003',
99
+ )
100
+ )
101
+ return found
102
+
103
+
104
+ @checks.register
105
+ def check_notifications(app_configs, **kwargs):
106
+ try:
107
+ order = notifications.channels()
108
+ except ImproperlyConfigured as error:
109
+ return [checks.Error(str(error), hint="For example ['email'], or ['slack', 'email'].", id='dg.E003')]
110
+ if 'slack' in order and not getattr(settings, 'DG_SLACK_BOT_TOKEN', ''):
111
+ return [
112
+ checks.Error(
113
+ 'DG_NOTIFICATIONS includes Slack, but DG_SLACK_BOT_TOKEN is not set.',
114
+ hint='Set it to the token of a Slack bot with the scopes users:read.email and chat:write.',
115
+ id='dg.E004',
116
+ )
117
+ ]
118
+ return []