django-aiogram 4.0.0__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.
Files changed (86) hide show
  1. django_aiogram/__init__.py +60 -0
  2. django_aiogram/_singleton.py +22 -0
  3. django_aiogram/admin.py +410 -0
  4. django_aiogram/api.py +38 -0
  5. django_aiogram/apps.py +56 -0
  6. django_aiogram/broker/__init__.py +10 -0
  7. django_aiogram/broker/base.py +390 -0
  8. django_aiogram/broker/exceptions.py +75 -0
  9. django_aiogram/broker/kafka/__init__.py +6 -0
  10. django_aiogram/broker/kafka/broker.py +660 -0
  11. django_aiogram/broker/kafka/client.py +281 -0
  12. django_aiogram/broker/kafka/exceptions.py +38 -0
  13. django_aiogram/broker/models.py +31 -0
  14. django_aiogram/broker/rabbitmq/__init__.py +6 -0
  15. django_aiogram/broker/rabbitmq/broker.py +350 -0
  16. django_aiogram/broker/rabbitmq/client.py +198 -0
  17. django_aiogram/broker/rabbitmq/exceptions.py +37 -0
  18. django_aiogram/broker/redis_list/__init__.py +6 -0
  19. django_aiogram/broker/redis_list/broker.py +263 -0
  20. django_aiogram/broker/redis_streams/__init__.py +6 -0
  21. django_aiogram/broker/redis_streams/broker.py +694 -0
  22. django_aiogram/broker/redis_streams/exceptions.py +68 -0
  23. django_aiogram/broker/registry.py +146 -0
  24. django_aiogram/config/__init__.py +15 -0
  25. django_aiogram/config/checks/__init__.py +179 -0
  26. django_aiogram/config/checks/bot.py +216 -0
  27. django_aiogram/config/checks/conditions.py +98 -0
  28. django_aiogram/config/checks/eventlog.py +320 -0
  29. django_aiogram/config/checks/problems.py +90 -0
  30. django_aiogram/config/checks/shapes.py +202 -0
  31. django_aiogram/config/checks/transport.py +455 -0
  32. django_aiogram/config/defaults.py +109 -0
  33. django_aiogram/config/enums.py +132 -0
  34. django_aiogram/config/settings.py +286 -0
  35. django_aiogram/consumer/__init__.py +13 -0
  36. django_aiogram/consumer/delivery.py +653 -0
  37. django_aiogram/consumer/routers.py +19 -0
  38. django_aiogram/consumer/webhook.py +161 -0
  39. django_aiogram/context.py +34 -0
  40. django_aiogram/db.py +87 -0
  41. django_aiogram/eventlog/__init__.py +20 -0
  42. django_aiogram/eventlog/bookkeeping.py +159 -0
  43. django_aiogram/eventlog/dbrouter.py +55 -0
  44. django_aiogram/eventlog/events.py +185 -0
  45. django_aiogram/eventlog/instrumentation.py +233 -0
  46. django_aiogram/eventlog/moving.py +97 -0
  47. django_aiogram/eventlog/pacing.py +67 -0
  48. django_aiogram/eventlog/publishing.py +97 -0
  49. django_aiogram/eventlog/recorder.py +689 -0
  50. django_aiogram/eventlog/records.py +68 -0
  51. django_aiogram/eventlog/signals.py +84 -0
  52. django_aiogram/eventlog/writer.py +235 -0
  53. django_aiogram/exceptions.py +97 -0
  54. django_aiogram/healthcheck.py +599 -0
  55. django_aiogram/management/__init__.py +1 -0
  56. django_aiogram/management/commands/__init__.py +1 -0
  57. django_aiogram/management/commands/start_tgbot.py +321 -0
  58. django_aiogram/management/commands/tgbot_backfill_short_ids.py +97 -0
  59. django_aiogram/management/commands/tgbot_healthcheck.py +65 -0
  60. django_aiogram/management/commands/tgbot_move_events.py +299 -0
  61. django_aiogram/management/commands/tgbot_prune_events.py +144 -0
  62. django_aiogram/management/commands/tgbot_reclaim.py +161 -0
  63. django_aiogram/management/commands/tgbot_webhook.py +87 -0
  64. django_aiogram/migrations/0001_initial.py +50 -0
  65. django_aiogram/migrations/0002_kind_id_index.py +32 -0
  66. django_aiogram/migrations/0003_short_id.py +34 -0
  67. django_aiogram/migrations/__init__.py +1 -0
  68. django_aiogram/models.py +85 -0
  69. django_aiogram/producer/__init__.py +13 -0
  70. django_aiogram/producer/client.py +1215 -0
  71. django_aiogram/producer/from_settings.py +88 -0
  72. django_aiogram/producer/looping.py +110 -0
  73. django_aiogram/producer/outbound.py +115 -0
  74. django_aiogram/producer/queueing.py +152 -0
  75. django_aiogram/producer/routing.py +104 -0
  76. django_aiogram/producer/throttling.py +336 -0
  77. django_aiogram/py.typed +0 -0
  78. django_aiogram/redis.py +464 -0
  79. django_aiogram/wire/__init__.py +14 -0
  80. django_aiogram/wire/envelope.py +156 -0
  81. django_aiogram/wire/payloads.py +205 -0
  82. django_aiogram/wire/serializers.py +539 -0
  83. django_aiogram-4.0.0.dist-info/METADATA +193 -0
  84. django_aiogram-4.0.0.dist-info/RECORD +86 -0
  85. django_aiogram-4.0.0.dist-info/WHEEL +4 -0
  86. django_aiogram-4.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,60 @@
