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.
Files changed (48) hide show
  1. django_aiogram/__init__.py +64 -0
  2. django_aiogram/_singleton.py +22 -0
  3. django_aiogram/admin.py +380 -0
  4. django_aiogram/api.py +38 -0
  5. django_aiogram/apps.py +56 -0
  6. django_aiogram/config/__init__.py +15 -0
  7. django_aiogram/config/checks.py +759 -0
  8. django_aiogram/config/defaults.py +102 -0
  9. django_aiogram/config/enums.py +108 -0
  10. django_aiogram/config/settings.py +266 -0
  11. django_aiogram/consumer/__init__.py +13 -0
  12. django_aiogram/consumer/delivery.py +625 -0
  13. django_aiogram/consumer/routers.py +19 -0
  14. django_aiogram/consumer/webhook.py +154 -0
  15. django_aiogram/context.py +34 -0
  16. django_aiogram/eventlog/__init__.py +16 -0
  17. django_aiogram/eventlog/dbrouter.py +55 -0
  18. django_aiogram/eventlog/events.py +120 -0
  19. django_aiogram/eventlog/instrumentation.py +231 -0
  20. django_aiogram/eventlog/recorder.py +922 -0
  21. django_aiogram/eventlog/signals.py +84 -0
  22. django_aiogram/eventlog/writer.py +231 -0
  23. django_aiogram/exceptions.py +60 -0
  24. django_aiogram/healthcheck.py +412 -0
  25. django_aiogram/management/__init__.py +1 -0
  26. django_aiogram/management/commands/__init__.py +1 -0
  27. django_aiogram/management/commands/start_tgbot.py +308 -0
  28. django_aiogram/management/commands/tgbot_healthcheck.py +57 -0
  29. django_aiogram/management/commands/tgbot_prune_events.py +144 -0
  30. django_aiogram/management/commands/tgbot_reclaim.py +135 -0
  31. django_aiogram/management/commands/tgbot_webhook.py +87 -0
  32. django_aiogram/migrations/0001_initial.py +50 -0
  33. django_aiogram/migrations/0002_kind_id_index.py +32 -0
  34. django_aiogram/migrations/__init__.py +1 -0
  35. django_aiogram/models.py +79 -0
  36. django_aiogram/producer/__init__.py +13 -0
  37. django_aiogram/producer/client.py +1540 -0
  38. django_aiogram/producer/throttling.py +336 -0
  39. django_aiogram/py.typed +0 -0
  40. django_aiogram/redis.py +394 -0
  41. django_aiogram/wire/__init__.py +14 -0
  42. django_aiogram/wire/envelope.py +146 -0
  43. django_aiogram/wire/payloads.py +195 -0
  44. django_aiogram/wire/serializers.py +533 -0
  45. django_aiogram-4.0.0.dev0.dist-info/METADATA +145 -0
  46. django_aiogram-4.0.0.dev0.dist-info/RECORD +48 -0
  47. django_aiogram-4.0.0.dev0.dist-info/WHEEL +4 -0
  48. 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()
@@ -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, ...] = ()