django-aiogram 4.0.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_aiogram/__init__.py +64 -0
- django_aiogram/_singleton.py +22 -0
- django_aiogram/admin.py +380 -0
- django_aiogram/api.py +38 -0
- django_aiogram/apps.py +56 -0
- django_aiogram/config/__init__.py +15 -0
- django_aiogram/config/checks.py +759 -0
- django_aiogram/config/defaults.py +102 -0
- django_aiogram/config/enums.py +108 -0
- django_aiogram/config/settings.py +266 -0
- django_aiogram/consumer/__init__.py +13 -0
- django_aiogram/consumer/delivery.py +625 -0
- django_aiogram/consumer/routers.py +19 -0
- django_aiogram/consumer/webhook.py +154 -0
- django_aiogram/context.py +34 -0
- django_aiogram/eventlog/__init__.py +16 -0
- django_aiogram/eventlog/dbrouter.py +55 -0
- django_aiogram/eventlog/events.py +120 -0
- django_aiogram/eventlog/instrumentation.py +231 -0
- django_aiogram/eventlog/recorder.py +922 -0
- django_aiogram/eventlog/signals.py +84 -0
- django_aiogram/eventlog/writer.py +231 -0
- django_aiogram/exceptions.py +60 -0
- django_aiogram/healthcheck.py +412 -0
- django_aiogram/management/__init__.py +1 -0
- django_aiogram/management/commands/__init__.py +1 -0
- django_aiogram/management/commands/start_tgbot.py +308 -0
- django_aiogram/management/commands/tgbot_healthcheck.py +57 -0
- django_aiogram/management/commands/tgbot_prune_events.py +144 -0
- django_aiogram/management/commands/tgbot_reclaim.py +135 -0
- django_aiogram/management/commands/tgbot_webhook.py +87 -0
- django_aiogram/migrations/0001_initial.py +50 -0
- django_aiogram/migrations/0002_kind_id_index.py +32 -0
- django_aiogram/migrations/__init__.py +1 -0
- django_aiogram/models.py +79 -0
- django_aiogram/producer/__init__.py +13 -0
- django_aiogram/producer/client.py +1540 -0
- django_aiogram/producer/throttling.py +336 -0
- django_aiogram/py.typed +0 -0
- django_aiogram/redis.py +394 -0
- django_aiogram/wire/__init__.py +14 -0
- django_aiogram/wire/envelope.py +146 -0
- django_aiogram/wire/payloads.py +195 -0
- django_aiogram/wire/serializers.py +533 -0
- django_aiogram-4.0.0.dev0.dist-info/METADATA +145 -0
- django_aiogram-4.0.0.dev0.dist-info/RECORD +48 -0
- django_aiogram-4.0.0.dev0.dist-info/WHEEL +4 -0
- django_aiogram-4.0.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Run aiogram next to Django and send Telegram messages through a Redis queue.
|
|
2
|
+
|
|
3
|
+
Importing this package is cheap on purpose: aiogram (and the pydantic stack
|
|
4
|
+
underneath it) costs most of a second, and a migration container or a test run
|
|
5
|
+
should not pay that for a bot it never talks to. Every export resolves on first
|
|
6
|
+
attribute access instead (PEP 562).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
#: not imported from ``typing``: with annotations postponed nothing here needs that
|
|
12
|
+
#: module at runtime, and it was over half of what importing this package cost. Type
|
|
13
|
+
#: checkers understand the sentinel, and a reader can see it is always false
|
|
14
|
+
TYPE_CHECKING = False
|
|
15
|
+
|
|
16
|
+
__version__ = '4.0.0.dev0'
|
|
17
|
+
|
|
18
|
+
__all__ = ('TelegramBot', '__version__', 'bot', 'conf', 'get_redis', 'redis_conn')
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from django_aiogram.config.settings import conf as conf
|
|
24
|
+
from django_aiogram.producer.client import TelegramBot as TelegramBot
|
|
25
|
+
from django_aiogram.redis import get_redis as get_redis
|
|
26
|
+
from django_aiogram.redis import redis_conn as redis_conn
|
|
27
|
+
|
|
28
|
+
bot: TelegramBot
|
|
29
|
+
|
|
30
|
+
#: which module each lazy export lives in
|
|
31
|
+
_EXPORTS = {
|
|
32
|
+
'TelegramBot': 'django_aiogram.producer.client',
|
|
33
|
+
'get_redis': 'django_aiogram.redis',
|
|
34
|
+
'redis_conn': 'django_aiogram.redis',
|
|
35
|
+
'conf': 'django_aiogram.config.settings',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def __getattr__(name: str) -> Any: # noqa: ANN401 - a module attribute is whatever the module exports
|
|
40
|
+
"""Resolve an export on first access, then cache it on the module."""
|
|
41
|
+
if name == 'bot':
|
|
42
|
+
# `_singleton`'s module body builds the one instance, and Python's import
|
|
43
|
+
# lock is what makes two threads racing here share it. That is why this
|
|
44
|
+
# package holds no lock of its own: an explicit one would need `threading`
|
|
45
|
+
# imported at module scope, which is most of what importing this used to cost
|
|
46
|
+
from django_aiogram._singleton import ( # noqa: PLC0415 - the point: pay for aiogram on use, not import
|
|
47
|
+
bot,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
globals()['bot'] = bot
|
|
51
|
+
return bot
|
|
52
|
+
if name in _EXPORTS:
|
|
53
|
+
from importlib import import_module # noqa: PLC0415 - as above
|
|
54
|
+
|
|
55
|
+
value = getattr(import_module(_EXPORTS[name]), name)
|
|
56
|
+
globals()[name] = value
|
|
57
|
+
return value
|
|
58
|
+
msg = f'module {__name__!r} has no attribute {name!r}'
|
|
59
|
+
raise AttributeError(msg)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def __dir__() -> list[str]:
|
|
63
|
+
"""List the lazy exports alongside whatever is already materialised."""
|
|
64
|
+
return sorted(set(globals()) | set(__all__))
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""The one shared :class:`~django_aiogram.producer.client.TelegramBot` for this process.
|
|
2
|
+
|
|
3
|
+
A module body, not a lock. Python guarantees a module executes once per process and
|
|
4
|
+
makes concurrent importers wait on that module's own import lock, so two threads
|
|
5
|
+
reaching for ``django_aiogram.bot`` at the same moment get the same instance
|
|
6
|
+
without this package holding a lock of its own — and without ``__init__`` importing
|
|
7
|
+
``threading`` to build one, which is most of what importing the package used to cost
|
|
8
|
+
outside Django.
|
|
9
|
+
|
|
10
|
+
Why it matters that they get the same one: each instance builds its own event loop and
|
|
11
|
+
its own HTTP session, and ``loop_lock`` serializes access to *a* loop. Two bots means
|
|
12
|
+
two loops, and the lock that exists to keep ``run_until_complete`` from being reentered
|
|
13
|
+
would be guarding one of them while the other was entered.
|
|
14
|
+
|
|
15
|
+
Importing this module is what pays for aiogram, so nothing imports it at module
|
|
16
|
+
scope: :func:`django_aiogram.__getattr__` reaches it on the first access to
|
|
17
|
+
``bot`` and never again.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from django_aiogram.producer.client import TelegramBot
|
|
21
|
+
|
|
22
|
+
bot = TelegramBot()
|
django_aiogram/admin.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""A read-only view of the event feed, sized for a table nobody wants to count.
|
|
2
|
+
|
|
3
|
+
Nothing here reads a setting and nothing registers itself: ``admin.autodiscover``
|
|
4
|
+
imports this module while the app registry is still loading, and reading
|
|
5
|
+
settings at import time is the defect 2.0 existed to remove. Registration
|
|
6
|
+
happens in :meth:`~django_aiogram.apps.TelegramBotAppConfig.ready`, where
|
|
7
|
+
settings are already safe to read.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
13
|
+
|
|
14
|
+
from django.contrib import admin, messages
|
|
15
|
+
from django.contrib.admin.views.main import ORDER_VAR
|
|
16
|
+
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
|
17
|
+
from django.core.paginator import Paginator
|
|
18
|
+
from django.db.models import Field, QuerySet
|
|
19
|
+
from django.http import HttpRequest
|
|
20
|
+
from django.utils.functional import cached_property
|
|
21
|
+
from django.utils.html import format_html, format_html_join
|
|
22
|
+
|
|
23
|
+
from django_aiogram.config.settings import SETTINGS_NAME, coerce_bool, conf
|
|
24
|
+
from django_aiogram.eventlog.events import failure_kinds, kind_choices
|
|
25
|
+
from django_aiogram.eventlog.writer import log_alias
|
|
26
|
+
from django_aiogram.models import TelegramEvent
|
|
27
|
+
|
|
28
|
+
#: fetched only on the page that renders them; see TelegramEventAdmin.get_queryset
|
|
29
|
+
PAYLOAD_COLUMNS = ('error', 'detail')
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
# django-stubs parameterises these; at runtime neither is subscriptable
|
|
33
|
+
ModelAdminBase = admin.ModelAdmin[TelegramEvent]
|
|
34
|
+
AnyModelAdmin = admin.ModelAdmin[Any]
|
|
35
|
+
else:
|
|
36
|
+
ModelAdminBase = admin.ModelAdmin
|
|
37
|
+
AnyModelAdmin = admin.ModelAdmin
|
|
38
|
+
|
|
39
|
+
#: how many stages of one message the detail page will render
|
|
40
|
+
MAX_STAGES = 200
|
|
41
|
+
#: rows the changelist will count before it stops asking
|
|
42
|
+
COUNT_LIMIT = 10_000
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def log_is_on() -> bool:
|
|
46
|
+
"""Report whether the feed is recorded, read per request so a test can override it."""
|
|
47
|
+
try:
|
|
48
|
+
return coerce_bool(conf['EVENT_LOG'], f"{SETTINGS_NAME}['EVENT_LOG']")
|
|
49
|
+
except ImproperlyConfigured:
|
|
50
|
+
# a misconfigured flag is E031's finding; the admin's answer is to hide
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def may_see_payloads(request: HttpRequest) -> bool:
|
|
55
|
+
"""Whether this user may read message bodies and exception text.
|
|
56
|
+
|
|
57
|
+
Split from plain view access on purpose: support needs to see that a message
|
|
58
|
+
went out and when, without reading what it said.
|
|
59
|
+
"""
|
|
60
|
+
checker = getattr(request.user, 'has_perm', None)
|
|
61
|
+
return bool(checker and checker('django_aiogram.view_telegramevent_payload'))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class KindFilter(admin.SimpleListFilter):
|
|
65
|
+
"""Filters by kind, from the registry rather than from the table.
|
|
66
|
+
|
|
67
|
+
A plain ``list_filter`` on the column would build its dropdown with
|
|
68
|
+
``SELECT DISTINCT``, which is a full scan every time the changelist loads.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
title = 'kind'
|
|
72
|
+
parameter_name = 'kind'
|
|
73
|
+
|
|
74
|
+
def lookups(self, _request: HttpRequest, _model_admin: AnyModelAdmin) -> list[tuple[str, str]]:
|
|
75
|
+
"""Every registered kind, in the order they were registered."""
|
|
76
|
+
return kind_choices()
|
|
77
|
+
|
|
78
|
+
def queryset(self, _request: HttpRequest, queryset: QuerySet[TelegramEvent]) -> QuerySet[TelegramEvent]:
|
|
79
|
+
"""Narrow to the chosen kind, which leads the index it uses."""
|
|
80
|
+
value = self.value()
|
|
81
|
+
return queryset.filter(kind=value) if value else queryset
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class OutcomeFilter(admin.SimpleListFilter):
|
|
85
|
+
"""Everything that went wrong, as one question."""
|
|
86
|
+
|
|
87
|
+
title = 'outcome'
|
|
88
|
+
parameter_name = 'outcome'
|
|
89
|
+
|
|
90
|
+
def lookups(self, _request: HttpRequest, _model_admin: AnyModelAdmin) -> list[tuple[str, str]]:
|
|
91
|
+
"""Two answers: the failure kinds, or everything else."""
|
|
92
|
+
return [('failed', 'Something went wrong'), ('ok', 'Went fine')]
|
|
93
|
+
|
|
94
|
+
def queryset(self, _request: HttpRequest, queryset: QuerySet[TelegramEvent]) -> QuerySet[TelegramEvent]:
|
|
95
|
+
"""Narrow to the outcome chosen, as an IN list on the kind index."""
|
|
96
|
+
failures = failure_kinds()
|
|
97
|
+
if self.value() == 'failed':
|
|
98
|
+
return queryset.filter(kind__in=failures)
|
|
99
|
+
if self.value() == 'ok':
|
|
100
|
+
return queryset.exclude(kind__in=failures)
|
|
101
|
+
return queryset
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class BoundedPaginator(Paginator): # type: ignore[type-arg]
|
|
105
|
+
"""Counts, but never past ``COUNT_LIMIT`` rows.
|
|
106
|
+
|
|
107
|
+
Django's changelist runs ``COUNT(*)`` over the filtered queryset to build
|
|
108
|
+
the page list. On a table sized by traffic that is a sequential scan on
|
|
109
|
+
every page load, and the number it produces is stale by the time it renders.
|
|
110
|
+
|
|
111
|
+
Counting inside a ``LIMIT`` keeps the answer honest for the filtered views
|
|
112
|
+
people actually read, and turns the unfiltered one into a bounded index
|
|
113
|
+
scan rather than the whole table. Past the limit the count stops growing,
|
|
114
|
+
so the deepest pages are unreachable — by then the answer is a filter, not
|
|
115
|
+
another page.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
#: whether the count stopped at the cap, so the page can say it did
|
|
119
|
+
truncated = False
|
|
120
|
+
|
|
121
|
+
@cached_property
|
|
122
|
+
def count(self) -> int:
|
|
123
|
+
"""Count what fits inside the cap, in one query the index can serve."""
|
|
124
|
+
# the changelist always paginates a queryset; the base class is typed
|
|
125
|
+
# for anything sliceable, which has no count()
|
|
126
|
+
rows = cast('QuerySet[TelegramEvent]', self.object_list)
|
|
127
|
+
# unordered on purpose. Which rows the cap admits does not change how many
|
|
128
|
+
# there are, and the ordering is what stopped the index serving this: an `IN`
|
|
129
|
+
# over the failure kinds cannot yield a global `id DESC` from `(kind, -id)`, so
|
|
130
|
+
# the database sorted every match before the LIMIT could bite — the same defect
|
|
131
|
+
# the index was added to remove, surviving in the filter that needs it most
|
|
132
|
+
rows = rows.order_by()
|
|
133
|
+
# one row past the cap, so the difference between "exactly ten thousand"
|
|
134
|
+
# and "more than we will count" is knowable rather than assumed
|
|
135
|
+
found = int(rows[: COUNT_LIMIT + 1].count())
|
|
136
|
+
self.truncated = found > COUNT_LIMIT
|
|
137
|
+
return min(found, COUNT_LIMIT)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TelegramEventAdmin(ModelAdminBase):
|
|
141
|
+
"""Read-only, and deliberately narrow about what it will ask the database."""
|
|
142
|
+
|
|
143
|
+
list_display = ('created_at', 'kind', 'function', 'chat_id', 'thread', 'worker', 'error_code')
|
|
144
|
+
list_filter = (KindFilter, OutcomeFilter)
|
|
145
|
+
# what makes the box appear; the lookup itself is get_search_results below
|
|
146
|
+
search_fields = ('correlation_id', 'chat_id')
|
|
147
|
+
search_help_text = 'An exact correlation id, or an exact chat id.'
|
|
148
|
+
show_full_result_count = False
|
|
149
|
+
paginator = BoundedPaginator
|
|
150
|
+
list_per_page = 50
|
|
151
|
+
# nothing to join: the model holds no foreign key, which is what keeps an
|
|
152
|
+
# insert from becoming a constraint check
|
|
153
|
+
list_select_related = False
|
|
154
|
+
ordering = ('-id',)
|
|
155
|
+
# only the columns an index can serve: function, worker and error_code have
|
|
156
|
+
# none, and one click on those headers sorts a table sized by traffic
|
|
157
|
+
sortable_by = ('created_at', 'kind', 'chat_id')
|
|
158
|
+
# no date_hierarchy: its drilldown truncates created_at for every row, which
|
|
159
|
+
# is a full scan no index can serve
|
|
160
|
+
|
|
161
|
+
def get_queryset(self, request: HttpRequest) -> QuerySet[TelegramEvent]:
|
|
162
|
+
"""Read from the alias the writer writes to, router installed or not.
|
|
163
|
+
|
|
164
|
+
The two wide columns are left behind. Between them they are most of what a
|
|
165
|
+
row weighs — about 1.4 MB per fifty-row page under ``EVENT_LOG_PAYLOAD:
|
|
166
|
+
'full'`` with long tracebacks, and much less on the default ``'summary'``
|
|
167
|
+
with its 8 KiB cap — and the changelist renders neither, so they were
|
|
168
|
+
fetched to be discarded, including for a user `get_fields` withholds them
|
|
169
|
+
from. :meth:`get_object` asks for them
|
|
170
|
+
back on the one page that shows them.
|
|
171
|
+
"""
|
|
172
|
+
return super().get_queryset(request).using(log_alias()).defer(*PAYLOAD_COLUMNS)
|
|
173
|
+
|
|
174
|
+
def get_object(
|
|
175
|
+
self,
|
|
176
|
+
request: HttpRequest,
|
|
177
|
+
object_id: str,
|
|
178
|
+
from_field: str | None = None,
|
|
179
|
+
) -> TelegramEvent | None:
|
|
180
|
+
"""Fetch one row with its payload columns, since this page renders them.
|
|
181
|
+
|
|
182
|
+
Django routes the detail page through `get_queryset` too, so without this
|
|
183
|
+
every deferred column would cost its own extra query when the template
|
|
184
|
+
touched it. Written out rather than delegated because the deferral has to
|
|
185
|
+
be lifted *before* the lookup, not after it.
|
|
186
|
+
|
|
187
|
+
Only for a reader allowed to see them. `get_fields` already keeps message
|
|
188
|
+
bodies and exception text off the page, but fetching them anyway would put
|
|
189
|
+
both on the wire and into the query log for someone the permission exists
|
|
190
|
+
to withhold them from.
|
|
191
|
+
"""
|
|
192
|
+
rows = self.get_queryset(request)
|
|
193
|
+
if may_see_payloads(request):
|
|
194
|
+
rows = rows.defer(None)
|
|
195
|
+
meta = TelegramEvent._meta # noqa: SLF001 - how Django itself asks a model for its fields
|
|
196
|
+
field = meta.pk if from_field is None else meta.get_field(from_field)
|
|
197
|
+
if not isinstance(field, Field):
|
|
198
|
+
# this model holds no relations, so nothing else can turn up here
|
|
199
|
+
return None
|
|
200
|
+
try:
|
|
201
|
+
return rows.get(**{field.name: field.to_python(object_id)})
|
|
202
|
+
except (TelegramEvent.DoesNotExist, ValidationError, ValueError):
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
def changelist_view(self, request: HttpRequest, extra_context: dict[str, Any] | None = None) -> Any: # noqa: ANN401 - Django types this as a bare response
|
|
206
|
+
"""Render the list, saying so when the count stopped at the cap.
|
|
207
|
+
|
|
208
|
+
A page that reports exactly ten thousand results reads as the whole
|
|
209
|
+
answer. Silently, it would be the same defect the paginator exists to
|
|
210
|
+
avoid, moved one step along.
|
|
211
|
+
|
|
212
|
+
It also drops an ``?o=`` naming a column no index can serve. ``sortable_by``
|
|
213
|
+
decides whether a header is rendered as a *link* and nothing else — Django reads
|
|
214
|
+
it in one place, the template tag, while ``ChangeList`` maps ``?o=`` straight onto
|
|
215
|
+
``list_display``. So a bookmark, a shared link or a query string kept from before
|
|
216
|
+
this restriction still ordered the whole table by ``function``, ``worker`` or
|
|
217
|
+
``error_code``: on 200 000 rows a sequential scan and a sort for the page. Not for
|
|
218
|
+
the count — :class:`BoundedPaginator` drops the ordering, for the reason given
|
|
219
|
+
there — so this is the page query alone, once per view. Filtered rather than
|
|
220
|
+
refused, because an operator following an old link wants the page; the ordering
|
|
221
|
+
falls back to the default, which the index serves.
|
|
222
|
+
"""
|
|
223
|
+
self._drop_unsortable_ordering(request)
|
|
224
|
+
response = super().changelist_view(request, extra_context)
|
|
225
|
+
changelist = getattr(response, 'context_data', {}).get('cl')
|
|
226
|
+
paginator = getattr(changelist, 'paginator', None)
|
|
227
|
+
if paginator is not None and paginator.count and getattr(paginator, 'truncated', False):
|
|
228
|
+
self.message_user(
|
|
229
|
+
request,
|
|
230
|
+
f'More than {COUNT_LIMIT:,} events match. Narrow the filter or search for an '
|
|
231
|
+
f'exact id; counting further would scan the table.',
|
|
232
|
+
messages.WARNING,
|
|
233
|
+
)
|
|
234
|
+
return response
|
|
235
|
+
|
|
236
|
+
def _drop_unsortable_ordering(self, request: HttpRequest) -> None:
|
|
237
|
+
"""Keep only the ``?o=`` terms whose column is in :attr:`sortable_by`."""
|
|
238
|
+
requested = request.GET.get(ORDER_VAR)
|
|
239
|
+
if not requested:
|
|
240
|
+
return
|
|
241
|
+
allowed = {str(index) for index, field in enumerate(self.list_display) if field in self.sortable_by}
|
|
242
|
+
terms = requested.split('.')
|
|
243
|
+
kept = [term for term in terms if term.lstrip('-') in allowed]
|
|
244
|
+
if len(kept) == len(terms):
|
|
245
|
+
return
|
|
246
|
+
params = request.GET.copy()
|
|
247
|
+
if kept:
|
|
248
|
+
params[ORDER_VAR] = '.'.join(kept)
|
|
249
|
+
else:
|
|
250
|
+
del params[ORDER_VAR]
|
|
251
|
+
# django-stubs types `request.GET` immutable, which it is by convention rather
|
|
252
|
+
# than by construction; rewriting it before `super()` reads the params is what
|
|
253
|
+
# Django's own admin does, and the alternative — a ChangeList subclass — puts the
|
|
254
|
+
# rule further from the reason for it
|
|
255
|
+
request.GET = params # type: ignore[assignment]
|
|
256
|
+
|
|
257
|
+
def get_fields(self, request: HttpRequest, _obj: TelegramEvent | None = None) -> list[Any]:
|
|
258
|
+
"""Hide the two columns that can hold a message body or a stack trace."""
|
|
259
|
+
fields = [
|
|
260
|
+
'created_at',
|
|
261
|
+
'correlation_id',
|
|
262
|
+
'kind',
|
|
263
|
+
'function',
|
|
264
|
+
'chat_id',
|
|
265
|
+
'user_id',
|
|
266
|
+
'message_id',
|
|
267
|
+
'update_id',
|
|
268
|
+
'worker',
|
|
269
|
+
'attempt',
|
|
270
|
+
'duration_ms',
|
|
271
|
+
'error_code',
|
|
272
|
+
'stages',
|
|
273
|
+
]
|
|
274
|
+
if may_see_payloads(request):
|
|
275
|
+
fields[-1:-1] = ['pretty_detail', 'error']
|
|
276
|
+
return fields
|
|
277
|
+
|
|
278
|
+
def get_readonly_fields(self, request: HttpRequest, obj: TelegramEvent | None = None) -> list[Any]:
|
|
279
|
+
"""Everything: the feed records what happened, and that is not editable."""
|
|
280
|
+
return self.get_fields(request, obj)
|
|
281
|
+
|
|
282
|
+
def get_search_results(
|
|
283
|
+
self,
|
|
284
|
+
_request: HttpRequest,
|
|
285
|
+
queryset: QuerySet[TelegramEvent],
|
|
286
|
+
search_term: str,
|
|
287
|
+
) -> tuple[QuerySet[TelegramEvent], bool]:
|
|
288
|
+
"""Match the two typed columns exactly, each on its own index.
|
|
289
|
+
|
|
290
|
+
Django's own search cannot: even the `=` prefix builds `iexact`, which
|
|
291
|
+
renders as `UPPER(correlation_id::text) = ...` — a function on the
|
|
292
|
+
column, so no index applies and the search becomes a sequential scan of
|
|
293
|
+
a table sized by traffic. Typed equality is what the indexes are for.
|
|
294
|
+
|
|
295
|
+
The cost of typed equality is that a term the column cannot hold raises
|
|
296
|
+
while the query is built, which is why anything neither column can hold
|
|
297
|
+
is answered with nothing rather than handed to the database.
|
|
298
|
+
"""
|
|
299
|
+
term = search_term.strip()
|
|
300
|
+
if not term:
|
|
301
|
+
return queryset, False
|
|
302
|
+
if term.lstrip('-').isdigit():
|
|
303
|
+
number = int(term)
|
|
304
|
+
# a chat_id is a BIGINT; a longer number is not one, and asking
|
|
305
|
+
# would be an error from the backend rather than an empty page
|
|
306
|
+
if -(2**63) <= number < 2**63:
|
|
307
|
+
return queryset.filter(chat_id=number), False
|
|
308
|
+
return queryset.none(), False
|
|
309
|
+
try:
|
|
310
|
+
identifier = uuid.UUID(term)
|
|
311
|
+
except ValueError:
|
|
312
|
+
return queryset.none(), False
|
|
313
|
+
return queryset.filter(correlation_id=identifier), False
|
|
314
|
+
|
|
315
|
+
@admin.display(description='detail')
|
|
316
|
+
def pretty_detail(self, obj: TelegramEvent) -> str:
|
|
317
|
+
"""Render the JSON readably, and escaped.
|
|
318
|
+
|
|
319
|
+
format_html escapes; mark_safe here would be stored XSS, because a
|
|
320
|
+
detail holds whatever came off the wire.
|
|
321
|
+
"""
|
|
322
|
+
return format_html('<pre>{}</pre>', json.dumps(obj.detail, indent=2, ensure_ascii=False, default=str))
|
|
323
|
+
|
|
324
|
+
@admin.display(description='every stage of this message')
|
|
325
|
+
def stages(self, obj: TelegramEvent) -> str:
|
|
326
|
+
"""Render the whole correlated chain, in order, from one indexed query.
|
|
327
|
+
|
|
328
|
+
Bounded on purpose: a message that retried ten thousand times is a bug,
|
|
329
|
+
and rendering all of it would make this page a second one. One row more
|
|
330
|
+
than the cap is read so the page can say it stopped rather than end at
|
|
331
|
+
a number that looks like the whole story.
|
|
332
|
+
"""
|
|
333
|
+
rows = list(
|
|
334
|
+
TelegramEvent.objects.using(log_alias())
|
|
335
|
+
.filter(correlation_id=obj.correlation_id)
|
|
336
|
+
.order_by('id')
|
|
337
|
+
.values_list('created_at', 'kind', 'worker')[: MAX_STAGES + 1]
|
|
338
|
+
)
|
|
339
|
+
body = format_html_join('', '<tr><td>{}</td><td>{}</td><td>{}</td></tr>', rows[:MAX_STAGES])
|
|
340
|
+
if len(rows) > MAX_STAGES:
|
|
341
|
+
body += format_html(
|
|
342
|
+
'<tr><td colspan="3">and more — only the first {} stages are shown</td></tr>',
|
|
343
|
+
MAX_STAGES,
|
|
344
|
+
)
|
|
345
|
+
return format_html('<table>{}</table>', body)
|
|
346
|
+
|
|
347
|
+
@admin.display(description='thread', ordering='correlation_id')
|
|
348
|
+
def thread(self, obj: TelegramEvent) -> str:
|
|
349
|
+
"""Link a row to the rest of its message, through the exact search."""
|
|
350
|
+
return format_html('<a href="?q={}">{}</a>', obj.correlation_id, str(obj.correlation_id)[:8])
|
|
351
|
+
|
|
352
|
+
def has_add_permission(self, _request: HttpRequest) -> bool:
|
|
353
|
+
"""Refuse: the feed is append-only, and only this package appends."""
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
def has_change_permission(self, _request: HttpRequest, _obj: TelegramEvent | None = None) -> bool:
|
|
357
|
+
"""Refuse: a record of what happened is not something to edit."""
|
|
358
|
+
return False
|
|
359
|
+
|
|
360
|
+
def has_delete_permission(self, _request: HttpRequest, _obj: TelegramEvent | None = None) -> bool:
|
|
361
|
+
"""Refuse: a table this size is pruned in ranges, not a row at a time.
|
|
362
|
+
|
|
363
|
+
`manage.py tgbot_prune_events` is what does it.
|
|
364
|
+
"""
|
|
365
|
+
return False
|
|
366
|
+
|
|
367
|
+
def has_view_permission(self, request: HttpRequest, obj: TelegramEvent | None = None) -> bool:
|
|
368
|
+
"""Read the flag per request, so override_settings works in a test."""
|
|
369
|
+
return log_is_on() and bool(super().has_view_permission(request, obj))
|
|
370
|
+
|
|
371
|
+
def has_module_permission(self, request: HttpRequest) -> bool:
|
|
372
|
+
"""Keep the app off the admin index entirely while the log is off."""
|
|
373
|
+
return log_is_on() and bool(super().has_module_permission(request))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def register_event_log_admin(site: admin.AdminSite | None = None) -> None:
|
|
377
|
+
"""Register the read-only admin. Called from ready(), behind the flag."""
|
|
378
|
+
target = site or admin.site
|
|
379
|
+
if not target.is_registered(TelegramEvent):
|
|
380
|
+
target.register(TelegramEvent, TelegramEventAdmin)
|
django_aiogram/api.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Which method names a queued payload is allowed to name.
|
|
2
|
+
|
|
3
|
+
A payload carries the name of the method to call, so without this list the queue
|
|
4
|
+
could reach anything public on ``Bot``: ``download_file`` writes to the
|
|
5
|
+
container's filesystem, ``token`` hands out the credential.
|
|
6
|
+
|
|
7
|
+
This lives apart from ``client`` so the delivery consumer can check a payload
|
|
8
|
+
before handing it anywhere, without importing the client.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
import aiogram.methods
|
|
14
|
+
from aiogram import Bot
|
|
15
|
+
|
|
16
|
+
from django_aiogram.exceptions import UnknownApiMethodError
|
|
17
|
+
|
|
18
|
+
#: API methods a queued payload must never reach. They are administrative, not
|
|
19
|
+
#: sends: set_webhook would point updates at someone else's URL, and log_out or
|
|
20
|
+
#: close ends the session for the whole deployment.
|
|
21
|
+
DENIED_METHODS = frozenset({'set_webhook', 'delete_webhook', 'log_out', 'close'})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _api_methods() -> frozenset[str]:
|
|
25
|
+
"""Return the Bot attributes that correspond to a Telegram API method."""
|
|
26
|
+
api = {re.sub(r'(?<!^)(?=[A-Z])', '_', name).lower() for name in aiogram.methods.__all__}
|
|
27
|
+
public = {name for name in dir(Bot) if not name.startswith('_')}
|
|
28
|
+
return frozenset(api & public) - DENIED_METHODS
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
API_METHODS = _api_methods()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check_function(function: str) -> str:
|
|
35
|
+
"""Return ``function`` if it names a Telegram API method, else raise."""
|
|
36
|
+
if function not in API_METHODS:
|
|
37
|
+
raise UnknownApiMethodError(function, len(API_METHODS))
|
|
38
|
+
return function
|
django_aiogram/apps.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The Django app that hooks this package into a project's startup.
|
|
2
|
+
|
|
3
|
+
Importing this module has to stay free of side effects: Django imports it while
|
|
4
|
+
the app registry is still being populated, before settings are safe to read.
|
|
5
|
+
Everything that needs configuration happens in ``ready()``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
from django.apps import AppConfig, apps
|
|
11
|
+
from django.core.checks import register
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger('django_aiogram')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TelegramBotAppConfig(AppConfig):
|
|
17
|
+
"""Registers the system checks and imports every app's router module."""
|
|
18
|
+
|
|
19
|
+
name = 'django_aiogram'
|
|
20
|
+
label = 'django_aiogram'
|
|
21
|
+
verbose_name = 'django-aiogram'
|
|
22
|
+
# app-local, so it does not touch the project's DEFAULT_AUTO_FIELD
|
|
23
|
+
default_auto_field = 'django.db.models.BigAutoField'
|
|
24
|
+
|
|
25
|
+
def ready(self) -> None:
|
|
26
|
+
"""Register the checks and autodiscover routers, unless disabled here."""
|
|
27
|
+
# deferred: apps.py is imported while the app registry is still loading
|
|
28
|
+
from django_aiogram.config.settings import SETTINGS_NAME, coerce_bool, conf # noqa: PLC0415 - as above
|
|
29
|
+
|
|
30
|
+
# parsed, not truthiness-tested: 'false' has to disable startup the same
|
|
31
|
+
# way it disables sending, otherwise the two disagree
|
|
32
|
+
enabled = coerce_bool(conf['ENABLED'], f"{SETTINGS_NAME}['ENABLED']")
|
|
33
|
+
recording = coerce_bool(conf['EVENT_LOG'], f"{SETTINGS_NAME}['EVENT_LOG']")
|
|
34
|
+
|
|
35
|
+
# above the ENABLED gate on purpose: reading the log is not talking to
|
|
36
|
+
# Telegram, so an admin process that never sends still has to show it.
|
|
37
|
+
# The import chain is admin -> models -> django.db, never aiogram
|
|
38
|
+
if recording and apps.is_installed('django.contrib.admin'):
|
|
39
|
+
from django_aiogram.admin import register_event_log_admin # noqa: PLC0415 - as above
|
|
40
|
+
|
|
41
|
+
register_event_log_admin()
|
|
42
|
+
|
|
43
|
+
if not (enabled or recording):
|
|
44
|
+
logger.debug('django-aiogram is disabled in this process')
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
# after the gate: checks are the only reason a disabled boot would pay
|
|
48
|
+
# for anything beyond the settings module
|
|
49
|
+
from django_aiogram.config.checks import check_settings # noqa: PLC0415 - only when there is a report to make
|
|
50
|
+
|
|
51
|
+
register(check_settings)
|
|
52
|
+
|
|
53
|
+
if enabled and coerce_bool(conf['AUTODISCOVER'], f"{SETTINGS_NAME}['AUTODISCOVER']"):
|
|
54
|
+
from django_aiogram.consumer.routers import autodiscover_tg_routers # noqa: PLC0415 - only when enabled
|
|
55
|
+
|
|
56
|
+
autodiscover_tg_routers()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""What a project configures, and what refuses a bad value.
|
|
2
|
+
|
|
3
|
+
`settings` reads the `TELEGRAM_BOT` dict and the `DJANGO_AIOGRAM_` environment twins;
|
|
4
|
+
`defaults` is the only place a default lives; `enums` holds the values a setting accepts;
|
|
5
|
+
`checks` judges the result and is the only module here that reports to Django.
|
|
6
|
+
|
|
7
|
+
Nothing in this package may import from `producer`, `consumer` or `broker`: configuration
|
|
8
|
+
is read by them and reads nothing of them, which is what keeps `manage.py check` from
|
|
9
|
+
paying for aiogram.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
#: deliberately empty: callers import from the modules in this package, not from the
|
|
13
|
+
#: package itself. A re-export here would make a second path to every name, and the one
|
|
14
|
+
#: nobody chose is the one that cannot be moved later
|
|
15
|
+
__all__: tuple[str, ...] = ()
|