diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fb7b302fb9dc90f83ead0c4a407ea26845143523..e5bbd752f65866e203629307d56e12045d9f5312 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,11 @@ and this project adheres to `Semantic Versioning`_. Unreleased ---------- +Added +----- + +* Info mailings + Fixed ----- diff --git a/aleksis/apps/paweljong/forms.py b/aleksis/apps/paweljong/forms.py index 2d38289db8ce18dd09b8771e35cd9484458ffea2..46dade3deda9d17afca981ec628b68591465ef85 100644 --- a/aleksis/apps/paweljong/forms.py +++ b/aleksis/apps/paweljong/forms.py @@ -11,7 +11,7 @@ from phonenumber_field.formfields import PhoneNumberField from aleksis.core.mixins import ExtensibleForm from aleksis.core.models import Group, Person -from .models import Event, EventRegistration, Terms, Voucher +from .models import Event, EventRegistration, InfoMailing, Terms, Voucher COMMENT_CHOICES = [ ("first", _("Only first name")), @@ -49,6 +49,7 @@ class EditEventForm(ExtensibleForm): Fieldset(_("Date data"), Row("date_event", "date_registration", "date_retraction")), Fieldset(_("Event details"), Row("cost", "max_participants"), "information"), Fieldset(_("Terms"), "terms"), + Fieldset(_("Info mailings"), "info_mailings"), ), ) @@ -68,6 +69,7 @@ class EditEventForm(ExtensibleForm): "max_participants", "terms", "information", + "info_mailings", ] widgets = { "linked_group": ModelSelect2Widget( @@ -78,6 +80,10 @@ class EditEventForm(ExtensibleForm): search_fields=["aspect__icontains"], attrs={"data-minimum-input-length": 0, "class": "browser-default"}, ), + "info_mailings": ModelSelect2MultipleWidget( + search_fields=["subject__icontains"], + attrs={"data-minimum-input-length": 0, "class": "browser-default"}, + ), } @@ -456,3 +462,17 @@ class RegisterEventAccount(SignupForm, ExtensibleForm): "The username must only contain lower case letters and numbers, " "and must begin with a letter." ) + + +class EditInfoMailingForm(forms.ModelForm): + + layout = Layout( + Row("sender", "reply_to", "active"), + Row("send_to_person", "send_to_guardians"), + Row("subject"), + Row("text"), + ) + + class Meta: + model = InfoMailing + exclude = ["sent_to"] diff --git a/aleksis/apps/paweljong/menus.py b/aleksis/apps/paweljong/menus.py index 4655f1535de29effb42a1a143c93acf6b601d60a..afe3056df07a741f0bd94d2bb1db4a015c532907 100644 --- a/aleksis/apps/paweljong/menus.py +++ b/aleksis/apps/paweljong/menus.py @@ -44,6 +44,17 @@ MENUS = { ) ], }, + { + "name": _("Info mailings"), + "url": "info_mailings", + "icon": "info", + "validators": [ + ( + "aleksis.core.util.predicates.permission_validator", + "paweljong.view_info_mailings_rule", + ) + ], + }, { "name": _("Generate participant list"), "url": "generate_lists", diff --git a/aleksis/apps/paweljong/migrations/0013_info_mailings.py b/aleksis/apps/paweljong/migrations/0013_info_mailings.py new file mode 100644 index 0000000000000000000000000000000000000000..f9bc02aa4ce93962f24dc134d7c1f21bf9e7bc94 --- /dev/null +++ b/aleksis/apps/paweljong/migrations/0013_info_mailings.py @@ -0,0 +1,50 @@ +# Generated by Django 3.2.12 on 2022-03-01 15:19 + +import ckeditor.fields +import django.contrib.sites.managers +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0037_alter_personinvitation_id'), + ('sites', '0002_alter_domain_unique'), + ('paweljong', '0012_event_slug'), + ] + + operations = [ + migrations.AlterField( + model_name='eventregistration', + name='event', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='registrations', to='paweljong.event', verbose_name='Event'), + ), + migrations.CreateModel( + name='InfoMailing', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extended_data', models.JSONField(default=dict, editable=False)), + ('subject', models.CharField(max_length=255, verbose_name='subject')), + ('text', ckeditor.fields.RichTextField(verbose_name='Text')), + ('reply_to', models.EmailField(blank=True, max_length=254, verbose_name='Request replies to')), + ('active', models.BooleanField(default=False, verbose_name='Mailing is active')), + ('sender', models.EmailField(blank=True, max_length=254, verbose_name='Sender')), + ('send_to_person', models.BooleanField(default=True, verbose_name='Send to registered person')), + ('send_to_guardians', models.BooleanField(default=False, verbose_name='Send to guardians')), + ('sent_to', models.ManyToManyField(blank=True, editable=False, related_name='received_info_mailings', to='core.Person', verbose_name='Sent to persons')), + ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')), + ], + options={ + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ], + ), + migrations.AddField( + model_name='event', + name='info_mailings', + field=models.ManyToManyField(blank=True, related_name='events', to='paweljong.InfoMailing', verbose_name='Info mailings'), + ), + ] diff --git a/aleksis/apps/paweljong/models.py b/aleksis/apps/paweljong/models.py index 72b02714f240f533867c736922fef638ad2fa1e4..55a067ad82e33466938c75670b2e2d1bd2955087 100644 --- a/aleksis/apps/paweljong/models.py +++ b/aleksis/apps/paweljong/models.py @@ -11,7 +11,8 @@ from django_iban.fields import IBANField from aleksis.core.mixins import ExtensibleModel from aleksis.core.models import Group, Person -from aleksis.core.util.core_helpers import generate_random_code +from aleksis.core.util.core_helpers import generate_random_code, get_site_preferences +from aleksis.core.util.email import send_email class Terms(ExtensibleModel): @@ -23,6 +24,75 @@ class Terms(ExtensibleModel): return self.title +class InfoMailing(ExtensibleModel): + subject = models.CharField(max_length=255, verbose_name=_("subject")) + text = RichTextField(verbose_name=_("Text")) + reply_to = models.EmailField(verbose_name=_("Request replies to"), blank=True) + + active = models.BooleanField(verbose_name=_("Mailing is active"), default=False) + + sender = models.EmailField(verbose_name=_("Sender"), blank=True) + send_to_person = models.BooleanField(verbose_name=_("Send to registered person"), default=True) + send_to_guardians = models.BooleanField(verbose_name=_("Send to guardians"), default=False) + + sent_to = models.ManyToManyField( + Person, + verbose_name=_("Sent to persons"), + related_name="received_info_mailings", + editable=False, + blank=True, + ) + + def __str__(self) -> str: + return self.subject + + @classmethod + def get_active_mailings(cls): + return cls.objects.filter(active=True) + + def send(self): + sent_to = self.sent_to.all() + + for event in self.events.all(): + for registration in event.registrations.all(): + if registration.person in sent_to: + continue + + subject = self.subject.format( + event=event, registration=registration, person=registration.person + ) + body = self.text.format( + event=event, registration=registration, person=registration.person + ) + + if self.send_to_person: + to = [registration.person.email] + if self.send_to_guardians: + cc = registration.person.guardians.values_list("email", flat=True).all() + else: + cc = [] + elif self.send_to_guardians: + to = registration.person.guardians.values_list("email", flat=True).all() + cc = [] + + sender = self.sender or get_site_preferences()["mail__address"] + reply_to = self.reply_to or sender + + context = {"subject": subject, "body": body} + send_email( + template_name="info_mailing", + context=context, + from_email=sender, + recipient_list=to, + cc=cc, + headers={ + "Reply-To": reply_to, + }, + ) + + self.sent_to.add(registration.person) + + class Event(ExtensibleModel): # Event details display_name = models.CharField(verbose_name=_("Display name"), max_length=255) @@ -44,6 +114,9 @@ class Event(ExtensibleModel): max_participants = models.PositiveSmallIntegerField(verbose_name=_("Maximum participants")) information = RichTextField(verbose_name=_("Information about the event")) terms = models.ManyToManyField(Terms, verbose_name=_("Terms"), related_name="event", blank=True) + info_mailings = models.ManyToManyField( + InfoMailing, verbose_name=_("Info mailings"), related_name="events", blank=True + ) def save(self, *args, **kwargs): if not self.slug: @@ -142,7 +215,9 @@ class Voucher(ExtensibleModel): class EventRegistration(ExtensibleModel): - event = models.ForeignKey(Event, on_delete=models.CASCADE, verbose_name=_("Event")) + event = models.ForeignKey( + Event, on_delete=models.CASCADE, verbose_name=_("Event"), related_name="registrations" + ) person = models.ForeignKey(Person, on_delete=models.CASCADE, verbose_name=_("Person")) date_registered = models.DateTimeField(auto_now_add=True, verbose_name=_("Registration date")) diff --git a/aleksis/apps/paweljong/rules.py b/aleksis/apps/paweljong/rules.py index 99254b87447353cbc6c437845bf3e7d61ebdcad5..056af3de4f17ea5c2fed2fce93b168a7c5fd8f74 100644 --- a/aleksis/apps/paweljong/rules.py +++ b/aleksis/apps/paweljong/rules.py @@ -109,3 +109,11 @@ view_terms_predicate = has_person & ( has_global_perm("paweljong.view_term") | has_any_object("paweljong.view_term", Terms) ) rules.add_perm("paweljong.view_terms_rule", view_terms_predicate) + + +# View info_mailings +view_info_mailings_predicate = has_person & ( + has_global_perm("paweljong.view_info_mailing") + | has_any_object("paweljong.view_info_mailing", Terms) +) +rules.add_perm("paweljong.view_info_mailings_rule", view_info_mailings_predicate) diff --git a/aleksis/apps/paweljong/tables.py b/aleksis/apps/paweljong/tables.py index 9dd0416c66601d415869104842983d8b0c7ad03a..2e7ac691d402d6c72ac1437eb5a7666146fe1e20 100644 --- a/aleksis/apps/paweljong/tables.py +++ b/aleksis/apps/paweljong/tables.py @@ -76,3 +76,23 @@ class TermsTable(tables.Table): verbose_name=_("Edit"), text=_("Edit"), ) + + +class InfoMailingsTable(tables.Table): + class Meta: + attrs = {"class": "responsive-table highlight"} + + subject = tables.Column() + + edit = tables.LinkColumn( + "edit_info_mailing_by_pk", + args=[A("id")], + verbose_name=_("Edit"), + text=_("Edit"), + ) + delete = tables.LinkColumn( + "delete_info_mailing_by_pk", + args=[A("id")], + verbose_name=_("Delete"), + text=_("Delete"), + ) diff --git a/aleksis/apps/paweljong/tasks.py b/aleksis/apps/paweljong/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..b1f34f10ec72dcc996aef7d85a9db5571a3a41bb --- /dev/null +++ b/aleksis/apps/paweljong/tasks.py @@ -0,0 +1,11 @@ +from datetime import timedelta + +from aleksis.core.celery import app + + +@app.task(run_every=timedelta(hours=1)) +def send_info_mailings() -> None: + from .models import InfoMailing # noqa + + for mailing in InfoMailing.get_active_mailings(): + mailing.send() diff --git a/aleksis/apps/paweljong/templates/paweljong/info_mailing/create.html b/aleksis/apps/paweljong/templates/paweljong/info_mailing/create.html new file mode 100644 index 0000000000000000000000000000000000000000..9c6952492f8a256e8e97bd5f17a7b56b6a00fbd8 --- /dev/null +++ b/aleksis/apps/paweljong/templates/paweljong/info_mailing/create.html @@ -0,0 +1,19 @@ +{% extends "core/base.html" %} +{% load material_form i18n %} + +{% block page_title %}{% blocktrans %}Create info mailing{% endblocktrans %}{% endblock %} +{% block browser_title %}{% blocktrans %}Create info mailing{% endblocktrans %}{% endblock %} + +{% block extra_head %} + {{ form.media.css }} +{% endblock %} + +{% block content %} + <form method="post"> + {% csrf_token %} + {% form form=form %}{% endform %} + {% include "core/partials/save_button.html" %} + </form> + + {{ form.media.js }} +{% endblock %} diff --git a/aleksis/apps/paweljong/templates/paweljong/info_mailing/edit.html b/aleksis/apps/paweljong/templates/paweljong/info_mailing/edit.html new file mode 100644 index 0000000000000000000000000000000000000000..d243a39ee8a5a9a0cbcd1a63a61ac5b5444a7157 --- /dev/null +++ b/aleksis/apps/paweljong/templates/paweljong/info_mailing/edit.html @@ -0,0 +1,18 @@ +{% extends "core/base.html" %} +{% load material_form i18n %} + +{% block page_title %}{% blocktrans %}Edit info mailing{% endblocktrans %}{% endblock %} +{% block browser_title %}{% blocktrans %}Edit info mailing{% endblocktrans %}{% endblock %} + +{% block extra_head %} + {{ form.media.css }} +{% endblock %} + +{% block content %} + <form method="post"> + {% csrf_token %} + {% form form=form %}{% endform %} + {% include "core/partials/save_button.html" %} + </form> + {{ form.media.js }} +{% endblock %} diff --git a/aleksis/apps/paweljong/templates/paweljong/info_mailing/list.html b/aleksis/apps/paweljong/templates/paweljong/info_mailing/list.html new file mode 100644 index 0000000000000000000000000000000000000000..28b1e1714120f774798cdb36851f83a12bed64ac --- /dev/null +++ b/aleksis/apps/paweljong/templates/paweljong/info_mailing/list.html @@ -0,0 +1,14 @@ +{% extends "core/base.html" %} +{% load material_form i18n %} + +{% load render_table from django_tables2 %} + +{% block page_title %}{% blocktrans %}Info mailings{% endblocktrans %}{% endblock %} +{% block browser_title %}{% blocktrans %}Info mailings{% endblocktrans %}{% endblock %} + +{% block content %} + + <a class="btn colour-primary waves-effect waves-light" href="{% url 'create_info_mailing' %}">{% trans "Create info mailing" %}</a> + {% render_table table %} + +{% endblock %} diff --git a/aleksis/apps/paweljong/templates/templated_email/info_mailing.email b/aleksis/apps/paweljong/templates/templated_email/info_mailing.email new file mode 100644 index 0000000000000000000000000000000000000000..946f635afbdfca45318bf3dda4232cbdf11a3c6c --- /dev/null +++ b/aleksis/apps/paweljong/templates/templated_email/info_mailing.email @@ -0,0 +1,3 @@ +{% block subject %}{{ subject }}{% endblock %} + +{% block html %}{{ body|safe }}{% endblock %} diff --git a/aleksis/apps/paweljong/urls.py b/aleksis/apps/paweljong/urls.py index 0be09dfc82b789b40636088edae650f9308c4371..e90bde07f8ef1bbc3ebcb2575bbf58c9f100a55c 100644 --- a/aleksis/apps/paweljong/urls.py +++ b/aleksis/apps/paweljong/urls.py @@ -102,4 +102,24 @@ urlpatterns = [ views.TermEditView.as_view(), name="edit_term_by_pk", ), + path( + "info_mailings/list", + views.InfoMailingListView.as_view(), + name="info_mailings", + ), + path( + "info_mailings/create", + views.InfoMailingCreateView.as_view(), + name="create_info_mailing", + ), + path( + "info_mailings/<int:pk>/edit", + views.InfoMailingEditView.as_view(), + name="edit_info_mailing_by_pk", + ), + path( + "info_mailings/<int:pk>/delete", + views.InfoMailingDeleteView.as_view(), + name="delete_info_mailing_by_pk", + ), ] diff --git a/aleksis/apps/paweljong/views.py b/aleksis/apps/paweljong/views.py index 972e2cabf863b1d6af8349708acf94a79675054f..efe2fee8e039f6022e85d02c56cad1ceaeb64477 100644 --- a/aleksis/apps/paweljong/views.py +++ b/aleksis/apps/paweljong/views.py @@ -32,12 +32,19 @@ from .filters import EventFilter, EventRegistrationFilter, VoucherFilter from .forms import ( EditEventForm, EditEventRegistrationForm, + EditInfoMailingForm, EditTermForm, EditVoucherForm, GenerateListForm, ) -from .models import Event, EventRegistration, Terms, Voucher -from .tables import EventRegistrationsTable, ManageEventsTable, TermsTable, VouchersTable +from .models import Event, EventRegistration, InfoMailing, Terms, Voucher +from .tables import ( + EventRegistrationsTable, + InfoMailingsTable, + ManageEventsTable, + TermsTable, + VouchersTable, +) User = get_user_model() @@ -769,3 +776,46 @@ class UpcomingEventsRSSFeed(Feed): class AccountRegisterStart(TemplateView): template_name = "paweljong/register_start.html" + + +class InfoMailingListView(PermissionRequiredMixin, SingleTableView): + """Table of all info mailings.""" + + model = InfoMailing + table_class = InfoMailingsTable + permission_required = "paweljong.view_info_mailing" + template_name = "paweljong/info_mailing/list.html" + + +@method_decorator(never_cache, name="dispatch") +class InfoMailingCreateView(PermissionRequiredMixin, AdvancedCreateView): + """Create view for info mailings.""" + + model = InfoMailing + form_class = EditInfoMailingForm + permission_required = "paweljong.add_info_mailing" + template_name = "paweljong/info_mailing/create.html" + success_url = reverse_lazy("info_mailings") + success_message = _("The info mailing has been created.") + + +@method_decorator(never_cache, name="dispatch") +class InfoMailingEditView(PermissionRequiredMixin, AdvancedEditView): + """Edit view for info mailings.""" + + model = InfoMailing + form_class = EditInfoMailingForm + permission_required = "paweljong.edit_info_mailing" + template_name = "paweljong/info_mailing/edit.html" + success_url = reverse_lazy("info_mailings") + success_message = _("The info mailing has been saved.") + + +class InfoMailingDeleteView(PermissionRequiredMixin, AdvancedDeleteView): + """Delete view for info mailings.""" + + model = InfoMailing + permission_required = "paweljong.delete_info_mailing" + template_name = "core/pages/delete.html" + success_url = reverse_lazy("info_mailings") + success_message = _("The info mailing has been deleted.")