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
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)
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
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']
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"
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"
"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"
--- /dev/null
+# 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')]),
+ ),
+ ]
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):
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)
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)
+
+++ /dev/null
-class Recipient:
- def __init__(self, email, hash_value, context):
- self.email = email
- self.hash_value = hash_value
- self.context = context
-
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 = [
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:
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,