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.
- django_dg_board-1.0.0/.gitignore +16 -0
- django_dg_board-1.0.0/CHANGELOG.md +9 -0
- django_dg_board-1.0.0/LICENSE +21 -0
- django_dg_board-1.0.0/PKG-INFO +187 -0
- django_dg_board-1.0.0/README.md +151 -0
- django_dg_board-1.0.0/pyproject.toml +48 -0
- django_dg_board-1.0.0/src/datagroceries/board/__init__.py +0 -0
- django_dg_board-1.0.0/src/datagroceries/board/activity.py +174 -0
- django_dg_board-1.0.0/src/datagroceries/board/admin.py +45 -0
- django_dg_board-1.0.0/src/datagroceries/board/api.py +409 -0
- django_dg_board-1.0.0/src/datagroceries/board/apps.py +21 -0
- django_dg_board-1.0.0/src/datagroceries/board/checks.py +17 -0
- django_dg_board-1.0.0/src/datagroceries/board/conf.py +37 -0
- django_dg_board-1.0.0/src/datagroceries/board/forms.py +236 -0
- django_dg_board-1.0.0/src/datagroceries/board/github.py +189 -0
- django_dg_board-1.0.0/src/datagroceries/board/locale/nl/LC_MESSAGES/django.mo +0 -0
- django_dg_board-1.0.0/src/datagroceries/board/locale/nl/LC_MESSAGES/django.po +1060 -0
- django_dg_board-1.0.0/src/datagroceries/board/locale/nl/LC_MESSAGES/djangojs.mo +0 -0
- django_dg_board-1.0.0/src/datagroceries/board/locale/nl/LC_MESSAGES/djangojs.po +96 -0
- django_dg_board-1.0.0/src/datagroceries/board/management/__init__.py +0 -0
- django_dg_board-1.0.0/src/datagroceries/board/management/commands/__init__.py +0 -0
- django_dg_board-1.0.0/src/datagroceries/board/management/commands/backfill_github.py +48 -0
- django_dg_board-1.0.0/src/datagroceries/board/mcp.py +223 -0
- django_dg_board-1.0.0/src/datagroceries/board/migrations/0001_initial.py +300 -0
- django_dg_board-1.0.0/src/datagroceries/board/migrations/__init__.py +0 -0
- django_dg_board-1.0.0/src/datagroceries/board/models.py +393 -0
- django_dg_board-1.0.0/src/datagroceries/board/notifications.py +104 -0
- django_dg_board-1.0.0/src/datagroceries/board/operations.py +112 -0
- django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/board.js +127 -0
- django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/card_assignee.js +52 -0
- django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/copy_link.js +19 -0
- django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/peek.js +121 -0
- django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/selects.js +114 -0
- django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/shortcuts.js +24 -0
- django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/ticket.js +190 -0
- django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/tickets.css +378 -0
- django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/vendor/htmx/LICENSE +25 -0
- django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/vendor/htmx/htmx.min.js +1 -0
- django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/vendor/sortablejs/LICENSE +21 -0
- django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/vendor/sortablejs/Sortable.min.js +2 -0
- django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/vendor/tom-select/LICENSE +202 -0
- django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/vendor/tom-select/tom-select.bootstrap5.min.css +1 -0
- django_dg_board-1.0.0/src/datagroceries/board/static/dg_board/vendor/tom-select/tom-select.complete.min.js +453 -0
- django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_assets.html +11 -0
- django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_assignee_options.html +5 -0
- django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_card.html +29 -0
- django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_card_assignee.html +3 -0
- django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_comment_edit.html +13 -0
- django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_editor_assets.html +2 -0
- django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_field.html +12 -0
- django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_form_errors.html +5 -0
- django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_lanes.html +59 -0
- django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_logo.html +6 -0
- django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/_peek.html +70 -0
- django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/board.html +145 -0
- django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/group_create.html +26 -0
- django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/group_edit.html +102 -0
- django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/ticket_detail.html +412 -0
- django_dg_board-1.0.0/src/datagroceries/board/templates/dg_board/ticket_form.html +44 -0
- django_dg_board-1.0.0/src/datagroceries/board/templatetags/__init__.py +0 -0
- django_dg_board-1.0.0/src/datagroceries/board/templatetags/dg_board.py +124 -0
- django_dg_board-1.0.0/src/datagroceries/board/tests/__init__.py +6 -0
- django_dg_board-1.0.0/src/datagroceries/board/tests/test_api.py +344 -0
- django_dg_board-1.0.0/src/datagroceries/board/tests/test_github.py +233 -0
- django_dg_board-1.0.0/src/datagroceries/board/tests/test_mcp.py +219 -0
- django_dg_board-1.0.0/src/datagroceries/board/tests/test_tickets.py +1221 -0
- django_dg_board-1.0.0/src/datagroceries/board/tests/test_translations.py +122 -0
- django_dg_board-1.0.0/src/datagroceries/board/ticket_links.py +40 -0
- django_dg_board-1.0.0/src/datagroceries/board/urls.py +38 -0
- django_dg_board-1.0.0/src/datagroceries/board/views.py +772 -0
|
@@ -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"]
|
|
File without changes
|
|
@@ -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')
|