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.
- django_dg_core-1.0.0/.gitignore +16 -0
- django_dg_core-1.0.0/CHANGELOG.md +15 -0
- django_dg_core-1.0.0/LICENSE +21 -0
- django_dg_core-1.0.0/PKG-INFO +64 -0
- django_dg_core-1.0.0/README.md +28 -0
- django_dg_core-1.0.0/pyproject.toml +48 -0
- django_dg_core-1.0.0/src/datagroceries/core/__init__.py +0 -0
- django_dg_core-1.0.0/src/datagroceries/core/api.py +30 -0
- django_dg_core-1.0.0/src/datagroceries/core/apps.py +17 -0
- django_dg_core-1.0.0/src/datagroceries/core/areas.py +39 -0
- django_dg_core-1.0.0/src/datagroceries/core/attachments.py +92 -0
- django_dg_core-1.0.0/src/datagroceries/core/authentication.py +128 -0
- django_dg_core-1.0.0/src/datagroceries/core/backlinks.py +40 -0
- django_dg_core-1.0.0/src/datagroceries/core/checks.py +118 -0
- django_dg_core-1.0.0/src/datagroceries/core/history.py +20 -0
- django_dg_core-1.0.0/src/datagroceries/core/locale/nl/LC_MESSAGES/django.mo +0 -0
- django_dg_core-1.0.0/src/datagroceries/core/locale/nl/LC_MESSAGES/django.po +119 -0
- django_dg_core-1.0.0/src/datagroceries/core/locale/nl/LC_MESSAGES/djangojs.mo +0 -0
- django_dg_core-1.0.0/src/datagroceries/core/locale/nl/LC_MESSAGES/djangojs.po +62 -0
- django_dg_core-1.0.0/src/datagroceries/core/markdown.py +258 -0
- django_dg_core-1.0.0/src/datagroceries/core/mcp.py +178 -0
- django_dg_core-1.0.0/src/datagroceries/core/mentions.py +66 -0
- django_dg_core-1.0.0/src/datagroceries/core/notifications.py +150 -0
- django_dg_core-1.0.0/src/datagroceries/core/people.py +190 -0
- django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/apple-touch-icon-180.png +0 -0
- django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/datagroceries.css +12 -0
- django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/editor.css +33 -0
- django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/editor.js +245 -0
- django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/favicon-32.png +0 -0
- django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/favicon.svg +1 -0
- django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/bootstrap/LICENSE +21 -0
- django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/bootstrap/bootstrap.bundle.min.js +6 -0
- django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/bootstrap/bootstrap.min.css +5 -0
- django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/bootstrap-icons/LICENSE +21 -0
- django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/bootstrap-icons/bootstrap-icons.min.css +5 -0
- django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/easymde/LICENSE +22 -0
- django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/easymde/easymde.min.css +7 -0
- django_dg_core-1.0.0/src/datagroceries/core/static/dg_core/vendor/easymde/easymde.min.js +7 -0
- django_dg_core-1.0.0/src/datagroceries/core/templates/dg_core/_editor_assets.html +9 -0
- django_dg_core-1.0.0/src/datagroceries/core/templates/dg_core/_markdown_help.html +31 -0
- django_dg_core-1.0.0/src/datagroceries/core/templates/dg_core/base.html +60 -0
- django_dg_core-1.0.0/src/datagroceries/core/templatetags/__init__.py +0 -0
- django_dg_core-1.0.0/src/datagroceries/core/templatetags/dg_core.py +80 -0
- django_dg_core-1.0.0/src/datagroceries/core/tests/__init__.py +56 -0
- django_dg_core-1.0.0/src/datagroceries/core/tests/test_apps.py +35 -0
- django_dg_core-1.0.0/src/datagroceries/core/tests/test_authentication.py +109 -0
- django_dg_core-1.0.0/src/datagroceries/core/tests/test_checks.py +40 -0
- django_dg_core-1.0.0/src/datagroceries/core/tests/test_languages.py +48 -0
- django_dg_core-1.0.0/src/datagroceries/core/tests/test_markdown.py +136 -0
- django_dg_core-1.0.0/src/datagroceries/core/tests/test_notifications.py +104 -0
- django_dg_core-1.0.0/src/datagroceries/core/tests/test_people.py +115 -0
- django_dg_core-1.0.0/src/datagroceries/core/urls.py +27 -0
- django_dg_core-1.0.0/src/datagroceries/urls.py +19 -0
|
@@ -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"]
|
|
File without changes
|
|
@@ -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 []
|