1
+ """Run aiogram next to Django and send Telegram messages through the broker you configure.
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'
17
+
18
+ __all__ = ('TelegramBot', '__version__', 'bot', 'conf')
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
+
26
+ bot: TelegramBot
27
+
28
+ #: which module each lazy export lives in
29
+ _EXPORTS = {
30
+ 'TelegramBot': 'django_aiogram.producer.client',
31
+ 'conf': 'django_aiogram.config.settings',
32
+ }
33
+
34
+
35
+ def __getattr__(name: str) -> Any: # noqa: ANN401 - a module attribute is whatever the module exports
36
+ """Resolve an export on first access, then cache it on the module."""
37
+ if name == 'bot':
38
+ # `_singleton`'s module body builds the one instance, and Python's import
39
+ # lock is what makes two threads racing here share it. That is why this
40
+ # package holds no lock of its own: an explicit one would need `threading`
41
+ # imported at module scope, which is most of what importing this used to cost
42
+ from django_aiogram._singleton import ( # noqa: PLC0415 - the point: pay for aiogram on use, not import
43
+ bot,
44
+ )
45
+
46
+ globals()['bot'] = bot
47
+ return bot
48
+ if name in _EXPORTS:
49
+ from importlib import import_module # noqa: PLC0415 - as above
50
+
51
+ value = getattr(import_module(_EXPORTS[name]), name)
52
+ globals()[name] = value
53
+ return value
54
+ msg = f'module {__name__!r} has no attribute {name!r}'
55
+ raise AttributeError(msg)
56
+
57
+
58
+ def __dir__() -> list[str]:
59
+ """List the lazy exports alongside whatever is already materialised."""
60
+ 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()
@@ -0,0 +1,410 @@
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, HttpResponse
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, normalise_short_id
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
+ # django-stubs makes `Paginator` generic in the object it pages over; Django does not, so writing
105
+ # the parameter would be a name that exists only for the type checker
106
+ class BoundedPaginator(Paginator): # type: ignore[type-arg] # generic in the stubs, not at runtime
107
+ """Counts, but never past ``COUNT_LIMIT`` rows.
108
+
109
+ Django's changelist runs ``COUNT(*)`` over the filtered queryset to build
110
+ the page list. On a table sized by traffic that is a sequential scan on
111
+ every page load, and the number it produces is stale by the time it renders.
112
+
113
+ Counting inside a ``LIMIT`` keeps the answer honest for the filtered views
114
+ people actually read, and turns the unfiltered one into a bounded index
115
+ scan rather than the whole table. Past the limit the count stops growing,
116
+ so the deepest pages are unreachable — by then the answer is a filter, not
117
+ another page.
118
+ """
119
+
120
+ #: whether the count stopped at the cap, so the page can say it did
121
+ truncated = False
122
+
123
+ @cached_property
124
+ def count(self) -> int:
125
+ """Count what fits inside the cap, in one query the index can serve."""
126
+ # the changelist always paginates a queryset; the base class is typed
127
+ # for anything sliceable, which has no count()
128
+ rows = cast('QuerySet[TelegramEvent]', self.object_list)
129
+ # unordered on purpose. Which rows the cap admits does not change how many
130
+ # there are, and the ordering is what stopped the index serving this: an `IN`
131
+ # over the failure kinds cannot yield a global `id DESC` from `(kind, -id)`, so
132
+ # the database sorted every match before the LIMIT could bite — the same defect
133
+ # the index was added to remove, surviving in the filter that needs it most
134
+ rows = rows.order_by()
135
+ # one row past the cap, so the difference between "exactly ten thousand"
136
+ # and "more than we will count" is knowable rather than assumed
137
+ found = int(rows[: COUNT_LIMIT + 1].count())
138
+ self.truncated = found > COUNT_LIMIT
139
+ return min(found, COUNT_LIMIT)
140
+
141
+
142
+ class TelegramEventAdmin(ModelAdminBase):
143
+ """Read-only, and deliberately narrow about what it will ask the database."""
144
+
145
+ list_display = ('created_at', 'kind', 'function', 'chat_id', 'thread', 'worker', 'error_code')
146
+ list_filter = (KindFilter, OutcomeFilter)
147
+ # what makes the box appear; the lookup itself is get_search_results below
148
+ search_fields = ('short_id', 'correlation_id', 'chat_id')
149
+ search_help_text = 'A short id, an exact correlation id, or an exact chat id.'
150
+ show_full_result_count = False
151
+ paginator = BoundedPaginator
152
+ list_per_page = 50
153
+ # nothing to join: the model holds no foreign key, which is what keeps an
154
+ # insert from becoming a constraint check
155
+ list_select_related = False
156
+ ordering = ('-id',)
157
+ # only the columns an index can serve: function, worker and error_code have
158
+ # none, and one click on those headers sorts a table sized by traffic
159
+ sortable_by = ('created_at', 'kind', 'chat_id')
160
+ # no date_hierarchy: its drilldown truncates created_at for every row, which
161
+ # is a full scan no index can serve
162
+
163
+ def get_queryset(self, request: HttpRequest) -> QuerySet[TelegramEvent]:
164
+ """Read from the alias the writer writes to, router installed or not.
165
+
166
+ The two wide columns are left behind. Between them they are most of what a
167
+ row weighs — about 1.4 MB per fifty-row page under ``EVENT_LOG_PAYLOAD:
168
+ 'full'`` with long tracebacks, and much less on the default ``'summary'``
169
+ with its 8 KiB cap — and the changelist renders neither, so they were
170
+ fetched to be discarded, including for a user `get_fields` withholds them
171
+ from. :meth:`get_object` asks for them
172
+ back on the one page that shows them.
173
+ """
174
+ return super().get_queryset(request).using(log_alias()).defer(*PAYLOAD_COLUMNS)
175
+
176
+ def get_object(
177
+ self,
178
+ request: HttpRequest,
179
+ object_id: str,
180
+ from_field: str | None = None,
181
+ ) -> TelegramEvent | None:
182
+ """Fetch one row with its payload columns, since this page renders them.
183
+
184
+ Django routes the detail page through `get_queryset` too, so without this
185
+ every deferred column would cost its own extra query when the template
186
+ touched it. Written out rather than delegated because the deferral has to
187
+ be lifted *before* the lookup, not after it.
188
+
189
+ Only for a reader allowed to see them. `get_fields` already keeps message
190
+ bodies and exception text off the page, but fetching them anyway would put
191
+ both on the wire and into the query log for someone the permission exists
192
+ to withhold them from.
193
+ """
194
+ rows = self.get_queryset(request)
195
+ if may_see_payloads(request):
196
+ rows = rows.defer(None)
197
+ meta = TelegramEvent._meta # noqa: SLF001 - how Django itself asks a model for its fields
198
+ field = meta.pk if from_field is None else meta.get_field(from_field)
199
+ if not isinstance(field, Field):
200
+ # this model holds no relations, so nothing else can turn up here
201
+ return None
202
+ try:
203
+ return rows.get(**{field.name: field.to_python(object_id)})
204
+ except (TelegramEvent.DoesNotExist, ValidationError, ValueError):
205
+ return None
206
+
207
+ def changelist_view(self, request: HttpRequest, extra_context: dict[str, Any] | None = None) -> HttpResponse:
208
+ """Render the list, saying so when the count stopped at the cap.
209
+
210
+ A page that reports exactly ten thousand results reads as the whole
211
+ answer. Silently, it would be the same defect the paginator exists to
212
+ avoid, moved one step along.
213
+
214
+ It also drops an ``?o=`` naming a column no index can serve. ``sortable_by``
215
+ decides whether a header is rendered as a *link* and nothing else — Django reads
216
+ it in one place, the template tag, while ``ChangeList`` maps ``?o=`` straight onto
217
+ ``list_display``. So a bookmark, a shared link or a query string kept from before
218
+ this restriction still ordered the whole table by ``function``, ``worker`` or
219
+ ``error_code``: on 200 000 rows a sequential scan and a sort for the page. Not for
220
+ the count — :class:`BoundedPaginator` drops the ordering, for the reason given
221
+ there — so this is the page query alone, once per view. Filtered rather than
222
+ refused, because an operator following an old link wants the page; the ordering
223
+ falls back to the default, which the index serves.
224
+ """
225
+ self._drop_unsortable_ordering(request)
226
+ response = super().changelist_view(request, extra_context)
227
+ changelist = getattr(response, 'context_data', {}).get('cl')
228
+ paginator = getattr(changelist, 'paginator', None)
229
+ if paginator is not None and paginator.count and getattr(paginator, 'truncated', False):
230
+ self.message_user(
231
+ request,
232
+ f'More than {COUNT_LIMIT:,} events match. Narrow the filter or search for an '
233
+ f'exact id; counting further would scan the table.',
234
+ messages.WARNING,
235
+ )
236
+ return response
237
+
238
+ def _drop_unsortable_ordering(self, request: HttpRequest) -> None:
239
+ """Keep only the ``?o=`` terms whose column is in :attr:`sortable_by`."""
240
+ requested = request.GET.get(ORDER_VAR)
241
+ if not requested:
242
+ return
243
+ allowed = {str(index) for index, field in enumerate(self.list_display) if field in self.sortable_by}
244
+ terms = requested.split('.')
245
+ kept = [term for term in terms if term.lstrip('-') in allowed]
246
+ if len(kept) == len(terms):
247
+ return
248
+ params = request.GET.copy()
249
+ if kept:
250
+ params[ORDER_VAR] = '.'.join(kept)
251
+ else:
252
+ del params[ORDER_VAR]
253
+ # django-stubs types `request.GET` immutable, which it is by convention rather
254
+ # than by construction; rewriting it before `super()` reads the params is what
255
+ # Django's own admin does, and the alternative — a ChangeList subclass — puts the
256
+ # rule further from the reason for it
257
+ request.GET = params # type: ignore[assignment]
258
+
259
+ def get_fields(self, request: HttpRequest, _obj: TelegramEvent | None = None) -> list[Any]:
260
+ """Hide the two columns that can hold a message body or a stack trace."""
261
+ fields = [
262
+ 'created_at',
263
+ 'correlation_id',
264
+ 'kind',
265
+ 'function',
266
+ 'chat_id',
267
+ 'user_id',
268
+ 'message_id',
269
+ 'update_id',
270
+ 'worker',
271
+ 'attempt',
272
+ 'duration_ms',
273
+ 'error_code',
274
+ 'stages',
275
+ ]
276
+ if may_see_payloads(request):
277
+ fields[-1:-1] = ['pretty_detail', 'error']
278
+ return fields
279
+
280
+ def get_readonly_fields(self, request: HttpRequest, obj: TelegramEvent | None = None) -> list[Any]:
281
+ """Everything: the feed records what happened, and that is not editable."""
282
+ return self.get_fields(request, obj)
283
+
284
+ def get_search_results(
285
+ self,
286
+ _request: HttpRequest,
287
+ queryset: QuerySet[TelegramEvent],
288
+ search_term: str,
289
+ ) -> tuple[QuerySet[TelegramEvent], bool]:
290
+ """Match the three typed columns exactly, each on its own index.
291
+
292
+ Django's own search cannot: even the `=` prefix builds `iexact`, which
293
+ renders as `UPPER(correlation_id::text) = ...` — a function on the
294
+ column, so no index applies and the search becomes a sequential scan of
295
+ a table sized by traffic. Typed equality is what the indexes are for.
296
+
297
+ The cost of typed equality is that a term the column cannot hold raises
298
+ while the query is built, which is why a term no column can hold is
299
+ answered with nothing rather than handed to the database.
300
+ """
301
+ term = search_term.strip()
302
+ if not term:
303
+ return queryset, False
304
+ if term.lstrip('-').isdigit():
305
+ number = int(term)
306
+ # a chat_id is a BIGINT; a longer number is not one, and asking
307
+ # would be an error from the backend rather than an empty page
308
+ if -(2**63) <= number < 2**63:
309
+ return queryset.filter(chat_id=number), False
310
+ return queryset.none(), False
311
+ # before the UUID, because a short id is what a person has: it is what the thread column
312
+ # shows and what a ticket carries. `normalise_short_id` answers '' for anything that is not
313
+ # one, which is how this tells the two apart without guessing at the length.
314
+ #
315
+ # after the digits, though, and that ambiguity is decided rather than stumbled into: a
316
+ # twelve-character term of nothing but digits is a legal code *and* a plausible chat id.
317
+ # Chat ids that long are ordinary; a code drawn entirely from ten of the thirty-two
318
+ # characters is about one in a million
319
+ code = normalise_short_id(term)
320
+ if code:
321
+ return queryset.filter(short_id=code), False
322
+ try:
323
+ identifier = uuid.UUID(term)
324
+ except ValueError:
325
+ return queryset.none(), False
326
+ return queryset.filter(correlation_id=identifier), False
327
+
328
+ @admin.display(description='detail')
329
+ def pretty_detail(self, obj: TelegramEvent) -> str:
330
+ """Render the JSON readably, and escaped.
331
+
332
+ format_html escapes; mark_safe here would be stored XSS, because a
333
+ detail holds whatever came off the wire.
334
+ """
335
+ return format_html('<pre>{}</pre>', json.dumps(obj.detail, indent=2, ensure_ascii=False, default=str))
336
+
337
+ @admin.display(description='every stage of this message')
338
+ def stages(self, obj: TelegramEvent) -> str:
339
+ """Render the whole correlated chain, in order, from one indexed query.
340
+
341
+ Bounded on purpose: a message that retried ten thousand times is a bug,
342
+ and rendering all of it would make this page a second one. One row more
343
+ than the cap is read so the page can say it stopped rather than end at
344
+ a number that looks like the whole story.
345
+ """
346
+ rows = list(
347
+ TelegramEvent.objects.using(log_alias())
348
+ .filter(correlation_id=obj.correlation_id)
349
+ .order_by('id')
350
+ .values_list('created_at', 'kind', 'worker')[: MAX_STAGES + 1]
351
+ )
352
+ body = format_html_join('', '<tr><td>{}</td><td>{}</td><td>{}</td></tr>', rows[:MAX_STAGES])
353
+ if len(rows) > MAX_STAGES:
354
+ body += format_html(
355
+ '<tr><td colspan="3">and more — only the first {} stages are shown</td></tr>',
356
+ MAX_STAGES,
357
+ )
358
+ return format_html('<table>{}</table>', body)
359
+
360
+ @admin.display(description='thread')
361
+ def thread(self, obj: TelegramEvent) -> str:
362
+ """Link a row to the rest of its message, and show something worth reading.
363
+
364
+ The label was the correlation id's first eight characters, which is the clock: a UUIDv7
365
+ opens with a 48-bit millisecond, and those eight are its top 32 bits — `ms >> 16`, measured
366
+ — so they change once every 2**16 ms, 65.5 seconds, and every row inside one of those steps
367
+ showed the same label. It looked like an identifier and named the minute it happened in.
368
+
369
+ A row written before the backfill has no short id, and says so rather than showing an empty
370
+ link. The href carries the code where there is one, so what is on screen is what the search
371
+ takes — copying the cell used to return nothing.
372
+
373
+ No `ordering`: the column is not in `sortable_by`, so the header is not a link and an `?o=`
374
+ naming it is dropped — and a code is random, so ordering by one answers nothing anybody
375
+ asked.
376
+ """
377
+ if not obj.short_id:
378
+ missing = '(not backfilled)'
379
+ return format_html('<a href="?q={}" title="{}">{}</a>', obj.correlation_id, obj.correlation_id, missing)
380
+ return format_html('<a href="?q={}" title="{}">{}</a>', obj.short_id, obj.correlation_id, obj.short_id)
381
+
382
+ def has_add_permission(self, _request: HttpRequest) -> bool:
383
+ """Refuse: the feed is append-only, and only this package appends."""
384
+ return False
385
+
386
+ def has_change_permission(self, _request: HttpRequest, _obj: TelegramEvent | None = None) -> bool:
387
+ """Refuse: a record of what happened is not something to edit."""
388
+ return False
389
+
390
+ def has_delete_permission(self, _request: HttpRequest, _obj: TelegramEvent | None = None) -> bool:
391
+ """Refuse: a table this size is pruned in ranges, not a row at a time.
392
+
393
+ `manage.py tgbot_prune_events` is what does it.
394
+ """
395
+ return False
396
+
397
+ def has_view_permission(self, request: HttpRequest, obj: TelegramEvent | None = None) -> bool:
398
+ """Read the flag per request, so override_settings works in a test."""
399
+ return log_is_on() and bool(super().has_view_permission(request, obj))
400
+
401
+ def has_module_permission(self, request: HttpRequest) -> bool:
402
+ """Keep the app off the admin index entirely while the log is off."""
403
+ return log_is_on() and bool(super().has_module_permission(request))
404
+
405
+
406
+ def register_event_log_admin(site: admin.AdminSite | None = None) -> None:
407
+ """Register the read-only admin. Called from ready(), behind the flag."""
408
+ target = site or admin.site
409
+ if not target.is_registered(TelegramEvent):
410
+ 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,10 @@
1
+ """One transport per package, and the contract every one of them answers.
2
+
3
+ `base` is the contract, `models` the shapes it hands back, `exceptions` what goes wrong
4
+ choosing or reaching a transport, and `registry` how the one this process uses is resolved.
5
+ Each transport is a package of its own beside them.
6
+ """
7
+
8
+ #: deliberately empty, like the other cluster packages: callers import from the modules, so
9
+ #: there is one path to `Broker` and one to `get_broker` rather than two of each
10
+ __all__: tuple[str, ...] = ()