From e852900e42eb0bde98daf7070d84816e44c3308e Mon Sep 17 00:00:00 2001
From: Radek Czajka <rczajka@rczajka.pl>
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)<Yx(zx~>H;V4IHv)VzCG9;P!x
zX8@N_50IulvK{pR-FOhsU>5IV9-pB~{uQ<05^COWAFrY+xaKxcMpdxY%_XS+RuY3G
zxW<Ovi>Q3T$G6;R<h9!)RB30BiiVBvE4;ZLBtz^8v+IZbpa)aacdfOuguXx90X;;D
z&~7L~SRO6ZPN;Pf>9A+kh+Txff&Wdf?|=_><32(gP-`YsMg6-rwr&P(gB<n}df(bn
zE3uQ<*z^wBXjn=c8XqfgxDgcZ1l0$ze55iL?W@ezyi1OfyfPY?J5edrF2@cZJJer2
t9iNOuOA|qLq5f?oF@E)0u@q#3shg9`?XBES{79BGw-lH=RT*h~{tu`FV}k$y

delta 1391
zcmbW$Ux-Xm9Ki82%r1uYXRSZ$-yX}_#q6$V@~3H%eWFP5Knd5(#hsnGcg($m*-e8;
zo|H+-N-2;2l_jjl+44Z~fEUS%q<Hcmp?rUL?#ANDY0iAk-+RvQcW2Je_|#Z>RUba5
zC~ed^)EyzEVt8>BUzFTvrNVds>zF%;6X~B|uolaK7f{A8;~2b&lkhH%#fLZ*pQ7x0
zhpkGL)W={!Lrrz#T<l^&3ywnz<-smS@i@u@ZXjFL9c;h>T#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+XrGyPUITeGFz<z!NP*(*Ksu(u|@C!dJuYKbSEl$q%5)h%sVTA5w8ku?{)`pA)Z
zr$43N94+heP&iilecN`Ypp%BJa|KUZd~Er6`%S&qGBYVx$IWbFOJPsiOnUK7+S;&F
z{@^+%RjiH_T+804Q;w%?$1^<3v7<U;b(=iKa{qD1roS%PVWocEv*Xt7(e0t~%$n=9
zW_#AKGUewrL*Zp>V#{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:<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
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 <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,
-- 
2.20.1