django-dg-board 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 (70) hide show
  1. django_dg_board-1.0.0/.gitignore +16 -0
  2. django_dg_board-1.0.0/CHANGELOG.md +9 -0
  3. django_dg_board-1.0.0/LICENSE +21 -0
  4. django_dg_board-1.0.0/PKG-INFO +187 -0
  5. django_dg_board-1.0.0/README.md +151 -0
  6. django_dg_board-1.0.0/pyproject.toml +48 -0
  7. django_dg_board-1.0.0/src/datagroceries/board/__init__.py +0 -0
  8. django_dg_board-1.0.0/src/datagroceries/board/activity.py +174 -0
  9. django_dg_board-1.0.0/src/datagroceries/board/admin.py +45 -0
  10. django_dg_board-1.0.0/src/datagroceries/board/api.py +409 -0
  11. django_dg_board-1.0.0/src/datagroceries/board/apps.py +21 -0
  12. django_dg_board-1.0.0/src/datagroceries/board/checks.py +17 -0
  13. django_dg_board-1.0.0/src/datagroceries/board/conf.py +37 -0
  14. django_dg_board-1.0.0/src/datagroceries/board/forms.py +236 -0
  15. django_dg_board-1.0.0/src/datagroceries/board/github.py +189 -0
  16. django_dg_board-1.0.0/src/datagroceries/board/locale/nl/LC_MESSAGES/django.mo +0 -0
  17. django_dg_board-1.0.0/src/datagroceries/board/locale/nl/LC_MESSAGES/django.po +1060 -0
  18. django_dg_board-1.0.0/src/datagroceries/board/locale/nl/LC_MESSAGES/djangojs.mo +0 -0
  19. django_dg_board-1.0.0/src/datagroceries/board/locale/nl/LC_MESSAGES/djangojs.po +96 -0
  20. django_dg_board-1.0.0/src/datagroceries/board/management/__init__.py +0 -0
  21. django_dg_board-1.0.0/src/datagroceries/board/management/commands/__init__.py +0 -0
  22. django_dg_board-1.0.0/src/datagroceries/board/management/commands/backfill_github.py +48 -0
  23. django_dg_board-1.0.0/src/datagroceries/board/mcp.py +223 -0
  24. django_dg_board-1.0.0/src/datagroceries/board/migrations/0001_initial.py +300 -0
  25. django_dg_board-1.0.0/src/datagroceries/board/migrations/__init__.py +0 -0
  26. django_dg_board-1.0.0/src/datagroceries/board/models.py +393 -0
  27. django_dg_board-1.0.0/src/datagroceries/board/notifications.py +104 -0
  28. django_dg_board-1.0.0/src/datagroceries/board/operations.py +112 -0
  29. django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/board.js +127 -0
  30. django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/card_assignee.js +52 -0
  31. django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/copy_link.js +19 -0
  32. django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/peek.js +121 -0
  33. django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/selects.js +114 -0
  34. django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/shortcuts.js +24 -0
  35. django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/ticket.js +190 -0
  36. django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/tickets.css +378 -0
  37. django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/vendor/htmx/LICENSE +25 -0
  38. django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/vendor/htmx/htmx.min.js +1 -0
  39. django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/vendor/sortablejs/LICENSE +21 -0
  40. django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/vendor/sortablejs/Sortable.min.js +2 -0
  41. django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/vendor/tom-select/LICENSE +202 -0
  42. django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/vendor/tom-select/tom-select.bootstrap5.min.css +1 -0
  43. django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/vendor/tom-select/tom-select.complete.min.js +453 -0
  44. django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_assets.html +11 -0
  45. django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_assignee_options.html +5 -0
  46. django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_card.html +29 -0
  47. django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_card_assignee.html +3 -0
  48. django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_comment_edit.html +13 -0
  49. django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_editor_assets.html +2 -0
  50. django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_field.html +12 -0
  51. django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_form_errors.html +5 -0
  52. django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_lanes.html +59 -0
  53. django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_logo.html +6 -0
  54. django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_peek.html +70 -0
  55. django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/board.html +145 -0
  56. django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/group_create.html +26 -0
  57. django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/group_edit.html +102 -0
  58. django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/ticket_detail.html +412 -0
  59. django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/ticket_form.html +44 -0
  60. django_dg_board-1.0.0/src/datagroceries/board/templatetags/__init__.py +0 -0
  61. django_dg_board-1.0.0/src/datagroceries/board/templatetags/dg_board.py +124 -0
  62. django_dg_board-1.0.0/src/datagroceries/board/tests/__init__.py +6 -0
  63. django_dg_board-1.0.0/src/datagroceries/board/tests/test_api.py +344 -0
  64. django_dg_board-1.0.0/src/datagroceries/board/tests/test_github.py +233 -0
  65. django_dg_board-1.0.0/src/datagroceries/board/tests/test_mcp.py +219 -0
  66. django_dg_board-1.0.0/src/datagroceries/board/tests/test_tickets.py +1221 -0
  67. django_dg_board-1.0.0/src/datagroceries/board/tests/test_translations.py +122 -0
  68. django_dg_board-1.0.0/src/datagroceries/board/ticket_links.py +40 -0
  69. django_dg_board-1.0.0/src/datagroceries/board/urls.py +38 -0
  70. django_dg_board-1.0.0/src/datagroceries/board/views.py +772 -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,9 @@
