From e852900e42eb0bde98daf7070d84816e44c3308e Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Wed, 29 Jan 2020 13:57:33 +0100 Subject: [PATCH] Small refactor in messaging. --- src/club/models.py | 7 +- src/messaging/admin.py | 8 +- src/messaging/locale/pl/LC_MESSAGES/django.mo | Bin 3309 -> 2967 bytes src/messaging/locale/pl/LC_MESSAGES/django.po | 136 ++++++++------- .../migrations/0005_auto_20200129_1309.py | 33 ++++ src/messaging/models.py | 114 ++++++------ src/messaging/recipient.py | 6 - src/messaging/states.py | 162 +++++++++++------- src/messaging/views.py | 31 ++-- 9 files changed, 290 insertions(+), 207 deletions(-) create mode 100644 src/messaging/migrations/0005_auto_20200129_1309.py delete mode 100644 src/messaging/recipient.py diff --git a/src/club/models.py b/src/club/models.py index f36aada20..6fd58e0b3 100644 --- a/src/club/models.py +++ b/src/club/models.py @@ -12,6 +12,7 @@ from django import template from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _, ungettext, ugettext, get_language from catalogue.utils import get_random_hash +from messaging.states import Level from .payment_methods import recurring_payment_method, single_payment_method from .payu import models as payu_models from . import utils @@ -113,14 +114,14 @@ class Schedule(models.Model): def update_contact(self): Contact = apps.get_model('messaging', 'Contact') if not self.payed_at: - level = Contact.TRIED + level = Level.TRIED since = self.started_at else: since = self.payed_at if self.is_recurring(): - level = Contact.RECURRING + level = Level.RECURRING else: - level = Contact.SINGLE + level = Level.SINGLE Contact.update(self.email, level, since, self.expires_at) diff --git a/src/messaging/admin.py b/src/messaging/admin.py index 558c34d8a..9ac76b53d 100644 --- a/src/messaging/admin.py +++ b/src/messaging/admin.py @@ -6,8 +6,8 @@ from . import models class EmailSentInline(admin.TabularInline): model = models.EmailSent - fields = ['timestamp', 'email', 'subject'] - readonly_fields = ['timestamp', 'email', 'subject'] + fields = ['timestamp', 'contact', 'subject'] + readonly_fields = ['timestamp', 'contact', 'subject'] extra = 0 can_delete = False show_change_link = True @@ -56,8 +56,8 @@ admin.site.register(models.EmailTemplate, EmailTemplateAdmin) class EmailSentAdmin(admin.ModelAdmin): list_filter = ['template'] - list_display = ['timestamp', 'template', 'email', 'subject'] - fields = ['timestamp', 'template', 'email', 'subject', 'body', 'hash_value'] + list_display = ['timestamp', 'template', 'contact', 'subject'] + fields = ['timestamp', 'template', 'contact', 'subject', 'body'] readonly_fields = fields change_links = ['template'] diff --git a/src/messaging/locale/pl/LC_MESSAGES/django.mo b/src/messaging/locale/pl/LC_MESSAGES/django.mo index 415c59bec22da72dfea975f7e87668f2b673d380..119350a7c17c954f13d670e85e995b05ff353575 100644 GIT binary patch delta 1055 zcmYk*Pe@cz6vy#1&FHA3HJLSy)--BmIu1c;Sh*AW1EH<@vnk4%MT}+Ov$BFgS_N%n zh!#=1qJktDX;a&3kp$(UU8uW)7H(bj{XI|MWzPHDd)~Y6zI)!Azv(Bb@~`IDW25yD zyNI!f*>U{1i39Cb)GUUtF^;pi8Q)_qesUK)zlgeS1-IZItiv_jhEZ;&c`58ND_e$+ zE;xajID$iX9_#TbYQY!Si}Toz%NX9MX8i#(*h0P&b2xz8vEbvoe*PskkpF;j*0;}e z^Z?)d#81?ME2tf;Vge(qmc%C1`3!1a-t$L1f70{kkg0YVwXq`3Q{C%+zJtkitZz9w zDor2i!c*9UXR!k>d43wRj2|NRus5h3&Y>#x1&`u)H;V4IHv)VzCG9;P!x zX8@N_50IulvK{pR-FOhsU>5IV9-pB~{uQ<05^COWAFrY+xaKxcMpdxY%_XS+RuY3G zxWQ3T$G6;R9A+kh+Txff&Wdf?|=_><32(gP-`YsMg6-rwr&P(gBRUba5 zC~ed^)EyzEVt8>BUzFTvrNVds>zF%;6X~B|uolaK7f{A8;~2b&lkhH%#fLZ*pQ7x0 zhpkGL)W={!Lrrz#TT#8R{8h#J@^(>b81V(TR z%Kn{xzoe8&Lk`HH+_;3}@eEGJzF__;N+bipcqJHr7L30|&QzaJGVmGu$=J7G{wABJ z(!VD%|Cs2;aK0L%QHLL}8NbK`&f>Znn~`UzE|kbNAWPJ4T#adzi(NrkcMZ2;f6%WZ z8rj!?n{Wxr^NU!L#xWXl@M-MCtEllMw&M?!6fT`mJ!mb;LF-X6wmEP&O2%@5$8ZY$ zv&dc4mB1Up`27jIf0=j`OuRsOr*BYF`5wubYNWPOrL<5NQJM1B!iXaKhRbvs3#e^W z`EmPMz~M5F^VM8>l1_Oi@*4PotNBz(=|Zaf5#*Bc8<|0EqLL6lQjL;9DYL5hnUSa^ zqf+XrGyPUITeGFzV#{w0)D}W9_rKzPJ#N*AxHme34@|d57X}{`E2s26M<3ETCu1b{ z`_XMRvqliGRUOEigMF1#e#BAT?c{rku4kkxCzHCzk2{&RXm#hETzBPURNJ|ZKSOW+ X*ZcV+^c7>Th02YQC&QtCja>Q(1gz-x diff --git a/src/messaging/locale/pl/LC_MESSAGES/django.po b/src/messaging/locale/pl/LC_MESSAGES/django.po index 921a64ac1..08edb0147 100644 --- a/src/messaging/locale/pl/LC_MESSAGES/django.po +++ b/src/messaging/locale/pl/LC_MESSAGES/django.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-01-29 10:51+0100\n" -"PO-Revision-Date: 2020-01-29 10:52+0100\n" +"POT-Creation-Date: 2020-01-29 13:25+0100\n" +"PO-Revision-Date: 2020-01-29 13:25+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: pl\n" @@ -38,135 +38,139 @@ msgid "You have no email set. Test e-mail not sent." msgstr "" "Nie masz ustawionego adresu e-mail. Wiadomość testowa nie została wysłana." -#: models.py:15 +#: models.py:14 msgid "state" msgstr "stan" -#: models.py:16 models.py:104 +#: models.py:15 models.py:148 msgid "subject" msgstr "temat" -#: models.py:17 models.py:105 +#: models.py:16 models.py:149 msgid "body" msgstr "treść" -#: models.py:18 +#: models.py:17 msgid "min days since" msgstr "dni po, od" -#: models.py:19 +#: models.py:18 msgid "max days since" msgstr "dni po, do" -#: models.py:20 +#: models.py:19 msgid "min hour" msgstr "od godziny" -#: models.py:21 +#: models.py:20 msgid "max hour" msgstr "do godziny" -#: models.py:22 +#: models.py:21 msgid "min day of month" msgstr "od dnia miesiąca" -#: models.py:23 +#: models.py:22 msgid "max day of month" msgstr "do dnia miesiąca" -#: models.py:24 +#: models.py:23 msgid "Monday" msgstr "poniedziałek" -#: models.py:25 +#: models.py:24 msgid "Tuesday" msgstr "wtorek" -#: models.py:26 +#: models.py:25 msgid "Wednesday" msgstr "środa" -#: models.py:27 +#: models.py:26 msgid "Thursday" msgstr "czwartek" -#: models.py:28 +#: models.py:27 msgid "Friday" msgstr "piątek" -#: models.py:29 +#: models.py:28 msgid "Saturday" msgstr "sobota" -#: models.py:30 +#: models.py:29 msgid "Sunday" msgstr "niedziela" -#: models.py:31 +#: models.py:30 msgid "active" msgstr "aktywny" -#: models.py:34 +#: models.py:33 msgid "email template" msgstr "szablon e-maila" -#: models.py:35 +#: models.py:34 msgid "email templates" msgstr "szablony e-maili" -#: models.py:103 -msgid "e-mail" -msgstr "e-mail" - -#: models.py:108 -msgid "email sent" -msgstr "wysłany e-mail" - -#: models.py:109 -msgid "emails sent" -msgstr "wysłane e-maile" +#: models.py:95 +msgid "Cold" +msgstr "Lodówka" -#: models.py:126 +#: models.py:96 msgid "Would-be donor" msgstr "Niedoszły darczyńca" -#: models.py:127 +#: models.py:97 msgid "One-time donor" msgstr "Darczyńca z jednorazową wpłatą" -#: models.py:128 +#: models.py:98 msgid "Recurring donor" msgstr "Darczyńca z wpłatą cykliczną" -#: models.py:129 -msgid "Cold" -msgstr "Lodówka" - -#: models.py:130 +#: models.py:99 msgid "Opt out" msgstr "Opt-out" -#: states.py:65 +#: models.py:106 +msgid "contact" +msgstr "kontakt" + +#: models.py:107 +msgid "contacts" +msgstr "kontakty" + +#: models.py:152 +msgid "email sent" +msgstr "wysłany e-mail" + +#: models.py:153 +msgid "emails sent" +msgstr "wysłane e-maile" + +#: states.py:68 msgid "club one-time donors" msgstr "darczyńcy z jednorazową wpłatą" -#: states.py:71 +#: states.py:76 msgid "club one-time donors with donation expiring" msgstr "darczyńcy z wygasającą jednorazową wpłatą" -#: states.py:86 +#: states.py:83 msgid "club would-be donors" msgstr "niedoszli darczyńcy" -#: states.py:98 +#: states.py:89 msgid "club recurring donors" msgstr "darczyńcy z wpłatą cykluczną" -#: states.py:103 +#: states.py:96 msgid "club recurring donors with donation expired" msgstr "darczyńcy z wygasającą wpłatą cykliczną" -#: states.py:112 +#: states.py:103 msgid "cold group" msgstr "lodówka" @@ -176,22 +180,32 @@ msgstr "" "Odwiedź ten adres, jeśli nie chcesz, byśmy się kontaktowali z Tobą w " "przyszłości:" -#: views.py:20 +#: views.py:14 #, python-format -msgid "" -"Context:
\n" -" {{ %(model_name)s }} – a " -"%(verbose_name)s object.
\n" -" You can put it in in the fields Subject and Body " -"using dot notation, like this:
\n" -" {{ %(model_name)s.id }}." -msgstr "" -"Kontest:
\n" -" {{ %(model_name)s }} – obiekt typu %(verbose_name)s.
\n" -" Możesz użyć go w polach Temat i Treść, korzystając " -"z notacji z kropką, np.:
\n" -" {{ %(model_name)s.id }}." +msgid "a %(verbose_name)s object." +msgstr "obiekt typu %(verbose_name)s." + +#: views.py:34 +msgid "Context" +msgstr "Kontekst" + +#~ msgid "e-mail" +#~ msgstr "e-mail" + +#~ msgid "" +#~ "Context:
\n" +#~ " {{ %(model_name)s }} – a " +#~ "%(verbose_name)s object.
\n" +#~ " You can put it in in the fields Subject and Body " +#~ "using dot notation, like this:
\n" +#~ " {{ %(model_name)s.id }}." +#~ msgstr "" +#~ "Kontest:
\n" +#~ " {{ %(model_name)s }} – obiekt typu %(verbose_name)s.
\n" +#~ " Możesz użyć go w polach Temat i Treść, " +#~ "korzystając z notacji z kropką, np.:
\n" +#~ " {{ %(model_name)s.id }}." #~ msgid "days" #~ msgstr "dni" diff --git a/src/messaging/migrations/0005_auto_20200129_1309.py b/src/messaging/migrations/0005_auto_20200129_1309.py new file mode 100644 index 000000000..c1020f889 --- /dev/null +++ b/src/messaging/migrations/0005_auto_20200129_1309.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2.9 on 2020-01-29 12:09 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('messaging', '0004_auto_20200129_1035'), + ] + + operations = [ + migrations.RemoveField( + model_name='emailsent', + name='email', + ), + migrations.RemoveField( + model_name='emailsent', + name='hash_value', + ), + migrations.AddField( + model_name='emailsent', + name='contact', + field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to='messaging.Contact'), + preserve_default=False, + ), + migrations.AlterField( + model_name='contact', + name='level', + field=models.PositiveSmallIntegerField(choices=[(10, 'Cold'), (20, 'Would-be donor'), (30, 'One-time donor'), (40, 'Recurring donor'), (50, 'Opt out')]), + ), + ] diff --git a/src/messaging/models.py b/src/messaging/models.py index 24cf623f1..8ef6c4c0f 100644 --- a/src/messaging/models.py +++ b/src/messaging/models.py @@ -7,8 +7,7 @@ from django.urls import reverse from django.utils.translation import ugettext_lazy as _ from sentry_sdk import capture_exception from catalogue.utils import get_random_hash -from .recipient import Recipient -from .states import states +from .states import Level, states class EmailTemplate(models.Model): @@ -38,101 +37,78 @@ class EmailTemplate(models.Model): return '%s (%+d)' % (self.get_state_display(), self.min_days_since or 0) def run(self, time=None, verbose=False, dry_run=False): - state = self.get_state() - recipients = state(time=time, offset=self.days).get_recipients() - hash_values = set(recipient.hash_value for recipient in recipients) - sent = set(EmailSent.objects.filter( - template=self, hash_value__in=hash_values - ).values_list('hash_value', flat=True)) - for recipient in recipients: - if recipient.hash_value in sent: - continue - self.send(recipient, verbose=verbose, dry_run=dry_run) - - def get_state(self): + state = self.get_state(time=time) + contacts = state.get_contacts() + + contacts = contacts.exclude(emailsent_set__template=self) + for contact in contacts: + self.send(contact, verbose=verbose, dry_run=dry_run) + + def get_state(self, time=None, test=False): for s in states: if s.slug == self.state: - return s + return s( + time=time, + min_days_since=self.min_days_since, + max_days_since=self.max_days_since, + test=test + ) raise ValueError('Unknown state', s.state) - def send(self, recipient, verbose=False, dry_run=False, test=False): - ctx = Context(recipient.context) - - if test: - contact = Contact(email=recipient.email, key='test') - else: - # TODO: actually, we should just use Contacts instead of recipients. - contact = Contact.objects.get(email=recipient.email) - + def send(self, contact, verbose=False, dry_run=False, test=False): + state = self.get_state(test=test) + ctx = state.get_context(contact) ctx['contact'] = contact + ctx = Context(ctx) subject = Template(self.subject).render(ctx) - if test: subject = "[test] " + subject body_template = '{% extends "messaging/email_body.html" %}{% block body %}' + self.body + '{% endblock %}' - body = Template(body_template).render(ctx) + if verbose: - print(recipient.email, subject) + print(contact.email, subject) if not dry_run: try: - send_mail(subject, body, settings.CONTACT_EMAIL, [recipient.email], fail_silently=False) + send_mail(subject, body, settings.CONTACT_EMAIL, [contact.email], fail_silently=False) except: capture_exception() else: if not test: self.emailsent_set.create( - hash_value=recipient.hash_value, - email=recipient.email, + contact=contact, subject=subject, body=body, ) def send_test_email(self, email): - state = self.get_state()() - recipient = state.get_example_recipient(email) - self.send(recipient, test=True) - - -class EmailSent(models.Model): - template = models.ForeignKey(EmailTemplate, models.CASCADE) - hash_value = models.CharField(max_length=1024) - timestamp = models.DateTimeField(auto_now_add=True) - email = models.CharField(_('e-mail'), max_length=1024) - subject = models.CharField(_('subject'), max_length=1024) - body = models.TextField(_('body')) - - class Meta: - verbose_name = _('email sent') - verbose_name_plural = _('emails sent') - ordering = ('-timestamp',) - - def __str__(self): - return '%s %s' % (self.email, self.timestamp) + contact = Contact( + email=email, + key='test' + ) + self.send(contact, test=True) class Contact(models.Model): - COLD = 10 - TRIED = 20 - SINGLE = 30 - RECURRING = 40 - OPT_OUT = 50 - email = models.EmailField(unique=True) level = models.PositiveSmallIntegerField( choices=[ - (TRIED, _('Would-be donor')), - (SINGLE, _('One-time donor')), - (RECURRING, _('Recurring donor')), - (COLD, _('Cold')), - (OPT_OUT, _('Opt out')), + (Level.COLD, _('Cold')), + (Level.TRIED, _('Would-be donor')), + (Level.SINGLE, _('One-time donor')), + (Level.RECURRING, _('Recurring donor')), + (Level.OPT_OUT, _('Opt out')), ]) since = models.DateTimeField() expires_at = models.DateTimeField(null=True, blank=True) key = models.CharField(max_length=64, blank=True) + class Meta: + verbose_name = _('contact') + verbose_name_plural = _('contacts') + def save(self, *args, **kwargs): if not self.key: self.key = get_random_hash(self.email) @@ -167,3 +143,19 @@ class Contact(models.Model): self.expires_at = expires_at self.save() + +class EmailSent(models.Model): + template = models.ForeignKey(EmailTemplate, models.CASCADE) + contact = models.ForeignKey(Contact, models.CASCADE) + timestamp = models.DateTimeField(auto_now_add=True) + subject = models.CharField(_('subject'), max_length=1024) + body = models.TextField(_('body')) + + class Meta: + verbose_name = _('email sent') + verbose_name_plural = _('emails sent') + ordering = ('-timestamp',) + + def __str__(self): + return '%s %s' % (self.email, self.timestamp) + diff --git a/src/messaging/recipient.py b/src/messaging/recipient.py deleted file mode 100644 index e1e83132d..000000000 --- a/src/messaging/recipient.py +++ /dev/null @@ -1,6 +0,0 @@ -class Recipient: - def __init__(self, email, hash_value, context): - self.email = email - self.hash_value = hash_value - self.context = context - diff --git a/src/messaging/states.py b/src/messaging/states.py index 44246e0f6..c0f3c7ef2 100644 --- a/src/messaging/states.py +++ b/src/messaging/states.py @@ -1,115 +1,153 @@ from datetime import timedelta +from django.apps import apps from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ -from .recipient import Recipient + + +class Level: + COLD = 10 + TRIED = 20 + SINGLE = 30 + RECURRING = 40 + OPT_OUT = 50 class State: allow_negative_offset = False - context_fields = [] - + level = None + expired = None - def __init__(self, time=None, min_days_since=None, max_days_since=None): + def __init__(self, time=None, min_days_since=None, max_days_since=None, test=False): self.time = time or now() self.min_days_since = min_days_since self.max_days_since = max_days_since + self.test = test + + def get_contacts(self): + Contact = apps.get_model('messaging', 'Contact') + contacts = Contact.objects.filter(level=self.level) + + if self.min_days_since is not None or self.expired: + cutoff = self.time - timedelta(self.min_days_since or 0) + if self.expired: + contacts = contacts.filter(expires_at__lt=cutoff) + else: + contacts = contacts.filter(since__lt=cutoff) + + if self.max_days_since is not None: + cutoff = self.time - timedelta(self.max_days_since) + if self.expired: + contacts = contacts.filter(expires_at__gt=cutoff) + else: + contacts = contacts.filter(since__gt=cutoff) + + if self.expired is False: + contacts = contacts.exclude(expires_at__lt=self.time) + + return contacts + + def get_context(self, contact): + if self.test: + return self.get_example_context(contact) + + Schedule = apps.get_model('club', 'Schedule') + schedules = Schedule.objects.filter(email=contact.email) + return { + "schedule": self.get_schedule(schedules) + } - def get_recipients(self): - return [ - self.get_recipient(obj) - for obj in self.get_objects() - ] - - def get_recipient(self, obj): - return Recipient( - hash_value=self.get_hash_value(obj), - email=self.get_email(obj), - context=self.get_context(obj), - ) - - def get_example_recipient(self, email): - return self.get_recipient( - self.get_example_object(email) - ) - - def get_example_object(self, email): - from club.models import Schedule - n = now() - return Schedule( - email=email, + def get_example_context(self, contact): + Schedule = apps.get_model('club', 'Schedule') + return { + "schedule": Schedule( + email=contact.email, key='xxxxxxxxx', amount=100, - payed_at=n - timedelta(2), - started_at=n - timedelta(1), - expires_at=n + timedelta(1), + payed_at=self.time - timedelta(2), + started_at=self.time - timedelta(1), + expires_at=self.time + timedelta(1), ) - - def get_objects(self): - raise NotImplemented - - def get_hash_value(self, obj): - return str(obj.pk) - - def get_email(self, obj): - return obj.email - - def get_context(self, obj): - ctx = { - obj._meta.model_name: obj, } - return ctx class ClubSingle(State): slug = 'club-single' name = _('club one-time donors') + level = Level.SINGLE + expired = False + + def get_schedule(self, schedules): + # Find first single non-expired schedule. + return schedules.filter( + monthly=False, yearly=False, + expires_at__gt=self.time + ).order_by('started_at').first() class ClubSingleExpired(State): slug = 'club-membership-expiring' allow_negative_offset = True name = _('club one-time donors with donation expiring') + level = Level.SINGLE + expired = True - def get_objects(self): - from club.models import Schedule - return Schedule.objects.filter( - is_active=True, - expires_at__lt=self.time - self.offset - ) - - def get_hashed_value(self, obj): - return '%s:%s' % (obj.pk, obj.expires_at.isoformat()) + def get_schedule(self, schedules): + # Find last single expired schedule. + return schedules.filter( + monthly=False, yearly=False, + expires_at__lt=self.time + ).order_by('-expires_at').first() class ClubTried(State): slug = 'club-payment-unfinished' name = _('club would-be donors') + level = Level.TRIED - def get_objects(self): - from club.models import Schedule - return Schedule.objects.filter( - payuorder=None, - started_at__lt=self.time - self.offset, - ) + def get_schedule(self, schedules): + # Find last unpaid schedule. + return schedules.filter( + payed_at=None + ).order_by('-started_at').first() class ClubRecurring(State): slug = 'club-recurring' name = _('club recurring donors') + level = Level.RECURRING + expired = False + + def get_schedule(self, schedules): + # Find first recurring non-expired schedule. + return schedules.exclude( + monthly=False, yearly=False + ).filter( + expires_at__gt=self.time + ).order_by('started_at').first() class ClubRecurringExpired(State): slug = 'club-recurring-payment-problem' name = _('club recurring donors with donation expired') + level = Level.RECURRING + expired = True - def get_objects(self): - from club.models import Schedule - return Schedule.objects.none() + def get_schedule(self, schedules): + # Find last recurring expired schedule. + return schedules.exclude( + monthly=False, yearly=False + ).filter( + expires_at__lt=self.time + ).order_by('-expires_at').first() class Cold(State): slug = 'cold' name = _('cold group') + level = Level.COLD + + def get_context(self, contact): + return {} states = [ diff --git a/src/messaging/views.py b/src/messaging/views.py index f3881bb61..6ea1a4098 100644 --- a/src/messaging/views.py +++ b/src/messaging/views.py @@ -3,11 +3,24 @@ from django.http import JsonResponse from django.urls import reverse from django.shortcuts import render from django.utils.translation import ugettext as _ +from django.views.decorators import cache from django.views.generic import UpdateView from . import models from .states import states +def describe(value): + if hasattr(value, '_meta'): + meta = value._meta + return _('''a %(verbose_name)s object.''') % { + 'docs_url': reverse('django-admindocs-models-detail', args=(meta.app_label, meta.model_name)), + 'verbose_name': meta.verbose_name, + } + else: + return type(value).__name__ + + +@cache.never_cache def state_info(request, slug): for state in states: if state.slug == slug: @@ -15,16 +28,14 @@ def state_info(request, slug): else: return JsonResponse({}) - meta = state().get_example_object('').model._meta - - help_text = _('''Context:
- {{ %(model_name)s }} – a %(verbose_name)s object.
- You can put it in in the fields Subject and Body using dot notation, like this:
- {{ %(model_name)s.id }}.''') % { - 'model_name': meta.model_name, - 'docs_url': reverse('django-admindocs-models-detail', args=(meta.app_label, meta.model_name)), - 'verbose_name': meta.verbose_name, - } + contact = models.Contact() + ctx = { + "contact": contact, + } + ctx.update(state(test=True).get_context(contact)) + help_text = '%s:
' % _('Context') + for k, v in ctx.items(): + help_text += '
{{ %s }} — %s
' % (k, describe(v)) return JsonResponse({ "help": help_text, -- 2.20.1