Small refactor in messaging.
authorRadek Czajka <rczajka@rczajka.pl>
Wed, 29 Jan 2020 12:57:33 +0000 (13:57 +0100)
committerRadek Czajka <rczajka@rczajka.pl>
Wed, 29 Jan 2020 12:57:33 +0000 (13:57 +0100)
src/club/models.py
src/messaging/admin.py
src/messaging/locale/pl/LC_MESSAGES/django.mo
src/messaging/locale/pl/LC_MESSAGES/django.po
src/messaging/migrations/0005_auto_20200129_1309.py [new file with mode: 0644]
src/messaging/models.py
src/messaging/recipient.py [deleted file]
src/messaging/states.py
src/messaging/views.py

index f36aada..6fd58e0 100644 (file)
@@ -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)
 
 
index 558c34d..9ac76b5 100644 (file)
@@ -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']
 
index 415c59b..119350a 100644 (file)
Binary files a/src/messaging/locale/pl/LC_MESSAGES/django.mo and b/src/messaging/locale/pl/LC_MESSAGES/django.mo differ
index 921a64a..08edb01 100644 (file)
@@ -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:<br>\n"
-"       <code>{{ %(model_name)s }}</code> – a <a href=\"%(docs_url)s\">"
-"%(verbose_name)s</a> object.<br>\n"
-"       You can put it in in the fields <em>Subject</em> and <em>Body</em> "
-"using dot notation, like this:<br>\n"
-"       <code>{{ %(model_name)s.id }}</code>."
-msgstr ""
-"Kontest:<br>\n"
-"       <code>{{ %(model_name)s }}</code> – obiekt typu <a href=\"%(docs_url)s"
-"\">%(verbose_name)s</a>.<br>\n"
-"       Możesz użyć go w polach <em>Temat</em> i <em>Treść</em>, korzystając "
-"z notacji z kropką, np.:<br>\n"
-"       <code>{{ %(model_name)s.id }}</code>."
+msgid "a <a href=\"%(docs_url)s\">%(verbose_name)s</a> object."
+msgstr "obiekt typu <a href=\"%(docs_url)s\">%(verbose_name)s</a>."
+
+#: views.py:34
+msgid "Context"
+msgstr "Kontekst"
+
+#~ msgid "e-mail"
+#~ msgstr "e-mail"
+
+#~ msgid ""
+#~ "Context:<br>\n"
+#~ "       <code>{{ %(model_name)s }}</code> – a <a href=\"%(docs_url)s\">"
+#~ "%(verbose_name)s</a> object.<br>\n"
+#~ "       You can put it in in the fields <em>Subject</em> and <em>Body</em> "
+#~ "using dot notation, like this:<br>\n"
+#~ "       <code>{{ %(model_name)s.id }}</code>."
+#~ msgstr ""
+#~ "Kontest:<br>\n"
+#~ "       <code>{{ %(model_name)s }}</code> – obiekt typu <a href="
+#~ "\"%(docs_url)s\">%(verbose_name)s</a>.<br>\n"
+#~ "       Możesz użyć go w polach <em>Temat</em> i <em>Treść</em>, "
+#~ "korzystając z notacji z kropką, np.:<br>\n"
+#~ "       <code>{{ %(model_name)s.id }}</code>."
 
 #~ 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 (file)
index 0000000..c1020f8
--- /dev/null
@@ -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')]),
+        ),
+    ]
index 24cf623..8ef6c4c 100644 (file)
@@ -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 (file)
index e1e8313..0000000
+++ /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
-
index 44246e0..c0f3c7e 100644 (file)
 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 = [
index f3881bb..6ea1a40 100644 (file)
@@ -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 <a href="%(docs_url)s">%(verbose_name)s</a> 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:<br>
-       <code>{{ %(model_name)s }}</code> – a <a href="%(docs_url)s">%(verbose_name)s</a> object.<br>
-       You can put it in in the fields <em>Subject</em> and <em>Body</em> using dot notation, like this:<br>
-       <code>{{ %(model_name)s.id }}</code>.''') % {
-               '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:<br>' % _('Context')
+    for k, v in ctx.items():
+        help_text += '<br><code>{{ %s }}</code> — %s<br>' % (k, describe(v))
 
     return JsonResponse({
         "help": help_text,