1
+ # Changelog of django-dg-board
2
+
3
+ ## 1.0.0 (2026-11-07)
4
+
5
+ The first release: the kanban board with groups and their own columns, tickets with parents, relations,
6
+ blockers, labels, sizes, comments and attachments, @-mentions and watchers, the activity feed, links to
7
+ GitHub code, what names a ticket in the other DataGroceries apps (such as wiki pages), a REST API and MCP tools
8
+ for working on tickets as on the pages, and English and Dutch. It started as Eddy Board, Eddy Grid's own
9
+ tracker.
@@ -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,187 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-dg-board
3
+ Version: 1.0.0
4
+ Summary: A kanban ticket tracker for Django that people and AI agents work on together.
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-board/CHANGELOG.md
9
+ Author: Jasper Cramwinckel
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai agents,django,issue tracker,kanban,mcp,ticket tracker
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
+ Classifier: Topic :: Office/Business :: Groupware
28
+ Classifier: Topic :: Software Development :: Bug Tracking
29
+ Requires-Python: >=3.11
30
+ Requires-Dist: django-dg-core>=1.0
31
+ Requires-Dist: django-simple-history
32
+ Requires-Dist: django>=5.2
33
+ Requires-Dist: markdown
34
+ Requires-Dist: requests
35
+ Description-Content-Type: text/markdown
36
+
37
+ # django-dg-board
38
+
39
+ A kanban ticket tracker for a Django project, that people and AI agents work on together. Part of
40
+ [DataGroceries](https://github.com/datagroceries/django-dg). It started as Eddy Board, the tracker
41
+ [Eddy Grid](https://eddygrid.com) built for its own team, which kindly agreed to its release, and keeps its
42
+ design.
43
+
44
+ ## What it does
45
+
46
+ - A kanban board with drag and drop, groups per team or project with their own columns, and filters on
47
+ assignee, creator, label, text, and a ticket with everything under it.
48
+ - Tickets with a Markdown description, labels, a size, comments with one level of replies, and
49
+ attachments dropped or pasted onto the page; parents and subtasks, blockers, duplicates and related
50
+ tickets.
51
+ - Every change kept, with who made it and through which agent, shown per ticket as one activity feed.
52
+ - @-mentions and watchers, told by email or Slack.
53
+ - Links to the GitHub commits, branches and pull requests that mention a ticket.
54
+ - A REST API and MCP tools, so an agent works on tickets as the person who connected it.
55
+ - English and Dutch, following the project's language.
56
+
57
+ How the board is built, its requirements and its principles are in
58
+ [docs/board.md](https://github.com/datagroceries/django-dg/blob/main/docs/board.md).
59
+
60
+ ## Installing
61
+
62
+ The full guide, for a project that already has its own logins, users, API tokens or front end, is
63
+ [docs/integrating.md](https://github.com/datagroceries/django-dg/blob/main/docs/integrating.md). In short:
64
+
65
+ ```python
66
+ INSTALLED_APPS = [
67
+ ...,
68
+ 'django.contrib.humanize',
69
+ 'rest_framework',
70
+ 'simple_history',
71
+ 'datagroceries.core',
72
+ 'datagroceries.board',
73
+ ]
74
+ ```
75
+
76
+ ```python
77
+ urlpatterns = [
78
+ ...,
79
+ path('dg/', include('datagroceries.urls')), # every DataGroceries app and the MCP endpoint
80
+ ]
81
+ ```
82
+
83
+ Then `manage.py migrate`, and `manage.py check`, which says what is still missing, such as who may use
84
+ the board. The pages bring their own layout, `dg_core/base.html`, so they work whatever
85
+ the project's front end is. To show them inside the project's own layout instead, override that template,
86
+ as with Django's admin: it says what it must keep.
87
+
88
+ Who may use the board is a Django permission, "Can use the board" (`dg_board.use`): grant it to a
89
+ group in the admin, and put people in that group. Superusers have it. Those who have it are the board's
90
+ people: they can be assigned and @-mentioned. Each one's @-handle is their username by default. A
91
+ project that sees its people differently, such as by a nickname, a photo or rules of its own, names a
92
+ subclass of `datagroceries.core.people.People` in `DG_PEOPLE`; that module says how.
93
+
94
+ **Settings.** `DG_BOARD_NAME`, `DG_BOARD_KEY_PREFIX`, `DG_BOARD_DISPLAY_TIMEZONE` and
95
+ `DG_BOARD_COLORS`, each with a default (`conf.py`), and the GitHub settings below. Avatars are an
96
+ image when the project's `People` gives one, and initials otherwise (`datagroceries.core.people`).
97
+
98
+ **Languages.** English and Dutch, following the project's language (`django.utils.translation`); the
99
+ JavaScript's texts through Django's JavaScript catalog (`dg/jsi18n/`). The API and the MCP tools stay
100
+ English.
101
+
102
+ **Front end.** Bootstrap 5.3.8, Bootstrap Icons 1.13.1 and the Markdown editor (EasyMDE 2.21.0)
103
+ from core, plus htmx 1.9.8, SortableJS 1.15.7 (drag and drop) and Tom Select 2.6.2 (searchable and
104
+ multi-selects). All ship in the packages (`static/*/vendor/`, each with its licence), not from a CDN, so
105
+ the pages work offline, on an internal network and under a strict Content Security Policy, and no third
106
+ party sees who visits them. Form fields get their
107
+ Bootstrap layout from the board's own `field` tag, so the project needs no form package. Markdown is rendered
108
+ by core (`datagroceries.core.markdown`), as in every DataGroceries app; the board adds its ticket keys and @-mentions to it
109
+ (`ticket_links.py`), so they show in the other apps' Markdown too.
110
+
111
+ ## Notifications
112
+
113
+ - A ticket's `Watcher`s hear about it: comments (with the text quoted), the ticket being blocked or
114
+ unblocked (with the reason) and its entering a done column. A new assignee and anyone @-mentioned in a
115
+ comment or the description hear about that too. A message about a comment links straight to it
116
+ (`#comment-12`), and so does a comment's own 🔗 on the page.
117
+ - Creator, assignee, commenters and @-mentioned people start watching on their own; anyone can switch
118
+ it on or off on the ticket page.
119
+ - `@jasper` mentions the person whose handle is `jasper`. In every Markdown editor,
120
+ `@` and the start of a handle, first or last name lists the people it may mean, and a pick fills in
121
+ the handle that reaches them (core's `editor.js`, from the board's `mention_people` tag); not inside code. While it is
122
+ written, each `@handle` that reaches someone is marked, so a typo shows before saving.
123
+ - Nobody hears about their own change. Only the views notify (`notifications.py`), never a model
124
+ signal, so bulk changes stay quiet.
125
+ - A message goes out once the change is committed, through `datagroceries.core.notifications`: by email, or as a
126
+ Slack direct message, or Slack with email for whoever it cannot reach (`DG_NOTIFICATIONS`).
127
+
128
+ ## API for agents
129
+
130
+ An agent reads and writes tickets through `dg/board/api/tickets/` as the person it works for, with a
131
+ connected app or a personal token (django-dg-connect, at `dg/connect/`), or through the board's tools on the one MCP endpoint, `dg/mcp`.
132
+ The token goes as `Authorization: Bearer dg_…`, or as `Token dg_…` as with DRF's own tokens. A
133
+ project with ways in of its own (its own tokens, JWT, anything DRF can check) lists their classes in
134
+ `DG_AUTHENTICATION_CLASSES`, so its people need no second token; a key without the `dg_` prefix is
135
+ left to them. A project with its own OAuth server names it in `DG_AUTHORIZATION_SERVER`, and the MCP
136
+ endpoint's 401 then points MCP clients there (`datagroceries.core.authentication`). The views are in `api.py` and
137
+ the tools, which run those views, in `mcp.py`.
138
+
139
+ - Whatever lets a request in, it reaches the board's area (`board`) only while its person may use the
140
+ board, and only if the token's scopes, when it has them, cover it. Checked on every request.
141
+ - People are named by their handle, with their email address beside it when they have one; an assignee
142
+ may be given as either.
143
+ - Changes go through `operations.py` and the pages' forms, notifications included. Their history
144
+ records carry `via <token name>`, `via <OAuth app>` or `via API` as change reason, which the activity
145
+ feed shows.
146
+ - Editing a comment works as on the page: only its author may, and only people it newly @-mentions
147
+ hear about it.
148
+ - Not in the API: deleting tickets or groups, uploading attachments, and making groups and columns.
149
+
150
+ ## GitHub
151
+
152
+ Code in the repository (`DG_BOARD_GITHUB_REPOSITORY`) that mentions a ticket's key is linked to it, as a
153
+ `CodeReference`, and listed in a Code panel on the ticket page (pull requests first, then branches, then
154
+ commits) and under `code` in the ticket API. `github.py` does the work:
155
+
156
+ - **Commits** on the default branch count by the keys in their message, which on `main` is the squashed
157
+ pull request.
158
+ - **Branches** count by the keys in their name, in any case (`anna/t-123-invoices`). A branch stands
159
+ in for its work until a pull request is opened from it, which then replaces it on the ticket, so
160
+ merged-and-deleted branches do not pile up.
161
+ - **Pull requests** count by the keys in their title and their branch's name, not their description,
162
+ which names other tickets in passing. The state follows the pull request (open, merged, closed, in
163
+ the colours of the column categories), and a key taken out of the title takes its link away.
164
+ - A key that names no ticket is ignored.
165
+ - **Deliberately not:** a merged pull request does not move its ticket to done. The link and its state
166
+ show on the ticket; moving it stays with people.
167
+
168
+ **The webhook** at `dg/board/github/webhook/` takes GitHub's `push`, `pull_request` and `create` events.
169
+ It needs no login, and proves the sender by the `X-Hub-Signature-256` signature instead, made with
170
+ `DG_BOARD_GITHUB_WEBHOOK_SECRET`. Other repositories, pushes to other branches, tags and other events
171
+ link nothing.
172
+
173
+ **The backfill**, `manage.py backfill_github [--since 2025-03-01]`, reads the default branch's commits
174
+ and every pull request from the GitHub API with `DG_BOARD_GITHUB_TOKEN`, 100 a request, and is safe to run
175
+ again. Branches are left to the webhook: GitHub keeps no record of who made one when, and a branch that
176
+ got a pull request is represented by it anyway.
177
+
178
+ **Setting it up**, once:
179
+
180
+ 1. `DG_BOARD_GITHUB_REPOSITORY` (`owner/name`), a fine-grained GitHub token for the repository with
181
+ read-only Contents, Pull requests and Metadata as `DG_BOARD_GITHUB_TOKEN`, and a long random
182
+ `DG_BOARD_GITHUB_WEBHOOK_SECRET`. While the secret is empty the webhook refuses every delivery,
183
+ and without the token the backfill stops.
184
+ 2. A webhook in the repository's settings: payload URL `https://<your site>/dg/board/github/webhook/`,
185
+ content type `application/json`, the same secret, and the individual events Pushes, Pull requests
186
+ and Branch or tag creation. GitHub's first delivery is a ping, which should answer 200.
187
+ 3. `manage.py backfill_github`.
@@ -0,0 +1,151 @@
1
+ # django-dg-board
2
+
3
+ A kanban ticket tracker for a Django project, that people and AI agents work on together. Part of
4
+ [DataGroceries](https://github.com/datagroceries/django-dg). It started as Eddy Board, the tracker
5
+ [Eddy Grid](https://eddygrid.com) built for its own team, which kindly agreed to its release, and keeps its
6
+ design.
7
+
8
+ ## What it does
9
+
10
+ - A kanban board with drag and drop, groups per team or project with their own columns, and filters on
11
+ assignee, creator, label, text, and a ticket with everything under it.
12
+ - Tickets with a Markdown description, labels, a size, comments with one level of replies, and
13
+ attachments dropped or pasted onto the page; parents and subtasks, blockers, duplicates and related
14
+ tickets.
15
+ - Every change kept, with who made it and through which agent, shown per ticket as one activity feed.
16
+ - @-mentions and watchers, told by email or Slack.
17
+ - Links to the GitHub commits, branches and pull requests that mention a ticket.
18
+ - A REST API and MCP tools, so an agent works on tickets as the person who connected it.
19
+ - English and Dutch, following the project's language.
20
+
21
+ How the board is built, its requirements and its principles are in
22
+ [docs/board.md](https://github.com/datagroceries/django-dg/blob/main/docs/board.md).
23
+
24
+ ## Installing
25
+
26
+ The full guide, for a project that already has its own logins, users, API tokens or front end, is
27
+ [docs/integrating.md](https://github.com/datagroceries/django-dg/blob/main/docs/integrating.md). In short:
28
+
29
+ ```python
30
+ INSTALLED_APPS = [
31
+ ...,
32
+ 'django.contrib.humanize',
33
+ 'rest_framework',
34
+ 'simple_history',
35
+ 'datagroceries.core',
36
+ 'datagroceries.board',
37
+ ]
38
+ ```
39
+
40
+ ```python
41
+ urlpatterns = [
42
+ ...,
43
+ path('dg/', include('datagroceries.urls')), # every DataGroceries app and the MCP endpoint
44
+ ]
45
+ ```
46
+
47
+ Then `manage.py migrate`, and `manage.py check`, which says what is still missing, such as who may use
48
+ the board. The pages bring their own layout, `dg_core/base.html`, so they work whatever
49
+ the project's front end is. To show them inside the project's own layout instead, override that template,
50
+ as with Django's admin: it says what it must keep.
51
+
52
+ Who may use the board is a Django permission, "Can use the board" (`dg_board.use`): grant it to a
53
+ group in the admin, and put people in that group. Superusers have it. Those who have it are the board's
54
+ people: they can be assigned and @-mentioned. Each one's @-handle is their username by default. A
55
+ project that sees its people differently, such as by a nickname, a photo or rules of its own, names a
56
+ subclass of `datagroceries.core.people.People` in `DG_PEOPLE`; that module says how.
57
+
58
+ **Settings.** `DG_BOARD_NAME`, `DG_BOARD_KEY_PREFIX`, `DG_BOARD_DISPLAY_TIMEZONE` and
59
+ `DG_BOARD_COLORS`, each with a default (`conf.py`), and the GitHub settings below. Avatars are an
60
+ image when the project's `People` gives one, and initials otherwise (`datagroceries.core.people`).
61
+
62
+ **Languages.** English and Dutch, following the project's language (`django.utils.translation`); the
63
+ JavaScript's texts through Django's JavaScript catalog (`dg/jsi18n/`). The API and the MCP tools stay
64
+ English.
65
+
66
+ **Front end.** Bootstrap 5.3.8, Bootstrap Icons 1.13.1 and the Markdown editor (EasyMDE 2.21.0)
67
+ from core, plus htmx 1.9.8, SortableJS 1.15.7 (drag and drop) and Tom Select 2.6.2 (searchable and
68
+ multi-selects). All ship in the packages (`static/*/vendor/`, each with its licence), not from a CDN, so
69
+ the pages work offline, on an internal network and under a strict Content Security Policy, and no third
70
+ party sees who visits them. Form fields get their
71
+ Bootstrap layout from the board's own `field` tag, so the project needs no form package. Markdown is rendered
72
+ by core (`datagroceries.core.markdown`), as in every DataGroceries app; the board adds its ticket keys and @-mentions to it
73
+ (`ticket_links.py`), so they show in the other apps' Markdown too.
74
+
75
+ ## Notifications
76
+
77
+ - A ticket's `Watcher`s hear about it: comments (with the text quoted), the ticket being blocked or
78
+ unblocked (with the reason) and its entering a done column. A new assignee and anyone @-mentioned in a
79
+ comment or the description hear about that too. A message about a comment links straight to it
80
+ (`#comment-12`), and so does a comment's own 🔗 on the page.
81
+ - Creator, assignee, commenters and @-mentioned people start watching on their own; anyone can switch
82
+ it on or off on the ticket page.
83
+ - `@jasper` mentions the person whose handle is `jasper`. In every Markdown editor,
84
+ `@` and the start of a handle, first or last name lists the people it may mean, and a pick fills in
85
+ the handle that reaches them (core's `editor.js`, from the board's `mention_people` tag); not inside code. While it is
86
+ written, each `@handle` that reaches someone is marked, so a typo shows before saving.
87
+ - Nobody hears about their own change. Only the views notify (`notifications.py`), never a model
88
+ signal, so bulk changes stay quiet.
89
+ - A message goes out once the change is committed, through `datagroceries.core.notifications`: by email, or as a
90
+ Slack direct message, or Slack with email for whoever it cannot reach (`DG_NOTIFICATIONS`).
91
+
92
+ ## API for agents
93
+
94
+ An agent reads and writes tickets through `dg/board/api/tickets/` as the person it works for, with a
95
+ connected app or a personal token (django-dg-connect, at `dg/connect/`), or through the board's tools on the one MCP endpoint, `dg/mcp`.
96
+ The token goes as `Authorization: Bearer dg_…`, or as `Token dg_…` as with DRF's own tokens. A
97
+ project with ways in of its own (its own tokens, JWT, anything DRF can check) lists their classes in
98
+ `DG_AUTHENTICATION_CLASSES`, so its people need no second token; a key without the `dg_` prefix is
99
+ left to them. A project with its own OAuth server names it in `DG_AUTHORIZATION_SERVER`, and the MCP
100
+ endpoint's 401 then points MCP clients there (`datagroceries.core.authentication`). The views are in `api.py` and
101
+ the tools, which run those views, in `mcp.py`.
102
+
103
+ - Whatever lets a request in, it reaches the board's area (`board`) only while its person may use the
104
+ board, and only if the token's scopes, when it has them, cover it. Checked on every request.
105
+ - People are named by their handle, with their email address beside it when they have one; an assignee
106
+ may be given as either.
107
+ - Changes go through `operations.py` and the pages' forms, notifications included. Their history
108
+ records carry `via <token name>`, `via <OAuth app>` or `via API` as change reason, which the activity
109
+ feed shows.
110
+ - Editing a comment works as on the page: only its author may, and only people it newly @-mentions
111
+ hear about it.
112
+ - Not in the API: deleting tickets or groups, uploading attachments, and making groups and columns.
113
+
114
+ ## GitHub
115
+
116
+ Code in the repository (`DG_BOARD_GITHUB_REPOSITORY`) that mentions a ticket's key is linked to it, as a
117
+ `CodeReference`, and listed in a Code panel on the ticket page (pull requests first, then branches, then
118
+ commits) and under `code` in the ticket API. `github.py` does the work:
119
+
120
+ - **Commits** on the default branch count by the keys in their message, which on `main` is the squashed
121
+ pull request.
122
+ - **Branches** count by the keys in their name, in any case (`anna/t-123-invoices`). A branch stands
123
+ in for its work until a pull request is opened from it, which then replaces it on the ticket, so
124
+ merged-and-deleted branches do not pile up.
125
+ - **Pull requests** count by the keys in their title and their branch's name, not their description,
126
+ which names other tickets in passing. The state follows the pull request (open, merged, closed, in
127
+ the colours of the column categories), and a key taken out of the title takes its link away.
128
+ - A key that names no ticket is ignored.
129
+ - **Deliberately not:** a merged pull request does not move its ticket to done. The link and its state
130
+ show on the ticket; moving it stays with people.
131
+
132
+ **The webhook** at `dg/board/github/webhook/` takes GitHub's `push`, `pull_request` and `create` events.
133
+ It needs no login, and proves the sender by the `X-Hub-Signature-256` signature instead, made with
134
+ `DG_BOARD_GITHUB_WEBHOOK_SECRET`. Other repositories, pushes to other branches, tags and other events
135
+ link nothing.
136
+
137
+ **The backfill**, `manage.py backfill_github [--since 2025-03-01]`, reads the default branch's commits
138
+ and every pull request from the GitHub API with `DG_BOARD_GITHUB_TOKEN`, 100 a request, and is safe to run
139
+ again. Branches are left to the webhook: GitHub keeps no record of who made one when, and a branch that
140
+ got a pull request is represented by it anyway.
141
+
142
+ **Setting it up**, once:
143
+
144
+ 1. `DG_BOARD_GITHUB_REPOSITORY` (`owner/name`), a fine-grained GitHub token for the repository with
145
+ read-only Contents, Pull requests and Metadata as `DG_BOARD_GITHUB_TOKEN`, and a long random
146
+ `DG_BOARD_GITHUB_WEBHOOK_SECRET`. While the secret is empty the webhook refuses every delivery,
147
+ and without the token the backfill stops.
148
+ 2. A webhook in the repository's settings: payload URL `https://<your site>/dg/board/github/webhook/`,
149
+ content type `application/json`, the same secret, and the individual events Pushes, Pull requests
150
+ and Branch or tag creation. GitHub's first delivery is a ping, which should answer 200.
151
+ 3. `manage.py backfill_github`.
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "django-dg-board"
3
+ version = "1.0.0"
4
+ description = "A kanban ticket tracker for Django that people and AI agents work on together."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ authors = [{ name = "Jasper Cramwinckel" }]
10
+ keywords = ["django", "kanban", "ticket tracker", "issue tracker", "mcp", "ai agents"]
11
+ dependencies = [
12
+ "django-dg-core>=1.0",
13
+ "Django>=5.2",
14
+ "django-simple-history",
15
+ "markdown",
16
+ "requests",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Environment :: Web Environment",
21
+ "Framework :: Django",
22
+ "Framework :: Django :: 5.2",
23
+ "Framework :: Django :: 6.0",
24
+ "Framework :: Django :: 6.1",
25
+ "Intended Audience :: Developers",
26
+ "Operating System :: OS Independent",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.11",
29
+ "Programming Language :: Python :: 3.12",
30
+ "Programming Language :: Python :: 3.13",
31
+ "Programming Language :: Python :: 3.14",
32
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
33
+ "Topic :: Software Development :: Bug Tracking",
34
+ "Topic :: Office/Business :: Groupware",
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-board/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,174 @@
1
+ """The activity feed on a ticket page: field changes, relation changes and comments, newest first."""
2
+
3
+ import dataclasses
4
+ import datetime
5
+
6
+ from django.db.models import Prefetch, Q
7
+ from django.utils.translation import gettext as _
8
+ from django.utils.translation import gettext_lazy
9
+ from simple_history.models import DeletedObject
10
+
11
+ from datagroceries.board.models import Column, Comment, Group, Relation, Ticket
12
+ from datagroceries.core import people
13
+
14
+ # Fields whose change is not worth a line: bookkeeping, or shown another way (blocked has its comment).
15
+ QUIET_FIELDS = ('closed_at', 'is_blocked')
16
+ # Bootstrap icon for an entry, by the first field it changed.
17
+ FIELD_ICONS = {
18
+ 'title': 'bi-pencil',
19
+ 'description': 'bi-text-left',
20
+ 'column': 'bi-arrow-right-circle',
21
+ 'assignee': 'bi-person',
22
+ 'size': 'bi-hourglass-split',
23
+ 'labels': 'bi-tag',
24
+ }
25
+
26
+
27
+ @dataclasses.dataclass
28
+ class Entry:
29
+ when: datetime.datetime
30
+ user: object
31
+ lines: list[str] = dataclasses.field(default_factory=list)
32
+ comment: Comment | None = None
33
+ # Who to name when there is no user, e.g. a change written by an import command.
34
+ fallback: str = gettext_lazy('Someone')
35
+ icon: str = 'bi-dot'
36
+ # "via Claude Code on my laptop" when made with an API token.
37
+ via: str = ''
38
+
39
+
40
+ def _via(record) -> str:
41
+ """The history keeps "via Claude Code on my laptop" in English; it shows in the reader's language."""
42
+ reason = record.history_change_reason or ''
43
+ return _('via %(name)s') % {'name': reason.removeprefix('via ')} if reason.startswith('via ') else ''
44
+
45
+
46
+ def _person(user) -> str:
47
+ if isinstance(user, DeletedObject):
48
+ return _('a deleted user')
49
+ return people.name(user) if user else _('nobody')
50
+
51
+
52
+ def _last_version(model, pk):
53
+ """The last history record of a row, which outlives the row itself."""
54
+ return model.history.filter(id=pk).order_by('-history_date', '-history_id').first()
55
+
56
+
57
+ def _column_names(column) -> tuple[str, str]:
58
+ """(group name, column name), also for a column deleted since, from its last history record."""
59
+ if not isinstance(column, DeletedObject):
60
+ return column.group.name, column.name
61
+ record = _last_version(Column, column.pk)
62
+ group = Group.objects.filter(pk=record.group_id).first() or _last_version(Group, record.group_id)
63
+ return group.name, record.name
64
+
65
+
66
+ def _change_line(field: str, old, new) -> str:
67
+ # simple_history hands an empty foreign key over as a DeletedObject without a pk.
68
+ old, new = (None if isinstance(value, DeletedObject) and value.pk is None else value for value in (old, new))
69
+ if field == 'title':
70
+ return _('renamed the ticket from "%(old)s" to "%(new)s"') % {'old': old, 'new': new}
71
+ if field == 'description':
72
+ return _('edited the description')
73
+ if field == 'column':
74
+ (old_group, old_name), (new_group, new_name) = _column_names(old), _column_names(new)
75
+ if old_group != new_group:
76
+ return _('moved it from %(old)s to %(new)s') % {
77
+ 'old': f'{old_group} / {old_name}',
78
+ 'new': f'{new_group} / {new_name}',
79
+ }
80
+ return _('moved it from %(old)s to %(new)s') % {'old': old_name, 'new': new_name}
81
+ if field == 'assignee':
82
+ if not old:
83
+ return _('assigned it to %(person)s') % {'person': _person(new)}
84
+ if not new:
85
+ return _('removed %(person)s as assignee') % {'person': _person(old)}
86
+ return _('changed the assignee from %(old)s to %(new)s') % {'old': _person(old), 'new': _person(new)}
87
+ if field == 'size':
88
+ return _('set the size to %(size)s') % {'size': Ticket.Size(new).label} if new else _('cleared the size')
89
+ if field == 'labels':
90
+ # With foreign_keys_are_objs each through row holds the Label itself (or a DeletedObject).
91
+ old_names = {str(row['label']) for row in old}
92
+ new_names = {str(row['label']) for row in new}
93
+ parts = [_('added label "%(name)s"') % {'name': name} for name in sorted(new_names - old_names)]
94
+ parts += [_('removed label "%(name)s"') % {'name': name} for name in sorted(old_names - new_names)]
95
+ return ', '.join(parts)
96
+ return _('changed %(field)s') % {'field': field}
97
+
98
+
99
+ def _relation_line(record, ticket: Ticket) -> str:
100
+ if record.source_id == ticket.pk:
101
+ relation = f'{Relation.Type(record.type).label} {Ticket(pk=record.target_id).key}'
102
+ else:
103
+ relation = f'{Relation.INVERSE_LABELS[record.type]} {Ticket(pk=record.source_id).key}'
104
+ if record.history_type == '+':
105
+ return _('added "%(relation)s"') % {'relation': relation}
106
+ return _('removed "%(relation)s"') % {'relation': relation}
107
+
108
+
109
+ def build_feed(ticket: Ticket) -> list[Entry]:
110
+ entries = []
111
+ previous = None
112
+ for record in ticket.history.select_related('history_user').order_by('history_date', 'history_id'):
113
+ if previous is None:
114
+ entries.append(
115
+ Entry(
116
+ record.history_date,
117
+ record.history_user,
118
+ [_('created the ticket')],
119
+ icon='bi-plus-circle',
120
+ via=_via(record),
121
+ ),
122
+ )
123
+ else:
124
+ delta = record.diff_against(previous, excluded_fields=QUIET_FIELDS, foreign_keys_are_objs=True)
125
+ changes = [change for change in delta.changes if _change_line(change.field, change.old, change.new)]
126
+ if changes:
127
+ entries.append(
128
+ Entry(
129
+ record.history_date,
130
+ record.history_user,
131
+ [_change_line(change.field, change.old, change.new) for change in changes],
132
+ fallback=record.history_change_reason or _('Someone'),
133
+ icon=FIELD_ICONS.get(changes[0].field, 'bi-dot'),
134
+ via=_via(record),
135
+ ),
136
+ )
137
+ previous = record
138
+
139
+ relation_records = Relation.history.filter(Q(source_id=ticket.pk) | Q(target_id=ticket.pk)).exclude(
140
+ history_type='~',
141
+ )
142
+ entries += [
143
+ Entry(
144
+ record.history_date,
145
+ record.history_user,
146
+ [_relation_line(record, ticket)],
147
+ fallback=record.history_change_reason or _('Someone'),
148
+ icon='bi-link-45deg',
149
+ via=_via(record),
150
+ )
151
+ for record in relation_records.select_related('history_user')
152
+ ]
153
+
154
+ comments = (
155
+ ticket.comments.filter(parent=None)
156
+ .select_related('author')
157
+ .prefetch_related(
158
+ Prefetch(
159
+ 'replies',
160
+ queryset=Comment.objects.select_related('author').order_by('-created_at', '-id'),
161
+ ),
162
+ )
163
+ )
164
+ created = Comment.history.filter(ticket_id=ticket.pk, history_type='+').only('id', 'history_change_reason')
165
+ via = {record.id: _via(record) for record in created}
166
+ # A comment is only ever changed by its author editing it.
167
+ edited = set(Comment.history.filter(ticket_id=ticket.pk, history_type='~').values_list('id', flat=True))
168
+ for comment in comments:
169
+ comment.via, comment.edited = via.get(comment.pk, ''), comment.pk in edited
170
+ for reply in comment.replies.all():
171
+ reply.via, reply.edited = via.get(reply.pk, ''), reply.pk in edited
172
+ entries.append(Entry(comment.created_at, comment.author, comment=comment))
173
+ # Newest first; replies under a comment are ordered the same way.
174
+ return sorted(entries, key=lambda entry: entry.when, reverse=True)
@@ -0,0 +1,45 @@
1
+ from django.contrib import admin
2
+ from simple_history.admin import SimpleHistoryAdmin
3
+
4
+ from datagroceries.board.models import Attachment, Column, Comment, Group, Label, Relation, Ticket
5
+
6
+
7
+ class ColumnInline(admin.TabularInline):
8
+ model = Column
9
+ extra = 0
10
+
11
+
12
+ @admin.register(Group)
13
+ class GroupAdmin(SimpleHistoryAdmin):
14
+ list_display = ('name', 'slug', 'position')
15
+ inlines = (ColumnInline,)
16
+
17
+
18
+ @admin.register(Ticket)
19
+ class TicketAdmin(SimpleHistoryAdmin):
20
+ list_display = ('__str__', 'column', 'assignee', 'size', 'is_blocked', 'closed_at')
21
+ list_filter = ('column__group', 'column__category', 'is_blocked')
22
+ search_fields = ('title',)
23
+ raw_id_fields = ('creator', 'assignee')
24
+ filter_horizontal = ('labels',)
25
+
26
+
27
+ @admin.register(Relation)
28
+ class RelationAdmin(SimpleHistoryAdmin):
29
+ list_display = ('source', 'type', 'target')
30
+ raw_id_fields = ('source', 'target')
31
+
32
+
33
+ @admin.register(Comment)
34
+ class CommentAdmin(SimpleHistoryAdmin):
35
+ list_display = ('__str__', 'author', 'sets_blocked', 'created_at')
36
+ raw_id_fields = ('ticket', 'author', 'parent')
37
+
38
+
39
+ admin.site.register(Label)
40
+
41
+
42
+ @admin.register(Attachment)
43
+ class AttachmentAdmin(admin.ModelAdmin):
44
+ list_display = ('name', 'ticket', 'content_type', 'size', 'uploaded_by', 'uploaded_at')
45
+ raw_id_fields = ('ticket', 'uploaded_by')