From: Radek Czajka Date: Mon, 30 Sep 2019 14:11:38 +0000 (+0200) Subject: Add messaging. X-Git-Url: https://git.mdrn.pl/wolnelektury.git/commitdiff_plain/a7e41fefbf46ad5bfa2eb97dbd1cc11d3a28a354 Add messaging. --- diff --git a/requirements/requirements.txt b/requirements/requirements.txt index ad07ec96f..4a9f0ccb4 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -3,6 +3,7 @@ # django Django==2.2.5 fnpdjango==0.4 +docutils #django-pipeline==1.6.13 # Because of https://github.com/jazzband/django-pipeline/pull/682 + https://github.com/getsentry/sentry-python/issues/436 diff --git a/src/club/locale/pl/LC_MESSAGES/django.mo b/src/club/locale/pl/LC_MESSAGES/django.mo index 921c37a12..f57a55a51 100644 Binary files a/src/club/locale/pl/LC_MESSAGES/django.mo and b/src/club/locale/pl/LC_MESSAGES/django.mo differ diff --git a/src/club/locale/pl/LC_MESSAGES/django.po b/src/club/locale/pl/LC_MESSAGES/django.po index a631b73f5..6207cae31 100644 --- a/src/club/locale/pl/LC_MESSAGES/django.po +++ b/src/club/locale/pl/LC_MESSAGES/django.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2019-03-04 11:36+0100\n" -"PO-Revision-Date: 2019-06-17 15:08+0200\n" +"POT-Creation-Date: 2019-09-30 15:58+0200\n" +"PO-Revision-Date: 2019-09-30 16:09+0200\n" "Last-Translator: \n" "Language-Team: \n" "Language: pl\n" @@ -20,26 +20,30 @@ msgstr "" "%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" "X-Generator: Poedit 2.0.6\n" -#: models.py:25 +#: models.py:24 msgid "a month" msgstr "miesięcznie" -#: models.py:26 +#: models.py:25 msgid "a year" msgstr "raz do roku" -#: models.py:27 +#: models.py:26 msgid "in perpetuity" msgstr "raz na zawsze" -#: models.py:30 +#: models.py:29 msgid "inteval" msgstr "okres" -#: models.py:31 -msgid "min_amount" +#: models.py:30 +msgid "min amount" msgstr "minimalna kwota" +#: models.py:31 +msgid "default amount" +msgstr "domyślna kwota" + #: models.py:32 msgid "allow recurring" msgstr "płatności cykliczne" @@ -48,11 +52,15 @@ msgstr "płatności cykliczne" msgid "allow one time" msgstr "płatności jednorazowe" -#: models.py:36 models.py:69 +#: models.py:34 +msgid "active" +msgstr "aktywny" + +#: models.py:37 models.py:69 msgid "plan" msgstr "plan" -#: models.py:37 +#: models.py:38 msgid "plans" msgstr "plany" @@ -64,7 +72,7 @@ msgstr "klucz" msgid "email" msgstr "email" -#: models.py:68 models.py:123 +#: models.py:68 models.py:129 msgid "membership" msgstr "członkostwo" @@ -77,13 +85,13 @@ msgid "method" msgstr "metoda płatności" #: models.py:72 -msgid "active" -msgstr "aktywny" - -#: models.py:73 msgid "cancelled" msgstr "anulowany" +#: models.py:73 +msgid "payed at" +msgstr "opłacona" + #: models.py:74 msgid "started at" msgstr "start" @@ -92,7 +100,7 @@ msgstr "start" msgid "expires_at" msgstr "wygasa" -#: models.py:79 models.py:105 +#: models.py:79 msgid "schedule" msgstr "harmonogram" @@ -100,51 +108,39 @@ msgstr "harmonogram" msgid "schedules" msgstr "harmonogramy" -#: models.py:106 models.py:120 -msgid "created at" -msgstr "utworzone" - -#: models.py:107 -msgid "payed at" -msgstr "opłacona" - -#: models.py:110 -msgid "payment" -msgstr "płatność" - -#: models.py:111 -msgid "payments" -msgstr "płatności" - -#: models.py:119 +#: models.py:123 msgid "user" msgstr "użytkownik" #: models.py:124 +msgid "created at" +msgstr "utworzone" + +#: models.py:130 msgid "memberships" msgstr "członkostwa" -#: models.py:131 +#: models.py:152 msgid "days before" msgstr "dni przed" -#: models.py:132 +#: models.py:153 msgid "subject" msgstr "temat" -#: models.py:133 +#: models.py:154 payu/models.py:136 msgid "body" msgstr "treść" -#: models.py:136 +#: models.py:157 msgid "reminder email" msgstr "email z przypomnieniem" -#: models.py:137 +#: models.py:158 msgid "reminder emails" msgstr "emaile z przypomnieniem" -#: models.py:142 +#: models.py:163 #, python-format msgid "a day before expiration" msgid_plural "%d days before expiration" @@ -153,7 +149,7 @@ msgstr[1] "%d dni przed wygaśnięciem" msgstr[2] "%d dni przed wygaśnięciem" msgstr[3] "%d dni przed wygaśnięciem" -#: models.py:144 +#: models.py:165 #, python-format msgid "a day after expiration" msgid_plural "%d days after expiration" @@ -161,3 +157,85 @@ msgstr[0] "%d dzień po wygaśnięciu" msgstr[1] "%d dni po wygaśnięciu" msgstr[2] "%d dni po wygaśnięciu" msgstr[3] "%d dni przed wygaśnięciem" + +#: models.py:192 +msgid "Towarzystwo Wolnych Lektur" +msgstr "" + +#: payu/models.py:16 payu/models.py:28 +msgid "POS id" +msgstr "" + +#: payu/models.py:17 +msgid "disposable token" +msgstr "token jednorazowy" + +#: payu/models.py:18 +msgid "reusable token" +msgstr "token wielokrotnego użytku" + +#: payu/models.py:19 +msgid "created_at" +msgstr "utworzone" + +#: payu/models.py:23 +msgid "PayU card token" +msgstr "token PayU karty płatniczej" + +#: payu/models.py:24 +msgid "PayU card tokens" +msgstr "tokeny PayU kart płatniczych" + +#: payu/models.py:29 +msgid "customer IP" +msgstr "adres IP klienta" + +#: payu/models.py:30 +msgid "order ID" +msgstr "ID zamówienia" + +#: payu/models.py:33 +msgid "Pending" +msgstr "Czeka" + +#: payu/models.py:34 +msgid "Waiting for confirmation" +msgstr "Czeka na potwierdzenie" + +#: payu/models.py:35 +msgid "Completed" +msgstr "Ukończone" + +#: payu/models.py:36 +msgid "Canceled" +msgstr "Anulowane" + +#: payu/models.py:37 +msgid "Rejected" +msgstr "Odrzucone" + +#: payu/models.py:42 +msgid "PayU order" +msgstr "zamówienie PayU" + +#: payu/models.py:43 +msgid "PayU orders" +msgstr "zamówienia PayU" + +#: payu/models.py:137 +msgid "received_at" +msgstr "odebrana" + +#: payu/models.py:141 +msgid "PayU notification" +msgstr "notyfikacja PayU" + +#: payu/models.py:142 +msgid "PayU notifications" +msgstr "notyfikacje PayU" + +#~ msgid "payment" +#~ msgstr "płatność" + +#~ msgid "payments" +#~ msgstr "płatności" diff --git a/src/messaging/__init__.py b/src/messaging/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/messaging/admin.py b/src/messaging/admin.py new file mode 100644 index 000000000..1b2bac599 --- /dev/null +++ b/src/messaging/admin.py @@ -0,0 +1,34 @@ +from django.contrib import admin +from .models import EmailTemplate, EmailSent + + +class EmailSentInline(admin.TabularInline): + model = EmailSent + fields = ['timestamp', 'email', 'subject'] + readonly_fields = ['timestamp', 'email', 'subject'] + extra = 0 + can_delete = False + show_change_link = True + + def has_add_permission(self, request, obj): + return False + + +class EmailTemplateAdmin(admin.ModelAdmin): + list_display = ['state', 'days', 'subject', 'hour'] + inlines = [EmailSentInline] + + +admin.site.register(EmailTemplate, EmailTemplateAdmin) + + +class EmailSentAdmin(admin.ModelAdmin): + list_filter = ['template'] + list_display = ['timestamp', 'template', 'email', 'subject'] + fields = ['timestamp', 'template', 'email', 'subject', 'body', 'hash_value'] + readonly_fields = fields + change_links = ['template'] + + +admin.site.register(EmailSent, EmailSentAdmin) + diff --git a/src/messaging/apps.py b/src/messaging/apps.py new file mode 100644 index 000000000..0c0cd1cd4 --- /dev/null +++ b/src/messaging/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class MessagingConfig(AppConfig): + name = 'messaging' diff --git a/src/messaging/locale/pl/LC_MESSAGES/django.mo b/src/messaging/locale/pl/LC_MESSAGES/django.mo new file mode 100644 index 000000000..318b4a710 Binary files /dev/null and b/src/messaging/locale/pl/LC_MESSAGES/django.mo differ diff --git a/src/messaging/locale/pl/LC_MESSAGES/django.po b/src/messaging/locale/pl/LC_MESSAGES/django.po new file mode 100644 index 000000000..d2a3c98f2 --- /dev/null +++ b/src/messaging/locale/pl/LC_MESSAGES/django.po @@ -0,0 +1,95 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2019-09-30 16:09+0200\n" +"PO-Revision-Date: 2019-09-30 16:10+0200\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: pl\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n" +"%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n" +"%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" +"X-Generator: Poedit 2.0.6\n" + +#: models.py:11 +msgid "state" +msgstr "stan" + +#: models.py:12 models.py:68 +msgid "subject" +msgstr "temat" + +#: models.py:13 models.py:69 +msgid "body" +msgstr "treść" + +#: models.py:14 +msgid "days" +msgstr "dni" + +#: models.py:15 +msgid "hour" +msgstr "godzina" + +#: models.py:18 +msgid "email template" +msgstr "szablon e-maila" + +#: models.py:19 +msgid "email templates" +msgstr "szablony e-maili" + +#: models.py:67 +msgid "e-mail" +msgstr "e-mail" + +#: models.py:72 +msgid "email sent" +msgstr "wysłany e-mail" + +#: models.py:73 +msgid "emails sent" +msgstr "wysłane e-maile" + +#: states.py:47 +msgid "club membership expiring" +msgstr "członkostwo w Towarzystwie wygasa" + +#: states.py:61 +msgid "club payment unfinished" +msgstr "niedokończona płatność w Towarzystwie" + +#: views.py:19 +#, 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 "New member" +#~ msgstr "Nowy członek" + +#~ msgid "event" +#~ msgstr "zdarzenie" + +#~ msgid "Select an event to see more details." +#~ msgstr "Wybierz zdarzenie by zobaczyć więcej szczegółów." diff --git a/src/messaging/management/__init__.py b/src/messaging/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/messaging/management/commands/__init__.py b/src/messaging/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/messaging/management/commands/messaging_send.py b/src/messaging/management/commands/messaging_send.py new file mode 100644 index 000000000..6fb4a696c --- /dev/null +++ b/src/messaging/management/commands/messaging_send.py @@ -0,0 +1,14 @@ +from django.core.management.base import BaseCommand, CommandError +from messaging.models import EmailTemplate + + +class Command(BaseCommand): + help = 'Send emails defined in templates.' + + def add_arguments(self, parser): + parser.add_argument('--dry-run', action='store_true', help='Dry run') + + def handle(self, *args, **options): + for et in EmailTemplate.objects.all(): + et.run(verbose=True, dry_run=options['dry_run']) + diff --git a/src/messaging/migrations/0001_initial.py b/src/messaging/migrations/0001_initial.py new file mode 100644 index 000000000..9af97607c --- /dev/null +++ b/src/messaging/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 2.2.5 on 2019-09-30 13:11 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='EmailTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('state', models.CharField(choices=[('club-membership-expiring', 'club membership expiring'), ('club-payment-unfinished', 'club payment unfinished')], help_text='?', max_length=128, verbose_name='state')), + ('subject', models.CharField(max_length=1024, verbose_name='subject')), + ('body', models.TextField(verbose_name='body')), + ('days', models.SmallIntegerField(blank=True, null=True, verbose_name='days')), + ('hour', models.IntegerField(blank=True, null=True, verbose_name='hour')), + ], + options={ + 'verbose_name': 'email template', + 'verbose_name_plural': 'email templates', + }, + ), + migrations.CreateModel( + name='EmailSent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('hash_value', models.CharField(max_length=1024)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('email', models.CharField(max_length=1024, verbose_name='e-mail')), + ('subject', models.CharField(max_length=1024, verbose_name='subject')), + ('body', models.TextField(verbose_name='body')), + ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='messaging.EmailTemplate')), + ], + options={ + 'verbose_name': 'email sent', + 'verbose_name_plural': 'emails sent', + 'ordering': ('-timestamp',), + }, + ), + ] diff --git a/src/messaging/migrations/__init__.py b/src/messaging/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/messaging/models.py b/src/messaging/models.py new file mode 100644 index 000000000..feb5daaa4 --- /dev/null +++ b/src/messaging/models.py @@ -0,0 +1,77 @@ +from django.conf import settings +from django.core.mail import send_mail +from django.db import models +from django.template import Template, Context +from django.utils.translation import ugettext_lazy as _ +from sentry_sdk import capture_exception +from .states import states + + +class EmailTemplate(models.Model): + state = models.CharField(_('state'), max_length=128, choices=[(s.slug, s.name) for s in states], help_text='?') + subject = models.CharField(_('subject'), max_length=1024) + body = models.TextField(_('body')) + days = models.SmallIntegerField(_('days'), null=True, blank=True) + hour = models.IntegerField(_('hour'), null=True, blank=True) + + class Meta: + verbose_name = _('email template') + verbose_name_plural = _('email templates') + + def __str__(self): + return '%s (%+d)' % (self.get_state_display(), self.days) + return self.subject + + 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): + for s in states: + if s.slug == self.state: + return s + raise ValueError('Unknown state', s.state) + + def send(self, recipient, verbose=False, dry_run=False): + subject = Template(self.subject).render(Context(recipient.context)) + body = Template(self.body).render(Context(recipient.context)) + if verbose: + print(recipient.email, subject) + if not dry_run: + try: + send_mail(subject, body, settings.CONTACT_EMAIL, [recipient.email], fail_silently=False) + except: + capture_exception() + else: + self.emailsent_set.create( + hash_value=recipient.hash_value, + email=recipient.email, + subject=subject, + body=body, + ) + + + +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) diff --git a/src/messaging/recipient.py b/src/messaging/recipient.py new file mode 100644 index 000000000..e1e83132d --- /dev/null +++ b/src/messaging/recipient.py @@ -0,0 +1,6 @@ +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 new file mode 100644 index 000000000..317be2170 --- /dev/null +++ b/src/messaging/states.py @@ -0,0 +1,75 @@ +from datetime import timedelta +from django.utils.timezone import now +from django.utils.translation import ugettext_lazy as _ +from .recipient import Recipient + + +class State: + allow_negative_offset = False + context_fields = [] + + + def __init__(self, offset=0, time=None): + self.time = time or now() + if isinstance(offset, int): + offset = timedelta(offset) + self.offset = offset + + def get_recipients(self): + return [ + Recipient( + hash_value=self.get_hash_value(obj), + email=self.get_email(obj), + context=self.get_context(obj), + ) + for obj in self.get_objects() + ] + + 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 ClubMembershipExpiring(State): + slug = 'club-membership-expiring' + allow_negative_offset = True + name = _('club membership expiring') + + 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()) + +class ClubPaymentUnfinished(State): + slug = 'club-payment-unfinished' + name = _('club payment unfinished') + + def get_objects(self): + from club.models import Schedule + return Schedule.objects.filter( + payuorder=None, + started_at__lt=self.time - self.offset, + ) + + +states = [ + ClubMembershipExpiring, + ClubPaymentUnfinished, +] + diff --git a/src/messaging/templates/admin/messaging/emailtemplate/change_form.html b/src/messaging/templates/admin/messaging/emailtemplate/change_form.html new file mode 100644 index 000000000..89c95d21b --- /dev/null +++ b/src/messaging/templates/admin/messaging/emailtemplate/change_form.html @@ -0,0 +1,19 @@ +{% extends 'admin/change_form.html' %} + + +{% block admin_change_form_document_ready %} +{{ block.super }} + + + +{% endblock %} diff --git a/src/messaging/urls.py b/src/messaging/urls.py new file mode 100644 index 000000000..8f1fc1f85 --- /dev/null +++ b/src/messaging/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from . import views + + +urlpatterns = [ + path('states//info.json', views.state_info), +] diff --git a/src/messaging/views.py b/src/messaging/views.py new file mode 100644 index 000000000..be83d95f2 --- /dev/null +++ b/src/messaging/views.py @@ -0,0 +1,31 @@ +import json +from django.http import JsonResponse +from django.urls import reverse +from django.shortcuts import render +from django.utils.translation import ugettext as _ +from .states import states + + +def state_info(request, slug): + for state in states: + if state.slug == slug: + break + else: + return JsonResponse({}) + + meta = state().get_objects().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, + } + + return JsonResponse({ + "help": help_text, + }) + diff --git a/src/wolnelektury/settings/apps.py b/src/wolnelektury/settings/apps.py index fa2f82f31..5b55b049e 100644 --- a/src/wolnelektury/settings/apps.py +++ b/src/wolnelektury/settings/apps.py @@ -12,6 +12,7 @@ INSTALLED_APPS_OUR = [ 'dictionary', 'infopages', 'lesmianator', + 'messaging', 'newtagging', 'opds', 'pdcounter', diff --git a/src/wolnelektury/urls.py b/src/wolnelektury/urls.py index 6188658cd..c328c83f5 100644 --- a/src/wolnelektury/urls.py +++ b/src/wolnelektury/urls.py @@ -44,6 +44,7 @@ urlpatterns += [ url(r'^newsletter/', include('newsletter.urls')), url(r'^formularz/', include('forms_builder.forms.urls')), url(r'^isbn/', include('isbn.urls')), + url(r'^messaging/', include('messaging.urls')), url(r'^paypal/app-form/$', RedirectView.as_view( url='/towarzystwo/?app=1', permanent=False)),